feat(auth): centralize token storage
This commit is contained in:
@@ -8,6 +8,7 @@ import React, {
|
|||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import { databaseService } from '../services/database';
|
import { databaseService } from '../services/database';
|
||||||
import { authService } from '../services/auth/auth.service';
|
import { authService } from '../services/auth/auth.service';
|
||||||
|
import { tokenStorage } from '../utils/token';
|
||||||
|
|
||||||
const SESSION_KEY = 'medication_app_session';
|
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);
|
console.warn('Updated user with last login:', updatedUser);
|
||||||
|
|
||||||
// Store access token for subsequent API calls.
|
tokenStorage.save({
|
||||||
localStorage.setItem('access_token', result.accessToken);
|
accessToken: result.accessToken,
|
||||||
|
refreshToken: result.refreshToken,
|
||||||
|
});
|
||||||
// Set the user from the login result
|
// Set the user from the login result
|
||||||
setUser(updatedUser);
|
setUser(updatedUser);
|
||||||
|
|
||||||
@@ -123,7 +126,10 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
console.warn('Updated OAuth user with last login:', updatedUser);
|
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);
|
setUser(updatedUser);
|
||||||
|
|
||||||
console.warn('OAuth user set in context');
|
console.warn('OAuth user set in context');
|
||||||
@@ -152,6 +158,7 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
|
tokenStorage.clear();
|
||||||
setUser(null);
|
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