Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sdk-70-backend-bootstrap-state.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/sdk-70-middleware-refactor.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/backend/src/__tests__/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ describe('subpath /internal exports', () => {
"authenticatedMachineObject",
"constants",
"createAuthenticateRequest",
"createBootstrapSignedOutState",
"createClerkRequest",
"createRedirect",
"debugRequestState",
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export {
getAuthObjectForAcceptedToken,
} from './tokens/authObjects';

export { AuthStatus } from './tokens/authStatus';
export { AuthStatus, createBootstrapSignedOutState } from './tokens/authStatus';
export type {
RequestState,
SignedInState,
Expand Down
55 changes: 55 additions & 0 deletions packages/backend/src/tokens/authStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,61 @@ export function signedOutInvalidToken(): UnauthenticatedState<null> {
});
}

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<SessionTokenType> {
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 = <T extends { headers: Headers; message?: string; reason?: AuthReason; status?: AuthStatus }>(
requestState: T,
): T => {
Expand Down
261 changes: 155 additions & 106 deletions packages/nextjs/src/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AuthObject, ClerkClient } from '@clerk/backend';
import type { AccountlessApplication, AuthObject, ClerkClient } from '@clerk/backend';
import type {
AuthenticatedState,
AuthenticateRequestOptions,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, string> = {};
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) => {
Expand Down Expand Up @@ -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<Logger>;
};

/**
* 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<Response> {
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<string, string> = {};
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';

Expand Down
Loading