diff --git a/.gitignore b/.gitignore index 31604081..ba76104c 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,7 @@ web_modules/ .env.test.local .env.production.local .env.local +.auth0-credentials # parcel-bundler cache (https://parceljs.org/) .cache @@ -132,3 +133,12 @@ dist /playwright-report/ /blob-report/ /playwright/.cache/ + +# local development files +.memory/ +.dev-files/ +*.env.* +*.tmp +*PLAN*.md +.yalc/ +yalc.lock \ No newline at end of file diff --git a/EXAMPLES.md b/EXAMPLES.md index 5e621732..da333d76 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -67,6 +67,23 @@ - [Troubleshooting](#troubleshooting) - [Common Issues](#common-issues) - [Debug Logging](#debug-logging) +- [Proxy Handler for My Account and My Organization APIs](#proxy-handler-for-my-account-and-my-organization-apis) + - [Overview](#overview) + - [How It Works](#how-it-works) + - [My Account API Proxy](#my-account-api-proxy) + - [Configuration](#configuration) + - [Client-Side Usage](#client-side-usage) + - [`scope` Header](#scope-header) + - [My Organization API Proxy](#my-organization-api-proxy) + - [Configuration](#configuration-1) + - [Client-Side Usage](#client-side-usage-1) + - [Integration with UI Components](#integration-with-ui-components) + - [HTTP Methods](#http-methods) + - [CORS Handling](#cors-handling) + - [Error Handling](#error-handling-1) + - [Token Management](#token-management) + - [Security Considerations](#security-considerations) + - [Debugging](#debugging) - [``](#auth0provider-) - [Passing an initial user from the server](#passing-an-initial-user-from-the-server) - [Hooks](#hooks) @@ -1550,6 +1567,341 @@ const fetcher = await auth0.createFetcher(req, { }); ``` +## Proxy Handler for My Account and My Organization APIs + +The SDK provides built-in proxy handler support for Auth0's My Account and My Organization Management APIs. This enables browser-initiated requests to these APIs while maintaining server-side DPoP authentication and token management. + +### Overview + +The proxy handler implements a Backend-for-Frontend (BFF) pattern that transparently forwards client requests to Auth0 APIs through the Next.js server. This architecture ensures: + +- DPoP private keys and tokens remain on the server, inaccessible to client-side JavaScript +- Automatic token retrieval and refresh based on requested audience and scope +- DPoP proof generation for each proxied request +- Session updates when tokens are refreshed +- Proper CORS handling for cross-origin requests + +The proxy handler is automatically enabled when using the SDK's middleware and requires no additional configuration. + +### How It Works + +When a client makes a request to `/me/*` or `/my-org/*` on your Next.js application: + +1. The SDK's middleware intercepts the request +2. Validates the user's session exists +3. Retrieves or refreshes the appropriate access token for the requested audience +4. Generates DPoP proof if DPoP is enabled +5. Forwards the request to the upstream Auth0 API with proper authentication headers +6. Returns the response to the client +7. Updates the session if tokens were refreshed + +### My Account API Proxy + +The My Account API proxy handles all requests to Auth0's My Account API at `/me/v1/*`. + +#### Configuration + +Enable My Account API access by configuring the audience and scopes: + +```ts +import { Auth0Client } from "@auth0/nextjs-auth0/server"; + +export const auth0 = new Auth0Client({ + useDPoP: true, + authorizationParameters: { + audience: "urn:your-api-identifier", + scope: { + [`https://${process.env.AUTH0_DOMAIN}/me/`]: "profile:read profile:write factors:manage" + } + } +}); +``` + +#### Client-Side Usage + +Make requests to the My Account API through the `/me/*` path: + +```tsx +"use client"; + +import { useState } from "react"; + +export default function MyAccountProfile() { + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(false); + + const fetchProfile = async () => { + setLoading(true); + try { + const response = await fetch("/me/v1/profile", { + method: "GET", + headers: { + "scope": "profile:read" + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + setProfile(data); + } catch (error) { + console.error("Failed to fetch profile:", error); + } finally { + setLoading(false); + } + }; + + const updateProfile = async (updates) => { + try { + const response = await fetch("/me/v1/profile", { + method: "PATCH", + headers: { + "content-type": "application/json", + "scope": "profile:write" + }, + body: JSON.stringify(updates) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error("Failed to update profile:", error); + throw error; + } + }; + + return ( +
+ + {profile && ( +
{JSON.stringify(profile, null, 2)}
+ )} +
+ ); +} +``` + +#### `scope` Header + +The `scope` header specifies the scope required for the request. The SDK uses this to retrieve an access token with the appropriate scope for the My Account API audience. + +Format: `"scope": "scope1 scope2 scope3"` + +Common scopes for My Account API: +- `profile:read` - Read user profile information +- `profile:write` - Update user profile information +- `factors:read` - Read enrolled MFA factors +- `factors:manage` - Manage MFA factors +- `identities:read` - Read linked identities +- `identities:manage` - Link and unlink identities + +### My Organization API Proxy + +The My Organization API proxy handles all requests to Auth0's My Organization Management API at `/my-org/*`. + +#### Configuration + +Enable My Organization API access by configuring the audience and scopes: + +```ts +import { Auth0Client } from "@auth0/nextjs-auth0/server"; + +export const auth0 = new Auth0Client({ + useDPoP: true, + authorizationParameters: { + audience: "urn:your-api-identifier", + scope: { + [`https://${process.env.AUTH0_DOMAIN}/my-org/`]: "org:read org:write members:read" + } + } +}); +``` + +#### Client-Side Usage + +Make requests to the My Organization API through the `/my-org/*` path: + +```tsx +"use client"; + +import { useState, useEffect } from "react"; + +export default function MyOrganization() { + const [organizations, setOrganizations] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchOrganizations(); + }, []); + + const fetchOrganizations = async () => { + setLoading(true); + try { + const response = await fetch("/my-org/organizations", { + method: "GET", + headers: { + "scope": "org:read" + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + setOrganizations(data.organizations || []); + } catch (error) { + console.error("Failed to fetch organizations:", error); + } finally { + setLoading(false); + } + }; + + const updateOrganization = async (orgId, updates) => { + try { + const response = await fetch(`/my-org/organizations/${orgId}`, { + method: "PATCH", + headers: { + "content-type": "application/json", + "scope": "org:write" + }, + body: JSON.stringify(updates) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error("Failed to update organization:", error); + throw error; + } + }; + + if (loading) return
Loading organizations...
; + + return ( +
+

My Organizations

+ +
+ ); +} +``` + +Common scopes for My Organization API: +- `org:read` - Read organization information +- `org:write` - Update organization information +- `members:read` - Read organization members +- `members:manage` - Manage organization members +- `roles:read` - Read organization roles +- `roles:manage` - Manage organization roles + +### Integration with UI Components + +When using Auth0 UI Components with the proxy handler, configure the client to target the proxy endpoints: + +```tsx +import { MyAccountClient } from "@auth0/my-account-js"; + +const myAccountClient = new MyAccountClient({ + domain: process.env.NEXT_PUBLIC_AUTH0_DOMAIN, + baseUrl: "/me", + fetcher: (url, init, authParams) => { + return fetch(url, { + ...init, + headers: { + ...init?.headers, + "scope": authParams?.scope?.join(" ") || "" + } + }); + } +}); +``` + +This configuration: +- Sets `baseUrl` to `/me` to route requests through the proxy +- Passes the required scope via the `scope` header +- Ensures the SDK middleware handles authentication transparently + +### HTTP Methods + +The proxy handler supports all standard HTTP methods: + +- `GET` - Retrieve resources +- `POST` - Create resources +- `PUT` - Replace resources +- `PATCH` - Update resources +- `DELETE` - Remove resources +- `OPTIONS` - CORS preflight requests (handled without authentication) +- `HEAD` - Retrieve headers only + +### CORS Handling + +The proxy handler correctly handles CORS preflight requests (OPTIONS with `access-control-request-method` header) by forwarding them to the upstream API without authentication headers, as required by RFC 7231 §4.3.1. + +CORS headers from the upstream API are forwarded to the client transparently. + +### Error Handling + +The proxy handler returns appropriate HTTP status codes: + +- `401 Unauthorized` - No active session or token refresh failed +- `4xx Client Error` - Forwarded from upstream API +- `5xx Server Error` - Forwarded from upstream API or proxy internal error + +Error responses from the upstream API are forwarded to the client with their original status code, headers, and body. + +### Token Management + +The proxy handler automatically: + +- Retrieves access tokens from the session for the requested audience +- Refreshes expired tokens using the refresh token +- Updates the session with new tokens after refresh +- Caches tokens per audience to minimize token endpoint calls +- Generates DPoP proofs for each request when DPoP is enabled + +### Security Considerations + +The proxy handler implements secure forwarding: + +- HTTP-only session cookies are not forwarded to upstream APIs +- Authorization headers from the client are replaced with server-generated tokens +- Hop-by-hop headers are stripped per RFC 2616 §13.5.1 +- Only allow-listed request headers are forwarded +- Response headers are filtered before returning to the client +- Host header is updated to match the upstream API + +### Debugging + +Enable debug logging to troubleshoot proxy requests: + +```ts +export const auth0 = new Auth0Client({ + // ... other config + enableDebugLogs: true +}); +``` + +This will log: +- Request proxying flow +- Token retrieval and refresh operations +- DPoP proof generation +- Session updates +- Errors and warnings ## `` diff --git a/README.md b/README.md index 268ee6b6..8289ae24 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,58 @@ AUTH0_DPOP_CLOCK_TOLERANCE=90 # Tolerance in seconds Respective counterparts are also available in the client configuration. See [Cookie Configuration](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#cookie-configuration) for more details. +### Proxy Handler for My Account and My Organization APIs + +The SDK provides built-in proxy support for Auth0's My Account and My Organization Management APIs, enabling secure browser-initiated requests while maintaining server-side DPoP authentication and token management. + +#### How It Works + +The proxy handler automatically intercepts requests to `/me/*` and `/my-org/*` paths in your Next.js application and forwards them to the respective Auth0 APIs with proper authentication headers. This implements a Backend-for-Frontend (BFF) pattern where: + +- Tokens and DPoP keys remain on the server +- Access tokens are automatically retrieved or refreshed +- DPoP proofs are generated for each request +- Session updates occur transparently + +#### Configuration + +Configure audience and scopes for the APIs: + +```ts +import { Auth0Client } from "@auth0/nextjs-auth0/server"; + +export const auth0 = new Auth0Client({ + useDPoP: true, + authorizationParameters: { + audience: "urn:your-api-identifier", + scope: { + [`https://${process.env.AUTH0_DOMAIN}/me/`]: "profile:read profile:write", + [`https://${process.env.AUTH0_DOMAIN}/my-org/`]: "org:read org:write" + } + } +}); +``` + +#### Client-Side Usage + +Make requests through the proxy paths: + +```tsx +// My Account API +const response = await fetch("/me/v1/profile", { + headers: { "scope": "profile:read" } +}); + +// My Organization API +const response = await fetch("/my-org/organizations", { + headers: { "scope": "org:read" } +}); +``` + +The `scope` header specifies the required scope. The SDK retrieves an access token with the appropriate audience and scope, then forwards the request with authentication headers. + +For complete documentation, examples, and integration patterns with UI Components, see [Proxy Handler for My Account and My Organization APIs](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#proxy-handler-for-my-account-and-my-organization-apis). + ## Base Path Your Next.js application may be configured to use a base path (e.g.: `/dashboard`) — this is usually done by setting the `basePath` option in the `next.config.js` file. To configure the SDK to use the base path, you will also need to set the `NEXT_PUBLIC_BASE_PATH` environment variable which will be used when mounting the authentication routes. diff --git a/src/server/auth-client.proxy.test.ts b/src/server/auth-client.proxy.test.ts new file mode 100644 index 00000000..457e0715 --- /dev/null +++ b/src/server/auth-client.proxy.test.ts @@ -0,0 +1,1381 @@ +import { NextRequest, NextResponse } from "next/server.js"; +import * as jose from "jose"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi +} from "vitest"; + +import { getDefaultRoutes } from "../test/defaults.js"; +import { generateSecret } from "../test/utils.js"; +import { SessionData } 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"; + +const DEFAULT = { + domain: "test.auth0.local", + 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") +}; + +describe("Authentication Client", async () => { + describe("handleMyAccount", async () => { + const myAccountResponse = { + branding: { + logo_url: + "https://cdn.cookielaw.org/logos/5b38f79c-c925-4d4e-af5e-ec27e97e1068/01963fbf-a156-710c-9ff0-e3528aa88982/baec8c9a-62ca-45e4-8549-18024c4409b1/auth0-logo.png", + colors: { page_background: "#ffffff", primary: "#007bff" } + }, + id: "org_HdiNOwdtHO4fuiTU", + display_name: "cyborg", + name: "cyborg" + }; + + const secret = await generateSecret(32); + let authClient: AuthClient; + + // Create MSW server with default handlers + const server = setupServer( + // Discovery endpoint + http.get( + `https://${DEFAULT.domain}/.well-known/openid-configuration`, + () => { + return HttpResponse.json(_authorizationServerMetadata); + } + ), + // OAuth token endpoint + http.post(`https://${DEFAULT.domain}/oauth/token`, async () => { + const jwt = await new jose.SignJWT({ + sid: DEFAULT.sid, + auth_time: Date.now(), + nonce: "nonce-value", + "https://example.com/custom_claim": "value" + }) + .setProtectedHeader({ alg: DEFAULT.alg }) + .setSubject(DEFAULT.sub) + .setIssuedAt() + .setIssuer(_authorizationServerMetadata.issuer) + .setAudience(DEFAULT.clientId) + .setExpirationTime("2h") + .sign(DEFAULT.keyPair.privateKey); + + return HttpResponse.json({ + token_type: "Bearer", + access_token: DEFAULT.accessToken, + refresh_token: DEFAULT.refreshToken, + id_token: jwt, + expires_in: 86400 // expires in 10 days + }); + }), + // My Account proxy endpoint (default GET) + http.get(`https://${DEFAULT.domain}/me/v1/foo-bar/12`, ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get("foo") === "bar") { + return HttpResponse.json(myAccountResponse); + } + return new HttpResponse(null, { status: 404 }); + }), + // My Account proxy endpoint (default POST) - acts as a fallback + http.post(`https://${DEFAULT.domain}/me/v1/foo-bar/12`, () => { + return HttpResponse.json(myAccountResponse); + }) + ); + + // Start MSW server before all tests + beforeAll(() => { + server.listen({ onUnhandledRequest: "bypass" }); + }); + + // Reset handlers after each test + afterEach(() => { + server.resetHandlers(); + }); + + // Stop MSW server after all tests + afterAll(() => { + server.close(); + }); + + beforeEach(async () => { + const dpopKeyPair = await generateDpopKeyPair(); + authClient = new AuthClient({ + transactionStore: new TransactionStore({ + secret + }), + sessionStore: new StatelessSessionStore({ + secret + }), + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + routes: getDefaultRoutes(), + + // No need to pass custom fetch - MSW will intercept native fetch + useDPoP: true, + dpopKeyPair: dpopKeyPair, + authorizationParameters: { + audience: "test-api", + scope: { + [`https://${DEFAULT.domain}/me/`]: "foo" + } + }, + fetch: (url, init) => + fetch(url, { ...init, ...(init?.body ? { duplex: "half" } : {}) }) + }); + }); + + it("should return 401 when no session", async () => { + const request = new NextRequest( + new URL("/me/foo-bar/12?foo=bar", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + scope: "foo:bar" + } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toEqual(401); + + const text = await response.text(); + expect(text).toEqual("The user does not have an active session."); + }); + + it("should proxy GET request to my account", async () => { + const session = createInitialSessionData(); + + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/me/foo-bar/12?foo=bar", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + scope: "foo:bar" + } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toEqual(200); + + const json = await response.json(); + expect(json).toEqual(myAccountResponse); + }); + + it("should read from the cache", async () => { + const cachedAccessToken = "cached_at_123"; + const session = createInitialSessionData({ + accessTokens: [ + { + audience: `https://${DEFAULT.domain}/me/`, + accessToken: cachedAccessToken, + scope: "foo foo:bar", + token_type: "Bearer", + expiresAt: Math.floor(Date.now() / 1000) + 3600 + } + ] + }); + const cookie = await createSessionCookie(session, secret); + + // Override the handler to check for the cached access token + server.use( + http.get( + `https://${DEFAULT.domain}/me/v1/foo-bar/12`, + ({ request }) => { + const authHeader = request.headers.get("authorization"); + const token = authHeader?.split(" ")[1]; + + if (token === cachedAccessToken) { + return HttpResponse.json(myAccountResponse); + } + return new HttpResponse(null, { status: 401 }); + } + ) + ); + + const request = new NextRequest( + new URL("/me/foo-bar/12", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + scope: "foo:bar" + } + } + ); + + const response = await authClient.handler(request); + + // The Set Cookie header is not updated since the cache was used + expect(response.headers.get("Set-Cookie")).toBeFalsy(); + }); + + it("should update the cache when using stateless storage when no entry", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/me/foo-bar/12?foo=bar", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + scope: "foo:bar" + } + } + ); + + const response = await authClient.handler(request); + + const accessToken = await getAccessTokenFromSetCookieHeader( + response, + secret, + `https://${DEFAULT.domain}/me/` + ); + + expect(accessToken).toBeDefined(); + expect(accessToken!.requestedScope).toEqual("foo foo:bar"); + }); + + it("should update the cache when using stateless storage when entry expired", async () => { + const cachedAccessToken = "cached_at_123"; + const session = createInitialSessionData({ + accessTokens: [ + { + audience: `https://${DEFAULT.domain}/me/`, + accessToken: cachedAccessToken, + scope: "foo foo:bar", + token_type: "Bearer", + expiresAt: Math.floor(Date.now() / 1000) - 3600 // expired + } + ] + }); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/me/foo-bar/12?foo=bar", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + scope: "foo:bar" + } + } + ); + + const response = await authClient.handler(request); + + const accessToken = await getAccessTokenFromSetCookieHeader( + response, + secret, + `https://${DEFAULT.domain}/me/` + ); + + expect(accessToken).toBeDefined(); + expect(accessToken!.requestedScope).toEqual("foo foo:bar"); + }); + + it("should proxy POST request to my account", async () => { + server.use( + http.post( + `https://${DEFAULT.domain}/me/v1/foo-bar/12`, + async ({ request }) => { + const body = await request.json(); + return HttpResponse.json(body, { status: 200 }); + } + ) + ); + + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/me/foo-bar/12", DEFAULT.appBaseUrl), + { + method: "POST", + headers: { + cookie, + scope: "foo:bar" + }, + body: JSON.stringify({ hello: "world" }), + duplex: "half" + } + ); + + const response = await authClient.handler(request); + + expect(response.status).toEqual(200); + + const json = await response.json(); + expect(json).toEqual({ hello: "world" }); + }); + + it("should proxy POST request to my account and proxy 204 responses without content", async () => { + server.use( + http.post( + `https://${DEFAULT.domain}/me/v1/foo-bar/12`, + async () => + new HttpResponse(null, { + status: 204, + headers: { + "X-RateLimit-Limit": "5" + } + }) + ) + ); + + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/me/foo-bar/12", DEFAULT.appBaseUrl), + { + method: "POST", + headers: { + cookie, + scope: "foo:bar" + }, + body: JSON.stringify({ hello: "world" }), + duplex: "half" + } + ); + + const response = await authClient.handler(request); + + expect(response.status).toEqual(204); + + const text = await response.text(); + expect(text).toBeFalsy(); + + expect(response.headers.get("X-RateLimit-Limit")).toEqual("5"); + }); + + it("should proxy PATCH request to my account", async () => { + server.use( + http.patch( + `https://${DEFAULT.domain}/me/v1/foo-bar/12`, + async ({ request }) => { + const body = (await request.json()) as any; + return HttpResponse.json( + { ...myAccountResponse, ...body }, + { status: 200 } + ); + } + ) + ); + + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/me/foo-bar/12", DEFAULT.appBaseUrl), + { + method: "PATCH", + headers: { + cookie, + scope: "foo:bar" + }, + body: JSON.stringify({ hello: "world" }), + duplex: "half" + } + ); + + const response = await authClient.handler(request); + + expect(response.status).toEqual(200); + + const json = await response.json(); + expect(json).toEqual({ ...myAccountResponse, hello: "world" }); + }); + + it("should proxy PUT request to my account", async () => { + server.use( + http.put( + `https://${DEFAULT.domain}/me/v1/foo-bar/12`, + async ({ request }) => { + const body = (await request.json()) as any; + return HttpResponse.json(body, { status: 200 }); + } + ) + ); + + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/me/foo-bar/12", DEFAULT.appBaseUrl), + { + method: "PUT", + headers: { + cookie, + scope: "foo:bar" + }, + body: JSON.stringify({ hello: "world" }), + duplex: "half" + } + ); + + const response = await authClient.handler(request); + + expect(response.status).toEqual(200); + + const json = await response.json(); + expect(json).toEqual({ hello: "world" }); + }); + + it("should proxy DELETE request to my account", async () => { + server.use( + http.delete(`https://${DEFAULT.domain}/me/v1/foo-bar/12`, async () => { + return new HttpResponse(null, { status: 204 }); + }) + ); + + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/me/foo-bar/12", DEFAULT.appBaseUrl), + { + method: "DELETE", + headers: { + cookie, + scope: "foo:bar" + }, + body: JSON.stringify({ hello: "world" }), + duplex: "half" + } + ); + + const response = await authClient.handler(request); + + expect(response.status).toEqual(204); + + const text = await response.text(); + expect(text).toBeFalsy(); + }); + + it("should handle when oauth/token throws", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + const request = new NextRequest( + new URL("/me/foo-bar/12?foo=bar", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + scope: "foo:bar" + } + } + ); + + // Override oauth/token handler to return an error + server.use( + http.post(`https://${DEFAULT.domain}/oauth/token`, () => { + return HttpResponse.json( + { + error: "test_error", + error_description: "An error from within the unit test." + }, + { status: 401 } + ); + }) + ); + + const response = await authClient.handler(request); + expect(response.status).toEqual(500); + + const text = await response.text(); + expect(text).toEqual("OAuth2Error: An error from within the unit test."); + }); + + it("should handle when getTokenSet throws", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + const request = new NextRequest( + new URL("/me/foo-bar/12?foo=bar", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + scope: "foo:bar" + } + } + ); + + authClient.getTokenSet = vi.fn().mockImplementation(() => { + { + throw new Error("An error from within the unit test."); + } + }); + + const response = await authClient.handler(request); + expect(response.status).toEqual(500); + + const text = await response.text(); + expect(text).toEqual("An error from within the unit test."); + }); + + it("should handle when getTokenSet throws without message", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + const request = new NextRequest( + new URL("/me/foo-bar/12?foo=bar", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + scope: "foo:bar" + } + } + ); + + authClient.getTokenSet = vi.fn().mockImplementation(() => { + { + throw new Error(); + } + }); + + const response = await authClient.handler(request); + expect(response.status).toEqual(500); + + const text = await response.text(); + expect(text).toEqual("An error occurred while proxying the request."); + }); + + describe("error responses", () => { + /** + * Test various error responses from the my-account endpoint + */ + [ + { status: 400, error: "bad_request", error_description: "Bad request" }, + { + status: 401, + error: "unauthorized", + error_description: "Not authorized" + }, + { + status: 403, + error: "insufficient_scope", + error_description: "You do not have the sufficient scope" + }, + { status: 404, error: "not_found", error_description: "Not Found" }, + { + status: 409, + error: "confict", + error_description: "There is a conflict" + }, + { + status: 429, + error: "rate_limit_exceeded", + error_description: "Rate limit exceeded" + }, + { + status: 500, + error: "internal_server_error", + error_description: "Internal Server Error" + } + ].forEach(({ status, error, error_description }) => { + it(`should handle ${status} from my-account and forward headers and error`, async () => { + server.use( + http.get(`https://${DEFAULT.domain}/me/v1/foo-bar/12`, async () => { + return HttpResponse.json( + { + error, + error_description + }, + { + status: status, + headers: { + "X-RateLimit-Limit": "5" + } + } + ); + }) + ); + + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + const request = new NextRequest( + new URL("/me/foo-bar/12?foo=bar", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + scope: "foo:bar" + } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toEqual(status); + + const headers = response.headers; + expect(headers.get("X-RateLimit-Limit")).toEqual("5"); + + const json = response.json(); + await expect(json).resolves.toEqual({ + error, + error_description + }); + }); + }); + }); + }); + + describe("handleMyOrg", async () => { + const myOrgResponse = { + branding: { + logo_url: + "https://cdn.cookielaw.org/logos/5b38f79c-c925-4d4e-af5e-ec27e97e1068/01963fbf-a156-710c-9ff0-e3528aa88982/baec8c9a-62ca-45e4-8549-18024c4409b1/auth0-logo.png", + colors: { page_background: "#ffffff", primary: "#007bff" } + }, + id: "org_HdiNOwdtHO4fuiTU", + display_name: "cyborg", + name: "cyborg" + }; + + const secret = await generateSecret(32); + let authClient: AuthClient; + + // Create MSW server with default handlers + const server = setupServer( + // Discovery endpoint + http.get( + `https://${DEFAULT.domain}/.well-known/openid-configuration`, + () => { + return HttpResponse.json(_authorizationServerMetadata); + } + ), + // OAuth token endpoint + http.post(`https://${DEFAULT.domain}/oauth/token`, async () => { + const jwt = await new jose.SignJWT({ + sid: DEFAULT.sid, + auth_time: Date.now(), + nonce: "nonce-value", + "https://example.com/custom_claim": "value" + }) + .setProtectedHeader({ alg: DEFAULT.alg }) + .setSubject(DEFAULT.sub) + .setIssuedAt() + .setIssuer(_authorizationServerMetadata.issuer) + .setAudience(DEFAULT.clientId) + .setExpirationTime("2h") + .sign(DEFAULT.keyPair.privateKey); + + return HttpResponse.json({ + token_type: "Bearer", + access_token: DEFAULT.accessToken, + refresh_token: DEFAULT.refreshToken, + id_token: jwt, + expires_in: 86400 // expires in 10 days + }); + }), + // My Org proxy endpoint (default GET) + http.get(`https://${DEFAULT.domain}/my-org/foo-bar/12`, ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get("foo") === "bar") { + return HttpResponse.json(myOrgResponse); + } + return new HttpResponse(null, { status: 404 }); + }), + // My Org proxy endpoint (default POST) - acts as a fallback + http.post(`https://${DEFAULT.domain}/my-org/v1/foo-bar/12`, () => { + return HttpResponse.json(myOrgResponse); + }) + ); + + // Start MSW server before all tests + beforeAll(() => { + server.listen({ onUnhandledRequest: "bypass" }); + }); + + // Reset handlers after each test + afterEach(() => { + server.resetHandlers(); + }); + + // Stop MSW server after all tests + afterAll(() => { + server.close(); + }); + + beforeEach(async () => { + const dpopKeyPair = await generateDpopKeyPair(); + authClient = new AuthClient({ + transactionStore: new TransactionStore({ + secret + }), + sessionStore: new StatelessSessionStore({ + secret + }), + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + routes: getDefaultRoutes(), + + // No need to pass custom fetch - MSW will intercept native fetch + useDPoP: true, + dpopKeyPair: dpopKeyPair, + authorizationParameters: { + audience: "test-api", + scope: { + [`https://${DEFAULT.domain}/my-org/`]: "foo" + } + }, + fetch: (url, init) => + fetch(url, { ...init, ...(init?.body ? { duplex: "half" } : {}) }) + }); + }); + + it("should return 401 when no session", async () => { + const request = new NextRequest( + new URL("/my-org/foo-bar/12?foo=bar", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + scope: "foo:bar" + } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toEqual(401); + + const text = await response.text(); + expect(text).toEqual("The user does not have an active session."); + }); + + it("should proxy GET request to my org", async () => { + const session = createInitialSessionData(); + + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/my-org/foo-bar/12?foo=bar", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + scope: "foo:bar" + } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toEqual(200); + + const json = await response.json(); + expect(json).toEqual(myOrgResponse); + }); + + it("should read from the cache", async () => { + const cachedAccessToken = "cached_at_123"; + const session = createInitialSessionData({ + accessTokens: [ + { + audience: `https://${DEFAULT.domain}/my-org/`, + accessToken: cachedAccessToken, + scope: "foo foo:bar", + token_type: "Bearer", + expiresAt: Math.floor(Date.now() / 1000) + 3600 + } + ] + }); + const cookie = await createSessionCookie(session, secret); + + // Override the handler to check for the cached access token + server.use( + http.get( + `https://${DEFAULT.domain}/my-org/v1/foo-bar/12`, + ({ request }) => { + const authHeader = request.headers.get("authorization"); + const token = authHeader?.split(" ")[1]; + + if (token === cachedAccessToken) { + return HttpResponse.json(myOrgResponse); + } + return new HttpResponse(null, { status: 401 }); + } + ) + ); + + const request = new NextRequest( + new URL("/my-org/foo-bar/12", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + scope: "foo:bar" + } + } + ); + + const response = await authClient.handler(request); + + // The Set Cookie header is not updated since the cache was used + expect(response.headers.get("Set-Cookie")).toBeFalsy(); + }); + + it("should update the cache when using stateless storage when no entry", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/my-org/foo-bar/12?foo=bar", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + scope: "foo:bar" + } + } + ); + + const response = await authClient.handler(request); + + const accessToken = await getAccessTokenFromSetCookieHeader( + response, + secret, + `https://${DEFAULT.domain}/my-org/` + ); + + expect(accessToken).toBeDefined(); + expect(accessToken!.requestedScope).toEqual("foo foo:bar"); + }); + + it("should update the cache when using stateless storage when entry expired", async () => { + const cachedAccessToken = "cached_at_123"; + const session = createInitialSessionData({ + accessTokens: [ + { + audience: `https://${DEFAULT.domain}/my-org/`, + accessToken: cachedAccessToken, + scope: "foo foo:bar", + token_type: "Bearer", + expiresAt: Math.floor(Date.now() / 1000) - 3600 // expired + } + ] + }); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/my-org/foo-bar/12?foo=bar", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + scope: "foo:bar" + } + } + ); + + const response = await authClient.handler(request); + + const accessToken = await getAccessTokenFromSetCookieHeader( + response, + secret, + `https://${DEFAULT.domain}/my-org/` + ); + + expect(accessToken).toBeDefined(); + expect(accessToken!.requestedScope).toEqual("foo foo:bar"); + }); + + it("should proxy POST request to my org", async () => { + server.use( + http.post( + `https://${DEFAULT.domain}/my-org/foo-bar/12`, + async ({ request }) => { + const body = await request.json(); + return HttpResponse.json(body, { status: 200 }); + } + ) + ); + + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/my-org/foo-bar/12", DEFAULT.appBaseUrl), + { + method: "POST", + headers: { + cookie, + scope: "foo:bar" + }, + body: JSON.stringify({ hello: "world" }), + duplex: "half" + } + ); + + const response = await authClient.handler(request); + + expect(response.status).toEqual(200); + + const json = await response.json(); + expect(json).toEqual({ hello: "world" }); + }); + + it("should proxy POST request to my org and proxy 204 responses without content", async () => { + server.use( + http.post( + `https://${DEFAULT.domain}/my-org/foo-bar/12`, + async () => new HttpResponse(null, { status: 204 }) + ) + ); + + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/my-org/foo-bar/12", DEFAULT.appBaseUrl), + { + method: "POST", + headers: { + cookie, + scope: "foo:bar" + }, + body: JSON.stringify({ hello: "world" }), + duplex: "half" + } + ); + + const response = await authClient.handler(request); + + expect(response.status).toEqual(204); + + const text = await response.text(); + expect(text).toBeFalsy(); + }); + + it("should proxy PATCH request to my org", async () => { + server.use( + http.patch( + `https://${DEFAULT.domain}/my-org/foo-bar/12`, + async ({ request }) => { + const body = (await request.json()) as any; + return HttpResponse.json( + { ...myOrgResponse, ...body }, + { status: 200 } + ); + } + ) + ); + + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/my-org/foo-bar/12", DEFAULT.appBaseUrl), + { + method: "PATCH", + headers: { + cookie, + scope: "foo:bar" + }, + body: JSON.stringify({ hello: "world" }), + duplex: "half" + } + ); + + const response = await authClient.handler(request); + + expect(response.status).toEqual(200); + + const json = await response.json(); + expect(json).toEqual({ ...myOrgResponse, hello: "world" }); + }); + + it("should proxy PUT request to my org", async () => { + server.use( + http.put( + `https://${DEFAULT.domain}/my-org/foo-bar/12`, + async ({ request }) => { + const body = (await request.json()) as any; + return HttpResponse.json(body, { status: 200 }); + } + ) + ); + + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/my-org/foo-bar/12", DEFAULT.appBaseUrl), + { + method: "PUT", + headers: { + cookie, + scope: "foo:bar" + }, + body: JSON.stringify({ hello: "world" }), + duplex: "half" + } + ); + + const response = await authClient.handler(request); + + expect(response.status).toEqual(200); + + const json = await response.json(); + expect(json).toEqual({ hello: "world" }); + }); + + it("should proxy DELETE request to my org", async () => { + server.use( + http.delete(`https://${DEFAULT.domain}/my-org/foo-bar/12`, async () => { + return new HttpResponse(null, { status: 204 }); + }) + ); + + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/my-org/foo-bar/12", DEFAULT.appBaseUrl), + { + method: "DELETE", + headers: { + cookie, + scope: "foo:bar" + }, + body: JSON.stringify({ hello: "world" }), + duplex: "half" + } + ); + + const response = await authClient.handler(request); + + expect(response.status).toEqual(204); + + const text = await response.text(); + expect(text).toBeFalsy(); + }); + + it("should handle when oauth/token throws", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + const request = new NextRequest( + new URL("/my-org/foo-bar/12?foo=bar", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + scope: "foo:bar" + } + } + ); + + // Override oauth/token handler to return an error + server.use( + http.post(`https://${DEFAULT.domain}/oauth/token`, () => { + return HttpResponse.json( + { + error: "test_error", + error_description: "An error from within the unit test." + }, + { status: 401 } + ); + }) + ); + + const response = await authClient.handler(request); + expect(response.status).toEqual(500); + + const text = await response.text(); + expect(text).toEqual("OAuth2Error: An error from within the unit test."); + }); + + it("should handle when getTokenSet throws", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + const request = new NextRequest( + new URL("/my-org/foo-bar/12?foo=bar", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + scope: "foo:bar" + } + } + ); + + authClient.getTokenSet = vi.fn().mockImplementation(() => { + { + throw new Error("An error from within the unit test."); + } + }); + + const response = await authClient.handler(request); + expect(response.status).toEqual(500); + + const text = await response.text(); + expect(text).toEqual("An error from within the unit test."); + }); + + it("should handle when getTokenSet throws without message", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + const request = new NextRequest( + new URL("/my-org/foo-bar/12?foo=bar", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + scope: "foo:bar" + } + } + ); + + authClient.getTokenSet = vi.fn().mockImplementation(() => { + { + throw new Error(); + } + }); + + const response = await authClient.handler(request); + expect(response.status).toEqual(500); + + const text = await response.text(); + expect(text).toEqual("An error occurred while proxying the request."); + }); + + describe("error responses", () => { + /** + * Test various error responses from the my-account endpoint + */ + [ + { status: 400, error: "bad_request", error_description: "Bad request" }, + { + status: 401, + error: "unauthorized", + error_description: "Not authorized" + }, + { + status: 403, + error: "insufficient_scope", + error_description: "You do not have the sufficient scope" + }, + { status: 404, error: "not_found", error_description: "Not Found" }, + { + status: 409, + error: "confict", + error_description: "There is a conflict" + }, + { + status: 429, + error: "rate_limit_exceeded", + error_description: "Rate limit exceeded" + }, + { + status: 500, + error: "internal_server_error", + error_description: "Internal Server Error" + } + ].forEach(({ status, error, error_description }) => { + it(`should handle ${status} from my-org and forward headers and error`, async () => { + server.use( + http.get( + `https://${DEFAULT.domain}/my-org/foo-bar/12`, + async () => { + return HttpResponse.json( + { + error, + error_description + }, + { + status: status, + headers: { + "X-RateLimit-Limit": "5" + } + } + ); + } + ) + ); + + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + const request = new NextRequest( + new URL("/my-org/foo-bar/12?foo=bar", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + scope: "foo:bar" + } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toEqual(status); + + const headers = response.headers; + expect(headers.get("X-RateLimit-Limit")).toEqual("5"); + + const json = response.json(); + await expect(json).resolves.toEqual({ + error, + error_description + }); + }); + }); + }); + }); +}); + +const _authorizationServerMetadata = { + issuer: `https://${DEFAULT.domain}/`, + authorization_endpoint: `https://${DEFAULT.domain}/authorize`, + token_endpoint: `https://${DEFAULT.domain}/oauth/token`, + device_authorization_endpoint: `https://${DEFAULT.domain}/oauth/device/code`, + userinfo_endpoint: `https://${DEFAULT.domain}/userinfo`, + mfa_challenge_endpoint: `https://${DEFAULT.domain}/mfa/challenge`, + jwks_uri: `https://${DEFAULT.domain}/jwks.json`, + registration_endpoint: `https://${DEFAULT.domain}/oidc/register`, + revocation_endpoint: `https://${DEFAULT.domain}/oauth/revoke`, + scopes_supported: [ + "openid", + "profile", + "offline_access", + "name", + "given_name", + "family_name", + "nickname", + "email", + "email_verified", + "picture", + "created_at", + "identities", + "phone", + "address" + ], + response_types_supported: [ + "code", + "token", + "id_token", + "code token", + "code id_token", + "token id_token", + "code token id_token" + ], + code_challenge_methods_supported: ["S256", "plain"], + response_modes_supported: ["query", "fragment", "form_post"], + subject_types_supported: ["public"], + token_endpoint_auth_methods_supported: [ + "client_secret_basic", + "client_secret_post", + "private_key_jwt" + ], + claims_supported: [ + "aud", + "auth_time", + "created_at", + "email", + "email_verified", + "exp", + "family_name", + "given_name", + "iat", + "identities", + "iss", + "name", + "nickname", + "phone_number", + "picture", + "sub" + ], + request_uri_parameter_supported: false, + request_parameter_supported: false, + id_token_signing_alg_values_supported: ["HS256", "RS256", "PS256"], + token_endpoint_auth_signing_alg_values_supported: ["RS256", "RS384", "PS256"], + backchannel_logout_supported: true, + backchannel_logout_session_supported: true, + end_session_endpoint: `https://${DEFAULT.domain}/oidc/logout`, + pushed_authorization_request_endpoint: `https://${DEFAULT.domain}/oauth/par`, + backchannel_authentication_endpoint: `https://${DEFAULT.domain}/bc-authorize`, + backchannel_token_delivery_modes_supported: ["poll"] +}; + +async function createSessionCookie(session: SessionData, secret: string) { + const maxAge = 60 * 60; // 1 hour + const expiration = Math.floor(Date.now() / 1000 + maxAge); + const sessionCookie = await encrypt(session, secret, expiration); + return `__session=${sessionCookie}`; +} + +async function getAccessTokenFromSetCookieHeader( + response: NextResponse, + secret: string, + audience: string +) { + const setCookie = response.headers.get("Set-Cookie"); + + const encryptedSessionCookieValue = setCookie?.split(";")[0].split("=")[1]; + + const sessionCookieValue = await decrypt( + encryptedSessionCookieValue!, + secret + ); + const accessTokens = sessionCookieValue?.payload.accessTokens; + return accessTokens?.find((at) => at.audience === audience); +} + +function createInitialSessionData( + sessionData: Partial = {} +): SessionData { + const expiresAt = Math.floor(Date.now() / 1000) + 3600; + return { + user: { + sub: DEFAULT.sub, + name: "John Doe", + email: "john@example.com", + picture: "https://example.com/john.jpg", + ...sessionData.user + }, + tokenSet: { + accessToken: DEFAULT.accessToken, + scope: "openid profile email", + refreshToken: DEFAULT.refreshToken, + expiresAt, + ...sessionData.tokenSet + }, + internal: { + sid: DEFAULT.sid, + createdAt: Math.floor(Date.now() / 1000), + ...sessionData.internal + }, + ...sessionData + }; +} diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index 9e3312ee..3386ceb3 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -1103,21 +1103,131 @@ ca/T0LLtgmbMmxSv/MmzIg== }); const request = new NextRequest( - // Next.js will strip the base path from the URL + // Simulate real Next.js behavior: basePath is included in pathname. + // With basePath='/base-path', Next.js sends pathname='/base-path/auth/login' + // to middleware. The handler must strip the basePath to match routes. new URL( - testCase.path, - `${DEFAULT.appBaseUrl}/${process.env.NEXT_PUBLIC_BASE_PATH}` + `${process.env.NEXT_PUBLIC_BASE_PATH}${testCase.path}`, + DEFAULT.appBaseUrl ), { method: testCase.method } ); + // Mock the basePath property that Next.js provides in middleware + Object.defineProperty(request.nextUrl, "basePath", { + value: process.env.NEXT_PUBLIC_BASE_PATH, + writable: false + }); + (authClient as any)[testCase.handler] = vi.fn(); await authClient.handler(request); expect((authClient as any)[testCase.handler]).toHaveBeenCalled(); } }); + + it("should handle requests without basePath (backward compatibility)", async () => { + // Clear basePath to test backward compatibility + delete process.env.NEXT_PUBLIC_BASE_PATH; + + 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" } + ); + + authClient.handleLogin = vi.fn(); + await authClient.handler(request); + expect(authClient.handleLogin).toHaveBeenCalled(); + + // Restore basePath for subsequent tests + process.env.NEXT_PUBLIC_BASE_PATH = "/base-path"; + }); + + it("should handle hardcoded /me routes with basePath", 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}/me/profile`, + DEFAULT.appBaseUrl + ), + { method: "GET" } + ); + + // Mock the basePath property that Next.js provides in middleware + Object.defineProperty(request.nextUrl, "basePath", { + value: process.env.NEXT_PUBLIC_BASE_PATH, + writable: false + }); + + authClient.handleMyAccount = vi.fn(); + await authClient.handler(request); + expect(authClient.handleMyAccount).toHaveBeenCalled(); + }); + + it("should handle hardcoded /my-org routes with basePath", 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}/my-org/members`, + DEFAULT.appBaseUrl + ), + { method: "GET" } + ); + + // Mock the basePath property that Next.js provides in middleware + Object.defineProperty(request.nextUrl, "basePath", { + value: process.env.NEXT_PUBLIC_BASE_PATH, + writable: false + }); + + authClient.handleMyOrg = vi.fn(); + await authClient.handler(request); + expect(authClient.handleMyOrg).toHaveBeenCalled(); + }); }); }); diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 312a9d7d..5996880b 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -44,6 +44,7 @@ import { GetAccessTokenOptions, LogoutStrategy, LogoutToken, + ProxyOptions, RESPONSE_TYPES, SessionData, StartInteractiveLoginOptions, @@ -60,6 +61,11 @@ import { normalizeWithBasePath, removeTrailingSlash } from "../utils/pathUtils.js"; +import { + buildForwardedRequestHeaders, + buildForwardedResponseHeaders, + transformTargetUrl +} from "../utils/proxy.js"; import { ensureDefaultScope, getScopeForAudience @@ -247,6 +253,8 @@ export class AuthClient { private dpopKeyPair?: DpopKeyPair; private readonly useDPoP: boolean; + private proxyFetchers: { [audience: string]: Fetcher } = {}; + constructor(options: AuthClientOptions) { // dependencies this.fetch = options.fetch || fetch; @@ -370,7 +378,17 @@ export class AuthClient { } async handler(req: NextRequest): Promise { - const { pathname } = req.nextUrl; + let { pathname } = req.nextUrl; + + // Next.js does NOT automatically strip basePath from pathname in middleware. + // We must manually strip it to match against our route configurations. + // Example: With basePath='/app', a request to '/app/auth/login' will have + // pathname='/app/auth/login', but routes are configured as '/auth/login'. + const basePath = req.nextUrl.basePath; + if (basePath && pathname.startsWith(basePath)) { + pathname = pathname.slice(basePath.length) || "/"; + } + const sanitizedPathname = removeTrailingSlash(pathname); const method = req.method; @@ -399,6 +417,10 @@ export class AuthClient { this.enableConnectAccountEndpoint ) { return this.handleConnectAccount(req); + } else if (sanitizedPathname.startsWith("/me/")) { + return this.handleMyAccount(req); + } else if (sanitizedPathname.startsWith("/my-org/")) { + return this.handleMyOrg(req); } else { // no auth handler found, simply touch the sessions // TODO: this should only happen if rolling sessions are enabled. Also, we should @@ -1055,6 +1077,24 @@ export class AuthClient { return connectAccountResponse; } + async handleMyAccount(req: NextRequest): Promise { + return this.#handleProxy(req, { + proxyPath: "/me", + targetBaseUrl: `${this.issuer}/me/v1`, + audience: `${this.issuer}/me/`, + scope: req.headers.get("scope") + }); + } + + async handleMyOrg(req: NextRequest): Promise { + return this.#handleProxy(req, { + proxyPath: "/my-org", + targetBaseUrl: `${this.issuer}/my-org`, + audience: `${this.issuer}/my-org/`, + scope: req.headers.get("scope") + }); + } + /** * Retrieves the token set from the session data, considering optional audience and scope parameters. * When audience and scope are provided, it checks if they match the global ones defined in the authorization parameters. @@ -2098,6 +2138,193 @@ export class AuthClient { return new Fetcher(fetcherConfig, fetcherHooks); } + /** + * Handles CORS preflight requests without authentication headers. + * + * Special-cases the OPTIONS method to forward preflight requests directly WITHOUT + * calling `fetcher.fetchWithAuth()`, which would incorrectly inject DPoP/auth headers + * on the preflight request. + * + * The browser never sends auth headers on preflight requests, and we should not either. + * Authorization checks must not be performed on preflight requests according to RFC 7231. + * Additionally, DPoP proofs are bound to HTTP requests, not to preflights (RFC 9449). + * + * @param req The incoming CORS preflight OPTIONS request. + * @param options Configuration options for the proxy including target base URL, audience, and scope. + * @returns A NextResponse containing the preflight response from the target server, or a 500 error if the preflight fails. + * + * @see RFC 7231 Section 4.3.1 - Authorization checks must not be performed on preflight requests + * @see RFC 9449 Section 4.1 - DPoP proofs are bound to HTTP requests, not to preflights + */ + async #handlePreflight( + req: NextRequest, + options: ProxyOptions + ): Promise { + const headers = buildForwardedRequestHeaders(req); + const targetUrl = transformTargetUrl(req, options); + + // Set Host header to upstream host + headers.set("host", targetUrl.host); + + try { + // Forward preflight directly WITHOUT DPoP/auth headers + const preflightResponse = await this.fetch(targetUrl.toString(), { + method: "OPTIONS", + headers + }); + + // Forward CORS headers from upstream + return new NextResponse(null, { + status: preflightResponse.status, + headers: buildForwardedResponseHeaders(preflightResponse) + }); + } catch (error: any) { + // If preflight fails, return 500 + return new NextResponse( + error.cause || error.message || "Preflight request failed", + { status: 500 } + ); + } + } + + /** + * Handles proxying requests to a target URL with authentication. + * + * This method retrieves the user's session, constructs the target URL, + * and forwards the request with appropriate authentication headers. + * It also manages token retrieval and session updates as needed. + * @param req The incoming Next.js request to be proxied. + * @param options Configuration options for the proxying behavior. + * @returns A Next.js response containing the proxied request's response. + */ + async #handleProxy( + req: NextRequest, + options: ProxyOptions + ): Promise { + const session = await this.sessionStore.get(req.cookies); + if (!session) { + return new NextResponse("The user does not have an active session.", { + status: 401 + }); + } + + // handle preflight requests + if ( + req.method === "OPTIONS" && + req.headers.has("access-control-request-method") + ) { + return this.#handlePreflight(req, options); + } + + const headers = buildForwardedRequestHeaders(req); + + // Clone the request to preserve body for DPoP nonce retry + // WHATWG Streams Spec: ReadableStream is single-consume (can only be read once). + // When oauth4webapi's protectedResourceRequest encounters a DPoP nonce error, + // it triggers a retry. Without cloning, the body stream will be exhausted on the first attempt, + // causing the retry to fail with "stream already disturbed" or empty body. + // To support retry, we buffer the body so it can be reused on retry. + // This retry will not happen with bearer auth so no need to clone if DPoP is false. + const clonedReq = this.useDPoP ? req.clone() : req; + const targetUrl = transformTargetUrl(req, options); + + // Set Host header to upstream host + headers.set("host", targetUrl.host); + + // Buffer the body to allow retry on DPoP nonce errors + // ReadableStreams can only be consumed once, so we need to buffer for retry + const bodyBuffer = clonedReq.body + ? await clonedReq.arrayBuffer() + : undefined; + + let tokenSetSideEffect!: GetTokenSetResponse; + + const getAccessToken: AccessTokenFactory = async (authParams) => { + const [error, tokenSetResponse] = await this.getTokenSet(session, { + audience: authParams.audience, + scope: authParams.scope + }); + + if (error) { + throw error; + } + + // Tracking the last used token set response for session updates later as a side effect. + // This relies on the fact that `getAccessToken` is called before the actual fetch. + // Not ideal, but works because of that order of execution. + // We need to do this because the fetcher does not return the token set used, and we need it to update the session if necessary. + // Additionally, updating the session requires the request and response objects, which are not available in the fetcher, + // so we can not update the session directly from the fetcher. + tokenSetSideEffect = tokenSetResponse; + + return tokenSetResponse.tokenSet; + }; + + // get/create fetcher isntance + let fetcher = this.proxyFetchers[options.audience]; + + if (!fetcher) { + fetcher = await this.fetcherFactory({ + useDPoP: this.useDPoP, + fetch: this.fetch, + getAccessToken: getAccessToken + }); + this.proxyFetchers[options.audience] = fetcher; + } else { + // @ts-expect-error Override fetcher's getAccessToken to capture token set side effects + fetcher.getAccessToken = getAccessToken; + } + + try { + const response = await fetcher.fetchWithAuth( + targetUrl.toString(), + { + method: clonedReq.method, + headers, + body: bodyBuffer + }, + { scope: options.scope, audience: options.audience } + ); + + const res = new NextResponse(response.body, { + status: response.status, + statusText: response.statusText, + headers: buildForwardedResponseHeaders(response) + }); + + // Using the last used token set response to determine if we need to update the session + // This is not ideal, as this kind of relies on the order of execution. + // As we know the fetcher's `getAccessToken` is called before the actual fetch, + // we know it should always be defined when we reach this point. + if (tokenSetSideEffect) { + await this.#updateSessionAfterTokenRetrieval( + req, + res, + session, + tokenSetSideEffect + ); + } + + return res; + } catch (e: any) { + // Return 401 for missing refresh token (cannot refresh expired token) + if ( + e instanceof AccessTokenError && + e.code === AccessTokenErrorCode.MISSING_REFRESH_TOKEN + ) { + return new NextResponse(e.message, { status: 401 }); + } + + // Generic error handling for other errors + return new NextResponse( + e.cause || e.message || "An error occurred while proxying the request.", + { + status: 500 + } + ); + } + } + /** * Updates the session after token retrieval if there are changes. * @@ -2107,6 +2334,10 @@ export class AuthClient { * 3. Finalizes the session through the beforeSessionSaved hook or default filtering * 4. Persists the updated session to the session store * 5. Adds cache control headers to the response + * + * @note This method mutates the `res` parameter by: + * - Setting session cookies via `res.cookies` + * - Adding cache control headers to the response */ async #updateSessionAfterTokenRetrieval( req: NextRequest, diff --git a/src/server/proxy-handler.test.ts b/src/server/proxy-handler.test.ts new file mode 100644 index 00000000..6428a665 --- /dev/null +++ b/src/server/proxy-handler.test.ts @@ -0,0 +1,1972 @@ +import { NextRequest } from "next/server.js"; +import * as jose from "jose"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it +} from "vitest"; + +import { getDefaultRoutes } from "../test/defaults.js"; +import { + createDPoPNonceRetryHandler, + createInitialSessionData, + createSessionCookie, + extractDPoPInfo +} from "../test/proxy-handler-test-helpers.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"; + +/** + * Comprehensive Test Suite: AuthClient Custom Proxy Handler + * + * This test suite validates the `#handleProxy()` method with custom proxy routes, + * covering Bearer/DPoP authentication, HTTP methods, headers, bodies, streaming, + * nonce retry, session updates, and error handling. + * + * Architecture: + * - MSW mocks Auth0 (discovery, token endpoint) and arbitrary upstream API + * - Tests use black-box flow approach (call handler, verify response) + * - DPoP nonce retry validated via stateful MSW handlers + * - Session updates verified via Set-Cookie headers + * + * Test Categories: + * 1: Basic Proxy Routing & Session Management + * 2: HTTP Method Routing + * 3: URL Path Matching & Transformation + * 4: HTTP Headers Forwarding + * 5: Request Body Handling + * 6: Bearer Token Handling + * 7: DPoP Token Handling + * 8: Session Update After Token Refresh + * 9: Error Scenarios + * 10: Concurrent Request Handling + * 11: CORS Handling + */ + +const DEFAULT = { + domain: "test.auth0.local", + clientId: "test_client_id", + clientSecret: "test_client_secret", + appBaseUrl: "https://example.com", + proxyPath: "/me", + upstreamBaseUrl: `https://test.auth0.local/me/v1`, + audience: `https://test.auth0.local/me/`, + accessToken: "at_test_123", + refreshToken: "rt_test_123", + sub: "user_test_123", + sid: "session_test_123", + alg: "RS256" as const +}; + +const UPSTREAM_RESPONSE_DATA = { + simpleJson: { id: 1, name: "test", data: "value" }, + largeJson: { + // ~100KB payload for streaming tests + items: Array.from({ length: 1000 }, (_, i) => ({ + id: i, + name: `item_${i}`, + description: "A".repeat(100), + metadata: { key1: "value1", key2: "value2", key3: "value3" } + })) + }, + htmlContent: "

Test

", + errorResponse: { error: "some_error", error_description: "Error occurred" } +}; + +// Discovery metadata +const _authorizationServerMetadata = { + 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`, + response_types_supported: ["code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + dpop_signing_alg_values_supported: ["RS256", "ES256"] +}; + +let keyPair: jose.GenerateKeyPairResult; +let dpopKeyPair: Awaited>; +let secret: string; +let authClient: AuthClient; + +const server = setupServer( + // Discovery endpoint + http.get(`https://${DEFAULT.domain}/.well-known/openid-configuration`, () => { + return HttpResponse.json(_authorizationServerMetadata); + }), + + // JWKS endpoint + http.get(`https://${DEFAULT.domain}/.well-known/jwks.json`, async () => { + const jwk = await jose.exportJWK(keyPair.publicKey); + return HttpResponse.json({ + keys: [{ ...jwk, kid: "test-key-1", alg: DEFAULT.alg, use: "sig" }] + }); + }), + + // Token endpoint + http.post(`https://${DEFAULT.domain}/oauth/token`, async () => { + // Generate ID token + const jwt = await new jose.SignJWT({ + sid: DEFAULT.sid, + auth_time: Math.floor(Date.now() / 1000), + nonce: "nonce-value" + }) + .setProtectedHeader({ alg: DEFAULT.alg }) + .setSubject(DEFAULT.sub) + .setIssuedAt() + .setIssuer(_authorizationServerMetadata.issuer) + .setAudience(DEFAULT.clientId) + .setExpirationTime("2h") + .sign(keyPair.privateKey); + + return HttpResponse.json({ + token_type: "Bearer", + access_token: DEFAULT.accessToken, + refresh_token: DEFAULT.refreshToken, + id_token: jwt, + expires_in: 3600 + }); + }), + + // Default upstream API handlers (will be overridden in tests) + // Match base URL without trailing slash + http.all(`${DEFAULT.upstreamBaseUrl}`, () => { + return HttpResponse.json(UPSTREAM_RESPONSE_DATA.simpleJson, { + status: 200 + }); + }), + // Match base URL with trailing slash + http.all(`${DEFAULT.upstreamBaseUrl}/`, () => { + return HttpResponse.json(UPSTREAM_RESPONSE_DATA.simpleJson, { + status: 200 + }); + }), + // Match all subpaths + http.all(`${DEFAULT.upstreamBaseUrl}/*`, () => { + return HttpResponse.json(UPSTREAM_RESPONSE_DATA.simpleJson, { + status: 200 + }); + }) +); + +beforeAll(async () => { + keyPair = await jose.generateKeyPair(DEFAULT.alg); + dpopKeyPair = await generateDpopKeyPair(); + secret = await generateSecret(32); + server.listen({ onUnhandledRequest: "error" }); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +afterAll(() => { + server.close(); +}); + +describe("Authentication Client - Custom Proxy Handler", async () => { + beforeEach(async () => { + authClient = new AuthClient({ + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + appBaseUrl: DEFAULT.appBaseUrl, + routes: getDefaultRoutes(), + secret, + sessionStore: new StatelessSessionStore({ secret }), + transactionStore: new TransactionStore({ secret }), + fetch: (url, init) => + fetch(url, { ...init, ...(init?.body ? { duplex: "half" } : {}) }) + }); + }); + + describe("Category 1: Basic Proxy Routing & Session Management", () => { + it("1.1 should return 200 (passthrough) when proxy handler not found", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL("/non-existent-proxy/users", DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + // Handler uses NextResponse.next() for unmatched routes, allowing them to pass through + // This is intentional to allow the handler to coexist with other Next.js routes + expect(response.status).toBe(200); + }); + + it("1.2 should return 401 when session missing", async () => { + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/users`, DEFAULT.appBaseUrl), + { method: "GET" } + ); + + const response = await authClient.handler(request); + expect(response.status).toBe(401); + const text = await response.text(); + expect(text).toContain("active session"); + }); + + it("1.3 should proxy request when valid session exists", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + // Override upstream handler + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/users`, () => { + return HttpResponse.json({ success: true, users: ["user1"] }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/users`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data).toEqual({ success: true, users: ["user1"] }); + }); + }); + + // GET, POST, PUT, DELETE, OPTIONS, HEAD, CORS + describe("Category 2: HTTP Method Routing", () => { + it("2.1 should proxy GET request", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/items`, () => { + return HttpResponse.json({ method: "GET", items: [] }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/items`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data.method).toBe("GET"); + }); + + it("2.2 should proxy POST request with JSON body", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + let receivedBody: any; + server.use( + http.post(`${DEFAULT.upstreamBaseUrl}/items`, async ({ request }) => { + receivedBody = await request.json(); + return HttpResponse.json({ method: "POST", created: true }); + }) + ); + + const requestBody = { name: "New Item", value: 42 }; + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/items`, DEFAULT.appBaseUrl), + { + method: "POST", + headers: { + cookie, + "content-type": "application/json" + }, + body: JSON.stringify(requestBody) + } + ); + + const response = await authClient.handler(request); + expect(response.status).toBe(200); + + expect(receivedBody).toEqual(requestBody); + }); + + it("2.3 should proxy PUT request with JSON body", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + let receivedBody: any; + server.use( + http.put(`${DEFAULT.upstreamBaseUrl}/items/1`, async ({ request }) => { + receivedBody = await request.json(); + return HttpResponse.json({ method: "PUT", updated: true }); + }) + ); + + const requestBody = { name: "Updated Item" }; + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/items/1`, DEFAULT.appBaseUrl), + { + method: "PUT", + headers: { + cookie, + "content-type": "application/json" + }, + body: JSON.stringify(requestBody) + } + ); + + const response = await authClient.handler(request); + expect(response.status).toBe(200); + + expect(receivedBody).toEqual(requestBody); + }); + + it("2.4 should proxy PATCH request with JSON body", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + let receivedBody: any; + server.use( + http.patch( + `${DEFAULT.upstreamBaseUrl}/items/1`, + async ({ request }) => { + receivedBody = await request.json(); + return HttpResponse.json({ method: "PATCH", patched: true }); + } + ) + ); + + const requestBody = { value: 99 }; + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/items/1`, DEFAULT.appBaseUrl), + { + method: "PATCH", + headers: { + cookie, + "content-type": "application/json" + }, + body: JSON.stringify(requestBody) + } + ); + + const response = await authClient.handler(request); + expect(response.status).toBe(200); + + expect(receivedBody).toEqual(requestBody); + }); + + it("2.5 should proxy DELETE request", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + server.use( + http.delete(`${DEFAULT.upstreamBaseUrl}/items/1`, () => { + return new HttpResponse(null, { status: 204 }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/items/1`, DEFAULT.appBaseUrl), + { + method: "DELETE", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toBe(204); + }); + + it("2.6 should proxy HEAD request", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + server.use( + http.head(`${DEFAULT.upstreamBaseUrl}/items`, () => { + return new HttpResponse(null, { + status: 200, + headers: { "x-total-count": "42" } + }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/items`, DEFAULT.appBaseUrl), + { + method: "HEAD", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toBe(200); + expect(response.headers.get("x-total-count")).toBe("42"); + }); + + it("2.7 should handle OPTIONS preflight CORS without auth", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + // Mock upstream to return CORS headers for preflight + server.use( + http.options(`${DEFAULT.upstreamBaseUrl}/items`, () => { + return new HttpResponse(null, { + status: 204, + headers: { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS", + "access-control-allow-headers": "content-type, authorization" + } + }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/items`, DEFAULT.appBaseUrl), + { + method: "OPTIONS", + headers: { + cookie, + origin: "https://frontend.example.com", + "access-control-request-method": "POST", + "access-control-request-headers": "content-type" + } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toBe(204); + + // Preflight should not include Authorization header + expect(response.headers.get("access-control-allow-origin")).toBeTruthy(); + }); + + it("2.8 should proxy OPTIONS non-preflight request", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + server.use( + http.options(`${DEFAULT.upstreamBaseUrl}/items`, () => { + return new HttpResponse(null, { + status: 200, + headers: { + allow: "GET, POST, PUT, DELETE, OPTIONS" + } + }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/items`, DEFAULT.appBaseUrl), + { + method: "OPTIONS", + headers: { cookie } + // Note: no access-control-request-method header = not preflight + } + ); + + const response = await authClient.handler(request); + expect(response.status).toBe(200); + expect(response.headers.get("allow")).toContain("GET"); + }); + }); + + // combine single level and multi level subpaths + describe("Category 3: URL Path Matching & Transformation", () => { + it("3.1 should reject exact proxy path without subpath (security)", async () => { + // Security: The My Account and My Org APIs have no endpoints at exactly /me or /my-org + // All real endpoints are like /me/v1/... or /my-org/v1/... + // Accepting exact paths could lead to security issues + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL(DEFAULT.proxyPath, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + // Should not proxy - should just touch sessions and return Next response + expect(response.status).toBe(200); + // Should not have proxied content + const text = await response.text(); + expect(text).not.toContain('{"path":"/"}'); + }); + + it("3.2 should proxy to single-level subpath", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/users`, () => { + return HttpResponse.json({ path: "/users" }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/users`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data.path).toBe("/users"); + }); + + it("3.3 should proxy to multi-level subpath", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/api/v1/users/123/profile`, () => { + return HttpResponse.json({ path: "/api/v1/users/123/profile" }); + }) + ); + + const request = new NextRequest( + new URL( + `${DEFAULT.proxyPath}/api/v1/users/123/profile`, + DEFAULT.appBaseUrl + ), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data.path).toBe("/api/v1/users/123/profile"); + }); + + it("3.4 should preserve query string parameters", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + let receivedUrl: string; + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/search`, ({ request }) => { + receivedUrl = request.url; + return HttpResponse.json({ received: true }); + }) + ); + + const request = new NextRequest( + new URL( + `${DEFAULT.proxyPath}/search?q=test&limit=10&offset=0`, + DEFAULT.appBaseUrl + ), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toBe(200); + + const url = new URL(receivedUrl!); + expect(url.searchParams.get("q")).toBe("test"); + expect(url.searchParams.get("limit")).toBe("10"); + expect(url.searchParams.get("offset")).toBe("0"); + }); + + it("3.5 should handle paths with special characters", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + server.use( + http.get( + `${DEFAULT.upstreamBaseUrl}/items/test%20item%2Bspecial`, + () => { + return HttpResponse.json({ success: true }); + } + ) + ); + + const request = new NextRequest( + new URL( + `${DEFAULT.proxyPath}/items/test%20item%2Bspecial`, + DEFAULT.appBaseUrl + ), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toBe(200); + }); + + it("3.6 should handle paths with trailing slash", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/users/`, () => { + return HttpResponse.json({ path: "/users/" }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/users/`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toBe(200); + }); + }); + + describe("Category 4: HTTP Headers Forwarding", () => { + it("4.1 should forward allow-listed request headers", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + let receivedHeaders: Headers; + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/data`, ({ request }) => { + receivedHeaders = request.headers; + return HttpResponse.json({ success: true }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + "x-request-id": "req-123", + "x-correlation-id": "corr-456" + } + } + ); + + await authClient.handler(request); + + // Only explicitly allow-listed headers should be forwarded + expect(receivedHeaders!.get("x-request-id")).toBe("req-123"); + expect(receivedHeaders!.get("x-correlation-id")).toBe("corr-456"); + }); + + it("4.1b should NOT forward arbitrary request headers not in allow-list", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + let receivedHeaders: Headers; + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/data`, ({ request }) => { + receivedHeaders = request.headers; + return HttpResponse.json({ success: true }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + "x-custom-header": "should-not-be-forwarded", + "some-custom-header-name": "also-not-forwarded", + "x-request-id": "req-123" // This IS in the allow-list + } + } + ); + + await authClient.handler(request); + + // Arbitrary x-* headers should NOT be forwarded + expect(receivedHeaders!.get("x-custom-header")).toBeNull(); + expect(receivedHeaders!.get("some-custom-header-name")).toBeNull(); + // But explicitly allow-listed x-* headers SHOULD be forwarded + expect(receivedHeaders!.get("x-request-id")).toBe("req-123"); + }); + + it("4.2 should forward standard headers (Accept, Content-Type)", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + let receivedHeaders: Headers; + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/data`, ({ request }) => { + receivedHeaders = request.headers; + return HttpResponse.json({ success: true }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + accept: "application/json", + "accept-language": "en-US", + "content-type": "application/json" + } + } + ); + + await authClient.handler(request); + + expect(receivedHeaders!.get("accept")).toBe("application/json"); + expect(receivedHeaders!.get("accept-language")).toBe("en-US"); + }); + + it("4.3 should strip Cookie header and replace Authorization with token", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + let receivedHeaders: Headers; + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/data`, ({ request }) => { + receivedHeaders = request.headers; + return HttpResponse.json({ success: true }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + authorization: "Bearer should-be-replaced" + } + } + ); + + await authClient.handler(request); + + // Cookie should be stripped + expect(receivedHeaders!.get("cookie")).toBeNull(); + + // Authorization should be replaced with session token + expect(receivedHeaders!.get("authorization")).toBe( + `Bearer ${DEFAULT.accessToken}` + ); + }); + + it("4.4 should update Host header to upstream host", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + let receivedHeaders: Headers; + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/data`, ({ request }) => { + receivedHeaders = request.headers; + return HttpResponse.json({ success: true }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + await authClient.handler(request); + + // Host should be updated to upstream host + const upstreamHost = new URL(DEFAULT.upstreamBaseUrl).host; + expect(receivedHeaders!.get("host")).toBe(upstreamHost); + }); + + it("4.5 should preserve User-Agent header", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + let receivedHeaders: Headers; + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/data`, ({ request }) => { + receivedHeaders = request.headers; + return HttpResponse.json({ success: true }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { + cookie, + "user-agent": "Test-Agent/1.0" + } + } + ); + + await authClient.handler(request); + + expect(receivedHeaders!.get("user-agent")).toBe("Test-Agent/1.0"); + }); + + it("4.6 should forward custom RESPONSE headers from upstream", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/data`, () => { + return HttpResponse.json( + { success: true }, + { + headers: { + "x-custom-response": "response-value", + "x-rate-limit": "100" + } + } + ); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + + expect(response.headers.get("x-custom-response")).toBe("response-value"); + expect(response.headers.get("x-rate-limit")).toBe("100"); + }); + + it("4.7 should forward CORS headers from upstream", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/data`, () => { + return HttpResponse.json( + { success: true }, + { + headers: { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST", + "access-control-allow-headers": "Content-Type" + } + } + ); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("access-control-allow-methods")).toBe( + "GET, POST" + ); + }); + }); + + describe("Category 5: Request Body Handling", () => { + it("5.1 should forward JSON body correctly", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + let receivedBody: any; + server.use( + http.post(`${DEFAULT.upstreamBaseUrl}/data`, async ({ request }) => { + receivedBody = await request.json(); + return HttpResponse.json({ received: true }); + }) + ); + + const requestBody = { name: "test", value: 42, nested: { key: "value" } }; + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "POST", + headers: { + cookie, + "content-type": "application/json" + }, + body: JSON.stringify(requestBody) + } + ); + + await authClient.handler(request); + + expect(receivedBody).toEqual(requestBody); + }); + + it("5.2 should forward form data body", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + let receivedBody: string; + server.use( + http.post(`${DEFAULT.upstreamBaseUrl}/data`, async ({ request }) => { + receivedBody = await request.text(); + return HttpResponse.json({ received: true }); + }) + ); + + const formData = new URLSearchParams({ + username: "testuser", + password: "testpass" + }); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "POST", + headers: { + cookie, + "content-type": "application/x-www-form-urlencoded" + }, + body: formData.toString() + } + ); + + await authClient.handler(request); + + expect(receivedBody!).toBe("username=testuser&password=testpass"); + }); + + it("5.3 should forward plain text body", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + let receivedBody: string; + server.use( + http.post(`${DEFAULT.upstreamBaseUrl}/data`, async ({ request }) => { + receivedBody = await request.text(); + return HttpResponse.json({ received: true }); + }) + ); + + const textBody = "This is plain text content\nWith multiple lines"; + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "POST", + headers: { + cookie, + "content-type": "text/plain" + }, + body: textBody + } + ); + + await authClient.handler(request); + + expect(receivedBody!).toBe(textBody); + }); + + it("5.4 should handle empty body", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + let bodyWasNull = false; + server.use( + http.post(`${DEFAULT.upstreamBaseUrl}/data`, async ({ request }) => { + bodyWasNull = request.body === null; + return HttpResponse.json({ bodyWasNull }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "POST", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + + expect(response.status).toBe(200); + expect(bodyWasNull).toBe(true); + }); + + it("5.5 should handle large JSON body", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + let receivedBody: any; + server.use( + http.post(`${DEFAULT.upstreamBaseUrl}/data`, async ({ request }) => { + receivedBody = await request.json(); + return HttpResponse.json({ received: true }); + }) + ); + + // Create large payload (~100KB) + const largeBody = { + items: Array.from({ length: 1000 }, (_, i) => ({ + id: i, + name: `item_${i}`, + data: "A".repeat(100) + })) + }; + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "POST", + headers: { + cookie, + "content-type": "application/json" + }, + body: JSON.stringify(largeBody) + } + ); + + await authClient.handler(request); + + expect(receivedBody).toEqual(largeBody); + }); + }); + + describe("Category 6: Bearer Token Handling", () => { + it("6.1 should send Bearer token in Authorization header", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + let receivedAuthHeader: string | null = null; + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/data`, ({ request }) => { + receivedAuthHeader = request.headers.get("authorization"); + return HttpResponse.json({ success: true }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + await authClient.handler(request); + + expect(receivedAuthHeader).toBe(`Bearer ${DEFAULT.accessToken}`); + }); + }); + + describe("Category 7: DPoP Token Handling", () => { + let dpopAuthClient: AuthClient; + + beforeEach(async () => { + // Create AuthClient with DPoP enabled + dpopAuthClient = new AuthClient({ + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + appBaseUrl: DEFAULT.appBaseUrl, + routes: getDefaultRoutes(), + secret, + sessionStore: new StatelessSessionStore({ secret }), + transactionStore: new TransactionStore({ secret }), + useDPoP: true, + dpopKeyPair: dpopKeyPair, + fetch: (url, init) => + fetch(url, { ...init, ...(init?.body ? { duplex: "half" } : {}) }) + }); + }); + it("7.1 should send DPoP proof in DPoP header", async () => { + const session = createInitialSessionData({ + tokenSet: { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + scope: "read:data", + audience: DEFAULT.audience, + token_type: "DPoP" + } + }); + const cookie = await createSessionCookie(session, secret); + + let receivedDPoPHeader: string | null = null; + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/data`, ({ request }) => { + receivedDPoPHeader = request.headers.get("dpop"); + return HttpResponse.json({ success: true }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + await dpopAuthClient.handler(request); + + expect(receivedDPoPHeader).toBeTruthy(); + expect(receivedDPoPHeader).toMatch( + /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/ + ); // JWT format + }); + + it("7.2 should include htm claim (HTTP method) in DPoP proof", async () => { + const session = createInitialSessionData({ + tokenSet: { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + scope: "read:data", + audience: DEFAULT.audience, + token_type: "DPoP" + } + }); + const cookie = await createSessionCookie(session, secret); + + let dpopProof: string | null = null; + server.use( + http.post(`${DEFAULT.upstreamBaseUrl}/data`, ({ request }) => { + dpopProof = request.headers.get("dpop"); + return HttpResponse.json({ success: true }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "POST", + headers: { + cookie, + "content-type": "application/json" + }, + body: JSON.stringify({ test: "data" }) + } + ); + + await dpopAuthClient.handler(request); + + const dpopInfo = extractDPoPInfo(dpopProof); + expect(dpopInfo.htm).toBe("POST"); + }); + + it("7.3 should include htu claim (HTTP URI) in DPoP proof", async () => { + const session = createInitialSessionData({ + tokenSet: { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + scope: "read:data", + audience: DEFAULT.audience, + token_type: "DPoP" + } + }); + const cookie = await createSessionCookie(session, secret); + + let dpopProof: string | null = null; + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/users/123`, ({ request }) => { + dpopProof = request.headers.get("dpop"); + return HttpResponse.json({ success: true }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/users/123`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + await dpopAuthClient.handler(request); + + const dpopInfo = extractDPoPInfo(dpopProof); + expect(dpopInfo.htu).toBe(`${DEFAULT.upstreamBaseUrl}/users/123`); + }); + + it("7.4 should include jti and iat claims in DPoP proof", async () => { + const session = createInitialSessionData({ + tokenSet: { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + scope: "read:data", + audience: DEFAULT.audience, + token_type: "DPoP" + } + }); + const cookie = await createSessionCookie(session, secret); + + let dpopProof: string | null = null; + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/data`, ({ request }) => { + dpopProof = request.headers.get("dpop"); + return HttpResponse.json({ success: true }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + await dpopAuthClient.handler(request); + + const dpopInfo = extractDPoPInfo(dpopProof); + expect(dpopInfo.jti).toBeTruthy(); + expect(dpopInfo.iat).toBeTruthy(); + expect(typeof dpopInfo.iat).toBe("number"); + }); + + it("7.5 should send DPoP token in Authorization header with DPoP prefix", async () => { + const session = createInitialSessionData({ + tokenSet: { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + scope: "read:data", + audience: DEFAULT.audience, + token_type: "DPoP" + } + }); + const cookie = await createSessionCookie(session, secret); + + let receivedAuthHeader: string | null = null; + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/data`, ({ request }) => { + receivedAuthHeader = request.headers.get("authorization"); + return HttpResponse.json({ success: true }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + await dpopAuthClient.handler(request); + + expect(receivedAuthHeader).toBe(`DPoP ${DEFAULT.accessToken}`); + }); + + it("7.6 should retry with nonce on use_dpop_nonce error", async () => { + const session = createInitialSessionData({ + tokenSet: { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + audience: DEFAULT.audience, + scope: "read:data", + token_type: "DPoP" + } + }); + const cookie = await createSessionCookie(session, secret); + + const { handler, state } = createDPoPNonceRetryHandler({ + baseUrl: DEFAULT.upstreamBaseUrl, + path: "/data", + method: "GET", + successResponse: { success: true } + }); + + server.use(http.get(`${DEFAULT.upstreamBaseUrl}/data`, handler)); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await dpopAuthClient.handler(request); + + expect(response.status).toBe(200); + expect(state.requestCount).toBe(2); // Initial + retry + expect(state.requests[0].hasDPoP).toBe(true); + expect(state.requests[0].hasNonce).toBe(false); + expect(state.requests[1].hasDPoP).toBe(true); + expect(state.requests[1].hasNonce).toBe(true); + expect(state.requests[1].nonce).toBe("server_nonce_123"); + }); + + it("7.7 should include nonce in retry DPoP proof", async () => { + const session = createInitialSessionData({ + tokenSet: { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + scope: "read:data", + audience: DEFAULT.audience, + token_type: "DPoP" + } + }); + const cookie = await createSessionCookie(session, secret); + + const { handler, state } = createDPoPNonceRetryHandler({ + baseUrl: DEFAULT.upstreamBaseUrl, + path: "/data", + method: "POST", + successResponse: { created: true } + }); + + server.use(http.post(`${DEFAULT.upstreamBaseUrl}/data`, handler)); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "POST", + headers: { + cookie, + "content-type": "application/json" + }, + body: JSON.stringify({ test: "data" }) + } + ); + + const response = await dpopAuthClient.handler(request); + + expect(response.status).toBe(200); + + // Verify nonce in retry proof + const retryDPoPInfo = extractDPoPInfo(state.requests[1].dpopJwt!); + expect(retryDPoPInfo.hasNonce).toBe(true); + expect(retryDPoPInfo.nonce).toBe("server_nonce_123"); + }); + }); + + describe("Category 8: Session Update After Token Refresh", () => { + it("8.1 should update session with new access token after refresh", async () => { + const now = Math.floor(Date.now() / 1000); + const session = createInitialSessionData({ + tokenSet: { + accessToken: "old_token", + refreshToken: DEFAULT.refreshToken, + expiresAt: now - 10, // Expired + scope: "read:data", + token_type: "Bearer" + } + }); + const cookie = await createSessionCookie(session, secret); + + const newAccessToken = "new_refreshed_token"; + server.use( + http.post(`https://${DEFAULT.domain}/oauth/token`, async () => { + const jwt = await new jose.SignJWT({ + sid: DEFAULT.sid, + auth_time: Math.floor(Date.now() / 1000) + }) + .setProtectedHeader({ alg: DEFAULT.alg }) + .setSubject(DEFAULT.sub) + .setIssuedAt() + .setIssuer(_authorizationServerMetadata.issuer) + .setAudience(DEFAULT.clientId) + .setExpirationTime("2h") + .sign(keyPair.privateKey); + + return HttpResponse.json({ + access_token: newAccessToken, + refresh_token: DEFAULT.refreshToken, + id_token: jwt, + token_type: "Bearer", + expires_in: 3600 + }); + }), + http.get(`${DEFAULT.upstreamBaseUrl}/data`, () => { + return HttpResponse.json({ success: true }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + + // Should have Set-Cookie header with updated session + const setCookieHeader = response.headers.get("set-cookie"); + expect(setCookieHeader).toBeTruthy(); + expect(setCookieHeader).toContain("__session="); + }); + + it("8.2 should update session expiresAt after refresh", async () => { + const now = Math.floor(Date.now() / 1000); + const session = createInitialSessionData({ + tokenSet: { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt: now - 10, + scope: "read:data", + token_type: "Bearer" + } + }); + const cookie = await createSessionCookie(session, secret); + + server.use( + http.post(`https://${DEFAULT.domain}/oauth/token`, async () => { + const jwt = await new jose.SignJWT({ + sid: DEFAULT.sid, + auth_time: Math.floor(Date.now() / 1000) + }) + .setProtectedHeader({ alg: DEFAULT.alg }) + .setSubject(DEFAULT.sub) + .setIssuedAt() + .setIssuer(_authorizationServerMetadata.issuer) + .setAudience(DEFAULT.clientId) + .setExpirationTime("2h") + .sign(keyPair.privateKey); + + return HttpResponse.json({ + access_token: "new_token", + refresh_token: DEFAULT.refreshToken, + id_token: jwt, + token_type: "Bearer", + expires_in: 7200 // 2 hours + }); + }), + http.get(`${DEFAULT.upstreamBaseUrl}/data`, () => { + return HttpResponse.json({ success: true }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + expect(response.status).toBe(200); + + // Verify session was updated (Set-Cookie present) + expect(response.headers.get("set-cookie")).toBeTruthy(); + }); + }); + + describe("Category 8b: Reused Fetcher Token Set Side Effect", () => { + /** + * CRITICAL TEST: Validates that tokenSetSideEffect is properly captured on each proxy call. + * + * PROBLEM: + * - Fetchers are cached per audience to reuse DPoP handles + * - Each proxy call creates a new `getAccessToken` closure that captures `tokenSetSideEffect` + * - When a fetcher is reused, if we don't override its `getAccessToken`, it uses the STALE + * closure from the first call, which references the OLD `tokenSetSideEffect` variable + * - This causes the second token refresh to update the WRONG tokenSetSideEffect variable, + * leading to the session not being updated on the second call + * + * SOLUTION: + * - Override `fetcher.getAccessToken` on every proxy call to capture fresh `tokenSetSideEffect` + * - See auth-client.ts line ~2367: `fetcher.getAccessToken = getAccessToken;` + * + * This test validates that BOTH proxy calls properly update their sessions after token refresh, + * which would fail if the tokenSetSideEffect closure is stale. + */ + it("8.3 should update session on BOTH calls when fetcher is reused for same audience", async () => { + const now = Math.floor(Date.now() / 1000); + + // Track how many times token endpoint is called + let tokenRefreshCount = 0; + const refreshedTokens = [ + "first_refreshed_token", + "second_refreshed_token" + ]; + + // Track which token was used in each upstream request to verify correct token flow + const tokensUsedInUpstreamRequests: string[] = []; + + // Override token endpoint to return different tokens on each refresh + server.use( + http.post(`https://${DEFAULT.domain}/oauth/token`, async () => { + const jwt = await new jose.SignJWT({ + sid: DEFAULT.sid, + auth_time: Math.floor(Date.now() / 1000) + }) + .setProtectedHeader({ alg: DEFAULT.alg }) + .setSubject(DEFAULT.sub) + .setIssuedAt() + .setIssuer(_authorizationServerMetadata.issuer) + .setAudience(DEFAULT.clientId) + .setExpirationTime("2h") + .sign(keyPair.privateKey); + + const token = refreshedTokens[tokenRefreshCount]; + tokenRefreshCount++; + + return HttpResponse.json({ + access_token: token, + refresh_token: DEFAULT.refreshToken, + id_token: jwt, + token_type: "Bearer", + expires_in: 3600 + }); + }), + // Track Authorization header to verify correct token is used + http.get(`${DEFAULT.upstreamBaseUrl}/data`, ({ request }) => { + const authHeader = request.headers.get("authorization"); + if (authHeader) { + tokensUsedInUpstreamRequests.push(authHeader); + } + return HttpResponse.json({ success: true }); + }) + ); + + // ===== FIRST REQUEST ===== + const session1 = createInitialSessionData({ + tokenSet: { + accessToken: "old_token_1", + refreshToken: DEFAULT.refreshToken, + expiresAt: now - 10, // Expired + scope: "read:data", + token_type: "Bearer", + audience: DEFAULT.audience + } + }); + const cookie1 = await createSessionCookie(session1, secret); + + const request1 = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie: cookie1 } + } + ); + + const response1 = await authClient.handler(request1); + expect(response1.status).toBe(200); + + // Verify first session was updated after token refresh + const setCookie1 = response1.headers.get("set-cookie"); + expect(setCookie1).toBeTruthy(); + expect(setCookie1).toContain("__session="); + expect(tokenRefreshCount).toBe(1); + + // Verify the refreshed token was used in the upstream request + expect(tokensUsedInUpstreamRequests).toHaveLength(1); + expect(tokensUsedInUpstreamRequests[0]).toBe( + `Bearer ${refreshedTokens[0]}` + ); + + // ===== SECOND REQUEST (reusing fetcher for same audience) ===== + // Key point: This will reuse the cached fetcher from the first request + // If the getAccessToken closure is stale, tokenSetSideEffect won't be updated + + // Simulate passage of time - token expires again + // We need to manually create a new session with expired token + // because we can't easily decrypt the cookie to verify its contents + const session2 = createInitialSessionData({ + tokenSet: { + accessToken: refreshedTokens[0], // This was the token from first refresh + refreshToken: DEFAULT.refreshToken, + expiresAt: now - 5, // Expired again + scope: "read:data", + token_type: "Bearer", + audience: DEFAULT.audience + } + }); + const cookie2 = await createSessionCookie(session2, secret); + + const request2 = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie: cookie2 } + } + ); + + const response2 = await authClient.handler(request2); + expect(response2.status).toBe(200); + + // CRITICAL ASSERTION: Verify second session was ALSO updated + // BUG SCENARIO: If tokenSetSideEffect closure is stale from the cached fetcher, + // the second token refresh would populate the OLD tokenSetSideEffect variable + // from the first call, which is no longer in scope. This would cause: + // 1. tokenSetSideEffect to remain undefined in the second call + // 2. #updateSessionAfterTokenRetrieval to skip (because tokenSetSideEffect is falsy) + // 3. No Set-Cookie header on the second response + // 4. Session not persisted with the new token + const setCookie2 = response2.headers.get("set-cookie"); + expect(setCookie2).toBeTruthy(); + expect(setCookie2).toContain("__session="); + + // Verify token was refreshed a second time + expect(tokenRefreshCount).toBe(2); + + // Verify the second refreshed token was used in the upstream request + expect(tokensUsedInUpstreamRequests).toHaveLength(2); + expect(tokensUsedInUpstreamRequests[1]).toBe( + `Bearer ${refreshedTokens[1]}` + ); + + // Verify the two session cookies are different (proving both were independently updated) + expect(setCookie2).not.toBe(setCookie1); + + // Summary: This test passes because auth-client.ts overrides fetcher.getAccessToken + // on reuse (line ~2367). Without that override, this test would FAIL because the + // second call's tokenSetSideEffect wouldn't be captured, preventing session updates. + }); + }); + + describe("Category 9: Error Scenarios", () => { + it("9.1 should return upstream 500 error to client", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/error`, () => { + return HttpResponse.json( + { error: "internal_error", message: "Something went wrong" }, + { status: 500 } + ); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/error`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error).toBe("internal_error"); + }); + + it("9.2 should handle upstream 404 error", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/notfound`, () => { + return HttpResponse.json({ error: "not_found" }, { status: 404 }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/notfound`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + + expect(response.status).toBe(404); + }); + + it("9.3 should return 401 when refresh token is missing and token expired", async () => { + const now = Math.floor(Date.now() / 1000); + const session = createInitialSessionData({ + tokenSet: { + accessToken: DEFAULT.accessToken, + refreshToken: undefined, // No refresh token + expiresAt: now - 10, // Expired + scope: "read:data", + token_type: "Bearer" + } + }); + const cookie = await createSessionCookie(session, secret); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ); + + const response = await authClient.handler(request); + + expect(response.status).toBe(401); + }); + }); + + describe("Category 10: Concurrent Request Handling", () => { + it("10.1 should handle multiple concurrent requests with valid token", async () => { + const session = createInitialSessionData({ + tokenSet: { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt: Math.floor(Date.now() / 1000) + 3600, // Far future + scope: "read:data", + token_type: "Bearer" + } + }); + const cookie = await createSessionCookie(session, secret); + + let tokenEndpointCallCount = 0; + let upstreamCallCount = 0; + + server.use( + http.post(`https://${DEFAULT.domain}/oauth/token`, () => { + tokenEndpointCallCount++; + return HttpResponse.json({ + access_token: "new_token", + token_type: "Bearer", + expires_in: 3600 + }); + }), + http.get(`${DEFAULT.upstreamBaseUrl}/data`, () => { + upstreamCallCount++; + return HttpResponse.json({ success: true, count: upstreamCallCount }); + }) + ); + + // Make 5 concurrent requests + const requests = Array.from({ length: 5 }, (_, i) => + authClient.handler( + new NextRequest( + new URL(`${DEFAULT.proxyPath}/data?id=${i}`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ) + ) + ); + + const responses = await Promise.all(requests); + + // All requests should succeed + expect(responses.every((r) => r.status === 200)).toBe(true); + + // All requests should reach upstream + expect(upstreamCallCount).toBe(5); + + // Token should not be refreshed (already valid) + expect(tokenEndpointCallCount).toBe(0); + }); + + it("10.2 should handle concurrent requests with expired token (single refresh)", async () => { + const now = Math.floor(Date.now() / 1000); + const session = createInitialSessionData({ + tokenSet: { + accessToken: DEFAULT.accessToken, + refreshToken: DEFAULT.refreshToken, + expiresAt: now - 10, // Expired + scope: "read:data", + token_type: "Bearer" + } + }); + const cookie = await createSessionCookie(session, secret); + + let tokenEndpointCallCount = 0; + let upstreamCallCount = 0; + + server.use( + http.post(`https://${DEFAULT.domain}/oauth/token`, async () => { + tokenEndpointCallCount++; + + const jwt = await new jose.SignJWT({ + sid: DEFAULT.sid, + auth_time: Math.floor(Date.now() / 1000) + }) + .setProtectedHeader({ alg: DEFAULT.alg }) + .setSubject(DEFAULT.sub) + .setIssuedAt() + .setIssuer(_authorizationServerMetadata.issuer) + .setAudience(DEFAULT.clientId) + .setExpirationTime("2h") + .sign(keyPair.privateKey); + + return HttpResponse.json({ + access_token: "refreshed_token", + refresh_token: DEFAULT.refreshToken, + id_token: jwt, + token_type: "Bearer", + expires_in: 3600 + }); + }), + http.get(`${DEFAULT.upstreamBaseUrl}/data`, () => { + upstreamCallCount++; + return HttpResponse.json({ success: true }); + }) + ); + + // Make 3 concurrent requests with expired token + const requests = Array.from({ length: 3 }, () => + authClient.handler( + new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ) + ) + ); + + const responses = await Promise.all(requests); + + // All requests should succeed + expect(responses.every((r) => r.status === 200)).toBe(true); + + // All requests should reach upstream + expect(upstreamCallCount).toBe(3); + + // Token refresh should be coordinated - ideally only 1 call + // (Note: implementation may vary, so we allow up to 3) + expect(tokenEndpointCallCount).toBeGreaterThan(0); + expect(tokenEndpointCallCount).toBeLessThanOrEqual(3); + }); + + it("10.3 should handle concurrent requests to different proxy routes independently", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + // Create AuthClient with multiple proxy routes + const multiProxyClient = new AuthClient({ + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + appBaseUrl: DEFAULT.appBaseUrl, + routes: getDefaultRoutes(), + secret, + sessionStore: new StatelessSessionStore({ secret }), + transactionStore: new TransactionStore({ secret }), + fetch: (url, init) => + fetch(url, { ...init, ...(init?.body ? { duplex: "half" } : {}) }) + }); + + let meCallCount = 0; + + server.use( + http.get(`${DEFAULT.upstreamBaseUrl}/data`, () => { + meCallCount++; + return HttpResponse.json({ api: "me", count: meCallCount }); + }) + ); + + // Make concurrent requests to /me endpoint + const requests = [ + multiProxyClient.handler( + new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ) + ), + multiProxyClient.handler( + new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ) + ), + multiProxyClient.handler( + new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "GET", + headers: { cookie } + } + ) + ) + ]; + + const responses = await Promise.all(requests); + + // All requests should succeed + expect(responses.every((r) => r.status === 200)).toBe(true); + + // All three concurrent requests should have been processed + expect(meCallCount).toBe(3); + }); + }); + + describe("Category 11: CORS Handling", () => { + it("11.1 should forward CORS preflight response from upstream", async () => { + const session = createInitialSessionData(); + const cookie = await createSessionCookie(session, secret); + + server.use( + http.options(`${DEFAULT.upstreamBaseUrl}/data`, () => { + return new HttpResponse(null, { + status: 204, + headers: { + "access-control-allow-origin": "https://example.com", + "access-control-allow-methods": "GET, POST, PUT", + "access-control-allow-headers": "Content-Type, Authorization" + } + }); + }) + ); + + const request = new NextRequest( + new URL(`${DEFAULT.proxyPath}/data`, DEFAULT.appBaseUrl), + { + method: "OPTIONS", + headers: { + cookie, + origin: "https://example.com", + "access-control-request-method": "POST" + } + } + ); + + const response = await authClient.handler(request); + + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe( + "https://example.com" + ); + }); + }); +}); diff --git a/src/test/proxy-handler-test-helpers.ts b/src/test/proxy-handler-test-helpers.ts new file mode 100644 index 00000000..ffac7b4d --- /dev/null +++ b/src/test/proxy-handler-test-helpers.ts @@ -0,0 +1,189 @@ +/** + * Test Helpers for Proxy Handler Tests + * + * Shared utilities for testing AuthClient proxy functionality with MSW mocking. + * These helpers support Bearer/DPoP authentication, session management, and + * DPoP nonce retry validation. + */ + +import { encrypt } from "../server/cookies.js"; +import { SessionData } from "../types/index.js"; + +/** + * Create initial session data for testing + * + * @param overrides - Partial session data to override defaults + * @returns Complete SessionData object + */ +export function createInitialSessionData( + overrides: Partial = {} +): SessionData { + const now = Math.floor(Date.now() / 1000); + + const defaults: SessionData = { + tokenSet: { + accessToken: "at_test_123", + refreshToken: "rt_test_123", + expiresAt: now + 3600, // 1 hour from now + scope: "read:data write:data", + token_type: "Bearer", + // Add audience to match the /me proxy route configuration + // This ensures the token is recognized as valid for the proxy route + // Without this, getTokenSet will think it needs a new token for the requested audience + audience: "https://test.auth0.local/me/" + }, + user: { + sub: "user_test_123" + }, + internal: { + sid: "session_test_123", + createdAt: now + } + }; + + // Deep merge tokenSet if provided in overrides + if (overrides.tokenSet) { + return { + ...defaults, + ...overrides, + tokenSet: { + ...defaults.tokenSet, + ...overrides.tokenSet + } + }; + } + + return { + ...defaults, + ...overrides + }; +} + +/** + * Create session cookie from session data + * + * @param sessionData - Session data to encrypt + * @param secretKey - Secret key for encryption + * @returns Cookie string in format "__session={encryptedValue}" + */ +export async function createSessionCookie( + sessionData: SessionData, + secretKey: string +): Promise { + const maxAge = 60 * 60; // 1 hour + const expiration = Math.floor(Date.now() / 1000 + maxAge); + const encryptedValue = await encrypt(sessionData, secretKey, expiration); + return `__session=${encryptedValue}`; +} + +/** + * Extract DPoP nonce and claims from DPoP JWT header + * + * @param dpopHeader - DPoP JWT header value + * @returns Object with nonce presence, nonce value, and JWT claims + */ +export function extractDPoPInfo(dpopHeader: string | null): { + hasNonce: boolean; + nonce?: string; + htm?: string; + htu?: string; + jti?: string; + iat?: number; +} { + if (!dpopHeader || typeof dpopHeader !== "string") { + return { hasNonce: false }; + } + + try { + const parts = dpopHeader.split("."); + if (parts.length === 3 && parts[1]) { + const payload = JSON.parse( + Buffer.from(parts[1], "base64url").toString("utf-8") + ); + return { + hasNonce: "nonce" in payload, + nonce: payload.nonce, + htm: payload.htm, + htu: payload.htu, + jti: payload.jti, + iat: payload.iat + }; + } + } catch { + // If parsing fails, return no nonce + } + + return { hasNonce: false }; +} + +/** + * Create stateful DPoP nonce retry handler for upstream API + * + * This handler tracks request attempts and simulates the DPoP nonce retry flow: + * - First request: Returns 401 with WWW-Authenticate header containing use_dpop_nonce error and DPoP-Nonce header + * - Second request: Returns success response + * + * Per RFC 9449 Section 8: Resource servers signal DPoP nonce requirement via 401 with WWW-Authenticate header + * + * @param config - Configuration for the handler + * @returns Handler function and state object for assertions + */ +export function createDPoPNonceRetryHandler(config: { + baseUrl: string; + path: string; + method: string; + successResponse?: any; + successStatus?: number; +}) { + const state = { + requestCount: 0, + requests: [] as Array<{ + attempt: number; + hasDPoP: boolean; + hasNonce: boolean; + nonce?: string; + dpopJwt?: string; + }> + }; + + const handler = async ({ request }: { request: Request }) => { + state.requestCount++; + + const dpopHeader = request.headers.get("dpop"); + const dpopInfo = extractDPoPInfo(dpopHeader); + + state.requests.push({ + attempt: state.requestCount, + hasDPoP: !!dpopHeader, + hasNonce: dpopInfo.hasNonce, + nonce: dpopInfo.nonce, + dpopJwt: dpopHeader || undefined + }); + + // First request: return use_dpop_nonce error + // RFC 9449 Section 8: Resource server responds with 401 and WWW-Authenticate header + if (state.requestCount === 1) { + return new Response( + JSON.stringify({ + error: "use_dpop_nonce", + error_description: "DPoP nonce is required" + }), + { + status: 401, + headers: { + "www-authenticate": 'DPoP error="use_dpop_nonce"', + "dpop-nonce": "server_nonce_123", + "content-type": "application/json" + } + } + ); + } + + // Second request: return success + return Response.json(config.successResponse || { success: true }, { + status: config.successStatus || 200 + }); + }; + + return { handler, state }; +} diff --git a/src/types/index.ts b/src/types/index.ts index 00c1ca4a..d05e9f1c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -167,6 +167,13 @@ export type GetAccessTokenOptions = { audience?: string | null; }; +export type ProxyOptions = { + proxyPath: string; + targetBaseUrl: string; + audience: string; + scope: string | null; +}; + export { AuthorizationParameters, StartInteractiveLoginOptions diff --git a/src/utils/proxy.test.ts b/src/utils/proxy.test.ts new file mode 100644 index 00000000..638e37be --- /dev/null +++ b/src/utils/proxy.test.ts @@ -0,0 +1,511 @@ +import { NextRequest } from "next/server.js"; +import { describe, expect, it } from "vitest"; + +import { ProxyOptions } from "../types/index.js"; +import { + buildForwardedRequestHeaders, + buildForwardedResponseHeaders, + transformTargetUrl +} from "./proxy.js"; + +describe("headers", () => { + describe("buildForwardedRequestHeaders", () => { + it("should forward headers from the default allow-list", () => { + const request = new NextRequest("https://example.com", { + headers: { + accept: "application/json", + "accept-language": "en-US", + "user-agent": "Mozilla/5.0", + "x-forwarded-for": "192.168.1.1", + "x-request-id": "abc123" + } + }); + + const result = buildForwardedRequestHeaders(request); + + expect(result.get("accept")).toBe("application/json"); + expect(result.get("accept-language")).toBe("en-US"); + expect(result.get("user-agent")).toBe("Mozilla/5.0"); + expect(result.get("x-forwarded-for")).toBe("192.168.1.1"); + expect(result.get("x-request-id")).toBe("abc123"); + }); + + it("should not forward headers not in the allow-list", () => { + const request = new NextRequest("https://example.com", { + headers: { + accept: "application/json", + "some-custom-header": "should-not-be-forwarded", + authorization: "Bearer token", + cookie: "session=xyz" + } + }); + + const result = buildForwardedRequestHeaders(request); + + expect(result.get("accept")).toBe("application/json"); + expect(result.get("some-custom-header")).toBeNull(); + expect(result.get("authorization")).toBeNull(); + expect(result.get("cookie")).toBeNull(); + }); + + it("should handle case-insensitive header names", () => { + const request = new NextRequest("https://example.com", { + headers: { + Accept: "application/json", + "Content-Type": "text/plain", + "User-Agent": "Mozilla/5.0" + } + }); + + const result = buildForwardedRequestHeaders(request); + + expect(result.get("accept")).toBe("application/json"); + expect(result.get("content-type")).toBe("text/plain"); + expect(result.get("user-agent")).toBe("Mozilla/5.0"); + }); + + it("should handle empty headers", () => { + const request = new NextRequest("https://example.com"); + + const result = buildForwardedRequestHeaders(request); + + expect(Array.from(result.keys()).length).toBe(0); + }); + + it("should forward caching and conditional request headers", () => { + const request = new NextRequest("https://example.com", { + headers: { + "cache-control": "no-cache", + "if-none-match": '"abc123"', + "if-modified-since": "Wed, 21 Oct 2015 07:28:00 GMT", + etag: '"xyz789"' + } + }); + + const result = buildForwardedRequestHeaders(request); + + expect(result.get("cache-control")).toBe("no-cache"); + expect(result.get("if-none-match")).toBe('"abc123"'); + expect(result.get("if-modified-since")).toBe( + "Wed, 21 Oct 2015 07:28:00 GMT" + ); + expect(result.get("etag")).toBe('"xyz789"'); + }); + + it("should forward tracing and observability headers", () => { + const request = new NextRequest("https://example.com", { + headers: { + traceparent: "00-abc123-def456-01", + tracestate: "vendor=value", + "x-correlation-id": "corr-123" + } + }); + + const result = buildForwardedRequestHeaders(request); + + expect(result.get("traceparent")).toBe("00-abc123-def456-01"); + expect(result.get("tracestate")).toBe("vendor=value"); + expect(result.get("x-correlation-id")).toBe("corr-123"); + }); + + it("should forward proxy headers for IP and rate limiting", () => { + const request = new NextRequest("https://example.com", { + headers: { + "x-forwarded-for": "192.168.1.1, 10.0.0.1", + "x-forwarded-host": "example.com", + "x-forwarded-proto": "https", + "x-real-ip": "192.168.1.1" + } + }); + + const result = buildForwardedRequestHeaders(request); + + expect(result.get("x-forwarded-for")).toBe("192.168.1.1, 10.0.0.1"); + expect(result.get("x-forwarded-host")).toBe("example.com"); + expect(result.get("x-forwarded-proto")).toBe("https"); + expect(result.get("x-real-ip")).toBe("192.168.1.1"); + }); + }); + + describe("buildForwardedResponseHeaders", () => { + it("should forward all headers except hop-by-hop headers", () => { + const response = new Response("body", { + headers: { + "content-type": "application/json", + "cache-control": "max-age=3600", + "x-custom-header": "custom-value", + etag: '"abc123"' + } + }); + + const result = buildForwardedResponseHeaders(response); + + expect(result.get("content-type")).toBe("application/json"); + expect(result.get("cache-control")).toBe("max-age=3600"); + // custom headers allowed in response headers + expect(result.get("x-custom-header")).toBe("custom-value"); + expect(result.get("etag")).toBe('"abc123"'); + }); + + it("should strip all hop-by-hop headers", () => { + const response = new Response("body", { + headers: { + "content-type": "application/json", + connection: "keep-alive", + "keep-alive": "timeout=5", + "proxy-authenticate": "Basic", + "proxy-authorization": "Bearer token", + te: "trailers", + trailer: "Expires", + "transfer-encoding": "chunked", + upgrade: "h2c" + } + }); + + const result = buildForwardedResponseHeaders(response); + + expect(result.get("content-type")).toBe("application/json"); + expect(result.get("connection")).toBeNull(); + expect(result.get("keep-alive")).toBeNull(); + expect(result.get("proxy-authenticate")).toBeNull(); + expect(result.get("proxy-authorization")).toBeNull(); + expect(result.get("te")).toBeNull(); + expect(result.get("trailer")).toBeNull(); + expect(result.get("transfer-encoding")).toBeNull(); + expect(result.get("upgrade")).toBeNull(); + }); + + it("should handle case-insensitive hop-by-hop headers", () => { + const response = new Response("body", { + headers: { + "content-type": "application/json", + Connection: "Keep-Alive", + "Transfer-Encoding": "chunked", + Upgrade: "WebSocket" + } + }); + + const result = buildForwardedResponseHeaders(response); + + expect(result.get("content-type")).toBe("application/json"); + expect(result.get("connection")).toBeNull(); + expect(result.get("transfer-encoding")).toBeNull(); + expect(result.get("upgrade")).toBeNull(); + }); + + it("should handle empty headers", () => { + const response = new Response("body"); + + const result = buildForwardedResponseHeaders(response); + + // Response objects may have default headers like content-type + // So we just check that hop-by-hop headers are not present + expect(result.get("connection")).toBeNull(); + expect(result.get("upgrade")).toBeNull(); + }); + + it("should forward standard response headers", () => { + const response = new Response("body", { + headers: { + "content-type": "application/json", + "content-length": "123", + "content-encoding": "gzip", + "content-language": "en-US", + date: "Wed, 21 Oct 2015 07:28:00 GMT", + expires: "Thu, 22 Oct 2015 07:28:00 GMT", + "last-modified": "Tue, 20 Oct 2015 07:28:00 GMT" + } + }); + + const result = buildForwardedResponseHeaders(response); + + expect(result.get("content-type")).toBe("application/json"); + expect(result.get("content-length")).toBe("123"); + expect(result.get("content-encoding")).toBe("gzip"); + expect(result.get("content-language")).toBe("en-US"); + expect(result.get("date")).toBe("Wed, 21 Oct 2015 07:28:00 GMT"); + expect(result.get("expires")).toBe("Thu, 22 Oct 2015 07:28:00 GMT"); + expect(result.get("last-modified")).toBe("Tue, 20 Oct 2015 07:28:00 GMT"); + }); + + it("should forward security headers", () => { + const response = new Response("body", { + headers: { + "strict-transport-security": "max-age=31536000", + "content-security-policy": "default-src 'self'", + "x-frame-options": "DENY", + "x-content-type-options": "nosniff", + "x-xss-protection": "1; mode=block" + } + }); + + const result = buildForwardedResponseHeaders(response); + + expect(result.get("strict-transport-security")).toBe("max-age=31536000"); + expect(result.get("content-security-policy")).toBe("default-src 'self'"); + expect(result.get("x-frame-options")).toBe("DENY"); + expect(result.get("x-content-type-options")).toBe("nosniff"); + expect(result.get("x-xss-protection")).toBe("1; mode=block"); + }); + + it("should forward CORS headers", () => { + const response = new Response("body", { + headers: { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, PUT", + "access-control-allow-headers": "Content-Type, Authorization", + "access-control-max-age": "86400", + "access-control-expose-headers": "X-Custom-Header" + } + }); + + const result = buildForwardedResponseHeaders(response); + + expect(result.get("access-control-allow-origin")).toBe("*"); + expect(result.get("access-control-allow-methods")).toBe("GET, POST, PUT"); + expect(result.get("access-control-allow-headers")).toBe( + "Content-Type, Authorization" + ); + expect(result.get("access-control-max-age")).toBe("86400"); + expect(result.get("access-control-expose-headers")).toBe( + "X-Custom-Header" + ); + }); + }); +}); + +describe("url", () => { + describe("transformTargetUrl", () => { + /** + * Helper to create a mock NextRequest with a given pathname and search params + */ + function createMockRequest( + pathname: string, + searchParams: Record = {} + ): NextRequest { + const url = new URL(`http://localhost${pathname}`); + Object.entries(searchParams).forEach(([key, value]) => { + url.searchParams.set(key, value); + }); + + return { + nextUrl: url, + headers: new Headers() + } as unknown as NextRequest; + } + + describe("Bug: Double path segment", () => { + it("should not duplicate path segments when targetBaseUrl includes path", () => { + // This is the reported bug scenario + const req = createMockRequest("/me/v1/some-endpoint"); + const options: ProxyOptions = { + proxyPath: "/me", + targetBaseUrl: "https://issuer/me/v1", + audience: "https://issuer/me/", + scope: null + }; + + const result = transformTargetUrl(req, options); + + // The path after /me is /v1/some-endpoint + // The targetBaseUrl already ends with /v1, so we detect and avoid duplication + // Result: https://issuer/me/v1/some-endpoint (NOT /v1/v1/) + expect(result.toString()).toBe("https://issuer/me/v1/some-endpoint"); + }); + }); + + describe("Single segment proxy paths", () => { + it("should handle simple single-segment proxy path", () => { + const req = createMockRequest("/api/users"); + const options: ProxyOptions = { + proxyPath: "/api", + targetBaseUrl: "https://backend.example.com", + audience: "https://backend.example.com/", + scope: null + }; + + const result = transformTargetUrl(req, options); + + expect(result.toString()).toBe("https://backend.example.com/users"); + }); + + it("should handle root path replacement", () => { + const req = createMockRequest("/users"); + const options: ProxyOptions = { + proxyPath: "/", + targetBaseUrl: "https://backend.example.com", + audience: "https://backend.example.com/", + scope: null + }; + + const result = transformTargetUrl(req, options); + + // URL normalizes to include trailing slash + expect(result.toString()).toBe("https://backend.example.com/users"); + }); + }); + + describe("Multi-segment proxy paths", () => { + it("should handle multi-segment proxy paths", () => { + const req = createMockRequest("/api/v1/users"); + const options: ProxyOptions = { + proxyPath: "/api/v1", + targetBaseUrl: "https://backend.example.com/api/v1", + audience: "https://backend.example.com/", + scope: null + }; + + const result = transformTargetUrl(req, options); + + expect(result.toString()).toBe( + "https://backend.example.com/api/v1/users" + ); + }); + + it("should handle complex nested paths", () => { + const req = createMockRequest("/proxy/auth/v2/some/nested/endpoint"); + const options: ProxyOptions = { + proxyPath: "/proxy/auth", + targetBaseUrl: "https://auth.example.com/auth/v2", + audience: "https://auth.example.com/", + scope: null + }; + + const result = transformTargetUrl(req, options); + + // After stripping "/proxy/auth", remaining is "/v2/some/nested/endpoint" + // targetBaseUrl ends with "/auth/v2", which overlaps with the start + // So we avoid duplication: /auth/v2/some/nested/endpoint (not /v2/v2/) + expect(result.toString()).toBe( + "https://auth.example.com/auth/v2/some/nested/endpoint" + ); + }); + }); + + describe("Query parameters", () => { + it("should preserve query parameters", () => { + const req = createMockRequest("/api/users", { + id: "123", + name: "test" + }); + const options: ProxyOptions = { + proxyPath: "/api", + targetBaseUrl: "https://backend.example.com", + audience: "https://backend.example.com/", + scope: null + }; + + const result = transformTargetUrl(req, options); + + expect(result.href).toContain("https://backend.example.com/users?"); + expect(result.searchParams.get("id")).toBe("123"); + expect(result.searchParams.get("name")).toBe("test"); + }); + + it("should handle multiple query parameters", () => { + const req = createMockRequest("/me/v1/profile", { + format: "json", + includeMetadata: "true" + }); + const options: ProxyOptions = { + proxyPath: "/me", + targetBaseUrl: "https://issuer/me/v1", + audience: "https://issuer/me/", + scope: null + }; + + const result = transformTargetUrl(req, options); + + // After stripping "/me", remaining is "/v1/profile" + // targetBaseUrl ends with "/v1", so avoid duplication + expect(result.toString()).toMatch( + /https:\/\/issuer\/me\/v1\/profile\?.*format=json/ + ); + expect(result.toString()).toMatch( + /https:\/\/issuer\/me\/v1\/profile\?.*includeMetadata=true/ + ); + }); + }); + + describe("Edge cases", () => { + it("should handle proxy path with trailing slash", () => { + const req = createMockRequest("/api/v1/test"); + const options: ProxyOptions = { + proxyPath: "/api/v1", + targetBaseUrl: "https://backend.example.com/api/v1/", + audience: "https://backend.example.com/", + scope: null + }; + + const result = transformTargetUrl(req, options); + + // The implementation removes trailing slash from base URL, so no double slash + expect(result.toString()).toBe( + "https://backend.example.com/api/v1/test" + ); + }); + + it("should handle target base URL without trailing slash", () => { + const req = createMockRequest("/api/resource"); + const options: ProxyOptions = { + proxyPath: "/api", + targetBaseUrl: "https://backend.example.com", + audience: "https://backend.example.com/", + scope: null + }; + + const result = transformTargetUrl(req, options); + + expect(result.toString()).toBe("https://backend.example.com/resource"); + }); + + it("should handle request path that doesn't match proxy path prefix", () => { + const req = createMockRequest("/other/path"); + const options: ProxyOptions = { + proxyPath: "/api", + targetBaseUrl: "https://backend.example.com", + audience: "https://backend.example.com/", + scope: null + }; + + const result = transformTargetUrl(req, options); + + // If the path doesn't start with proxyPath, the entire path is used + expect(result.toString()).toBe( + "https://backend.example.com/other/path" + ); + }); + + it("should handle exactly matching proxy path", () => { + const req = createMockRequest("/api"); + const options: ProxyOptions = { + proxyPath: "/api", + targetBaseUrl: "https://backend.example.com", + audience: "https://backend.example.com/", + scope: null + }; + + const result = transformTargetUrl(req, options); + + // URL naturally normalizes with trailing slash + expect(result.toString()).toBe("https://backend.example.com/"); + }); + + it("should handle path with special characters", () => { + const req = createMockRequest("/api/user%20name/profile"); + const options: ProxyOptions = { + proxyPath: "/api", + targetBaseUrl: "https://backend.example.com", + audience: "https://backend.example.com/", + scope: null + }; + + const result = transformTargetUrl(req, options); + + expect(result.toString()).toBe( + "https://backend.example.com/user%20name/profile" + ); + }); + }); + }); +}); diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts new file mode 100644 index 00000000..6b476b03 --- /dev/null +++ b/src/utils/proxy.ts @@ -0,0 +1,199 @@ +import { NextRequest } from "next/server.js"; + +import { ProxyOptions } from "../types/index.js"; + +/** + * A default allow-list of headers to forward. + */ +const DEFAULT_HEADER_ALLOW_LIST: Set = new Set([ + // Common End-to-End Headers + "accept", + "accept-language", + "content-language", + "content-type", + "user-agent", + + // Caching & Conditional Requests + "cache-control", + "if-match", + "if-none-match", + "if-modified-since", + "if-unmodified-since", + "etag", + + // Tracing & Observability + "x-request-id", + "x-correlation-id", + "traceparent", + "tracestate", + + // PROXY HEADERS (for IP & Rate Limiting) + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "x-real-ip", + + // CORS REQUEST HEADERS + // Without these headers, Preflight fails, browser blocks all cross-origin requests + // See: RFC 7231 §4.3.1 (preflight semantics), RFC 6454 (origin), WHATWG Fetch Spec + "origin", + "access-control-request-method", + "access-control-request-headers" +]); + +/** + * Hop-by-hop headers that MUST be removed. + * These are relevant only for a single transport link (client <-> proxy). + * @see https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1 + */ +const HOP_BY_HOP_HEADERS: Set = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade" +]); + +/** + * Securely builds a Headers object for forwarding a NextRequest via fetch. + * + * This function: + * 1. Uses a strict **allow-list** (DEFAULT_HEADER_ALLOW_LIST). + * 2. Strips all hop-by-hop headers as defined by https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1. + * + * @param request The incoming NextRequest object. + * @returns A WHATWG Headers object suitable for `fetch`. + */ +export function buildForwardedRequestHeaders(request: NextRequest): Headers { + const forwardedHeaders = new Headers(); + + request.headers.forEach((value, key) => { + const lowerKey = key.toLowerCase(); + + // Forward if: + // 1. It's in the allow-list, AND + // 2. It's not a hop-by-hop header + const shouldForward = + DEFAULT_HEADER_ALLOW_LIST.has(lowerKey) && + !HOP_BY_HOP_HEADERS.has(lowerKey); + + if (shouldForward) { + forwardedHeaders.set(key, value); + } + }); + + return forwardedHeaders; +} + +/** + * Securely builds a Headers object for forwarding a fetch response. + * + * This function: + * 1. Strips all hop-by-hop headers as defined by https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1. + * + * @param request The incoming Response object. + * @returns A WHATWG Headers object suitable for `fetch`. + */ +export function buildForwardedResponseHeaders(response: Response): Headers { + const forwardedHeaders = new Headers(); + + response.headers.forEach((value, key) => { + const lowerKey = key.toLowerCase(); + + // Only forward if it's not a hop-by-hop header + if (!HOP_BY_HOP_HEADERS.has(lowerKey)) { + forwardedHeaders.set(key, value); + } + }); + + return forwardedHeaders; +} + +/** + * Builds a URL representing the upstream target for a proxied request. + * + * This function correctly handles the path transformation by: + * 1. Extracting the path segment that comes AFTER the proxyPath + * 2. Intelligently combining it with targetBaseUrl to avoid path segment duplication + * + * Example: + * - proxyPath: "/me" + * - targetBaseUrl: "https://issuer/me/v1" + * - incoming: "/me/v1/some-endpoint" + * - remaining path: "/v1/some-endpoint" (after removing "/me") + * - result: "https://issuer/me/v1/some-endpoint" (no /v1 duplication) + * + * @param req - The incoming request to mirror when constructing the target URL. + * @param options - Proxy configuration containing the base URL and proxy path. + * @returns A URL object pointing to the resolved target endpoint with forwarded query parameters. + */ +export function transformTargetUrl( + req: NextRequest, + options: ProxyOptions +): URL { + const targetBaseUrl = options.targetBaseUrl; + + // Extract the path segment that comes AFTER the proxyPath + // If proxyPath is "/me" and pathname is "/me/v1/some-endpoint", + // the remaining path is "/v1/some-endpoint" + let remainingPath = req.nextUrl.pathname.startsWith(options.proxyPath) + ? req.nextUrl.pathname.slice(options.proxyPath.length) + : req.nextUrl.pathname; + + // Ensure proper path joining by handling the slash + // If remainingPath is empty or doesn't start with /, handle accordingly + if (remainingPath && !remainingPath.startsWith("/")) { + remainingPath = "/" + remainingPath; + } + + // Remove trailing slash from targetBaseUrl for consistent joining + const baseUrlTrimmed = targetBaseUrl.replace(/\/$/, ""); + + // Parse the targetBaseUrl to extract its path + const baseUrl = new URL(baseUrlTrimmed); + const basePath = baseUrl.pathname; + + // Check if remainingPath starts with a segment that's already at the end of basePath + // to avoid duplication (e.g., basePath="/me/v1" + remainingPath="/v1/x" → "/me/v1/x") + let finalPath = basePath; + + if (remainingPath && remainingPath !== "/") { + // Split paths into segments for comparison + const baseSegments = basePath.split("/").filter(Boolean); + const remainingSegments = remainingPath.split("/").filter(Boolean); + + // Find the longest overlap by checking from longest to shortest + // Break on first match + let overlapLength = 0; + const maxOverlap = Math.min(baseSegments.length, remainingSegments.length); + + for (let i = maxOverlap; i >= 1; i--) { + const baseEnd = baseSegments.slice(-i); + const remainingStart = remainingSegments.slice(0, i); + + if (baseEnd.every((seg, idx) => seg === remainingStart[idx])) { + overlapLength = i; + break; // Found longest match, stop searching + } + } + + // Build final path by appending non-overlapping segments + const nonOverlappingSegments = remainingSegments.slice(overlapLength); + if (nonOverlappingSegments.length > 0) { + const separator = basePath === "/" || basePath.endsWith("/") ? "" : "/"; + finalPath = basePath + separator + nonOverlappingSegments.join("/"); + } + } + + // Build the final URL with the de-duplicated path + const targetUrl = new URL(baseUrl.origin + finalPath); + + req.nextUrl.searchParams.forEach((value, key) => { + targetUrl.searchParams.set(key, value); + }); + + return targetUrl; +}