156 lines
6 KiB
TypeScript
156 lines
6 KiB
TypeScript
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
|
|
const mockKeychainStore = new Map<string, { username: string; password: string }>()
|
|
const mockResetCalls: string[] = []
|
|
let mockSetResult: false | { service: string; storage: string } = {
|
|
service: 'mock',
|
|
storage: 'AES_GCM_NO_AUTH',
|
|
}
|
|
|
|
jest.mock('react-native-keychain', () => ({
|
|
ACCESSIBLE: { AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 'AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY' },
|
|
SECURITY_LEVEL: { SECURE_SOFTWARE: 'SECURE_SOFTWARE' },
|
|
STORAGE_TYPE: { AES_GCM_NO_AUTH: 'AES_GCM_NO_AUTH' },
|
|
getGenericPassword: jest.fn(async ({ service }: { service: string }) => (
|
|
mockKeychainStore.get(service) ?? false
|
|
)),
|
|
setGenericPassword: jest.fn(async (
|
|
username: string,
|
|
password: string,
|
|
{ service }: { service: string },
|
|
) => {
|
|
if (mockSetResult === false) return false
|
|
mockKeychainStore.set(service, { username, password })
|
|
return { ...mockSetResult, service }
|
|
}),
|
|
resetGenericPassword: jest.fn(async ({ service }: { service: string }) => {
|
|
mockResetCalls.push(service)
|
|
mockKeychainStore.delete(service)
|
|
return true
|
|
}),
|
|
getAllGenericPasswordServices: jest.fn(async () => [...mockKeychainStore.keys()]),
|
|
}))
|
|
|
|
import {
|
|
clearAllSecureAuthStorage,
|
|
secureAuthStorage,
|
|
secureAuthStorageInternals,
|
|
} from '../src/lib/secure-auth-storage'
|
|
|
|
describe('secure Supabase auth storage', () => {
|
|
beforeEach(async () => {
|
|
mockKeychainStore.clear()
|
|
mockResetCalls.length = 0
|
|
mockSetResult = { service: 'mock', storage: 'AES_GCM_NO_AUTH' }
|
|
await AsyncStorage.clear()
|
|
jest.restoreAllMocks()
|
|
})
|
|
|
|
test('stores sessions in Keychain and removes plaintext duplicates', async () => {
|
|
const key = 'sb-project-auth-token'
|
|
await AsyncStorage.setItem(key, 'old-plaintext')
|
|
|
|
await secureAuthStorage.setItem(key, 'encrypted-session')
|
|
|
|
const service = secureAuthStorageInternals.serviceForKey(key)
|
|
expect(mockKeychainStore.get(service)).toEqual({
|
|
username: key,
|
|
password: 'encrypted-session',
|
|
})
|
|
await expect(AsyncStorage.getItem(key)).resolves.toBeNull()
|
|
})
|
|
|
|
test('migrates a legacy AsyncStorage session before returning it', async () => {
|
|
const key = 'sb-project-auth-token'
|
|
await AsyncStorage.setItem(key, 'legacy-session')
|
|
|
|
await expect(secureAuthStorage.getItem(key)).resolves.toBe('legacy-session')
|
|
|
|
const service = secureAuthStorageInternals.serviceForKey(key)
|
|
expect(mockKeychainStore.get(service)?.password).toBe('legacy-session')
|
|
await expect(AsyncStorage.getItem(key)).resolves.toBeNull()
|
|
})
|
|
|
|
test('removes a stale plaintext duplicate before returning a secure session', async () => {
|
|
const key = 'sb-project-auth-token'
|
|
const service = secureAuthStorageInternals.serviceForKey(key)
|
|
mockKeychainStore.set(service, { username: key, password: 'secure-session' })
|
|
await AsyncStorage.setItem(key, 'stale-session')
|
|
|
|
await expect(secureAuthStorage.getItem(key)).resolves.toBe('secure-session')
|
|
await expect(AsyncStorage.getItem(key)).resolves.toBeNull()
|
|
})
|
|
|
|
test('fails closed on a mismatched secure-storage identity', async () => {
|
|
const key = 'sb-project-auth-token'
|
|
const service = secureAuthStorageInternals.serviceForKey(key)
|
|
mockKeychainStore.set(service, { username: 'other-key', password: 'foreign-session' })
|
|
await AsyncStorage.setItem(key, 'legacy-session')
|
|
|
|
await expect(secureAuthStorage.getItem(key))
|
|
.rejects.toThrow('secure_auth_storage_identity_mismatch')
|
|
expect(mockResetCalls).toContain(service)
|
|
await expect(AsyncStorage.getItem(key)).resolves.toBe('legacy-session')
|
|
})
|
|
|
|
test('fails closed when the native bridge returns a malformed credential response', async () => {
|
|
const key = 'sb-project-auth-token'
|
|
const keychain = jest.requireMock('react-native-keychain') as {
|
|
getGenericPassword: jest.Mock
|
|
}
|
|
keychain.getGenericPassword.mockResolvedValueOnce(undefined)
|
|
|
|
await expect(secureAuthStorage.getItem(key))
|
|
.rejects.toThrow('secure_auth_storage_invalid_response')
|
|
})
|
|
|
|
test('does not fall back to plaintext when Keychain rejects a write', async () => {
|
|
const key = 'sb-project-auth-token'
|
|
await AsyncStorage.setItem(key, 'legacy-session')
|
|
mockSetResult = false
|
|
|
|
await expect(secureAuthStorage.getItem(key))
|
|
.rejects.toThrow('secure_auth_storage_write_failed')
|
|
await expect(AsyncStorage.getItem(key)).resolves.toBe('legacy-session')
|
|
})
|
|
|
|
test('removes both secure and legacy values on sign-out', async () => {
|
|
const key = 'sb-project-auth-token'
|
|
await secureAuthStorage.setItem(key, 'session')
|
|
await AsyncStorage.setItem(key, 'stale-session')
|
|
|
|
await secureAuthStorage.removeItem(key)
|
|
|
|
expect(mockKeychainStore.size).toBe(0)
|
|
await expect(AsyncStorage.getItem(key)).resolves.toBeNull()
|
|
})
|
|
|
|
test('clears every auth service and legacy Supabase key without touching app preferences', async () => {
|
|
const firstKey = 'sb-first-auth-token'
|
|
const secondKey = 'sb-second-auth-token-code-verifier'
|
|
await secureAuthStorage.setItem(firstKey, 'first')
|
|
await secureAuthStorage.setItem(secondKey, 'second')
|
|
mockKeychainStore.set('com.d3ro.voice.history-cache.v1.user', {
|
|
username: 'user',
|
|
password: 'history',
|
|
})
|
|
await AsyncStorage.multiSet([
|
|
[firstKey, 'legacy-first'],
|
|
[secondKey, 'legacy-second'],
|
|
['mobile-preferences', 'preserve-me'],
|
|
])
|
|
|
|
await clearAllSecureAuthStorage()
|
|
|
|
expect([...mockKeychainStore.keys()]).toEqual(['com.d3ro.voice.history-cache.v1.user'])
|
|
await expect(AsyncStorage.getItem(firstKey)).resolves.toBeNull()
|
|
await expect(AsyncStorage.getItem(secondKey)).resolves.toBeNull()
|
|
await expect(AsyncStorage.getItem('mobile-preferences')).resolves.toBe('preserve-me')
|
|
})
|
|
|
|
test('rejects malformed storage keys', async () => {
|
|
await expect(secureAuthStorage.getItem('')).rejects.toThrow('secure_auth_storage_invalid_key')
|
|
await expect(secureAuthStorage.setItem(`bad\u0000key`, 'value'))
|
|
.rejects.toThrow('secure_auth_storage_invalid_key')
|
|
})
|
|
})
|