528 lines
16 KiB
TypeScript
528 lines
16 KiB
TypeScript
/**
|
|
* 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<T>(
|
|
path: string,
|
|
method: string,
|
|
body?: unknown,
|
|
opts?: { skipAuth?: boolean }
|
|
): Promise<T> {
|
|
const headers: Record<string, string> = {
|
|
'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<string, string>).message ||
|
|
(data as Record<string, string>).error ||
|
|
`HTTP ${res.status}`
|
|
);
|
|
}
|
|
|
|
if (res.status === 204) return undefined as T;
|
|
return res.json() as Promise<T>;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
// ── Singleton refresh guard ─────────────────────
|
|
|
|
let _refreshPromise: Promise<boolean> | null = null;
|
|
|
|
async function refreshAccessToken(): Promise<boolean> {
|
|
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<AuthResult | MfaRequiredResult> {
|
|
const result = await request<AuthResult | MfaRequiredResult>('/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<AuthResult> {
|
|
const result = await request<AuthResult>('/auth/register', 'POST', {
|
|
email,
|
|
password,
|
|
displayName,
|
|
productId,
|
|
});
|
|
setTokens(result.accessToken, result.refreshToken);
|
|
return result;
|
|
}
|
|
|
|
async function getMe(): Promise<AuthUser> {
|
|
return request<AuthUser>('/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<AuthResult | MfaRequiredResult> {
|
|
const result = await request<AuthResult | MfaRequiredResult>(
|
|
`/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<AuthResult | MfaRequiredResult> {
|
|
return loginWithOAuth('google', idToken);
|
|
}
|
|
|
|
async function loginWithMicrosoft(idToken: string): Promise<AuthResult | MfaRequiredResult> {
|
|
return loginWithOAuth('microsoft', idToken);
|
|
}
|
|
|
|
async function loginWithApple(idToken: string): Promise<AuthResult | MfaRequiredResult> {
|
|
return loginWithOAuth('apple', idToken);
|
|
}
|
|
|
|
// ── Provider management (Phase 1C) ─────────────────
|
|
|
|
async function getProviders(): Promise<AuthProvider[]> {
|
|
const data = await request<{ providers: AuthProvider[] }>('/auth/providers', 'GET');
|
|
return data.providers;
|
|
}
|
|
|
|
async function linkProvider(provider: string, idToken: string): Promise<void> {
|
|
await request<void>('/auth/providers/link', 'POST', { provider, idToken });
|
|
}
|
|
|
|
async function unlinkProvider(provider: string): Promise<void> {
|
|
await request<void>(`/auth/providers/${provider}`, 'DELETE');
|
|
}
|
|
|
|
// ── MFA (Phase 2D) ─────────────────────────────────
|
|
|
|
async function verifyMfa(
|
|
challengeToken: string,
|
|
code: string,
|
|
method: 'totp' | 'recovery'
|
|
): Promise<AuthResult> {
|
|
const result = await request<AuthResult>('/auth/mfa/verify', 'POST', {
|
|
challengeToken,
|
|
code,
|
|
method,
|
|
});
|
|
setTokens(result.accessToken, result.refreshToken);
|
|
return result;
|
|
}
|
|
|
|
async function setupTotp(): Promise<TotpSetupResult> {
|
|
return request<TotpSetupResult>('/auth/mfa/setup', 'POST');
|
|
}
|
|
|
|
async function verifyTotpSetup(code: string): Promise<void> {
|
|
await request<void>('/auth/mfa/verify-setup', 'POST', { code });
|
|
}
|
|
|
|
async function disableMfa(code: string): Promise<void> {
|
|
await request<void>('/auth/mfa/disable', 'POST', { code });
|
|
}
|
|
|
|
async function getMfaStatus(): Promise<MfaStatus> {
|
|
return request<MfaStatus>('/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<unknown> {
|
|
return request<unknown>('/auth/passkeys/register/options', 'POST');
|
|
}
|
|
|
|
async function verifyPasskeyRegistration(response: unknown): Promise<void> {
|
|
await request<void>('/auth/passkeys/register/verify', 'POST', response);
|
|
}
|
|
|
|
async function getPasskeyAuthOptions(): Promise<unknown> {
|
|
return request<unknown>('/auth/passkeys/authenticate/options', 'POST', undefined, {
|
|
skipAuth: true,
|
|
});
|
|
}
|
|
|
|
async function verifyPasskeyAuth(response: unknown): Promise<AuthResult> {
|
|
const result = await request<AuthResult>(
|
|
'/auth/passkeys/authenticate/verify',
|
|
'POST',
|
|
response,
|
|
{ skipAuth: true }
|
|
);
|
|
setTokens(result.accessToken, result.refreshToken);
|
|
return result;
|
|
}
|
|
|
|
async function listPasskeys(): Promise<Passkey[]> {
|
|
const data = await request<{ passkeys: Passkey[] }>('/auth/passkeys', 'GET');
|
|
return data.passkeys;
|
|
}
|
|
|
|
async function deletePasskey(id: string): Promise<void> {
|
|
await request<void>(`/auth/passkeys/${id}`, 'DELETE');
|
|
}
|
|
|
|
// ── Devices (Phase 3) ──────────────────────────────
|
|
|
|
async function listDevices(): Promise<Device[]> {
|
|
const data = await request<{ devices: Device[] }>('/auth/devices', 'GET');
|
|
return data.devices;
|
|
}
|
|
|
|
async function trustDevice(
|
|
fingerprint: string,
|
|
trustLevel: 'trusted' | 'remembered',
|
|
deviceInfo?: Record<string, string>
|
|
): Promise<void> {
|
|
await request<void>('/auth/devices/trust', 'POST', { fingerprint, trustLevel, deviceInfo });
|
|
}
|
|
|
|
async function revokeDevice(fingerprint: string): Promise<void> {
|
|
await request<void>(`/auth/devices/${fingerprint}`, 'DELETE');
|
|
}
|
|
|
|
async function revokeAllDevices(): Promise<void> {
|
|
await request<void>('/auth/devices/revoke-all', 'POST');
|
|
}
|
|
|
|
// ── Admin security (Phase 5B) ──────────────────────
|
|
|
|
async function getSecurityOverview(): Promise<SecurityOverview> {
|
|
return request<SecurityOverview>('/auth/security/overview', 'GET');
|
|
}
|
|
|
|
async function unlockUser(userId: string): Promise<void> {
|
|
await request<void>(`/auth/users/${userId}/unlock`, 'POST');
|
|
}
|
|
|
|
async function exportAuthData(): Promise<unknown> {
|
|
return request<unknown>('/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<LoginEventInfo[]> {
|
|
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<LoginEventInfo[]> {
|
|
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<Device[]> {
|
|
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,
|
|
};
|
|
}
|