Files
floci-ui/packages/api/src/auth.ts
T
Zakaria 91ce11274d
CI / checks (pull_request) Successful in 25m47s
chore: show TheCloud instead of Floci in console text and messages
Labels, empty states, error messages and defaults the console shows now name
TheCloud or its runtimes. Identifiers that must match the emulator (FLOCI_*
settings, the X-Floci-CSRF header, emulator-returned host names) are unchanged.
2026-10-24 14:30:44 -04:00

352 lines
28 KiB
TypeScript

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<string, Session>()
const attempts = new Map<string, Attempt>()
const oidcRequests = new Map<string, {nonce: string; verifier: string; returnTo: string; expiresAt: number}>()
const discoveries = new Map<string, {document: OidcDiscovery; expiresAt: number}>()
const jwksSets = new Map<string, ReturnType<typeof createRemoteJWKSet>>()
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<Response> {
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<Response> {
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<Response> {
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<JWTPayload> {
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<boolean> {
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<Response> {
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<Session | null> {
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'
? `<form method="get" action="/api/auth/oidc/login"><input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}"><button class="sso-button" type="submit"><span class="ready"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 9.5 8 11 4.6-1.5 8-6 8-11V5l-8-3Zm0 4 4 1.5V11c0 3.2-1.9 6.2-4 7.4C9.9 17.2 8 14.2 8 11V7.5L12 6Z"/></svg>Sign in with Authentik</span><span class="loading"><i></i>Redirecting...</span></button></form>`
: `<form method="post" action="/api/auth/login"><input type="hidden" name="csrf" value="${csrf}"><input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}"><label for="username">Username</label><input id="username" name="username" autocomplete="username" required autofocus><label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required><button class="sso-button" type="submit">Sign in</button></form>`
const error = loginErrorMessage(c.req.query('error'))
return c.html(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Welcome back | TheCloud</title><style>:root{color-scheme:dark;--bg:#0f141a;--surface:#161d26;--raised:#232b37;--border:#424650;--text:#c6c6cd;--bright:#f9f9fa;--muted:#a4a4ad;--accent:#42b4ff;--danger:#ff9b93}@media(prefers-color-scheme:light){:root{color-scheme:light;--bg:#efe7d2;--surface:#fffaf0;--raised:#ece4cf;--border:rgba(21,20,15,.16);--text:#2a2620;--bright:#15140f;--muted:#5a5448;--accent:#ed6f5c;--danger:#bd3f30}}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;display:grid;place-items:center;padding:28px 20px;color:var(--text);font:14px Sora,Inter,system-ui,sans-serif;background:radial-gradient(circle at 14% 12%,color-mix(in srgb,var(--accent) 16%,transparent),transparent 28rem),linear-gradient(135deg,var(--bg),var(--raised));}.shell{width:min(420px,100%);animation:enter .45s ease-out}.brand{display:flex;align-items:center;gap:12px;margin:0 0 20px 8px;color:var(--bright)}.brand svg{width:34px;height:34px;fill:none;stroke:var(--accent);stroke-width:1.6}.brand strong{display:block;font-size:16px;letter-spacing:-.02em}.brand small{display:block;margin-top:2px;color:var(--muted);font-size:11px}.card{padding:30px;border:1px solid var(--border);border-radius:14px;background:color-mix(in srgb,var(--surface) 94%,transparent);box-shadow:0 24px 64px #0005}.eyebrow{margin:0 0 8px;color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}h1{margin:0;color:var(--bright);font-size:26px;letter-spacing:-.04em}p{margin:9px 0 24px;color:var(--muted);line-height:1.55}.alert{display:flex;gap:9px;margin:0 0 18px;padding:11px 12px;border:1px solid color-mix(in srgb,var(--danger) 55%,var(--border));border-radius:8px;color:var(--danger);background:color-mix(in srgb,var(--danger) 10%,transparent);line-height:1.4}.sso-button{display:grid;place-items:center;width:100%;min-height:46px;border:0;border-radius:8px;background:var(--accent);color:#06131d;font:700 14px inherit;cursor:pointer;transition:transform .15s,filter .15s,box-shadow .15s}.sso-button:hover{filter:brightness(1.08);box-shadow:0 8px 20px color-mix(in srgb,var(--accent) 28%,transparent)}.sso-button:active{transform:translateY(1px)}.sso-button:focus-visible{outline:3px solid color-mix(in srgb,var(--accent) 58%,white);outline-offset:3px}.ready,.loading{display:flex;align-items:center;gap:9px}.ready svg{width:19px;height:19px;fill:currentColor;stroke:none}.loading{display:none}.sso-button:active .ready{display:none}.sso-button:active .loading{display:flex}.loading i{width:15px;height:15px;border:2px solid #06131d55;border-top-color:#06131d;border-radius:50%;animation:spin .7s linear infinite}.footer{margin:19px 0 0;text-align:center;font-size:12px;color:var(--muted)}label{display:block;margin:14px 0 6px}input{width:100%;padding:10px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--bright)}@keyframes enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:380px){body{padding:18px 14px}.card{padding:24px 20px}h1{font-size:23px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}</style></head><body><main class="shell" aria-labelledby="welcome"><header class="brand"><svg viewBox="0 0 40 40" aria-hidden="true"><path d="M7 25c-3-7 3-13 10-11 4-8 16-5 16 4 5 1 6 8 1 10H12c-2 0-4-1-5-3Z"/><path d="M13 31h14"/></svg><span><strong>TheCloud</strong><small>Local AWS, on your own infrastructure.</small></span></header><section class="card" aria-label="Sign in"><div class="eyebrow">Secure console</div><h1 id="welcome">Welcome back</h1><p>Sign in with your account to access the console.</p>${error ? `<div class="alert" role="alert">${error}</div>` : ''}${content}<div class="footer">Protected by Authentik SSO</div></section></main></body></html>`)
}
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) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'}[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<OidcDiscovery> { 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<typeof createRemoteJWKSet> { 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<string> { 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') }