UNPKG

@auth0/nextjs-auth0

Version:
369 lines (368 loc) 17.8 kB
import * as oauth from "oauth4webapi"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getDefaultRoutes } from "../test/defaults.js"; import { generateSecret } from "../test/utils.js"; import { generateDpopKeyPair } from "../utils/dpopUtils.js"; import { AuthClient } from "./auth-client.js"; import { StatelessSessionStore } from "./session/stateless-session-store.js"; import { TransactionStore } from "./transaction-store.js"; // Mock oauth4webapi for integration tests vi.mock("oauth4webapi", async () => { const actual = await vi.importActual("oauth4webapi"); return { ...actual, protectedResourceRequest: vi.fn(), isDPoPNonceError: vi.fn(), DPoP: vi.fn((client, keyPair) => ({ client, keyPair })), // Simple mock DPoP handle generateKeyPair: vi.fn(async () => ({ privateKey: {}, publicKey: {} })), // Mock discovery functions for proper discovery flow discoveryRequest: vi.fn(), processDiscoveryResponse: vi.fn(), customFetch: Symbol("customFetch"), allowInsecureRequests: Symbol("allowInsecureRequests") }; }); describe("Connected Accounts DPoP Integration Tests", () => { let sessionStore; let secret; let dpopKeyPair; const DEFAULT = { domain: "test.auth0.com", clientId: "test-client-id", clientSecret: "test-client-secret", appBaseUrl: "https://example.com", sub: "user_123", accessToken: "at_123", connectAccount: { authSession: "auth-session-123", ticket: "ticket-123", connection: "google-oauth2" } }; // Mock authorization server function getMockAuthorizationServer(options = {}) { const { onConnectAccountRequest, onCompleteConnectAccountRequest } = options; return async (input, init) => { const url = new URL(typeof input === "string" ? input : input.toString()); // Discovery endpoint if (url.pathname === "/.well-known/openid_configuration") { return Response.json({ issuer: `https://${DEFAULT.domain}/`, authorization_endpoint: `https://${DEFAULT.domain}/authorize`, token_endpoint: `https://${DEFAULT.domain}/oauth/token`, jwks_uri: `https://${DEFAULT.domain}/.well-known/jwks.json`, end_session_endpoint: `https://${DEFAULT.domain}/v2/logout` }); } // Connect Account if (url.pathname === "/me/v1/connected-accounts/connect") { if (onConnectAccountRequest) { await onConnectAccountRequest(new Request(input, init)); } return Response.json({ connect_uri: `https://${DEFAULT.domain}/connect`, auth_session: DEFAULT.connectAccount.authSession, connect_params: { ticket: DEFAULT.connectAccount.ticket }, expires_in: 300 }, { status: 201 }); } // Connect Account complete if (url.pathname === "/me/v1/connected-accounts/complete") { if (onCompleteConnectAccountRequest) { await onCompleteConnectAccountRequest(new Request(input, init)); } return Response.json({ id: "conn_123", connection: DEFAULT.connectAccount.connection, access_type: "offline", scopes: ["profile", "email"], created_at: new Date().toISOString(), expires_at: null }, { status: 200 }); } return new Response("Not Found", { status: 404 }); }; } beforeEach(async () => { secret = await generateSecret(32); dpopKeyPair = await generateDpopKeyPair(); sessionStore = new StatelessSessionStore({ secret }); // Reset mocks vi.mocked(oauth.protectedResourceRequest).mockReset(); vi.mocked(oauth.isDPoPNonceError).mockReset(); vi.mocked(oauth.DPoP).mockReset(); // Setup discovery mocks vi.mocked(oauth.discoveryRequest).mockResolvedValue(new Response()); vi.mocked(oauth.processDiscoveryResponse).mockResolvedValue({ issuer: `https://${DEFAULT.domain}/`, authorization_endpoint: `https://${DEFAULT.domain}/authorize`, token_endpoint: `https://${DEFAULT.domain}/oauth/token`, jwks_uri: `https://${DEFAULT.domain}/.well-known/jwks.json`, end_session_endpoint: `https://${DEFAULT.domain}/v2/logout` }); }); afterEach(() => { vi.clearAllMocks(); }); describe("createConnectAccountTicket authentication", () => { it("should use DPoP authentication when DPoP is enabled", async () => { // Setup successful DPoP response const mockResponse = new Response(JSON.stringify({ connect_uri: `https://${DEFAULT.domain}/connect`, auth_session: DEFAULT.connectAccount.authSession, connect_params: { ticket: DEFAULT.connectAccount.ticket }, expires_in: 300 }), { status: 201 }); vi.mocked(oauth.protectedResourceRequest).mockResolvedValue(mockResponse); vi.mocked(oauth.DPoP).mockReturnValue({ client: {}, keyPair: {} }); // Create auth client with DPoP enabled const authClientWithDPoP = new AuthClient({ transactionStore: new TransactionStore({ secret }), sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), dpopKeyPair, useDPoP: true, fetch: getMockAuthorizationServer({}) }); // Call the private method via reflection const createConnectAccountTicket = authClientWithDPoP.createConnectAccountTicket.bind(authClientWithDPoP); const connectAccountRequest = { tokenSet: { accessToken: DEFAULT.accessToken, expiresAt: null, scope: "scope", token_type: "DPoP" }, connection: DEFAULT.connectAccount.connection, redirectUri: `${DEFAULT.appBaseUrl}/auth/callback`, state: "test-state", codeChallenge: "test-challenge", codeChallengeMethod: "S256", authorizationParams: {} }; const [error, result] = await createConnectAccountTicket(connectAccountRequest); // Verify DPoP was used expect(error).toBeNull(); expect(result).toBeDefined(); expect(oauth.protectedResourceRequest).toHaveBeenCalledWith(DEFAULT.accessToken, "POST", expect.any(URL), expect.any(Headers), expect.any(ReadableStream), expect.objectContaining({ DPoP: { client: {}, keyPair: {} } })); expect(oauth.DPoP).toHaveBeenCalledWith(expect.anything(), // clientMetadata dpopKeyPair); }); it("should use Bearer token authentication when DPoP is disabled", async () => { // Setup successful response for protectedResourceRequest const mockResponse = new Response(JSON.stringify({ connect_uri: `https://${DEFAULT.domain}/connect`, auth_session: DEFAULT.connectAccount.authSession, connect_params: { ticket: DEFAULT.connectAccount.ticket }, expires_in: 300 }), { status: 201 }); vi.mocked(oauth.protectedResourceRequest).mockResolvedValue(mockResponse); // Create auth client without DPoP const authClientNoDPoP = new AuthClient({ transactionStore: new TransactionStore({ secret }), sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), // No DPoP configuration fetch: getMockAuthorizationServer({}) }); const createConnectAccountTicket = authClientNoDPoP.createConnectAccountTicket.bind(authClientNoDPoP); const connectAccountRequest = { tokenSet: { accessToken: DEFAULT.accessToken, expiresAt: null, scope: "scope", token_type: "Bearer" }, connection: DEFAULT.connectAccount.connection, redirectUri: `${DEFAULT.appBaseUrl}/auth/callback`, state: "test-state", codeChallenge: "test-challenge", codeChallengeMethod: "S256", authorizationParams: {} }; const [error, result] = await createConnectAccountTicket(connectAccountRequest); // Verify request succeeded expect(error).toBeNull(); expect(result).toBeDefined(); // Verify protectedResourceRequest was used (it handles Bearer tokens when DPoP is disabled) expect(oauth.protectedResourceRequest).toHaveBeenCalledWith(DEFAULT.accessToken, "POST", expect.any(URL), expect.any(Headers), expect.any(ReadableStream), expect.objectContaining({ // Should NOT contain DPoP handle when DPoP is disabled })); // Verify the options passed to protectedResourceRequest don't include DPoP const callArgs = oauth.protectedResourceRequest.mock.calls[0]; const options = callArgs[5]; expect(options.DPoP).toBeUndefined(); }); }); describe("completeConnectAccount authentication", () => { it("should use DPoP authentication when DPoP is enabled", async () => { // Setup successful DPoP response const mockResponse = new Response(JSON.stringify({ id: "conn_123", connection: DEFAULT.connectAccount.connection, access_type: "offline", scopes: ["profile", "email"], created_at: new Date().toISOString(), expires_at: null }), { status: 200 }); vi.mocked(oauth.protectedResourceRequest).mockResolvedValue(mockResponse); vi.mocked(oauth.DPoP).mockReturnValue({ client: {}, keyPair: {} }); // Create auth client with DPoP enabled const authClientWithDPoP = new AuthClient({ transactionStore: new TransactionStore({ secret }), sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), dpopKeyPair, useDPoP: true, fetch: getMockAuthorizationServer({}) }); // Call the private method via reflection const completeConnectAccount = authClientWithDPoP.completeConnectAccount.bind(authClientWithDPoP); const completeRequest = { tokenSet: { accessToken: DEFAULT.accessToken, expiresAt: null, scope: "scope", token_type: "DPoP" }, authSession: DEFAULT.connectAccount.authSession, connectCode: "connect-code-123", redirectUri: `${DEFAULT.appBaseUrl}/auth/callback`, codeVerifier: "code-verifier" }; const [error, result] = await completeConnectAccount(completeRequest); // Verify DPoP was used expect(error).toBeNull(); expect(result).toBeDefined(); expect(oauth.protectedResourceRequest).toHaveBeenCalledWith(DEFAULT.accessToken, "POST", expect.any(URL), expect.any(Headers), expect.any(ReadableStream), expect.objectContaining({ DPoP: { client: {}, keyPair: {} } })); expect(oauth.DPoP).toHaveBeenCalledWith(expect.anything(), // clientMetadata dpopKeyPair); }); it("should use Bearer token authentication when DPoP is disabled", async () => { // Setup successful response for protectedResourceRequest const mockResponse = new Response(JSON.stringify({ id: "conn_123", connection: DEFAULT.connectAccount.connection, access_type: "offline", scopes: ["profile", "email"], created_at: new Date().toISOString(), expires_at: null }), { status: 200 }); vi.mocked(oauth.protectedResourceRequest).mockResolvedValue(mockResponse); // Create auth client without DPoP const authClientNoDPoP = new AuthClient({ transactionStore: new TransactionStore({ secret }), sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), // No DPoP configuration fetch: getMockAuthorizationServer({}) }); const completeConnectAccount = authClientNoDPoP.completeConnectAccount.bind(authClientNoDPoP); const completeRequest = { tokenSet: { accessToken: DEFAULT.accessToken, expiresAt: null, scope: "scope", token_type: "Bearer" }, authSession: DEFAULT.connectAccount.authSession, connectCode: "connect-code-123", redirectUri: `${DEFAULT.appBaseUrl}/auth/callback`, codeVerifier: "code-verifier" }; const [error, result] = await completeConnectAccount(completeRequest); // Verify request succeeded expect(error).toBeNull(); expect(result).toBeDefined(); // Verify protectedResourceRequest was used (it handles Bearer tokens when DPoP is disabled) expect(oauth.protectedResourceRequest).toHaveBeenCalledWith(DEFAULT.accessToken, "POST", expect.any(URL), expect.any(Headers), expect.any(ReadableStream), expect.objectContaining({ // Should NOT contain DPoP handle when DPoP is disabled })); // Verify the options passed to protectedResourceRequest don't include DPoP const callArgs = oauth.protectedResourceRequest.mock.calls[0]; const options = callArgs[5]; expect(options.DPoP).toBeUndefined(); }); }); describe("DPoP misconfigured behavior", () => { it("should throw an error when DPoP is enabled but dpopKeyPair is missing", async () => { // Setup successful response for protectedResourceRequest const mockResponse = new Response(JSON.stringify({ connect_uri: `https://${DEFAULT.domain}/connect`, auth_session: DEFAULT.connectAccount.authSession, connect_params: { ticket: DEFAULT.connectAccount.ticket }, expires_in: 300 }), { status: 201 }); vi.mocked(oauth.protectedResourceRequest).mockResolvedValue(mockResponse); // Create auth client with DPoP enabled but no key pair const authClientDPoPNoKeys = new AuthClient({ transactionStore: new TransactionStore({ secret }), sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), useDPoP: true, // dpopKeyPair intentionally omitted fetch: getMockAuthorizationServer({}) }); const createConnectAccountTicket = authClientDPoPNoKeys.createConnectAccountTicket.bind(authClientDPoPNoKeys); const connectAccountRequest = { tokenSet: { accessToken: DEFAULT.accessToken, expiresAt: null, scope: "scope", token_type: "Bearer" }, connection: DEFAULT.connectAccount.connection, redirectUri: `${DEFAULT.appBaseUrl}/auth/callback`, state: "test-state", codeChallenge: "test-challenge", codeChallengeMethod: "S256", authorizationParams: {} }; const [error, result] = await createConnectAccountTicket(connectAccountRequest); // Verify it fell back to Bearer tokens expect(error).not.toBeNull(); expect(result).toBeNull(); expect(error.message).toBe("DPoP is enabled but no keypair is configured."); }); }); });