Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/iframe-session-visibility-refresh.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

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.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "72KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
import { expect, test } from '@playwright/test';

const PORT = process.env.PORT || 4011;
const HOST = `http://localhost:${PORT}`;

type ProbeEvent = { type: string; visibility: string; hasFocus: boolean; t: number };

/**
* Real-browser proof of the browser facts behind the embedded-app (e.g. Replit preview) session-refresh
* bug. clerk-js historically refreshed the session cookie only on the window `focus` event and gated the
* cookie write on `document.hasFocus()`. Both assume a top-level browsing context. This test demonstrates,
* in real Chromium, why that fails for a Clerk app running inside a cross-origin iframe:
*
* - a VISIBLE cross-origin iframe reports `document.hasFocus() === false`
* - focusing the parent document never delivers a `focus` event to the iframe (it stays unfocused)
* - the iframe only receives `focus` when it is explicitly focused (clicked into)
*
* So an embedded app is "visible but unfocused" essentially all the time, which is exactly why the
* focus-only refresh never runs and the `!document.hasFocus()` write-gate blocks it. The fix adds a
* `visibilitychange` trigger (which does propagate into the iframe) and lets a visible cross-origin
* iframe write the cookie. clerk-js's response to `visibilitychange` is covered by the AuthCookieService
* unit test; here we verify the browser semantics the fix relies on.
*/
test.describe('cross-origin iframe focus semantics @iframe', () => {
test('a visible cross-origin iframe is unfocused and only receives focus when explicitly focused', async ({
page,
}) => {
await page.goto(`${HOST}/iframe-host.html?framePath=/iframe-probe.html`);

const handle = await page.waitForSelector('#f');
const frame = await handle.contentFrame();
if (!frame) {
throw new Error('iframe contentFrame() was null');
}

await frame.waitForFunction(() => Array.isArray((window as any).__events));

// We are genuinely cross-origin: parent on localhost, iframe on 127.0.0.1.
expect(page.url()).toContain('localhost');
expect(frame.url()).toContain('127.0.0.1');

const liveState = () => frame.evaluate(() => ({ vis: document.visibilityState, hasFocus: document.hasFocus() }));
const events = () => frame.evaluate(() => (window as any).__events as ProbeEvent[]);

// 1) The iframe is visible but does NOT have focus.
const initial = await liveState();
expect(initial.vis).toBe('visible');
expect(initial.hasFocus).toBe(false);

// 2) Focusing an element in the PARENT document does not focus the iframe.
await page.evaluate(() => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
});
const afterParentFocus = await liveState();
expect(afterParentFocus.vis).toBe('visible');
expect(afterParentFocus.hasFocus).toBe(false);
expect((await events()).some(e => e.type === 'window.focus')).toBe(false);

// 3) Explicitly focusing into the iframe (a real click) delivers `focus` and flips hasFocus.
await handle.click();
await frame.waitForFunction(() => (window as any).__events.some((e: ProbeEvent) => e.type === 'window.focus'));
const afterIframeFocus = await liveState();
expect(afterIframeFocus.hasFocus).toBe(true);
});
});
28 changes: 28 additions & 0 deletions packages/clerk-js/sandbox/public/iframe-host.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe host (parent)</title>
</head>
<body>
<h1>iframe host</h1>
<p>Embeds a cross-origin iframe to reproduce the embedded-app (e.g. Replit preview) session-refresh scenario.</p>
<iframe
id="f"
style="width: 480px; height: 360px; border: 1px solid #ccc"
></iframe>
<script>
// Build a cross-origin URL for the iframe by swapping localhost <-> 127.0.0.1 on the same dev server.
const params = new URLSearchParams(location.search);
const framePath = params.get('framePath') || '/iframe-probe.html';
const frameSearch = params.get('frameSearch') || '';
const u = new URL(location.href);
u.hostname = location.hostname === 'localhost' ? '127.0.0.1' : 'localhost';
u.pathname = framePath;
u.search = frameSearch;
u.hash = '';
window.__frameSrc = u.toString();
document.getElementById('f').src = window.__frameSrc;
</script>
</body>
</html>
26 changes: 26 additions & 0 deletions packages/clerk-js/sandbox/public/iframe-probe.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe probe (no Clerk)</title>
</head>
<body>
<h1>iframe probe</h1>
<script>
// Records the events the embedded document actually receives, so a test can assert
// which of `focus` / `visibilitychange` fire on parent-tab re-activation.
window.__events = [];
const rec = type =>
window.__events.push({
type,
visibility: document.visibilityState,
hasFocus: document.hasFocus(),
t: Date.now(),
});
window.addEventListener('focus', () => rec('window.focus'));
window.addEventListener('blur', () => rec('window.blur'));
document.addEventListener('visibilitychange', () => rec('visibilitychange'));
rec('init');
</script>
</body>
</html>
25 changes: 20 additions & 5 deletions packages/clerk-js/src/core/auth/AuthCookieService.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
isNetworkError,
isUnauthenticatedError,
} from '@clerk/shared/error';
import { inCrossOriginIframe } from '@clerk/shared/internal/clerk-js/runtime';
import type { Clerk, InstanceType } from '@clerk/shared/types';
import { noop } from '@clerk/shared/utils';

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

private refreshTokenOnFocus() {
window.addEventListener('focus', () => {
const refreshIfVisible = () => {
if (document.visibilityState === 'visible') {
// Certain data-fetching libraries that refetch on focus use setTimeout(cb, 0) to schedule a task on the event loop.
// This gives us an opportunity to ensure the session cookie is updated with a fresh token before the fetch occurs, but it needs to
Expand All@@ -166,7 +167,15 @@ export class AuthCookieService {
// While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break.
void this.refreshSessionToken({ updateCookieImmediately: true });
}
});
};

// `focus` covers top-level tabs (and the multi-tab active-organization handoff added in #3786), but it never fires
// inside a cross-origin iframe on tab re-activation unless the frame itself is clicked. `visibilitychange` does
// propagate into the iframe, so embedded apps (e.g. a preview pane) still get a fresh cookie before they refetch.
window.addEventListener('focus', refreshIfVisible);
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', refreshIfVisible);
}
}

private async refreshSessionToken({
Expand All@@ -189,8 +198,10 @@ export class AuthCookieService {
}

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

Expand DownExpand Up@@ -258,6 +269,10 @@ export class AuthCookieService {
}
}

private inVisibleCrossOriginIframe() {
return inCrossOriginIframe() && document.visibilityState === 'visible';
}

private isCurrentContextActive() {
const activeContext = this.activeCookie.get();
if (!activeContext) {
Expand Down
139 changes: 139 additions & 0 deletions packages/clerk-js/src/core/auth/__tests__/AuthCookieService.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { eventBus, events } from '../../events';

const mocks = vi.hoisted(() => ({
sessionCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn() },
clientUatCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn(() => 0) },
activeContextCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn<() => string | undefined>(() => undefined) },
inCrossOriginIframe: vi.fn(() => false),
}));

vi.mock('../cookies/session', () => ({ createSessionCookie: () => mocks.sessionCookie }));
vi.mock('../cookies/clientUat', () => ({ createClientUatCookie: () => mocks.clientUatCookie }));
vi.mock('../cookies/activeContext', () => ({ createActiveContextCookie: () => mocks.activeContextCookie }));
vi.mock('../cookieSuffix', () => ({ getCookieSuffix: vi.fn(() => Promise.resolve('suffix')) }));
vi.mock('../devBrowser', () => ({
createDevBrowser: () => ({
clear: vi.fn(),
setup: vi.fn(() => Promise.resolve()),
getDevBrowser: vi.fn(() => 'deadbeef'),
refreshCookies: vi.fn(),
}),
}));
vi.mock('@clerk/shared/internal/clerk-js/runtime', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, inCrossOriginIframe: () => mocks.inCrossOriginIframe() };
});

import { AuthCookieService } from '../AuthCookieService';

const setFocus = (hasFocus: boolean) =>
Object.defineProperty(document, 'hasFocus', { value: () => hasFocus, configurable: true });
const setVisibility = (state: DocumentVisibilityState) =>
Object.defineProperty(document, 'visibilityState', { value: state, configurable: true });

describe('AuthCookieService session cookie refresh', () => {
const getToken = vi.fn(() => Promise.resolve('fresh-jwt'));
const clerkStub = {
publishableKey: 'pk_test_Y2xlcmsuYWJjZWYuMTIzNDUuZGV2LmxjbGNsZXJrLmNvbSQ',
frontendApi: 'clerk.abcef.12345.dev.lclclerk.com',
loaded: true,
session: { id: 'sess_active', getToken },
organization: null,
user: {},
client: {},
handleUnauthenticated: vi.fn(),
} as any;
const clerkEventBusStub = { emit: vi.fn(), on: vi.fn(), prioritizedOn: vi.fn() } as any;

const createService = () => AuthCookieService.create(clerkStub, {} as any, 'production', clerkEventBusStub);
const emitTokenUpdate = (raw: string) =>
eventBus.emit(events.TokenUpdate, { token: { getRawString: () => raw } as any });

let service: Awaited<ReturnType<typeof createService>> | undefined;

beforeEach(() => {
vi.clearAllMocks();
mocks.inCrossOriginIframe.mockReturnValue(false);
mocks.activeContextCookie.get.mockReturnValue(undefined);
getToken.mockResolvedValue('fresh-jwt');
setFocus(true);
setVisibility('visible');
});

afterEach(() => {
service?.stopPollingForToken();
service = undefined;
// The service registers listeners on the shared event bus on construction.
eventBus.off(events.TokenUpdate);
eventBus.off(events.UserSignOut);
eventBus.off(events.EnvironmentUpdate);
});

it('registers both focus and visibilitychange listeners', async () => {
const windowSpy = vi.spyOn(window, 'addEventListener');
const documentSpy = vi.spyOn(document, 'addEventListener');

service = await createService();

expect(windowSpy).toHaveBeenCalledWith('focus', expect.any(Function));
expect(documentSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
});

it('writes the session cookie on token:update when the tab is focused', async () => {
service = await createService();

emitTokenUpdate('jwt-focused');

expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-focused');
});

it('does not write when unfocused, outside an iframe, and the active context does not match', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
service = await createService();
setFocus(false);

emitTokenUpdate('jwt-blocked');

expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-blocked');
});

it('writes when unfocused but in a visible cross-origin iframe, even if the active context does not match', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('visible');

emitTokenUpdate('jwt-iframe');

expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-iframe');
});

it('does not write in a cross-origin iframe while it is hidden', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('hidden');

emitTokenUpdate('jwt-hidden');

expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-hidden');
});

it('refreshes the session cookie when an unfocused iframe becomes visible (visibilitychange)', async () => {
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('visible');
getToken.mockResolvedValue('jwt-on-visible');
mocks.sessionCookie.set.mockClear();

document.dispatchEvent(new Event('visibilitychange'));
await vi.waitFor(() => expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-on-visible'));

expect(getToken).toHaveBeenCalled();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/iframe-session-visibility-refresh.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

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.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "72KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
import { expect, test } from '@playwright/test';

const PORT = process.env.PORT || 4011;
const HOST = `http://localhost:${PORT}`;

type ProbeEvent = { type: string; visibility: string; hasFocus: boolean; t: number };

/**
* Real-browser proof of the browser facts behind the embedded-app (e.g. Replit preview) session-refresh
* bug. clerk-js historically refreshed the session cookie only on the window `focus` event and gated the
* cookie write on `document.hasFocus()`. Both assume a top-level browsing context. This test demonstrates,
* in real Chromium, why that fails for a Clerk app running inside a cross-origin iframe:
*
* - a VISIBLE cross-origin iframe reports `document.hasFocus() === false`
* - focusing the parent document never delivers a `focus` event to the iframe (it stays unfocused)
* - the iframe only receives `focus` when it is explicitly focused (clicked into)
*
* So an embedded app is "visible but unfocused" essentially all the time, which is exactly why the
* focus-only refresh never runs and the `!document.hasFocus()` write-gate blocks it. The fix adds a
* `visibilitychange` trigger (which does propagate into the iframe) and lets a visible cross-origin
* iframe write the cookie. clerk-js's response to `visibilitychange` is covered by the AuthCookieService
* unit test; here we verify the browser semantics the fix relies on.
*/
test.describe('cross-origin iframe focus semantics @iframe', () => {
test('a visible cross-origin iframe is unfocused and only receives focus when explicitly focused', async ({
page,
}) => {
await page.goto(`${HOST}/iframe-host.html?framePath=/iframe-probe.html`);

const handle = await page.waitForSelector('#f');
const frame = await handle.contentFrame();
if (!frame) {
throw new Error('iframe contentFrame() was null');
}

await frame.waitForFunction(() => Array.isArray((window as any).__events));

// We are genuinely cross-origin: parent on localhost, iframe on 127.0.0.1.
expect(page.url()).toContain('localhost');
expect(frame.url()).toContain('127.0.0.1');

const liveState = () => frame.evaluate(() => ({ vis: document.visibilityState, hasFocus: document.hasFocus() }));
const events = () => frame.evaluate(() => (window as any).__events as ProbeEvent[]);

// 1) The iframe is visible but does NOT have focus.
const initial = await liveState();
expect(initial.vis).toBe('visible');
expect(initial.hasFocus).toBe(false);

// 2) Focusing an element in the PARENT document does not focus the iframe.
await page.evaluate(() => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
});
const afterParentFocus = await liveState();
expect(afterParentFocus.vis).toBe('visible');
expect(afterParentFocus.hasFocus).toBe(false);
expect((await events()).some(e => e.type === 'window.focus')).toBe(false);

// 3) Explicitly focusing into the iframe (a real click) delivers `focus` and flips hasFocus.
await handle.click();
await frame.waitForFunction(() => (window as any).__events.some((e: ProbeEvent) => e.type === 'window.focus'));
const afterIframeFocus = await liveState();
expect(afterIframeFocus.hasFocus).toBe(true);
});
});
28 changes: 28 additions & 0 deletions packages/clerk-js/sandbox/public/iframe-host.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe host (parent)</title>
</head>
<body>
<h1>iframe host</h1>
<p>Embeds a cross-origin iframe to reproduce the embedded-app (e.g. Replit preview) session-refresh scenario.</p>
<iframe
id="f"
style="width: 480px; height: 360px; border: 1px solid #ccc"
></iframe>
<script>
// Build a cross-origin URL for the iframe by swapping localhost <-> 127.0.0.1 on the same dev server.
const params = new URLSearchParams(location.search);
const framePath = params.get('framePath') || '/iframe-probe.html';
const frameSearch = params.get('frameSearch') || '';
const u = new URL(location.href);
u.hostname = location.hostname === 'localhost' ? '127.0.0.1' : 'localhost';
u.pathname = framePath;
u.search = frameSearch;
u.hash = '';
window.__frameSrc = u.toString();
document.getElementById('f').src = window.__frameSrc;
</script>
</body>
</html>
26 changes: 26 additions & 0 deletions packages/clerk-js/sandbox/public/iframe-probe.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe probe (no Clerk)</title>
</head>
<body>
<h1>iframe probe</h1>
<script>
// Records the events the embedded document actually receives, so a test can assert
// which of `focus` / `visibilitychange` fire on parent-tab re-activation.
window.__events = [];
const rec = type =>
window.__events.push({
type,
visibility: document.visibilityState,
hasFocus: document.hasFocus(),
t: Date.now(),
});
window.addEventListener('focus', () => rec('window.focus'));
window.addEventListener('blur', () => rec('window.blur'));
document.addEventListener('visibilitychange', () => rec('visibilitychange'));
rec('init');
</script>
</body>
</html>
25 changes: 20 additions & 5 deletions packages/clerk-js/src/core/auth/AuthCookieService.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
isNetworkError,
isUnauthenticatedError,
} from '@clerk/shared/error';
import { inCrossOriginIframe } from '@clerk/shared/internal/clerk-js/runtime';
import type { Clerk, InstanceType } from '@clerk/shared/types';
import { noop } from '@clerk/shared/utils';

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

private refreshTokenOnFocus() {
window.addEventListener('focus', () => {
const refreshIfVisible = () => {
if (document.visibilityState === 'visible') {
// Certain data-fetching libraries that refetch on focus use setTimeout(cb, 0) to schedule a task on the event loop.
// This gives us an opportunity to ensure the session cookie is updated with a fresh token before the fetch occurs, but it needs to
Expand All@@ -166,7 +167,15 @@ export class AuthCookieService {
// While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break.
void this.refreshSessionToken({ updateCookieImmediately: true });
}
});
};

// `focus` covers top-level tabs (and the multi-tab active-organization handoff added in #3786), but it never fires
// inside a cross-origin iframe on tab re-activation unless the frame itself is clicked. `visibilitychange` does
// propagate into the iframe, so embedded apps (e.g. a preview pane) still get a fresh cookie before they refetch.
window.addEventListener('focus', refreshIfVisible);
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', refreshIfVisible);
}
}

private async refreshSessionToken({
Expand All@@ -189,8 +198,10 @@ export class AuthCookieService {
}

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

Expand DownExpand Up@@ -258,6 +269,10 @@ export class AuthCookieService {
}
}

private inVisibleCrossOriginIframe() {
return inCrossOriginIframe() && document.visibilityState === 'visible';
}

private isCurrentContextActive() {
const activeContext = this.activeCookie.get();
if (!activeContext) {
Expand Down
139 changes: 139 additions & 0 deletions packages/clerk-js/src/core/auth/__tests__/AuthCookieService.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { eventBus, events } from '../../events';

const mocks = vi.hoisted(() => ({
sessionCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn() },
clientUatCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn(() => 0) },
activeContextCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn<() => string | undefined>(() => undefined) },
inCrossOriginIframe: vi.fn(() => false),
}));

vi.mock('../cookies/session', () => ({ createSessionCookie: () => mocks.sessionCookie }));
vi.mock('../cookies/clientUat', () => ({ createClientUatCookie: () => mocks.clientUatCookie }));
vi.mock('../cookies/activeContext', () => ({ createActiveContextCookie: () => mocks.activeContextCookie }));
vi.mock('../cookieSuffix', () => ({ getCookieSuffix: vi.fn(() => Promise.resolve('suffix')) }));
vi.mock('../devBrowser', () => ({
createDevBrowser: () => ({
clear: vi.fn(),
setup: vi.fn(() => Promise.resolve()),
getDevBrowser: vi.fn(() => 'deadbeef'),
refreshCookies: vi.fn(),
}),
}));
vi.mock('@clerk/shared/internal/clerk-js/runtime', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, inCrossOriginIframe: () => mocks.inCrossOriginIframe() };
});

import { AuthCookieService } from '../AuthCookieService';

const setFocus = (hasFocus: boolean) =>
Object.defineProperty(document, 'hasFocus', { value: () => hasFocus, configurable: true });
const setVisibility = (state: DocumentVisibilityState) =>
Object.defineProperty(document, 'visibilityState', { value: state, configurable: true });

describe('AuthCookieService session cookie refresh', () => {
const getToken = vi.fn(() => Promise.resolve('fresh-jwt'));
const clerkStub = {
publishableKey: 'pk_test_Y2xlcmsuYWJjZWYuMTIzNDUuZGV2LmxjbGNsZXJrLmNvbSQ',
frontendApi: 'clerk.abcef.12345.dev.lclclerk.com',
loaded: true,
session: { id: 'sess_active', getToken },
organization: null,
user: {},
client: {},
handleUnauthenticated: vi.fn(),
} as any;
const clerkEventBusStub = { emit: vi.fn(), on: vi.fn(), prioritizedOn: vi.fn() } as any;

const createService = () => AuthCookieService.create(clerkStub, {} as any, 'production', clerkEventBusStub);
const emitTokenUpdate = (raw: string) =>
eventBus.emit(events.TokenUpdate, { token: { getRawString: () => raw } as any });

let service: Awaited<ReturnType<typeof createService>> | undefined;

beforeEach(() => {
vi.clearAllMocks();
mocks.inCrossOriginIframe.mockReturnValue(false);
mocks.activeContextCookie.get.mockReturnValue(undefined);
getToken.mockResolvedValue('fresh-jwt');
setFocus(true);
setVisibility('visible');
});

afterEach(() => {
service?.stopPollingForToken();
service = undefined;
// The service registers listeners on the shared event bus on construction.
eventBus.off(events.TokenUpdate);
eventBus.off(events.UserSignOut);
eventBus.off(events.EnvironmentUpdate);
});

it('registers both focus and visibilitychange listeners', async () => {
const windowSpy = vi.spyOn(window, 'addEventListener');
const documentSpy = vi.spyOn(document, 'addEventListener');

service = await createService();

expect(windowSpy).toHaveBeenCalledWith('focus', expect.any(Function));
expect(documentSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
});

it('writes the session cookie on token:update when the tab is focused', async () => {
service = await createService();

emitTokenUpdate('jwt-focused');

expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-focused');
});

it('does not write when unfocused, outside an iframe, and the active context does not match', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
service = await createService();
setFocus(false);

emitTokenUpdate('jwt-blocked');

expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-blocked');
});

it('writes when unfocused but in a visible cross-origin iframe, even if the active context does not match', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('visible');

emitTokenUpdate('jwt-iframe');

expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-iframe');
});

it('does not write in a cross-origin iframe while it is hidden', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('hidden');

emitTokenUpdate('jwt-hidden');

expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-hidden');
});

it('refreshes the session cookie when an unfocused iframe becomes visible (visibilitychange)', async () => {
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('visible');
getToken.mockResolvedValue('jwt-on-visible');
mocks.sessionCookie.set.mockClear();

document.dispatchEvent(new Event('visibilitychange'));
await vi.waitFor(() => expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-on-visible'));

expect(getToken).toHaveBeenCalled();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/iframe-session-visibility-refresh.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

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.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "72KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
import { expect, test } from '@playwright/test';

const PORT = process.env.PORT || 4011;
const HOST = `http://localhost:${PORT}`;

type ProbeEvent = { type: string; visibility: string; hasFocus: boolean; t: number };

/**
* Real-browser proof of the browser facts behind the embedded-app (e.g. Replit preview) session-refresh
* bug. clerk-js historically refreshed the session cookie only on the window `focus` event and gated the
* cookie write on `document.hasFocus()`. Both assume a top-level browsing context. This test demonstrates,
* in real Chromium, why that fails for a Clerk app running inside a cross-origin iframe:
*
* - a VISIBLE cross-origin iframe reports `document.hasFocus() === false`
* - focusing the parent document never delivers a `focus` event to the iframe (it stays unfocused)
* - the iframe only receives `focus` when it is explicitly focused (clicked into)
*
* So an embedded app is "visible but unfocused" essentially all the time, which is exactly why the
* focus-only refresh never runs and the `!document.hasFocus()` write-gate blocks it. The fix adds a
* `visibilitychange` trigger (which does propagate into the iframe) and lets a visible cross-origin
* iframe write the cookie. clerk-js's response to `visibilitychange` is covered by the AuthCookieService
* unit test; here we verify the browser semantics the fix relies on.
*/
test.describe('cross-origin iframe focus semantics @iframe', () => {
test('a visible cross-origin iframe is unfocused and only receives focus when explicitly focused', async ({
page,
}) => {
await page.goto(`${HOST}/iframe-host.html?framePath=/iframe-probe.html`);

const handle = await page.waitForSelector('#f');
const frame = await handle.contentFrame();
if (!frame) {
throw new Error('iframe contentFrame() was null');
}

await frame.waitForFunction(() => Array.isArray((window as any).__events));

// We are genuinely cross-origin: parent on localhost, iframe on 127.0.0.1.
expect(page.url()).toContain('localhost');
expect(frame.url()).toContain('127.0.0.1');

const liveState = () => frame.evaluate(() => ({ vis: document.visibilityState, hasFocus: document.hasFocus() }));
const events = () => frame.evaluate(() => (window as any).__events as ProbeEvent[]);

// 1) The iframe is visible but does NOT have focus.
const initial = await liveState();
expect(initial.vis).toBe('visible');
expect(initial.hasFocus).toBe(false);

// 2) Focusing an element in the PARENT document does not focus the iframe.
await page.evaluate(() => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
});
const afterParentFocus = await liveState();
expect(afterParentFocus.vis).toBe('visible');
expect(afterParentFocus.hasFocus).toBe(false);
expect((await events()).some(e => e.type === 'window.focus')).toBe(false);

// 3) Explicitly focusing into the iframe (a real click) delivers `focus` and flips hasFocus.
await handle.click();
await frame.waitForFunction(() => (window as any).__events.some((e: ProbeEvent) => e.type === 'window.focus'));
const afterIframeFocus = await liveState();
expect(afterIframeFocus.hasFocus).toBe(true);
});
});
28 changes: 28 additions & 0 deletions packages/clerk-js/sandbox/public/iframe-host.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe host (parent)</title>
</head>
<body>
<h1>iframe host</h1>
<p>Embeds a cross-origin iframe to reproduce the embedded-app (e.g. Replit preview) session-refresh scenario.</p>
<iframe
id="f"
style="width: 480px; height: 360px; border: 1px solid #ccc"
></iframe>
<script>
// Build a cross-origin URL for the iframe by swapping localhost <-> 127.0.0.1 on the same dev server.
const params = new URLSearchParams(location.search);
const framePath = params.get('framePath') || '/iframe-probe.html';
const frameSearch = params.get('frameSearch') || '';
const u = new URL(location.href);
u.hostname = location.hostname === 'localhost' ? '127.0.0.1' : 'localhost';
u.pathname = framePath;
u.search = frameSearch;
u.hash = '';
window.__frameSrc = u.toString();
document.getElementById('f').src = window.__frameSrc;
</script>
</body>
</html>
26 changes: 26 additions & 0 deletions packages/clerk-js/sandbox/public/iframe-probe.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe probe (no Clerk)</title>
</head>
<body>
<h1>iframe probe</h1>
<script>
// Records the events the embedded document actually receives, so a test can assert
// which of `focus` / `visibilitychange` fire on parent-tab re-activation.
window.__events = [];
const rec = type =>
window.__events.push({
type,
visibility: document.visibilityState,
hasFocus: document.hasFocus(),
t: Date.now(),
});
window.addEventListener('focus', () => rec('window.focus'));
window.addEventListener('blur', () => rec('window.blur'));
document.addEventListener('visibilitychange', () => rec('visibilitychange'));
rec('init');
</script>
</body>
</html>
25 changes: 20 additions & 5 deletions packages/clerk-js/src/core/auth/AuthCookieService.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
isNetworkError,
isUnauthenticatedError,
} from '@clerk/shared/error';
import { inCrossOriginIframe } from '@clerk/shared/internal/clerk-js/runtime';
import type { Clerk, InstanceType } from '@clerk/shared/types';
import { noop } from '@clerk/shared/utils';

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

private refreshTokenOnFocus() {
window.addEventListener('focus', () => {
const refreshIfVisible = () => {
if (document.visibilityState === 'visible') {
// Certain data-fetching libraries that refetch on focus use setTimeout(cb, 0) to schedule a task on the event loop.
// This gives us an opportunity to ensure the session cookie is updated with a fresh token before the fetch occurs, but it needs to
Expand All@@ -166,7 +167,15 @@ export class AuthCookieService {
// While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break.
void this.refreshSessionToken({ updateCookieImmediately: true });
}
});
};

// `focus` covers top-level tabs (and the multi-tab active-organization handoff added in #3786), but it never fires
// inside a cross-origin iframe on tab re-activation unless the frame itself is clicked. `visibilitychange` does
// propagate into the iframe, so embedded apps (e.g. a preview pane) still get a fresh cookie before they refetch.
window.addEventListener('focus', refreshIfVisible);
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', refreshIfVisible);
}
}

private async refreshSessionToken({
Expand All@@ -189,8 +198,10 @@ export class AuthCookieService {
}

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

Expand DownExpand Up@@ -258,6 +269,10 @@ export class AuthCookieService {
}
}

private inVisibleCrossOriginIframe() {
return inCrossOriginIframe() && document.visibilityState === 'visible';
}

private isCurrentContextActive() {
const activeContext = this.activeCookie.get();
if (!activeContext) {
Expand Down
139 changes: 139 additions & 0 deletions packages/clerk-js/src/core/auth/__tests__/AuthCookieService.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { eventBus, events } from '../../events';

const mocks = vi.hoisted(() => ({
sessionCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn() },
clientUatCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn(() => 0) },
activeContextCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn<() => string | undefined>(() => undefined) },
inCrossOriginIframe: vi.fn(() => false),
}));

vi.mock('../cookies/session', () => ({ createSessionCookie: () => mocks.sessionCookie }));
vi.mock('../cookies/clientUat', () => ({ createClientUatCookie: () => mocks.clientUatCookie }));
vi.mock('../cookies/activeContext', () => ({ createActiveContextCookie: () => mocks.activeContextCookie }));
vi.mock('../cookieSuffix', () => ({ getCookieSuffix: vi.fn(() => Promise.resolve('suffix')) }));
vi.mock('../devBrowser', () => ({
createDevBrowser: () => ({
clear: vi.fn(),
setup: vi.fn(() => Promise.resolve()),
getDevBrowser: vi.fn(() => 'deadbeef'),
refreshCookies: vi.fn(),
}),
}));
vi.mock('@clerk/shared/internal/clerk-js/runtime', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, inCrossOriginIframe: () => mocks.inCrossOriginIframe() };
});

import { AuthCookieService } from '../AuthCookieService';

const setFocus = (hasFocus: boolean) =>
Object.defineProperty(document, 'hasFocus', { value: () => hasFocus, configurable: true });
const setVisibility = (state: DocumentVisibilityState) =>
Object.defineProperty(document, 'visibilityState', { value: state, configurable: true });

describe('AuthCookieService session cookie refresh', () => {
const getToken = vi.fn(() => Promise.resolve('fresh-jwt'));
const clerkStub = {
publishableKey: 'pk_test_Y2xlcmsuYWJjZWYuMTIzNDUuZGV2LmxjbGNsZXJrLmNvbSQ',
frontendApi: 'clerk.abcef.12345.dev.lclclerk.com',
loaded: true,
session: { id: 'sess_active', getToken },
organization: null,
user: {},
client: {},
handleUnauthenticated: vi.fn(),
} as any;
const clerkEventBusStub = { emit: vi.fn(), on: vi.fn(), prioritizedOn: vi.fn() } as any;

const createService = () => AuthCookieService.create(clerkStub, {} as any, 'production', clerkEventBusStub);
const emitTokenUpdate = (raw: string) =>
eventBus.emit(events.TokenUpdate, { token: { getRawString: () => raw } as any });

let service: Awaited<ReturnType<typeof createService>> | undefined;

beforeEach(() => {
vi.clearAllMocks();
mocks.inCrossOriginIframe.mockReturnValue(false);
mocks.activeContextCookie.get.mockReturnValue(undefined);
getToken.mockResolvedValue('fresh-jwt');
setFocus(true);
setVisibility('visible');
});

afterEach(() => {
service?.stopPollingForToken();
service = undefined;
// The service registers listeners on the shared event bus on construction.
eventBus.off(events.TokenUpdate);
eventBus.off(events.UserSignOut);
eventBus.off(events.EnvironmentUpdate);
});

it('registers both focus and visibilitychange listeners', async () => {
const windowSpy = vi.spyOn(window, 'addEventListener');
const documentSpy = vi.spyOn(document, 'addEventListener');

service = await createService();

expect(windowSpy).toHaveBeenCalledWith('focus', expect.any(Function));
expect(documentSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
});

it('writes the session cookie on token:update when the tab is focused', async () => {
service = await createService();

emitTokenUpdate('jwt-focused');

expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-focused');
});

it('does not write when unfocused, outside an iframe, and the active context does not match', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
service = await createService();
setFocus(false);

emitTokenUpdate('jwt-blocked');

expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-blocked');
});

it('writes when unfocused but in a visible cross-origin iframe, even if the active context does not match', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('visible');

emitTokenUpdate('jwt-iframe');

expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-iframe');
});

it('does not write in a cross-origin iframe while it is hidden', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('hidden');

emitTokenUpdate('jwt-hidden');

expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-hidden');
});

it('refreshes the session cookie when an unfocused iframe becomes visible (visibilitychange)', async () => {
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('visible');
getToken.mockResolvedValue('jwt-on-visible');
mocks.sessionCookie.set.mockClear();

document.dispatchEvent(new Event('visibilitychange'));
await vi.waitFor(() => expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-on-visible'));

expect(getToken).toHaveBeenCalled();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/iframe-session-visibility-refresh.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

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.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "72KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
import { expect, test } from '@playwright/test';

const PORT = process.env.PORT || 4011;
const HOST = `http://localhost:${PORT}`;

type ProbeEvent = { type: string; visibility: string; hasFocus: boolean; t: number };

/**
* Real-browser proof of the browser facts behind the embedded-app (e.g. Replit preview) session-refresh
* bug. clerk-js historically refreshed the session cookie only on the window `focus` event and gated the
* cookie write on `document.hasFocus()`. Both assume a top-level browsing context. This test demonstrates,
* in real Chromium, why that fails for a Clerk app running inside a cross-origin iframe:
*
* - a VISIBLE cross-origin iframe reports `document.hasFocus() === false`
* - focusing the parent document never delivers a `focus` event to the iframe (it stays unfocused)
* - the iframe only receives `focus` when it is explicitly focused (clicked into)
*
* So an embedded app is "visible but unfocused" essentially all the time, which is exactly why the
* focus-only refresh never runs and the `!document.hasFocus()` write-gate blocks it. The fix adds a
* `visibilitychange` trigger (which does propagate into the iframe) and lets a visible cross-origin
* iframe write the cookie. clerk-js's response to `visibilitychange` is covered by the AuthCookieService
* unit test; here we verify the browser semantics the fix relies on.
*/
test.describe('cross-origin iframe focus semantics @iframe', () => {
test('a visible cross-origin iframe is unfocused and only receives focus when explicitly focused', async ({
page,
}) => {
await page.goto(`${HOST}/iframe-host.html?framePath=/iframe-probe.html`);

const handle = await page.waitForSelector('#f');
const frame = await handle.contentFrame();
if (!frame) {
throw new Error('iframe contentFrame() was null');
}

await frame.waitForFunction(() => Array.isArray((window as any).__events));

// We are genuinely cross-origin: parent on localhost, iframe on 127.0.0.1.
expect(page.url()).toContain('localhost');
expect(frame.url()).toContain('127.0.0.1');

const liveState = () => frame.evaluate(() => ({ vis: document.visibilityState, hasFocus: document.hasFocus() }));
const events = () => frame.evaluate(() => (window as any).__events as ProbeEvent[]);

// 1) The iframe is visible but does NOT have focus.
const initial = await liveState();
expect(initial.vis).toBe('visible');
expect(initial.hasFocus).toBe(false);

// 2) Focusing an element in the PARENT document does not focus the iframe.
await page.evaluate(() => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
});
const afterParentFocus = await liveState();
expect(afterParentFocus.vis).toBe('visible');
expect(afterParentFocus.hasFocus).toBe(false);
expect((await events()).some(e => e.type === 'window.focus')).toBe(false);

// 3) Explicitly focusing into the iframe (a real click) delivers `focus` and flips hasFocus.
await handle.click();
await frame.waitForFunction(() => (window as any).__events.some((e: ProbeEvent) => e.type === 'window.focus'));
const afterIframeFocus = await liveState();
expect(afterIframeFocus.hasFocus).toBe(true);
});
});
28 changes: 28 additions & 0 deletions packages/clerk-js/sandbox/public/iframe-host.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe host (parent)</title>
</head>
<body>
<h1>iframe host</h1>
<p>Embeds a cross-origin iframe to reproduce the embedded-app (e.g. Replit preview) session-refresh scenario.</p>
<iframe
id="f"
style="width: 480px; height: 360px; border: 1px solid #ccc"
></iframe>
<script>
// Build a cross-origin URL for the iframe by swapping localhost <-> 127.0.0.1 on the same dev server.
const params = new URLSearchParams(location.search);
const framePath = params.get('framePath') || '/iframe-probe.html';
const frameSearch = params.get('frameSearch') || '';
const u = new URL(location.href);
u.hostname = location.hostname === 'localhost' ? '127.0.0.1' : 'localhost';
u.pathname = framePath;
u.search = frameSearch;
u.hash = '';
window.__frameSrc = u.toString();
document.getElementById('f').src = window.__frameSrc;
</script>
</body>
</html>
26 changes: 26 additions & 0 deletions packages/clerk-js/sandbox/public/iframe-probe.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe probe (no Clerk)</title>
</head>
<body>
<h1>iframe probe</h1>
<script>
// Records the events the embedded document actually receives, so a test can assert
// which of `focus` / `visibilitychange` fire on parent-tab re-activation.
window.__events = [];
const rec = type =>
window.__events.push({
type,
visibility: document.visibilityState,
hasFocus: document.hasFocus(),
t: Date.now(),
});
window.addEventListener('focus', () => rec('window.focus'));
window.addEventListener('blur', () => rec('window.blur'));
document.addEventListener('visibilitychange', () => rec('visibilitychange'));
rec('init');
</script>
</body>
</html>
25 changes: 20 additions & 5 deletions packages/clerk-js/src/core/auth/AuthCookieService.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
isNetworkError,
isUnauthenticatedError,
} from '@clerk/shared/error';
import { inCrossOriginIframe } from '@clerk/shared/internal/clerk-js/runtime';
import type { Clerk, InstanceType } from '@clerk/shared/types';
import { noop } from '@clerk/shared/utils';

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

private refreshTokenOnFocus() {
window.addEventListener('focus', () => {
const refreshIfVisible = () => {
if (document.visibilityState === 'visible') {
// Certain data-fetching libraries that refetch on focus use setTimeout(cb, 0) to schedule a task on the event loop.
// This gives us an opportunity to ensure the session cookie is updated with a fresh token before the fetch occurs, but it needs to
Expand All@@ -166,7 +167,15 @@ export class AuthCookieService {
// While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break.
void this.refreshSessionToken({ updateCookieImmediately: true });
}
});
};

// `focus` covers top-level tabs (and the multi-tab active-organization handoff added in #3786), but it never fires
// inside a cross-origin iframe on tab re-activation unless the frame itself is clicked. `visibilitychange` does
// propagate into the iframe, so embedded apps (e.g. a preview pane) still get a fresh cookie before they refetch.
window.addEventListener('focus', refreshIfVisible);
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', refreshIfVisible);
}
}

private async refreshSessionToken({
Expand All@@ -189,8 +198,10 @@ export class AuthCookieService {
}

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

Expand DownExpand Up@@ -258,6 +269,10 @@ export class AuthCookieService {
}
}

private inVisibleCrossOriginIframe() {
return inCrossOriginIframe() && document.visibilityState === 'visible';
}

private isCurrentContextActive() {
const activeContext = this.activeCookie.get();
if (!activeContext) {
Expand Down
139 changes: 139 additions & 0 deletions packages/clerk-js/src/core/auth/__tests__/AuthCookieService.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { eventBus, events } from '../../events';

const mocks = vi.hoisted(() => ({
sessionCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn() },
clientUatCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn(() => 0) },
activeContextCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn<() => string | undefined>(() => undefined) },
inCrossOriginIframe: vi.fn(() => false),
}));

vi.mock('../cookies/session', () => ({ createSessionCookie: () => mocks.sessionCookie }));
vi.mock('../cookies/clientUat', () => ({ createClientUatCookie: () => mocks.clientUatCookie }));
vi.mock('../cookies/activeContext', () => ({ createActiveContextCookie: () => mocks.activeContextCookie }));
vi.mock('../cookieSuffix', () => ({ getCookieSuffix: vi.fn(() => Promise.resolve('suffix')) }));
vi.mock('../devBrowser', () => ({
createDevBrowser: () => ({
clear: vi.fn(),
setup: vi.fn(() => Promise.resolve()),
getDevBrowser: vi.fn(() => 'deadbeef'),
refreshCookies: vi.fn(),
}),
}));
vi.mock('@clerk/shared/internal/clerk-js/runtime', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, inCrossOriginIframe: () => mocks.inCrossOriginIframe() };
});

import { AuthCookieService } from '../AuthCookieService';

const setFocus = (hasFocus: boolean) =>
Object.defineProperty(document, 'hasFocus', { value: () => hasFocus, configurable: true });
const setVisibility = (state: DocumentVisibilityState) =>
Object.defineProperty(document, 'visibilityState', { value: state, configurable: true });

describe('AuthCookieService session cookie refresh', () => {
const getToken = vi.fn(() => Promise.resolve('fresh-jwt'));
const clerkStub = {
publishableKey: 'pk_test_Y2xlcmsuYWJjZWYuMTIzNDUuZGV2LmxjbGNsZXJrLmNvbSQ',
frontendApi: 'clerk.abcef.12345.dev.lclclerk.com',
loaded: true,
session: { id: 'sess_active', getToken },
organization: null,
user: {},
client: {},
handleUnauthenticated: vi.fn(),
} as any;
const clerkEventBusStub = { emit: vi.fn(), on: vi.fn(), prioritizedOn: vi.fn() } as any;

const createService = () => AuthCookieService.create(clerkStub, {} as any, 'production', clerkEventBusStub);
const emitTokenUpdate = (raw: string) =>
eventBus.emit(events.TokenUpdate, { token: { getRawString: () => raw } as any });

let service: Awaited<ReturnType<typeof createService>> | undefined;

beforeEach(() => {
vi.clearAllMocks();
mocks.inCrossOriginIframe.mockReturnValue(false);
mocks.activeContextCookie.get.mockReturnValue(undefined);
getToken.mockResolvedValue('fresh-jwt');
setFocus(true);
setVisibility('visible');
});

afterEach(() => {
service?.stopPollingForToken();
service = undefined;
// The service registers listeners on the shared event bus on construction.
eventBus.off(events.TokenUpdate);
eventBus.off(events.UserSignOut);
eventBus.off(events.EnvironmentUpdate);
});

it('registers both focus and visibilitychange listeners', async () => {
const windowSpy = vi.spyOn(window, 'addEventListener');
const documentSpy = vi.spyOn(document, 'addEventListener');

service = await createService();

expect(windowSpy).toHaveBeenCalledWith('focus', expect.any(Function));
expect(documentSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
});

it('writes the session cookie on token:update when the tab is focused', async () => {
service = await createService();

emitTokenUpdate('jwt-focused');

expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-focused');
});

it('does not write when unfocused, outside an iframe, and the active context does not match', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
service = await createService();
setFocus(false);

emitTokenUpdate('jwt-blocked');

expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-blocked');
});

it('writes when unfocused but in a visible cross-origin iframe, even if the active context does not match', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('visible');

emitTokenUpdate('jwt-iframe');

expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-iframe');
});

it('does not write in a cross-origin iframe while it is hidden', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('hidden');

emitTokenUpdate('jwt-hidden');

expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-hidden');
});

it('refreshes the session cookie when an unfocused iframe becomes visible (visibilitychange)', async () => {
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('visible');
getToken.mockResolvedValue('jwt-on-visible');
mocks.sessionCookie.set.mockClear();

document.dispatchEvent(new Event('visibilitychange'));
await vi.waitFor(() => expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-on-visible'));

expect(getToken).toHaveBeenCalled();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/iframe-session-visibility-refresh.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

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.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "72KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
import { expect, test } from '@playwright/test';

const PORT = process.env.PORT || 4011;
const HOST = `http://localhost:${PORT}`;

type ProbeEvent = { type: string; visibility: string; hasFocus: boolean; t: number };

/**
* Real-browser proof of the browser facts behind the embedded-app (e.g. Replit preview) session-refresh
* bug. clerk-js historically refreshed the session cookie only on the window `focus` event and gated the
* cookie write on `document.hasFocus()`. Both assume a top-level browsing context. This test demonstrates,
* in real Chromium, why that fails for a Clerk app running inside a cross-origin iframe:
*
* - a VISIBLE cross-origin iframe reports `document.hasFocus() === false`
* - focusing the parent document never delivers a `focus` event to the iframe (it stays unfocused)
* - the iframe only receives `focus` when it is explicitly focused (clicked into)
*
* So an embedded app is "visible but unfocused" essentially all the time, which is exactly why the
* focus-only refresh never runs and the `!document.hasFocus()` write-gate blocks it. The fix adds a
* `visibilitychange` trigger (which does propagate into the iframe) and lets a visible cross-origin
* iframe write the cookie. clerk-js's response to `visibilitychange` is covered by the AuthCookieService
* unit test; here we verify the browser semantics the fix relies on.
*/
test.describe('cross-origin iframe focus semantics @iframe', () => {
test('a visible cross-origin iframe is unfocused and only receives focus when explicitly focused', async ({
page,
}) => {
await page.goto(`${HOST}/iframe-host.html?framePath=/iframe-probe.html`);

const handle = await page.waitForSelector('#f');
const frame = await handle.contentFrame();
if (!frame) {
throw new Error('iframe contentFrame() was null');
}

await frame.waitForFunction(() => Array.isArray((window as any).__events));

// We are genuinely cross-origin: parent on localhost, iframe on 127.0.0.1.
expect(page.url()).toContain('localhost');
expect(frame.url()).toContain('127.0.0.1');

const liveState = () => frame.evaluate(() => ({ vis: document.visibilityState, hasFocus: document.hasFocus() }));
const events = () => frame.evaluate(() => (window as any).__events as ProbeEvent[]);

// 1) The iframe is visible but does NOT have focus.
const initial = await liveState();
expect(initial.vis).toBe('visible');
expect(initial.hasFocus).toBe(false);

// 2) Focusing an element in the PARENT document does not focus the iframe.
await page.evaluate(() => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
});
const afterParentFocus = await liveState();
expect(afterParentFocus.vis).toBe('visible');
expect(afterParentFocus.hasFocus).toBe(false);
expect((await events()).some(e => e.type === 'window.focus')).toBe(false);

// 3) Explicitly focusing into the iframe (a real click) delivers `focus` and flips hasFocus.
await handle.click();
await frame.waitForFunction(() => (window as any).__events.some((e: ProbeEvent) => e.type === 'window.focus'));
const afterIframeFocus = await liveState();
expect(afterIframeFocus.hasFocus).toBe(true);
});
});
28 changes: 28 additions & 0 deletions packages/clerk-js/sandbox/public/iframe-host.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe host (parent)</title>
</head>
<body>
<h1>iframe host</h1>
<p>Embeds a cross-origin iframe to reproduce the embedded-app (e.g. Replit preview) session-refresh scenario.</p>
<iframe
id="f"
style="width: 480px; height: 360px; border: 1px solid #ccc"
></iframe>
<script>
// Build a cross-origin URL for the iframe by swapping localhost <-> 127.0.0.1 on the same dev server.
const params = new URLSearchParams(location.search);
const framePath = params.get('framePath') || '/iframe-probe.html';
const frameSearch = params.get('frameSearch') || '';
const u = new URL(location.href);
u.hostname = location.hostname === 'localhost' ? '127.0.0.1' : 'localhost';
u.pathname = framePath;
u.search = frameSearch;
u.hash = '';
window.__frameSrc = u.toString();
document.getElementById('f').src = window.__frameSrc;
</script>
</body>
</html>
26 changes: 26 additions & 0 deletions packages/clerk-js/sandbox/public/iframe-probe.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe probe (no Clerk)</title>
</head>
<body>
<h1>iframe probe</h1>
<script>
// Records the events the embedded document actually receives, so a test can assert
// which of `focus` / `visibilitychange` fire on parent-tab re-activation.
window.__events = [];
const rec = type =>
window.__events.push({
type,
visibility: document.visibilityState,
hasFocus: document.hasFocus(),
t: Date.now(),
});
window.addEventListener('focus', () => rec('window.focus'));
window.addEventListener('blur', () => rec('window.blur'));
document.addEventListener('visibilitychange', () => rec('visibilitychange'));
rec('init');
</script>
</body>
</html>
25 changes: 20 additions & 5 deletions packages/clerk-js/src/core/auth/AuthCookieService.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
isNetworkError,
isUnauthenticatedError,
} from '@clerk/shared/error';
import { inCrossOriginIframe } from '@clerk/shared/internal/clerk-js/runtime';
import type { Clerk, InstanceType } from '@clerk/shared/types';
import { noop } from '@clerk/shared/utils';

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

private refreshTokenOnFocus() {
window.addEventListener('focus', () => {
const refreshIfVisible = () => {
if (document.visibilityState === 'visible') {
// Certain data-fetching libraries that refetch on focus use setTimeout(cb, 0) to schedule a task on the event loop.
// This gives us an opportunity to ensure the session cookie is updated with a fresh token before the fetch occurs, but it needs to
Expand All@@ -166,7 +167,15 @@ export class AuthCookieService {
// While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break.
void this.refreshSessionToken({ updateCookieImmediately: true });
}
});
};

// `focus` covers top-level tabs (and the multi-tab active-organization handoff added in #3786), but it never fires
// inside a cross-origin iframe on tab re-activation unless the frame itself is clicked. `visibilitychange` does
// propagate into the iframe, so embedded apps (e.g. a preview pane) still get a fresh cookie before they refetch.
window.addEventListener('focus', refreshIfVisible);
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', refreshIfVisible);
}
}

private async refreshSessionToken({
Expand All@@ -189,8 +198,10 @@ export class AuthCookieService {
}

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

Expand DownExpand Up@@ -258,6 +269,10 @@ export class AuthCookieService {
}
}

private inVisibleCrossOriginIframe() {
return inCrossOriginIframe() && document.visibilityState === 'visible';
}

private isCurrentContextActive() {
const activeContext = this.activeCookie.get();
if (!activeContext) {
Expand Down
139 changes: 139 additions & 0 deletions packages/clerk-js/src/core/auth/__tests__/AuthCookieService.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { eventBus, events } from '../../events';

const mocks = vi.hoisted(() => ({
sessionCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn() },
clientUatCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn(() => 0) },
activeContextCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn<() => string | undefined>(() => undefined) },
inCrossOriginIframe: vi.fn(() => false),
}));

vi.mock('../cookies/session', () => ({ createSessionCookie: () => mocks.sessionCookie }));
vi.mock('../cookies/clientUat', () => ({ createClientUatCookie: () => mocks.clientUatCookie }));
vi.mock('../cookies/activeContext', () => ({ createActiveContextCookie: () => mocks.activeContextCookie }));
vi.mock('../cookieSuffix', () => ({ getCookieSuffix: vi.fn(() => Promise.resolve('suffix')) }));
vi.mock('../devBrowser', () => ({
createDevBrowser: () => ({
clear: vi.fn(),
setup: vi.fn(() => Promise.resolve()),
getDevBrowser: vi.fn(() => 'deadbeef'),
refreshCookies: vi.fn(),
}),
}));
vi.mock('@clerk/shared/internal/clerk-js/runtime', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, inCrossOriginIframe: () => mocks.inCrossOriginIframe() };
});

import { AuthCookieService } from '../AuthCookieService';

const setFocus = (hasFocus: boolean) =>
Object.defineProperty(document, 'hasFocus', { value: () => hasFocus, configurable: true });
const setVisibility = (state: DocumentVisibilityState) =>
Object.defineProperty(document, 'visibilityState', { value: state, configurable: true });

describe('AuthCookieService session cookie refresh', () => {
const getToken = vi.fn(() => Promise.resolve('fresh-jwt'));
const clerkStub = {
publishableKey: 'pk_test_Y2xlcmsuYWJjZWYuMTIzNDUuZGV2LmxjbGNsZXJrLmNvbSQ',
frontendApi: 'clerk.abcef.12345.dev.lclclerk.com',
loaded: true,
session: { id: 'sess_active', getToken },
organization: null,
user: {},
client: {},
handleUnauthenticated: vi.fn(),
} as any;
const clerkEventBusStub = { emit: vi.fn(), on: vi.fn(), prioritizedOn: vi.fn() } as any;

const createService = () => AuthCookieService.create(clerkStub, {} as any, 'production', clerkEventBusStub);
const emitTokenUpdate = (raw: string) =>
eventBus.emit(events.TokenUpdate, { token: { getRawString: () => raw } as any });

let service: Awaited<ReturnType<typeof createService>> | undefined;

beforeEach(() => {
vi.clearAllMocks();
mocks.inCrossOriginIframe.mockReturnValue(false);
mocks.activeContextCookie.get.mockReturnValue(undefined);
getToken.mockResolvedValue('fresh-jwt');
setFocus(true);
setVisibility('visible');
});

afterEach(() => {
service?.stopPollingForToken();
service = undefined;
// The service registers listeners on the shared event bus on construction.
eventBus.off(events.TokenUpdate);
eventBus.off(events.UserSignOut);
eventBus.off(events.EnvironmentUpdate);
});

it('registers both focus and visibilitychange listeners', async () => {
const windowSpy = vi.spyOn(window, 'addEventListener');
const documentSpy = vi.spyOn(document, 'addEventListener');

service = await createService();

expect(windowSpy).toHaveBeenCalledWith('focus', expect.any(Function));
expect(documentSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
});

it('writes the session cookie on token:update when the tab is focused', async () => {
service = await createService();

emitTokenUpdate('jwt-focused');

expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-focused');
});

it('does not write when unfocused, outside an iframe, and the active context does not match', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
service = await createService();
setFocus(false);

emitTokenUpdate('jwt-blocked');

expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-blocked');
});

it('writes when unfocused but in a visible cross-origin iframe, even if the active context does not match', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('visible');

emitTokenUpdate('jwt-iframe');

expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-iframe');
});

it('does not write in a cross-origin iframe while it is hidden', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('hidden');

emitTokenUpdate('jwt-hidden');

expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-hidden');
});

it('refreshes the session cookie when an unfocused iframe becomes visible (visibilitychange)', async () => {
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('visible');
getToken.mockResolvedValue('jwt-on-visible');
mocks.sessionCookie.set.mockClear();

document.dispatchEvent(new Event('visibilitychange'));
await vi.waitFor(() => expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-on-visible'));

expect(getToken).toHaveBeenCalled();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/iframe-session-visibility-refresh.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

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.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "72KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
import { expect, test } from '@playwright/test';

const PORT = process.env.PORT || 4011;
const HOST = `http://localhost:${PORT}`;

type ProbeEvent = { type: string; visibility: string; hasFocus: boolean; t: number };

/**
* Real-browser proof of the browser facts behind the embedded-app (e.g. Replit preview) session-refresh
* bug. clerk-js historically refreshed the session cookie only on the window `focus` event and gated the
* cookie write on `document.hasFocus()`. Both assume a top-level browsing context. This test demonstrates,
* in real Chromium, why that fails for a Clerk app running inside a cross-origin iframe:
*
* - a VISIBLE cross-origin iframe reports `document.hasFocus() === false`
* - focusing the parent document never delivers a `focus` event to the iframe (it stays unfocused)
* - the iframe only receives `focus` when it is explicitly focused (clicked into)
*
* So an embedded app is "visible but unfocused" essentially all the time, which is exactly why the
* focus-only refresh never runs and the `!document.hasFocus()` write-gate blocks it. The fix adds a
* `visibilitychange` trigger (which does propagate into the iframe) and lets a visible cross-origin
* iframe write the cookie. clerk-js's response to `visibilitychange` is covered by the AuthCookieService
* unit test; here we verify the browser semantics the fix relies on.
*/
test.describe('cross-origin iframe focus semantics @iframe', () => {
test('a visible cross-origin iframe is unfocused and only receives focus when explicitly focused', async ({
page,
}) => {
await page.goto(`${HOST}/iframe-host.html?framePath=/iframe-probe.html`);

const handle = await page.waitForSelector('#f');
const frame = await handle.contentFrame();
if (!frame) {
throw new Error('iframe contentFrame() was null');
}

await frame.waitForFunction(() => Array.isArray((window as any).__events));

// We are genuinely cross-origin: parent on localhost, iframe on 127.0.0.1.
expect(page.url()).toContain('localhost');
expect(frame.url()).toContain('127.0.0.1');

const liveState = () => frame.evaluate(() => ({ vis: document.visibilityState, hasFocus: document.hasFocus() }));
const events = () => frame.evaluate(() => (window as any).__events as ProbeEvent[]);

// 1) The iframe is visible but does NOT have focus.
const initial = await liveState();
expect(initial.vis).toBe('visible');
expect(initial.hasFocus).toBe(false);

// 2) Focusing an element in the PARENT document does not focus the iframe.
await page.evaluate(() => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
});
const afterParentFocus = await liveState();
expect(afterParentFocus.vis).toBe('visible');
expect(afterParentFocus.hasFocus).toBe(false);
expect((await events()).some(e => e.type === 'window.focus')).toBe(false);

// 3) Explicitly focusing into the iframe (a real click) delivers `focus` and flips hasFocus.
await handle.click();
await frame.waitForFunction(() => (window as any).__events.some((e: ProbeEvent) => e.type === 'window.focus'));
const afterIframeFocus = await liveState();
expect(afterIframeFocus.hasFocus).toBe(true);
});
});
28 changes: 28 additions & 0 deletions packages/clerk-js/sandbox/public/iframe-host.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe host (parent)</title>
</head>
<body>
<h1>iframe host</h1>
<p>Embeds a cross-origin iframe to reproduce the embedded-app (e.g. Replit preview) session-refresh scenario.</p>
<iframe
id="f"
style="width: 480px; height: 360px; border: 1px solid #ccc"
></iframe>
<script>
// Build a cross-origin URL for the iframe by swapping localhost <-> 127.0.0.1 on the same dev server.
const params = new URLSearchParams(location.search);
const framePath = params.get('framePath') || '/iframe-probe.html';
const frameSearch = params.get('frameSearch') || '';
const u = new URL(location.href);
u.hostname = location.hostname === 'localhost' ? '127.0.0.1' : 'localhost';
u.pathname = framePath;
u.search = frameSearch;
u.hash = '';
window.__frameSrc = u.toString();
document.getElementById('f').src = window.__frameSrc;
</script>
</body>
</html>
26 changes: 26 additions & 0 deletions packages/clerk-js/sandbox/public/iframe-probe.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe probe (no Clerk)</title>
</head>
<body>
<h1>iframe probe</h1>
<script>
// Records the events the embedded document actually receives, so a test can assert
// which of `focus` / `visibilitychange` fire on parent-tab re-activation.
window.__events = [];
const rec = type =>
window.__events.push({
type,
visibility: document.visibilityState,
hasFocus: document.hasFocus(),
t: Date.now(),
});
window.addEventListener('focus', () => rec('window.focus'));
window.addEventListener('blur', () => rec('window.blur'));
document.addEventListener('visibilitychange', () => rec('visibilitychange'));
rec('init');
</script>
</body>
</html>
25 changes: 20 additions & 5 deletions packages/clerk-js/src/core/auth/AuthCookieService.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
isNetworkError,
isUnauthenticatedError,
} from '@clerk/shared/error';
import { inCrossOriginIframe } from '@clerk/shared/internal/clerk-js/runtime';
import type { Clerk, InstanceType } from '@clerk/shared/types';
import { noop } from '@clerk/shared/utils';

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

private refreshTokenOnFocus() {
window.addEventListener('focus', () => {
const refreshIfVisible = () => {
if (document.visibilityState === 'visible') {
// Certain data-fetching libraries that refetch on focus use setTimeout(cb, 0) to schedule a task on the event loop.
// This gives us an opportunity to ensure the session cookie is updated with a fresh token before the fetch occurs, but it needs to
Expand All@@ -166,7 +167,15 @@ export class AuthCookieService {
// While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break.
void this.refreshSessionToken({ updateCookieImmediately: true });
}
});
};

// `focus` covers top-level tabs (and the multi-tab active-organization handoff added in #3786), but it never fires
// inside a cross-origin iframe on tab re-activation unless the frame itself is clicked. `visibilitychange` does
// propagate into the iframe, so embedded apps (e.g. a preview pane) still get a fresh cookie before they refetch.
window.addEventListener('focus', refreshIfVisible);
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', refreshIfVisible);
}
}

private async refreshSessionToken({
Expand All@@ -189,8 +198,10 @@ export class AuthCookieService {
}

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

Expand DownExpand Up@@ -258,6 +269,10 @@ export class AuthCookieService {
}
}

private inVisibleCrossOriginIframe() {
return inCrossOriginIframe() && document.visibilityState === 'visible';
}

private isCurrentContextActive() {
const activeContext = this.activeCookie.get();
if (!activeContext) {
Expand Down
139 changes: 139 additions & 0 deletions packages/clerk-js/src/core/auth/__tests__/AuthCookieService.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { eventBus, events } from '../../events';

const mocks = vi.hoisted(() => ({
sessionCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn() },
clientUatCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn(() => 0) },
activeContextCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn<() => string | undefined>(() => undefined) },
inCrossOriginIframe: vi.fn(() => false),
}));

vi.mock('../cookies/session', () => ({ createSessionCookie: () => mocks.sessionCookie }));
vi.mock('../cookies/clientUat', () => ({ createClientUatCookie: () => mocks.clientUatCookie }));
vi.mock('../cookies/activeContext', () => ({ createActiveContextCookie: () => mocks.activeContextCookie }));
vi.mock('../cookieSuffix', () => ({ getCookieSuffix: vi.fn(() => Promise.resolve('suffix')) }));
vi.mock('../devBrowser', () => ({
createDevBrowser: () => ({
clear: vi.fn(),
setup: vi.fn(() => Promise.resolve()),
getDevBrowser: vi.fn(() => 'deadbeef'),
refreshCookies: vi.fn(),
}),
}));
vi.mock('@clerk/shared/internal/clerk-js/runtime', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, inCrossOriginIframe: () => mocks.inCrossOriginIframe() };
});

import { AuthCookieService } from '../AuthCookieService';

const setFocus = (hasFocus: boolean) =>
Object.defineProperty(document, 'hasFocus', { value: () => hasFocus, configurable: true });
const setVisibility = (state: DocumentVisibilityState) =>
Object.defineProperty(document, 'visibilityState', { value: state, configurable: true });

describe('AuthCookieService session cookie refresh', () => {
const getToken = vi.fn(() => Promise.resolve('fresh-jwt'));
const clerkStub = {
publishableKey: 'pk_test_Y2xlcmsuYWJjZWYuMTIzNDUuZGV2LmxjbGNsZXJrLmNvbSQ',
frontendApi: 'clerk.abcef.12345.dev.lclclerk.com',
loaded: true,
session: { id: 'sess_active', getToken },
organization: null,
user: {},
client: {},
handleUnauthenticated: vi.fn(),
} as any;
const clerkEventBusStub = { emit: vi.fn(), on: vi.fn(), prioritizedOn: vi.fn() } as any;

const createService = () => AuthCookieService.create(clerkStub, {} as any, 'production', clerkEventBusStub);
const emitTokenUpdate = (raw: string) =>
eventBus.emit(events.TokenUpdate, { token: { getRawString: () => raw } as any });

let service: Awaited<ReturnType<typeof createService>> | undefined;

beforeEach(() => {
vi.clearAllMocks();
mocks.inCrossOriginIframe.mockReturnValue(false);
mocks.activeContextCookie.get.mockReturnValue(undefined);
getToken.mockResolvedValue('fresh-jwt');
setFocus(true);
setVisibility('visible');
});

afterEach(() => {
service?.stopPollingForToken();
service = undefined;
// The service registers listeners on the shared event bus on construction.
eventBus.off(events.TokenUpdate);
eventBus.off(events.UserSignOut);
eventBus.off(events.EnvironmentUpdate);
});

it('registers both focus and visibilitychange listeners', async () => {
const windowSpy = vi.spyOn(window, 'addEventListener');
const documentSpy = vi.spyOn(document, 'addEventListener');

service = await createService();

expect(windowSpy).toHaveBeenCalledWith('focus', expect.any(Function));
expect(documentSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
});

it('writes the session cookie on token:update when the tab is focused', async () => {
service = await createService();

emitTokenUpdate('jwt-focused');

expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-focused');
});

it('does not write when unfocused, outside an iframe, and the active context does not match', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
service = await createService();
setFocus(false);

emitTokenUpdate('jwt-blocked');

expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-blocked');
});

it('writes when unfocused but in a visible cross-origin iframe, even if the active context does not match', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('visible');

emitTokenUpdate('jwt-iframe');

expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-iframe');
});

it('does not write in a cross-origin iframe while it is hidden', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('hidden');

emitTokenUpdate('jwt-hidden');

expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-hidden');
});

it('refreshes the session cookie when an unfocused iframe becomes visible (visibilitychange)', async () => {
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('visible');
getToken.mockResolvedValue('jwt-on-visible');
mocks.sessionCookie.set.mockClear();

document.dispatchEvent(new Event('visibilitychange'));
await vi.waitFor(() => expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-on-visible'));

expect(getToken).toHaveBeenCalled();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/iframe-session-visibility-refresh.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

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.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "72KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
import { expect, test } from '@playwright/test';

const PORT = process.env.PORT || 4011;
const HOST = `http://localhost:${PORT}`;

type ProbeEvent = { type: string; visibility: string; hasFocus: boolean; t: number };

/**
* Real-browser proof of the browser facts behind the embedded-app (e.g. Replit preview) session-refresh
* bug. clerk-js historically refreshed the session cookie only on the window `focus` event and gated the
* cookie write on `document.hasFocus()`. Both assume a top-level browsing context. This test demonstrates,
* in real Chromium, why that fails for a Clerk app running inside a cross-origin iframe:
*
* - a VISIBLE cross-origin iframe reports `document.hasFocus() === false`
* - focusing the parent document never delivers a `focus` event to the iframe (it stays unfocused)
* - the iframe only receives `focus` when it is explicitly focused (clicked into)
*
* So an embedded app is "visible but unfocused" essentially all the time, which is exactly why the
* focus-only refresh never runs and the `!document.hasFocus()` write-gate blocks it. The fix adds a
* `visibilitychange` trigger (which does propagate into the iframe) and lets a visible cross-origin
* iframe write the cookie. clerk-js's response to `visibilitychange` is covered by the AuthCookieService
* unit test; here we verify the browser semantics the fix relies on.
*/
test.describe('cross-origin iframe focus semantics @iframe', () => {
test('a visible cross-origin iframe is unfocused and only receives focus when explicitly focused', async ({
page,
}) => {
await page.goto(`${HOST}/iframe-host.html?framePath=/iframe-probe.html`);

const handle = await page.waitForSelector('#f');
const frame = await handle.contentFrame();
if (!frame) {
throw new Error('iframe contentFrame() was null');
}

await frame.waitForFunction(() => Array.isArray((window as any).__events));

// We are genuinely cross-origin: parent on localhost, iframe on 127.0.0.1.
expect(page.url()).toContain('localhost');
expect(frame.url()).toContain('127.0.0.1');

const liveState = () => frame.evaluate(() => ({ vis: document.visibilityState, hasFocus: document.hasFocus() }));
const events = () => frame.evaluate(() => (window as any).__events as ProbeEvent[]);

// 1) The iframe is visible but does NOT have focus.
const initial = await liveState();
expect(initial.vis).toBe('visible');
expect(initial.hasFocus).toBe(false);

// 2) Focusing an element in the PARENT document does not focus the iframe.
await page.evaluate(() => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
});
const afterParentFocus = await liveState();
expect(afterParentFocus.vis).toBe('visible');
expect(afterParentFocus.hasFocus).toBe(false);
expect((await events()).some(e => e.type === 'window.focus')).toBe(false);

// 3) Explicitly focusing into the iframe (a real click) delivers `focus` and flips hasFocus.
await handle.click();
await frame.waitForFunction(() => (window as any).__events.some((e: ProbeEvent) => e.type === 'window.focus'));
const afterIframeFocus = await liveState();
expect(afterIframeFocus.hasFocus).toBe(true);
});
});
28 changes: 28 additions & 0 deletions packages/clerk-js/sandbox/public/iframe-host.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe host (parent)</title>
</head>
<body>
<h1>iframe host</h1>
<p>Embeds a cross-origin iframe to reproduce the embedded-app (e.g. Replit preview) session-refresh scenario.</p>
<iframe
id="f"
style="width: 480px; height: 360px; border: 1px solid #ccc"
></iframe>
<script>
// Build a cross-origin URL for the iframe by swapping localhost <-> 127.0.0.1 on the same dev server.
const params = new URLSearchParams(location.search);
const framePath = params.get('framePath') || '/iframe-probe.html';
const frameSearch = params.get('frameSearch') || '';
const u = new URL(location.href);
u.hostname = location.hostname === 'localhost' ? '127.0.0.1' : 'localhost';
u.pathname = framePath;
u.search = frameSearch;
u.hash = '';
window.__frameSrc = u.toString();
document.getElementById('f').src = window.__frameSrc;
</script>
</body>
</html>
26 changes: 26 additions & 0 deletions packages/clerk-js/sandbox/public/iframe-probe.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe probe (no Clerk)</title>
</head>
<body>
<h1>iframe probe</h1>
<script>
// Records the events the embedded document actually receives, so a test can assert
// which of `focus` / `visibilitychange` fire on parent-tab re-activation.
window.__events = [];
const rec = type =>
window.__events.push({
type,
visibility: document.visibilityState,
hasFocus: document.hasFocus(),
t: Date.now(),
});
window.addEventListener('focus', () => rec('window.focus'));
window.addEventListener('blur', () => rec('window.blur'));
document.addEventListener('visibilitychange', () => rec('visibilitychange'));
rec('init');
</script>
</body>
</html>
25 changes: 20 additions & 5 deletions packages/clerk-js/src/core/auth/AuthCookieService.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
isNetworkError,
isUnauthenticatedError,
} from '@clerk/shared/error';
import { inCrossOriginIframe } from '@clerk/shared/internal/clerk-js/runtime';
import type { Clerk, InstanceType } from '@clerk/shared/types';
import { noop } from '@clerk/shared/utils';

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

private refreshTokenOnFocus() {
window.addEventListener('focus', () => {
const refreshIfVisible = () => {
if (document.visibilityState === 'visible') {
// Certain data-fetching libraries that refetch on focus use setTimeout(cb, 0) to schedule a task on the event loop.
// This gives us an opportunity to ensure the session cookie is updated with a fresh token before the fetch occurs, but it needs to
Expand All@@ -166,7 +167,15 @@ export class AuthCookieService {
// While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break.
void this.refreshSessionToken({ updateCookieImmediately: true });
}
});
};

// `focus` covers top-level tabs (and the multi-tab active-organization handoff added in #3786), but it never fires
// inside a cross-origin iframe on tab re-activation unless the frame itself is clicked. `visibilitychange` does
// propagate into the iframe, so embedded apps (e.g. a preview pane) still get a fresh cookie before they refetch.
window.addEventListener('focus', refreshIfVisible);
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', refreshIfVisible);
}
}

private async refreshSessionToken({
Expand All@@ -189,8 +198,10 @@ export class AuthCookieService {
}

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

Expand DownExpand Up@@ -258,6 +269,10 @@ export class AuthCookieService {
}
}

private inVisibleCrossOriginIframe() {
return inCrossOriginIframe() && document.visibilityState === 'visible';
}

private isCurrentContextActive() {
const activeContext = this.activeCookie.get();
if (!activeContext) {
Expand Down
139 changes: 139 additions & 0 deletions packages/clerk-js/src/core/auth/__tests__/AuthCookieService.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { eventBus, events } from '../../events';

const mocks = vi.hoisted(() => ({
sessionCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn() },
clientUatCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn(() => 0) },
activeContextCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn<() => string | undefined>(() => undefined) },
inCrossOriginIframe: vi.fn(() => false),
}));

vi.mock('../cookies/session', () => ({ createSessionCookie: () => mocks.sessionCookie }));
vi.mock('../cookies/clientUat', () => ({ createClientUatCookie: () => mocks.clientUatCookie }));
vi.mock('../cookies/activeContext', () => ({ createActiveContextCookie: () => mocks.activeContextCookie }));
vi.mock('../cookieSuffix', () => ({ getCookieSuffix: vi.fn(() => Promise.resolve('suffix')) }));
vi.mock('../devBrowser', () => ({
createDevBrowser: () => ({
clear: vi.fn(),
setup: vi.fn(() => Promise.resolve()),
getDevBrowser: vi.fn(() => 'deadbeef'),
refreshCookies: vi.fn(),
}),
}));
vi.mock('@clerk/shared/internal/clerk-js/runtime', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, inCrossOriginIframe: () => mocks.inCrossOriginIframe() };
});

import { AuthCookieService } from '../AuthCookieService';

const setFocus = (hasFocus: boolean) =>
Object.defineProperty(document, 'hasFocus', { value: () => hasFocus, configurable: true });
const setVisibility = (state: DocumentVisibilityState) =>
Object.defineProperty(document, 'visibilityState', { value: state, configurable: true });

describe('AuthCookieService session cookie refresh', () => {
const getToken = vi.fn(() => Promise.resolve('fresh-jwt'));
const clerkStub = {
publishableKey: 'pk_test_Y2xlcmsuYWJjZWYuMTIzNDUuZGV2LmxjbGNsZXJrLmNvbSQ',
frontendApi: 'clerk.abcef.12345.dev.lclclerk.com',
loaded: true,
session: { id: 'sess_active', getToken },
organization: null,
user: {},
client: {},
handleUnauthenticated: vi.fn(),
} as any;
const clerkEventBusStub = { emit: vi.fn(), on: vi.fn(), prioritizedOn: vi.fn() } as any;

const createService = () => AuthCookieService.create(clerkStub, {} as any, 'production', clerkEventBusStub);
const emitTokenUpdate = (raw: string) =>
eventBus.emit(events.TokenUpdate, { token: { getRawString: () => raw } as any });

let service: Awaited<ReturnType<typeof createService>> | undefined;

beforeEach(() => {
vi.clearAllMocks();
mocks.inCrossOriginIframe.mockReturnValue(false);
mocks.activeContextCookie.get.mockReturnValue(undefined);
getToken.mockResolvedValue('fresh-jwt');
setFocus(true);
setVisibility('visible');
});

afterEach(() => {
service?.stopPollingForToken();
service = undefined;
// The service registers listeners on the shared event bus on construction.
eventBus.off(events.TokenUpdate);
eventBus.off(events.UserSignOut);
eventBus.off(events.EnvironmentUpdate);
});

it('registers both focus and visibilitychange listeners', async () => {
const windowSpy = vi.spyOn(window, 'addEventListener');
const documentSpy = vi.spyOn(document, 'addEventListener');

service = await createService();

expect(windowSpy).toHaveBeenCalledWith('focus', expect.any(Function));
expect(documentSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
});

it('writes the session cookie on token:update when the tab is focused', async () => {
service = await createService();

emitTokenUpdate('jwt-focused');

expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-focused');
});

it('does not write when unfocused, outside an iframe, and the active context does not match', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
service = await createService();
setFocus(false);

emitTokenUpdate('jwt-blocked');

expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-blocked');
});

it('writes when unfocused but in a visible cross-origin iframe, even if the active context does not match', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('visible');

emitTokenUpdate('jwt-iframe');

expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-iframe');
});

it('does not write in a cross-origin iframe while it is hidden', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('hidden');

emitTokenUpdate('jwt-hidden');

expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-hidden');
});

it('refreshes the session cookie when an unfocused iframe becomes visible (visibilitychange)', async () => {
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('visible');
getToken.mockResolvedValue('jwt-on-visible');
mocks.sessionCookie.set.mockClear();

document.dispatchEvent(new Event('visibilitychange'));
await vi.waitFor(() => expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-on-visible'));

expect(getToken).toHaveBeenCalled();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/iframe-session-visibility-refresh.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

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.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "72KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
import { expect, test } from '@playwright/test';

const PORT = process.env.PORT || 4011;
const HOST = `http://localhost:${PORT}`;

type ProbeEvent = { type: string; visibility: string; hasFocus: boolean; t: number };

/**
* Real-browser proof of the browser facts behind the embedded-app (e.g. Replit preview) session-refresh
* bug. clerk-js historically refreshed the session cookie only on the window `focus` event and gated the
* cookie write on `document.hasFocus()`. Both assume a top-level browsing context. This test demonstrates,
* in real Chromium, why that fails for a Clerk app running inside a cross-origin iframe:
*
* - a VISIBLE cross-origin iframe reports `document.hasFocus() === false`
* - focusing the parent document never delivers a `focus` event to the iframe (it stays unfocused)
* - the iframe only receives `focus` when it is explicitly focused (clicked into)
*
* So an embedded app is "visible but unfocused" essentially all the time, which is exactly why the
* focus-only refresh never runs and the `!document.hasFocus()` write-gate blocks it. The fix adds a
* `visibilitychange` trigger (which does propagate into the iframe) and lets a visible cross-origin
* iframe write the cookie. clerk-js's response to `visibilitychange` is covered by the AuthCookieService
* unit test; here we verify the browser semantics the fix relies on.
*/
test.describe('cross-origin iframe focus semantics @iframe', () => {
test('a visible cross-origin iframe is unfocused and only receives focus when explicitly focused', async ({
page,
}) => {
await page.goto(`${HOST}/iframe-host.html?framePath=/iframe-probe.html`);

const handle = await page.waitForSelector('#f');
const frame = await handle.contentFrame();
if (!frame) {
throw new Error('iframe contentFrame() was null');
}

await frame.waitForFunction(() => Array.isArray((window as any).__events));

// We are genuinely cross-origin: parent on localhost, iframe on 127.0.0.1.
expect(page.url()).toContain('localhost');
expect(frame.url()).toContain('127.0.0.1');

const liveState = () => frame.evaluate(() => ({ vis: document.visibilityState, hasFocus: document.hasFocus() }));
const events = () => frame.evaluate(() => (window as any).__events as ProbeEvent[]);

// 1) The iframe is visible but does NOT have focus.
const initial = await liveState();
expect(initial.vis).toBe('visible');
expect(initial.hasFocus).toBe(false);

// 2) Focusing an element in the PARENT document does not focus the iframe.
await page.evaluate(() => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
});
const afterParentFocus = await liveState();
expect(afterParentFocus.vis).toBe('visible');
expect(afterParentFocus.hasFocus).toBe(false);
expect((await events()).some(e => e.type === 'window.focus')).toBe(false);

// 3) Explicitly focusing into the iframe (a real click) delivers `focus` and flips hasFocus.
await handle.click();
await frame.waitForFunction(() => (window as any).__events.some((e: ProbeEvent) => e.type === 'window.focus'));
const afterIframeFocus = await liveState();
expect(afterIframeFocus.hasFocus).toBe(true);
});
});
28 changes: 28 additions & 0 deletions packages/clerk-js/sandbox/public/iframe-host.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe host (parent)</title>
</head>
<body>
<h1>iframe host</h1>
<p>Embeds a cross-origin iframe to reproduce the embedded-app (e.g. Replit preview) session-refresh scenario.</p>
<iframe
id="f"
style="width: 480px; height: 360px; border: 1px solid #ccc"
></iframe>
<script>
// Build a cross-origin URL for the iframe by swapping localhost <-> 127.0.0.1 on the same dev server.
const params = new URLSearchParams(location.search);
const framePath = params.get('framePath') || '/iframe-probe.html';
const frameSearch = params.get('frameSearch') || '';
const u = new URL(location.href);
u.hostname = location.hostname === 'localhost' ? '127.0.0.1' : 'localhost';
u.pathname = framePath;
u.search = frameSearch;
u.hash = '';
window.__frameSrc = u.toString();
document.getElementById('f').src = window.__frameSrc;
</script>
</body>
</html>
26 changes: 26 additions & 0 deletions packages/clerk-js/sandbox/public/iframe-probe.html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>iframe probe (no Clerk)</title>
</head>
<body>
<h1>iframe probe</h1>
<script>
// Records the events the embedded document actually receives, so a test can assert
// which of `focus` / `visibilitychange` fire on parent-tab re-activation.
window.__events = [];
const rec = type =>
window.__events.push({
type,
visibility: document.visibilityState,
hasFocus: document.hasFocus(),
t: Date.now(),
});
window.addEventListener('focus', () => rec('window.focus'));
window.addEventListener('blur', () => rec('window.blur'));
document.addEventListener('visibilitychange', () => rec('visibilitychange'));
rec('init');
</script>
</body>
</html>
25 changes: 20 additions & 5 deletions packages/clerk-js/src/core/auth/AuthCookieService.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
isNetworkError,
isUnauthenticatedError,
} from '@clerk/shared/error';
import { inCrossOriginIframe } from '@clerk/shared/internal/clerk-js/runtime';
import type { Clerk, InstanceType } from '@clerk/shared/types';
import { noop } from '@clerk/shared/utils';

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

private refreshTokenOnFocus() {
window.addEventListener('focus', () => {
const refreshIfVisible = () => {
if (document.visibilityState === 'visible') {
// Certain data-fetching libraries that refetch on focus use setTimeout(cb, 0) to schedule a task on the event loop.
// This gives us an opportunity to ensure the session cookie is updated with a fresh token before the fetch occurs, but it needs to
Expand All@@ -166,7 +167,15 @@ export class AuthCookieService {
// While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break.
void this.refreshSessionToken({ updateCookieImmediately: true });
}
});
};

// `focus` covers top-level tabs (and the multi-tab active-organization handoff added in #3786), but it never fires
// inside a cross-origin iframe on tab re-activation unless the frame itself is clicked. `visibilitychange` does
// propagate into the iframe, so embedded apps (e.g. a preview pane) still get a fresh cookie before they refetch.
window.addEventListener('focus', refreshIfVisible);
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', refreshIfVisible);
}
}

private async refreshSessionToken({
Expand All@@ -189,8 +198,10 @@ export class AuthCookieService {
}

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

Expand DownExpand Up@@ -258,6 +269,10 @@ export class AuthCookieService {
}
}

private inVisibleCrossOriginIframe() {
return inCrossOriginIframe() && document.visibilityState === 'visible';
}

private isCurrentContextActive() {
const activeContext = this.activeCookie.get();
if (!activeContext) {
Expand Down
139 changes: 139 additions & 0 deletions packages/clerk-js/src/core/auth/__tests__/AuthCookieService.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { eventBus, events } from '../../events';

const mocks = vi.hoisted(() => ({
sessionCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn() },
clientUatCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn(() => 0) },
activeContextCookie: { set: vi.fn(), remove: vi.fn(), get: vi.fn<() => string | undefined>(() => undefined) },
inCrossOriginIframe: vi.fn(() => false),
}));

vi.mock('../cookies/session', () => ({ createSessionCookie: () => mocks.sessionCookie }));
vi.mock('../cookies/clientUat', () => ({ createClientUatCookie: () => mocks.clientUatCookie }));
vi.mock('../cookies/activeContext', () => ({ createActiveContextCookie: () => mocks.activeContextCookie }));
vi.mock('../cookieSuffix', () => ({ getCookieSuffix: vi.fn(() => Promise.resolve('suffix')) }));
vi.mock('../devBrowser', () => ({
createDevBrowser: () => ({
clear: vi.fn(),
setup: vi.fn(() => Promise.resolve()),
getDevBrowser: vi.fn(() => 'deadbeef'),
refreshCookies: vi.fn(),
}),
}));
vi.mock('@clerk/shared/internal/clerk-js/runtime', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, inCrossOriginIframe: () => mocks.inCrossOriginIframe() };
});

import { AuthCookieService } from '../AuthCookieService';

const setFocus = (hasFocus: boolean) =>
Object.defineProperty(document, 'hasFocus', { value: () => hasFocus, configurable: true });
const setVisibility = (state: DocumentVisibilityState) =>
Object.defineProperty(document, 'visibilityState', { value: state, configurable: true });

describe('AuthCookieService session cookie refresh', () => {
const getToken = vi.fn(() => Promise.resolve('fresh-jwt'));
const clerkStub = {
publishableKey: 'pk_test_Y2xlcmsuYWJjZWYuMTIzNDUuZGV2LmxjbGNsZXJrLmNvbSQ',
frontendApi: 'clerk.abcef.12345.dev.lclclerk.com',
loaded: true,
session: { id: 'sess_active', getToken },
organization: null,
user: {},
client: {},
handleUnauthenticated: vi.fn(),
} as any;
const clerkEventBusStub = { emit: vi.fn(), on: vi.fn(), prioritizedOn: vi.fn() } as any;

const createService = () => AuthCookieService.create(clerkStub, {} as any, 'production', clerkEventBusStub);
const emitTokenUpdate = (raw: string) =>
eventBus.emit(events.TokenUpdate, { token: { getRawString: () => raw } as any });

let service: Awaited<ReturnType<typeof createService>> | undefined;

beforeEach(() => {
vi.clearAllMocks();
mocks.inCrossOriginIframe.mockReturnValue(false);
mocks.activeContextCookie.get.mockReturnValue(undefined);
getToken.mockResolvedValue('fresh-jwt');
setFocus(true);
setVisibility('visible');
});

afterEach(() => {
service?.stopPollingForToken();
service = undefined;
// The service registers listeners on the shared event bus on construction.
eventBus.off(events.TokenUpdate);
eventBus.off(events.UserSignOut);
eventBus.off(events.EnvironmentUpdate);
});

it('registers both focus and visibilitychange listeners', async () => {
const windowSpy = vi.spyOn(window, 'addEventListener');
const documentSpy = vi.spyOn(document, 'addEventListener');

service = await createService();

expect(windowSpy).toHaveBeenCalledWith('focus', expect.any(Function));
expect(documentSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
});

it('writes the session cookie on token:update when the tab is focused', async () => {
service = await createService();

emitTokenUpdate('jwt-focused');

expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-focused');
});

it('does not write when unfocused, outside an iframe, and the active context does not match', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
service = await createService();
setFocus(false);

emitTokenUpdate('jwt-blocked');

expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-blocked');
});

it('writes when unfocused but in a visible cross-origin iframe, even if the active context does not match', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('visible');

emitTokenUpdate('jwt-iframe');

expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-iframe');
});

it('does not write in a cross-origin iframe while it is hidden', async () => {
mocks.activeContextCookie.get.mockReturnValue('sess_other:');
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('hidden');

emitTokenUpdate('jwt-hidden');

expect(mocks.sessionCookie.set).not.toHaveBeenCalledWith('jwt-hidden');
});

it('refreshes the session cookie when an unfocused iframe becomes visible (visibilitychange)', async () => {
mocks.inCrossOriginIframe.mockReturnValue(true);
service = await createService();
setFocus(false);
setVisibility('visible');
getToken.mockResolvedValue('jwt-on-visible');
mocks.sessionCookie.set.mockClear();

document.dispatchEvent(new Event('visibilitychange'));
await vi.waitFor(() => expect(mocks.sessionCookie.set).toHaveBeenCalledWith('jwt-on-visible'));

expect(getToken).toHaveBeenCalled();
});
});
Loading