51a0a60a73
CI / checks (pull_request) Successful in 25m28s
CI failed on a different set of tests each run because bun runs every test file in one process and the file order differs between machines: - the secretsmanager route test replaced the whole ../aws module, so files loaded after it lost awsClients, lambda and defaultAccountId (API Gateway, SES) - the Lightsail tests restored FLOCI_ENDPOINT by assigning undefined, which stores the string "undefined" (runtime diagnostics) - the auth tests used full-strength argon2 for ~30 sign-ins and timed out on slower runners; the OIDC cleanup test cached a discovery document without userinfo_endpoint for the shared issuer Checked with 20 runs under bun test --randomize.
254 lines
14 KiB
TypeScript
254 lines
14 KiB
TypeScript
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 () => {
|
|
// Cheap parameters keep the many sign-in attempts below fast on slow CI runners; verification
|
|
// reads the parameters from the hash, so the code under test is unchanged.
|
|
passwordHash = await Bun.password.hash('correct-horse-battery', {algorithm: 'argon2id', memoryCost: 1024, timeCost: 1})
|
|
})
|
|
|
|
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<string, string> = {}) {
|
|
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<string, string> = {}): Record<string, string> {
|
|
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<string, string> = {}) {
|
|
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<string, string> = {}) {
|
|
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)
|
|
// The same document as mockIdp: discovery is cached per issuer for the rest of the run.
|
|
globalThis.fetch = (async () => 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'})) 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})
|
|
})
|
|
})
|