conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

commit ec84d4a080f05c2053c7b9a2a40686f8b010016d
parent e2474744346f085d1e1fa01f78fbc874bf83aa25
Author: finwo <finwo@pm.me>
Date:   Sat, 19 Sep 2026 03:27:13 +0200

OIDC discovery and just-in-time account provisioning

Diffstat:
MREADME.md | 16+++++++++++++++-
Mconductor.example.yaml | 3+++
Amigrations/mysql/003_external_accounts.sql | 7+++++++
Amigrations/postgres/003_external_accounts.sql | 7+++++++
Amigrations/sqlite/003_external_accounts.sql | 15+++++++++++++++
Msrc/lib/auth/index.js | 17++++++++++++++++-
Msrc/lib/auth/oidc.js | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Msrc/lib/config.js | 4++++
Msrc/lib/users.js | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtest/helpers/harness.js | 10+++++++++-
Atest/helpers/oidc.js | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/oidc.test.js | 369+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 793 insertions(+), 20 deletions(-)

diff --git a/README.md b/README.md @@ -68,6 +68,14 @@ Three capabilities switch on when configured and fall back when not: | `storage.s3.bucket` | S3 compatible store | local filesystem | | `auth.oidc.issuer` | OIDC | built-in accounts | +With OIDC the provider owns identity. The key set is located through the +issuer's discovery document, so any compliant provider works; set +`auth.oidc.jwks_uri` only for one that publishes no discovery document. An +account is created locally the first time someone presents a valid token, +keyed on issuer and subject, so they can own projects and workers. The role +in the token wins on every request, and disabling the local account locks +them out regardless of what the provider says. + Usage ----- @@ -198,7 +206,13 @@ Testing npm test ``` -S3 coverage is skipped unless a server is available: +Tests that need a container skip themselves when docker is unavailable. +With docker present the suite starts what it needs and cleans up after +itself: real containers for the worker to run jobs in, and a real OIDC +provider to authenticate against. Nothing is stubbed, because the failures +worth catching are in the parts a stub would replace. + +S3 coverage is opt in, since it needs a server to point at: ```sh docker run -d -p 9000:9000 -e MINIO_ROOT_USER=testkey \ diff --git a/conductor.example.yaml b/conductor.example.yaml @@ -50,6 +50,9 @@ auth: issuer: null audience: null admin_role: conductor-admin + # Found through the issuer's discovery document. Set this only for a + # provider that does not publish one. + jwks_uri: null # With OIDC configured, built-in accounts stop being accepted. Set this to # keep one as a break-glass login, for example while the provider is being # set up. Ignored when OIDC is not in use. diff --git a/migrations/mysql/003_external_accounts.sql b/migrations/mysql/003_external_accounts.sql @@ -0,0 +1,7 @@ +-- 003_external_accounts.sql - accounts provisioned by an identity provider +-- +-- See migrations/sqlite/003_external_accounts.sql for the reasoning. + +ALTER TABLE users + ADD COLUMN external_id VARCHAR(255) NULL, + ADD UNIQUE KEY idx_users_external (external_id); diff --git a/migrations/postgres/003_external_accounts.sql b/migrations/postgres/003_external_accounts.sql @@ -0,0 +1,7 @@ +-- 003_external_accounts.sql - accounts provisioned by an identity provider +-- +-- See migrations/sqlite/003_external_accounts.sql for the reasoning. + +ALTER TABLE users ADD COLUMN external_id TEXT; + +CREATE UNIQUE INDEX idx_users_external ON users (external_id); diff --git a/migrations/sqlite/003_external_accounts.sql b/migrations/sqlite/003_external_accounts.sql @@ -0,0 +1,15 @@ +-- 003_external_accounts.sql - accounts provisioned by an identity provider +-- +-- With OIDC the provider owns identity, but the conductor still needs a +-- local row for each person: projects and worker tokens reference users(id), +-- so a user who exists only inside a token cannot own anything. +-- +-- An account is therefore created the first time someone presents a valid +-- token, keyed by issuer and subject rather than by the display name, which +-- a provider is free to change. The local row carries the role most +-- recently seen in a token, so revoking a role at the provider takes effect +-- on the next request rather than requiring a change here as well. + +ALTER TABLE users ADD COLUMN external_id TEXT; + +CREATE UNIQUE INDEX idx_users_external ON users (external_id); diff --git a/src/lib/auth/index.js b/src/lib/auth/index.js @@ -39,7 +39,22 @@ export function createAuth({ cfg, users, logger = console }) { if (oidc) { try { - return await oidc.verify(credential); + const profile = await oidc.verify(credential); + + // The provider vouched for them, but they still need a local row + // to own anything, since projects and worker tokens reference + // users(id). Created on first sight, refreshed after that. + const account = await users.upsertExternal({ + externalId: `${oidc.issuer}|${profile.subject}`, + username: profile.username, + role: profile.role, + }); + + // An administrator can still lock out an account the provider + // would happily keep admitting. + if (account.disabled === 1) return null; + + return { id: account.id, username: account.username, role: account.role, source: 'oidc' }; } catch (e) { logger.debug?.(`oidc verification failed: ${e.message}`); // Fall through only when local logins are also permitted. diff --git a/src/lib/auth/oidc.js b/src/lib/auth/oidc.js @@ -4,12 +4,19 @@ // provider, so jose does the work here: fetching and caching the JWKS, // checking the signature, issuer, audience and expiry. // +// The key set is located by discovery rather than by assuming a path. +// Keycloak serves it from /protocol/openid-connect/certs, but that is a +// Keycloak detail and nothing else follows it; guessing it works with one +// provider and silently fails with every other. auth.oidc.jwks_uri is +// available for a provider that publishes no discovery document. +// // Role mapping deliberately looks in several places. Keycloak puts realm // roles under realm_access.roles, other providers use a flat roles claim or // groups, and there is no standard. const ADMIN = 'admin'; const VIEWER = 'viewer'; +const DISCOVERY_TIMEOUT = 10000; export function collectRoles(claims) { const roles = new Set(); @@ -26,33 +33,74 @@ export function collectRoles(claims) { return [...roles]; } +// Reads the provider's metadata to find the key set. +export async function discoverJwksUri(issuer, { fetchImpl = fetch } = {}) { + const base = issuer.replace(/\/+$/, ''); + const url = `${base}/.well-known/openid-configuration`; + + let res; + try { + res = await fetchImpl(url, { signal: AbortSignal.timeout(DISCOVERY_TIMEOUT) }); + } catch (e) { + throw new Error(`could not reach the OIDC discovery document at ${url}: ${e.message}`, { cause: e }); + } + if (!res.ok) { + throw new Error(`OIDC discovery failed: ${url} returned ${res.status} ${res.statusText}`); + } + + const document = await res.json(); + + // A document claiming a different issuer than the one configured means + // the provider is misconfigured, or the URL is not what it claims to be. + if (document.issuer !== base && document.issuer !== `${base}/`) { + throw new Error( + `OIDC discovery mismatch: ${url} declares issuer ${JSON.stringify(document.issuer)}, ` + + `but auth.oidc.issuer is ${JSON.stringify(issuer)}` + ); + } + if (typeof document.jwks_uri !== 'string' || document.jwks_uri.length === 0) { + throw new Error(`OIDC discovery at ${url} did not advertise a jwks_uri`); + } + + return document.jwks_uri; +} + export function createOidc(cfg) { - const { issuer, audience, admin_role: adminRole } = cfg.auth.oidc; + const { issuer, audience, admin_role: adminRole, jwks_uri: configuredJwks } = cfg.auth.oidc; + let jwks = null; let jwtVerify = null; + let loading = null; async function load() { if (jwks) return; - let jose; - try { - jose = await import('jose'); - } catch (e) { - throw new Error('auth.oidc.issuer is set but the jose package is not installed', { cause: e }); + // Concurrent requests during startup should discover once, not once + // each, and a failure must not be cached. + if (!loading) { + loading = (async () => { + let jose; + try { + jose = await import('jose'); + } catch (e) { + throw new Error('auth.oidc.issuer is set but the jose package is not installed', { cause: e }); + } + const uri = configuredJwks || await discoverJwksUri(issuer); + jwtVerify = jose.jwtVerify; + jwks = jose.createRemoteJWKSet(new URL(uri)); + return uri; + })().finally(() => { loading = null; }); } - jwtVerify = jose.jwtVerify; - // jose caches the key set and refetches on rotation. - jwks = jose.createRemoteJWKSet(new URL(`${issuer.replace(/\/+$/, '')}/protocol/openid-connect/certs`)); + return loading; } return { mode: 'oidc', issuer, - // Allows a deployment to point at a provider whose JWKS is not at the - // Keycloak path. - setJwksUri(uri) { - jwks = null; - this.jwksUri = uri; + // Exposed so a health check can report what was discovered. + async jwksUri() { + await load(); + return configuredJwks || discoverJwksUri(issuer); }, async verify(token) { @@ -64,7 +112,7 @@ export function createOidc(cfg) { const roles = collectRoles(payload); return { - id: payload.sub, + subject: payload.sub, username: payload.preferred_username ?? payload.email ?? payload.sub, role: roles.includes(adminRole) ? ADMIN : VIEWER, source: 'oidc', diff --git a/src/lib/config.js b/src/lib/config.js @@ -57,6 +57,9 @@ const DEFAULTS = { issuer: null, audience: null, admin_role: 'conductor-admin', + // Normally found through the issuer's discovery document. Set only + // for a provider that does not publish one. + jwks_uri: null, }, // Created on first boot when the users table is empty. bootstrap_admin: { @@ -108,6 +111,7 @@ const ENV_MAP = [ ['CONDUCTOR_OIDC_ISSUER', 'auth.oidc.issuer', String], ['CONDUCTOR_OIDC_AUDIENCE', 'auth.oidc.audience', String], ['CONDUCTOR_OIDC_ADMIN_ROLE', 'auth.oidc.admin_role', String], + ['CONDUCTOR_OIDC_JWKS_URI', 'auth.oidc.jwks_uri', String], ['CONDUCTOR_ADMIN_USERNAME', 'auth.bootstrap_admin.username', String], ['CONDUCTOR_ADMIN_PASSWORD', 'auth.bootstrap_admin.password', String], ['CONDUCTOR_SECRET_KEY', 'secrets.encryption_key', String], diff --git a/src/lib/users.js b/src/lib/users.js @@ -8,7 +8,22 @@ import { hashPassword, verifyPassword, generatePassword, validatePassword } from export const ROLES = ['admin', 'viewer']; -const PUBLIC_COLUMNS = 'id, username, role, disabled, created_at, last_login_at'; +// A provider may hand over anything as a display name, so it is reduced to +// the character set local accounts already use. +function sanitizeUsername(value) { + return String(value ?? '') + .toLowerCase() + .replace(/[^a-z0-9_.@-]+/g, '-') + .replace(/^[^a-z0-9]+|-+$/g, '') + .slice(0, 48); +} + +const PUBLIC_COLUMNS = 'id, username, role, disabled, external_id, created_at, last_login_at'; + +// Stored in password_hash for accounts the identity provider owns. It does +// not parse as a scrypt hash, so verifyPassword rejects it and such an +// account can never be signed into with a password. +export const EXTERNAL_MARKER = 'external'; export function createUsers({ db, logger = console }) { return { @@ -23,6 +38,56 @@ export function createUsers({ db, logger = console }) { ); }, + async byExternalId(externalId) { + return db.get(`SELECT ${PUBLIC_COLUMNS} FROM users WHERE external_id = {externalId}`, { externalId }); + }, + + // Creates or refreshes the local row for someone the identity provider + // vouched for. Without this an OIDC user could authenticate but own + // nothing, because projects and worker tokens reference users(id). + // + // The provider is authoritative for the role, so it is written on every + // call: losing a role there takes effect here on the next request. + // Being disabled locally is not overwritten, which leaves an + // administrator a way to lock someone out immediately. + async upsertExternal({ externalId, username, role }) { + if (!externalId) throw new Error('an external account needs a stable identifier'); + const desired = ROLES.includes(role) ? role : 'viewer'; + + return db.transaction(async (tx) => { + const existing = await tx.get( + `SELECT ${PUBLIC_COLUMNS} FROM users WHERE external_id = {externalId}`, + { externalId } + ); + + if (existing) { + if (existing.role !== desired) { + await tx.run('UPDATE users SET role = {role} WHERE id = {id}', { id: existing.id, role: desired }); + } + await tx.run('UPDATE users SET last_login_at = {now} WHERE id = {id}', + { id: existing.id, now: Date.now() }); + return { ...existing, role: desired }; + } + + // Display names come from the provider and are not guaranteed + // unique, so a clash is resolved rather than allowed to fail. + const base = sanitizeUsername(username) || 'user'; + let candidate = base; + for (let i = 1; await tx.get('SELECT id FROM users WHERE username = {u}', { u: candidate }); i += 1) { + candidate = `${base}-${i}`; + if (i > 50) throw new Error(`could not find a free username for ${base}`); + } + + const id = newUserId(); + await tx.run( + `INSERT INTO users (id, username, password_hash, role, disabled, external_id, created_at, last_login_at) + VALUES ({id}, {username}, {hash}, {role}, 0, {externalId}, {now}, {now})`, + { id, username: candidate, hash: EXTERNAL_MARKER, role: desired, externalId, now: Date.now() } + ); + return tx.get(`SELECT ${PUBLIC_COLUMNS} FROM users WHERE id = {id}`, { id }); + }); + }, + async list() { return db.all(`SELECT ${PUBLIC_COLUMNS} FROM users ORDER BY username`); }, @@ -30,7 +95,7 @@ export function createUsers({ db, logger = console }) { // With what each account owns, since removing one takes those with it. async listWithHoldings() { return db.all( - `SELECT u.id, u.username, u.role, u.disabled, u.created_at, u.last_login_at, + `SELECT u.id, u.username, u.role, u.disabled, u.external_id, u.created_at, u.last_login_at, (SELECT COUNT(*) FROM projects p WHERE p.owner_id = u.id) AS project_count, (SELECT COUNT(*) FROM worker_tokens w WHERE w.owner_id = u.id) AS worker_count FROM users u ORDER BY u.username` diff --git a/test/helpers/harness.js b/test/helpers/harness.js @@ -86,7 +86,15 @@ export async function startHarness(options = {}) { ' reap_interval: 30', 'auth:', ' session_secret: test-session-secret-not-for-real-use', - ...(options.oidcIssuer ? [' oidc:', ` issuer: ${options.oidcIssuer}`] : []), + ...(options.oidcIssuer + ? [ + ' oidc:', + ` issuer: ${options.oidcIssuer}`, + ...(options.oidcAudience ? [` audience: ${options.oidcAudience}`] : []), + ...(options.oidcAdminRole ? [` admin_role: ${options.oidcAdminRole}`] : []), + ...(options.oidcJwksUri ? [` jwks_uri: ${options.oidcJwksUri}`] : []), + ] + : []), ...(options.allowLocalLogin ? [' allow_local_login: true'] : []), ' bootstrap_admin:', ` username: ${options.adminUsername ?? 'admin'}`, diff --git a/test/helpers/oidc.js b/test/helpers/oidc.js @@ -0,0 +1,218 @@ +// test/helpers/oidc.js - a real identity provider for the tests +// +// Runs navikt/mock-oauth2-server, which speaks real OIDC discovery and +// signs real tokens with a real key set. Faking the provider would test the +// fake: the interesting failures here are discovery paths, signature +// verification and claim shapes, and none of those survive a stub. +// +// Personas are declared up front as token callbacks and requested by scope, +// so a test can ask for a specific set of claims without a browser. + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import crypto from 'node:crypto'; + +const execFileAsync = promisify(execFile); + +export const IMAGE = 'ghcr.io/navikt/mock-oauth2-server:2.1.10'; +const ISSUER_ID = 'conductor'; + +// Each persona is selected with scope=<name> at the token endpoint. +const PERSONAS = { + 'persona-admin': { + sub: 'alice-sub', + preferred_username: 'alice', + realm_access: { roles: ['conductor-admin'] }, + aud: ['conductor'], + }, + 'persona-viewer': { + sub: 'bob-sub', + preferred_username: 'bob', + realm_access: { roles: ['developer'] }, + aud: ['conductor'], + }, + // The same person as persona-admin, to prove identity is keyed on the + // subject rather than on a display name the provider may change. + 'persona-admin-renamed': { + sub: 'alice-sub', + preferred_username: 'alice-moved', + realm_access: { roles: ['conductor-admin'] }, + aud: ['conductor'], + }, + // Roles arriving in the other shapes providers use. + 'persona-groups': { + sub: 'carol-sub', + preferred_username: 'carol', + groups: ['conductor-admin'], + aud: ['conductor'], + }, + 'persona-resource': { + sub: 'dave-sub', + preferred_username: 'dave', + resource_access: { conductor: { roles: ['conductor-admin'] } }, + aud: ['conductor'], + }, + // A display name that collides with an existing local account. + 'persona-collision': { + sub: 'eve-sub', + preferred_username: 'admin', + realm_access: { roles: ['developer'] }, + aud: ['conductor'], + }, + // Audience the conductor is not expecting. + 'persona-wrong-audience': { + sub: 'frank-sub', + preferred_username: 'frank', + realm_access: { roles: ['conductor-admin'] }, + aud: ['some-other-service'], + }, + // Already expired when issued. + 'persona-expired': { + sub: 'grace-sub', + preferred_username: 'grace', + realm_access: { roles: ['conductor-admin'] }, + aud: ['conductor'], + exp: 1000000000, + }, +}; + +export async function dockerAvailable() { + if (process.env.CONDUCTOR_TEST_NO_DOCKER) return false; + try { + await execFileAsync('docker', ['version', '--format', '{{.Server.Version}}'], { timeout: 15000 }); + return true; + } catch { + return false; + } +} + +async function freePort() { + const net = await import('node:net'); + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); +} + +const NAME_PREFIX = 'conductor-oidc-test-'; + +// A test run that crashes hard, or is killed, never reaches its cleanup +// hook and leaves the container behind. Since exactly one provider is +// needed at a time, anything already carrying the prefix is stale and can +// go. Cheaper than asking people to clean up after a failed run. +async function reapStale() { + try { + const { stdout } = await execFileAsync('docker', [ + 'ps', '-aq', '--filter', `name=${NAME_PREFIX}`, + ], { timeout: 30000 }); + const ids = stdout.trim().split('\n').filter(Boolean); + if (ids.length > 0) await execFileAsync('docker', ['rm', '-f', ...ids], { timeout: 60000 }); + } catch { + // Best effort; a failure here must not fail the tests. + } +} + +export async function startOidc() { + await reapStale(); + + const port = await freePort(); + const name = `${NAME_PREFIX}${crypto.randomBytes(4).toString('hex')}`; + const issuer = `http://127.0.0.1:${port}/${ISSUER_ID}`; + + const config = { + interactiveLogin: false, + tokenCallbacks: [{ + issuerId: ISSUER_ID, + requestMappings: Object.entries(PERSONAS).map(([scope, claims]) => ({ + requestParam: 'scope', + match: scope, + claims, + })), + }], + }; + + await execFileAsync('docker', [ + 'run', '--detach', '--rm', + '--name', name, + '--publish', `${port}:8080`, + '--env', `JSON_CONFIG=${JSON.stringify(config)}`, + IMAGE, + ], { timeout: 120000 }); + + const discovery = `${issuer}/.well-known/openid-configuration`; + let ready = false; + for (let i = 0; i < 60; i += 1) { + try { + const res = await fetch(discovery, { signal: AbortSignal.timeout(2000) }); + if (res.ok) { + ready = true; + break; + } + } catch { + // Still starting. + } + await new Promise((r) => { setTimeout(r, 500).unref?.(); }); + } + + if (!ready) { + await execFileAsync('docker', ['rm', '-f', name]).catch(() => {}); + throw new Error(`the OIDC provider did not become ready at ${discovery}`); + } + + return { + issuer, + port, + name, + discovery, + + // Obtains a signed token for one of the personas above. + async token(persona) { + if (!Object.hasOwn(PERSONAS, persona)) throw new Error(`unknown persona ${persona}`); + const res = await fetch(`${issuer}/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: 'conductor', + client_secret: 'secret', + scope: persona, + }), + }); + if (!res.ok) throw new Error(`token request failed: ${res.status} ${await res.text()}`); + return (await res.json()).access_token; + }, + + // Bearer headers for a persona. + async headers(persona) { + return { authorization: `Bearer ${await this.token(persona)}` }; + }, + + async stop() { + await execFileAsync('docker', ['rm', '-f', name], { timeout: 60000 }).catch(() => {}); + }, + }; +} + +// A token signed by a key the provider does not publish, for checking that +// a forged signature is refused. +export async function forgedToken(issuer, claims = {}) { + const jose = await import('jose'); + const { privateKey } = await jose.generateKeyPair('RS256'); + return new jose.SignJWT({ + preferred_username: 'mallory', + realm_access: { roles: ['conductor-admin'] }, + ...claims, + }) + .setProtectedHeader({ alg: 'RS256', kid: 'forged' }) + .setSubject('mallory-sub') + .setIssuer(issuer) + .setAudience('conductor') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey); +} diff --git a/test/oidc.test.js b/test/oidc.test.js @@ -0,0 +1,369 @@ +// test/oidc.test.js - authentication against a real identity provider +// +// Runs navikt/mock-oauth2-server in a container and verifies real signed +// tokens against its real key set. Skipped when docker is unavailable. +// +// The two things worth proving here cannot be proven against a stub: that +// the key set is located by discovery rather than by guessing a provider +// specific path, and that somebody who exists only inside a token still +// ends up able to own projects. + +import test, { before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { startHarness } from './helpers/harness.js'; +import { startOidc, forgedToken } from './helpers/oidc.js'; +import { discoverJwksUri } from '../src/lib/auth/oidc.js'; + +// Checked synchronously: the runner needs to know whether to skip while it +// is collecting tests, and an unsettled top-level await at that point +// aborts the whole file. +function dockerAvailableSync() { + if (process.env.CONDUCTOR_TEST_NO_DOCKER) return false; + try { + execFileSync('docker', ['version', '--format', '{{.Server.Version}}'], { + timeout: 15000, + stdio: 'ignore', + }); + return true; + } catch { + return false; + } +} + +const available = dockerAvailableSync(); +const opts = { skip: available ? false : 'docker is not available' }; + +// One provider for the whole file; each test gets a fresh conductor. +let oidc = null; + +before(async () => { + if (available) oidc = await startOidc(); +}, { timeout: 180000 }); + +after(async () => { + if (oidc) await oidc.stop(); +}); + +async function withOidcConductor(options, fn) { + const h = await startHarness({ + oidcIssuer: oidc.issuer, + oidcAudience: 'conductor', + oidcAdminRole: 'conductor-admin', + ...options, + }); + try { + return await fn(h); + } finally { + await h.stop(); + } +} + +const json = (headers) => ({ ...headers, 'content-type': 'application/json' }); + +test('the key set is found through discovery, not a guessed path', opts, async () => { + const uri = await discoverJwksUri(oidc.issuer); + + // Keycloak serves it from /protocol/openid-connect/certs. Assuming that + // path is exactly the bug this guards against. + assert.equal(uri, `${oidc.issuer}/jwks`); + assert.ok(!uri.includes('protocol/openid-connect')); + + const keys = await (await fetch(uri)).json(); + assert.ok(Array.isArray(keys.keys) && keys.keys.length > 0); +}); + +// The guard against a bad discovery document is checked by injection +// rather than against the live provider, which serves a self consistent +// document for every path and so can never disagree with itself. +test('discovery rejects a document that declares a different issuer', async () => { + const fetchImpl = async () => new Response( + JSON.stringify({ issuer: 'https://evil.example/realm', jwks_uri: 'https://evil.example/keys' }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + + await assert.rejects( + discoverJwksUri('https://idp.example/realm', { fetchImpl }), + /discovery mismatch/ + ); +}); + +test('discovery tolerates a trailing slash on the advertised issuer', async () => { + const fetchImpl = async (url) => { + assert.equal(url, 'https://idp.example/realm/.well-known/openid-configuration'); + return new Response( + JSON.stringify({ issuer: 'https://idp.example/realm/', jwks_uri: 'https://idp.example/realm/keys' }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }; + + assert.equal(await discoverJwksUri('https://idp.example/realm/', { fetchImpl }), 'https://idp.example/realm/keys'); +}); + +test('discovery reports a missing key set and an unreachable provider', async () => { + const withoutKeys = async () => new Response( + JSON.stringify({ issuer: 'https://idp.example/realm' }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + await assert.rejects( + discoverJwksUri('https://idp.example/realm', { fetchImpl: withoutKeys }), + /did not advertise a jwks_uri/ + ); + + const notFound = async () => new Response('nope', { status: 404, statusText: 'Not Found' }); + await assert.rejects( + discoverJwksUri('https://idp.example/realm', { fetchImpl: notFound }), + /returned 404/ + ); + + const offline = async () => { throw new Error('connection refused'); }; + await assert.rejects( + discoverJwksUri('https://idp.example/realm', { fetchImpl: offline }), + /could not reach the OIDC discovery document/ + ); +}); + +test('a token carrying the admin role authenticates as an administrator', opts, async () => { + await withOidcConductor({}, async (h) => { + const headers = await oidc.headers('persona-admin'); + + const me = await h.app.inject({ method: 'GET', url: '/api/auth/me', headers }); + assert.equal(me.statusCode, 200); + assert.equal(me.json().user.username, 'alice'); + assert.equal(me.json().user.role, 'admin'); + assert.equal(me.json().user.source, 'oidc'); + + // And can reach the administrator only surface. + assert.equal((await h.app.inject({ method: 'GET', url: '/api/admin/users', headers })).statusCode, 200); + }); +}); + +test('a token without the admin role is a viewer', opts, async () => { + await withOidcConductor({}, async (h) => { + const headers = await oidc.headers('persona-viewer'); + + const me = await h.app.inject({ method: 'GET', url: '/api/auth/me', headers }); + assert.equal(me.json().user.username, 'bob'); + assert.equal(me.json().user.role, 'viewer'); + + assert.equal((await h.app.inject({ method: 'GET', url: '/api/admin/users', headers })).statusCode, 403); + }); +}); + +test('roles are recognised in the groups and resource_access shapes', opts, async () => { + await withOidcConductor({}, async (h) => { + for (const persona of ['persona-groups', 'persona-resource']) { + const me = await h.app.inject({ + method: 'GET', url: '/api/auth/me', headers: await oidc.headers(persona), + }); + assert.equal(me.json().user.role, 'admin', `${persona} should map to admin`); + } + }); +}); + +test('an OIDC user gets a local account and can own a project', opts, async () => { + await withOidcConductor({}, async (h) => { + const headers = await oidc.headers('persona-viewer'); + + // Nothing exists for them before they appear. + assert.equal(await h.services.users.byUsername('bob'), undefined); + + const created = await h.app.inject({ + method: 'POST', + url: '/api/projects', + headers: json(headers), + payload: JSON.stringify({ id: 'bobs-app', repo_url: 'https://git.example.com/bob.git' }), + }); + // Without a local row this fails on the owner_id foreign key. + assert.equal(created.statusCode, 201, created.body); + + const account = await h.services.users.byUsername('bob'); + assert.ok(account, 'a local account should have been provisioned'); + assert.equal(created.json().project.owner_id, account.id); + + // The account cannot be signed into with a password. + assert.equal(await h.services.users.authenticate('bob', 'external'), null); + assert.equal(await h.services.users.authenticate('bob', ''), null); + }); +}); + +test('the same subject keeps one account even when the display name changes', opts, async () => { + await withOidcConductor({}, async (h) => { + const first = await h.app.inject({ + method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-admin'), + }); + const second = await h.app.inject({ + method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-admin-renamed'), + }); + + // Identity is the subject, not the label the provider shows. + assert.equal(second.json().user.id, first.json().user.id); + assert.equal((await h.services.users.list()).filter((u) => u.external_id).length, 1); + }); +}); + +test('a display name colliding with a local account gets its own username', opts, async () => { + await withOidcConductor({ bootstrap: true, allowLocalLogin: true }, async (h) => { + // The bootstrap administrator already holds the name 'admin'. + const local = await h.services.users.byUsername('admin'); + assert.ok(local); + + const me = await h.app.inject({ + method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-collision'), + }); + assert.equal(me.statusCode, 200); + assert.notEqual(me.json().user.id, local.id, 'the local account must not be taken over'); + assert.notEqual(me.json().user.username, 'admin'); + assert.match(me.json().user.username, /^admin-\d+$/); + }); +}); + +test('a role revoked at the provider is lost on the next request', opts, async () => { + await withOidcConductor({}, async (h) => { + const asAdmin = await h.app.inject({ + method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-admin'), + }); + assert.equal(asAdmin.json().user.role, 'admin'); + + // The provider stops asserting the role for that subject. + const account = await h.services.users.byExternalId(`${oidc.issuer}|alice-sub`); + await h.services.users.upsertExternal({ + externalId: `${oidc.issuer}|alice-sub`, username: 'alice', role: 'viewer', + }); + const refreshed = await h.services.users.get(account.id); + assert.equal(refreshed.role, 'viewer'); + }); +}); + +test('an account disabled locally is refused despite a valid token', opts, async () => { + await withOidcConductor({}, async (h) => { + const headers = await oidc.headers('persona-admin'); + assert.equal((await h.app.inject({ method: 'GET', url: '/api/auth/me', headers })).statusCode, 200); + + const account = await h.services.users.byExternalId(`${oidc.issuer}|alice-sub`); + await h.services.users.setDisabled(account.id, true); + + // The provider would still admit them; the conductor does not. + assert.equal((await h.app.inject({ method: 'GET', url: '/api/auth/me', headers })).statusCode, 401); + }); +}); + +test('a forged signature is refused', opts, async () => { + await withOidcConductor({}, async (h) => { + const token = await forgedToken(oidc.issuer); + const res = await h.app.inject({ + method: 'GET', url: '/api/auth/me', headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(res.statusCode, 401); + assert.equal(await h.services.users.byUsername('mallory'), undefined, 'no account should be created'); + }); +}); + +test('an expired token is refused', opts, async () => { + await withOidcConductor({}, async (h) => { + const res = await h.app.inject({ + method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-expired'), + }); + assert.equal(res.statusCode, 401); + }); +}); + +test('a token for another audience is refused when an audience is configured', opts, async () => { + await withOidcConductor({}, async (h) => { + const res = await h.app.inject({ + method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-wrong-audience'), + }); + assert.equal(res.statusCode, 401); + }); +}); + +test('the same token is accepted when no audience is configured', opts, async () => { + await withOidcConductor({ oidcAudience: null }, async (h) => { + const res = await h.app.inject({ + method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-wrong-audience'), + }); + assert.equal(res.statusCode, 200); + }); +}); + +test('a token from a different issuer is refused', opts, async () => { + // The conductor trusts one issuer; a token minted under another path of + // the same server must not be accepted. + await withOidcConductor({ oidcIssuer: oidc.issuer.replace(/\/conductor$/, '/other') }, async (h) => { + const res = await h.app.inject({ + method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-admin'), + }); + assert.equal(res.statusCode, 401); + }); +}); + +test('an explicit jwks_uri bypasses discovery', opts, async () => { + await withOidcConductor({ oidcJwksUri: `${oidc.issuer}/jwks` }, async (h) => { + const res = await h.app.inject({ + method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-admin'), + }); + assert.equal(res.statusCode, 200); + }); +}); + +test('local passwords are refused while OIDC is in charge', opts, async () => { + await withOidcConductor({ bootstrap: true }, async (h) => { + const res = await h.app.inject({ + method: 'POST', + url: '/api/auth/login', + headers: { 'content-type': 'application/json' }, + payload: JSON.stringify({ username: 'admin', password: 'bootstrap-password' }), + }); + assert.equal(res.statusCode, 400); + assert.match(res.json().error, /OIDC/); + }); +}); + +test('an OIDC administrator can drive the interface', opts, async () => { + await withOidcConductor({}, async (h) => { + const headers = await oidc.headers('persona-admin'); + + const page = await h.app.inject({ method: 'GET', url: '/', headers }); + assert.equal(page.statusCode, 200); + assert.match(page.body, /href="\/users"/, 'an admin should see the users link'); + + const users = await h.app.inject({ method: 'GET', url: '/users', headers }); + assert.equal(users.statusCode, 200); + assert.match(users.body, /alice/); + }); +}); + +test('worker ownership follows the OIDC identity', opts, async () => { + await withOidcConductor({ visibility: 'private' }, async (h) => { + const alice = await oidc.headers('persona-admin'); + const bob = await oidc.headers('persona-viewer'); + + // Bob owns a project and registers a worker for it. + const project = await h.app.inject({ + method: 'POST', + url: '/api/projects', + headers: json(bob), + payload: JSON.stringify({ id: 'bob-proj', repo_url: h.repoDir }), + }); + assert.equal(project.statusCode, 201); + + const bobToken = await h.app.inject({ + method: 'POST', url: '/api/worker-tokens', headers: json(bob), payload: JSON.stringify({ name: 'bob-pi' }), + }); + const aliceToken = await h.app.inject({ + method: 'POST', url: '/api/worker-tokens', headers: json(alice), payload: JSON.stringify({ name: 'alice-pi' }), + }); + + // Alice cannot see bob's worker, even as an administrator listing her + // own, and bob cannot see the project he does not own. + const aliceList = await h.app.inject({ method: 'GET', url: '/api/worker-tokens', headers: alice }); + const names = aliceList.json().worker_tokens.map((t) => t.name); + assert.ok(names.includes('bob-pi'), 'an administrator sees every worker'); + + const bobList = await h.app.inject({ method: 'GET', url: '/api/worker-tokens', headers: bob }); + assert.deepEqual(bobList.json().worker_tokens.map((t) => t.name), ['bob-pi']); + + assert.ok(bobToken.json().token); + assert.ok(aliceToken.json().token); + }); +});