Skip to content

Commit 8c61153

Browse files
authored
fix(clerk-js,expo): harden native session-minter token path (#9284)
1 parent 6464fe7 commit 8c61153

14 files changed

Lines changed: 580 additions & 72 deletions

File tree

.changeset/lucky-donkeys-brake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
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.

.changeset/tender-pugs-shave.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@clerk/expo': patch
3+
---
4+
5+
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.
6+
7+
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.
8+
9+
Fix the `tokenCache` prop documentation: the cache stores the client JWT, not the session token.

packages/clerk-js/src/core/resources/Client.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { unixEpochToDate } from '../../utils/date';
1212
import { eventBus } from '../events';
1313
import type { FapiResponseJSON } from '../fapiClient';
1414
import { SessionTokenCache } from '../tokenCache';
15+
import { shouldKeepExistingLastActiveToken } from '../tokenFreshness';
1516
import { BaseResource, Session, SignIn, SignUp } from './internal';
1617

1718
export function getClientResourceFromPayload<J>(responseJSON: FapiResponseJSON<J> | null): ClientResource | undefined {
@@ -141,7 +142,20 @@ export class Client extends BaseResource implements ClientResource {
141142
fromJSON(data: ClientJSON | ClientJSONSnapshot | null): this {
142143
if (data) {
143144
this.id = data.id;
144-
this.sessions = (data.sessions || []).map(s => new Session(s));
145+
// Rebuilt session objects replace the live ones, so a stale piggybacked token must not win.
146+
const previousTokens = new Map(this.sessions.map(session => [session.id, session.lastActiveToken]));
147+
this.sessions = (data.sessions || []).map(s => {
148+
const session = new Session(s);
149+
const previousToken = previousTokens.get(session.id);
150+
if (
151+
previousToken &&
152+
session.lastActiveToken &&
153+
shouldKeepExistingLastActiveToken(previousToken, session.lastActiveToken)
154+
) {
155+
session.lastActiveToken = previousToken;
156+
}
157+
return session;
158+
});
145159

146160
if (data.sign_up && this.signUp instanceof SignUp && this.signUp.id === data.sign_up.id) {
147161
this.signUp.__internal_updateFromJSON(data.sign_up);

packages/clerk-js/src/core/resources/Session.ts

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../e
5151
import { eventBus, events } from '../events';
5252
import type { FapiResponseJSON } from '../fapiClient';
5353
import { SessionTokenCache } from '../tokenCache';
54-
import { normalizeOrgId, pickFreshestJwt, tokenOrgId, tokenSid } from '../tokenFreshness';
54+
import { shouldKeepExistingLastActiveToken } from '../tokenFreshness';
5555
import { BaseResource, getClientResourceFromPayload, PublicUserData, Token, User } from './internal';
5656
import { SessionVerification } from './SessionVerification';
5757

@@ -411,7 +411,10 @@ export class Session extends BaseResource implements SessionResource {
411411
this.publicUserData = new PublicUserData(data.public_user_data);
412412
}
413413

414-
this.lastActiveToken = data.last_active_token ? new Token(data.last_active_token) : null;
414+
const incomingLastActiveToken = data.last_active_token ? new Token(data.last_active_token) : null;
415+
if (!incomingLastActiveToken || !shouldKeepExistingLastActiveToken(this.lastActiveToken, incomingLastActiveToken)) {
416+
this.lastActiveToken = incomingLastActiveToken;
417+
}
415418

416419
return this;
417420
}
@@ -528,30 +531,12 @@ export class Session extends BaseResource implements SessionResource {
528531

529532
eventBus.emit(events.TokenUpdate, { token });
530533

531-
if (token.jwt && !this.#shouldKeepExistingLastActiveToken(token)) {
534+
if (token.jwt && !shouldKeepExistingLastActiveToken(this.lastActiveToken, token)) {
532535
this.lastActiveToken = token;
533536
eventBus.emit(events.SessionTokenResolved, null);
534537
}
535538
}
536539

537-
// Mirrors the cookie guard: only a same session+org lastActiveToken is a comparable
538-
// freshness baseline, so a session or org switch always adopts the incoming token.
539-
// Without this, an org-switch token minted by a stale edge (lower oiat) would lose
540-
// to the previous org's token and pin lastActiveToken to the old org's claims.
541-
#shouldKeepExistingLastActiveToken(incoming: TokenResource): boolean {
542-
const current = this.lastActiveToken;
543-
if (!current?.jwt) {
544-
return false;
545-
}
546-
if (
547-
tokenSid(current) !== tokenSid(incoming) ||
548-
normalizeOrgId(tokenOrgId(current)) !== normalizeOrgId(tokenOrgId(incoming))
549-
) {
550-
return false;
551-
}
552-
return pickFreshestJwt(current, incoming) !== incoming;
553-
}
554-
555540
#fetchToken(
556541
template: string | undefined,
557542
organizationId: string | undefined | null,

packages/clerk-js/src/core/resources/__tests__/Session.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { TokenId } from '@/utils/tokenId';
1515
import { eventBus } from '../../events';
1616
import { createFapiClient } from '../../fapiClient';
1717
import { SessionTokenCache } from '../../tokenCache';
18-
import { BaseResource, Organization, Session } from '../internal';
18+
import { BaseResource, Client, Organization, Session } from '../internal';
1919

2020
const baseFapiClientOptions = {
2121
frontendApi: 'clerk.example.com',
@@ -2269,6 +2269,7 @@ describe('Session', () => {
22692269
afterEach(() => {
22702270
dispatchSpy?.mockRestore();
22712271
fetchSpy?.mockRestore();
2272+
Client.clearInstance();
22722273
BaseResource.clerk = null as any;
22732274
SessionTokenCache.clear();
22742275
});
@@ -2444,5 +2445,81 @@ describe('Session', () => {
24442445
// wins even though a stale edge minted it with a lower oiat.
24452446
expect(session.lastActiveToken?.getRawString()).toBe(orgLow);
24462447
});
2448+
2449+
describe('fromJSON', () => {
2450+
const tokenJSON = (jwt: string) => ({ object: 'token' as const, id: 'tok_1', jwt });
2451+
2452+
const touchResponse = (lastActiveToken: ReturnType<typeof tokenJSON> | null) => ({
2453+
response: {
2454+
status: 'active',
2455+
id: 'session_1',
2456+
object: 'session',
2457+
user: createUser({}),
2458+
last_active_organization_id: null,
2459+
actor: null,
2460+
created_at: Date.now(),
2461+
updated_at: Date.now(),
2462+
last_active_token: lastActiveToken,
2463+
} as unknown as SessionJSON,
2464+
});
2465+
2466+
it('a stale touch response does not regress lastActiveToken', async () => {
2467+
const high = createJwtWithOiat(NOW, NOW + 30);
2468+
const low = createJwtWithOiat(NOW, NOW);
2469+
const session = makeSession({ last_active_token: tokenJSON(high) } as Partial<SessionJSON>);
2470+
2471+
fetchSpy.mockResolvedValueOnce(touchResponse(tokenJSON(low)) as any);
2472+
await session.touch();
2473+
2474+
expect(session.lastActiveToken?.getRawString()).toBe(high);
2475+
});
2476+
2477+
it('a fresher touch response replaces lastActiveToken', async () => {
2478+
const low = createJwtWithOiat(NOW, NOW);
2479+
const high = createJwtWithOiat(NOW, NOW + 30);
2480+
const session = makeSession({ last_active_token: tokenJSON(low) } as Partial<SessionJSON>);
2481+
2482+
fetchSpy.mockResolvedValueOnce(touchResponse(tokenJSON(high)) as any);
2483+
await session.touch();
2484+
2485+
expect(session.lastActiveToken?.getRawString()).toBe(high);
2486+
});
2487+
2488+
it('a touch response without a token still clears lastActiveToken', async () => {
2489+
const high = createJwtWithOiat(NOW, NOW + 30);
2490+
const session = makeSession({ last_active_token: tokenJSON(high) } as Partial<SessionJSON>);
2491+
2492+
fetchSpy.mockResolvedValueOnce(touchResponse(null) as any);
2493+
await session.touch();
2494+
2495+
expect(session.lastActiveToken).toBeNull();
2496+
});
2497+
2498+
it('a stale piggybacked client payload does not regress the rebuilt session token', () => {
2499+
const high = createJwtWithOiat(NOW, NOW + 30);
2500+
const low = createJwtWithOiat(NOW, NOW);
2501+
const higher = createJwtWithOiat(NOW, NOW + 60);
2502+
2503+
const clientJSON = (lastActiveToken: ReturnType<typeof tokenJSON> | null) =>
2504+
({
2505+
object: 'client',
2506+
id: 'client_1',
2507+
last_active_session_id: 'session_1',
2508+
sessions: [touchResponse(lastActiveToken).response],
2509+
}) as any;
2510+
2511+
const client = Client.getOrCreateInstance().fromJSON(clientJSON(tokenJSON(high)));
2512+
expect(client.sessions[0]?.lastActiveToken?.getRawString()).toBe(high);
2513+
2514+
client.fromJSON(clientJSON(tokenJSON(low)));
2515+
expect(client.sessions[0]?.lastActiveToken?.getRawString()).toBe(high);
2516+
2517+
client.fromJSON(clientJSON(tokenJSON(higher)));
2518+
expect(client.sessions[0]?.lastActiveToken?.getRawString()).toBe(higher);
2519+
2520+
client.fromJSON(clientJSON(null));
2521+
expect(client.sessions[0]?.lastActiveToken).toBeNull();
2522+
});
2523+
});
24472524
});
24482525
});

packages/clerk-js/src/core/tokenFreshness.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,20 @@ export function tokenOrgId(input: TokenResource | JWT): string {
7272
export function normalizeOrgId(orgId?: string | null): string {
7373
return orgId || '';
7474
}
75+
76+
// Mirrors the cookie guard: only a same session+org lastActiveToken is a comparable
77+
// freshness baseline, so a session or org switch always adopts the incoming token.
78+
// Without this, an org-switch token minted by a stale edge (lower oiat) would lose
79+
// to the previous org's token and pin lastActiveToken to the old org's claims.
80+
export function shouldKeepExistingLastActiveToken(
81+
current: TokenResource | null | undefined,
82+
incoming: TokenResource,
83+
): boolean {
84+
if (!current?.jwt) {
85+
return false;
86+
}
87+
if (tokenSid(current) !== tokenSid(incoming) || tokenOrgId(current) !== tokenOrgId(incoming)) {
88+
return false;
89+
}
90+
return pickFreshestJwt(current, incoming) !== incoming;
91+
}

packages/expo/src/cache/dummy-data/environment-resource.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const DUMMY_CLERK_ENVIRONMENT_RESOURCE = {
99
single_session_mode: true,
1010
claimed_at: null,
1111
reverification: true,
12+
session_minter: false,
1213
},
1314
display_config: {
1415
object: 'display_config',
Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,16 @@
1-
export { DUMMY_CLERK_CLIENT_RESOURCE } from './client-resource';
2-
export { DUMMY_CLERK_ENVIRONMENT_RESOURCE } from './environment-resource';
1+
import { DUMMY_CLERK_CLIENT_RESOURCE } from './client-resource';
2+
import { DUMMY_CLERK_ENVIRONMENT_RESOURCE } from './environment-resource';
3+
4+
export { DUMMY_CLERK_CLIENT_RESOURCE, DUMMY_CLERK_ENVIRONMENT_RESOURCE };
5+
6+
export function isDummyClient(client: { id?: string | null } | null | undefined): boolean {
7+
return client?.id === DUMMY_CLERK_CLIENT_RESOURCE.id;
8+
}
9+
10+
// The dummy environment's own id is empty, so its display_config id is the reliable marker.
11+
export function isDummyEnvironment(
12+
environment: { display_config?: { id?: string } | null; displayConfig?: { id?: string } | null } | null | undefined,
13+
): boolean {
14+
const id = environment?.display_config?.id ?? environment?.displayConfig?.id;
15+
return id === DUMMY_CLERK_ENVIRONMENT_RESOURCE.display_config.id;
16+
}

packages/expo/src/cache/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,9 @@ export type { TokenCache } from './types';
22

33
export { MemoryTokenCache } from './MemoryTokenCache';
44
export { ClientResourceCache, EnvironmentResourceCache, SessionJWTCache } from './ResourceCache';
5-
export { DUMMY_CLERK_ENVIRONMENT_RESOURCE, DUMMY_CLERK_CLIENT_RESOURCE } from './dummy-data';
5+
export {
6+
DUMMY_CLERK_ENVIRONMENT_RESOURCE,
7+
DUMMY_CLERK_CLIENT_RESOURCE,
8+
isDummyClient,
9+
isDummyEnvironment,
10+
} from './dummy-data';

packages/expo/src/provider/ClerkProvider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export type ClerkProviderProps<TUi extends Ui = Ui> = Omit<ReactClerkProviderPro
3232
*/
3333
publishableKey: string;
3434
/**
35-
* The token cache is used to persist the active user's session token. Clerk stores this token in memory by default, however it is recommended to use a token cache for production applications.
35+
* The token cache is used to persist the client JWT that identifies this device to Clerk. Clerk keeps it in memory by default, however it is recommended to use a token cache backed by secure storage for production applications.
3636
* @see https://clerk.com/docs/quickstarts/expo#configure-the-token-cache-with-expo
3737
*/
3838
tokenCache?: TokenCache;

0 commit comments

Comments
 (0)