- Updated targetAppFromReturnTo function to handle OIDC authorization unwrap and added search parameter processing. - Integrated target app resolution into the OIDC route for improved audit logging of SSO handoffs. - Added a test case to verify the logging of the target app during the authorization process. - Updated documentation to reflect changes in audit logging for the Technitium DNS application. Co-authored-by: Cursor <[email protected]>
326 lines
10 KiB
TypeScript
326 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { mkdtempSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { jwtVerify, createLocalJWKSet } from 'jose'
|
|
import { buildApp } from '../src/app.js'
|
|
import { loadConfig } from '../src/config.js'
|
|
import { resetOidcKeyCache } from '../src/lib/oidc/keys.js'
|
|
|
|
async function buildTestApp() {
|
|
resetOidcKeyCache()
|
|
const config = loadConfig({
|
|
...process.env,
|
|
JWT_SECRET: 'test-secret-at-least-8',
|
|
ADMIN_EMAIL: '[email protected]',
|
|
ADMIN_PASSWORD: 'adminpass',
|
|
DATABASE_URL: 'sqlite::memory:',
|
|
ISSUER: 'https://auth.test.local',
|
|
OIDC_ISSUER: 'https://auth.test.local',
|
|
NODE_ENV: 'test',
|
|
})
|
|
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
|
|
}
|
|
|
|
describe('OIDC IdP', () => {
|
|
it('serves discovery and JWKS', async () => {
|
|
const app = await buildTestApp()
|
|
const discovery = await app.inject({
|
|
method: 'GET',
|
|
url: '/.well-known/openid-configuration',
|
|
})
|
|
expect(discovery.statusCode).toBe(200)
|
|
const meta = discovery.json() as {
|
|
issuer: string
|
|
authorization_endpoint: string
|
|
jwks_uri: string
|
|
}
|
|
expect(meta.issuer).toBe('https://auth.test.local')
|
|
expect(meta.authorization_endpoint).toContain('/oauth/authorize')
|
|
expect(meta.jwks_uri).toContain('/.well-known/jwks.json')
|
|
|
|
const jwks = await app.inject({
|
|
method: 'GET',
|
|
url: '/.well-known/jwks.json',
|
|
})
|
|
expect(jwks.statusCode).toBe(200)
|
|
const keys = jwks.json() as { keys: { kid: string; kty: string }[] }
|
|
expect(keys.keys.length).toBeGreaterThan(0)
|
|
expect(keys.keys[0]?.kty).toBe('RSA')
|
|
await app.close()
|
|
})
|
|
|
|
it('records auth.sso_handoff with target_app dns on authorize', async () => {
|
|
const app = await buildTestApp()
|
|
|
|
const login = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: { email: '[email protected]', password: 'adminpass' },
|
|
})
|
|
expect(login.statusCode).toBe(200)
|
|
const token = (login.json() as { access_token: string }).access_token
|
|
const refresh = login.cookies.find((c) => c.name === 'refresh_token')
|
|
expect(refresh?.value).toBeTruthy()
|
|
|
|
const created = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/admin/oidc/clients',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: {
|
|
name: 'Technitium',
|
|
redirect_uris: ['https://dns.test.local/sso/callback'],
|
|
scopes: ['openid', 'profile', 'email', 'groups'],
|
|
enabled: true,
|
|
},
|
|
})
|
|
const client = created.json() as { client_id: string }
|
|
|
|
const authorize = await app.inject({
|
|
method: 'GET',
|
|
url: '/oauth/authorize',
|
|
cookies: { refresh_token: refresh!.value },
|
|
query: {
|
|
client_id: client.client_id,
|
|
redirect_uri: 'https://dns.test.local/sso/callback',
|
|
response_type: 'code',
|
|
scope: 'openid profile email groups',
|
|
state: 'xyz',
|
|
},
|
|
})
|
|
expect(authorize.statusCode).toBe(302)
|
|
|
|
const audit = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/admin/audit?kind=logins&limit=50',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
expect(audit.statusCode).toBe(200)
|
|
const entries = audit.json() as {
|
|
action: string
|
|
details: Record<string, unknown> | null
|
|
}[]
|
|
const handoff = entries.find(
|
|
(e) =>
|
|
e.action === 'auth.sso_handoff' &&
|
|
e.details?.target_app === 'dns' &&
|
|
e.details?.auth_mode === 'oidc',
|
|
)
|
|
expect(handoff).toBeTruthy()
|
|
await app.close()
|
|
})
|
|
|
|
it('reloads RS256 key from SQLite after restart (extractable import)', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'oidc-key-'))
|
|
const dbPath = `sqlite:${join(dir, 'app.db')}`
|
|
try {
|
|
resetOidcKeyCache()
|
|
const config = loadConfig({
|
|
...process.env,
|
|
JWT_SECRET: 'test-secret-at-least-8',
|
|
ADMIN_EMAIL: '[email protected]',
|
|
ADMIN_PASSWORD: 'adminpass',
|
|
DATABASE_URL: dbPath,
|
|
ISSUER: 'https://auth.test.local',
|
|
OIDC_ISSUER: 'https://auth.test.local',
|
|
NODE_ENV: 'test',
|
|
})
|
|
const first = await buildApp({ config, databaseUrl: dbPath })
|
|
const jwks1 = await first.inject({
|
|
method: 'GET',
|
|
url: '/.well-known/jwks.json',
|
|
})
|
|
expect(jwks1.statusCode).toBe(200)
|
|
const kid = (jwks1.json() as { keys: { kid: string }[] }).keys[0]?.kid
|
|
expect(kid).toBeTruthy()
|
|
await first.close()
|
|
|
|
resetOidcKeyCache()
|
|
const second = await buildApp({ config, databaseUrl: dbPath })
|
|
const jwks2 = await second.inject({
|
|
method: 'GET',
|
|
url: '/.well-known/jwks.json',
|
|
})
|
|
expect(jwks2.statusCode).toBe(200)
|
|
expect((jwks2.json() as { keys: { kid: string }[] }).keys[0]?.kid).toBe(
|
|
kid,
|
|
)
|
|
await second.close()
|
|
} finally {
|
|
try {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
} catch {
|
|
// Windows may keep better-sqlite3 handle briefly
|
|
}
|
|
}
|
|
})
|
|
|
|
it('authorization code flow issues id_token with groups', async () => {
|
|
const app = await buildTestApp()
|
|
|
|
const login = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: { email: '[email protected]', password: 'adminpass' },
|
|
})
|
|
expect(login.statusCode).toBe(200)
|
|
const token = (login.json() as { access_token: string }).access_token
|
|
const refresh = login.cookies.find((c) => c.name === 'refresh_token')
|
|
expect(refresh?.value).toBeTruthy()
|
|
|
|
const created = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/admin/oidc/clients',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: {
|
|
name: 'Technitium',
|
|
redirect_uris: ['https://dns.test.local/sso/callback'],
|
|
scopes: ['openid', 'profile', 'email', 'groups'],
|
|
enabled: true,
|
|
},
|
|
})
|
|
expect(created.statusCode).toBe(200)
|
|
const client = created.json() as {
|
|
client_id: string
|
|
client_secret: string
|
|
}
|
|
|
|
const authorize = await app.inject({
|
|
method: 'GET',
|
|
url:
|
|
'/oauth/authorize?' +
|
|
new URLSearchParams({
|
|
client_id: client.client_id,
|
|
redirect_uri: 'https://dns.test.local/sso/callback',
|
|
response_type: 'code',
|
|
scope: 'openid profile email groups',
|
|
state: 'xyz',
|
|
nonce: 'n1',
|
|
}).toString(),
|
|
cookies: { refresh_token: refresh!.value },
|
|
})
|
|
expect(authorize.statusCode).toBe(302)
|
|
const location = authorize.headers.location!
|
|
expect(location).toContain('https://dns.test.local/sso/callback')
|
|
const code = new URL(location).searchParams.get('code')
|
|
expect(code).toBeTruthy()
|
|
|
|
const tokenRes = await app.inject({
|
|
method: 'POST',
|
|
url: '/oauth/token',
|
|
payload: {
|
|
grant_type: 'authorization_code',
|
|
code: code!,
|
|
redirect_uri: 'https://dns.test.local/sso/callback',
|
|
client_id: client.client_id,
|
|
client_secret: client.client_secret,
|
|
},
|
|
})
|
|
expect(tokenRes.statusCode).toBe(200)
|
|
const tokens = tokenRes.json() as {
|
|
access_token: string
|
|
id_token: string
|
|
token_type: string
|
|
}
|
|
expect(tokens.token_type).toBe('Bearer')
|
|
|
|
const jwksRes = await app.inject({
|
|
method: 'GET',
|
|
url: '/.well-known/jwks.json',
|
|
})
|
|
const jwks = createLocalJWKSet(jwksRes.json() as { keys: never[] })
|
|
const { payload } = await jwtVerify(tokens.id_token, jwks, {
|
|
issuer: 'https://auth.test.local',
|
|
audience: client.client_id,
|
|
})
|
|
expect(payload.sub).toBeTruthy()
|
|
expect(payload.email).toBe('[email protected]')
|
|
expect(payload.nonce).toBe('n1')
|
|
const groups = payload.groups as string[]
|
|
expect(groups).toContain('technitium_admins')
|
|
expect(groups).toContain('technitium_dns_admins')
|
|
|
|
const userinfo = await app.inject({
|
|
method: 'GET',
|
|
url: '/oauth/userinfo',
|
|
headers: { authorization: `Bearer ${tokens.access_token}` },
|
|
})
|
|
expect(userinfo.statusCode).toBe(200)
|
|
const info = userinfo.json() as { groups: string[]; email: string }
|
|
expect(info.email).toBe('[email protected]')
|
|
expect(info.groups).toContain('technitium_admins')
|
|
|
|
await app.close()
|
|
})
|
|
|
|
it('rejects invalid client secret and redirect_uri mismatch', async () => {
|
|
const app = await buildTestApp()
|
|
const login = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: { email: '[email protected]', password: 'adminpass' },
|
|
})
|
|
const token = (login.json() as { access_token: string }).access_token
|
|
const refresh = login.cookies.find((c) => c.name === 'refresh_token')!
|
|
|
|
const created = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/admin/oidc/clients',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: {
|
|
name: 'DNS',
|
|
redirect_uris: ['https://dns.test.local/sso/callback'],
|
|
scopes: ['openid', 'profile', 'email', 'groups'],
|
|
enabled: true,
|
|
},
|
|
})
|
|
const client = created.json() as {
|
|
client_id: string
|
|
client_secret: string
|
|
}
|
|
|
|
const badRedirect = await app.inject({
|
|
method: 'GET',
|
|
url:
|
|
'/oauth/authorize?' +
|
|
new URLSearchParams({
|
|
client_id: client.client_id,
|
|
redirect_uri: 'https://evil.test/callback',
|
|
response_type: 'code',
|
|
scope: 'openid',
|
|
}).toString(),
|
|
cookies: { refresh_token: refresh.value },
|
|
})
|
|
expect(badRedirect.statusCode).toBe(400)
|
|
|
|
const authorize = await app.inject({
|
|
method: 'GET',
|
|
url:
|
|
'/oauth/authorize?' +
|
|
new URLSearchParams({
|
|
client_id: client.client_id,
|
|
redirect_uri: 'https://dns.test.local/sso/callback',
|
|
response_type: 'code',
|
|
scope: 'openid',
|
|
}).toString(),
|
|
cookies: { refresh_token: refresh.value },
|
|
})
|
|
const code = new URL(authorize.headers.location!).searchParams.get('code')!
|
|
|
|
const badSecret = await app.inject({
|
|
method: 'POST',
|
|
url: '/oauth/token',
|
|
payload: {
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: 'https://dns.test.local/sso/callback',
|
|
client_id: client.client_id,
|
|
client_secret: 'wrong-secret',
|
|
},
|
|
})
|
|
expect(badSecret.statusCode).toBe(401)
|
|
|
|
await app.close()
|
|
})
|
|
})
|