/** * Browser/React Native-safe auth API client for platform-service. * * Replaces hand-rolled auth clients in ChronoMind web, MindLyst web, NomGap, etc. * No Node.js dependencies — uses globalThis.fetch and configurable storage. * * @example * ```ts * import { createAuthClient } from '@bytelyst/auth-client'; * * const auth = createAuthClient({ * baseUrl: 'http://localhost:4003/api', * productId: 'chronomind', * }); * * const result = await auth.login('user@example.com', 'password123'); * console.log(result.user.displayName); * ``` */ import type { AuthClient, AuthClientConfig, AuthProvider, AuthResult, AuthUser, Device, LoginEventInfo, MfaRequiredResult, MfaStatus, Passkey, SecurityOverview, TokenStorage, TotpSetupResult, } from './types.js'; // ── Default localStorage adapter ───────────────────────────────── /** * No-op storage fallback used when `localStorage` is unavailable (e.g. SSR / Node.js). * Tokens stored via noopStorage are NOT persisted — they are lost on page reload. * For server-side rendering, use cookie-based auth instead of relying on this client. */ const noopStorage: TokenStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {}, }; function getDefaultStorage(): TokenStorage { if ( typeof globalThis.localStorage !== 'undefined' && typeof globalThis.localStorage?.getItem === 'function' ) { return globalThis.localStorage; } return noopStorage; } // ── UUID helper (browser + RN safe) ────────────────────────────── function uuid(): string { if (typeof globalThis.crypto?.randomUUID === 'function') { return globalThis.crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = (Math.random() * 16) | 0; return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); }); } // ── Factory ────────────────────────────────────────────────────── export function createAuthClient(config: AuthClientConfig): AuthClient { const { baseUrl, productId, timeoutMs = 15_000 } = config; const storage = config.storage ?? getDefaultStorage(); const prefix = config.storagePrefix ?? productId; const KEYS = { accessToken: `${prefix}-auth-token`, refreshToken: `${prefix}-refresh-token`, } as const; // ── Token management ──────────────────────────── function getAccessToken(): string | null { return storage.getItem(KEYS.accessToken); } function getRefreshToken(): string | null { return storage.getItem(KEYS.refreshToken); } function setTokens(accessToken: string, refreshToken: string): void { storage.setItem(KEYS.accessToken, accessToken); storage.setItem(KEYS.refreshToken, refreshToken); } function clearTokens(): void { storage.removeItem(KEYS.accessToken); storage.removeItem(KEYS.refreshToken); } function isAuthenticated(): boolean { return getAccessToken() !== null; } // ── HTTP helper ───────────────────────────────── async function request( path: string, method: string, body?: unknown, opts?: { skipAuth?: boolean } ): Promise { const headers: Record = { 'Content-Type': 'application/json', 'x-product-id': productId, 'x-request-id': uuid(), }; if (!opts?.skipAuth) { const token = getAccessToken(); if (token) headers['Authorization'] = `Bearer ${token}`; } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await globalThis.fetch(`${baseUrl}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error( (data as Record).message || (data as Record).error || `HTTP ${res.status}` ); } if (res.status === 204) return undefined as T; return res.json() as Promise; } finally { clearTimeout(timer); } } // ── Singleton refresh guard ───────────────────── let _refreshPromise: Promise | null = null; async function refreshAccessToken(): Promise { if (_refreshPromise) return _refreshPromise; _refreshPromise = (async () => { const rt = getRefreshToken(); if (!rt) return false; try { const data = await request<{ accessToken: string; refreshToken: string }>( '/auth/refresh', 'POST', { refreshToken: rt }, { skipAuth: true } ); setTokens(data.accessToken, data.refreshToken); return true; } catch { clearTokens(); return false; } })(); try { return await _refreshPromise; } finally { _refreshPromise = null; } } // ── Auth operations ───────────────────────────── async function login(email: string, password: string): Promise { const result = await request('/auth/login', 'POST', { email, password, productId, }); if ('mfaRequired' in result && result.mfaRequired) { return result; } const authResult = result as AuthResult; setTokens(authResult.accessToken, authResult.refreshToken); return authResult; } async function register( email: string, password: string, displayName: string ): Promise { const result = await request('/auth/register', 'POST', { email, password, displayName, productId, }); setTokens(result.accessToken, result.refreshToken); return result; } async function getMe(): Promise { return request('/auth/me', 'GET'); } // ── Password management ───────────────────────── async function forgotPassword(email: string): Promise<{ message: string }> { return request<{ message: string }>('/auth/forgot-password', 'POST', { email, productId, }); } async function resetPassword(token: string, newPassword: string): Promise<{ message: string }> { return request<{ message: string }>('/auth/reset-password', 'POST', { token, newPassword, }); } async function changePassword( currentPassword: string, newPassword: string ): Promise<{ message: string }> { return request<{ message: string }>('/auth/change-password', 'POST', { currentPassword, newPassword, }); } // ── Account management ────────────────────────── async function deleteAccount(password: string): Promise<{ message: string }> { const result = await request<{ message: string }>('/auth/account', 'DELETE', { password, }); clearTokens(); return result; } // ── Email verification ────────────────────────── async function verifyEmail(token: string): Promise<{ message: string }> { return request<{ message: string }>('/auth/verify-email', 'POST', { token }); } async function resendVerification(email: string): Promise<{ message: string }> { return request<{ message: string }>('/auth/resend-verification', 'POST', { email, productId, }); } // ── OAuth / Social login (Phase 1C) ──────────────── async function loginWithOAuth( provider: string, idToken: string ): Promise { const result = await request( `/auth/oauth/${provider}`, 'POST', { idToken, productId }, { skipAuth: true } ); if ('mfaRequired' in result && result.mfaRequired) { return result; } const authResult = result as AuthResult; setTokens(authResult.accessToken, authResult.refreshToken); return authResult; } async function loginWithGoogle(idToken: string): Promise { return loginWithOAuth('google', idToken); } async function loginWithMicrosoft(idToken: string): Promise { return loginWithOAuth('microsoft', idToken); } async function loginWithApple(idToken: string): Promise { return loginWithOAuth('apple', idToken); } // ── Provider management (Phase 1C) ───────────────── async function getProviders(): Promise { const data = await request<{ providers: AuthProvider[] }>('/auth/providers', 'GET'); return data.providers; } async function linkProvider(provider: string, idToken: string): Promise { await request('/auth/providers/link', 'POST', { provider, idToken }); } async function unlinkProvider(provider: string): Promise { await request(`/auth/providers/${provider}`, 'DELETE'); } // ── MFA (Phase 2D) ───────────────────────────────── async function verifyMfa( challengeToken: string, code: string, method: 'totp' | 'recovery' ): Promise { const result = await request('/auth/mfa/verify', 'POST', { challengeToken, code, method, }); setTokens(result.accessToken, result.refreshToken); return result; } async function setupTotp(): Promise { return request('/auth/mfa/setup', 'POST'); } async function verifyTotpSetup(code: string): Promise { await request('/auth/mfa/verify-setup', 'POST', { code }); } async function disableMfa(code: string): Promise { await request('/auth/mfa/disable', 'POST', { code }); } async function getMfaStatus(): Promise { return request('/auth/mfa/status', 'GET'); } async function regenerateRecoveryCodes(): Promise<{ codes: string[] }> { return request<{ codes: string[] }>('/auth/mfa/recovery/regenerate', 'POST'); } // ── Passkeys (Phase 3) ───────────────────────────── async function getPasskeyRegisterOptions(): Promise { return request('/auth/passkeys/register/options', 'POST'); } async function verifyPasskeyRegistration(response: unknown): Promise { await request('/auth/passkeys/register/verify', 'POST', response); } async function getPasskeyAuthOptions(): Promise { return request('/auth/passkeys/authenticate/options', 'POST', undefined, { skipAuth: true, }); } async function verifyPasskeyAuth(response: unknown): Promise { const result = await request( '/auth/passkeys/authenticate/verify', 'POST', response, { skipAuth: true } ); setTokens(result.accessToken, result.refreshToken); return result; } async function listPasskeys(): Promise { const data = await request<{ passkeys: Passkey[] }>('/auth/passkeys', 'GET'); return data.passkeys; } async function deletePasskey(id: string): Promise { await request(`/auth/passkeys/${id}`, 'DELETE'); } // ── Devices (Phase 3) ────────────────────────────── async function listDevices(): Promise { const data = await request<{ devices: Device[] }>('/auth/devices', 'GET'); return data.devices; } async function trustDevice( fingerprint: string, trustLevel: 'trusted' | 'remembered', deviceInfo?: Record ): Promise { await request('/auth/devices/trust', 'POST', { fingerprint, trustLevel, deviceInfo }); } async function revokeDevice(fingerprint: string): Promise { await request(`/auth/devices/${fingerprint}`, 'DELETE'); } async function revokeAllDevices(): Promise { await request('/auth/devices/revoke-all', 'POST'); } // ── Admin security (Phase 5B) ────────────────────── async function getSecurityOverview(): Promise { return request('/auth/security/overview', 'GET'); } async function unlockUser(userId: string): Promise { await request(`/auth/users/${userId}/unlock`, 'POST'); } async function exportAuthData(): Promise { return request('/auth/export', 'GET'); } async function cancelDeletion(): Promise<{ message: string }> { return request<{ message: string }>('/auth/account/cancel-deletion', 'POST'); } // ── Step-up auth ──────────────────────────────────── async function stepUp(method: string, credential: string): Promise<{ stepUpToken: string }> { return request<{ stepUpToken: string }>('/auth/step-up', 'POST', { method, credential }); } // ── Login history ─────────────────────────────────── async function getLoginHistory(limit = 50): Promise { const data = await request<{ events: LoginEventInfo[] }>( `/auth/login-events?limit=${limit}`, 'GET' ); return data.events; } // ── Admin security ────────────────────────────────── async function getAdminLoginEvents(opts?: { userId?: string; suspicious?: boolean; limit?: number; }): Promise { const params = new URLSearchParams(); if (opts?.userId) params.set('userId', opts.userId); if (opts?.suspicious) params.set('suspicious', 'true'); if (opts?.limit) params.set('limit', String(opts.limit)); const qs = params.toString(); const data = await request<{ events: LoginEventInfo[] }>( `/auth/login-events/admin${qs ? `?${qs}` : ''}`, 'GET' ); return data.events; } async function getAdminDevices(userId: string): Promise { const data = await request<{ devices: Device[] }>(`/auth/devices/user/${userId}`, 'GET'); return data.devices; } return { getAccessToken, getRefreshToken, setTokens, clearTokens, isAuthenticated, login, register, getMe, refreshAccessToken, forgotPassword, resetPassword, changePassword, deleteAccount, verifyEmail, resendVerification, // OAuth / Social login (Phase 1C) loginWithGoogle, loginWithMicrosoft, loginWithApple, // Provider management (Phase 1C) getProviders, linkProvider, unlinkProvider, // MFA (Phase 2D) verifyMfa, setupTotp, verifyTotpSetup, disableMfa, getMfaStatus, regenerateRecoveryCodes, // Passkeys (Phase 3) getPasskeyRegisterOptions, verifyPasskeyRegistration, getPasskeyAuthOptions, verifyPasskeyAuth, listPasskeys, deletePasskey, // Devices (Phase 3) listDevices, trustDevice, revokeDevice, revokeAllDevices, // Admin security (Phase 5B) getSecurityOverview, unlockUser, exportAuthData, cancelDeletion, // Step-up auth stepUp, // Login history getLoginHistory, // Admin queries getAdminLoginEvents, getAdminDevices, }; }