From 367328bbbc34850952cee2f810ee61806d887065 Mon Sep 17 00:00:00 2001 From: oladev2026-tech Date: Sun, 23 Aug 2026 09:42:52 +0100 Subject: [PATCH] feat(auth): unify token lifecycle with coordinated silent refresh Introduces a single TokenManager that owns the access/refresh tokens so the REST client, WebSocket connections, the notification socket and the offline replay queue all share one credential and one refresh path. - TokenManager (src/lib/auth/tokenManager.ts): single-flight refresh so concurrent 401s collapse into one /auth/refresh call; proactive silent refresh a configurable skew before exp; token:rotated / token:revoked / auth:logout events; auth lifecycle metrics. - REST: api.ts sources its token from the manager and performs one coordinated refresh + replay on 401; apiInterceptors attaches a freshly refreshed token and routes 401 through the manager, hard-logging-out only when the refresh itself fails. - Sockets: websocketManager re-authenticates live connections on rotation and drops them on revocation; the notification socket does the same in place. - Offline: offlineApi and offlineSync gate replay on a valid token, refreshing silently and refusing (dead-letter) when logged out so the queue is not burned against a dead credential. - jwt.ts adds a client-safe decode and a detailed verifier that surfaces the exp/nbf/signature failure reason; config/constants add the refresh endpoint and skew. Adds unit tests covering single-flight dedupe, skew scheduling, rotation and revocation, persistence and forced logout. --- src/config/environment.ts | 23 ++ src/constants/app.constants.ts | 11 + src/lib/api.ts | 26 +- src/lib/apiInterceptors.ts | 31 +- src/lib/auth/__tests__/tokenManager.test.ts | 274 +++++++++++++++ src/lib/auth/jwt.ts | 96 +++++- src/lib/auth/tokenManager.ts | 355 ++++++++++++++++++++ src/lib/monitoring/metrics.ts | 22 ++ src/lib/notifications/socket.ts | 29 ++ src/lib/websocketManager.ts | 22 +- src/services/offlineApi.ts | 20 ++ src/services/offlineSync.ts | 16 + 12 files changed, 916 insertions(+), 9 deletions(-) create mode 100644 src/lib/auth/__tests__/tokenManager.test.ts create mode 100644 src/lib/auth/tokenManager.ts diff --git a/src/config/environment.ts b/src/config/environment.ts index b81c3a89..12059445 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -4,3 +4,26 @@ export const getEnvironment = (): 'development' | 'staging' | 'production' => { if (nodeEnv === 'staging' || nodeEnv === 'test') return 'staging'; return 'development'; }; + +export interface AuthConfig { + /** Endpoint used to exchange a refresh token for a new access token. */ + refreshEndpoint: string; + /** Milliseconds before `exp` at which a token becomes due for refresh. */ + refreshSkewMs: number; +} + +/** + * Resolves the authentication token-lifecycle configuration, allowing the + * refresh endpoint and skew to be overridden per environment via + * `NEXT_PUBLIC_AUTH_REFRESH_ENDPOINT` / `NEXT_PUBLIC_AUTH_REFRESH_SKEW_MS`. + */ +export const getAuthConfig = (): AuthConfig => { + const endpoint = process.env.NEXT_PUBLIC_AUTH_REFRESH_ENDPOINT; + const skewRaw = process.env.NEXT_PUBLIC_AUTH_REFRESH_SKEW_MS; + const skew = skewRaw ? Number.parseInt(skewRaw, 10) : NaN; + + return { + refreshEndpoint: endpoint && endpoint.length > 0 ? endpoint : '/api/auth/refresh', + refreshSkewMs: Number.isFinite(skew) && skew >= 0 ? skew : 60_000, + }; +}; diff --git a/src/constants/app.constants.ts b/src/constants/app.constants.ts index c385f0e3..646df831 100644 --- a/src/constants/app.constants.ts +++ b/src/constants/app.constants.ts @@ -75,8 +75,19 @@ export const STARKNET_NETWORKS = { export const STORAGE_KEYS = { PERF_TRENDS: 'teachlink:perf:trends', AUTH_TOKEN: 'token', + REFRESH_TOKEN: 'refresh_token', }; +/** + * How long before an access token's `exp` the token manager treats it as due + * for a silent refresh. A generous skew keeps long-lived sockets and queued + * offline operations from ever carrying a token that lapses mid-flight. + */ +export const AUTH_REFRESH_SKEW_MS = 60_000; + +/** Default endpoint used to exchange a refresh token for a new access token. */ +export const AUTH_REFRESH_ENDPOINT = '/api/auth/refresh'; + /** * Domains permitted in sanitized HTML links and sanitizeUrl(). * Subdomains are automatically permitted (e.g. www.youtube.com matches youtube.com). diff --git a/src/lib/api.ts b/src/lib/api.ts index a20a6db2..23c2f048 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -13,6 +13,7 @@ import { API_CACHE_TTL_DEFAULT, } from '@/constants/app.constants'; import { logContextStorage } from './logging/context'; +import { tokenManager } from '@/lib/auth/tokenManager'; export type { ErrorInfo }; @@ -45,6 +46,7 @@ export interface RequestConfig extends RequestInit { schema?: z.ZodSchema; useCache?: boolean; _bypassCacheRead?: boolean; + _authRetried?: boolean; ttl?: number; } @@ -109,6 +111,12 @@ class ApiClientImpl { } private getToken(): string | null { + // The token manager is the single source of truth; it hydrates from + // localStorage under STORAGE_KEYS.AUTH_TOKEN, so the value is identical to + // the previous direct read but is now shared with sockets and the offline + // queue and kept fresh by silent refresh. + const managed = tokenManager.getAccessTokenSync(); + if (managed) return managed; if (typeof window === 'undefined') return null; return localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); } @@ -131,7 +139,9 @@ class ApiClientImpl { } private async requestWithRetry(config: RequestConfig, attempt = 1): Promise { - const token = this.getToken(); + // Proactively ensure a non-expired access token before sending. Concurrent + // requests share a single refresh; falls back to the cached token. + const token = (await tokenManager.getValidAccessToken()) ?? this.getToken(); const baseURL = this.config.baseURL.replace(/\/+$/, ''); const resolvedUrl = getVersionedApiPath(config.url); @@ -175,6 +185,18 @@ class ApiClientImpl { clearTimeout(timer); if (!response.ok) { + // A 401 triggers one coordinated refresh + replay. The single-flight + // refresh in the token manager collapses concurrent 401s into a single + // network round-trip; `_authRetried` prevents an infinite loop. + if (response.status === 401 && !config._authRetried) { + try { + await tokenManager.refresh(); + return this.requestWithRetry({ ...config, _authRetried: true }, 1); + } catch { + // Refresh failed — fall through to the normal 401 error path below. + } + } + if (shouldRetry(response.status, attempt, this.config.maxRetries)) { await new Promise((r) => setTimeout(r, getRetryDelay(attempt, this.config.retryDelay))); return this.requestWithRetry(config, attempt + 1); @@ -284,4 +306,4 @@ class ApiClientImpl { // Singleton export const apiClient = new ApiClientImpl(); -export type { ApiClientImpl }; \ No newline at end of file +export type { ApiClientImpl }; diff --git a/src/lib/apiInterceptors.ts b/src/lib/apiInterceptors.ts index d18db511..23fd8f6b 100644 --- a/src/lib/apiInterceptors.ts +++ b/src/lib/apiInterceptors.ts @@ -16,6 +16,7 @@ import { } from './api'; import { createLogger } from '@/lib/logging'; +import { tokenManager } from '@/lib/auth/tokenManager'; declare global { interface Window { @@ -62,12 +63,35 @@ export const loggingErrorInterceptor: ErrorInterceptor = async (error: Error) => apiLogger.error('API request failed', { error }); }; +/** + * Attaches a valid (silently refreshed if needed) access token to outgoing + * requests via the shared token manager, so the REST client, sockets and the + * offline queue all present the same, non-expired credential. + */ +export const authRequestInterceptor: RequestInterceptor = async (config) => { + const token = await tokenManager.getValidAccessToken(); + if (token) { + const headers: Record = (config.headers as Record) || {}; + headers['Authorization'] = `Bearer ${token}`; + config.headers = headers; + } + return config; +}; + export const authRefreshInterceptor: ErrorInterceptor = async (error: Error) => { const isUnauthorized = error.message?.includes('401') || error.message?.includes('Unauthorized'); + if (!isUnauthorized) return; - if (isUnauthorized && typeof window !== 'undefined') { - localStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN); - window.location.href = '/login'; + // Route the failure through the token manager so a single coordinated refresh + // happens (single-flight). Only if the refresh itself fails do we hard-logout. + try { + await tokenManager.refresh(); + } catch { + tokenManager.forceLogout('rest_401'); + if (typeof window !== 'undefined') { + localStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN); + window.location.href = '/login'; + } } }; @@ -105,6 +129,7 @@ export const headerEnhancementInterceptor: RequestInterceptor = async (config) = export function setupApiInterceptors(): void { apiClient.addRequestInterceptor(loggingRequestInterceptor); + apiClient.addRequestInterceptor(authRequestInterceptor); apiClient.addRequestInterceptor(timeoutInterceptor); apiClient.addRequestInterceptor(headerEnhancementInterceptor); diff --git a/src/lib/auth/__tests__/tokenManager.test.ts b/src/lib/auth/__tests__/tokenManager.test.ts new file mode 100644 index 00000000..58b2e927 --- /dev/null +++ b/src/lib/auth/__tests__/tokenManager.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TokenManager, readTokenExpiryMs, type TokenManagerOptions } from '../tokenManager'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const NOW = 1_700_000_000_000; // fixed clock (ms) + +function base64url(input: string): string { + return btoa(input).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** Build an unsigned-but-well-formed JWT whose `exp` is `expSeconds`. */ +function makeJwt(expSeconds: number): string { + const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const payload = base64url(JSON.stringify({ sub: 'user-1', exp: expSeconds })); + return `${header}.${payload}.signature`; +} + +const soonToken = () => makeJwt(Math.floor(NOW / 1000) + 1); // expires in 1s +const freshToken = () => makeJwt(Math.floor(NOW / 1000) + 3600); // 1h + +function createStorage() { + const map = new Map(); + return { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k), + _map: map, + }; +} + +function okResponse(body: unknown): Response { + return { + ok: true, + status: 200, + json: async () => body, + } as unknown as Response; +} + +function errorResponse(status: number): Response { + return { + ok: false, + status, + json: async () => ({}), + } as unknown as Response; +} + +interface ManagerParts { + manager: TokenManager; + fetchImpl: ReturnType; + recordMetric: ReturnType; + storage: ReturnType; +} + +function build(overrides: Partial = {}): ManagerParts { + const storage = createStorage(); + const fetchImpl = vi.fn(); + const recordMetric = vi.fn(); + const manager = new TokenManager({ + now: () => NOW, + fetchImpl: fetchImpl as unknown as typeof fetch, + storage, + refreshEndpoint: '/api/auth/refresh', + refreshSkewMs: 60_000, + scheduleRefresh: false, + recordMetric, + ...overrides, + }); + return { manager, fetchImpl, recordMetric, storage }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('readTokenExpiryMs', () => { + it('decodes the exp claim as epoch milliseconds', () => { + const exp = Math.floor(NOW / 1000) + 100; + expect(readTokenExpiryMs(makeJwt(exp))).toBe(exp * 1000); + }); + + it('returns null for a malformed token', () => { + expect(readTokenExpiryMs('not-a-jwt')).toBeNull(); + expect(readTokenExpiryMs('a.b')).toBeNull(); + }); +}); + +describe('TokenManager.getValidAccessToken', () => { + it('returns the current token unchanged when it is not expiring soon', async () => { + const { manager, fetchImpl } = build(); + const token = freshToken(); + manager.setTokens({ accessToken: token, refreshToken: 'refresh-1' }); + + await expect(manager.getValidAccessToken()).resolves.toBe(token); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('refreshes when the token is within the skew of expiry', async () => { + const { manager, fetchImpl } = build(); + const newToken = freshToken(); + fetchImpl.mockResolvedValue(okResponse({ accessToken: newToken })); + manager.setTokens({ accessToken: soonToken(), refreshToken: 'refresh-1' }); + + await expect(manager.getValidAccessToken()).resolves.toBe(newToken); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('returns the existing token (no refresh) when there is no refresh token', async () => { + const { manager, fetchImpl } = build(); + const token = soonToken(); + manager.setTokens({ accessToken: token, refreshToken: null }); + + await expect(manager.getValidAccessToken()).resolves.toBe(token); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + +describe('TokenManager single-flight refresh', () => { + it('collapses concurrent refreshes into a single network request', async () => { + const { manager, fetchImpl } = build(); + const newToken = freshToken(); + let resolveFetch: (r: Response) => void = () => {}; + fetchImpl.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + manager.setTokens({ accessToken: soonToken(), refreshToken: 'refresh-1' }); + + const calls = Promise.all([ + manager.getValidAccessToken(), + manager.getValidAccessToken(), + manager.refresh(), + manager.refresh(), + manager.getValidAccessToken(), + ]); + + resolveFetch(okResponse({ accessToken: newToken })); + const results = await calls; + + expect(fetchImpl).toHaveBeenCalledTimes(1); + for (const r of results) expect(r).toBe(newToken); + }); + + it('allows a subsequent refresh after the in-flight one settles', async () => { + const { manager, fetchImpl } = build(); + fetchImpl.mockResolvedValueOnce( + okResponse({ accessToken: makeJwt(Math.floor(NOW / 1000) + 10) }), + ); + manager.setTokens({ accessToken: soonToken(), refreshToken: 'refresh-1' }); + + await manager.refresh(); + fetchImpl.mockResolvedValueOnce(okResponse({ accessToken: freshToken() })); + await manager.refresh(); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); +}); + +describe('TokenManager refresh outcomes', () => { + it('emits token:rotated and records a metric on a successful rotation', async () => { + const { manager, fetchImpl, recordMetric } = build(); + const newToken = freshToken(); + fetchImpl.mockResolvedValue(okResponse({ accessToken: newToken, refreshToken: 'refresh-2' })); + manager.setTokens({ accessToken: soonToken(), refreshToken: 'refresh-1' }); + + const rotated = vi.fn(); + manager.on('token:rotated', rotated); + + await manager.refresh(); + + expect(rotated).toHaveBeenCalledWith({ accessToken: newToken }); + expect(recordMetric).toHaveBeenCalledWith('auth.refresh_success'); + expect(recordMetric).toHaveBeenCalledWith('auth.token_rotated'); + }); + + it('forces logout and broadcasts revocation when the refresh token is rejected', async () => { + const { manager, fetchImpl, recordMetric, storage } = build(); + fetchImpl.mockResolvedValue(errorResponse(401)); + manager.setTokens({ accessToken: soonToken(), refreshToken: 'refresh-1' }); + + const revoked = vi.fn(); + const loggedOut = vi.fn(); + manager.on('token:revoked', revoked); + manager.on('auth:logout', loggedOut); + + await expect(manager.refresh()).rejects.toThrow(); + + expect(revoked).toHaveBeenCalled(); + expect(loggedOut).toHaveBeenCalled(); + expect(manager.getAccessTokenSync()).toBeNull(); + expect(storage.getItem('token')).toBeNull(); + expect(recordMetric).toHaveBeenCalledWith('auth.forced_logout', { reason: 'refresh_rejected' }); + }); + + it('propagates a network error without logging out', async () => { + const { manager, fetchImpl } = build(); + fetchImpl.mockRejectedValue(new Error('network down')); + manager.setTokens({ accessToken: soonToken(), refreshToken: 'refresh-1' }); + + const loggedOut = vi.fn(); + manager.on('auth:logout', loggedOut); + + await expect(manager.refresh()).rejects.toThrow('network down'); + expect(loggedOut).not.toHaveBeenCalled(); + // The session is still intact and can be retried. + expect(manager.getAccessTokenSync()).not.toBeNull(); + }); +}); + +describe('TokenManager persistence and logout', () => { + it('persists tokens to storage and hydrates from it', () => { + const storage = createStorage(); + const first = new TokenManager({ storage, scheduleRefresh: false, now: () => NOW }); + const token = freshToken(); + first.setTokens({ accessToken: token, refreshToken: 'refresh-1' }); + + // A fresh instance backed by the same storage sees the token. + const second = new TokenManager({ storage, scheduleRefresh: false, now: () => NOW }); + expect(second.getAccessTokenSync()).toBe(token); + }); + + it('forceLogout clears tokens, storage and unsubscribed listeners are not called', () => { + const { manager, storage } = build(); + manager.setTokens({ accessToken: freshToken(), refreshToken: 'refresh-1' }); + + const revoked = vi.fn(); + const off = manager.on('token:revoked', revoked); + off(); + + manager.forceLogout('manual'); + + expect(manager.getAccessTokenSync()).toBeNull(); + expect(storage.getItem('token')).toBeNull(); + expect(storage.getItem('refresh_token')).toBeNull(); + expect(revoked).not.toHaveBeenCalled(); + }); +}); + +describe('TokenManager proactive refresh scheduling', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('schedules a silent refresh before expiry', async () => { + const storage = createStorage(); + const fetchImpl = vi.fn().mockResolvedValue(okResponse({ accessToken: freshToken() })); + const manager = new TokenManager({ + storage, + fetchImpl: fetchImpl as unknown as typeof fetch, + refreshSkewMs: 60_000, + scheduleRefresh: true, + }); + + // Token expires in 100s; with a 60s skew the refresh should fire at ~40s. + manager.setTokens({ + accessToken: makeJwt(Math.floor(NOW / 1000) + 100), + refreshToken: 'refresh-1', + }); + + expect(fetchImpl).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(41_000); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + manager.dispose(); + }); +}); diff --git a/src/lib/auth/jwt.ts b/src/lib/auth/jwt.ts index 6cd9eb8a..705d3919 100644 --- a/src/lib/auth/jwt.ts +++ b/src/lib/auth/jwt.ts @@ -7,6 +7,23 @@ export interface JWTPayload { email?: string; iat?: number; exp?: number; + nbf?: number; +} + +/** Distinct reasons a token can fail verification, for logging/monitoring. */ +export type TokenFailureReason = + | 'missing' + | 'malformed' + | 'no_secret' + | 'bad_signature' + | 'expired' + | 'not_yet_valid' + | 'invalid_role'; + +export interface TokenVerification { + valid: boolean; + payload?: JWTPayload; + reason?: TokenFailureReason; } const getSecret = () => { @@ -18,9 +35,7 @@ const getSecret = () => { /** * Signs a new JWT for the given payload. Uses the `jose` library (Node runtime). */ -export async function signToken( - payload: Omit, -): Promise { +export async function signToken(payload: Omit): Promise { return new SignJWT(payload) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() @@ -92,3 +107,78 @@ function base64UrlDecode(str: string): Uint8Array { const binary = atob(padded); return Uint8Array.from(binary, (c) => c.charCodeAt(0)); } + +/** + * Decodes a JWT's payload **without verifying its signature**. Safe to call on + * the client (no secret required) — used only to read non-sensitive claims such + * as `exp` for scheduling a refresh. Never trust the result for authorization. + */ +export function decodeTokenPayload(token: string | undefined | null): JWTPayload | null { + if (!token) return null; + const parts = token.split('.'); + if (parts.length !== 3) return null; + try { + const json = new TextDecoder().decode(base64UrlDecode(parts[1])); + return JSON.parse(json) as JWTPayload; + } catch { + return null; + } +} + +/** + * Like {@link verifyToken}, but returns a structured result that distinguishes + * *why* verification failed (expired vs. not-yet-valid vs. bad signature, …) so + * callers can log/monitor the specific reason instead of a bare `null`. + */ +export async function verifyTokenDetailed( + token: string | undefined | null, +): Promise { + if (!token) return { valid: false, reason: 'missing' }; + + const secret = process.env.JWT_SECRET; + if (!secret) return { valid: false, reason: 'no_secret' }; + + const parts = token.split('.'); + if (parts.length !== 3) return { valid: false, reason: 'malformed' }; + + const [headerB64, payloadB64, signatureB64] = parts; + + try { + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'], + ); + + const signatureBytes = base64UrlDecode(signatureB64).buffer as ArrayBuffer; + const dataToVerify = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + const isValid = await crypto.subtle.verify('HMAC', key, signatureBytes, dataToVerify); + if (!isValid) return { valid: false, reason: 'bad_signature' }; + + const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64))) as JWTPayload; + + const nowSeconds = Date.now() / 1000; + if (payload.exp && nowSeconds > payload.exp) { + return { valid: false, payload, reason: 'expired' }; + } + if (payload.nbf && nowSeconds < payload.nbf) { + return { valid: false, payload, reason: 'not_yet_valid' }; + } + + const validRoles: UserRole[] = [ + UserRole.ADMIN, + UserRole.INSTRUCTOR, + UserRole.STUDENT, + UserRole.GUEST, + ]; + if (!validRoles.includes(payload.role)) { + return { valid: false, payload, reason: 'invalid_role' }; + } + + return { valid: true, payload }; + } catch { + return { valid: false, reason: 'malformed' }; + } +} diff --git a/src/lib/auth/tokenManager.ts b/src/lib/auth/tokenManager.ts new file mode 100644 index 00000000..e369842a --- /dev/null +++ b/src/lib/auth/tokenManager.ts @@ -0,0 +1,355 @@ +import { STORAGE_KEYS } from '@/constants/app.constants'; +import { getAuthConfig } from '@/config/environment'; +import { createCounterMetric } from '@/lib/logging/performance'; + +/** + * Single source of truth for the authentication token lifecycle. + * + * Every consumer — the REST client, WebSocket connections, GraphQL + * subscriptions and the offline replay queue — obtains its access token from + * here via {@link TokenManager.getValidAccessToken}, so that: + * + * - A refresh that becomes necessary under concurrent load happens **once** + * (single-flight): callers share the same in-flight promise instead of each + * firing their own `/auth/refresh` request (no thundering herd). + * - The access token is refreshed silently a configurable skew before it + * expires, so long-lived sockets and queued offline operations never carry a + * token that is about to lapse. + * - Rotation and revocation are broadcast (`token:rotated` / `token:revoked` / + * `auth:logout`) so sockets can re-authenticate or disconnect and the offline + * queue can stop draining, rather than each consumer discovering the change + * independently when a request happens to fail. + */ + +export type AuthEvent = 'token:rotated' | 'token:revoked' | 'auth:logout'; + +export interface AuthEventPayloads { + 'token:rotated': { accessToken: string }; + 'token:revoked': { reason: string }; + 'auth:logout': { reason: string }; +} + +export type AuthEventListener = (payload: AuthEventPayloads[E]) => void; + +export interface TokenPair { + accessToken: string; + refreshToken?: string | null; +} + +interface RefreshResponse { + accessToken?: string; + token?: string; + refreshToken?: string; +} + +type MetricSink = (name: string, tags?: Record) => void; + +type MinimalStorage = Pick; + +export interface TokenManagerOptions { + /** Clock, injectable for tests. Defaults to `Date.now`. */ + now?: () => number; + /** `fetch` implementation, injectable for tests. */ + fetchImpl?: typeof fetch; + /** Storage backing tokens; `null` disables persistence. */ + storage?: MinimalStorage | null; + /** Endpoint used to exchange a refresh token for a new access token. */ + refreshEndpoint?: string; + /** How long before `exp` a token is considered due for refresh (ms). */ + refreshSkewMs?: number; + /** When true, a background timer refreshes proactively before `exp`. */ + scheduleRefresh?: boolean; + /** Metric sink; defaults to the app performance recorder. */ + recordMetric?: MetricSink; +} + +function defaultStorage(): MinimalStorage | null { + if (typeof window === 'undefined') return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +function decodeBase64Url(input: string): string { + const base64 = input.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + if (typeof atob === 'function') return atob(padded); + if (typeof Buffer !== 'undefined') { + return Buffer.from(padded, 'base64').toString('binary'); + } + return ''; +} + +/** + * Read the `exp` claim (as epoch milliseconds) from a JWT without verifying its + * signature. Verification is the server's job; the client only needs `exp` to + * decide when to refresh. Returns `null` for malformed tokens. + */ +export function readTokenExpiryMs(token: string): number | null { + const parts = token.split('.'); + if (parts.length !== 3) return null; + try { + const payload = JSON.parse(decodeBase64Url(parts[1])) as { exp?: number }; + if (typeof payload.exp !== 'number') return null; + return payload.exp * 1000; + } catch { + return null; + } +} + +export class TokenManager { + private accessToken: string | null = null; + private refreshToken: string | null = null; + private expiresAtMs: number | null = null; + + private refreshInFlight: Promise | null = null; + private refreshTimer: ReturnType | null = null; + private hydrated = false; + + private readonly now: () => number; + private readonly fetchImpl: typeof fetch; + private readonly storage: MinimalStorage | null; + private readonly refreshEndpoint: string; + private readonly refreshSkewMs: number; + private readonly scheduleRefresh: boolean; + private readonly recordMetric: MetricSink; + + private readonly listeners: { + [E in AuthEvent]: Set>; + } = { + 'token:rotated': new Set(), + 'token:revoked': new Set(), + 'auth:logout': new Set(), + }; + + constructor(options: TokenManagerOptions = {}) { + const config = getAuthConfig(); + this.now = options.now ?? (() => Date.now()); + this.fetchImpl = + options.fetchImpl ?? (typeof fetch !== 'undefined' ? fetch.bind(globalThis) : undefinedFetch); + this.storage = options.storage === undefined ? defaultStorage() : options.storage; + this.refreshEndpoint = options.refreshEndpoint ?? config.refreshEndpoint; + this.refreshSkewMs = options.refreshSkewMs ?? config.refreshSkewMs; + this.scheduleRefresh = options.scheduleRefresh ?? true; + this.recordMetric = + options.recordMetric ?? + ((name, tags) => { + createCounterMetric(name, 1, tags); + }); + } + + // -- Event bus ------------------------------------------------------------- + + on(event: E, listener: AuthEventListener): () => void { + this.listeners[event].add(listener); + return () => this.off(event, listener); + } + + off(event: E, listener: AuthEventListener): void { + this.listeners[event].delete(listener); + } + + private emit(event: E, payload: AuthEventPayloads[E]): void { + for (const listener of this.listeners[event]) { + try { + listener(payload); + } catch { + // A misbehaving listener must not break token management. + } + } + } + + // -- Hydration & persistence ---------------------------------------------- + + private hydrate(): void { + if (this.hydrated) return; + this.hydrated = true; + if (!this.storage) return; + const access = this.storage.getItem(STORAGE_KEYS.AUTH_TOKEN); + const refresh = this.storage.getItem(STORAGE_KEYS.REFRESH_TOKEN); + if (access) { + this.accessToken = access; + this.expiresAtMs = readTokenExpiryMs(access); + } + if (refresh) this.refreshToken = refresh; + this.rescheduleRefresh(); + } + + private persist(): void { + if (!this.storage) return; + if (this.accessToken) { + this.storage.setItem(STORAGE_KEYS.AUTH_TOKEN, this.accessToken); + } else { + this.storage.removeItem(STORAGE_KEYS.AUTH_TOKEN); + } + if (this.refreshToken) { + this.storage.setItem(STORAGE_KEYS.REFRESH_TOKEN, this.refreshToken); + } else { + this.storage.removeItem(STORAGE_KEYS.REFRESH_TOKEN); + } + } + + // -- Public token API ------------------------------------------------------ + + /** Set the token pair after a login or an external refresh. */ + setTokens(tokens: TokenPair): void { + this.hydrate(); + this.accessToken = tokens.accessToken; + if (tokens.refreshToken !== undefined) { + this.refreshToken = tokens.refreshToken; + } + this.expiresAtMs = readTokenExpiryMs(tokens.accessToken); + this.persist(); + this.rescheduleRefresh(); + } + + /** The currently cached access token, without triggering a refresh. */ + getAccessTokenSync(): string | null { + this.hydrate(); + return this.accessToken; + } + + /** True when there is no token, or it is within the refresh skew of expiry. */ + private isExpiringSoon(): boolean { + if (!this.accessToken) return true; + if (this.expiresAtMs === null) return false; // opaque token — trust until 401 + return this.expiresAtMs - this.now() <= this.refreshSkewMs; + } + + /** + * Return a valid access token, refreshing first if the current one is missing + * or about to expire. Concurrent callers share a single refresh. + */ + async getValidAccessToken(): Promise { + this.hydrate(); + if (!this.isExpiringSoon()) return this.accessToken; + if (!this.refreshToken) return this.accessToken; + try { + return await this.refresh(); + } catch { + return null; + } + } + + /** + * Force a token refresh. All concurrent callers receive the same in-flight + * promise (single-flight), so only one network request is made. + */ + refresh(): Promise { + this.hydrate(); + if (this.refreshInFlight) return this.refreshInFlight; + + const inFlight = this.performRefresh().finally(() => { + this.refreshInFlight = null; + }); + this.refreshInFlight = inFlight; + return inFlight; + } + + private async performRefresh(): Promise { + if (!this.refreshToken) { + this.forceLogout('missing_refresh_token'); + throw new Error('No refresh token available'); + } + + let response: Response; + try { + response = await this.fetchImpl(this.refreshEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken: this.refreshToken }), + }); + } catch (error) { + this.recordMetric('auth.refresh_failure', { reason: 'network' }); + throw error instanceof Error ? error : new Error('Refresh request failed'); + } + + if (!response.ok) { + this.recordMetric('auth.refresh_failure', { status: response.status }); + // 401/403 on refresh means the refresh token itself is dead → hard logout. + if (response.status === 401 || response.status === 403) { + this.forceLogout('refresh_rejected'); + } + throw new Error(`Refresh failed with status ${response.status}`); + } + + const data = (await response.json().catch(() => ({}))) as RefreshResponse; + const newAccess = data.accessToken ?? data.token; + if (!newAccess) { + this.recordMetric('auth.refresh_failure', { reason: 'no_token_in_response' }); + throw new Error('Refresh response did not include an access token'); + } + + const previousAccess = this.accessToken; + this.accessToken = newAccess; + if (data.refreshToken) this.refreshToken = data.refreshToken; + this.expiresAtMs = readTokenExpiryMs(newAccess); + this.persist(); + this.rescheduleRefresh(); + + this.recordMetric('auth.refresh_success'); + if (previousAccess !== newAccess) { + this.recordMetric('auth.token_rotated'); + this.emit('token:rotated', { accessToken: newAccess }); + } + return newAccess; + } + + /** + * Clear all tokens and broadcast logout. Consumers should disconnect sockets + * and stop draining the offline queue in response. + */ + forceLogout(reason = 'manual'): void { + this.accessToken = null; + this.refreshToken = null; + this.expiresAtMs = null; + this.clearTimer(); + this.persist(); + this.recordMetric('auth.forced_logout', { reason }); + this.emit('token:revoked', { reason }); + this.emit('auth:logout', { reason }); + } + + // -- Proactive refresh scheduling ----------------------------------------- + + private clearTimer(): void { + if (this.refreshTimer !== null) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + } + + private rescheduleRefresh(): void { + this.clearTimer(); + if (!this.scheduleRefresh) return; + if (typeof setTimeout === 'undefined') return; + if (!this.accessToken || this.expiresAtMs === null || !this.refreshToken) { + return; + } + const fireInMs = Math.max(0, this.expiresAtMs - this.now() - this.refreshSkewMs); + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + void this.refresh().catch(() => { + // Swallowed: a failed proactive refresh surfaces on the next request. + }); + }, fireInMs); + } + + /** Testing/teardown helper: cancel the background refresh timer. */ + dispose(): void { + this.clearTimer(); + } +} + +function undefinedFetch(): Promise { + return Promise.reject(new Error('No fetch implementation available')); +} + +/** + * Process-wide singleton used by the REST client, sockets and the offline + * queue. Tests instantiate their own {@link TokenManager} with injected + * dependencies instead of using this instance. + */ +export const tokenManager = new TokenManager(); diff --git a/src/lib/monitoring/metrics.ts b/src/lib/monitoring/metrics.ts index 08e0868f..31fa21a8 100644 --- a/src/lib/monitoring/metrics.ts +++ b/src/lib/monitoring/metrics.ts @@ -17,6 +17,28 @@ export function recordRealtimeMetric( createCounterMetric(name, value, tags); } +/** + * Authentication lifecycle events emitted by the token manager. Kept as a union + * so dashboards and alerts can query a stable, documented set of metric names. + */ +export type AuthMetricName = + | 'auth.refresh_success' + | 'auth.refresh_failure' + | 'auth.token_rotated' + | 'auth.forced_logout'; + +/** + * Record an auth lifecycle metric as a counter. Used by the token manager so + * refresh success/failure, rotation and forced logout are observable in the + * same monitoring pipeline as the rest of the app. + */ +export function recordAuthMetric( + name: AuthMetricName, + tags?: Record, +): void { + createCounterMetric(name, 1, tags); +} + export function useMetrics() { const [metrics, setMetrics] = useState([]); diff --git a/src/lib/notifications/socket.ts b/src/lib/notifications/socket.ts index 6342aee5..17bc3e75 100644 --- a/src/lib/notifications/socket.ts +++ b/src/lib/notifications/socket.ts @@ -6,6 +6,7 @@ import { type ConnectionStatus, registerSupervisor, } from '@/lib/realtime/connectionSupervisor'; +import { tokenManager } from '@/lib/auth/tokenManager'; const logger = createLogger('notification-socket'); @@ -62,6 +63,17 @@ export class NotificationSocketService { status: 'idle', reconnectAttempts: 0, }; + private readonly authUnsubscribers: Array<() => void> = []; + private readonly handleTokenRotated = ({ accessToken }: { accessToken: string }) => { + // Re-authenticate through the supervisor's queue so a rotated token keeps + // the connection valid (flushed immediately when connected, queued if not). + this.supervisor.send({ event: 'authenticate', payload: { token: accessToken } }); + }; + private readonly handleTokenRevoked = () => { + // The session was revoked — tear the socket down so it cannot keep + // receiving notifications under a dead credential. + this.disconnect(); + }; private readonly handleOnline = () => { if (!this.intentionallyClosed) { this.supervisor.reconnectNow(); @@ -91,12 +103,14 @@ export class NotificationSocketService { connect(): void { this.intentionallyClosed = false; this.registerNetworkListeners(); + this.registerAuthListeners(); this.supervisor.connect(); } disconnect(): void { this.intentionallyClosed = true; this.unregisterNetworkListeners(); + this.unregisterAuthListeners(); this.supervisor.disconnect(); } @@ -171,6 +185,21 @@ export class NotificationSocketService { this.connectionListeners.forEach((listener) => listener(this.connectionState)); } + private registerAuthListeners(): void { + if (this.authUnsubscribers.length > 0) { + return; + } + this.authUnsubscribers.push( + tokenManager.on('token:rotated', this.handleTokenRotated), + tokenManager.on('token:revoked', this.handleTokenRevoked), + ); + } + + private unregisterAuthListeners(): void { + this.authUnsubscribers.forEach((off) => off()); + this.authUnsubscribers.length = 0; + } + private registerNetworkListeners(): void { if (typeof window === 'undefined') { return; diff --git a/src/lib/websocketManager.ts b/src/lib/websocketManager.ts index fe92bcf7..21da00fd 100644 --- a/src/lib/websocketManager.ts +++ b/src/lib/websocketManager.ts @@ -7,6 +7,7 @@ import { type ConnectionStatus, registerSupervisor, } from '@/lib/realtime/connectionSupervisor'; +import { tokenManager } from '@/lib/auth/tokenManager'; export interface WebSocketConfig { url: string; @@ -89,7 +90,26 @@ export class WebSocketManager { private transports: Map = new Map(); private configs: Map = new Map(); - private constructor() {} + private constructor() { + // React to the shared token lifecycle: re-authenticate live sockets when the + // access token rotates, and drop every connection when the session is + // revoked so a stale/expired credential can never keep receiving data. + tokenManager.on('token:rotated', ({ accessToken }) => { + this.reauthenticateAll(accessToken); + }); + tokenManager.on('token:revoked', () => { + this.disconnectAll(); + }); + } + + private reauthenticateAll(accessToken: string): void { + this.transports.forEach((transport) => { + const socket = transport.getSocket(); + if (socket?.connected) { + socket.emit('authenticate', { token: accessToken }); + } + }); + } static getInstance(): WebSocketManager { if (!WebSocketManager.instance) { diff --git a/src/services/offlineApi.ts b/src/services/offlineApi.ts index 0bdd9700..80371690 100644 --- a/src/services/offlineApi.ts +++ b/src/services/offlineApi.ts @@ -1,5 +1,18 @@ import { apiClient } from '@/lib/api'; import { VersionVector } from '@/lib/conflict/types'; +import { tokenManager } from '@/lib/auth/tokenManager'; + +/** + * Thrown when an offline operation is replayed without an authenticated + * session, so the caller can dead-letter it instead of retrying against a dead + * credential. + */ +export class OfflineAuthRequiredError extends Error { + constructor() { + super('Offline replay requires an authenticated session'); + this.name = 'OfflineAuthRequiredError'; + } +} export interface OfflineProgressPayload { courseId: string; @@ -36,6 +49,13 @@ export const offlineApi = { syncLessonProgress: async ( progress: OfflineProgressPayload, ): Promise => { + // Ensure a valid, non-expired token before replaying (silent refresh via the + // shared token manager); refuse when logged out so the sync layer can + // dead-letter rather than retry endlessly. + const token = await tokenManager.getValidAccessToken(); + if (!token) { + throw new OfflineAuthRequiredError(); + } return apiClient.patch( `/api/lessons/${encodeURIComponent(progress.moduleId)}/progress`, progress, diff --git a/src/services/offlineSync.ts b/src/services/offlineSync.ts index c6cbe46e..0a735673 100644 --- a/src/services/offlineSync.ts +++ b/src/services/offlineSync.ts @@ -19,6 +19,7 @@ import { DEAD_LETTER_RETENTION_MS, } from '@/constants/app.constants'; import { offlineApi } from './offlineApi'; +import { tokenManager } from '@/lib/auth/tokenManager'; export type SyncItemType = 'course_progress'; @@ -741,6 +742,21 @@ export class OfflineSyncService { throw new Error('Sync already in progress'); } + // Gate the drain on a valid session. The token manager silently refreshes an + // about-to-expire token; if the user is logged out it returns null and we + // skip draining entirely, leaving the queue intact to replay once the user + // re-authenticates rather than burning retries against a dead credential. + const accessToken = await tokenManager.getValidAccessToken(); + if (!accessToken) { + return { + success: false, + syncedItems: 0, + conflicts: [], + errors: ['Skipped: no authenticated session'], + lastSyncTime: new Date().toISOString(), + }; + } + this.isSyncing = true; const result: SyncResult = {