@sphereon/oid4vci-common
Version:
OpenID 4 Verifiable Credential Issuance Common Types
480 lines (442 loc) • 18.8 kB
text/typescript
import Debug from 'debug';
import { jwtDecode, JwtPayload } from 'jwt-decode';
import { PRE_AUTH_CODE_LITERAL, PRE_AUTH_GRANT_LITERAL, VCI_LOG_COMMON } from '../index';
import {
AssertedUniformCredentialOffer,
AuthzFlowType,
CredentialOffer,
CredentialOfferPayload,
CredentialOfferPayloadV1_0_08,
CredentialOfferPayloadV1_0_09,
CredentialOfferPayloadV1_0_11,
CredentialOfferPayloadV1_0_13,
DefaultURISchemes,
Grant,
GrantTypes,
OpenId4VCIVersion,
OpenIDResponse,
UniformCredentialOffer,
UniformCredentialOfferPayload,
UniformCredentialOfferRequest,
} from '../types';
import { getJson } from './HttpUtils';
const debug = Debug('sphereon:oid4vci:offer');
export function determineSpecVersionFromURI(uri: string): OpenId4VCIVersion {
let version = determineSpecVersionFromScheme(uri, OpenId4VCIVersion.VER_UNKNOWN) ?? OpenId4VCIVersion.VER_UNKNOWN;
version = getVersionFromURIParam(uri, version, [OpenId4VCIVersion.VER_1_0_08], 'initiate_issuance');
version = getVersionFromURIParam(uri, version, [OpenId4VCIVersion.VER_1_0_08], 'credential_type');
version = getVersionFromURIParam(uri, version, [OpenId4VCIVersion.VER_1_0_08], 'op_state');
// version = getVersionFromURIParam(uri, version, OpenId4VCIVersion.VER_1_0_09, 'credentials');
// version = getVersionFromURIParam(uri, version, OpenId4VCIVersion.VER_1_0_09, 'initiate_issuance_uri')
// version = getVersionFromURIParam(uri, version, OpenId4VCIVersion.VER_1_0_11, 'credential_offer=');
version = getVersionFromURIParam(uri, version, [OpenId4VCIVersion.VER_1_0_11], 'credentials');
version = getVersionFromURIParam(uri, version, [OpenId4VCIVersion.VER_1_0_11], 'grants.user_pin_required');
version = getVersionFromURIParam(uri, version, [OpenId4VCIVersion.VER_1_0_13], 'credential_configuration_ids');
version = getVersionFromURIParam(uri, version, [OpenId4VCIVersion.VER_1_0_13], 'tx_code');
if (version === OpenId4VCIVersion.VER_UNKNOWN) {
version = OpenId4VCIVersion.VER_1_0_13;
}
return version;
}
export function determineSpecVersionFromScheme(credentialOfferURI: string, openId4VCIVersion: OpenId4VCIVersion) {
const scheme = getScheme(credentialOfferURI);
if (credentialOfferURI.includes(DefaultURISchemes.INITIATE_ISSUANCE)) {
return recordVersion(openId4VCIVersion, [OpenId4VCIVersion.VER_1_0_08], scheme);
}
if (credentialOfferURI.includes('credential_offer_uri')) {
return undefined;
}
// todo: drop support for v1_0_8. version 11 and version 13 have the same scheme 'openid-credential-offer'
else if (credentialOfferURI.includes(DefaultURISchemes.CREDENTIAL_OFFER)) {
if (credentialOfferURI.includes('credentials:') || credentialOfferURI.includes('credentials%22')) {
return recordVersion(openId4VCIVersion, [OpenId4VCIVersion.VER_1_0_11], scheme);
}
return recordVersion(openId4VCIVersion, [OpenId4VCIVersion.VER_1_0_13], scheme);
} else {
return recordVersion(openId4VCIVersion, [OpenId4VCIVersion.VER_UNKNOWN], scheme);
}
}
export function getScheme(credentialOfferURI: string) {
if (!credentialOfferURI || !credentialOfferURI.includes('://')) {
throw Error('Invalid credential offer URI');
}
return credentialOfferURI.split('://')[0];
}
export function getIssuerFromCredentialOfferPayload(request: CredentialOfferPayload): string | undefined {
if (!request || (!('issuer' in request) && !('credential_issuer' in request))) {
return undefined;
}
return 'issuer' in request ? request.issuer : request['credential_issuer'];
}
export const getClientIdFromCredentialOfferPayload = (credentialOffer?: CredentialOfferPayload): string | undefined => {
if (!credentialOffer) {
return;
}
if ('client_id' in credentialOffer) {
return credentialOffer.client_id;
}
const state: string | undefined = getStateFromCredentialOfferPayload(credentialOffer);
if (state && isJWT(state)) {
const decoded = jwtDecode<JwtPayload>(state, { header: false });
if ('client_id' in decoded && typeof decoded.client_id === 'string') {
return decoded.client_id;
}
}
return;
};
const isJWT = (input?: string) => {
if (!input) {
return false;
}
const noParts = input?.split('.').length;
return input?.startsWith('ey') && noParts === 3;
};
export const getStateFromCredentialOfferPayload = (credentialOffer: CredentialOfferPayload): string | undefined => {
if ('grants' in credentialOffer) {
if (credentialOffer.grants?.authorization_code) {
return credentialOffer.grants.authorization_code.issuer_state;
} else if (credentialOffer.grants?.[PRE_AUTH_GRANT_LITERAL]) {
return credentialOffer.grants?.[PRE_AUTH_GRANT_LITERAL]?.[PRE_AUTH_CODE_LITERAL];
}
}
if ('op_state' in credentialOffer) {
// older spec versions
return credentialOffer.op_state;
} else if (PRE_AUTH_CODE_LITERAL in credentialOffer) {
return credentialOffer[PRE_AUTH_CODE_LITERAL];
}
return;
};
export function determineSpecVersionFromOffer(offer: CredentialOfferPayload | CredentialOffer): OpenId4VCIVersion {
if (isCredentialOfferV1_0_13(offer)) {
return OpenId4VCIVersion.VER_1_0_13;
// We don't have full support for V12, so let's skip for now
/*} else if (isCredentialOfferV1_0_12(offer)) {
return OpenId4VCIVersion.VER_1_0_12;*/
} else if (isCredentialOfferV1_0_11(offer)) {
return OpenId4VCIVersion.VER_1_0_11;
} else if (isCredentialOfferV1_0_09(offer)) {
return OpenId4VCIVersion.VER_1_0_09;
} else if (isCredentialOfferV1_0_08(offer)) {
return OpenId4VCIVersion.VER_1_0_08;
}
return OpenId4VCIVersion.VER_UNKNOWN;
}
export function isCredentialOfferVersion(offer: CredentialOfferPayload | CredentialOffer, min: OpenId4VCIVersion, max?: OpenId4VCIVersion) {
if (max && max.valueOf() < min.valueOf()) {
throw Error(`Cannot have a max ${max.valueOf()} version smaller than the min version ${min.valueOf()}`);
}
const version = determineSpecVersionFromOffer(offer);
if (version.valueOf() < min.valueOf()) {
debug(`Credential offer version (${version.valueOf()}) is lower than minimum required version (${min.valueOf()})`);
return false;
} else if (max && version.valueOf() > max.valueOf()) {
debug(`Credential offer version (${version.valueOf()}) is higher than maximum required version (${max.valueOf()})`);
return false;
}
return true;
}
function isCredentialOfferV1_0_08(offer: CredentialOfferPayload | CredentialOffer): boolean {
if (!offer) {
return false;
}
if ('issuer' in offer && 'credential_type' in offer) {
// payload
return true;
}
if ('credential_offer' in offer && offer['credential_offer']) {
// offer, so check payload
return isCredentialOfferV1_0_08(offer['credential_offer']);
}
return false;
}
function isCredentialOfferV1_0_09(offer: CredentialOfferPayload | CredentialOffer): boolean {
if (!offer) {
return false;
}
if ('issuer' in offer && 'credentials' in offer) {
// payload
return true;
}
if ('credential_offer' in offer && offer['credential_offer']) {
// offer, so check payload
return isCredentialOfferV1_0_09(offer['credential_offer']);
}
return false;
}
function isCredentialOfferV1_0_11(offer: CredentialOfferPayload | CredentialOffer): boolean {
if (!offer) {
return false;
}
if ('credential_issuer' in offer && 'credentials' in offer) {
// payload
return true;
}
if ('credential_offer' in offer && offer['credential_offer']) {
// offer, so check payload
return isCredentialOfferV1_0_11(offer['credential_offer']);
}
return 'credential_offer_uri' in offer;
}
/*
function isCredentialOfferV1_0_12(offer: CredentialOfferPayload | CredentialOffer): boolean {
if (!offer) {
return false;
}
if ('credential_issuer' in offer && 'credentials' in offer) {
// payload
return true;
}
if ('credential_offer' in offer && offer['credential_offer']) {
// offer, so check payload
return isCredentialOfferV1_0_12(offer['credential_offer']);
}
return 'credential_offer_uri' in offer;
}
*/
function isCredentialOfferV1_0_13(offer: CredentialOfferPayload | CredentialOffer): boolean {
if (!offer) {
return false;
} else if (typeof offer === 'string' && (offer as string).startsWith('{')) {
offer = JSON.parse(offer);
}
if ('credential_issuer' in offer && 'credential_configuration_ids' in offer) {
// payload
return true;
}
if ('credential_offer' in offer && offer['credential_offer']) {
// offer, so check payload
return isCredentialOfferV1_0_13(offer['credential_offer']);
}
return 'credential_offer_uri' in offer;
}
export async function toUniformCredentialOfferRequest(
offer: CredentialOffer,
opts?: {
resolve?: boolean;
version?: OpenId4VCIVersion;
},
): Promise<UniformCredentialOfferRequest> {
let version = opts?.version ?? determineSpecVersionFromOffer(offer);
let originalCredentialOffer = offer.credential_offer;
let credentialOfferURI: string | undefined;
if ('credential_offer_uri' in offer && offer?.credential_offer_uri !== undefined) {
credentialOfferURI = offer.credential_offer_uri;
if (opts?.resolve || opts?.resolve === undefined) {
VCI_LOG_COMMON.log(`Credential offer contained a URI. Will use that to get the credential offer payload: ${credentialOfferURI}`);
originalCredentialOffer = (await resolveCredentialOfferURI(credentialOfferURI)) as
| CredentialOfferPayloadV1_0_09
| CredentialOfferPayloadV1_0_11
| CredentialOfferPayloadV1_0_13;
} else if (!originalCredentialOffer) {
throw Error(`Credential offer uri (${credentialOfferURI}) found, but resolution was explicitly disabled and credential_offer was supplied`);
}
// We need to redetermine the version of the offer, as we only had the offer_uri until now
version = determineSpecVersionFromOffer(originalCredentialOffer);
VCI_LOG_COMMON.log(`Offer URI payload determined to be of version ${version}`);
}
if (!originalCredentialOffer) {
throw Error('No credential offer available');
}
const payload = toUniformCredentialOfferPayload(originalCredentialOffer, { ...opts, version });
const supportedFlows = determineFlowType(payload, version);
return {
credential_offer: payload,
original_credential_offer: originalCredentialOffer,
...(credentialOfferURI && { credential_offer_uri: credentialOfferURI }),
supportedFlows,
version,
};
}
export function isPreAuthCode(request: UniformCredentialOfferPayload | UniformCredentialOffer) {
const payload = 'credential_offer' in request ? request.credential_offer : (request as UniformCredentialOfferPayload);
return payload?.grants?.[PRE_AUTH_GRANT_LITERAL]?.[PRE_AUTH_CODE_LITERAL] !== undefined;
}
export async function assertedUniformCredentialOffer(
origCredentialOffer: UniformCredentialOffer,
opts?: {
resolve?: boolean;
},
): Promise<AssertedUniformCredentialOffer> {
const credentialOffer = JSON.parse(JSON.stringify(origCredentialOffer));
if (credentialOffer.credential_offer_uri && !credentialOffer.credential_offer) {
if (opts?.resolve === undefined || opts.resolve) {
credentialOffer.credential_offer = await resolveCredentialOfferURI(credentialOffer.credential_offer_uri);
} else {
throw Error(`No credential_offer present, but we did get a URI, but resolution was explicitly disabled`);
}
}
if (!credentialOffer.credential_offer) {
throw Error(`No credential_offer present`);
}
credentialOffer.credential_offer = await toUniformCredentialOfferPayload(credentialOffer.credential_offer, { version: credentialOffer.version });
return credentialOffer as AssertedUniformCredentialOffer;
}
export async function resolveCredentialOfferURI(uri?: string): Promise<UniformCredentialOfferPayload | undefined> {
if (!uri) {
return undefined;
}
const response = (await getJson(uri)) as OpenIDResponse<UniformCredentialOfferPayload>;
if (!response || !response.successBody) {
throw Error(`Could not get credential offer from uri: ${uri}: ${JSON.stringify(response?.errorBody)}`);
}
return response.successBody as UniformCredentialOfferPayload;
}
export function toUniformCredentialOfferPayload(
offer: CredentialOfferPayload,
opts?: {
version?: OpenId4VCIVersion;
},
): UniformCredentialOfferPayload {
// todo: create test to check idempotence once a payload is already been made uniform.
const version = opts?.version ?? determineSpecVersionFromOffer(offer);
if (version >= OpenId4VCIVersion.VER_1_0_11) {
const orig = offer as UniformCredentialOfferPayload;
return {
...orig,
};
}
const grants: Grant = 'grants' in offer ? (offer.grants as Grant) : {};
let offerPayloadAsV8V9 = offer as CredentialOfferPayloadV1_0_08 | CredentialOfferPayloadV1_0_09;
if (isCredentialOfferVersion(offer, OpenId4VCIVersion.VER_1_0_08, OpenId4VCIVersion.VER_1_0_09)) {
if (offerPayloadAsV8V9.op_state) {
grants.authorization_code = {
...grants.authorization_code,
issuer_state: offerPayloadAsV8V9.op_state,
};
}
let user_pin_required = false;
if (typeof offerPayloadAsV8V9.user_pin_required === 'string') {
user_pin_required = offerPayloadAsV8V9.user_pin_required === 'true' || offerPayloadAsV8V9.user_pin_required === 'yes';
} else if (offerPayloadAsV8V9.user_pin_required !== undefined) {
user_pin_required = offerPayloadAsV8V9.user_pin_required;
}
if (offerPayloadAsV8V9[PRE_AUTH_CODE_LITERAL]) {
grants[PRE_AUTH_GRANT_LITERAL] = {
'pre-authorized_code': offerPayloadAsV8V9[PRE_AUTH_CODE_LITERAL],
user_pin_required,
};
}
}
const issuer = getIssuerFromCredentialOfferPayload(offer);
if (version === OpenId4VCIVersion.VER_1_0_09) {
offerPayloadAsV8V9 = offer as CredentialOfferPayloadV1_0_09;
return {
// credential_definition: getCredentialsSupported(never, offerPayloadAsV8V9.credentials).map(sup => {credentialSubject: sup.credentialSubject})[0],
credential_issuer: issuer ?? offerPayloadAsV8V9.issuer,
credentials: offerPayloadAsV8V9.credentials,
grants,
};
}
if (version === OpenId4VCIVersion.VER_1_0_08) {
offerPayloadAsV8V9 = offer as CredentialOfferPayloadV1_0_08;
return {
credential_issuer: issuer ?? offerPayloadAsV8V9.issuer,
credentials: Array.isArray(offerPayloadAsV8V9.credential_type) ? offerPayloadAsV8V9.credential_type : [offerPayloadAsV8V9.credential_type],
grants,
} as UniformCredentialOfferPayload;
}
throw Error(`Could not create uniform payload for version ${version}`);
}
export function determineFlowType(
suppliedOffer: AssertedUniformCredentialOffer | UniformCredentialOfferPayload,
version: OpenId4VCIVersion,
): AuthzFlowType[] {
const payload: UniformCredentialOfferPayload = getCredentialOfferPayload(suppliedOffer);
const supportedFlows: AuthzFlowType[] = [];
if (payload.grants?.authorization_code) {
supportedFlows.push(AuthzFlowType.AUTHORIZATION_CODE_FLOW);
}
if (payload.grants?.[PRE_AUTH_GRANT_LITERAL]?.[PRE_AUTH_CODE_LITERAL]) {
supportedFlows.push(AuthzFlowType.PRE_AUTHORIZED_CODE_FLOW);
}
if (supportedFlows.length === 0 && version < OpenId4VCIVersion.VER_1_0_09) {
// auth flow without op_state was possible in v08. The only way to know is that the detections would result in finding nothing.
supportedFlows.push(AuthzFlowType.AUTHORIZATION_CODE_FLOW);
}
return supportedFlows;
}
export function getCredentialOfferPayload(offer: AssertedUniformCredentialOffer | UniformCredentialOfferPayload): UniformCredentialOfferPayload {
let payload: UniformCredentialOfferPayload;
if ('credential_offer' in offer && offer['credential_offer']) {
payload = offer.credential_offer;
} else {
payload = offer as UniformCredentialOfferPayload;
}
return payload;
}
export function determineGrantTypes(
offer:
| AssertedUniformCredentialOffer
| UniformCredentialOfferPayload
| ({
grants: Grant;
} & Record<never, never>),
): GrantTypes[] {
let grants: Grant | undefined;
if ('grants' in offer && offer.grants) {
grants = offer.grants;
} else {
grants = getCredentialOfferPayload(offer as AssertedUniformCredentialOffer | UniformCredentialOfferPayload).grants;
}
const types: GrantTypes[] = [];
if (grants) {
if ('authorization_code' in grants) {
types.push(GrantTypes.AUTHORIZATION_CODE);
}
if (PRE_AUTH_GRANT_LITERAL in grants) {
types.push(GrantTypes.PRE_AUTHORIZED_CODE);
}
}
return types;
}
function getVersionFromURIParam(
credentialOfferURI: string,
currentVersion: OpenId4VCIVersion,
matchingVersion: OpenId4VCIVersion[],
param: string,
allowUpgrade = true,
) {
if (credentialOfferURI.includes(param)) {
return recordVersion(currentVersion, matchingVersion, param, allowUpgrade);
}
return currentVersion;
}
function recordVersion(currentVersion: OpenId4VCIVersion, matchingVersion: OpenId4VCIVersion[], key: string, allowUpgrade = true) {
matchingVersion = matchingVersion.sort().reverse();
if (currentVersion === OpenId4VCIVersion.VER_UNKNOWN) {
return matchingVersion[0];
} else if (matchingVersion.includes(currentVersion)) {
if (!allowUpgrade) {
return currentVersion;
}
return matchingVersion[0];
}
throw new Error(
`Invalid param. Some keys have been used from version: ${currentVersion} version while '${key}' is used from version: ${JSON.stringify(matchingVersion)}`,
);
}
export function getTypesFromOfferV1_0_11(credentialOffer: CredentialOfferPayloadV1_0_11, opts?: { filterVerifiableCredential: boolean }) {
const types = credentialOffer.credentials.reduce<string[]>((prev, curr) => {
// FIXME returning the string value is wrong (as it's an id), but just matching the current behavior of this library
// The credential_type (from draft 8) and the actual 'type' value in a VC (from draft 11) are mixed up
// Fix for this here: https://github.com/Sphereon-Opensource/OID4VCI/pull/54
if (typeof curr === 'string') {
return [...prev, curr];
} else if (curr.format === 'jwt_vc_json-ld' || curr.format === 'ldp_vc') {
return [...prev, ...curr.credential_definition.types];
} else if (curr.format === 'jwt_vc_json' || curr.format === 'jwt_vc') {
return [...prev, ...curr.types];
} else if (curr.format === 'vc+sd-jwt') {
return [...prev, curr.vct];
}
return prev;
}, []);
if (!types || types.length === 0) {
throw Error('Could not deduce types from credential offer');
}
if (opts?.filterVerifiableCredential) {
return types.filter((type) => type !== 'VerifiableCredential');
}
return types;
}