diff --git a/.changeset/expo-keep-rotated-client-token.md b/.changeset/expo-keep-rotated-client-token.md new file mode 100644 index 00000000000..7810f477c3d --- /dev/null +++ b/.changeset/expo-keep-rotated-client-token.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Fix an intermittent `No session was found with id sess_...` error right after signing in when native components are installed. A request that the JS or native SDK sent with the client token from before the sign-in could leave both SDKs on a new, empty client. The SDK now keeps the client that the sign-in created, and no longer briefly reports the user as signed out while it recovers. diff --git a/packages/expo/src/provider/__tests__/nativeClientSync.test.ts b/packages/expo/src/provider/__tests__/nativeClientSync.test.ts index e2c6d2837d8..7f178173d36 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSync.test.ts +++ b/packages/expo/src/provider/__tests__/nativeClientSync.test.ts @@ -44,6 +44,27 @@ function storedTokenCache(clientToken: string): TokenCache { }; } +function clientToken(clientId: string, rotation: number): string { + const payload = Buffer.from(JSON.stringify({ id: clientId, rotating_token: `rotation_${rotation}` })).toString( + 'base64url', + ); + return `header.${payload}.signature`; +} + +function nativeWithStoredToken(initial: string): ClerkExpoNativeModule { + const native = nativeModule(); + let nativeToken: string | null = initial; + vi.mocked(native.getDeviceToken).mockImplementation(() => Promise.resolve(nativeToken)); + vi.mocked(native.setDeviceToken).mockImplementation((token, expected) => { + const didSet = nativeToken === expected; + if (didSet) { + nativeToken = token; + } + return Promise.resolve(didSet); + }); + return native; +} + async function loadEngine() { const { createNativeClientSync } = await import('../nativeClientSync'); return createNativeClientSync; @@ -208,4 +229,69 @@ describe('createNativeClientSync', () => { expect(getToken).toHaveBeenCalledTimes(2); expect(native.configureNative).toHaveBeenCalledWith('pk_test_123', 'seed-token', null); }); + + test('keeps the token a sign-in rotated when a request with the old token minted another client first', async () => { + const createNativeClientSync = await loadEngine(); + const beforeSignIn = clientToken('client_signed_in', 1); + const afterSignIn = clientToken('client_signed_in', 2); + const minted = clientToken('client_minted', 1); + const native = nativeWithStoredToken(minted); + const cache = storedTokenCache(beforeSignIn); + + const sync = createNativeClientSync(native, 'pk_test_123', null, () => cache); + await sync.tokenCache.saveClientToken(afterSignIn, beforeSignIn); + + await expect(sync.tokenCache.getToken(CLERK_CLIENT_JWT_KEY)).resolves.toBe(afterSignIn); + expect(native.setDeviceToken).toHaveBeenLastCalledWith(afterSignIn, minted); + }); + + test('keeps the rotated token when a late response mints another client from the old token', async () => { + const createNativeClientSync = await loadEngine(); + const beforeSignIn = clientToken('client_signed_in', 1); + const afterSignIn = clientToken('client_signed_in', 2); + const native = nativeWithStoredToken(afterSignIn); + const cache = storedTokenCache(beforeSignIn); + + const sync = createNativeClientSync(native, 'pk_test_123', null, () => cache); + await sync.tokenCache.saveClientToken(clientToken('client_minted', 1), beforeSignIn); + + await expect(sync.tokenCache.getToken(CLERK_CLIENT_JWT_KEY)).resolves.toBe(afterSignIn); + }); + + test('keeps a newer native token for the same client', async () => { + const createNativeClientSync = await loadEngine(); + const newer = clientToken('client_signed_in', 3); + const native = nativeWithStoredToken(newer); + const cache = storedTokenCache(clientToken('client_signed_in', 1)); + + const sync = createNativeClientSync(native, 'pk_test_123', null, () => cache); + await sync.tokenCache.saveClientToken(clientToken('client_signed_in', 2), clientToken('client_signed_in', 1)); + + await expect(sync.tokenCache.getToken(CLERK_CLIENT_JWT_KEY)).resolves.toBe(newer); + expect(native.setDeviceToken).toHaveBeenCalledTimes(1); + }); + + test('keeps the native token when the tokens are not client JWTs', async () => { + const createNativeClientSync = await loadEngine(); + const native = nativeWithStoredToken('other-native-token'); + const cache = storedTokenCache('seed-token'); + + const sync = createNativeClientSync(native, 'pk_test_123', null, () => cache); + await sync.tokenCache.saveClientToken('signed-in-token', 'seed-token'); + + await expect(sync.tokenCache.getToken(CLERK_CLIENT_JWT_KEY)).resolves.toBe('other-native-token'); + expect(native.setDeviceToken).toHaveBeenCalledTimes(1); + }); + + test('keeps a native token that is not a client JWT', async () => { + const createNativeClientSync = await loadEngine(); + const native = nativeWithStoredToken('other-native-token'); + const cache = storedTokenCache(clientToken('client_signed_in', 1)); + + const sync = createNativeClientSync(native, 'pk_test_123', null, () => cache); + await sync.tokenCache.saveClientToken(clientToken('client_signed_in', 2), clientToken('client_signed_in', 1)); + + await expect(sync.tokenCache.getToken(CLERK_CLIENT_JWT_KEY)).resolves.toBe('other-native-token'); + expect(native.setDeviceToken).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeNativeClerk.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeNativeClerk.ts index 398a0982fc0..ca903152e06 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeNativeClerk.ts +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeNativeClerk.ts @@ -119,6 +119,10 @@ export class FakeNativeClerk { await this.#observe(() => this.server.removeAllSessions('native', this.token)); } + async refresh(): Promise { + await this.#observe(() => this.server.getClient('native', this.token)); + } + async rotateDeviceToken(): Promise { if (this.token) { this.server.rotateTokenOnNextResponse(this.token); diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts index f5ead4deec6..8df719a2e9e 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts @@ -27,10 +27,21 @@ export type FapiRequestLogEntry = { token: string | null; }; +export type HeldResponse = { + handled: Promise; + release: () => void; +}; + +type ResponseHold = { caller: FapiCaller; operation: string; handled: () => void; released: Promise }; + function networkRoundTrip(): Promise { return new Promise(resolve => setTimeout(resolve, 0)); } +function base64Url(value: string): string { + return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + function cloneClient(client: FakeServerClientJSON): FakeServerClientJSON { return { id: client.id, @@ -41,14 +52,19 @@ function cloneClient(client: FakeServerClientJSON): FakeServerClientJSON { /** * A single Frontend API shared by the JS and native SDKs. Clients are keyed by device token, and - * both SDKs only ever see server state through these requests. + * both SDKs only ever see server state through these requests. A device token is a JWT whose payload + * names its client. Rotating a token retires the previous one, and like the real Frontend API a request + * that carries a retired token is answered as a brand new client with no sessions. */ export class FakeClerkServer { readonly requests: FapiRequestLogEntry[] = []; #clients = new Map(); #clientIdByToken = new Map(); + #retiredTokens = new Set(); #pendingRotations = new Map(); + #responseHolds: ResponseHold[] = []; #sequence = 0; + #rotations = 0; #nextId(prefix: string): string { this.#sequence += 1; @@ -63,12 +79,25 @@ export class FakeClerkServer { return clientId ? (this.#clients.get(clientId) ?? null) : null; } + #mintToken(clientId: string): string { + this.#rotations += 1; + const payload = base64Url(JSON.stringify({ id: clientId, rotating_token: String(this.#rotations) })); + const token = `header.${payload}.signature`; + this.#clientIdByToken.set(token, clientId); + return token; + } + #createClient(): { client: FakeServerClientJSON; token: string } { const client: FakeServerClientJSON = { id: this.#nextId('client'), sessions: [], lastActiveSessionId: null }; - const token = this.#nextId(`device_token_${client.id}`); this.#clients.set(client.id, client); - this.#clientIdByToken.set(token, client.id); - return { client, token }; + return { client, token: this.#mintToken(client.id) }; + } + + #authenticate(token: string | null): { client: FakeServerClientJSON | null; token: string | null } { + if (token && this.#retiredTokens.has(token)) { + return this.#createClient(); + } + return { client: this.#clientFor(token), token }; } #respond(token: string | null, client: FakeServerClientJSON | null, status: 200 | 401 = 200): FapiResponse { @@ -76,15 +105,51 @@ export class FakeClerkServer { const pendingRotations = client ? (this.#pendingRotations.get(client.id) ?? 0) : 0; if (client && pendingRotations > 0) { this.#pendingRotations.set(client.id, pendingRotations - 1); - responseToken = this.#nextId(`device_token_${client.id}`); - this.#clientIdByToken.set(responseToken, client.id); + if (token) { + this.#clientIdByToken.delete(token); + this.#retiredTokens.add(token); + } + responseToken = this.#mintToken(client.id); } return { status, client: client ? cloneClient(client) : null, token: responseToken }; } - async #request(caller: FapiCaller, operation: string, token: string | null): Promise { + async #send( + caller: FapiCaller, + operation: string, + token: string | null, + handle: () => T, + ): Promise { this.requests.push({ caller, operation, token }); await networkRoundTrip(); + const response = handle(); + const holdIndex = this.#responseHolds.findIndex(hold => hold.caller === caller && hold.operation === operation); + if (holdIndex >= 0) { + const [hold] = this.#responseHolds.splice(holdIndex, 1); + hold.handled(); + await hold.released; + } + return response; + } + + holdNextResponse(caller: FapiCaller, operation: string): HeldResponse { + let handled!: () => void; + let release!: () => void; + const hold: ResponseHold = { + caller, + operation, + handled: () => handled(), + released: new Promise(resolve => { + release = resolve; + }), + }; + this.#responseHolds.push(hold); + return { + handled: new Promise(resolve => { + handled = resolve; + }), + release, + }; } seedClient({ users = [], activeUser }: { users?: string[]; activeUser?: string } = {}): { @@ -125,91 +190,93 @@ export class FakeClerkServer { .length; } - async getClient(caller: FapiCaller, token: string | null): Promise { - await this.#request(caller, 'GET /client', token); - return this.#respond(token, this.#clientFor(token)); + getClient(caller: FapiCaller, token: string | null): Promise { + return this.#send(caller, 'GET /client', token, () => { + const auth = this.#authenticate(token); + return this.#respond(auth.token, auth.client); + }); } - async createSessionlessClient(caller: FapiCaller): Promise { - await this.#request(caller, 'POST /client', null); - const { client, token } = this.#createClient(); - return this.#respond(token, client); + createSessionlessClient(caller: FapiCaller): Promise { + return this.#send(caller, 'POST /client', null, () => { + const { client, token } = this.#createClient(); + return this.#respond(token, client); + }); } - async signIn( - caller: FapiCaller, - token: string | null, - userId: string, - ): Promise { - await this.#request(caller, 'POST /client/sign_ins', token); - let client = this.#clientFor(token); - let responseToken = token; - if (!client) { - const created = this.#createClient(); - client = created.client; - responseToken = created.token; - } - const session = { id: this.#nextId('sess'), userId, profileVersion: 0, activityVersion: 0 }; - client.sessions.push(session); - client.lastActiveSessionId = session.id; - return { ...this.#respond(responseToken, client), sessionId: session.id }; + signIn(caller: FapiCaller, token: string | null, userId: string): Promise { + return this.#send(caller, 'POST /client/sign_ins', token, () => { + const auth = this.#authenticate(token); + const { client, token: responseToken } = auth.client + ? { client: auth.client, token: auth.token } + : this.#createClient(); + const session = { id: this.#nextId('sess'), userId, profileVersion: 0, activityVersion: 0 }; + client.sessions.push(session); + client.lastActiveSessionId = session.id; + return { ...this.#respond(responseToken, client), sessionId: session.id }; + }); } - async touchSession(caller: FapiCaller, token: string | null, sessionId: string): Promise { - await this.#request(caller, 'POST /client/sessions/:id/touch', token); - const client = this.#clientFor(token); - if (!client?.sessions.some(session => session.id === sessionId)) { - return this.#respond(token, client, 401); - } - client.lastActiveSessionId = sessionId; - return this.#respond(token, client); + touchSession(caller: FapiCaller, token: string | null, sessionId: string): Promise { + return this.#send(caller, 'POST /client/sessions/:id/touch', token, () => { + const { client, token: clientToken } = this.#authenticate(token); + if (!client?.sessions.some(session => session.id === sessionId)) { + return this.#respond(clientToken, client, 401); + } + client.lastActiveSessionId = sessionId; + return this.#respond(clientToken, client); + }); } - async removeSession(caller: FapiCaller, token: string | null, sessionId: string): Promise { - await this.#request(caller, 'POST /client/sessions/:id/remove', token); - const client = this.#clientFor(token); - if (!client) { - return this.#respond(token, null, 401); - } - client.sessions = client.sessions.filter(session => session.id !== sessionId); - if (client.lastActiveSessionId === sessionId) { - client.lastActiveSessionId = client.sessions[0]?.id ?? null; - } - return this.#respond(token, client); + removeSession(caller: FapiCaller, token: string | null, sessionId: string): Promise { + return this.#send(caller, 'POST /client/sessions/:id/remove', token, () => { + const { client, token: clientToken } = this.#authenticate(token); + if (!client) { + return this.#respond(clientToken, null, 401); + } + client.sessions = client.sessions.filter(session => session.id !== sessionId); + if (client.lastActiveSessionId === sessionId) { + client.lastActiveSessionId = client.sessions[0]?.id ?? null; + } + return this.#respond(clientToken, client); + }); } - async removeAllSessions(caller: FapiCaller, token: string | null): Promise { - await this.#request(caller, 'DELETE /client/sessions', token); - const client = this.#clientFor(token); - if (client) { - client.sessions = []; - client.lastActiveSessionId = null; - } - return this.#respond(token, client); + removeAllSessions(caller: FapiCaller, token: string | null): Promise { + return this.#send(caller, 'DELETE /client/sessions', token, () => { + const { client, token: clientToken } = this.#authenticate(token); + if (client) { + client.sessions = []; + client.lastActiveSessionId = null; + } + return this.#respond(clientToken, client); + }); } - async createSessionToken(caller: FapiCaller, token: string | null, sessionId: string): Promise { - await this.#request(caller, 'POST /client/sessions/:id/tokens', token); - const client = this.#clientFor(token); - const session = client?.sessions.find(candidate => candidate.id === sessionId); - if (session) { - session.activityVersion += 1; - } - return this.#respond(token, client, session ? 200 : 401); + createSessionToken(caller: FapiCaller, token: string | null, sessionId: string): Promise { + return this.#send(caller, 'POST /client/sessions/:id/tokens', token, () => { + const { client, token: clientToken } = this.#authenticate(token); + const session = client?.sessions.find(candidate => candidate.id === sessionId); + if (session) { + session.activityVersion += 1; + } + return this.#respond(clientToken, client, session ? 200 : 401); + }); } - async updateUser(caller: FapiCaller, token: string | null, sessionId: string): Promise { - await this.#request(caller, 'PATCH /me', token); - const client = this.#clientFor(token); - const session = client?.sessions.find(candidate => candidate.id === sessionId); - if (!client || !session) { - return this.#respond(token, client, 401); - } - for (const candidate of client.sessions) { - if (candidate.userId === session.userId) { - candidate.profileVersion += 1; + updateUser(caller: FapiCaller, token: string | null, sessionId: string): Promise { + return this.#send(caller, 'PATCH /me', token, () => { + const { client, token: clientToken } = this.#authenticate(token); + const session = client?.sessions.find(candidate => candidate.id === sessionId); + if (!client || !session) { + return this.#respond(clientToken, client, 401); } - } - return this.#respond(token, client); + for (const candidate of client.sessions) { + if (candidate.userId === session.userId) { + candidate.profileVersion += 1; + } + } + return this.#respond(clientToken, client); + }); } } diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx index 3af4094a01f..19404694f4c 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx @@ -436,5 +436,80 @@ describe('native client sync behavior', () => { expect(h.js.session?.id).toBe(sessionIds.user_1); await expectConverged(h); }); + + it('keeps a JS sign-in on its client when a stale-token native refresh lands a new client first', async () => { + const { server, token, clientId } = seedDevice([]); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + + server.rotateTokenOnNextResponse(token); + const signInResponse = server.holdNextResponse('js', 'POST /client/sign_ins'); + const signingIn = h.js.signIn('user_1'); + await signInResponse.handled; + await h.run(() => h.native.refresh()); + await h.settle(); + expect(server.clientIdForToken(h.native.token)).not.toBe(clientId); + + signInResponse.release(); + const sessionId = await h.run(() => signingIn); + await h.settle(); + + expect(h.js.client?.id).toBe(clientId); + expect(h.js.session?.id).toBe(sessionId); + expect(h.native.activeSessionId).toBe(sessionId); + await expectConverged(h); + }); + + it("keeps JS signed in when a pull answers for a client that is no longer the shared token's client", async () => { + const { server, token, clientId } = seedDevice([]); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + + server.rotateTokenOnNextResponse(token); + const signInResponse = server.holdNextResponse('js', 'POST /client/sign_ins'); + const signingIn = h.js.signIn('user_1'); + await signInResponse.handled; + const pullResponse = server.holdNextResponse('js', 'GET /client'); + await h.run(() => h.native.refresh()); + await pullResponse.handled; + signInResponse.release(); + const sessionId = await h.run(() => signingIn); + expect(h.js.session?.id).toBe(sessionId); + const emissionIndex = h.js.emissions.length; + + pullResponse.release(); + await h.settle(); + + expect(signedOutEmissionsSince(h, emissionIndex)).toEqual([]); + expect(h.js.client?.id).toBe(clientId); + expect(h.js.session?.id).toBe(sessionId); + const sharedToken = await h.jsDeviceToken(); + expect(h.native.token).toBe(sharedToken); + expect(server.clientIdForToken(sharedToken)).toBe(clientId); + await expectConverged(h); + }); + + it('keeps the session when native signs out with the pre-rotation token while a JS response rotates it', async () => { + const { server, token, clientId, sessionIds } = seedDevice(['user_1']); + const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token }); + + server.rotateTokenOnNextResponse(token); + const profileResponse = server.holdNextResponse('js', 'PATCH /me'); + const updating = h.js.updateProfile(); + await profileResponse.handled; + await h.run(() => h.native.signOut()); + await h.settle(); + expect(server.clientIdForToken(h.native.token)).not.toBe(clientId); + + profileResponse.release(); + await h.run(() => updating); + await h.settle(); + + const sharedToken = await h.jsDeviceToken(); + expect(h.native.token).toBe(sharedToken); + expect(server.clientIdForToken(sharedToken)).toBe(clientId); + expect(server.inspectClient(sharedToken)?.sessions.map(session => session.id)).toEqual([sessionIds.user_1]); + expect(h.js.client?.id).toBe(clientId); + expect(h.js.session?.id).toBe(sessionIds.user_1); + await expectConverged(h); + }); }); }); diff --git a/packages/expo/src/provider/nativeClientSync.ts b/packages/expo/src/provider/nativeClientSync.ts index 7e10de6836a..e55c754dc9d 100644 --- a/packages/expo/src/provider/nativeClientSync.ts +++ b/packages/expo/src/provider/nativeClientSync.ts @@ -1,3 +1,4 @@ +import { isomorphicAtob } from '@clerk/shared/isomorphicAtob'; import type { ClientResource, SignedInSessionResource } from '@clerk/shared/types'; import { MemoryTokenCache } from '../cache/MemoryTokenCache'; @@ -104,6 +105,22 @@ function fingerprint(clerk: SyncableClerk): string { ]); } +function clientIdOf(token: string | null): string | null { + const payload = token?.split('.')[1]; + if (!payload) { + return null; + } + try { + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + const { id } = JSON.parse(isomorphicAtob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='))) as { + id?: unknown; + }; + return typeof id === 'string' ? id : null; + } catch { + return null; + } +} + async function readSeedDeviceToken(tokenCache: TokenCache): Promise { try { return (await tokenCache.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; @@ -185,6 +202,15 @@ export function createNativeClientSync( return false; }); + const reclaimRotatedToken = async (token: string, requestToken: string | null, current: string | null) => { + const clientId = clientIdOf(requestToken); + if (!clientId || clientIdOf(token) !== clientId) { + return false; + } + const currentClientId = clientIdOf(current); + return !!currentClientId && currentClientId !== clientId && setDeviceToken(token, current); + }; + const tokenCache: ClientTokenCache = { getToken: async key => { if (key !== CLERK_CLIENT_JWT_KEY || !(await ready())) { @@ -206,8 +232,16 @@ export function createNativeClientSync( if (!(await ready())) { return fallback().saveToken(CLERK_CLIENT_JWT_KEY, token); } - const didSet = token === requestToken || (await setDeviceToken(token, requestToken)); - mirror(didSet ? token : await native.getDeviceToken().catch(() => null)); + if (token === requestToken || (await setDeviceToken(token, requestToken))) { + mirror(token); + return; + } + const current = await native.getDeviceToken().catch(() => null); + const didReclaim = await reclaimRotatedToken(token, requestToken, current); + mirror(didReclaim ? token : current); + if (didReclaim) { + void native.refreshClient().catch(error => warn('Failed to refresh the native client.', error)); + } }, }; @@ -218,7 +252,12 @@ export function createNativeClientSync( const pull = async () => { const client = clerk.client as FetchableClient | null | undefined; if (typeof client?.fetch === 'function') { - clerk.updateClient(await client.fetch({ fetchMaxTries: 1 })); + let fetched = await client.fetch({ fetchMaxTries: 1 }); + const sharedClientId = clientIdOf(await native.getDeviceToken().catch(() => null)); + if (sharedClientId && fetched.id && fetched.id !== sharedClientId) { + fetched = await client.fetch({ fetchMaxTries: 1 }); + } + clerk.updateClient(fetched); } else { await clerk.__internal_reloadInitialResources?.(); }