diff --git a/jest.setup.js b/jest.setup.js index c9ecbeb..891ce6d 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -135,6 +135,23 @@ jest.mock('expo-secure-store', () => ({ deleteItemAsync: jest.fn(() => Promise.resolve()), })); +// Mock expo-local-authentication for biometric auth tests +jest.mock('expo-local-authentication', () => ({ + hasHardwareAsync: jest.fn(() => Promise.resolve(true)), + isEnrolledAsync: jest.fn(() => Promise.resolve(true)), + getEnrolledLevelAsync: jest.fn(() => Promise.resolve(1)), + getSupportedAuthenticationTypesAsync: jest.fn(() => + Promise.resolve([1]) // BIOMETRIC + ), + authenticateAsync: jest.fn(() => + Promise.resolve({ success: true, error: null }) + ), + SupportedAuthenticationTypes: { + BIOMETRIC: 1, + DEVICE_PASSCODE: 2, + }, +})); + // Mock expo-device. __esModule: true prevents Babel's _interopRequireWildcard // from copying values at import time, so tests can mutate properties directly. jest.mock('expo-device', () => ({ @@ -474,4 +491,3 @@ jest.mock('expo-clipboard', () => ({ addClipboardListener: jest.fn(() => ({ remove: jest.fn() })), removeClipboardListener: jest.fn(), }), { virtual: true }); - diff --git a/src/__tests__/services/mobileAuth.test.ts b/src/__tests__/services/mobileAuth.test.ts new file mode 100644 index 0000000..6f63f9c --- /dev/null +++ b/src/__tests__/services/mobileAuth.test.ts @@ -0,0 +1,590 @@ +/** + * Unit tests for MobileAuthService biometric re-enrollment flow. + * + * Covers: + * - enableBiometrics / disableBiometrics + * - isBiometricAvailable / getSupportedBiometricType + * - loginWithBiometrics (including re-enrollment detection) + * - checkBiometricReenrollment + * - reEnrollBiometrics + * - BiometricReenrollmentError + */ + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +jest.mock('../../utils/logger', () => ({ + __esModule: true, + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +jest.mock('../../services/api/axios.config', () => ({ + __esModule: true, + default: { + post: jest.fn(), + }, +})); + +jest.mock('../../services/secureStorage', () => ({ + isBiometricEnabled: jest.fn(), + setBiometricEnabled: jest.fn(), + saveBiometricEnrollmentId: jest.fn(), + getBiometricEnrollmentId: jest.fn(), + clearBiometricEnrollmentId: jest.fn(), + isSessionValid: jest.fn(), + getRefreshToken: jest.fn(), + getUserData: jest.fn(), + getAccessToken: jest.fn(), + getSessionExpiresAt: jest.fn(), + saveTokens: jest.fn(), + saveUserData: jest.fn(), + setRememberMe: jest.fn(), + saveRememberedEmail: jest.fn(), + isRememberMeEnabled: jest.fn(), + getRememberedEmail: jest.fn(), + clearAllAuthData: jest.fn(), + isSecureStorageReady: jest.fn(), +})); + +// ─── Imports (after mocks) ──────────────────────────────────────────────────── + +import * as LocalAuthentication from 'expo-local-authentication'; +import apiClient from '../../services/api/axios.config'; +import { + BiometricReenrollmentError, + mobileAuthService, +} from '../../services/mobileAuth'; +import * as secureStorage from '../../services/secureStorage'; + +// ─── Mock references ───────────────────────────────────────────────────────── + +const mockSecureStorage = secureStorage as jest.Mocked; +const mockApiClient = apiClient as jest.Mocked; +const mockLocalAuth = LocalAuthentication as jest.Mocked; + +// ─── Test data ──────────────────────────────────────────────────────────────── + +const MOCK_USER = { id: 'u1', name: 'Ada Lovelace', email: 'ada@teachlink.com' }; +const MOCK_TOKENS = { + accessToken: 'at_abc', + refreshToken: 'rt_xyz', + expiresAt: Date.now() + 3_600_000, +}; +const MOCK_AUTH_RESULT = { user: MOCK_USER, tokens: MOCK_TOKENS }; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function resetAllMocks() { + jest.clearAllMocks(); + // Default: secure storage is ready + mockSecureStorage.isSecureStorageReady.mockReturnValue(true); +} + +function setupBiometricAvailable() { + mockLocalAuth.hasHardwareAsync.mockResolvedValue(true); + mockLocalAuth.isEnrolledAsync.mockResolvedValue(true); +} + +function setupBiometricUnavailable() { + mockLocalAuth.hasHardwareAsync.mockResolvedValue(false); + mockLocalAuth.isEnrolledAsync.mockResolvedValue(false); +} + +function setupAuthSuccess() { + mockLocalAuth.authenticateAsync.mockResolvedValue({ success: true, error: null }); +} + +function setupAuthFailure() { + mockLocalAuth.authenticateAsync.mockResolvedValue({ success: false, error: 'cancelled' }); +} + +function setupValidSession() { + mockSecureStorage.isSessionValid.mockResolvedValue(true); + mockSecureStorage.getUserData.mockResolvedValue(MOCK_USER); + mockSecureStorage.getAccessToken.mockResolvedValue(MOCK_TOKENS.accessToken); + mockSecureStorage.getRefreshToken.mockResolvedValue(MOCK_TOKENS.refreshToken); + mockSecureStorage.getSessionExpiresAt.mockResolvedValue(MOCK_TOKENS.expiresAt); +} + +function setupNoSession() { + mockSecureStorage.isSessionValid.mockResolvedValue(false); + mockSecureStorage.getRefreshToken.mockResolvedValue(null); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('MobileAuthService — Biometric Re-enrollment Flow', () => { + beforeEach(() => resetAllMocks()); + + // ── BiometricReenrollmentError ──────────────────────────────────────────── + + describe('BiometricReenrollmentError', () => { + it('should have the correct name and code', () => { + const error = new BiometricReenrollmentError(); + expect(error.name).toBe('BiometricReenrollmentError'); + expect(error.code).toBe('BIOMETRIC_REENROLLMENT_REQUIRED'); + }); + + it('should use the default message', () => { + const error = new BiometricReenrollmentError(); + expect(error.message).toContain('biometric enrollment has changed'); + }); + + it('should accept a custom message', () => { + const error = new BiometricReenrollmentError('Custom message'); + expect(error.message).toBe('Custom message'); + }); + + it('should be an instance of Error', () => { + const error = new BiometricReenrollmentError(); + expect(error).toBeInstanceOf(Error); + }); + }); + + // ── isBiometricAvailable ────────────────────────────────────────────────── + + describe('isBiometricAvailable', () => { + it('should return true when hardware is available and biometrics are enrolled', async () => { + setupBiometricAvailable(); + + const result = await mobileAuthService.isBiometricAvailable(); + + expect(result).toBe(true); + expect(mockLocalAuth.hasHardwareAsync).toHaveBeenCalled(); + expect(mockLocalAuth.isEnrolledAsync).toHaveBeenCalled(); + }); + + it('should return false when hardware is not available', async () => { + mockLocalAuth.hasHardwareAsync.mockResolvedValue(false); + mockLocalAuth.isEnrolledAsync.mockResolvedValue(true); + + const result = await mobileAuthService.isBiometricAvailable(); + + expect(result).toBe(false); + }); + + it('should return false when no biometrics are enrolled', async () => { + mockLocalAuth.hasHardwareAsync.mockResolvedValue(true); + mockLocalAuth.isEnrolledAsync.mockResolvedValue(false); + + const result = await mobileAuthService.isBiometricAvailable(); + + expect(result).toBe(false); + }); + + it('should return false when both hardware and enrollment are unavailable', async () => { + mockLocalAuth.hasHardwareAsync.mockResolvedValue(false); + mockLocalAuth.isEnrolledAsync.mockResolvedValue(false); + + const result = await mobileAuthService.isBiometricAvailable(); + + expect(result).toBe(false); + }); + + it('should return false when expo-local-authentication throws', async () => { + mockLocalAuth.hasHardwareAsync.mockRejectedValue(new Error('Module not found')); + + const result = await mobileAuthService.isBiometricAvailable(); + + expect(result).toBe(false); + }); + }); + + // ── getSupportedBiometricType ───────────────────────────────────────────── + + describe('getSupportedBiometricType', () => { + it('should return "fingerprint" when biometric type is supported', async () => { + mockLocalAuth.getSupportedAuthenticationTypesAsync.mockResolvedValue([1]); + + const result = await mobileAuthService.getSupportedBiometricType(); + + expect(result).toBe('fingerprint'); + }); + + it('should return "none" when no biometric type is supported', async () => { + mockLocalAuth.getSupportedAuthenticationTypesAsync.mockResolvedValue([2]); + + const result = await mobileAuthService.getSupportedBiometricType(); + + expect(result).toBe('none'); + }); + + it('should return "none" when the types array is empty', async () => { + mockLocalAuth.getSupportedAuthenticationTypesAsync.mockResolvedValue([]); + + const result = await mobileAuthService.getSupportedBiometricType(); + + expect(result).toBe('none'); + }); + + it('should return "none" when expo-local-authentication throws', async () => { + mockLocalAuth.getSupportedAuthenticationTypesAsync.mockRejectedValue(new Error('Failed')); + + const result = await mobileAuthService.getSupportedBiometricType(); + + expect(result).toBe('none'); + }); + }); + + // ── enableBiometrics ────────────────────────────────────────────────────── + + describe('enableBiometrics', () => { + it('should enable biometrics when available and auth succeeds', async () => { + setupBiometricAvailable(); + setupAuthSuccess(); + + await mobileAuthService.enableBiometrics(); + + expect(mockSecureStorage.setBiometricEnabled).toHaveBeenCalledWith(true); + expect(mockSecureStorage.saveBiometricEnrollmentId).toHaveBeenCalled(); + // Verify an enrollment id was saved + const savedId = mockSecureStorage.saveBiometricEnrollmentId.mock.calls[0][0]; + expect(savedId).toBeTruthy(); + expect(typeof savedId).toBe('string'); + }); + + it('should throw when biometrics are not available', async () => { + setupBiometricUnavailable(); + + await expect(mobileAuthService.enableBiometrics()).rejects.toThrow( + 'Biometric authentication is not available on this device.' + ); + + expect(mockSecureStorage.setBiometricEnabled).not.toHaveBeenCalled(); + expect(mockSecureStorage.saveBiometricEnrollmentId).not.toHaveBeenCalled(); + }); + + it('should throw when biometric auth fails', async () => { + setupBiometricAvailable(); + setupAuthFailure(); + + await expect(mobileAuthService.enableBiometrics()).rejects.toThrow( + 'Biometric authentication was cancelled or failed.' + ); + + expect(mockSecureStorage.setBiometricEnabled).not.toHaveBeenCalled(); + expect(mockSecureStorage.saveBiometricEnrollmentId).not.toHaveBeenCalled(); + }); + + it('should generate a unique enrollment id each time', async () => { + setupBiometricAvailable(); + setupAuthSuccess(); + + await mobileAuthService.enableBiometrics(); + const id1 = mockSecureStorage.saveBiometricEnrollmentId.mock.calls[0][0]; + + await mobileAuthService.enableBiometrics(); + const id2 = mockSecureStorage.saveBiometricEnrollmentId.mock.calls[1][0]; + + expect(id1).not.toBe(id2); + }); + }); + + // ── disableBiometrics ───────────────────────────────────────────────────── + + describe('disableBiometrics', () => { + it('should disable biometrics and clear the enrollment id', async () => { + await mobileAuthService.disableBiometrics(); + + expect(mockSecureStorage.setBiometricEnabled).toHaveBeenCalledWith(false); + expect(mockSecureStorage.clearBiometricEnrollmentId).toHaveBeenCalled(); + }); + }); + + // ── checkBiometricReenrollment ──────────────────────────────────────────── + + describe('checkBiometricReenrollment', () => { + it('should return false when biometrics are not enabled', async () => { + mockSecureStorage.isBiometricEnabled.mockResolvedValue(false); + + const result = await mobileAuthService.checkBiometricReenrollment(); + + expect(result).toBe(false); + }); + + it('should return false when biometrics are not available', async () => { + mockSecureStorage.isBiometricEnabled.mockResolvedValue(true); + setupBiometricUnavailable(); + + const result = await mobileAuthService.checkBiometricReenrollment(); + + expect(result).toBe(false); + }); + + it('should return true when the enrollment id is missing', async () => { + mockSecureStorage.isBiometricEnabled.mockResolvedValue(true); + setupBiometricAvailable(); + mockSecureStorage.getBiometricEnrollmentId.mockResolvedValue(null); + + const result = await mobileAuthService.checkBiometricReenrollment(); + + expect(result).toBe(true); + }); + + it('should return true when session is invalid and no refresh token exists', async () => { + mockSecureStorage.isBiometricEnabled.mockResolvedValue(true); + setupBiometricAvailable(); + mockSecureStorage.getBiometricEnrollmentId.mockResolvedValue('enrollment-123'); + mockSecureStorage.isSessionValid.mockResolvedValue(false); + mockSecureStorage.getRefreshToken.mockResolvedValue(null); + + const result = await mobileAuthService.checkBiometricReenrollment(); + + expect(result).toBe(true); + }); + + it('should return false when session is valid', async () => { + mockSecureStorage.isBiometricEnabled.mockResolvedValue(true); + setupBiometricAvailable(); + mockSecureStorage.getBiometricEnrollmentId.mockResolvedValue('enrollment-123'); + mockSecureStorage.isSessionValid.mockResolvedValue(true); + + const result = await mobileAuthService.checkBiometricReenrollment(); + + expect(result).toBe(false); + }); + + it('should return false when session is invalid but refresh token exists', async () => { + mockSecureStorage.isBiometricEnabled.mockResolvedValue(true); + setupBiometricAvailable(); + mockSecureStorage.getBiometricEnrollmentId.mockResolvedValue('enrollment-123'); + mockSecureStorage.isSessionValid.mockResolvedValue(false); + mockSecureStorage.getRefreshToken.mockResolvedValue('rt_xyz'); + + const result = await mobileAuthService.checkBiometricReenrollment(); + + expect(result).toBe(false); + }); + + it('should return true when secure storage access throws', async () => { + mockSecureStorage.isBiometricEnabled.mockResolvedValue(true); + setupBiometricAvailable(); + mockSecureStorage.getBiometricEnrollmentId.mockResolvedValue('enrollment-123'); + mockSecureStorage.isSessionValid.mockRejectedValue(new Error('Keychain access denied')); + + const result = await mobileAuthService.checkBiometricReenrollment(); + + expect(result).toBe(true); + }); + }); + + // ── loginWithBiometrics ─────────────────────────────────────────────────── + + describe('loginWithBiometrics', () => { + it('should succeed when biometrics are enabled, available, and session exists', async () => { + mockSecureStorage.isBiometricEnabled.mockResolvedValue(true); + setupBiometricAvailable(); + mockSecureStorage.getBiometricEnrollmentId.mockResolvedValue('enrollment-123'); + setupValidSession(); + setupAuthSuccess(); + + const result = await mobileAuthService.loginWithBiometrics(); + + expect(result).toEqual(MOCK_AUTH_RESULT); + expect(mockLocalAuth.authenticateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + promptMessage: 'Unlock with biometrics', + }) + ); + }); + + it('should throw when biometrics are not enabled', async () => { + mockSecureStorage.isBiometricEnabled.mockResolvedValue(false); + + await expect(mobileAuthService.loginWithBiometrics()).rejects.toThrow( + 'Biometric login is not enabled. Please enable it in settings.' + ); + }); + + it('should throw when biometrics are not available', async () => { + mockSecureStorage.isBiometricEnabled.mockResolvedValue(true); + setupBiometricUnavailable(); + + await expect(mobileAuthService.loginWithBiometrics()).rejects.toThrow( + 'Biometric authentication is not available on this device.' + ); + }); + + it('should throw BiometricReenrollmentError when enrollment has changed', async () => { + mockSecureStorage.isBiometricEnabled.mockResolvedValue(true); + setupBiometricAvailable(); + // Simulate enrollment change: enrollment id is missing + mockSecureStorage.getBiometricEnrollmentId.mockResolvedValue(null); + + await expect(mobileAuthService.loginWithBiometrics()).rejects.toThrow( + BiometricReenrollmentError + ); + + // Should NOT have prompted for biometric auth + expect(mockLocalAuth.authenticateAsync).not.toHaveBeenCalled(); + }); + + it('should throw BiometricReenrollmentError when session is invalid and no refresh token', async () => { + mockSecureStorage.isBiometricEnabled.mockResolvedValue(true); + setupBiometricAvailable(); + mockSecureStorage.getBiometricEnrollmentId.mockResolvedValue('enrollment-123'); + mockSecureStorage.isSessionValid.mockResolvedValue(false); + mockSecureStorage.getRefreshToken.mockResolvedValue(null); + + await expect(mobileAuthService.loginWithBiometrics()).rejects.toThrow( + BiometricReenrollmentError + ); + }); + + it('should throw when biometric auth fails', async () => { + mockSecureStorage.isBiometricEnabled.mockResolvedValue(true); + setupBiometricAvailable(); + mockSecureStorage.getBiometricEnrollmentId.mockResolvedValue('enrollment-123'); + setupValidSession(); + setupAuthFailure(); + + await expect(mobileAuthService.loginWithBiometrics()).rejects.toThrow( + 'Biometric authentication was cancelled or failed.' + ); + }); + + it('should throw when no stored session is found', async () => { + mockSecureStorage.isBiometricEnabled.mockResolvedValue(true); + setupBiometricAvailable(); + mockSecureStorage.getBiometricEnrollmentId.mockResolvedValue('enrollment-123'); + setupAuthSuccess(); + setupNoSession(); + + await expect(mobileAuthService.loginWithBiometrics()).rejects.toThrow( + 'No stored session found. Please log in with your password.' + ); + }); + + it('should not call authenticateAsync when re-enrollment is needed', async () => { + mockSecureStorage.isBiometricEnabled.mockResolvedValue(true); + setupBiometricAvailable(); + mockSecureStorage.getBiometricEnrollmentId.mockResolvedValue(null); + + try { + await mobileAuthService.loginWithBiometrics(); + } catch { + // Expected + } + + expect(mockLocalAuth.authenticateAsync).not.toHaveBeenCalled(); + }); + }); + + // ── reEnrollBiometrics ──────────────────────────────────────────────────── + + describe('reEnrollBiometrics', () => { + it('should re-enroll when available and auth succeeds', async () => { + setupBiometricAvailable(); + setupAuthSuccess(); + + await mobileAuthService.reEnrollBiometrics(); + + // Should have cleared old data first + expect(mockSecureStorage.clearBiometricEnrollmentId).toHaveBeenCalled(); + expect(mockSecureStorage.setBiometricEnabled).toHaveBeenCalledWith(false); + + // Then set new data + expect(mockSecureStorage.setBiometricEnabled).toHaveBeenCalledWith(true); + expect(mockSecureStorage.saveBiometricEnrollmentId).toHaveBeenCalled(); + + // Verify a new enrollment id was saved + const savedId = mockSecureStorage.saveBiometricEnrollmentId.mock.calls[0][0]; + expect(savedId).toBeTruthy(); + }); + + it('should throw when biometrics are not available', async () => { + setupBiometricUnavailable(); + + await expect(mobileAuthService.reEnrollBiometrics()).rejects.toThrow( + 'Biometric authentication is not available on this device.' + ); + + // Should not have saved any enrollment id + expect(mockSecureStorage.saveBiometricEnrollmentId).not.toHaveBeenCalled(); + }); + + it('should throw when biometric auth fails', async () => { + setupBiometricAvailable(); + setupAuthFailure(); + + await expect(mobileAuthService.reEnrollBiometrics()).rejects.toThrow( + 'Biometric authentication was cancelled or failed.' + ); + + // Should have cleared old data but not saved new data + expect(mockSecureStorage.clearBiometricEnrollmentId).toHaveBeenCalled(); + expect(mockSecureStorage.saveBiometricEnrollmentId).not.toHaveBeenCalled(); + }); + + it('should clear old enrollment id before prompting', async () => { + setupBiometricAvailable(); + setupAuthSuccess(); + + await mobileAuthService.reEnrollBiometrics(); + + // Verify clear was called before authenticate + const clearCallOrder = mockSecureStorage.clearBiometricEnrollmentId.mock.invocationCallOrder[0]; + const authCallOrder = mockLocalAuth.authenticateAsync.mock.invocationCallOrder[0]; + expect(clearCallOrder).toBeLessThan(authCallOrder); + }); + + it('should save new enrollment id after successful auth', async () => { + setupBiometricAvailable(); + setupAuthSuccess(); + + await mobileAuthService.reEnrollBiometrics(); + + // Verify save was called after authenticate + const authCallOrder = mockLocalAuth.authenticateAsync.mock.invocationCallOrder[0]; + const saveCallOrder = mockSecureStorage.saveBiometricEnrollmentId.mock.invocationCallOrder[0]; + expect(authCallOrder).toBeLessThan(saveCallOrder); + }); + }); + + // ── Integration: enable → login → re-enroll flow ────────────────────────── + + describe('Integration: enable → login → re-enroll flow', () => { + it('should complete the full biometric lifecycle', async () => { + // Step 1: Enable biometrics + setupBiometricAvailable(); + setupAuthSuccess(); + + await mobileAuthService.enableBiometrics(); + + expect(mockSecureStorage.setBiometricEnabled).toHaveBeenCalledWith(true); + const enrollmentId = mockSecureStorage.saveBiometricEnrollmentId.mock.calls[0][0]; + expect(enrollmentId).toBeTruthy(); + + // Step 2: Login with biometrics (should succeed) + mockSecureStorage.getBiometricEnrollmentId.mockResolvedValue(enrollmentId); + setupValidSession(); + + const result = await mobileAuthService.loginWithBiometrics(); + expect(result).toEqual(MOCK_AUTH_RESULT); + + // Step 3: Simulate enrollment change (enrollment id becomes invalid) + mockSecureStorage.getBiometricEnrollmentId.mockResolvedValue(null); + + // Step 4: Login should now require re-enrollment + await expect(mobileAuthService.loginWithBiometrics()).rejects.toThrow( + BiometricReenrollmentError + ); + + // Step 5: Re-enroll + await mobileAuthService.reEnrollBiometrics(); + + const newEnrollmentId = mockSecureStorage.saveBiometricEnrollmentId.mock.calls[1][0]; + expect(newEnrollmentId).not.toBe(enrollmentId); + + // Step 6: Login should succeed again + mockSecureStorage.getBiometricEnrollmentId.mockResolvedValue(newEnrollmentId); + + const result2 = await mobileAuthService.loginWithBiometrics(); + expect(result2).toEqual(MOCK_AUTH_RESULT); + }); + }); +}); diff --git a/src/services/mobileAuth.ts b/src/services/mobileAuth.ts index 5e3b980..d74a478 100644 --- a/src/services/mobileAuth.ts +++ b/src/services/mobileAuth.ts @@ -38,6 +38,25 @@ export interface SocialProvider { export type BiometricType = 'fingerprint' | 'face' | 'iris' | 'none'; +// ─── Biometric re-enrollment error ──────────────────────────────────────────── + +/** + * Thrown when the device's biometric enrollment has changed since the + * user last enabled biometric login (e.g. they removed and re-added a + * fingerprint, or added a new face). + * + * The caller should catch this error and present a re-enrollment UI that + * guides the user through enabling biometric login again. + */ +export class BiometricReenrollmentError extends Error { + readonly code = 'BIOMETRIC_REENROLLMENT_REQUIRED'; + + constructor(message = 'Your biometric enrollment has changed. Please re-enable biometric login.') { + super(message); + this.name = 'BiometricReenrollmentError'; + } +} + // ─── Auth API endpoints ─────────────────────────────────────────────────────── const ENDPOINTS = { @@ -63,7 +82,6 @@ function validateSecureStorageReady(): void { } } - // ─── Service ────────────────────────────────────────────────────────────────── class MobileAuthService { @@ -95,6 +113,10 @@ class MobileAuthService { /** * Authenticate using the device biometrics (Face ID / Touch ID / Fingerprint). * Requires biometrics to have been previously enabled via enableBiometrics(). + * + * If the device's biometric enrollment has changed since the user last + * enabled biometric login, a BiometricReenrollmentError is thrown so the + * caller can guide the user through re-enrollment. */ async loginWithBiometrics(): Promise { const enabled = await secureStorage.isBiometricEnabled(); @@ -107,6 +129,20 @@ class MobileAuthService { throw new Error('Biometric authentication is not available on this device.'); } + // Detect if the biometric enrollment has changed since the user + // last enabled biometric login. This handles the case where the + // user removed and re-added a fingerprint, or added a new face. + const needsReenrollment = await this.checkBiometricReenrollment(); + if (needsReenrollment) { + throw new BiometricReenrollmentError(); + } + + // Prompt the user for biometric authentication + const authResult = await this._authenticateAsync('Unlock with biometrics'); + if (!authResult.success) { + throw new Error('Biometric authentication was cancelled or failed.'); + } + // A successful biometric prompt unlocks the session persisted in secure // storage. Each dependency is mockable, so all outcomes (enabled/disabled, // available/unavailable, session present/absent) are testable. @@ -174,21 +210,217 @@ class MobileAuthService { // ── Biometric management ────────────────────────────────────────────────── + /** + * Enable biometric login for the current user. + * + * Prompts the user for biometric authentication to verify they can use + * biometrics, then stores a biometric enrollment id so that future + * enrollment changes can be detected. + * + * @throws {Error} If biometrics are not available or the user cancels. + */ async enableBiometrics(): Promise { - throw new Error('Biometric authentication is not available on this device.'); + const available = await this.isBiometricAvailable(); + if (!available) { + throw new Error('Biometric authentication is not available on this device.'); + } + + // Prompt the user to authenticate with biometrics to verify capability + const result = await this._authenticateAsync('Enable biometric login'); + if (!result.success) { + throw new Error('Biometric authentication was cancelled or failed.'); + } + + // Store a new enrollment id so we can detect future enrollment changes + const enrollmentId = this._generateEnrollmentId(); + await Promise.all([ + secureStorage.setBiometricEnabled(true), + secureStorage.saveBiometricEnrollmentId(enrollmentId), + ]); + + logger.info('MobileAuth: biometric login enabled'); } + /** + * Disable biometric login and clear all biometric-related data. + */ async disableBiometrics(): Promise { - await secureStorage.setBiometricEnabled(false); + await Promise.all([ + secureStorage.setBiometricEnabled(false), + secureStorage.clearBiometricEnrollmentId(), + ]); logger.info('MobileAuth: biometric login disabled'); } + /** + * Check whether biometric authentication is available on this device. + * + * Uses expo-local-authentication to verify that the device has the + * hardware and that at least one biometric is enrolled. + */ async isBiometricAvailable(): Promise { - return false; + try { + const LocalAuthentication = this._getLocalAuthentication(); + const [hasHardware, isEnrolled] = await Promise.all([ + LocalAuthentication.hasHardwareAsync(), + LocalAuthentication.isEnrolledAsync(), + ]); + return hasHardware && isEnrolled; + } catch { + return false; + } } + /** + * Get the type of biometric authentication supported by the device. + * + * Returns 'fingerprint', 'face', 'iris', or 'none'. + */ async getSupportedBiometricType(): Promise { - return 'none'; + try { + const LocalAuthentication = this._getLocalAuthentication(); + const types = await LocalAuthentication.getSupportedAuthenticationTypesAsync(); + + // expo-local-authentication returns an array of SupportedAuthenticationTypes + // enum values. We map the first supported type to our BiometricType. + if (types.includes(1)) { + // BIOMETRIC = 1 (fingerprint, face, etc.) + // On iOS we can't distinguish face vs fingerprint from this enum, + // so we default to 'fingerprint' and let the UI adapt. + return 'fingerprint'; + } + return 'none'; + } catch { + return 'none'; + } + } + + /** + * Check whether the user needs to re-enroll their biometrics. + * + * This is called on app launch or when the user attempts biometric login. + * If the biometric enrollment has changed since the user last enabled + * biometric login (e.g. they removed and re-added a fingerprint), the + * stored enrollment id will no longer match and re-enrollment is required. + * + * @returns `true` if re-enrollment is needed, `false` otherwise. + */ + async checkBiometricReenrollment(): Promise { + const enabled = await secureStorage.isBiometricEnabled(); + if (!enabled) return false; + + const available = await this.isBiometricAvailable(); + if (!available) return false; + + // If the enrollment id is missing, the Keychain/Keystore data was + // likely invalidated by a biometric enrollment change. + const storedEnrollmentId = await secureStorage.getBiometricEnrollmentId(); + if (!storedEnrollmentId) { + return true; + } + + // Try to access the stored session data. If the Keychain/Keystore + // items were invalidated by a biometric enrollment change, this + // will fail and we know re-enrollment is needed. + try { + const sessionValid = await secureStorage.isSessionValid(); + if (!sessionValid) { + // Session is invalid — could be expired or invalidated. + // Check if we can still access the refresh token. + const refreshToken = await secureStorage.getRefreshToken(); + if (!refreshToken) { + // No refresh token — the Keychain/Keystore data was likely + // invalidated by a biometric enrollment change. + return true; + } + } + } catch { + // Secure storage access failed — likely due to biometric enrollment change + return true; + } + + return false; + } + + /** + * Re-enroll biometric login after the device's biometric enrollment + * has changed. + * + * This clears the old biometric data, prompts the user for biometric + * authentication, and stores a new enrollment id. + * + * @throws {Error} If biometrics are not available or the user cancels. + */ + async reEnrollBiometrics(): Promise { + const available = await this.isBiometricAvailable(); + if (!available) { + throw new Error('Biometric authentication is not available on this device.'); + } + + // Clear old biometric data + await Promise.all([ + secureStorage.clearBiometricEnrollmentId(), + secureStorage.setBiometricEnabled(false), + ]); + + // Prompt the user to authenticate with biometrics + const result = await this._authenticateAsync('Re-enable biometric login'); + if (!result.success) { + throw new Error('Biometric authentication was cancelled or failed.'); + } + + // Store a new enrollment id + const enrollmentId = this._generateEnrollmentId(); + await Promise.all([ + secureStorage.setBiometricEnabled(true), + secureStorage.saveBiometricEnrollmentId(enrollmentId), + ]); + + logger.info('MobileAuth: biometric login re-enrolled successfully'); + } + + // ── Biometric private helpers ───────────────────────────────────────────── + + /** + * Lazily require expo-local-authentication. + * + * Using a dynamic require (same pattern as secureStorage.ts) avoids + * bundling the native module on platforms where it's not available + * and makes the dependency mockable in tests. + */ + private _getLocalAuthentication(): any { + return require('expo-local-authentication'); + } + + /** + * Prompt the user for biometric authentication. + * + * @param promptMessage The message to display in the biometric prompt. + * @returns The authentication result from expo-local-authentication. + */ + private async _authenticateAsync(promptMessage: string): Promise<{ + success: boolean; + error?: string; + }> { + const LocalAuthentication = this._getLocalAuthentication(); + return LocalAuthentication.authenticateAsync({ + promptMessage, + cancelTitle: 'Cancel', + fallbackTitle: 'Use passcode', + disableDeviceFallback: false, + }); + } + + /** + * Generate a unique enrollment id. + * + * Combines a timestamp, random value, and platform to create a + * UUID-like string that uniquely identifies a biometric enrollment + * session. + */ + private _generateEnrollmentId(): string { + const { Platform } = require('react-native'); + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${Platform.OS}`; } // ── Logout ──────────────────────────────────────────────────────────────── diff --git a/src/services/secureStorage.ts b/src/services/secureStorage.ts index 07c235b..3ff79e4 100644 --- a/src/services/secureStorage.ts +++ b/src/services/secureStorage.ts @@ -38,6 +38,7 @@ const KEYS = { USER_DATA: 'teachlink_user_data', SESSION_EXPIRES_AT: 'teachlink_session_expires_at', BIOMETRIC_ENABLED: 'teachlink_biometric_enabled', + BIOMETRIC_ENROLLMENT_ID: 'teachlink_biometric_enrollment_id', REMEMBERED_EMAIL: 'teachlink_remembered_email', REMEMBER_ME: 'teachlink_remember_me', INSTALL_UUID: 'teachlink_install_uuid', @@ -328,7 +329,53 @@ export async function isBiometricEnabled(): Promise { return value === '1'; } -// ─── AsyncStorage Security Guard ─────────────────────────────────────────────── +// ─── Biometric enrollment tracking ──────────────────────────────────────────── +// +// When a user adds, removes, or re-enrolls their biometrics (e.g. a new +// fingerprint), the OS may invalidate Keychain/Keystore items that were +// protected by the previous biometric enrollment. To detect this we store +// a "biometric enrollment id" — a random UUID generated at the time the +// user first enables biometric login. On every subsequent biometric +// attempt we compare the stored id with the current state; if they differ +// (or the secure data is no longer accessible) we know the enrollment has +// changed and the user must re-enroll. +// +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Save the biometric enrollment identifier to secure storage. + * + * This id is generated once when the user enables biometric login and + * should be compared against the current enrollment state on every + * biometric login attempt. + * + * @param enrollmentId A UUID that uniquely identifies the enrollment session. + */ +export async function saveBiometricEnrollmentId(enrollmentId: string): Promise { + await setItem(KEYS.BIOMETRIC_ENROLLMENT_ID, enrollmentId, false); + logger.info('Biometric enrollment id saved to secure storage'); +} + +/** + * Retrieve the stored biometric enrollment identifier. + * + * @returns The enrollment id, or `null` if none has been stored. + */ +export async function getBiometricEnrollmentId(): Promise { + return getItem(KEYS.BIOMETRIC_ENROLLMENT_ID, false); +} + +/** + * Remove the stored biometric enrollment identifier. + * + * Called during re-enrollment or when biometric login is disabled. + */ +export async function clearBiometricEnrollmentId(): Promise { + await removeItem(KEYS.BIOMETRIC_ENROLLMENT_ID); + logger.info('Biometric enrollment id cleared from secure storage'); +} + +// ─── AsyncStorage Security Guard ──────────────────────────────────────────────── // SECURITY: AsyncStorage is UNENCRYPTED plaintext storage. // NEVER use AsyncStorage for: tokens, passwords, session data, PII, or any // sensitive user data. It is only acceptable for non-sensitive caches,