448 lines
13 KiB
TypeScript
448 lines
13 KiB
TypeScript
/* 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<void> {
|
|
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<T>(
|
|
method: string,
|
|
path: string,
|
|
body?: unknown
|
|
): Promise<T> {
|
|
if (!this.couchBaseUrl || !this.couchAuthHeader) {
|
|
throw new Error('CouchDB not configured');
|
|
}
|
|
|
|
const url = `${this.couchBaseUrl}${path}`;
|
|
const headers: Record<string, string> = {
|
|
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<T>(key: string): T[] {
|
|
if (typeof localStorage === 'undefined') return [];
|
|
try {
|
|
return JSON.parse(localStorage.getItem(key) || '[]') as T[];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private setLocalArray<T>(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<void> {
|
|
if (this.usingCouch) {
|
|
await this.ensureDatabase();
|
|
const docId = `ver-${token.token}`;
|
|
let existing: TokenDoc | null = null;
|
|
try {
|
|
existing = await this.makeCouchRequest<TokenDoc>(
|
|
'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<EmailVerificationToken>(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<EmailVerificationToken | null> {
|
|
if (this.usingCouch) {
|
|
await this.ensureDatabase();
|
|
const docId = `ver-${token}`;
|
|
try {
|
|
const doc = await this.makeCouchRequest<TokenDoc>(
|
|
'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<EmailVerificationToken>(LS_VERIFICATION_KEY);
|
|
const found = list.find(t => t.token === token);
|
|
return found
|
|
? {
|
|
...found,
|
|
expiresAt: fromISO(found.expiresAt),
|
|
}
|
|
: null;
|
|
}
|
|
|
|
async deleteVerificationTokensForUser(userId: string): Promise<void> {
|
|
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<EmailVerificationToken>(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<void> {
|
|
if (this.usingCouch) {
|
|
await this.ensureDatabase();
|
|
const docId = `rst-${token.token}`;
|
|
let existing: TokenDoc | null = null;
|
|
try {
|
|
existing = await this.makeCouchRequest<TokenDoc>(
|
|
'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<PasswordResetToken>(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<PasswordResetToken | null> {
|
|
if (this.usingCouch) {
|
|
await this.ensureDatabase();
|
|
const docId = `rst-${token}`;
|
|
try {
|
|
const doc = await this.makeCouchRequest<TokenDoc>(
|
|
'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<PasswordResetToken>(LS_RESET_KEY);
|
|
const found = list.find(t => t.token === token);
|
|
return found
|
|
? {
|
|
...found,
|
|
expiresAt: fromISO(found.expiresAt),
|
|
}
|
|
: null;
|
|
}
|
|
|
|
async deletePasswordResetToken(token: string): Promise<void> {
|
|
if (this.usingCouch) {
|
|
await this.ensureDatabase();
|
|
const docId = `rst-${token}`;
|
|
// Need current _rev to delete
|
|
try {
|
|
const doc = await this.makeCouchRequest<TokenDoc>(
|
|
'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<PasswordResetToken>(LS_RESET_KEY);
|
|
const filtered = list.filter(t => t.token !== token);
|
|
this.setLocalArray(LS_RESET_KEY, filtered);
|
|
}
|
|
|
|
async deletePasswordResetTokensForUser(userId: string): Promise<void> {
|
|
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<PasswordResetToken>(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<number> {
|
|
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<EmailVerificationToken>(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<PasswordResetToken>(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;
|