/** * Core Authentication Module * * Security features: * - Argon2id password hashing via Bun.password (memory-hard, timing-attack resistant) * - Cryptographically secure 32-byte random session tokens * - HttpOnly cookies (prevents XSS from reading tokens) * - Secure flag (HTTPS only in production) * - SameSite=Strict (CSRF protection) */ import { randomBytes } from 'node:crypto'; import os from 'node:os'; 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'; // 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 { 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 via Bun.password) // ============================================ /** * Hash a password using Argon2id via Bun's native password API * Argon2id is the recommended variant - resistant to both side-channel and GPU attacks */ export async function hashPassword(password: string): Promise { return Bun.password.hash(password, { algorithm: 'argon2id', memoryCost: 65536, // 64 MB timeCost: 3 // 3 iterations }); } /** * Verify a password against a hash * Uses constant-time comparison internally */ export async function verifyPassword(password: string, hash: string): Promise { try { return await Bun.password.verify(password, hash); } catch { return false; } } // ============================================ // Session Management // ============================================ /** * Generate a cryptographically secure session token * 32 bytes = 256 bits of entropy */ function generateSessionToken(): string { return randomBytes(32).toString('base64url'); } /** * Create a new session for a user * @param provider - Auth provider: 'local', or provider name like 'Keycloak', 'Azure AD', etc. */ export async function createUserSession( userId: number, provider: string, cookies: Cookies ): Promise { // Clean up expired sessions periodically await deleteExpiredSessions(); // Generate secure token const sessionId = generateSessionToken(); // Get session timeout from settings const settings = await getAuthSettings(); const expiresAt = new Date(Date.now() + settings.sessionTimeout * 1000).toISOString(); // Create session in database const session = await dbCreateSession(sessionId, userId, provider, expiresAt); // Set secure cookie setSessionCookie(cookies, sessionId, settings.sessionTimeout); // 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): void { cookies.set(SESSION_COOKIE_NAME, sessionId, { path: '/', httpOnly: true, // Prevents XSS attacks from reading cookie secure: process.env.NODE_ENV === 'production', // HTTPS only in production sameSite: 'strict', // CSRF protection 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 { const sessionId = getSessionIdFromCookies(cookies); if (!sessionId) return null; const session = await dbGetSession(sessionId); if (!session) return null; // Check if session is expired 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'); } /** * Destroy a session (logout) */ export async function destroySession(cookies: Cookies): Promise { 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 { 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 { 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 { // 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 { 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 { 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 { const user = await getUserByUsername(username); if (!user) { // Use constant time to prevent timing attacks await Bun.password.hash('dummy', { algorithm: 'argon2id' }); return { success: false, error: 'Invalid username or password' }; } if (!user.isActive) { return { success: false, error: 'Account is disabled' }; } 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 { const configs = await getLdapConfigs(); return configs.filter(config => config.enabled); } /** * Test LDAP connection and configuration */ export async function testLdapConnection(configId: number): Promise { 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 { // 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' }; } /** * Try authentication against a specific LDAP configuration */ async function tryLdapAuth( username: string, password: string, config: LdapConfig ): Promise { 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); } // Search for the user const filter = config.userFilter.replace('{{username}}', username); const { searchEntries } = await client.search(config.baseDn, { scope: 'sub', filter: filter, sizeLimit: 1, attributes: [ 'dn', config.usernameAttribute, config.emailAttribute, config.displayNameAttribute ] }); if (searchEntries.length === 0) { await client.unbind(); return { success: false, error: 'User not found' }; } 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) { // Assign Admin role await assignUserRole(user.id, adminRole.id, null); } // Note: We don't remove Admin role if not in LDAP group anymore // to prevent accidental lockouts (same behavior as before) } // 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)); for (const mapping of roleMappings) { // Skip if user already has this role if (existingRoleIds.has(mapping.roleId)) continue; // Check if user is a member of the LDAP group const isInGroup = await checkLdapGroupMembership(config, userDn, mapping.groupDn); if (isInGroup) { await assignUserRole(user.id, mapping.roleId, undefined); } } } 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 {} console.error('LDAP authentication error:', error); 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 { 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 searchBase = 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 && !config.groupFilter ? 'base' : 'sub', filter: groupFilter, sizeLimit: 1 }); await client.unbind(); return searchEntries.length > 0; } catch (error) { console.error('LDAP group membership check failed:', error); 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) // ============================================ import * as OTPAuth from 'otpauth'; import * as QRCode from 'qrcode'; /** * 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 (user must verify before it's enabled) await updateUser(userId, { mfaSecret: secretBase32 }); return { secret: secretBase32, qrDataUrl }; } /** * Verify MFA token and enable MFA if valid */ export async function verifyAndEnableMfa(userId: number, token: string): Promise { const user = await getUser(userId); if (!user || !user.mfaSecret) return false; const totp = new OTPAuth.TOTP({ issuer: 'Dockhand', label: user.username, algorithm: 'SHA1', digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(user.mfaSecret) }); const delta = totp.validate({ token, window: 1 }); if (delta === null) return false; // Enable MFA await updateUser(userId, { mfaEnabled: true }); return true; } /** * Verify MFA token during login */ export async function verifyMfaToken(userId: number, token: string): Promise { const user = await getUser(userId); if (!user || !user.mfaEnabled || !user.mfaSecret) return false; const totp = new OTPAuth.TOTP({ issuer: 'Dockhand', label: user.username, algorithm: 'SHA1', digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(user.mfaSecret) }); const delta = totp.validate({ token, window: 1 }); return delta !== null; } /** * Disable MFA for a user */ export async function disableMfa(userId: number): Promise { 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(); 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 | undefined; var __authOidcStateCleanupInterval: ReturnType | 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(); // 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(); 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 { const configs = await getOidcConfigs(); return configs.filter(config => config.enabled); } /** * Fetch and cache OIDC discovery document */ async function getOidcDiscovery(issuerUrl: string): Promise { 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 = randomBytes(32).toString('base64url'); const hasher = new Bun.CryptoHasher('sha256'); hasher.update(codeVerifier); const codeChallenge = hasher.digest('base64url') as string; 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 = randomBytes(32).toString('base64url'); const nonce = randomBytes(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) { console.error('Failed to build OIDC authorization URL:', error); 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 { // 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 = {}; 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); } } 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) { console.error('OIDC callback error:', error); 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 { 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 { 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; } }