Skip to content

feat: signal handshake nonce support for all flows #5908

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 28 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c8379a0
feat: oauth hadnshake nonce support
jacekradko May 14, 2025
6df50f4
Merge branch 'main' into feat/signal-handshake-nonce-support-oauth
jacekradko May 14, 2025
583f41c
Merge branch 'main' into feat/signal-handshake-nonce-support-oauth
jacekradko May 15, 2025
f3517a1
Merge branch 'main' into feat/signal-handshake-nonce-support-oauth
jacekradko May 15, 2025
5e80be1
add comment
jacekradko May 15, 2025
1946bc8
format
jacekradko May 15, 2025
2608c89
set cookie on fapi domain
jacekradko May 15, 2025
0d7aae9
wip
jacekradko May 15, 2025
e37ecd7
remove clerk.
jacekradko May 15, 2025
09ee066
wip
jacekradko May 15, 2025
07d25d4
Merge branch 'main' into feat/signal-handshake-nonce-support-oauth
jacekradko Jun 25, 2025
06fbe9d
wip
jacekradko Jun 25, 2025
0723b0e
fix build
jacekradko Jun 25, 2025
f51b719
wip
jacekradko Jun 25, 2025
88a4e31
wip
jacekradko Jun 25, 2025
7615018
Merge branch 'main' into feat/signal-handshake-nonce-support-oauth
jacekradko Jun 25, 2025
3764d2e
wip
jacekradko Jun 25, 2025
d48ea78
wip
jacekradko Jun 25, 2025
335245c
wip
jacekradko Jun 25, 2025
962c95a
wip
jacekradko Jun 25, 2025
eb4c138
wip
jacekradko Jun 25, 2025
7249bb9
wip
jacekradko Jun 25, 2025
08e0e8e
wip
jacekradko Jun 26, 2025
4ca3450
wip
jacekradko Jun 26, 2025
e533c8f
wip
jacekradko Jun 26, 2025
fd5207d
Merge branch 'main' into feat/signal-handshake-nonce-support-oauth
jacekradko Jun 26, 2025
280a5cb
wip
jacekradko Jun 26, 2025
864809c
Merge branch 'main' into feat/signal-handshake-nonce-support-oauth
jacekradko Jun 26, 2025
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
6 changes: 4 additions & 2 deletions packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;

Expand All @@ -33,9 +35,9 @@ const QueryParameters = {
Handshake: Cookies.Handshake,
HandshakeHelp: '__clerk_help',
LegacyDevBrowser: '__dev_session',
HandshakeReason: '__clerk_hs_reason',
HandshakeNonce: Cookies.HandshakeNonce,
HandshakeFormat: 'format',
HandshakeNonce: Cookies.HandshakeNonce,
HandshakeReason: '__clerk_hs_reason',
} as const;

const Headers = {
Expand Down
36 changes: 36 additions & 0 deletions packages/backend/src/tokens/__tests__/handshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,42 @@ describe('HandshakeService', () => {
expect(url.searchParams.get('__clerk_api_version')).toBe('2025-04-10');
expect(url.searchParams.get(constants.QueryParameters.SuffixedCookies)).toMatch(/^(true|false)$/);
expect(url.searchParams.get(constants.QueryParameters.HandshakeReason)).toBe('test-reason');
expect(url.searchParams.get(constants.QueryParameters.HandshakeFormat)).toBe('nonce');
});

it('should include handshake format parameter', () => {
const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

// Verify the handshake format parameter is present
expect(url.searchParams.get(constants.QueryParameters.HandshakeFormat)).toBe('nonce');
});

it('should include handshake format parameter in development mode', () => {
const developmentContext = {
...mockAuthenticateContext,
instanceType: 'development',
devBrowserToken: 'dev-browser-token',
} as AuthenticateContext;

const developmentHandshakeService = new HandshakeService(
developmentContext,
mockOptions,
mockOrganizationMatcher,
);
const headers = developmentHandshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);

if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

expect(url.searchParams.get(constants.QueryParameters.HandshakeFormat)).toBe('nonce');
});
});

Expand Down
22 changes: 22 additions & 0 deletions packages/backend/src/tokens/authenticateContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface AuthenticateContext extends AuthenticateRequestOptions {

// handshake-related values
devBrowserToken: string | undefined;
handshakeFormat: 'nonce' | 'token' | undefined;
handshakeNonce: string | undefined;
handshakeRedirectLoopCounter: number;
handshakeToken: string | undefined;
Expand Down Expand Up @@ -218,6 +219,10 @@ class AuthenticateContext implements AuthenticateContext {
this.handshakeRedirectLoopCounter = Number(this.getCookie(constants.Cookies.RedirectCount)) || 0;
this.handshakeNonce =
this.getQueryParam(constants.QueryParameters.HandshakeNonce) || this.getCookie(constants.Cookies.HandshakeNonce);
this.handshakeFormat =
(this.getQueryParam(constants.QueryParameters.HandshakeFormat) as 'nonce' | 'token') ||
(this.getCookie(constants.Cookies.HandshakeFormat) as 'nonce' | 'token') ||
'nonce';
}

private getQueryParam(name: string) {
Expand Down Expand Up @@ -288,6 +293,23 @@ class AuthenticateContext implements AuthenticateContext {
private sessionExpired(jwt: Jwt | undefined): boolean {
return !!jwt && jwt?.payload.exp <= (Date.now() / 1000) >> 0;
}

/**
* Checks if the current context can handle nonce-based handshakes
* by reading the handshake format from cookies or query parameters
* @returns true if nonce handshakes are supported, false otherwise
*/
public canHandleNonceHandshake(): boolean {
return this.handshakeFormat === 'nonce';
}

/**
* Gets the handshake format from the request context, defaulting to 'token' if not specified
* @returns The handshake format ('nonce' or 'token')
*/
public getHandshakeFormat(): 'nonce' | 'token' {
return this.handshakeFormat || 'token';
}
}

export type { AuthenticateContext };
Expand Down
9 changes: 7 additions & 2 deletions packages/backend/src/tokens/handshake.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -149,7 +149,12 @@ export class HandshakeService {
this.authenticateContext.usesSuffixedCookies().toString(),
);
url.searchParams.append(constants.QueryParameters.HandshakeReason, reason);
url.searchParams.append(constants.QueryParameters.HandshakeFormat, 'nonce');
/**
* Appends the supported handshake format parameter to the URL
* This parameter indicates the format of the handshake response that the client expects
* and implicitly signals that this backend version supports nonce handshakes
*/
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);
Expand Down
76 changes: 72 additions & 4 deletions packages/backend/src/tokens/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { constants } from '../constants';
import type { TokenCarrier } from '../errors';
import { MachineTokenVerificationError, TokenVerificationError, TokenVerificationErrorReason } from '../errors';
import { decodeJwt } from '../jwt/verifyJwt';
import { enhanceOAuthRedirectUrl } from '../util/handshakeUtils';
import { assertValidSecretKey } from '../util/optionsAssertions';
import { isDevelopmentFromSecretKey } from '../util/shared';
import type { AuthenticateContext } from './authenticateContext';
Expand Down Expand Up @@ -141,6 +142,73 @@ export const authenticateRequest: AuthenticateRequest = (async (
const authenticateContext = await createAuthenticateContext(createClerkRequest(request), options);
assertValidSecretKey(authenticateContext.secretKey);

/**
* Merges headers from the RequestState with a handshake format cookie.
* Creates a new Headers object with the configured handshake format and adds all headers from the result.
* Also modifies OAuth callback URLs to include the handshake format parameter.
*
* @param result - The RequestState containing headers to merge
* @returns The RequestState with merged headers
*/
function mergeHeaders(result: RequestState): RequestState {
const headers = new Headers();
const handshakeFormatValue = authenticateContext.handshakeFormat || 'nonce';

let domain = '';
try {
if (authenticateContext.frontendApi) {
const host = authenticateContext.frontendApi.startsWith('http')
? new URL(authenticateContext.frontendApi).hostname
: authenticateContext.frontendApi;

if (host.startsWith('clerk.')) {
domain = host.replace(/^clerk\./, '');
} else if (host.includes('.clerk.')) {
domain = host.split('.clerk.')[1];
} else if (host.includes('.')) {
const parts = host.split('.');
if (parts.length >= 2) {
domain = parts.slice(-2).join('.');
}
}
}

if (!domain) {
domain = authenticateContext.domain || '';
}
} catch {
domain = authenticateContext.domain || '';
}

headers.append(
'Set-Cookie',
`${constants.Cookies.HandshakeFormat}=${handshakeFormatValue}; Path=/; SameSite=None; Secure; Domain=${domain};`,
);

// Check if this is a redirect response that might contain OAuth URLs in the Location header
const locationHeader = result.headers.get(constants.Headers.Location);
if (locationHeader) {
// Enhance OAuth redirect URLs to include the handshake format parameter
const enhancedUrl = enhanceOAuthRedirectUrl(locationHeader, authenticateContext);
if (enhancedUrl !== locationHeader) {
headers.set(constants.Headers.Location, enhancedUrl);
}
}

for (const [key, value] of result.headers.entries()) {
// Don't duplicate the Location header if we already processed it above
if (key.toLowerCase() === 'location' && locationHeader) {
const enhancedUrl = enhanceOAuthRedirectUrl(locationHeader, authenticateContext);
if (enhancedUrl !== locationHeader) {
continue; // Skip since we already set the enhanced header
}
}
headers.append(key, value);
}
result.headers = headers;
return result;
}

// Default tokenType is session_token for backwards compatibility.
const acceptsToken = options.acceptsToken ?? TokenType.SessionToken;

Expand Down Expand Up @@ -746,12 +814,12 @@ export const authenticateRequest: AuthenticateRequest = (async (

if (authenticateContext.tokenInHeader) {
if (acceptsToken === 'any') {
return authenticateAnyRequestWithTokenInHeader();
return await authenticateAnyRequestWithTokenInHeader();
}
if (acceptsToken === TokenType.SessionToken) {
return authenticateRequestWithTokenInHeader();
return mergeHeaders(await authenticateRequestWithTokenInHeader());
}
return authenticateMachineRequestWithTokenInHeader();
return await authenticateMachineRequestWithTokenInHeader();
}

// Machine requests cannot have the token in the cookie, it must be in header.
Expand All @@ -767,7 +835,7 @@ export const authenticateRequest: AuthenticateRequest = (async (
});
}

return authenticateRequestWithTokenInCookie();
return mergeHeaders(await authenticateRequestWithTokenInCookie());
}) as AuthenticateRequest;

/**
Expand Down
8 changes: 8 additions & 0 deletions packages/backend/src/tokens/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,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
*/
Expand Down
Loading
Loading