This commit is contained in:
jarek committed 2026-04-18 08:34:20 +02:00
1 parent c210ef0a8e
commit afee09866d
55 files changed
+7767 -131

No files matched your search

+272
View File
@@ -0,0 +1,272 @@
/**
* API Token Management
*
* Provides Bearer token authentication for CI/CD pipelines and scripts.
* Tokens use `dh_` prefix, Argon2id hashing, and prefix-based lookup.
*
* Performance: An in-memory cache (SHA-256 key, 60s TTL) avoids running
* Argon2id on every request. First request: ~100ms. Subsequent: ~0ms.
*/
import { createHash } from 'node:crypto';
import { db, eq, and } from '$lib/server/db/drizzle';
import { hashPassword, verifyPassword, type AuthenticatedUser } from './auth';
import { secureRandomBytes } from './crypto-fallback';
import { getUserRoles, userHasAdminRole, type Permissions } from './db';
import { isEnterprise } from './license';
import { tokenCache, ensureCleanupInterval, invalidateTokenCacheForUser, clearTokenCache } from './token-cache';
// Re-export cache functions so existing consumers don't need to change imports
export { invalidateTokenCacheForUser, clearTokenCache } from './token-cache';
// Dynamic schema import (same pattern as db.ts)
let apiTokensTable: any;
async function getApiTokensTable() {
if (apiTokensTable) return apiTokensTable;
const isPostgres = !!(process.env.DATABASE_URL && (
process.env.DATABASE_URL.startsWith('postgres://') ||
process.env.DATABASE_URL.startsWith('postgresql://')
));
const schema = isPostgres
? await import('./db/schema/pg-schema.js')
: await import('./db/schema/index.js');
apiTokensTable = schema.apiTokens;
return apiTokensTable;
}
// Token format: dh_ + 32 bytes base64url = dh_ + 43 chars
const TOKEN_PREFIX = 'dh_';
const TOKEN_BYTES = 32;
const PREFIX_LENGTH = 8; // chars after dh_ stored for identification
const MAX_TOKEN_LENGTH = 200;
const CACHE_TTL = 60_000; // 60 seconds
function cacheKey(rawToken: string): string {
return createHash('sha256').update(rawToken).digest('hex');
}
// Pre-computed dummy hash for timing protection on invalid prefixes
let dummyHash: string | null = null;
async function getDummyHash(): Promise<string> {
if (!dummyHash) {
dummyHash = await hashPassword('dh_dummy_token_for_timing_protection');
}
return dummyHash;
}
// Initialize dummy hash on import (fire and forget)
void getDummyHash();
/**
* Generate a new API token.
* Returns the plaintext token (shown once) and the database record.
*/
export async function generateApiToken(
userId: number,
name: string,
expiresAt?: string | null
): Promise<{ token: string; id: number; tokenPrefix: string }> {
const table = await getApiTokensTable();
// Generate random token
const randomBytes = secureRandomBytes(TOKEN_BYTES);
const rawToken = TOKEN_PREFIX + randomBytes.toString('base64url');
const tokenPrefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + PREFIX_LENGTH);
// Hash for storage
const tokenHash = await hashPassword(rawToken);
const result = await db.insert(table).values({
userId,
name,
tokenHash,
tokenPrefix,
expiresAt: expiresAt || null
}).returning();
return {
token: rawToken,
id: result[0].id,
tokenPrefix
};
}
/**
* Validate a Bearer token and return the associated user.
* Uses cache to avoid Argon2id on every request.
*/
export async function validateApiToken(rawToken: string): Promise<AuthenticatedUser | null> {
// Input validation
if (!rawToken || rawToken.length > MAX_TOKEN_LENGTH || !rawToken.startsWith(TOKEN_PREFIX)) {
return null;
}
// Check cache first
ensureCleanupInterval();
const key = cacheKey(rawToken);
const cached = tokenCache.get(key);
if (cached && cached.expiresAt > Date.now()) {
return cached.user;
}
const table = await getApiTokensTable();
// Extract prefix for lookup
const prefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + PREFIX_LENGTH);
// Find tokens with matching prefix (deleted tokens are gone, no isActive filter needed)
const candidates = await db
.select()
.from(table)
.where(eq(table.tokenPrefix, prefix));
if (candidates.length === 0) {
// Timing protection: run Argon2id anyway
await verifyPassword(rawToken, await getDummyHash());
return null;
}
// Verify against each candidate (usually just one)
for (const candidate of candidates) {
const valid = await verifyPassword(rawToken, candidate.tokenHash);
if (!valid) continue;
// Check expiration AFTER hash verification to avoid timing oracle
if (candidate.expiresAt && new Date(candidate.expiresAt) < new Date()) {
continue;
}
// Build AuthenticatedUser from the token's user
const user = await buildUserFromToken(candidate);
if (!user) continue;
// Update lastUsed (fire and forget — non-critical audit field)
void db.update(table)
.set({ lastUsed: new Date().toISOString() })
.where(eq(table.id, candidate.id))
.catch((err) => {
if (typeof process !== 'undefined' && process.env.DB_VERBOSE_LOGGING === 'true') {
console.debug('[api-tokens] lastUsed update failed:', err?.message);
}
});
// Cache the result — cap TTL at token expiry time if sooner
let cacheTtl = CACHE_TTL;
if (candidate.expiresAt) {
const timeUntilExpiry = new Date(candidate.expiresAt).getTime() - Date.now();
if (timeUntilExpiry < cacheTtl) {
cacheTtl = Math.max(0, timeUntilExpiry);
}
}
tokenCache.set(key, { user, expiresAt: Date.now() + cacheTtl });
return user;
}
return null;
}
/**
* Build an AuthenticatedUser from a token's database record.
*/
async function buildUserFromToken(tokenRecord: any): Promise<AuthenticatedUser | null> {
// Import getUserWithoutPassword dynamically to avoid circular deps
// This avoids keeping passwordHash in memory unnecessarily
const { getUserWithoutPassword } = await import('./db');
const dbUser = await getUserWithoutPassword(tokenRecord.userId);
if (!dbUser || !dbUser.isActive) return null;
const enterprise = await isEnterprise();
let isAdmin = false;
let permissions: Permissions;
if (!enterprise) {
// Free edition: everyone is effectively admin
isAdmin = true;
const { getRoleByName } = await import('./db');
const adminRole = await getRoleByName('Admin');
permissions = adminRole?.permissions ?? {} as Permissions;
} else {
isAdmin = await userHasAdminRole(dbUser.id);
const roles = await getUserRoles(dbUser.id);
// Merge permissions from all roles
permissions = {} as Permissions;
for (const role of roles) {
const rolePerms = typeof role.permissions === 'string'
? JSON.parse(role.permissions)
: role.permissions;
for (const [key, actions] of Object.entries(rolePerms)) {
if (!permissions[key as keyof Permissions]) {
permissions[key as keyof Permissions] = [];
}
for (const action of actions as string[]) {
if (!permissions[key as keyof Permissions].includes(action)) {
permissions[key as keyof Permissions].push(action);
}
}
}
}
}
// Determine provider from authProvider field
let provider: 'local' | 'ldap' | 'oidc' = 'local';
if (dbUser.authProvider?.startsWith('ldap')) provider = 'ldap';
else if (dbUser.authProvider?.startsWith('oidc')) provider = 'oidc';
return {
id: dbUser.id,
username: dbUser.username,
email: dbUser.email ?? undefined,
displayName: dbUser.displayName ?? undefined,
avatar: dbUser.avatar ?? undefined,
isAdmin,
provider,
permissions
};
}
/**
* List all tokens for a user (no hashes returned).
*/
export async function listUserTokens(userId: number) {
const table = await getApiTokensTable();
return db
.select({
id: table.id,
name: table.name,
tokenPrefix: table.tokenPrefix,
lastUsed: table.lastUsed,
expiresAt: table.expiresAt,
createdAt: table.createdAt
})
.from(table)
.where(eq(table.userId, userId));
}
/**
* Revoke (delete) a token. Owner or admin can revoke.
*/
export async function revokeApiToken(tokenId: number, requestingUserId: number, isAdmin: boolean): Promise<boolean> {
const table = await getApiTokensTable();
// Find the token
const [token] = await db.select().from(table).where(eq(table.id, tokenId));
if (!token) return false;
// Check ownership or admin
if (token.userId !== requestingUserId && !isAdmin) {
return false;
}
// Hard-delete
await db.delete(table).where(eq(table.id, tokenId));
// Clear cache — we can't map prefix to SHA-256 cache keys, so clear all
clearTokenCache();
return true;
}
+5 -3
View File
@@ -9,6 +9,7 @@ import type { RequestEvent } from '@sveltejs/kit';
import { isEnterprise } from './license';
import { logAuditEvent, type AuditAction, type AuditEntityType, type AuditLogCreateData } from './db';
import { authorize } from './authorize';
import { getRequestContext } from './request-context';
export interface AuditContext {
userId?: number | null;
@@ -21,7 +22,8 @@ export interface AuditContext {
* Extract audit context from a request event
*/
export async function getAuditContext(event: RequestEvent): Promise<AuditContext> {
const auth = await authorize(event.cookies);
const ctx = getRequestContext();
const user = ctx?.user ?? (await authorize(event.cookies)).user;
// Get IP address from various headers (proxied requests)
const forwardedFor = event.request.headers.get('x-forwarded-for');
@@ -40,8 +42,8 @@ export async function getAuditContext(event: RequestEvent): Promise<AuditContext
const userAgent = event.request.headers.get('user-agent') || null;
return {
userId: auth.user?.id ?? null,
username: auth.user?.username ?? 'anonymous',
userId: user?.id ?? null,
username: user?.username ?? 'anonymous',
ipAddress,
userAgent
};
+7
View File
@@ -44,6 +44,7 @@ import {
import { Client as LdapClient } from 'ldapts';
import { isEnterprise } from './license';
import { secureRandomBytes } from './crypto-fallback';
import { invalidateTokenCacheForUser } from './token-cache';
// Session cookie name
const SESSION_COOKIE_NAME = 'dockhand_session';
@@ -735,6 +736,9 @@ async function tryLdapAuth(
}
}
// Clear cached token permissions after role sync
invalidateTokenCacheForUser(user.id);
if (!user.isActive) {
return { success: false, error: 'Account is disabled' };
}
@@ -1449,6 +1453,9 @@ export async function handleOidcCallback(
}
}
// Clear cached token permissions after role sync
invalidateTokenCacheForUser(user.id);
if (!user.isActive) {
return { success: false, error: 'Account is disabled' };
}
+5 -1
View File
@@ -40,6 +40,7 @@ import type { Permissions } from './db';
import { getUserAccessibleEnvironments, userCanAccessEnvironment, userHasAdminRole } from './db';
import { validateSession, isAuthEnabled, checkPermission, type AuthenticatedUser } from './auth';
import { isEnterprise } from './license';
import { getRequestContext } from './request-context';
export interface AuthorizationContext {
/** Whether authentication is enabled globally */
@@ -113,7 +114,10 @@ export interface AuthorizationContext {
export async function authorize(cookies: Cookies): Promise<AuthorizationContext> {
const authEnabled = await isAuthEnabled();
const enterprise = await isEnterprise();
const user = authEnabled ? await validateSession(cookies) : null;
// Try request context first (set by hook — handles both cookie and Bearer)
const reqCtx = getRequestContext();
const user = reqCtx?.user ?? (authEnabled ? await validateSession(cookies) : null);
// Determine admin status:
// - Free edition: all authenticated users are effectively admins (full access)
+44 -1
View File
@@ -1185,6 +1185,37 @@ export async function getUser(id: number): Promise<UserData | null> {
return results[0] as UserData || null;
}
export interface SafeUserData {
id: number;
username: string;
email: string | null;
displayName: string | null;
avatar: string | null;
authProvider: string | null;
mfaEnabled: boolean;
isActive: boolean;
lastLogin: string | null;
createdAt: string;
updatedAt: string;
}
export async function getUserWithoutPassword(id: number): Promise<SafeUserData | null> {
const results = await db.select({
id: users.id,
username: users.username,
email: users.email,
displayName: users.displayName,
avatar: users.avatar,
authProvider: users.authProvider,
mfaEnabled: users.mfaEnabled,
isActive: users.isActive,
lastLogin: users.lastLogin,
createdAt: users.createdAt,
updatedAt: users.updatedAt
}).from(users).where(eq(users.id, id));
return results[0] as SafeUserData || null;
}
export async function hasAdminUser(): Promise<boolean> {
// Check if any user has the Admin role assigned
const adminRole = await db.select().from(roles).where(eq(roles.name, 'Admin')).limit(1);
@@ -3046,7 +3077,7 @@ export type AuditAction =
export type AuditEntityType =
| 'container' | 'image' | 'stack' | 'volume' | 'network'
| 'user' | 'role' | 'settings' | 'environment' | 'registry' | 'git_repository' | 'git_credential'
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack';
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack' | 'api_token';
export interface AuditLogData {
id: number;
@@ -4586,6 +4617,18 @@ export async function setStackEnvVars(
}
}
/**
* Get the set of secret key names for a stack.
* Used to mask secret values in container inspect responses.
*/
export async function getSecretKeyNames(
stackName: string,
environmentId?: number | null
): Promise<Set<string>> {
const vars = await getStackEnvVars(stackName, environmentId, true);
return new Set(vars.filter(v => v.isSecret).map(v => v.key));
}
/**
* Get count of environment variables for a stack.
* @param stackName - Name of the stack
+8 -4
View File
@@ -335,7 +335,8 @@ const REQUIRED_TABLES = [
'audit_logs',
'container_events',
'schedule_executions',
'user_preferences'
'user_preferences',
'api_tokens'
];
/**
@@ -768,7 +769,7 @@ async function seedDatabase(): Promise<void> {
license: ['manage'],
audit_logs: ['view'],
activity: ['view'],
schedules: ['view']
schedules: ['view', 'edit', 'run']
});
const operatorPermissions = JSON.stringify({
@@ -787,7 +788,7 @@ async function seedDatabase(): Promise<void> {
license: [],
audit_logs: [],
activity: ['view'],
schedules: ['view']
schedules: ['view', 'edit', 'run']
});
const viewerPermissions = JSON.stringify({
@@ -898,6 +899,7 @@ export const userPreferences = schemaProxy.userPreferences;
export const scheduleExecutions = schemaProxy.scheduleExecutions;
export const stackEnvironmentVariables = schemaProxy.stackEnvironmentVariables;
export const pendingContainerUpdates = schemaProxy.pendingContainerUpdates;
export const apiTokens = schemaProxy.apiTokens;
// Re-export types from SQLite schema (they're compatible with PostgreSQL)
export type {
@@ -956,7 +958,9 @@ export type {
StackEnvironmentVariable,
NewStackEnvironmentVariable,
PendingContainerUpdate,
NewPendingContainerUpdate
NewPendingContainerUpdate,
ApiToken,
NewApiToken
} from './schema/index.js';
export { eq, and, or, desc, asc, like, sql, inArray, isNull, isNotNull } from 'drizzle-orm';
+22
View File
@@ -467,6 +467,25 @@ export const pendingContainerUpdates = sqliteTable('pending_container_updates',
envContainerUnique: unique().on(table.environmentId, table.containerId)
}));
// =============================================================================
// API TOKENS TABLE
// =============================================================================
export const apiTokens = sqliteTable('api_tokens', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
tokenHash: text('token_hash').notNull().unique(),
tokenPrefix: text('token_prefix').notNull(),
lastUsed: text('last_used'),
expiresAt: text('expires_at'),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
}, (table) => ({
userIdIdx: index('api_tokens_user_id_idx').on(table.userId),
tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix)
}));
// =============================================================================
// USER PREFERENCES TABLE (unified key-value store)
// =============================================================================
@@ -570,3 +589,6 @@ export type NewStackEnvironmentVariable = typeof stackEnvironmentVariables.$infe
export type PendingContainerUpdate = typeof pendingContainerUpdates.$inferSelect;
export type NewPendingContainerUpdate = typeof pendingContainerUpdates.$inferInsert;
export type ApiToken = typeof apiTokens.$inferSelect;
export type NewApiToken = typeof apiTokens.$inferInsert;
+19
View File
@@ -470,6 +470,25 @@ export const pendingContainerUpdates = pgTable('pending_container_updates', {
envContainerUnique: unique().on(table.environmentId, table.containerId)
}));
// =============================================================================
// API TOKENS TABLE
// =============================================================================
export const apiTokens = pgTable('api_tokens', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
tokenHash: text('token_hash').notNull().unique(),
tokenPrefix: text('token_prefix').notNull(),
lastUsed: timestamp('last_used', { mode: 'string' }),
expiresAt: timestamp('expires_at', { mode: 'string' }),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
}, (table) => ({
userIdIdx: index('api_tokens_user_id_idx').on(table.userId),
tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix)
}));
// =============================================================================
// USER PREFERENCES TABLE (unified key-value store)
// =============================================================================
+22 -10
View File
@@ -415,7 +415,8 @@ export function httpsAgentRequest(
if (!streaming) {
const isComposeOperation = path === '/_hawser/compose';
const composeTimeoutMs = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000;
reqOptions.timeout = isComposeOperation ? composeTimeoutMs : 30000;
const isPrune = path.endsWith('/prune');
reqOptions.timeout = isComposeOperation ? composeTimeoutMs : isPrune ? 300000 : 30000;
}
// Honor AbortSignal from caller (e.g., AbortSignal.timeout(5000) for ping)
@@ -884,7 +885,7 @@ export async function dockerFetch(
body,
headers,
streaming || false,
(streaming || path === '/_hawser/compose') ? 300000 : 30000, // 5 min for streaming/compose, 30s for normal
(streaming || path === '/_hawser/compose' || path.endsWith('/prune')) ? 300000 : 30000, // 5 min for streaming/compose/prune, 30s for normal
isBinary,
fetchOptions.signal ?? undefined
);
@@ -960,7 +961,8 @@ export async function dockerFetch(
if (!streaming && !finalOptions.signal) {
const isComposeOperation = path === '/_hawser/compose';
const composeTimeoutMs = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000;
finalOptions.signal = AbortSignal.timeout(isComposeOperation ? composeTimeoutMs : 30000);
const isPrune = path.endsWith('/prune');
finalOptions.signal = AbortSignal.timeout(isComposeOperation ? composeTimeoutMs : isPrune ? 300000 : 30000);
}
try {
@@ -1225,9 +1227,9 @@ export interface DeviceRequest {
export interface CreateContainerOptions {
name: string;
image: string;
ports?: { [key: string]: { HostIp?: string; HostPort: string } };
ports?: { [key: string]: { HostIp?: string; HostPort: string } } | null;
volumes?: { [key: string]: {} };
volumeBinds?: string[];
volumeBinds?: string[] | null;
env?: string[];
labels?: { [key: string]: string };
cmd?: string[];
@@ -1247,7 +1249,7 @@ export interface CreateContainerOptions {
networkGwPriority?: number;
user?: string | null;
privileged?: boolean;
healthcheck?: HealthcheckConfig;
healthcheck?: HealthcheckConfig | null;
memory?: number;
memoryReservation?: number;
memorySwap?: number;
@@ -1338,7 +1340,10 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
containerConfig.User = options.user ?? '';
}
if (options.healthcheck) {
if (options.healthcheck === null) {
// Explicitly disable healthcheck (user cleared it)
containerConfig.Healthcheck = { Test: ["NONE"] };
} else if (options.healthcheck) {
containerConfig.Healthcheck = {};
if (options.healthcheck.test && options.healthcheck.test.length > 0) {
containerConfig.Healthcheck.Test = options.healthcheck.test;
@@ -1357,7 +1362,11 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
}
}
if (options.ports) {
if (options.ports === null) {
// Explicitly clear ports (user removed all mappings)
containerConfig.ExposedPorts = {};
containerConfig.HostConfig.PortBindings = {};
} else if (options.ports) {
containerConfig.ExposedPorts = {};
containerConfig.HostConfig.PortBindings = {};
@@ -1367,7 +1376,10 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
}
}
if (options.volumeBinds && options.volumeBinds.length > 0) {
if (options.volumeBinds === null) {
// Explicitly clear volume binds (user removed all)
containerConfig.HostConfig.Binds = [];
} else if (options.volumeBinds && options.volumeBinds.length > 0) {
containerConfig.HostConfig.Binds = options.volumeBinds;
}
@@ -2393,7 +2405,7 @@ export async function updateContainer(id: string, options: Partial<CreateContain
}
// 5. Start if needed
if (startAfterUpdate || wasRunning) {
if (startAfterUpdate) {
try {
await newContainer.start();
} catch (startError) {
+23
View File
@@ -0,0 +1,23 @@
/**
* Request-scoped context using AsyncLocalStorage.
*
* The hook sets the authenticated user (from cookie or Bearer token)
* and wraps resolve() in requestContext.run(). Downstream code like
* authorize() reads the pre-resolved user from here instead of
* re-validating the session.
*/
import { AsyncLocalStorage } from 'node:async_hooks';
import type { AuthenticatedUser } from './auth';
export interface RequestContext {
user: AuthenticatedUser | null;
authEnabled: boolean;
authMethod: 'cookie' | 'bearer' | 'none';
}
export const requestContext = new AsyncLocalStorage<RequestContext>();
export function getRequestContext(): RequestContext | undefined {
return requestContext.getStore();
}
+4 -3
View File
@@ -752,9 +752,10 @@ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]', api
}
try {
// Extract registry host from URL
const url = new URL(reg.url);
const registryHost = url.host;
// Extract registry host from URL (parseRegistryUrl handles bare hostnames like 'ghcr.io')
const { parseRegistryUrl } = await import('./docker.js');
const { host } = parseRegistryUrl(reg.url);
const registryHost = host;
console.log(`${logPrefix} Logging into registry: ${registryHost}`);
+109
View File
@@ -0,0 +1,109 @@
/**
* API Token Cache — LRU with TTL
*
* In-memory cache for validated API tokens. Without this, every Bearer
* request would run Argon2id verification (~64MB RAM, ~100ms CPU per call).
*
* Cache key is SHA-256(fullToken) — safe to hold in memory (not reversible).
* TTL is 60s by default, capped at the token's expiry time if sooner.
*
* Uses a bounded LRU (max 1024 entries) to cap memory usage. On overflow
* the least-recently-used entry is evicted. Expired entries are also pruned
* every 5 minutes.
*
* Separated from api-tokens.ts to avoid circular dependencies
* (auth.ts ↔ api-tokens.ts).
*/
export interface TokenCacheEntry {
user: { id: number; [key: string]: any };
expiresAt: number;
}
const MAX_CACHE_SIZE = 1024;
/**
* Simple LRU cache backed by a Map.
* Map iteration order is insertion order, so we delete-and-re-set on every
* access to move the entry to the "newest" position. The oldest entry
* (Map.keys().next()) is evicted when the cache exceeds MAX_CACHE_SIZE.
*/
class LruTokenCache {
private map = new Map<string, TokenCacheEntry>();
get(key: string): TokenCacheEntry | undefined {
const entry = this.map.get(key);
if (!entry) return undefined;
// Move to end (most-recently-used)
this.map.delete(key);
this.map.set(key, entry);
return entry;
}
set(key: string, entry: TokenCacheEntry): void {
// If key already exists, delete first so re-insert moves it to end
if (this.map.has(key)) {
this.map.delete(key);
}
this.map.set(key, entry);
// Evict oldest if over capacity
if (this.map.size > MAX_CACHE_SIZE) {
const oldest = this.map.keys().next().value!;
this.map.delete(oldest);
}
}
delete(key: string): void {
this.map.delete(key);
}
clear(): void {
this.map.clear();
}
entries(): IterableIterator<[string, TokenCacheEntry]> {
return this.map.entries();
}
get size(): number {
return this.map.size;
}
}
export const tokenCache = new LruTokenCache();
/**
* Invalidate all cached tokens for a specific user.
* Call when user permissions change, roles are updated, or user is deleted/deactivated.
*/
export function invalidateTokenCacheForUser(userId: number): void {
for (const [key, entry] of tokenCache.entries()) {
if (entry.user.id === userId) {
tokenCache.delete(key);
}
}
}
/**
* Clear the entire token cache.
* Called on revocation, role permission edits, and license state changes.
*/
export function clearTokenCache(): void {
tokenCache.clear();
}
// Periodic cleanup every 5 minutes
let cleanupInterval: ReturnType<typeof setInterval> | undefined;
export function ensureCleanupInterval(): void {
if (cleanupInterval) return;
cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [key, entry] of tokenCache.entries()) {
if (entry.expiresAt <= now) {
tokenCache.delete(key);
}
}
}, 5 * 60 * 1000);
if (cleanupInterval.unref) cleanupInterval.unref();
}