From 2deb7c39fa8bdf6ab9100967924dd055a510c0d4 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Thu, 27 Aug 2026 06:21:49 +0400 Subject: [PATCH 1/3] fix(electron): retain client token when persistence fails --- .changeset/bright-dogs-remember.md | 5 ++ .../src/storage/__tests__/index.test.ts | 60 ++++++++++++++++++- packages/electron/src/storage/index.ts | 27 +++++++-- 3 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 .changeset/bright-dogs-remember.md diff --git a/.changeset/bright-dogs-remember.md b/.changeset/bright-dogs-remember.md new file mode 100644 index 00000000000..5005a4384c8 --- /dev/null +++ b/.changeset/bright-dogs-remember.md @@ -0,0 +1,5 @@ +--- +'@clerk/electron': patch +--- + +Keep the current client token available for the lifetime of the Electron app when secure persistence is unavailable or fails. diff --git a/packages/electron/src/storage/__tests__/index.test.ts b/packages/electron/src/storage/__tests__/index.test.ts index 256db2ded7c..9ed9d5a4fc9 100644 --- a/packages/electron/src/storage/__tests__/index.test.ts +++ b/packages/electron/src/storage/__tests__/index.test.ts @@ -194,6 +194,18 @@ describe('setItem', () => { expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); }); + it('continues reading persisted storage after a successful write', async () => { + installSync(); + storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); + vi.mocked(safeStorage.decryptString).mockReturnValue('persisted-jwt'); + const adapter = storage(); + + await adapter.setItem('token-key', 'jwt'); + + await expect(adapter.getItem('token-key')).resolves.toBe('persisted-jwt'); + expect(storeGet).toHaveBeenCalledWith('token-key'); + }); + it('encrypts tokens before storing them (async backend)', async () => { installSync(); installAsync(); @@ -218,10 +230,13 @@ describe('setItem', () => { it('does not persist when no encryption is available and no fallback is configured', async () => { installSync({ available: false }); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const adapter = storage(); - await storage().setItem('token-key', 'jwt'); + await adapter.setItem('token-key', 'jwt'); expect(storeSet).not.toHaveBeenCalled(); + await expect(adapter.getItem('token-key')).resolves.toBe('jwt'); + expect(storeGet).not.toHaveBeenCalled(); expect(warn).toHaveBeenCalledOnce(); warn.mockRestore(); @@ -239,6 +254,23 @@ describe('setItem', () => { warn.mockRestore(); }); + it('retains tokens in memory when unencrypted persistence fails', async () => { + installSync({ available: false }); + storeSet.mockImplementationOnce(() => { + throw new Error('write failed'); + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const adapter = storage({ unencryptedFallback: true }); + + await adapter.setItem('token-key', 'jwt'); + + await expect(adapter.getItem('token-key')).resolves.toBe('jwt'); + expect(storeGet).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); + + warn.mockRestore(); + }); + it('only warns once across repeated saves', async () => { installSync({ available: false }); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -262,9 +294,12 @@ describe('setItem', () => { // Even with the fallback enabled, a *failed encrypt* (vs. unavailable encryption) must not be // persisted in the clear. - await storage({ unencryptedFallback: true }).setItem('token-key', 'jwt'); + const adapter = storage({ unencryptedFallback: true }); + await adapter.setItem('token-key', 'jwt'); expect(storeSet).not.toHaveBeenCalled(); + await expect(adapter.getItem('token-key')).resolves.toBe('jwt'); + expect(storeGet).not.toHaveBeenCalled(); expect(warn).toHaveBeenCalledOnce(); warn.mockRestore(); @@ -281,10 +316,15 @@ describe('setItem', () => { await adapter.setItem('token-key', 'jwt'); expect(storeSet).not.toHaveBeenCalled(); // not persisted while unavailable + await expect(adapter.getItem('token-key')).resolves.toBe('jwt'); await adapter.setItem('token-key', 'jwt'); expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); + storeGet.mockReturnValue(`enc:${Buffer.from('cipher').toString('base64')}`); + vi.mocked(safeStorage.decryptString).mockReturnValue('persisted-jwt'); + await expect(adapter.getItem('token-key')).resolves.toBe('persisted-jwt'); + warn.mockRestore(); }); @@ -309,4 +349,20 @@ describe('removeItem', () => { expect(storeDelete).toHaveBeenCalledWith('token-key'); }); + + it('removes tokens retained after persistence is unavailable', async () => { + installSync({ available: false }); + storeGet.mockReturnValue(undefined); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const adapter = storage(); + + await adapter.setItem('token-key', 'jwt'); + await adapter.removeItem('token-key'); + + await expect(adapter.getItem('token-key')).resolves.toBeNull(); + expect(storeDelete).toHaveBeenCalledWith('token-key'); + expect(storeGet).toHaveBeenCalledWith('token-key'); + + warn.mockRestore(); + }); }); diff --git a/packages/electron/src/storage/index.ts b/packages/electron/src/storage/index.ts index 1adabba43da..6b6cca60669 100644 --- a/packages/electron/src/storage/index.ts +++ b/packages/electron/src/storage/index.ts @@ -127,6 +127,7 @@ export function storage(options: StorageOptions = {}): TokenStorage { name: options.name ?? 'clerk-tokens', ...(options.path ? { cwd: options.path } : {}), }); + const memoryFallback = new Map(); let cachedCipher: Cipher | null = null; let resolving: Promise | null = null; @@ -155,6 +156,11 @@ export function storage(options: StorageOptions = {}): TokenStorage { return { async getItem(key) { + const fallback = memoryFallback.get(key); + if (fallback !== undefined) { + return fallback; + } + const stored = store.get(key); if (!stored) { @@ -203,11 +209,18 @@ export function storage(options: StorageOptions = {}): TokenStorage { if (!cipher) { if (options.unencryptedFallback) { - warnOnce( - 'Clerk: OS encryption is unavailable; falling back to unencrypted storage. Session tokens are being stored unencrypted on local disk.', - ); - store.set(key, RAW_PREFIX + value); + try { + store.set(key, RAW_PREFIX + value); + memoryFallback.delete(key); + warnOnce( + 'Clerk: OS encryption is unavailable; falling back to unencrypted storage. Tokens are being stored unencrypted on local disk.', + ); + } catch { + memoryFallback.set(key, value); + warnOnce('Clerk: failed to persist a token; it will only be available until the app exits.'); + } } else { + memoryFallback.set(key, value); warnOnce( 'Clerk: OS encryption is unavailable and unencryptedFallback is not enabled, so tokens are not being persisted. The user will be signed out on the next launch. Pass `storage({ unencryptedFallback: true })` to persist unencrypted (less secure).', ); @@ -217,12 +230,14 @@ export function storage(options: StorageOptions = {}): TokenStorage { try { store.set(key, ENCRYPTED_PREFIX + (await cipher.encrypt(value))); + memoryFallback.delete(key); } catch { - // Encryption is available but encryption failed - warnOnce('Clerk: failed to encrypt the session token; it was not persisted.'); + memoryFallback.set(key, value); + warnOnce('Clerk: failed to securely persist a token; it will only be available until the app exits.'); } }, removeItem(key) { + memoryFallback.delete(key); store.delete(key); }, }; From fd017244cd6fc9c334461ac8509a4895ce90f696 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Thu, 27 Aug 2026 06:49:06 +0400 Subject: [PATCH 2/3] fix(electron): prevent stale token writes --- .../src/storage/__tests__/index.test.ts | 67 +++++++++++++++++++ packages/electron/src/storage/index.ts | 29 +++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/packages/electron/src/storage/__tests__/index.test.ts b/packages/electron/src/storage/__tests__/index.test.ts index 9ed9d5a4fc9..133f5593c45 100644 --- a/packages/electron/src/storage/__tests__/index.test.ts +++ b/packages/electron/src/storage/__tests__/index.test.ts @@ -34,6 +34,16 @@ vi.mock('electron-store', () => ({ const ss = safeStorage as unknown as Record; const asyncSafeStorage = safeStorage as typeof safeStorage & AsyncSafeStorage; +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, reject, resolve }; +} + /** Installs the synchronous `safeStorage` API. */ function installSync({ available = true }: { available?: boolean } = {}) { ss.isEncryptionAvailable = vi.fn(() => available); @@ -160,6 +170,24 @@ describe('getItem', () => { expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); }); + it('does not re-save a rotated token after it is removed', async () => { + installSync(); + installAsync(); + const encryption = deferred(); + storeGet.mockReturnValue(`enc:${Buffer.from('old-cipher').toString('base64')}`); + vi.mocked(asyncSafeStorage.decryptStringAsync).mockResolvedValue({ result: 'jwt', shouldReEncrypt: true }); + vi.mocked(asyncSafeStorage.encryptStringAsync).mockReturnValue(encryption.promise); + const adapter = storage(); + + const read = adapter.getItem('token-key'); + await vi.waitFor(() => expect(asyncSafeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt')); + await adapter.removeItem('token-key'); + encryption.resolve(Buffer.from('re-encrypted')); + + await expect(read).resolves.toBe('jwt'); + expect(storeSet).not.toHaveBeenCalled(); + }); + it('returns null without deleting the entry when decryption rejects', async () => { installSync(); installAsync(); @@ -216,6 +244,45 @@ describe('setItem', () => { expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(jwt)').toString('base64')}`); }); + it('does not persist a pending write after the token is removed', async () => { + installSync(); + installAsync(); + const encryption = deferred(); + vi.mocked(asyncSafeStorage.encryptStringAsync).mockReturnValue(encryption.promise); + const adapter = storage(); + + const write = adapter.setItem('token-key', 'jwt'); + await vi.waitFor(() => expect(asyncSafeStorage.encryptStringAsync).toHaveBeenCalledWith('jwt')); + await adapter.removeItem('token-key'); + encryption.resolve(Buffer.from('enc(jwt)')); + await write; + + expect(storeSet).not.toHaveBeenCalled(); + await expect(adapter.getItem('token-key')).resolves.toBeNull(); + }); + + it('does not let an older failed write replace a newer token', async () => { + installSync(); + installAsync(); + const olderEncryption = deferred(); + vi.mocked(asyncSafeStorage.encryptStringAsync).mockImplementation(value => { + return value === 'old-jwt' ? olderEncryption.promise : Promise.resolve(Buffer.from(`enc(${value})`)); + }); + storeGet.mockReturnValue(`enc:${Buffer.from('new-cipher').toString('base64')}`); + vi.mocked(asyncSafeStorage.decryptStringAsync).mockResolvedValue({ result: 'new-jwt', shouldReEncrypt: false }); + const adapter = storage(); + + const olderWrite = adapter.setItem('token-key', 'old-jwt'); + await vi.waitFor(() => expect(asyncSafeStorage.encryptStringAsync).toHaveBeenCalledWith('old-jwt')); + await adapter.setItem('token-key', 'new-jwt'); + olderEncryption.reject(new Error('encrypt failed')); + await olderWrite; + + await expect(adapter.getItem('token-key')).resolves.toBe('new-jwt'); + expect(storeSet).toHaveBeenCalledOnce(); + expect(storeSet).toHaveBeenCalledWith('token-key', `enc:${Buffer.from('enc(new-jwt)').toString('base64')}`); + }); + it('falls back to the sync API (never calling the async crypto) when async encryption is unavailable', async () => { installSync(); installAsync({ available: false }); diff --git a/packages/electron/src/storage/index.ts b/packages/electron/src/storage/index.ts index 6b6cca60669..a79d8bd46a3 100644 --- a/packages/electron/src/storage/index.ts +++ b/packages/electron/src/storage/index.ts @@ -128,6 +128,14 @@ export function storage(options: StorageOptions = {}): TokenStorage { ...(options.path ? { cwd: options.path } : {}), }); const memoryFallback = new Map(); + const mutationVersions = new Map(); + + const beginMutation = (key: string) => { + const version = (mutationVersions.get(key) ?? 0) + 1; + mutationVersions.set(key, version); + return version; + }; + const isCurrentMutation = (key: string, version: number) => (mutationVersions.get(key) ?? 0) === version; let cachedCipher: Cipher | null = null; let resolving: Promise | null = null; @@ -172,6 +180,7 @@ export function storage(options: StorageOptions = {}): TokenStorage { } if (stored.startsWith(ENCRYPTED_PREFIX)) { + const mutationVersion = mutationVersions.get(key) ?? 0; const cipher = await getCipher(); // No usable OS encryption, preserve entry. @@ -187,7 +196,10 @@ export function storage(options: StorageOptions = {}): TokenStorage { if (shouldReEncrypt) { // OS key has rotated, persist with new value try { - store.set(key, ENCRYPTED_PREFIX + (await cipher.encrypt(value))); + const encrypted = await cipher.encrypt(value); + if (isCurrentMutation(key, mutationVersion)) { + store.set(key, ENCRYPTED_PREFIX + encrypted); + } } catch { // keep the existing payload; it still decrypts for now } @@ -205,8 +217,13 @@ export function storage(options: StorageOptions = {}): TokenStorage { return null; }, async setItem(key, value) { + const mutationVersion = beginMutation(key); const cipher = await getCipher(); + if (!isCurrentMutation(key, mutationVersion)) { + return; + } + if (!cipher) { if (options.unencryptedFallback) { try { @@ -229,14 +246,22 @@ export function storage(options: StorageOptions = {}): TokenStorage { } try { - store.set(key, ENCRYPTED_PREFIX + (await cipher.encrypt(value))); + const encrypted = await cipher.encrypt(value); + if (!isCurrentMutation(key, mutationVersion)) { + return; + } + store.set(key, ENCRYPTED_PREFIX + encrypted); memoryFallback.delete(key); } catch { + if (!isCurrentMutation(key, mutationVersion)) { + return; + } memoryFallback.set(key, value); warnOnce('Clerk: failed to securely persist a token; it will only be available until the app exits.'); } }, removeItem(key) { + beginMutation(key); memoryFallback.delete(key); store.delete(key); }, From 3f6f5c3ff66912b66cd01e6ea7eb1eea2958b4ff Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Thu, 27 Aug 2026 07:07:49 +0400 Subject: [PATCH 3/3] docs(electron): clarify token write ordering --- .changeset/bright-dogs-remember.md | 2 ++ packages/electron/src/storage/index.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/.changeset/bright-dogs-remember.md b/.changeset/bright-dogs-remember.md index 5005a4384c8..23208a82107 100644 --- a/.changeset/bright-dogs-remember.md +++ b/.changeset/bright-dogs-remember.md @@ -3,3 +3,5 @@ --- Keep the current client token available for the lifetime of the Electron app when secure persistence is unavailable or fails. + +Fixed a race where an older in-flight token write could replace a newer token when multiple requests completed at the same time. diff --git a/packages/electron/src/storage/index.ts b/packages/electron/src/storage/index.ts index a79d8bd46a3..040e96b813b 100644 --- a/packages/electron/src/storage/index.ts +++ b/packages/electron/src/storage/index.ts @@ -128,6 +128,7 @@ export function storage(options: StorageOptions = {}): TokenStorage { ...(options.path ? { cwd: options.path } : {}), }); const memoryFallback = new Map(); + // IPC requests can resolve out of order, so only the latest mutation may update storage. const mutationVersions = new Map(); const beginMutation = (key: string) => {