From 6b6a44acefe31ce761f7805f3d56c83fcfbccf12 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 23 Sep 2025 10:11:14 -0700 Subject: [PATCH] feat(auth): centralize token storage --- contexts/UserContext.tsx | 13 ++++- utils/__tests__/token.test.ts | 103 ++++++++++++++++++++++++++++++++++ utils/token.ts | 101 +++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 utils/__tests__/token.test.ts create mode 100644 utils/token.ts diff --git a/contexts/UserContext.tsx b/contexts/UserContext.tsx index 01e5bcd..4fe51c9 100644 --- a/contexts/UserContext.tsx +++ b/contexts/UserContext.tsx @@ -8,6 +8,7 @@ import React, { import { User } from '../types'; import { databaseService } from '../services/database'; import { authService } from '../services/auth/auth.service'; +import { tokenStorage } from '../utils/token'; const SESSION_KEY = 'medication_app_session'; @@ -78,8 +79,10 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ console.warn('Updated user with last login:', updatedUser); - // Store access token for subsequent API calls. - localStorage.setItem('access_token', result.accessToken); + tokenStorage.save({ + accessToken: result.accessToken, + refreshToken: result.refreshToken, + }); // Set the user from the login result setUser(updatedUser); @@ -123,7 +126,10 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ console.warn('Updated OAuth user with last login:', updatedUser); - localStorage.setItem('access_token', result.accessToken); + tokenStorage.save({ + accessToken: result.accessToken, + refreshToken: result.refreshToken, + }); setUser(updatedUser); console.warn('OAuth user set in context'); @@ -152,6 +158,7 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ }; const logout = () => { + tokenStorage.clear(); setUser(null); }; diff --git a/utils/__tests__/token.test.ts b/utils/__tests__/token.test.ts new file mode 100644 index 0000000..4fd93e0 --- /dev/null +++ b/utils/__tests__/token.test.ts @@ -0,0 +1,103 @@ +import { tokenStorage } from '../token'; +import { logger } from '../../services/logging'; + +describe('tokenStorage', () => { + const STORAGE_KEY = 'meds_auth_tokens'; + const originalLocalStorageDescriptor = Object.getOwnPropertyDescriptor( + window, + 'localStorage' + ); + + beforeEach(() => { + jest.restoreAllMocks(); + tokenStorage.clear(); + window.localStorage.clear(); + }); + + afterAll(() => { + if (originalLocalStorageDescriptor) { + Object.defineProperty( + window, + 'localStorage', + originalLocalStorageDescriptor + ); + } + }); + + it('persists tokens with a single storage key', () => { + const tokens = { accessToken: 'access-123', refreshToken: 'refresh-789' }; + + tokenStorage.save(tokens); + + expect(window.localStorage.getItem(STORAGE_KEY)).toEqual( + JSON.stringify({ + accessToken: 'access-123', + refreshToken: 'refresh-789', + }) + ); + expect(tokenStorage.getAccessToken()).toBe('access-123'); + }); + + it('clears tokens from storage and cache', () => { + tokenStorage.save({ + accessToken: 'access-123', + refreshToken: 'refresh-789', + }); + + tokenStorage.clear(); + + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); + expect(tokenStorage.getTokens()).toBeNull(); + expect(tokenStorage.getAccessToken()).toBeNull(); + }); + + it('handles corrupted storage values by clearing and logging', () => { + window.localStorage.setItem(STORAGE_KEY, 'not json'); + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); + + expect(tokenStorage.getTokens()).toBeNull(); + + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to parse stored tokens, clearing cache', + 'AUTH_TOKENS', + expect.any(Error) + ); + }); + + it('falls back to in-memory storage when localStorage is unavailable', () => { + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); + const error = new Error('blocked'); + + Object.defineProperty(window, 'localStorage', { + configurable: true, + get: () => { + throw error; + }, + }); + + tokenStorage.clear(); + tokenStorage.save({ accessToken: 'access-only' }); + + expect(tokenStorage.getAccessToken()).toBe('access-only'); + expect(warnSpy).toHaveBeenCalledWith( + 'Token storage fallback to memory', + 'AUTH_TOKENS', + error + ); + + if (originalLocalStorageDescriptor) { + Object.defineProperty( + window, + 'localStorage', + originalLocalStorageDescriptor + ); + } + }); + + it('throws when attempting to save without an access token', () => { + expect(() => tokenStorage.save({ accessToken: '' })).toThrow( + 'Token payload must include an access token' + ); + }); +}); diff --git a/utils/token.ts b/utils/token.ts new file mode 100644 index 0000000..49561c4 --- /dev/null +++ b/utils/token.ts @@ -0,0 +1,101 @@ +import { logger } from '../services/logging'; + +export interface AuthTokens { + accessToken: string; + refreshToken?: string | null; +} + +const TOKEN_STORAGE_KEY = 'meds_auth_tokens'; + +let memoryTokens: AuthTokens | null = null; + +function getStorage(): Storage | null { + if (typeof window === 'undefined') { + return null; + } + + try { + return window.localStorage; + } catch (error) { + // LocalStorage may be unavailable (e.g. Safari private mode); gracefully degrade. + logger.warn('Token storage fallback to memory', 'AUTH_TOKENS', error); + return null; + } +} + +function persist(tokens: AuthTokens | null): void { + const storage = getStorage(); + + if (!storage) { + memoryTokens = tokens; + return; + } + + if (!tokens) { + storage.removeItem(TOKEN_STORAGE_KEY); + memoryTokens = null; + return; + } + + const payload: AuthTokens = { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken ?? null, + }; + + storage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(payload)); + memoryTokens = payload; +} + +function readTokens(): AuthTokens | null { + const storage = getStorage(); + + if (!storage) { + return memoryTokens; + } + + try { + const raw = storage.getItem(TOKEN_STORAGE_KEY); + if (!raw) { + memoryTokens = null; + return null; + } + + const parsed = JSON.parse(raw) as AuthTokens; + memoryTokens = parsed; + return parsed; + } catch (error) { + logger.warn( + 'Failed to parse stored tokens, clearing cache', + 'AUTH_TOKENS', + error + ); + storage.removeItem(TOKEN_STORAGE_KEY); + memoryTokens = null; + return null; + } +} + +export const tokenStorage = { + save(tokens: AuthTokens): void { + if (!tokens || !tokens.accessToken) { + throw new Error('Token payload must include an access token'); + } + + persist(tokens); + }, + + getTokens(): AuthTokens | null { + return readTokens(); + }, + + getAccessToken(): string | null { + const tokens = readTokens(); + return tokens?.accessToken ?? null; + }, + + clear(): void { + persist(null); + }, +}; + +export default tokenStorage;