From 36246364161192c093ef695b4cf77427c97edf87 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sat, 18 Oct 2025 17:13:07 -0400 Subject: [PATCH 1/7] Refactor tests. --- tests/unit/{oid4vp.spec.js => convert.spec.js} | 2 +- tests/unit/iso18013-7.spec.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename tests/unit/{oid4vp.spec.js => convert.spec.js} (99%) diff --git a/tests/unit/oid4vp.spec.js b/tests/unit/convert.spec.js similarity index 99% rename from tests/unit/oid4vp.spec.js rename to tests/unit/convert.spec.js index 6b425ea..31c638f 100644 --- a/tests/unit/oid4vp.spec.js +++ b/tests/unit/convert.spec.js @@ -7,7 +7,7 @@ import chai from 'chai'; chai.should(); const {expect} = chai; -describe('OID4VP', () => { +describe('convert', () => { describe('QueryByExample => Presentation Definition', () => { it('should NOT include "vc" prefix in paths', async () => { const presentation_definition = _fromQueryByExampleQuery({ diff --git a/tests/unit/iso18013-7.spec.js b/tests/unit/iso18013-7.spec.js index 3727a48..611754e 100644 --- a/tests/unit/iso18013-7.spec.js +++ b/tests/unit/iso18013-7.spec.js @@ -11,7 +11,7 @@ import {generateCertificateChain} from '../certUtils.js'; chai.should(); const {expect} = chai; -describe('ISO 18013-7', () => { +describe('OID4VP ISO 18013-7 Annex B', () => { it('should pass', async () => { // get device key pair const deviceKeyPair = await mdlUtils.generateDeviceKeyPair(); From 28a7d8e4597d38c853c66ccc63ac1590c457c28a Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sat, 18 Oct 2025 17:20:31 -0400 Subject: [PATCH 2/7] Reorganize code. --- lib/{convert.js => convert/index.js} | 2 +- lib/index.js | 2 +- lib/{ => oid4vp}/authorizationRequest.js | 2 +- lib/{ => oid4vp}/authorizationResponse.js | 4 ++-- lib/{oid4vp.js => oid4vp/index.js} | 4 ++-- lib/{ => oid4vp}/verifier.js | 2 +- lib/{ => oid4vp}/x509.js | 2 +- tests/unit/convert.spec.js | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) rename lib/{convert.js => convert/index.js} (99%) rename lib/{ => oid4vp}/authorizationRequest.js (99%) rename lib/{ => oid4vp}/authorizationResponse.js (98%) rename lib/{oid4vp.js => oid4vp/index.js} (90%) rename lib/{ => oid4vp}/verifier.js (98%) rename lib/{ => oid4vp}/x509.js (97%) diff --git a/lib/convert.js b/lib/convert/index.js similarity index 99% rename from lib/convert.js rename to lib/convert/index.js index e6644f7..088c918 100644 --- a/lib/convert.js +++ b/lib/convert/index.js @@ -5,7 +5,7 @@ import {JSONPath} from 'jsonpath-plus'; import jsonpointer from 'jsonpointer'; import { validate as validateAuthorizationRequest -} from './authorizationRequest.js'; +} from '../oid4vp/authorizationRequest.js'; // converts a VPR to partial "authorization request" export function fromVpr({ diff --git a/lib/index.js b/lib/index.js index faa81c4..06e839d 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,7 +1,7 @@ /*! * Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved. */ -export * as oid4vp from './oid4vp.js'; +export * as oid4vp from './oid4vp/index.js'; export { discoverIssuer, generateDIDProofJWT, diff --git a/lib/authorizationRequest.js b/lib/oid4vp/authorizationRequest.js similarity index 99% rename from lib/authorizationRequest.js rename to lib/oid4vp/authorizationRequest.js index ccdd347..f46fd10 100644 --- a/lib/authorizationRequest.js +++ b/lib/oid4vp/authorizationRequest.js @@ -3,7 +3,7 @@ */ import { assert, assertOptional, base64Encode, createNamedError, fetchJSON, selectJwk -} from './util.js'; +} from '../util.js'; import {decodeJwt, importX509, jwtVerify} from 'jose'; import { hasDomainSubjectAltName, parseCertificateChain, verifyCertificateChain diff --git a/lib/authorizationResponse.js b/lib/oid4vp/authorizationResponse.js similarity index 98% rename from lib/authorizationResponse.js rename to lib/oid4vp/authorizationResponse.js index 3853bf7..7e54290 100644 --- a/lib/authorizationResponse.js +++ b/lib/oid4vp/authorizationResponse.js @@ -1,11 +1,11 @@ /*! * Copyright (c) 2023-2025 Digital Bazaar, Inc. All rights reserved. */ -import {createNamedError, selectJwk} from './util.js'; +import {createNamedError, selectJwk} from '../util.js'; import {EncryptJWT} from 'jose'; import {httpClient} from '@digitalbazaar/http-client'; import jsonpointer from 'jsonpointer'; -import {pathsToVerifiableCredentialPointers} from './convert.js'; +import {pathsToVerifiableCredentialPointers} from '../convert/index.js'; const TEXT_ENCODER = new TextEncoder(); diff --git a/lib/oid4vp.js b/lib/oid4vp/index.js similarity index 90% rename from lib/oid4vp.js rename to lib/oid4vp/index.js index c83bcc8..f510e63 100644 --- a/lib/oid4vp.js +++ b/lib/oid4vp/index.js @@ -3,7 +3,7 @@ */ export * as authzRequest from './authorizationRequest.js'; export * as authzResponse from './authorizationResponse.js'; -export * as convert from './convert.js'; +export * as convert from '../convert/index.js'; export * as verifier from './verifier.js'; // backwards compatibility APIs @@ -18,7 +18,7 @@ export { fromVpr, toVpr, // exported for testing purposes only _fromQueryByExampleQuery -} from './convert.js'; +} from '../convert/index.js'; // Note: for examples of presentation request and responses, see: // eslint-disable-next-line max-len diff --git a/lib/verifier.js b/lib/oid4vp/verifier.js similarity index 98% rename from lib/verifier.js rename to lib/oid4vp/verifier.js index a9f8b06..df0b5ed 100644 --- a/lib/verifier.js +++ b/lib/oid4vp/verifier.js @@ -1,7 +1,7 @@ /*! * Copyright (c) 2023-2025 Digital Bazaar, Inc. All rights reserved. */ -import {createNamedError, selectJwk} from './util.js'; +import {createNamedError, selectJwk} from '../util.js'; import {importJWK, jwtDecrypt} from 'jose'; // parses (and decrypts) an authz response from a response body object diff --git a/lib/x509.js b/lib/oid4vp/x509.js similarity index 97% rename from lib/x509.js rename to lib/oid4vp/x509.js index 1554a3e..e90c5db 100644 --- a/lib/x509.js +++ b/lib/oid4vp/x509.js @@ -6,7 +6,7 @@ import { CertificateChainValidationEngine, id_SubjectAltName } from 'pkijs'; -import {base64Decode} from './util.js'; +import {base64Decode} from '../util.js'; export function fromPemOrBase64(str) { const tag = 'CERTIFICATE'; diff --git a/tests/unit/convert.spec.js b/tests/unit/convert.spec.js index 31c638f..e768583 100644 --- a/tests/unit/convert.spec.js +++ b/tests/unit/convert.spec.js @@ -1,7 +1,7 @@ /*! * Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved. */ -import {_fromQueryByExampleQuery} from '../../lib/oid4vp.js'; +import {_fromQueryByExampleQuery} from '../../lib/convert/index.js'; import chai from 'chai'; chai.should(); From e1dfa5463233d746260bbca91623c34c7b6e336f Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sat, 18 Oct 2025 17:24:22 -0400 Subject: [PATCH 3/7] Move discovery functions to their own file. --- lib/discovery.js | 126 +++++++++++++++++++++++++++++++++++++++++++++++ lib/index.js | 4 +- 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 lib/discovery.js diff --git a/lib/discovery.js b/lib/discovery.js new file mode 100644 index 0000000..bbc4fe6 --- /dev/null +++ b/lib/discovery.js @@ -0,0 +1,126 @@ +/*! + * Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved. + */ +import {assert, fetchJSON} from './util.js'; + +const WELL_KNOWN_REGEX = /\/\.well-known\/([^\/]+)/; + +export async function discoverIssuer({issuerConfigUrl, agent} = {}) { + try { + assert(issuerConfigUrl, 'issuerConfigUrl', 'string'); + + const response = await fetchJSON({url: issuerConfigUrl, agent}); + if(!response.data) { + const error = new Error('Issuer configuration format is not JSON.'); + error.name = 'DataError'; + throw error; + } + const {data: issuerMetaData} = response; + const {issuer, authorization_server} = issuerMetaData; + + if(authorization_server && authorization_server !== issuer) { + // not yet implemented + throw new Error('Separate authorization server not yet implemented.'); + } + + // validate `issuer` + if(!(typeof issuer === 'string' && issuer.startsWith('https://'))) { + const error = new Error('"issuer" is not an HTTPS URL.'); + error.name = 'DataError'; + throw error; + } + + // ensure `credential_issuer` matches `issuer`, if present + const {credential_issuer} = issuerMetaData; + if(credential_issuer !== undefined && credential_issuer !== issuer) { + const error = new Error('"credential_issuer" must match "issuer".'); + error.name = 'DataError'; + throw error; + } + + /* Validate `issuer` value against `issuerConfigUrl` (per RFC 8414): + + The `origin` and `path` element must be parsed from `issuer` and checked + against `issuerConfigUrl` like so: + + For issuer `` (no path), `issuerConfigUrl` must match: + `/.well-known/` + + For issuer ``, `issuerConfigUrl` must be: + `/.well-known/` */ + const {pathname: wellKnownPath} = new URL(issuerConfigUrl); + const anyPathSegment = wellKnownPath.match(WELL_KNOWN_REGEX)[1]; + const {origin, pathname} = new URL(issuer); + let expectedConfigUrl = `${origin}/.well-known/${anyPathSegment}`; + if(pathname !== '/') { + expectedConfigUrl += pathname; + } + if(issuerConfigUrl !== expectedConfigUrl) { + // alternatively, against RFC 8414, but according to OID4VCI, make sure + // the issuer config URL matches: + // /.well-known/ + expectedConfigUrl = origin; + if(pathname !== '/') { + expectedConfigUrl += pathname; + } + expectedConfigUrl += `/.well-known/${anyPathSegment}`; + if(issuerConfigUrl !== expectedConfigUrl) { + const error = new Error('"issuer" does not match configuration URL.'); + error.name = 'DataError'; + throw error; + } + } + + // fetch AS meta data + const asMetaDataUrl = + `${origin}/.well-known/oauth-authorization-server${pathname}`; + const asMetaDataResponse = await fetchJSON({url: asMetaDataUrl, agent}); + if(!asMetaDataResponse.data) { + const error = new Error('Authorization server meta data is not JSON.'); + error.name = 'DataError'; + throw error; + } + + const {data: asMetaData} = response; + // merge AS meta data into total issuer config + const issuerConfig = {...issuerMetaData, ...asMetaData}; + + // ensure `token_endpoint` is valid + const {token_endpoint} = asMetaData; + assert(token_endpoint, 'token_endpoint', 'string'); + + // return merged config and separate issuer and AS configs + const metadata = {issuer: issuerMetaData, authorizationServer: asMetaData}; + return {issuerConfig, metadata}; + } catch(cause) { + const error = new Error('Could not get OpenID issuer configuration.'); + error.name = 'OperationError'; + error.cause = cause; + throw error; + } +} + +export async function robustDiscoverIssuer({issuer, agent} = {}) { + // try issuer config URLs based on OID4VCI (first) and RFC 8414 (second) + const parsedIssuer = new URL(issuer); + const {origin} = parsedIssuer; + const path = parsedIssuer.pathname === '/' ? '' : parsedIssuer.pathname; + + const issuerConfigUrls = [ + // OID4VCI + `${origin}${path}/.well-known/openid-credential-issuer`, + // RFC 8414 + `${origin}/.well-known/openid-credential-issuer${path}` + ]; + + let error; + for(const issuerConfigUrl of issuerConfigUrls) { + try { + const config = await discoverIssuer({issuerConfigUrl, agent}); + return config; + } catch(e) { + error = e; + } + } + throw error; +} diff --git a/lib/index.js b/lib/index.js index 06e839d..f4f86e6 100644 --- a/lib/index.js +++ b/lib/index.js @@ -4,10 +4,12 @@ export * as oid4vp from './oid4vp/index.js'; export { discoverIssuer, + robustDiscoverIssuer +} from './discovery.js'; +export { generateDIDProofJWT, getCredentialOffer, parseCredentialOfferUrl, - robustDiscoverIssuer, signJWT, selectJwk } from './util.js'; From f0321ce47eb26906ec01213ff3fec4ec4a303cb1 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sat, 18 Oct 2025 17:28:46 -0400 Subject: [PATCH 4/7] Refactor offer/discovery code. --- lib/OID4Client.js | 5 +- lib/index.js | 8 +- lib/oid4vci/credentialOffer.js | 68 ++++++++++++ lib/{ => oid4vci}/discovery.js | 2 +- lib/util.js | 185 --------------------------------- 5 files changed, 77 insertions(+), 191 deletions(-) create mode 100644 lib/oid4vci/credentialOffer.js rename lib/{ => oid4vci}/discovery.js (98%) diff --git a/lib/OID4Client.js b/lib/OID4Client.js index 1e4b8a7..d1ec47a 100644 --- a/lib/OID4Client.js +++ b/lib/OID4Client.js @@ -1,8 +1,9 @@ /*! - * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved. */ -import {generateDIDProofJWT, robustDiscoverIssuer} from './util.js'; +import {generateDIDProofJWT} from './util.js'; import {httpClient} from '@digitalbazaar/http-client'; +import {robustDiscoverIssuer} from './oid4vci/discovery.js'; const GRANT_TYPES = new Map([ ['preAuthorizedCode', 'urn:ietf:params:oauth:grant-type:pre-authorized_code'] diff --git a/lib/index.js b/lib/index.js index f4f86e6..e680519 100644 --- a/lib/index.js +++ b/lib/index.js @@ -5,11 +5,13 @@ export * as oid4vp from './oid4vp/index.js'; export { discoverIssuer, robustDiscoverIssuer -} from './discovery.js'; +} from './oid4vci/discovery.js'; export { - generateDIDProofJWT, getCredentialOffer, - parseCredentialOfferUrl, + parseCredentialOfferUrl +} from './oid4vci/credentialOffer.js'; +export { + generateDIDProofJWT, signJWT, selectJwk } from './util.js'; diff --git a/lib/oid4vci/credentialOffer.js b/lib/oid4vci/credentialOffer.js new file mode 100644 index 0000000..af2785a --- /dev/null +++ b/lib/oid4vci/credentialOffer.js @@ -0,0 +1,68 @@ +/*! + * Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved. + */ +import {assert, fetchJSON} from '../util.js'; + +export async function getCredentialOffer({url, agent} = {}) { + const {protocol, searchParams} = new URL(url); + if(protocol !== 'openid-credential-offer:') { + throw new SyntaxError( + '"url" must express a URL with the ' + + '"openid-credential-offer" protocol.'); + } + const offer = searchParams.get('credential_offer'); + if(offer) { + return JSON.parse(offer); + } + + // try to fetch offer from URL + const offerUrl = searchParams.get('credential_offer_uri'); + if(!offerUrl) { + throw new SyntaxError( + 'OID4VCI credential offer must have "credential_offer" or ' + + '"credential_offer_uri".'); + } + + if(!offerUrl.startsWith('https://')) { + const error = new Error( + `"credential_offer_uri" (${offerUrl}) must start with "https://".`); + error.name = 'NotSupportedError'; + throw error; + } + + const response = await fetchJSON({url: offerUrl, agent}); + if(!response.data) { + const error = new Error( + `Credential offer fetched from "${offerUrl}" is not JSON.`); + error.name = 'DataError'; + throw error; + } + return response.data; +} + +export function parseCredentialOfferUrl({url} = {}) { + assert(url, 'url', 'string'); + + /* Parse URL, e.g.: + + 'openid-credential-offer://?' + + 'credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2F' + + 'localhost%3A18443%2Fexchangers%2Fz19t8xb568tNRD1zVm9R5diXR%2F' + + 'exchanges%2Fz1ADs3ur2s9tm6JUW6CnTiyn3%22%2C%22credentials' + + '%22%3A%5B%7B%22format%22%3A%22ldp_vc%22%2C%22credential_definition' + + '%22%3A%7B%22%40context%22%3A%5B%22https%3A%2F%2Fwww.w3.org%2F2018%2F' + + 'credentials%2Fv1%22%2C%22https%3A%2F%2Fwww.w3.org%2F2018%2F' + + 'credentials%2Fexamples%2Fv1%22%5D%2C%22type%22%3A%5B%22' + + 'VerifiableCredential%22%2C%22UniversityDegreeCredential' + + '%22%5D%7D%7D%5D%2C%22grants%22%3A%7B%22urn%3Aietf%3Aparams' + + '%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22' + + 'pre-authorized_code%22%3A%22z1AEvnk2cqeRM1Mfv75vzHSUo%22%7D%7D%7D'; + */ + const {protocol, searchParams} = new URL(url); + if(protocol !== 'openid-credential-offer:') { + throw new SyntaxError( + '"url" must express a URL with the ' + + '"openid-credential-offer" protocol.'); + } + return JSON.parse(searchParams.get('credential_offer')); +} diff --git a/lib/discovery.js b/lib/oid4vci/discovery.js similarity index 98% rename from lib/discovery.js rename to lib/oid4vci/discovery.js index bbc4fe6..11b061a 100644 --- a/lib/discovery.js +++ b/lib/oid4vci/discovery.js @@ -1,7 +1,7 @@ /*! * Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved. */ -import {assert, fetchJSON} from './util.js'; +import {assert, fetchJSON} from '../util.js'; const WELL_KNOWN_REGEX = /\/\.well-known\/([^\/]+)/; diff --git a/lib/util.js b/lib/util.js index 3b9c751..9552b26 100644 --- a/lib/util.js +++ b/lib/util.js @@ -6,7 +6,6 @@ import {httpClient} from '@digitalbazaar/http-client'; const TEXT_ENCODER = new TextEncoder(); const ENCODED_PERIOD = TEXT_ENCODER.encode('.'); -const WELL_KNOWN_REGEX = /\/\.well-known\/([^\/]+)/; export function assert(x, name, type, optional = false) { const article = type === 'object' ? 'an' : 'a'; @@ -46,101 +45,6 @@ export function createNamedError({message, name, details, cause} = {}) { return error; } -export async function discoverIssuer({issuerConfigUrl, agent} = {}) { - try { - assert(issuerConfigUrl, 'issuerConfigUrl', 'string'); - - const response = await fetchJSON({url: issuerConfigUrl, agent}); - if(!response.data) { - const error = new Error('Issuer configuration format is not JSON.'); - error.name = 'DataError'; - throw error; - } - const {data: issuerMetaData} = response; - const {issuer, authorization_server} = issuerMetaData; - - if(authorization_server && authorization_server !== issuer) { - // not yet implemented - throw new Error('Separate authorization server not yet implemented.'); - } - - // validate `issuer` - if(!(typeof issuer === 'string' && issuer.startsWith('https://'))) { - const error = new Error('"issuer" is not an HTTPS URL.'); - error.name = 'DataError'; - throw error; - } - - // ensure `credential_issuer` matches `issuer`, if present - const {credential_issuer} = issuerMetaData; - if(credential_issuer !== undefined && credential_issuer !== issuer) { - const error = new Error('"credential_issuer" must match "issuer".'); - error.name = 'DataError'; - throw error; - } - - /* Validate `issuer` value against `issuerConfigUrl` (per RFC 8414): - - The `origin` and `path` element must be parsed from `issuer` and checked - against `issuerConfigUrl` like so: - - For issuer `` (no path), `issuerConfigUrl` must match: - `/.well-known/` - - For issuer ``, `issuerConfigUrl` must be: - `/.well-known/` */ - const {pathname: wellKnownPath} = new URL(issuerConfigUrl); - const anyPathSegment = wellKnownPath.match(WELL_KNOWN_REGEX)[1]; - const {origin, pathname} = new URL(issuer); - let expectedConfigUrl = `${origin}/.well-known/${anyPathSegment}`; - if(pathname !== '/') { - expectedConfigUrl += pathname; - } - if(issuerConfigUrl !== expectedConfigUrl) { - // alternatively, against RFC 8414, but according to OID4VCI, make sure - // the issuer config URL matches: - // /.well-known/ - expectedConfigUrl = origin; - if(pathname !== '/') { - expectedConfigUrl += pathname; - } - expectedConfigUrl += `/.well-known/${anyPathSegment}`; - if(issuerConfigUrl !== expectedConfigUrl) { - const error = new Error('"issuer" does not match configuration URL.'); - error.name = 'DataError'; - throw error; - } - } - - // fetch AS meta data - const asMetaDataUrl = - `${origin}/.well-known/oauth-authorization-server${pathname}`; - const asMetaDataResponse = await fetchJSON({url: asMetaDataUrl, agent}); - if(!asMetaDataResponse.data) { - const error = new Error('Authorization server meta data is not JSON.'); - error.name = 'DataError'; - throw error; - } - - const {data: asMetaData} = response; - // merge AS meta data into total issuer config - const issuerConfig = {...issuerMetaData, ...asMetaData}; - - // ensure `token_endpoint` is valid - const {token_endpoint} = asMetaData; - assert(token_endpoint, 'token_endpoint', 'string'); - - // return merged config and separate issuer and AS configs - const metadata = {issuer: issuerMetaData, authorizationServer: asMetaData}; - return {issuerConfig, metadata}; - } catch(cause) { - const error = new Error('Could not get OpenID issuer configuration.'); - error.name = 'OperationError'; - error.cause = cause; - throw error; - } -} - export function fetchJSON({url, agent} = {}) { // allow these params to be passed / configured const fetchOptions = { @@ -187,95 +91,6 @@ export async function generateDIDProofJWT({ return signJWT({payload, protectedHeader, signer}); } -export async function getCredentialOffer({url, agent} = {}) { - const {protocol, searchParams} = new URL(url); - if(protocol !== 'openid-credential-offer:') { - throw new SyntaxError( - '"url" must express a URL with the ' + - '"openid-credential-offer" protocol.'); - } - const offer = searchParams.get('credential_offer'); - if(offer) { - return JSON.parse(offer); - } - - // try to fetch offer from URL - const offerUrl = searchParams.get('credential_offer_uri'); - if(!offerUrl) { - throw new SyntaxError( - 'OID4VCI credential offer must have "credential_offer" or ' + - '"credential_offer_uri".'); - } - - if(!offerUrl.startsWith('https://')) { - const error = new Error( - `"credential_offer_uri" (${offerUrl}) must start with "https://".`); - error.name = 'NotSupportedError'; - throw error; - } - - const response = await fetchJSON({url: offerUrl, agent}); - if(!response.data) { - const error = new Error( - `Credential offer fetched from "${offerUrl}" is not JSON.`); - error.name = 'DataError'; - throw error; - } - return response.data; -} - -export function parseCredentialOfferUrl({url} = {}) { - assert(url, 'url', 'string'); - - /* Parse URL, e.g.: - - 'openid-credential-offer://?' + - 'credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2F' + - 'localhost%3A18443%2Fexchangers%2Fz19t8xb568tNRD1zVm9R5diXR%2F' + - 'exchanges%2Fz1ADs3ur2s9tm6JUW6CnTiyn3%22%2C%22credentials' + - '%22%3A%5B%7B%22format%22%3A%22ldp_vc%22%2C%22credential_definition' + - '%22%3A%7B%22%40context%22%3A%5B%22https%3A%2F%2Fwww.w3.org%2F2018%2F' + - 'credentials%2Fv1%22%2C%22https%3A%2F%2Fwww.w3.org%2F2018%2F' + - 'credentials%2Fexamples%2Fv1%22%5D%2C%22type%22%3A%5B%22' + - 'VerifiableCredential%22%2C%22UniversityDegreeCredential' + - '%22%5D%7D%7D%5D%2C%22grants%22%3A%7B%22urn%3Aietf%3Aparams' + - '%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22' + - 'pre-authorized_code%22%3A%22z1AEvnk2cqeRM1Mfv75vzHSUo%22%7D%7D%7D'; - */ - const {protocol, searchParams} = new URL(url); - if(protocol !== 'openid-credential-offer:') { - throw new SyntaxError( - '"url" must express a URL with the ' + - '"openid-credential-offer" protocol.'); - } - return JSON.parse(searchParams.get('credential_offer')); -} - -export async function robustDiscoverIssuer({issuer, agent} = {}) { - // try issuer config URLs based on OID4VCI (first) and RFC 8414 (second) - const parsedIssuer = new URL(issuer); - const {origin} = parsedIssuer; - const path = parsedIssuer.pathname === '/' ? '' : parsedIssuer.pathname; - - const issuerConfigUrls = [ - // OID4VCI - `${origin}${path}/.well-known/openid-credential-issuer`, - // RFC 8414 - `${origin}/.well-known/openid-credential-issuer${path}` - ]; - - let error; - for(const issuerConfigUrl of issuerConfigUrls) { - try { - const config = await discoverIssuer({issuerConfigUrl, agent}); - return config; - } catch(e) { - error = e; - } - } - throw error; -} - export function selectJwk({keys, kid, alg, kty, crv, use} = {}) { /* Example JWKs "keys": "jwks": { From 7e848a77820c05e0b6938894104399ac2ad32f81 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sat, 18 Oct 2025 17:35:35 -0400 Subject: [PATCH 5/7] Move OID4VCI proof code to its own file. --- lib/OID4Client.js | 2 +- lib/index.js | 2 +- lib/oid4vci/proofs.js | 50 +++++++++++++++++++++++++++++++++++++++++++ lib/util.js | 46 --------------------------------------- 4 files changed, 52 insertions(+), 48 deletions(-) create mode 100644 lib/oid4vci/proofs.js diff --git a/lib/OID4Client.js b/lib/OID4Client.js index d1ec47a..5347d86 100644 --- a/lib/OID4Client.js +++ b/lib/OID4Client.js @@ -1,7 +1,7 @@ /*! * Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved. */ -import {generateDIDProofJWT} from './util.js'; +import {generateDIDProofJWT} from './oid4vci/proofs.js'; import {httpClient} from '@digitalbazaar/http-client'; import {robustDiscoverIssuer} from './oid4vci/discovery.js'; diff --git a/lib/index.js b/lib/index.js index e680519..428e597 100644 --- a/lib/index.js +++ b/lib/index.js @@ -11,8 +11,8 @@ export { parseCredentialOfferUrl } from './oid4vci/credentialOffer.js'; export { - generateDIDProofJWT, signJWT, selectJwk } from './util.js'; +export {generateDIDProofJWT} from './oid4vci/proofs.js'; export {OID4Client} from './OID4Client.js'; diff --git a/lib/oid4vci/proofs.js b/lib/oid4vci/proofs.js new file mode 100644 index 0000000..4a37de7 --- /dev/null +++ b/lib/oid4vci/proofs.js @@ -0,0 +1,50 @@ +/*! + * Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved. + */ +import {signJWT} from '../util.js'; + +export async function generateDIDProofJWT({ + signer, nonce, iss, aud, exp, nbf +} = {}) { + /* Example: + { + "alg": "ES256", + "kid":"did:example:ebfeb1f712ebc6f1c276e12ec21/keys/1" + }. + { + "iss": "s6BhdRkqt3", + "aud": "https://server.example.com", + "iat": 1659145924, + "nonce": "tZignsnFbp" + } + */ + + if(exp === undefined) { + // default to 5 minute expiration time + exp = Math.floor(Date.now() / 1000) + 60 * 5; + } + if(nbf === undefined) { + // default to now + nbf = Math.floor(Date.now() / 1000); + } + + const {id: kid} = signer; + const alg = _curveToAlg(signer.algorithm); + const payload = {nonce, iss, aud, exp, nbf}; + const protectedHeader = {alg, kid}; + + return signJWT({payload, protectedHeader, signer}); +} + +function _curveToAlg(crv) { + if(crv === 'Ed25519' || crv === 'Ed448') { + return 'EdDSA'; + } + if(crv?.startsWith('P-')) { + return `ES${crv.slice(2)}`; + } + if(crv === 'secp256k1') { + return 'ES256K'; + } + return crv; +} diff --git a/lib/util.js b/lib/util.js index 9552b26..598fb0e 100644 --- a/lib/util.js +++ b/lib/util.js @@ -58,39 +58,6 @@ export function fetchJSON({url, agent} = {}) { return httpClient.get(url, fetchOptions); } -export async function generateDIDProofJWT({ - signer, nonce, iss, aud, exp, nbf -} = {}) { - /* Example: - { - "alg": "ES256", - "kid":"did:example:ebfeb1f712ebc6f1c276e12ec21/keys/1" - }. - { - "iss": "s6BhdRkqt3", - "aud": "https://server.example.com", - "iat": 1659145924, - "nonce": "tZignsnFbp" - } - */ - - if(exp === undefined) { - // default to 5 minute expiration time - exp = Math.floor(Date.now() / 1000) + 60 * 5; - } - if(nbf === undefined) { - // default to now - nbf = Math.floor(Date.now() / 1000); - } - - const {id: kid} = signer; - const alg = _curveToAlg(signer.algorithm); - const payload = {nonce, iss, aud, exp, nbf}; - const protectedHeader = {alg, kid}; - - return signJWT({payload, protectedHeader, signer}); -} - export function selectJwk({keys, kid, alg, kty, crv, use} = {}) { /* Example JWKs "keys": "jwks": { @@ -161,16 +128,3 @@ export async function signJWT({payload, protectedHeader, signer} = {}) { // create compact JWT return `${jws.protected}.${jws.payload}.${jws.signature}`; } - -function _curveToAlg(crv) { - if(crv === 'Ed25519' || crv === 'Ed448') { - return 'EdDSA'; - } - if(crv?.startsWith('P-')) { - return `ES${crv.slice(2)}`; - } - if(crv === 'secp256k1') { - return 'ES256K'; - } - return crv; -} From 1e4192ca9785c2e1b3d75215a3142569d1f6fa3f Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sat, 18 Oct 2025 17:39:30 -0400 Subject: [PATCH 6/7] Move credential offer utilities into `credentialOffer.js`. --- lib/OID4Client.js | 75 ++-------------------------------- lib/oid4vci/credentialOffer.js | 70 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 72 deletions(-) diff --git a/lib/OID4Client.js b/lib/OID4Client.js index 5347d86..5ace512 100644 --- a/lib/OID4Client.js +++ b/lib/OID4Client.js @@ -1,6 +1,7 @@ /*! * Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved. */ +import {createCredentialRequestsFromOffer} from './oid4vci/credentialOffer.js'; import {generateDIDProofJWT} from './oid4vci/proofs.js'; import {httpClient} from '@digitalbazaar/http-client'; import {robustDiscoverIssuer} from './oid4vci/discovery.js'; @@ -67,7 +68,7 @@ export class OID4Client { if(!offer) { throw new TypeError('"credentialDefinition" must be an object.'); } - requests = _createCredentialRequestsFromOffer({ + requests = createCredentialRequestsFromOffer({ issuerConfig, offer, format }); if(requests.length > 1) { @@ -98,7 +99,7 @@ export class OID4Client { const {issuerConfig, offer} = this; if(requests === undefined && offer) { - requests = _createCredentialRequestsFromOffer({ + requests = createCredentialRequestsFromOffer({ issuerConfig, offer, format }); } else if(!(Array.isArray(requests) && requests.length > 0)) { @@ -465,73 +466,3 @@ function _isPresentationRequired(error) { const errorType = error.data?.error; return error.status === 400 && errorType === 'presentation_required'; } - -function _createCredentialRequestsFromOffer({ - issuerConfig, offer, format -}) { - // get any supported credential configurations from issuer config - const supported = _createSupportedCredentialsMap({issuerConfig}); - - // build requests from credentials identified in `offer` and remove any - // that do not match the given format - const credentials = offer.credential_configuration_ids ?? offer.credentials; - const requests = credentials.map(c => { - if(typeof c === 'string') { - // use supported credential config - return _getSupportedCredentialById({id: c, supported}); - } - return c; - }).filter(r => r.format === format); - - if(requests.length === 0) { - throw new Error( - `No supported credential(s) with format "${format}" found.`); - } - - return requests; -} - -function _createSupportedCredentialsMap({issuerConfig}) { - const { - credential_configurations_supported, - credentials_supported - } = issuerConfig; - - let supported; - if(credential_configurations_supported && - typeof credential_configurations_supported === 'object') { - supported = new Map(Object.entries( - issuerConfig.credential_configurations_supported)); - } else if(Array.isArray(credentials_supported)) { - // handle legacy `credentials_supported` array - supported = new Map(); - for(const entry of issuerConfig.credentials_supported) { - supported.set(entry.id, entry); - } - } else { - // no supported credentials from issuer config - supported = new Map(); - } - - return supported; -} - -function _getSupportedCredentialById({id, supported}) { - const meta = supported.get(id); - if(!meta) { - throw new Error(`No supported credential "${id}" found.`); - } - const {format, credential_definition} = meta; - if(typeof format !== 'string') { - throw new Error( - `Invalid supported credential "${id}"; "format" not specified.`); - } - if(!(Array.isArray(credential_definition?.['@context']) && - (Array.isArray(credential_definition?.types) || - Array.isArray(credential_definition?.type)))) { - throw new Error( - `Invalid supported credential "${id}"; "credential_definition" not ` + - 'fully specified.'); - } - return {format, credential_definition}; -} diff --git a/lib/oid4vci/credentialOffer.js b/lib/oid4vci/credentialOffer.js index af2785a..60b7226 100644 --- a/lib/oid4vci/credentialOffer.js +++ b/lib/oid4vci/credentialOffer.js @@ -3,6 +3,31 @@ */ import {assert, fetchJSON} from '../util.js'; +export function createCredentialRequestsFromOffer({ + issuerConfig, offer, format +} = {}) { + // get any supported credential configurations from issuer config + const supported = _createSupportedCredentialsMap({issuerConfig}); + + // build requests from credentials identified in `offer` and remove any + // that do not match the given format + const credentials = offer.credential_configuration_ids ?? offer.credentials; + const requests = credentials.map(c => { + if(typeof c === 'string') { + // use supported credential config + return _getSupportedCredentialById({id: c, supported}); + } + return c; + }).filter(r => r.format === format); + + if(requests.length === 0) { + throw new Error( + `No supported credential(s) with format "${format}" found.`); + } + + return requests; +} + export async function getCredentialOffer({url, agent} = {}) { const {protocol, searchParams} = new URL(url); if(protocol !== 'openid-credential-offer:') { @@ -66,3 +91,48 @@ export function parseCredentialOfferUrl({url} = {}) { } return JSON.parse(searchParams.get('credential_offer')); } + +function _createSupportedCredentialsMap({issuerConfig}) { + const { + credential_configurations_supported, + credentials_supported + } = issuerConfig; + + let supported; + if(credential_configurations_supported && + typeof credential_configurations_supported === 'object') { + supported = new Map(Object.entries( + issuerConfig.credential_configurations_supported)); + } else if(Array.isArray(credentials_supported)) { + // handle legacy `credentials_supported` array + supported = new Map(); + for(const entry of issuerConfig.credentials_supported) { + supported.set(entry.id, entry); + } + } else { + // no supported credentials from issuer config + supported = new Map(); + } + + return supported; +} + +function _getSupportedCredentialById({id, supported}) { + const meta = supported.get(id); + if(!meta) { + throw new Error(`No supported credential "${id}" found.`); + } + const {format, credential_definition} = meta; + if(typeof format !== 'string') { + throw new Error( + `Invalid supported credential "${id}"; "format" not specified.`); + } + if(!(Array.isArray(credential_definition?.['@context']) && + (Array.isArray(credential_definition?.types) || + Array.isArray(credential_definition?.type)))) { + throw new Error( + `Invalid supported credential "${id}"; "credential_definition" not ` + + 'fully specified.'); + } + return {format, credential_definition}; +} From 21717c34ec30386df76cc51a70a624e1330568a4 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sat, 18 Oct 2025 17:46:20 -0400 Subject: [PATCH 7/7] Reorganize utility functions. --- lib/oid4vp/authorizationRequest.js | 13 +++---------- lib/oid4vp/verifier.js | 19 +++---------------- lib/util.js | 21 +++++++++++++++++++++ 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/lib/oid4vp/authorizationRequest.js b/lib/oid4vp/authorizationRequest.js index f46fd10..3df86c0 100644 --- a/lib/oid4vp/authorizationRequest.js +++ b/lib/oid4vp/authorizationRequest.js @@ -2,7 +2,8 @@ * Copyright (c) 2023-2025 Digital Bazaar, Inc. All rights reserved. */ import { - assert, assertOptional, base64Encode, createNamedError, fetchJSON, selectJwk + assert, assertOptional, base64Encode, + createNamedError, fetchJSON, selectJwk, sha256 } from '../util.js'; import {decodeJwt, importX509, jwtVerify} from 'jose'; import { @@ -269,7 +270,7 @@ async function _checkClientIdSchemeRequirements({ and it includes the client ID. */ } else if(clientIdScheme === 'x509_hash') { // `x509_hash:` - const hash = base64Encode(await _sha256(chain[0].toBER())); + const hash = base64Encode(await sha256(chain[0].toBER())); if(clientId !== hash) { throw createNamedError({ message: @@ -444,14 +445,6 @@ function _parseOID4VPUrl({url}) { return {authorizationRequest}; } -async function _sha256(data) { - if(typeof data === 'string') { - data = new TextEncoder().encode(data); - } - const algorithm = {name: 'SHA-256'}; - return new Uint8Array(await crypto.subtle.digest(algorithm, data)); -} - function _throwKeyNotFound(protectedHeader) { const error = new Error( 'Could not verify signed authorization request; ' + diff --git a/lib/oid4vp/verifier.js b/lib/oid4vp/verifier.js index df0b5ed..dbb4b9f 100644 --- a/lib/oid4vp/verifier.js +++ b/lib/oid4vp/verifier.js @@ -1,7 +1,7 @@ /*! * Copyright (c) 2023-2025 Digital Bazaar, Inc. All rights reserved. */ -import {createNamedError, selectJwk} from '../util.js'; +import {createNamedError, parseJSON, selectJwk} from '../util.js'; import {importJWK, jwtDecrypt} from 'jose'; // parses (and decrypts) an authz response from a response body object @@ -31,7 +31,7 @@ export async function parseAuthorizationResponse({ responseMode = 'direct_post'; _assertSupportedResponseMode({responseMode, supportedResponseModes}); payload = body; - parsed.presentationSubmission = _jsonParse( + parsed.presentationSubmission = parseJSON( payload.presentation_submission, 'presentation_submission'); } @@ -46,7 +46,7 @@ export async function parseAuthorizationResponse({ if(typeof vp_token === 'string' && (vp_token.startsWith('{') || vp_token.startsWith('[') || vp_token.startsWith('"'))) { - parsed.vpToken = _jsonParse(vp_token, 'vp_token'); + parsed.vpToken = parseJSON(vp_token, 'vp_token'); } else { parsed.vpToken = vp_token; } @@ -100,16 +100,3 @@ async function _decrypt({jwt, getDecryptParameters}) { keyManagementAlgorithms: ['ECDH-ES'] }); } - -function _jsonParse(x, name) { - try { - return JSON.parse(x); - } catch(cause) { - throw createNamedError({ - message: `Could not parse "${name}".`, - name: 'DataError', - details: {httpStatusCode: 400, public: true}, - cause - }); - } -} diff --git a/lib/util.js b/lib/util.js index 598fb0e..90ebacd 100644 --- a/lib/util.js +++ b/lib/util.js @@ -58,6 +58,19 @@ export function fetchJSON({url, agent} = {}) { return httpClient.get(url, fetchOptions); } +export function parseJSON(x, name) { + try { + return JSON.parse(x); + } catch(cause) { + throw createNamedError({ + message: `Could not parse "${name}".`, + name: 'DataError', + details: {httpStatusCode: 400, public: true}, + cause + }); + } +} + export function selectJwk({keys, kid, alg, kty, crv, use} = {}) { /* Example JWKs "keys": "jwks": { @@ -101,6 +114,14 @@ export function selectJwk({keys, kid, alg, kty, crv, use} = {}) { }); } +export async function sha256(data) { + if(typeof data === 'string') { + data = new TextEncoder().encode(data); + } + const algorithm = {name: 'SHA-256'}; + return new Uint8Array(await crypto.subtle.digest(algorithm, data)); +} + export async function signJWT({payload, protectedHeader, signer} = {}) { // encode payload and protected header const b64Payload = base64url.encode(JSON.stringify(payload));