Files
dockhand/src/lib/server/auth.ts
T
2026-06-17 08:21:38 +02:00

1567 lines
44 KiB
TypeScript

/**
* Core Authentication Module
*
* Security features:
* - Argon2id password hashing via argon2 (memory-hard, timing-attack resistant)
* - Cryptographically secure 32-byte random session tokens
* - HttpOnly cookies (prevents XSS from reading tokens)
* - Secure flag (protocol-aware: x-forwarded-proto or COOKIE_SECURE env var, default off)
* - SameSite=Strict (CSRF protection)
*/
import os from 'node:os';
import { createHash } from 'node:crypto';
import argon2 from 'argon2';
import type { Cookies } from '@sveltejs/kit';
import {
getAuthSettings,
getUser,
getUserByUsername,
getSession as dbGetSession,
createSession as dbCreateSession,
deleteSession as dbDeleteSession,
deleteExpiredSessions,
updateUser,
createUser,
getUserRoles,
getUserRolesForEnvironment,
getUserAccessibleEnvironments,
userCanAccessEnvironment,
userHasAdminRole,
getRoleByName,
assignUserRole,
removeUserRole,
getLdapConfigs,
getLdapConfig,
getOidcConfigs,
getOidcConfig,
type User,
type Session,
type Permissions,
type LdapConfig,
type OidcConfig
} from './db';
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';
// Default empty permissions
const EMPTY_PERMISSIONS: Permissions = {
containers: [],
images: [],
volumes: [],
networks: [],
stacks: [],
environments: [],
registries: [],
notifications: [],
configsets: [],
settings: [],
users: [],
git: [],
license: [],
audit_logs: [],
activity: [],
schedules: []
};
/**
* Get Admin role permissions from the database.
* Falls back to EMPTY_PERMISSIONS if Admin role not found.
*/
async function getAdminPermissions(): Promise<Permissions> {
const adminRole = await getRoleByName('Admin');
return adminRole?.permissions ?? EMPTY_PERMISSIONS;
}
export interface AuthenticatedUser {
id: number;
username: string;
email?: string;
displayName?: string;
avatar?: string;
isAdmin: boolean;
provider: 'local' | 'ldap' | 'oidc';
permissions: Permissions;
}
export interface LoginResult {
success: boolean;
error?: string;
requiresMfa?: boolean;
user?: AuthenticatedUser;
}
// ============================================
// Password Hashing (Argon2id)
// ============================================
// Argon2id parameters
const ARGON2_MEMORY_COST = 65536; // 64 MB in kibibytes
const ARGON2_TIME_COST = 3; // 3 iterations
const ARGON2_PARALLELISM = 1; // Single-threaded
const ARGON2_HASH_LENGTH = 32; // 256-bit output
/**
* Hash a password using Argon2id
*
* Uses the argon2 npm package (C binding) for native performance.
* Returns PHC format: $argon2id$v=19$m=65536,t=3,p=1$...
*/
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: ARGON2_MEMORY_COST,
timeCost: ARGON2_TIME_COST,
parallelism: ARGON2_PARALLELISM,
hashLength: ARGON2_HASH_LENGTH
});
}
/**
* Verify a password against a hash
* Uses constant-time comparison internally
*
* The argon2 npm package uses standard PHC format, compatible with existing hashes
*/
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch (e) {
console.error('[Auth] argon2.verify() threw unexpectedly:', e);
return false;
}
}
let dummyAuthHashCache: Promise<string> | null = null;
export function getDummyAuthHash(): Promise<string> {
if (!dummyAuthHashCache) {
dummyAuthHashCache = hashPassword(`dummy-${Math.random()}-${Date.now()}`);
}
return dummyAuthHashCache;
}
// ============================================
// Session Management
// ============================================
/**
* Generate a cryptographically secure session token
* 32 bytes = 256 bits of entropy
*/
function generateSessionToken(): string {
return secureRandomBytes(32).toString('base64url');
}
/**
* Determine whether to set the Secure flag on the session cookie.
*
* Priority:
* 1. COOKIE_SECURE env var ('true' / 'false') — explicit override
* 2. x-forwarded-proto header — set by Traefik / Nginx / Caddy
* 3. false — default, matches v1.0.18 Bun behavior
*
* Defaulting to false (not NODE_ENV) is intentional: Dockhand is commonly
* run over plain HTTP in homelabs. Setting Secure unconditionally in production
* causes a login loop when there is no HTTPS reverse proxy, because browsers
* silently discard Secure cookies on HTTP connections.
*
* Users behind an HTTPS reverse proxy get Secure cookies automatically via
* x-forwarded-proto. Users who terminate TLS in the app itself can opt in
* with COOKIE_SECURE=true.
*/
function isSecureContext(request?: Request): boolean {
if (process.env.COOKIE_SECURE === 'false') return false;
if (process.env.COOKIE_SECURE === 'true') return true;
if (request) {
const proto = request.headers.get('x-forwarded-proto');
if (proto === 'https') return true;
}
return false;
}
/**
* Create a new session for a user
* @param provider - Auth provider: 'local', or provider name like 'Keycloak', 'Azure AD', etc.
* @param request - Optional incoming request (used to detect HTTPS via x-forwarded-proto)
*/
export async function createUserSession(
userId: number,
provider: string,
cookies: Cookies,
request?: Request
): Promise<Session> {
// Clean up expired sessions periodically
await deleteExpiredSessions();
// Generate secure token
const sessionId = generateSessionToken();
// Get session timeout from settings
const settings = await getAuthSettings();
// Safety: ensure sessionTimeout is valid (1 second to 30 days), default to 24h if invalid
const MAX_SESSION_TIMEOUT = 2592000; // 30 days in seconds
const DEFAULT_SESSION_TIMEOUT = 86400; // 24 hours in seconds
const sessionTimeout = (settings?.sessionTimeout > 0 && settings?.sessionTimeout <= MAX_SESSION_TIMEOUT)
? settings.sessionTimeout
: DEFAULT_SESSION_TIMEOUT;
const expiresAt = new Date(Date.now() + sessionTimeout * 1000).toISOString();
// Create session in database
const session = await dbCreateSession(sessionId, userId, provider, expiresAt);
// Set secure cookie
setSessionCookie(cookies, sessionId, sessionTimeout, request);
// Update user's last login time
await updateUser(userId, { lastLogin: new Date().toISOString() });
return session;
}
/**
* Set the session cookie with secure attributes
*/
function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number, request?: Request): void {
cookies.set(SESSION_COOKIE_NAME, sessionId, {
path: '/',
httpOnly: true, // Prevents XSS attacks from reading cookie
secure: isSecureContext(request), // Protocol-aware: checks x-forwarded-proto or NODE_ENV
sameSite: 'lax', // Lax required for OIDC/SSO cross-site redirects
maxAge: maxAge // Session timeout in seconds
});
}
/**
* Get the session ID from cookies
*/
function getSessionIdFromCookies(cookies: Cookies): string | null {
return cookies.get(SESSION_COOKIE_NAME) || null;
}
/**
* Validate a session and return the authenticated user
*/
export async function validateSession(cookies: Cookies): Promise<AuthenticatedUser | null> {
const sessionId = getSessionIdFromCookies(cookies);
if (!sessionId) return null;
return validateSessionById(sessionId);
}
/**
* Validate a session by raw session ID (without the SvelteKit Cookies object).
*
* Used by WebSocket upgrade handlers in server.js / vite.config.ts that only
* have a raw Cookie header string. Mirrors validateSession() semantics:
* returns the AuthenticatedUser on success, null on missing/expired/disabled.
*/
export async function validateSessionById(sessionId: string): Promise<AuthenticatedUser | null> {
if (!sessionId) return null;
const session = await dbGetSession(sessionId);
if (!session) return null;
const expiresAt = new Date(session.expiresAt);
if (expiresAt < new Date()) {
await dbDeleteSession(sessionId);
return null;
}
const user = await getUser(session.userId);
if (!user || !user.isActive) return null;
return await buildAuthenticatedUser(user, session.provider as 'local' | 'ldap' | 'oidc');
}
/**
* Cookie name used for browser session auth. Exported so raw header parsers
* (WebSocket upgrade handlers) can look it up without re-encoding the
* constant.
*/
export const SESSION_COOKIE = SESSION_COOKIE_NAME;
/**
* Destroy a session (logout)
*/
export async function destroySession(cookies: Cookies): Promise<void> {
const sessionId = getSessionIdFromCookies(cookies);
if (sessionId) {
await dbDeleteSession(sessionId);
}
// Clear the cookie
cookies.delete(SESSION_COOKIE_NAME, { path: '/' });
}
// ============================================
// User Permissions
// ============================================
/**
* Build an authenticated user object with merged permissions
*/
async function buildAuthenticatedUser(
user: User,
provider: 'local' | 'ldap' | 'oidc'
): Promise<AuthenticatedUser> {
const permissions = await getUserPermissionsById(user.id);
// Determine admin status:
// - Free edition: all authenticated users are admins
// - Enterprise: check Admin role assignment
const enterprise = await isEnterprise();
const isAdmin = enterprise ? await userHasAdminRole(user.id) : true;
return {
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName,
avatar: user.avatar,
isAdmin,
provider,
permissions
};
}
/**
* Get merged permissions for a user from all their roles
*/
export async function getUserPermissionsById(userId: number): Promise<Permissions> {
const user = await getUser(userId);
if (!user) return EMPTY_PERMISSIONS;
// Admins (those with Admin role) have all permissions
if (await userHasAdminRole(userId)) {
return getAdminPermissions();
}
// Get all roles for this user
const userRoles = await getUserRoles(userId);
// Merge permissions from all roles
const merged: Permissions = {
containers: [],
images: [],
volumes: [],
networks: [],
stacks: [],
environments: [],
registries: [],
notifications: [],
configsets: [],
settings: [],
users: [],
git: [],
license: [],
audit_logs: [],
activity: [],
schedules: []
};
for (const ur of userRoles) {
const perms = ur.role.permissions;
for (const key of Object.keys(merged) as (keyof Permissions)[]) {
if (perms[key]) {
merged[key] = [...new Set([...merged[key], ...perms[key]])];
}
}
}
return merged;
}
/**
* Check if a user has a specific permission
* Note: Permission checks only apply in Enterprise edition
* In Free edition, all authenticated users have full access
*
* @param user - The authenticated user
* @param resource - The resource category (containers, images, etc.)
* @param action - The action to check (view, create, etc.)
* @param environmentId - Optional: check permission in context of specific environment
*/
export async function checkPermission(
user: AuthenticatedUser,
resource: keyof Permissions,
action: string,
environmentId?: number
): Promise<boolean> {
// In free edition, all authenticated users have full access
if (!(await isEnterprise())) return true;
// Admins (those with Admin role) can do anything
if (await userHasAdminRole(user.id)) return true;
// If checking within an environment context, get environment-specific permissions
if (environmentId !== undefined) {
const permissions = await getUserPermissionsForEnvironment(user.id, environmentId);
return permissions[resource]?.includes(action) ?? false;
}
// Otherwise use global permissions (from all roles)
return user.permissions[resource]?.includes(action) ?? false;
}
/**
* Get merged permissions for a user for a specific environment.
* Only includes permissions from roles that apply to this environment
* (roles with null environmentId OR matching environmentId).
*/
export async function getUserPermissionsForEnvironment(userId: number, environmentId: number): Promise<Permissions> {
const user = await getUser(userId);
if (!user) return EMPTY_PERMISSIONS;
// Admins (those with Admin role) have all permissions
if (await userHasAdminRole(userId)) {
return getAdminPermissions();
}
// Get roles that apply to this specific environment
const userRoles = await getUserRolesForEnvironment(userId, environmentId);
// Merge permissions from applicable roles only
const merged: Permissions = {
containers: [],
images: [],
volumes: [],
networks: [],
stacks: [],
environments: [],
registries: [],
notifications: [],
configsets: [],
settings: [],
users: [],
git: [],
license: [],
audit_logs: [],
activity: [],
schedules: []
};
for (const ur of userRoles) {
if (!ur.role) continue;
const perms = ur.role.permissions;
for (const key of Object.keys(merged) as (keyof Permissions)[]) {
if (perms[key]) {
merged[key] = [...new Set([...merged[key], ...perms[key]])];
}
}
}
return merged;
}
// Re-export for convenience
export { getUserAccessibleEnvironments, userCanAccessEnvironment };
// ============================================
// Authentication State
// ============================================
/**
* Check if authentication is enabled
*/
export async function isAuthEnabled(): Promise<boolean> {
try {
const settings = await getAuthSettings();
return settings.authEnabled;
} catch {
// If database is not initialized, auth is disabled
return false;
}
}
/**
* Local authentication
*/
export async function authenticateLocal(
username: string,
password: string
): Promise<LoginResult> {
const user = await getUserByUsername(username);
if (!user) {
await verifyPassword(password, await getDummyAuthHash());
return { success: false, error: 'Invalid username or password' };
}
if (!user.isActive) {
await verifyPassword(password, await getDummyAuthHash());
console.warn(`[Auth] Login attempt for disabled account: user=${username}`);
return { success: false, error: 'Invalid username or password' };
}
const validPassword = await verifyPassword(password, user.passwordHash);
if (!validPassword) {
return { success: false, error: 'Invalid username or password' };
}
// Check if MFA is required
if (user.mfaEnabled) {
return { success: true, requiresMfa: true };
}
return {
success: true,
user: await buildAuthenticatedUser(user, 'local')
};
}
// ============================================
// LDAP Authentication
// ============================================
export interface LdapTestResult {
success: boolean;
error?: string;
userCount?: number;
}
/**
* Get enabled LDAP configurations
*/
export async function getEnabledLdapConfigs(): Promise<LdapConfig[]> {
const configs = await getLdapConfigs();
return configs.filter(config => config.enabled);
}
/**
* Test LDAP connection and configuration
*/
export async function testLdapConnection(configId: number): Promise<LdapTestResult> {
const config = await getLdapConfig(configId);
if (!config) {
return { success: false, error: 'LDAP configuration not found' };
}
const client = new LdapClient({
url: config.serverUrl,
tlsOptions: config.tlsEnabled ? {
rejectUnauthorized: !config.tlsCa, // If CA provided, validate; otherwise trust
ca: config.tlsCa ? [config.tlsCa] : undefined
} : undefined
});
try {
// Bind with service account if configured
if (config.bindDn && config.bindPassword) {
await client.bind(config.bindDn, config.bindPassword);
}
// Search for users to validate base_dn and filter
const filter = config.userFilter.replace('{{username}}', '*');
const { searchEntries } = await client.search(config.baseDn, {
scope: 'sub',
filter: filter,
sizeLimit: 10,
attributes: [config.usernameAttribute]
});
await client.unbind();
return { success: true, userCount: searchEntries.length };
} catch (error: any) {
try { await client.unbind(); } catch {}
return { success: false, error: error.message || 'Connection failed' };
}
}
/**
* Authenticate user against LDAP
*/
export async function authenticateLdap(
username: string,
password: string,
configId?: number
): Promise<LoginResult & { providerName?: string }> {
// Get LDAP configurations
const configs = configId
? [await getLdapConfig(configId)].filter(Boolean) as LdapConfig[]
: await getEnabledLdapConfigs();
if (configs.length === 0) {
return { success: false, error: 'No LDAP configuration available' };
}
// Try each LDAP configuration
for (const config of configs) {
const result = await tryLdapAuth(username, password, config);
if (result.success) {
return { ...result, providerName: config.name };
}
}
return { success: false, error: 'Invalid username or password' };
}
/**
* Escape special characters in an LDAP filter value (RFC 4515).
* Prevents LDAP injection via wildcards or control characters.
*/
function escapeLdapFilterValue(value: string): string {
return value
.replace(/\\/g, '\\5c')
.replace(/\*/g, '\\2a')
.replace(/\(/g, '\\28')
.replace(/\)/g, '\\29')
.replace(/\0/g, '\\00');
}
/**
* Try authentication against a specific LDAP configuration
*/
async function tryLdapAuth(
username: string,
password: string,
config: LdapConfig
): Promise<LoginResult> {
const client = new LdapClient({
url: config.serverUrl,
tlsOptions: config.tlsEnabled ? {
rejectUnauthorized: !config.tlsCa,
ca: config.tlsCa ? [config.tlsCa] : undefined
} : undefined
});
try {
// First, bind with service account to search for the user
if (config.bindDn && config.bindPassword) {
await client.bind(config.bindDn, config.bindPassword);
}
// Escape the username before interpolating into the LDAP filter (RFC 4515)
// to prevent LDAP injection via wildcards or special characters.
const safeUsername = escapeLdapFilterValue(username);
const filter = config.userFilter.replace('{{username}}', safeUsername);
const { searchEntries } = await client.search(config.baseDn, {
scope: 'sub',
filter: filter,
sizeLimit: 1,
attributes: [
'dn',
config.usernameAttribute,
config.emailAttribute,
config.displayNameAttribute
]
});
// Use a single generic error for both "not found" and "wrong password"
// to avoid leaking whether a username exists via response content or timing.
if (searchEntries.length === 0) {
await client.unbind();
return { success: false, error: 'Invalid username or password' };
}
const userEntry = searchEntries[0];
const userDn = userEntry.dn;
// Unbind service account
await client.unbind();
// Create new client and try to bind as the user (authentication)
const userClient = new LdapClient({
url: config.serverUrl,
tlsOptions: config.tlsEnabled ? {
rejectUnauthorized: !config.tlsCa,
ca: config.tlsCa ? [config.tlsCa] : undefined
} : undefined
});
try {
await userClient.bind(userDn, password);
await userClient.unbind();
} catch (bindError) {
return { success: false, error: 'Invalid username or password' };
}
// Authentication successful - get or create local user
const ldapUsername = getAttributeValue(userEntry, config.usernameAttribute) || username;
const email = getAttributeValue(userEntry, config.emailAttribute);
const displayName = getAttributeValue(userEntry, config.displayNameAttribute);
// Check if user is in admin group
let shouldBeAdmin = false;
if (config.adminGroup) {
shouldBeAdmin = await checkLdapGroupMembership(config, userDn, config.adminGroup);
}
// Build provider string for storage (e.g., "ldap:Active Directory")
const authProvider = `ldap:${config.name}`;
// Get or create local user
let user = await getUserByUsername(ldapUsername);
if (!user) {
// Create new user from LDAP
user = await createUser({
username: ldapUsername,
email: email || undefined,
displayName: displayName || undefined,
passwordHash: '', // No local password for LDAP users
authProvider
});
} else {
// Update user info from LDAP
await updateUser(user.id, {
email: email || undefined,
displayName: displayName || undefined,
authProvider
});
user = (await getUser(user.id))!;
}
// Manage Admin role assignment based on LDAP group membership
const adminRole = await getRoleByName('Admin');
if (adminRole) {
const hasAdminRole = await userHasAdminRole(user.id);
if (shouldBeAdmin && !hasAdminRole) {
await assignUserRole(user.id, adminRole.id, null);
} else if (!shouldBeAdmin && hasAdminRole && config.adminGroup) {
// Remove Admin role if user is no longer in admin group
await removeUserRole(user.id, adminRole.id);
}
}
// Process role mappings (Enterprise feature)
// Note: roleMappings is parsed from JSON by getLdapConfig, but TypeScript type is string
const roleMappings = typeof config.roleMappings === 'string'
? JSON.parse(config.roleMappings) as { groupDn: string; roleId: number }[]
: config.roleMappings as { groupDn: string; roleId: number }[] | null | undefined;
if (roleMappings && roleMappings.length > 0 && config.groupBaseDn && await isEnterprise()) {
const userExistingRoles = await getUserRoles(user.id);
const existingRoleIds = new Set(userExistingRoles.map(r => r.roleId));
// All role IDs referenced in mappings (these are LDAP-managed)
const mappedRoleIds = new Set(roleMappings.map(m => m.roleId));
// Determine which mapped roles user should have based on current group membership
const shouldHaveRoleIds = new Set<number>();
for (const mapping of roleMappings) {
const isInGroup = await checkLdapGroupMembership(config, userDn, mapping.groupDn);
if (isInGroup) {
shouldHaveRoleIds.add(mapping.roleId);
}
}
// Add roles user should have but doesn't
for (const roleId of shouldHaveRoleIds) {
if (!existingRoleIds.has(roleId)) {
await assignUserRole(user.id, roleId, undefined);
}
}
// Remove mapped roles user has but shouldn't
for (const roleId of mappedRoleIds) {
if (existingRoleIds.has(roleId) && !shouldHaveRoleIds.has(roleId)) {
await removeUserRole(user.id, roleId);
}
}
}
// Clear cached token permissions after role sync
invalidateTokenCacheForUser(user.id);
if (!user.isActive) {
return { success: false, error: 'Account is disabled' };
}
// Check if MFA is required
if (user.mfaEnabled) {
return { success: true, requiresMfa: true };
}
return {
success: true,
user: await buildAuthenticatedUser(user, 'ldap')
};
} catch (error: any) {
try { await client.unbind(); } catch {}
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[LDAP] Authentication error:', errorMsg);
return { success: false, error: 'LDAP authentication failed' };
}
}
/**
* Check if a user is a member of an LDAP group
*/
async function checkLdapGroupMembership(
config: LdapConfig,
userDn: string,
groupDnOrName: string
): Promise<boolean> {
const client = new LdapClient({
url: config.serverUrl,
tlsOptions: config.tlsEnabled ? {
rejectUnauthorized: !config.tlsCa,
ca: config.tlsCa ? [config.tlsCa] : undefined
} : undefined
});
try {
if (config.bindDn && config.bindPassword) {
await client.bind(config.bindDn, config.bindPassword);
}
// Detect if groupDnOrName is a full DN (contains = and ,)
const isFullDn = groupDnOrName.includes('=') && groupDnOrName.includes(',');
let searchBase: string;
let groupFilter: string;
if (config.groupFilter) {
// User provided custom filter - use group DN directly when it's a full DN
// to avoid searching all groups under groupBaseDn
searchBase = isFullDn ? groupDnOrName : (config.groupBaseDn || groupDnOrName);
groupFilter = config.groupFilter
.replace('{{username}}', userDn)
.replace('{{user_dn}}', userDn)
.replace('{{group}}', groupDnOrName);
} else if (isFullDn) {
// Full DN provided - search directly at that DN
searchBase = groupDnOrName;
groupFilter = `(member=${userDn})`;
} else {
// Just a group name - search in groupBaseDn
if (!config.groupBaseDn) {
await client.unbind();
return false;
}
searchBase = config.groupBaseDn;
groupFilter = `(&(cn=${groupDnOrName})(member=${userDn}))`;
}
const { searchEntries } = await client.search(searchBase, {
scope: isFullDn ? 'base' : 'sub',
filter: groupFilter,
sizeLimit: 1
});
await client.unbind();
return searchEntries.length > 0;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[LDAP] Group membership check failed:', errorMsg);
try { await client.unbind(); } catch {}
return false;
}
}
/**
* Helper to get attribute value from LDAP entry
*/
function getAttributeValue(entry: any, attribute: string): string | undefined {
const value = entry[attribute];
if (!value) return undefined;
if (Array.isArray(value)) return value[0]?.toString();
return value.toString();
}
// ============================================
// MFA (TOTP) with Backup Codes
// ============================================
import * as OTPAuth from 'otpauth';
import * as QRCode from 'qrcode';
// MFA data stored in mfaSecret field as JSON
interface MfaData {
secret: string; // TOTP secret (base32)
backupCodes: string[]; // Hashed backup codes (unused ones)
}
/**
* Generate 10 random backup codes (8 characters each, alphanumeric)
*/
function generateBackupCodes(): string[] {
const codes: string[] = [];
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed confusable chars: 0, O, 1, I
for (let i = 0; i < 10; i++) {
let code = '';
for (let j = 0; j < 8; j++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
codes.push(code);
}
return codes;
}
/**
* Hash a backup code for storage
*/
async function hashBackupCode(code: string): Promise<string> {
// Normalize: uppercase, remove spaces and dashes
const normalized = code.toUpperCase().replace(/[\s-]/g, '');
return createHash('sha256').update(normalized).digest('hex');
}
/**
* Parse MFA data from database field
*/
function parseMfaData(mfaSecret: string | null | undefined): MfaData | null {
if (!mfaSecret) return null;
try {
// Try parsing as JSON first (new format)
const parsed = JSON.parse(mfaSecret);
if (parsed && typeof parsed.secret === 'string') {
return {
secret: parsed.secret,
backupCodes: parsed.backupCodes || []
};
}
} catch {
// Legacy format: plain base32 secret string
return {
secret: mfaSecret,
backupCodes: []
};
}
return null;
}
/**
* Generate MFA secret and QR code for setup
*/
export async function generateMfaSetup(userId: number): Promise<{
secret: string;
qrDataUrl: string;
} | null> {
const user = await getUser(userId);
if (!user) return null;
// Build issuer name with hostname (same as license matching)
const hostname = process.env.DOCKHAND_HOSTNAME || os.hostname();
const issuer = `Dockhand (${hostname})`;
// Create a new TOTP secret
const totpSecret = new OTPAuth.Secret({ size: 20 });
const totp = new OTPAuth.TOTP({
issuer,
label: user.username,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: totpSecret
});
const secretBase32 = totp.secret.base32;
const otpauthUrl = totp.toString();
// Generate QR code
const qrDataUrl = await QRCode.toDataURL(otpauthUrl, {
width: 200,
margin: 2
});
// Store secret temporarily as JSON (user must verify before it's enabled)
// Backup codes will be generated after verification
const mfaData: MfaData = { secret: secretBase32, backupCodes: [] };
await updateUser(userId, { mfaSecret: JSON.stringify(mfaData) });
return { secret: secretBase32, qrDataUrl };
}
/**
* Verify MFA token and enable MFA if valid
* Returns backup codes on success (shown only once)
*/
export async function verifyAndEnableMfa(userId: number, token: string): Promise<{ success: false } | { success: true; backupCodes: string[] }> {
const user = await getUser(userId);
if (!user || !user.mfaSecret) return { success: false };
const mfaData = parseMfaData(user.mfaSecret);
if (!mfaData) return { success: false };
const totp = new OTPAuth.TOTP({
issuer: 'Dockhand',
label: user.username,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(mfaData.secret)
});
const delta = totp.validate({ token, window: 1 });
if (delta === null) return { success: false };
// Generate backup codes
const plainBackupCodes = generateBackupCodes();
const hashedBackupCodes = await Promise.all(plainBackupCodes.map(hashBackupCode));
// Update MFA data with hashed backup codes and enable MFA
const updatedMfaData: MfaData = {
secret: mfaData.secret,
backupCodes: hashedBackupCodes
};
await updateUser(userId, {
mfaEnabled: true,
mfaSecret: JSON.stringify(updatedMfaData)
});
// Return plain backup codes (shown only once)
return { success: true, backupCodes: plainBackupCodes };
}
/**
* Verify MFA token during login (accepts TOTP code or backup code)
*/
export async function verifyMfaToken(userId: number, token: string): Promise<boolean> {
const user = await getUser(userId);
if (!user || !user.mfaEnabled || !user.mfaSecret) return false;
const mfaData = parseMfaData(user.mfaSecret);
if (!mfaData) return false;
// First, try TOTP verification
const totp = new OTPAuth.TOTP({
issuer: 'Dockhand',
label: user.username,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(mfaData.secret)
});
const delta = totp.validate({ token, window: 1 });
if (delta !== null) return true;
// If TOTP fails, try backup code
if (mfaData.backupCodes && mfaData.backupCodes.length > 0) {
const hashedInput = await hashBackupCode(token);
const codeIndex = mfaData.backupCodes.indexOf(hashedInput);
if (codeIndex !== -1) {
// Remove used backup code
const updatedBackupCodes = [...mfaData.backupCodes];
updatedBackupCodes.splice(codeIndex, 1);
const updatedMfaData: MfaData = {
secret: mfaData.secret,
backupCodes: updatedBackupCodes
};
await updateUser(userId, { mfaSecret: JSON.stringify(updatedMfaData) });
return true;
}
}
return false;
}
/**
* Disable MFA for a user
*/
export async function disableMfa(userId: number): Promise<boolean> {
const user = await getUser(userId);
if (!user) return false;
await updateUser(userId, { mfaEnabled: false, mfaSecret: undefined });
return true;
}
// ============================================
// Rate Limiting (Simple in-memory)
// ============================================
interface RateLimitEntry {
attempts: number;
lastAttempt: number;
lockedUntil: number | null;
}
const rateLimitStore = new Map<string, RateLimitEntry>();
const RATE_LIMIT_MAX_ATTEMPTS = 5;
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
const RATE_LIMIT_LOCKOUT_MS = 15 * 60 * 1000; // 15 minute lockout
// Guard against multiple intervals during HMR
declare global {
var __authRateLimitCleanupInterval: ReturnType<typeof setInterval> | undefined;
var __authOidcStateCleanupInterval: ReturnType<typeof setInterval> | undefined;
}
// Cleanup expired rate limit entries every 5 minutes (guarded for HMR)
if (!globalThis.__authRateLimitCleanupInterval) {
globalThis.__authRateLimitCleanupInterval = setInterval(() => {
const now = Date.now();
for (const [key, entry] of rateLimitStore) {
if (now - entry.lastAttempt > RATE_LIMIT_WINDOW_MS) {
rateLimitStore.delete(key);
}
}
}, 5 * 60 * 1000);
}
/**
* Check if a login attempt is rate limited
*/
export function isRateLimited(identifier: string): { limited: boolean; retryAfter?: number } {
const entry = rateLimitStore.get(identifier);
const now = Date.now();
if (!entry) return { limited: false };
// Check if locked out
if (entry.lockedUntil && entry.lockedUntil > now) {
return {
limited: true,
retryAfter: Math.ceil((entry.lockedUntil - now) / 1000)
};
}
// Reset if outside window
if (now - entry.lastAttempt > RATE_LIMIT_WINDOW_MS) {
rateLimitStore.delete(identifier);
return { limited: false };
}
return { limited: false };
}
/**
* Record a failed login attempt
*/
export function recordFailedAttempt(identifier: string): void {
const now = Date.now();
const entry = rateLimitStore.get(identifier);
if (!entry || now - entry.lastAttempt > RATE_LIMIT_WINDOW_MS) {
rateLimitStore.set(identifier, {
attempts: 1,
lastAttempt: now,
lockedUntil: null
});
return;
}
entry.attempts++;
entry.lastAttempt = now;
if (entry.attempts >= RATE_LIMIT_MAX_ATTEMPTS) {
entry.lockedUntil = now + RATE_LIMIT_LOCKOUT_MS;
}
}
/**
* Clear rate limit for an identifier (on successful login)
*/
export function clearRateLimit(identifier: string): void {
rateLimitStore.delete(identifier);
}
// ============================================
// OIDC/SSO Authentication
// ============================================
// In-memory store for OIDC state (nonce, code_verifier)
// In production, consider using Redis or database for multi-instance deployments
const oidcStateStore = new Map<string, {
configId: number;
codeVerifier: string;
nonce: string;
redirectUrl: string;
expiresAt: number;
}>();
// Clean up expired OIDC states periodically (guarded for HMR)
if (!globalThis.__authOidcStateCleanupInterval) {
globalThis.__authOidcStateCleanupInterval = setInterval(() => {
const now = Date.now();
for (const [state, data] of oidcStateStore.entries()) {
if (data.expiresAt < now) {
oidcStateStore.delete(state);
}
}
}, 60000); // Every minute
}
// OIDC Discovery document cache
const oidcDiscoveryCache = new Map<string, {
document: OidcDiscoveryDocument;
expiresAt: number;
}>();
export interface OidcDiscoveryDocument {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint?: string;
jwks_uri: string;
end_session_endpoint?: string;
scopes_supported?: string[];
response_types_supported?: string[];
code_challenge_methods_supported?: string[];
}
/**
* Get enabled OIDC configurations
*/
export async function getEnabledOidcConfigs(): Promise<OidcConfig[]> {
const configs = await getOidcConfigs();
return configs.filter(config => config.enabled);
}
/**
* Fetch and cache OIDC discovery document
*/
async function getOidcDiscovery(issuerUrl: string): Promise<OidcDiscoveryDocument> {
const cached = oidcDiscoveryCache.get(issuerUrl);
if (cached && cached.expiresAt > Date.now()) {
return cached.document;
}
const wellKnownUrl = issuerUrl.endsWith('/')
? `${issuerUrl}.well-known/openid-configuration`
: `${issuerUrl}/.well-known/openid-configuration`;
const response = await fetch(wellKnownUrl);
if (!response.ok) {
throw new Error(`Failed to fetch OIDC discovery document: ${response.statusText}`);
}
const document = await response.json() as OidcDiscoveryDocument;
// Cache for 1 hour
oidcDiscoveryCache.set(issuerUrl, {
document,
expiresAt: Date.now() + 3600000
});
return document;
}
/**
* Generate PKCE code verifier and challenge
*/
function generatePkce(): { codeVerifier: string; codeChallenge: string } {
const codeVerifier = secureRandomBytes(32).toString('base64url');
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
return { codeVerifier, codeChallenge };
}
/**
* Build OIDC authorization URL for SSO initiation
*/
export async function buildOidcAuthorizationUrl(
configId: number,
redirectUrl: string = '/'
): Promise<{ url: string; state: string } | { error: string }> {
const config = await getOidcConfig(configId);
if (!config || !config.enabled) {
return { error: 'OIDC configuration not found or disabled' };
}
try {
const discovery = await getOidcDiscovery(config.issuerUrl);
// Generate state, nonce, and PKCE
const state = secureRandomBytes(32).toString('base64url');
const nonce = secureRandomBytes(16).toString('base64url');
const { codeVerifier, codeChallenge } = generatePkce();
// Store state for callback verification (expires in 10 minutes)
oidcStateStore.set(state, {
configId,
codeVerifier,
nonce,
redirectUrl,
expiresAt: Date.now() + 600000
});
// Build authorization URL
const params = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: config.scopes || 'openid profile email',
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
const authUrl = `${discovery.authorization_endpoint}?${params.toString()}`;
return { url: authUrl, state };
} catch (error: any) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[OIDC] Failed to build authorization URL:', errorMsg);
return { error: error.message || 'Failed to initialize SSO' };
}
}
/**
* Exchange authorization code for tokens and authenticate user
*/
export async function handleOidcCallback(
code: string,
state: string
): Promise<LoginResult & { redirectUrl?: string }> {
// Validate state
const stateData = oidcStateStore.get(state);
if (!stateData) {
return { success: false, error: 'Invalid or expired state' };
}
// Remove state immediately to prevent replay
oidcStateStore.delete(state);
if (stateData.expiresAt < Date.now()) {
return { success: false, error: 'SSO session expired' };
}
const config = await getOidcConfig(stateData.configId);
if (!config || !config.enabled) {
return { success: false, error: 'OIDC configuration not found or disabled' };
}
try {
const discovery = await getOidcDiscovery(config.issuerUrl);
// Exchange code for tokens
const tokenResponse = await fetch(discovery.token_endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.redirectUri,
client_id: config.clientId,
client_secret: config.clientSecret,
code_verifier: stateData.codeVerifier
})
});
if (!tokenResponse.ok) {
const errorBody = await tokenResponse.text();
console.error('Token exchange failed:', tokenResponse.status, errorBody);
console.error('Token endpoint:', discovery.token_endpoint);
console.error('Redirect URI:', config.redirectUri);
console.error('Client ID:', config.clientId);
return { success: false, error: `Failed to exchange authorization code: ${errorBody}` };
}
const tokens = await tokenResponse.json() as {
access_token: string;
id_token?: string;
token_type: string;
expires_in?: number;
};
// Decode and validate ID token (basic validation - in production use a JWT library)
let claims: Record<string, any> = {};
if (tokens.id_token) {
const idTokenParts = tokens.id_token.split('.');
if (idTokenParts.length === 3) {
try {
claims = JSON.parse(Buffer.from(idTokenParts[1], 'base64url').toString());
} catch {
return { success: false, error: 'Invalid ID token' };
}
}
}
// If no ID token or need more info, fetch from userinfo endpoint
if (discovery.userinfo_endpoint && tokens.access_token) {
try {
const userinfoResponse = await fetch(discovery.userinfo_endpoint, {
headers: {
'Authorization': `Bearer ${tokens.access_token}`
}
});
if (userinfoResponse.ok) {
const userinfo = await userinfoResponse.json();
claims = { ...claims, ...userinfo };
}
} catch (e) {
console.warn('Failed to fetch userinfo:', e);
}
}
// Validate nonce if present in ID token
if (claims.nonce && claims.nonce !== stateData.nonce) {
return { success: false, error: 'Invalid nonce' };
}
// Extract user information using configured claims
const username = claims[config.usernameClaim] || claims.preferred_username || claims.sub;
const email = claims[config.emailClaim] || claims.email;
const displayName = claims[config.displayNameClaim] || claims.name;
if (!username) {
return { success: false, error: 'Username claim not found in token' };
}
// Determine if user should be admin based on claim
let shouldBeAdmin = false;
if (config.adminClaim && config.adminValue) {
const adminClaimValue = claims[config.adminClaim];
// Support multiple comma-separated admin values
const adminValues = config.adminValue.split(',').map((v: string) => v.trim());
if (Array.isArray(adminClaimValue)) {
shouldBeAdmin = adminClaimValue.some((v: string) => adminValues.includes(v));
} else {
shouldBeAdmin = adminValues.includes(adminClaimValue);
}
}
// Build provider string for storage (e.g., "oidc:Keycloak")
const authProvider = `oidc:${config.name}`;
// Get or create local user
let user = await getUserByUsername(username);
if (!user) {
// Create new user from OIDC
user = await createUser({
username,
email: email || undefined,
displayName: displayName || undefined,
passwordHash: '', // No local password for OIDC users
authProvider
});
} else {
// Update user info from OIDC
await updateUser(user.id, {
email: email || undefined,
displayName: displayName || undefined,
authProvider
});
user = (await getUser(user.id))!;
}
// Manage Admin role assignment based on OIDC claim
const adminRole = await getRoleByName('Admin');
if (adminRole) {
const hasAdminRole = await userHasAdminRole(user.id);
if (shouldBeAdmin && !hasAdminRole) {
// Assign Admin role
await assignUserRole(user.id, adminRole.id, null);
}
// Note: We don't remove Admin role if claim is not present anymore
// to prevent accidental lockouts (same behavior as before)
}
// Process role mappings from OIDC config
if (config.roleMappings) {
try {
const roleMappings = typeof config.roleMappings === 'string'
? JSON.parse(config.roleMappings)
: config.roleMappings;
const roleMappingsClaim = config.roleMappingsClaim || 'groups';
const claimValue = claims[roleMappingsClaim];
if (Array.isArray(roleMappings) && claimValue) {
const claimValues = Array.isArray(claimValue) ? claimValue : [claimValue];
// Get user's current roles to avoid duplicates
const userRoles = await getUserRoles(user.id);
for (const mapping of roleMappings) {
if (mapping.claimValue && mapping.roleId && claimValues.includes(mapping.claimValue)) {
const hasRole = userRoles.some(r => r.roleId === mapping.roleId);
if (!hasRole) {
await assignUserRole(user.id, mapping.roleId, null);
}
}
}
}
} catch (e) {
console.warn('Failed to process OIDC role mappings:', e);
}
}
// Clear cached token permissions after role sync
invalidateTokenCacheForUser(user.id);
if (!user.isActive) {
return { success: false, error: 'Account is disabled' };
}
// OIDC users bypass MFA (they authenticated through IdP)
return {
success: true,
user: await buildAuthenticatedUser(user, 'oidc'),
redirectUrl: stateData.redirectUrl,
providerName: config.name
};
} catch (error: any) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[OIDC] Callback error:', errorMsg);
return { success: false, error: error.message || 'SSO authentication failed' };
}
}
export interface OidcTestResult {
success: boolean;
error?: string;
issuer?: string;
endpoints?: {
authorization: string;
token: string;
userinfo?: string;
};
}
/**
* Test OIDC configuration by fetching discovery document
*/
export async function testOidcConnection(configId: number): Promise<OidcTestResult> {
const config = await getOidcConfig(configId);
if (!config) {
return { success: false, error: 'OIDC configuration not found' };
}
try {
const discovery = await getOidcDiscovery(config.issuerUrl);
return {
success: true,
issuer: discovery.issuer,
endpoints: {
authorization: discovery.authorization_endpoint,
token: discovery.token_endpoint,
userinfo: discovery.userinfo_endpoint
}
};
} catch (error: any) {
return { success: false, error: error.message || 'Failed to connect to OIDC provider' };
}
}
/**
* Build the OIDC logout URL for single logout
*/
export async function buildOidcLogoutUrl(
configId: number,
postLogoutRedirectUri?: string
): Promise<string | null> {
const config = await getOidcConfig(configId);
if (!config) return null;
try {
const discovery = await getOidcDiscovery(config.issuerUrl);
if (!discovery.end_session_endpoint) return null;
const params = new URLSearchParams({
client_id: config.clientId
});
if (postLogoutRedirectUri) {
params.set('post_logout_redirect_uri', postLogoutRedirectUri);
}
return `${discovery.end_session_endpoint}?${params.toString()}`;
} catch {
return null;
}
}