feat(auth): centralize token storage

This commit is contained in:
William Valentin
2025-09-23 10:11:14 -07:00
parent 3702c1da8a
commit 6b6a44acef
3 changed files with 214 additions and 3 deletions

View File

@@ -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);
};

View File

@@ -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'
);
});
});

101
utils/token.ts Normal file
View File

@@ -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;