import {timingSafeEqual} from 'node:crypto' import {BlockList, isIP} from 'node:net' import {createRemoteJWKSet, jwtVerify, type JWTPayload} from 'jose' import type {Context, MiddlewareHandler} from 'hono' import {getConnInfo} from 'hono/bun' import {deleteCookie, getCookie, setCookie} from 'hono/cookie' import {renderLoginPage} from './loginPage' const SESSION_COOKIE = 'floci_ui_session' const CSRF_COOKIE = 'floci_ui_csrf' const LOGIN_WINDOW_MS = 15 * 60_000 const LOGIN_ATTEMPTS = 5 const LOGIN_ATTEMPTS_PER_IP = 20 const OIDC_STARTS_PER_IP = 30 const OIDC_REQUEST_TTL_MS = 10 * 60_000 const OIDC_DISCOVERY_TTL_MS = 60 * 60_000 const SWEEP_INTERVAL_MS = 60_000 const MAX_SESSIONS = 1_000 const MAX_OIDC_REQUESTS = 1_000 const MAX_ATTEMPTS = 10_000 type OidcConfig = {issuer: string; clientId: string; clientSecret: string; publicBaseUrl: string; allowedGroups: string[]; allowedEmails: string[]; allowAllUsers: boolean} export type AuthConfig = { mode: 'local' | 'oidc' adminUser?: string passwordHash?: string secret: string idleTimeoutMs: number absoluteTimeoutMs: number secureCookies: boolean trustedProxies: BlockList | null oidc?: OidcConfig } type Session = {username: string; createdAt: number; lastSeenAt: number; csrfToken: string} type Attempt = {count: number; firstAt: number; lockedUntil: number} type OidcDiscovery = {authorization_endpoint: string; token_endpoint: string; jwks_uri: string; userinfo_endpoint?: string} const sessions = new Map() const attempts = new Map() const oidcRequests = new Map() const discoveries = new Map() const jwksSets = new Map>() export function authConfig(env = process.env): AuthConfig { const mode = (env.FLOCI_UI_AUTH_MODE ?? 'local') as 'local' | 'oidc' if (mode !== 'local' && mode !== 'oidc') throw new Error('FLOCI_UI_AUTH_MODE must be local or oidc') const secret = required(env.FLOCI_UI_SESSION_SECRET, 'FLOCI_UI_SESSION_SECRET') if (secret.length < 32) throw new Error('FLOCI_UI_SESSION_SECRET must be at least 32 characters') const idleTimeoutMs = seconds(env.FLOCI_UI_SESSION_IDLE_TIMEOUT_SECONDS, 1800) const absoluteTimeoutMs = seconds(env.FLOCI_UI_SESSION_ABSOLUTE_TIMEOUT_SECONDS, 28_800) if (idleTimeoutMs > absoluteTimeoutMs) throw new Error('FLOCI_UI_SESSION_IDLE_TIMEOUT_SECONDS cannot exceed FLOCI_UI_SESSION_ABSOLUTE_TIMEOUT_SECONDS') const secureCookies = env.FLOCI_UI_SECURE_COOKIES !== 'false' const trustedProxies = parseTrustedProxies(env.FLOCI_UI_TRUSTED_PROXIES) if (mode === 'oidc') { const issuer = `${required(env.FLOCI_UI_OIDC_ISSUER, 'FLOCI_UI_OIDC_ISSUER').replace(/\/$/, '')}/` const allowedGroups = list(env.FLOCI_UI_OIDC_ALLOWED_GROUPS) const allowedEmails = list(env.FLOCI_UI_OIDC_ALLOWED_EMAILS).map((email) => email.toLowerCase()) const allowAllUsers = env.FLOCI_UI_OIDC_ALLOW_ALL_USERS === 'true' if (!allowAllUsers && allowedGroups.length === 0 && allowedEmails.length === 0) { throw new Error('OIDC mode requires FLOCI_UI_OIDC_ALLOWED_GROUPS or FLOCI_UI_OIDC_ALLOWED_EMAILS (or FLOCI_UI_OIDC_ALLOW_ALL_USERS=true to admit every identity-provider user)') } return {mode, secret, idleTimeoutMs, absoluteTimeoutMs, secureCookies, trustedProxies, oidc: {issuer, clientId: required(env.FLOCI_UI_OIDC_CLIENT_ID, 'FLOCI_UI_OIDC_CLIENT_ID'), clientSecret: required(env.FLOCI_UI_OIDC_CLIENT_SECRET, 'FLOCI_UI_OIDC_CLIENT_SECRET'), publicBaseUrl: required(env.FLOCI_UI_PUBLIC_BASE_URL, 'FLOCI_UI_PUBLIC_BASE_URL').replace(/\/$/, ''), allowedGroups, allowedEmails, allowAllUsers}} } const adminUser = required(env.FLOCI_UI_ADMIN_USER, 'FLOCI_UI_ADMIN_USER') const passwordHash = required(env.FLOCI_UI_ADMIN_PASSWORD_HASH, 'FLOCI_UI_ADMIN_PASSWORD_HASH') if (!passwordHash.startsWith('$argon2id$') && !passwordHash.startsWith('$2')) throw new Error('FLOCI_UI_ADMIN_PASSWORD_HASH must be an argon2id or bcrypt hash') return {mode, adminUser, passwordHash, secret, idleTimeoutMs, absoluteTimeoutMs, secureCookies, trustedProxies} } export function authMiddleware(config: AuthConfig): MiddlewareHandler { setInterval(() => sweepExpired(config), SWEEP_INTERVAL_MS).unref() return async (c, next) => { const styleNonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64url') c.set('styleNonce', styleNonce) applySecurityHeaders(c, config, styleNonce) if (c.req.method === 'OPTIONS') return next() if (isPublic(c.req.path)) return next() const session = await sessionFor(c, config) if (!session) return unauthenticated(c) if (stateChanging(c.req.method) && !csrfValid(c, session)) return c.json({error: 'CSRF validation failed'}, 403) await next() } } export function authRoutes(config: AuthConfig): MiddlewareHandler { return async (c, next) => { if (c.req.path === '/login' && c.req.method === 'GET') return renderLogin(c, config) if (c.req.path === '/api/auth/login' && c.req.method === 'POST') return login(c, config) if (c.req.path === '/api/auth/oidc/login' && c.req.method === 'GET') return oidcLogin(c, config) if (c.req.path === '/api/auth/oidc/callback' && c.req.method === 'GET') return oidcCallback(c, config) if (c.req.path === '/api/auth/logout' && c.req.method === 'POST') return logout(c, config) if (c.req.path === '/api/auth/session' && c.req.method === 'GET') { const session = await sessionFor(c, config) return session ? c.json({authenticated: true, username: session.username, csrfToken: session.csrfToken}) : c.json({authenticated: false}, 401) } return next() } } async function login(c: Context, config: AuthConfig): Promise { if (config.mode !== 'local') return c.json({error: 'Local login is disabled'}, 404) const form = await c.req.parseBody() const username = typeof form.username === 'string' ? form.username : '' const password = typeof form.password === 'string' ? form.password : '' const csrf = typeof form.csrf === 'string' ? form.csrf : c.req.header('x-floci-csrf') ?? '' if (!sameToken(getCookie(c, CSRF_COOKIE), csrf)) return loginFailure(c, 403) const ip = clientIp(c, config) const key = `login:${ip}:${username.toLowerCase()}` const ipKey = `login-ip:${ip}` if (locked(key) || locked(ipKey)) return loginFailure(c, 429) const usernameMatches = sameToken(username, config.adminUser) const passwordMatches = await Bun.password.verify(password, config.passwordHash!) if (!usernameMatches || !passwordMatches) { recordFailure(key, LOGIN_ATTEMPTS) recordFailure(ipKey, LOGIN_ATTEMPTS_PER_IP) return loginFailure(c, 401) } attempts.delete(key) if (!await createSession(c, config, config.adminUser!)) return c.json({error: 'Too many active sessions. Try again later.'}, 503) const redirectTo = safeReturnTo(typeof form.returnTo === 'string' ? form.returnTo : undefined) return acceptsHtml(c) ? c.redirect(redirectTo, 302) : c.json({ok: true, redirectTo}) } async function oidcLogin(c: Context, config: AuthConfig): Promise { if (!config.oidc) return c.notFound() const startKey = `oidc-start:${clientIp(c, config)}` if (locked(startKey)) return c.redirect('/login?error=throttled', 302) recordFailure(startKey, OIDC_STARTS_PER_IP) let discovery: OidcDiscovery try { discovery = await oidcDiscovery(config.oidc.issuer) } catch { return c.redirect('/login?error=unavailable', 302) } const state = crypto.randomUUID() const nonce = crypto.randomUUID() const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url') const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) const challenge = Buffer.from(digest).toString('base64url') if (oidcRequests.size >= MAX_OIDC_REQUESTS) oidcRequests.delete(oidcRequests.keys().next().value!) oidcRequests.set(state, {nonce, verifier, returnTo: safeReturnTo(c.req.query('returnTo')), expiresAt: Date.now() + OIDC_REQUEST_TTL_MS}) const url = new URL(discovery.authorization_endpoint) url.search = new URLSearchParams({response_type: 'code', client_id: config.oidc.clientId, redirect_uri: `${config.oidc.publicBaseUrl}/api/auth/oidc/callback`, scope: 'openid profile email', state, nonce, code_challenge: challenge, code_challenge_method: 'S256'}).toString() return c.redirect(url.toString(), 302) } async function oidcCallback(c: Context, config: AuthConfig): Promise { if (!config.oidc) return c.notFound() const state = c.req.query('state') const request = state ? oidcRequests.get(state) : undefined if (state) oidcRequests.delete(state) if (c.req.query('error')) return c.redirect(`/login?error=${c.req.query('error') === 'access_denied' ? 'denied' : 'failed'}`, 302) const code = c.req.query('code') if (!code || !request || request.expiresAt < Date.now()) return c.redirect('/login?error=expired', 302) let claims: JWTPayload try { claims = await verifiedOidcClaims(config.oidc, code, request) } catch { return c.redirect('/login?error=failed', 302) } if (!oidcUserAllowed(claims, config.oidc)) return c.redirect('/login?error=forbidden', 302) const username = String(claims.preferred_username ?? claims.email ?? claims.sub ?? '') if (!username) return c.redirect('/login?error=failed', 302) if (!await createSession(c, config, username)) return c.redirect('/login?error=unavailable', 302) attempts.delete(`oidc-start:${clientIp(c, config)}`) return c.redirect(request.returnTo, 302) } async function verifiedOidcClaims(oidc: OidcConfig, code: string, request: {nonce: string; verifier: string}): Promise { const discovery = await oidcDiscovery(oidc.issuer) 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: `${oidc.publicBaseUrl}/api/auth/oidc/callback`, client_id: oidc.clientId, client_secret: oidc.clientSecret, code_verifier: request.verifier})}) const tokens = await tokenResponse.json() as {id_token?: string; access_token?: string} if (!tokenResponse.ok || !tokens.id_token) throw new Error('OIDC token exchange failed') const {payload} = await jwtVerify(tokens.id_token, jwksFor(discovery.jwks_uri), {issuer: oidc.issuer, audience: oidc.clientId}) if (payload.nonce !== request.nonce) throw new Error('OIDC nonce validation failed') if (oidc.allowedGroups.length === 0 || 'groups' in payload || !tokens.access_token || !discovery.userinfo_endpoint) return payload // Providers may leave profile claims such as `groups` out of the ID token; the // UserInfo response is authoritative for them once its `sub` matches. const userinfoResponse = await fetch(discovery.userinfo_endpoint, {headers: {authorization: `Bearer ${tokens.access_token}`}}) if (!userinfoResponse.ok) throw new Error('OIDC userinfo request failed') const userinfo = await userinfoResponse.json() as JWTPayload if (userinfo.sub !== payload.sub) throw new Error('OIDC userinfo subject mismatch') return {...userinfo, ...payload, groups: userinfo.groups} } function oidcUserAllowed(claims: JWTPayload, oidc: OidcConfig): boolean { if (oidc.allowAllUsers) return true const groups = Array.isArray(claims.groups) ? claims.groups.filter((group): group is string => typeof group === 'string') : [] if (groups.some((group) => oidc.allowedGroups.includes(group))) return true const email = typeof claims.email === 'string' ? claims.email.toLowerCase() : '' return claims.email_verified === true && email !== '' && oidc.allowedEmails.includes(email) } async function createSession(c: Context, config: AuthConfig, username: string): Promise { if (sessions.size >= MAX_SESSIONS) sweepExpired(config) if (sessions.size >= MAX_SESSIONS) return false const id = crypto.randomUUID() const csrfToken = crypto.randomUUID() sessions.set(id, {username, createdAt: Date.now(), lastSeenAt: Date.now(), csrfToken}) setCookie(c, SESSION_COOKIE, `${id}.${await signature(id, config.secret)}`, cookieOptions(config)) setCookie(c, CSRF_COOKIE, csrfToken, {...cookieOptions(config), httpOnly: false}) return true } export function sweepExpired(config: AuthConfig, now = Date.now()): void { for (const [id, session] of sessions) { if (now - session.lastSeenAt > config.idleTimeoutMs || now - session.createdAt > config.absoluteTimeoutMs) sessions.delete(id) } for (const [state, request] of oidcRequests) { if (request.expiresAt < now) oidcRequests.delete(state) } for (const [key, attempt] of attempts) { if (attempt.lockedUntil <= now && now - attempt.firstAt > LOGIN_WINDOW_MS) attempts.delete(key) } } export function authStateSizes(): {sessions: number; attempts: number; oidcRequests: number} { return {sessions: sessions.size, attempts: attempts.size, oidcRequests: oidcRequests.size} } async function logout(c: Context, config: AuthConfig): Promise { const raw = getCookie(c, SESSION_COOKIE) const id = raw?.split('.', 1)[0] if (id) sessions.delete(id) deleteCookie(c, SESSION_COOKIE, {path: '/', secure: config.secureCookies, sameSite: 'Lax'}) deleteCookie(c, CSRF_COOKIE, {path: '/', secure: config.secureCookies, sameSite: 'Lax'}) return c.json({ok: true}) } async function sessionFor(c: Context, config: AuthConfig): Promise { const raw = getCookie(c, SESSION_COOKIE) if (!raw) return null const [id, signed] = raw.split('.') if (!id || !signed || !sameToken(signed, await signature(id, config.secret))) return null const session = sessions.get(id) if (!session) return null const now = Date.now() if (now - session.lastSeenAt > config.idleTimeoutMs || now - session.createdAt > config.absoluteTimeoutMs) { sessions.delete(id) return null } session.lastSeenAt = now return session } function csrfValid(c: Context, session: Session): boolean { return sameToken(getCookie(c, CSRF_COOKIE), session.csrfToken) && sameToken(c.req.header('x-floci-csrf'), session.csrfToken) } function renderLogin(c: Context, config: AuthConfig): Response { const csrf = getCookie(c, CSRF_COOKIE) ?? crypto.randomUUID() if (!getCookie(c, CSRF_COOKIE)) setCookie(c, CSRF_COOKIE, csrf, {...cookieOptions(config), httpOnly: false}) const returnTo = safeReturnTo(c.req.query('returnTo')) return c.html(renderLoginPage({ mode: config.mode, csrf, returnTo, error: loginErrorMessage(c.req.query('error')), styleNonce: String(c.get('styleNonce')), })) } function loginPage(c: Context, config: AuthConfig): Response { const csrf = getCookie(c, CSRF_COOKIE) ?? crypto.randomUUID() if (!getCookie(c, CSRF_COOKIE)) setCookie(c, CSRF_COOKIE, csrf, {...cookieOptions(config), httpOnly: false}) const returnTo = safeReturnTo(c.req.query('returnTo')) const content = config.mode === 'oidc' ? `
` : `
` const error = loginErrorMessage(c.req.query('error')) return c.html(`Welcome back | TheCloud
TheCloudLocal AWS, on your own infrastructure.
Secure console

Welcome back

Sign in with your account to access the console.

${error ? `` : ''}${content}
`) } function loginFailure(c: Context, status: 401 | 403 | 429): Response { if (acceptsHtml(c)) return c.redirect('/login?error=1', 302) return c.json({error: status === 429 ? 'Too many sign-in attempts. Try again later.' : 'Invalid username or password.'}, status) } function loginErrorMessage(code: string | undefined): string | null { if (!code) return null if (code === 'denied') return 'Your sign-in was cancelled. Please try again when you are ready.' if (code === 'expired') return 'Your sign-in session expired. Please start again.' if (code === 'forbidden') return 'Your account is not allowed to access this console.' if (code === 'throttled') return 'Too many sign-in attempts. Try again later.' if (code === 'unavailable') return 'Sign-in is temporarily unavailable. Please try again shortly.' return 'We could not complete your sign-in. Please try again.' } function isPublic(path: string): boolean { return path === '/login' || path === '/login.js' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' } function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) } // Browsers treat `\` like `/` and drop tabs/newlines in URLs, so `/\evil.example` would // otherwise pass a plain prefix check and land on another origin. function safeReturnTo(value?: string): string { const fallback = '/console/aws' if (!value || !value.startsWith('/') || /[\\\u0000-\u001f\u007f]/.test(value)) return fallback const base = 'http://return-to.invalid' try { const url = new URL(value, base) return url.origin === base ? `${url.pathname}${url.search}${url.hash}` : fallback } catch { return fallback } } function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character] ?? character)) } function acceptsHtml(c: Context): boolean { return c.req.header('accept')?.includes('text/html') ?? false } function stateChanging(method: string): boolean { return !['GET', 'HEAD', 'OPTIONS'].includes(method) } function cookieOptions(config: AuthConfig) { return {httpOnly: true, secure: config.secureCookies, sameSite: 'Lax' as const, path: '/', maxAge: Math.floor(config.absoluteTimeoutMs / 1000)} } function seconds(value: string | undefined, fallback: number): number { const parsed = Number(value ?? fallback); if (!Number.isInteger(parsed) || parsed < 60) throw new Error('Session timeout values must be whole seconds of at least 60'); return parsed * 1000 } function required(value: string | undefined, name: string): string { if (!value?.trim()) throw new Error(`${name} is required`); return value } function list(value: string | undefined): string[] { return (value ?? '').split(',').map((entry) => entry.trim()).filter(Boolean) } async function oidcDiscovery(issuer: string): Promise { const cached = discoveries.get(issuer); if (cached && cached.expiresAt > Date.now()) return cached.document; const response = await fetch(`${issuer}.well-known/openid-configuration`); if (!response.ok) throw new Error('Unable to load OIDC discovery document'); const document = await response.json() as OidcDiscovery; discoveries.set(issuer, {document, expiresAt: Date.now() + OIDC_DISCOVERY_TTL_MS}); return document } function jwksFor(uri: string): ReturnType { let jwks = jwksSets.get(uri); if (!jwks) { jwks = createRemoteJWKSet(new URL(uri)); jwksSets.set(uri, jwks) } return jwks } async function signature(value: string, secret: string): Promise { const bytes = await crypto.subtle.sign('HMAC', await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), {name: 'HMAC', hash: 'SHA-256'}, false, ['sign']), new TextEncoder().encode(value)); return Buffer.from(bytes).toString('base64url') } function sameToken(left: string | undefined, right: string | undefined): boolean { if (!left || !right) return false; const a = Buffer.from(left); const b = Buffer.from(right); return a.length === b.length && timingSafeEqual(a, b) } function locked(key: string): boolean { const attempt = attempts.get(key); return Boolean(attempt && attempt.lockedUntil > Date.now()) } function recordFailure(key: string, limit: number): void { const now = Date.now(); const attempt = attempts.get(key); const current = !attempt || now - attempt.firstAt > LOGIN_WINDOW_MS ? {count: 0, firstAt: now, lockedUntil: 0} : attempt; current.count++; if (current.count >= limit) current.lockedUntil = now + Math.min(15 * 60_000, 30_000 * 2 ** (current.count - limit)); if (!attempts.has(key) && attempts.size >= MAX_ATTEMPTS) attempts.delete(attempts.keys().next().value!); attempts.set(key, current) } // X-Forwarded-For is client-controlled, so it is only read when the TCP peer is an // allow-listed proxy. The right-most address not belonging to a trusted proxy is the // one the outermost trusted proxy actually saw; anything left of it may be forged. function clientIp(c: Context, config: AuthConfig): string { const peer = peerAddress(c) if (!peer || !trusted(peer, config.trustedProxies)) return peer ?? 'unknown' const forwarded = (c.req.header('x-forwarded-for') ?? '').split(',').map((entry) => normalizeIp(entry.trim())) for (let index = forwarded.length - 1; index >= 0; index--) { const address = forwarded[index] if (!isIP(address)) return peer if (!trusted(address, config.trustedProxies)) return address } return peer } function peerAddress(c: Context): string | undefined { try { const address = getConnInfo(c).remote.address; return address ? normalizeIp(address) : undefined } catch { return undefined } } function normalizeIp(address: string): string { return /^::ffff:\d+\.\d+\.\d+\.\d+$/i.test(address) ? address.slice(7) : address } function trusted(address: string, proxies: BlockList | null): boolean { return Boolean(proxies?.check(address, isIP(address) === 6 ? 'ipv6' : 'ipv4')) } function parseTrustedProxies(value: string | undefined): BlockList | null { const entries = list(value) if (entries.length === 0) return null const proxies = new BlockList() for (const entry of entries) { const [address, prefix] = entry.split('/') const family = isIP(address) const bits = prefix === undefined ? undefined : Number(prefix) if (!family || (bits !== undefined && (!Number.isInteger(bits) || bits < 0 || bits > (family === 6 ? 128 : 32)))) throw new Error(`FLOCI_UI_TRUSTED_PROXIES has an invalid IP or CIDR: ${entry}`) const type = family === 6 ? 'ipv6' : 'ipv4' if (bits === undefined) proxies.addAddress(address, type) else proxies.addSubnet(address, bits, type) } return proxies } function applySecurityHeaders(c: Context, config: AuthConfig, styleNonce: string): void { c.header('Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${styleNonce}' 'sha256-AbRSUfgtaJwJXaS+/FXX4ATIx390dHnVN1Z7G7jqB8s='; style-src 'self' 'nonce-${styleNonce}' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; object-src 'none'`); c.header('X-Frame-Options', 'DENY'); c.header('X-Content-Type-Options', 'nosniff'); c.header('Referrer-Policy', 'same-origin'); if (config.secureCookies) c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') }