A reference OpenID Federation 1.0 brokering POC built on node-oidc-provider
  • TypeScript 95.3%
  • Shell 2.4%
  • JavaScript 2.2%
  • Scheme 0.1%
Find a file
2026-06-10 15:36:12 +01:00
apps feat: initial release 2026-06-10 15:36:12 +01:00
docs/architecture/likec4 feat: initial release 2026-06-10 15:36:12 +01:00
interop feat: initial release 2026-06-10 15:36:12 +01:00
packages feat: initial release 2026-06-10 15:36:12 +01:00
scripts feat: initial release 2026-06-10 15:36:12 +01:00
tests/e2e feat: initial release 2026-06-10 15:36:12 +01:00
types feat: initial release 2026-06-10 15:36:12 +01:00
.envrc feat: initial release 2026-06-10 15:36:12 +01:00
.gitignore feat: initial release 2026-06-10 15:36:12 +01:00
biome.json feat: initial release 2026-06-10 15:36:12 +01:00
CHANGELOG.md feat: initial release 2026-06-10 15:36:12 +01:00
manifest.scm feat: initial release 2026-06-10 15:36:12 +01:00
package.json feat: initial release 2026-06-10 15:36:12 +01:00
pnpm-lock.yaml feat: initial release 2026-06-10 15:36:12 +01:00
pnpm-workspace.yaml feat: initial release 2026-06-10 15:36:12 +01:00
README.md feat: initial release 2026-06-10 15:36:12 +01:00
tsconfig.base.json feat: initial release 2026-06-10 15:36:12 +01:00
vitest.config.ts feat: initial release 2026-06-10 15:36:12 +01:00

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

Federation brokering — end-to-end flow

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 thumbprint kid and 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). fastify is a peer dep so the crypto core stays testable without booting Fastify.
  • @federation/op (packages/op) — synthesizing oidc-provider Client adapter: materializes a private_key_jwt client 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/anchor fixture over HTTP.
  • LightHouse (interop/) — Go Trust Anchor + Resolver (oidfed/lighthouse:0.20.4), behind Caddy TLS. Not built here — runs via pnpm 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-federation is a valid signed entity configuration; OPs and RPs advertise client_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-member client_id is 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/core unit 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 test skips 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:all is single-process, in-memory. All entities share one runtime in scripts/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 https for entity identifiers. We run http in the main suite. The M3 LightHouse interop adds a local CA and real TLS — that's the only topology where entity IDs are https.
  • 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.