From dee6897931e85dcff851befc242b56a4707d3e0f Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 27 Feb 2026 10:24:20 +0000 Subject: [PATCH] feat(api-tokens): add ability to roll (regenerate) API token secrets and UI to display the newly generated token once --- changelog.md | 10 ++++ ts/00_commitinfo_data.ts | 2 +- ts/config/classes.api-token-manager.ts | 18 +++++++ ts/opsserver/handlers/api-token.handler.ts | 19 +++++++ ts_interfaces/requests/api-tokens.ts | 20 +++++++ ts_web/00_commitinfo_data.ts | 2 +- ts_web/appstate.ts | 12 +++++ ts_web/elements/ops-view-apitokens.ts | 63 ++++++++++++++++++++++ 8 files changed, 144 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index 63b48d3..f3b809b 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2026-02-27 - 10.1.0 - feat(api-tokens) +add ability to roll (regenerate) API token secrets and UI to display the newly generated token once + +- Server: added ApiTokenManager.rollToken(id) to regenerate a token secret, update its hash, persist it and log the action. +- Server: added opsserver handler 'rollApiToken' which requires admin identity and returns the new raw token value (shown once) or error messages. +- API: added typed request interface IReq_RollApiToken for the rollApiToken RPC. +- Web: added appstate.rollApiToken wrapper to call the new typed request. +- UI: ops-view-apitokens updated with a 'Roll' action and a modal flow to confirm rolling, call the API, refresh token list, and present the new token value to copy (token value is shown only once). +- Security: operation is admin-only and the raw token is returned only once after rolling. + ## 2026-02-27 - 10.0.0 - BREAKING CHANGE(remote-ingress) replace tlsConfigured boolean with tlsMode ('custom' | 'acme' | 'self-signed') and compute TLS mode server-side diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index ebce8d5..bb6fec0 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: '10.0.0', + version: '10.1.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 d778b12..0d0e2c3 100644 --- a/ts/config/classes.api-token-manager.ts +++ b/ts/config/classes.api-token-manager.ts @@ -122,6 +122,24 @@ export class ApiTokenManager { 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. */ diff --git a/ts/opsserver/handlers/api-token.handler.ts b/ts/opsserver/handlers/api-token.handler.ts index c4e0668..2f3537a 100644 --- a/ts/opsserver/handlers/api-token.handler.ts +++ b/ts/opsserver/handlers/api-token.handler.ts @@ -77,6 +77,25 @@ export class ApiTokenHandler { ), ); + // Roll API token + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'rollApiToken', + async (dataArg) => { + await this.requireAdmin(dataArg.identity); + const manager = this.opsServerRef.dcRouterRef.apiTokenManager; + if (!manager) { + return { success: false, message: 'Token management not initialized' }; + } + const result = await manager.rollToken(dataArg.id); + if (!result) { + return { success: false, message: 'Token not found' }; + } + return { success: true, tokenValue: result.rawToken }; + }, + ), + ); + // Toggle API token this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( diff --git a/ts_interfaces/requests/api-tokens.ts b/ts_interfaces/requests/api-tokens.ts index 3674ae4..f4e94f3 100644 --- a/ts_interfaces/requests/api-tokens.ts +++ b/ts_interfaces/requests/api-tokens.ts @@ -63,6 +63,26 @@ export interface IReq_RevokeApiToken extends plugins.typedrequestInterfaces.impl }; } +/** + * Roll (regenerate) an API token's secret. Returns the new raw token value once. + * Admin JWT only. + */ +export interface IReq_RollApiToken extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_RollApiToken +> { + method: 'rollApiToken'; + request: { + identity?: authInterfaces.IIdentity; + id: string; + }; + response: { + success: boolean; + tokenValue?: string; + message?: string; + }; +} + /** * Enable or disable an API token. */ diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index ebce8d5..bb6fec0 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: '10.0.0', + version: '10.1.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index a013d94..eddf256 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -1115,6 +1115,18 @@ export async function createApiToken(name: string, scopes: interfaces.data.TApiT }); } +export async function rollApiToken(id: string) { + const context = getActionContext(); + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_RollApiToken + >('/typedrequest', 'rollApiToken'); + + return request.fire({ + identity: context.identity, + id, + }); +} + export const revokeApiTokenAction = routeManagementStatePart.createAction( async (statePartArg, tokenId) => { const context = getActionContext(); diff --git a/ts_web/elements/ops-view-apitokens.ts b/ts_web/elements/ops-view-apitokens.ts index a6d10a7..abed64c 100644 --- a/ts_web/elements/ops-view-apitokens.ts +++ b/ts_web/elements/ops-view-apitokens.ts @@ -152,6 +152,15 @@ export class OpsViewApiTokens extends DeesElement { ); }, }, + { + name: 'Roll', + iconName: 'lucide:refresh-cw', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const token = actionData.item as interfaces.data.IApiTokenInfo; + await this.showRollTokenDialog(token); + }, + }, { name: 'Revoke', iconName: 'lucide:trash2', @@ -279,6 +288,60 @@ export class OpsViewApiTokens extends DeesElement { }); } + private async showRollTokenDialog(token: interfaces.data.IApiTokenInfo) { + const { DeesModal } = await import('@design.estate/dees-catalog'); + + await DeesModal.createAndShow({ + heading: 'Roll Token Secret', + content: html` +
+

This will regenerate the secret for ${token.name}. The old token value will stop working immediately.

+
+ `, + menuOptions: [ + { + name: 'Cancel', + iconName: 'lucide:x', + action: async (modalArg: any) => await modalArg.destroy(), + }, + { + name: 'Roll Token', + iconName: 'lucide:refresh-cw', + action: async (modalArg: any) => { + await modalArg.destroy(); + try { + const response = await appstate.rollApiToken(token.id); + if (response.success && response.tokenValue) { + await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null); + + await DeesModal.createAndShow({ + heading: 'Token Rolled', + content: html` +
+

Copy this token now. It will not be shown again.

+
+ ${response.tokenValue} +
+
+ `, + menuOptions: [ + { + name: 'Done', + iconName: 'lucide:check', + action: async (m: any) => await m.destroy(), + }, + ], + }); + } + } catch (error) { + console.error('Failed to roll token:', error); + } + }, + }, + ], + }); + } + async firstUpdated() { await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null); }