From 2db02ed36940a96e0fa0067753fbaa54f2023257 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Fri, 23 Oct 2026 09:44:14 -0400 Subject: [PATCH] fix(api): harden sign-in throttling, OIDC authorization and auth state - Key login throttling on the real peer address; read X-Forwarded-For only from FLOCI_UI_TRUSTED_PROXIES (right-most untrusted hop). Add a per-IP limit across usernames so one attacker can no longer lock out the admin. - Require an OIDC allow-list (FLOCI_UI_OIDC_ALLOWED_GROUPS / _EMAILS, or FLOCI_UI_OIDC_ALLOW_ALL_USERS=true); fall back to UserInfo for groups when the ID token omits them. Redirect IdP and token errors to /login instead of returning 500s; cache discovery and JWKS. - Sweep expired sessions, attempts and pending OIDC requests every 60s, cap each map, and throttle OIDC sign-in starts per IP. - Fix the .ts import extension that broke the API type-check. - Add auth.test.ts and document the new settings. --- .env.example | 9 ++ README.md | 4 +- docker-compose.yml | 4 + packages/api/src/auth.test.ts | 229 ++++++++++++++++++++++++++++++++++ packages/api/src/auth.ts | 180 +++++++++++++++++++++----- 5 files changed, 392 insertions(+), 34 deletions(-) create mode 100644 packages/api/src/auth.test.ts diff --git a/.env.example b/.env.example index 0c106f6..97c3c26 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,10 @@ FLOCI_UI_SESSION_IDLE_TIMEOUT_SECONDS=1800 FLOCI_UI_SESSION_ABSOLUTE_TIMEOUT_SECONDS=28800 # Set false only for local HTTP development. Keep true behind HTTPS reverse proxies. FLOCI_UI_SECURE_COOKIES=true +# Reverse proxies (IPs or CIDRs, comma-separated) allowed to report the client address +# via X-Forwarded-For. Required behind Nginx Proxy Manager, otherwise every visitor +# shares the proxy's address for sign-in throttling. Leave empty when exposed directly. +# FLOCI_UI_TRUSTED_PROXIES=192.168.1.10 # Optional Authentik / OIDC mode. These are server-only values. # FLOCI_UI_AUTH_MODE=oidc @@ -27,3 +31,8 @@ FLOCI_UI_SECURE_COOKIES=true # FLOCI_UI_OIDC_CLIENT_ID= # FLOCI_UI_OIDC_CLIENT_SECRET= # FLOCI_UI_PUBLIC_BASE_URL=https://thecloud.example.com +# OIDC mode refuses to start without an allow-list (comma-separated). Group names come +# from the `groups` claim; emails must also be verified by the identity provider. +# FLOCI_UI_OIDC_ALLOWED_GROUPS=thecloud-admins +# FLOCI_UI_OIDC_ALLOWED_EMAILS= +# FLOCI_UI_OIDC_ALLOW_ALL_USERS=false diff --git a/README.md b/README.md index 561804f..dbfb555 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,9 @@ Open [http://localhost:4500](http://localhost:4500). Floci UI requires an administrator username, an Argon2id password hash, and a session secret. Copy `.env.example` to `.env`, generate the hash with `cd packages/api && bun run scripts/hash-password.ts ''`, and generate a session secret with `openssl rand -base64 48`. Do not put plaintext passwords in `.env`. -Put only Floci UI behind Nginx Proxy Manager, terminating TLS there and forwarding to port `4500`. Keep `FLOCI_UI_SECURE_COOKIES=true` in that deployment. Bind Floci core (`4566`) to the Docker network or localhost only: it must not be publicly reachable, because the authenticated UI proxy is the intended access path. Configure Nginx Proxy Manager to pass `X-Forwarded-Proto` and `X-Forwarded-For`, and place Cloudflare or another edge proxy in front of it for public deployments. +Put only Floci UI behind Nginx Proxy Manager, terminating TLS there and forwarding to port `4500`. Keep `FLOCI_UI_SECURE_COOKIES=true` in that deployment. Bind Floci core (`4566`) to the Docker network or localhost only: it must not be publicly reachable, because the authenticated UI proxy is the intended access path. Configure Nginx Proxy Manager to pass `X-Forwarded-Proto` and `X-Forwarded-For`, and place Cloudflare or another edge proxy in front of it for public deployments. Set `FLOCI_UI_TRUSTED_PROXIES` to the proxy's IP or CIDR (comma-separated for several hops, e.g. Cloudflare's published ranges when it sits in front of Nginx Proxy Manager); `X-Forwarded-For` is ignored from any other peer, and without it sign-in throttling treats every visitor as the proxy. -Set `FLOCI_UI_AUTH_MODE=oidc` to use an OIDC provider such as Authentik. Configure `FLOCI_UI_OIDC_ISSUER`, `FLOCI_UI_OIDC_CLIENT_ID`, `FLOCI_UI_OIDC_CLIENT_SECRET`, and `FLOCI_UI_PUBLIC_BASE_URL`; the callback is `${FLOCI_UI_PUBLIC_BASE_URL}/api/auth/oidc/callback`. +Set `FLOCI_UI_AUTH_MODE=oidc` to use an OIDC provider such as Authentik. Configure `FLOCI_UI_OIDC_ISSUER`, `FLOCI_UI_OIDC_CLIENT_ID`, `FLOCI_UI_OIDC_CLIENT_SECRET`, and `FLOCI_UI_PUBLIC_BASE_URL`; the callback is `${FLOCI_UI_PUBLIC_BASE_URL}/api/auth/oidc/callback`. OIDC mode also requires an allow-list: `FLOCI_UI_OIDC_ALLOWED_GROUPS` (matched against the `groups` claim, read from UserInfo when the ID token omits it) and/or `FLOCI_UI_OIDC_ALLOWED_EMAILS` (verified emails only). Set `FLOCI_UI_OIDC_ALLOW_ALL_USERS=true` only if every user of the identity provider should have console access. ## What The UI Actually Exposes Today diff --git a/docker-compose.yml b/docker-compose.yml index 4518626..6431899 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,10 @@ services: FLOCI_UI_OIDC_CLIENT_ID: ${FLOCI_UI_OIDC_CLIENT_ID:-} FLOCI_UI_OIDC_CLIENT_SECRET: ${FLOCI_UI_OIDC_CLIENT_SECRET:-} FLOCI_UI_PUBLIC_BASE_URL: ${FLOCI_UI_PUBLIC_BASE_URL:-} + FLOCI_UI_OIDC_ALLOWED_GROUPS: ${FLOCI_UI_OIDC_ALLOWED_GROUPS:-} + FLOCI_UI_OIDC_ALLOWED_EMAILS: ${FLOCI_UI_OIDC_ALLOWED_EMAILS:-} + FLOCI_UI_OIDC_ALLOW_ALL_USERS: ${FLOCI_UI_OIDC_ALLOW_ALL_USERS:-false} + FLOCI_UI_TRUSTED_PROXIES: ${FLOCI_UI_TRUSTED_PROXIES:-} extra_hosts: - "host.docker.internal:host-gateway" depends_on: diff --git a/packages/api/src/auth.test.ts b/packages/api/src/auth.test.ts new file mode 100644 index 0000000..84a4fec --- /dev/null +++ b/packages/api/src/auth.test.ts @@ -0,0 +1,229 @@ +import {afterEach, beforeAll, describe, expect, test} from 'bun:test' +import {Hono} from 'hono' +import {exportJWK, generateKeyPair, SignJWT, type JWK, type JWTPayload} from 'jose' +import {authConfig, authMiddleware, authRoutes, authStateSizes, sweepExpired, type AuthConfig} from './auth' + +const SECRET = '01234567890123456789012345678901' +const ISSUER = 'https://idp.test/application/o/the-cloud/' +const CLIENT_ID = 'the-cloud' +const PUBLIC_BASE_URL = 'https://thecloud.test' +const NPM_PROXY = '192.168.1.10' +const realFetch = globalThis.fetch + +let passwordHash: string + +beforeAll(async () => { + passwordHash = await Bun.password.hash('correct-horse-battery') +}) + +afterEach(() => { + globalThis.fetch = realFetch +}) + +function app(config: AuthConfig) { + const hono = new Hono() + hono.use('*', authMiddleware(config)) + hono.use('*', authRoutes(config)) + hono.get('/api/protected', (c) => c.json({ok: true})) + return hono +} + +function request(hono: Hono, url: string, peer: string, init: RequestInit = {}) { + return hono.fetch(new Request(`http://localhost${url}`, init), {requestIP: () => ({address: peer, family: peer.includes(':') ? 'IPv6' : 'IPv4', port: 50000})}) +} + +function localConfig(env: Record = {}) { + return authConfig({FLOCI_UI_SESSION_SECRET: SECRET, FLOCI_UI_ADMIN_USER: 'admin', FLOCI_UI_ADMIN_PASSWORD_HASH: passwordHash, FLOCI_UI_SECURE_COOKIES: 'false', ...env}) +} + +function oidcEnv(env: Record = {}): Record { + return {FLOCI_UI_SESSION_SECRET: SECRET, FLOCI_UI_AUTH_MODE: 'oidc', FLOCI_UI_OIDC_ISSUER: ISSUER, FLOCI_UI_OIDC_CLIENT_ID: CLIENT_ID, FLOCI_UI_OIDC_CLIENT_SECRET: 'client-secret', FLOCI_UI_PUBLIC_BASE_URL: PUBLIC_BASE_URL, FLOCI_UI_SECURE_COOKIES: 'false', ...env} +} + +function login(hono: Hono, peer: string, password: string, headers: Record = {}) { + return request(hono, '/api/auth/login', peer, { + method: 'POST', + headers: {cookie: 'floci_ui_csrf=csrf-token', 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json', ...headers}, + body: new URLSearchParams({username: 'admin', password, csrf: 'csrf-token'}), + }) +} + +async function failLogins(hono: Hono, peer: string, count: number, headers: Record = {}) { + for (let index = 0; index < count; index++) expect((await login(hono, peer, 'wrong-password', headers)).status).toBe(401) +} + +describe('login throttling', () => { + test('locks out the attacking address without locking the admin out elsewhere', async () => { + const hono = app(localConfig()) + await failLogins(hono, '203.0.113.1', 5) + + expect((await login(hono, '203.0.113.1', 'correct-horse-battery')).status).toBe(429) + expect((await login(hono, '198.51.100.1', 'correct-horse-battery')).status).toBe(200) + }) + + test('ignores X-Forwarded-For from a peer that is not a trusted proxy', async () => { + const hono = app(localConfig({FLOCI_UI_TRUSTED_PROXIES: NPM_PROXY})) + for (let index = 0; index < 5; index++) { + await login(hono, '203.0.113.2', 'wrong-password', {'x-forwarded-for': `198.51.100.${index + 10}`}) + } + + expect((await login(hono, '203.0.113.2', 'correct-horse-battery', {'x-forwarded-for': '198.51.100.99'})).status).toBe(429) + }) + + test('keys clients behind a trusted proxy by their forwarded address', async () => { + const hono = app(localConfig({FLOCI_UI_TRUSTED_PROXIES: `${NPM_PROXY}/32`})) + await failLogins(hono, NPM_PROXY, 5, {'x-forwarded-for': '203.0.113.3'}) + + expect((await login(hono, NPM_PROXY, 'correct-horse-battery', {'x-forwarded-for': '203.0.113.3'})).status).toBe(429) + expect((await login(hono, NPM_PROXY, 'correct-horse-battery', {'x-forwarded-for': '198.51.100.3'})).status).toBe(200) + }) + + test('uses the right-most untrusted forwarded address so a forged prefix is ignored', async () => { + const hono = app(localConfig({FLOCI_UI_TRUSTED_PROXIES: NPM_PROXY})) + for (let index = 0; index < 5; index++) { + await login(hono, NPM_PROXY, 'wrong-password', {'x-forwarded-for': `10.9.9.${index}, 203.0.113.4`}) + } + + expect((await login(hono, NPM_PROXY, 'correct-horse-battery', {'x-forwarded-for': '10.9.9.200, 203.0.113.4'})).status).toBe(429) + }) + + test('throttles one address that rotates through usernames', async () => { + const hono = app(localConfig()) + for (let index = 0; index < 20; index++) { + await request(hono, '/api/auth/login', '203.0.113.5', { + method: 'POST', + headers: {cookie: 'floci_ui_csrf=csrf-token', 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'}, + body: new URLSearchParams({username: `user-${index}`, password: 'wrong-password', csrf: 'csrf-token'}), + }) + } + + expect((await login(hono, '203.0.113.5', 'correct-horse-battery')).status).toBe(429) + }) + + test('rejects an invalid trusted proxy entry at startup', () => { + expect(() => localConfig({FLOCI_UI_TRUSTED_PROXIES: '192.168.1.0/33'})).toThrow('FLOCI_UI_TRUSTED_PROXIES') + expect(() => localConfig({FLOCI_UI_TRUSTED_PROXIES: 'npm.local'})).toThrow('FLOCI_UI_TRUSTED_PROXIES') + }) +}) + +describe('OIDC authorization', () => { + let privateKey: CryptoKey + let jwk: JWK + + beforeAll(async () => { + const pair = await generateKeyPair('RS256') + privateKey = pair.privateKey + jwk = {...await exportJWK(pair.publicKey), kid: 'key-1', alg: 'RS256', use: 'sig'} + }) + + function mockIdp(claims: (nonce: string) => JWTPayload, userinfo?: JWTPayload) { + let nonce = '' + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input) + if (url.endsWith('/.well-known/openid-configuration')) { + return Response.json({authorization_endpoint: 'https://idp.test/authorize', token_endpoint: 'https://idp.test/token', jwks_uri: 'https://idp.test/jwks', userinfo_endpoint: 'https://idp.test/userinfo'}) + } + if (url === 'https://idp.test/jwks') return Response.json({keys: [jwk]}) + if (url === 'https://idp.test/token') { + expect(new URLSearchParams(String(init?.body)).get('code')).toBe('auth-code') + const idToken = await new SignJWT(claims(nonce)).setProtectedHeader({alg: 'RS256', kid: 'key-1'}).setIssuer(ISSUER).setAudience(CLIENT_ID).setIssuedAt().setExpirationTime('5m').sign(privateKey) + return Response.json({id_token: idToken, access_token: 'access-token'}) + } + if (url === 'https://idp.test/userinfo' && userinfo) return Response.json(userinfo) + return new Response('not found', {status: 404}) + }) as typeof fetch + return {setNonce: (value: string) => { nonce = value }} + } + + async function signIn(hono: Hono, peer: string, idp: {setNonce: (value: string) => void}) { + const start = await request(hono, '/api/auth/oidc/login', peer) + const authorize = new URL(start.headers.get('location')!) + idp.setNonce(authorize.searchParams.get('nonce')!) + return request(hono, `/api/auth/oidc/callback?state=${authorize.searchParams.get('state')}&code=auth-code`, peer) + } + + test('refuses to start in OIDC mode without an allow-list', () => { + expect(() => authConfig(oidcEnv())).toThrow('FLOCI_UI_OIDC_ALLOWED_GROUPS') + expect(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOW_ALL_USERS: 'true'})).oidc?.allowAllUsers).toBe(true) + }) + + test('admits a user in an allowed group', async () => { + const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'}))) + const idp = mockIdp((nonce) => ({sub: 'user-1', nonce, preferred_username: 'zak', groups: ['thecloud-admins']})) + const callback = await signIn(hono, '203.0.113.20', idp) + + expect(callback.status).toBe(302) + expect(callback.headers.get('location')).toBe('/console/aws') + expect(callback.headers.get('set-cookie')).toContain('floci_ui_session=') + }) + + test('rejects a user outside the allowed groups', async () => { + const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'}))) + const idp = mockIdp((nonce) => ({sub: 'user-2', nonce, preferred_username: 'guest', groups: ['jellyfin-users']})) + const callback = await signIn(hono, '203.0.113.21', idp) + + expect(callback.headers.get('location')).toBe('/login?error=forbidden') + expect(callback.headers.get('set-cookie') ?? '').not.toContain('floci_ui_session=') + }) + + test('reads groups from UserInfo when the ID token omits them', async () => { + const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'}))) + const idp = mockIdp((nonce) => ({sub: 'user-3', nonce, preferred_username: 'zak'}), {sub: 'user-3', groups: ['thecloud-admins']}) + + expect((await signIn(hono, '203.0.113.22', idp)).headers.get('location')).toBe('/console/aws') + }) + + test('ignores UserInfo groups that belong to a different subject', async () => { + const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'}))) + const idp = mockIdp((nonce) => ({sub: 'user-4', nonce, preferred_username: 'zak'}), {sub: 'someone-else', groups: ['thecloud-admins']}) + + expect((await signIn(hono, '203.0.113.23', idp)).headers.get('location')).toBe('/login?error=failed') + }) + + test('admits an allowed email only when it is verified', async () => { + const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_EMAILS: 'Zak@Example.com'}))) + const unverified = mockIdp((nonce) => ({sub: 'user-5', nonce, email: 'zak@example.com', email_verified: false})) + expect((await signIn(hono, '203.0.113.24', unverified)).headers.get('location')).toBe('/login?error=forbidden') + + const verified = mockIdp((nonce) => ({sub: 'user-5', nonce, email: 'zak@example.com', email_verified: true})) + expect((await signIn(hono, '203.0.113.24', verified)).headers.get('location')).toBe('/console/aws') + }) + + test('redirects identity-provider errors and stale callbacks to the login page', async () => { + const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'}))) + mockIdp((nonce) => ({sub: 'user-6', nonce})) + + expect((await request(hono, '/api/auth/oidc/callback?error=access_denied&state=x', '203.0.113.25')).headers.get('location')).toBe('/login?error=denied') + expect((await request(hono, '/api/auth/oidc/callback?state=unknown&code=auth-code', '203.0.113.25')).headers.get('location')).toBe('/login?error=expired') + }) + + test('turns a failed token exchange into a login error instead of a server error', async () => { + const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'}))) + const idp = mockIdp(() => ({sub: 'user-7', nonce: 'not-the-issued-nonce', groups: ['thecloud-admins']})) + + expect((await signIn(hono, '203.0.113.26', idp)).headers.get('location')).toBe('/login?error=failed') + }) + + test('throttles repeated sign-in starts from one address', async () => { + const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'}))) + mockIdp((nonce) => ({sub: 'user-8', nonce})) + for (let index = 0; index < 30; index++) await request(hono, '/api/auth/oidc/login', '203.0.113.27') + + expect((await request(hono, '/api/auth/oidc/login', '203.0.113.27')).headers.get('location')).toBe('/login?error=throttled') + expect((await request(hono, '/api/auth/oidc/login', '203.0.113.28')).headers.get('location')).toStartWith('https://idp.test/authorize') + }) +}) + +describe('auth state cleanup', () => { + test('caps pending OIDC requests and sweeps expired state', async () => { + const config = authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOW_ALL_USERS: 'true'})) + const hono = app(config) + globalThis.fetch = (async () => Response.json({authorization_endpoint: 'https://idp.test/authorize', token_endpoint: 'https://idp.test/token', jwks_uri: 'https://idp.test/jwks'})) as unknown as typeof fetch + for (let index = 0; index < 1_050; index++) await request(hono, '/api/auth/oidc/login', `10.1.${Math.floor(index / 250)}.${index % 250}`) + + expect(authStateSizes().oidcRequests).toBe(1_000) + + sweepExpired(config, Date.now() + 24 * 60 * 60_000) + expect(authStateSizes()).toEqual({sessions: 0, attempts: 0, oidcRequests: 0}) + }) +}) diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts index 0de9776..15ba34b 100644 --- a/packages/api/src/auth.ts +++ b/packages/api/src/auth.ts @@ -1,15 +1,27 @@ import {timingSafeEqual} from 'node:crypto' -import {createRemoteJWKSet, jwtVerify} from 'jose' +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.ts' +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 AuthConfig = { +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 @@ -17,15 +29,19 @@ type AuthConfig = { idleTimeoutMs: number absoluteTimeoutMs: number secureCookies: boolean - oidc?: {issuer: string; clientId: string; clientSecret: string; publicBaseUrl: string} + 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' @@ -35,17 +51,26 @@ export function authConfig(env = process.env): AuthConfig { 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(/\/$/, '')}/` - 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 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: env.FLOCI_UI_SECURE_COOKIES !== 'false'} + 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) @@ -81,29 +106,41 @@ async function login(c: Context, config: AuthConfig): Promise { 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 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) + recordFailure(key, LOGIN_ATTEMPTS) + recordFailure(ipKey, LOGIN_ATTEMPTS_PER_IP) return loginFailure(c, 401) } attempts.delete(key) - await createSession(c, config, config.adminUser!) + 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 discovery = await oidcDiscovery(config.oidc.issuer) + 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') - oidcRequests.set(state, {nonce, verifier, returnTo: safeReturnTo(c.req.query('returnTo')), expiresAt: Date.now() + 10 * 60_000}) + 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) @@ -112,29 +149,75 @@ async function oidcLogin(c: Context, config: AuthConfig): Promise { async function oidcCallback(c: Context, config: AuthConfig): Promise { 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) + 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 createSession(c: Context, config: AuthConfig, username: string): Promise { +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 { @@ -199,6 +282,9 @@ 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.' } @@ -211,13 +297,43 @@ function stateChanging(method: string): boolean { return !['GET', 'HEAD', 'OPTIO 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() } +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): 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 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-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') } -- 2.52.0