import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import { ApiTokenDoc } from '../db/index.js'; import type { IApiTokenPolicy, IStoredApiToken, IApiTokenInfo, TApiTokenScope, } from '../../ts_interfaces/data/route-management.js'; const TOKEN_PREFIX_STR = 'dcr_'; export class ApiTokenManager { private tokens = new Map(); constructor() {} public async initialize(): Promise { await this.loadTokens(); if (this.tokens.size > 0) { logger.log('info', `Loaded ${this.tokens.size} API token(s) from storage`); } } // ========================================================================= // Token lifecycle // ========================================================================= /** * Create a new API token. Returns the raw token value (shown once). */ public async createToken( name: string, scopes: TApiTokenScope[], expiresInDays: number | null, createdBy: string, policy?: IApiTokenPolicy, ): Promise<{ id: string; rawToken: string }> { const id = plugins.uuid.v4(); const randomBytes = plugins.crypto.randomBytes(32); const rawPayload = `${id}:${randomBytes.toString('base64url')}`; const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`; const tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex'); const now = Date.now(); const stored: IStoredApiToken = { id, name, tokenHash, scopes, policy, createdAt: now, expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null, lastUsedAt: null, createdBy, enabled: true, }; this.tokens.set(id, stored); await this.persistToken(stored); logger.log('info', `API token '${name}' created (id: ${id})`); return { id, rawToken }; } /** * Validate a raw token string. Returns the stored token if valid, null otherwise. * Also updates lastUsedAt. */ public async validateToken(rawToken: string): Promise { if (!rawToken.startsWith(TOKEN_PREFIX_STR)) return null; const hash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex'); for (const stored of this.tokens.values()) { if (stored.tokenHash === hash) { if (!stored.enabled) return null; if (stored.expiresAt !== null && stored.expiresAt < Date.now()) return null; // Update lastUsedAt (fire and forget) stored.lastUsedAt = Date.now(); this.persistToken(stored).catch(() => {}); return stored; } } return null; } /** * Check if a token has a specific scope. */ public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean { if (token.policy?.role === 'admin') return true; const isGatewayClientToken = token.policy?.role === 'gatewayClient'; const gatewayClientAllowedScopes = new Set([ 'gateway-clients:read', 'gateway-clients:write', 'workhosters:read', 'workhosters:write', ]); if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) { return false; } if (!isGatewayClientToken && token.scopes.includes('*')) return true; const scopes = new Set([...token.scopes, ...(token.policy?.scopes || [])]); if (scopes.has(scope)) return true; const compatibilityAliases: Partial> = { 'gateway-clients:read': ['workhosters:read'], 'gateway-clients:write': ['workhosters:write'], 'workhosters:read': ['gateway-clients:read'], 'workhosters:write': ['gateway-clients:write'], }; return Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias))); } /** * List all tokens (safe info only, no hashes). */ public listTokens(): IApiTokenInfo[] { const result: IApiTokenInfo[] = []; for (const stored of this.tokens.values()) { result.push({ id: stored.id, name: stored.name, scopes: stored.scopes, policy: stored.policy, createdAt: stored.createdAt, expiresAt: stored.expiresAt, lastUsedAt: stored.lastUsedAt, enabled: stored.enabled, }); } return result; } /** * Revoke (delete) a token. */ public async revokeToken(id: string): Promise { if (!this.tokens.has(id)) return false; const token = this.tokens.get(id)!; this.tokens.delete(id); const doc = await ApiTokenDoc.findById(id); if (doc) await doc.delete(); logger.log('info', `API token '${token.name}' revoked (id: ${id})`); return true; } /** * Roll (regenerate) a token's secret while keeping its identity. * Returns the new raw token value (shown once). */ public async rollToken(id: string): Promise<{ id: string; rawToken: string } | null> { const stored = this.tokens.get(id); if (!stored) return null; const randomBytes = plugins.crypto.randomBytes(32); const rawPayload = `${id}:${randomBytes.toString('base64url')}`; const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`; stored.tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex'); await this.persistToken(stored); logger.log('info', `API token '${stored.name}' rolled (id: ${id})`); return { id, rawToken }; } /** * Enable or disable a token. */ public async toggleToken(id: string, enabled: boolean): Promise { const stored = this.tokens.get(id); if (!stored) return false; stored.enabled = enabled; await this.persistToken(stored); logger.log('info', `API token '${stored.name}' ${enabled ? 'enabled' : 'disabled'} (id: ${id})`); return true; } // ========================================================================= // Private // ========================================================================= private async loadTokens(): Promise { const docs = await ApiTokenDoc.findAll(); for (const doc of docs) { if (doc.id) { this.tokens.set(doc.id, { id: doc.id, name: doc.name, tokenHash: doc.tokenHash, scopes: doc.scopes, policy: doc.policy, createdAt: doc.createdAt, expiresAt: doc.expiresAt, lastUsedAt: doc.lastUsedAt, createdBy: doc.createdBy, enabled: doc.enabled, }); } } } private async persistToken(stored: IStoredApiToken): Promise { const existing = await ApiTokenDoc.findById(stored.id); if (existing) { existing.name = stored.name; existing.tokenHash = stored.tokenHash; existing.scopes = stored.scopes; existing.policy = stored.policy; existing.createdAt = stored.createdAt; existing.expiresAt = stored.expiresAt; existing.lastUsedAt = stored.lastUsedAt; existing.createdBy = stored.createdBy; existing.enabled = stored.enabled; await existing.save(); } else { const doc = new ApiTokenDoc(); doc.id = stored.id; doc.name = stored.name; doc.tokenHash = stored.tokenHash; doc.scopes = stored.scopes; doc.policy = stored.policy; doc.createdAt = stored.createdAt; doc.expiresAt = stored.expiresAt; doc.lastUsedAt = stored.lastUsedAt; doc.createdBy = stored.createdBy; doc.enabled = stored.enabled; await doc.save(); } } }