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); + }); +}); 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); }