@bedrock/vc-delivery
Version:
Bedrock Verifiable Credential Delivery
448 lines (398 loc) • 15.3 kB
JavaScript
/*!
* Copyright (c) 2022-2026 Digital Bazaar, Inc. All rights reserved.
*/
import * as bedrock from '@bedrock/core';
import {
create as createAuthorizationRequest,
removeClientIdPrefix
} from './authorizationRequest.js';
import {
evaluateExchangeStep,
resolveVariableName,
setVariable
} from '../helpers.js';
import {getClientBaseUrl, getClientProfile} from './clientProfiles.js';
import {verify as defaultVerify} from '../verify.js';
import {ExchangeProcessor} from '../ExchangeProcessor.js';
import {oid4vp} from '@digitalbazaar/oid4-client';
import {parse as parseAuthorizationResponse} from './authorizationResponse.js';
const {util: {BedrockError}} = bedrock;
export {encode as encodeAuthorizationRequest} from './authorizationRequest.js';
export async function getAuthorizationRequest({req, clientProfileId}) {
const {config: workflow} = req.serviceObject;
const exchangeRecord = await req.getExchange();
// process exchange and capture values to return
const result = {};
const exchangeProcessor = new ExchangeProcessor({
workflow, exchangeRecord,
async prepareStep({exchange, step}) {
const {
clientProfile, authorizationRequest
} = await getStepAuthorizationRequest({
workflow, exchange, step, clientProfileId
});
// save values to return
result.clientProfile = clientProfile;
result.authorizationRequest = authorizationRequest;
result.exchange = exchange;
result.step = step;
},
inputRequired() {
// input always required (authz response required)
return true;
}
});
await exchangeProcessor.process();
return result;
}
export async function getStepAuthorizationRequest({
workflow, exchange, step, clientProfileId
}) {
// step must have `openId` to perform OID4VP
if(!step.openId) {
_throwUnsupportedProtocol();
}
// deny retrieval of authorization request if an authorization response
// has already been accepted for this step
if(exchange.variables.results[exchange.step]?.verifiablePresentation) {
throw new BedrockError('This OID4VP exchange is already in progress.', {
name: 'NotAllowedError',
details: {httpStatusCode: 403, public: true}
});
}
// get OID4VP client profile
const clientProfile = getClientProfile({step, clientProfileId});
// generate authorization request
const {authorizationRequest} = await _getOrCreateStepAuthorizationRequest({
workflow, exchange, clientProfileId, clientProfile, step
});
return {clientProfile, authorizationRequest};
}
export async function getOID4VPProtocols({workflow, exchange, step}) {
// no OID4VP protocols supported
if(!await supportsOID4VP({workflow, exchange, step})) {
return {};
}
// OID4VP supported; add protocol URL for each client profile...
const {openId} = step;
const clientProfiles = openId.clientProfiles ?
Object.entries(openId.clientProfiles) : [[undefined, openId]];
// generate protocol URL for each client profile; if there are any profile
// name conflicts, the last profile will provide the profile URL for that
// profile name
const protocols = {};
for(const [clientProfileId, clientProfile] of clientProfiles) {
// get supported protocol URL parameters
const {name, scheme, version} = _getProtocolUrlParameters({clientProfile});
// generate default OID4VP protocol URL
const clientBaseUrl = getClientBaseUrl({
workflow, exchange, clientProfileId
});
const {
authorizationRequest
} = await _getOrCreateStepAuthorizationRequest({
workflow, exchange, clientProfileId, clientProfile, step
});
const {client_id, request_uri_method} = authorizationRequest;
const searchParams = new URLSearchParams({
client_id,
request_uri: `${clientBaseUrl}/authorization/request`
});
if(request_uri_method && version !== 'OID4VP-draft18') {
searchParams.set('request_uri_method', request_uri_method);
}
protocols[name] = `${scheme}://?${searchParams}`;
}
return protocols;
}
export async function initExchange({workflow, exchange, initialStep} = {}) {
if(!await supportsOID4VP({workflow, exchange, step: initialStep})) {
return;
}
// generate authz request for initial step for each supported client profile
// to ensure that OID4VP protocols will be available
const {openId} = initialStep;
const clientProfiles = openId.clientProfiles ?
Object.entries(openId.clientProfiles) : [[undefined, openId]];
await Promise.all(clientProfiles.map(([clientProfileId, clientProfile]) =>
_getOrCreateStepAuthorizationRequest({
workflow, exchange, clientProfileId, clientProfile, step: initialStep
})));
}
export async function processAuthorizationResponse({req, clientProfileId}) {
const {config: workflow} = req.serviceObject;
const exchangeRecord = await req.getExchange();
// process exchange and produce result
let parseResponseResult;
const result = {};
const exchangeProcessor = new ExchangeProcessor({
workflow, exchangeRecord,
async prepareStep({exchange, step}) {
const {authorizationRequest} = await getStepAuthorizationRequest({
workflow, exchange, step, clientProfileId
});
result.authorizationRequest = authorizationRequest;
// ensure authz response can be parsed
parseResponseResult = await parseAuthorizationResponse({
req, exchange: exchangeRecord.exchange, clientProfileId,
authorizationRequest
});
// only mark exchange complete if there is nothing to be issued; this
// handles same-step OID4VCI+OID4VP case
const {credentialTemplates = []} = workflow;
if(credentialTemplates.length === 0) {
exchange.state = 'complete';
}
// include `redirect_uri` if specified in step
const {redirect_uri} = step.openId;
if(redirect_uri) {
result.redirect_uri = redirect_uri;
}
const {presentation: receivedPresentation} = parseResponseResult;
return {receivedPresentation};
},
inputRequired({step}) {
// indicate input always required to avoid automatically advancing
// to issuance, but clear `step.verifiablePresentationRequest`
// to avoid overwriting previous value
delete step.verifiablePresentationRequest;
return true;
},
async verify({
workflow, exchange,
verifyPresentationOptions,
verifyPresentationResultSchema,
presentation,
allowUnprotectedPresentation,
expectedChallenge,
expectedDomain
}) {
const {authorizationRequest} = result;
verifyPresentationOptions.challenge = authorizationRequest.nonce;
const usesRedirectUri = oid4vp.authzRequest.usesClientIdScheme({
authorizationRequest, scheme: 'redirect_uri'
});
verifyPresentationOptions.domain = usesRedirectUri ?
authorizationRequest.client_id : authorizationRequest.response_uri;
expectedDomain = verifyPresentationOptions.domain;
const {envelope} = parseResponseResult;
if(envelope?.mediaType === 'application/mdl-vp-token') {
// generate `handover` for mDL verification
let handover;
// common `handover` parameters:
const origin = authorizationRequest?.expected_origins?.[0] ??
new URL(authorizationRequest.response_uri).origin;
const nonce = authorizationRequest.nonce;
// `direct_post.jwt` => ISO18013-7 Annex B
// FIXME: same response mode is also used for OID4VP 1.0 with
// `OpenID4VPHandover` for non-Annex-B; this is not yet supported
// https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-invocation-via-redirects
const {responseMode} = parseResponseResult;
if(responseMode === 'direct_post.jwt') {
handover = {
type: 'AnnexBHandover',
// per ISO 18013-7 B the `mdocGeneratedNonce` is base64url-encoded
// and put into the `apu` protected header parameter, so parse that
// here and convert it to a UTF-8 string instead
mdocGeneratedNonce: Buffer
.from(parseResponseResult.protectedHeader?.apu ?? '', 'base64url')
.toString('utf8'),
clientId: authorizationRequest.client_id,
responseUri: authorizationRequest.response_uri,
verifierGeneratedNonce: nonce
};
} else if(responseMode === 'dc_api') {
// `dc_api` => ISO18013-7 Annex C
handover = {
type: 'dcapi',
origin,
nonce,
recipientPublicJwk: parseResponseResult.recipientPublicJwk
};
} else if(responseMode === 'dc_api.jwt') {
// `dc_api.jwt` => ISO18013-7 Annex D
handover = {
type: 'OpenID4VPDCAPIHandover',
origin,
nonce,
jwkThumbprint: parseResponseResult.recipientPublicJwkThumbprint
};
}
verifyPresentationOptions.mdl = {
...verifyPresentationOptions.mdl,
// send encoded mDL `sessionTranscript`
sessionTranscript: Buffer
.from(await oid4vp.mdl.encodeSessionTranscript({handover}))
.toString('base64url')
};
}
// verify presentation
const verifyResult = await defaultVerify({
workflow,
verifyPresentationOptions,
verifyPresentationResultSchema,
presentation,
allowUnprotectedPresentation,
expectedChallenge,
expectedDomain
});
// save OID4VP results in exchange
const {presentationSubmission} = parseResponseResult;
exchange.variables.results[exchange.step] = {
...exchange.variables.results[exchange.step],
openId: {
clientProfileId,
authorizationRequest,
presentationSubmission
}
};
if(parseResponseResult.envelope) {
// include enveloped VP in step result
exchange.variables.results[exchange.step]
.envelopedPresentation = presentation;
}
return verifyResult;
}
});
await exchangeProcessor.process();
return result;
}
export async function supportsOID4VP({workflow, exchange, step}) {
if(!step) {
if(!exchange.step) {
return false;
}
step = await evaluateExchangeStep({workflow, exchange});
}
return step.openId !== undefined;
}
function _getProtocolUrlParameters({clientProfile}) {
const protocolUrlParameters = {
name: 'OID4VP',
scheme: 'openid4vp',
version: undefined,
...clientProfile.protocolUrlParameters
};
if(protocolUrlParameters.version === undefined) {
protocolUrlParameters.version =
protocolUrlParameters.scheme === 'mdoc-openid4vp' ?
'OID4VP-draft18' : 'OID4VP-1.0';
}
return protocolUrlParameters;
}
async function _getOrCreateStepAuthorizationRequest({
workflow, exchange, clientProfileId, clientProfile, step
}) {
let authorizationRequest;
// get authorization request
authorizationRequest = clientProfile.authorizationRequest;
if(authorizationRequest) {
authorizationRequest = _normalizeAuthorizationRequest({
authorizationRequest, exchange, clientProfile
});
return {authorizationRequest, exchangeChanged: false};
}
// create authorization request...
// get variable name for authorization request
const authzReqVarName = clientProfile.createAuthorizationRequest;
if(authzReqVarName === undefined) {
_throwUnsupportedProtocol();
}
// create or get cached authorization request...
if(authzReqVarName === '/') {
// overwriting all variables is not permitted
throw new BedrockError(
`Invalid authorization request variable name "${authzReqVarName}".`, {
name: 'NotSupportedError',
details: {httpStatusCode: 500, public: true}
});
}
authorizationRequest = resolveVariableName({
variables: exchange.variables, name: authzReqVarName
});
if(authorizationRequest) {
authorizationRequest = _normalizeAuthorizationRequest({
authorizationRequest, exchange, clientProfile
});
return {authorizationRequest, exchangeChanged: false};
}
// create authz request
const {verifiablePresentationRequest} = step;
const result = await createAuthorizationRequest({
workflow, exchange,
clientProfile, clientProfileId,
verifiablePresentationRequest
});
authorizationRequest = _normalizeAuthorizationRequest({
authorizationRequest: result.authorizationRequest, exchange, clientProfile
});
// merge any newly created exchange secrets
exchange.secrets = {
...exchange.secrets,
oid4vp: {
...exchange.secrets?.oid4vp,
clientProfiles: {
...exchange.secrets?.oid4vp?.clientProfiles,
[clientProfileId ?? 'default']: result.secrets
}
}
};
// store generated authorization request
if(!exchange.variables) {
exchange.variables = {};
}
setVariable({
variables: exchange.variables,
name: authzReqVarName,
value: authorizationRequest
});
return {authorizationRequest, exchangeChanged: true};
}
function _normalizeAuthorizationRequest({
authorizationRequest, exchange, clientProfile
}) {
const {
client_id, client_id_scheme, request_uri_method, state
} = authorizationRequest;
authorizationRequest = {...authorizationRequest};
// get any explicit version to be used with client profile
const {version} = _getProtocolUrlParameters({clientProfile});
// if `version` is OID4VP draft 18, remove any `client_id_scheme` prefix
// from the `client_id`; otherwise add it
if(version === 'OID4VP-draft18') {
authorizationRequest.client_id = removeClientIdPrefix({
clientId: client_id
});
return authorizationRequest;
}
// version is OID4VP 1.0+ or that + compatibility w/Draft18...
if(client_id_scheme && client_id && !client_id.startsWith(client_id_scheme)) {
// note: for `redirect_uri` `client_id_scheme`, it is ok to always include
// `redirect_uri:` prefix in the client ID, even for versions that support
// Draft 18 compatibility along with other versions, as the client ID
// should be treated as opaque when using `response_mode=direct*` and
// omitting the `redirect_uri` parameter; which is the only supported
// configuration in this implementation for Draft 18 clients
authorizationRequest.client_id = `${client_id_scheme}:${client_id}`;
}
// OID4VP 1.0+ requires `state` to be included for authz requests that do
// not require "holder binding", but always including it does not cause any
// known issues, so just include `state` using `referenceId` (if set) or
// `localExchangeId`
if(!state && oid4vp.authzRequest.usesClientIdScheme({
authorizationRequest, scheme: 'redirect_uri'
})) {
authorizationRequest.state = exchange.referenceId ?? exchange.id;
}
// default to `request_uri_method=post` in OID4VP 1.0+
if(!request_uri_method) {
authorizationRequest.request_uri_method = 'post';
}
return authorizationRequest;
}
function _throwUnsupportedProtocol() {
throw new BedrockError('OID4VP is not supported by this exchange.', {
name: 'NotSupportedError',
details: {httpStatusCode: 400, public: true}
});
}