@learncard/openid4vc-plugin 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +592 -0
  3. package/dist/dcql/adapt.d.ts +80 -0
  4. package/dist/dcql/adapt.d.ts.map +1 -0
  5. package/dist/dcql/build.d.ts +122 -0
  6. package/dist/dcql/build.d.ts.map +1 -0
  7. package/dist/dcql/compose.d.ts +78 -0
  8. package/dist/dcql/compose.d.ts.map +1 -0
  9. package/dist/dcql/index.d.ts +18 -0
  10. package/dist/dcql/index.d.ts.map +1 -0
  11. package/dist/dcql/parse.d.ts +16 -0
  12. package/dist/dcql/parse.d.ts.map +1 -0
  13. package/dist/dcql/respond.d.ts +115 -0
  14. package/dist/dcql/respond.d.ts.map +1 -0
  15. package/dist/dcql/select.d.ts +63 -0
  16. package/dist/dcql/select.d.ts.map +1 -0
  17. package/dist/dcql/types.d.ts +93 -0
  18. package/dist/dcql/types.d.ts.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +7 -0
  22. package/dist/offer/index.d.ts +4 -0
  23. package/dist/offer/index.d.ts.map +1 -0
  24. package/dist/offer/normalize.d.ts +15 -0
  25. package/dist/offer/normalize.d.ts.map +1 -0
  26. package/dist/offer/parse.d.ts +24 -0
  27. package/dist/offer/parse.d.ts.map +1 -0
  28. package/dist/offer/types.d.ts +92 -0
  29. package/dist/offer/types.d.ts.map +1 -0
  30. package/dist/openid4vc-plugin.cjs.development.js +27402 -0
  31. package/dist/openid4vc-plugin.cjs.development.js.map +7 -0
  32. package/dist/openid4vc-plugin.cjs.production.min.js +146 -0
  33. package/dist/openid4vc-plugin.cjs.production.min.js.map +7 -0
  34. package/dist/openid4vc-plugin.esm.js +27396 -0
  35. package/dist/openid4vc-plugin.esm.js.map +7 -0
  36. package/dist/plugin.d.ts +12 -0
  37. package/dist/plugin.d.ts.map +1 -0
  38. package/dist/siop/index.d.ts +11 -0
  39. package/dist/siop/index.d.ts.map +1 -0
  40. package/dist/siop/sign.d.ts +92 -0
  41. package/dist/siop/sign.d.ts.map +1 -0
  42. package/dist/types.d.ts +359 -0
  43. package/dist/types.d.ts.map +1 -0
  44. package/dist/vci/accept.d.ts +24 -0
  45. package/dist/vci/accept.d.ts.map +1 -0
  46. package/dist/vci/auth-code.d.ts +148 -0
  47. package/dist/vci/auth-code.d.ts.map +1 -0
  48. package/dist/vci/decode.d.ts +65 -0
  49. package/dist/vci/decode.d.ts.map +1 -0
  50. package/dist/vci/errors.d.ts +22 -0
  51. package/dist/vci/errors.d.ts.map +1 -0
  52. package/dist/vci/index.d.ts +12 -0
  53. package/dist/vci/index.d.ts.map +1 -0
  54. package/dist/vci/metadata.d.ts +25 -0
  55. package/dist/vci/metadata.d.ts.map +1 -0
  56. package/dist/vci/pkce.d.ts +85 -0
  57. package/dist/vci/pkce.d.ts.map +1 -0
  58. package/dist/vci/proof.d.ts +43 -0
  59. package/dist/vci/proof.d.ts.map +1 -0
  60. package/dist/vci/request.d.ts +48 -0
  61. package/dist/vci/request.d.ts.map +1 -0
  62. package/dist/vci/store.d.ts +89 -0
  63. package/dist/vci/store.d.ts.map +1 -0
  64. package/dist/vci/token.d.ts +16 -0
  65. package/dist/vci/token.d.ts.map +1 -0
  66. package/dist/vci/types.d.ts +156 -0
  67. package/dist/vci/types.d.ts.map +1 -0
  68. package/dist/vp/client-id-prefix.d.ts +115 -0
  69. package/dist/vp/client-id-prefix.d.ts.map +1 -0
  70. package/dist/vp/encrypt.d.ts +221 -0
  71. package/dist/vp/encrypt.d.ts.map +1 -0
  72. package/dist/vp/index.d.ts +27 -0
  73. package/dist/vp/index.d.ts.map +1 -0
  74. package/dist/vp/parse.d.ts +60 -0
  75. package/dist/vp/parse.d.ts.map +1 -0
  76. package/dist/vp/pex.d.ts +83 -0
  77. package/dist/vp/pex.d.ts.map +1 -0
  78. package/dist/vp/present.d.ts +126 -0
  79. package/dist/vp/present.d.ts.map +1 -0
  80. package/dist/vp/request-object.d.ts +99 -0
  81. package/dist/vp/request-object.d.ts.map +1 -0
  82. package/dist/vp/select.d.ts +137 -0
  83. package/dist/vp/select.d.ts.map +1 -0
  84. package/dist/vp/sign.d.ts +125 -0
  85. package/dist/vp/sign.d.ts.map +1 -0
  86. package/dist/vp/submit.d.ts +175 -0
  87. package/dist/vp/submit.d.ts.map +1 -0
  88. package/dist/vp/types.d.ts +255 -0
  89. package/dist/vp/types.d.ts.map +1 -0
  90. package/package.json +52 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Learning Economy Foundation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,592 @@
1
+ # @learncard/openid4vc-plugin
2
+
3
+ OpenID for Verifiable Credentials **holder-side** support for LearnCard:
4
+
5
+ - **OID4VCI** — accept credential offers, exchange pre-authorized / authorization codes for access tokens, request credentials from issuers.
6
+ - **OID4VP** — parse Authorization Requests, match held credentials against Presentation Definitions (DIF PEX v2), build and sign VP tokens.
7
+ - **SIOPv2** — issue self-issued ID tokens for holder authentication.
8
+
9
+ ## Status
10
+
11
+ **Holder surface feature-complete.** Every slice that touches the wire — offer intake, both VCI flows, OID4VP with PEX *and* DCQL, JARM-encrypted responses, SIOPv2 — is implemented and verified live against walt.id, Sphereon, and Animo Paradym. The remaining work is wallet-app UI integration (Slice 10) and CI infrastructure (Slice 11).
12
+
13
+ ### OID4VCI — issuance side
14
+
15
+ | Capability | Status |
16
+ |---|---|
17
+ | Credential Offer URI parsing (by-value + by-reference) | ✅ Slice 1 |
18
+ | Draft 11 → Draft 13 normalization | ✅ Slice 1 |
19
+ | Issuer + authorization-server metadata fetching | ✅ Slice 2 |
20
+ | Pre-authorized code flow (`jwt_vc_json`) | ✅ Slice 2 |
21
+ | Proof-of-possession JWT (EdDSA, default signer from host LearnCard) | ✅ Slice 2 |
22
+ | Wallet index / LearnCloud integration (one-call `acceptAndStoreCredentialOffer`) | ✅ Slice 3 |
23
+ | JWT VC → W3C VC reconstruction (VCDM §6.3.1) with raw JWT preserved under `proof.jwt` | ✅ Slice 3 |
24
+ | Partial-failure reporting (one bad credential doesn't abort a batch) | ✅ Slice 3 |
25
+ | Authorization code flow + PKCE (`beginCredentialOfferAuthCode` / `completeCredentialOfferAuthCode`) | ✅ Slice 4 |
26
+ | `ldp_vc` issuance format | ⏳ Slice 5 — `jwt_vc_json` covers every issuer we test against; revisit when a partner needs LD-proof issuance. |
27
+
28
+ ### OID4VP — presentation side
29
+
30
+ | Capability | Status |
31
+ |---|---|
32
+ | OID4VP Authorization Request URI parsing (inline + `presentation_definition_uri`) | ✅ Slice 6 |
33
+ | DIF PEX v2 matcher (JSONPath subset + JSON Schema filter subset) | ✅ Slice 6 |
34
+ | Candidate selection + `submission_requirements` (`all` / `pick` / `from_nested`) | ✅ Slice 6 |
35
+ | Presentation Submission `descriptor_map` builder | ✅ Slice 6 |
36
+ | Format designation matching (`jwt_vc_json` / `ldp_vc` / `ldp`) | ✅ Slice 6 |
37
+ | VP construction (`buildPresentation`): unsigned VP + PresentationSubmission | ✅ Slice 7a |
38
+ | VP signing (`signPresentation`): `jwt_vp_json` (JWS) + `ldp_vp` (LD proof via host) | ✅ Slice 7b |
39
+ | `direct_post` VP response (`submitPresentation`) | ✅ Slice 7c |
40
+ | End-to-end `presentCredentials()` verified against **walt.id**, **Sphereon**, **Animo Paradym** | ✅ Slice 7 |
41
+ | Signed Request Objects (`request` / `request_uri` JWS) — `client_id_scheme=did` (did:jwk, did:web) + `x509_san_dns` (trusted-roots or self-signed-dev) | ✅ Slice 7.5 |
42
+ | **DCQL query** — W3C OID4VP Draft 22 alternative to PEX. Same `prepareVerifiablePresentation` / `presentCredentials` entry points; DCQL takes precedence when both `dcql_query` and `presentation_definition` are present. | ✅ |
43
+ | **JARM** — encrypted `direct_post.jwt` responses (JWE A128CBC-HS256 / ECDH-ES). Selected automatically when the verifier requests `response_mode=direct_post.jwt`. | ✅ |
44
+
45
+ ### SIOPv2 + cross-cutting
46
+
47
+ | Capability | Status |
48
+ |---|---|
49
+ | SIOPv2 ID token (`signIdToken`) standalone + auto-bundled when `response_type` includes `id_token` | ✅ Slice 8 |
50
+ | Browser-safe runtime (no `node:crypto` / `node:zlib`) — bundles cleanly into `apps/learn-card-app` | ✅ Slice 7.6 |
51
+ | Auto-wired into every seed-based `@learncard/init` function | ✅ Slice 7.6 |
52
+ | Bitstring Status List checking | Handled by `@learncard/didkit-plugin`. `lc.invoke.verifyCredential(vc)` automatically checks `BitstringStatusListEntry` / `StatusList2021Entry` / `RevocationList2020` when the credential carries a `credentialStatus` field. |
53
+ | Deep-link / QR entry points in `learn-card-app` | ⏳ Slice 9 |
54
+ | UI adapter for consent + selection in `learn-card-app` / `learn-card-base` | ⏳ Slice 10 |
55
+ | Self-hosted issuer + verifier in CI | ⏳ Slice 11 |
56
+
57
+ See the California RFP epic for full scope.
58
+
59
+ ## Spec versions
60
+
61
+ - **OID4VCI**: [Draft 13](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html). Draft 11 offers are accepted and normalized on ingest.
62
+ - **OID4VP**: [Draft 22](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html).
63
+ - **SIOPv2**: [final](https://openid.net/specs/openid-connect-self-issued-v2-1_0.html).
64
+ - **PEX**: DIF Presentation Exchange v2.
65
+
66
+ ## Installation
67
+
68
+ ```bash
69
+ pnpm add @learncard/openid4vc-plugin
70
+ ```
71
+
72
+ Required peer plugins: `@learncard/vc-plugin`, `@learncard/didkit-plugin`.
73
+
74
+ ### Related plugins
75
+
76
+ Bitstring Status List revocation/suspension checking is performed inside `@learncard/didkit-plugin` as part of `lc.invoke.verifyCredential(...)`. The plugin recognises `BitstringStatusListEntry` (W3C VC 2.0), `StatusList2021Entry` (legacy alias), and `RevocationList2020`.
77
+
78
+ ### Bundled by default with `@learncard/init`
79
+
80
+ Every seed-based initializer in `@learncard/init` (`learnCardFromSeed`, `networkLearnCardFromSeed`, `didWebLearnCardFromSeed`, `didWebNetworkLearnCardFromSeed`) automatically attaches this plugin to the resulting LearnCard. **You do not need to call `getOpenID4VCPlugin()` yourself** unless you're composing a custom LearnCard from `@learncard/core`. Pass plugin config through the init function's `openid4vc` option:
81
+
82
+ ```ts
83
+ import { initLearnCard } from '@learncard/init';
84
+
85
+ const lc = await initLearnCard({
86
+ seed: 'a'.repeat(64),
87
+ openid4vc: {
88
+ // Optional. Defaults are sensible for most callers.
89
+ // fetchImpl: customFetch,
90
+ // trustedX509Roots: [pemString],
91
+ },
92
+ });
93
+
94
+ // All OpenID4VC methods are now available on `lc.invoke.*`.
95
+ ```
96
+
97
+ ### Browser compatibility
98
+
99
+ As of `lc-1794` the plugin's runtime surface is fully cross-platform. The previously node-only dependencies (`node:crypto.X509Certificate`, `node:crypto.randomBytes`, `node:zlib.gunzipSync`) have been swapped for Web Crypto, `crypto.getRandomValues`, `@peculiar/x509`, and `fflate`. The plugin can be bundled directly into a browser app without polyfills; the holder methods all run end-to-end in `apps/learn-card-app`.
100
+
101
+ ## Usage
102
+
103
+ ```ts
104
+ import { initLearnCard } from '@learncard/init';
105
+
106
+ const lc = await initLearnCard({ seed: 'a'.repeat(64) });
107
+
108
+ // Slice 1 — parse a credential offer URI without hitting the network:
109
+ const parsed = lc.invoke.parseCredentialOffer(
110
+ 'openid-credential-offer://?credential_offer=...'
111
+ );
112
+
113
+ if (parsed.kind === 'by_value') {
114
+ console.log('Issuer:', parsed.offer.credential_issuer);
115
+ console.log('Credentials on offer:', parsed.offer.credential_configuration_ids);
116
+ }
117
+
118
+ // Slice 2 — accept the offer end-to-end (fetch metadata, exchange the
119
+ // pre-authorized code for a token, build a proof-of-possession JWT, and
120
+ // request the credential):
121
+ const result = await lc.invoke.acceptCredentialOffer(
122
+ 'openid-credential-offer://?credential_offer=...',
123
+ { txCode: '1234' } // only required when the offer carries a tx_code
124
+ );
125
+
126
+ for (const entry of result.credentials) {
127
+ console.log(entry.format, entry.credential); // e.g. 'jwt_vc_json', 'eyJ.vc.jwt'
128
+ }
129
+ ```
130
+
131
+ ### Slice 3 — accept **and** store in one call
132
+
133
+ For the common case (accept an offer, persist the credentials, have them show up in the wallet UI), use `acceptAndStoreCredentialOffer`:
134
+
135
+ ```ts
136
+ const result = await lc.invoke.acceptAndStoreCredentialOffer(offerUri, {
137
+ txCode: '1234', // only if the offer requires it
138
+ category: 'Achievement', // optional override; otherwise auto-derived from VC types
139
+ });
140
+
141
+ // All credentials issued by the offer:
142
+ result.credentials; // same as acceptCredentialOffer
143
+ // Credentials that were successfully persisted:
144
+ result.stored; // [{ uri, recordId, vc, configurationId, format }]
145
+ // Credentials that failed to normalize / upload / index:
146
+ result.failures; // [{ configurationId, format, error: VciError }]
147
+ ```
148
+
149
+ What it does under the hood:
150
+
151
+ 1. Runs the pre-authorized code flow exactly like `acceptCredentialOffer`.
152
+ 2. Decodes each `jwt_vc_json` credential into a W3C VC per VCDM §6.3.1, preserving the raw JWT under `proof.jwt` so future verification can re-check the issuer signature.
153
+ 3. Uploads each VC via `learnCard.store.LearnCloud.uploadEncrypted` (default — encrypted at rest for the holder) or `upload` when encryption isn't available.
154
+ 4. Indexes each credential via `learnCard.index.LearnCloud.add(record)` so the wallet's credential list picks it up immediately.
155
+ 5. Catches per-credential errors so one bad credential doesn't discard a whole batch.
156
+
157
+ Storage overrides:
158
+
159
+ ```ts
160
+ await lc.invoke.acceptAndStoreCredentialOffer(offerUri, {
161
+ encrypt: false, // use plain `upload` instead of `uploadEncrypted`
162
+ category: vc => (vc.type?.includes('IdentityCredential') ? 'ID' : 'Achievement'),
163
+ title: vc => vc.name as string,
164
+ imgUrl: 'https://issuer.example.com/logo.png',
165
+
166
+ // Low-level escape hatches for custom backends or tests:
167
+ upload: async vc => myCustomStore.put(vc),
168
+ addToIndex: async record => myCustomIndex.insert(record),
169
+ });
170
+ ```
171
+
172
+ ### Slice 4 — authorization code flow with PKCE
173
+
174
+ When an offer's `grants` carries `authorization_code` (with or without an `issuer_state`), the issuer wants the wallet to bounce the user through their authorization endpoint before issuing. The plugin splits this into two awaits with the user-redirect step in between:
175
+
176
+ ```ts
177
+ // 1. Offer in hand. Build the authorize URL + persist a flow handle
178
+ // (PKCE verifier, nonce, etc.). Open `authorizationUrl` in a popup
179
+ // or top-level redirect.
180
+ const { authorizationUrl, flowHandle } = await lc.invoke.beginCredentialOfferAuthCode(
181
+ offerUri,
182
+ {
183
+ redirectUri: 'https://wallet.example.com/callback',
184
+ clientId: 'wallet.example.com',
185
+ scope: ['openid'], // optional
186
+ // userHint: 'alice@example.com', // optional `login_hint`
187
+ }
188
+ );
189
+
190
+ localStorage.setItem('vci.flow', JSON.stringify(flowHandle));
191
+ window.location.href = authorizationUrl;
192
+
193
+ // 2. After the issuer redirects back to `redirect_uri` with `?code=...&state=...`,
194
+ // finish the exchange. Same return shape as `acceptCredentialOffer`.
195
+ const flowHandle = JSON.parse(localStorage.getItem('vci.flow')!);
196
+ const { code, state } = parseQuery(window.location.search);
197
+
198
+ const result = await lc.invoke.completeCredentialOfferAuthCode({
199
+ flowHandle,
200
+ code,
201
+ state,
202
+ });
203
+
204
+ for (const entry of result.credentials) {
205
+ console.log(entry.format, entry.credential);
206
+ }
207
+ ```
208
+
209
+ `flowHandle` is a small JSON-safe object — you can persist it in `localStorage`, a cookie, or your wallet's session store. It carries the PKCE `code_verifier`, the offer, and the issuer/AS metadata snapshot so step 2 doesn't need to re-fetch.
210
+
211
+ ### Slice 8 — SIOPv2 ID tokens
212
+
213
+ Some verifiers want a self-issued ID token instead of (or alongside) a VP. `presentCredentials` does the right thing automatically when `response_type` includes `id_token`, but you can also drive it standalone:
214
+
215
+ ```ts
216
+ const { idToken } = await lc.invoke.signIdToken(authorizationRequestUri, {
217
+ // All optional — sensible defaults from the host LearnCard.
218
+ // holder: 'did:jwk:eyJ...',
219
+ // signer: customSigner,
220
+ // additionalClaims: { name: 'Alice', email: 'alice@example.com' },
221
+ });
222
+ ```
223
+
224
+ The ID token is a JWS over a SIOPv2 payload (`iss = sub = holder DID`, `aud = client_id`, `nonce` from the request). When the verifier asks for `vp_token id_token`, `presentCredentials` signs both, bundles the ID token into the `direct_post`, and returns it on the result for inspection.
225
+
226
+ ### Using your own signer
227
+
228
+ Callers using a non-Ed25519 key (HSM, secp256k1) should pass a custom signer:
229
+
230
+ ```ts
231
+ import { ProofJwtSigner } from '@learncard/openid4vc-plugin';
232
+
233
+ const signer: ProofJwtSigner = {
234
+ alg: 'ES256',
235
+ kid: 'did:key:zDn...#zDn...',
236
+ sign: async (header, payload) => myHsm.signCompactJws(header, payload),
237
+ };
238
+
239
+ await lc.invoke.acceptCredentialOffer(uri, { signer });
240
+ ```
241
+
242
+ ## OID4VP — presenting credentials to a verifier
243
+
244
+ When a verifier scans/links you an `openid4vp://` URI, the wallet needs to: (1) resolve the verifier's request (including verifying any signed Request Object), (2) figure out which held credentials satisfy the DIF PEX Presentation Definition, (3) let the user choose, (4) sign + POST a VP token. All four steps are wired up end-to-end via `plugin.presentCredentials()` (Slices 6 + 7 + 7.5).
245
+
246
+ ### Slice 6a — parse an Authorization Request without touching the network
247
+
248
+ ```ts
249
+ const parsed = lc.invoke.parseAuthorizationRequest(
250
+ 'openid4vp://?client_id=https%3A%2F%2Fverifier.example.com&...'
251
+ );
252
+
253
+ if (parsed.kind === 'by_value') {
254
+ console.log('Verifier:', parsed.request.client_id);
255
+ console.log('Nonce:', parsed.request.nonce);
256
+ console.log('Return to:', parsed.request.response_uri);
257
+ }
258
+ ```
259
+
260
+ Three discriminated variants:
261
+
262
+ - **`by_value`** — every param was inline; the returned `request` is ready for matching.
263
+ - **`by_reference_request_uri`** — the verifier delegated the request to a signed JWS fetched from `request_uri`. Slice 7.5 verifies the signature per `client_id_scheme` (see below) and inlines the claims.
264
+ - **`by_reference_request_jwt`** — same thing but the JWS is embedded directly via the `request` param.
265
+
266
+ ### Slice 6b — resolve `presentation_definition_uri` over HTTP
267
+
268
+ ```ts
269
+ // One call: parse + fetch out-of-band PD + return a fully-resolved request.
270
+ const request = await lc.invoke.resolveAuthorizationRequest(uri);
271
+ console.log(request.presentation_definition?.input_descriptors);
272
+ ```
273
+
274
+ ### Slice 6c — match held credentials against the PD (the main method)
275
+
276
+ `prepareVerifiablePresentation` is the turnkey entry point: resolve the verifier's request, walk every `input_descriptor`, and return a preview object the UI can render directly.
277
+
278
+ ```ts
279
+ import type { CandidateCredential } from '@learncard/openid4vc-plugin';
280
+
281
+ // Your wallet's credentials, in whatever shape they live in today.
282
+ const candidates: CandidateCredential[] = myRecords.map(r => ({
283
+ credential: r.vc, // JSON-LD object, or a JWT-VC string
284
+ format: r.format, // optional; inferred from shape when absent
285
+ id: r.uri, // optional caller-side id for audit/UI
286
+ }));
287
+
288
+ const { request, selection } = await lc.invoke.prepareVerifiablePresentation(
289
+ uri,
290
+ candidates
291
+ );
292
+
293
+ if (!selection.canSatisfy) {
294
+ // UI shows a "We couldn't satisfy this request" state.
295
+ // `selection.reason` is a user-readable summary; per-descriptor
296
+ // reasons live at `selection.descriptors[i].reason`.
297
+ showError(selection.reason);
298
+ return;
299
+ }
300
+
301
+ for (const descriptor of selection.descriptors) {
302
+ console.log(`Descriptor ${descriptor.descriptorId}: ${descriptor.candidates.length} match(es)`);
303
+ for (const match of descriptor.candidates) {
304
+ console.log(' ', match.candidate.id, '→', match.candidate.format);
305
+ }
306
+ }
307
+ ```
308
+
309
+ ### Slice 6d — build a Presentation Submission
310
+
311
+ Once the user picks one credential per descriptor, build the DIF PEX v2 `presentation_submission` that will ride alongside the vp_token:
312
+
313
+ ```ts
314
+ import { buildPresentationSubmission } from '@learncard/openid4vc-plugin';
315
+
316
+ const submission = buildPresentationSubmission(request.presentation_definition!, [
317
+ {
318
+ descriptorId: 'university-degree',
319
+ format: 'ldp_vc',
320
+ path: '$.verifiableCredential[0]',
321
+ },
322
+ {
323
+ descriptorId: 'drivers-license',
324
+ format: 'jwt_vc_json',
325
+ path: '$.verifiableCredential[1]',
326
+ pathNested: '$', // JWT VC is the whole value at that path
327
+ },
328
+ ]);
329
+
330
+ // submission.id, submission.definition_id, submission.descriptor_map[]
331
+ ```
332
+
333
+ ### PEX subset supported
334
+
335
+ The matcher is spec-correct for the vast majority of Presentation Definitions seen in the wild. It covers:
336
+
337
+ - **JSONPath (`field.path[]`)** — root (`$`), dotted (`$.a.b.c`), bracketed (`$['foo bar']`), numeric index (`$.a[0]`), wildcard (`$.a[*]`, `$.a.*`), recursive descent (`$..foo`). Filter predicates (`[?(@.x=='y')]`) and array slicing (`[0:2]`) throw a descriptive error rather than silently missing.
338
+ - **JSON Schema filter (`field.filter`)** — `type`, `const`, `enum`, `pattern`, `minimum` / `maximum` / `exclusiveMinimum` / `exclusiveMaximum`, `minLength` / `maxLength`, `contains`, `items`, `minItems` / `maxItems`. Unknown keywords pass by default (lenient mode) so verifiers introducing new keywords don't block wallet upgrades.
339
+ - **`submission_requirements`** — `rule: 'all'`, `rule: 'pick'` with `count` / `min` / (soft) `max`, and nested `from_nested` groups.
340
+ - **Format designations** — `descriptor.format` takes precedence over `pd.format`; candidates are filtered before JSONPath runs. `jwt_vc_json` credentials are transparently base64url-decoded for matching (paths like `$.credentialSubject.id` work even when the raw credential is a compact JWS).
341
+
342
+ If your verifier lands a PEX feature we haven't modeled, open an issue with the Presentation Definition — the matcher layer is designed to swap for a full `ajv`-backed implementation without touching the plugin surface.
343
+
344
+ ### DCQL — OID4VP Draft 22 query language
345
+
346
+ DCQL (Digital Credentials Query Language) is OID4VP Draft 22's spec-native alternative to DIF PEX. When a verifier sends `dcql_query` instead of `presentation_definition`, the same entry points work — `prepareVerifiablePresentation` returns DCQL match results in `selection.descriptors`, and `presentCredentials` builds a DCQL `vp_token` (a record keyed by `credential_query_id`) instead of a PEX submission. DCQL takes precedence when a verifier sends both.
347
+
348
+ ```ts
349
+ // Same call as before — no caller-side change needed for DCQL.
350
+ const { request, selection } = await lc.invoke.prepareVerifiablePresentation(
351
+ uri,
352
+ candidates
353
+ );
354
+
355
+ if (request.dcql_query) {
356
+ // selection.descriptors[i].descriptorId === credential_query.id
357
+ // selection.descriptors[i].candidates === DCQL match candidates
358
+ }
359
+ ```
360
+
361
+ ### JARM — encrypted response mode
362
+
363
+ When a verifier requests `response_mode=direct_post.jwt`, the wallet must encrypt the response body to the verifier's JWKS (advertised via `client_metadata.jwks` or `client_metadata.jwks_uri`). The plugin handles this automatically: `submitPresentation` (and therefore `presentCredentials`) inspects the resolved request, discovers the verifier's encryption key, and emits a JWE (`alg=ECDH-ES`, `enc=A128CBC-HS256` by default) wrapping the standard direct_post payload. No caller-side change required — if a verifier asks for JARM, you get JARM.
364
+
365
+ ## Errors
366
+
367
+ VCI errors are thrown as `CredentialOfferParseError` / `VciError`; VP errors as `VpError`. Every error carries a stable `code` field so UI can map to friendly copy without string-matching messages.
368
+
369
+ ### `CredentialOfferParseError.code`
370
+
371
+ | `code` | Meaning |
372
+ |---|---|
373
+ | `invalid_uri` | URI is malformed, non-https reference, or network fetch failed |
374
+ | `missing_offer` | Neither `credential_offer` nor `credential_offer_uri` present |
375
+ | `both_offer_and_uri` | Both parameters present (spec violation) |
376
+ | `invalid_json` | Offer payload or by-reference response was not valid JSON |
377
+ | `missing_issuer` | Offer has no `credential_issuer` |
378
+ | `missing_credentials` | Offer has no resolvable credential identifiers |
379
+ | `invalid_grants` | `grants` object is malformed |
380
+
381
+ ### `VpError.code`
382
+
383
+ | `code` | Meaning |
384
+ |---|---|
385
+ | `invalid_uri` | Authorization Request URI is malformed or has no query string |
386
+ | `invalid_json` | `presentation_definition` / `client_metadata` / resolved PD was not valid JSON |
387
+ | `missing_client_id` | Authorization Request has no `client_id` |
388
+ | `missing_nonce` | Authorization Request has no `nonce` (replay-protection required by spec) |
389
+ | `missing_response_type` | Authorization Request has no `response_type` |
390
+ | `unsupported_response_type` | `response_type` is neither `vp_token` nor `id_token` |
391
+ | `missing_response_target` | Neither `response_uri` (Draft 22+) nor legacy `redirect_uri` is present |
392
+ | `missing_presentation_definition` | `vp_token` request carries no `presentation_definition`, `presentation_definition_uri`, or `scope` |
393
+ | `invalid_presentation_definition` | PD is structurally malformed (missing `id`, empty `input_descriptors`, descriptor without `constraints`) |
394
+ | `both_definition_and_uri` | Both `presentation_definition` and `presentation_definition_uri` supplied (spec violation) |
395
+ | `presentation_definition_fetch_failed` | Fetching `presentation_definition_uri` timed out, returned non-2xx, or disallowed by scheme |
396
+
397
+ Signed Request Objects surface a separate typed error, `RequestObjectError`, with these codes:
398
+
399
+ | `code` | Meaning |
400
+ |---|---|
401
+ | `invalid_request_object` | JWS couldn't be decoded, or the payload was missing required claims (`client_id` in particular) |
402
+ | `request_fetch_failed` | Fetching `request_uri` failed or returned non-2xx |
403
+ | `missing_client_id_scheme` | Neither URL params nor JWS claims supplied `client_id_scheme` |
404
+ | `unsupported_client_id_scheme` | Scheme is one we don't yet implement (`pre-registered`, `verifier_attestation`), or a forbidden scheme (`redirect_uri`) was combined with a signed JWS |
405
+ | `client_id_mismatch` | Outer URL `client_id` disagrees with signed `client_id`, OR (for x509) the leaf cert SAN doesn't cover the `client_id` host |
406
+ | `request_signature_invalid` | JWS signature failed to verify against the resolved key |
407
+ | `request_signer_untrusted` | DID resolver couldn't find the `kid`; or the x509 chain didn't root into `trustedX509Roots` |
408
+ | `did_resolution_failed` | DID resolver couldn't produce a document (network error for `did:web`, unsupported method for others) |
409
+
410
+ ## Testing
411
+
412
+ ### Unit tests
413
+
414
+ ```bash
415
+ pnpm --filter @learncard/openid4vc-plugin test
416
+ ```
417
+
418
+ ### Driving a real issuer end-to-end — `try-offer` harness
419
+
420
+ `scripts/try-offer.ts` drives `acceptCredentialOffer` against any Draft 13 conformant issuer with an ephemeral `did:jwk` keypair. It skips the full LearnCard wiring (no wallet, no LearnCloud plugin required), so it's the fastest way to validate the flow against live infrastructure during development.
421
+
422
+ ```bash
423
+ # from repo root
424
+ pnpm --filter @learncard/openid4vc-plugin try-offer "<offer-uri>"
425
+
426
+ # or from the plugin dir
427
+ cd packages/plugins/openid4vc
428
+ pnpm try-offer "<offer-uri>"
429
+ ```
430
+
431
+ Flags:
432
+
433
+ | Flag | Purpose |
434
+ |---|---|
435
+ | `--tx-code <code>` | PIN the issuer delivered out-of-band (if the offer carries `tx_code`) |
436
+ | `--client-id <id>` | Client identifier, if the authorization server requires one |
437
+ | `--only <id1,id2>` | Comma-separated subset of `credential_configuration_ids` to request |
438
+ | `--save <path>` | Write the issued credential(s) as JSON at `<path>` — single VC as an object, multiple as an array. Feed it straight into `try-verify --credentials <path>`. `*.vc.json`, `my-vc*.json`, and `issued-credentials*.json` are gitignored by default so real holder credentials never get committed. |
439
+ | `--verbose`, `-v` | Log the full resolved offer and each credential's raw JWT |
440
+ | `--help`, `-h` | Show usage |
441
+
442
+ Output on success: the decoded W3C VC for each issued credential plus the issuer's `notification_id` (if supplied). On failure: the `VciError` code, HTTP status, error body, and cause — so you can distinguish `metadata_fetch_failed` from `token_request_failed` from `credential_request_failed` at a glance.
443
+
444
+ #### Recipes for public test issuers
445
+
446
+ All of these expose pre-authorized-code offers, so the harness works as-is.
447
+
448
+ **EUDI Reference Issuer** — most spec-conformant. Good first target.
449
+
450
+ 1. Visit `https://dev.issuer.eudiw.dev/`.
451
+ 2. Pick a credential (PID, mDL, etc.), start the issuance flow.
452
+ 3. Instead of scanning the QR with a wallet app, copy the `openid-credential-offer://...` URI the page displays.
453
+ 4. Run: `pnpm try-offer "openid-credential-offer://?credential_offer_uri=..."`
454
+ 5. If prompted for a PIN, pass it via `--tx-code`.
455
+
456
+ **WaltID Portal** — friendliest UI.
457
+
458
+ 1. Visit `https://portal.walt.id`, sign in (free), go to Issuer.
459
+ 2. Issue a credential — the portal shows an offer URI / QR on the success page.
460
+ 3. Copy the `openid-credential-offer://...` URI and feed it to the harness.
461
+
462
+ **Animo Funke** — Credo-TS based, EUDI-compatible.
463
+
464
+ 1. Visit `https://funke.animo.id`, select a credential profile.
465
+ 2. Start the issuance flow and copy the offer URI.
466
+ 3. Run against the harness.
467
+
468
+ #### Running a local issuer
469
+
470
+ When you want deterministic iteration (same offer twice, offline, step-through debugging), run WaltID's issuer in Docker:
471
+
472
+ ```bash
473
+ docker run --rm -p 7002:7002 -p 7003:7003 waltid/issuer-api:latest
474
+ # in another terminal, POST an offer config to http://localhost:7002/...
475
+ # then feed the returned offer URI to pnpm try-offer
476
+ ```
477
+
478
+ #### What the harness does NOT cover
479
+
480
+ - **No wallet integration.** Credentials are decoded and printed, not persisted. Once Slice 10 wires `acceptAndStoreCredentialOffer` into the wallet UI, use the learn-card-app instead for that part of the flow.
481
+ - **No signature verification.** The harness trusts the issuer's response. Verifying the credential JWT against the issuer's published key is a separate concern handled by the VC plugin on read.
482
+ - **Pre-authorized code only.** The harness drives the pre-authorized flow because it's headless. The plugin itself supports the authorization code flow via `beginCredentialOfferAuthCode` / `completeCredentialOfferAuthCode` (Slice 4); exercising it requires a real browser to bounce through the issuer's authorization endpoint, so it's covered by the wallet app's e2e tests rather than this harness.
483
+
484
+ ### Driving a real verifier end-to-end — `try-verify` harness
485
+
486
+ `scripts/try-verify.ts` is the OID4VP counterpart to `try-offer`: it takes a verifier's Authorization Request URI, resolves the Presentation Definition, runs the PEX matcher + selector against credentials you supply, and prints the exact `presentation_submission` the wallet would POST back. Slice 6's entire surface gets exercised in one command.
487
+
488
+ ```bash
489
+ pnpm --filter @learncard/openid4vc-plugin try-verify \
490
+ "<oid4vp-uri>" \
491
+ --credentials ./my-vc.json
492
+ ```
493
+
494
+ Flags:
495
+
496
+ | Flag | Purpose |
497
+ |---|---|
498
+ | `--credentials <path>` | JSON file containing a W3C VC object, a compact JWT-VC string, or an array of either |
499
+ | `--holder <path>` | Sidecar written by `try-offer --save` — carries the holder `did` / `kid` / private JWK. Default: `<credentials>.holder.json` |
500
+ | `--submit` | After PEX selection, sign the VP and POST it via `direct_post` |
501
+ | `--envelope <fmt>` | Force `jwt_vp_json` or `ldp_vp` (default: inferred from PD + VC formats) |
502
+ | `--trusted-root <path>` | PEM file with a trusted X.509 root — required for `client_id_scheme=x509_san_dns` verifiers. Repeatable. |
503
+ | `--unsafe-allow-self-signed` | Dev-only: accept self-signed x509 chains. Disables the chain-to-trust-root check. **Never production.** |
504
+ | `--verbose`, `-v` | Log the full resolved request + per-field JSONPath matches |
505
+ | `--help`, `-h` | Show usage |
506
+
507
+ The harness drives the full Slice 7 pipeline when `--submit` is set: build → sign → POST. Without it, it stops after previewing the `presentation_submission` so you can inspect what would be sent.
508
+
509
+ #### Recipe: walt.id Verifier Portal (UI flow)
510
+
511
+ The quickest path to a real OID4VP URI.
512
+
513
+ 1. Visit `https://portal.walt.id`, sign in (free), open **Verifier**.
514
+ 2. Pick a credential template (e.g. `OpenBadgeCredential`, `VerifiableDiploma`) and click **Create Verification**.
515
+ 3. The portal shows a QR and a clickable `openid4vp://...` URL. Copy the URL.
516
+ 4. Run:
517
+
518
+ ```bash
519
+ pnpm try-verify "openid4vp://?..." --credentials ./my-vc.json --submit
520
+ ```
521
+
522
+ walt.id's default verifier flow uses `client_id_scheme=redirect_uri` with inline PD — no trust-anchor setup required. If you pick a flow that wraps the request in a signed JWS, the harness automatically verifies it via the configured DID resolver (did:jwk / did:web out of the box).
523
+ 5. If `canSatisfy: ✓ YES`, the harness signs a JWT-VP, POSTs to the verifier's `response_uri`, and prints the `redirect_uri` the walt.id portal hands back. If `✗ NO`, each descriptor's `reason` field explains why (wrong format, missing type, filter mismatch).
524
+
525
+ #### Recipe: walt.id Verifier REST API (scripted flow)
526
+
527
+ When you want to iterate quickly on a specific Presentation Definition without the portal UI.
528
+
529
+ ```bash
530
+ # 1. POST a PD to walt.id's hosted verifier to get a session URI.
531
+ curl -s -X POST https://verifier.demo.walt.id/openid4vc/verify \
532
+ -H "Content-Type: application/json" \
533
+ -H "authorizeBaseUrl: openid4vp://authorize" \
534
+ -H "responseMode: direct_post" \
535
+ --data '{
536
+ "request_credentials": [
537
+ { "input_descriptor": {
538
+ "id": "UniversityDegree",
539
+ "format": { "jwt_vc_json": { "alg": ["EdDSA","ES256"] } },
540
+ "constraints": {
541
+ "fields": [ { "path": ["$.type"],
542
+ "filter": { "type": "array",
543
+ "contains": { "const": "UniversityDegreeCredential" } } } ]
544
+ }
545
+ } }
546
+ ]
547
+ }'
548
+ # → returns a plain openid4vp://... URI in the response body.
549
+
550
+ # 2. Run the harness against it.
551
+ pnpm try-verify "openid4vp://..." --credentials ./my-vc.json --submit
552
+ ```
553
+
554
+ This path is perfect for regression-testing after we change the PEX matcher — you can keep a single PD template and rerun it after every selector change.
555
+
556
+ #### Recipe: Sphereon demo verifier
557
+
558
+ Sphereon's reference verifier (`https://ssi.sphereon.com`) emits both by-value URIs and signed Request Objects (`client_id_scheme=did` with `did:web`). Both work without any extra flags — Slice 7.5 resolves the did:web document over the harness's fetch and verifies the JWS before matching.
559
+
560
+ 1. Visit the Sphereon demo verifier, pick a flow (e.g. "Request University Degree").
561
+ 2. Copy the `openid4vp://` URI.
562
+ 3. `pnpm try-verify "openid4vp://..." --credentials ./my-vc.json`.
563
+
564
+ #### Recipe: Animo Paradym
565
+
566
+ Animo's Paradym verifier playground (`https://paradym.id`) supports both `ldp_vc` and `jwt_vc_json` requests and is the second real-world verifier we smoke-tested against. Flow is identical to walt.id's.
567
+
568
+ #### Where to get a credential to feed it
569
+
570
+ The fastest loop is `try-offer --save` → `try-verify --credentials`:
571
+
572
+ ```bash
573
+ # 1. Issue a credential from walt.id / Animo / EUDI and write it to disk.
574
+ pnpm try-offer "<offer-uri>" --save ./my-vc.json
575
+
576
+ # 2. Point a real verifier at the same file.
577
+ pnpm try-verify "<oid4vp-uri>" --credentials ./my-vc.json --submit
578
+ ```
579
+
580
+ `try-offer` writes the credential in its normalized W3C VC shape (JWT-VCs keep their compact JWS under `proof.jwt`), which `try-verify`'s `inferCredentialFormat` classifies correctly in both directions — so a single `./my-vc.json` round-trips through matchers for either format.
581
+
582
+ The plugin's `.gitignore` excludes `my-vc.json`, `my-vc*.json`, `*.vc.json`, and `issued-credentials*.json` by default so a real credential dropped into this directory never gets committed. Use any of those names for your local testing file.
583
+
584
+ #### What the harness does NOT cover
585
+
586
+ - **No `client_id_scheme=pre-registered` / `verifier_attestation`.** Both surface `unsupported_client_id_scheme` from the Slice 7.5 verifier. `did` (did:jwk + did:web built-in, others via custom `didResolver`) and `x509_san_dns` (with `trustedX509Roots`) are the supported schemes.
587
+
588
+ JARM (encrypted `direct_post.jwt`) and DCQL queries are both supported in the plugin and exercised through the wallet's e2e suite, not via this harness.
589
+
590
+ ## License
591
+
592
+ MIT © [Learning Economy Foundation](https://www.learningeconomy.io)