From b9f756e13f83088d4c77277f7d7bdde74dcb5aa8 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Sun, 18 Jan 2026 14:09:12 +0200 Subject: [PATCH 1/2] feat(clerk-js,ui,shared): Add decorateUrl to setActive navigate callback for Safari ITP fix Why: Safari's Intelligent Tracking Prevention (ITP) caps cookies set via fetch/XHR to 7 days. When users switched from redirectUrl to the navigate callback pattern, the existing ITP workaround (via /v1/client/touch endpoint) stopped working because the touch endpoint logic only ran in the redirectUrl branch. What changed: - Added decorateUrl function to the navigate callback that wraps URLs with the touch endpoint when Safari ITP fix is needed (client.isEligibleForTouch()) - Updated SetActiveNavigate type signature to include decorateUrl parameter - Added dev-mode warning when decorateUrl is not called but ITP fix is needed - Updated all internal usages in SignIn, SignUp, and SessionTasks components to pass decorateUrl through navigateOnSetActive Context: The decorateUrl may return an external URL (https://...) when ITP fix is needed, requiring window.location.href instead of client-side navigation. This pattern is documented in the type definitions. --- .../clerk-js/src/core/__tests__/clerk.test.ts | 75 +++++++++++++++++++ packages/clerk-js/src/core/clerk.ts | 32 +++++++- packages/shared/src/types/clerk.ts | 59 +++++++++++++-- .../ChooseOrganizationScreen.tsx | 4 +- .../CreateOrganizationScreen.tsx | 4 +- .../tasks/TaskResetPassword/index.tsx | 4 +- ...nInFactorOneAlternativeChannelCodeForm.tsx | 4 +- .../SignIn/SignInFactorOneCodeForm.tsx | 4 +- .../SignIn/SignInFactorOnePasswordCard.tsx | 4 +- .../SignIn/SignInFactorTwoBackupCodeCard.tsx | 4 +- .../SignIn/SignInFactorTwoCodeForm.tsx | 4 +- .../components/SignIn/SignInSocialButtons.tsx | 4 +- .../ui/src/components/SignIn/SignInStart.tsx | 16 ++-- .../SignIn/handleCombinedFlowTransfer.ts | 11 ++- packages/ui/src/components/SignIn/shared.ts | 4 +- .../src/components/SignUp/SignUpContinue.tsx | 4 +- .../components/SignUp/SignUpEmailLinkCard.tsx | 4 +- .../ui/src/components/SignUp/SignUpStart.tsx | 8 +- .../SignUp/SignUpVerificationCodeForm.tsx | 4 +- .../src/contexts/components/SessionTasks.ts | 19 ++++- packages/ui/src/contexts/components/SignIn.ts | 27 ++++++- packages/ui/src/contexts/components/SignUp.ts | 27 ++++++- 22 files changed, 268 insertions(+), 58 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index de0562da1bf..31b694d2b3f 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -364,6 +364,81 @@ describe('Clerk singleton', () => { expect(navigate).toHaveBeenCalled(); }); + it('passes decorateUrl to the navigate callback', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + const navigate = vi.fn(); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource, navigate }); + + expect(navigate).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.any(Object), + decorateUrl: expect.any(Function), + }), + ); + }); + + it('decorateUrl returns touch URL when isEligibleForTouch is true', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockSession], + cookieExpiresAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now + isEligibleForTouch: () => true, + buildTouchUrl: ({ redirectUrl }: { redirectUrl: URL }) => + `https://clerk.example.com/v1/client/touch?redirect_url=${redirectUrl.href}`, + }), + ); + + let capturedDecorateUrl: ((url: string) => string) | undefined; + const navigate = vi.fn(({ decorateUrl }) => { + capturedDecorateUrl = decorateUrl; + }); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource, navigate }); + + expect(capturedDecorateUrl).toBeDefined(); + const decoratedUrl = capturedDecorateUrl!('/dashboard'); + + // Should return touch URL when ITP fix is needed + expect(decoratedUrl).toContain('/v1/client/touch'); + expect(decoratedUrl).toContain('redirect_url='); + expect(decoratedUrl).toContain('%2Fdashboard'); + }); + + it('decorateUrl returns original URL when isEligibleForTouch is false', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockSession], + cookieExpiresAt: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10 days from now + isEligibleForTouch: () => false, + buildTouchUrl: ({ redirectUrl }: { redirectUrl: URL }) => + `https://clerk.example.com/v1/client/touch?redirect_url=${redirectUrl.href}`, + }), + ); + + let capturedDecorateUrl: ((url: string) => string) | undefined; + const navigate = vi.fn(({ decorateUrl }) => { + capturedDecorateUrl = decorateUrl; + }); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource, navigate }); + + expect(capturedDecorateUrl).toBeDefined(); + const decoratedUrl = capturedDecorateUrl!('/dashboard'); + + // Should return original URL when ITP fix is not needed + expect(decoratedUrl).toBe('/dashboard'); + }); + mockNativeRuntime(() => { it('calls session.touch in a non-standard browser', async () => { mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 4678c2266d0..1d07cc5a94a 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1576,7 +1576,37 @@ export class Clerk implements ClerkInterface { : taskUrl; await this.navigate(taskUrlWithRedirect); } else if (setActiveNavigate && newSession) { - await setActiveNavigate({ session: newSession }); + // Track whether decorateUrl was called for dev-mode warning + let decorateUrlCalled = false; + + /** + * Creates a URL that goes through the /v1/client/touch endpoint when Safari ITP fix is needed. + * This allows the session cookie to be refreshed via a full page navigation, bypassing + * Safari's 7-day cap on cookies set via fetch/XHR. + */ + const decorateUrl = (url: string): string => { + decorateUrlCalled = true; + + if (!this.client?.isEligibleForTouch()) { + return url; + } + + const absoluteUrl = new URL(url, window.location.href); + const touchUrl = this.client.buildTouchUrl({ redirectUrl: absoluteUrl }); + return this.buildUrlWithAuth(touchUrl); + }; + + await setActiveNavigate({ session: newSession, decorateUrl }); + + // Warn in development if decorateUrl wasn't called but the client is eligible for touch + if (this.#instanceType === 'development' && !decorateUrlCalled && this.client.isEligibleForTouch()) { + logger.warnOnce( + 'Clerk: The navigate callback in setActive() did not call decorateUrl(). ' + + 'In Safari, sessions may be limited to 7 days due to Intelligent Tracking Prevention (ITP). ' + + 'Use decorateUrl() to wrap your destination URL to enable the ITP workaround. ' + + 'Learn more: https://clerk.com/docs/troubleshooting/safari-itp', + ); + } } else if (redirectUrl) { if (this.client.isEligibleForTouch()) { const absoluteRedirectUrl = new URL(redirectUrl, window.location.href); diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 33b15d3bfe8..804512f7546 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -136,7 +136,41 @@ export type SDKMetadata = { export type ListenerCallback = (emission: Resources) => void; export type UnsubscribeCallback = () => void; -export type SetActiveNavigate = ({ session }: { session: SessionResource }) => void | Promise; + +/** + * A function to decorate URLs for Safari ITP workaround. + * + * Safari's Intelligent Tracking Prevention (ITP) caps cookies set via fetch/XHR requests to 7 days. + * This function returns a URL that goes through the `/v1/client/touch` endpoint when the ITP fix is needed, + * allowing the cookie to be refreshed via a full page navigation. + * + * @param url - The destination URL to potentially decorate + * @returns The decorated URL if ITP fix is needed, otherwise the original URL unchanged + * + * @example + * ```typescript + * const url = decorateUrl('/dashboard'); + * // When ITP fix is needed: 'https://clerk.example.com/v1/client/touch?redirect_url=https://app.example.com/dashboard' + * // When not needed: '/dashboard' + * + * // decorateUrl may return an external URL when Safari ITP fix is needed + * if (url.startsWith('https')) { + * window.location.href = url; // External redirect + * } else { + * router.push(url); // Client-side navigation + * } + * ``` + */ +export type DecorateUrl = (url: string) => string; + +export type SetActiveNavigate = (params: { + session: SessionResource; + /** + * Decorate the destination URL to enable Safari ITP cookie refresh when needed. + * @see {@link DecorateUrl} + */ + decorateUrl: DecorateUrl; +}) => void | Promise; export type SignOutCallback = () => void | Promise; @@ -1336,18 +1370,27 @@ export type SetActiveParams = { * * When provided, it takes precedence over the `redirectUrl` parameter for navigation. * + * The callback receives a `decorateUrl` function that should be used to wrap destination URLs. + * This enables Safari ITP cookie refresh when needed. The decorated URL may be an external URL + * (starting with `https://`) that requires `window.location.href` instead of client-side navigation. + * * @example * ```typescript * await clerk.setActive({ * session, - * navigate: async ({ session }) => { - * const currentTask = session.currentTask; - * if (currentTask) { - * await router.push(`/onboarding/${currentTask.key}`) - * return + * navigate: async ({ session, decorateUrl }) => { + * const destination = session.currentTask + * ? `/onboarding/${session.currentTask.key}` + * : '/dashboard'; + * + * const url = decorateUrl(destination); + * + * // decorateUrl may return an external URL when Safari ITP fix is needed + * if (url.startsWith('https')) { + * window.location.href = url; + * } else { + * router.push(url); * } - * - * router.push('/dashboard'); * } * }); * ``` diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx index 2c47d5710dc..972fb3682ed 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx @@ -127,8 +127,8 @@ const MembershipPreview = (props: { organization: OrganizationResource }) => { try { await setActive({ organization, - navigate: async ({ session }) => { - await navigateOnSetActive?.({ session, redirectUrlComplete }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive?.({ session, redirectUrlComplete, decorateUrl }); }, }); } catch (err: any) { diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx index f2a8106f967..d0262f71a6f 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx @@ -75,8 +75,8 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = await setActive({ organization, - navigate: async ({ session }) => { - await navigateOnSetActive?.({ session, redirectUrlComplete }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive?.({ session, redirectUrlComplete, decorateUrl }); }, }); } catch (err: any) { diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/index.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/index.tsx index dc88aa35244..9e1ef8cc10c 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/index.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskResetPassword/index.tsx @@ -93,8 +93,8 @@ const TaskResetPasswordInternal = () => { // Update session to have the latest list of tasks (eg: if reset-password gets resolved) await clerk.setActive({ session: clerk.session, - navigate: async ({ session }) => { - await navigateOnSetActive?.({ session, redirectUrlComplete }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive?.({ session, redirectUrlComplete, decorateUrl }); }, }); } catch (e: any) { diff --git a/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx index eaf0b15af0e..c7a112ca08b 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx @@ -67,8 +67,8 @@ export const SignInFactorOneAlternativeChannelCodeForm = (props: SignInFactorOne case 'complete': return setActive({ session: res.createdSessionId, - navigate: async ({ session }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); }, }); case 'needs_second_factor': diff --git a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx index 0a21dddf09f..da2863fd3d9 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx @@ -98,8 +98,8 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => case 'complete': return setActive({ session: res.createdSessionId, - navigate: async ({ session }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); }, }); case 'needs_second_factor': diff --git a/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx index 27829ec7675..f4a453b4fe5 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx @@ -78,8 +78,8 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) case 'complete': return setActive({ session: res.createdSessionId, - navigate: ({ session }) => { - return navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + navigate: ({ session, decorateUrl }) => { + return navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); }, }); case 'needs_second_factor': diff --git a/packages/ui/src/components/SignIn/SignInFactorTwoBackupCodeCard.tsx b/packages/ui/src/components/SignIn/SignInFactorTwoBackupCodeCard.tsx index ed86694d2c0..119eb6f3308 100644 --- a/packages/ui/src/components/SignIn/SignInFactorTwoBackupCodeCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorTwoBackupCodeCard.tsx @@ -54,8 +54,8 @@ export const SignInFactorTwoBackupCodeCard = (props: SignInFactorTwoBackupCodeCa } return setActive({ session: res.createdSessionId, - navigate: async ({ session }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); }, }); default: diff --git a/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx index 2cf59b83d6b..0cf1ad32f57 100644 --- a/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx @@ -93,8 +93,8 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) => } return setActive({ session: res.createdSessionId, - navigate: async ({ session }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); }, }); default: diff --git a/packages/ui/src/components/SignIn/SignInSocialButtons.tsx b/packages/ui/src/components/SignIn/SignInSocialButtons.tsx index abb128536ce..0cdcb0884c1 100644 --- a/packages/ui/src/components/SignIn/SignInSocialButtons.tsx +++ b/packages/ui/src/components/SignIn/SignInSocialButtons.tsx @@ -42,8 +42,8 @@ export const SignInSocialButtons = React.memo((props: SignInSocialButtonsProps) if (sessionAlreadyExistsError) { return clerk.setActive({ session: clerk.client.lastActiveSessionId, - navigate: async ({ session }) => { - await ctx.navigateOnSetActive({ session, redirectUrl: ctx.afterSignInUrl }); + navigate: async ({ session, decorateUrl }) => { + await ctx.navigateOnSetActive({ session, redirectUrl: ctx.afterSignInUrl, decorateUrl }); }, }); } diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index fd27013ed4e..07dc3e83772 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -241,8 +241,8 @@ function SignInStartInternal(): JSX.Element { removeClerkQueryParam('__clerk_ticket'); return clerk.setActive({ session: res.createdSessionId, - navigate: async ({ session }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); }, }); default: { @@ -397,8 +397,8 @@ function SignInStartInternal(): JSX.Element { case 'complete': return clerk.setActive({ session: res.createdSessionId, - navigate: async ({ session }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); }, }); default: { @@ -451,8 +451,8 @@ function SignInStartInternal(): JSX.Element { } else if (sessionAlreadyExistsError) { await clerk.setActive({ session: clerk.client.lastActiveSessionId, - navigate: async ({ session }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); }, }); } else if (alreadySignedInError) { @@ -460,8 +460,8 @@ function SignInStartInternal(): JSX.Element { const sid = alreadySignedInError.meta!.sessionId!; await clerk.setActive({ session: sid, - navigate: async ({ session }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); }, }); } else if (isCombinedFlow && accountDoesNotExistError) { diff --git a/packages/ui/src/components/SignIn/handleCombinedFlowTransfer.ts b/packages/ui/src/components/SignIn/handleCombinedFlowTransfer.ts index 5ed1926acb8..0a60f72893b 100644 --- a/packages/ui/src/components/SignIn/handleCombinedFlowTransfer.ts +++ b/packages/ui/src/components/SignIn/handleCombinedFlowTransfer.ts @@ -1,5 +1,6 @@ import { SIGN_UP_MODES } from '@clerk/shared/internal/clerk-js/constants'; import type { + DecorateUrl, LoadedClerk, PhoneCodeChannel, PhoneCodeStrategy, @@ -24,7 +25,11 @@ type HandleCombinedFlowTransferProps = { redirectUrlComplete?: string; passwordEnabled: boolean; alternativePhoneCodeChannel?: PhoneCodeChannel | null; - navigateOnSetActive: (opts: { session: SessionResource; redirectUrl: string }) => Promise; + navigateOnSetActive: (opts: { + session: SessionResource; + redirectUrl: string; + decorateUrl: DecorateUrl; + }) => Promise; }; /** @@ -95,8 +100,8 @@ export function handleCombinedFlowTransfer({ handleComplete: () => clerk.setActive({ session: res.createdSessionId, - navigate: async ({ session }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, decorateUrl }); }, }), navigate, diff --git a/packages/ui/src/components/SignIn/shared.ts b/packages/ui/src/components/SignIn/shared.ts index 656b844bbc7..ec25432ea00 100644 --- a/packages/ui/src/components/SignIn/shared.ts +++ b/packages/ui/src/components/SignIn/shared.ts @@ -32,8 +32,8 @@ function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise case 'complete': return setActive({ session: res.createdSessionId, - navigate: async ({ session }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignInUrl }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); }, }); case 'needs_second_factor': diff --git a/packages/ui/src/components/SignUp/SignUpContinue.tsx b/packages/ui/src/components/SignUp/SignUpContinue.tsx index 9acbc23a2f2..29ae18c8e31 100644 --- a/packages/ui/src/components/SignUp/SignUpContinue.tsx +++ b/packages/ui/src/components/SignUp/SignUpContinue.tsx @@ -182,8 +182,8 @@ function SignUpContinueInternal() { handleComplete: () => clerk.setActive({ session: res.createdSessionId, - navigate: async ({ session }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, decorateUrl }); }, }), navigate, diff --git a/packages/ui/src/components/SignUp/SignUpEmailLinkCard.tsx b/packages/ui/src/components/SignUp/SignUpEmailLinkCard.tsx index 4f08cd90a7c..66207e4d6b7 100644 --- a/packages/ui/src/components/SignUp/SignUpEmailLinkCard.tsx +++ b/packages/ui/src/components/SignUp/SignUpEmailLinkCard.tsx @@ -59,8 +59,8 @@ export const SignUpEmailLinkCard = () => { handleComplete: () => setActive({ session: su.createdSessionId, - navigate: async ({ session }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, decorateUrl }); }, }), navigate, diff --git a/packages/ui/src/components/SignUp/SignUpStart.tsx b/packages/ui/src/components/SignUp/SignUpStart.tsx index c32e64d23e2..660b7b7d7f8 100644 --- a/packages/ui/src/components/SignUp/SignUpStart.tsx +++ b/packages/ui/src/components/SignUp/SignUpStart.tsx @@ -170,8 +170,8 @@ function SignUpStartInternal(): JSX.Element { removeClerkQueryParam('__clerk_invitation_token'); return setActive({ session: signUp.createdSessionId, - navigate: async ({ session }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, decorateUrl }); }, }); }, @@ -347,8 +347,8 @@ function SignUpStartInternal(): JSX.Element { handleComplete: () => setActive({ session: res.createdSessionId, - navigate: async ({ session }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, decorateUrl }); }, }), navigate, diff --git a/packages/ui/src/components/SignUp/SignUpVerificationCodeForm.tsx b/packages/ui/src/components/SignUp/SignUpVerificationCodeForm.tsx index 4ca48081849..349b81962aa 100644 --- a/packages/ui/src/components/SignUp/SignUpVerificationCodeForm.tsx +++ b/packages/ui/src/components/SignUp/SignUpVerificationCodeForm.tsx @@ -50,8 +50,8 @@ export const SignUpVerificationCodeForm = (props: SignInFactorOneCodeFormProps) handleComplete: () => setActive({ session: res.createdSessionId, - navigate: async ({ session }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl }); + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, decorateUrl }); }, }), navigate, diff --git a/packages/ui/src/contexts/components/SessionTasks.ts b/packages/ui/src/contexts/components/SessionTasks.ts index 9fc69910fa2..1837cab2a49 100644 --- a/packages/ui/src/contexts/components/SessionTasks.ts +++ b/packages/ui/src/contexts/components/SessionTasks.ts @@ -1,5 +1,5 @@ import { getTaskEndpoint } from '@clerk/shared/internal/clerk-js/sessionTasks'; -import type { SessionResource } from '@clerk/shared/types'; +import type { DecorateUrl, SessionResource } from '@clerk/shared/types'; import { createContext, useContext } from 'react'; import { useRouter } from '@/ui/router'; @@ -9,7 +9,11 @@ import type { SessionTasksCtx, TaskChooseOrganizationCtx, TaskResetPasswordCtx } export const SessionTasksContext = createContext(null); type SessionTasksContextType = SessionTasksCtx & { - navigateOnSetActive: (opts: { session: SessionResource; redirectUrlComplete: string }) => Promise; + navigateOnSetActive: (opts: { + session: SessionResource; + redirectUrlComplete: string; + decorateUrl: DecorateUrl; + }) => Promise; }; export const useSessionTasksContext = (): SessionTasksContextType => { @@ -23,12 +27,23 @@ export const useSessionTasksContext = (): SessionTasksContextType => { const navigateOnSetActive = async ({ session, redirectUrlComplete, + decorateUrl, }: { session: SessionResource; redirectUrlComplete: string; + decorateUrl: DecorateUrl; }) => { const currentTask = session.currentTask; if (!currentTask) { + // Use decorateUrl to enable Safari ITP cookie refresh when needed + const decoratedUrl = decorateUrl(redirectUrlComplete); + + // If decorateUrl returns an external URL (Safari ITP fix), do a full page navigation + if (decoratedUrl.startsWith('https://')) { + window.location.href = decoratedUrl; + return; + } + return navigate(redirectUrlComplete); } diff --git a/packages/ui/src/contexts/components/SignIn.ts b/packages/ui/src/contexts/components/SignIn.ts index 33f8c3eeb15..61ff0be8b65 100644 --- a/packages/ui/src/contexts/components/SignIn.ts +++ b/packages/ui/src/contexts/components/SignIn.ts @@ -3,7 +3,7 @@ import { RedirectUrls } from '@clerk/shared/internal/clerk-js/redirectUrls'; import { getTaskEndpoint } from '@clerk/shared/internal/clerk-js/sessionTasks'; import { buildURL } from '@clerk/shared/internal/clerk-js/url'; import { useClerk } from '@clerk/shared/react'; -import type { SessionResource } from '@clerk/shared/types'; +import type { DecorateUrl, SessionResource } from '@clerk/shared/types'; import { isAbsoluteUrl } from '@clerk/shared/url'; import { createContext, useContext, useMemo } from 'react'; @@ -28,7 +28,11 @@ export type SignInContextType = Omit Promise; + navigateOnSetActive: (opts: { + session: SessionResource; + redirectUrl: string; + decorateUrl: DecorateUrl; + }) => Promise; taskUrl: string | null; }; @@ -119,9 +123,26 @@ export const useSignInContext = (): SignInContextType => { const signUpContinueUrl = buildURL({ base: signUpUrl, hashPath: '/continue' }, { stringify: true }); - const navigateOnSetActive = async ({ session, redirectUrl }: { session: SessionResource; redirectUrl: string }) => { + const navigateOnSetActive = async ({ + session, + redirectUrl, + decorateUrl, + }: { + session: SessionResource; + redirectUrl: string; + decorateUrl: DecorateUrl; + }) => { const currentTask = session.currentTask; if (!currentTask) { + // Use decorateUrl to enable Safari ITP cookie refresh when needed + const decoratedUrl = decorateUrl(redirectUrl); + + // If decorateUrl returns an external URL (Safari ITP fix), do a full page navigation + if (decoratedUrl.startsWith('https://')) { + window.location.href = decoratedUrl; + return; + } + return navigate(redirectUrl); } diff --git a/packages/ui/src/contexts/components/SignUp.ts b/packages/ui/src/contexts/components/SignUp.ts index a614dc30941..8b4328f1d23 100644 --- a/packages/ui/src/contexts/components/SignUp.ts +++ b/packages/ui/src/contexts/components/SignUp.ts @@ -3,7 +3,7 @@ import { RedirectUrls } from '@clerk/shared/internal/clerk-js/redirectUrls'; import { getTaskEndpoint } from '@clerk/shared/internal/clerk-js/sessionTasks'; import { buildURL } from '@clerk/shared/internal/clerk-js/url'; import { useClerk } from '@clerk/shared/react'; -import type { SessionResource } from '@clerk/shared/types'; +import type { DecorateUrl, SessionResource } from '@clerk/shared/types'; import { isAbsoluteUrl } from '@clerk/shared/url'; import { createContext, useContext, useMemo } from 'react'; @@ -27,7 +27,11 @@ export type SignUpContextType = Omit Promise; + navigateOnSetActive: (opts: { + session: SessionResource; + redirectUrl: string; + decorateUrl: DecorateUrl; + }) => Promise; taskUrl: string | null; }; @@ -114,9 +118,26 @@ export const useSignUpContext = (): SignUpContextType => { // TODO: Avoid building this url again to remove duplicate code. Get it from window.Clerk instead. const secondFactorUrl = buildURL({ base: signInUrl, hashPath: '/factor-two' }, { stringify: true }); - const navigateOnSetActive = async ({ session, redirectUrl }: { session: SessionResource; redirectUrl: string }) => { + const navigateOnSetActive = async ({ + session, + redirectUrl, + decorateUrl, + }: { + session: SessionResource; + redirectUrl: string; + decorateUrl: DecorateUrl; + }) => { const currentTask = session.currentTask; if (!currentTask) { + // Use decorateUrl to enable Safari ITP cookie refresh when needed + const decoratedUrl = decorateUrl(redirectUrl); + + // If decorateUrl returns an external URL (Safari ITP fix), do a full page navigation + if (decoratedUrl.startsWith('https://')) { + window.location.href = decoratedUrl; + return; + } + return navigate(redirectUrl); } From 4ebca17f50a09f1e1dbc6ff0ac4f4f3789129764 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Sun, 18 Jan 2026 14:22:44 +0200 Subject: [PATCH 2/2] test(e2e): Add integration tests for Safari ITP decorateUrl Why: The Safari ITP fix (decorateUrl in setActive) was added without integration test coverage. These tests ensure the touch endpoint navigation works correctly when the client cookie is close to expiration. What changed: - Added 4 tests covering the Safari ITP workaround flow - Tests verify touch endpoint is called when cookie expires within 8 days - Tests verify decorateUrl behavior with mocked isEligibleForTouch --- integration/tests/safari-itp.test.ts | 213 +++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 integration/tests/safari-itp.test.ts diff --git a/integration/tests/safari-itp.test.ts b/integration/tests/safari-itp.test.ts new file mode 100644 index 00000000000..074ab58f445 --- /dev/null +++ b/integration/tests/safari-itp.test.ts @@ -0,0 +1,213 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import type { FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +/** + * Tests Safari ITP (Intelligent Tracking Prevention) workaround + * + * Safari's ITP caps cookies set via fetch/XHR to 7 days. When the client cookie + * is close to expiring (within 8 days), Clerk uses a full-page navigation through + * the /v1/client/touch endpoint to refresh the cookie, bypassing the 7-day cap. + * + * The decorateUrl function in setActive() wraps redirect URLs with the touch + * endpoint when the Safari ITP fix is needed. + */ +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('Safari ITP @generic @nextjs', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('navigates through touch endpoint when cookie is close to expiration', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Intercept client responses and modify cookie_expires_at to be within 8 days + // This makes isEligibleForTouch() return true + await page.route('**/v1/client?**', async route => { + const response = await route.fetch(); + const json = await response.json(); + + // Set cookie to expire in 2 days (within the 8-day threshold) + // The API returns milliseconds since epoch + const twoDaysFromNow = Date.now() + 2 * 24 * 60 * 60 * 1000; + json.response.cookie_expires_at = twoDaysFromNow; + + await route.fulfill({ + response, + json, + }); + }); + + // Track if touch endpoint is called during navigation + let touchEndpointCalled = false; + let touchRedirectUrl: string | null = null; + + await page.route('**/v1/client/touch**', async route => { + touchEndpointCalled = true; + const url = new URL(route.request().url()); + touchRedirectUrl = url.searchParams.get('redirect_url'); + // Let the request continue normally + await route.continue(); + }); + + // Sign in + await u.po.signIn.goTo(); + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + + // Wait for navigation to complete + await u.po.expect.toBeSignedIn(); + + // Verify touch endpoint was called + expect(touchEndpointCalled).toBe(true); + expect(touchRedirectUrl).toBeTruthy(); + }); + + test('does not use touch endpoint when cookie is not close to expiration', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Intercept client responses and set cookie_expires_at to be far in the future + // This makes isEligibleForTouch() return false + await page.route('**/v1/client?**', async route => { + const response = await route.fetch(); + const json = await response.json(); + + // Set cookie to expire in 30 days (outside the 8-day threshold) + // The API returns milliseconds since epoch + const thirtyDaysFromNow = Date.now() + 30 * 24 * 60 * 60 * 1000; + json.response.cookie_expires_at = thirtyDaysFromNow; + + await route.fulfill({ + response, + json, + }); + }); + + // Track if touch endpoint is called + let touchEndpointCalled = false; + + await page.route('**/v1/client/touch**', async route => { + touchEndpointCalled = true; + await route.continue(); + }); + + // Sign in + await u.po.signIn.goTo(); + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + + // Wait for navigation to complete + await u.po.expect.toBeSignedIn(); + + // Verify touch endpoint was NOT called + expect(touchEndpointCalled).toBe(false); + }); + + test('decorateUrl returns touch URL when client is eligible for touch', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Sign in first without mocking to get a valid session + await u.po.signIn.goTo(); + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + await u.po.expect.toBeSignedIn(); + + // Now test setActive with a navigate callback that captures decorateUrl behavior + const result = await page.evaluate(async () => { + const clerk = (window as any).Clerk; + + // Mock isEligibleForTouch to return true + const originalIsEligibleForTouch = clerk.client.isEligibleForTouch.bind(clerk.client); + clerk.client.isEligibleForTouch = () => true; + + let capturedDecorateUrl: ((url: string) => string) | undefined; + let decoratedUrl: string | undefined; + + try { + await clerk.setActive({ + session: clerk.session.id, + navigate: ({ decorateUrl }: { decorateUrl: (url: string) => string }) => { + capturedDecorateUrl = decorateUrl; + decoratedUrl = decorateUrl('/dashboard'); + }, + }); + } finally { + // Restore original + clerk.client.isEligibleForTouch = originalIsEligibleForTouch; + } + + return { + decorateUrlCaptured: !!capturedDecorateUrl, + decoratedUrl, + containsTouch: decoratedUrl?.includes('/v1/client/touch') ?? false, + containsRedirectUrl: decoratedUrl?.includes('redirect_url=') ?? false, + }; + }); + + expect(result.decorateUrlCaptured).toBe(true); + expect(result.containsTouch).toBe(true); + expect(result.containsRedirectUrl).toBe(true); + }); + + test('decorateUrl returns original URL when client is not eligible for touch', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Sign in first + await u.po.signIn.goTo(); + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + await u.po.expect.toBeSignedIn(); + + // Test setActive with navigate callback when isEligibleForTouch is false + const result = await page.evaluate(async () => { + const clerk = (window as any).Clerk; + + // Ensure isEligibleForTouch returns false + const originalIsEligibleForTouch = clerk.client.isEligibleForTouch.bind(clerk.client); + clerk.client.isEligibleForTouch = () => false; + + let decoratedUrl: string | undefined; + + try { + await clerk.setActive({ + session: clerk.session.id, + navigate: ({ decorateUrl }: { decorateUrl: (url: string) => string }) => { + decoratedUrl = decorateUrl('/dashboard'); + }, + }); + } finally { + // Restore original + clerk.client.isEligibleForTouch = originalIsEligibleForTouch; + } + + return { + decoratedUrl, + isOriginalUrl: decoratedUrl === '/dashboard', + containsTouch: decoratedUrl?.includes('/v1/client/touch') ?? false, + }; + }); + + expect(result.isOriginalUrl).toBe(true); + expect(result.containsTouch).toBe(false); + }); +});