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,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); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 5c564d92f32..416e89c826a 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,6 +273,7 @@ async function refreshJsClientFromNativeState({ rejectForeignSessionlessClient?: boolean; reloadInitialResources: boolean; shouldSyncDeviceToken?: boolean; + suppressDeviceTokenRollbackNotification?: boolean; suppressTokenCacheNotificationsRef?: MutableRefObject; tokenCache: TokenCache | undefined; }): Promise { @@ -281,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); }; @@ -570,6 +584,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 +758,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 +802,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 }); @@ -797,6 +823,7 @@ export function NativeClientSync({ previousDeviceToken, rejectForeignSessionlessClient: true, reloadInitialResources: false, + suppressDeviceTokenRollbackNotification: true, suppressTokenCacheNotificationsRef, tokenCache, }); 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, + }; }; } }