From e16252a011765a7468394063a6d00c03c899da7f Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 30 Jul 2026 12:18:52 +0300 Subject: [PATCH 1/2] fix: harden native session-minter token path Native apps feed the previous session token to the edge Session Minter as the mint seed, so a stale or regressed lastActiveToken is no longer just a stale local token, it becomes the input to the next mint and the staleness chains forward. Three gaps made that reachable on the Expo/native path. clerk-js: Client.fromJSON rebuilds every session object on a client update, and the rebuilt objects replaced the live ones while unconditionally adopting the payload's last_active_token. A piggybacked response carrying an older token could therefore regress the active session's token. fromJSON now carries the freshest of the prior instance's token and the payload token, using the same same-sid/same-org oiat guard that already protects the in-place path, so a genuine session or org switch still adopts the incoming token while a stale piggyback cannot win. Token-clearing on a token-less payload is preserved. expo: a failed initial load substituted dummy resources for both environment and client, wiping auth_config.session_minter for the whole instance lifetime even when a good cached environment existed; it now substitutes only the missing resource. The patched 401 handler ran full native-state recovery plus a client refetch on every 401; a short cooldown collapses a burst to one cycle, and a rotated device token clears the cooldown so fresh native identity always gets a fresh attempt. tokenCache doc corrected to say it stores the client JWT. --- .changeset/lucky-donkeys-brake.md | 5 + .changeset/tender-pugs-shave.md | 9 ++ .../clerk-js/src/core/resources/Client.ts | 8 +- .../clerk-js/src/core/resources/Session.ts | 50 +++++--- .../core/resources/__tests__/Session.test.ts | 118 +++++++++++++++++- .../cache/dummy-data/environment-resource.ts | 1 + packages/expo/src/provider/ClerkProvider.tsx | 2 +- .../ClerkProvider.nativeClientSync.test.tsx | 99 ++++++++++++++- .../expo/src/provider/nativeClientSync.tsx | 13 ++ .../__tests__/createClerkInstance.test.ts | 33 +++++ .../provider/singleton/createClerkInstance.ts | 12 +- 11 files changed, 325 insertions(+), 25 deletions(-) create mode 100644 .changeset/lucky-donkeys-brake.md create mode 100644 .changeset/tender-pugs-shave.md diff --git a/.changeset/lucky-donkeys-brake.md b/.changeset/lucky-donkeys-brake.md new file mode 100644 index 00000000000..b9153adc9e8 --- /dev/null +++ b/.changeset/lucky-donkeys-brake.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Keep the freshest session token when a server response carries an older one. A slow response, or the client payload attached to one, could previously roll `lastActiveToken` back to a stale token, which is the token sent as the previous-token hint on the next token request. diff --git a/.changeset/tender-pugs-shave.md b/.changeset/tender-pugs-shave.md new file mode 100644 index 00000000000..1d1eceb2e3a --- /dev/null +++ b/.changeset/tender-pugs-shave.md @@ -0,0 +1,9 @@ +--- +'@clerk/expo': patch +--- + +Keep a cached environment when the app starts offline and only the client cache is empty. Previously both resources fell back to placeholder data, so instance settings were lost until the app was restarted with a working network. + +Repeated unauthenticated responses now share one native recovery attempt within a few seconds of each other, instead of reading native state and refetching the client for every response. + +Fix the `tokenCache` prop documentation: the cache stores the client JWT, not the session token. diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 7697b936a66..68156cd1907 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -141,7 +141,13 @@ export class Client extends BaseResource implements ClientResource { fromJSON(data: ClientJSON | ClientJSONSnapshot | null): this { if (data) { this.id = data.id; - this.sessions = (data.sessions || []).map(s => new Session(s)); + // Rebuilt session objects replace the live ones, so a stale piggybacked token must not win. + const previousTokens = new Map(this.sessions.map(session => [session.id, session.lastActiveToken])); + this.sessions = (data.sessions || []).map(s => { + const session = new Session(s); + session.__internal_keepFreshestLastActiveToken(previousTokens.get(session.id)); + return session; + }); if (data.sign_up && this.signUp instanceof SignUp && this.signUp.id === data.sign_up.id) { this.signUp.__internal_updateFromJSON(data.sign_up); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 1ae3efb157b..f4f262ed2de 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -58,6 +58,23 @@ import { SessionVerification } from './SessionVerification'; const focusedRefresh = (onRefresh: () => void): { onRefresh?: () => void } => isTabFocused() === false ? {} : { onRefresh }; +// Mirrors the cookie guard: only a same session+org lastActiveToken is a comparable +// freshness baseline, so a session or org switch always adopts the incoming token. +// Without this, an org-switch token minted by a stale edge (lower oiat) would lose +// to the previous org's token and pin lastActiveToken to the old org's claims. +function shouldKeepExistingLastActiveToken(current: TokenResource | null | undefined, incoming: TokenResource) { + if (!current?.jwt) { + return false; + } + if ( + tokenSid(current) !== tokenSid(incoming) || + normalizeOrgId(tokenOrgId(current)) !== normalizeOrgId(tokenOrgId(incoming)) + ) { + return false; + } + return pickFreshestJwt(current, incoming) !== incoming; +} + export class Session extends BaseResource implements SessionResource { pathRoot = '/client/sessions'; @@ -411,7 +428,11 @@ export class Session extends BaseResource implements SessionResource { this.publicUserData = new PublicUserData(data.public_user_data); } - this.lastActiveToken = data.last_active_token ? new Token(data.last_active_token) : null; + // Responses are applied in place on a live session, so a piggybacked token can be staler than the one held. + const incomingLastActiveToken = data.last_active_token ? new Token(data.last_active_token) : null; + if (!incomingLastActiveToken || !shouldKeepExistingLastActiveToken(this.lastActiveToken, incomingLastActiveToken)) { + this.lastActiveToken = incomingLastActiveToken; + } return this; } @@ -528,28 +549,25 @@ export class Session extends BaseResource implements SessionResource { eventBus.emit(events.TokenUpdate, { token }); - if (token.jwt && !this.#shouldKeepExistingLastActiveToken(token)) { + if (token.jwt && !shouldKeepExistingLastActiveToken(this.lastActiveToken, token)) { this.lastActiveToken = token; eventBus.emit(events.SessionTokenResolved, null); } } - // Mirrors the cookie guard: only a same session+org lastActiveToken is a comparable - // freshness baseline, so a session or org switch always adopts the incoming token. - // Without this, an org-switch token minted by a stale edge (lower oiat) would lose - // to the previous org's token and pin lastActiveToken to the old org's claims. - #shouldKeepExistingLastActiveToken(incoming: TokenResource): boolean { - const current = this.lastActiveToken; - if (!current?.jwt) { - return false; + /** + * Carries a token forward from the Session instance this one replaces. A client payload rebuilds + * every session object, so without this a stale piggybacked token becomes the next mint seed. + * + * @internal + */ + public __internal_keepFreshestLastActiveToken(previous: TokenResource | null | undefined): void { + if (!previous || !this.lastActiveToken) { + return; } - if ( - tokenSid(current) !== tokenSid(incoming) || - normalizeOrgId(tokenOrgId(current)) !== normalizeOrgId(tokenOrgId(incoming)) - ) { - return false; + if (shouldKeepExistingLastActiveToken(previous, this.lastActiveToken)) { + this.lastActiveToken = previous; } - return pickFreshestJwt(current, incoming) !== incoming; } #fetchToken( 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 b87e4e4041c..6e017fe94f6 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -15,7 +15,7 @@ import { TokenId } from '@/utils/tokenId'; import { eventBus } from '../../events'; import { createFapiClient } from '../../fapiClient'; import { SessionTokenCache } from '../../tokenCache'; -import { BaseResource, Organization, Session } from '../internal'; +import { BaseResource, Client, Organization, Session } from '../internal'; const baseFapiClientOptions = { frontendApi: 'clerk.example.com', @@ -2269,6 +2269,7 @@ describe('Session', () => { afterEach(() => { dispatchSpy?.mockRestore(); fetchSpy?.mockRestore(); + Client.clearInstance(); BaseResource.clerk = null as any; SessionTokenCache.clear(); }); @@ -2444,5 +2445,120 @@ describe('Session', () => { // wins even though a stale edge minted it with a lower oiat. expect(session.lastActiveToken?.getRawString()).toBe(orgLow); }); + + describe('fromJSON', () => { + const tokenJSON = (jwt: string) => ({ object: 'token' as const, id: 'tok_1', jwt }); + + const touchResponse = (lastActiveToken: ReturnType | null) => ({ + response: { + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: Date.now(), + updated_at: Date.now(), + last_active_token: lastActiveToken, + } as unknown as SessionJSON, + }); + + it('a stale touch response does not regress lastActiveToken', async () => { + const high = createJwtWithOiat(NOW, NOW + 30); + const low = createJwtWithOiat(NOW, NOW); + const session = makeSession({ last_active_token: tokenJSON(high) } as Partial); + + fetchSpy.mockResolvedValueOnce(touchResponse(tokenJSON(low)) as any); + await session.touch(); + + expect(session.lastActiveToken?.getRawString()).toBe(high); + }); + + it('a fresher touch response replaces lastActiveToken', async () => { + const low = createJwtWithOiat(NOW, NOW); + const high = createJwtWithOiat(NOW, NOW + 30); + const session = makeSession({ last_active_token: tokenJSON(low) } as Partial); + + fetchSpy.mockResolvedValueOnce(touchResponse(tokenJSON(high)) as any); + await session.touch(); + + expect(session.lastActiveToken?.getRawString()).toBe(high); + }); + + it('a touch response without a token still clears lastActiveToken', async () => { + const high = createJwtWithOiat(NOW, NOW + 30); + const session = makeSession({ last_active_token: tokenJSON(high) } as Partial); + + fetchSpy.mockResolvedValueOnce(touchResponse(null) as any); + await session.touch(); + + expect(session.lastActiveToken).toBeNull(); + }); + + it('constructor hydration adopts the token in the payload', () => { + const high = createJwtWithOiat(NOW, NOW + 30); + const low = createJwtWithOiat(NOW, NOW); + + expect( + makeSession({ last_active_token: tokenJSON(high) } as Partial).lastActiveToken?.getRawString(), + ).toBe(high); + expect( + makeSession({ last_active_token: tokenJSON(low) } as Partial).lastActiveToken?.getRawString(), + ).toBe(low); + }); + + it('a stale piggybacked client does not regress the active session token', async () => { + // Exercises the real path: fapi response -> _updateClient -> Client.fromJSON rebuild. + fetchSpy.mockRestore(); + + const high = createJwtWithOiat(NOW, NOW + 30); + const low = createJwtWithOiat(NOW, NOW); + + const sessionJSON = (jwt: string) => + ({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: Date.now(), + updated_at: Date.now(), + last_active_token: tokenJSON(jwt), + }) as unknown as SessionJSON; + + const clientJSON = (jwt: string) => + ({ + object: 'client', + id: 'client_1', + last_active_session_id: 'session_1', + sessions: [sessionJSON(jwt)], + }) as any; + + const client = Client.getOrCreateInstance().fromJSON(clientJSON(high)); + const clerk: any = { + __internal_environment: { authConfig: { sessionMinter: true } }, + client, + session: client.sessions[0], + getFapiClient: () => ({ + request: vi.fn().mockResolvedValue({ + status: 200, + payload: { response: sessionJSON(low), client: clientJSON(low) }, + }), + }), + }; + clerk.updateClient = (newClient: any) => { + clerk.client = newClient; + clerk.session = newClient.sessions.find((s: Session) => s.id === newClient.lastActiveSessionId); + }; + BaseResource.clerk = clerk; + + expect(clerk.session.lastActiveToken?.getRawString()).toBe(high); + + await clerk.session.touch(); + + expect(clerk.session.lastActiveToken?.getRawString()).toBe(high); + }); + }); }); }); diff --git a/packages/expo/src/cache/dummy-data/environment-resource.ts b/packages/expo/src/cache/dummy-data/environment-resource.ts index 9ac020f988f..8a31023ec7a 100644 --- a/packages/expo/src/cache/dummy-data/environment-resource.ts +++ b/packages/expo/src/cache/dummy-data/environment-resource.ts @@ -9,6 +9,7 @@ export const DUMMY_CLERK_ENVIRONMENT_RESOURCE = { single_session_mode: true, claimed_at: null, reverification: true, + session_minter: false, }, display_config: { object: 'display_config', diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 127600b48a9..720f424031c 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -32,7 +32,7 @@ export type ClerkProviderProps = Omit { user: { id: 'user_1' }, }; const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; + let reentersUnauthenticated = false; mocks.clerkInstance.client = { signedInSessions: [removedSession], lastActiveSessionId: 'session_1', fetch: vi.fn().mockImplementation(async () => { - await mocks.clerkInstance.handleUnauthenticated(); + if (reentersUnauthenticated) { + await mocks.clerkInstance.handleUnauthenticated(); + } throw new Error('stale session 401'); }), }; @@ -1177,6 +1180,7 @@ describe('ClerkProvider native client sync', () => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); }); + reentersUnauthenticated = true; await act(async () => { await mocks.clerkInstance.handleUnauthenticated(); }); @@ -1231,6 +1235,99 @@ describe('ClerkProvider native client sync', () => { expect(originalHandleUnauthenticated).toHaveBeenCalled(); }); + test('runs native recovery once for a burst of unauthenticated responses', async () => { + const removedSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; + const fetchClient = vi.fn().mockResolvedValue(null); + + mocks.clerkInstance.client = { + signedInSessions: [removedSession], + lastActiveSessionId: 'session_1', + fetch: fetchClient, + }; + mocks.clerkInstance.session = removedSession; + + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); + }); + + await act(async () => { + await mocks.clerkInstance.handleUnauthenticated(); + await mocks.clerkInstance.handleUnauthenticated(); + }); + + expect(fetchClient).toHaveBeenCalledTimes(1); + expect(originalHandleUnauthenticated).toHaveBeenCalledTimes(2); + }); + + test('recovers again inside the cooldown window once native pushes a new device token', async () => { + const session = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; + const fetchClient = vi.fn(); + const client = { + id: 'client_1', + signedInSessions: [session], + lastActiveSessionId: 'session_1', + fetch: fetchClient, + }; + fetchClient.mockResolvedValue(client); + + mocks.clerkInstance.client = client; + mocks.clerkInstance.session = session; + mocks.getClientToken.mockResolvedValue('native-client-token'); + + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); + }); + + // Drop the client fetches the bootstrap already made; only the 401 handling matters here. + fetchClient.mockClear(); + + await act(async () => { + await mocks.clerkInstance.handleUnauthenticated(); + }); + expect(fetchClient).toHaveBeenCalledTimes(1); + + await act(async () => { + await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'rotated-native-client-token'); + }); + + await act(async () => { + await mocks.clerkInstance.handleUnauthenticated(); + }); + + expect(fetchClient).toHaveBeenCalledTimes(2); + expect(originalHandleUnauthenticated).not.toHaveBeenCalled(); + }); + test('refreshes native from the server after the JS client changes', async () => { mocks.tokenCache.getToken.mockResolvedValue(null); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 5c564d92f32..7a4eea73dec 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -12,6 +12,7 @@ const tokenCacheReadTimeoutMs = 1_000; const nativeDeviceTokenPollIntervalMs = 100; const nativeDeviceTokenAvailabilityTimeoutMs = 3_000; const nativeClientSyncSourceIdPrefix = 'clerk-expo-js-sync'; +const unauthenticatedRecoveryCooldownMs = 5_000; export type SyncableClerkInstance = { addListener?: (listener: () => void, options?: { skipInitialEmit?: boolean }) => () => void; @@ -570,6 +571,7 @@ export function NativeClientSync({ const pendingNativeRefreshRef = useRef(null); const pendingNativeRefreshBeforeReadyRef = useRef(null); const nativeRefreshGenerationRef = useRef(0); + const lastUnauthenticatedRecoveryRef = useRef(undefined); const enabledRef = useRef(enabled); enabledRef.current = enabled; @@ -743,6 +745,9 @@ export function NativeClientSync({ useEffect(() => { const listener: DeviceTokenCacheListener = deviceToken => { + // A rotated device token is new input for recovery, so it reopens the unauthenticated cooldown. + lastUnauthenticatedRecoveryRef.current = undefined; + const options = { deviceToken, didChangeClient: false, @@ -784,6 +789,14 @@ export function NativeClientSync({ isHandlingUnauthenticated = true; try { + // Re-reading native state and refetching the client for every response in a 401 burst only amplifies it. + const now = Date.now(); + const lastRecovery = lastUnauthenticatedRecoveryRef.current; + if (lastRecovery !== undefined && now - lastRecovery < unauthenticatedRecoveryCooldownMs) { + return await originalHandleUnauthenticated(options); + } + lastUnauthenticatedRecoveryRef.current = now; + return await runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { try { const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); diff --git a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts index 67544ad0866..8ece24b5646 100644 --- a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts +++ b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts @@ -2,6 +2,7 @@ import type { Clerk } from '@clerk/clerk-js'; import { ClerkRuntimeError } from '@clerk/shared/error'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { DUMMY_CLERK_CLIENT_RESOURCE } from '../../../cache'; import type { TokenCache } from '../../../cache/types'; import { CLERK_CLIENT_JWT_KEY } from '../../../constants'; @@ -44,6 +45,12 @@ const createUnavailableResourceCache = () => ({ set: () => Promise.resolve(), }); +const createEnvironmentOnlyResourceCache = (environment: unknown) => () => ({ + get: (key: string) => + Promise.resolve(key.startsWith('__clerk_cache_environment') ? JSON.stringify(environment) : null), + set: () => Promise.resolve(), +}); + const loadCreateClerkInstance = async () => { const mod = await import('../createClerkInstance'); return mod.createClerkInstance; @@ -483,6 +490,32 @@ describe('createClerkInstance', () => { } }); + test('keeps a cached environment when the client cache is empty', async () => { + mocks.requestInitialResources.mockImplementation(() => new Promise(() => {})); + + const cachedEnvironment = { + object: 'environment', + id: 'env_cached', + auth_config: { object: 'auth_config', id: 'aac_cached', session_minter: true }, + }; + + const createClerkInstance = await loadCreateClerkInstance(); + const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); + const clerk = getClerkInstance({ + publishableKey: 'pk_test_123', + __experimental_resourceCache: createEnvironmentOnlyResourceCache(cachedEnvironment), + }) as unknown as MockClerk; + + const resources = await clerk.__internal_getCachedResources?.(); + + expect(resources?.environment).toEqual(cachedEnvironment); + expect(resources?.client).toMatchObject({ id: DUMMY_CLERK_CLIENT_RESOURCE.id }); + + // The client is still missing, so recovery is still scheduled. + await vi.advanceTimersByTimeAsync(3_000); + expect(mocks.requestInitialResources).toHaveBeenCalledTimes(1); + }); + test('stops recovering after the initial resources load', async () => { mocks.requestInitialResources.mockResolvedValue(undefined); diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts index 48ec802af10..c9f15ab51cf 100644 --- a/packages/expo/src/provider/singleton/createClerkInstance.ts +++ b/packages/expo/src/provider/singleton/createClerkInstance.ts @@ -260,14 +260,16 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null; }> => { - let environment = await EnvironmentResourceCache.load(); - let client = await ClientResourceCache.load(); + const environment = await EnvironmentResourceCache.load(); + const client = await ClientResourceCache.load(); if (!environment || !client) { - environment = DUMMY_CLERK_ENVIRONMENT_RESOURCE; - client = DUMMY_CLERK_CLIENT_RESOURCE; scheduleResourceRetry(3000); } - return { client, environment }; + // Substitute only what is missing: a dummy environment drops instance settings for the whole app run. + return { + client: client ?? DUMMY_CLERK_CLIENT_RESOURCE, + environment: environment ?? DUMMY_CLERK_ENVIRONMENT_RESOURCE, + }; }; } } From e94cb0335ba1882676b2c6b5c2020e11a404b93d Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 30 Jul 2026 12:35:31 +0300 Subject: [PATCH 2/2] fix(expo): keep the 401 cooldown when a failed recovery rolls back the device token The native 401 cooldown clears whenever the device-token cache changes, so a fresh external identity gets a fresh recovery attempt. But a failed recovery rolls the device token back to its previous value, and that rollback write fired the same listener and cleared the cooldown, so a second 401 inside the window re-ran full recovery, reopening the storm on exactly the failing-recovery path the cooldown is meant to bound. Route the rollback write on the 401 path through the notification suppression recovery already uses for its own writes, so a rollback no longer clears the cooldown. The native-client-event recovery path keeps notifying, since there the rollback notification is load-bearing: it queues the native refresh that pushes the restored token back to the native module. --- .../ClerkProvider.nativeClientSync.test.tsx | 50 +++++++++++++++++++ .../expo/src/provider/nativeClientSync.tsx | 14 ++++++ 2 files changed, 64 insertions(+) diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index ee529985ab3..cde4075ff02 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -1328,6 +1328,56 @@ describe('ClerkProvider native client sync', () => { expect(originalHandleUnauthenticated).not.toHaveBeenCalled(); }); + test('keeps the cooldown when a failed recovery rolls the device token back', async () => { + const session = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated; + const fetchClient = vi.fn().mockRejectedValue(new Error('stale session 401')); + + mocks.clerkInstance.client = { + id: 'client_1', + signedInSessions: [session], + lastActiveSessionId: 'session_1', + fetch: fetchClient, + }; + mocks.clerkInstance.session = session; + // Cached token A differs from native token B, so the rollback write changes the cached value. + mocks.tokenCache.getToken.mockResolvedValue('cached-token-A'); + mocks.getClientToken.mockResolvedValue('native-token-B'); + + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); + }); + + fetchClient.mockClear(); + + await act(async () => { + await mocks.clerkInstance.handleUnauthenticated(); + }); + expect(fetchClient).toHaveBeenCalledTimes(1); + + await act(async () => { + await mocks.clerkInstance.handleUnauthenticated(); + }); + + // The rollback is internal recovery, not an external rotation, so the second 401 delegates to core. + expect(fetchClient).toHaveBeenCalledTimes(1); + expect(originalHandleUnauthenticated).toHaveBeenCalledTimes(1); + }); + test('refreshes native from the server after the JS client changes', async () => { mocks.tokenCache.getToken.mockResolvedValue(null); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 7a4eea73dec..416e89c826a 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -263,6 +263,7 @@ async function refreshJsClientFromNativeState({ rejectForeignSessionlessClient = false, reloadInitialResources, shouldSyncDeviceToken = true, + suppressDeviceTokenRollbackNotification = false, suppressTokenCacheNotificationsRef, tokenCache, }: { @@ -272,6 +273,7 @@ async function refreshJsClientFromNativeState({ rejectForeignSessionlessClient?: boolean; reloadInitialResources: boolean; shouldSyncDeviceToken?: boolean; + suppressDeviceTokenRollbackNotification?: boolean; suppressTokenCacheNotificationsRef?: MutableRefObject; tokenCache: TokenCache | undefined; }): Promise { @@ -282,6 +284,17 @@ async function refreshJsClientFromNativeState({ return; } + // On the 401 path a rollback is part of recovery, not an external rotation, so it must not + // reopen the cooldown. The native-event path still notifies so native resyncs the restored token. + if (suppressDeviceTokenRollbackNotification) { + await syncNativeDeviceTokenToCache({ + deviceToken: previousDeviceToken, + suppressTokenCacheNotificationsRef, + tokenCache, + }); + return; + } + await syncDeviceTokenToCache(tokenCache, previousDeviceToken); }; @@ -810,6 +823,7 @@ export function NativeClientSync({ previousDeviceToken, rejectForeignSessionlessClient: true, reloadInitialResources: false, + suppressDeviceTokenRollbackNotification: true, suppressTokenCacheNotificationsRef, tokenCache, });