From 60237ac189f086a1d604b963cabec61d9d56d5d6 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Fri, 28 Aug 2026 16:57:49 -0400 Subject: [PATCH 1/3] Add support for `application/mdoc` media type and related options. --- CHANGELOG.md | 11 ++++++ lib/convert/index.js | 2 +- lib/oid4vp/authorizationResponse.js | 55 +++++++++++++++++++---------- lib/oid4vp/index.js | 4 ++- lib/oid4vp/verifier.js | 8 +++-- lib/query/dcql.js | 39 +++++++++++++++----- lib/query/presentationExchange.js | 42 ++++++++++++---------- 7 files changed, 112 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 401db10..75a1557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # @digitalbazaar/oid4-client Changelog +## 5.14.0 - 2026-mm-dd + +### Added +- Add support for `application/mdoc` as the media type for mso mdoc enveloped + credentials, including when reading/converting `acceptedEnvelopes` which + will also include `meta: {docType}` when using this new media type. This is + now preferred over `application/mdl`. Notably, `application/mdoc-vp-token` + is now also accepted for mdoc device responses expressed as base64url- + encoded `vp_token` values, but this media type is presently not output from + the parser to avoid a breaking change. + ## 5.13.2 - 2026-07-19 ### Fixed diff --git a/lib/convert/index.js b/lib/convert/index.js index 5b5c88c..01b1b90 100644 --- a/lib/convert/index.js +++ b/lib/convert/index.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2023-2025 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2023-2026 Digital Bazaar, Inc. */ import {dcqlQueryToVprGroups, vprGroupsToDcqlQuery} from '../query/dcql.js'; import { diff --git a/lib/oid4vp/authorizationResponse.js b/lib/oid4vp/authorizationResponse.js index ec58d52..d87415c 100644 --- a/lib/oid4vp/authorizationResponse.js +++ b/lib/oid4vp/authorizationResponse.js @@ -51,17 +51,27 @@ export async function create({ // prepare response body const body = {}; + // possible mso_mdoc handover options + const handoverOptions = encryptionOptions?.mdoc?.handover; + // if `authorizationRequest.response_mode` is `direct.jwt` or // `dc_api.jwt`, then generate a JWT if(authorizationRequest.response_mode === 'direct_post.jwt' || authorizationRequest.response_mode === 'dc_api.jwt') { - // `presentationSubmission` is not used in OID4VP 1.0+ - if((vpTokenMediaType === 'application/mdl-vp-token' || - submitsFormat({presentationSubmission, format: 'mso_mdoc'})) && - !encryptionOptions?.mdl?.handover) { + // note: `presentationSubmission` is not used in OID4VP 1.0+ + const peMsoMdoc = submitsFormat({ + presentationSubmission, format: 'mso_mdoc' + }); + + // deprecated `application/mdl-vp-token` media type; prefer + // `application/mdoc-vp-token` + if((vpTokenMediaType === 'application/mdoc-vp-token' || + vpTokenMediaType === 'application/mdl-vp-token' || peMsoMdoc) && + !handoverOptions) { throw createNamedError({ - message: '"encryptionOptions.mdl.handover" is required ' + - 'to submit an mDL presentation.', + message: '"encryptionOptions.mdoc.handover" or ' + + '"encryptionOptions.mdl.handover" (deprecated) is required ' + + 'to submit an mdoc presentation.', name: 'DataError' }); } @@ -70,7 +80,7 @@ export async function create({ vpToken, presentationSubmission, authorizationRequest, encryptionOptions }); body.response = jwt; - } else if(encryptionOptions?.mdl?.handover?.type === 'dcapi') { + } else if(handoverOptions?.type === 'dcapi') { // ISO 18013-7 Annex C (HPKE encrypted payload) body.Response = await _encrypt({ vpToken, presentationSubmission, authorizationRequest, encryptionOptions @@ -247,14 +257,14 @@ async function _encrypt({ // determine if encrypting to a JWT or using HPKE... + const handoverOptions = encryptionOptions?.mdoc?.handover; + // only mDL Annex C uses HPKE at this time; handover type 'dcapi' === Annex C - if(encryptionOptions?.mdl?.handover?.type === 'dcapi') { + if(handoverOptions?.type === 'dcapi') { const pt = typeof vpToken === 'string' ? base64url.decode(vpToken) : vpToken; // set encoded session transcript as `info` - const info = await encodeSessionTranscript({ - handover: encryptionOptions.mdl.handover - }); + const info = await encodeSessionTranscript({handover: handoverOptions}); const { enc, ct: cipherText } = await hpkeEncrypt({pt, info, encryptionOptions}); @@ -403,16 +413,25 @@ function _matchesInputDescriptor({ function _normalizeEncryptionOptions({ authorizationRequest, encryptionOptions }) { - if(encryptionOptions?.mdl?.sessionTranscript && - !encryptionOptions?.mdl.handover) { + // `mdl` is deprecated; use `mdoc` + if(encryptionOptions?.mdl && !encryptionOptions?.mdoc) { + encryptionOptions = { + ...encryptionOptions, + mdoc: encryptionOptions.mdl + }; + delete encryptionOptions.mdl; + } + + const mdocOptions = encryptionOptions?.mdoc; + if(mdocOptions?.sessionTranscript && !mdocOptions.handover) { // deprecated Annex B style handover info encryptionOptions = { ...encryptionOptions, - mdl: { - ...encryptionOptions.mdl, + mdoc: { + ...mdocOptions, handover: { type: 'AnnexBHandover', - ...encryptionOptions.mdl.sessionTranscript + ...mdocOptions.sessionTranscript } } }; @@ -421,7 +440,7 @@ function _normalizeEncryptionOptions({ // configure `keyManagementParameters` for `EncryptJWT` API if(!encryptionOptions?.keyManagementParameters) { const keyManagementParameters = {}; - if(encryptionOptions?.mdl?.handover) { + if(mdocOptions?.handover) { // ISO 18013-7 Annex B has specific handover params for apu + apv; for // Annex D generate `apu` and use `nonce` for `apv` but this isn't a // requirement; Annex C uses HPKE not a JWT so not relevant here @@ -429,7 +448,7 @@ function _normalizeEncryptionOptions({ mdocGeneratedNonce, nonce, verifierGeneratedNonce - } = encryptionOptions.mdl.handover; + } = mdocOptions.handover; // generate 128-bit random `apu` if no `mdocGeneratedNonce` provided const apu = mdocGeneratedNonce ?? diff --git a/lib/oid4vp/index.js b/lib/oid4vp/index.js index c57315c..47da58a 100644 --- a/lib/oid4vp/index.js +++ b/lib/oid4vp/index.js @@ -1,10 +1,12 @@ /*! - * Copyright (c) 2023-2026 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2023-2026 Digital Bazaar, Inc. */ export * as authzRequest from './authorizationRequest.js'; export * as authzResponse from './authorizationResponse.js'; export * as convert from '../convert/index.js'; +// `mdl` export is deprecated; prefer `mdoc` export export * as mdl from './mdl.js'; +export * as mdoc from './mdl.js'; export * as verifier from './verifier.js'; // backwards compatibility APIs diff --git a/lib/oid4vp/verifier.js b/lib/oid4vp/verifier.js index 4daee49..01e812a 100644 --- a/lib/oid4vp/verifier.js +++ b/lib/oid4vp/verifier.js @@ -56,6 +56,8 @@ export async function parseAuthorizationResponse({ })); // normalize payload to base64url-encoded mDL device response parsed.vpToken = base64url.encode(payload); + // FIXME: future breaking change might be to rename this to + // `application/mdoc-vp-token` since it isn't mDL specific vpTokenMediaType = 'application/mdl-vp-token'; } else { responseMode = 'direct_post'; @@ -102,9 +104,11 @@ export async function parseAuthorizationResponse({ vpTokenMediaType = 'application/vp'; } } else { - // cases 4-5: JWT or mDL device response + // cases 4-5: JWT or mdoc device response parsed.vpToken = vpToken; - // if does not look like a JWT, assume mDL device response + // if does not look like a JWT, assume mdoc mDL device response + // FIXME: future breaking change might be to rename this to + // `application/mdoc-vp-token` since it isn't mDL specific vpTokenMediaType = vpToken.startsWith('ey') ? 'application/jwt' : 'application/mdl-vp-token'; } diff --git a/lib/query/dcql.js b/lib/query/dcql.js index a388bd9..596a846 100644 --- a/lib/query/dcql.js +++ b/lib/query/dcql.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2025-2026 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2025-2026 Digital Bazaar, Inc. */ import { fromJsonPointerMap, isNumber, toIntegerIfInteger, toJsonPointerMap @@ -104,6 +104,13 @@ export function vprGroupsToDcqlQuery({groupMap, options = {}} = {}) { return dcqlQuery; } +function _getAcceptedEnvelopes(acceptedEnvelopes) { + // array elements can be '' or {mediaType: '', ...} + return acceptedEnvelopes + ?.filter(e => e && (typeof e === 'string' || e.mediaType)) + ?.map(e => typeof e === 'string' ? {mediaType: e} : e) ?? []; +} + // exported for testing purposes only export function _fromQueryByExampleQuery({ credentialQuery, nullyifyArrayIndices = false @@ -124,14 +131,23 @@ export function _fromQueryByExampleQuery({ const {example = {}} = credentialQuery ?? {}; // determine credential format - if(Array.isArray(credentialQuery.acceptedEnvelopes)) { - const set = new Set(credentialQuery.acceptedEnvelopes); - if(set.has('application/jwt')) { + const acceptedEnvelopes = _getAcceptedEnvelopes( + credentialQuery.acceptedEnvelopes); + if(acceptedEnvelopes.length > 0) { + const map = new Map(acceptedEnvelopes.map(e => [e.mediaType, e])); + if(map.has('application/jwt')) { result.format = 'jwt_vc_json'; - } else if(set.has('application/mdl')) { + } else if(map.has('application/mdl')) { + // note: `application/mdl` is deprecated, `application/mdoc` is preferred result.format = 'mso_mdoc'; result.meta = {doctype_value: MDOC_MDL}; - } else if(set.has('application/dc+sd-jwt')) { + } else if(map.has('application/mdoc')) { + result.format = 'mso_mdoc'; + // if `meta?.docType` is erroneously omitted, guess mDL, a docType is + // required for conversion to DCQL + const e = map.get('application/mdoc'); + result.meta = {doctype_value: e.meta?.docType ?? MDOC_MDL}; + } else if(map.has('application/dc+sd-jwt')) { result.format = 'dc+sd-jwt'; result.meta = {vct_values: []}; if(Array.isArray(example?.type)) { @@ -232,11 +248,18 @@ export function _toQueryByExampleQuery({dcqlCredentialQuery}) { if(format === 'jwt_vc_json') { credentialQuery.acceptedEnvelopes = ['application/jwt']; } else if(format === 'mso_mdoc') { - if(meta?.doctype_value === MDOC_MDL) { - credentialQuery.acceptedEnvelopes = ['application/mdl']; + if(meta?.doctype_value) { + credentialQuery.acceptedEnvelopes = [{ + mediaType: 'application/mdoc', + meta: {docType: meta.doctype_value} + }]; } else { credentialQuery.acceptedEnvelopes = ['application/mdoc']; } + // include deprecated `application/mdl` media type as appropriate + if(meta?.doctype_value === MDOC_MDL) { + credentialQuery.acceptedEnvelopes.push('application/mdl'); + } } else if(format === 'dc+sd-jwt') { // FIXME: consider adding `vct_values` as params credentialQuery.acceptedEnvelopes = ['application/dc+sd-jwt']; diff --git a/lib/query/presentationExchange.js b/lib/query/presentationExchange.js index b041b3a..fd8e28e 100644 --- a/lib/query/presentationExchange.js +++ b/lib/query/presentationExchange.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2023-2026 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2023-2026 Digital Bazaar, Inc. */ import {resolvePointer, toJsonPointerMap} from './util.js'; import {exampleToJsonPointerMap} from './queryByExample.js'; @@ -88,30 +88,34 @@ export function vprGroupsToPresentationDefinition({ const envelopes = _getEnvelopes(acceptedEnvelopes); const cryptosuites = _getCryptosuites(acceptedCryptosuites); - const shouldAddFormat = - envelopes?.length > 0 || cryptosuites?.length > 0; + const shouldAddFormat = envelopes.length > 0 || + cryptosuites.length > 0; if(shouldAddFormat && !inputDescriptor.format) { inputDescriptor.format = {}; } - if(envelopes?.includes('application/jwt')) { - inputDescriptor.format.jwt_vc_json = { - alg: SUPPORTED_JWT_ALGS - }; + if(envelopes.some(({mediaType}) => mediaType === 'application/jwt')) { + inputDescriptor.format.jwt_vc_json = {alg: SUPPORTED_JWT_ALGS}; acceptsVcJwt = true; } - if(envelopes?.includes('application/mdl') || - envelopes?.includes('application/mdoc')) { + if(envelopes.some(({mediaType}) => mediaType === 'application/mdl')) { inputDescriptor.format.mso_mdoc = {}; // `inputDescriptor` MUST be `org.iso.18013.5.1.mDL` for an mDL // query for ecosystem compatibility - if(envelopes?.includes('application/mdl')) { - inputDescriptor.id = MDOC_MDL; + inputDescriptor.id = MDOC_MDL; + } else { + const e = envelopes.find( + ({mediaType}) => mediaType === 'application/mdoc'); + if(e) { + inputDescriptor.format.mso_mdoc = {}; + // `inputDescriptor` MUST be `` for some queries for + // ecosystem compatibility + if(typeof e.meta?.docType === 'string') { + inputDescriptor.id = e.meta.docType; + } } } - if(cryptosuites?.length > 0) { - inputDescriptor.format.ldp_vc = { - proof_type: cryptosuites - }; + if(cryptosuites.length > 0) { + inputDescriptor.format.ldp_vc = {proof_type: cryptosuites}; for(const cryptosuite of cryptosuites) { ldpVcProofTypes.add(cryptosuite); } @@ -131,10 +135,10 @@ export function vprGroupsToPresentationDefinition({ const envelopes = _getEnvelopes(acceptedEnvelopes); const cryptosuites = _getCryptosuites(acceptedCryptosuites); - if(envelopes?.includes('application/jwt')) { + if(envelopes.some(({mediaType}) => mediaType === 'application/jwt')) { acceptsVpJwt = true; } - if(cryptosuites?.length > 0) { + if(cryptosuites.length > 0) { for(const cryptosuite of cryptosuites) { ldpVpProofTypes.add(cryptosuite); } @@ -438,8 +442,8 @@ function _getCryptosuites(acceptedCryptosuites) { } function _getEnvelopes(acceptedEnvelopes) { - // array elements can be '' or {mediaType: ''} + // array elements can be '' or {mediaType: '', ...} return acceptedEnvelopes ?.filter(e => e && (typeof e === 'string' || e.mediaType)) - ?.map(e => typeof e === 'string' ? e : e.mediaType) ?? []; + ?.map(e => typeof e === 'string' ? {mediaType: e} : e) ?? []; } From b995f6946850d7e265179cec0bffe4c516a3b9c2 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Fri, 28 Aug 2026 17:05:10 -0400 Subject: [PATCH 2/3] Include `application/mdoc` in tests. --- tests/mdlUtils.js | 11 ++++------- tests/unit/iso18013-7-C.spec.js | 6 +++++- tests/unit/iso18013-7-D.spec.js | 6 +++--- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/tests/mdlUtils.js b/tests/mdlUtils.js index 90ef7be..2d048e4 100644 --- a/tests/mdlUtils.js +++ b/tests/mdlUtils.js @@ -1,7 +1,6 @@ /* - * Copyright (c) 2025-2026 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2025-2026 Digital Bazaar, Inc. */ -import * as base64url from 'base64url-universal'; import { DeviceResponse, Document, MDoc, /*parse,*/ Verifier } from '@auth0/mdl'; @@ -44,15 +43,13 @@ export async function createPresentation({ mdoc, presentationDefinition, handover, devicePrivateJwk }); - // FIXME: define a base64url-encoded mdl vp token mime type? const encodedDeviceResponse = deviceResponse.encode(); - const vpToken = base64url.encode(encodedDeviceResponse); + const b64 = Buffer.from(encodedDeviceResponse).toString('base64'); // console.log('device side: device response cbor', encodedDeviceResponse); - // console.log(vpToken, 'vpToken'); return { '@context': [VC_CONTEXT_2], - id: `data:application/mdl-vp-token,${vpToken}`, + id: `data:application/mdoc,${b64}`, type: 'EnvelopedVerifiablePresentation' }; } @@ -144,7 +141,7 @@ export async function verifyPresentation({ '@context': [VC_CONTEXT_2], type: 'VerifiablePresentation', verifiableCredential: [{ - id: `data:application/mdl;base64,${b64Mdl}`, + id: `data:application/mdoc;base64,${b64Mdl}`, type: 'EnvelopedVerifiableCredential' }] }; diff --git a/tests/unit/iso18013-7-C.spec.js b/tests/unit/iso18013-7-C.spec.js index 6559530..bb3d97d 100644 --- a/tests/unit/iso18013-7-C.spec.js +++ b/tests/unit/iso18013-7-C.spec.js @@ -160,7 +160,11 @@ describe('OID4VP ISO 18013-7 Annex C', () => { // create authz response const {authorizationResponse} = await oid4vp.authzResponse.create({ authorizationRequest, - vpToken, vpTokenMediaType: 'application/mdl-vp-token', + vpToken, + // intentionally use deprecated 'application/mdl-vp-token'; modern + // 'application/mdoc-vp-token' is used in Annex D test to provide + // coverage for both + vpTokenMediaType: 'application/mdl-vp-token', encryptionOptions: { mdl: {handover}, recipientPublicJwk diff --git a/tests/unit/iso18013-7-D.spec.js b/tests/unit/iso18013-7-D.spec.js index 033db8e..d296a8c 100644 --- a/tests/unit/iso18013-7-D.spec.js +++ b/tests/unit/iso18013-7-D.spec.js @@ -165,7 +165,7 @@ describe('OID4VP ISO 18013-7 Annex D', () => { // create authz response const {authorizationResponse} = await oid4vp.authzResponse.create({ authorizationRequest, - vpToken, vpTokenMediaType: 'application/mdl-vp-token', + vpToken, vpTokenMediaType: 'application/mdoc-vp-token', encryptionOptions: { mdl: {handover}, recipientPublicJwk @@ -365,9 +365,9 @@ describe('OID4VP ISO 18013-7 Annex D', () => { // create authz response const {authorizationResponse} = await oid4vp.authzResponse.create({ authorizationRequest, - vpToken, vpTokenMediaType: 'application/mdl-vp-token', + vpToken, vpTokenMediaType: 'application/mdoc-vp-token', encryptionOptions: { - mdl: { + mdoc: { handover }, recipientPublicJwk, From 5e25a4e39c43686670c3852668d8a969b0d8775d Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Fri, 28 Aug 2026 17:07:34 -0400 Subject: [PATCH 3/3] Update changelog. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75a1557..d78dd9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ is now also accepted for mdoc device responses expressed as base64url- encoded `vp_token` values, but this media type is presently not output from the parser to avoid a breaking change. +- Expose `mdoc` export for mdoc-related functions. This exposes the same API + as `mdl` does (but this pathway is now deprecated). ## 5.13.2 - 2026-07-19