@digitalbazaar/oid4-client 5.1.0 → 5.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/OID4Client.js +6 -74
- package/lib/convert/index.js +349 -0
- package/lib/index.js +9 -4
- package/lib/oid4vci/credentialOffer.js +138 -0
- package/lib/oid4vci/discovery.js +126 -0
- package/lib/oid4vci/proofs.js +50 -0
- package/lib/{authorizationRequest.js → oid4vp/authorizationRequest.js} +4 -11
- package/lib/{authorizationResponse.js → oid4vp/authorizationResponse.js} +4 -4
- package/lib/{oid4vp.js → oid4vp/index.js} +2 -6
- package/lib/{verifier.js → oid4vp/verifier.js} +3 -16
- package/lib/{x509.js → oid4vp/x509.js} +1 -1
- package/lib/query/dcql.js +244 -0
- package/lib/query/index.js +18 -0
- package/lib/query/match.js +80 -0
- package/lib/query/presentationExchange.js +318 -0
- package/lib/query/queryByExample.js +8 -0
- package/lib/query/util.js +105 -0
- package/lib/util.js +22 -230
- package/package.json +2 -2
- package/lib/convert.js +0 -430
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import {assert, fetchJSON} from '../util.js';
|
|
5
|
+
|
|
6
|
+
const WELL_KNOWN_REGEX = /\/\.well-known\/([^\/]+)/;
|
|
7
|
+
|
|
8
|
+
export async function discoverIssuer({issuerConfigUrl, agent} = {}) {
|
|
9
|
+
try {
|
|
10
|
+
assert(issuerConfigUrl, 'issuerConfigUrl', 'string');
|
|
11
|
+
|
|
12
|
+
const response = await fetchJSON({url: issuerConfigUrl, agent});
|
|
13
|
+
if(!response.data) {
|
|
14
|
+
const error = new Error('Issuer configuration format is not JSON.');
|
|
15
|
+
error.name = 'DataError';
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
const {data: issuerMetaData} = response;
|
|
19
|
+
const {issuer, authorization_server} = issuerMetaData;
|
|
20
|
+
|
|
21
|
+
if(authorization_server && authorization_server !== issuer) {
|
|
22
|
+
// not yet implemented
|
|
23
|
+
throw new Error('Separate authorization server not yet implemented.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// validate `issuer`
|
|
27
|
+
if(!(typeof issuer === 'string' && issuer.startsWith('https://'))) {
|
|
28
|
+
const error = new Error('"issuer" is not an HTTPS URL.');
|
|
29
|
+
error.name = 'DataError';
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ensure `credential_issuer` matches `issuer`, if present
|
|
34
|
+
const {credential_issuer} = issuerMetaData;
|
|
35
|
+
if(credential_issuer !== undefined && credential_issuer !== issuer) {
|
|
36
|
+
const error = new Error('"credential_issuer" must match "issuer".');
|
|
37
|
+
error.name = 'DataError';
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Validate `issuer` value against `issuerConfigUrl` (per RFC 8414):
|
|
42
|
+
|
|
43
|
+
The `origin` and `path` element must be parsed from `issuer` and checked
|
|
44
|
+
against `issuerConfigUrl` like so:
|
|
45
|
+
|
|
46
|
+
For issuer `<origin>` (no path), `issuerConfigUrl` must match:
|
|
47
|
+
`<origin>/.well-known/<any-path-segment>`
|
|
48
|
+
|
|
49
|
+
For issuer `<origin><path>`, `issuerConfigUrl` must be:
|
|
50
|
+
`<origin>/.well-known/<any-path-segment><path>` */
|
|
51
|
+
const {pathname: wellKnownPath} = new URL(issuerConfigUrl);
|
|
52
|
+
const anyPathSegment = wellKnownPath.match(WELL_KNOWN_REGEX)[1];
|
|
53
|
+
const {origin, pathname} = new URL(issuer);
|
|
54
|
+
let expectedConfigUrl = `${origin}/.well-known/${anyPathSegment}`;
|
|
55
|
+
if(pathname !== '/') {
|
|
56
|
+
expectedConfigUrl += pathname;
|
|
57
|
+
}
|
|
58
|
+
if(issuerConfigUrl !== expectedConfigUrl) {
|
|
59
|
+
// alternatively, against RFC 8414, but according to OID4VCI, make sure
|
|
60
|
+
// the issuer config URL matches:
|
|
61
|
+
// <origin><path>/.well-known/<any-path-segment>
|
|
62
|
+
expectedConfigUrl = origin;
|
|
63
|
+
if(pathname !== '/') {
|
|
64
|
+
expectedConfigUrl += pathname;
|
|
65
|
+
}
|
|
66
|
+
expectedConfigUrl += `/.well-known/${anyPathSegment}`;
|
|
67
|
+
if(issuerConfigUrl !== expectedConfigUrl) {
|
|
68
|
+
const error = new Error('"issuer" does not match configuration URL.');
|
|
69
|
+
error.name = 'DataError';
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// fetch AS meta data
|
|
75
|
+
const asMetaDataUrl =
|
|
76
|
+
`${origin}/.well-known/oauth-authorization-server${pathname}`;
|
|
77
|
+
const asMetaDataResponse = await fetchJSON({url: asMetaDataUrl, agent});
|
|
78
|
+
if(!asMetaDataResponse.data) {
|
|
79
|
+
const error = new Error('Authorization server meta data is not JSON.');
|
|
80
|
+
error.name = 'DataError';
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const {data: asMetaData} = response;
|
|
85
|
+
// merge AS meta data into total issuer config
|
|
86
|
+
const issuerConfig = {...issuerMetaData, ...asMetaData};
|
|
87
|
+
|
|
88
|
+
// ensure `token_endpoint` is valid
|
|
89
|
+
const {token_endpoint} = asMetaData;
|
|
90
|
+
assert(token_endpoint, 'token_endpoint', 'string');
|
|
91
|
+
|
|
92
|
+
// return merged config and separate issuer and AS configs
|
|
93
|
+
const metadata = {issuer: issuerMetaData, authorizationServer: asMetaData};
|
|
94
|
+
return {issuerConfig, metadata};
|
|
95
|
+
} catch(cause) {
|
|
96
|
+
const error = new Error('Could not get OpenID issuer configuration.');
|
|
97
|
+
error.name = 'OperationError';
|
|
98
|
+
error.cause = cause;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function robustDiscoverIssuer({issuer, agent} = {}) {
|
|
104
|
+
// try issuer config URLs based on OID4VCI (first) and RFC 8414 (second)
|
|
105
|
+
const parsedIssuer = new URL(issuer);
|
|
106
|
+
const {origin} = parsedIssuer;
|
|
107
|
+
const path = parsedIssuer.pathname === '/' ? '' : parsedIssuer.pathname;
|
|
108
|
+
|
|
109
|
+
const issuerConfigUrls = [
|
|
110
|
+
// OID4VCI
|
|
111
|
+
`${origin}${path}/.well-known/openid-credential-issuer`,
|
|
112
|
+
// RFC 8414
|
|
113
|
+
`${origin}/.well-known/openid-credential-issuer${path}`
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
let error;
|
|
117
|
+
for(const issuerConfigUrl of issuerConfigUrls) {
|
|
118
|
+
try {
|
|
119
|
+
const config = await discoverIssuer({issuerConfigUrl, agent});
|
|
120
|
+
return config;
|
|
121
|
+
} catch(e) {
|
|
122
|
+
error = e;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import {signJWT} from '../util.js';
|
|
5
|
+
|
|
6
|
+
export async function generateDIDProofJWT({
|
|
7
|
+
signer, nonce, iss, aud, exp, nbf
|
|
8
|
+
} = {}) {
|
|
9
|
+
/* Example:
|
|
10
|
+
{
|
|
11
|
+
"alg": "ES256",
|
|
12
|
+
"kid":"did:example:ebfeb1f712ebc6f1c276e12ec21/keys/1"
|
|
13
|
+
}.
|
|
14
|
+
{
|
|
15
|
+
"iss": "s6BhdRkqt3",
|
|
16
|
+
"aud": "https://server.example.com",
|
|
17
|
+
"iat": 1659145924,
|
|
18
|
+
"nonce": "tZignsnFbp"
|
|
19
|
+
}
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
if(exp === undefined) {
|
|
23
|
+
// default to 5 minute expiration time
|
|
24
|
+
exp = Math.floor(Date.now() / 1000) + 60 * 5;
|
|
25
|
+
}
|
|
26
|
+
if(nbf === undefined) {
|
|
27
|
+
// default to now
|
|
28
|
+
nbf = Math.floor(Date.now() / 1000);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const {id: kid} = signer;
|
|
32
|
+
const alg = _curveToAlg(signer.algorithm);
|
|
33
|
+
const payload = {nonce, iss, aud, exp, nbf};
|
|
34
|
+
const protectedHeader = {alg, kid};
|
|
35
|
+
|
|
36
|
+
return signJWT({payload, protectedHeader, signer});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _curveToAlg(crv) {
|
|
40
|
+
if(crv === 'Ed25519' || crv === 'Ed448') {
|
|
41
|
+
return 'EdDSA';
|
|
42
|
+
}
|
|
43
|
+
if(crv?.startsWith('P-')) {
|
|
44
|
+
return `ES${crv.slice(2)}`;
|
|
45
|
+
}
|
|
46
|
+
if(crv === 'secp256k1') {
|
|
47
|
+
return 'ES256K';
|
|
48
|
+
}
|
|
49
|
+
return crv;
|
|
50
|
+
}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* Copyright (c) 2023-2025 Digital Bazaar, Inc. All rights reserved.
|
|
3
3
|
*/
|
|
4
4
|
import {
|
|
5
|
-
assert, assertOptional, base64Encode,
|
|
6
|
-
|
|
5
|
+
assert, assertOptional, base64Encode,
|
|
6
|
+
createNamedError, fetchJSON, selectJwk, sha256
|
|
7
|
+
} from '../util.js';
|
|
7
8
|
import {decodeJwt, importX509, jwtVerify} from 'jose';
|
|
8
9
|
import {
|
|
9
10
|
hasDomainSubjectAltName, parseCertificateChain, verifyCertificateChain
|
|
@@ -269,7 +270,7 @@ async function _checkClientIdSchemeRequirements({
|
|
|
269
270
|
and it includes the client ID. */
|
|
270
271
|
} else if(clientIdScheme === 'x509_hash') {
|
|
271
272
|
// `x509_hash:<base64url sha256-hash of DER leaf cert>`
|
|
272
|
-
const hash = base64Encode(await
|
|
273
|
+
const hash = base64Encode(await sha256(chain[0].toBER()));
|
|
273
274
|
if(clientId !== hash) {
|
|
274
275
|
throw createNamedError({
|
|
275
276
|
message:
|
|
@@ -444,14 +445,6 @@ function _parseOID4VPUrl({url}) {
|
|
|
444
445
|
return {authorizationRequest};
|
|
445
446
|
}
|
|
446
447
|
|
|
447
|
-
async function _sha256(data) {
|
|
448
|
-
if(typeof data === 'string') {
|
|
449
|
-
data = new TextEncoder().encode(data);
|
|
450
|
-
}
|
|
451
|
-
const algorithm = {name: 'SHA-256'};
|
|
452
|
-
return new Uint8Array(await crypto.subtle.digest(algorithm, data));
|
|
453
|
-
}
|
|
454
|
-
|
|
455
448
|
function _throwKeyNotFound(protectedHeader) {
|
|
456
449
|
const error = new Error(
|
|
457
450
|
'Could not verify signed authorization request; ' +
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/*!
|
|
2
2
|
* Copyright (c) 2023-2025 Digital Bazaar, Inc. All rights reserved.
|
|
3
3
|
*/
|
|
4
|
-
import {createNamedError, selectJwk} from '
|
|
4
|
+
import {createNamedError, selectJwk} from '../util.js';
|
|
5
5
|
import {EncryptJWT} from 'jose';
|
|
6
6
|
import {httpClient} from '@digitalbazaar/http-client';
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
7
|
+
import {pathsToVerifiableCredentialPointers} from '../convert/index.js';
|
|
8
|
+
import {resolvePointer} from '../query/util.js';
|
|
9
9
|
|
|
10
10
|
const TEXT_ENCODER = new TextEncoder();
|
|
11
11
|
|
|
@@ -340,7 +340,7 @@ function _matchesInputDescriptor({
|
|
|
340
340
|
|
|
341
341
|
// check for a value at at least one path
|
|
342
342
|
for(const pointer of pointers) {
|
|
343
|
-
const existing =
|
|
343
|
+
const existing = resolvePointer(verifiableCredential, pointer);
|
|
344
344
|
if(existing === undefined) {
|
|
345
345
|
// VC does not match
|
|
346
346
|
return false;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export * as authzRequest from './authorizationRequest.js';
|
|
5
5
|
export * as authzResponse from './authorizationResponse.js';
|
|
6
|
-
export * as convert from '
|
|
6
|
+
export * as convert from '../convert/index.js';
|
|
7
7
|
export * as verifier from './verifier.js';
|
|
8
8
|
|
|
9
9
|
// backwards compatibility APIs
|
|
@@ -14,11 +14,7 @@ export {
|
|
|
14
14
|
createPresentationSubmission,
|
|
15
15
|
send as sendAuthorizationResponse
|
|
16
16
|
} from './authorizationResponse.js';
|
|
17
|
-
export {
|
|
18
|
-
fromVpr, toVpr,
|
|
19
|
-
// exported for testing purposes only
|
|
20
|
-
_fromQueryByExampleQuery
|
|
21
|
-
} from './convert.js';
|
|
17
|
+
export {fromVpr, toVpr} from '../convert/index.js';
|
|
22
18
|
|
|
23
19
|
// Note: for examples of presentation request and responses, see:
|
|
24
20
|
// eslint-disable-next-line max-len
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/*!
|
|
2
2
|
* Copyright (c) 2023-2025 Digital Bazaar, Inc. All rights reserved.
|
|
3
3
|
*/
|
|
4
|
-
import {createNamedError, selectJwk} from '
|
|
4
|
+
import {createNamedError, parseJSON, selectJwk} from '../util.js';
|
|
5
5
|
import {importJWK, jwtDecrypt} from 'jose';
|
|
6
6
|
|
|
7
7
|
// parses (and decrypts) an authz response from a response body object
|
|
@@ -31,7 +31,7 @@ export async function parseAuthorizationResponse({
|
|
|
31
31
|
responseMode = 'direct_post';
|
|
32
32
|
_assertSupportedResponseMode({responseMode, supportedResponseModes});
|
|
33
33
|
payload = body;
|
|
34
|
-
parsed.presentationSubmission =
|
|
34
|
+
parsed.presentationSubmission = parseJSON(
|
|
35
35
|
payload.presentation_submission, 'presentation_submission');
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -46,7 +46,7 @@ export async function parseAuthorizationResponse({
|
|
|
46
46
|
if(typeof vp_token === 'string' &&
|
|
47
47
|
(vp_token.startsWith('{') || vp_token.startsWith('[') ||
|
|
48
48
|
vp_token.startsWith('"'))) {
|
|
49
|
-
parsed.vpToken =
|
|
49
|
+
parsed.vpToken = parseJSON(vp_token, 'vp_token');
|
|
50
50
|
} else {
|
|
51
51
|
parsed.vpToken = vp_token;
|
|
52
52
|
}
|
|
@@ -100,16 +100,3 @@ async function _decrypt({jwt, getDecryptParameters}) {
|
|
|
100
100
|
keyManagementAlgorithms: ['ECDH-ES']
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
|
-
|
|
104
|
-
function _jsonParse(x, name) {
|
|
105
|
-
try {
|
|
106
|
-
return JSON.parse(x);
|
|
107
|
-
} catch(cause) {
|
|
108
|
-
throw createNamedError({
|
|
109
|
-
message: `Could not parse "${name}".`,
|
|
110
|
-
name: 'DataError',
|
|
111
|
-
details: {httpStatusCode: 400, public: true},
|
|
112
|
-
cause
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
fromJsonPointerMap, isNumber, toJsonPointerMap, toNumberIfNumber
|
|
6
|
+
} from './util.js';
|
|
7
|
+
import {exampleToJsonPointerMap} from './queryByExample.js';
|
|
8
|
+
import jsonpointer from 'json-pointer';
|
|
9
|
+
|
|
10
|
+
const MDOC_MDL = 'org.iso.18013.5.1.mDL';
|
|
11
|
+
|
|
12
|
+
export function dcqlQueryToVprGroups({dcql_query} = {}) {
|
|
13
|
+
const {credentials = []} = dcql_query;
|
|
14
|
+
let {credential_sets: credentialSets} = dcql_query;
|
|
15
|
+
if(!credentialSets) {
|
|
16
|
+
credentialSets = [{
|
|
17
|
+
options: [credentials.map(q => q.id)]
|
|
18
|
+
}];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// convert `credentials` into a map based on `ID`
|
|
22
|
+
const credentialQueryMap = new Map(credentials.map(
|
|
23
|
+
query => [query.id, query]));
|
|
24
|
+
|
|
25
|
+
// output group map
|
|
26
|
+
const groupMap = new Map();
|
|
27
|
+
for(const credentialSet of credentialSets) {
|
|
28
|
+
for(const option of credentialSet.options) {
|
|
29
|
+
const groupId = crypto.randomUUID();
|
|
30
|
+
const queries = option.map(id => {
|
|
31
|
+
const dcqlCredentialQuery = credentialQueryMap.get(id);
|
|
32
|
+
return {
|
|
33
|
+
type: 'QueryByExample',
|
|
34
|
+
group: groupId,
|
|
35
|
+
credentialQuery: _toQueryByExampleQuery({dcqlCredentialQuery})
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
groupMap.set(groupId, new Map([['QueryByExample', queries]]));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return groupMap;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function dcqlCredentialQueryToJsonPointerMap({
|
|
45
|
+
dcqlCredentialQuery
|
|
46
|
+
} = {}) {
|
|
47
|
+
const queryByExample = _toQueryByExampleQuery({dcqlCredentialQuery});
|
|
48
|
+
return exampleToJsonPointerMap(queryByExample);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function vprGroupsToDcqlQuery({groupMap, options = {}} = {}) {
|
|
52
|
+
// if there is any DCQL query, return it as-is
|
|
53
|
+
const groups = [...groupMap.values()];
|
|
54
|
+
let dcqlQuery = groups
|
|
55
|
+
.filter(g => g.has('DigitalCredentialQueryLanguage'))
|
|
56
|
+
.map(g => g.get('DigitalCredentialQueryLanguage'))
|
|
57
|
+
.at(-1);
|
|
58
|
+
if(dcqlQuery) {
|
|
59
|
+
return dcqlQuery;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// convert all `QueryByExample` queries to a single DCQL query
|
|
63
|
+
const {nullyifyArrayIndices = false} = options;
|
|
64
|
+
dcqlQuery = {};
|
|
65
|
+
const credentials = [];
|
|
66
|
+
const credentialSets = [{options: []}];
|
|
67
|
+
|
|
68
|
+
// note: same group ID is logical "AND" and different group ID is "OR"
|
|
69
|
+
for(const queries of groups) {
|
|
70
|
+
// only `QueryByExample` is convertible
|
|
71
|
+
const queryByExamples = queries.get('QueryByExample');
|
|
72
|
+
if(!(queryByExamples?.length > 0)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// for each `QueryByExample`, add another option
|
|
77
|
+
const option = [];
|
|
78
|
+
for(const queryByExample of queryByExamples) {
|
|
79
|
+
// should only be one `credentialQuery` but handle each one as a new
|
|
80
|
+
// DCQL credential query
|
|
81
|
+
const all = Array.isArray(queryByExample.credentialQuery) ?
|
|
82
|
+
queryByExample.credentialQuery : [queryByExample.credentialQuery];
|
|
83
|
+
for(const credentialQuery of all) {
|
|
84
|
+
const result = _fromQueryByExampleQuery({
|
|
85
|
+
credentialQuery, nullyifyArrayIndices
|
|
86
|
+
});
|
|
87
|
+
credentials.push(result);
|
|
88
|
+
// DCQL credential set "option" includes all queries in the "AND" group
|
|
89
|
+
option.push(result.id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// add "option" as another "OR" in the DCQL "options"
|
|
94
|
+
credentialSets[0].options.push(option);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if(credentials.length > 0) {
|
|
98
|
+
dcqlQuery.credentials = credentials;
|
|
99
|
+
}
|
|
100
|
+
if(credentialSets[0].options?.length > 0) {
|
|
101
|
+
dcqlQuery.credential_sets = credentialSets;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return dcqlQuery;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// exported for testing purposes only
|
|
108
|
+
export function _fromQueryByExampleQuery({
|
|
109
|
+
credentialQuery, nullyifyArrayIndices = false
|
|
110
|
+
}) {
|
|
111
|
+
const result = {
|
|
112
|
+
id: crypto.randomUUID(),
|
|
113
|
+
format: 'ldp_vc',
|
|
114
|
+
meta: {
|
|
115
|
+
type_values: ['https://www.w3.org/2018/credentials#VerifiableCredential']
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
if(credentialQuery?.reason) {
|
|
119
|
+
result.meta.reason = credentialQuery.reason;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const {example = {}} = credentialQuery ?? {};
|
|
123
|
+
|
|
124
|
+
// determine credential format
|
|
125
|
+
if(Array.isArray(credentialQuery.acceptedEnvelopes)) {
|
|
126
|
+
const set = new Set(credentialQuery.acceptedEnvelopes);
|
|
127
|
+
if(set.has('application/jwt')) {
|
|
128
|
+
result.format = 'jwt_vc_json';
|
|
129
|
+
} else if(set.has('application/mdl')) {
|
|
130
|
+
result.format = 'mso_mdoc';
|
|
131
|
+
result.meta = {doctype_value: MDOC_MDL};
|
|
132
|
+
} else if(set.has('application/dc+sd-jwt')) {
|
|
133
|
+
result.format = 'dc+sd-jwt';
|
|
134
|
+
result.meta = {vct_values: []};
|
|
135
|
+
if(Array.isArray(example?.type)) {
|
|
136
|
+
result.meta.vct_values.push(...example.type);
|
|
137
|
+
} else if(typeof example.type === 'string') {
|
|
138
|
+
result.meta.vct_values.push(example.type);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// convert `example` into json pointers and walk to produce DCQL claim paths
|
|
144
|
+
const pointers = toJsonPointerMap({obj: example, flat: true});
|
|
145
|
+
const pathsMap = new Map();
|
|
146
|
+
for(const [pointer, value] of pointers) {
|
|
147
|
+
// parse path into DCQL path w/ numbers for array indexes
|
|
148
|
+
let path = jsonpointer.parse(pointer).map(toNumberIfNumber);
|
|
149
|
+
|
|
150
|
+
// special process non-`@context` paths to convert some array indexes
|
|
151
|
+
// to DCQL `null` (which means "any" index)
|
|
152
|
+
if(path[0] !== '@context') {
|
|
153
|
+
if(nullyifyArrayIndices) {
|
|
154
|
+
// brute force convert every array index to `null` by request
|
|
155
|
+
path = path.map(p => isNumber(p) ? null : p);
|
|
156
|
+
} else if(isNumber(path.at(-1)) && !isNumber(path.at(-2))) {
|
|
157
|
+
// when a pointer terminates at an array element it means candidate
|
|
158
|
+
// matching values are expressed in the `example`, so make sure to
|
|
159
|
+
// share the path for all candidates
|
|
160
|
+
path[path.length - 1] = null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// compile processed path back to a key to consolidate `values`
|
|
165
|
+
const key = jsonpointer.compile(path.map(p => p === null ? 'null' : p));
|
|
166
|
+
|
|
167
|
+
// create shared entry for path and candidate matching values
|
|
168
|
+
let entry = pathsMap.get(key);
|
|
169
|
+
if(!entry) {
|
|
170
|
+
entry = {path, valueSet: new Set()};
|
|
171
|
+
pathsMap.set(key, entry);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// add any non-QueryByExample-wildcard as a DCQL candidate match value
|
|
175
|
+
if(!(value === '' || value instanceof Map || value instanceof Set)) {
|
|
176
|
+
entry.valueSet.add(value);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// produce DCQL `claims`
|
|
181
|
+
const claims = [...pathsMap.values()].map(({path, valueSet}) => {
|
|
182
|
+
const claim = {path};
|
|
183
|
+
if(valueSet.size > 0) {
|
|
184
|
+
claim.values = [...valueSet];
|
|
185
|
+
}
|
|
186
|
+
return claim;
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if(claims.length > 0) {
|
|
190
|
+
result.claims = claims;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// exported for testing purposes only
|
|
197
|
+
export function _toQueryByExampleQuery({dcqlCredentialQuery}) {
|
|
198
|
+
// convert DCQL credential query to pointers
|
|
199
|
+
const pointers = new Map();
|
|
200
|
+
const {format, meta, claims = []} = dcqlCredentialQuery;
|
|
201
|
+
for(const claim of claims) {
|
|
202
|
+
const {values} = claim;
|
|
203
|
+
|
|
204
|
+
// a trailing `null` in a path means `values` should be treated as a set
|
|
205
|
+
// of candidates inside an array value at path-1
|
|
206
|
+
const path = claim.path?.at(-1) === null ?
|
|
207
|
+
claim.path.slice(0, -1) : claim.path;
|
|
208
|
+
|
|
209
|
+
// convert `null` path tokens to an index; assume the use of `null` will
|
|
210
|
+
// not be combined with any other index
|
|
211
|
+
const pointer = jsonpointer.compile(path.map(p => p === null ? '0' : p));
|
|
212
|
+
if(!values) {
|
|
213
|
+
pointers.set(pointer, '');
|
|
214
|
+
} else if(values.length === 1 && claim.path.at(-1) !== null) {
|
|
215
|
+
// convert a single choice for a non-array value to a primitive
|
|
216
|
+
pointers.set(pointer, values[0]);
|
|
217
|
+
} else {
|
|
218
|
+
pointers.set(pointer, new Set(values));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const credentialQuery = {};
|
|
223
|
+
if(meta?.reason) {
|
|
224
|
+
credentialQuery.reason = meta.reason;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// convert pointers to example object
|
|
228
|
+
credentialQuery.example = fromJsonPointerMap({map: pointers});
|
|
229
|
+
|
|
230
|
+
if(format === 'jwt_vc_json') {
|
|
231
|
+
credentialQuery.acceptedEnvelopes = ['application/jwt'];
|
|
232
|
+
} else if(format === 'mso_mdoc') {
|
|
233
|
+
if(meta?.doctype_value === MDOC_MDL) {
|
|
234
|
+
credentialQuery.acceptedEnvelopes = ['application/mdl'];
|
|
235
|
+
} else {
|
|
236
|
+
credentialQuery.acceptedEnvelopes = ['application/mdoc'];
|
|
237
|
+
}
|
|
238
|
+
} else if(format === 'dc+sd-jwt') {
|
|
239
|
+
// FIXME: consider adding `vct_values` as params
|
|
240
|
+
credentialQuery.acceptedEnvelopes = ['application/dc+sd-jwt'];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return credentialQuery;
|
|
244
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import {dcqlCredentialQueryToJsonPointerMap} from './dcql.js';
|
|
5
|
+
import {exampleToJsonPointerMap} from './queryByExample.js';
|
|
6
|
+
import {inputDescriptorToJsonPointerMap} from './presentationExchange.js';
|
|
7
|
+
|
|
8
|
+
export {credentialMatches} from './match.js';
|
|
9
|
+
|
|
10
|
+
export const dcql = {
|
|
11
|
+
toJsonPointerMap: dcqlCredentialQueryToJsonPointerMap
|
|
12
|
+
};
|
|
13
|
+
export const presentationExchange = {
|
|
14
|
+
toJsonPointerMap: inputDescriptorToJsonPointerMap
|
|
15
|
+
};
|
|
16
|
+
export const queryByExample = {
|
|
17
|
+
toJsonPointerMap: exampleToJsonPointerMap
|
|
18
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import {isObject, resolvePointer, toNumberIfNumber} from './util.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns whether a credential matches against a JSON pointer map.
|
|
8
|
+
*
|
|
9
|
+
* A JSON pointer map must be created from a QueryByExample `example`, a DCQL
|
|
10
|
+
* `credential` query value, or a Presentation Exchange input descriptor
|
|
11
|
+
* by calling the respective utility APIs in this library. It is more efficient
|
|
12
|
+
* to produce this JSON pointer map just once when looking for matches in a
|
|
13
|
+
* list of more than one credential.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} options - The options.
|
|
16
|
+
* @param {object} options.credential - The credential to try to match.
|
|
17
|
+
* @param {Map} options.map - The JSON pointer map.
|
|
18
|
+
* @param {object} options.options - Match options, such as:
|
|
19
|
+
* [coerceNumbers=true] - String/numbers will be coerced.
|
|
20
|
+
*
|
|
21
|
+
* @returns {boolean} `true` if the credential matches, `false` if not.
|
|
22
|
+
*/
|
|
23
|
+
export function credentialMatches({
|
|
24
|
+
credential, map, options = {coerceNumbers: true}
|
|
25
|
+
} = {}) {
|
|
26
|
+
// credential must be an object to match
|
|
27
|
+
if(!isObject(credential)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return _match({cursor: credential, matchValue: map, options});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function _match({cursor, matchValue, options}) {
|
|
34
|
+
// handle wildcard matching
|
|
35
|
+
if(_isWildcard(matchValue)) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if(matchValue instanceof Set) {
|
|
40
|
+
// some element in the set must match `cursor`
|
|
41
|
+
return [...matchValue].some(e => _match({cursor, matchValue: e, options}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if(matchValue instanceof Map) {
|
|
45
|
+
// all pointers and values in the map must match `cursor`
|
|
46
|
+
return [...matchValue.entries()].every(([pointer, matchValue]) => {
|
|
47
|
+
const value = resolvePointer(cursor, pointer);
|
|
48
|
+
if(value === undefined) {
|
|
49
|
+
// no value at `pointer`; no match
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
// handles case where `value` is an empty array + wildcard `matchValue`
|
|
53
|
+
if(_isWildcard(matchValue)) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
// normalize value to an array for matching
|
|
57
|
+
const values = Array.isArray(value) ? value : [value];
|
|
58
|
+
return values.some(v => _match({cursor: v, matchValue, options}));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// primitive comparison
|
|
63
|
+
if(cursor === matchValue) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// string/number coercion
|
|
68
|
+
if(options.coerceNumbers) {
|
|
69
|
+
const cursorNumber = toNumberIfNumber(cursor);
|
|
70
|
+
const matchNumber = toNumberIfNumber(matchValue);
|
|
71
|
+
return cursorNumber !== undefined && cursorNumber === matchNumber;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function _isWildcard(value) {
|
|
78
|
+
// empty string, Map, or Set
|
|
79
|
+
return value === '' || value?.size === 0;
|
|
80
|
+
}
|