diff --git a/.changeset/sdk-70-backend-bootstrap-state.md b/.changeset/sdk-70-backend-bootstrap-state.md new file mode 100644 index 00000000000..81b29c95eea --- /dev/null +++ b/.changeset/sdk-70-backend-bootstrap-state.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Add `createBootstrapSignedOutState` helper to `@clerk/backend/internal`. Returns a synthetic `UnauthenticatedState<'session_token'>` without requiring a publishable key or an `AuthenticateContext`. Intended for framework integrations that need to run authorization logic before real Clerk keys are available (e.g. the Next.js keyless bootstrap window). Accepts optional `signInUrl`, `signUpUrl`, `isSatellite`, `domain`, and `proxyUrl` so that `createRedirect`-driven flows (including cross-origin satellite sign-in with the `__clerk_status=needs-sync` handshake marker) behave correctly during bootstrap. diff --git a/.changeset/sdk-70-middleware-refactor.md b/.changeset/sdk-70-middleware-refactor.md new file mode 100644 index 00000000000..802f85d278e --- /dev/null +++ b/.changeset/sdk-70-middleware-refactor.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': patch +--- + +Refactor `clerkMiddleware` internals to factor the post-authentication pipeline (handler invocation, CSP, redirects, response decoration) into a private `runHandlerWithRequestState` helper. Pure refactor — no behavioral change. diff --git a/packages/backend/src/__tests__/exports.test.ts b/packages/backend/src/__tests__/exports.test.ts index ddf83dd5a82..7892bf9f554 100644 --- a/packages/backend/src/__tests__/exports.test.ts +++ b/packages/backend/src/__tests__/exports.test.ts @@ -45,6 +45,7 @@ describe('subpath /internal exports', () => { "authenticatedMachineObject", "constants", "createAuthenticateRequest", + "createBootstrapSignedOutState", "createClerkRequest", "createRedirect", "debugRequestState", diff --git a/packages/backend/src/internal.ts b/packages/backend/src/internal.ts index 020dcab4217..27b44f31f98 100644 --- a/packages/backend/src/internal.ts +++ b/packages/backend/src/internal.ts @@ -38,7 +38,7 @@ export { getAuthObjectForAcceptedToken, } from './tokens/authObjects'; -export { AuthStatus } from './tokens/authStatus'; +export { AuthStatus, createBootstrapSignedOutState } from './tokens/authStatus'; export type { RequestState, SignedInState, diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index 27205ed40b4..421c7bd61f4 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -268,6 +268,61 @@ export function signedOutInvalidToken(): UnauthenticatedState { }); } +type BootstrapSignedOutParams = { + signInUrl?: string; + signUpUrl?: string; + isSatellite?: boolean; + domain?: string; + proxyUrl?: string; + reason?: AuthReason; + message?: string; + headers?: Headers; +}; + +/** + * Returns a synthetic `UnauthenticatedState` without requiring a publishable key or an + * `AuthenticateContext`. Intended for framework integrations that need to run + * authorization logic for a request that arrived before real Clerk keys are available + * (e.g. the Next.js keyless bootstrap window). The returned state has + * `status: 'signed-out'` and `toAuth()` returns a standard signed-out session auth object. + * + * `signInUrl` / `signUpUrl` are carried through so that `redirectToSignIn` / + * `redirectToSignUp` can resolve to the application's own routes during bootstrap. + * `isSatellite` / `domain` / `proxyUrl` are carried through so that cross-origin + * satellite redirects produced by `createRedirect` include the `__clerk_status=needs-sync` + * marker required for the return-trip handshake. + */ +export function createBootstrapSignedOutState({ + signInUrl = '', + signUpUrl = '', + isSatellite = false, + domain = '', + proxyUrl = '', + reason = AuthErrorReason.SessionTokenAndUATMissing, + message = '', + headers = new Headers(), +}: BootstrapSignedOutParams = {}): UnauthenticatedState { + return withDebugHeaders({ + status: AuthStatus.SignedOut, + reason, + message, + proxyUrl, + publishableKey: '', + isSatellite, + domain, + signInUrl, + signUpUrl, + afterSignInUrl: '', + afterSignUpUrl: '', + isSignedIn: false, + isAuthenticated: false, + tokenType: TokenType.SessionToken, + toAuth: () => signedOutAuthObject({ status: AuthStatus.SignedOut, reason, message }), + headers, + token: null, + }); +} + const withDebugHeaders = ( requestState: T, ): T => { diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 93e4f6c04e5..20a69a8c24e 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -1,4 +1,4 @@ -import type { AuthObject, ClerkClient } from '@clerk/backend'; +import type { AccountlessApplication, AuthObject, ClerkClient } from '@clerk/backend'; import type { AuthenticatedState, AuthenticateRequestOptions, @@ -31,6 +31,7 @@ import { NextResponse } from 'next/server'; import type { AuthFn } from '../app-router/server/auth'; import type { GetAuthOptions } from '../server/createGetAuth'; import { isRedirect, serverRedirectWithAuth, setHeader } from '../utils'; +import type { Logger, LoggerNoCommit } from '../utils/debugLogger'; import { withLogger } from '../utils/debugLogger'; import { canUseKeyless } from '../utils/feature-flags'; import { clerkClient } from './clerkClient'; @@ -216,114 +217,17 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl createAuthenticateRequestOptions(clerkRequest, options), ); - logger.debug('requestState', () => ({ - status: requestState.status, - headers: JSON.stringify(Object.fromEntries(requestState.headers)), - reason: requestState.reason, - })); - - const locationHeader = requestState.headers.get(constants.Headers.Location); - if (locationHeader) { - handleNetlifyCacheInDevInstance({ - locationHeader, - requestStateHeaders: requestState.headers, - publishableKey: requestState.publishableKey, - }); - - const res = NextResponse.redirect(requestState.headers.get(constants.Headers.Location) || locationHeader); - requestState.headers.forEach((value, key) => { - if (key === constants.Headers.Location) { - return; - } - res.headers.append(key, value); - }); - return res; - } else if (requestState.status === AuthStatus.Handshake) { - throw new Error('Clerk: handshake status without redirect'); - } - - const authObject = requestState.toAuth(); - logger.debug('auth', () => ({ auth: authObject, debug: authObject.debug() })); - - const redirectToSignIn = createMiddlewareRedirectToSignIn(clerkRequest); - const redirectToSignUp = createMiddlewareRedirectToSignUp(clerkRequest); - const protect = await createMiddlewareProtect(clerkRequest, authObject, redirectToSignIn); - - const authHandler = createMiddlewareAuthHandler(requestState, redirectToSignIn, redirectToSignUp); - authHandler.protect = protect; - - let handlerResult: Response = NextResponse.next(); - try { - const userHandlerResult = await clerkMiddlewareRequestDataStorage.run( - clerkMiddlewareRequestDataStore, - async () => handler?.(authHandler, request, event), - ); - handlerResult = userHandlerResult || handlerResult; - } catch (e: any) { - handlerResult = handleControlFlowErrors(e, clerkRequest, request, requestState); - } - if (options.contentSecurityPolicy) { - const { headers } = createContentSecurityPolicyHeaders( - (parsePublishableKey(publishableKey)?.frontendApi ?? '').replace('$', ''), - options.contentSecurityPolicy, - ); - - const cspRequestHeaders: Record = {}; - headers.forEach(([key, value]) => { - setHeader(handlerResult, key, value); - cspRequestHeaders[key] = value; - }); - - // Forward CSP headers as request headers so server components - // can access the nonce via headers() - setRequestHeadersOnNextResponse(handlerResult, clerkRequest, cspRequestHeaders); - - logger.debug('Clerk generated CSP', () => ({ - headers, - })); - } - - // TODO @nikos: we need to make this more generic - // and move the logic in clerk/backend - if (requestState.headers) { - requestState.headers.forEach((value, key) => { - if (key === constants.Headers.ContentSecurityPolicy) { - logger.debug('Content-Security-Policy detected', () => ({ - value, - })); - } - handlerResult.headers.append(key, value); - }); - } - - if (isRedirect(handlerResult)) { - logger.debug('handlerResult is redirect'); - return serverRedirectWithAuth(clerkRequest, handlerResult, options); - } - - if (options.debug) { - setRequestHeadersOnNextResponse(handlerResult, clerkRequest, { [constants.Headers.EnableDebug]: 'true' }); - } - - const keylessKeysForRequestData = - // Only pass keyless credentials when there are no explicit keys - secretKey === keyless?.secretKey - ? { - publishableKey: keyless?.publishableKey, - secretKey: keyless?.secretKey, - } - : {}; - - decorateRequest( + return runHandlerWithRequestState({ clerkRequest, - handlerResult, + request, + event, requestState, + handler, + options, resolvedParams, - keylessKeysForRequestData, - authObject.tokenType === 'session_token' ? null : makeAuthObjectSerializable(authObject), - ); - - return handlerResult; + keyless, + logger, + }); }); const keylessMiddleware: NextMiddleware = async (request, event) => { @@ -390,6 +294,151 @@ const parseHandlerAndOptions = (args: unknown[]) => { ] as [ClerkMiddlewareHandler | undefined, ClerkMiddlewareOptions | ClerkMiddlewareOptionsCallback]; }; +type RunHandlerWithRequestStateArgs = { + clerkRequest: ClerkRequest; + request: NextMiddlewareRequestParam; + event: NextMiddlewareEvtParam; + requestState: RequestState<'session_token'>; + handler: ClerkMiddlewareHandler | undefined; + options: ClerkMiddlewareOptions & { + publishableKey: string; + secretKey: string; + signInUrl: string; + signUpUrl: string; + }; + resolvedParams: ClerkMiddlewareOptions; + keyless: AccountlessApplication | undefined; + logger: LoggerNoCommit; +}; + +/** + * Drives the post-authentication pipeline: handler invocation, CSP, redirects, header propagation, + * and response decoration. Accepts a pre-computed `requestState` so callers can supply either a + * real authentication result from `authenticateRequest()` or a synthetic signed-out state + * (e.g. during keyless bootstrap when no publishable key is available yet). + */ +async function runHandlerWithRequestState({ + clerkRequest, + request, + event, + requestState, + handler, + options, + resolvedParams, + keyless, + logger, +}: RunHandlerWithRequestStateArgs): Promise { + const { publishableKey, secretKey } = options; + + logger.debug('requestState', () => ({ + status: requestState.status, + headers: JSON.stringify(Object.fromEntries(requestState.headers)), + reason: requestState.reason, + })); + + const locationHeader = requestState.headers.get(constants.Headers.Location); + if (locationHeader) { + handleNetlifyCacheInDevInstance({ + locationHeader, + requestStateHeaders: requestState.headers, + publishableKey: requestState.publishableKey, + }); + + const res = NextResponse.redirect(requestState.headers.get(constants.Headers.Location) || locationHeader); + requestState.headers.forEach((value, key) => { + if (key === constants.Headers.Location) { + return; + } + res.headers.append(key, value); + }); + return res; + } else if (requestState.status === AuthStatus.Handshake) { + throw new Error('Clerk: handshake status without redirect'); + } + + const authObject = requestState.toAuth(); + logger.debug('auth', () => ({ auth: authObject, debug: authObject.debug() })); + + const redirectToSignIn = createMiddlewareRedirectToSignIn(clerkRequest); + const redirectToSignUp = createMiddlewareRedirectToSignUp(clerkRequest); + const protect = await createMiddlewareProtect(clerkRequest, authObject, redirectToSignIn); + + const authHandler = createMiddlewareAuthHandler(requestState, redirectToSignIn, redirectToSignUp); + authHandler.protect = protect; + + let handlerResult: Response = NextResponse.next(); + try { + const userHandlerResult = await clerkMiddlewareRequestDataStorage.run(clerkMiddlewareRequestDataStore, async () => + handler?.(authHandler, request, event), + ); + handlerResult = userHandlerResult || handlerResult; + } catch (e: any) { + handlerResult = handleControlFlowErrors(e, clerkRequest, request, requestState); + } + if (options.contentSecurityPolicy) { + const { headers } = createContentSecurityPolicyHeaders( + (parsePublishableKey(publishableKey)?.frontendApi ?? '').replace('$', ''), + options.contentSecurityPolicy, + ); + + const cspRequestHeaders: Record = {}; + headers.forEach(([key, value]) => { + setHeader(handlerResult, key, value); + cspRequestHeaders[key] = value; + }); + + // Forward CSP headers as request headers so server components + // can access the nonce via headers() + setRequestHeadersOnNextResponse(handlerResult, clerkRequest, cspRequestHeaders); + + logger.debug('Clerk generated CSP', () => ({ + headers, + })); + } + + // TODO @nikos: we need to make this more generic + // and move the logic in clerk/backend + if (requestState.headers) { + requestState.headers.forEach((value, key) => { + if (key === constants.Headers.ContentSecurityPolicy) { + logger.debug('Content-Security-Policy detected', () => ({ + value, + })); + } + handlerResult.headers.append(key, value); + }); + } + + if (isRedirect(handlerResult)) { + logger.debug('handlerResult is redirect'); + return serverRedirectWithAuth(clerkRequest, handlerResult, options); + } + + if (options.debug) { + setRequestHeadersOnNextResponse(handlerResult, clerkRequest, { [constants.Headers.EnableDebug]: 'true' }); + } + + const keylessKeysForRequestData = + // Only pass keyless credentials when there are no explicit keys + secretKey === keyless?.secretKey + ? { + publishableKey: keyless?.publishableKey, + secretKey: keyless?.secretKey, + } + : {}; + + decorateRequest( + clerkRequest, + handlerResult, + requestState, + resolvedParams, + keylessKeysForRequestData, + authObject.tokenType === 'session_token' ? null : makeAuthObjectSerializable(authObject), + ); + + return handlerResult; +} + const isKeylessSyncRequest = (request: NextMiddlewareRequestParam) => request.nextUrl.pathname === '/clerk-sync-keyless';