@bedrock/vc-delivery
Version:
Bedrock Verifiable Credential Delivery
419 lines (391 loc) • 14.2 kB
JavaScript
/*!
* Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
*/
import * as oid4vci from './oid4vci.js';
import * as oid4vp from './oid4vp.js';
import {
openIdAuthorizationResponseBody,
openIdBatchCredentialBody,
openIdCredentialBody,
openIdTokenBody
} from '../../schemas/bedrock-vc-workflow.js';
import {asyncHandler} from '@bedrock/express';
import bodyParser from 'body-parser';
import cors from 'cors';
import {logger} from '../logger.js';
import {UnsecuredJWT} from 'jose';
import {createValidateMiddleware as validate} from '@bedrock/validation';
// re-export support detection helpers
export {supportsOID4VCI} from './oid4vci.js';
export {supportsOID4VP} from './oid4vp.js';
/* NOTE: Parts of the OID4VCI design imply tight integration between the
authorization server and the credential issuance / delivery server. This
file provides the routes for both and treats them as integrated; supporting
the OID4VCI pre-authz code flow only as a result. However, we also try to
avoid tight-coupling where possible to enable the non-pre-authz code flow
that would use, somehow, a separate authorization server.
One tight coupling we try to avoid involves the option where the authorization
server generates the challenge nonce to be signed in a DID proof, but the
credential delivery server is the system responsible for checking and tracking
this challenge. The Credential Delivery server cannot know the challenge is
authentic without breaking some abstraction around how the Authorization
Server is implemented behind its API. Here we do not implement this option,
instead, if a challenge is required, the credential delivery server will send
an error with the challenge nonce if one was not provided in the payload to the
credential endpoint. This error follows the OID4VCI spec and avoids this
particular tight coupling.
Other tight couplings cannot be avoided at this time -- such as the fact that
the credential endpoint is specified in the authorization server's metadata;
this creates challenges for SaaS based solutions and for issuers that want to
use multiple different Issuance / Delivery server backends. We solve these
challenges by using the "pre-authorized code" flows and effectively
instantiating a new authorization server instance per VC exchange. */
// creates OID4VCI Authorization Server + Credential Delivery Server
// endpoints for each individual exchange
export async function createRoutes({
app, exchangeRoute, getConfigMiddleware, getExchange
} = {}) {
const openIdRoute = `${exchangeRoute}/openid`;
const routes = {
// OID4VCI routes
asMetadata1: `/.well-known/oauth-authorization-server${exchangeRoute}`,
asMetadata2: `${exchangeRoute}/.well-known/oauth-authorization-server`,
ciMetadata1: `/.well-known/openid-credential-issuer${exchangeRoute}`,
ciMetadata2: `${exchangeRoute}/.well-known/openid-credential-issuer`,
batchCredential: `${openIdRoute}/batch_credential`,
credential: `${openIdRoute}/credential`,
credentialOffer: `${openIdRoute}/credential-offer`,
nonce: `${openIdRoute}/nonce`,
token: `${openIdRoute}/token`,
jwks: `${openIdRoute}/jwks`,
// OID4VP routes
authorizationRequest: `${openIdRoute}/client/authorization/request`,
authorizationResponse: `${openIdRoute}/client/authorization/response`
};
// urlencoded body parser
const urlencodedSmall = bodyParser.urlencoded({
// (extended=true for rich JSON-like representation)
extended: true
});
const urlencodedLarge = bodyParser.urlencoded({
// (extended=true for rich JSON-like representation)
extended: true,
// allow larger payloads
limit: '10MB'
});
/* Note: The well-known metadata paths for the OID4VCI spec have been
specified in at least two different ways over time, including
`<path>/.well-known/...` and `/.well-known/.../<path>`, so they are provided
here using both approaches to maximize interoperability with clients. It
is also notable that some versions of the spec have indicated that the
credential issuer metadata should be expressed in the authorization server
metadata and others have indicated that they can be separate; since our
approach virtualizes both the AS and CI anyway, it is all served together. */
// an authorization server meta data endpoint
// serves `.well-known` oauth2 AS config for each exchange; each config is
// based on the workflow used to create the exchange
app.get(
routes.asMetadata1,
cors(),
getConfigMiddleware,
getExchange,
asyncHandler(async (req, res) => {
res.json(await oid4vci.getAuthorizationServerConfig({req}));
}));
// an authorization server meta data endpoint
// serves `.well-known` oauth2 AS config for each exchange; each config is
// based on the workflow used to create the exchange
app.get(
routes.asMetadata2,
cors(),
getConfigMiddleware,
getExchange,
asyncHandler(async (req, res) => {
res.json(await oid4vci.getAuthorizationServerConfig({req}));
}));
// a credential issuer meta data endpoint
// serves `.well-known` oauth2 AS / CI config for each exchange; each config
// is based on the workflow used to create the exchange
app.get(
routes.ciMetadata1,
cors(),
getConfigMiddleware,
getExchange,
asyncHandler(async (req, res) => {
res.json(await oid4vci.getCredentialIssuerConfig({req}));
}));
// a credential issuer meta data endpoint
// serves `.well-known` oauth2 AS / CI config for each exchange; each config
// is based on the workflow used to create the exchange
app.get(
routes.ciMetadata2,
cors(),
getConfigMiddleware,
getExchange,
asyncHandler(async (req, res) => {
res.json(await oid4vci.getCredentialIssuerConfig({req}));
}));
// an authorization server endpoint
// serves JWKs associated with each exchange; JWKs are stored with the
// workflow used to create the exchange
app.get(
routes.jwks,
cors(),
getExchange,
asyncHandler(async (req, res) => {
// serve exchange's public key(s)
const keys = await oid4vci.getJwks({req});
res.json({keys});
}));
// an authorization server endpoint
// handles pre-authorization code exchange for access token; only supports
// pre-authorization code grant type
app.options(routes.token, cors());
app.post(
routes.token,
cors(),
urlencodedSmall,
validate({bodySchema: openIdTokenBody}),
getConfigMiddleware,
getExchange,
asyncHandler(async (req, res) => {
let result;
try {
result = await oid4vci.processAccessTokenRequest({req, res});
} catch(error) {
return _sendOID4Error({res, error});
}
res.json(result);
}));
// a credential delivery server endpoint
// receives a credential request and returns VCs
app.options(routes.credential, cors());
app.post(
routes.credential,
cors(),
validate({bodySchema: openIdCredentialBody}),
getConfigMiddleware,
getExchange,
asyncHandler(async (req, res) => {
/* Clients must POST, e.g.:
POST /credential HTTP/1.1
Host: server.example.com
Content-Type: application/json
Authorization: BEARER czZCaGRSa3F0MzpnWDFmQmF0M2JW
{
"format": "ldp_vc",
"credential_definition": {
"@context": [
"https://www.w3.org/2018/credentials/v1",
"https://www.w3.org/2018/credentials/examples/v1"
],
"type": [
"VerifiableCredential",
"UniversityDegreeCredential"
]
},
"did": "did:example:ebfeb1f712ebc6f1c276e12ec21",
"proof": {
"proof_type": "jwt",
"jwt": "eyJra...nOzM"
}
}
*/
let result;
try {
result = await oid4vci.processCredentialRequests({
req, res, isBatchRequest: false
});
if(!result) {
// DID proof request response sent
return;
}
// send VC(s)
const {
response: {verifiablePresentation: {verifiableCredential}},
format
} = result;
// FIXME: "format" doesn't seem to be in the spec anymore (draft 14+)...
const credentials = verifiableCredential.map(vc => {
// parse any enveloped VC
let credential;
if(vc.type === 'EnvelopedVerifiableCredential' &&
vc.id?.startsWith('data:application/jwt,')) {
credential = vc.id.slice('data:application/jwt,'.length);
} else {
credential = vc;
}
return credential;
});
/* Note: The `/credential` route only supports sending VCs of the same
type, but there can be more than one of them. The above `isBatchRequest`
check will ensure that the workflow used here only allows a single
credential request, indicating a single type. */
// send OID4VCI response
result = credentials.length === 1 ?
{format, credential: credentials[0]} : {format, credentials};
} catch(error) {
return _sendOID4Error({res, error});
}
res.json(result);
}));
// a credential delivery server endpoint
// serves the credential offer for all possible credentials in the exchange
app.get(
routes.credentialOffer,
cors(),
getConfigMiddleware,
getExchange,
asyncHandler(async (req, res) => {
let result;
try {
result = await oid4vci.getCredentialOffer({req});
} catch(error) {
return _sendOID4Error({res, error});
}
res.json(result);
}));
// a credential delivery server endpoint
// serves a nonce to be used in OID4VCI proofs (if required)
app.options(routes.nonce, cors());
app.post(
routes.nonce,
cors(),
getExchange,
asyncHandler(async (req, res) => {
// serve exchange ID as nonce
const exchangeRecord = await req.getExchange();
const {exchange} = exchangeRecord;
res.json({c_nonce: exchange.id});
}));
// a batch credential delivery server endpoint
// receives N credential requests and returns N VCs
app.options(routes.batchCredential, cors());
app.post(
routes.batchCredential,
cors(),
validate({bodySchema: openIdBatchCredentialBody}),
getConfigMiddleware,
getExchange,
asyncHandler(async (req, res) => {
/* Clients must POST, e.g.:
POST /batch_credential HTTP/1.1
Host: server.example.com
Content-Type: application/json
Authorization: BEARER czZCaGRSa3F0MzpnWDFmQmF0M2JW
{
credential_requests: [{
"format": "ldp_vc",
"credential_definition": {
"@context": [
"https://www.w3.org/2018/credentials/v1",
"https://www.w3.org/2018/credentials/examples/v1"
],
"type": [
"VerifiableCredential",
"UniversityDegreeCredential"
]
},
"did": "did:example:ebfeb1f712ebc6f1c276e12ec21",
"proof": {
"proof_type": "jwt",
"jwt": "eyJra...nOzM"
}
}]
}
*/
let result;
try {
result = await oid4vci.processCredentialRequests({
req, res, isBatchRequest: true
});
if(!result) {
// DID proof request response sent
return;
}
// send VCs
const {
response: {verifiablePresentation: {verifiableCredential}},
format
} = result;
// FIXME: "format" doesn't seem to be in the spec anymore (draft 14+)...
result = verifiableCredential.map(vc => {
// parse any enveloped VC
let credential;
if(vc.type === 'EnvelopedVerifiableCredential' &&
vc.id?.startsWith('data:application/jwt,')) {
credential = vc.id.slice('data:application/jwt,'.length);
} else {
credential = vc;
}
return {format, credential};
});
} catch(error) {
return _sendOID4Error({res, error});
}
res.json({credential_responses: result});
}));
// an OID4VP verifier endpoint
// serves the authorization request, including presentation definition
// associated with the current step in the exchange
app.get(
routes.authorizationRequest,
cors(),
getConfigMiddleware,
getExchange,
asyncHandler(async (req, res) => {
let result;
try {
const {
authorizationRequest
} = await oid4vp.getAuthorizationRequest({req});
// construct and send authz request as unsecured JWT
result = new UnsecuredJWT(authorizationRequest).encode();
res.set('content-type', 'application/oauth-authz-req+jwt');
} catch(error) {
return _sendOID4Error({res, error});
}
res.send(result);
}));
// an OID4VP verifier endpoint
// receives an authorization response with vp_token
app.options(routes.authorizationResponse, cors());
app.post(
routes.authorizationResponse,
cors(),
urlencodedLarge,
validate({bodySchema: openIdAuthorizationResponseBody()}),
getConfigMiddleware,
getExchange,
asyncHandler(async (req, res) => {
let result;
try {
result = await oid4vp.processAuthorizationResponse({req});
} catch(error) {
return _sendOID4Error({res, error});
}
res.json(result);
}));
}
function _sendOID4Error({res, error}) {
logger.error(error.message, {error});
const status = error.details?.httpStatusCode ?? 500;
const oid4Error = {
error: _camelToSnakeCase(error.name ?? 'OperationError'),
error_description: error.message
};
if(error?.details?.public) {
oid4Error.details = error.details;
// expose first level cause only
if(oid4Error.cause?.details?.public) {
oid4Error.cause = {
name: error.cause.name,
message: error.cause.message
};
}
}
res.status(status).json(oid4Error);
}
function _camelToSnakeCase(s) {
return s.replace(/[A-Z]/g, (c, i) => (i === 0 ? '' : '_') + c.toLowerCase());
}