Skip to content
Closed
Show file tree
Hide file tree
Changes from 17 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
3 changes: 3 additions & 0 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 @@ -34,6 +36,7 @@ const QueryParameters = {
HandshakeHelp: '__clerk_help',
LegacyDevBrowser: '__dev_session',
HandshakeReason: '__clerk_hs_reason',
HandshakeFormat: Cookies.HandshakeFormat,
HandshakeNonce: Cookies.HandshakeNonce,
} as const;

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 @@ -428,6 +428,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') ||
undefined;
}

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
8 changes: 7 additions & 1 deletion 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, SUPPORTS_HANDSHAKE_NONCE } from '../constants';
import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from '../errors';
import type { VerifyJwtOptions } from '../jwt';
import { assertHeaderAlgorithm, assertHeaderType } from '../jwt/assertions';
Expand Down Expand Up @@ -149,6 +149,12 @@ 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
* 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
52 changes: 50 additions & 2 deletions packages/backend/src/tokens/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,54 @@ 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.
*
* @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};`,
);
for (const [key, value] of result.headers.entries()) {
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 @@ -749,7 +797,7 @@ export const authenticateRequest: AuthenticateRequest = (async (
return authenticateAnyRequestWithTokenInHeader();
}
if (acceptsToken === TokenType.SessionToken) {
return authenticateRequestWithTokenInHeader();
return mergeHeaders(await authenticateRequestWithTokenInHeader());
}
return authenticateMachineRequestWithTokenInHeader();
}
Expand All @@ -767,7 +815,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
88 changes: 88 additions & 0 deletions packages/backend/src/util/__tests__/handshakeUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { constants, SUPPORTED_HANDSHAKE_FORMAT } from '../../constants';
import type { AuthenticateContext } from '../../tokens/authenticateContext';
import {
appendHandshakeFormatToOAuthCallback,
createHandshakeFormatHeaders,
getHandshakeFormatCookie,
} from '../handshakeUtils';

describe('handshakeUtils', () => {
const mockAuthenticateContextWithNonce = {
canHandleNonceHandshake: () => true,
getHandshakeFormat: () => 'nonce' as const,
} as AuthenticateContext;

const mockAuthenticateContextWithToken = {
canHandleNonceHandshake: () => false,
getHandshakeFormat: () => 'token' as const,
} as AuthenticateContext;

describe('appendHandshakeFormatToOAuthCallback', () => {
it('should append handshake format parameter when nonce is supported', () => {
const originalUrl = 'https://example.com/oauth/callback';
const modifiedUrl = appendHandshakeFormatToOAuthCallback(originalUrl, mockAuthenticateContextWithNonce);

const url = new URL(modifiedUrl);
expect(url.searchParams.get(constants.QueryParameters.HandshakeFormat)).toBe(SUPPORTED_HANDSHAKE_FORMAT);
});

it('should not append handshake format parameter when token is used', () => {
const originalUrl = 'https://example.com/oauth/callback';
const modifiedUrl = appendHandshakeFormatToOAuthCallback(originalUrl, mockAuthenticateContextWithToken);

const url = new URL(modifiedUrl);
expect(url.searchParams.get(constants.QueryParameters.HandshakeFormat)).toBeNull();
});

it('should preserve existing query parameters', () => {
const originalUrl = 'https://example.com/oauth/callback?existing=param&other=value';
const modifiedUrl = appendHandshakeFormatToOAuthCallback(originalUrl, mockAuthenticateContextWithNonce);

const url = new URL(modifiedUrl);
expect(url.searchParams.get('existing')).toBe('param');
expect(url.searchParams.get('other')).toBe('value');
expect(url.searchParams.get(constants.QueryParameters.HandshakeFormat)).toBe(SUPPORTED_HANDSHAKE_FORMAT);
});
});

describe('getHandshakeFormatCookie', () => {
it('should return cookie string when nonce is supported', () => {
const cookie = getHandshakeFormatCookie(mockAuthenticateContextWithNonce);

expect(cookie).toBe(
`${constants.Cookies.HandshakeFormat}=${SUPPORTED_HANDSHAKE_FORMAT}; Path=/; SameSite=None; Secure`,
);
});

it('should return null when token is used', () => {
const cookie = getHandshakeFormatCookie(mockAuthenticateContextWithToken);

expect(cookie).toBeNull();
});
});

describe('createHandshakeFormatHeaders', () => {
it('should create headers with Set-Cookie when nonce is supported', () => {
const headers = createHandshakeFormatHeaders(mockAuthenticateContextWithNonce);

const setCookieHeader = headers.get('Set-Cookie');
expect(setCookieHeader).toBe(
`${constants.Cookies.HandshakeFormat}=${SUPPORTED_HANDSHAKE_FORMAT}; Path=/; SameSite=None; Secure`,
);
});

it('should create empty headers when token is used', () => {
const headers = createHandshakeFormatHeaders(mockAuthenticateContextWithToken);

const setCookieHeader = headers.get('Set-Cookie');
expect(setCookieHeader).toBeNull();
});

it('should return Headers instance', () => {
const headers = createHandshakeFormatHeaders(mockAuthenticateContextWithNonce);

expect(headers).toBeInstanceOf(Headers);
});
});
});
51 changes: 51 additions & 0 deletions packages/backend/src/util/handshakeUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { constants, SUPPORTED_HANDSHAKE_FORMAT, SUPPORTS_HANDSHAKE_NONCE } from '../constants';
import type { AuthenticateContext } from '../tokens/authenticateContext';

/**
* Appends handshake format query parameter to an OAuth callback URL
* so that FAPI knows the backend can handle nonce-based handshakes
*
* @param url - The OAuth callback URL to modify
* @param authenticateContext - The authentication context containing handshake format info
* @returns The modified URL with handshake format parameter
*/
export function appendHandshakeFormatToOAuthCallback(url: string, authenticateContext: AuthenticateContext): string {
const callbackUrl = new URL(url);

// If the backend can handle nonce handshakes, add the format parameter
if (authenticateContext.canHandleNonceHandshake()) {
callbackUrl.searchParams.set(constants.QueryParameters.HandshakeFormat, SUPPORTED_HANDSHAKE_FORMAT);
}

return callbackUrl.toString();
}

/**
* Gets the handshake format cookie value that indicates nonce capability
*
* @param authenticateContext - The authentication context
* @returns Cookie string if nonce handshakes are supported, null otherwise
*/
export function getHandshakeFormatCookie(authenticateContext: AuthenticateContext): string | null {
if (authenticateContext.canHandleNonceHandshake()) {
return `${constants.Cookies.HandshakeFormat}=${SUPPORTED_HANDSHAKE_FORMAT}; Path=/; SameSite=None; Secure`;
}
return null;
}

/**
* Creates headers with handshake format cookie for OAuth flows
*
* @param authenticateContext - The authentication context
* @returns Headers object with Set-Cookie header if nonce is supported
*/
export function createHandshakeFormatHeaders(authenticateContext: AuthenticateContext): Headers {
const headers = new Headers();
const cookie = getHandshakeFormatCookie(authenticateContext);

if (cookie) {
headers.append('Set-Cookie', cookie);
}

return headers;
}
3 changes: 3 additions & 0 deletions packages/types/src/factors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
5 changes: 5 additions & 0 deletions packages/types/src/redirects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ export type AuthenticateWithRedirectParams = {
*/
legalAccepted?: boolean;

/**
* Whether to use handshake nonce or handshake token
*/
handshakeFormat?: 'nonce' | 'token';

/**
* Optional for `oauth_<provider>` 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.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/signIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ export type SignInCreateParams = (
identifier?: string;
oidcPrompt?: string;
oidcLoginHint?: string;
handshakeFormat?: 'nonce' | 'token';
}
| {
strategy: TicketStrategy;
Expand Down
Loading