Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# @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.
- 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

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion lib/convert/index.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
55 changes: 37 additions & 18 deletions lib/oid4vp/authorizationResponse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
});
}
Expand All @@ -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
Expand Down Expand Up @@ -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});
Expand Down Expand Up @@ -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
}
}
};
Expand All @@ -421,15 +440,15 @@ 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
const {
mdocGeneratedNonce,
nonce,
verifierGeneratedNonce
} = encryptionOptions.mdl.handover;
} = mdocOptions.handover;

// generate 128-bit random `apu` if no `mdocGeneratedNonce` provided
const apu = mdocGeneratedNonce ??
Expand Down
4 changes: 3 additions & 1 deletion lib/oid4vp/index.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 6 additions & 2 deletions lib/oid4vp/verifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
}
Expand Down
39 changes: 31 additions & 8 deletions lib/query/dcql.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -104,6 +104,13 @@ export function vprGroupsToDcqlQuery({groupMap, options = {}} = {}) {
return dcqlQuery;
}

function _getAcceptedEnvelopes(acceptedEnvelopes) {
// array elements can be '<mediaType>' or {mediaType: '<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
Expand All @@ -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)) {
Expand Down Expand Up @@ -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'];
Expand Down
42 changes: 23 additions & 19 deletions lib/query/presentationExchange.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 `<docType>` 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);
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -438,8 +442,8 @@ function _getCryptosuites(acceptedCryptosuites) {
}

function _getEnvelopes(acceptedEnvelopes) {
// array elements can be '<mediaType>' or {mediaType: '<mediaType>'}
// array elements can be '<mediaType>' or {mediaType: '<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) ?? [];
}
11 changes: 4 additions & 7 deletions tests/mdlUtils.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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'
};
}
Expand Down Expand Up @@ -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'
}]
};
Expand Down
6 changes: 5 additions & 1 deletion tests/unit/iso18013-7-C.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions tests/unit/iso18013-7-D.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading