feat: added a login page with Authentik authentication

This commit is contained in:
Zakaria
2026-10-16 19:35:27 -04:00
parent 668911ed6e
commit 924e354788
18 changed files with 441 additions and 54 deletions
+223
View File
@@ -0,0 +1,223 @@
import {timingSafeEqual} from 'node:crypto'
import {createRemoteJWKSet, jwtVerify} from 'jose'
import type {Context, MiddlewareHandler} from 'hono'
import {deleteCookie, getCookie, setCookie} from 'hono/cookie'
import {renderLoginPage} from './loginPage.ts'
const SESSION_COOKIE = 'floci_ui_session'
const CSRF_COOKIE = 'floci_ui_csrf'
const LOGIN_WINDOW_MS = 15 * 60_000
const LOGIN_ATTEMPTS = 5
type AuthConfig = {
mode: 'local' | 'oidc'
adminUser?: string
passwordHash?: string
secret: string
idleTimeoutMs: number
absoluteTimeoutMs: number
secureCookies: boolean
oidc?: {issuer: string; clientId: string; clientSecret: string; publicBaseUrl: string}
}
type Session = {username: string; createdAt: number; lastSeenAt: number; csrfToken: string}
type Attempt = {count: number; firstAt: number; lockedUntil: number}
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}>()
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')
if (mode === 'oidc') {
const issuer = `${required(env.FLOCI_UI_OIDC_ISSUER, 'FLOCI_UI_OIDC_ISSUER').replace(/\/$/, '')}/`
return {mode, secret, idleTimeoutMs, absoluteTimeoutMs, secureCookies: env.FLOCI_UI_SECURE_COOKIES !== 'false', 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(/\/$/, '')}}
}
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: env.FLOCI_UI_SECURE_COOKIES !== 'false'}
}
export function authMiddleware(config: AuthConfig): MiddlewareHandler {
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 key = `${clientIp(c)}:${username.toLowerCase()}`
if (locked(key)) return loginFailure(c, 429)
const usernameMatches = sameToken(username, config.adminUser)
const passwordMatches = await Bun.password.verify(password, config.passwordHash!)
if (!usernameMatches || !passwordMatches) {
recordFailure(key)
return loginFailure(c, 401)
}
attempts.delete(key)
await createSession(c, config, config.adminUser!)
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 discovery = await oidcDiscovery(config.oidc.issuer)
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')
oidcRequests.set(state, {nonce, verifier, returnTo: safeReturnTo(c.req.query('returnTo')), expiresAt: Date.now() + 10 * 60_000})
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 code = c.req.query('code')
const request = state ? oidcRequests.get(state) : undefined
if (!state || !code || !request || request.expiresAt < Date.now()) return c.json({error: 'Invalid OIDC callback'}, 400)
oidcRequests.delete(state)
const discovery = await oidcDiscovery(config.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: `${config.oidc.publicBaseUrl}/api/auth/oidc/callback`, client_id: config.oidc.clientId, client_secret: config.oidc.clientSecret, code_verifier: request.verifier})})
const tokens = await tokenResponse.json() as {id_token?: string}
if (!tokenResponse.ok || !tokens.id_token) return c.json({error: 'OIDC token exchange failed'}, 401)
const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri))
const verified = await jwtVerify(tokens.id_token, jwks, {issuer: config.oidc.issuer, audience: config.oidc.clientId})
if (verified.payload.nonce !== request.nonce) return c.json({error: 'OIDC nonce validation failed'}, 401)
const username = String(verified.payload.preferred_username ?? verified.payload.email ?? verified.payload.sub ?? '')
if (!username) return c.json({error: 'OIDC identity has no usable subject'}, 401)
await createSession(c, config, username)
return c.redirect(request.returnTo, 302)
}
async function createSession(c: Context, config: AuthConfig, username: string): Promise<void> {
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})
}
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>Floci DevTools</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.'
return 'We could not complete your sign-in. Please try again.'
}
function isPublic(path: string): boolean { return path === '/login' || path === '/TheCloud.png' || path === '/authentik.svg' || 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) }
function safeReturnTo(value?: string): string { return value && value.startsWith('/') && !value.startsWith('//') ? value : '/console/aws' }
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 }
async function oidcDiscovery(issuer: string): Promise<{authorization_endpoint: string; token_endpoint: string; jwks_uri: string}> { const response = await fetch(`${issuer}.well-known/openid-configuration`); if (!response.ok) throw new Error('Unable to load OIDC discovery document'); return response.json() }
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): 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 >= LOGIN_ATTEMPTS) current.lockedUntil = now + Math.min(15 * 60_000, 30_000 * 2 ** (current.count - LOGIN_ATTEMPTS)); attempts.set(key, current) }
// Hono's portable Request API does not expose the peer address. Do not trust a
// client-supplied X-Forwarded-For value until the Bun server is wired to verify
// it against an explicit proxy address allow-list.
function clientIp(_c: Context): string { return 'unknown' }
function applySecurityHeaders(c: Context, config: AuthConfig, styleNonce: string): void { c.header('Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${styleNonce}' 'sha256-mgwziK4YXYUt2nNB711bt/nNzs3je0rpErGfX/6eqmc='; style-src 'self' 'nonce-${styleNonce}' https://fonts.googleapis.com; 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') }