import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { createHmac, timingSafeEqual, randomUUID } from 'crypto'; import { SessionService } from '../auth/session.service'; import type { WidgetSiteKey } from '../widget/widget.types'; const KEY_PREFIX = 'widget:keys:'; @Injectable() export class WidgetKeysService { private readonly logger = new Logger(WidgetKeysService.name); private readonly secret: string; constructor( private config: ConfigService, private session: SessionService, ) { this.secret = process.env.WIDGET_SECRET ?? config.get('WIDGET_SECRET') ?? 'helix-widget-default-secret'; } generateKey(hospitalName: string, allowedOrigins: string[]): { key: string; siteKey: WidgetSiteKey } { const siteId = randomUUID().replace(/-/g, '').substring(0, 16); const signature = this.sign(siteId); const key = `${siteId}.${signature}`; const siteKey: WidgetSiteKey = { siteId, hospitalName, allowedOrigins, active: true, createdAt: new Date().toISOString(), }; return { key, siteKey }; } async saveKey(siteKey: WidgetSiteKey): Promise { await this.session.setCachePersistent(`${KEY_PREFIX}${siteKey.siteId}`, JSON.stringify(siteKey)); this.logger.log(`Widget key saved: ${siteKey.siteId} (${siteKey.hospitalName})`); } async validateKey(rawKey: string): Promise { const dotIndex = rawKey.indexOf('.'); if (dotIndex === -1) return null; const siteId = rawKey.substring(0, dotIndex); const signature = rawKey.substring(dotIndex + 1); const expected = this.sign(siteId); try { if (!timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) return null; } catch { return null; } const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`); if (!data) return null; const siteKey: WidgetSiteKey = JSON.parse(data); if (!siteKey.active) return null; return siteKey; } validateOrigin(siteKey: WidgetSiteKey, origin: string | undefined): boolean { if (!origin) return true; // Allow no-origin for dev/testing if (siteKey.allowedOrigins.length === 0) return true; return siteKey.allowedOrigins.some(allowed => origin.startsWith(allowed)); } async listKeys(): Promise { const keys = await this.session.scanKeys(`${KEY_PREFIX}*`); const results: WidgetSiteKey[] = []; for (const key of keys) { const data = await this.session.getCache(key); if (data) results.push(JSON.parse(data)); } return results; } async revokeKey(siteId: string): Promise { const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`); if (!data) return false; const siteKey: WidgetSiteKey = JSON.parse(data); siteKey.active = false; await this.session.setCachePersistent(`${KEY_PREFIX}${siteId}`, JSON.stringify(siteKey)); this.logger.log(`Widget key revoked: ${siteId}`); return true; } private sign(data: string): string { return createHmac('sha256', this.secret).update(data).digest('hex'); } }