|
1 | 1 | import { NextRequest, NextResponse } from "next/server"; |
2 | 2 | import * as jose from "jose"; |
3 | 3 | import * as oauth from "oauth4webapi"; |
4 | | -import { describe, expect, it, vi } from "vitest"; |
| 4 | +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; |
5 | 5 |
|
6 | 6 | import { generateSecret } from "../test/utils"; |
7 | 7 | import { SessionData } from "../types"; |
@@ -806,6 +806,89 @@ ca/T0LLtgmbMmxSv/MmzIg== |
806 | 806 | delete process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE; |
807 | 807 | }); |
808 | 808 | }); |
| 809 | + |
| 810 | + describe("with a base path", async () => { |
| 811 | + beforeAll(() => { |
| 812 | + process.env.NEXT_PUBLIC_BASE_PATH = "/base-path"; |
| 813 | + }); |
| 814 | + |
| 815 | + afterAll(() => { |
| 816 | + delete process.env.NEXT_PUBLIC_BASE_PATH; |
| 817 | + }); |
| 818 | + |
| 819 | + it("should call the appropriate handlers when routes are called with base path", async () => { |
| 820 | + const testCases = [ |
| 821 | + { |
| 822 | + path: "/auth/login", |
| 823 | + method: "GET", |
| 824 | + handler: "handleLogin" |
| 825 | + }, |
| 826 | + { |
| 827 | + path: "/auth/logout", |
| 828 | + method: "GET", |
| 829 | + handler: "handleLogout" |
| 830 | + }, |
| 831 | + { |
| 832 | + path: "/auth/callback", |
| 833 | + method: "GET", |
| 834 | + handler: "handleCallback" |
| 835 | + }, |
| 836 | + { |
| 837 | + path: "/auth/backchannel-logout", |
| 838 | + method: "POST", |
| 839 | + handler: "handleBackChannelLogout" |
| 840 | + }, |
| 841 | + { |
| 842 | + path: "/auth/profile", |
| 843 | + method: "GET", |
| 844 | + handler: "handleProfile" |
| 845 | + }, |
| 846 | + { |
| 847 | + path: "/auth/access-token", |
| 848 | + method: "GET", |
| 849 | + handler: "handleAccessToken" |
| 850 | + } |
| 851 | + ]; |
| 852 | + |
| 853 | + for (const testCase of testCases) { |
| 854 | + const secret = await generateSecret(32); |
| 855 | + const transactionStore = new TransactionStore({ |
| 856 | + secret |
| 857 | + }); |
| 858 | + const sessionStore = new StatelessSessionStore({ |
| 859 | + secret |
| 860 | + }); |
| 861 | + const authClient = new AuthClient({ |
| 862 | + transactionStore, |
| 863 | + sessionStore, |
| 864 | + |
| 865 | + domain: DEFAULT.domain, |
| 866 | + clientId: DEFAULT.clientId, |
| 867 | + clientSecret: DEFAULT.clientSecret, |
| 868 | + |
| 869 | + secret, |
| 870 | + appBaseUrl: DEFAULT.appBaseUrl, |
| 871 | + |
| 872 | + fetch: getMockAuthorizationServer() |
| 873 | + }); |
| 874 | + |
| 875 | + const request = new NextRequest( |
| 876 | + // Next.js will strip the base path from the URL |
| 877 | + new URL( |
| 878 | + testCase.path, |
| 879 | + `${DEFAULT.appBaseUrl}/${process.env.NEXT_PUBLIC_BASE_PATH}` |
| 880 | + ), |
| 881 | + { |
| 882 | + method: testCase.method |
| 883 | + } |
| 884 | + ); |
| 885 | + |
| 886 | + (authClient as any)[testCase.handler] = vi.fn(); |
| 887 | + await authClient.handler(request); |
| 888 | + expect((authClient as any)[testCase.handler]).toHaveBeenCalled(); |
| 889 | + } |
| 890 | + }); |
| 891 | + }); |
809 | 892 | }); |
810 | 893 |
|
811 | 894 | describe("handleLogin", async () => { |
@@ -918,6 +1001,55 @@ ca/T0LLtgmbMmxSv/MmzIg== |
918 | 1001 | ); |
919 | 1002 | }); |
920 | 1003 |
|
| 1004 | + describe("with a base path", async () => { |
| 1005 | + beforeAll(() => { |
| 1006 | + process.env.NEXT_PUBLIC_BASE_PATH = "/base-path"; |
| 1007 | + }); |
| 1008 | + |
| 1009 | + afterAll(() => { |
| 1010 | + delete process.env.NEXT_PUBLIC_BASE_PATH; |
| 1011 | + }); |
| 1012 | + |
| 1013 | + it("should prepend the base path to the redirect_uri", async () => { |
| 1014 | + const secret = await generateSecret(32); |
| 1015 | + const transactionStore = new TransactionStore({ |
| 1016 | + secret |
| 1017 | + }); |
| 1018 | + const sessionStore = new StatelessSessionStore({ |
| 1019 | + secret |
| 1020 | + }); |
| 1021 | + const authClient = new AuthClient({ |
| 1022 | + transactionStore, |
| 1023 | + sessionStore, |
| 1024 | + |
| 1025 | + domain: DEFAULT.domain, |
| 1026 | + clientId: DEFAULT.clientId, |
| 1027 | + clientSecret: DEFAULT.clientSecret, |
| 1028 | + |
| 1029 | + secret, |
| 1030 | + appBaseUrl: `${DEFAULT.appBaseUrl}`, |
| 1031 | + |
| 1032 | + fetch: getMockAuthorizationServer() |
| 1033 | + }); |
| 1034 | + const request = new NextRequest( |
| 1035 | + new URL( |
| 1036 | + process.env.NEXT_PUBLIC_BASE_PATH + "/auth/login", |
| 1037 | + DEFAULT.appBaseUrl |
| 1038 | + ), |
| 1039 | + { |
| 1040 | + method: "GET" |
| 1041 | + } |
| 1042 | + ); |
| 1043 | + |
| 1044 | + const response = await authClient.handleLogin(request); |
| 1045 | + const authorizationUrl = new URL(response.headers.get("Location")!); |
| 1046 | + |
| 1047 | + expect(authorizationUrl.searchParams.get("redirect_uri")).toEqual( |
| 1048 | + `${DEFAULT.appBaseUrl}/base-path/auth/callback` |
| 1049 | + ); |
| 1050 | + }); |
| 1051 | + }); |
| 1052 | + |
921 | 1053 | it("should return an error if the discovery endpoint could not be fetched", async () => { |
922 | 1054 | const secret = await generateSecret(32); |
923 | 1055 | const transactionStore = new TransactionStore({ |
@@ -1600,6 +1732,79 @@ ca/T0LLtgmbMmxSv/MmzIg== |
1600 | 1732 | ); |
1601 | 1733 | }); |
1602 | 1734 |
|
| 1735 | + describe("with a base path", async () => { |
| 1736 | + beforeAll(() => { |
| 1737 | + process.env.NEXT_PUBLIC_BASE_PATH = "/base-path"; |
| 1738 | + }); |
| 1739 | + |
| 1740 | + afterAll(() => { |
| 1741 | + delete process.env.NEXT_PUBLIC_BASE_PATH; |
| 1742 | + }); |
| 1743 | + |
| 1744 | + it("should prepend the base path to the redirect_uri", async () => { |
| 1745 | + const secret = await generateSecret(32); |
| 1746 | + const transactionStore = new TransactionStore({ |
| 1747 | + secret |
| 1748 | + }); |
| 1749 | + const sessionStore = new StatelessSessionStore({ |
| 1750 | + secret |
| 1751 | + }); |
| 1752 | + const authClient = new AuthClient({ |
| 1753 | + transactionStore, |
| 1754 | + sessionStore, |
| 1755 | + domain: DEFAULT.domain, |
| 1756 | + clientId: DEFAULT.clientId, |
| 1757 | + clientSecret: DEFAULT.clientSecret, |
| 1758 | + pushedAuthorizationRequests: true, |
| 1759 | + secret, |
| 1760 | + appBaseUrl: DEFAULT.appBaseUrl, |
| 1761 | + fetch: getMockAuthorizationServer({ |
| 1762 | + onParRequest: async (request) => { |
| 1763 | + const params = new URLSearchParams(await request.text()); |
| 1764 | + expect(params.get("client_id")).toEqual(DEFAULT.clientId); |
| 1765 | + expect(params.get("redirect_uri")).toEqual( |
| 1766 | + `${DEFAULT.appBaseUrl}/base-path/auth/callback` |
| 1767 | + ); |
| 1768 | + expect(params.get("response_type")).toEqual("code"); |
| 1769 | + expect(params.get("code_challenge")).toEqual( |
| 1770 | + expect.any(String) |
| 1771 | + ); |
| 1772 | + expect(params.get("code_challenge_method")).toEqual("S256"); |
| 1773 | + expect(params.get("state")).toEqual(expect.any(String)); |
| 1774 | + expect(params.get("nonce")).toEqual(expect.any(String)); |
| 1775 | + expect(params.get("scope")).toEqual( |
| 1776 | + "openid profile email offline_access" |
| 1777 | + ); |
| 1778 | + } |
| 1779 | + }) |
| 1780 | + }); |
| 1781 | + |
| 1782 | + const request = new NextRequest( |
| 1783 | + new URL( |
| 1784 | + process.env.NEXT_PUBLIC_BASE_PATH + "/auth/login", |
| 1785 | + DEFAULT.appBaseUrl |
| 1786 | + ), |
| 1787 | + { |
| 1788 | + method: "GET" |
| 1789 | + } |
| 1790 | + ); |
| 1791 | + |
| 1792 | + const response = await authClient.handleLogin(request); |
| 1793 | + expect(response.status).toEqual(307); |
| 1794 | + expect(response.headers.get("Location")).not.toBeNull(); |
| 1795 | + |
| 1796 | + const authorizationUrl = new URL(response.headers.get("Location")!); |
| 1797 | + expect(authorizationUrl.origin).toEqual(`https://${DEFAULT.domain}`); |
| 1798 | + // query parameters should only include the `request_uri` and not the standard auth params |
| 1799 | + expect(authorizationUrl.searchParams.get("request_uri")).toEqual( |
| 1800 | + DEFAULT.requestUri |
| 1801 | + ); |
| 1802 | + expect(authorizationUrl.searchParams.get("client_id")).toEqual( |
| 1803 | + DEFAULT.clientId |
| 1804 | + ); |
| 1805 | + }); |
| 1806 | + }); |
| 1807 | + |
1603 | 1808 | describe("custom parameters to the authorization server", async () => { |
1604 | 1809 | it("should not forward any custom parameters sent via the query parameters to /auth/login", async () => { |
1605 | 1810 | const secret = await generateSecret(32); |
@@ -2335,6 +2540,76 @@ ca/T0LLtgmbMmxSv/MmzIg== |
2335 | 2540 | ); |
2336 | 2541 | }); |
2337 | 2542 |
|
| 2543 | + describe("when a base path is defined", async () => { |
| 2544 | + beforeAll(() => { |
| 2545 | + process.env.NEXT_PUBLIC_BASE_PATH = "/base-path"; |
| 2546 | + }); |
| 2547 | + |
| 2548 | + afterAll(() => { |
| 2549 | + delete process.env.NEXT_PUBLIC_BASE_PATH; |
| 2550 | + }); |
| 2551 | + |
| 2552 | + it("should generate a callback URL with the base path", async () => { |
| 2553 | + const state = "transaction-state"; |
| 2554 | + const code = "auth-code"; |
| 2555 | + |
| 2556 | + const secret = await generateSecret(32); |
| 2557 | + const transactionStore = new TransactionStore({ |
| 2558 | + secret |
| 2559 | + }); |
| 2560 | + const sessionStore = new StatelessSessionStore({ |
| 2561 | + secret |
| 2562 | + }); |
| 2563 | + const authClient = new AuthClient({ |
| 2564 | + transactionStore, |
| 2565 | + sessionStore, |
| 2566 | + |
| 2567 | + domain: DEFAULT.domain, |
| 2568 | + clientId: DEFAULT.clientId, |
| 2569 | + clientSecret: DEFAULT.clientSecret, |
| 2570 | + |
| 2571 | + secret, |
| 2572 | + appBaseUrl: DEFAULT.appBaseUrl, |
| 2573 | + |
| 2574 | + fetch: getMockAuthorizationServer() |
| 2575 | + }); |
| 2576 | + |
| 2577 | + const url = new URL( |
| 2578 | + process.env.NEXT_PUBLIC_BASE_PATH + "/auth/callback", |
| 2579 | + DEFAULT.appBaseUrl |
| 2580 | + ); |
| 2581 | + url.searchParams.set("code", code); |
| 2582 | + url.searchParams.set("state", state); |
| 2583 | + |
| 2584 | + const headers = new Headers(); |
| 2585 | + const transactionState: TransactionState = { |
| 2586 | + nonce: "nonce-value", |
| 2587 | + maxAge: 3600, |
| 2588 | + codeVerifier: "code-verifier", |
| 2589 | + responseType: "code", |
| 2590 | + state: state, |
| 2591 | + returnTo: "/dashboard" |
| 2592 | + }; |
| 2593 | + const maxAge = 60 * 60; // 1 hour |
| 2594 | + const expiration = Math.floor(Date.now() / 1000 + maxAge); |
| 2595 | + headers.set( |
| 2596 | + "cookie", |
| 2597 | + `__txn_${state}=${await encrypt(transactionState, secret, expiration)}` |
| 2598 | + ); |
| 2599 | + const request = new NextRequest(url, { |
| 2600 | + method: "GET", |
| 2601 | + headers |
| 2602 | + }); |
| 2603 | + |
| 2604 | + const response = await authClient.handleCallback(request); |
| 2605 | + expect(response.status).toEqual(307); |
| 2606 | + expect(response.headers.get("Location")).not.toBeNull(); |
| 2607 | + |
| 2608 | + const redirectUrl = new URL(response.headers.get("Location")!); |
| 2609 | + expect(redirectUrl.pathname).toEqual("/base-path/dashboard"); |
| 2610 | + }); |
| 2611 | + }); |
| 2612 | + |
2338 | 2613 | it("must use private_key_jwt when a clientAssertionSigningKey is specified", async () => { |
2339 | 2614 | function pemToArrayBuffer(pem: string) { |
2340 | 2615 | const b64 = pem |
|
0 commit comments