From 3a7a28c567fc6835d72f6061278f9a9a000d0cb4 Mon Sep 17 00:00:00 2001 From: Mitch Fultz Date: Sat, 12 Sep 2026 23:03:17 -0600 Subject: [PATCH 1/2] fix: start page authentication in the callback route Fixes DAX-3330. Preserve page-level guards without render-time cookie writes or speculative PKCE cookies. --- README.md | 71 ++++++----- src/auth-start.spec.ts | 222 ++++++++++++++++++++++++++++++++++ src/authkit-callback-route.ts | 48 +++++++- src/pkce.ts | 11 +- src/session.spec.ts | 22 ++-- src/session.ts | 22 ++-- 6 files changed, 340 insertions(+), 56 deletions(-) create mode 100644 src/auth-start.spec.ts diff --git a/README.md b/README.md index bdab4c0..940607a 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ export const GET = async () => { }; ``` -In the [WorkOS dashboard](https://dashboard.workos.com), go to **Redirects** and set the **Sign-in URL** to match this route (e.g., `http://localhost:3000/sign-in`). +In the [WorkOS dashboard](https://dashboard.workos.com), go to **Redirects** and set the **Sign-in URL** to match this route (e.g., `http://localhost:3000/sign-in`). Use a normal `` link to this route so navigation, not prefetching, starts sign-in. > [!IMPORTANT] > The Sign-in URL is required for features like [impersonation](https://workos.com/docs/user-management/impersonation) to work correctly. Without it, WorkOS-initiated flows (such as impersonating a user from the dashboard) will fail because they cannot complete the PKCE/CSRF verification that this library enforces on every callback. @@ -388,32 +388,17 @@ export default async function RootLayout({ children }: { children: React.ReactNo For pages where you want to display a signed-in and signed-out view, use `withAuth` to retrieve the user session from WorkOS. ```jsx -import Link from 'next/link'; -import { getSignInUrl, getSignUpUrl, withAuth, signOut } from '@workos-inc/authkit-nextjs'; +import { withAuth, signOut } from '@workos-inc/authkit-nextjs'; export default async function HomePage() { // Retrieves the user from the session or returns `null` if no user is signed in const { user } = await withAuth(); if (!user) { - // Get the URL to redirect the user to AuthKit to sign in - const signInUrl = await getSignInUrl(); - - // Get the URL to redirect the user to AuthKit to sign up - const signUpUrl = await getSignUpUrl(); - - // You can also pass custom state data through the auth flow - const signInUrlWithState = await getSignInUrl({ - state: JSON.stringify({ - teamId: 'team_123', - referrer: 'homepage', - }), - }); - return ( <> - Log in - Sign Up + Log in + Sign up ); } @@ -432,6 +417,18 @@ export default async function HomePage() { } ``` +`getSignInUrl()` and `getSignUpUrl()` set a PKCE cookie, so call them in a Route Handler or Server Action, not while rendering a Server Component. Use the [sign-in route](#sign-in-url) above and an equivalent sign-up route: + +```ts +// app/sign-up/route.ts +import { getSignUpUrl } from '@workos-inc/authkit-nextjs'; +import { redirect } from 'next/navigation'; + +export async function GET() { + redirect(await getSignUpUrl()); +} +``` + ### Get the current user in a client component For client components, use the `useAuth` hook to get the current user session. @@ -511,7 +508,7 @@ const { user } = await withAuth({ ensureSignedIn: true }); const { user, loading } = useAuth({ ensureSignedIn: true }); ``` -Enabling `ensureSignedIn` will redirect users to AuthKit if they attempt to access the page without being authenticated. +Enabling `ensureSignedIn` will redirect users to AuthKit if they attempt to access the page without being authenticated. The Server Component guard first visits your existing `handleAuth()` callback route to set up PKCE; no additional route or configuration is needed. Rendering or prefetching the page does not set a verifier cookie. The cookie is created when the browser navigates to start authentication. ### Re-authentication @@ -666,20 +663,32 @@ JWT tokens are sensitive credentials and should be handled carefully: ### Passing Custom State Through Authentication -You can pass custom state data through the authentication flow using the `state` parameter. The state parameter is a string value that gets passed through OAuth and returned in the callback. To pass complex data, serialize it as JSON: +You can pass custom state data through the authentication flow using the `state` parameter. The state parameter is a string value that gets passed through OAuth and returned in the callback. Generate the URL in a Route Handler or Server Action, serializing complex data as JSON: ```ts -// When generating sign-in/sign-up URLs, serialize your data as JSON -const signInUrl = await getSignInUrl({ - state: JSON.stringify({ - teamId: 'team_123', - feature: 'billing', - referrer: 'pricing-page', - timestamp: Date.now(), - }), -}); +// app/sign-in/route.ts +import { getSignInUrl } from '@workos-inc/authkit-nextjs'; +import { redirect } from 'next/navigation'; + +export async function GET() { + const signInUrl = await getSignInUrl({ + state: JSON.stringify({ + teamId: 'team_123', + feature: 'billing', + referrer: 'pricing-page', + timestamp: Date.now(), + }), + }); + redirect(signInUrl); +} +``` + +The state data is available in the callback handler: + +```ts +// app/callback/route.ts +import { handleAuth } from '@workos-inc/authkit-nextjs'; -// The state data is available in the callback handler export const GET = handleAuth({ onSuccess: async ({ user, state }) => { // Parse the state string back to an object diff --git a/src/auth-start.spec.ts b/src/auth-start.spec.ts new file mode 100644 index 0000000..cae17bd --- /dev/null +++ b/src/auth-start.spec.ts @@ -0,0 +1,222 @@ +import { createHash } from 'node:crypto'; +import { sealData, unsealData } from 'iron-session'; +import * as nextHeaders from 'next/headers'; +import { redirect } from 'next/navigation'; +import { NextRequest } from 'next/server'; +import { RequestCookies, ResponseCookies } from 'next/dist/server/web/spec-extension/cookies.js'; +import { RequestCookiesAdapter } from 'next/dist/server/web/spec-extension/adapters/request-cookies.js'; +import { handleAuth } from './authkit-callback-route.js'; +import { getPKCECookieNameForState, getStateFromPKCECookieValue } from './pkce.js'; +import { updateSessionMiddleware, withAuth } from './session.js'; +import { getWorkOS } from './workos.js'; + +const callbackUri = 'https://preview.example/auth/callback?tenant=blue&label=hello%20world~'; +const documentHeaders = { accept: 'text/html' }; +const routingData = { + purpose: 'authkit-start', + redirectUri: callbackUri, + returnPathname: '/dashboard?tab=details', + screenHint: 'sign-in', +}; +const sealOptions = { password: process.env.WORKOS_COOKIE_PASSWORD!, ttl: 0 }; + +async function readFlow(response: Response) { + expect(response.status).toBe(307); + const url = new URL(response.headers.get('Location')!); + const state = url.searchParams.get('state')!; + const cookie = new ResponseCookies(response.headers).get(getPKCECookieNameForState(state)); + expect(cookie?.value).toBe(state); + return { url, state, cookie: cookie!, data: await getStateFromPKCECookieValue(state) }; +} + +describe('page authentication start', () => { + let startUrl: string; + + beforeEach(async () => { + const cookies = await nextHeaders.cookies(); + // @ts-expect-error - _reset is part of the shared Next mock + cookies._reset(); + const headers = await nextHeaders.headers(); + // @ts-expect-error - _reset is part of the shared Next mock + headers._reset(); + headers.set('x-workos-middleware', 'true'); + headers.set('x-url', 'https://preview.example/dashboard?tab=details'); + headers.set('x-redirect-uri', callbackUri); + await withAuth({ ensureSignedIn: true }); + startUrl = vi.mocked(redirect).mock.calls.at(-1)![0]; + vi.mocked(redirect).mockClear(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('redirects through the existing callback without writing render-time cookies', async () => { + const request = new NextRequest('https://preview.example/dashboard?tab=details', { + headers: documentHeaders, + }); + const proxyResponse = await updateSessionMiddleware( + request, + false, + { enabled: false, unauthenticatedPaths: [] }, + callbackUri, + ['/dashboard'], + ); + expect(proxyResponse.headers.getSetCookie()).toEqual([]); + + const headers = await nextHeaders.headers(); + for (const name of proxyResponse.headers.get('x-middleware-override-headers')!.split(',')) { + headers.set(name, proxyResponse.headers.get(`x-middleware-request-${name}`)!); + } + const readonlyCookies = RequestCookiesAdapter.seal(new RequestCookies(new Headers())); + const cookieSpy = vi.spyOn(nextHeaders, 'cookies').mockResolvedValue(readonlyCookies); + await withAuth({ ensureSignedIn: true }); + cookieSpy.mockRestore(); + + const start = new URL(vi.mocked(redirect).mock.calls[0][0]); + expect(start.origin + start.pathname).toBe('https://preview.example/auth/callback'); + expect(start.searchParams.get('tenant')).toBe('blue'); + expect(start.searchParams.get('state')).toBeNull(); + expect(await unsealData(start.searchParams.get('__authkit_start')!, sealOptions)).toEqual({ + ...routingData, + screenHint: 'sign-up', + }); + + const flow = await readFlow(await handleAuth()(new NextRequest(start, { headers: documentHeaders }))); + expect(flow.url.origin).toBe('https://api.workos.com'); + expect(flow.url.searchParams.get('redirect_uri')).toBe(callbackUri); + expect(flow.url.searchParams.get('screen_hint')).toBe('sign-up'); + expect(flow.data.returnPathname).toBe('/dashboard?tab=details'); + expect(flow.url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(flow.url.searchParams.get('code_challenge')).toBe( + createHash('sha256').update(flow.data.codeVerifier).digest('base64url'), + ); + expect(flow.cookie).toMatchObject({ httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600 }); + }); + + it('preserves the configured URI and cookie settings when baseURL corrects an internal callback origin', async () => { + const request = new NextRequest(startUrl.replace('https://preview.example', 'http://internal:3000'), { + headers: documentHeaders, + }); + const flow = await readFlow(await handleAuth({ baseURL: 'https://preview.example' })(request)); + expect(flow.url.searchParams.get('redirect_uri')).toBe(callbackUri); + expect(flow.cookie.secure).toBe(true); + expect(flow.data.returnPathname).toBe('/dashboard?tab=details'); + }); + + it('starts fresh PKCE from a cached routing link rather than expiring the link', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(Date.now() + 60 * 60 * 1000); + const flow = await readFlow(await handleAuth()(new NextRequest(startUrl, { headers: documentHeaders }))); + expect(flow.cookie.maxAge).toBe(600); + expect(flow.data.returnPathname).toBe('/dashboard?tab=details'); + }); + + it.each>([ + { accept: '*/*', RSC: '1', 'Next-Router-State-Tree': '["",{}]' }, + { accept: '*/*', RSC: '1', 'Next-Router-Prefetch': '1' }, + { accept: 'text/html', Purpose: 'prefetch' }, + { accept: 'text/html', 'Sec-Purpose': 'prefetch' }, + ])('does not start a flow for a passive request with %j', async (headers) => { + const generate = vi.spyOn(getWorkOS().pkce, 'generate'); + const handler = handleAuth(); + const response = await handler(new NextRequest(startUrl, { headers })); + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toContain('text/html'); + expect(response.headers.get('Cache-Control')).toContain('no-store'); + expect(response.headers.get('Location')).toBeNull(); + expect(response.headers.getSetCookie()).toEqual([]); + expect(await response.text()).toBe(''); + expect(generate).not.toHaveBeenCalled(); + + // The same URL still starts authentication when Next falls back to a document navigation. + await readFlow(await handler(new NextRequest(startUrl, { headers: documentHeaders }))); + expect(generate).toHaveBeenCalledTimes(1); + }); + + it('creates independent flows and treats code/state plus start parameters as a normal callback', async () => { + const handler = handleAuth(); + const flowA = await readFlow(await handler(new NextRequest(startUrl, { headers: documentHeaders }))); + const secondStart = new NextRequest(startUrl, { headers: documentHeaders }); + secondStart.cookies.set(flowA.cookie.name, flowA.cookie.value); + const flowB = await readFlow(await handler(secondStart)); + expect(flowB.state).not.toBe(flowA.state); + expect(flowB.data.codeVerifier).not.toBe(flowA.data.codeVerifier); + expect(flowB.cookie.name).not.toBe(flowA.cookie.name); + + const callback = new NextRequest(startUrl, { headers: documentHeaders }); + callback.nextUrl.searchParams.set('code', 'test-code'); + callback.nextUrl.searchParams.set('state', flowA.state); + callback.cookies.set(flowA.cookie.name, flowA.cookie.value); + callback.cookies.set(flowB.cookie.name, flowB.cookie.value); + const exchange = vi + .spyOn(getWorkOS().userManagement, 'authenticateWithCode') + .mockRejectedValue(new Error('offline exchange stub')); + const response = await handler(callback); + expect(exchange).toHaveBeenCalledWith({ + clientId: process.env.WORKOS_CLIENT_ID, + code: 'test-code', + codeVerifier: flowA.data.codeVerifier, + }); + const cookies = response.headers.getSetCookie(); + expect(cookies.find((cookie) => cookie.startsWith(`${flowA.cookie.name}=`))).toContain('Max-Age=0'); + expect(cookies.some((cookie) => cookie.startsWith(`${flowB.cookie.name}=`))).toBe(false); + + // Possessing a valid sealed state is not enough to create its cookie or exchange a code. + exchange.mockClear(); + callback.cookies.delete(flowA.cookie.name); + const rejected = await handler(callback); + expect(rejected.status).toBe(500); + expect(exchange).not.toHaveBeenCalled(); + expect(rejected.headers.getSetCookie().find((cookie) => cookie.startsWith(`${flowA.cookie.name}=`))).toContain( + 'Max-Age=0', + ); + + const substitutedState = new URL(startUrl); + substitutedState.searchParams.set('__authkit_start', flowA.state); + const rejectedStart = await handler(new NextRequest(substitutedState, { headers: documentHeaders })); + expect(rejectedStart.status).toBe(500); + expect(rejectedStart.headers.getSetCookie()).toEqual([]); + expect(exchange).not.toHaveBeenCalled(); + }); + + it.each([ + { ...routingData, purpose: 'oauth-state' }, + { ...routingData, redirectUri: 'not-a-url' }, + { ...routingData, returnPathname: 'https://evil.example' }, + { ...routingData, returnPathname: '//evil.example' }, + { ...routingData, returnPathname: '/\\evil.example' }, + { ...routingData, screenHint: 'unknown' }, + { ...routingData, codeVerifier: 'not-routing-data' }, + ])('rejects a sealed payload with the wrong routing shape: %j', async (data) => { + const generate = vi.spyOn(getWorkOS().pkce, 'generate'); + const url = new URL(startUrl); + url.searchParams.set('__authkit_start', await sealData(data, sealOptions)); + const response = await handleAuth()(new NextRequest(url, { headers: documentHeaders })); + expect(response.status).toBe(500); + expect(response.headers.getSetCookie()).toEqual([]); + expect(generate).not.toHaveBeenCalled(); + }); + + it.each(['&__authkit_start=duplicate', '&code=', '&state='])( + 'does not bypass callback checks with %s', + async (suffix) => { + const generate = vi.spyOn(getWorkOS().pkce, 'generate'); + const response = await handleAuth()(new NextRequest(startUrl + suffix, { headers: documentHeaders })); + expect(response.status).toBe(500); + expect(response.headers.getSetCookie()).toEqual([]); + expect(generate).not.toHaveBeenCalled(); + }, + ); + + it('rejects a tampered routing payload', async () => { + const url = new URL(startUrl); + const payload = url.searchParams.get('__authkit_start')!; + url.searchParams.set('__authkit_start', payload.replace('Fe26.2', 'invalid')); + const response = await handleAuth()(new NextRequest(url, { headers: documentHeaders })); + expect(response.status).toBe(500); + expect(response.headers.getSetCookie()).toEqual([]); + }); +}); diff --git a/src/authkit-callback-route.ts b/src/authkit-callback-route.ts index 326a6db..075fda3 100644 --- a/src/authkit-callback-route.ts +++ b/src/authkit-callback-route.ts @@ -1,13 +1,29 @@ +import { unsealData } from 'iron-session'; import { NextRequest } from 'next/server'; +import * as v from 'valibot'; import { getPKCECookieOptions } from './cookie.js'; -import { WORKOS_CLIENT_ID } from './env-variables.js'; +import { WORKOS_CLIENT_ID, WORKOS_COOKIE_PASSWORD } from './env-variables.js'; import { CallbackError } from './errors.js'; +import { getAuthorizationUrl } from './get-authorization-url.js'; import { HandleAuthOptions } from './interfaces.js'; -import { PKCE_COOKIE_NAME, getPKCECookieNameForState, getStateFromPKCECookieValue } from './pkce.js'; +import { + PKCE_COOKIE_NAME, + appendPKCESetCookieHeader, + getPKCECookieNameForState, + getStateFromPKCECookieValue, + isInitialDocumentRequest, +} from './pkce.js'; import { saveSession } from './session.js'; import { errorResponseWithFallback, redirectWithFallback, setCachePreventionHeaders } from './utils.js'; import { getWorkOS } from './workos.js'; +const AuthStartSchema = v.strictObject({ + purpose: v.literal('authkit-start'), + redirectUri: v.pipe(v.string(), v.url()), + returnPathname: v.string(), + screenHint: v.picklist(['sign-in', 'sign-up']), +}); + function preventCaching(headers: Headers): void { headers.set('Vary', 'Cookie'); setCachePreventionHeaders(headers); @@ -32,6 +48,7 @@ export function handleAuth(options: HandleAuthOptions = {}) { // Gather mandatory information const code = requestUrl.searchParams.get('code'); const state = requestUrl.searchParams.get('state'); + const authStart = requestUrl.searchParams.get('__authkit_start'); // Attribution for thrown errors: which request failed and what it carried, // with param values omitted since `code` is a live credential. @@ -46,6 +63,33 @@ export function handleAuth(options: HandleAuthOptions = {}) { // destroying the 1-use PKCE cookie to prevent replay attacks or stale // cookies affecting future auth attempts. try { + if (code === null && state === null && authStart !== null) { + if (requestUrl.searchParams.getAll('__authkit_start').length !== 1) { + throw new Error('Invalid authentication start request'); + } + const { redirectUri, returnPathname, screenHint } = v.parse( + AuthStartSchema, + await unsealData(authStart, { password: WORKOS_COOKIE_PASSWORD, ttl: 0 }), + ); + const returnUrl = new URL(returnPathname, redirectUri); + if (!returnPathname.startsWith('/') || returnUrl.origin !== new URL(redirectUri).origin) { + throw new Error('Authentication return path must be on the application origin'); + } + + const responseHeaders = new Headers(); + preventCaching(responseHeaders); + if (!isInitialDocumentRequest(request)) { + // Next ignores non-Flight prefetches and uses a full navigation when the user follows the redirect. + responseHeaders.set('Content-Type', 'text/html; charset=utf-8'); + return new Response('', { headers: responseHeaders }); + } + + // Use the configured URI, not Next's normalized request URL. Never reuse routing data as OAuth state. + const { url, sealedState } = await getAuthorizationUrl({ returnPathname, screenHint, redirectUri }); + appendPKCESetCookieHeader(request, responseHeaders, sealedState, redirectUri); + return redirectWithFallback(url, responseHeaders); + } + if (!code || !state) { throw new CallbackError('Missing required auth parameter', 'missing_auth_params', errorContext); } diff --git a/src/pkce.ts b/src/pkce.ts index 52afa3c..0a8cd24 100644 --- a/src/pkce.ts +++ b/src/pkce.ts @@ -63,7 +63,12 @@ export function setPendingPKCERedirectHeaders(headers: Headers, authorizationUrl * AuthKit. Fetch/XHR/RSC/prefetch requests never follow cross-origin redirects * to complete OAuth, so they do not need verifier cookies. */ -export function appendPKCESetCookieHeader(request: NextRequest, headers: Headers, sealedState: string): void { +export function appendPKCESetCookieHeader( + request: NextRequest, + headers: Headers, + sealedState: string, + cookieUrl = request.url, +): void { if (!isInitialDocumentRequest(request)) { return; } @@ -76,7 +81,7 @@ export function appendPKCESetCookieHeader(request: NextRequest, headers: Headers // A small number of concurrent PKCE cookies is normal (multiple tabs each // starting an OAuth flow). Only purge when accumulation risks HTTP 431. if (pkceCookies.length >= MAX_PKCE_COOKIES) { - const expiredOptions = getPKCECookieOptions(request.url, true, true); + const expiredOptions = getPKCECookieOptions(cookieUrl, true, true); for (const { name } of pkceCookies) { if (name !== newCookieName) { headers.append('Set-Cookie', `${name}=; ${expiredOptions}`); @@ -84,7 +89,7 @@ export function appendPKCESetCookieHeader(request: NextRequest, headers: Headers } } - headers.append('Set-Cookie', `${newCookieName}=${sealedState}; ${getPKCECookieOptions(request.url, true)}`); + headers.append('Set-Cookie', `${newCookieName}=${sealedState}; ${getPKCECookieOptions(cookieUrl, true)}`); } export function stripPKCESetCookieHeaders(headers: Headers): void { diff --git a/src/session.spec.ts b/src/session.spec.ts index 6298c8e..12a835b 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -20,9 +20,8 @@ import { SignJWT, jwtVerify } from 'jose'; function setEnvVar(mod: Record, key: string, value: unknown) { Object.defineProperty(mod, key, { value, configurable: true }); } -import { sealData } from 'iron-session'; +import { sealData, unsealData } from 'iron-session'; import { User } from '@workos-inc/node'; -import { getStateFromPKCECookieValue } from './pkce.js'; import { handleAuthkitHeaders } from './middleware-helpers.js'; vi.mock('jose', async () => { @@ -198,12 +197,12 @@ describe('session.ts', () => { await withAuth({ ensureSignedIn: true }); - // The state is now sealed, se we need to unseal it - const redirectUrl = new URL((redirect as unknown as Mock).mock.calls[0][0]); - const sealedState = redirectUrl.searchParams.get('state')!; - const { returnPathname } = await getStateFromPKCECookieValue(sealedState); - - expect(returnPathname).toBe('/protected?test=123'); + const redirectUrl = new URL(vi.mocked(redirect).mock.calls[0][0]); + const routing = await unsealData(redirectUrl.searchParams.get('__authkit_start')!, { + password: process.env.WORKOS_COOKIE_PASSWORD!, + ttl: 0, + }); + expect(routing).toMatchObject({ returnPathname: '/protected?test=123' }); }); }); @@ -735,7 +734,12 @@ describe('session.ts', () => { await withAuth({ ensureSignedIn: true }); expect(redirect).toHaveBeenCalledTimes(1); - expect(redirect).toHaveBeenCalledWith(expect.stringContaining('screen_hint=sign-up')); + const redirectUrl = new URL(vi.mocked(redirect).mock.calls[0][0]); + const routing = await unsealData(redirectUrl.searchParams.get('__authkit_start')!, { + password: process.env.WORKOS_COOKIE_PASSWORD!, + ttl: 0, + }); + expect(routing).toMatchObject({ screenHint: 'sign-up' }); }); }); }); diff --git a/src/session.ts b/src/session.ts index 9cd410f..baa78f1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -18,12 +18,7 @@ import { Session, UserInfo, } from './interfaces.js'; -import { - appendPKCESetCookieHeader, - isInitialDocumentRequest, - setPKCECookie, - setPendingPKCERedirectHeaders, -} from './pkce.js'; +import { appendPKCESetCookieHeader, isInitialDocumentRequest, setPendingPKCERedirectHeaders } from './pkce.js'; import { getWorkOS } from './workos.js'; import type { AuthenticationResponse } from '@workos-inc/node'; @@ -496,11 +491,16 @@ async function redirectToSignIn() { const pathname = new URL(url).pathname; const screenHint = getScreenHint(signUpPaths, pathname); - const returnPathname = getReturnPathname(url); - - const { url: authkitUrl, sealedState } = await getAuthorizationUrl({ returnPathname, screenHint }); - await setPKCECookie(sealedState); - redirect(authkitUrl); + // Server Components cannot write cookies. Start PKCE on a document request to the existing callback instead. + const redirectUri = headersList.get('x-redirect-uri') ?? WORKOS_REDIRECT_URI; + const startUrl = new URL(redirectUri); + const routing = await sealData( + { purpose: 'authkit-start', redirectUri, returnPathname: getReturnPathname(url), screenHint }, + // Routing data is not an OAuth credential; a cached link can always start a fresh flow. + { password: WORKOS_COOKIE_PASSWORD, ttl: 0 }, + ); + startUrl.searchParams.set('__authkit_start', routing); + redirect(startUrl.toString()); } export async function getTokenClaims>( From 9665082932faa332dfc0100c79966804545ff5c9 Mon Sep 17 00:00:00 2001 From: Mitch Fultz Date: Sat, 12 Sep 2026 23:13:47 -0600 Subject: [PATCH 2/2] fix: keep rejected routing data out of error diagnostics --- src/auth-start.spec.ts | 6 +++++- src/authkit-callback-route.ts | 10 ++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/auth-start.spec.ts b/src/auth-start.spec.ts index cae17bd..82114db 100644 --- a/src/auth-start.spec.ts +++ b/src/auth-start.spec.ts @@ -176,10 +176,14 @@ describe('page authentication start', () => { const substitutedState = new URL(startUrl); substitutedState.searchParams.set('__authkit_start', flowA.state); - const rejectedStart = await handler(new NextRequest(substitutedState, { headers: documentHeaders })); + const onError = vi.fn(() => new Response(null, { status: 500 })); + const rejectedStart = await handleAuth({ onError })( + new NextRequest(substitutedState, { headers: documentHeaders }), + ); expect(rejectedStart.status).toBe(500); expect(rejectedStart.headers.getSetCookie()).toEqual([]); expect(exchange).not.toHaveBeenCalled(); + expect(JSON.stringify(onError.mock.calls[0])).not.toContain(flowA.data.codeVerifier); }); it.each([ diff --git a/src/authkit-callback-route.ts b/src/authkit-callback-route.ts index 075fda3..887b565 100644 --- a/src/authkit-callback-route.ts +++ b/src/authkit-callback-route.ts @@ -64,13 +64,15 @@ export function handleAuth(options: HandleAuthOptions = {}) { // cookies affecting future auth attempts. try { if (code === null && state === null && authStart !== null) { - if (requestUrl.searchParams.getAll('__authkit_start').length !== 1) { - throw new Error('Invalid authentication start request'); - } - const { redirectUri, returnPathname, screenHint } = v.parse( + const routing = v.safeParse( AuthStartSchema, await unsealData(authStart, { password: WORKOS_COOKIE_PASSWORD, ttl: 0 }), ); + // Schema errors include their input. Do not expose decrypted data through logs or onError. + if (requestUrl.searchParams.getAll('__authkit_start').length !== 1 || !routing.success) { + throw new Error('Invalid authentication start request'); + } + const { redirectUri, returnPathname, screenHint } = routing.output; const returnUrl = new URL(returnPathname, redirectUri); if (!returnPathname.startsWith('/') || returnUrl.origin !== new URL(redirectUri).origin) { throw new Error('Authentication return path must be on the application origin');