feat(auth): OIDC SSO for admin users — phase 1 (#798)

Authorization-code + PKCE against a single configurable IdP via
openid-client v5, with JIT provisioning. Verified end-to-end against a
real Keycloak 26 (realm + confidential client + verified-email user):
settings → discovery test → login button → Keycloak → dashboard.

Backend:
- migration 162: admin_users.auth_provider ('local' default) +
  external_subject, composite unique index
- oidcService: settings-driven config (client secret AES-256-GCM at
  rest, mfaService pattern, OIDC_ENCRYPTION_KEY fallback JWT_SECRET),
  cached discovery, sub-based identity binding — email linking of
  existing admins only with email_verified=true; JIT behind
  oidc_autoprovision with configurable default role and an unusable
  random password hash
- GET /api/auth/admin/sso/login + /callback: state/nonce/PKCE verifier
  cross the redirect in a 10-min signed httpOnly SameSite=Lax cookie;
  the callback reuses the local login's session establishment
  (completeAdminLogin split into establishAdminSession + JSON wrapper)
  so SSO sessions are identical downstream; every failure lands on
  /admin/login?sso_error=<key> as a translated toast
- dedicated /admin/settings/sso GET/PUT/test endpoints (secret
  write-only, redacted to a set-flag; registered ABOVE the generic
  /:type matcher which would shadow them); oidc_client_secret added to
  the reserved keys stripped from generic settings upserts
- public settings expose only oidc_enabled + oidc_button_label for the
  login page

Frontend:
- Settings → Single Sign-On (OIDC) tab: issuer/client/secret, scopes,
  autoprovision + default role, button label, enable toggle, redirect
  URI copy box, server-side discovery test
- login page: SSO button (custom label) when enabled; sso_error query
  param surfaced as translated toasts; EN+DE i18n

Tests: 11 integration cases against an in-process mock IdP (real
discovery/JWKS/PKCE/ID-token validation) — JIT on/off, sub-vs-email
binding, unverified-email rejection, deactivated admin, missing/forged
state cookie, nonce tamper, secret encryption round-trip, disabled 404.

MFA is delegated to the IdP on the SSO path; local login stays
available as break-glass. Role-claim mapping and logout-to-IdP follow
in phase 2/3.
This commit is contained in:
Paul Nothaft
2026-07-16 08:54:26 +02:00
parent f0cdcddb92
commit ed5fc5ad5c
17 changed files with 1507 additions and 18 deletions
@@ -0,0 +1,150 @@
/**
* Minimal in-process OIDC provider for integration tests (#798).
*
* Serves just enough of the spec for openid-client's full validation to
* pass: discovery, JWKS (RS256), authorization endpoint (immediate redirect,
* no login UI), and token endpoint (authorization_code + PKCE). Claims for
* the next login are scripted per test via `setNextUser()`.
*
* Runs on an ephemeral localhost port over plain http — the service allows
* that in NODE_ENV=test only.
*/
const http = require('http');
const crypto = require('crypto');
const { URL } = require('url');
function b64url(input) {
return Buffer.from(input).toString('base64url');
}
class MockOidcProvider {
constructor() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
this.privateKey = privateKey;
this.publicJwk = publicKey.export({ format: 'jwk' });
this.publicJwk.kid = 'test-key-1';
this.publicJwk.alg = 'RS256';
this.publicJwk.use = 'sig';
this.clientId = 'picpeak-test';
this.clientSecret = 'test-client-secret';
this.codes = new Map(); // code -> { nonce, redirectUri, codeChallenge, user }
this.nextUser = { sub: 'user-1', email: '[email protected]', email_verified: true };
// Test hooks:
this.tamperNonce = false; // sign the ID token with a WRONG nonce
this.server = null;
this.issuer = null;
}
setNextUser(user) {
this.nextUser = user;
}
signIdToken({ sub, nonce, extraClaims = {} }) {
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'RS256', kid: this.publicJwk.kid, typ: 'JWT' };
const payload = {
iss: this.issuer,
aud: this.clientId,
sub,
iat: now,
exp: now + 300,
nonce,
...extraClaims,
};
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
const signature = crypto.sign('RSA-SHA256', Buffer.from(signingInput), this.privateKey);
return `${signingInput}.${signature.toString('base64url')}`;
}
async start() {
this.server = http.createServer((req, res) => this.handle(req, res));
await new Promise((resolve) => this.server.listen(0, '127.0.0.1', resolve));
this.issuer = `http://127.0.0.1:${this.server.address().port}`;
return this.issuer;
}
async stop() {
if (this.server) await new Promise((resolve) => this.server.close(resolve));
}
handle(req, res) {
const url = new URL(req.url, this.issuer);
const json = (status, body) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
if (url.pathname === '/.well-known/openid-configuration') {
return json(200, {
issuer: this.issuer,
authorization_endpoint: `${this.issuer}/authorize`,
token_endpoint: `${this.issuer}/token`,
jwks_uri: `${this.issuer}/jwks`,
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
});
}
if (url.pathname === '/jwks') {
return json(200, { keys: [this.publicJwk] });
}
if (url.pathname === '/authorize') {
// "Log in" instantly: mint a code bound to this request's params and
// bounce back to the redirect_uri like a real IdP would.
const code = crypto.randomBytes(16).toString('base64url');
this.codes.set(code, {
nonce: url.searchParams.get('nonce'),
redirectUri: url.searchParams.get('redirect_uri'),
codeChallenge: url.searchParams.get('code_challenge'),
user: this.nextUser,
});
const back = new URL(url.searchParams.get('redirect_uri'));
back.searchParams.set('code', code);
back.searchParams.set('state', url.searchParams.get('state'));
res.writeHead(302, { location: back.href });
return res.end();
}
if (url.pathname === '/token' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
const params = new URLSearchParams(body);
const stored = this.codes.get(params.get('code'));
if (!stored) return json(400, { error: 'invalid_grant' });
this.codes.delete(params.get('code'));
// PKCE check — S256(code_verifier) must match the challenge.
const verifier = params.get('code_verifier') || '';
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
if (challenge !== stored.codeChallenge) {
return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
}
const { sub, ...extraClaims } = stored.user;
const idToken = this.signIdToken({
sub,
nonce: this.tamperNonce ? 'tampered-nonce' : stored.nonce,
extraClaims,
});
return json(200, {
access_token: crypto.randomBytes(16).toString('base64url'),
token_type: 'Bearer',
expires_in: 300,
id_token: idToken,
});
});
return undefined;
}
return json(404, { error: 'not_found' });
}
}
module.exports = { MockOidcProvider };