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..899600f1b8d 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -12,6 +12,7 @@ import { unixEpochToDate } from '../../utils/date'; import { eventBus } from '../events'; import type { FapiResponseJSON } from '../fapiClient'; import { SessionTokenCache } from '../tokenCache'; +import { shouldKeepExistingLastActiveToken } from '../tokenFreshness'; import { BaseResource, Session, SignIn, SignUp } from './internal'; export function getClientResourceFromPayload(responseJSON: FapiResponseJSON | null): ClientResource | undefined { @@ -141,7 +142,20 @@ 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); + const previousToken = previousTokens.get(session.id); + if ( + previousToken && + session.lastActiveToken && + shouldKeepExistingLastActiveToken(previousToken, session.lastActiveToken) + ) { + session.lastActiveToken = previousToken; + } + 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..981a30a6f6c 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -51,7 +51,7 @@ import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../e import { eventBus, events } from '../events'; import type { FapiResponseJSON } from '../fapiClient'; import { SessionTokenCache } from '../tokenCache'; -import { normalizeOrgId, pickFreshestJwt, tokenOrgId, tokenSid } from '../tokenFreshness'; +import { shouldKeepExistingLastActiveToken } from '../tokenFreshness'; import { BaseResource, getClientResourceFromPayload, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; @@ -411,7 +411,10 @@ 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; + 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,30 +531,12 @@ 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; - } - if ( - tokenSid(current) !== tokenSid(incoming) || - normalizeOrgId(tokenOrgId(current)) !== normalizeOrgId(tokenOrgId(incoming)) - ) { - return false; - } - return pickFreshestJwt(current, incoming) !== incoming; - } - #fetchToken( template: string | undefined, organizationId: string | undefined | null, 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..33ce91597e1 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,81 @@ 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('a stale piggybacked client payload does not regress the rebuilt session token', () => { + const high = createJwtWithOiat(NOW, NOW + 30); + const low = createJwtWithOiat(NOW, NOW); + const higher = createJwtWithOiat(NOW, NOW + 60); + + const clientJSON = (lastActiveToken: ReturnType | null) => + ({ + object: 'client', + id: 'client_1', + last_active_session_id: 'session_1', + sessions: [touchResponse(lastActiveToken).response], + }) as any; + + const client = Client.getOrCreateInstance().fromJSON(clientJSON(tokenJSON(high))); + expect(client.sessions[0]?.lastActiveToken?.getRawString()).toBe(high); + + client.fromJSON(clientJSON(tokenJSON(low))); + expect(client.sessions[0]?.lastActiveToken?.getRawString()).toBe(high); + + client.fromJSON(clientJSON(tokenJSON(higher))); + expect(client.sessions[0]?.lastActiveToken?.getRawString()).toBe(higher); + + client.fromJSON(clientJSON(null)); + expect(client.sessions[0]?.lastActiveToken).toBeNull(); + }); + }); }); }); diff --git a/packages/clerk-js/src/core/tokenFreshness.ts b/packages/clerk-js/src/core/tokenFreshness.ts index 73a060462fd..997135f9be1 100644 --- a/packages/clerk-js/src/core/tokenFreshness.ts +++ b/packages/clerk-js/src/core/tokenFreshness.ts @@ -72,3 +72,20 @@ export function tokenOrgId(input: TokenResource | JWT): string { export function normalizeOrgId(orgId?: string | null): string { return orgId || ''; } + +// 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. +export function shouldKeepExistingLastActiveToken( + current: TokenResource | null | undefined, + incoming: TokenResource, +): boolean { + if (!current?.jwt) { + return false; + } + if (tokenSid(current) !== tokenSid(incoming) || tokenOrgId(current) !== tokenOrgId(incoming)) { + return false; + } + return pickFreshestJwt(current, incoming) !== incoming; +} 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/cache/dummy-data/index.ts b/packages/expo/src/cache/dummy-data/index.ts index de09f6e5a52..c18318f586a 100644 --- a/packages/expo/src/cache/dummy-data/index.ts +++ b/packages/expo/src/cache/dummy-data/index.ts @@ -1,2 +1,16 @@ -export { DUMMY_CLERK_CLIENT_RESOURCE } from './client-resource'; -export { DUMMY_CLERK_ENVIRONMENT_RESOURCE } from './environment-resource'; +import { DUMMY_CLERK_CLIENT_RESOURCE } from './client-resource'; +import { DUMMY_CLERK_ENVIRONMENT_RESOURCE } from './environment-resource'; + +export { DUMMY_CLERK_CLIENT_RESOURCE, DUMMY_CLERK_ENVIRONMENT_RESOURCE }; + +export function isDummyClient(client: { id?: string | null } | null | undefined): boolean { + return client?.id === DUMMY_CLERK_CLIENT_RESOURCE.id; +} + +// The dummy environment's own id is empty, so its display_config id is the reliable marker. +export function isDummyEnvironment( + environment: { display_config?: { id?: string } | null; displayConfig?: { id?: string } | null } | null | undefined, +): boolean { + const id = environment?.display_config?.id ?? environment?.displayConfig?.id; + return id === DUMMY_CLERK_ENVIRONMENT_RESOURCE.display_config.id; +} diff --git a/packages/expo/src/cache/index.ts b/packages/expo/src/cache/index.ts index a83a3b7cc5f..c7ec0186a5a 100644 --- a/packages/expo/src/cache/index.ts +++ b/packages/expo/src/cache/index.ts @@ -2,4 +2,9 @@ export type { TokenCache } from './types'; export { MemoryTokenCache } from './MemoryTokenCache'; export { ClientResourceCache, EnvironmentResourceCache, SessionJWTCache } from './ResourceCache'; -export { DUMMY_CLERK_ENVIRONMENT_RESOURCE, DUMMY_CLERK_CLIENT_RESOURCE } from './dummy-data'; +export { + DUMMY_CLERK_ENVIRONMENT_RESOURCE, + DUMMY_CLERK_CLIENT_RESOURCE, + isDummyClient, + isDummyEnvironment, +} from './dummy-data'; 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,149 @@ 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('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); @@ -1717,6 +1864,7 @@ describe('ClerkProvider native client sync', () => { mocks.getClientToken.mockResolvedValue('ghost-device-token'); mocks.tokenCache.saveToken.mockClear(); + mocks.syncClientStateFromJs.mockClear(); await act(async () => { await mocks.clerkInstance.handleUnauthenticated(); @@ -1727,6 +1875,10 @@ describe('ClerkProvider native client sync', () => { expect(originalHandleUnauthenticated).not.toHaveBeenCalled(); expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'ghost-device-token'); expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'js-device-token'); + // The rollback write is notification-suppressed, so the restored token reaches native via a direct push. + await waitFor(() => { + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('js-device-token', expect.any(String), false, true); + }); }); test('skips native adoption when the cached device token read times out while signed in', async () => { diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 5c564d92f32..5c5bca2c978 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; @@ -262,6 +263,7 @@ async function refreshJsClientFromNativeState({ rejectForeignSessionlessClient = false, reloadInitialResources, shouldSyncDeviceToken = true, + suppressDeviceTokenRollbackNotification = false, suppressTokenCacheNotificationsRef, tokenCache, }: { @@ -271,9 +273,10 @@ async function refreshJsClientFromNativeState({ rejectForeignSessionlessClient?: boolean; reloadInitialResources: boolean; shouldSyncDeviceToken?: boolean; + suppressDeviceTokenRollbackNotification?: boolean; suppressTokenCacheNotificationsRef?: MutableRefObject; tokenCache: TokenCache | undefined; -}): Promise { +}): Promise { const previousClientSnapshot = snapshotClientState(clerkInstance.client); const restorePreviousDeviceToken = async () => { @@ -281,7 +284,15 @@ async function refreshJsClientFromNativeState({ return; } - await syncDeviceTokenToCache(tokenCache, previousDeviceToken); + // 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. + await syncNativeDeviceTokenToCache({ + deviceToken: previousDeviceToken, + suppressTokenCacheNotificationsRef: suppressDeviceTokenRollbackNotification + ? suppressTokenCacheNotificationsRef + : undefined, + tokenCache, + }); }; let refreshedClient: ClientResource | null; @@ -310,14 +321,14 @@ async function refreshJsClientFromNativeState({ clerkInstance, }); } - return true; + return 'restored'; } clerkInstance.updateClient?.(refreshedClient); await reconcileJsActiveSessionFromClient({ clerkInstance, }); - return true; + return 'refreshed'; } if (reloadInitialResources && typeof clerkInstance.__internal_reloadInitialResources === 'function') { @@ -325,7 +336,7 @@ async function refreshJsClientFromNativeState({ await reconcileJsActiveSessionFromClient({ clerkInstance, }); - return Boolean(getDefaultSignedInSession(clerkInstance.client)); + return getDefaultSignedInSession(clerkInstance.client) ? 'refreshed' : false; } return false; @@ -570,6 +581,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 +755,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,39 +799,64 @@ export function NativeClientSync({ isHandlingUnauthenticated = true; try { - return await runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { - try { - const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); - const previousDeviceToken = await getCachedDeviceToken(tokenCache); - // Native may have already moved the server-side client to a new - // active session. Refresh JS before allowing Clerk JS' stale-session - // 401 path to collapse the whole client to signed out. - const didRecover = await refreshJsClientFromNativeState({ - clerkInstance, - nativeDeviceToken, - previousDeviceToken, - rejectForeignSessionlessClient: true, - reloadInitialResources: false, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - if (didRecover) { - return; - } - } catch (error) { - const didRecover = await recoverJsClientFromNativeDeviceToken({ - clerkInstance, - error, - suppressTokenCacheNotificationsRef, - tokenCache, - }); - if (didRecover) { - return; - } + // Re-reading native state and refetching the client for every response in a 401 burst only amplifies it. + const lastRecovery = lastUnauthenticatedRecoveryRef.current; + if (lastRecovery !== undefined) { + const elapsed = Date.now() - lastRecovery; + // A backwards clock jump makes elapsed negative; treat it as expired instead of waiting out the gap. + if (elapsed >= 0 && elapsed < unauthenticatedRecoveryCooldownMs) { + return await originalHandleUnauthenticated(options); } + } + lastUnauthenticatedRecoveryRef.current = Date.now(); - return originalHandleUnauthenticated(options); - }); + try { + return await runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { + try { + const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); + const previousDeviceToken = await getCachedDeviceToken(tokenCache); + // Native may have already moved the server-side client to a new + // active session. Refresh JS before allowing Clerk JS' stale-session + // 401 path to collapse the whole client to signed out. + const result = await refreshJsClientFromNativeState({ + clerkInstance, + nativeDeviceToken, + previousDeviceToken, + rejectForeignSessionlessClient: true, + reloadInitialResources: false, + suppressDeviceTokenRollbackNotification: true, + suppressTokenCacheNotificationsRef, + tokenCache, + }); + // The suppressed rollback write skips the listener that resyncs native, so the + // restored token must be pushed to native from here. + if (result === 'restored' && previousDeviceToken !== undefined) { + nativeRefreshFromJsControllerRef.current?.syncDeviceTokenToNative(previousDeviceToken); + } + if (result) { + return; + } + } catch (error) { + const didRecover = await recoverJsClientFromNativeDeviceToken({ + clerkInstance, + error, + suppressTokenCacheNotificationsRef, + tokenCache, + }); + if (didRecover) { + return; + } + } + + return originalHandleUnauthenticated(options); + }); + } finally { + // Slow attempts must not finish with a mostly spent window, so the stamp moves to settle + // time. A rotation mid-attempt cleared the ref to force a fresh attempt; keep it cleared. + if (lastUnauthenticatedRecoveryRef.current !== undefined) { + lastUnauthenticatedRecoveryRef.current = Date.now(); + } + } } finally { isHandlingUnauthenticated = false; } @@ -829,7 +869,13 @@ export function NativeClientSync({ clerkInstance.handleUnauthenticated = originalHandleUnauthenticated; } }; - }, [clerkInstance, suppressJsClientChangedRef, suppressTokenCacheNotificationsRef, tokenCache]); + }, [ + clerkInstance, + nativeRefreshFromJsControllerRef, + suppressJsClientChangedRef, + suppressTokenCacheNotificationsRef, + tokenCache, + ]); useEffect(() => { if (!clerkInstance || typeof clerkInstance.addListener !== 'function') { diff --git a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts index 67544ad0866..6b3324ae8a3 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, DUMMY_CLERK_ENVIRONMENT_RESOURCE } from '../../../cache'; import type { TokenCache } from '../../../cache/types'; import { CLERK_CLIENT_JWT_KEY } from '../../../constants'; @@ -44,6 +45,29 @@ const createUnavailableResourceCache = () => ({ set: () => Promise.resolve(), }); +const createResourceCacheStub = + ({ + environment, + client, + set = () => Promise.resolve(), + }: { + environment?: unknown; + client?: unknown; + set?: (key: string, value: string) => Promise; + }) => + () => ({ + get: (key: string) => { + if (environment && key.startsWith('__clerk_cache_environment')) { + return Promise.resolve(JSON.stringify(environment)); + } + if (client && key.startsWith('__clerk_cache_client')) { + return Promise.resolve(JSON.stringify(client)); + } + return Promise.resolve(null); + }, + set, + }); + const loadCreateClerkInstance = async () => { const mod = await import('../createClerkInstance'); return mod.createClerkInstance; @@ -483,6 +507,155 @@ 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: createResourceCacheStub({ environment: 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('treats a persisted dummy client as missing', 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: createResourceCacheStub({ + environment: cachedEnvironment, + client: DUMMY_CLERK_CLIENT_RESOURCE, + }), + }) 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 }); + + await vi.advanceTimersByTimeAsync(3_000); + expect(mocks.requestInitialResources).toHaveBeenCalledTimes(1); + }); + + test('does not persist the dummy client snapshot but still clears the cached session JWT', async () => { + const set = vi.fn(() => Promise.resolve()); + + const createClerkInstance = await loadCreateClerkInstance(); + const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); + const clerk = getClerkInstance({ + publishableKey: 'pk_test_123', + __experimental_resourceCache: createResourceCacheStub({ set }), + }) as unknown as MockClerk; + + const listener = clerk.addListener.mock.calls[0]?.[0] as (payload: { client: unknown }) => void; + expect(listener).toBeTypeOf('function'); + + listener({ client: { id: DUMMY_CLERK_CLIENT_RESOURCE.id, lastActiveSessionId: null } }); + expect(set).not.toHaveBeenCalledWith(expect.stringContaining('__clerk_cache_client'), expect.any(String)); + // A sessionless emission, dummy or not, still wipes the offline JWT fallback. + expect(set).toHaveBeenCalledWith(expect.stringContaining('__clerk_cache_session_jwt'), ''); + + listener({ + client: { + id: 'client_real', + lastActiveSessionId: null, + signedInSessions: [], + __internal_toSnapshot: () => ({ id: 'client_real' }), + }, + }); + expect(set).toHaveBeenCalledWith(expect.stringContaining('__clerk_cache_client'), expect.any(String)); + }); + + test('does not persist the dummy environment snapshot', async () => { + const set = vi.fn(() => Promise.resolve()); + + const createClerkInstance = await loadCreateClerkInstance(); + const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); + const clerk = getClerkInstance({ + publishableKey: 'pk_test_123', + __experimental_resourceCache: createResourceCacheStub({ set }), + }) as unknown as MockClerk; + + (clerk as unknown as { __internal_environment: unknown }).__internal_environment = { + displayConfig: { id: DUMMY_CLERK_ENVIRONMENT_RESOURCE.display_config.id }, + __internal_toSnapshot: () => ({ + object: 'environment', + id: '', + display_config: { id: DUMMY_CLERK_ENVIRONMENT_RESOURCE.display_config.id }, + }), + }; + + const listener = clerk.addListener.mock.calls[0]?.[0] as (payload: { client: unknown }) => void; + listener({ client: null }); + expect(set).not.toHaveBeenCalledWith(expect.stringContaining('__clerk_cache_environment'), expect.any(String)); + + (clerk as unknown as { __internal_environment: unknown }).__internal_environment = { + displayConfig: { id: 'display_config_real' }, + __internal_toSnapshot: () => ({ + object: 'environment', + id: 'env_real', + display_config: { id: 'display_config_real' }, + }), + }; + listener({ client: null }); + expect(set).toHaveBeenCalledWith(expect.stringContaining('__clerk_cache_environment'), expect.any(String)); + }); + + test('treats a persisted dummy environment as missing', async () => { + mocks.requestInitialResources.mockImplementation(() => new Promise(() => {})); + + const cachedClient = { object: 'client', id: 'client_cached', sessions: [] }; + const dummyEnvironmentSnapshot = { + object: 'environment', + id: '', + display_config: { id: DUMMY_CLERK_ENVIRONMENT_RESOURCE.display_config.id }, + }; + + const createClerkInstance = await loadCreateClerkInstance(); + const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk); + const clerk = getClerkInstance({ + publishableKey: 'pk_test_123', + __experimental_resourceCache: createResourceCacheStub({ + environment: dummyEnvironmentSnapshot, + client: cachedClient, + }), + }) as unknown as MockClerk; + + const resources = await clerk.__internal_getCachedResources?.(); + + expect(resources?.client).toEqual(cachedClient); + expect(resources?.environment).toMatchObject({ + auth_config: { session_minter: false }, + }); + + 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..e1630787a22 100644 --- a/packages/expo/src/provider/singleton/createClerkInstance.ts +++ b/packages/expo/src/provider/singleton/createClerkInstance.ts @@ -15,6 +15,8 @@ import { DUMMY_CLERK_CLIENT_RESOURCE, DUMMY_CLERK_ENVIRONMENT_RESOURCE, EnvironmentResourceCache, + isDummyClient, + isDummyEnvironment, SessionJWTCache, } from '../../cache'; import { MemoryTokenCache } from '../../cache/MemoryTokenCache'; @@ -238,12 +240,15 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { __internal_clerk.addListener(({ client }) => { // @ts-expect-error - This is an internal API const environment = __internal_clerk?.__internal_environment as EnvironmentResource; - if (environment) { + // Persisting a dummy would make the next boot see a populated cache and skip recovery. + if (environment && !isDummyEnvironment(environment)) { void EnvironmentResourceCache.save(environment.__internal_toSnapshot()); } if (client) { - void ClientResourceCache.save(client.__internal_toSnapshot()); + if (!isDummyClient(client)) { + void ClientResourceCache.save(client.__internal_toSnapshot()); + } if (client.lastActiveSessionId) { const currentSession = client.signedInSessions.find(s => s.id === client.lastActiveSessionId); const token = currentSession?.lastActiveToken?.getRawString(); @@ -260,14 +265,19 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null; }> => { - let environment = await EnvironmentResourceCache.load(); - let client = await ClientResourceCache.load(); + const cachedEnvironment = await EnvironmentResourceCache.load(); + const cachedClient = await ClientResourceCache.load(); + // Installs that persisted a dummy before the save guard existed must still recover. + const environment = isDummyEnvironment(cachedEnvironment) ? null : cachedEnvironment; + const client = isDummyClient(cachedClient) ? null : cachedClient; 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, + }; }; } }