diff --git a/changelog.md b/changelog.md index c1a33dc..e41d6fa 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-05-09 - 13.27.0 - feat(api-token-manager) +seed and rotate the environment-managed admin API token during initialization + +- Add initialization support for DCROUTER_ADMIN_API_TOKEN with validation, persistence, and admin policy assignment +- Ensure the environment-managed token is updated when the configured raw token changes +- Refactor token hashing into a shared helper and add coverage for seeding, validation, redaction, and rotation behavior + ## 2026-05-09 - 13.26.0 - feat(gateway-clients) add policy-based gateway client tokens and gateway client route and DNS management endpoints diff --git a/test/test.api-token-manager.node.ts b/test/test.api-token-manager.node.ts new file mode 100644 index 0000000..77b688c --- /dev/null +++ b/test/test.api-token-manager.node.ts @@ -0,0 +1,75 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import { ApiTokenManager } from '../ts/config/classes.api-token-manager.js'; +import { DcRouterDb } from '../ts/db/index.js'; +import * as plugins from '../ts/plugins.js'; + +const createTestDb = async () => { + const storagePath = plugins.path.join( + plugins.os.tmpdir(), + `dcrouter-api-token-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + + DcRouterDb.resetInstance(); + const db = DcRouterDb.getInstance({ + storagePath, + dbName: `dcrouter-api-token-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`, + }); + await db.start(); + await db.getDb().mongoDb.createCollection('__test_init'); + + return { + async cleanup() { + await db.stop(); + DcRouterDb.resetInstance(); + await plugins.fs.promises.rm(storagePath, { recursive: true, force: true }); + }, + }; +}; + +tap.test('ApiTokenManager seeds and rotates an env admin API token', async () => { + const previousToken = process.env.DCROUTER_ADMIN_API_TOKEN; + const previousName = process.env.DCROUTER_ADMIN_API_TOKEN_NAME; + const testDb = await createTestDb(); + + try { + const rawToken1 = `dcr_${plugins.crypto.randomBytes(32).toString('base64url')}`; + const rawToken2 = `dcr_${plugins.crypto.randomBytes(32).toString('base64url')}`; + process.env.DCROUTER_ADMIN_API_TOKEN = rawToken1; + process.env.DCROUTER_ADMIN_API_TOKEN_NAME = 'Onebox Managed Admin'; + + const manager = new ApiTokenManager(); + await manager.initialize(); + + const token1 = await manager.validateToken(rawToken1); + expect(token1?.id).toEqual('env-admin-token'); + expect(token1?.name).toEqual('Onebox Managed Admin'); + expect(token1?.policy?.role).toEqual('admin'); + expect(manager.hasScope(token1!, 'tokens:manage')).toEqual(true); + + const listedToken = manager.listTokens().find((token) => token.id === 'env-admin-token') as any; + expect(listedToken.tokenHash).toBeUndefined(); + + process.env.DCROUTER_ADMIN_API_TOKEN = rawToken2; + const rotatedManager = new ApiTokenManager(); + await rotatedManager.initialize(); + + expect(await rotatedManager.validateToken(rawToken1)).toBeNull(); + const token2 = await rotatedManager.validateToken(rawToken2); + expect(token2?.id).toEqual('env-admin-token'); + expect(token2?.policy?.role).toEqual('admin'); + } finally { + if (previousToken === undefined) { + delete process.env.DCROUTER_ADMIN_API_TOKEN; + } else { + process.env.DCROUTER_ADMIN_API_TOKEN = previousToken; + } + if (previousName === undefined) { + delete process.env.DCROUTER_ADMIN_API_TOKEN_NAME; + } else { + process.env.DCROUTER_ADMIN_API_TOKEN_NAME = previousName; + } + await testDb.cleanup(); + } +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index f5f958d..493a27e 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.26.0', + version: '13.27.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/config/classes.api-token-manager.ts b/ts/config/classes.api-token-manager.ts index 3f9077c..1852cbd 100644 --- a/ts/config/classes.api-token-manager.ts +++ b/ts/config/classes.api-token-manager.ts @@ -9,6 +9,8 @@ import type { } 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'; export class ApiTokenManager { private tokens = new Map(); @@ -17,6 +19,7 @@ export class ApiTokenManager { 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`); } @@ -41,7 +44,7 @@ export class ApiTokenManager { const rawPayload = `${id}:${randomBytes.toString('base64url')}`; const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`; - const tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex'); + const tokenHash = this.hashToken(rawToken); const now = Date.now(); const stored: IStoredApiToken = { @@ -70,7 +73,7 @@ export class ApiTokenManager { public async validateToken(rawToken: string): Promise { if (!rawToken.startsWith(TOKEN_PREFIX_STR)) return null; - const hash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex'); + const hash = this.hashToken(rawToken); for (const stored of this.tokens.values()) { if (stored.tokenHash === hash) { @@ -162,7 +165,7 @@ export class ApiTokenManager { const rawPayload = `${id}:${randomBytes.toString('base64url')}`; const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`; - stored.tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex'); + stored.tokenHash = this.hashToken(rawToken); await this.persistToken(stored); logger.log('info', `API token '${stored.name}' rolled (id: ${id})`); return { id, rawToken }; @@ -204,6 +207,41 @@ export class ApiTokenManager { } } + 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' }, + 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 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) { diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index f5f958d..493a27e 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.26.0', + version: '13.27.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' }