@digitalbazaar/oid4-client
Version:
An OID4 (VC + VP) client
362 lines (317 loc) • 11.2 kB
JavaScript
/*!
* Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved.
*/
import * as base64url from 'base64url-universal';
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';
if(x !== undefined && typeof x !== type) {
throw new TypeError(
`${optional ? 'When present, ' : ''} ` +
`"${name}" must be ${article} ${type}.`);
}
}
export function assertOptional(x, name, type) {
return assert(x, name, type, true);
}
export function base64Decode(str) {
if(Uint8Array.fromBase64) {
return Uint8Array.fromBase64(str);
}
return base64url.decode(
str.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'));
}
export function base64Encode(data) {
if(data.toBase64) {
return data.toBase64();
}
// note: this is base64-no-pad; will only work with specific data lengths
return base64url.encode(data).replace(/-/g, '+').replace(/_/g, '/');
}
export function createNamedError({message, name, details, cause} = {}) {
const error = new Error(message, {cause});
error.name = name;
if(details) {
error.details = details;
}
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 `<origin>` (no path), `issuerConfigUrl` must match:
`<origin>/.well-known/<any-path-segment>`
For issuer `<origin><path>`, `issuerConfigUrl` must be:
`<origin>/.well-known/<any-path-segment><path>` */
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:
// <origin><path>/.well-known/<any-path-segment>
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 = {
// max size for issuer config related responses (in bytes, ~4 KiB)
size: 4096,
// timeout in ms for fetching an issuer config
timeout: 5000,
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 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": {
"keys": [
{
"kty": "EC",
"use": "enc",
"crv": "P-256",
"x": "...",
"y": "...",
"alg": "ECDH-ES",
"kid": "..."
}
]
} */
if(!Array.isArray(keys)) {
return;
}
// match `kid` exactly if given
if(kid !== undefined) {
return keys.find(jwk => jwk?.kid === kid);
}
return keys.find(jwk => {
// default unspecified search values to whatever is in `jwk`
const alg1 = alg ?? jwk.alg;
const kty1 = kty ?? jwk.kty;
const crv1 = crv ?? jwk.crv;
const use1 = use ?? jwk.use;
const {
// default missing `alg` value in `jwk` to search value
alg: alg2 = alg1,
kty: kty2,
crv: crv2,
// default missing `use` value in `jwk` to search value
use: use2 = use1
} = jwk;
// return if `jwk` matches computed values
return alg1 === alg2 && kty1 === kty2 && crv1 === crv2 && use1 === use2;
});
}
export async function signJWT({payload, protectedHeader, signer} = {}) {
// encode payload and protected header
const b64Payload = base64url.encode(JSON.stringify(payload));
const b64ProtectedHeader = base64url.encode(JSON.stringify(protectedHeader));
payload = TEXT_ENCODER.encode(b64Payload);
protectedHeader = TEXT_ENCODER.encode(b64ProtectedHeader);
// concatenate
const data = new Uint8Array(
protectedHeader.length + ENCODED_PERIOD.length + payload.length);
data.set(protectedHeader);
data.set(ENCODED_PERIOD, protectedHeader.length);
data.set(payload, protectedHeader.length + ENCODED_PERIOD.length);
// sign
const signature = await signer.sign({data});
// create JWS
const jws = {
signature: base64url.encode(signature),
payload: b64Payload,
protected: b64ProtectedHeader
};
// 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;
}