@openeudi/openid4vp
v0.9.1
Published
OpenID4VP credential parsing and validation for EUDI Wallets (SD-JWT VC + mDOC)
Downloads
657
Maintainers
Readme
@openeudi/openid4vp
OpenID4VP credential parsing and validation for EUDI Wallets. Supports SD-JWT VC and mDOC credential formats with issuer trust verification, expiry checking, selective disclosure claim extraction, and DCQL-based credential matching.
Install
npm install @openeudi/openid4vpQuick start
Parse a Verifiable Presentation token and extract identity claims:
import { parsePresentation } from "@openeudi/openid4vp";
const result = await parsePresentation(vpToken, {
trustedCertificates: [issuerCertBytes],
nonce: "expected-nonce-value",
});
if (result.valid) {
console.log(result.format); // 'sd-jwt-vc' | 'mdoc'
console.log(result.claims.age_over_18); // true
console.log(result.issuer.country); // 'DE'
} else {
console.error(result.error);
}parsePresentation automatically detects the credential format. String tokens with ~ separators are parsed as SD-JWT VC; binary Uint8Array tokens are parsed as CBOR-encoded mDOC.
Authorization requests
Build an OpenID4VP authorization request URI to send to an EUDI Wallet. The request carries a DCQL query (Digital Credentials Query Language) describing the credentials you want:
import { buildHaipQuery, createAuthorizationRequest } from "@openeudi/openid4vp";
const query = buildHaipQuery({
credentialId: "pid",
format: "dc+sd-jwt",
vctValues: ["https://pid.eu/v1"],
claims: ["age_over_18"],
});
const request = createAuthorizationRequest(
{
clientId: "x509_san_dns:verifier.example.com",
responseUri: "https://verifier.example.com/cb",
nonce: crypto.randomUUID(),
},
query,
);
console.log(request.uri);
// openid4vp://authorize?response_type=vp_token&response_mode=direct_post&...
console.log(request.state);
// auto-generated UUID unless you provide one
console.log(request.dcqlQuery);
// the DCQL query embedded in the requestAuthorizationRequestInput
| Field | Type | Required | Description |
| -------------- | -------- | -------- | ---------------------------------------------- |
| clientId | string | Yes | Your verifier client identifier |
| responseUri | string | Yes | Callback URL for the wallet response |
| nonce | string | Yes | Challenge nonce for replay protection |
| state | string | No | Session state (auto-generated UUID if omitted) |
The second argument is a DCQL Query object. Use buildHaipQuery (below) or hand-construct one and validate it via validateHaipQuery.
HAIP helpers
For the High Assurance Interoperability Profile (HAIP) commonly used by EUDI Wallets:
import { buildHaipQuery, validateHaipQuery } from "@openeudi/openid4vp";
// Build a HAIP-compliant DCQL query:
const query = buildHaipQuery({
credentialId: "pid",
format: "dc+sd-jwt",
vctValues: ["https://pid.eu/v1"],
claims: ["age_over_18", "given_name"],
});
// Or validate a hand-built DCQL query:
validateHaipQuery(query); // throws HaipValidationError on violationSupported formats: dc+sd-jwt and mso_mdoc. Other formats (e.g., jwt_vc_json) will be rejected by the validator.
Known EUDI doctypes auto-namespace their claim paths (e.g., org.iso.18013.5.1.mDL → claims under org.iso.18013.5.1). Unknown doctypes use the full doctype string as the namespace.
Verifying presentations against a query
Use verifyPresentation to combine crypto/structural verification with DCQL matching in a single call:
import { verifyPresentation } from "@openeudi/openid4vp";
const result = await verifyPresentation(vpToken, query, {
nonce,
trustedCertificates,
});
if (result.valid) {
console.log("matched claims:", result.match.matches[0].extractedClaims);
console.log("submission:", result.submission);
} else {
console.warn("mismatch reasons:", result.match.unmatched);
// each entry: { queryId, reason, detail? }
// reason ∈ { format_mismatch, vct_mismatch, doctype_mismatch, missing_claims, value_mismatch, trusted_authority_mismatch, no_credential_found /* only when the candidate list is empty */ }
}Mismatches return valid: false — they do not throw. Only crypto/structural failures (malformed VP tokens, invalid signatures, expired credentials) and malformed DCQL queries throw exceptions.
Privacy — diagnostics are verifier-internal.
match.unmatched[].reasonanddetail(includingvalue_mismatch) are intended for verifier-side logging, debugging, and admin UIs. OpenID4VP §11 warns that per-claim verification outcomes can reveal wallet contents to observers. Do NOT echo these diagnostics into the OpenID4VP wire response sent back to the wallet, into end-user-visible error messages that another party could correlate, or into public analytics/third-party logs. The protocol's own error codes are the public interface; these fields are your internal instrumentation.
Signed authorization requests (x509_san_dns)
For flows that require a signed request object (JAR) per OpenID4VP 1.0 §5.10, use createSignedAuthorizationRequest:
import { createSignedAuthorizationRequest } from "@openeudi/openid4vp";
const req = await createSignedAuthorizationRequest({
hostname: "verifier.example.com",
requestUri: "https://verifier.example.com/request.jwt",
responseUri: "https://verifier.example.com/response",
nonce,
signer: verifierKeyPair, // CryptoKeyPair with public+private
certificateChain: [leafCertDer], // DER-encoded, leaf SAN DNSName must equal hostname
encryptionKey: {
publicJwk: encryptionPublicJwk, // must include alg, e.g. "ECDH-ES"
},
vpFormatsSupported: {
"dc+sd-jwt": { "sd-jwt_alg_values": ["ES256"] },
},
}, dcqlQuery);
// req.uri — the short URI to hand to the wallet
// req.requestObject — the JWS the verifier must host at requestUri
// (Content-Type: application/oauth-authz-req+jwt)The caller hosts req.requestObject at requestUri (the library does not host HTTP). The library verifies that the signing key's public SPKI matches the leaf certificate's public key — an attempt to sign with a mismatched key fails with SignedRequestBuildError: signing_key_cert_mismatch.
The emitted client_metadata carries both shapes for compatibility:
- 1.0 Final plural —
encrypted_response_enc_values_supported: ["A128GCM", ...] - ID3 singular —
authorization_encrypted_response_alg: "ECDH-ES",authorization_encrypted_response_enc: "A128GCM"
Verifiers reading either shape (e.g. the OIDF conformance suite reads ID3 directly) work without bespoke configuration.
Authorization responses (direct_post and direct_post.jwt)
Wallets POST the Authorization Response to your responseUri. The library is stateless — you MUST compare the envelope's state against the value you issued before treating the response as trustworthy. The recommended pattern differs slightly between the unencrypted and encrypted modes.
Unencrypted (direct_post)
The envelope arrives as form-encoded JSON; parse it, check state, then verify:
import { verifyAuthorizationResponse } from "@openeudi/openid4vp";
const envelope = parsedVpTokenObject; // { vp_token, state, ... }
if (envelope.state !== submittedState) {
throw new Error("state mismatch — possible CSRF / replay");
}
const result = await verifyAuthorizationResponse(envelope, dcqlQuery, {
trustedCertificates: [issuerCertDer],
nonce,
});Encrypted (direct_post.jwt)
The wallet wraps the envelope in a JWE. Decrypt explicitly so you can check state against the decrypted envelope before verification runs:
import {
decryptAuthorizationResponse,
verifyAuthorizationResponse,
} from "@openeudi/openid4vp";
const decrypted = await decryptAuthorizationResponse(
form.get("response"), // the JWE string
verifierEncryptionPrivateKey,
);
if (decrypted.state !== submittedState) {
throw new Error("state mismatch — possible CSRF / replay");
}
const result = await verifyAuthorizationResponse(decrypted, dcqlQuery, {
trustedCertificates: [issuerCertDer],
nonce,
});verifyAuthorizationResponse also accepts the JWE directly via { response: jwe } together with options.decryptionKey — but that path makes the state check easy to skip, since the caller never holds the decrypted envelope. Prefer the explicit two-step pattern above.
verifyAuthorizationResponse accepts the OpenID4VP 1.0 §8.1 envelope shape: vp_token is always an object keyed by DCQL credential query id, with arrays of presentations. This release supports single-credential single-presentation only — multi-credential queries or multi-presentation arrays throw MultipleCredentialsNotSupportedError.
mDOC SessionTranscript on the encrypted path
Verifying an mDOC credential requires the ISO 18013-5 SessionTranscript the device signed over (see mDOC below). For direct_post.jwt, verifyAuthorizationResponse can auto-build the SessionTranscript for you, in one of two profiles selected via options.sessionTranscriptProfile:
'iso-18013-7'(default) — the ISO 18013-7 Annex B OID4VP transcript, matching id2/id3-era wallets. Passoptions.clientIdandoptions.responseUrialongside the usualoptions.nonce, and the library derives themdoc-generated-noncefrom the JWE'sapuheader to construct the transcript before verification runs.const result = await verifyAuthorizationResponse(envelope, dcqlQuery, { trustedCertificates: [issuerCertDer], nonce, decryptionKey: verifierEncryptionPrivateKey, clientId: verifierClientId, responseUri: verifierResponseUri, });'openid4vp-1.0'— the OpenID4VP 1.0-FinalOpenID4VPHandovertranscript, for wallets that implement the final spec's SessionTranscript shape instead of the Annex B one. PasssessionTranscriptProfile: 'openid4vp-1.0'together withclientId,responseUri,nonce, and — for the encrypted path —verifierEncryptionJwk(the verifier's response-encryption public JWK, used to derive the handover's JWK thumbprint). This profile does not read the JWEapuheader.const result = await verifyAuthorizationResponse(envelope, dcqlQuery, { trustedCertificates: [issuerCertDer], nonce, decryptionKey: verifierEncryptionPrivateKey, clientId: verifierClientId, responseUri: verifierResponseUri, sessionTranscriptProfile: 'openid4vp-1.0', verifierEncryptionJwk, // verifier's response-encryption public JWK });The transcript is
[null, null, ["OpenID4VPHandover", SHA-256(cbor([client_id, nonce, jwk_thumbprint | null, response_uri]))]], wherejwk_thumbprintis the RFC 7638 SHA-256 thumbprint ofverifierEncryptionJwk(ornullwhen the response is unencrypted). Callers who need this transcript outsideverifyAuthorizationResponsecan use the exportedbuildOpenID4VPHandoverSessionTranscript({ clientId, nonce, responseUri, verifierEncryptionJwk? }).
If you already have the transcript bytes (or are verifying an mDOC outside either auto-build path, e.g. the unencrypted direct_post flow), pass options.mdocSessionTranscript: Uint8Array explicitly — it always takes precedence over either auto-built value. buildOid4vpSessionTranscript({ clientId, responseUri, nonce, mdocGeneratedNonce }) (Annex B) and buildOpenID4VPHandoverSessionTranscript({ clientId, nonce, responseUri, verifierEncryptionJwk? }) (1.0-Final) are both exported for callers who need to construct a transcript themselves. Without a transcript, the mDOC parser fails closed — see mDOC.
Supported JWE algorithms
direct_post.jwt decryption supports:
alg:ECDH-ES(driven by the encryption JWK'salgparameter)enc:A128GCM,A256GCM(HAIP requires both)
Other algorithms throw UnsupportedJweError.
ParseOptions / VerifyOptions
Both parsePresentation and verifyPresentation accept:
nonce(required) — the nonce bound into the VP token at creation time.requireKeyBinding?— force SD-JWT holder-binding verification even when the issuer JWT carries nocnfclaim. When the credential is holder-bound (cnfpresent), a KB-JWT is always required regardless of this flag — fail-closed, not opt-in. This flag only extends the requirement to credentials that lackcnf. Defaultfalse. See SD-JWT VC.mdocSessionTranscript?— CBOR bytes of the ISO 18013-5SessionTranscriptfor the current OpenID4VP exchange. Required to verify mDOC device authentication; the mDOC parser fails closed without it. Fordirect_post.jwt,verifyAuthorizationResponsecan build this for you fromclientId/responseUri— see mDOC SessionTranscript on the encrypted path. See mDOC.sessionTranscriptProfile?— (VerifyAuthorizationResponseOptionsonly — thedirect_post/direct_post.jwtauto-build described above) whichSessionTranscriptshape to build fromclientId/responseUri/nonce:'iso-18013-7'(default) builds the Annex B,apu-derived transcript for id2/id3-era wallets;'openid4vp-1.0'builds the OpenID4VP 1.0-FinalOpenID4VPHandovertranscript instead. See mDOC SessionTranscript on the encrypted path.verifierEncryptionJwk?— (VerifyAuthorizationResponseOptionsonly) the verifier's response-encryption public JWK. Required on the encrypted path whensessionTranscriptProfile: 'openid4vp-1.0'is used, to derive the handover's JWK thumbprint; ignored for the'iso-18013-7'profile.trustedCertificates(required whentrustStoreis unset) — DER-encoded issuer leaf certificates for the 0.4.x byte-equality trust check. Deprecated since 0.5.0 — pass an empty array and supplytrustStorefor production deployments.trustStore?—TrustStoreinstance for full RFC 5280 chain validation (e.g.LotlTrustStore,StaticTrustStore, orCompositeTrustStore). When set, takes precedence overtrustedCertificates.revocationPolicy?—'skip'(default) |'prefer'|'require'. Controls whether the chain validator consults OCSP / CRL.fetcher?— HTTP transport for CRL/OCSP/LOTL fetches. Defaults toglobalThis.fetch.cache?— cache for CRL/OCSP/LOTL artefacts. Defaults tonew InMemoryCache().clockSkewTolerance?— seconds of slack applied to certificate validity checks. Default 60.trustedIssuerJwks?— opt-in alternate trust path for SD-JWT VCs whose issuer JWT lacks anx5cheader. The library matches bykid(or iterates the array when nokidis present) and skips chain validation entirely. Intended for harness setups (e.g. OIDF conformance suite) where the wallet signs withoutx5cand the verifier knows the signing key out-of-band. Not recommended for production verifiers —trustStoreis the secure path.audience?— expected audience for key binding JWT verification.allowedAlgorithms?— restrict signature algorithms. Defaults to['ES256','ES384','ES512'].skipTrustCheck?— skip trust checks entirely (dev/test only).expectedDocType?— for mDOC verification, lock the credentialdocType(or SD-JWTvct).
Supported formats
SD-JWT VC
Selective Disclosure JSON Web Token Verifiable Credentials. The token is a string in jwt~disclosure~kb format. The parser:
- Decodes the issuer JWT and extracts the
x5ccertificate chain - Verifies the issuer certificate against your trusted set
- Checks credential expiry from the
expclaim - Resolves selective disclosures using SHA-256
- Enforces holder binding (key binding JWT / KB-JWT): mandatory whenever the issuer JWT carries a
cnfclaim — the credential is holder-bound and verification fails closed if the KB-JWT is missing. SetrequireKeyBinding: trueto extend this requirement to credentials withoutcnf. When a KB-JWT is required, the parser verifies its signature against thecnf.jwkholder key and validates its claims: a non-emptynonce(matched againstoptions.nonce),sd_hash(over the SD-JWT + disclosures),audience(whenoptions.audienceis set), and standard JWT claims includingiat.
Breaking change (0.9.0): prior releases only validated the KB-JWT's nonce when a KB-JWT happened to be present and silently accepted holder-bound credentials presented without one. As of 0.9.0, a holder-bound credential missing its KB-JWT is rejected outright — see CHANGELOG.
mDOC
Mobile Document credentials as defined in ISO 18013-5. The token is a CBOR-encoded Uint8Array containing a DeviceResponse. The parser:
- Decodes the CBOR DeviceResponse structure
- Extracts the issuer certificate from the COSE_Sign1
issuerAuth(x5chain label 33) - Verifies the certificate against your trusted set
- Checks the validity period from
validityInfo - Extracts claims from the
eu.europa.ec.eudi.pid.1namespace - Verifies each
IssuerSignedItem's digest against the MSO'svalueDigests, computed over the full tag-24IssuerSignedItemBytes(#6.24(bstr .cbor IssuerSignedItem)) per ISO 18013-5 §9.1.2.4 — not just the inner CBOR — matching how real wallets encode mdocs. mDOC verification, including this digest check and device authentication below, is validated against the OIDF conformance suite acting as an independent ISO 18013-5 mdl wallet. - Performs full ISO 18013-5 §9.1.3 device authentication: verifies the
DeviceSignature(COSE_Sign1) overDeviceAuthentication, which binds theSessionTranscript/nonce, using the EC2 device key committed in the MSO'sdeviceKeyInfo.DeviceMac(COSE_Mac0) is not supported and is rejected. The parser fails closed ifdeviceSigned, theSessionTranscript(options.mdocSessionTranscript), or the device key is missing — a capturedissuerSignedpayload alone is no longer accepted as proof of presentation.
Breaking change (0.9.0): prior releases verified only the issuer-signed data (
issuerSigned), so a replayed or intercepted mDOC device response — without any proof the presenting device held the credentialed key — was accepted. As of 0.9.0, device authentication is mandatory and fails closed; see CHANGELOG.
Custom parsers
Implement ICredentialParser to add support for additional credential formats:
import type { ICredentialParser, ParseOptions, CredentialFormat, PresentationResult } from "@openeudi/openid4vp";
class MyCustomParser implements ICredentialParser {
readonly format: CredentialFormat = "sd-jwt-vc"; // or 'mdoc'
canParse(vpToken: unknown): boolean {
// Return true if this parser can handle the token
return typeof vpToken === "string" && vpToken.startsWith("custom:");
}
async parse(vpToken: unknown, options: ParseOptions): Promise<PresentationResult> {
// Validate trust using options.trustedCertificates
// Verify nonce using options.nonce
// Extract and return claims
return {
valid: true,
format: this.format,
claims: { age_over_18: true },
issuer: { certificate: new Uint8Array(), country: "DE" },
};
}
}PresentationResult
| Field | Type | Description |
| -------- | ------------------ | ------------------------------------------ |
| valid | boolean | Whether the credential passed all checks |
| format | CredentialFormat | 'sd-jwt-vc' or 'mdoc' |
| claims | CredentialClaims | Extracted identity claims |
| issuer | IssuerInfo | Issuer certificate and country |
| error | string? | Reason for failure when valid is false |
Error types
| Error class | Default message | Thrown when |
| -------------------------- | ----------------------------------------- | -------------------------------------------------- |
| InvalidSignatureError | Credential signature validation failed | Signature verification fails |
| ExpiredCredentialError | Credential has expired | Credential exp or validUntil is in the past |
| UnsupportedFormatError | Unsupported credential format: {format} | Token format is not SD-JWT VC or mDOC |
| MalformedCredentialError | Credential structure is malformed | Token cannot be decoded or is structurally invalid |
| NonceValidationError | Nonce does not match expected value | Key binding JWT nonce does not match |
| HaipValidationError | HAIP query constraint violated | DCQL query fails validateHaipQuery |
import { MalformedCredentialError, ExpiredCredentialError } from "@openeudi/openid4vp";
try {
const result = await parsePresentation(vpToken, options);
} catch (err) {
if (err instanceof MalformedCredentialError) {
// Token structure could not be decoded
}
}Scope and limitations
This library implements the verifier side of OpenID4VP for SD-JWT VC and mDOC credentials.
What is implemented:
- SD-JWT VC — full cryptographic verification (issuer JWT signature via
x5c, transitive disclosure-hash check, key binding JWT signature +sd_hash, nonce check). Holder binding (KB-JWT) is mandatory and fails closed whenever the issuer JWT carries acnfclaim;requireKeyBindingextends the requirement to credentials withoutcnf. OptionaltrustedIssuerJwksalternate trust path for VCs withoutx5c. - mDOC / ISO 18013-5
mso_mdocformat — CBOR decoding, claim extraction, COSE_Sign1 signature verification, MobileSecurityObject validity enforcement, IssuerSignedItem digest verification. Device authentication (ISO 18013-5 §9.1.3) is mandatory and fails closed: theDeviceSignature(COSE_Sign1) overDeviceAuthentication/SessionTranscriptis verified against the MSO-committed device key.DeviceMac(COSE_Mac0) is not supported and is rejected. - DCQL — authorization request builder with DCQL query, matching via @openeudi/dcql,
verifyPresentationfor combined crypto + match. - HAIP —
buildHaipQuery/validateHaipQueryhelpers for the High Assurance Interoperability Profile. - Signed authorization requests (JAR) —
createSignedAuthorizationRequestper RFC 9101 / OpenID4VP 1.0 §5.10 withx509_san_dnsclient-id binding. Emits both 1.0 Final and ID3client_metadatashapes for verifier interop. - Encrypted responses —
decryptAuthorizationResponsefordirect_post.jwt(ECDH-ES + A128GCM/A256GCM),verifyAuthorizationResponsefor the 1.0 §8.1 object-keyedvp_tokenenvelope. - X.509 chain validation — RFC 5280 chain building including
nameConstraints,StaticTrustStore,CompositeTrustStore. - Revocation checking — OCSP-first with CRL fallback (
revocationPolicy: 'skip' | 'prefer' | 'require'). - EU List of Trusted Lists —
LotlTrustStoreresolves national trust lists via signed XML fetch + XAdES verification; populatesprovenance(LoA, qualified status, country, service name) on verified presentations. - OIDF conformance — automated against the OpenID Foundation conformance suite in CI (
oidf-pr.ymlhappy-flow gate,oidf-release.ymlfull plan). - Algorithm allowlist — ES256/384/512 (ECDSA only per EUDI policy); configurable via
allowedAlgorithms.
What is NOT yet implemented (planned for follow-up releases):
- Multi-credential DCQL queries (multiple query ids) and multi-presentation arrays per query id — currently rejected with
MultipleCredentialsNotSupportedError. client_id_scheme: x509_hash(HAIP 1.0 final's mandated scheme) — onlyx509_san_dnsis supported today.- Self-signed-leaf rejection per HAIP 1.0 final's strict constraint (current behaviour accepts self-signed leaves for the verifier's own identity).
- SIOPv2 (Self-Issued OpenID Provider) identity flows.
EUDI Architecture Reference Framework (ARF) alignment: tracks OpenID4VP 1.0 final. Full ARF 1.4+ profile compliance will be added before a stable 1.0.
OIDF Conformance Testing
Verifier-side conformance is automated against a self-hosted OpenID Foundation conformance suite. See docs/manual-testing/oidf-interop.md for both the CI orchestrator (npm run oidf:ci -- --profile=happy-flow|full) and the manual hosted-demo escape hatch.
Related packages
- @openeudi/core -- Framework-agnostic EUDI Wallet verification protocol engine with session management and QR code generation.
- @openeudi/dcql -- DCQL query matching engine used internally by
verifyPresentation. - eIDAS Pro -- Managed verification service with admin dashboard, webhook integrations, and plugin support for WooCommerce and Shopify.
Migration
See CHANGELOG.md for per-release changes. Key migration moments:
- 0.4.0 —
presentationDefinition(PEX) replaced by DCQL queries;verifyPresentationintroduced. - 0.5.0 —
trustStoreoption added for RFC 5280 chain validation;trustedCertificatesdeprecated. - 0.6.0 — DCQL surfaces specific
UnmatchedReasonvalues via@openeudi/[email protected](BREAKING for callers readingmatch.unmatched[].reason). - 0.7.0 —
createSignedAuthorizationRequest,decryptAuthorizationResponse,verifyAuthorizationResponsefor HAIP / 1.0 §8.1 envelopes. - 0.8.0 — additive: ID3
client_metadatabridge,trustedIssuerJwksopt-in, transitive SD-JWT disclosure check.
