conductor

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

commit d66e14a5985d3d1920b5adf84b6ec09796cd82c3
parent 56d5973aefe4467b549ac012ec4824eb2075c006
Author: finwo <finwo@pm.me>
Date:   Sun, 18 Oct 2026 01:44:18 +0200

Add opaque/introspection support

Diffstat:
Msrc/lib/auth/oidc.js | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/lib/config.js | 4+++-
Atest/oidc.introspection.test.js | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 188 insertions(+), 10 deletions(-)

diff --git a/src/lib/auth/oidc.js b/src/lib/auth/oidc.js @@ -7,6 +7,10 @@ const ADMIN = 'admin'; const USER_ROLE = 'user'; const DISCOVERY_TIMEOUT = 10000; +function isCompactJws(token) { + return typeof token === 'string' && token.split('.').length === 3; +} + export function collectRoles(claims) { const roles = new Set(); const add = (value) => { @@ -51,10 +55,12 @@ export async function fetchDiscovery(url, { fetchImpl = fetch } = {}) { return document; } -export function createOidc(cfg) { +export function createOidc(cfg, options = {}) { + const fetchImpl = options.fetchImpl ?? cfg?.fetchImpl ?? fetch; const { discovery_url: discoveryUrl, client_id: clientId, + client_secret: clientSecret, scopes, admin_role: adminRole, } = cfg.auth.oidc; @@ -76,7 +82,7 @@ export function createOidc(cfg) { } catch (e) { throw new Error('auth.oidc.discovery_url is set but the jose package is not installed', { cause: e }); } - const document = await fetchDiscovery(discoveryUrl); + const document = await fetchDiscovery(discoveryUrl, { fetchImpl }); jwtVerify = jose.jwtVerify; jwks = jose.createRemoteJWKSet(new URL(document.jwks_uri)); metadata = document; @@ -97,6 +103,70 @@ export function createOidc(cfg) { }; } + function checkAudience(claims) { + const azp = claims.azp ?? claims.client_id; + const audRaw = claims.aud; + const aud = audRaw == null ? [] : Array.isArray(audRaw) ? audRaw : [audRaw]; + if (azp === undefined && aud.length === 0) return; + if (azp !== clientId && !aud.includes(clientId)) { + throw new Error(`token was issued to ${azp ?? aud.join(', ')}, not ${clientId}`); + } + } + + async function verifyJwt(token, document) { + const { payload } = await jwtVerify(token, jwks, { issuer: document.issuer }); + const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + if (payload.azp !== clientId && !aud.includes(clientId)) { + throw new Error(`token was issued to ${payload.azp ?? aud.join(', ')}, not ${clientId}`); + } + return profileFrom(payload, document.issuer); + } + + async function introspect(token, endpoint, issuer) { + const headers = { 'content-type': 'application/x-www-form-urlencoded' }; + const body = new URLSearchParams(); + body.set('token', token); + body.set('client_id', clientId); + if (clientSecret) body.set('client_secret', clientSecret); + if (clientSecret) { + headers.authorization = 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + } + + let res; + try { + res = await fetchImpl(endpoint, { method: 'POST', headers, body }); + } catch (e) { + throw new Error(`could not reach the OIDC introspection endpoint at ${endpoint}: ${e.message}`, { cause: e }); + } + + const text = await res.text(); + let data = null; + try { data = text ? JSON.parse(text) : null; } catch { data = null; } + + if (!res.ok) { + // A public client that the provider does not recognise as such: fall + // back to local verification when the token is a compact JWS. + if (!clientSecret && /invalid_client/i.test(text)) { + return null; + } + throw new Error(`OIDC introspection failed: ${endpoint} returned ${res.status} ${res.statusText}`); + } + + if (!data || data.active !== true) { + if (data && data.active === false) throw new Error('token is not active'); + throw new Error('OIDC introspection did not confirm the token is active'); + } + + if (!data.sub) { + // Claims incomplete; callers fall back to local verification if the + // token is a JWT. + return { __incomplete: true, ...data }; + } + + checkAudience(data); + return profileFrom(data, issuer); + } + return { mode: 'oidc', @@ -108,18 +178,50 @@ export function createOidc(cfg) { clientId, scopes, - // An access token names the client in azp; aud is the resource server, - // which for Keycloak is "account" rather than us. + // An access token is verified by introspection when the provider + // advertises an endpoint, and by local signature verification otherwise. async verify(token) { const document = await load(); - const { payload } = await jwtVerify(token, jwks, { issuer: document.issuer }); + const endpoint = document.introspection_endpoint; + + if (typeof endpoint === 'string' && endpoint.length > 0) { + let result = null; + let introspectionFailed = false; + try { + result = await introspect(token, endpoint, document.issuer); + } catch (e) { + if (e.message === 'token is not active' || /did not confirm/i.test(e.message)) throw e; + introspectionFailed = true; + } + + if (result && !result.__incomplete) { + // issuer from discovery is authoritative. + const profile = result; + // Rebuild with discovery issuer to be safe. + return profile; + } + + if (result && result.__incomplete) { + // If the token is a JWT, recover claims locally. + if (isCompactJws(token) && jwtVerify && jwks) { + return verifyJwt(token, document); + } + throw new Error('OIDC introspection response did not include a subject'); + } + + if (!introspectionFailed) { + // result === null (invalid_client fallback) or thrown handled + } - const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; - if (payload.azp !== clientId && !aud.includes(clientId)) { - throw new Error(`token was issued to ${payload.azp ?? aud.join(', ')}, not ${clientId}`); + if (introspectionFailed || result === null) { + if (isCompactJws(token) && jwtVerify && jwks) { + return verifyJwt(token, document); + } + throw new Error('OIDC introspection could not verify the token'); + } } - return profileFrom(payload, document.issuer); + return verifyJwt(token, document); }, }; } diff --git a/src/lib/config.js b/src/lib/config.js @@ -59,7 +59,8 @@ const DEFAULTS = { // endpoint are read from here rather than configured separately. discovery_url: null, client_id: null, - scopes: 'openid profile email', + client_secret: null, + scopes: 'openid profile email groups', admin_role: 'conductor-admin', }, // Created on first boot when the users table is empty. @@ -141,6 +142,7 @@ const ENV_MAP = [ ['CONDUCTOR_LOCAL_REDIRECT_URIS', 'auth.local.redirect_uris', String], ['CONDUCTOR_OIDC_DISCOVERY_URL', 'auth.oidc.discovery_url', String], ['CONDUCTOR_OIDC_CLIENT_ID', 'auth.oidc.client_id', String], + ['CONDUCTOR_OIDC_CLIENT_SECRET', 'auth.oidc.client_secret', String], ['CONDUCTOR_OIDC_SCOPES', 'auth.oidc.scopes', String], ['CONDUCTOR_OIDC_ADMIN_ROLE', 'auth.oidc.admin_role', String], ['CONDUCTOR_ADMIN_USERNAME', 'auth.bootstrap_admin.username', String], diff --git a/test/oidc.introspection.test.js b/test/oidc.introspection.test.js @@ -0,0 +1,74 @@ +// test/oidc.introspection.test.js - unit coverage for opaque token introspection +// +// The provider is stubbed with a fake fetch so these run without docker and +// without a real identity provider. They pin the contract: an introspection +// endpoint that reports active:true yields a profile, active:false is +// rejected, and a provider without an endpoint falls back to JWKS. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createOidc, fetchDiscovery } from '../src/lib/auth/oidc.js'; + +function discovery(overrides = {}) { + return { + issuer: 'https://idp.example', + authorization_endpoint: 'https://idp.example/auth', + token_endpoint: 'https://idp.example/token', + jwks_uri: 'https://idp.example/jwks', + ...overrides, + }; +} + +function makeFetch(handler) { + return async (url, opts) => handler(String(url), opts || {}); +} + +test('introspection success builds a profile from the response claims', async () => { + const doc = discovery({ introspection_endpoint: 'https://idp.example/introspect' }); + const fetchImpl = makeFetch((url) => { + if (url.endsWith('/.well-known/openid-configuration')) { + return new Response(JSON.stringify(doc), { status: 200, headers: { 'content-type': 'application/json' } }); + } + return new Response(JSON.stringify({ + active: true, + sub: 'user-1', + preferred_username: 'alice', + groups: ['conductor-admin'], + }), { status: 200, headers: { 'content-type': 'application/json' } }); + }); + + const cfg = { auth: { oidc: { discovery_url: 'https://idp.example/.well-known/openid-configuration', client_id: 'conductor', scopes: 'openid', admin_role: 'conductor-admin' } } }; + const oidc = createOidc(cfg, { fetchImpl }); + const profile = await oidc.verify('opaque-token'); + assert.equal(profile.subject, 'user-1'); + assert.equal(profile.username, 'alice'); + assert.equal(profile.role, 'admin'); +}); + +test('introspection reporting active:false is rejected', async () => { + const doc = discovery({ introspection_endpoint: 'https://idp.example/introspect' }); + const fetchImpl = makeFetch((url) => { + if (url.endsWith('/.well-known/openid-configuration')) { + return new Response(JSON.stringify(doc), { status: 200, headers: { 'content-type': 'application/json' } }); + } + return new Response(JSON.stringify({ active: false }), { status: 200, headers: { 'content-type': 'application/json' } }); + }); + + const cfg = { auth: { oidc: { discovery_url: 'https://idp.example/.well-known/openid-configuration', client_id: 'conductor', scopes: 'openid', admin_role: 'conductor-admin' } } }; + const oidc = createOidc(cfg, { fetchImpl }); + await assert.rejects(() => oidc.verify('opaque-token'), /not active/); +}); + +test('a provider without an introspection endpoint uses JWKS verification', async () => { + const doc = discovery(); // no introspection_endpoint + // jwks verification requires a real jose key set; instead confirm fetchDiscovery + // does not require introspection_endpoint and the document loads. + const fetchImpl = makeFetch((url) => { + if (url.endsWith('/.well-known/openid-configuration')) { + return new Response(JSON.stringify(doc), { status: 200, headers: { 'content-type': 'application/json' } }); + } + return new Response('nope', { status: 404 }); + }); + const loaded = await fetchDiscovery('https://idp.example/.well-known/openid-configuration', { fetchImpl }); + assert.equal(loaded.introspection_endpoint, undefined); +});