import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import { ApiTokenDoc } from '../db/index.js'; import type { IApiTokenPolicy, IGatewayCredentialLifecycle, IStoredApiToken, IApiTokenInfo, TApiTokenScope, } from '../../ts_interfaces/data/route-management.js'; const TOKEN_PREFIX_STR = 'dcr_'; const ENV_ADMIN_TOKEN_ID = 'env-admin-token'; const ENV_ADMIN_TOKEN_CREATED_BY = 'dcrouter-env'; const LAST_USED_PERSIST_INTERVAL_MS = 60_000; export class ApiTokenManager { private tokens = new Map(); private lastUsedPersistedAt = new Map(); constructor() {} public async initialize(): Promise { await this.loadTokens(); await this.ensureEnvAdminToken(); 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, gatewayCredentialLifecycle: IGatewayCredentialLifecycle | null = null, ): 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 = this.hashToken(rawToken); const now = Date.now(); const stored: IStoredApiToken = { id, name, tokenHash, scopes, policy, gatewayCredentialLifecycle: this.normalizeGatewayCredentialLifecycle(gatewayCredentialLifecycle, id), 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 }; } public async createProvisionedGatewayCredentialCandidate( name: string, scopes: TApiTokenScope[], expiresInDays: number | null, createdBy: string, policy: IApiTokenPolicy, credentialSequence: number, policyDigest: string, ): Promise<{ id: string; rawToken: string }> { if (policy.role !== 'gatewayClient' || !policy.gatewayClient?.id || !Number.isSafeInteger(policy.gatewayClient.policyGeneration) || !Number.isSafeInteger(credentialSequence) || credentialSequence < 1 || !/^[a-f0-9]{64}$/.test(policyDigest)) { throw new Error('Provisioned gateway credentials require a bound gateway-client policy'); } return await this.createToken(name, scopes, expiresInDays, createdBy, policy, { source: 'provisioned', state: 'candidate', sequence: credentialSequence, policyGeneration: policy.gatewayClient.policyGeneration, policyDigest, finalizedAt: null, cleanupCompletedAt: null, revokedCredentialCount: null, }); } /** * 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 = this.hashToken(rawToken); 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; const now = Date.now(); stored.lastUsedAt = now; const persistedAt = this.lastUsedPersistedAt.get(stored.id) || 0; if (now - persistedAt >= LAST_USED_PERSIST_INTERVAL_MS) { this.lastUsedPersistedAt.set(stored.id, now); try { await this.persistToken(stored); } catch (error) { this.lastUsedPersistedAt.set(stored.id, persistedAt); throw error; } } 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 equivalentScopes: Partial> = { 'gateway-clients:read': ['workhosters:read'], 'gateway-clients:write': ['workhosters:write'], 'workhosters:read': ['gateway-clients:read'], 'workhosters:write': ['gateway-clients:write'], }; return Boolean(equivalentScopes[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)!; // Disable durably before deletion so a failed delete cannot resurrect the // credential after a restart. token.enabled = false; await this.persistToken(token); const doc = await ApiTokenDoc.findById(id); if (doc) await doc.delete(); this.tokens.delete(id); this.lastUsedPersistedAt.delete(id); logger.log('info', `API token '${token.name}' revoked (id: ${id})`); return true; } /** Revoke only ordinary gateway-client credentials bound to one live client. */ public async revokeGatewayClientCredentials( gatewayClientId: string, excludingTokenId?: string, ): Promise { const ids = Array.from(this.tokens.values()) .filter((token) => token.id !== excludingTokenId) .filter((token) => token.id !== ENV_ADMIN_TOKEN_ID) .filter((token) => token.policy?.role === 'gatewayClient') .filter((token) => token.policy?.gatewayClient?.id === gatewayClientId) .map((token) => token.id); let revoked = 0; for (const id of ids) { if (await this.revokeToken(id)) revoked++; } return revoked; } /** Revoke only older provisioned credentials during a finalized handover. */ public async revokeOlderProvisionedGatewayCredentials( gatewayClientId: string, finalizedSequence: number, excludingTokenId: string, ): Promise { if (!Number.isSafeInteger(finalizedSequence) || finalizedSequence < 1) { throw new Error('Gateway credential finalization sequence is invalid'); } const ids = Array.from(this.tokens.values()) .filter((token) => token.id !== excludingTokenId) .filter((token) => token.policy?.role === 'gatewayClient') .filter((token) => token.policy?.gatewayClient?.id === gatewayClientId) .filter((token) => token.gatewayCredentialLifecycle?.source === 'provisioned') .filter((token) => Number(token.gatewayCredentialLifecycle?.sequence) < finalizedSequence) .map((token) => token.id); let revoked = 0; for (const id of ids) { if (await this.revokeToken(id)) revoked++; } return revoked; } /** Durably activate a provisioned candidate while preserving retry identity. */ public async activateProvisionedGatewayCredential( tokenId: string, gatewayClientId: string, ): Promise<{ finalizedAt: number; wasAlreadyActive: boolean; lifecycle: IGatewayCredentialLifecycle; }> { const token = this.tokens.get(tokenId); if (!token || token.policy?.role !== 'gatewayClient' || token.policy.gatewayClient?.id !== gatewayClientId) { throw new Error('Provisioned gateway credential is missing or has the wrong owner'); } const lifecycle = this.normalizeGatewayCredentialLifecycle( token.gatewayCredentialLifecycle, token.id, ); if (!lifecycle) { throw new Error('Manual gateway credentials cannot be finalized'); } if (lifecycle.state === 'active') { return { finalizedAt: lifecycle.finalizedAt!, wasAlreadyActive: true, lifecycle, }; } const previousLifecycle = lifecycle; const finalizedAt = Date.now(); token.gatewayCredentialLifecycle = { ...lifecycle, state: 'active', finalizedAt, cleanupCompletedAt: null, revokedCredentialCount: null, }; try { await this.persistToken(token); } catch (error) { token.gatewayCredentialLifecycle = previousLifecycle; throw error; } return { finalizedAt, wasAlreadyActive: false, lifecycle: token.gatewayCredentialLifecycle, }; } /** Mark sibling cleanup complete so later retries never touch newer tokens. */ public async completeProvisionedGatewayCredentialHandover( tokenId: string, gatewayClientId: string, revokedCredentialCount: number, ): Promise { const token = this.tokens.get(tokenId); if (!token || token.policy?.role !== 'gatewayClient' || token.policy.gatewayClient?.id !== gatewayClientId) { throw new Error('Provisioned gateway credential is missing or has the wrong owner'); } const lifecycle = this.normalizeGatewayCredentialLifecycle( token.gatewayCredentialLifecycle, token.id, ); if (!lifecycle || lifecycle.state !== 'active') { throw new Error('Provisioned gateway credential is not active'); } if (lifecycle.cleanupCompletedAt !== null) return lifecycle; if (!Number.isSafeInteger(revokedCredentialCount) || revokedCredentialCount < 0) { throw new Error('Revoked credential count is invalid'); } const previousLifecycle = lifecycle; token.gatewayCredentialLifecycle = { ...lifecycle, cleanupCompletedAt: Date.now(), revokedCredentialCount, }; try { await this.persistToken(token); } catch (error) { token.gatewayCredentialLifecycle = previousLifecycle; throw error; } return token.gatewayCredentialLifecycle; } /** * 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 = this.hashToken(rawToken); 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, gatewayCredentialLifecycle: this.normalizeGatewayCredentialLifecycle(doc.gatewayCredentialLifecycle, doc.id), createdAt: doc.createdAt, expiresAt: doc.expiresAt, lastUsedAt: doc.lastUsedAt, createdBy: doc.createdBy, enabled: doc.enabled, }); this.lastUsedPersistedAt.set(doc.id, doc.lastUsedAt || 0); } } } private async ensureEnvAdminToken(): Promise { const rawToken = process.env.DCROUTER_ADMIN_API_TOKEN?.trim(); if (!rawToken) return; if (!rawToken.startsWith(TOKEN_PREFIX_STR)) { throw new Error(`DCROUTER_ADMIN_API_TOKEN must start with ${TOKEN_PREFIX_STR}`); } if (rawToken.length < TOKEN_PREFIX_STR.length + 32) { throw new Error('DCROUTER_ADMIN_API_TOKEN is too short'); } const now = Date.now(); const existing = this.tokens.get(ENV_ADMIN_TOKEN_ID); const stored: IStoredApiToken = { id: ENV_ADMIN_TOKEN_ID, name: process.env.DCROUTER_ADMIN_API_TOKEN_NAME?.trim() || 'Environment Admin Token', tokenHash: this.hashToken(rawToken), scopes: ['*'], policy: { role: 'admin' }, gatewayCredentialLifecycle: null, createdAt: existing?.createdAt || now, expiresAt: null, lastUsedAt: existing?.lastUsedAt || null, createdBy: existing?.createdBy || ENV_ADMIN_TOKEN_CREATED_BY, enabled: true, }; this.tokens.set(stored.id, stored); await this.persistToken(stored); logger.log('info', `Environment admin API token ensured (id: ${stored.id})`); } private normalizeGatewayCredentialLifecycle( valueArg: unknown, tokenIdArg: string, ): IGatewayCredentialLifecycle | null { if (valueArg == null) return null; if (typeof valueArg !== 'object' || Array.isArray(valueArg)) { throw new Error(`API token '${tokenIdArg}' has malformed gateway credential lifecycle`); } const value = valueArg as Partial; if (value.source !== 'provisioned') { throw new Error(`API token '${tokenIdArg}' has malformed gateway credential lifecycle`); } if (!Number.isSafeInteger(value.sequence) || Number(value.sequence) < 1 || !Number.isSafeInteger(value.policyGeneration) || Number(value.policyGeneration) < 1 || typeof value.policyDigest !== 'string' || !/^[a-f0-9]{64}$/.test(value.policyDigest)) { throw new Error(`API token '${tokenIdArg}' has malformed gateway credential lifecycle`); } const base = { source: 'provisioned' as const, sequence: Number(value.sequence), policyGeneration: Number(value.policyGeneration), policyDigest: value.policyDigest, }; if (value.state === 'candidate' && value.finalizedAt === null && value.cleanupCompletedAt === null && value.revokedCredentialCount === null) { return { ...base, state: 'candidate', finalizedAt: null, cleanupCompletedAt: null, revokedCredentialCount: null, }; } if (value.state === 'active' && Number.isSafeInteger(value.finalizedAt) && Number(value.finalizedAt) > 0) { const cleanupPending = value.cleanupCompletedAt === null && value.revokedCredentialCount === null; const cleanupComplete = Number.isSafeInteger(value.cleanupCompletedAt) && Number(value.cleanupCompletedAt) > 0 && Number.isSafeInteger(value.revokedCredentialCount) && Number(value.revokedCredentialCount) >= 0; if (cleanupPending || cleanupComplete) { return { ...base, state: 'active', finalizedAt: Number(value.finalizedAt), cleanupCompletedAt: cleanupComplete ? Number(value.cleanupCompletedAt) : null, revokedCredentialCount: cleanupComplete ? Number(value.revokedCredentialCount) : null, }; } } throw new Error(`API token '${tokenIdArg}' has malformed gateway credential lifecycle`); } private hashToken(rawToken: string): string { return plugins.crypto.createHash('sha256').update(rawToken).digest('hex'); } 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.gatewayCredentialLifecycle = stored.gatewayCredentialLifecycle ?? null; 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.gatewayCredentialLifecycle = stored.gatewayCredentialLifecycle ?? null; doc.createdAt = stored.createdAt; doc.expiresAt = stored.expiresAt; doc.lastUsedAt = stored.lastUsedAt; doc.createdBy = stored.createdBy; doc.enabled = stored.enabled; await doc.save(); } } }