Skip to content

Commit 632c134

Browse files
authored
feat: support basePath configuration (#2167)
1 parent ec89fc1 commit 632c134

File tree

7 files changed

+424
-38
lines changed

7 files changed

+424
-38
lines changed

README.md

Lines changed: 45 additions & 33 deletions
Large diffs are not rendered by default.

src/client/helpers/get-access-token.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AccessTokenError } from "../../errors";
2+
import { normailizeWithBasePath } from "../../utils/pathUtils";
23

34
type AccessTokenResponse = {
45
token: string;
@@ -8,7 +9,9 @@ type AccessTokenResponse = {
89

910
export async function getAccessToken(): Promise<string> {
1011
const tokenRes = await fetch(
11-
process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE || "/auth/access-token"
12+
normailizeWithBasePath(
13+
process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE || "/auth/access-token"
14+
)
1215
);
1316

1417
if (!tokenRes.ok) {

src/client/hooks/use-user.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
import useSWR from "swr";
44

55
import type { User } from "../../types";
6+
import { normailizeWithBasePath } from "../../utils/pathUtils";
67

78
export function useUser() {
89
const { data, error, isLoading, mutate } = useSWR<User, Error, string>(
9-
process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile",
10+
normailizeWithBasePath(
11+
process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile"
12+
),
1013
(...args) =>
1114
fetch(...args).then((res) => {
1215
if (!res.ok) {

src/server/auth-client.test.ts

Lines changed: 276 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import * as jose from "jose";
33
import * as oauth from "oauth4webapi";
4-
import { describe, expect, it, vi } from "vitest";
4+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
55

66
import { generateSecret } from "../test/utils";
77
import { SessionData } from "../types";
@@ -806,6 +806,89 @@ ca/T0LLtgmbMmxSv/MmzIg==
806806
delete process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE;
807807
});
808808
});
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+
});
809892
});
810893

811894
describe("handleLogin", async () => {
@@ -918,6 +1001,55 @@ ca/T0LLtgmbMmxSv/MmzIg==
9181001
);
9191002
});
9201003

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+
9211053
it("should return an error if the discovery endpoint could not be fetched", async () => {
9221054
const secret = await generateSecret(32);
9231055
const transactionStore = new TransactionStore({
@@ -1600,6 +1732,79 @@ ca/T0LLtgmbMmxSv/MmzIg==
16001732
);
16011733
});
16021734

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+
16031808
describe("custom parameters to the authorization server", async () => {
16041809
it("should not forward any custom parameters sent via the query parameters to /auth/login", async () => {
16051810
const secret = await generateSecret(32);
@@ -2335,6 +2540,76 @@ ca/T0LLtgmbMmxSv/MmzIg==
23352540
);
23362541
});
23372542

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+
23382613
it("must use private_key_jwt when a clientAssertionSigningKey is specified", async () => {
23392614
function pemToArrayBuffer(pem: string) {
23402615
const b64 = pem

src/server/auth-client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import {
3030
ensureNoLeadingSlash,
3131
ensureTrailingSlash,
32+
normailizeWithBasePath,
3233
removeTrailingSlash
3334
} from "../utils/pathUtils";
3435
import { toSafeRedirect } from "../utils/url-helpers";
@@ -138,7 +139,10 @@ export interface AuthClientOptions {
138139
}
139140

140141
function createRouteUrl(url: string, base: string) {
141-
return new URL(ensureNoLeadingSlash(url), ensureTrailingSlash(base));
142+
return new URL(
143+
ensureNoLeadingSlash(normailizeWithBasePath(url)),
144+
ensureTrailingSlash(base)
145+
);
142146
}
143147

144148
export class AuthClient {

0 commit comments

Comments
 (0)