Files
rxminder/services/auth/token.service.ts

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;