@sphereon/oid4vci-common
Version:
OpenID 4 Verifiable Credential Issuance Common Types
189 lines (174 loc) • 6.51 kB
text/typescript
import { BaseJWK, JWK } from '@sphereon/oid4vc-common';
import Debug from 'debug';
import { jwtDecode } from 'jwt-decode';
import { PoPMode, VCI_LOG_COMMON } from '..';
import {
BAD_PARAMS,
JWS_NOT_VALID,
Jwt,
JWTHeader,
JWTPayload,
JWTVerifyCallback,
JwtVerifyResult,
ProofOfPossession,
ProofOfPossessionCallbacks,
Typ,
} from '../types';
const debug = Debug('sphereon:openid4vci:common');
/**
*
* - proofOfPossessionCallback: JWTSignerCallback
* Mandatory if you want to create (sign) ProofOfPossession
* - proofOfPossessionVerifierCallback?: JWTVerifyCallback
* If exists, verifies the ProofOfPossession
* - proofOfPossessionCallbackArgs: ProofOfPossessionCallbackArgs
* arguments needed for signing ProofOfPossession
* - proofOfPossessionCallback: JWTSignerCallback
* Mandatory to create (sign) ProofOfPossession
* - proofOfPossessionVerifierCallback?: JWTVerifyCallback
* If exists, verifies the ProofOfPossession
* @param popMode
* @param callbacks
* @param jwtProps
* @param existingJwt
* - Optional, clientId of the party requesting the credential
*/
export const createProofOfPossession = async <DIDDoc extends object = never>(
popMode: PoPMode,
callbacks: ProofOfPossessionCallbacks,
jwtProps?: JwtProps,
existingJwt?: Jwt,
): Promise<ProofOfPossession> => {
if (!callbacks.signCallback) {
debug(`no jwt signer callback or arguments supplied!`);
throw new Error(BAD_PARAMS);
}
const jwtPayload = createJWT(popMode, jwtProps, existingJwt);
const jwt = await callbacks.signCallback(jwtPayload, jwtPayload.header.kid);
const proof = {
proof_type: 'jwt',
jwt,
} as ProofOfPossession;
try {
partiallyValidateJWS(jwt);
if (callbacks.verifyCallback) {
debug(`Calling supplied verify callback....`);
await callbacks.verifyCallback({ jwt, kid: jwtPayload.header.kid });
debug(`Supplied verify callback return success result`);
}
} catch {
debug(`JWS was not valid`);
throw new Error(JWS_NOT_VALID);
}
debug(`Proof of Possession JWT:\r\n${jwt}`);
return proof;
};
const partiallyValidateJWS = (jws: string): void => {
if (jws.split('.').length !== 3 || !jws.startsWith('ey')) {
throw new Error(JWS_NOT_VALID);
}
};
export const isJWS = (token: string): boolean => {
try {
partiallyValidateJWS(token);
return true;
} catch (e) {
return false;
}
};
export const extractBearerToken = (authorizationHeader?: string): string | undefined => {
return authorizationHeader ? /Bearer (.*)/i.exec(authorizationHeader)?.[1] : undefined;
};
export const validateJWT = async <DIDDoc extends object = never>(
jwt?: string,
opts?: { kid?: string; accessTokenVerificationCallback?: JWTVerifyCallback },
): Promise<JwtVerifyResult> => {
if (!jwt) {
throw Error('No JWT was supplied');
}
if (!opts?.accessTokenVerificationCallback) {
VCI_LOG_COMMON.warning(`No access token verification callback supplied. Access tokens will not be verified, except for a very basic check`);
partiallyValidateJWS(jwt);
const header = jwtDecode<JWTHeader>(jwt, { header: true });
const payload = jwtDecode<JWTPayload>(jwt, { header: false });
return {
jwt: { header, payload } satisfies Jwt,
...header,
...payload,
};
} else {
return await opts.accessTokenVerificationCallback({ jwt, kid: opts.kid });
}
};
export interface JwtProps {
typ?: Typ;
kid?: string;
jwk?: JWK;
x5c?: string[];
aud?: string | string[];
issuer?: string;
clientId?: string;
alg?: string;
jti?: string;
nonce?: string;
}
const createJWT = (mode: PoPMode, jwtProps?: JwtProps, existingJwt?: Jwt): Jwt => {
const aud =
mode === 'pop'
? getJwtProperty<string | string[]>('aud', true, jwtProps?.issuer, existingJwt?.payload?.aud)
: getJwtProperty<string | string[]>('aud', false, jwtProps?.aud, existingJwt?.payload?.aud);
const iss =
mode === 'pop'
? getJwtProperty<string>('iss', false, jwtProps?.clientId, existingJwt?.payload?.iss)
: getJwtProperty<string>('iss', false, jwtProps?.issuer, existingJwt?.payload?.iss);
const client_id = mode === 'JWT' ? getJwtProperty<string>('client_id', false, jwtProps?.clientId, existingJwt?.payload?.client_id) : undefined;
const jti = getJwtProperty<string>('jti', false, jwtProps?.jti, existingJwt?.payload?.jti);
const typ = getJwtProperty<string>('typ', true, jwtProps?.typ, existingJwt?.header?.typ, 'openid4vci-proof+jwt');
const nonce = getJwtProperty<string>('nonce', false, jwtProps?.nonce, existingJwt?.payload?.nonce); // Officially this is required, but some implementations don't have it
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const alg = getJwtProperty<string>('alg', false, jwtProps?.alg, existingJwt?.header?.alg, 'ES256')!;
const kid = getJwtProperty<string>('kid', false, jwtProps?.kid, existingJwt?.header?.kid);
const jwk = getJwtProperty<BaseJWK>('jwk', false, jwtProps?.jwk, existingJwt?.header?.jwk);
const x5c = getJwtProperty<string[]>('x5c', false, jwtProps?.x5c, existingJwt?.header.x5c);
const jwt: Partial<Jwt> = { ...existingJwt };
const now = +new Date();
const jwtPayload: Partial<JWTPayload> = {
...(aud && { aud }),
iat: jwt.payload?.iat ?? Math.floor(now / 1000) - 60, // Let's ensure we subtract 60 seconds for potential time offsets
exp: jwt.payload?.exp ?? Math.floor(now / 1000) + 10 * 60,
nonce,
...(client_id && { client_id }),
...(iss && { iss }),
...(jti && { jti }),
};
const jwtHeader: JWTHeader = {
typ,
alg,
...(kid && { kid }),
...(jwk && { jwk }),
...(x5c && { x5c }),
};
return {
payload: { ...jwt.payload, ...jwtPayload },
header: { ...jwt.header, ...jwtHeader },
};
};
const getJwtProperty = <T>(
propertyName: string,
required: boolean,
option?: string | string[] | JWK,
jwtProperty?: T,
defaultValue?: T,
): T | undefined => {
if ((typeof option === 'string' || Array.isArray(option)) && option && jwtProperty && option !== jwtProperty) {
throw Error(`Cannot have a property '${propertyName}' with value '${option}' and different JWT value '${jwtProperty}' at the same time`);
}
let result = (jwtProperty ? jwtProperty : option) as T | undefined;
if (!result) {
if (required) {
throw Error(`No ${propertyName} property provided either in a JWT or as option`);
}
result = defaultValue;
}
return result;
};