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:
@@ -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 };
|
||||
Reference in New Issue
Block a user