Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lucky-donkeys-brake.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions .changeset/tender-pugs-shave.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion packages/clerk-js/src/core/resources/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
50 changes: 34 additions & 16 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(
Expand Down
118 changes: 117 additions & 1 deletion packages/clerk-js/src/core/resources/__tests__/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -2269,6 +2269,7 @@ describe('Session', () => {
afterEach(() => {
dispatchSpy?.mockRestore();
fetchSpy?.mockRestore();
Client.clearInstance();
BaseResource.clerk = null as any;
SessionTokenCache.clear();
});
Expand Down Expand Up @@ -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<typeof tokenJSON> | 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<SessionJSON>);

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<SessionJSON>);

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<SessionJSON>);

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<SessionJSON>).lastActiveToken?.getRawString(),
).toBe(high);
expect(
makeSession({ last_active_token: tokenJSON(low) } as Partial<SessionJSON>).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);
});
});
});
});
1 change: 1 addition & 0 deletions packages/expo/src/cache/dummy-data/environment-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion packages/expo/src/provider/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export type ClerkProviderProps<TUi extends Ui = Ui> = Omit<ReactClerkProviderPro
*/
publishableKey: string;
/**
* 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.
* 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.
* @see https://clerk.com/docs/quickstarts/expo#configure-the-token-cache-with-expo
*/
tokenCache?: TokenCache;
Expand Down
Loading
Loading