commit d66e14a5985d3d1920b5adf84b6ec09796cd82c3
parent 56d5973aefe4467b549ac012ec4824eb2075c006
Author: finwo <finwo@pm.me>
Date: Sun, 18 Oct 2026 01:44:18 +0200
Add opaque/introspection support
Diffstat:
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);
+});