db(couchdb): auto-provision databases on startup for production strategy; add TokenService with CouchDB-backed token storage and localStorage fallback; switch OAuth to unified config for client IDs and redirect URI; express Request typing for req.user; align exportAsEnvVars with show-config expectations; remove Vite importmap from index.html; prefer babel-jest over ts-jest; remove duplicate uuid mocking from Jest config
This commit is contained in:
447
services/auth/token.service.ts
Normal file
447
services/auth/token.service.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
/* 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;
|
||||
Reference in New Issue
Block a user