Skip to content
Merged
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
36 changes: 22 additions & 14 deletions README.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export {
generateVerifier,
} from './pkce.js'
export type { GenerateVerifierOptions } from './pkce.js'
export { persistBundle } from './persist.js'
export type { PersistBundleOptions } from './persist.js'
export { createPkceProvider } from './providers/pkce.js'
export type { PkceLazyString, PkceProviderOptions } from './providers/pkce.js'
export type {
Expand All @@ -32,6 +34,8 @@ export type {
ExchangeResult,
PrepareInput,
PrepareResult,
RefreshInput,
TokenBundle,
TokenStore,
ValidateInput,
} from './types.js'
Expand Down
162 changes: 160 additions & 2 deletions src/auth/keyring/record-write.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'

import { buildSingleSlot, buildUserRecords } from '../../test-support/keyring-mocks.js'
import { writeRecordWithKeyringFallback } from './record-write.js'
import type { TokenBundle } from '../types.js'
import { writeBundleWithKeyringFallback, writeRecordWithKeyringFallback } from './record-write.js'
import { SecureStoreUnavailableError } from './secure-store.js'

type Account = { id: string; label?: string; email: string }
Expand All @@ -22,7 +23,7 @@ describe('writeRecordWithKeyringFallback', () => {

expect(result.storedSecurely).toBe(true)
expect(secureStore.setSpy).toHaveBeenCalledWith('tok_secret')
expect(upsertSpy).toHaveBeenCalledWith({ account })
expect(upsertSpy).toHaveBeenCalledWith({ account, hasRefreshToken: false })
expect(state.records.get('42')?.fallbackToken).toBeUndefined()
})

Expand Down Expand Up @@ -95,3 +96,160 @@ describe('writeRecordWithKeyringFallback', () => {
expect(secureStore.deleteSpy).not.toHaveBeenCalled()
})
})

describe('writeBundleWithKeyringFallback', () => {
const bundle: TokenBundle = {
accessToken: 'tok_a',
refreshToken: 'tok_r',
accessTokenExpiresAt: 1_700_000_000_000,
refreshTokenExpiresAt: 1_701_000_000_000,
}

function harness() {
const accessStore = buildSingleSlot()
const refreshStore = buildSingleSlot()
const records = buildUserRecords<Account>()
return { accessStore, refreshStore, ...records }
}

it('writes both slots and persists the bundle metadata on the happy path', async () => {
const { accessStore, refreshStore, store: userRecords, state, upsertSpy } = harness()

const result = await writeBundleWithKeyringFallback({
accessStore,
refreshStore,
userRecords,
account,
bundle,
})

expect(result).toEqual({ accessStoredSecurely: true, refreshStoredSecurely: true })
expect(accessStore.setSpy).toHaveBeenCalledWith('tok_a')
expect(refreshStore.setSpy).toHaveBeenCalledWith('tok_r')
expect(upsertSpy).toHaveBeenCalledWith({
account,
accessTokenExpiresAt: 1_700_000_000_000,
refreshTokenExpiresAt: 1_701_000_000_000,
hasRefreshToken: true,
})
expect(state.records.get('42')?.fallbackToken).toBeUndefined()
expect(state.records.get('42')?.fallbackRefreshToken).toBeUndefined()
})

it('clears the refresh slot when the bundle has no refresh token (no stale carryover)', async () => {
const { accessStore, refreshStore, store: userRecords, state } = harness()

await writeBundleWithKeyringFallback({
accessStore,
refreshStore,
userRecords,
account,
bundle: { accessToken: 'tok_a' },
})

expect(refreshStore.deleteSpy).toHaveBeenCalledTimes(1)
expect(state.records.get('42')?.hasRefreshToken).toBe(false)
})

it('falls back to fallbackRefreshToken when the refresh slot is offline', async () => {
Comment thread
scottlovegrove marked this conversation as resolved.
const { accessStore, refreshStore, store: userRecords, state } = harness()
refreshStore.setSpy.mockRejectedValueOnce(new SecureStoreUnavailableError('no dbus'))

const result = await writeBundleWithKeyringFallback({
accessStore,
refreshStore,
userRecords,
account,
bundle,
})

expect(result).toEqual({ accessStoredSecurely: true, refreshStoredSecurely: false })
expect(state.records.get('42')?.fallbackRefreshToken).toBe('tok_r')
expect(state.records.get('42')?.fallbackToken).toBeUndefined()
})

it('rolls back the access slot when a non-keyring refresh-slot setSecret error fires', async () => {
// Refresh-slot setSecret throws an unexpected error (not
// SecureStoreUnavailable) — leaving the access slot written would
// orphan a credential against a never-persisted record.
const { accessStore, refreshStore, store: userRecords, state } = harness()
refreshStore.setSpy.mockRejectedValueOnce(new Error('refresh slot blew up'))

await expect(
writeBundleWithKeyringFallback({
accessStore,
refreshStore,
userRecords,
account,
bundle,
}),
).rejects.toThrow('refresh slot blew up')

expect(accessStore.deleteSpy).toHaveBeenCalledTimes(1)
expect(state.records.size).toBe(0)
})

it('rolls back BOTH keyring slots when upsert fails after both writes succeeded', async () => {
const { accessStore, refreshStore, store: userRecords, upsertSpy } = harness()
upsertSpy.mockRejectedValueOnce(new Error('disk full'))

await expect(
writeBundleWithKeyringFallback({
accessStore,
refreshStore,
userRecords,
account,
bundle,
}),
).rejects.toThrow('disk full')

expect(accessStore.deleteSpy).toHaveBeenCalledTimes(1)
expect(refreshStore.deleteSpy).toHaveBeenCalledTimes(1)
})

it('falls back to fallbackToken when the access slot is offline (headless/WSL bundle path)', async () => {
// Real-world headless / WSL / locked-Keychain scenario: D-Bus is
// unavailable, so BOTH slot writes throw SecureStoreUnavailable.
// The record must persist both tokens as plaintext fallbacks.
const { accessStore, refreshStore, store: userRecords, state } = harness()
accessStore.setSpy.mockRejectedValueOnce(new SecureStoreUnavailableError('no dbus'))
refreshStore.setSpy.mockRejectedValueOnce(new SecureStoreUnavailableError('no dbus'))

const result = await writeBundleWithKeyringFallback({
accessStore,
refreshStore,
userRecords,
account,
bundle,
})

expect(result).toEqual({ accessStoredSecurely: false, refreshStoredSecurely: false })
expect(state.records.get('42')?.fallbackToken).toBe('tok_a')
expect(state.records.get('42')?.fallbackRefreshToken).toBe('tok_r')
expect(state.records.get('42')?.hasRefreshToken).toBe(true)
})

it('defers the no-refresh slot wipe until after upsert succeeds', async () => {
// Regression: wiping before upsert would lose refresh state if the
// upsert then rejected. Order must be set-access → upsert → wipe.
const { accessStore, refreshStore, store: userRecords, upsertSpy } = harness()
const callOrder: string[] = []
refreshStore.deleteSpy.mockImplementationOnce(async () => {
callOrder.push('refresh-delete')
return false
})
upsertSpy.mockImplementationOnce(async () => {
callOrder.push('upsert')
})

await writeBundleWithKeyringFallback({
accessStore,
refreshStore,
userRecords,
account,
bundle: { accessToken: 'tok_a' },
})

expect(callOrder).toEqual(['upsert', 'refresh-delete'])
})
})
Loading
Loading