- TypeScript 95.3%
- Shell 2.4%
- JavaScript 2.2%
- Scheme 0.1%
| apps | ||
| docs/architecture/likec4 | ||
| interop | ||
| packages | ||
| scripts | ||
| tests/e2e | ||
| types | ||
| .envrc | ||
| .gitignore | ||
| biome.json | ||
| CHANGELOG.md | ||
| manifest.scm | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| tsconfig.base.json | ||
| vitest.config.ts | ||
node-oidc-provider-federation
A reference OpenID Federation 1.0 brokering POC built on node-oidc-provider, demonstrating bring-your-own-users trust across two independent OPs — with zero manual configuration — and cross-implementation interop against the Go LightHouse Trust Anchor.
What this is
The standard story for "login with your company's IdP" is: an admin at each side exchanges a client ID and secret, pastes a discovery URL, and clicks save. Works fine for one pair. Breaks at scale.
OpenID Federation 1.0 removes the bilateral wiring. Every entity — OPs, RPs, and anchors — publishes a signed entity configuration at /.well-known/openid-federation. A Trust Anchor signs subordinate statements about members. To trust a stranger, you resolve the chain to an anchor you already trust — no pre-shared secret, no admin needed. A company joins the federation and its users work immediately.
This POC is not "login with Google." It's the zero-configuration, trust-by-membership part — a company onboards, and an OP it has never spoken to auto-registers it at the protocol level, because both are federation members. Without that, it's just a redirect.
OpenID Federation 1.0 reached Final on 2026-02-17. This targets the finalized standard.
How brokering works
Both OPs start with empty client stores. Nothing is pre-registered.
The user opens the App, which resolves Platform A's trust chain and sends a signed Request Object (JAR + PKCE) — iss = client_id = the App's entity identifier, aud = A. A has never seen the App; it resolves the App's chain to the Trust Anchor, verifies the JAR signature against the resolved jwks, and auto-registers it (hop 1). A's login page offers "sign in with Company B." Via @federation/rp, A resolves B's chain and sends its own signed JAR to B. B has never seen A; it resolves A's chain, verifies, and auto-registers A (hop 2). The user authenticates at B, B issues a code, and A exchanges it — validating B's id_token against B's resolved keys. A mints its own platform session and redirects the user back to the App. B's id_token never leaves A.
The landscape view and the editable LikeC4 source live in docs/architecture/likec4/.
What's here
Packages
@federation/crypto(packages/crypto) — ES256 keypairs with thumbprintkidand typ-aware JWS sign/verify with kid-based key selection.@federation/core(packages/core) — OpenID Federation 1.0 entity statements, a metadata-policy engine (§6.1 operators + top-down merge), and a trust-chain resolver with §10.2 validation and min-exp (§10.4). Validated by a differential oracle against@oidfed/core.@federation/anchor(packages/anchor) — an in-memory Trust Anchor + Intermediate test federation fixture, importable in e2e and runnable as a process.@federation/http(packages/federation-http) — thin Fastify route factories for the four federation endpoints (/.well-known/openid-federation, Fetch, List, Resolve).fastifyis a peer dep so the crypto core stays testable without booting Fastify.@federation/op(packages/op) — synthesizing oidc-providerClientadapter: materializes aprivate_key_jwtclient from a resolved trust chain at request time (§12.1 automatic registration).@federation/rp(packages/rp) — RP-side: resolve the OP's chain, build the signed JAR + PKCE, drive the code exchange, validate the id_token against resolved keys, plus provider discovery.@federation/web-ui(packages/web-ui) — shared Pico classless HTML shell,escapeHtml, CSP/security headers, self-hosted stylesheet. Vendored from the sibling.
Apps
@federation/op-a(apps/op-a) — Platform OP + broker. node-oidc-provider: OP to the App, federation RP that brokers to op-b. Renders a "choose your company" login page and a/reveal?sub=<entityId>view that fetches and renders the entity's live trust chain as decoded statements. No users of its own.@federation/op-b(apps/op-b) — Company OP. node-oidc-provider with a real user store; devInteractions for login/consent. Where users actually authenticate.@federation/app(apps/app) — The platform RP. A Fastify OIDC client that auto-registers at op-a via@federation/rp, then shows the id_token claims and a link to the reveal view.@federation/anchor-app(apps/anchor) — The HTTP Trust Anchor process, serving the in-process@federation/anchorfixture over HTTP.- LightHouse (
interop/) — Go Trust Anchor + Resolver (oidfed/lighthouse:0.20.4), behind Caddy TLS. Not built here — runs viapnpm test:interop(Podman required).
Stack
Node 22, TypeScript (ESM, NodeNext), pnpm workspaces, Fastify, oidc-provider 9.8.4, jose, vitest, biome. Everything runs in-memory over HTTP — a dev/reference setup, not hardened for production. The M3 LightHouse interop adds a local CA and HTTPS; the in-memory tests stay plain HTTP.
Running
pnpm install
pnpm test # full suite (unit + integration + e2e), 114 tests, 2 skipped (interop)
pnpm typecheck # tsc --noEmit across all packages
pnpm dev:all # boots App + op-a + op-b on dynamic ports — clickable demo (prints URL)
pnpm test:interop # LightHouse cross-impl interop via Podman (needs interop/ stack up)
pnpm dev:all prints the App URL. From there you can walk the full App → A → B → A flow in a browser, and hit /reveal on op-a to see the decoded trust chains.
What to expect
The e2e files are the best tour — they boot the real Fastify apps on ephemeral ports and drive every flow over HTTP:
tests/e2e/discovery.e2e.test.ts— every entity's/.well-known/openid-federationis a valid signed entity configuration; OPs and RPs advertiseclient_registration_types(_supported): ["automatic"].tests/e2e/auto-registration.e2e.test.ts— empty client store; a stranger App sends a signed JAR; op-b resolves the chain, verifies the signature, auto-registers, and completes Auth Code + PKCE login.tests/e2e/security.e2e.test.ts— federation negatives at the OP: a non-memberclient_idis rejected, an unsigned request from a federation client is rejected (require_signed_request_object), and a forged Request Object signature is rejected. Trust-chain validation negatives (tampered statement, expired chain, untrusted/wrong anchor,alg:none) live in the@federation/coreunit tests.tests/e2e/broker-login.e2e.test.ts— the money shot: App → A → B → A; a user from Company B lands on Platform A with zero pre-configuration at either hop.tests/e2e/interop.e2e.test.ts— our resolver accepts a trust chain signed by LightHouse (Go) and rejects a tampered one. Gated (DOCKER_E2E=1);pnpm testskips it.
Faithfulness debt
A reference setup, not a faithful federation deployment. Honest caveats so you don't mistake it for the real thing:
- op-b uses devInteractions. op-b uses oidc-provider's built-in devInteractions for login/consent — real server-rendered pages, but the quick-start dev scaffold rather than bespoke forms with a persistent user store. A faithful Company OP would replace them; for this POC it's a deliberate scoping choice (op-a, the platform, does render its own pages). Must be disabled for any non-demo use.
pnpm dev:allis single-process, in-memory. All entities share one runtime inscripts/dev-all.mjs. The faithful shape — separate processes, HTTP entity-config fetching across the wire — remains a known limitation of the demo script.- In-memory subordinate registry, client stores, and ephemeral keys. The anchor's enrolled members, auto-registered clients, and the broker's federated-session cache all reset on restart. Each entity regenerates its federation signing key(s) on start. No persistence, no key rollover story.
- HTTP-only for the in-memory tests. The spec mandates
httpsfor entity identifiers. We runhttpin the main suite. The M3 LightHouse interop adds a local CA and real TLS — that's the only topology where entity IDs arehttps. - A's consent is auto-granted. op-a does not prompt the user before brokering to op-b. A real platform would present a visible "you're about to log in with Company B" step.
- Automatic registration only. Explicit registration (§12.2) is not implemented. Trust Marks (§7) are out of scope — the natural v2.
- The broker holds the session server-side. op-a caches the brokered identity and mints its own platform session. Inherent to brokering — A is a confidential party. The faithful part, the user authenticating at B with A trusting B only via the resolved chain, is kept.
- Dev secrets are hardcoded defaults — cookie keys, session secrets. Rotate for any real use.
