/* meds/services/auth/token.service.ts * * TokenService - unified persistence for email verification and password reset tokens. * - In production (CouchDB configured), tokens are stored in a CouchDB database. * - Otherwise, tokens are stored in localStorage for demo/dev/testing. */ import { EmailVerificationToken } from './auth.types'; import type { CouchDBDocument } from '../../types'; import { getDatabaseConfig } from '../../config/unified.config'; import { logger } from '../logging'; export interface PasswordResetToken { userId: string; email: string; token: string; expiresAt: Date; } type TokenType = 'verification' | 'reset'; interface TokenDoc extends CouchDBDocument { tokenType: TokenType; token: string; userId: string; email?: string; expiresAt: string; // ISO string createdAt: string; // ISO string } const DB_NAME = 'auth_tokens'; // Storage keys for localStorage fallback (kept compatible with existing code) const LS_VERIFICATION_KEY = 'verification_tokens'; const LS_RESET_KEY = 'password_reset_tokens'; function toISO(date: Date | string): string { return (date instanceof Date ? date : new Date(date)).toISOString(); } function fromISO(date: string | Date): Date { return date instanceof Date ? date : new Date(date); } function base64Auth(user: string, pass: string): string { // btoa may not exist in some environments (e.g., Node). Fallback to Buffer. if (typeof btoa !== 'undefined') { return btoa(`${user}:${pass}`); } return Buffer.from(`${user}:${pass}`).toString('base64'); } export class TokenService { private couchBaseUrl: string | null = null; private couchAuthHeader: string | null = null; private initialized = false; private usingCouch = false; constructor() { this.configure(); } private configure() { const db = getDatabaseConfig(); // Use CouchDB only when a URL is provided and not explicitly mocked this.usingCouch = !!db.url && db.url !== 'mock' && !db.useMock; if (this.usingCouch) { this.couchBaseUrl = db.url.replace(/\/+$/, ''); this.couchAuthHeader = `Basic ${base64Auth(db.username, db.password)}`; // Best-effort DB provisioning; do not block or throw this.ensureDatabase().catch(err => { logger.db.error('Failed to ensure auth token database', err as Error); // Fallback to localStorage if DB provisioning fails this.usingCouch = false; }); } } private async ensureDatabase(): Promise { if (this.initialized || !this.usingCouch || !this.couchBaseUrl) return; const head = await fetch(`${this.couchBaseUrl}/${DB_NAME}`, { method: 'HEAD', headers: { Authorization: this.couchAuthHeader!, }, }); if (head.status === 404) { const create = await fetch(`${this.couchBaseUrl}/${DB_NAME}`, { method: 'PUT', headers: { Authorization: this.couchAuthHeader!, 'Content-Type': 'application/json', }, }); if (!create.ok) { throw new Error( `Failed to create ${DB_NAME} database: HTTP ${create.status}` ); } } else if (!head.ok && head.status !== 200) { throw new Error( `Failed to check database ${DB_NAME}: HTTP ${head.status}` ); } this.initialized = true; } private async makeCouchRequest( method: string, path: string, body?: unknown ): Promise { if (!this.couchBaseUrl || !this.couchAuthHeader) { throw new Error('CouchDB not configured'); } const url = `${this.couchBaseUrl}${path}`; const headers: Record = { Authorization: this.couchAuthHeader, 'Content-Type': 'application/json', }; const init: RequestInit = { method, headers }; if (body !== undefined) init.body = JSON.stringify(body); const res = await fetch(url, init); if (!res.ok) { const text = await res.text(); throw new Error(`HTTP ${res.status}: ${text}`); } // Some Couch endpoints (DELETE) may return JSON or text; attempt JSON first const contentType = res.headers.get('content-type') || ''; if (contentType.includes('application/json')) { return (await res.json()) as T; } return (await res.text()) as unknown as T; } // -------- LocalStorage helpers -------- private getLocalArray(key: string): T[] { if (typeof localStorage === 'undefined') return []; try { return JSON.parse(localStorage.getItem(key) || '[]') as T[]; } catch { return []; } } private setLocalArray(key: string, items: T[]): void { if (typeof localStorage === 'undefined') return; localStorage.setItem(key, JSON.stringify(items)); } // -------- Public API: Verification Tokens -------- async saveVerificationToken(token: EmailVerificationToken): Promise { if (this.usingCouch) { await this.ensureDatabase(); const docId = `ver-${token.token}`; let existing: TokenDoc | null = null; try { existing = await this.makeCouchRequest( 'GET', `/${DB_NAME}/${docId}` ); } catch (_err) { existing = null; } const doc: TokenDoc = { _id: docId, _rev: existing?._rev || '', tokenType: 'verification', token: token.token, userId: token.userId, email: token.email, expiresAt: toISO(token.expiresAt), createdAt: existing?.createdAt || new Date().toISOString(), }; const res = await this.makeCouchRequest<{ id: string; rev: string }>( 'PUT', `/${DB_NAME}/${docId}`, doc ); doc._rev = res.rev; return; } // localStorage fallback const list = this.getLocalArray(LS_VERIFICATION_KEY); // Replace any existing token with same token value const filtered = list.filter(t => t.token !== token.token); filtered.push(token); this.setLocalArray(LS_VERIFICATION_KEY, filtered); } async findVerificationToken( token: string ): Promise { if (this.usingCouch) { await this.ensureDatabase(); const docId = `ver-${token}`; try { const doc = await this.makeCouchRequest( 'GET', `/${DB_NAME}/${docId}` ); return { userId: doc.userId, email: doc.email || '', token: doc.token, expiresAt: fromISO(doc.expiresAt), }; } catch (_err) { return null; } } const list = this.getLocalArray(LS_VERIFICATION_KEY); const found = list.find(t => t.token === token); return found ? { ...found, expiresAt: fromISO(found.expiresAt), } : null; } async deleteVerificationTokensForUser(userId: string): Promise { if (this.usingCouch) { await this.ensureDatabase(); // Use Mango query to find all verification tokens for user const result = await this.makeCouchRequest<{ docs: TokenDoc[]; warning?: string; }>('POST', `/${DB_NAME}/_find`, { selector: { userId, tokenType: 'verification' }, }); for (const doc of result.docs) { try { await this.makeCouchRequest( 'DELETE', `/${DB_NAME}/${doc._id}?rev=${doc._rev}` ); } catch (err) { logger.db.error( 'Failed to delete verification token doc', err as Error ); } } return; } const list = this.getLocalArray(LS_VERIFICATION_KEY); const filtered = list.filter(t => t.userId !== userId); this.setLocalArray(LS_VERIFICATION_KEY, filtered); } // -------- Public API: Password Reset Tokens -------- async savePasswordResetToken(token: PasswordResetToken): Promise { if (this.usingCouch) { await this.ensureDatabase(); const docId = `rst-${token.token}`; let existing: TokenDoc | null = null; try { existing = await this.makeCouchRequest( 'GET', `/${DB_NAME}/${docId}` ); } catch (_err) { existing = null; } const doc: TokenDoc = { _id: docId, _rev: existing?._rev || '', tokenType: 'reset', token: token.token, userId: token.userId, email: token.email, expiresAt: toISO(token.expiresAt), createdAt: existing?.createdAt || new Date().toISOString(), }; const res = await this.makeCouchRequest<{ id: string; rev: string }>( 'PUT', `/${DB_NAME}/${docId}`, doc ); doc._rev = res.rev; return; } const list = this.getLocalArray(LS_RESET_KEY); const filtered = list.filter(t => t.token !== token.token); filtered.push(token); this.setLocalArray(LS_RESET_KEY, filtered); } async findPasswordResetToken( token: string ): Promise { if (this.usingCouch) { await this.ensureDatabase(); const docId = `rst-${token}`; try { const doc = await this.makeCouchRequest( 'GET', `/${DB_NAME}/${docId}` ); return { userId: doc.userId, email: doc.email || '', token: doc.token, expiresAt: fromISO(doc.expiresAt), }; } catch (_err) { return null; } } const list = this.getLocalArray(LS_RESET_KEY); const found = list.find(t => t.token === token); return found ? { ...found, expiresAt: fromISO(found.expiresAt), } : null; } async deletePasswordResetToken(token: string): Promise { if (this.usingCouch) { await this.ensureDatabase(); const docId = `rst-${token}`; // Need current _rev to delete try { const doc = await this.makeCouchRequest( 'GET', `/${DB_NAME}/${docId}` ); await this.makeCouchRequest( 'DELETE', `/${DB_NAME}/${docId}?rev=${doc._rev}` ); } catch (err) { // Token may already be gone; log at debug level if available logger.db.warn?.( 'Password reset token not found to delete', err as Error ); } return; } const list = this.getLocalArray(LS_RESET_KEY); const filtered = list.filter(t => t.token !== token); this.setLocalArray(LS_RESET_KEY, filtered); } async deletePasswordResetTokensForUser(userId: string): Promise { if (this.usingCouch) { await this.ensureDatabase(); const result = await this.makeCouchRequest<{ docs: TokenDoc[]; }>('POST', `/${DB_NAME}/_find`, { selector: { userId, tokenType: 'reset' }, }); for (const doc of result.docs) { try { await this.makeCouchRequest( 'DELETE', `/${DB_NAME}/${doc._id}?rev=${doc._rev}` ); } catch (err) { logger.db.error( 'Failed to delete password reset token doc', err as Error ); } } return; } const list = this.getLocalArray(LS_RESET_KEY); const filtered = list.filter(t => t.userId !== userId); this.setLocalArray(LS_RESET_KEY, filtered); } // Optional maintenance utility: remove expired tokens async cleanupExpiredTokens(): Promise { let removed = 0; const now = new Date(); if (this.usingCouch) { await this.ensureDatabase(); // Fetch all tokens and filter by expiry (for small scale). For large scale, create an index. const result = await this.makeCouchRequest<{ rows: Array<{ id: string; value: unknown; doc: TokenDoc }>; }>('GET', `/${DB_NAME}/_all_docs?include_docs=true`); for (const row of result.rows) { const doc = row.doc; if (doc && new Date(doc.expiresAt) < now) { try { await this.makeCouchRequest( 'DELETE', `/${DB_NAME}/${doc._id}?rev=${doc._rev}` ); removed++; } catch (err) { logger.db.error('Failed to delete expired token', err as Error); } } } return removed; } // localStorage cleanup const ver = this.getLocalArray(LS_VERIFICATION_KEY); const verKeep = ver.filter(t => fromISO(t.expiresAt) >= now); removed += ver.length - verKeep.length; this.setLocalArray(LS_VERIFICATION_KEY, verKeep); const rst = this.getLocalArray(LS_RESET_KEY); const rstKeep = rst.filter(t => fromISO(t.expiresAt) >= now); removed += rst.length - rstKeep.length; this.setLocalArray(LS_RESET_KEY, rstKeep); return removed; } } export const tokenService = new TokenService(); export default tokenService;