Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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