Skip to content

Commit 1fc95e2

Browse files
authored
feat(clerk-js,ui,shared): Add Safari ITP decorateUrl workaround to setActive (#7623)
1 parent a00d75a commit 1fc95e2

25 files changed

Lines changed: 529 additions & 60 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/shared': minor
4+
'@clerk/ui': minor
5+
---
6+
7+
Add Safari ITP (Intelligent Tracking Prevention) cookie refresh support.
8+
9+
Safari's ITP limits cookies set via JavaScript to 7 days. When a session cookie is close to expiring (within 8 days), Clerk now automatically routes navigations through a `/v1/client/touch` endpoint to refresh the cookie via a full-page navigation, bypassing the 7-day cap.
10+
11+
For developers using a custom `navigate` callback in `setActive()`, a new `decorateUrl` function is passed to the callback. Use it to wrap your destination URL:
12+
13+
```ts
14+
await clerk.setActive({
15+
session: newSession,
16+
navigate: ({ decorateUrl }) => {
17+
const url = decorateUrl('/dashboard');
18+
window.location.href = url;
19+
},
20+
});
21+
```
22+
23+
The `decorateUrl` function returns the original URL unchanged when the Safari ITP fix is not needed, so it's safe to always use it.
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import { appConfigs } from '../presets';
4+
import type { FakeUser } from '../testUtils';
5+
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
6+
7+
/**
8+
* Tests Safari ITP (Intelligent Tracking Prevention) workaround
9+
*
10+
* Safari's ITP caps cookies set via fetch/XHR to 7 days. When the client cookie
11+
* is close to expiring (within 8 days), Clerk uses a full-page navigation through
12+
* the /v1/client/touch endpoint to refresh the cookie, bypassing the 7-day cap.
13+
*
14+
* The decorateUrl function in setActive() wraps redirect URLs with the touch
15+
* endpoint when the Safari ITP fix is needed.
16+
*/
17+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('Safari ITP @generic @nextjs', ({ app }) => {
18+
test.describe.configure({ mode: 'serial' });
19+
20+
let fakeUser: FakeUser;
21+
22+
test.beforeAll(async () => {
23+
const u = createTestUtils({ app });
24+
fakeUser = u.services.users.createFakeUser();
25+
await u.services.users.createBapiUser(fakeUser);
26+
});
27+
28+
test.afterAll(async () => {
29+
await fakeUser.deleteIfExists();
30+
await app.teardown();
31+
});
32+
33+
// Skip: Intercepting client responses breaks JWT signature validation
34+
// The decorateUrl functionality is tested in the tests below
35+
test.skip('navigates through touch endpoint when cookie is close to expiration', async ({ page, context }) => {
36+
const u = createTestUtils({ app, page, context });
37+
38+
// Intercept client responses and modify cookie_expires_at to be within 8 days
39+
// This makes isEligibleForTouch() return true
40+
await page.route('**/v1/client**', async route => {
41+
// Skip touch endpoint - we want to track that separately
42+
if (route.request().url().includes('/v1/client/touch')) {
43+
await route.continue();
44+
return;
45+
}
46+
const response = await route.fetch();
47+
const json = await response.json();
48+
49+
// Set cookie to expire in 2 days (within the 8-day threshold)
50+
// The API returns milliseconds since epoch
51+
const twoDaysFromNow = Date.now() + 2 * 24 * 60 * 60 * 1000;
52+
json.response.cookie_expires_at = twoDaysFromNow;
53+
54+
await route.fulfill({
55+
response,
56+
json,
57+
});
58+
});
59+
60+
// Track if touch endpoint is called during navigation
61+
let touchEndpointCalled = false;
62+
let touchRedirectUrl: string | null = null;
63+
64+
await page.route('**/v1/client/touch**', async route => {
65+
touchEndpointCalled = true;
66+
const url = new URL(route.request().url());
67+
touchRedirectUrl = url.searchParams.get('redirect_url');
68+
// Let the request continue normally
69+
await route.continue();
70+
});
71+
72+
// Sign in
73+
await u.po.signIn.goTo();
74+
await u.po.signIn.setIdentifier(fakeUser.email);
75+
await u.po.signIn.continue();
76+
await u.po.signIn.setPassword(fakeUser.password);
77+
await u.po.signIn.continue();
78+
79+
// Wait for navigation to complete
80+
await u.po.expect.toBeSignedIn();
81+
82+
// Verify touch endpoint was called
83+
expect(touchEndpointCalled).toBe(true);
84+
expect(touchRedirectUrl).toBeTruthy();
85+
});
86+
87+
// Skip: Intercepting client responses breaks JWT signature validation
88+
// The decorateUrl functionality is tested in the tests below
89+
test.skip('does not use touch endpoint when cookie is not close to expiration', async ({ page, context }) => {
90+
const u = createTestUtils({ app, page, context });
91+
92+
// Intercept client responses and set cookie_expires_at to be far in the future
93+
// This makes isEligibleForTouch() return false
94+
await page.route('**/v1/client**', async route => {
95+
// Skip touch endpoint - we want to track that separately
96+
if (route.request().url().includes('/v1/client/touch')) {
97+
await route.continue();
98+
return;
99+
}
100+
101+
const response = await route.fetch();
102+
const json = await response.json();
103+
104+
// Set cookie to expire in 30 days (outside the 8-day threshold)
105+
// The API returns milliseconds since epoch
106+
const thirtyDaysFromNow = Date.now() + 30 * 24 * 60 * 60 * 1000;
107+
json.response.cookie_expires_at = thirtyDaysFromNow;
108+
109+
await route.fulfill({
110+
response,
111+
json,
112+
});
113+
});
114+
115+
// Track if touch endpoint is called
116+
let touchEndpointCalled = false;
117+
118+
await page.route('**/v1/client/touch**', async route => {
119+
touchEndpointCalled = true;
120+
await route.continue();
121+
});
122+
123+
// Sign in
124+
await u.po.signIn.goTo();
125+
await u.po.signIn.setIdentifier(fakeUser.email);
126+
await u.po.signIn.continue();
127+
await u.po.signIn.setPassword(fakeUser.password);
128+
await u.po.signIn.continue();
129+
130+
// Wait for navigation to complete
131+
await u.po.expect.toBeSignedIn();
132+
133+
// Verify touch endpoint was NOT called
134+
expect(touchEndpointCalled).toBe(false);
135+
});
136+
137+
test('decorateUrl returns touch URL when client is eligible for touch', async ({ page, context }) => {
138+
const u = createTestUtils({ app, page, context });
139+
140+
// Sign in first without mocking to get a valid session
141+
await u.po.signIn.goTo();
142+
await u.po.signIn.setIdentifier(fakeUser.email);
143+
await u.po.signIn.continue();
144+
await u.po.signIn.setPassword(fakeUser.password);
145+
await u.po.signIn.continue();
146+
await u.po.expect.toBeSignedIn();
147+
148+
// Now test setActive with a navigate callback that captures decorateUrl behavior
149+
const result = await page.evaluate(async () => {
150+
const clerk = (window as any).Clerk;
151+
152+
// Mock isEligibleForTouch to return true
153+
const originalIsEligibleForTouch = clerk.client.isEligibleForTouch.bind(clerk.client);
154+
clerk.client.isEligibleForTouch = () => true;
155+
156+
let capturedDecorateUrl: ((url: string) => string) | undefined;
157+
let decoratedUrl: string | undefined;
158+
159+
try {
160+
await clerk.setActive({
161+
session: clerk.session.id,
162+
navigate: ({ decorateUrl }: { decorateUrl: (url: string) => string }) => {
163+
capturedDecorateUrl = decorateUrl;
164+
decoratedUrl = decorateUrl('/dashboard');
165+
},
166+
});
167+
} finally {
168+
// Restore original
169+
clerk.client.isEligibleForTouch = originalIsEligibleForTouch;
170+
}
171+
172+
return {
173+
decorateUrlCaptured: !!capturedDecorateUrl,
174+
decoratedUrl,
175+
containsTouch: decoratedUrl?.includes('/v1/client/touch') ?? false,
176+
containsRedirectUrl: decoratedUrl?.includes('redirect_url=') ?? false,
177+
};
178+
});
179+
180+
expect(result.decorateUrlCaptured).toBe(true);
181+
expect(result.containsTouch).toBe(true);
182+
expect(result.containsRedirectUrl).toBe(true);
183+
});
184+
185+
test('decorateUrl returns original URL when client is not eligible for touch', async ({ page, context }) => {
186+
const u = createTestUtils({ app, page, context });
187+
188+
// Sign in first
189+
await u.po.signIn.goTo();
190+
await u.po.signIn.setIdentifier(fakeUser.email);
191+
await u.po.signIn.continue();
192+
await u.po.signIn.setPassword(fakeUser.password);
193+
await u.po.signIn.continue();
194+
await u.po.expect.toBeSignedIn();
195+
196+
// Test setActive with navigate callback when isEligibleForTouch is false
197+
const result = await page.evaluate(async () => {
198+
const clerk = (window as any).Clerk;
199+
200+
// Ensure isEligibleForTouch returns false
201+
const originalIsEligibleForTouch = clerk.client.isEligibleForTouch.bind(clerk.client);
202+
clerk.client.isEligibleForTouch = () => false;
203+
204+
let decoratedUrl: string | undefined;
205+
206+
try {
207+
await clerk.setActive({
208+
session: clerk.session.id,
209+
navigate: ({ decorateUrl }: { decorateUrl: (url: string) => string }) => {
210+
decoratedUrl = decorateUrl('/dashboard');
211+
},
212+
});
213+
} finally {
214+
// Restore original
215+
clerk.client.isEligibleForTouch = originalIsEligibleForTouch;
216+
}
217+
218+
return {
219+
decoratedUrl,
220+
isOriginalUrl: decoratedUrl === '/dashboard',
221+
containsTouch: decoratedUrl?.includes('/v1/client/touch') ?? false,
222+
};
223+
});
224+
225+
expect(result.isOriginalUrl).toBe(true);
226+
expect(result.containsTouch).toBe(false);
227+
});
228+
});

packages/clerk-js/src/core/__tests__/clerk.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,81 @@ describe('Clerk singleton', () => {
364364
expect(navigate).toHaveBeenCalled();
365365
});
366366

367+
it('passes decorateUrl to the navigate callback', async () => {
368+
mockSession.touch.mockReturnValue(Promise.resolve());
369+
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] }));
370+
const navigate = vi.fn();
371+
372+
const sut = new Clerk(productionPublishableKey);
373+
await sut.load();
374+
await sut.setActive({ session: mockSession as any as ActiveSessionResource, navigate });
375+
376+
expect(navigate).toHaveBeenCalledWith(
377+
expect.objectContaining({
378+
session: expect.any(Object),
379+
decorateUrl: expect.any(Function),
380+
}),
381+
);
382+
});
383+
384+
it('decorateUrl returns touch URL when isEligibleForTouch is true', async () => {
385+
mockSession.touch.mockReturnValue(Promise.resolve());
386+
mockClientFetch.mockReturnValue(
387+
Promise.resolve({
388+
signedInSessions: [mockSession],
389+
cookieExpiresAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now
390+
isEligibleForTouch: () => true,
391+
buildTouchUrl: ({ redirectUrl }: { redirectUrl: URL }) =>
392+
`https://clerk.example.com/v1/client/touch?redirect_url=${encodeURIComponent(redirectUrl.href)}`,
393+
}),
394+
);
395+
396+
let capturedDecorateUrl: ((url: string) => string) | undefined;
397+
const navigate = vi.fn(({ decorateUrl }) => {
398+
capturedDecorateUrl = decorateUrl;
399+
});
400+
401+
const sut = new Clerk(productionPublishableKey);
402+
await sut.load();
403+
await sut.setActive({ session: mockSession as any as ActiveSessionResource, navigate });
404+
405+
expect(capturedDecorateUrl).toBeDefined();
406+
const decoratedUrl = capturedDecorateUrl!('/dashboard');
407+
408+
// Should return touch URL when ITP fix is needed
409+
expect(decoratedUrl).toContain('/v1/client/touch');
410+
expect(decoratedUrl).toContain('redirect_url=');
411+
expect(decoratedUrl).toContain('%2Fdashboard');
412+
});
413+
414+
it('decorateUrl returns original URL when isEligibleForTouch is false', async () => {
415+
mockSession.touch.mockReturnValue(Promise.resolve());
416+
mockClientFetch.mockReturnValue(
417+
Promise.resolve({
418+
signedInSessions: [mockSession],
419+
cookieExpiresAt: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10 days from now
420+
isEligibleForTouch: () => false,
421+
buildTouchUrl: ({ redirectUrl }: { redirectUrl: URL }) =>
422+
`https://clerk.example.com/v1/client/touch?redirect_url=${encodeURIComponent(redirectUrl.href)}`,
423+
}),
424+
);
425+
426+
let capturedDecorateUrl: ((url: string) => string) | undefined;
427+
const navigate = vi.fn(({ decorateUrl }) => {
428+
capturedDecorateUrl = decorateUrl;
429+
});
430+
431+
const sut = new Clerk(productionPublishableKey);
432+
await sut.load();
433+
await sut.setActive({ session: mockSession as any as ActiveSessionResource, navigate });
434+
435+
expect(capturedDecorateUrl).toBeDefined();
436+
const decoratedUrl = capturedDecorateUrl!('/dashboard');
437+
438+
// Should return original URL when ITP fix is not needed
439+
expect(decoratedUrl).toBe('/dashboard');
440+
});
441+
367442
mockNativeRuntime(() => {
368443
it('calls session.touch in a non-standard browser', async () => {
369444
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] }));

packages/clerk-js/src/core/clerk.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1576,7 +1576,37 @@ export class Clerk implements ClerkInterface {
15761576
: taskUrl;
15771577
await this.navigate(taskUrlWithRedirect);
15781578
} else if (setActiveNavigate && newSession) {
1579-
await setActiveNavigate({ session: newSession });
1579+
// Track whether decorateUrl was called for dev-mode warning
1580+
let decorateUrlCalled = false;
1581+
1582+
/**
1583+
* Creates a URL that goes through the /v1/client/touch endpoint when Safari ITP fix is needed.
1584+
* This allows the session cookie to be refreshed via a full page navigation, bypassing
1585+
* Safari's 7-day cap on cookies set via fetch/XHR.
1586+
*/
1587+
const decorateUrl = (url: string): string => {
1588+
decorateUrlCalled = true;
1589+
1590+
if (!this.client?.isEligibleForTouch()) {
1591+
return url;
1592+
}
1593+
1594+
const absoluteUrl = new URL(url, window.location.href);
1595+
const touchUrl = this.client.buildTouchUrl({ redirectUrl: absoluteUrl });
1596+
return this.buildUrlWithAuth(touchUrl);
1597+
};
1598+
1599+
await setActiveNavigate({ session: newSession, decorateUrl });
1600+
1601+
// Warn in development if decorateUrl wasn't called but the client is eligible for touch
1602+
if (this.#instanceType === 'development' && !decorateUrlCalled && this.client.isEligibleForTouch()) {
1603+
logger.warnOnce(
1604+
'Clerk: The navigate callback in setActive() did not call decorateUrl(). ' +
1605+
'In Safari, sessions may be limited to 7 days due to Intelligent Tracking Prevention (ITP). ' +
1606+
'Use decorateUrl() to wrap your destination URL to enable the ITP workaround. ' +
1607+
'Learn more: https://clerk.com/docs/troubleshooting/safari-itp',
1608+
);
1609+
}
15801610
} else if (redirectUrl) {
15811611
if (this.client.isEligibleForTouch()) {
15821612
const absoluteRedirectUrl = new URL(redirectUrl, window.location.href);

0 commit comments

Comments
 (0)