2026-02-23 12:40:26 +00:00
|
|
|
import * as plugins from '../plugins.js';
|
|
|
|
|
import { logger } from '../logger.js';
|
2026-03-31 15:31:16 +00:00
|
|
|
import { ApiTokenDoc } from '../db/index.js';
|
2026-02-23 12:40:26 +00:00
|
|
|
import type {
|
|
|
|
|
IStoredApiToken,
|
|
|
|
|
IApiTokenInfo,
|
|
|
|
|
TApiTokenScope,
|
|
|
|
|
} from '../../ts_interfaces/data/route-management.js';
|
|
|
|
|
|
|
|
|
|
const TOKEN_PREFIX_STR = 'dcr_';
|
|
|
|
|
|
|
|
|
|
export class ApiTokenManager {
|
|
|
|
|
private tokens = new Map<string, IStoredApiToken>();
|
|
|
|
|
|
2026-03-31 15:31:16 +00:00
|
|
|
constructor() {}
|
2026-02-23 12:40:26 +00:00
|
|
|
|
|
|
|
|
public async initialize(): Promise<void> {
|
|
|
|
|
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,
|
|
|
|
|
): 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,
|
|
|
|
|
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<IStoredApiToken | null> {
|
|
|
|
|
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 {
|
|
|
|
|
return token.scopes.includes(scope);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
createdAt: stored.createdAt,
|
|
|
|
|
expiresAt: stored.expiresAt,
|
|
|
|
|
lastUsedAt: stored.lastUsedAt,
|
|
|
|
|
enabled: stored.enabled,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Revoke (delete) a token.
|
|
|
|
|
*/
|
|
|
|
|
public async revokeToken(id: string): Promise<boolean> {
|
|
|
|
|
if (!this.tokens.has(id)) return false;
|
|
|
|
|
const token = this.tokens.get(id)!;
|
|
|
|
|
this.tokens.delete(id);
|
2026-03-31 15:31:16 +00:00
|
|
|
const doc = await ApiTokenDoc.findById(id);
|
|
|
|
|
if (doc) await doc.delete();
|
2026-02-23 12:40:26 +00:00
|
|
|
logger.log('info', `API token '${token.name}' revoked (id: ${id})`);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 10:24:20 +00:00
|
|
|
/**
|
|
|
|
|
* 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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:40:26 +00:00
|
|
|
/**
|
|
|
|
|
* Enable or disable a token.
|
|
|
|
|
*/
|
|
|
|
|
public async toggleToken(id: string, enabled: boolean): Promise<boolean> {
|
|
|
|
|
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<void> {
|
2026-03-31 15:31:16 +00:00
|
|
|
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,
|
|
|
|
|
createdAt: doc.createdAt,
|
|
|
|
|
expiresAt: doc.expiresAt,
|
|
|
|
|
lastUsedAt: doc.lastUsedAt,
|
|
|
|
|
createdBy: doc.createdBy,
|
|
|
|
|
enabled: doc.enabled,
|
|
|
|
|
});
|
2026-02-23 12:40:26 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async persistToken(stored: IStoredApiToken): Promise<void> {
|
2026-03-31 15:31:16 +00:00
|
|
|
const existing = await ApiTokenDoc.findById(stored.id);
|
|
|
|
|
if (existing) {
|
|
|
|
|
existing.name = stored.name;
|
|
|
|
|
existing.tokenHash = stored.tokenHash;
|
|
|
|
|
existing.scopes = stored.scopes;
|
|
|
|
|
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.createdAt = stored.createdAt;
|
|
|
|
|
doc.expiresAt = stored.expiresAt;
|
|
|
|
|
doc.lastUsedAt = stored.lastUsedAt;
|
|
|
|
|
doc.createdBy = stored.createdBy;
|
|
|
|
|
doc.enabled = stored.enabled;
|
|
|
|
|
await doc.save();
|
|
|
|
|
}
|
2026-02-23 12:40:26 +00:00
|
|
|
}
|
|
|
|
|
}
|