Files
picpeak/backend/__tests__/integration/helpers/mockOidcProvider.js
T
Paul Nothaft 219d07b04a feat(auth): OIDC logout-to-IdP — phase 3 (#798) (#865)
* feat(auth): OIDC logout-to-IdP — phase 3 (#798)

RP-initiated logout behind a new oidc_logout_from_idp setting: logging
out of PicPeak also ends the IdP session. The SSO callback stores the
raw ID token in an HttpOnly cookie (also the marker that the session
came in via SSO — local-password sessions never bounce to the IdP);
/logout builds the end_session URL from discovery metadata with
id_token_hint + post_logout_redirect_uri + client_id and returns it as
ssoLogoutUrl for the frontend to navigate to. Any failure (no
end_session_endpoint, IdP unreachable, feature off) degrades to the
plain local logout.

Settings surface exposes the toggle plus the computed post-logout
redirect URI to register at the IdP. Session timeouts deliberately stay
local-only.

6 integration tests over the mock IdP; live-verified against
Keycloak 26 (logout ends the Keycloak session, no confirmation prompt).

* fix(auth): harden the SSO logout marker cookie (#798 phase 3)

Codex review round 1:
- Derive the oidc_id_token cookie options from the shared cookie policy
  (COOKIE_SAMESITE / COOKIE_DOMAIN / secure resolution) — hardcoded Lax
  meant split-origin deployments running on SameSite=None never sent the
  marker to the cross-site /logout XHR, silently disabling logout-to-IdP.
- Oversized ID tokens (>3.9KB) now store a bare 'sso' marker instead of
  no cookie, so the claimed client_id-only end-session fallback actually
  happens; /logout only passes the value as id_token_hint when it is a
  real JWT.
- establishAdminSession clears any stale marker on every fresh login —
  sessions can die without /logout (deactivation, expiry, restore), and
  a surviving marker would bounce a later local-password session to the
  IdP. The SSO callback re-sets the marker for its own session.

Tests: oversized-token marker + hint-less end-session URL, stale-marker
cleared on local login; helper updated for the clear+set cookie pair.

* fix(auth): validate the logout hint against the current OIDC config (#798 phase 3)

Codex review round 2: an ID token stored at login can outlive an
issuer/client config change; sending it to the newly configured IdP as
id_token_hint strands the user on the IdP's error page (providers
validate iss/aud on the hint). buildEndSessionUrl now decodes the hint
(no verification — routing only): different issuer → skip the round-trip
entirely (the session belongs to another IdP); same issuer but changed
client → keep the round-trip, drop the unusable hint. Two tests pin both
paths.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-24 11:42:09 +02:00

168 lines
6.2 KiB
JavaScript

/**
* 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: 'sso@example.com', email_verified: true };
// Test hooks:
this.tamperNonce = false; // sign the ID token with a WRONG nonce
this.emailViaUserinfoOnly = false; // omit email from the ID token; serve it on /userinfo
this.advertiseEndSession = true; // include end_session_endpoint in discovery (#798 phase 3)
this.accessTokens = new Map(); // access_token -> user (for /userinfo)
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`,
userinfo_endpoint: `${this.issuer}/userinfo`,
jwks_uri: `${this.issuer}/jwks`,
...(this.advertiseEndSession ? { end_session_endpoint: `${this.issuer}/logout` } : {}),
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;
// Spec-compliant providers may keep profile/email claims OFF the ID
// token and serve them from /userinfo only — this hook simulates that.
const idTokenClaims = this.emailViaUserinfoOnly ? {} : extraClaims;
const idToken = this.signIdToken({
sub,
nonce: this.tamperNonce ? 'tampered-nonce' : stored.nonce,
extraClaims: idTokenClaims,
});
const accessToken = crypto.randomBytes(16).toString('base64url');
this.accessTokens.set(accessToken, stored.user);
return json(200, {
access_token: accessToken,
token_type: 'Bearer',
expires_in: 300,
id_token: idToken,
});
});
return undefined;
}
if (url.pathname === '/userinfo') {
const auth = req.headers.authorization || '';
const user = this.accessTokens.get(auth.replace(/^Bearer\s+/i, ''));
if (!user) return json(401, { error: 'invalid_token' });
return json(200, { ...user });
}
return json(404, { error: 'not_found' });
}
}
module.exports = { MockOidcProvider };