UNPKG

@sphereon/oid4vc-common

Version:

OpenID 4 Verifiable Credentials Common

243 lines (208 loc) 8.99 kB
import { jwtDecode } from 'jwt-decode'; import * as u8a from 'uint8arrays'; import { v4 as uuidv4 } from 'uuid'; import { defaultHasher } from '../hasher'; import { calculateJwkThumbprint, CreateJwtCallback, epochTime, getNowSkewed, JWK, JwtHeader, JwtIssuerJwk, JwtPayload, parseJWT, SigningAlgo, VerifyJwtCallbackBase, } from './../jwt'; export const dpopTokenRequestNonceError = 'use_dpop_nonce'; export interface DPoPJwtIssuerWithContext extends JwtIssuerJwk { type: 'dpop'; dPoPSigningAlgValuesSupported?: string[]; } export type DPoPJwtPayloadProps = { htu: string; iat: number; htm: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'TRACE' | 'CONNECT' | 'PATCH'; ath?: string; nonce?: string; jti: string; }; export type DPoPJwtHeaderProps = { typ: 'dpop+jwt'; alg: SigningAlgo; jwk: JWK }; export type CreateDPoPJwtPayloadProps = Omit<DPoPJwtPayloadProps, 'iat' | 'jti' | 'ath'> & { accessToken?: string }; export interface CreateDPoPOpts<JwtPayloadProps = CreateDPoPJwtPayloadProps> { createJwtCallback: CreateJwtCallback<DPoPJwtIssuerWithContext>; jwtIssuer: Omit<JwtIssuerJwk, 'method' | 'type'>; jwtPayloadProps: Record<string, unknown> & JwtPayloadProps; dPoPSigningAlgValuesSupported?: (string | SigningAlgo)[]; } export type CreateDPoPClientOpts = CreateDPoPOpts<Omit<CreateDPoPJwtPayloadProps, 'htm' | 'htu'>>; export function getCreateDPoPOptions( createDPoPClientOpts: CreateDPoPClientOpts, endPointUrl: string, resourceRequestOpts?: { accessToken: string }, ): CreateDPoPOpts { const htu = endPointUrl.split('?')[0].split('#')[0]; return { ...createDPoPClientOpts, jwtPayloadProps: { ...createDPoPClientOpts.jwtPayloadProps, htu, htm: 'POST', ...(resourceRequestOpts && { accessToken: resourceRequestOpts.accessToken }), }, }; } export async function createDPoP(options: CreateDPoPOpts): Promise<string> { const { createJwtCallback, jwtIssuer, jwtPayloadProps, dPoPSigningAlgValuesSupported } = options; if (jwtPayloadProps.accessToken && (jwtPayloadProps.accessToken?.startsWith('DPoP ') || jwtPayloadProps.accessToken?.startsWith('Bearer '))) { throw new Error('expected access token without scheme'); } const ath = jwtPayloadProps.accessToken ? u8a.toString(defaultHasher(jwtPayloadProps.accessToken, 'sha256'), 'base64url') : undefined; return createJwtCallback( { method: 'jwk', type: 'dpop', alg: jwtIssuer.alg, jwk: jwtIssuer.jwk, dPoPSigningAlgValuesSupported }, { header: { ...jwtIssuer, typ: 'dpop+jwt', alg: jwtIssuer.alg, jwk: jwtIssuer.jwk }, payload: { ...jwtPayloadProps, iat: epochTime(), jti: uuidv4(), ...(ath && { ath }), }, }, ); } export type DPoPVerifyJwtCallback = VerifyJwtCallbackBase<JwtIssuerJwk & { type: 'dpop' }>; export interface DPoPVerifyOptions { expectedNonce?: string; acceptedAlgorithms?: (string | SigningAlgo)[]; // defaults to 300 seconds (5 minutes) maxIatAgeInSeconds?: number; expectAccessToken?: boolean; jwtVerifyCallback: DPoPVerifyJwtCallback; now?: number; } export async function verifyDPoP( request: { headers: Record<string, string | string[] | undefined>; fullUrl: string } & Pick<Request, 'method'>, options: DPoPVerifyOptions, ) { // There is not more than one DPoP HTTP request header field. const dpop = request.headers['dpop']; if (!dpop || typeof dpop !== 'string') { throw new Error('missing or invalid dpop header. Expected compact JWT'); } // The DPoP HTTP request header field value is a single and well-formed JWT. const { header: dPoPHeader, payload: dPoPPayload } = parseJWT<JwtHeader, JwtPayload & Partial<DPoPJwtPayloadProps>>(dpop); // Ensure all required header claims are present if (dPoPHeader.typ !== 'dpop+jwt' || !dPoPHeader.alg || !dPoPHeader.jwk || typeof dPoPHeader.jwk !== 'object' || dPoPHeader.jwk.d) { throw new Error('invalid_dpop_proof. Invalid header claims'); } // Ensure all required payload claims are present if (!dPoPPayload.htm || !dPoPPayload.htu || !dPoPPayload.iat || !dPoPPayload.jti) { throw new Error('invalid_dpop_proof. Missing required claims'); } // Validate alg is supported if (options?.acceptedAlgorithms && !options.acceptedAlgorithms.includes(dPoPHeader.alg)) { throw new Error(`invalid_dpop_proof. Invalid 'alg' claim '${dPoPHeader.alg}'. Only ${options.acceptedAlgorithms.join(', ')} are supported.`); } // Validate nonce if provided if ((options?.expectedNonce && !dPoPPayload.nonce) || dPoPPayload.nonce !== options.expectedNonce) { throw new Error('invalid_dpop_proof. Nonce mismatch'); } // Verify JWT signature try { const verificationResult = await options.jwtVerifyCallback( { method: 'jwk', type: 'dpop', jwk: dPoPHeader.jwk, alg: dPoPHeader.alg, }, { header: dPoPHeader, payload: dPoPPayload, raw: dpop, }, ); if (!verificationResult) { throw new Error('invalid_dpop_proof. Invalid JWT signature'); } } catch (error: unknown) { throw new Error('invalid_dpop_proof. Invalid JWT signature. ' + (error instanceof Error ? error.message : 'Unknown error')); } // Validate htm claim if (dPoPPayload.htm !== request.method) { throw new Error(`invalid_dpop_proof. Invalid htm claim. Must match request method '${request.method}'`); } // The htu claim matches the HTTP URI value for the HTTP request in which the JWT was received, ignoring any query and fragment parts. const currentUri = request.fullUrl.split('?')[0].split('#')[0]; if (dPoPPayload.htu !== currentUri) { throw new Error('invalid_dpop_proof. Invalid htu claim'); } // Validate nonce if provided if ((options.expectedNonce && dPoPPayload.nonce !== options.expectedNonce) || (!options.expectedNonce && dPoPPayload.nonce)) { throw new Error('invalid_dpop_proof. Nonce mismatch'); } // Validate iat claim const { nowSkewedPast, nowSkewedFuture } = getNowSkewed(options.now); if ( // iat claim is too far in the future nowSkewedPast - (options.maxIatAgeInSeconds ?? 60) > dPoPPayload.iat || // iat claim is too old nowSkewedFuture + (options.maxIatAgeInSeconds ?? 60) < dPoPPayload.iat ) { // 5 minute window throw new Error('invalid_dpop_proof. Invalid iat claim'); } // If access token is present, validate ath claim const authorizationHeader = request.headers.authorization; if (!options.expectAccessToken && authorizationHeader) { throw new Error('invalid_dpop_proof. Received an unexpected authorization header.'); } if (options.expectAccessToken) { if (!dPoPPayload.ath) { throw new Error('invalid_dpop_proof. Missing expected ath claim.'); } // validate that the DPOP proof is made for the provided access token if (!authorizationHeader || typeof authorizationHeader !== 'string' || !authorizationHeader.startsWith('DPoP ')) { throw new Error('invalid_dpop_proof. Invalid authorization header.'); } const accessToken = authorizationHeader.replace('DPoP ', ''); const expectedAth = u8a.toString(defaultHasher(accessToken, 'sha256'), 'base64url'); if (dPoPPayload.ath !== expectedAth) { throw new Error('invalid_dpop_proof. Invalid ath claim'); } // validate that the access token is signed with the same key as the DPOP proof const accessTokenPayload = jwtDecode<JwtPayload & { cnf?: { jkt?: string } }>(accessToken, { header: false }); if (!accessTokenPayload.cnf?.jkt) { throw new Error('invalid_dpop_proof. Access token is missing the jkt claim'); } const thumprint = await calculateJwkThumbprint(dPoPHeader.jwk, 'sha256'); if (accessTokenPayload.cnf?.jkt !== thumprint) { throw new Error('invalid_dpop_proof. JwkThumbprint mismatch'); } } // If all validations pass, return the dpop jwk return dPoPHeader.jwk; } /** * DPoP verifications for resource requests * For Bearer token compatibility jwt's must have a token_type claim * The access token itself must be validated before using this method * If the token_type is not DPoP, then the request is not a DPoP request * and we don't need to verify the DPoP proof */ export async function verifyResourceDPoP( request: { headers: Record<string, string | string[] | undefined>; fullUrl: string } & Pick<Request, 'method'>, options: Omit<DPoPVerifyOptions, 'expectAccessToken'>, ) { if (!request.headers.authorization || typeof request.headers.authorization !== 'string') { throw new Error('Received an invalid resource request. Missing authorization header.'); } const tokenPayload = jwtDecode<JwtPayload & { token_type?: string }>(request.headers.authorization, { header: false }); const tokenType = tokenPayload.token_type; if (tokenType !== 'DPoP') { return; } return verifyDPoP(request, { ...options, expectAccessToken: true }); }