From 00368a4ae1cca2cfdcbc5c7d1ddda667ceabad76 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 29 Apr 2025 09:48:05 -0500 Subject: [PATCH 1/5] refactor(backend): Handshake service --- packages/backend/src/tokens/handshake.ts | 238 +++++++++++++++++++++++ packages/backend/src/tokens/request.ts | 218 +-------------------- 2 files changed, 247 insertions(+), 209 deletions(-) diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts index 1ac4b95dc30..f7b00d7ecf9 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -1,9 +1,18 @@ +import type { Match, MatchFunction } from '@clerk/shared/pathToRegexp'; + +import { constants } from '../constants'; import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from '../errors'; import type { VerifyJwtOptions } from '../jwt'; import { assertHeaderAlgorithm, assertHeaderType } from '../jwt/assertions'; import { decodeJwt, hasValidSignature } from '../jwt/verifyJwt'; +import type { AuthenticateContext } from './authenticateContext'; +import type { SignedInState, SignedOutState } from './authStatus'; +import { AuthErrorReason, signedIn, signedOut } from './authStatus'; +import { getCookieName, getCookieValue } from './cookie'; import { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from './keys'; +import type { OrganizationSyncOptions } from './types'; import type { VerifyTokenOptions } from './verify'; +import { verifyToken } from './verify'; async function verifyHandshakeJwt(token: string, { key }: VerifyJwtOptions): Promise<{ handshake: string[] }> { const { data: decoded, errors } = decodeJwt(token); @@ -73,3 +82,232 @@ export async function verifyHandshakeToken( key, }); } + +export type OrganizationSyncTargetMatchers = { + OrganizationMatcher: MatchFunction>> | null; + PersonalAccountMatcher: MatchFunction>> | null; +}; + +export type OrganizationSyncTarget = + | { type: 'personalAccount' } + | { type: 'organization'; organizationId?: string; organizationSlug?: string }; + +export class HandshakeService { + private handshakeRedirectLoopCounter: number; + + constructor() { + this.handshakeRedirectLoopCounter = 0; + } + + isRequestEligibleForHandshake(authenticateContext: { secFetchDest?: string; accept?: string }): boolean { + const { accept, secFetchDest } = authenticateContext; + + if (secFetchDest === 'document' || secFetchDest === 'iframe') { + return true; + } + + if (!secFetchDest && accept?.startsWith('text/html')) { + return true; + } + + return false; + } + + buildRedirectToHandshake( + authenticateContext: AuthenticateContext, + organizationSyncTargetMatchers: OrganizationSyncTargetMatchers, + options: { organizationSyncOptions?: OrganizationSyncOptions }, + reason: string, + ): Headers { + const redirectUrl = this.removeDevBrowserFromURL(authenticateContext.clerkUrl); + const frontendApiNoProtocol = authenticateContext.frontendApi.replace(/http(s)?:\/\//, ''); + + const url = new URL(`https://${frontendApiNoProtocol}/v1/client/handshake`); + url.searchParams.append('redirect_url', redirectUrl?.href || ''); + url.searchParams.append( + constants.QueryParameters.SuffixedCookies, + authenticateContext.usesSuffixedCookies().toString(), + ); + url.searchParams.append(constants.QueryParameters.HandshakeReason, reason); + + if (authenticateContext.instanceType === 'development' && authenticateContext.devBrowserToken) { + url.searchParams.append(constants.QueryParameters.DevBrowser, authenticateContext.devBrowserToken); + } + + const toActivate = this.getOrganizationSyncTarget( + authenticateContext.clerkUrl, + options.organizationSyncOptions, + organizationSyncTargetMatchers, + ); + if (toActivate) { + const params = this.getOrganizationSyncQueryParams(toActivate); + params.forEach((value, key) => { + url.searchParams.append(key, value); + }); + } + + return new Headers({ [constants.Headers.Location]: url.href }); + } + + async resolveHandshake(authenticateContext: AuthenticateContext): Promise { + const headers = new Headers({ + 'Access-Control-Allow-Origin': 'null', + 'Access-Control-Allow-Credentials': 'true', + }); + + const cookiesToSet: string[] = []; + + if (authenticateContext.handshakeNonce) { + // TODO: implement handshake nonce handling, fetch handshake payload with nonce + } else if (authenticateContext.handshakeToken) { + const handshakePayload = await verifyHandshakeToken(authenticateContext.handshakeToken, authenticateContext); + cookiesToSet.push(...handshakePayload.handshake); + } + + let sessionToken = ''; + cookiesToSet.forEach((x: string) => { + headers.append('Set-Cookie', x); + if (getCookieName(x).startsWith(constants.Cookies.Session)) { + sessionToken = getCookieValue(x); + } + }); + + if (authenticateContext.instanceType === 'development') { + const newUrl = new URL(authenticateContext.clerkUrl); + newUrl.searchParams.delete(constants.QueryParameters.Handshake); + newUrl.searchParams.delete(constants.QueryParameters.HandshakeHelp); + headers.append(constants.Headers.Location, newUrl.toString()); + headers.set(constants.Headers.CacheControl, 'no-store'); + } + + if (sessionToken === '') { + return signedOut(authenticateContext, AuthErrorReason.SessionTokenMissing, '', headers); + } + + const { data, errors: [error] = [] } = await verifyToken(sessionToken, authenticateContext); + if (data) { + return signedIn(authenticateContext, data, headers, sessionToken); + } + + if ( + authenticateContext.instanceType === 'development' && + (error?.reason === TokenVerificationErrorReason.TokenExpired || + error?.reason === TokenVerificationErrorReason.TokenNotActiveYet || + error?.reason === TokenVerificationErrorReason.TokenIatInTheFuture) + ) { + error.tokenCarrier = 'cookie'; + console.error( + `Clerk: Clock skew detected. This usually means that your system clock is inaccurate. Clerk will attempt to account for the clock skew in development. + +To resolve this issue, make sure your system's clock is set to the correct time (e.g. turn off and on automatic time synchronization). + +--- + +${error.getFullMessage()}`, + ); + + const { data: retryResult, errors: [retryError] = [] } = await verifyToken(sessionToken, { + ...authenticateContext, + clockSkewInMs: 86_400_000, + }); + if (retryResult) { + return signedIn(authenticateContext, retryResult, headers, sessionToken); + } + + throw new Error(retryError?.message || 'Clerk: Handshake retry failed.'); + } + + throw new Error(error?.message || 'Clerk: Handshake failed.'); + } + + handleHandshakeTokenVerificationErrorInDevelopment(error: TokenVerificationError): void { + if (error.reason === TokenVerificationErrorReason.TokenInvalidSignature) { + const msg = `Clerk: Handshake token verification failed due to an invalid signature. If you have switched Clerk keys locally, clear your cookies and try again.`; + throw new Error(msg); + } + throw new Error(`Clerk: Handshake token verification failed: ${error.getFullMessage()}.`); + } + + setHandshakeInfiniteRedirectionLoopHeaders(headers: Headers): boolean { + if (this.handshakeRedirectLoopCounter === 3) { + return true; + } + + const newCounterValue = this.handshakeRedirectLoopCounter + 1; + const cookieName = constants.Cookies.RedirectCount; + headers.append('Set-Cookie', `${cookieName}=${newCounterValue}; SameSite=Lax; HttpOnly; Max-Age=3`); + return false; + } + + private removeDevBrowserFromURL(url: URL): URL { + const updatedURL = new URL(url); + updatedURL.searchParams.delete(constants.QueryParameters.DevBrowser); + updatedURL.searchParams.delete(constants.QueryParameters.LegacyDevBrowser); + return updatedURL; + } + + private getOrganizationSyncTarget( + url: URL, + options: OrganizationSyncOptions | undefined, + matchers: OrganizationSyncTargetMatchers, + ): OrganizationSyncTarget | null { + if (!options) { + return null; + } + + if (matchers.OrganizationMatcher) { + let orgResult: Match>>; + try { + orgResult = matchers.OrganizationMatcher(url.pathname); + } catch (e) { + console.error(`Clerk: Failed to apply organization pattern "${options.organizationPatterns}" to a path`, e); + return null; + } + + if (orgResult && 'params' in orgResult) { + const params = orgResult.params; + + if ('id' in params && typeof params.id === 'string') { + return { type: 'organization', organizationId: params.id }; + } + if ('slug' in params && typeof params.slug === 'string') { + return { type: 'organization', organizationSlug: params.slug }; + } + console.warn( + 'Clerk: Detected an organization pattern match, but no organization ID or slug was found in the URL. Does the pattern include `:id` or `:slug`?', + ); + } + } + + if (matchers.PersonalAccountMatcher) { + let personalResult: Match>>; + try { + personalResult = matchers.PersonalAccountMatcher(url.pathname); + } catch (e) { + console.error(`Failed to apply personal account pattern "${options.personalAccountPatterns}" to a path`, e); + return null; + } + + if (personalResult) { + return { type: 'personalAccount' }; + } + } + return null; + } + + private getOrganizationSyncQueryParams(toActivate: OrganizationSyncTarget): Map { + const ret = new Map(); + if (toActivate.type === 'personalAccount') { + ret.set('organization_id', ''); + } + if (toActivate.type === 'organization') { + if (toActivate.organizationId) { + ret.set('organization_id', toActivate.organizationId); + } + if (toActivate.organizationSlug) { + ret.set('organization_id', toActivate.organizationSlug); + } + } + return ret; + } +} diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 30bca5bbb1c..39265dfc0c2 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -15,7 +15,7 @@ import type { HandshakeState, RequestState, SignedInState, SignedOutState } from import { AuthErrorReason, handshake, signedIn, signedOut } from './authStatus'; import { createClerkRequest } from './clerkRequest'; import { getCookieName, getCookieValue } from './cookie'; -import { verifyHandshakeToken } from './handshake'; +import { HandshakeService } from './handshake'; import type { AuthenticateRequestOptions, OrganizationSyncOptions } from './types'; import { verifyToken } from './verify'; @@ -58,26 +58,6 @@ function assertSignInUrlFormatAndOrigin(_signInUrl: string, origin: string) { } } -/** - * Currently, a request is only eligible for a handshake if we can say it's *probably* a request for a document, not a fetch or some other exotic request. - * This heuristic should give us a reliable enough signal for browsers that support `Sec-Fetch-Dest` and for those that don't. - */ -function isRequestEligibleForHandshake(authenticateContext: { secFetchDest?: string; accept?: string }) { - const { accept, secFetchDest } = authenticateContext; - - // NOTE: we could also check sec-fetch-mode === navigate here, but according to the spec, sec-fetch-dest: document should indicate that the request is the data of a user navigation. - // Also, we check for 'iframe' because it's the value set when a doc request is made by an iframe. - if (secFetchDest === 'document' || secFetchDest === 'iframe') { - return true; - } - - if (!secFetchDest && accept?.startsWith('text/html')) { - return true; - } - - return false; -} - function isRequestEligibleForRefresh( err: TokenVerificationError, authenticateContext: { refreshTokenInCookie?: string }, @@ -105,124 +85,8 @@ export async function authenticateRequest( assertProxyUrlOrDomain(authenticateContext.proxyUrl || authenticateContext.domain); } - // NOTE(izaak): compute regex matchers early for efficiency - they can be used multiple times. const organizationSyncTargetMatchers = computeOrganizationSyncTargetMatchers(options.organizationSyncOptions); - - function removeDevBrowserFromURL(url: URL) { - const updatedURL = new URL(url); - - updatedURL.searchParams.delete(constants.QueryParameters.DevBrowser); - // Remove legacy dev browser query param key to support local app with v5 using AP with v4 - updatedURL.searchParams.delete(constants.QueryParameters.LegacyDevBrowser); - - return updatedURL; - } - - function buildRedirectToHandshake({ handshakeReason }: { handshakeReason: string }) { - const redirectUrl = removeDevBrowserFromURL(authenticateContext.clerkUrl); - const frontendApiNoProtocol = authenticateContext.frontendApi.replace(/http(s)?:\/\//, ''); - - const url = new URL(`https://${frontendApiNoProtocol}/v1/client/handshake`); - url.searchParams.append('redirect_url', redirectUrl?.href || ''); - url.searchParams.append( - constants.QueryParameters.SuffixedCookies, - authenticateContext.usesSuffixedCookies().toString(), - ); - url.searchParams.append(constants.QueryParameters.HandshakeReason, handshakeReason); - - if (authenticateContext.instanceType === 'development' && authenticateContext.devBrowserToken) { - url.searchParams.append(constants.QueryParameters.DevBrowser, authenticateContext.devBrowserToken); - } - - const toActivate = getOrganizationSyncTarget( - authenticateContext.clerkUrl, - options.organizationSyncOptions, - organizationSyncTargetMatchers, - ); - if (toActivate) { - const params = getOrganizationSyncQueryParams(toActivate); - - params.forEach((value, key) => { - url.searchParams.append(key, value); - }); - } - - return new Headers({ [constants.Headers.Location]: url.href }); - } - - async function resolveHandshake() { - const headers = new Headers({ - 'Access-Control-Allow-Origin': 'null', - 'Access-Control-Allow-Credentials': 'true', - }); - - const cookiesToSet: string[] = []; - - if (authenticateContext.handshakeNonce) { - // TODO: implement handshake nonce handling, fetch handshake payload with nonce - } else { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const handshakePayload = await verifyHandshakeToken(authenticateContext.handshakeToken!, authenticateContext); - cookiesToSet.push(...handshakePayload.handshake); - } - - let sessionToken = ''; - cookiesToSet.forEach((x: string) => { - headers.append('Set-Cookie', x); - if (getCookieName(x).startsWith(constants.Cookies.Session)) { - sessionToken = getCookieValue(x); - } - }); - - if (authenticateContext.instanceType === 'development') { - const newUrl = new URL(authenticateContext.clerkUrl); - newUrl.searchParams.delete(constants.QueryParameters.Handshake); - newUrl.searchParams.delete(constants.QueryParameters.HandshakeHelp); - headers.append(constants.Headers.Location, newUrl.toString()); - headers.set(constants.Headers.CacheControl, 'no-store'); - } - - if (sessionToken === '') { - return signedOut(authenticateContext, AuthErrorReason.SessionTokenMissing, '', headers); - } - - const { data, errors: [error] = [] } = await verifyToken(sessionToken, authenticateContext); - if (data) { - return signedIn(authenticateContext, data, headers, sessionToken); - } - - if ( - authenticateContext.instanceType === 'development' && - (error?.reason === TokenVerificationErrorReason.TokenExpired || - error?.reason === TokenVerificationErrorReason.TokenNotActiveYet || - error?.reason === TokenVerificationErrorReason.TokenIatInTheFuture) - ) { - error.tokenCarrier = 'cookie'; - // This probably means we're dealing with clock skew - console.error( - `Clerk: Clock skew detected. This usually means that your system clock is inaccurate. Clerk will attempt to account for the clock skew in development. - -To resolve this issue, make sure your system's clock is set to the correct time (e.g. turn off and on automatic time synchronization). - ---- - -${error.getFullMessage()}`, - ); - - // Retry with a generous clock skew allowance (1 day) - const { data: retryResult, errors: [retryError] = [] } = await verifyToken(sessionToken, { - ...authenticateContext, - clockSkewInMs: 86_400_000, - }); - if (retryResult) { - return signedIn(authenticateContext, retryResult, headers, sessionToken); - } - - throw new Error(retryError?.message || 'Clerk: Handshake retry failed.'); - } - - throw new Error(error?.message || 'Clerk: Handshake failed.'); - } + const handshakeService = new HandshakeService(); async function refreshToken( authenticateContext: AuthenticateContext, @@ -360,21 +224,16 @@ ${error.getFullMessage()}`, message: string, headers?: Headers, ): SignedInState | SignedOutState | HandshakeState { - if (isRequestEligibleForHandshake(authenticateContext)) { - // Right now the only usage of passing in different headers is for multi-domain sync, which redirects somewhere else. - // In the future if we want to decorate the handshake redirect with additional headers per call we need to tweak this logic. - const handshakeHeaders = headers ?? buildRedirectToHandshake({ handshakeReason: reason }); + if (handshakeService.isRequestEligibleForHandshake(authenticateContext)) { + const handshakeHeaders = + headers ?? + handshakeService.buildRedirectToHandshake(authenticateContext, organizationSyncTargetMatchers, options, reason); - // Chrome aggressively caches inactive tabs. If we don't set the header here, - // all 307 redirects will be cached and the handshake will end up in an infinite loop. if (handshakeHeaders.get(constants.Headers.Location)) { handshakeHeaders.set(constants.Headers.CacheControl, 'no-store'); } - // Introduce the mechanism to protect for infinite handshake redirect loops - // using a cookie and returning true if it's infinite redirect loop or false if we can - // proceed with triggering handshake. - const isRedirectLoop = setHandshakeInfiniteRedirectionLoopHeaders(handshakeHeaders); + const isRedirectLoop = handshakeService.setHandshakeInfiniteRedirectionLoopHeaders(handshakeHeaders); if (isRedirectLoop) { const msg = `Clerk: Refreshing the session token resulted in an infinite redirect loop. This usually means that your Clerk instance keys do not match - make sure to copy the correct publishable and secret keys from the Clerk dashboard.`; console.log(msg); @@ -465,34 +324,6 @@ ${error.getFullMessage()}`, } } - // We want to prevent infinite handshake redirection loops. - // We incrementally set a `__clerk_redirection_loop` cookie, and when it loops 3 times, we throw an error. - // We also utilize the `referer` header to skip the prefetch requests. - function setHandshakeInfiniteRedirectionLoopHeaders(headers: Headers): boolean { - if (authenticateContext.handshakeRedirectLoopCounter === 3) { - return true; - } - - const newCounterValue = authenticateContext.handshakeRedirectLoopCounter + 1; - const cookieName = constants.Cookies.RedirectCount; - headers.append('Set-Cookie', `${cookieName}=${newCounterValue}; SameSite=Lax; HttpOnly; Max-Age=3`); - return false; - } - - function handleHandshakeTokenVerificationErrorInDevelopment(error: TokenVerificationError) { - // In development, the handshake token is being transferred in the URL as a query parameter, so there is no - // possibility of collision with a handshake token of another app running on the same local domain - // (etc one app on localhost:3000 and one on localhost:3001). - // Therefore, if the handshake token is invalid, it is likely that the user has switched Clerk keys locally. - // We make sure to throw a descriptive error message and then stop the handshake flow in every case, - // to avoid the possibility of an infinite loop. - if (error.reason === TokenVerificationErrorReason.TokenInvalidSignature) { - const msg = `Clerk: Handshake token verification failed due to an invalid signature. If you have switched Clerk keys locally, clear your cookies and try again.`; - throw new Error(msg); - } - throw new Error(`Clerk: Handshake token verification failed: ${error.getFullMessage()}.`); - } - async function authenticateRequestWithTokenInCookie() { const hasActiveClient = authenticateContext.clientUat; const hasSessionToken = !!authenticateContext.sessionTokenInCookie; @@ -503,21 +334,10 @@ ${error.getFullMessage()}`, */ if (authenticateContext.handshakeToken) { try { - return await resolveHandshake(); + return await handshakeService.resolveHandshake(authenticateContext); } catch (error) { - // In production, the handshake token is being transferred as a cookie, so there is a possibility of collision - // with a handshake token of another app running on the same etld+1 domain. - // For example, if one app is running on sub1.clerk.com and another on sub2.clerk.com, the handshake token - // cookie for both apps will be set on etld+1 (clerk.com) so there's a possibility that one app will accidentally - // use the handshake token of a different app during the handshake flow. - // In this scenario, verification will fail with TokenInvalidSignature. In contrast to the development case, - // we need to allow the flow to continue so the app eventually retries another handshake with the correct token. - // We need to make sure, however, that we don't allow the flow to continue indefinitely, so we throw an error after X - // retries to avoid an infinite loop. An infinite loop can happen if the customer switched Clerk keys for their prod app. - - // Check the handleHandshakeTokenVerificationErrorInDevelopment function for the development case. if (error instanceof TokenVerificationError && authenticateContext.instanceType === 'development') { - handleHandshakeTokenVerificationErrorInDevelopment(error); + handshakeService.handleHandshakeTokenVerificationErrorInDevelopment(error); } else { console.error('Clerk: unable to resolve handshake:', error); } @@ -818,26 +638,6 @@ export type OrganizationSyncTarget = | { type: 'personalAccount' } | { type: 'organization'; organizationId?: string; organizationSlug?: string }; -/** - * Generates the query parameters to activate an organization or personal account - * via the FAPI handshake api. - */ -function getOrganizationSyncQueryParams(toActivate: OrganizationSyncTarget): Map { - const ret = new Map(); - if (toActivate.type === 'personalAccount') { - ret.set('organization_id', ''); - } - if (toActivate.type === 'organization') { - if (toActivate.organizationId) { - ret.set('organization_id', toActivate.organizationId); - } - if (toActivate.organizationSlug) { - ret.set('organization_id', toActivate.organizationSlug); - } - } - return ret; -} - const convertTokenVerificationErrorReasonToAuthErrorReason = ({ tokenError, refreshError, From 9dba9c57e98d4ab79f9488efd314f8fa8c6172c2 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 29 Apr 2025 09:55:05 -0500 Subject: [PATCH 2/5] jsdoc --- packages/backend/src/tokens/handshake.ts | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts index f7b00d7ecf9..7b4709153d6 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -99,6 +99,11 @@ export class HandshakeService { this.handshakeRedirectLoopCounter = 0; } + /** + * Determines if a request is eligible for handshake based on its headers + * @param authenticateContext - The authentication context containing request headers + * @returns boolean indicating if the request is eligible for handshake + */ isRequestEligibleForHandshake(authenticateContext: { secFetchDest?: string; accept?: string }): boolean { const { accept, secFetchDest } = authenticateContext; @@ -113,12 +118,25 @@ export class HandshakeService { return false; } + /** + * Builds the redirect headers for a handshake request + * @param authenticateContext - The authentication context containing request information + * @param organizationSyncTargetMatchers - Matchers for organization sync patterns + * @param options - Options containing organization sync configuration + * @param reason - The reason for the handshake (e.g. 'session-token-expired') + * @returns Headers object containing the Location header for redirect + * @throws Error if clerkUrl is missing in authenticateContext + */ buildRedirectToHandshake( authenticateContext: AuthenticateContext, organizationSyncTargetMatchers: OrganizationSyncTargetMatchers, options: { organizationSyncOptions?: OrganizationSyncOptions }, reason: string, ): Headers { + if (!authenticateContext?.clerkUrl) { + throw new Error('Missing clerkUrl in authenticateContext'); + } + const redirectUrl = this.removeDevBrowserFromURL(authenticateContext.clerkUrl); const frontendApiNoProtocol = authenticateContext.frontendApi.replace(/http(s)?:\/\//, ''); @@ -149,6 +167,12 @@ export class HandshakeService { return new Headers({ [constants.Headers.Location]: url.href }); } + /** + * Resolves a handshake request by verifying the handshake token and setting appropriate cookies + * @param authenticateContext - The authentication context containing handshake information + * @returns Promise resolving to either a SignedInState or SignedOutState + * @throws Error if handshake verification fails or if there are issues with the session token + */ async resolveHandshake(authenticateContext: AuthenticateContext): Promise { const headers = new Headers({ 'Access-Control-Allow-Origin': 'null', @@ -220,6 +244,11 @@ ${error.getFullMessage()}`, throw new Error(error?.message || 'Clerk: Handshake failed.'); } + /** + * Handles handshake token verification errors in development mode + * @param error - The TokenVerificationError that occurred + * @throws Error with a descriptive message about the verification failure + */ handleHandshakeTokenVerificationErrorInDevelopment(error: TokenVerificationError): void { if (error.reason === TokenVerificationErrorReason.TokenInvalidSignature) { const msg = `Clerk: Handshake token verification failed due to an invalid signature. If you have switched Clerk keys locally, clear your cookies and try again.`; @@ -228,6 +257,11 @@ ${error.getFullMessage()}`, throw new Error(`Clerk: Handshake token verification failed: ${error.getFullMessage()}.`); } + /** + * Sets headers to prevent infinite handshake redirection loops + * @param headers - The Headers object to modify + * @returns boolean indicating if a redirect loop was detected (true) or if the request can proceed (false) + */ setHandshakeInfiniteRedirectionLoopHeaders(headers: Headers): boolean { if (this.handshakeRedirectLoopCounter === 3) { return true; From 80084ebae6878ca367cf919062db1bb79c0037f5 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 29 Apr 2025 09:58:21 -0500 Subject: [PATCH 3/5] clarify authenticate context usage --- packages/backend/src/tokens/handshake.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts index 7b4709153d6..974410e6103 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -120,7 +120,7 @@ export class HandshakeService { /** * Builds the redirect headers for a handshake request - * @param authenticateContext - The authentication context containing request information + * @param authenticateContext - The authentication context containing request information. This object is not modified. * @param organizationSyncTargetMatchers - Matchers for organization sync patterns * @param options - Options containing organization sync configuration * @param reason - The reason for the handshake (e.g. 'session-token-expired') @@ -169,7 +169,7 @@ export class HandshakeService { /** * Resolves a handshake request by verifying the handshake token and setting appropriate cookies - * @param authenticateContext - The authentication context containing handshake information + * @param authenticateContext - The authentication context containing handshake information. This object is not modified. * @returns Promise resolving to either a SignedInState or SignedOutState * @throws Error if handshake verification fails or if there are issues with the session token */ @@ -219,7 +219,15 @@ export class HandshakeService { error?.reason === TokenVerificationErrorReason.TokenNotActiveYet || error?.reason === TokenVerificationErrorReason.TokenIatInTheFuture) ) { - error.tokenCarrier = 'cookie'; + // Create a new error object with the same properties + const developmentError = new TokenVerificationError({ + action: error.action, + message: error.message, + reason: error.reason, + }); + // Set the tokenCarrier after construction + developmentError.tokenCarrier = 'cookie'; + console.error( `Clerk: Clock skew detected. This usually means that your system clock is inaccurate. Clerk will attempt to account for the clock skew in development. @@ -227,7 +235,7 @@ To resolve this issue, make sure your system's clock is set to the correct time --- -${error.getFullMessage()}`, +${developmentError.getFullMessage()}`, ); const { data: retryResult, errors: [retryError] = [] } = await verifyToken(sessionToken, { From 285daedb198e29a90207d1a17c1616ca52e271c8 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 29 Apr 2025 10:23:12 -0500 Subject: [PATCH 4/5] wip --- .../src/tokens/__tests__/handshake.test.ts | 400 ++++++++++++++++++ packages/backend/src/tokens/handshake.ts | 73 ++-- packages/backend/src/tokens/request.ts | 10 +- 3 files changed, 442 insertions(+), 41 deletions(-) create mode 100644 packages/backend/src/tokens/__tests__/handshake.test.ts diff --git a/packages/backend/src/tokens/__tests__/handshake.test.ts b/packages/backend/src/tokens/__tests__/handshake.test.ts new file mode 100644 index 00000000000..201f3638f10 --- /dev/null +++ b/packages/backend/src/tokens/__tests__/handshake.test.ts @@ -0,0 +1,400 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { constants } from '../../constants'; +import { TokenVerificationError, TokenVerificationErrorReason } from '../../errors'; +import type { AuthenticateContext } from '../authenticateContext'; +import { AuthErrorReason, signedIn, signedOut } from '../authStatus'; +import type { OrganizationSyncTargetMatchers } from '../handshake'; +import { HandshakeService } from '../handshake'; + +// Mock dependencies +vi.mock('../handshake.js', async importOriginal => { + const actual: any = await importOriginal(); + return { + ...actual, + verifyHandshakeToken: vi.fn().mockResolvedValue({ + handshake: ['cookie1=value1', 'session=session-token'], + }), + }; +}); + +vi.mock('../verify.js', async importOriginal => { + const actual: any = await importOriginal(); + return { + ...actual, + verifyToken: vi.fn(), + }; +}); + +vi.mock('../../jwt/verifyJwt.js', () => ({ + decodeJwt: vi.fn().mockReturnValue({ + data: { + header: { typ: 'JWT', alg: 'RS256', kid: 'test-kid' }, + payload: { + sub: 'user_123', + __raw: 'raw-token', + iss: 'issuer', + sid: 'session-id', + nbf: 1234567890, + exp: 1234567890, + iat: 1234567890, + v: 2 as const, + fea: undefined, + pla: undefined, + o: undefined, + org_permissions: undefined, + org_id: undefined, + org_slug: undefined, + org_role: undefined, + }, + signature: new Uint8Array([1, 2, 3]), + raw: { + header: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9', + payload: 'eyJzdWIiOiJ1c2VyXzEyMyJ9', + signature: 'signature', + text: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.signature', + }, + }, + errors: undefined, + }), +})); + +describe('HandshakeService', () => { + let mockAuthenticateContext: AuthenticateContext; + let mockOrganizationSyncTargetMatchers: OrganizationSyncTargetMatchers; + let mockOptions: { + organizationSyncOptions?: { organizationPatterns?: string[]; personalAccountPatterns?: string[] }; + }; + let handshakeService: HandshakeService; + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + + // Setup mock authenticate context + mockAuthenticateContext = { + clerkUrl: new URL('https://example.com'), + frontendApi: 'api.clerk.com', + instanceType: 'production', + usesSuffixedCookies: () => true, + secFetchDest: 'document', + accept: 'text/html', + } as AuthenticateContext; + + // Setup mock organization sync matchers + mockOrganizationSyncTargetMatchers = { + OrganizationMatcher: null, + PersonalAccountMatcher: null, + }; + + // Setup mock options + mockOptions = { + organizationSyncOptions: { + organizationPatterns: ['/org/:id'], + personalAccountPatterns: ['/account'], + }, + }; + + // Create service instance + handshakeService = new HandshakeService(mockAuthenticateContext, mockOrganizationSyncTargetMatchers, mockOptions); + }); + + describe('isRequestEligibleForHandshake', () => { + it('should return true for document secFetchDest', () => { + mockAuthenticateContext.secFetchDest = 'document'; + expect(handshakeService.isRequestEligibleForHandshake()).toBe(true); + }); + + it('should return true for iframe secFetchDest', () => { + mockAuthenticateContext.secFetchDest = 'iframe'; + expect(handshakeService.isRequestEligibleForHandshake()).toBe(true); + }); + + it('should return true for text/html accept header without secFetchDest', () => { + mockAuthenticateContext.secFetchDest = undefined; + mockAuthenticateContext.accept = 'text/html'; + expect(handshakeService.isRequestEligibleForHandshake()).toBe(true); + }); + + it('should return false for non-eligible requests', () => { + mockAuthenticateContext.secFetchDest = 'image'; + mockAuthenticateContext.accept = 'image/png'; + expect(handshakeService.isRequestEligibleForHandshake()).toBe(false); + }); + }); + + describe('buildRedirectToHandshake', () => { + it('should build redirect headers with basic parameters', () => { + 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); + + expect(url.hostname).toBe('api.clerk.com'); + expect(url.pathname).toBe('/v1/client/handshake'); + expect(url.searchParams.get('redirect_url')).toBe('https://example.com/'); + expect(url.searchParams.get(constants.QueryParameters.SuffixedCookies)).toBe('true'); + expect(url.searchParams.get(constants.QueryParameters.HandshakeReason)).toBe('test-reason'); + }); + + it('should include dev browser token in development mode', () => { + mockAuthenticateContext.instanceType = 'development'; + mockAuthenticateContext.devBrowserToken = 'dev-token'; + 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); + + expect(url.searchParams.get(constants.QueryParameters.DevBrowser)).toBe('dev-token'); + }); + + it('should throw error if clerkUrl is missing', () => { + mockAuthenticateContext.clerkUrl = undefined as any; + expect(() => handshakeService.buildRedirectToHandshake('test-reason')).toThrow( + 'Missing clerkUrl in authenticateContext', + ); + }); + }); + + describe.skip('resolveHandshake', () => { + it('should resolve handshake with valid token', async () => { + // Mock a valid JWT structure + const mockJwt = { + header: { + typ: 'JWT', + alg: 'RS256', + kid: 'test-kid', + }, + payload: { + sub: 'user_123', + __raw: 'raw-token', + iss: 'issuer', + sid: 'session-id', + nbf: 1234567890, + exp: 1234567890, + iat: 1234567890, + v: 2 as const, + fea: undefined, + pla: undefined, + o: undefined, + org_permissions: undefined, + org_id: undefined, + org_slug: undefined, + org_role: undefined, + }, + signature: new Uint8Array([1, 2, 3]), + raw: { + header: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9', + payload: 'eyJzdWIiOiJ1c2VyXzEyMyJ9', + signature: 'signature', + text: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.signature', + }, + }; + const mockHandshakePayload = { + handshake: ['cookie1=value1', 'session=session-token'], + }; + + const mockVerifyToken = vi.mocked(await import('../handshake.js')).verifyHandshakeToken; + mockVerifyToken.mockResolvedValue(mockHandshakePayload); + + const mockVerifyTokenResult = vi.mocked(await import('../verify.js')).verifyToken; + mockVerifyTokenResult.mockResolvedValue({ + data: mockJwt.payload, + errors: undefined, + }); + + const mockDecodeJwt = vi.mocked(await import('../../jwt/verifyJwt.js')).decodeJwt; + mockDecodeJwt.mockReturnValue({ + data: mockJwt, + errors: undefined, + }); + + // Mock verifyHandshakeToken to return our mock data directly + vi.mocked(await import('../handshake.js')).verifyHandshakeToken.mockResolvedValue(mockHandshakePayload); + + mockAuthenticateContext.handshakeToken = 'any-token'; + const result = await handshakeService.resolveHandshake(); + + expect(result).toEqual( + signedIn( + mockAuthenticateContext, + { + sub: 'user_123', + __raw: 'raw-token', + iss: 'issuer', + sid: 'session-id', + nbf: 1234567890, + exp: 1234567890, + iat: 1234567890, + v: 2 as const, + fea: undefined, + pla: undefined, + o: undefined, + org_permissions: undefined, + org_id: undefined, + org_slug: undefined, + org_role: undefined, + }, + expect.any(Headers), + 'session-token', + ), + ); + }); + + it('should handle missing session token', async () => { + const mockHandshakePayload = { handshake: ['cookie1=value1'] }; + const mockVerifyToken = vi.mocked(await import('../handshake.js')).verifyHandshakeToken; + mockVerifyToken.mockResolvedValue(mockHandshakePayload); + + mockAuthenticateContext.handshakeToken = 'valid-token'; + const result = await handshakeService.resolveHandshake(); + + expect(result).toEqual( + signedOut(mockAuthenticateContext, AuthErrorReason.SessionTokenMissing, '', expect.any(Headers)), + ); + }); + + it('should handle development mode clock skew', async () => { + mockAuthenticateContext.instanceType = 'development'; + + // Mock a valid JWT structure + const mockJwt = { + header: { + typ: 'JWT', + alg: 'RS256', + kid: 'test-kid', + }, + payload: { + sub: 'user_123', + __raw: 'raw-token', + iss: 'issuer', + sid: 'session-id', + nbf: 1234567890, + exp: 1234567890, + iat: 1234567890, + v: 2 as const, + fea: undefined, + pla: undefined, + o: undefined, + org_permissions: undefined, + org_id: undefined, + org_slug: undefined, + org_role: undefined, + }, + signature: new Uint8Array([1, 2, 3]), + raw: { + header: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9', + payload: 'eyJzdWIiOiJ1c2VyXzEyMyJ9', + signature: 'signature', + text: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.signature', + }, + }; + const mockHandshakePayload = { + handshake: ['cookie1=value1', 'session=session-token'], + }; + + const mockVerifyToken = vi.mocked(await import('../handshake.js')).verifyHandshakeToken; + mockVerifyToken.mockResolvedValue(mockHandshakePayload); + + const mockVerifyTokenResult = vi.mocked(await import('../verify.js')).verifyToken; + mockVerifyTokenResult + .mockRejectedValueOnce( + new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenExpired, + message: 'Token expired', + }), + ) + .mockResolvedValueOnce({ + data: mockJwt.payload, + errors: undefined, + }); + + const mockDecodeJwt = vi.mocked(await import('../../jwt/verifyJwt.js')).decodeJwt; + mockDecodeJwt.mockReturnValue({ + data: mockJwt, + errors: undefined, + }); + + // Mock verifyHandshakeToken to return our mock data directly + vi.mocked(await import('../handshake.js')).verifyHandshakeToken.mockResolvedValue(mockHandshakePayload); + + mockAuthenticateContext.handshakeToken = 'any-token'; + const result = await handshakeService.resolveHandshake(); + + expect(result).toEqual( + signedIn( + mockAuthenticateContext, + { + sub: 'user_123', + __raw: 'raw-token', + iss: 'issuer', + sid: 'session-id', + nbf: 1234567890, + exp: 1234567890, + iat: 1234567890, + v: 2 as const, + fea: undefined, + pla: undefined, + o: undefined, + org_permissions: undefined, + org_id: undefined, + org_slug: undefined, + org_role: undefined, + }, + expect.any(Headers), + 'session-token', + ), + ); + }); + }); + + describe('handleHandshakeTokenVerificationErrorInDevelopment', () => { + it('should throw specific error for invalid signature', () => { + const error = new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenInvalidSignature, + message: 'Invalid signature', + }); + + expect(() => handshakeService.handleHandshakeTokenVerificationErrorInDevelopment(error)).toThrow( + 'Clerk: Handshake token verification failed due to an invalid signature', + ); + }); + + it('should throw generic error for other verification failures', () => { + const error = new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenExpired, + message: 'Token expired', + }); + + expect(() => handshakeService.handleHandshakeTokenVerificationErrorInDevelopment(error)).toThrow( + 'Clerk: Handshake token verification failed: Token expired', + ); + }); + }); + + describe('setHandshakeInfiniteRedirectionLoopHeaders', () => { + it('should return true after 3 redirects', () => { + const headers = new Headers(); + handshakeService['handshakeRedirectLoopCounter'] = 3; + + const result = handshakeService.setHandshakeInfiniteRedirectionLoopHeaders(headers); + + expect(result).toBe(true); + expect(headers.get('Set-Cookie')).toBeNull(); + }); + + it('should increment counter and set cookie for first redirect', () => { + const headers = new Headers(); + handshakeService['handshakeRedirectLoopCounter'] = 0; + + const result = handshakeService.setHandshakeInfiniteRedirectionLoopHeaders(headers); + + expect(result).toBe(false); + expect(headers.get('Set-Cookie')).toContain('__clerk_redirect_count=1'); + }); + }); +}); diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts index 974410e6103..3ef29543719 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -94,18 +94,27 @@ export type OrganizationSyncTarget = export class HandshakeService { private handshakeRedirectLoopCounter: number; + private readonly authenticateContext: AuthenticateContext; + private readonly organizationSyncTargetMatchers: OrganizationSyncTargetMatchers; + private readonly options: { organizationSyncOptions?: OrganizationSyncOptions }; - constructor() { + constructor( + authenticateContext: AuthenticateContext, + organizationSyncTargetMatchers: OrganizationSyncTargetMatchers, + options: { organizationSyncOptions?: OrganizationSyncOptions }, + ) { this.handshakeRedirectLoopCounter = 0; + this.authenticateContext = authenticateContext; + this.organizationSyncTargetMatchers = organizationSyncTargetMatchers; + this.options = options; } /** * Determines if a request is eligible for handshake based on its headers - * @param authenticateContext - The authentication context containing request headers * @returns boolean indicating if the request is eligible for handshake */ - isRequestEligibleForHandshake(authenticateContext: { secFetchDest?: string; accept?: string }): boolean { - const { accept, secFetchDest } = authenticateContext; + isRequestEligibleForHandshake(): boolean { + const { accept, secFetchDest } = this.authenticateContext; if (secFetchDest === 'document' || secFetchDest === 'iframe') { return true; @@ -120,42 +129,34 @@ export class HandshakeService { /** * Builds the redirect headers for a handshake request - * @param authenticateContext - The authentication context containing request information. This object is not modified. - * @param organizationSyncTargetMatchers - Matchers for organization sync patterns - * @param options - Options containing organization sync configuration * @param reason - The reason for the handshake (e.g. 'session-token-expired') * @returns Headers object containing the Location header for redirect * @throws Error if clerkUrl is missing in authenticateContext */ - buildRedirectToHandshake( - authenticateContext: AuthenticateContext, - organizationSyncTargetMatchers: OrganizationSyncTargetMatchers, - options: { organizationSyncOptions?: OrganizationSyncOptions }, - reason: string, - ): Headers { - if (!authenticateContext?.clerkUrl) { + buildRedirectToHandshake(reason: string): Headers { + if (!this.authenticateContext?.clerkUrl) { throw new Error('Missing clerkUrl in authenticateContext'); } - const redirectUrl = this.removeDevBrowserFromURL(authenticateContext.clerkUrl); - const frontendApiNoProtocol = authenticateContext.frontendApi.replace(/http(s)?:\/\//, ''); + const redirectUrl = this.removeDevBrowserFromURL(this.authenticateContext.clerkUrl); + const frontendApiNoProtocol = this.authenticateContext.frontendApi.replace(/http(s)?:\/\//, ''); const url = new URL(`https://${frontendApiNoProtocol}/v1/client/handshake`); url.searchParams.append('redirect_url', redirectUrl?.href || ''); url.searchParams.append( constants.QueryParameters.SuffixedCookies, - authenticateContext.usesSuffixedCookies().toString(), + this.authenticateContext.usesSuffixedCookies().toString(), ); url.searchParams.append(constants.QueryParameters.HandshakeReason, reason); - if (authenticateContext.instanceType === 'development' && authenticateContext.devBrowserToken) { - url.searchParams.append(constants.QueryParameters.DevBrowser, authenticateContext.devBrowserToken); + if (this.authenticateContext.instanceType === 'development' && this.authenticateContext.devBrowserToken) { + url.searchParams.append(constants.QueryParameters.DevBrowser, this.authenticateContext.devBrowserToken); } const toActivate = this.getOrganizationSyncTarget( - authenticateContext.clerkUrl, - options.organizationSyncOptions, - organizationSyncTargetMatchers, + this.authenticateContext.clerkUrl, + this.options.organizationSyncOptions, + this.organizationSyncTargetMatchers, ); if (toActivate) { const params = this.getOrganizationSyncQueryParams(toActivate); @@ -169,11 +170,10 @@ export class HandshakeService { /** * Resolves a handshake request by verifying the handshake token and setting appropriate cookies - * @param authenticateContext - The authentication context containing handshake information. This object is not modified. * @returns Promise resolving to either a SignedInState or SignedOutState * @throws Error if handshake verification fails or if there are issues with the session token */ - async resolveHandshake(authenticateContext: AuthenticateContext): Promise { + async resolveHandshake(): Promise { const headers = new Headers({ 'Access-Control-Allow-Origin': 'null', 'Access-Control-Allow-Credentials': 'true', @@ -181,10 +181,13 @@ export class HandshakeService { const cookiesToSet: string[] = []; - if (authenticateContext.handshakeNonce) { + if (this.authenticateContext.handshakeNonce) { // TODO: implement handshake nonce handling, fetch handshake payload with nonce - } else if (authenticateContext.handshakeToken) { - const handshakePayload = await verifyHandshakeToken(authenticateContext.handshakeToken, authenticateContext); + } else if (this.authenticateContext.handshakeToken) { + const handshakePayload = await verifyHandshakeToken( + this.authenticateContext.handshakeToken, + this.authenticateContext, + ); cookiesToSet.push(...handshakePayload.handshake); } @@ -196,8 +199,8 @@ export class HandshakeService { } }); - if (authenticateContext.instanceType === 'development') { - const newUrl = new URL(authenticateContext.clerkUrl); + if (this.authenticateContext.instanceType === 'development') { + const newUrl = new URL(this.authenticateContext.clerkUrl); newUrl.searchParams.delete(constants.QueryParameters.Handshake); newUrl.searchParams.delete(constants.QueryParameters.HandshakeHelp); headers.append(constants.Headers.Location, newUrl.toString()); @@ -205,16 +208,16 @@ export class HandshakeService { } if (sessionToken === '') { - return signedOut(authenticateContext, AuthErrorReason.SessionTokenMissing, '', headers); + return signedOut(this.authenticateContext, AuthErrorReason.SessionTokenMissing, '', headers); } - const { data, errors: [error] = [] } = await verifyToken(sessionToken, authenticateContext); + const { data, errors: [error] = [] } = await verifyToken(sessionToken, this.authenticateContext); if (data) { - return signedIn(authenticateContext, data, headers, sessionToken); + return signedIn(this.authenticateContext, data, headers, sessionToken); } if ( - authenticateContext.instanceType === 'development' && + this.authenticateContext.instanceType === 'development' && (error?.reason === TokenVerificationErrorReason.TokenExpired || error?.reason === TokenVerificationErrorReason.TokenNotActiveYet || error?.reason === TokenVerificationErrorReason.TokenIatInTheFuture) @@ -239,11 +242,11 @@ ${developmentError.getFullMessage()}`, ); const { data: retryResult, errors: [retryError] = [] } = await verifyToken(sessionToken, { - ...authenticateContext, + ...this.authenticateContext, clockSkewInMs: 86_400_000, }); if (retryResult) { - return signedIn(authenticateContext, retryResult, headers, sessionToken); + return signedIn(this.authenticateContext, retryResult, headers, sessionToken); } throw new Error(retryError?.message || 'Clerk: Handshake retry failed.'); diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 39265dfc0c2..971ee331a78 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -86,7 +86,7 @@ export async function authenticateRequest( } const organizationSyncTargetMatchers = computeOrganizationSyncTargetMatchers(options.organizationSyncOptions); - const handshakeService = new HandshakeService(); + const handshakeService = new HandshakeService(authenticateContext, organizationSyncTargetMatchers, options); async function refreshToken( authenticateContext: AuthenticateContext, @@ -224,10 +224,8 @@ export async function authenticateRequest( message: string, headers?: Headers, ): SignedInState | SignedOutState | HandshakeState { - if (handshakeService.isRequestEligibleForHandshake(authenticateContext)) { - const handshakeHeaders = - headers ?? - handshakeService.buildRedirectToHandshake(authenticateContext, organizationSyncTargetMatchers, options, reason); + if (handshakeService.isRequestEligibleForHandshake()) { + const handshakeHeaders = headers ?? handshakeService.buildRedirectToHandshake(reason); if (handshakeHeaders.get(constants.Headers.Location)) { handshakeHeaders.set(constants.Headers.CacheControl, 'no-store'); @@ -334,7 +332,7 @@ export async function authenticateRequest( */ if (authenticateContext.handshakeToken) { try { - return await handshakeService.resolveHandshake(authenticateContext); + return await handshakeService.resolveHandshake(); } catch (error) { if (error instanceof TokenVerificationError && authenticateContext.instanceType === 'development') { handshakeService.handleHandshakeTokenVerificationErrorInDevelopment(error); From 292751c892ba0ce27ae972f9318425f3bff5cf9f Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 29 Apr 2025 10:24:00 -0500 Subject: [PATCH 5/5] wip --- packages/backend/src/tokens/__tests__/handshake.test.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/handshake.test.ts b/packages/backend/src/tokens/__tests__/handshake.test.ts index 201f3638f10..11bf493f34e 100644 --- a/packages/backend/src/tokens/__tests__/handshake.test.ts +++ b/packages/backend/src/tokens/__tests__/handshake.test.ts @@ -7,7 +7,6 @@ import { AuthErrorReason, signedIn, signedOut } from '../authStatus'; import type { OrganizationSyncTargetMatchers } from '../handshake'; import { HandshakeService } from '../handshake'; -// Mock dependencies vi.mock('../handshake.js', async importOriginal => { const actual: any = await importOriginal(); return { @@ -68,10 +67,8 @@ describe('HandshakeService', () => { let handshakeService: HandshakeService; beforeEach(() => { - // Reset mocks vi.clearAllMocks(); - // Setup mock authenticate context mockAuthenticateContext = { clerkUrl: new URL('https://example.com'), frontendApi: 'api.clerk.com', @@ -81,13 +78,11 @@ describe('HandshakeService', () => { accept: 'text/html', } as AuthenticateContext; - // Setup mock organization sync matchers mockOrganizationSyncTargetMatchers = { OrganizationMatcher: null, PersonalAccountMatcher: null, }; - // Setup mock options mockOptions = { organizationSyncOptions: { organizationPatterns: ['/org/:id'], @@ -95,7 +90,6 @@ describe('HandshakeService', () => { }, }; - // Create service instance handshakeService = new HandshakeService(mockAuthenticateContext, mockOrganizationSyncTargetMatchers, mockOptions); }); @@ -162,7 +156,6 @@ describe('HandshakeService', () => { describe.skip('resolveHandshake', () => { it('should resolve handshake with valid token', async () => { - // Mock a valid JWT structure const mockJwt = { header: { typ: 'JWT', @@ -213,7 +206,6 @@ describe('HandshakeService', () => { errors: undefined, }); - // Mock verifyHandshakeToken to return our mock data directly vi.mocked(await import('../handshake.js')).verifyHandshakeToken.mockResolvedValue(mockHandshakePayload); mockAuthenticateContext.handshakeToken = 'any-token'; @@ -261,7 +253,6 @@ describe('HandshakeService', () => { it('should handle development mode clock skew', async () => { mockAuthenticateContext.instanceType = 'development'; - // Mock a valid JWT structure const mockJwt = { header: { typ: 'JWT',