diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index 08e90a3164f..77653125d51 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -4,6 +4,7 @@ export const API_VERSION = 'v1'; export const USER_AGENT = `${PACKAGE_NAME}@${PACKAGE_VERSION}`; export const MAX_CACHE_LAST_UPDATED_AT_SECONDS = 5 * 60; export const SUPPORTED_BAPI_VERSION = '2025-04-10'; +export const SUPPORTED_HANDSHAKE_FORMAT = 'nonce'; const Attributes = { AuthToken: '__clerkAuthToken', @@ -21,6 +22,7 @@ const Cookies = { Handshake: '__clerk_handshake', DevBrowser: '__clerk_db_jwt', RedirectCount: '__clerk_redirect_count', + HandshakeFormat: '__clerk_handshake_format', HandshakeNonce: '__clerk_handshake_nonce', } as const; @@ -34,6 +36,7 @@ const QueryParameters = { HandshakeHelp: '__clerk_help', LegacyDevBrowser: '__dev_session', HandshakeReason: '__clerk_hs_reason', + HandshakeFormat: Cookies.HandshakeFormat, HandshakeNonce: Cookies.HandshakeNonce, } as const; diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index db3765be5de..4af206e1697 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -25,6 +25,7 @@ interface AuthenticateContext extends AuthenticateRequestOptions { clientUat: number; // handshake-related values devBrowserToken: string | undefined; + handshakeFormat: 'nonce' | 'token' | undefined; handshakeNonce: string | undefined; handshakeToken: string | undefined; handshakeRedirectLoopCounter: number; diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts index 417bab8c999..0263c93bf62 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -1,4 +1,4 @@ -import { constants, SUPPORTED_BAPI_VERSION } from '../constants'; +import { constants, SUPPORTED_BAPI_VERSION, SUPPORTED_HANDSHAKE_FORMAT } from '../constants'; import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from '../errors'; import type { VerifyJwtOptions } from '../jwt'; import { assertHeaderAlgorithm, assertHeaderType } from '../jwt/assertions'; @@ -145,6 +145,11 @@ export class HandshakeService { this.authenticateContext.usesSuffixedCookies().toString(), ); url.searchParams.append(constants.QueryParameters.HandshakeReason, reason); + /** + * Appends the supported handshake format parameter to the URL + * This parameter indicates the format of the handshake response that the client expects + */ + url.searchParams.append(constants.QueryParameters.HandshakeFormat, SUPPORTED_HANDSHAKE_FORMAT); if (this.authenticateContext.instanceType === 'development' && this.authenticateContext.devBrowserToken) { url.searchParams.append(constants.QueryParameters.DevBrowser, this.authenticateContext.devBrowserToken); diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 2330a6fa32e..b7887a30efa 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -76,6 +76,26 @@ export async function authenticateRequest( const authenticateContext = await createAuthenticateContext(createClerkRequest(request), options); assertValidSecretKey(authenticateContext.secretKey); + /** + * Merges headers from the RequestState with a default handshake cookie. + * Creates a new Headers object with a nonce cookie and adds all headers from the result. + * + * @param result - The RequestState containing headers to merge + * @returns The RequestState with merged headers + */ + function mergeHeaders(result: RequestState): RequestState { + const headers = new Headers(); + headers.append( + 'Set-Cookie', + `${constants.Cookies.HandshakeFormat}=nonce; Path=/; SameSite=None; Secure; Domain=${authenticateContext.frontendApi.replace(/^clerk\./, '') || ''};`, + ); + for (const [key, value] of result.headers.entries()) { + headers.append(key, value); + } + result.headers = headers; + return result; + } + if (authenticateContext.isSatellite) { assertSignInUrlExists(authenticateContext.signInUrl, authenticateContext.secretKey); if (authenticateContext.signInUrl && authenticateContext.origin) { @@ -533,10 +553,10 @@ export async function authenticateRequest( } if (authenticateContext.sessionTokenInHeader) { - return authenticateRequestWithTokenInHeader(); + return mergeHeaders(await authenticateRequestWithTokenInHeader()); } - return authenticateRequestWithTokenInCookie(); + return mergeHeaders(await authenticateRequestWithTokenInCookie()); } /** diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index ad282609dc6..5cbb87844d3 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -45,6 +45,14 @@ export type AuthenticateRequestOptions = { * If the activation can't be performed, either because an organization doesn't exist or the user lacks access, the active organization in the session won't be changed. Ultimately, it's the responsibility of the page to verify that the resources are appropriate to render given the URL and handle mismatches appropriately (e.g., by returning a 404). */ organizationSyncOptions?: OrganizationSyncOptions; + /** + * Specifies the handshake format to be used during OAuth authentication flows. + * When set to 'nonce', the backend signals to the frontend that it can handle nonce-based handshakes + * during OAuth flow resolution. + * + * @default 'token' + */ + handshakeFormat?: 'nonce' | 'token'; /** * @internal */ diff --git a/packages/types/src/factors.ts b/packages/types/src/factors.ts index 7505beac359..bb88ce046e6 100644 --- a/packages/types/src/factors.ts +++ b/packages/types/src/factors.ts @@ -106,16 +106,19 @@ export type OAuthConfig = OauthFactor & { actionCompleteRedirectUrl: string; oidcPrompt?: string; oidcLoginHint?: string; + handshakeFormat?: 'nonce' | 'token'; }; export type SamlConfig = SamlFactor & { redirectUrl: string; actionCompleteRedirectUrl: string; + handshakeFormat?: 'nonce' | 'token'; }; export type EnterpriseSSOConfig = EnterpriseSSOFactor & { redirectUrl: string; actionCompleteRedirectUrl: string; + handshakeFormat?: 'nonce' | 'token'; oidcPrompt?: string; }; diff --git a/packages/types/src/redirects.ts b/packages/types/src/redirects.ts index a960e7e40fd..ac1853f67d9 100644 --- a/packages/types/src/redirects.ts +++ b/packages/types/src/redirects.ts @@ -82,6 +82,11 @@ export type AuthenticateWithRedirectParams = { */ legalAccepted?: boolean; + /** + * Whether to use handshake nonce or handshake token + */ + handshakeFormat?: 'nonce' | 'token'; + /** * Optional for `oauth_` or `enterprise_sso` strategies. The value to pass to the [OIDC prompt parameter](https://openid.net/specs/openid-connect-core-1_0.html#:~:text=prompt,reauthentication%20and%20consent.) in the generated OAuth redirect URL. */ diff --git a/packages/types/src/signIn.ts b/packages/types/src/signIn.ts index 0f8ccdbde66..a4ef8d67f22 100644 --- a/packages/types/src/signIn.ts +++ b/packages/types/src/signIn.ts @@ -195,6 +195,7 @@ export type SignInCreateParams = ( identifier?: string; oidcPrompt?: string; oidcLoginHint?: string; + handshakeFormat?: 'nonce' | 'token'; } | { strategy: TicketStrategy;