@learncard/openid4vc-plugin
v0.1.0
Published
OpenID for Verifiable Credentials (OID4VCI + OID4VP + SIOPv2) holder support for LearnCard
Keywords
Readme
@learncard/openid4vc-plugin
OpenID for Verifiable Credentials holder-side support for LearnCard:
- OID4VCI — accept credential offers, exchange pre-authorized / authorization codes for access tokens, request credentials from issuers.
- OID4VP — parse Authorization Requests, match held credentials against Presentation Definitions (DIF PEX v2), build and sign VP tokens.
- SIOPv2 — issue self-issued ID tokens for holder authentication.
Status
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).
OID4VCI — issuance side
| Capability | Status |
|---|---|
| Credential Offer URI parsing (by-value + by-reference) | ✅ Slice 1 |
| Draft 11 → Draft 13 normalization | ✅ Slice 1 |
| Issuer + authorization-server metadata fetching | ✅ Slice 2 |
| Pre-authorized code flow (jwt_vc_json) | ✅ Slice 2 |
| Proof-of-possession JWT (EdDSA, default signer from host LearnCard) | ✅ Slice 2 |
| Wallet index / LearnCloud integration (one-call acceptAndStoreCredentialOffer) | ✅ Slice 3 |
| JWT VC → W3C VC reconstruction (VCDM §6.3.1) with raw JWT preserved under proof.jwt | ✅ Slice 3 |
| Partial-failure reporting (one bad credential doesn't abort a batch) | ✅ Slice 3 |
| Authorization code flow + PKCE (beginCredentialOfferAuthCode / completeCredentialOfferAuthCode) | ✅ Slice 4 |
| ldp_vc issuance format | ⏳ Slice 5 — jwt_vc_json covers every issuer we test against; revisit when a partner needs LD-proof issuance. |
OID4VP — presentation side
| Capability | Status |
|---|---|
| OID4VP Authorization Request URI parsing (inline + presentation_definition_uri) | ✅ Slice 6 |
| DIF PEX v2 matcher (JSONPath subset + JSON Schema filter subset) | ✅ Slice 6 |
| Candidate selection + submission_requirements (all / pick / from_nested) | ✅ Slice 6 |
| Presentation Submission descriptor_map builder | ✅ Slice 6 |
| Format designation matching (jwt_vc_json / ldp_vc / ldp) | ✅ Slice 6 |
| VP construction (buildPresentation): unsigned VP + PresentationSubmission | ✅ Slice 7a |
| VP signing (signPresentation): jwt_vp_json (JWS) + ldp_vp (LD proof via host) | ✅ Slice 7b |
| direct_post VP response (submitPresentation) | ✅ Slice 7c |
| End-to-end presentCredentials() verified against walt.id, Sphereon, Animo Paradym | ✅ Slice 7 |
| 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 |
| 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. | ✅ |
| JARM — encrypted direct_post.jwt responses (JWE A128CBC-HS256 / ECDH-ES). Selected automatically when the verifier requests response_mode=direct_post.jwt. | ✅ |
SIOPv2 + cross-cutting
| Capability | Status |
|---|---|
| SIOPv2 ID token (signIdToken) standalone + auto-bundled when response_type includes id_token | ✅ Slice 8 |
| Browser-safe runtime (no node:crypto / node:zlib) — bundles cleanly into apps/learn-card-app | ✅ Slice 7.6 |
| Auto-wired into every seed-based @learncard/init function | ✅ Slice 7.6 |
| 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. |
| Deep-link / QR entry points in learn-card-app | ⏳ Slice 9 |
| UI adapter for consent + selection in learn-card-app / learn-card-base | ⏳ Slice 10 |
| Self-hosted issuer + verifier in CI | ⏳ Slice 11 |
See the California RFP epic for full scope.
Spec versions
- OID4VCI: Draft 13. Draft 11 offers are accepted and normalized on ingest.
- OID4VP: Draft 22.
- SIOPv2: final.
- PEX: DIF Presentation Exchange v2.
Installation
pnpm add @learncard/openid4vc-pluginRequired peer plugins: @learncard/vc-plugin, @learncard/didkit-plugin.
Related plugins
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.
Bundled by default with @learncard/init
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:
import { initLearnCard } from '@learncard/init';
const lc = await initLearnCard({
seed: 'a'.repeat(64),
openid4vc: {
// Optional. Defaults are sensible for most callers.
// fetchImpl: customFetch,
// trustedX509Roots: [pemString],
},
});
// All OpenID4VC methods are now available on `lc.invoke.*`.Browser compatibility
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.
Usage
import { initLearnCard } from '@learncard/init';
const lc = await initLearnCard({ seed: 'a'.repeat(64) });
// Slice 1 — parse a credential offer URI without hitting the network:
const parsed = lc.invoke.parseCredentialOffer(
'openid-credential-offer://?credential_offer=...'
);
if (parsed.kind === 'by_value') {
console.log('Issuer:', parsed.offer.credential_issuer);
console.log('Credentials on offer:', parsed.offer.credential_configuration_ids);
}
// Slice 2 — accept the offer end-to-end (fetch metadata, exchange the
// pre-authorized code for a token, build a proof-of-possession JWT, and
// request the credential):
const result = await lc.invoke.acceptCredentialOffer(
'openid-credential-offer://?credential_offer=...',
{ txCode: '1234' } // only required when the offer carries a tx_code
);
for (const entry of result.credentials) {
console.log(entry.format, entry.credential); // e.g. 'jwt_vc_json', 'eyJ.vc.jwt'
}Slice 3 — accept and store in one call
For the common case (accept an offer, persist the credentials, have them show up in the wallet UI), use acceptAndStoreCredentialOffer:
const result = await lc.invoke.acceptAndStoreCredentialOffer(offerUri, {
txCode: '1234', // only if the offer requires it
category: 'Achievement', // optional override; otherwise auto-derived from VC types
});
// All credentials issued by the offer:
result.credentials; // same as acceptCredentialOffer
// Credentials that were successfully persisted:
result.stored; // [{ uri, recordId, vc, configurationId, format }]
// Credentials that failed to normalize / upload / index:
result.failures; // [{ configurationId, format, error: VciError }]What it does under the hood:
- Runs the pre-authorized code flow exactly like
acceptCredentialOffer. - Decodes each
jwt_vc_jsoncredential into a W3C VC per VCDM §6.3.1, preserving the raw JWT underproof.jwtso future verification can re-check the issuer signature. - Uploads each VC via
learnCard.store.LearnCloud.uploadEncrypted(default — encrypted at rest for the holder) oruploadwhen encryption isn't available. - Indexes each credential via
learnCard.index.LearnCloud.add(record)so the wallet's credential list picks it up immediately. - Catches per-credential errors so one bad credential doesn't discard a whole batch.
Storage overrides:
await lc.invoke.acceptAndStoreCredentialOffer(offerUri, {
encrypt: false, // use plain `upload` instead of `uploadEncrypted`
category: vc => (vc.type?.includes('IdentityCredential') ? 'ID' : 'Achievement'),
title: vc => vc.name as string,
imgUrl: 'https://issuer.example.com/logo.png',
// Low-level escape hatches for custom backends or tests:
upload: async vc => myCustomStore.put(vc),
addToIndex: async record => myCustomIndex.insert(record),
});Slice 4 — authorization code flow with PKCE
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:
// 1. Offer in hand. Build the authorize URL + persist a flow handle
// (PKCE verifier, nonce, etc.). Open `authorizationUrl` in a popup
// or top-level redirect.
const { authorizationUrl, flowHandle } = await lc.invoke.beginCredentialOfferAuthCode(
offerUri,
{
redirectUri: 'https://wallet.example.com/callback',
clientId: 'wallet.example.com',
scope: ['openid'], // optional
// userHint: '[email protected]', // optional `login_hint`
}
);
localStorage.setItem('vci.flow', JSON.stringify(flowHandle));
window.location.href = authorizationUrl;
// 2. After the issuer redirects back to `redirect_uri` with `?code=...&state=...`,
// finish the exchange. Same return shape as `acceptCredentialOffer`.
const flowHandle = JSON.parse(localStorage.getItem('vci.flow')!);
const { code, state } = parseQuery(window.location.search);
const result = await lc.invoke.completeCredentialOfferAuthCode({
flowHandle,
code,
state,
});
for (const entry of result.credentials) {
console.log(entry.format, entry.credential);
}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.
Slice 8 — SIOPv2 ID tokens
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:
const { idToken } = await lc.invoke.signIdToken(authorizationRequestUri, {
// All optional — sensible defaults from the host LearnCard.
// holder: 'did:jwk:eyJ...',
// signer: customSigner,
// additionalClaims: { name: 'Alice', email: '[email protected]' },
});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.
Using your own signer
Callers using a non-Ed25519 key (HSM, secp256k1) should pass a custom signer:
import { ProofJwtSigner } from '@learncard/openid4vc-plugin';
const signer: ProofJwtSigner = {
alg: 'ES256',
kid: 'did:key:zDn...#zDn...',
sign: async (header, payload) => myHsm.signCompactJws(header, payload),
};
await lc.invoke.acceptCredentialOffer(uri, { signer });OID4VP — presenting credentials to a verifier
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).
Slice 6a — parse an Authorization Request without touching the network
const parsed = lc.invoke.parseAuthorizationRequest(
'openid4vp://?client_id=https%3A%2F%2Fverifier.example.com&...'
);
if (parsed.kind === 'by_value') {
console.log('Verifier:', parsed.request.client_id);
console.log('Nonce:', parsed.request.nonce);
console.log('Return to:', parsed.request.response_uri);
}Three discriminated variants:
by_value— every param was inline; the returnedrequestis ready for matching.by_reference_request_uri— the verifier delegated the request to a signed JWS fetched fromrequest_uri. Slice 7.5 verifies the signature perclient_id_scheme(see below) and inlines the claims.by_reference_request_jwt— same thing but the JWS is embedded directly via therequestparam.
Slice 6b — resolve presentation_definition_uri over HTTP
// One call: parse + fetch out-of-band PD + return a fully-resolved request.
const request = await lc.invoke.resolveAuthorizationRequest(uri);
console.log(request.presentation_definition?.input_descriptors);Slice 6c — match held credentials against the PD (the main method)
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.
import type { CandidateCredential } from '@learncard/openid4vc-plugin';
// Your wallet's credentials, in whatever shape they live in today.
const candidates: CandidateCredential[] = myRecords.map(r => ({
credential: r.vc, // JSON-LD object, or a JWT-VC string
format: r.format, // optional; inferred from shape when absent
id: r.uri, // optional caller-side id for audit/UI
}));
const { request, selection } = await lc.invoke.prepareVerifiablePresentation(
uri,
candidates
);
if (!selection.canSatisfy) {
// UI shows a "We couldn't satisfy this request" state.
// `selection.reason` is a user-readable summary; per-descriptor
// reasons live at `selection.descriptors[i].reason`.
showError(selection.reason);
return;
}
for (const descriptor of selection.descriptors) {
console.log(`Descriptor ${descriptor.descriptorId}: ${descriptor.candidates.length} match(es)`);
for (const match of descriptor.candidates) {
console.log(' ', match.candidate.id, '→', match.candidate.format);
}
}Slice 6d — build a Presentation Submission
Once the user picks one credential per descriptor, build the DIF PEX v2 presentation_submission that will ride alongside the vp_token:
import { buildPresentationSubmission } from '@learncard/openid4vc-plugin';
const submission = buildPresentationSubmission(request.presentation_definition!, [
{
descriptorId: 'university-degree',
format: 'ldp_vc',
path: '$.verifiableCredential[0]',
},
{
descriptorId: 'drivers-license',
format: 'jwt_vc_json',
path: '$.verifiableCredential[1]',
pathNested: '$', // JWT VC is the whole value at that path
},
]);
// submission.id, submission.definition_id, submission.descriptor_map[]PEX subset supported
The matcher is spec-correct for the vast majority of Presentation Definitions seen in the wild. It covers:
- 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. - 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. submission_requirements—rule: 'all',rule: 'pick'withcount/min/ (soft)max, and nestedfrom_nestedgroups.- Format designations —
descriptor.formattakes precedence overpd.format; candidates are filtered before JSONPath runs.jwt_vc_jsoncredentials are transparently base64url-decoded for matching (paths like$.credentialSubject.idwork even when the raw credential is a compact JWS).
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.
DCQL — OID4VP Draft 22 query language
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.
// Same call as before — no caller-side change needed for DCQL.
const { request, selection } = await lc.invoke.prepareVerifiablePresentation(
uri,
candidates
);
if (request.dcql_query) {
// selection.descriptors[i].descriptorId === credential_query.id
// selection.descriptors[i].candidates === DCQL match candidates
}JARM — encrypted response mode
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.
Errors
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.
CredentialOfferParseError.code
| code | Meaning |
|---|---|
| invalid_uri | URI is malformed, non-https reference, or network fetch failed |
| missing_offer | Neither credential_offer nor credential_offer_uri present |
| both_offer_and_uri | Both parameters present (spec violation) |
| invalid_json | Offer payload or by-reference response was not valid JSON |
| missing_issuer | Offer has no credential_issuer |
| missing_credentials | Offer has no resolvable credential identifiers |
| invalid_grants | grants object is malformed |
VpError.code
| code | Meaning |
|---|---|
| invalid_uri | Authorization Request URI is malformed or has no query string |
| invalid_json | presentation_definition / client_metadata / resolved PD was not valid JSON |
| missing_client_id | Authorization Request has no client_id |
| missing_nonce | Authorization Request has no nonce (replay-protection required by spec) |
| missing_response_type | Authorization Request has no response_type |
| unsupported_response_type | response_type is neither vp_token nor id_token |
| missing_response_target | Neither response_uri (Draft 22+) nor legacy redirect_uri is present |
| missing_presentation_definition | vp_token request carries no presentation_definition, presentation_definition_uri, or scope |
| invalid_presentation_definition | PD is structurally malformed (missing id, empty input_descriptors, descriptor without constraints) |
| both_definition_and_uri | Both presentation_definition and presentation_definition_uri supplied (spec violation) |
| presentation_definition_fetch_failed | Fetching presentation_definition_uri timed out, returned non-2xx, or disallowed by scheme |
Signed Request Objects surface a separate typed error, RequestObjectError, with these codes:
| code | Meaning |
|---|---|
| invalid_request_object | JWS couldn't be decoded, or the payload was missing required claims (client_id in particular) |
| request_fetch_failed | Fetching request_uri failed or returned non-2xx |
| missing_client_id_scheme | Neither URL params nor JWS claims supplied client_id_scheme |
| 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 |
| 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 |
| request_signature_invalid | JWS signature failed to verify against the resolved key |
| request_signer_untrusted | DID resolver couldn't find the kid; or the x509 chain didn't root into trustedX509Roots |
| did_resolution_failed | DID resolver couldn't produce a document (network error for did:web, unsupported method for others) |
Testing
Unit tests
pnpm --filter @learncard/openid4vc-plugin testDriving a real issuer end-to-end — try-offer harness
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.
# from repo root
pnpm --filter @learncard/openid4vc-plugin try-offer "<offer-uri>"
# or from the plugin dir
cd packages/plugins/openid4vc
pnpm try-offer "<offer-uri>"Flags:
| Flag | Purpose |
|---|---|
| --tx-code <code> | PIN the issuer delivered out-of-band (if the offer carries tx_code) |
| --client-id <id> | Client identifier, if the authorization server requires one |
| --only <id1,id2> | Comma-separated subset of credential_configuration_ids to request |
| --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. |
| --verbose, -v | Log the full resolved offer and each credential's raw JWT |
| --help, -h | Show usage |
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.
Recipes for public test issuers
All of these expose pre-authorized-code offers, so the harness works as-is.
EUDI Reference Issuer — most spec-conformant. Good first target.
- Visit
https://dev.issuer.eudiw.dev/. - Pick a credential (PID, mDL, etc.), start the issuance flow.
- Instead of scanning the QR with a wallet app, copy the
openid-credential-offer://...URI the page displays. - Run:
pnpm try-offer "openid-credential-offer://?credential_offer_uri=..." - If prompted for a PIN, pass it via
--tx-code.
WaltID Portal — friendliest UI.
- Visit
https://portal.walt.id, sign in (free), go to Issuer. - Issue a credential — the portal shows an offer URI / QR on the success page.
- Copy the
openid-credential-offer://...URI and feed it to the harness.
Animo Funke — Credo-TS based, EUDI-compatible.
- Visit
https://funke.animo.id, select a credential profile. - Start the issuance flow and copy the offer URI.
- Run against the harness.
Running a local issuer
When you want deterministic iteration (same offer twice, offline, step-through debugging), run WaltID's issuer in Docker:
docker run --rm -p 7002:7002 -p 7003:7003 waltid/issuer-api:latest
# in another terminal, POST an offer config to http://localhost:7002/...
# then feed the returned offer URI to pnpm try-offerWhat the harness does NOT cover
- No wallet integration. Credentials are decoded and printed, not persisted. Once Slice 10 wires
acceptAndStoreCredentialOfferinto the wallet UI, use the learn-card-app instead for that part of the flow. - 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.
- 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.
Driving a real verifier end-to-end — try-verify harness
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.
pnpm --filter @learncard/openid4vc-plugin try-verify \
"<oid4vp-uri>" \
--credentials ./my-vc.jsonFlags:
| Flag | Purpose |
|---|---|
| --credentials <path> | JSON file containing a W3C VC object, a compact JWT-VC string, or an array of either |
| --holder <path> | Sidecar written by try-offer --save — carries the holder did / kid / private JWK. Default: <credentials>.holder.json |
| --submit | After PEX selection, sign the VP and POST it via direct_post |
| --envelope <fmt> | Force jwt_vp_json or ldp_vp (default: inferred from PD + VC formats) |
| --trusted-root <path> | PEM file with a trusted X.509 root — required for client_id_scheme=x509_san_dns verifiers. Repeatable. |
| --unsafe-allow-self-signed | Dev-only: accept self-signed x509 chains. Disables the chain-to-trust-root check. Never production. |
| --verbose, -v | Log the full resolved request + per-field JSONPath matches |
| --help, -h | Show usage |
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.
Recipe: walt.id Verifier Portal (UI flow)
The quickest path to a real OID4VP URI.
Visit
https://portal.walt.id, sign in (free), open Verifier.Pick a credential template (e.g.
OpenBadgeCredential,VerifiableDiploma) and click Create Verification.The portal shows a QR and a clickable
openid4vp://...URL. Copy the URL.Run:
pnpm try-verify "openid4vp://?..." --credentials ./my-vc.json --submitwalt.id's default verifier flow uses
client_id_scheme=redirect_uriwith 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).If
canSatisfy: ✓ YES, the harness signs a JWT-VP, POSTs to the verifier'sresponse_uri, and prints theredirect_urithe walt.id portal hands back. If✗ NO, each descriptor'sreasonfield explains why (wrong format, missing type, filter mismatch).
Recipe: walt.id Verifier REST API (scripted flow)
When you want to iterate quickly on a specific Presentation Definition without the portal UI.
# 1. POST a PD to walt.id's hosted verifier to get a session URI.
curl -s -X POST https://verifier.demo.walt.id/openid4vc/verify \
-H "Content-Type: application/json" \
-H "authorizeBaseUrl: openid4vp://authorize" \
-H "responseMode: direct_post" \
--data '{
"request_credentials": [
{ "input_descriptor": {
"id": "UniversityDegree",
"format": { "jwt_vc_json": { "alg": ["EdDSA","ES256"] } },
"constraints": {
"fields": [ { "path": ["$.type"],
"filter": { "type": "array",
"contains": { "const": "UniversityDegreeCredential" } } } ]
}
} }
]
}'
# → returns a plain openid4vp://... URI in the response body.
# 2. Run the harness against it.
pnpm try-verify "openid4vp://..." --credentials ./my-vc.json --submitThis 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.
Recipe: Sphereon demo verifier
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.
- Visit the Sphereon demo verifier, pick a flow (e.g. "Request University Degree").
- Copy the
openid4vp://URI. pnpm try-verify "openid4vp://..." --credentials ./my-vc.json.
Recipe: Animo Paradym
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.
Where to get a credential to feed it
The fastest loop is try-offer --save → try-verify --credentials:
# 1. Issue a credential from walt.id / Animo / EUDI and write it to disk.
pnpm try-offer "<offer-uri>" --save ./my-vc.json
# 2. Point a real verifier at the same file.
pnpm try-verify "<oid4vp-uri>" --credentials ./my-vc.json --submittry-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.
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.
What the harness does NOT cover
No
client_id_scheme=pre-registered/verifier_attestation. Both surfaceunsupported_client_id_schemefrom the Slice 7.5 verifier.did(did:jwk + did:web built-in, others via customdidResolver) andx509_san_dns(withtrustedX509Roots) are the supported schemes.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.
