diff --git a/.changeset/fix_background_refresh_session_nuke.md b/.changeset/fix_background_refresh_session_nuke.md new file mode 100644 index 00000000000..712acd90682 --- /dev/null +++ b/.changeset/fix_background_refresh_session_nuke.md @@ -0,0 +1,9 @@ +--- +'@clerk/clerk-js': patch +--- + +fix(clerk-js): Prevent background token refresh from destroying sessions on mobile + +On iOS, background thread throttling can starve the JS event loop for hours (e.g., overnight audio apps). When the SDK's background refresh timer eventually fires with stale credentials, the resulting 401 would trigger `handleUnauthenticated()` and destroy the session even though it's still valid on the server. + +Adds an early return in `#refreshTokenInBackground()`, gated to headless/mobile runtimes only (Expo sets `runtimeEnvironment` to `'headless'`). If the token has already expired when the refresh timer fires, bail out instead of sending a request with stale credentials. The next foreground `getToken()` call handles token acquisition through the normal path with proper retry logic. diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 2a9c84f1380..1380ac5ccaf 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -574,6 +574,20 @@ export class Session extends BaseResource implements SessionResource { Session.#backgroundRefreshInProgress.add(tokenId); + // Mobile only: skip this refresh if the token is already expired. + // On iOS, the OS throttles background JS threads for hours (e.g. overnight audio apps). + // The refresh timer fires late — well past token expiry — with stale credentials. + // If we send that request, the 401 response triggers handleUnauthenticated(), which + // destroys the session even though it's still valid on the server (30-day lifetime). + // Instead, bail out here and let the next foreground getToken() call recover normally. + const experimental = Session.clerk?.__internal_getOption?.('experimental'); + const isHeadless = experimental?.runtimeEnvironment === 'headless'; + const lastTokenExp = this.lastActiveToken?.jwt?.claims?.exp; + if (isHeadless && lastTokenExp && Date.now() / 1000 > lastTokenExp) { + Session.#backgroundRefreshInProgress.delete(tokenId); + return; + } + const tokenResolver = this.#createTokenResolver(template, organizationId, false); // Don't cache the promise immediately - only update cache on success diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 90663aac820..aee7f42f614 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -730,6 +730,49 @@ describe('Session', () => { expect(token).toEqual(mockJwt); }); + it('skips background refresh when token is expired on headless runtime', async () => { + BaseResource.clerk = clerkMock({ + // Simulate Expo/React Native headless runtime + __internal_getOption: vi.fn().mockImplementation((key: string) => { + if (key === 'experimental') { + return { runtimeEnvironment: 'headless' }; + } + return undefined; + }), + }); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const _session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + // Let the initial cache populate from lastActiveToken + await Promise.resolve(); + requestSpy.mockClear(); + + // Simulate iOS background throttling: jump the system clock well past + // token expiration WITHOUT firing timers. This is what happens when iOS + // starves the JS thread — the scheduled timer doesn't fire on time. + // mockJwt has iat=1666648250, exp=1666648310 (60s token) + vi.setSystemTime(new Date(1666648400 * 1000)); // 150s after iat, 90s past exp + + // Now fire the pending refresh timer. It was scheduled for ~43s but + // fires late (simulating iOS throttling). Date.now() is past exp, + // so the early return should prevent the API call. + await vi.advanceTimersByTimeAsync(44 * 1000); + + // No API call should have been made — the early return bailed out + expect(requestSpy).not.toHaveBeenCalled(); + }); + it('does not make API call when token has plenty of time remaining', async () => { BaseResource.clerk = clerkMock(); const requestSpy = BaseResource.clerk.getFapiClient().request as Mock;