Skip to content

Commit 59f7327

Browse files
jacekradkothiskevinwangwobsoriano
authored
fix(clerk-js): refresh session cookie on visibilitychange in cross-origin iframes (#8923)
Co-authored-by: Kevin Wang <26389321+thiskevinwang@users.noreply.github.com> Co-authored-by: wobsoriano <sorianorobertc@gmail.com>
1 parent 2ddaed5 commit 59f7327

7 files changed

Lines changed: 286 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Refresh the session cookie when a tab becomes visible again, not only on window `focus`. Apps embedded in a cross-origin iframe (for example a preview pane) never receive a `focus` event when their parent tab is re-activated, which could leave the session token stale and cause requests to fail with a 401 until the page was manually refreshed. clerk-js now also refreshes on `visibilitychange` (which does reach iframes) and allows a visible embedded frame to write the refreshed cookie.

packages/clerk-js/bundlewatch.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
55
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" },
66
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
7-
{ "path": "./dist/clerk.native.js", "maxSize": "72KB" },
7+
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
88
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
99
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
1010
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
const PORT = process.env.PORT || 4011;
4+
const HOST = `http://localhost:${PORT}`;
5+
6+
type ProbeEvent = { type: string; visibility: string; hasFocus: boolean; t: number };
7+
8+
/**
9+
* Real-browser proof of the browser facts behind the embedded-app (e.g. Replit preview) session-refresh
10+
* bug. clerk-js historically refreshed the session cookie only on the window `focus` event and gated the
11+
* cookie write on `document.hasFocus()`. Both assume a top-level browsing context. This test demonstrates,
12+
* in real Chromium, why that fails for a Clerk app running inside a cross-origin iframe:
13+
*
14+
* - a VISIBLE cross-origin iframe reports `document.hasFocus() === false`
15+
* - focusing the parent document never delivers a `focus` event to the iframe (it stays unfocused)
16+
* - the iframe only receives `focus` when it is explicitly focused (clicked into)
17+
*
18+
* So an embedded app is "visible but unfocused" essentially all the time, which is exactly why the
19+
* focus-only refresh never runs and the `!document.hasFocus()` write-gate blocks it. The fix adds a
20+
* `visibilitychange` trigger (which does propagate into the iframe) and lets a visible cross-origin
21+
* iframe write the cookie. clerk-js's response to `visibilitychange` is covered by the AuthCookieService
22+
* unit test; here we verify the browser semantics the fix relies on.
23+
*/
24+
test.describe('cross-origin iframe focus semantics @iframe', () => {
25+
test('a visible cross-origin iframe is unfocused and only receives focus when explicitly focused', async ({
26+
page,
27+
}) => {
28+
await page.goto(`${HOST}/iframe-host.html?framePath=/iframe-probe.html`);
29+
30+
const handle = await page.waitForSelector('#f');
31+
const frame = await handle.contentFrame();
32+
if (!frame) {
33+
throw new Error('iframe contentFrame() was null');
34+
}
35+
36+
await frame.waitForFunction(() => Array.isArray((window as any).__events));
37+
38+
// We are genuinely cross-origin: parent on localhost, iframe on 127.0.0.1.
39+
expect(page.url()).toContain('localhost');
40+
expect(frame.url()).toContain('127.0.0.1');
41+
42+
const liveState = () => frame.evaluate(() => ({ vis: document.visibilityState, hasFocus: document.hasFocus() }));
43+
const events = () => frame.evaluate(() => (window as any).__events as ProbeEvent[]);
44+
45+
// 1) The iframe is visible but does NOT have focus.
46+
const initial = await liveState();
47+
expect(initial.vis).toBe('visible');
48+
expect(initial.hasFocus).toBe(false);
49+
50+
// 2) Focusing an element in the PARENT document does not focus the iframe.
51+
await page.evaluate(() => {
52+
const input = document.createElement('input');
53+
document.body.appendChild(input);
54+
input.focus();
55+
});
56+
const afterParentFocus = await liveState();
57+
expect(afterParentFocus.vis).toBe('visible');
58+
expect(afterParentFocus.hasFocus).toBe(false);
59+
expect((await events()).some(e => e.type === 'window.focus')).toBe(false);
60+
61+
// 3) Explicitly focusing into the iframe (a real click) delivers `focus` and flips hasFocus.
62+
await handle.click();
63+
await frame.waitForFunction(() => (window as any).__events.some((e: ProbeEvent) => e.type === 'window.focus'));
64+
const afterIframeFocus = await liveState();
65+
expect(afterIframeFocus.hasFocus).toBe(true);
66+
});
67+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>iframe host (parent)</title>
6+
</head>
7+
<body>
8+
<h1>iframe host</h1>
9+
<p>Embeds a cross-origin iframe to reproduce the embedded-app (e.g. Replit preview) session-refresh scenario.</p>
10+
<iframe
11+
id="f"
12+
style="width: 480px; height: 360px; border: 1px solid #ccc"
13+
></iframe>
14+
<script>
15+
// Build a cross-origin URL for the iframe by swapping localhost <-> 127.0.0.1 on the same dev server.
16+
const params = new URLSearchParams(location.search);
17+
const framePath = params.get('framePath') || '/iframe-probe.html';
18+
const frameSearch = params.get('frameSearch') || '';
19+
const u = new URL(location.href);
20+
u.hostname = location.hostname === 'localhost' ? '127.0.0.1' : 'localhost';
21+
u.pathname = framePath;
22+
u.search = frameSearch;
23+
u.hash = '';
24+
window.__frameSrc = u.toString();
25+
document.getElementById('f').src = window.__frameSrc;
26+
</script>
27+
</body>
28+
</html>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>iframe probe (no Clerk)</title>
6+
</head>
7+
<body>
8+
<h1>iframe probe</h1>
9+
<script>
10+
// Records the events the embedded document actually receives, so a test can assert
11+
// which of `focus` / `visibilitychange` fire on parent-tab re-activation.
12+
window.__events = [];
13+
const rec = type =>
14+
window.__events.push({
15+
type,
16+
visibility: document.visibilityState,
17+
hasFocus: document.hasFocus(),
18+
t: Date.now(),
19+
});
20+
window.addEventListener('focus', () => rec('window.focus'));
21+
window.addEventListener('blur', () => rec('window.blur'));
22+
document.addEventListener('visibilitychange', () => rec('visibilitychange'));
23+
rec('init');
24+
</script>
25+
</body>
26+
</html>

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

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
isNetworkError,
1010
isUnauthenticatedError,
1111
} from '@clerk/shared/error';
12+
import { inCrossOriginIframe } from '@clerk/shared/internal/clerk-js/runtime';
1213
import type { Clerk, InstanceType } from '@clerk/shared/types';
1314
import { noop } from '@clerk/shared/utils';
1415

@@ -37,7 +38,7 @@ import { SessionCookiePoller } from './SessionCookiePoller';
3738
* and auth from the Clerk instance.
3839
* This service is responsible to:
3940
* - refresh the session cookie using a poller
40-
* - refresh the session cookie on tab visibility change
41+
* - refresh the session cookie on tab focus and visibility change
4142
* - update the related cookies listening to the `token:update` event
4243
* - initialize auth related cookies for development instances (eg __client_uat, __clerk_db_jwt)
4344
* - cookie setup for production / development instances
@@ -156,7 +157,7 @@ export class AuthCookieService {
156157
}
157158

158159
private refreshTokenOnFocus() {
159-
window.addEventListener('focus', () => {
160+
const refreshIfVisible = () => {
160161
if (document.visibilityState === 'visible') {
161162
// Certain data-fetching libraries that refetch on focus use setTimeout(cb, 0) to schedule a task on the event loop.
162163
// This gives us an opportunity to ensure the session cookie is updated with a fresh token before the fetch occurs, but it needs to
@@ -166,7 +167,15 @@ export class AuthCookieService {
166167
// While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break.
167168
void this.refreshSessionToken({ updateCookieImmediately: true });
168169
}
169-
});
170+
};
171+
172+
// `focus` covers top-level tabs (and the multi-tab active-organization handoff added in #3786), but it never fires
173+
// inside a cross-origin iframe on tab re-activation unless the frame itself is clicked. `visibilitychange` does
174+
// propagate into the iframe, so embedded apps (e.g. a preview pane) still get a fresh cookie before they refetch.
175+
window.addEventListener('focus', refreshIfVisible);
176+
if (typeof document !== 'undefined') {
177+
document.addEventListener('visibilitychange', refreshIfVisible);
178+
}
170179
}
171180

172181
private async refreshSessionToken({
@@ -189,8 +198,10 @@ export class AuthCookieService {
189198
}
190199

191200
private updateSessionCookie(token: string | null) {
192-
// Only allow background tabs to update if both session and organization match
193-
if (!document.hasFocus() && !this.isCurrentContextActive()) {
201+
// Only allow background tabs to update if both session and organization match.
202+
// A cross-origin iframe never reports focus even while visible, so treat a visible embedded
203+
// frame as eligible to write; top-level multi-tab ownership still keys on `document.hasFocus()`.
204+
if (!document.hasFocus() && !this.inVisibleCrossOriginIframe() && !this.isCurrentContextActive()) {
194205
return;
195206
}
196207

@@ -258,6 +269,10 @@ export class AuthCookieService {
258269
}
259270
}
260271

272+
private inVisibleCrossOriginIframe() {
273+
return inCrossOriginIframe() && document.visibilityState === 'visible';
274+
}
275+
261276
private isCurrentContextActive() {
262277
const activeContext = this.activeCookie.get();
263278
if (!activeContext) {
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { eventBus, events } from '../../events';
4+
5+
const mocks = vi.hoisted(() => ({
6+
sessionCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn() },
7+
clientUatCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn(() => 0) },
8+
activeContextCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn<() => string | undefined>(() => undefined) },
9+
inCrossOriginIframe: vi.fn(() => false),
10+
}));
11+
12+
vi.mock('../cookies/session', () => ({ createSessionCookie: () => mocks.sessionCookie }));
13+
vi.mock('../cookies/clientUat', () => ({ createClientUatCookie: () => mocks.clientUatCookie }));
14+
vi.mock('../cookies/activeContext', () => ({ createActiveContextCookie: () => mocks.activeContextCookie }));
15+
vi.mock('../cookieSuffix', () => ({ getCookieSuffix: vi.fn(() => Promise.resolve('suffix')) }));
16+
vi.mock('../devBrowser', () => ({
17+
createDevBrowser: () => ({
18+
clear: vi.fn(),
19+
setup: vi.fn(() => Promise.resolve()),
20+
getDevBrowser: vi.fn(() => 'deadbeef'),
21+
refreshCookies: vi.fn(),
22+
}),
23+
}));
24+
vi.mock('@clerk/shared/internal/clerk-js/runtime', async importOriginal => {
25+
const actual = await importOriginal<Record<string, unknown>>();
26+
return { ...actual, inCrossOriginIframe: () => mocks.inCrossOriginIframe() };
27+
});
28+
29+
import { AuthCookieService } from '../AuthCookieService';
30+
31+
const setFocus = (hasFocus: boolean) =>
32+
Object.defineProperty(document, 'hasFocus', { value: () => hasFocus, configurable: true });
33+
const setVisibility = (state: DocumentVisibilityState) =>
34+
Object.defineProperty(document, 'visibilityState', { value: state, configurable: true });
35+
36+
describe('AuthCookieService session cookie refresh', () => {
37+
const getToken = vi.fn(() => Promise.resolve('fresh-jwt'));
38+
const clerkStub = {
39+
publishableKey: 'pk_test_Y2xlcmsuYWJjZWYuMTIzNDUuZGV2LmxjbGNsZXJrLmNvbSQ',
40+
frontendApi: 'clerk.abcef.12345.dev.lclclerk.com',
41+
loaded: true,
42+
session: { id: 'sess_active', getToken },
43+
organization: null,
44+
user: {},
45+
client: {},
46+
handleUnauthenticated: vi.fn(),
47+
} as any;
48+
const clerkEventBusStub = { emit: vi.fn(), on: vi.fn(), prioritizedOn: vi.fn() } as any;
49+
50+
const createService = () => AuthCookieService.create(clerkStub, {} as any, 'production', clerkEventBusStub);
51+
const emitTokenUpdate = (raw: string) =>
52+
eventBus.emit(events.TokenUpdate, { token: { getRawString: () => raw } as any });
53+
54+
let service: Awaited<ReturnType<typeof createService>> | undefined;
55+
56+
beforeEach(() => {
57+
vi.clearAllMocks();
58+
mocks.inCrossOriginIframe.mockReturnValue(false);
59+
mocks.activeContextCookie.get.mockReturnValue(undefined);
60+
getToken.mockResolvedValue('fresh-jwt');
61+
setFocus(true);
62+
setVisibility('visible');
63+
});
64+
65+
afterEach(() => {
66+
service?.stopPollingForToken();
67+
service = undefined;
68+
// The service registers listeners on the shared event bus on construction.
69+
eventBus.off(events.TokenUpdate);
70+
eventBus.off(events.UserSignOut);
71+
eventBus.off(events.EnvironmentUpdate);
72+
});
73+
74+
it('registers both focus and visibilitychange listeners', async () => {
75+
const windowSpy = vi.spyOn(window, 'addEventListener');
76+
const documentSpy = vi.spyOn(document, 'addEventListener');
77+
78+
service = await createService();
79+
80+
expect(windowSpy).toHaveBeenCalledWith('focus', expect.any(Function));
81+
expect(documentSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
82+
});
83+
84+
it('writes the session cookie on token:update when the tab is focused', async () => {
85+
service = await createService();
86+
87+
emitTokenUpdate('jwt-focused');
88+
89+
expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-focused');
90+
});
91+
92+
it('does not write when unfocused, outside an iframe, and the active context does not match', async () => {
93+
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
94+
service = await createService();
95+
setFocus(false);
96+
97+
emitTokenUpdate('jwt-blocked');
98+
99+
expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-blocked');
100+
});
101+
102+
it('writes when unfocused but in a visible cross-origin iframe, even if the active context does not match', async () => {
103+
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
104+
mocks.inCrossOriginIframe.mockReturnValue(true);
105+
service = await createService();
106+
setFocus(false);
107+
setVisibility('visible');
108+
109+
emitTokenUpdate('jwt-iframe');
110+
111+
expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-iframe');
112+
});
113+
114+
it('does not write in a cross-origin iframe while it is hidden', async () => {
115+
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
116+
mocks.inCrossOriginIframe.mockReturnValue(true);
117+
service = await createService();
118+
setFocus(false);
119+
setVisibility('hidden');
120+
121+
emitTokenUpdate('jwt-hidden');
122+
123+
expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-hidden');
124+
});
125+
126+
it('refreshes the session cookie when an unfocused iframe becomes visible (visibilitychange)', async () => {
127+
mocks.inCrossOriginIframe.mockReturnValue(true);
128+
service = await createService();
129+
setFocus(false);
130+
setVisibility('visible');
131+
getToken.mockResolvedValue('jwt-on-visible');
132+
mocks.sessionCookie.set.mockClear();
133+
134+
document.dispatchEvent(new Event('visibilitychange'));
135+
await vi.waitFor(() => expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-on-visible'));
136+
137+
expect(getToken).toHaveBeenCalled();
138+
});
139+
});

0 commit comments

Comments
 (0)