A reference OID4VCI credential issuer built on node-oidc-provider and the @openid4vc/* suite
  • TypeScript 99%
  • JavaScript 1%
Find a file
2026-06-08 19:18:36 +01:00
apps feat: style the frontend with self-hosted Pico.css 2026-06-08 19:18:36 +01:00
docs/architecture/likec4 feat: initial release 2026-06-06 08:53:49 +01:00
packages feat: style the frontend with self-hosted Pico.css 2026-06-08 19:18:36 +01:00
scripts feat: initial release 2026-06-06 08:53:49 +01:00
tests/e2e feat: revocable credentials via Token Status List; enforce CSRF unconditionally 2026-06-08 09:22:24 +01:00
.envrc feat: initial release 2026-06-06 08:53:49 +01:00
.gitignore feat: initial release 2026-06-06 08:53:49 +01:00
biome.json feat: initial release 2026-06-06 08:53:49 +01:00
manifest.scm feat: initial release 2026-06-06 08:53:49 +01:00
package.json feat: initial release 2026-06-06 08:53:49 +01:00
pnpm-lock.yaml feat: style the frontend with self-hosted Pico.css 2026-06-08 19:18:36 +01:00
pnpm-workspace.yaml feat: initial release 2026-06-06 08:53:49 +01:00
README.md feat: revocable credentials via Token Status List; enforce CSRF unconditionally 2026-06-08 09:22:24 +01:00
tsconfig.base.json feat: initial release 2026-06-06 08:53:49 +01:00
vitest.config.ts feat: initial release 2026-06-06 08:53:49 +01:00

node-oidc-provider-oid4vci

A reference OID4VCI credential issuer built on node-oidc-provider and the @openid4vc/* suite. It issues SD-JWT VC credentials (dc+sd-jwt, ES256) — bounded with exp and revocable through a Token Status List — and implements a two-server credential chain you can drive from a browser.

How the chain works

Credential chain — end-to-end flow

The user authenticates at Server A (the wallet never sees those credentials), receives Credential A, then the wallet presents it to Server B over OID4VP to get Credential B — carrying the verified email and bound to the same holder key. A landscape view and the editable LikeC4 source live in docs/architecture/likec4/.

What's here

  • Server A (apps/server-a) — an issuer with users. node-oidc-provider is the authorization server (registration, login, and consent served as real HTML, plus /token); the OID4VCI authorization-code flow issues "Credential A" (an identity credential) into a wallet.
  • Server B (apps/server-b) — an issuer without users, with two ways in: an anonymous pre-authorized-code flow (mint an offer, redeem it), and the credential chain — Server B is also an OID4VP verifier, so the wallet presents Credential A and Server B issues "Credential B" (a membership credential) via presentation-during-issuance.
  • apps/wallet — a server-rendered wallet BFF that holds the holder key + credentials and walks both legs for you (see Browser frontend).
  • packages/wallet-core — holder-side protocol logic (auth-code build/complete, credential fetch, pre-auth, OID4VP present + presentation-during-issuance), shared by apps/wallet and the test wallet so there's one wallet implementation.
  • packages/issuer-core — issuer/verifier SD-JWT VC signing + verification (including KB-JWT presentation verification), a single-use nonce store, and a Token Status List (sign the statuslist+jwt token, plus the verifier-side fetch-and-check that powers revocation).
  • packages/web-ui — minimal HTML escaping + a shared page layout/CSP for the server-rendered UIs.
  • packages/crypto — ES256 keys, the jose-based crypto callbacks the libraries expect, and the SD-JWT signer/hasher.
  • packages/test-wallet — a programmatic holder for the tests (no app or phone needed), a thin wrapper over wallet-core.

Design docs and the implementation plans live under docs/superpowers/.

Stack

Node 22, TypeScript (ESM), pnpm workspaces, Fastify, vitest. Everything is in-memory and HTTP-only — this is a dev/reference setup, not hardened for production (no DPoP, no persistence, no tx_code, keys are ephemeral per process).

Running

pnpm install
pnpm test          # full suite (unit + integration + e2e), 126 tests
pnpm typecheck     # tsc --noEmit across all packages

pnpm dev:server-a  # http://127.0.0.1:3000
pnpm dev:server-b  # http://127.0.0.1:3001

What to expect

The tests are the best tour — the e2e files drive each flow end to end:

  • tests/e2e/credential-a.e2e.test.ts — register → log in over HTTP → wallet receives a verifiable Credential A.
  • tests/e2e/credential-b.e2e.test.ts — mint an offer → wallet redeems the pre-auth code → verifiable Credential B.
  • tests/e2e/credential-chain.e2e.test.ts — the chain: wallet presents Credential A to Server B over OID4VP and gets Credential B (carrying the verified email, bound to the same holder key).
  • tests/e2e/frontend-chain.e2e.test.ts — the same chain driven through the browser surfaces (register/login/consent forms), plus the security negatives (cross-session state, bad iss, XSS, CSRF).

Running a server gives you the live metadata:

curl -s http://127.0.0.1:3000/.well-known/openid-credential-issuer    # Server A
curl -s http://127.0.0.1:3001/.well-known/openid-credential-issuer    # Server B
curl -s http://127.0.0.1:3001/.well-known/oauth-authorization-server  # Server B's AS
curl -s http://127.0.0.1:3000/status-lists/1                          # Server A's status list (a statuslist+jwt)

# Server B: mint an offer, then redeem its pre-authorized_code at /token
curl -s -X POST http://127.0.0.1:3001/offers \
  -H 'content-type: application/json' \
  -d '{"claims":{"member_id":"m-1","level":"gold"}}'

Each issued credential is an SD-JWT VC bound to the wallet's holder key (cnf), with the claims selectively disclosable. It also carries an exp and a status claim pointing at the issuer's Token Status List.

Revocation

Both issuers keep a Token Status List (bits: 1, one bit per credential) and serve it as a signed statuslist+jwt at /status-lists/1. Every credential embeds status.status_list = { idx, uri }, so a verifier can fetch the list, check the bit at idx, and reject a revoked credential. The chain closes the loop: when the wallet presents Credential A to Server B, Server B fetches Server A's status list and refuses to issue Credential B if Credential A has been revoked (see tests/e2e/revocation.e2e.test.ts).

Browser frontend (M4)

There's a browser frontend now, so you can drive the whole A → wallet → B chain by clicking instead of reading e2e tests. Server A serves real HTML for register/login/consent, and a thin wallet BFF (apps/wallet) holds the session and walks the OID4VCI/OID4VP legs for you.

Start all three wired together with one command:

pnpm dev:all       # Server A :3000, Server B :3001, wallet :3002

It prints a wallet URL — open it. From there:

  • Get identity credential sends you to Server A. Register (or log in) and consent; you land back at the wallet holding Credential A.
  • Get membership credential then shows a consent screen naming exactly the claims Server B asks for. Confirm, and the wallet presents Credential A to Server B over OID4VP and gets Credential B back — all in one click.

Ports are overridable (SERVER_A_PORT, SERVER_B_PORT, WALLET_PORT). Ctrl-C stops everything.

Faithfulness debt

This is a reference setup, not a faithful wallet deployment. A few honest caveats so you don't mistake it for the real thing:

  • The BFF holds keys server-side. A real wallet is a public client on the user's device with keys in a secure enclave. The BFF is a demo convenience and inverts part of the trust model — the "wallet" service could see or replay the holder's credentials. The faithful part, the user authenticating at Server A, is kept.
  • Per-session, in-memory holder and credential store — no persistence, no multi-device; everything resets on restart.
  • Revocation is in-memory. Each issuer's Token Status List lives in process memory, so the index allocation and any revocations reset on restart, and the statuslist+jwt is minted fresh per request (no caching, no ttl-driven refresh story). The wire format and the verifier-side check are faithful; the storage isn't.
  • OID4VP uses the redirect_uri client-identifier prefix (unsigned request; KB-JWT aud is the bare response_uri). A HAIP/EUDI deployment would use a signed request object with x509_hash.
  • Dev secrets are hardcoded defaults — the cookie key, Server A's CSRF cookie secret, and WALLET_SESSION_SECRET. Rotate them for any real use.
  • No JWE on the OID4VP envelope (throwing stubs).
  • Standards mix — Credential A is OID4VCI 1.0 Final authorization-code issuance; Credential B's presentation-during-issuance leg is draft-tracking (OID4VCI v1.1 working draft + draft-ietf-oauth-first-party-apps). Intentional: PDI doesn't exist in 1.0 Final.