feat(auth): centralize token storage
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
103
utils/__tests__/token.test.ts
Normal file
103
utils/__tests__/token.test.ts
Normal 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
101
utils/token.ts
Normal 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;
|
||||
Reference in New Issue
Block a user