Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions src/lib/server/api-tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/**
* API Token Management
*
* Provides stateless Bearer token authentication as an alternative to cookie-based sessions.
* Tokens inherit the full permissions of the creating user.
*
* Token format: dh_<32-byte-base64url>
* Storage: Argon2id hash + 8-character prefix for DB lookup optimization
*/

import { secureGetRandomValues } from './crypto-fallback';
import { hashPassword, verifyPassword, getUserPermissionsById } from './auth';
import {
db,
apiTokens,
users,
eq,
and
} from './db/drizzle.js';
import { userHasAdminRole } from './db';
import { isEnterprise } from './license';
import type { AuthenticatedUser } from './auth';

// Token prefix for detection in logs and secret scanners
const TOKEN_PREFIX = 'dh_';
const TOKEN_BYTES = 32;

/**
* Generate a new API token for a user.
* The plaintext token is returned ONCE and never stored again.
*
* @param userId - ID of the user owning this token
* @param name - Descriptive name (e.g. "CI/CD Pipeline")
* @param expiresAt - Optional expiration date (ISO string), null = no expiration
* @returns { token: plaintext token (once!), tokenId: DB ID, tokenPrefix }
*/
export async function generateApiToken(
userId: number,
name: string,
expiresAt?: string | null
): Promise<{ token: string; tokenId: number; tokenPrefix: string }> {
// 32 bytes cryptographically secure random data
const tokenBytes = new Uint8Array(TOKEN_BYTES);
secureGetRandomValues(tokenBytes);
const rawToken = TOKEN_PREFIX + Buffer.from(tokenBytes).toString('base64url');

// Prefix for DB lookup (8 chars after 'dh_')
const tokenPrefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + 8);

// Argon2id hash for DB storage
const tokenHash = await hashPassword(rawToken);

// Persist to DB
const now = new Date().toISOString();
const result = await db
.insert(apiTokens)
.values({
userId,
name,
tokenHash,
tokenPrefix,
isActive: true,
expiresAt: expiresAt ?? null,
createdAt: now,
updatedAt: now
})
.returning({ id: apiTokens.id });

return {
token: rawToken, // One-time plaintext
tokenId: result[0].id,
tokenPrefix
};
}

/**
* Validate a Bearer token and return the associated AuthenticatedUser.
* Timing-attack resistant: Argon2id verification even on invalid prefix.
*
* @param rawToken - Complete token from the Authorization header
* @returns AuthenticatedUser or null for invalid/expired tokens
*/
export async function validateApiToken(rawToken: string): Promise<AuthenticatedUser | null> {
// Quick check: format validation
if (!rawToken.startsWith(TOKEN_PREFIX)) {
return null;
}

const tokenPrefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + 8);

Comment on lines +95 to +102
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateApiToken() only checks startsWith('dh_') and then proceeds to DB lookup + Argon2 verification. This accepts arbitrarily long/short inputs and doesn’t enforce the documented base64url/length constraints, which increases DoS risk (expensive Argon2 on attacker-controlled header sizes) and deviates from the stated token format. Add strict format/length validation (e.g., exact expected length and base64url charset) and reject anything outside a small maximum length before querying/verifying.

Copilot uses AI. Check for mistakes.
// Candidate lookup via index (avoids O(n) Argon2id computations)
const candidates = await db
.select()
.from(apiTokens)
.where(
and(
eq(apiTokens.tokenPrefix, tokenPrefix),
eq(apiTokens.isActive, true)
)
);

// Timing-attack protection: run hash operation even with no candidates
if (candidates.length === 0) {
// Dummy verification prevents timing leak from different response times
await verifyPassword(
rawToken,
'$argon2id$v=19$m=65536,t=3,p=1$dummysalt1234567$dummyhash12345678901234567890123456789012'
);
return null;
}
Comment on lines +114 to +120
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dummy Argon2 hash used for timing-attack protection is not a valid PHC argon2id string. verifyPassword() logs an error whenever argon2.verify() throws, so unknown-prefix requests will spam logs and can create an easy log-flooding/DoS vector. Replace this with a known-valid Argon2id hash string (precomputed once) or another approach that guarantees argon2.verify() won’t throw on the dummy path.

Copilot uses AI. Check for mistakes.

// Hash verification (Argon2id, constant-time)
for (const candidate of candidates) {
try {
// Expiration check
if (candidate.expiresAt && new Date(candidate.expiresAt) < new Date()) {
// Deactivate expired token (lazy)
await db
.update(apiTokens)
.set({ isActive: false, updatedAt: new Date().toISOString() })
.where(eq(apiTokens.id, candidate.id));
continue;
}

const isValid = await verifyPassword(rawToken, candidate.tokenHash);

if (isValid) {
// Update last_used (fire-and-forget)
db.update(apiTokens)
.set({ lastUsed: new Date().toISOString() })
.where(eq(apiTokens.id, candidate.id))
.catch((err: unknown) => console.error('[ApiToken] Failed to update last_used:', err));

// Load user and build AuthenticatedUser
const userResult = await db
.select()
.from(users)
.where(and(eq(users.id, candidate.userId), eq(users.isActive, true)));

if (userResult.length === 0) return null;

const user = userResult[0];
const permissions = await getUserPermissionsById(user.id);
const enterprise = await isEnterprise();
const isAdmin = enterprise ? await userHasAdminRole(user.id) : true;

return {
id: user.id,
username: user.username,
email: user.email ?? undefined,
displayName: user.displayName ?? undefined,
avatar: user.avatar ?? undefined,
isAdmin,
provider: (user.authProvider?.split(':')[0] as 'local' | 'ldap' | 'oidc') || 'local',
permissions
};
}
} catch {
// Invalid hash format, continue checking
continue;
}
}

return null;
}

/**
* List all API tokens of a user (without token_hash).
*/
export async function listUserApiTokens(userId: number) {
return db
.select({
id: apiTokens.id,
name: apiTokens.name,
tokenPrefix: apiTokens.tokenPrefix,
lastUsed: apiTokens.lastUsed,
expiresAt: apiTokens.expiresAt,
isActive: apiTokens.isActive,
createdAt: apiTokens.createdAt
})
.from(apiTokens)
.where(eq(apiTokens.userId, userId))
.orderBy(apiTokens.createdAt);
}

/**
* Revoke an API token (sets is_active = false).
* Returns false if token not found or not owned by user.
*/
export async function revokeApiToken(
tokenId: number,
userId: number,
isAdmin: boolean
): Promise<boolean> {
// Load token for ownership check
const tokenResult = await db
.select({ id: apiTokens.id, userId: apiTokens.userId })
.from(apiTokens)
.where(eq(apiTokens.id, tokenId));

if (tokenResult.length === 0) return false;

const token = tokenResult[0];

// Only owner or admin may revoke
if (token.userId !== userId && !isAdmin) return false;

await db
.update(apiTokens)
.set({ isActive: false, updatedAt: new Date().toISOString() })
.where(eq(apiTokens.id, tokenId));

return true;
}
21 changes: 19 additions & 2 deletions src/lib/server/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { validateApiToken } from './api-tokens';

export interface AuthorizationContext {
/** Whether authentication is enabled globally */
Expand Down Expand Up @@ -110,10 +111,26 @@ export interface AuthorizationContext {
* Create an authorization context from cookies.
* This is the main entry point for authorization checks.
*/
export async function authorize(cookies: Cookies): Promise<AuthorizationContext> {
export async function authorize(cookies: Cookies, request?: Request): Promise<AuthorizationContext> {
const authEnabled = await isAuthEnabled();
const enterprise = await isEnterprise();
const user = authEnabled ? await validateSession(cookies) : null;

// 1. Cookie-based session auth (existing, unchanged)
let user: AuthenticatedUser | null = null;
if (authEnabled) {
user = await validateSession(cookies);

// 2. Bearer token auth (fallback when no cookie session)
if (!user && request) {
const authHeader = request.headers.get('Authorization');
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7).trim();
if (token) {
user = await validateApiToken(token);
}
}
}
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bearer-token auth only runs when the optional request parameter is passed. Most existing API route handlers still call authorize(cookies) (no request), so Authorization: Bearer dh_... will not authenticate for the majority of the API, which conflicts with the PR’s stated goal. Consider adding an authorizeEvent(event) helper (or similar) and updating route handlers to pass request so bearer tokens work across the API surface (and audit logging can resolve the user as well).

Copilot uses AI. Check for mistakes.
}

// Determine admin status:
// - Free edition: all authenticated users are effectively admins (full access)
Expand Down
8 changes: 6 additions & 2 deletions src/lib/server/db/drizzle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,8 @@ const REQUIRED_TABLES = [
'audit_logs',
'container_events',
'schedule_executions',
'user_preferences'
'user_preferences',
'api_tokens'
Comment on lines +338 to +339
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

api_tokens is now treated as a required table, but there are no corresponding SQL migrations in drizzle/ or drizzle-pg/ to create it (and the meta snapshots/journal aren’t updated). As-is, fresh installs and migrated instances will be missing the table and schema health will report unhealthy. Add a new migration (SQLite + Postgres) that creates api_tokens (and indexes) and update the migration metadata accordingly.

Suggested change
'user_preferences',
'api_tokens'
'user_preferences'

Copilot uses AI. Check for mistakes.
];

/**
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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';
Expand Down
27 changes: 27 additions & 0 deletions src/lib/server/db/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,30 @@ export const pendingContainerUpdates = sqliteTable('pending_container_updates',
// USER PREFERENCES TABLE (unified key-value store)
// =============================================================================

// =============================================================================
// API TOKEN TABLES
// =============================================================================

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(), // First 8 chars after 'dh_' for UI identification
lastUsed: text('last_used'),
expiresAt: text('expires_at'), // NULL = no expiration
isActive: integer('is_active', { mode: 'boolean' }).default(true),
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)
// =============================================================================

export const userPreferences = sqliteTable('user_preferences', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }), // NULL = shared (free edition), set = per-user (enterprise)
Expand Down Expand Up @@ -567,3 +591,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;
24 changes: 24 additions & 0 deletions src/lib/server/db/schema/pg-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,30 @@ export const pendingContainerUpdates = pgTable('pending_container_updates', {
// USER PREFERENCES TABLE (unified key-value store)
// =============================================================================

// =============================================================================
// API TOKEN TABLES
// =============================================================================

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(), // First 8 chars after 'dh_' for UI identification
lastUsed: timestamp('last_used', { mode: 'string' }),
expiresAt: timestamp('expires_at', { mode: 'string' }), // NULL = no expiration
isActive: boolean('is_active').default(true),
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)
// =============================================================================

export const userPreferences = pgTable('user_preferences', {
id: serial('id').primaryKey(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }), // NULL = shared (free edition), set = per-user (enterprise)
Expand Down
Loading
Loading