UNPKG

@auth0/nextjs-auth0

Version:
274 lines (273 loc) 12.2 kB
import { NextRequest } from "next/server.js"; import * as jose from "jose"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import { beforeAll, describe, expect, it } from "vitest"; import { getDefaultRoutes } from "../test/defaults.js"; import { generateSecret } from "../test/utils.js"; import { RESPONSE_TYPES } from "../types/index.js"; import { generateDpopKeyPair } from "../utils/dpopUtils.js"; import { AuthClient } from "./auth-client.js"; import { decrypt, encrypt } from "./cookies.js"; import { StatelessSessionStore } from "./session/stateless-session-store.js"; import { TransactionStore } from "./transaction-store.js"; /** * Real SDK Integration Test for DPoP Nonce Retry on Auth Code Callback * * This test validates that AuthClient.handleCallback() properly implements * RFC 9449 Section 8 behavior: when a token endpoint returns 400 with * use_dpop_nonce error and DPoP-Nonce header, the SDK automatically retries * the request with the nonce included in the DPoP proof. * * Test Flow: * 1. Create AuthClient with DPoP enabled * 2. Build callback request with authorization code and transaction cookie * 3. Call handleCallback() - the actual SDK method users call * 4. Custom fetch mock intercepts: first token request fails with use_dpop_nonce, second succeeds * 5. Validate handleCallback() returns successful response with session cookie * 6. This proves the retry wrapper is working transparently at the SDK level */ // Test constants const DEFAULT = { domain: "auth0.local", clientId: "test_client_id", clientSecret: "test_client_secret", appBaseUrl: "https://example.com", sub: "user_123", sid: "auth0-session-id", alg: "RS256", accessToken: "access_token_123", refreshToken: "refresh_token_123", authorizationCode: "auth_code_123", nonce: "nonce_value_123" }; let keyPair; let dpopKeyPair; /** * Helper to create a stateful DPoP nonce retry handler for MSW * Manages internal state tracking for request count and DPoP nonce validation * Returns 400 with use_dpop_nonce on first request, 200 with tokens on retry */ function createDPoPNonceRetryHandler(keyPairParam) { // Internal state management for this handler instance const state = { requestCount: 0, requests: [] }; // Helper to parse DPoP JWT and extract nonce claim const extractDPoPNonce = (dpopHeader) => { if (!dpopHeader || typeof dpopHeader !== "string") { return { hasNonce: false }; } try { // DPoP is a JWT: header.payload.signature const parts = dpopHeader.split("."); if (parts.length === 3 && parts[1]) { const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8")); if ("nonce" in payload) { return { hasNonce: true, nonce: payload.nonce }; } } } catch { // If parsing fails, assume no nonce } return { hasNonce: false }; }; // MSW handler that manages the retry flow const handler = async ({ request }) => { state.requestCount++; // Extract DPoP header from request const dpopHeader = request.headers.get("dpop"); const { hasNonce, nonce } = extractDPoPNonce(dpopHeader); // Track request details for assertions state.requests.push({ attempt: state.requestCount, hasDPoP: !!dpopHeader, hasNonce, nonce, dpopJwt: dpopHeader || undefined }); // First request: Return 400 with use_dpop_nonce error if (state.requestCount === 1) { return HttpResponse.json({ error: "use_dpop_nonce", error_description: "Authorization server requires nonce in DPoP proof" }, { status: 400, headers: { "dpop-nonce": "server_nonce_value_123" } }); } // Second request: Return 200 with tokens const idToken = await new jose.SignJWT({ sid: DEFAULT.sid, sub: DEFAULT.sub, nonce: DEFAULT.nonce, auth_time: Math.floor(Date.now() / 1000), iss: `https://${DEFAULT.domain}/`, aud: DEFAULT.clientId }) .setProtectedHeader({ alg: DEFAULT.alg }) .setIssuedAt() .setExpirationTime("1h") .sign(keyPairParam.privateKey); return HttpResponse.json({ access_token: DEFAULT.accessToken, refresh_token: DEFAULT.refreshToken, id_token: idToken, token_type: "Bearer", expires_in: 86400 }); }; return { handler, state }; } beforeAll(async () => { keyPair = await jose.generateKeyPair("RS256"); dpopKeyPair = await generateDpopKeyPair(); }); describe("AuthClient.handleCallback with DPoP Nonce Retry", () => { it("should transparently retry auth code exchange when server returns use_dpop_nonce error", async () => { // Create handler with internal state management const { handler: tokenHandler, state: tokenHandlerState } = createDPoPNonceRetryHandler(keyPair); // Setup MSW handlers const handlers = [ http.get(`https://${DEFAULT.domain}/.well-known/openid-configuration`, () => { return HttpResponse.json({ issuer: `https://${DEFAULT.domain}/`, token_endpoint: `https://${DEFAULT.domain}/oauth/token`, jwks_uri: `https://${DEFAULT.domain}/.well-known/jwks.json` }); }), http.get(`https://${DEFAULT.domain}/.well-known/jwks.json`, async () => { const jwk = await jose.exportJWK(keyPair.publicKey); return HttpResponse.json({ keys: [jwk] }); }), http.post(`https://${DEFAULT.domain}/oauth/token`, tokenHandler) ]; const server = setupServer(...handlers); // Start MSW server for this test server.listen({ onUnhandledRequest: "error" }); try { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); // Create AuthClient with DPoP enabled // Note: No custom fetch needed - MSW intercepts global fetch automatically const authClient = new AuthClient({ transactionStore, sessionStore, domain: DEFAULT.domain, clientId: DEFAULT.clientId, clientSecret: DEFAULT.clientSecret, secret, appBaseUrl: DEFAULT.appBaseUrl, routes: getDefaultRoutes(), dpopKeyPair, useDPoP: true }); // Build callback request const state = "test-state-123"; const callbackUrl = new URL("/auth/callback", DEFAULT.appBaseUrl); callbackUrl.searchParams.set("code", DEFAULT.authorizationCode); callbackUrl.searchParams.set("state", state); // Create and encrypt transaction state const transactionState = { nonce: DEFAULT.nonce, maxAge: 3600, codeVerifier: "code_verifier_123", responseType: RESPONSE_TYPES.CODE, state, returnTo: "/dashboard" }; const maxAge = 60 * 60; // 1 hour const expiration = Math.floor(Date.now() / 1000 + maxAge); const encryptedTxn = await encrypt(transactionState, secret, expiration); const headers = new Headers(); headers.set("cookie", `__txn_${state}=${encryptedTxn}`); const request = new NextRequest(callbackUrl, { method: "GET", headers }); // CALL THE ACTUAL SDK METHOD // This is where withDPoPNonceRetry() is applied internally const response = await authClient.handleCallback(request); // Validate response expect(response.status).toBe(307); // Successful callback redirect // Validate redirect goes to returnTo const location = response.headers.get("Location"); expect(location).toBeTruthy(); expect(new URL(location, DEFAULT.appBaseUrl).pathname).toBe("/dashboard"); // Validate session cookie was created with tokens const sessionCookie = response.cookies.get("__session"); expect(sessionCookie).toBeDefined(); const { payload: session } = (await decrypt(sessionCookie.value, secret)); expect(session).toMatchObject({ user: { sub: DEFAULT.sub }, tokenSet: { accessToken: DEFAULT.accessToken, refreshToken: DEFAULT.refreshToken, idToken: expect.stringMatching(/^eyJhbGciOiJSUzI1NiJ9\..+\..+$/) } }); // Validate transaction cookie was cleaned up const txnCookie = response.cookies.get(`__txn_${state}`); expect(txnCookie).toBeDefined(); expect(txnCookie.value).toBe(""); expect(txnCookie.maxAge).toBe(0); // Validate that TWO fetch calls were made to token endpoint: // 1. First call: No nonce → Got 400 use_dpop_nonce error // 2. Second call: With nonce → Got 200 with tokens // // This proves handleCallback() used withDPoPNonceRetry() wrapper, // which detected the 400 error, extracted the nonce from DPoP-Nonce // header, and automatically retried with the nonce included. // // If the retry wrapper wasn't applied, we'd see: // - Only 1 token endpoint call (the failing one) // - handleCallback would propagate the 400 error to the user // - Test would fail at the response.status.toBe(307) assertion expect(tokenHandlerState.requestCount).toBe(2); // Verify DPoP headers were sent correctly expect(tokenHandlerState.requests).toHaveLength(2); // First request: DPoP WITHOUT nonce expect(tokenHandlerState.requests[0]).toMatchObject({ attempt: 1, hasDPoP: true, hasNonce: false, nonce: undefined }); // Second request: DPoP WITH nonce (the retry) // This is the critical validation - the client actually sent the nonce expect(tokenHandlerState.requests[1]).toMatchObject({ attempt: 2, hasDPoP: true, hasNonce: true }); // Verify the nonce value matches what server provided // This proves oauth4webapi DPoP handle correctly: // 1. Received the DPoP-Nonce header from the 400 error response // 2. Extracted the nonce value ("server_nonce_value_123") // 3. Injected it into the DPoP JWT payload on retry expect(tokenHandlerState.requests[1].nonce).toBe("server_nonce_value_123"); // Additional validation: Decode the second request's DPoP JWT and verify // the payload contains the exact nonce claim const secondRequestDPoP = tokenHandlerState.requests[1].dpopJwt; expect(secondRequestDPoP).toBeDefined(); if (secondRequestDPoP) { const dpoPPayload = jose.decodeJwt(secondRequestDPoP); expect(dpoPPayload.nonce).toBe("server_nonce_value_123"); // Additional claims to verify DPoP structure expect(dpoPPayload.htm).toBe("POST"); expect(dpoPPayload.htu).toMatch(/oauth\/token$/); } } finally { // Clean up MSW server server.close(); } }); });