Files
dockhand-upstream/src/lib/server/api-tokens.ts
T
2026-07-11 15:31:52 +02:00

290 lines
8.8 KiB
TypeScript

/**
* 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');
}
let dummyHash: string | null = null;
async function getDummyHash(): Promise<string> {
if (!dummyHash) {
dummyHash = await hashPassword('dh_init_seed');
}
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 userRoleAssignments = await getUserRoles(dbUser.id);
// Merge permissions from all roles
permissions = {} as Permissions;
for (const assignment of userRoleAssignments) {
if (!assignment.role) continue;
const rolePerms = typeof assignment.role.permissions === 'string'
? JSON.parse(assignment.role.permissions)
: assignment.role.permissions;
if (!rolePerms) continue;
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));
}
/**
* Fleet-wide API token stats for the metrics endpoint (no secrets returned):
* total configured, how many are expired, and how many have never been used.
*/
export async function getApiTokenStats(): Promise<{ total: number; expired: number; neverUsed: number }> {
const table = await getApiTokensTable();
const rows = await db.select({ lastUsed: table.lastUsed, expiresAt: table.expiresAt }).from(table);
const now = Date.now();
let expired = 0, neverUsed = 0;
for (const r of rows) {
if (r.expiresAt && new Date(r.expiresAt).getTime() < now) expired++;
if (!r.lastUsed) neverUsed++;
}
return { total: rows.length, expired, neverUsed };
}
/**
* 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;
}