Skip to content
Open
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/tokens-tab-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

When running in a browser context, session token requests now include a `tab_state` parameter (`focused`, `visible`, or `hidden`) so the backend can distinguish foreground from background token refreshes. The parameter is omitted in environments without a document, such as extension service workers.
22 changes: 22 additions & 0 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ import { normalizeOrgId, pickFreshestJwt, tokenOrgId, tokenSid } from '../tokenF
import { BaseResource, getClientResourceFromPayload, PublicUserData, Token, User } from './internal';
import { SessionVerification } from './SessionVerification';

const getTabState = (): 'focused' | 'visible' | 'hidden' | undefined => {
try {
if (typeof document === 'undefined' || typeof document.hasFocus !== 'function') {
return undefined;
}

if (document.hasFocus()) {
return 'focused';
}

if (document.visibilityState === 'visible') {
return 'visible';
}

return 'hidden';
} catch {
return undefined;
}
};

export class Session extends BaseResource implements SessionResource {
pathRoot = '/client/sessions';

Expand Down Expand Up @@ -485,10 +505,12 @@ export class Session extends BaseResource implements SessionResource {
const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`;
// TODO: update template endpoint to accept organizationId
const sessionMinterEnabled = Session.clerk?.__internal_environment?.authConfig?.sessionMinter;
const tabState = template ? undefined : getTabState();
const params: Record<string, string | null> = template
? {}
: {
organizationId: organizationId ?? null,
...(tabState ? { tabState } : {}),
...(sessionMinterEnabled && this.lastActiveToken ? { token: this.lastActiveToken.getRawString() } : {}),
...(sessionMinterEnabled && skipCache ? { forceOrigin: 'true' } : {}),
};
Expand Down
91 changes: 90 additions & 1 deletion packages/clerk-js/src/core/resources/__tests__/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ClerkAPIResponseError, ClerkOfflineError } from '@clerk/shared/error';
import type { InstanceType, OrganizationJSON, SessionJSON } from '@clerk/shared/types';
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';

import { clerkMock, createUser, mockJwt, mockNetworkFailedFetch } from '@/test/core-fixtures';
import { clerkMock, createUser, mockFetch, mockJwt, mockNetworkFailedFetch } from '@/test/core-fixtures';
import { TokenId } from '@/utils/tokenId';

import { eventBus } from '../../events';
Expand Down Expand Up @@ -1844,6 +1844,95 @@ describe('Session', () => {
});
});

describe('sends tab_state in /tokens request body', () => {
let originalHasFocusDescriptor: PropertyDescriptor | undefined;
let originalVisibilityStateDescriptor: PropertyDescriptor | undefined;

const createSession = () =>
new Session({
status: 'active',
id: 'session_1',
object: 'session',
user: createUser({}),
last_active_organization_id: null,
actor: null,
created_at: new Date().getTime(),
updated_at: new Date().getTime(),
} as SessionJSON);

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

beforeEach(() => {
originalHasFocusDescriptor = Object.getOwnPropertyDescriptor(document, 'hasFocus');
originalVisibilityStateDescriptor = Object.getOwnPropertyDescriptor(document, 'visibilityState');
BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any;
mockFetch(true, 200, { object: 'token', jwt: mockJwt });
});

afterEach(() => {
BaseResource.clerk = null as any;
vi.unstubAllGlobals();
if (originalHasFocusDescriptor) {
Object.defineProperty(document, 'hasFocus', originalHasFocusDescriptor);
} else {
Reflect.deleteProperty(document, 'hasFocus');
}
if (originalVisibilityStateDescriptor) {
Object.defineProperty(document, 'visibilityState', originalVisibilityStateDescriptor);
} else {
Reflect.deleteProperty(document, 'visibilityState');
}
});

it.each([
['focused', true, 'hidden'],
['visible', false, 'visible'],
['hidden', false, 'hidden'],
])('serializes tab_state=%s', async (tabState, hasFocus, visibilityState) => {
setDocumentState(hasFocus, visibilityState as DocumentVisibilityState);

await createSession().getToken({ skipCache: true });

const [, request] = (global.fetch as Mock).mock.calls[0];
expect(request.body).toBe(`organization_id=&tab_state=${tabState}`);
});

it('omits tab_state when document is undefined', async () => {
vi.stubGlobal('document', undefined);

await createSession().getToken({ skipCache: true });

const [, request] = (global.fetch as Mock).mock.calls[0];
expect(request.body).toBe('organization_id=');
});

it('omits tab_state when document.hasFocus is not a function', async () => {
Object.defineProperty(document, 'hasFocus', { value: undefined, configurable: true });

await createSession().getToken({ skipCache: true });

const [, request] = (global.fetch as Mock).mock.calls[0];
expect(request.body).toBe('organization_id=');
});

it('omits tab_state when document.hasFocus throws', async () => {
Object.defineProperty(document, 'hasFocus', {
value: () => {
throw new Error('focus unavailable');
},
configurable: true,
});

await createSession().getToken({ skipCache: true });

const [, request] = (global.fetch as Mock).mock.calls[0];
expect(request.body).toBe('organization_id=');
});
});

describe('origin outage mode fallback', () => {
let dispatchSpy: ReturnType<typeof vi.spyOn>;
let fetchSpy: ReturnType<typeof vi.spyOn>;
Expand Down
Loading