import { ConflictException, Injectable, NotFoundException, UnauthorizedException, OnModuleInit } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, MoreThan, Not, Repository } from 'typeorm'; import { randomBytes } from 'crypto'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { writeSecretFile } from '../../common/utils/secret-file'; import { ipMatches } from '../../common/utils/ip'; import { hashApiKey } from './api-key-hash'; import { ApiKey, ApiKeyRole } from './entities/api-key.entity'; import { CreateApiKeyDto, UpdateApiKeyDto } from './dto'; import { createLogger } from '../../common/services/logger.service'; import { EventsGateway, type ApiKeyEvictionReason } from '../events/events.gateway'; import { getDataDirectory } from '../../config/data-directory'; const API_KEY_FILE = join(getDataDirectory(), '.api-key'); /** * Resolves the API key to seed on first boot (when no keys exist yet). * Precedence: an explicit `API_MASTER_KEY` always wins; otherwise a * cryptographically random `owa_k1_` key is generated — the secure default, * including in non-production. The legacy fixed `dev-admin-key` is used only when * a developer explicitly opts in with `ALLOW_DEV_API_KEY=true`, never by default. */ export function resolveSeedApiKey(): string { if (process.env.API_MASTER_KEY) { return process.env.API_MASTER_KEY; } if (process.env.ALLOW_DEV_API_KEY === 'true') { return 'dev-admin-key'; } return `owa_k1_${randomBytes(32).toString('hex')}`; } /** * The line to print for the API key in the startup banner. The full raw key is shown ONLY when it was * just created (first run, when the operator needs to capture it once). On every subsequent boot the * key is masked to a short non-secret fingerprint, so the live admin key is not re-written to the log * pipeline (Docker/Loki/CloudWatch) on each restart — it stays in `data/.api-key` (0600) and the * dashboard. A placeholder (e.g. "(check dashboard for keys)") is passed through unchanged. */ export function bannerKeyLine(displayKey: string, isNewKey: boolean): string { if (isNewKey) return displayKey; if (displayKey.startsWith('(')) return displayKey; return `${displayKey.slice(0, 8)}… (full key in data/.api-key or the dashboard)`; } @Injectable() export class AuthService implements OnModuleInit { private readonly logger = createLogger('AuthService'); /** Coalesce per-request usage-stat writes to at most one DB write per key per window. */ private static readonly STAT_FLUSH_INTERVAL_MS = 60_000; /** keyId -> usage increments observed but not yet persisted (flushed on the next windowed write). */ private readonly pendingUsage = new Map(); constructor( @InjectRepository(ApiKey, 'main') private readonly apiKeyRepository: Repository, private readonly moduleRef: ModuleRef, ) {} async onModuleInit(): Promise { // Seed a default API key if none exist const count = await this.apiKeyRepository.count(); let displayKey: string; let isNewKey = false; if (count === 0) { displayKey = resolveSeedApiKey(); await this.seedApiKey(displayKey, 'Default Admin Key', ApiKeyRole.ADMIN); isNewKey = true; // Save raw key to file for startup script to read (owner-only — it's the raw admin key). try { writeSecretFile(API_KEY_FILE, displayKey); } catch (err) { this.logger.warn('Could not save API key file', { error: String(err) }); } } else { // Read saved API key from file if exists if (existsSync(API_KEY_FILE)) { try { displayKey = readFileSync(API_KEY_FILE, 'utf-8').trim(); } catch (error) { this.logger.warn(`Failed to read API key file: ${API_KEY_FILE}`, { error: String(error) }); displayKey = '(check dashboard for keys)'; } } else { displayKey = '(check dashboard for keys)'; } } // Always show the welcome banner on startup const apiBaseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 2785}`; // The dashboard is served by NestJS at the same origin as the API now, so default to it. const dashboardUrl = process.env.DASHBOARD_URL || apiBaseUrl; this.logger.log(''); this.logger.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); this.logger.log(''); this.logger.log(' 🟢 Welcome to OpenWA - WhatsApp API Gateway'); this.logger.log(''); this.logger.log(` 📊 Dashboard: ${dashboardUrl}`); this.logger.log(` 📚 API Docs: ${apiBaseUrl}/api/docs`); this.logger.log(''); if (isNewKey) { this.logger.log(' 🔑 API Key (newly created):'); } else { this.logger.log(' 🔑 API Key:'); } this.logger.log(` ${bannerKeyLine(displayKey, isNewKey)}`); this.logger.log(''); this.logger.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); this.logger.log(''); } private async seedApiKey(rawKey: string, name: string, role: ApiKeyRole): Promise { const keyHash = this.hashKey(rawKey); const keyPrefix = rawKey.substring(0, 12); const apiKey = this.apiKeyRepository.create({ name, keyHash, keyPrefix, role, }); return this.apiKeyRepository.save(apiKey); } async createApiKey(dto: CreateApiKeyDto): Promise<{ apiKey: ApiKey; rawKey: string }> { // Generate secure random key: owa_k1_<32 bytes hex> const rawKey = `owa_k1_${randomBytes(32).toString('hex')}`; const keyHash = this.hashKey(rawKey); const keyPrefix = rawKey.substring(0, 12); const apiKey = this.apiKeyRepository.create({ name: dto.name, keyHash, keyPrefix, role: dto.role || ApiKeyRole.OPERATOR, allowedIps: dto.allowedIps || null, allowedSessions: dto.allowedSessions || null, expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, }); const saved = await this.apiKeyRepository.save(apiKey); this.logger.log(`API key created: ${saved.name}`, { keyId: saved.id, role: saved.role, action: 'api_key_created', }); return { apiKey: saved, rawKey }; } async findAll(): Promise { return this.apiKeyRepository.find({ order: { createdAt: 'DESC' }, }); } async findOne(id: string): Promise { const apiKey = await this.apiKeyRepository.findOne({ where: { id } }); if (!apiKey) { throw new NotFoundException(`API key with id '${id}' not found`); } return apiKey; } async update(id: string, dto: UpdateApiKeyDto): Promise { const apiKey = await this.findOne(id); const removesOrSchedulesLastAdmin = (dto.role !== undefined && dto.role !== ApiKeyRole.ADMIN) || (dto.expiresAt !== undefined && dto.expiresAt !== null); if (removesOrSchedulesLastAdmin) { await this.assertNotLastUsableAdmin(apiKey); } // Capture the authorization-relevant fields BEFORE applying the change. Only a change to role, // allowedIps, allowedSessions, or expiry can widen or restrict what an already-connected WebSocket // socket may see, so only those trigger eviction of live /events sockets — a benign rename must // NOT disconnect clients. REST enforces the new state immediately; without eviction a live socket // keeps streaming events for sessions/IPs the key just lost until it resubscribes or drops. const before = { role: apiKey.role, allowedIps: apiKey.allowedIps, allowedSessions: apiKey.allowedSessions, expiresAt: apiKey.expiresAt, }; if (dto.name) apiKey.name = dto.name; if (dto.role) apiKey.role = dto.role; if (dto.allowedIps !== undefined) apiKey.allowedIps = dto.allowedIps; if (dto.allowedSessions !== undefined) apiKey.allowedSessions = dto.allowedSessions; if (dto.expiresAt !== undefined) apiKey.expiresAt = dto.expiresAt ? new Date(dto.expiresAt) : null; const saved = await this.apiKeyRepository.save(apiKey); // Compare membership, not order: a pure reorder of allowedIps/allowedSessions is a no-op for the // .includes()-based enforcement, so sort before stringify to avoid a spurious eviction on a reorder. const ordered = (v: string[] | null) => (v ? [...v].sort() : v); const authzChanged = saved.role !== before.role || saved.expiresAt?.getTime() !== before.expiresAt?.getTime() || JSON.stringify(ordered(saved.allowedIps)) !== JSON.stringify(ordered(before.allowedIps)) || JSON.stringify(ordered(saved.allowedSessions)) !== JSON.stringify(ordered(before.allowedSessions)); if (authzChanged) { this.evictActiveSockets(id, 'authorization_changed'); } return saved; } async delete(id: string): Promise { const apiKey = await this.findOne(id); await this.assertNotLastUsableAdmin(apiKey); // Drop any un-flushed usage accumulator so a deleted key leaves nothing behind in the Map. this.pendingUsage.delete(id); await this.apiKeyRepository.remove(apiKey); this.evictActiveSockets(id, 'deleted'); this.logger.log(`API key deleted: ${apiKey.name}`, { keyId: id, action: 'api_key_deleted', }); } async revoke(id: string): Promise { const apiKey = await this.findOne(id); await this.assertNotLastUsableAdmin(apiKey); // A revoked key fails validation before its next flush, so its accumulator would orphan — // drop it here. this.pendingUsage.delete(id); apiKey.isActive = false; const saved = await this.apiKeyRepository.save(apiKey); // Kick any WebSocket connections already authenticated with this key: without this, a revoked // key keeps receiving events on already-subscribed sockets until they happen to disconnect. this.evictActiveSockets(id, 'revoked'); return saved; } private async assertNotLastUsableAdmin(target: ApiKey): Promise { const now = new Date(); const targetIsUsableAdmin = target.role === ApiKeyRole.ADMIN && target.isActive && (!target.expiresAt || target.expiresAt > now); if (!targetIsUsableAdmin) return; const otherUsableAdmins = await this.apiKeyRepository.count({ where: [ { id: Not(target.id), role: ApiKeyRole.ADMIN, isActive: true, expiresAt: IsNull() }, { id: Not(target.id), role: ApiKeyRole.ADMIN, isActive: true, expiresAt: MoreThan(now) }, ], }); if (otherUsableAdmins === 0) { throw new ConflictException('Cannot remove the last active admin key'); } } /** * Disconnect every WebSocket socket authenticated with the given key id. Resolved lazily via * ModuleRef (not constructor injection) to avoid a static DI cycle between AuthModule and * EventsModule. Best-effort: if the WS gateway isn't loaded (or has no sockets for the key), * this is a silent no-op. */ private evictActiveSockets(keyId: string, reason: ApiKeyEvictionReason = 'revoked'): void { try { const gateway = this.moduleRef.get(EventsGateway, { strict: false }); if (gateway) { gateway.evictApiKey(keyId, reason); } } catch (error) { // Eviction is best-effort: the key's DB state is already authoritative (validateApiKey // rejects it), so a failure here must never roll back the revoke/delete. this.logger.warn(`Failed to evict WebSocket sockets for key ${keyId}`, { error: error instanceof Error ? error.message : String(error), }); } } async validateApiKey(rawKey: string, clientIp?: string, sessionId?: string): Promise { const keyHash = this.hashKey(rawKey); const apiKey = await this.apiKeyRepository.findOne({ where: { keyHash } }); if (!apiKey) { throw new UnauthorizedException('Invalid API key'); } if (!apiKey.isActive) { throw new UnauthorizedException('API key is revoked'); } if (apiKey.expiresAt && apiKey.expiresAt < new Date()) { throw new UnauthorizedException('API key has expired'); } // Check IP whitelist (fail closed: if a whitelist is configured but the client // IP could not be determined, reject rather than silently skipping the check) if (apiKey.allowedIps && apiKey.allowedIps.length > 0) { if (!clientIp) { throw new UnauthorizedException('Client IP could not be determined'); } if (!this.isIpAllowed(clientIp, apiKey.allowedIps)) { this.logger.warn(`IP not allowed: ${clientIp}`, { keyId: apiKey.id, action: 'ip_rejected', }); throw new UnauthorizedException('IP address not allowed'); } } // Check session restriction if (apiKey.allowedSessions && apiKey.allowedSessions.length > 0 && sessionId) { if (!apiKey.allowedSessions.includes(sessionId)) { throw new UnauthorizedException('API key not authorized for this session'); } } // Update usage stats — coalesced. Validation above is unchanged/synchronous; only // the stat WRITE is throttled to at most once per key per window. usageCount stays // accurate via an in-memory accumulator; the returned object reflects the true count. const pending = (this.pendingUsage.get(apiKey.id) ?? 0) + 1; const previousLastUsedAt = apiKey.lastUsedAt; apiKey.lastUsedAt = new Date(); apiKey.usageCount += pending; // DB value + all not-yet-persisted increments (incl. this request) const due = !previousLastUsedAt || apiKey.lastUsedAt.getTime() - previousLastUsedAt.getTime() >= AuthService.STAT_FLUSH_INTERVAL_MS; if (due) { this.pendingUsage.delete(apiKey.id); await this.apiKeyRepository.save(apiKey); } else { this.pendingUsage.set(apiKey.id, pending); } return apiKey; } private hashKey(rawKey: string): string { return hashApiKey(rawKey, process.env.API_KEY_PEPPER); } private isIpAllowed(clientIp: string, allowedIps: string[]): boolean { // Delegate to the shared, hardened matcher (also used by the throttler and the API-key guard's IP // resolution): it handles both an exact IP entry and CIDR notation, and — unlike the previous local // parser — rejects a malformed octet instead of coercing it into range. return allowedIps.some(entry => ipMatches(clientIp, entry)); } hasPermission(apiKey: ApiKey, requiredRole: ApiKeyRole): boolean { const roleHierarchy: Record = { [ApiKeyRole.VIEWER]: 1, [ApiKeyRole.OPERATOR]: 2, [ApiKeyRole.ADMIN]: 3, }; return roleHierarchy[apiKey.role] >= roleHierarchy[requiredRole]; } }