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('post-login redirect', () => { async function redirectAfterLogin(returnTo: string) { const response = await request(app(localConfig()), '/api/auth/login', '198.51.100.40', { method: 'POST', headers: {cookie: 'floci_ui_csrf=csrf-token', 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'}, body: new URLSearchParams({username: 'admin', password: 'correct-horse-battery', csrf: 'csrf-token', returnTo}), }) return (await response.json() as {redirectTo: string}).redirectTo } test('keeps same-origin paths with their query and fragment', async () => { expect(await redirectAfterLogin('/cloud-explorer/aws/storage?search=logs#top')).toBe('/cloud-explorer/aws/storage?search=logs#top') }) test('refuses destinations that a browser would resolve to another origin', async () => { for (const returnTo of ['//evil.example', '/\\evil.example', '/\\/evil.example', '/\t/evil.example', 'https://evil.example', '/%0d%0a/evil.example'.replace('%0d%0a', '\r\n')]) { expect(await redirectAfterLogin(returnTo)).toBe('/console/aws') } }) }) 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}) }) })