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
+
+ {organizations.map((org) => (
+ - {org.display_name}
+ ))}
+
+
+ );
+}
+```
+
+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;
+}