- TypeScript 99%
- JavaScript 1%
| apps | ||
| docs/architecture/likec4 | ||
| packages | ||
| scripts | ||
| tests/e2e | ||
| .envrc | ||
| .gitignore | ||
| biome.json | ||
| manifest.scm | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| tsconfig.base.json | ||
| vitest.config.ts | ||
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
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 byapps/walletand 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 thestatuslist+jwttoken, 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, thejose-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 overwallet-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, badiss, 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+jwtis minted fresh per request (no caching, nottl-driven refresh story). The wire format and the verifier-side check are faithful; the storage isn't. - OID4VP uses the
redirect_uriclient-identifier prefix (unsigned request; KB-JWTaudis the bareresponse_uri). A HAIP/EUDI deployment would use a signed request object withx509_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.
