e9495a63ad
link are checking that email matches, +email alias are normalize into email if email is already used on another account, link is not possible not usurpation risk as the IDP is choosen by the admin
452 lines
19 KiB
JavaScript
452 lines
19 KiB
JavaScript
// Fake OpenID Connect Identity Provider for the OxiCloud OIDC
|
|
// integration test (tests/oidc/oidc.hurl).
|
|
//
|
|
// Wraps panva/node-oidc-provider — a spec-compliant OP — with a
|
|
// minimal Node http front-end that auto-resolves every interaction
|
|
// (login and consent) for a hard-coded test user. We don't wrap with
|
|
// our own Koa instance because oidc-provider ships a bundled Koa that
|
|
// the response prototype-checks against; layering another Koa around
|
|
// it triggers `vary: res argument is required` on the first request.
|
|
//
|
|
// What we get from the library that we'd otherwise hand-roll:
|
|
// * Discovery (.well-known/openid-configuration)
|
|
// * JWKS endpoint + RS256-signed JWTs
|
|
// * PKCE S256 verification
|
|
// * Authorization code lifecycle
|
|
// * Refresh token + id_token + access_token shapes
|
|
//
|
|
// What we get FOR FREE when we later add coverage for:
|
|
// * Back-channel logout — flip features.backchannelLogout.enabled
|
|
// * RP-initiated logout — flip features.rpInitiatedLogout.enabled
|
|
// * Token revocation (RFC 7009) — flip features.revocation.enabled
|
|
// * Token introspection (RFC 7662) — flip features.introspection.enabled
|
|
//
|
|
// Each future OIDC feature is a config flag in this file rather than
|
|
// new Rust protocol code to maintain.
|
|
|
|
import http from 'node:http';
|
|
import { URL } from 'node:url';
|
|
import { default as Provider } from 'oidc-provider';
|
|
// `jose` ships as a transitive dep of oidc-provider (it's what the
|
|
// library uses internally for JWTs). We reuse it to (a) generate the
|
|
// signing keypair at boot so oidc-provider signs id_tokens with keys
|
|
// we also own, and (b) mint spec-compliant logout_token JWTs in the
|
|
// /control/backchannel-logout endpoint below.
|
|
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
|
|
|
|
// ── Configuration knobs ─────────────────────────────────────────────────
|
|
const ISSUER = process.env.FAKE_IDP_ISSUER || 'http://localhost:1080';
|
|
const PORT = parseInt(process.env.FAKE_IDP_PORT || '1080', 10);
|
|
const TEST_USER_SUB = 'oidc-test-user';
|
|
const TEST_USER_USERNAME = 'oidc_user';
|
|
const TEST_USER_EMAIL = 'oidc@example.com';
|
|
// Full claim set pinned to deterministic values so the Hurl test can
|
|
// assert that JIT provisioning (auth_application_service.rs:2257)
|
|
// stores each one verbatim. Keep the claim names matching the OIDC
|
|
// `IdTokenClaims` struct in src/infrastructure/services/oidc_service.rs.
|
|
const TEST_USER_NAME = 'OIDC Test User';
|
|
const TEST_USER_GIVEN_NAME = 'OIDC';
|
|
const TEST_USER_FAMILY_NAME = 'Test';
|
|
// `picture` is the OIDC claim; OxiCloud persists it as `User.image`
|
|
// (a URL or data URI). We use a stable HTTP URL so a simple equality
|
|
// check works in the Hurl assertion.
|
|
const TEST_USER_PICTURE = 'https://example.com/oidc-test-user.png';
|
|
// Group claim — paired with OXICLOUD_OIDC_ADMIN_GROUPS=admin-users in
|
|
// server-with-oidc.env. The JIT path intersects this list against the
|
|
// configured admin groups; a non-empty intersection escalates the new
|
|
// user's role from `user` to `admin`. This is the typical SSO pattern
|
|
// every Authentik/Keycloak/Entra deployment uses to map IdP groups to
|
|
// app roles.
|
|
const TEST_USER_GROUPS = ['admin-users'];
|
|
|
|
// OxiCloud's base URL — derived from the callback URI so run.sh only
|
|
// has one place (test.env) to change the port. Used by the BCL control
|
|
// endpoint to POST logout_tokens back to OxiCloud.
|
|
const OXICLOUD_BASE_URL =
|
|
process.env.OXICLOUD_BASE_URL_FOR_BCL || 'http://localhost:8087';
|
|
const BCL_KID = 'fake-idp-key-1';
|
|
const BCL_EVENT = 'http://schemas.openid.net/event/backchannel-logout';
|
|
|
|
// ── Runtime-toggleable state for negative tests ────────────────────────
|
|
// `email_verified` is normally true; the test flips it to false via
|
|
// `POST /control/email-verified/false` to drive OxiCloud's anti-takeover
|
|
// rejection branch (auth_application_service.rs: only `email_verified`
|
|
// callers reach JIT-provisioning), then flips back. Module-level state
|
|
// because oidc-provider doesn't pass test-specific context into the
|
|
// claims() callback.
|
|
let emailVerifiedState = true;
|
|
|
|
// Runtime-swappable email — normally the pinned TEST_USER_EMAIL, but
|
|
// the OIDC-account-linking Hurl suite flips it via
|
|
// `POST /control/set-email` to test the auto-link + self-service-link
|
|
// safety checks: email mismatch refusal, +alias normalization
|
|
// equivalence, etc. Reset by `POST /control/reset-email` (or by
|
|
// setting to the pinned value explicitly).
|
|
let emailOverride = null;
|
|
|
|
// Pre-generate the signing keypair. oidc-provider v9 accepts private
|
|
// JWKs via configuration.jwks and exports the public halves at
|
|
// /jwks.json; keeping our own reference to the private key means we
|
|
// can also mint valid logout_token JWTs from the /control endpoint,
|
|
// so OxiCloud's back-channel-logout validator (which fetches the same
|
|
// JWKS) accepts them.
|
|
const { publicKey: bclPublicKey, privateKey: bclPrivateKey } =
|
|
await generateKeyPair('RS256', { extractable: true });
|
|
const bclPrivateJwk = await exportJWK(bclPrivateKey);
|
|
bclPrivateJwk.use = 'sig';
|
|
bclPrivateJwk.alg = 'RS256';
|
|
bclPrivateJwk.kid = BCL_KID;
|
|
// eslint-disable-next-line no-unused-vars
|
|
const _bclPublicKeyRef = bclPublicKey; // kept for symmetry / debugging
|
|
|
|
const configuration = {
|
|
clients: [
|
|
{
|
|
client_id: 'oxicloud-test',
|
|
client_secret: 'test-client-secret-not-used-in-prod',
|
|
// 8087: automated tests/oidc/oidc.hurl suite. 8090: human-run
|
|
// tests/oidc/run-manual-sso-only.sh (SSO-only auto-redirect check).
|
|
redirect_uris: [
|
|
'http://localhost:8087/api/auth/oidc/callback',
|
|
'http://localhost:8090/api/auth/oidc/callback',
|
|
],
|
|
grant_types: ['authorization_code'],
|
|
response_types: ['code'],
|
|
token_endpoint_auth_method: 'client_secret_post',
|
|
// Back-Channel Logout 1.0 wire-up. The URI is where OxiCloud's
|
|
// handler lives (POST /api/auth/oidc/backchannel-logout). With
|
|
// session_required = true, the OP MUST include `sid` in both the
|
|
// id_token AND the logout_token — mirrors Keycloak's "Backchannel
|
|
// Logout Session Required" client toggle. OxiCloud persists the
|
|
// id_token sid on auth.sessions.oidc_sid so per-device revocation
|
|
// works; without session_required we'd fall back to sub-based
|
|
// (all-device) revocation.
|
|
backchannel_logout_uri: `${OXICLOUD_BASE_URL}/api/auth/oidc/backchannel-logout`,
|
|
backchannel_logout_session_required: true,
|
|
// RP-initiated logout — required for tests/oidc/sso-only.hurl to
|
|
// exercise the `post_logout_url` shape returned by OxiCloud's
|
|
// /api/auth/logout when the session is OIDC-backed. The `/login`
|
|
// URLs on both automated (8087) and manual (8090) ports are
|
|
// registered so both runners can drive the flow.
|
|
post_logout_redirect_uris: [
|
|
'http://localhost:8087/login',
|
|
'http://localhost:8090/login',
|
|
],
|
|
},
|
|
],
|
|
|
|
// Register the private JWK we generated above. The library uses it
|
|
// to sign id_tokens; the public half is served at /jwks.json and is
|
|
// what OxiCloud's OidcService caches for id_token AND logout_token
|
|
// signature verification (they share the same JWKS per BCL 1.0).
|
|
jwks: { keys: [bclPrivateJwk] },
|
|
|
|
pkce: { required: () => true, methods: ['S256'] },
|
|
|
|
claims: {
|
|
openid: ['sub'],
|
|
email: ['email', 'email_verified'],
|
|
// `profile` is the standard scope OxiCloud requests
|
|
// (OXICLOUD_OIDC_SCOPES in server-with-oidc.env). It covers every
|
|
// claim the JIT-provisioning code in auth_application_service.rs
|
|
// reads except email — name + given/family + picture +
|
|
// preferred_username + groups all ride here.
|
|
profile: [
|
|
'name',
|
|
'given_name',
|
|
'family_name',
|
|
'preferred_username',
|
|
'picture',
|
|
'groups',
|
|
],
|
|
},
|
|
|
|
async findAccount(_ctx, sub) {
|
|
if (sub !== TEST_USER_SUB) return undefined;
|
|
return {
|
|
accountId: sub,
|
|
// Return EVERY claim the OIDC client could ask for. The provider
|
|
// filters by the consented scope before issuing — values not in
|
|
// a granted scope are dropped from the ID token / userinfo.
|
|
async claims() {
|
|
return {
|
|
sub: TEST_USER_SUB,
|
|
email: emailOverride ?? TEST_USER_EMAIL,
|
|
email_verified: emailVerifiedState,
|
|
name: TEST_USER_NAME,
|
|
given_name: TEST_USER_GIVEN_NAME,
|
|
family_name: TEST_USER_FAMILY_NAME,
|
|
preferred_username: TEST_USER_USERNAME,
|
|
picture: TEST_USER_PICTURE,
|
|
groups: TEST_USER_GROUPS,
|
|
};
|
|
},
|
|
};
|
|
},
|
|
|
|
features: {
|
|
// Turn off the dev login/consent UI; we own the interaction route.
|
|
devInteractions: { enabled: false },
|
|
// OIDC Back-Channel Logout 1.0. Turning it on makes the OP
|
|
// advertise `backchannel_logout_supported` in discovery and
|
|
// emit `sid` in id_tokens when the client has
|
|
// `backchannel_logout_session_required: true`. We do NOT rely on
|
|
// oidc-provider to send BCL notifications from its internal
|
|
// session-destroy path (which would require driving OP session
|
|
// lifecycle from the test); the /control/backchannel-logout
|
|
// endpoint below mints a spec-compliant logout_token directly
|
|
// and POSTs it to OxiCloud. That's the same wire shape a real
|
|
// IdP produces, so OxiCloud's validator is exercised end-to-end.
|
|
backchannelLogout: { enabled: true },
|
|
// RP-Initiated Logout 1.0. Turning it on advertises
|
|
// `end_session_endpoint` in discovery so OxiCloud's
|
|
// `build_end_session_url` (invoked from POST /api/auth/logout)
|
|
// returns a real URL instead of None. Without this the SSO-only
|
|
// Hurl assertion `post_logout_url is present` fails silently.
|
|
rpInitiatedLogout: { enabled: true },
|
|
},
|
|
|
|
// Put scope-implied claims (name, given_name, family_name,
|
|
// preferred_username, picture, email, …) directly into the ID token
|
|
// instead of keeping them at /userinfo only.
|
|
//
|
|
// OxiCloud's OIDC client (auth_application_service.rs:2085) only
|
|
// calls /userinfo when the ID token lacks `email` — with the email
|
|
// scope granted the ID token DOES carry email, so userinfo never
|
|
// runs, and the default (conformIdTokenClaims: true) means `picture`
|
|
// would silently vanish during JIT provisioning. Setting this to
|
|
// `false` mirrors what most real-world IdPs (Authentik, Keycloak's
|
|
// default profile) do for browser SSO clients.
|
|
conformIdTokenClaims: false,
|
|
|
|
// Point every interaction at our auto-resolver below.
|
|
interactions: {
|
|
url(_ctx, interaction) {
|
|
return `/auto/${interaction.uid}`;
|
|
},
|
|
},
|
|
|
|
cookies: {
|
|
keys: ['fake-idp-cookie-key-not-a-real-secret'],
|
|
},
|
|
};
|
|
|
|
const provider = new Provider(ISSUER, configuration);
|
|
provider.proxy = false;
|
|
|
|
// `provider.callback()` is an http-compatible request handler.
|
|
// We intercept /auto/<uid> ourselves and forward everything else.
|
|
const oidcHandler = provider.callback();
|
|
|
|
// `/control/*` paths are test-only hooks the Hurl suite uses to
|
|
// flip IdP-side state between flows (e.g. force email_verified=false
|
|
// to exercise OxiCloud's anti-takeover rejection branch). Kept on the
|
|
// SAME port as the OIDC endpoints so we don't have to thread two ports
|
|
// through every test config. Never used in production-shaped flows.
|
|
async function handleControl(req, res) {
|
|
const url = new URL(req.url, ISSUER);
|
|
if (req.method === 'POST' && url.pathname === '/control/email-verified/true') {
|
|
emailVerifiedState = true;
|
|
res.statusCode = 200;
|
|
res.setHeader('content-type', 'application/json');
|
|
return res.end(JSON.stringify({ email_verified: true }));
|
|
}
|
|
if (req.method === 'POST' && url.pathname === '/control/email-verified/false') {
|
|
emailVerifiedState = false;
|
|
res.statusCode = 200;
|
|
res.setHeader('content-type', 'application/json');
|
|
return res.end(JSON.stringify({ email_verified: false }));
|
|
}
|
|
// Swap the IdP-returned email to test the OIDC-account-linking
|
|
// safety checks (email match, +alias normalization, mismatch refusal).
|
|
// Body: `{ email: "alice@example.com" }` — or `null`/`""` to reset
|
|
// to the pinned TEST_USER_EMAIL.
|
|
if (req.method === 'POST' && url.pathname === '/control/set-email') {
|
|
let body = '';
|
|
for await (const chunk of req) body += chunk;
|
|
let parsed = {};
|
|
try {
|
|
parsed = body ? JSON.parse(body) : {};
|
|
} catch {
|
|
res.statusCode = 400;
|
|
res.setHeader('content-type', 'application/json');
|
|
return res.end(JSON.stringify({ error: 'invalid_json' }));
|
|
}
|
|
emailOverride =
|
|
parsed.email && typeof parsed.email === 'string' && parsed.email.length > 0
|
|
? parsed.email
|
|
: null;
|
|
res.statusCode = 200;
|
|
res.setHeader('content-type', 'application/json');
|
|
return res.end(
|
|
JSON.stringify({
|
|
email: emailOverride ?? TEST_USER_EMAIL,
|
|
overridden: emailOverride !== null,
|
|
}),
|
|
);
|
|
}
|
|
if (req.method === 'POST' && url.pathname === '/control/backchannel-logout') {
|
|
// Body shape: `{ sub?: string, sid?: string }`. Optional so the test
|
|
// can exercise both revocation modes:
|
|
// * sub only → OxiCloud falls back to revoke-by-subject (kills all
|
|
// the user's sessions).
|
|
// * sid present → OxiCloud revokes just the session bound to that
|
|
// sid (per-device path — the "typical" mode when
|
|
// backchannel_logout_session_required is on).
|
|
// Default to sub-only against the built-in test user when neither is
|
|
// supplied — that keeps the simplest scenario a one-liner in Hurl.
|
|
let body = '';
|
|
for await (const chunk of req) body += chunk;
|
|
let parsed = {};
|
|
try {
|
|
parsed = body ? JSON.parse(body) : {};
|
|
} catch {
|
|
res.statusCode = 400;
|
|
res.setHeader('content-type', 'application/json');
|
|
return res.end(JSON.stringify({ error: 'invalid_json' }));
|
|
}
|
|
const sub = parsed.sub ?? TEST_USER_SUB;
|
|
const sid = parsed.sid; // may be undefined
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
// Mint the logout_token per BCL 1.0 §2.4:
|
|
// * `events` MUST contain the backchannel-logout URI as a key.
|
|
// * `sub` and/or `sid` MUST be present (we always include sub;
|
|
// sid conditional).
|
|
// * `nonce` MUST NOT be present (SignJWT does not add one by default).
|
|
// * `iat` present, `jti` present for replay-guard testing.
|
|
const payload = { events: { [BCL_EVENT]: {} } };
|
|
if (sub) payload.sub = sub;
|
|
if (sid) payload.sid = sid;
|
|
|
|
const jwt = await new SignJWT(payload)
|
|
.setProtectedHeader({ alg: 'RS256', kid: BCL_KID, typ: 'JWT' })
|
|
.setIssuer(ISSUER)
|
|
.setAudience('oxicloud-test')
|
|
.setIssuedAt(now)
|
|
.setJti(`bcl-${now}-${Math.random().toString(36).slice(2, 10)}`)
|
|
.sign(bclPrivateKey);
|
|
|
|
// POST as application/x-www-form-urlencoded per BCL §2.5.
|
|
const target = `${OXICLOUD_BASE_URL}/api/auth/oidc/backchannel-logout`;
|
|
try {
|
|
const resp = await fetch(target, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({ logout_token: jwt }).toString(),
|
|
});
|
|
const respBody = await resp.text();
|
|
res.statusCode = 200;
|
|
res.setHeader('content-type', 'application/json');
|
|
return res.end(
|
|
JSON.stringify({
|
|
forwarded_to: target,
|
|
oxicloud_status: resp.status,
|
|
oxicloud_body: respBody,
|
|
}),
|
|
);
|
|
} catch (e) {
|
|
res.statusCode = 502;
|
|
res.setHeader('content-type', 'application/json');
|
|
return res.end(
|
|
JSON.stringify({ error: 'forward_failed', detail: String(e) }),
|
|
);
|
|
}
|
|
}
|
|
res.statusCode = 404;
|
|
res.setHeader('content-type', 'application/json');
|
|
return res.end(JSON.stringify({ error: 'no such control endpoint' }));
|
|
}
|
|
|
|
// One-line per-request log — useful when a future test fails
|
|
// mysteriously ("did OxiCloud actually call /me?" / "is the
|
|
// /authorize redirect hitting the right URL?"). Kept because it's
|
|
// low-noise and makes the next debugging session 10x easier; the
|
|
// payload-dumping diagnostics that helped land the
|
|
// `image`-missing-from-INSERT fix (UserPgRepository::create_user)
|
|
// have been stripped.
|
|
const server = http.createServer(async (req, res) => {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`[fake-idp] ${req.method} ${req.url}`);
|
|
if (req.url.startsWith('/control/')) return await handleControl(req, res);
|
|
|
|
try {
|
|
const url = new URL(req.url, ISSUER);
|
|
const autoMatch = url.pathname.match(/^\/auto\/[^/]+\/?$/);
|
|
|
|
if (autoMatch) {
|
|
return await handleAuto(req, res);
|
|
}
|
|
|
|
return oidcHandler(req, res);
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.error('[fake-idp] unhandled error:', e);
|
|
if (!res.headersSent) {
|
|
res.statusCode = 500;
|
|
res.setHeader('content-type', 'application/json');
|
|
res.end(JSON.stringify({ error: 'internal', detail: String(e) }));
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── Auto-approve handler ───────────────────────────────────────────────
|
|
// The library redirects /authorize to /auto/<uid>. We pull the
|
|
// interaction state, sign the test user in (prompt=login), then grant
|
|
// every requested claim+scope (prompt=consent). The provider issues
|
|
// the authorization code and 302s back to OxiCloud's callback.
|
|
async function handleAuto(req, res) {
|
|
const details = await provider.interactionDetails(req, res);
|
|
const {
|
|
prompt: { name },
|
|
params,
|
|
} = details;
|
|
|
|
if (name === 'login') {
|
|
return provider.interactionFinished(
|
|
req,
|
|
res,
|
|
{ login: { accountId: TEST_USER_SUB } },
|
|
{ mergeWithLastSubmission: false },
|
|
);
|
|
}
|
|
|
|
if (name === 'consent') {
|
|
const grant = new provider.Grant({
|
|
accountId: TEST_USER_SUB,
|
|
clientId: params.client_id,
|
|
});
|
|
if (params.scope) grant.addOIDCScope(params.scope);
|
|
// Explicitly grant every profile claim OxiCloud reads at JIT
|
|
// provisioning (see src/application/services/auth_application_service.rs
|
|
// around line 2257). `addOIDCClaims` is additive to whatever the
|
|
// scope already implies, so listing them here is belt-and-braces
|
|
// for keeping the claim set complete.
|
|
grant.addOIDCClaims([
|
|
'email',
|
|
'email_verified',
|
|
'name',
|
|
'given_name',
|
|
'family_name',
|
|
'preferred_username',
|
|
'picture',
|
|
]);
|
|
const grantId = await grant.save();
|
|
return provider.interactionFinished(
|
|
req,
|
|
res,
|
|
{ consent: { grantId } },
|
|
{ mergeWithLastSubmission: true },
|
|
);
|
|
}
|
|
|
|
res.statusCode = 400;
|
|
res.setHeader('content-type', 'application/json');
|
|
res.end(JSON.stringify({ error: 'unsupported_prompt', prompt: name }));
|
|
}
|
|
|
|
server.listen(PORT, () => {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`[fake-idp] listening on ${ISSUER} (test user sub=${TEST_USER_SUB})`);
|
|
});
|