Files
dockhand/lib/server/auth.ts
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

1347 lines
36 KiB
TypeScript

/**
* 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<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 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<string> {
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<boolean> {
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<Session> {
// 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<AuthenticatedUser | null> {
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<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) {
// 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<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' };
}
/**
* 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);
}
// 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<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
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<boolean> {
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<boolean> {
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<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 = 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<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);
}
}
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<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;
}
}