UNPKG

@auth0/nextjs-auth0

Version:
1,049 lines (1,048 loc) 243 kB
import { NextRequest, NextResponse } from "next/server.js"; import * as jose from "jose"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { getDefaultRoutes } from "../test/defaults.js"; import { generateSecret } from "../test/utils.js"; import { SUBJECT_TOKEN_TYPES } from "../types/index.js"; import { AuthClient } from "./auth-client.js"; import { decrypt, encrypt } from "./cookies.js"; import { StatefulSessionStore } from "./session/stateful-session-store.js"; import { StatelessSessionStore } from "./session/stateless-session-store.js"; import { TransactionStore } from "./transaction-store.js"; describe("Authentication Client", async () => { const DEFAULT = { domain: "guabu.us.auth0.com", clientId: "client_123", clientSecret: "client-secret", appBaseUrl: "https://example.com", sid: "auth0-sid", idToken: "idt_123", accessToken: "at_123", refreshToken: "rt_123", sub: "user_123", alg: "RS256", keyPair: await jose.generateKeyPair("RS256"), clientAssertionSigningKey: `-----BEGIN PRIVATE KEY----- MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDbTKOQLtaZ6U1k 3fcYCMVoy8poieNPPcbj15TCLOm4Bbox73/UUxIArqczVcjtUGnL+jn5982V5EiB y8W51m5K9mIBgEFLYdLkXk+OW5UTE/AdMPtfsIjConGrrs3mxN4WSH9kvh9Yr41r hWUUSwqFyMOssbGE8K46Cv0WYvS7RXH9MzcyTcMSFp/60yUXH4rdHYZElF7XCdiE 63WxebxI1Qza4xkjTlbp5EWfWBQB1Ms10JO8NjrtkCXrDI57Bij5YanPAVhctcO9 z5/y9i5xEzcer8ZLO8VDiXSdEsuP/fe+UKDyYHUITD8u51p3O2JwCKvdTHduemej 3Kd1RlHrAgMBAAECggEATWdzpASkQpcSdjPSb21JIIAt5VAmJ2YKuYjyPMdVh1qe Kdn7KJpZlFwRMBFrZjgn35Nmu1A4BFwbK5UdKUcCjvsABL+cTFsu8ORI+Fpi9+Tl r6gGUfQhkXF85bhBfN6n9P2J2akxrz/njrf6wXrrL+V5C498tQuus1YFls0+zIpD N+GngNOPHlGeY3gW4K/HjGuHwuJOvWNmE4KNQhBijdd50Am824Y4NV/SmsIo7z+s 8CLjp/qtihwnE4rkUHnR6M4u5lpzXOnodzkDTG8euOJds0T8DwLNTx1b+ETim35i D/hOCVwl8QFoj2aatjuJ5LXZtZUEpGpBF2TQecB+gQKBgQDvaZ1jG/FNPnKdayYv z5yTOhKM6JTB+WjB0GSx8rebtbFppiHGgVhOd1bLIzli9uMOPdCNuXh7CKzIgSA6 Q76Wxfuaw8F6CBIdlG9bZNL6x8wp6zF8tGz/BgW7fFKBwFYSWzTcStGr2QGtwr6F 9p1gYPSGfdERGOQc7RmhoNNHcQKBgQDqfkhpPfJlP/SdFnF7DDUvuMnaswzUsM6D ZPhvfzdMBV8jGc0WjCW2Vd3pvsdPgWXZqAKjN7+A5HiT/8qv5ruoqOJSR9ZFZI/B 8v+8gS9Af7K56mCuCFKZmOXUmaL+3J2FKtzAyOlSLjEYyLuCgmhEA9Zo+duGR5xX AIjx7N/ZGwKBgCZAYqQeJ8ymqJtcLkq/Sg3/3kzjMDlZxxIIYL5JwGpBemod4BGe QuSujpCAPUABoD97QuIR+xz1Qt36O5LzlfTzBwMwOa5ssbBGMhCRKGBnIcikylBZ Z3zLkojlES2n9FiUd/qmfZ+OWYVQsy4mO/jVJNyEJ64qou+4NjsrvfYRAoGAORki 3K1+1nSqRY3vd/zS/pnKXPx4RVoADzKI4+1gM5yjO9LOg40AqdNiw8X2lj9143fr nH64nNQFIFSKsCZIz5q/8TUY0bDY6GsZJnd2YAg4JtkRTY8tPcVjQU9fxxtFJ+X1 9uN1HNOulNBcCD1k0hr1HH6qm5nYUb8JmY8KOr0CgYB85pvPhBqqfcWi6qaVQtK1 ukIdiJtMNPwePfsT/2KqrbnftQnAKNnhsgcYGo8NAvntX4FokOAEdunyYmm85mLp BGKYgVXJqnm6+TJyCRac1ro3noG898P/LZ8MOBoaYQtWeWRpDc46jPrA0FqUJy+i ca/T0LLtgmbMmxSv/MmzIg== -----END PRIVATE KEY-----`, requestUri: "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" }; function getMockAuthorizationServer({ tokenEndpointResponse, tokenEndpointErrorResponse, tokenEndpointFetchError, discoveryResponse, audience, nonce, keyPair = DEFAULT.keyPair, onParRequest, onBackchannelAuthRequest } = {}) { // this function acts as a mock authorization server return vi.fn(async (input, init) => { let url; if (input instanceof Request) { url = new URL(input.url); } else { url = new URL(input); } if (url.pathname === "/oauth/token") { if (tokenEndpointFetchError) { throw tokenEndpointFetchError; } const jwt = await new jose.SignJWT({ sid: DEFAULT.sid, auth_time: Date.now(), nonce: nonce ?? "nonce-value", "https://example.com/custom_claim": "value" }) .setProtectedHeader({ alg: DEFAULT.alg }) .setSubject(DEFAULT.sub) .setIssuedAt() .setIssuer(_authorizationServerMetadata.issuer) .setAudience(audience ?? DEFAULT.clientId) .setExpirationTime("2h") .sign(keyPair.privateKey); if (tokenEndpointErrorResponse) { return Response.json(tokenEndpointErrorResponse, { status: 400 }); } return Response.json(tokenEndpointResponse ?? { token_type: "Bearer", access_token: DEFAULT.accessToken, refresh_token: DEFAULT.refreshToken, id_token: jwt, expires_in: 86400 // expires in 10 days }); } // discovery URL if (url.pathname === "/.well-known/openid-configuration") { return (discoveryResponse ?? Response.json(_authorizationServerMetadata)); } // PAR endpoint if (url.pathname === "/oauth/par") { if (onParRequest) { await onParRequest(new Request(input, init)); } return Response.json({ request_uri: DEFAULT.requestUri, expires_in: 30 }, { status: 201 }); } // Backchannel Authorize endpoint if (url.pathname === "/bc-authorize") { if (onBackchannelAuthRequest) { await onBackchannelAuthRequest(new Request(input, init)); } return Response.json({ auth_req_id: "auth-req-id", expires_in: 30, interval: 0.01 }, { status: 200 }); } return new Response(null, { status: 404 }); }); } async function generateLogoutToken({ claims = {}, audience = DEFAULT.clientId, issuer = _authorizationServerMetadata.issuer, alg = DEFAULT.alg, privateKey = DEFAULT.keyPair.privateKey }) { return await new jose.SignJWT({ events: { "http://schemas.openid.net/event/backchannel-logout": {} }, sub: DEFAULT.sub, sid: DEFAULT.sid, ...claims }) .setProtectedHeader({ alg, typ: "logout+jwt" }) .setIssuedAt() .setIssuer(issuer) .setAudience(audience) .setExpirationTime("2h") .setJti("some-jti") .sign(privateKey); } async function getCachedJWKS() { const publicJwk = await jose.exportJWK(DEFAULT.keyPair.publicKey); return { jwks: { keys: [publicJwk] }, uat: Date.now() - 1000 * 60 }; } describe("initialization", async () => { it("should throw an error if the openid scope is not included", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); expect(() => new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), authorizationParameters: { scope: "profile email" }, fetch: getMockAuthorizationServer() })).toThrowError(); }); }); describe("handler", async () => { it("should call the login handler if the path is /auth/login", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const request = new NextRequest("https://example.com/auth/login", { method: "GET" }); authClient.handleLogin = vi.fn(); await authClient.handler(request); expect(authClient.handleLogin).toHaveBeenCalled(); }); it("should call the callback handler if the path is /auth/callback", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const request = new NextRequest("https://example.com/auth/callback", { method: "GET" }); authClient.handleCallback = vi.fn(); await authClient.handler(request); expect(authClient.handleCallback).toHaveBeenCalled(); }); it("should call the logout handler if the path is /auth/logout", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const request = new NextRequest("https://example.com/auth/logout", { method: "GET" }); authClient.handleLogout = vi.fn(); await authClient.handler(request); expect(authClient.handleLogout).toHaveBeenCalled(); }); it("should call the profile handler if the path is /auth/profile", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const request = new NextRequest("https://example.com/auth/profile", { method: "GET" }); authClient.handleProfile = vi.fn(); await authClient.handler(request); expect(authClient.handleProfile).toHaveBeenCalled(); }); it("should call the handleAccessToken method if the path is /auth/access-token and enableAccessTokenEndpoint is true", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), enableAccessTokenEndpoint: true, fetch: getMockAuthorizationServer() }); const request = new NextRequest("https://example.com/auth/access-token", { method: "GET" }); authClient.handleAccessToken = vi.fn(); await authClient.handler(request); expect(authClient.handleAccessToken).toHaveBeenCalled(); }); it("should not call the handleAccessToken method if the path is /auth/access-token but enableAccessTokenEndpoint is false", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), enableAccessTokenEndpoint: false, fetch: getMockAuthorizationServer() }); const request = new NextRequest("https://example.com/auth/access-token", { method: "GET" }); authClient.handleAccessToken = vi.fn(); const response = await authClient.handler(request); expect(authClient.handleAccessToken).not.toHaveBeenCalled(); // When a route doesn't match, the handler returns a NextResponse.next() with status 200 expect(response.status).toBe(200); }); it("should use the default value (true) for enableAccessTokenEndpoint when not explicitly provided", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), // enableAccessTokenEndpoint not specified, should default to true fetch: getMockAuthorizationServer() }); const request = new NextRequest("https://example.com/auth/access-token", { method: "GET" }); authClient.handleAccessToken = vi.fn(); await authClient.handler(request); expect(authClient.handleAccessToken).toHaveBeenCalled(); }); it("should call the back-channel logout handler if the path is /auth/backchannel-logout", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const request = new NextRequest("https://example.com/auth/backchannel-logout", { method: "POST" }); authClient.handleBackChannelLogout = vi.fn(); await authClient.handler(request); expect(authClient.handleBackChannelLogout).toHaveBeenCalled(); }); describe("rolling sessions - no matching auth route", async () => { it("should update the session expiry if a session exists", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret, rolling: true, absoluteDuration: 3600, inactivityDuration: 1800 }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const session = { user: { sub: DEFAULT.sub }, tokenSet: { accessToken: DEFAULT.accessToken, refreshToken: DEFAULT.refreshToken, expiresAt: 123456 }, internal: { sid: DEFAULT.sid, createdAt: Math.floor(Date.now() / 1000) } }; const maxAge = 60 * 60; // 1 hour const expiration = Math.floor(Date.now() / 1000 + maxAge); const sessionCookie = await encrypt(session, secret, expiration); const headers = new Headers(); headers.append("cookie", `__session=${sessionCookie}`); const request = new NextRequest("https://example.com/dashboard/projects", { method: "GET", headers }); const response = await authClient.handler(request); // assert session has been updated const updatedSessionCookie = response.cookies.get("__session"); expect(updatedSessionCookie).toBeDefined(); const { payload: updatedSessionCookieValue } = (await decrypt(updatedSessionCookie.value, secret)); expect(updatedSessionCookieValue).toEqual(expect.objectContaining({ user: { sub: DEFAULT.sub }, tokenSet: { accessToken: "at_123", refreshToken: "rt_123", expiresAt: expect.any(Number) }, internal: { sid: DEFAULT.sid, createdAt: expect.any(Number) } })); // assert that the session expiry has been extended by the inactivity duration expect(updatedSessionCookie?.maxAge).toEqual(1800); }); it("should pass the request through if there is no session", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret, rolling: true, absoluteDuration: 3600, inactivityDuration: 1800 }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const request = new NextRequest("https://example.com/dashboard/projects", { method: "GET" }); authClient.getTokenSet = vi.fn(); const response = await authClient.handler(request); expect(authClient.getTokenSet).not.toHaveBeenCalled(); // assert session has not been updated const updatedSessionCookie = response.cookies.get("__session"); expect(updatedSessionCookie).toBeUndefined(); }); }); describe("with custom routes", async () => { it("should call the login handler when the configured route is called", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, fetch: getMockAuthorizationServer(), routes: { ...getDefaultRoutes(), login: "/custom-login" } }); const request = new NextRequest(new URL("/custom-login", DEFAULT.appBaseUrl), { method: "GET" }); authClient.handleLogin = vi.fn(); await authClient.handler(request); expect(authClient.handleLogin).toHaveBeenCalled(); }); it("should call the logout handler when the configured route is called", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, fetch: getMockAuthorizationServer(), routes: { ...getDefaultRoutes(), logout: "/custom-logout" } }); const request = new NextRequest(new URL("/custom-logout", DEFAULT.appBaseUrl), { method: "GET" }); authClient.handleLogout = vi.fn(); await authClient.handler(request); expect(authClient.handleLogout).toHaveBeenCalled(); }); it("should call the callback handler when the configured route is called", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, fetch: getMockAuthorizationServer(), routes: { ...getDefaultRoutes(), callback: "/custom-callback" } }); const request = new NextRequest(new URL("/custom-callback", DEFAULT.appBaseUrl), { method: "GET" }); authClient.handleCallback = vi.fn(); await authClient.handler(request); expect(authClient.handleCallback).toHaveBeenCalled(); }); it("should call the backChannelLogout handler when the configured route is called", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, fetch: getMockAuthorizationServer(), routes: { ...getDefaultRoutes(), backChannelLogout: "/custom-backchannel-logout" } }); const request = new NextRequest(new URL("/custom-backchannel-logout", DEFAULT.appBaseUrl), { method: "POST" }); authClient.handleBackChannelLogout = vi.fn(); await authClient.handler(request); expect(authClient.handleBackChannelLogout).toHaveBeenCalled(); }); it("should call the profile handler when the configured route is called", async () => { process.env.NEXT_PUBLIC_PROFILE_ROUTE = "/custom-profile"; const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const request = new NextRequest(new URL("/custom-profile", DEFAULT.appBaseUrl), { method: "GET" }); authClient.handleProfile = vi.fn(); await authClient.handler(request); expect(authClient.handleProfile).toHaveBeenCalled(); delete process.env.NEXT_PUBLIC_PROFILE_ROUTE; }); it("should call the access-token handler when the configured route is called", async () => { process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE = "/custom-access-token"; const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const request = new NextRequest(new URL("/custom-access-token", DEFAULT.appBaseUrl), { method: "GET" }); authClient.handleAccessToken = vi.fn(); await authClient.handler(request); expect(authClient.handleAccessToken).toHaveBeenCalled(); delete process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE; }); }); describe("with a base path", async () => { beforeAll(() => { process.env.NEXT_PUBLIC_BASE_PATH = "/base-path"; }); afterAll(() => { delete process.env.NEXT_PUBLIC_BASE_PATH; }); it("should call the appropriate handlers when routes are called with base path", async () => { const testCases = [ { path: "/auth/login", method: "GET", handler: "handleLogin" }, { path: "/auth/logout", method: "GET", handler: "handleLogout" }, { path: "/auth/callback", method: "GET", handler: "handleCallback" }, { path: "/auth/backchannel-logout", method: "POST", handler: "handleBackChannelLogout" }, { path: "/auth/profile", method: "GET", handler: "handleProfile" }, { path: "/auth/access-token", method: "GET", handler: "handleAccessToken" } ]; for (const testCase of testCases) { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const request = new NextRequest( // Next.js will strip the base path from the URL new URL(testCase.path, `${DEFAULT.appBaseUrl}/${process.env.NEXT_PUBLIC_BASE_PATH}`), { method: testCase.method }); authClient[testCase.handler] = vi.fn(); await authClient.handler(request); expect(authClient[testCase.handler]).toHaveBeenCalled(); } }); }); }); describe("handleLogin", async () => { it("should redirect to the authorization server and store the transaction state", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const request = new NextRequest(new URL("/auth/login", DEFAULT.appBaseUrl), { method: "GET" }); const response = await authClient.handleLogin(request); expect(response.status).toEqual(307); expect(response.headers.get("Location")).not.toBeNull(); const authorizationUrl = new URL(response.headers.get("Location")); expect(authorizationUrl.origin).toEqual(`https://${DEFAULT.domain}`); // query parameters expect(authorizationUrl.searchParams.get("client_id")).toEqual(DEFAULT.clientId); expect(authorizationUrl.searchParams.get("redirect_uri")).toEqual(`${DEFAULT.appBaseUrl}/auth/callback`); expect(authorizationUrl.searchParams.get("response_type")).toEqual("code"); expect(authorizationUrl.searchParams.get("code_challenge")).not.toBeNull(); expect(authorizationUrl.searchParams.get("code_challenge_method")).toEqual("S256"); expect(authorizationUrl.searchParams.get("state")).not.toBeNull(); expect(authorizationUrl.searchParams.get("nonce")).not.toBeNull(); expect(authorizationUrl.searchParams.get("scope")).toEqual("openid profile email offline_access"); // transaction state const transactionCookie = response.cookies.get(`__txn_${authorizationUrl.searchParams.get("state")}`); expect(transactionCookie).toBeDefined(); expect((await decrypt(transactionCookie.value, secret)).payload).toEqual(expect.objectContaining({ nonce: authorizationUrl.searchParams.get("nonce"), codeVerifier: expect.any(String), responseType: "code", state: authorizationUrl.searchParams.get("state"), returnTo: "/" })); }); it("should configure redirect_uri when appBaseUrl isnt the root", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: `${DEFAULT.appBaseUrl}/sub-path`, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const request = new NextRequest(new URL("/auth/login", DEFAULT.appBaseUrl), { method: "GET" }); const response = await authClient.handleLogin(request); const authorizationUrl = new URL(response.headers.get("Location")); expect(authorizationUrl.searchParams.get("redirect_uri")).toEqual(`${DEFAULT.appBaseUrl}/sub-path/auth/callback`); }); describe("with a base path", async () => { beforeAll(() => { process.env.NEXT_PUBLIC_BASE_PATH = "/base-path"; }); afterAll(() => { delete process.env.NEXT_PUBLIC_BASE_PATH; }); it("should prepend the base path to the redirect_uri", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: `${DEFAULT.appBaseUrl}`, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const request = new NextRequest(new URL(process.env.NEXT_PUBLIC_BASE_PATH + "/auth/login", DEFAULT.appBaseUrl), { method: "GET" }); const response = await authClient.handleLogin(request); const authorizationUrl = new URL(response.headers.get("Location")); expect(authorizationUrl.searchParams.get("redirect_uri")).toEqual(`${DEFAULT.appBaseUrl}/base-path/auth/callback`); }); }); it("should return an error if the discovery endpoint could not be fetched", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer({ discoveryResponse: new Response(null, { status: 500 }) }) }); const request = new NextRequest(new URL("/auth/login", DEFAULT.appBaseUrl), { method: "GET" }); const response = await authClient.handleLogin(request); expect(response.status).toEqual(500); expect(await response.text()).toContain("An error occured while trying to initiate the login request."); }); describe("authorization parameters", async () => { it("should forward the query parameters to the authorization server", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const loginUrl = new URL("/auth/login", DEFAULT.appBaseUrl); loginUrl.searchParams.set("custom_param", "custom_value"); loginUrl.searchParams.set("audience", "urn:mystore:api"); const request = new NextRequest(loginUrl, { method: "GET" }); const response = await authClient.handleLogin(request); expect(response.status).toEqual(307); expect(response.headers.get("Location")).not.toBeNull(); const authorizationUrl = new URL(response.headers.get("Location")); expect(authorizationUrl.origin).toEqual(`https://${DEFAULT.domain}`); // query parameters expect(authorizationUrl.searchParams.get("client_id")).toEqual(DEFAULT.clientId); expect(authorizationUrl.searchParams.get("redirect_uri")).toEqual(`${DEFAULT.appBaseUrl}/auth/callback`); expect(authorizationUrl.searchParams.get("response_type")).toEqual("code"); expect(authorizationUrl.searchParams.get("code_challenge")).not.toBeNull(); expect(authorizationUrl.searchParams.get("code_challenge_method")).toEqual("S256"); expect(authorizationUrl.searchParams.get("state")).not.toBeNull(); expect(authorizationUrl.searchParams.get("nonce")).not.toBeNull(); expect(authorizationUrl.searchParams.get("scope")).toEqual("openid profile email offline_access"); expect(authorizationUrl.searchParams.get("custom_param")).toEqual("custom_value"); expect(authorizationUrl.searchParams.get("audience")).toEqual("urn:mystore:api"); // transaction state const transactionCookie = response.cookies.get(`__txn_${authorizationUrl.searchParams.get("state")}`); expect(transactionCookie).toBeDefined(); expect((await decrypt(transactionCookie.value, secret)).payload).toEqual(expect.objectContaining({ nonce: authorizationUrl.searchParams.get("nonce"), codeVerifier: expect.any(String), responseType: "code", state: authorizationUrl.searchParams.get("state"), returnTo: "/" })); }); it("should forward the configured authorization parameters to the authorization server", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, authorizationParameters: { scope: "openid profile email offline_access custom_scope", audience: "urn:mystore:api", custom_param: "custom_value" }, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const loginUrl = new URL("/auth/login", DEFAULT.appBaseUrl); const request = new NextRequest(loginUrl, { method: "GET" }); const response = await authClient.handleLogin(request); expect(response.status).toEqual(307); expect(response.headers.get("Location")).not.toBeNull(); const authorizationUrl = new URL(response.headers.get("Location")); expect(authorizationUrl.origin).toEqual(`https://${DEFAULT.domain}`); // query parameters expect(authorizationUrl.searchParams.get("client_id")).toEqual(DEFAULT.clientId); expect(authorizationUrl.searchParams.get("redirect_uri")).toEqual(`${DEFAULT.appBaseUrl}/auth/callback`); expect(authorizationUrl.searchParams.get("response_type")).toEqual("code"); expect(authorizationUrl.searchParams.get("code_challenge")).not.toBeNull(); expect(authorizationUrl.searchParams.get("code_challenge_method")).toEqual("S256"); expect(authorizationUrl.searchParams.get("state")).not.toBeNull(); expect(authorizationUrl.searchParams.get("nonce")).not.toBeNull(); expect(authorizationUrl.searchParams.get("scope")).toEqual("openid profile email offline_access custom_scope"); expect(authorizationUrl.searchParams.get("custom_param")).toEqual("custom_value"); expect(authorizationUrl.searchParams.get("audience")).toEqual("urn:mystore:api"); }); it("should override the configured authorization parameters with the query parameters", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, authorizationParameters: { audience: "from-config", custom_param: "from-config" }, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const loginUrl = new URL("/auth/login", DEFAULT.appBaseUrl); loginUrl.searchParams.set("custom_param", "from-query"); loginUrl.searchParams.set("audience", "from-query"); const request = new NextRequest(loginUrl, { method: "GET" }); const response = await authClient.handleLogin(request); expect(response.status).toEqual(307); expect(response.headers.get("Location")).not.toBeNull(); const authorizationUrl = new URL(response.headers.get("Location")); expect(authorizationUrl.origin).toEqual(`https://${DEFAULT.domain}`); // query parameters expect(authorizationUrl.searchParams.get("client_id")).toEqual(DEFAULT.clientId); expect(authorizationUrl.searchParams.get("redirect_uri")).toEqual(`${DEFAULT.appBaseUrl}/auth/callback`); expect(authorizationUrl.searchParams.get("response_type")).toEqual("code"); expect(authorizationUrl.searchParams.get("code_challenge")).not.toBeNull(); expect(authorizationUrl.searchParams.get("code_challenge_method")).toEqual("S256"); expect(authorizationUrl.searchParams.get("state")).not.toBeNull(); expect(authorizationUrl.searchParams.get("nonce")).not.toBeNull(); expect(authorizationUrl.searchParams.get("scope")).toEqual("openid profile email offline_access"); expect(authorizationUrl.searchParams.get("custom_param")).toEqual("from-query"); expect(authorizationUrl.searchParams.get("audience")).toEqual("from-query"); }); it("should not override internal authorization parameter values", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, authorizationParameters: { client_id: "from-config", redirect_uri: "from-config", response_type: "from-config", code_challenge: "from-config", code_challenge_method: "from-config", state: "from-config", nonce: "from-config", // allowed to be overridden custom_param: "from-config", scope: "openid profile email offline_access custom_scope", audience: "from-config" }, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), fetch: getMockAuthorizationServer() }); const loginUrl = new URL("/auth/login", DEFAULT.appBaseUrl); loginUrl.searchParams.set("client_id", "from-query"); loginUrl.searchParams.set("redirect_uri", "from-query"); loginUrl.searchParams.set("response_type", "from-query");