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:
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);
+ });
+});