From a1028540432115f26677a860bf6bac10e630a1d9 Mon Sep 17 00:00:00 2001 From: Martin Auer Date: Tue, 30 Jul 2024 17:24:32 +0200 Subject: [PATCH] feat: add additional dpop retry mechanisms --- packages/client/lib/AccessTokenClient.ts | 32 ++++++++++++---- .../client/lib/AccessTokenClientV1_0_11.ts | 32 +++++++++++----- .../client/lib/CredentialRequestClient.ts | 37 +++++++++++++++---- .../lib/CredentialRequestClientV1_0_11.ts | 35 ++++++++++++++---- packages/client/lib/functions/dpopUtil.ts | 33 +++++++++++++++++ packages/common/lib/dpop/DPoP.ts | 3 ++ .../lib/DidJwtAdapter.ts | 2 +- .../did-auth-siop-adapter/lib/did/DidJWT.ts | 2 +- .../lib/types/Authorization.types.ts | 7 +++- 9 files changed, 148 insertions(+), 35 deletions(-) create mode 100644 packages/client/lib/functions/dpopUtil.ts diff --git a/packages/client/lib/AccessTokenClient.ts b/packages/client/lib/AccessTokenClient.ts index 837b8199..3c5e259e 100644 --- a/packages/client/lib/AccessTokenClient.ts +++ b/packages/client/lib/AccessTokenClient.ts @@ -7,6 +7,7 @@ import { AuthorizationServerOpts, AuthzFlowType, convertJsonToURI, + DPoPResponseParams, EndpointMetadata, formPost, getIssuerFromCredentialOfferPayload, @@ -25,10 +26,11 @@ import { ObjectUtils } from '@sphereon/ssi-types'; import { MetadataClientV1_0_13 } from './MetadataClientV1_0_13'; import { createJwtBearerClientAssertion } from './functions'; +import { dPoPShouldRetryRequestWithNonce } from './functions/dpopUtil'; import { LOG } from './types'; export class AccessTokenClient { - public async acquireAccessToken(opts: AccessTokenRequestOpts): Promise> { + public async acquireAccessToken(opts: AccessTokenRequestOpts): Promise> { const { asOpts, pin, codeVerifier, code, redirectUri, metadata, createDPoPOpts } = opts; const credentialOffer = opts.credentialOffer ? await assertedUniformCredentialOffer(opts.credentialOffer) : undefined; @@ -78,7 +80,7 @@ export class AccessTokenClient { asOpts?: AuthorizationServerOpts; issuerOpts?: IssuerOpts; createDPoPOpts?: CreateDPoPClientOpts; - }): Promise> { + }): Promise> { this.validate(accessTokenRequest, pinMetadata); const requestTokenURL = AccessTokenClient.determineTokenURL({ @@ -91,17 +93,31 @@ export class AccessTokenClient { : undefined, }); - let dPoP: string | undefined; - if (createDPoPOpts?.dPoPSigningAlgValuesSupported && createDPoPOpts.dPoPSigningAlgValuesSupported.length > 0) { - dPoP = createDPoPOpts ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, requestTokenURL)) : undefined; + const useDpop = createDPoPOpts?.dPoPSigningAlgValuesSupported && createDPoPOpts.dPoPSigningAlgValuesSupported.length > 0; + let dPoP = useDpop ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, requestTokenURL)) : undefined; + + let response = await this.sendAuthCode(requestTokenURL, accessTokenRequest, dPoP ? { headers: { dPoP } } : undefined); + + let nextDPoPNonce = createDPoPOpts?.jwtPayloadProps.nonce; + const retryWithNonce = dPoPShouldRetryRequestWithNonce(response); + if (retryWithNonce.ok && createDPoPOpts) { + createDPoPOpts.jwtPayloadProps.nonce = retryWithNonce.dpopNonce; + + dPoP = await createDPoP(getCreateDPoPOptions(createDPoPOpts, requestTokenURL)); + response = await this.sendAuthCode(requestTokenURL, accessTokenRequest, dPoP ? { headers: { dPoP } } : undefined); + const successDPoPNonce = response.origResponse.headers.get('DPoP-Nonce'); + + nextDPoPNonce = successDPoPNonce ?? retryWithNonce.dpopNonce; } - const response = await this.sendAuthCode(requestTokenURL, accessTokenRequest, dPoP ? { headers: { dPoP } } : undefined); if (response.successBody && createDPoPOpts && createDPoPOpts && response.successBody.token_type !== 'DPoP') { throw new Error('Invalid token type returned. Expected DPoP. Received: ' + response.successBody.token_type); } - return response; + return { + ...response, + params: { ...(nextDPoPNonce && { dpop: { dpopNonce: nextDPoPNonce } }) }, + }; } public async createAccessTokenRequest(opts: Omit): Promise { @@ -239,7 +255,7 @@ export class AccessTokenClient { requestTokenURL: string, accessTokenRequest: AccessTokenRequest, opts?: { headers?: Record }, - ): Promise> { + ): Promise> { return await formPost(requestTokenURL, convertJsonToURI(accessTokenRequest, { mode: JsonURIMode.X_FORM_WWW_URLENCODED }), { customHeaders: opts?.headers ? opts.headers : undefined, }); diff --git a/packages/client/lib/AccessTokenClientV1_0_11.ts b/packages/client/lib/AccessTokenClientV1_0_11.ts index 9feffe13..84432121 100644 --- a/packages/client/lib/AccessTokenClientV1_0_11.ts +++ b/packages/client/lib/AccessTokenClientV1_0_11.ts @@ -9,6 +9,7 @@ import { convertJsonToURI, CredentialOfferV1_0_11, CredentialOfferV1_0_13, + DPoPResponseParams, EndpointMetadata, formPost, getIssuerFromCredentialOfferPayload, @@ -28,11 +29,12 @@ import Debug from 'debug'; import { MetadataClientV1_0_13 } from './MetadataClientV1_0_13'; import { createJwtBearerClientAssertion } from './functions'; +import { dPoPShouldRetryRequestWithNonce } from './functions/dpopUtil'; const debug = Debug('sphereon:oid4vci:token'); export class AccessTokenClientV1_0_11 { - public async acquireAccessToken(opts: AccessTokenRequestOpts): Promise> { + public async acquireAccessToken(opts: AccessTokenRequestOpts): Promise> { const { asOpts, pin, codeVerifier, code, redirectUri, metadata, createDPoPOpts } = opts; const credentialOffer = opts.credentialOffer ? await assertedUniformCredentialOffer(opts.credentialOffer) : undefined; @@ -82,7 +84,7 @@ export class AccessTokenClientV1_0_11 { asOpts?: AuthorizationServerOpts; issuerOpts?: IssuerOpts; createDPoPOpts?: CreateDPoPClientOpts; - }): Promise> { + }): Promise> { this.validate(accessTokenRequest, isPinRequired); const requestTokenURL = AccessTokenClientV1_0_11.determineTokenURL({ @@ -95,18 +97,30 @@ export class AccessTokenClientV1_0_11 { : undefined, }); - let dPoP: string | undefined; - if (createDPoPOpts?.dPoPSigningAlgValuesSupported && createDPoPOpts.dPoPSigningAlgValuesSupported.length > 0) { - dPoP = createDPoPOpts ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, requestTokenURL)) : undefined; - } + const useDpop = createDPoPOpts?.dPoPSigningAlgValuesSupported && createDPoPOpts.dPoPSigningAlgValuesSupported.length > 0; + let dPoP = useDpop ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, requestTokenURL)) : undefined; + + let response = await this.sendAuthCode(requestTokenURL, accessTokenRequest, dPoP ? { headers: { dPoP } } : undefined); - const response = await this.sendAuthCode(requestTokenURL, accessTokenRequest, dPoP ? { headers: { dPoP } } : undefined); + let nextDPoPNonce = createDPoPOpts?.jwtPayloadProps.nonce; + const retryWithNonce = dPoPShouldRetryRequestWithNonce(response); + if (retryWithNonce.ok && createDPoPOpts) { + createDPoPOpts.jwtPayloadProps.nonce = retryWithNonce.dpopNonce; + + dPoP = await createDPoP(getCreateDPoPOptions(createDPoPOpts, requestTokenURL)); + response = await this.sendAuthCode(requestTokenURL, accessTokenRequest, dPoP ? { headers: { dPoP } } : undefined); + const successDPoPNonce = response.origResponse.headers.get('DPoP-Nonce'); + + nextDPoPNonce = successDPoPNonce ?? retryWithNonce.dpopNonce; + } if (response.successBody && createDPoPOpts && createDPoPOpts && response.successBody.token_type !== 'DPoP') { throw new Error('Invalid token type returned. Expected DPoP. Received: ' + response.successBody.token_type); } - - return response; + return { + ...response, + params: { ...(nextDPoPNonce && { dpop: { dpopNonce: nextDPoPNonce } }) }, + }; } public async createAccessTokenRequest(opts: Omit): Promise { diff --git a/packages/client/lib/CredentialRequestClient.ts b/packages/client/lib/CredentialRequestClient.ts index 8879d099..5fc77c17 100644 --- a/packages/client/lib/CredentialRequestClient.ts +++ b/packages/client/lib/CredentialRequestClient.ts @@ -3,6 +3,7 @@ import { acquireDeferredCredential, CredentialRequestV1_0_13, CredentialResponse, + DPoPResponseParams, getCredentialRequestForVersion, getUniformFormat, isDeferredCredentialResponse, @@ -22,6 +23,7 @@ import Debug from 'debug'; import { CredentialRequestClientBuilderV1_0_11 } from './CredentialRequestClientBuilderV1_0_11'; import { CredentialRequestClientBuilderV1_0_13 } from './CredentialRequestClientBuilderV1_0_13'; import { ProofOfPossessionBuilder } from './ProofOfPossessionBuilder'; +import { dPoPShouldRetryResourceRequestWithNonce } from './functions/dpopUtil'; const debug = Debug('sphereon:oid4vci:credential'); @@ -91,7 +93,7 @@ export class CredentialRequestClient { format?: CredentialFormat | OID4VCICredentialFormat; subjectIssuance?: ExperimentalSubjectIssuance; createDPoPOpts?: CreateDPoPClientOpts; - }): Promise & { access_token: string }> { + }): Promise & { access_token: string }> { const { credentialIdentifier, credentialTypes, proofInput, format, context, subjectIssuance } = opts; const request = await this.createCredentialRequest({ @@ -109,7 +111,7 @@ export class CredentialRequestClient { public async acquireCredentialsUsingRequest( uniformRequest: UniformCredentialRequest, createDPoPOpts?: CreateDPoPClientOpts, - ): Promise & { access_token: string }> { + ): Promise & { access_token: string }> { if (this.version() < OpenId4VCIVersion.VER_1_0_13) { throw new Error('Versions below v1.0.13 (draft 13) are not supported by the V13 credential request client.'); } @@ -123,17 +125,32 @@ export class CredentialRequestClient { debug(`request\n: ${JSON.stringify(request, null, 2)}`); const requestToken: string = this.credentialRequestOpts.token; - let dPoP: string | undefined; - if (createDPoPOpts) { - dPoP = createDPoPOpts ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, credentialEndpoint, { accessToken: requestToken })) : undefined; - } + let dPoP = createDPoPOpts ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, credentialEndpoint, { accessToken: requestToken })) : undefined; let response = (await post(credentialEndpoint, JSON.stringify(request), { bearerToken: requestToken, - customHeaders: { ...(createDPoPOpts && { dpop: dPoP }) }, + customHeaders: { ...(dPoP && { dPoP }) }, })) as OpenIDResponse & { access_token: string; }; + + let nextDPoPNonce = createDPoPOpts?.jwtPayloadProps.nonce; + const retryWithNonce = dPoPShouldRetryResourceRequestWithNonce(response); + if (retryWithNonce.ok && createDPoPOpts) { + createDPoPOpts.jwtPayloadProps.nonce = retryWithNonce.dpopNonce; + dPoP = await createDPoP(getCreateDPoPOptions(createDPoPOpts, credentialEndpoint, { accessToken: requestToken })); + + response = (await post(credentialEndpoint, JSON.stringify(request), { + bearerToken: requestToken, + customHeaders: { ...(createDPoPOpts && { dPoP }) }, + })) as OpenIDResponse & { + access_token: string; + }; + + const successDPoPNonce = response.origResponse.headers.get('DPoP-Nonce'); + nextDPoPNonce = successDPoPNonce ?? retryWithNonce.dpopNonce; + } + this._isDeferred = isDeferredCredentialResponse(response); if (this.isDeferred() && this.credentialRequestOpts.deferredCredentialAwait && response.successBody) { response = await this.acquireDeferredCredential(response.successBody, { bearerToken: this.credentialRequestOpts.token }); @@ -146,7 +163,11 @@ export class CredentialRequestClient { } } debug(`Credential endpoint ${credentialEndpoint} response:\r\n${JSON.stringify(response, null, 2)}`); - return response; + + return { + ...response, + params: { ...(nextDPoPNonce && { dpop: { dpopNonce: nextDPoPNonce } }) }, + }; } public async acquireDeferredCredential( diff --git a/packages/client/lib/CredentialRequestClientV1_0_11.ts b/packages/client/lib/CredentialRequestClientV1_0_11.ts index ee9983a4..0e56331f 100644 --- a/packages/client/lib/CredentialRequestClientV1_0_11.ts +++ b/packages/client/lib/CredentialRequestClientV1_0_11.ts @@ -2,6 +2,7 @@ import { createDPoP, CreateDPoPClientOpts, getCreateDPoPOptions } from '@sphereo import { acquireDeferredCredential, CredentialResponse, + DPoPResponseParams, getCredentialRequestForVersion, getUniformFormat, isDeferredCredentialResponse, @@ -21,6 +22,7 @@ import Debug from 'debug'; import { buildProof } from './CredentialRequestClient'; import { CredentialRequestClientBuilderV1_0_11 } from './CredentialRequestClientBuilderV1_0_11'; import { ProofOfPossessionBuilder } from './ProofOfPossessionBuilder'; +import { dPoPShouldRetryResourceRequestWithNonce } from './functions/dpopUtil'; const debug = Debug('sphereon:oid4vci:credential'); @@ -66,7 +68,7 @@ export class CredentialRequestClientV1_0_11 { context?: string[]; format?: CredentialFormat | OID4VCICredentialFormat; createDPoPOpts?: CreateDPoPClientOpts; - }): Promise & { access_token: string }> { + }): Promise & { access_token: string }> { const { credentialTypes, proofInput, format, context } = opts; const request = await this.createCredentialRequest({ proofInput, credentialTypes, context, format, version: this.version() }); @@ -76,7 +78,7 @@ export class CredentialRequestClientV1_0_11 { public async acquireCredentialsUsingRequest( uniformRequest: UniformCredentialRequest, createDPoPOpts?: CreateDPoPClientOpts, - ): Promise & { access_token: string }> { + ): Promise & { access_token: string }> { const request = getCredentialRequestForVersion(uniformRequest, this.version()); const credentialEndpoint: string = this.credentialRequestOpts.credentialEndpoint; if (!isValidURL(credentialEndpoint)) { @@ -87,10 +89,7 @@ export class CredentialRequestClientV1_0_11 { debug(`request\n: ${JSON.stringify(request, null, 2)}`); const requestToken: string = this.credentialRequestOpts.token; - let dPoP: string | undefined; - if (createDPoPOpts) { - dPoP = createDPoPOpts ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, credentialEndpoint, { accessToken: requestToken })) : undefined; - } + let dPoP = createDPoPOpts ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, credentialEndpoint, { accessToken: requestToken })) : undefined; let response = (await post(credentialEndpoint, JSON.stringify(request), { bearerToken: requestToken, @@ -98,6 +97,24 @@ export class CredentialRequestClientV1_0_11 { })) as OpenIDResponse & { access_token: string; }; + + let nextDPoPNonce = createDPoPOpts?.jwtPayloadProps.nonce; + const retryWithNonce = dPoPShouldRetryResourceRequestWithNonce(response); + if (retryWithNonce.ok && createDPoPOpts) { + createDPoPOpts.jwtPayloadProps.nonce = retryWithNonce.dpopNonce; + dPoP = await createDPoP(getCreateDPoPOptions(createDPoPOpts, credentialEndpoint, { accessToken: requestToken })); + + response = (await post(credentialEndpoint, JSON.stringify(request), { + bearerToken: requestToken, + customHeaders: { ...(createDPoPOpts && { dPoP }) }, + })) as OpenIDResponse & { + access_token: string; + }; + + const successDPoPNonce = response.origResponse.headers.get('DPoP-Nonce'); + nextDPoPNonce = successDPoPNonce ?? retryWithNonce.dpopNonce; + } + this._isDeferred = isDeferredCredentialResponse(response); if (this.isDeferred() && this.credentialRequestOpts.deferredCredentialAwait && response.successBody) { response = await this.acquireDeferredCredential(response.successBody, { bearerToken: this.credentialRequestOpts.token }); @@ -105,7 +122,11 @@ export class CredentialRequestClientV1_0_11 { response.access_token = requestToken; debug(`Credential endpoint ${credentialEndpoint} response:\r\n${JSON.stringify(response, null, 2)}`); - return response; + + return { + ...response, + params: { ...(nextDPoPNonce && { dpop: { dpopNonce: nextDPoPNonce } }) }, + }; } public async acquireDeferredCredential( diff --git a/packages/client/lib/functions/dpopUtil.ts b/packages/client/lib/functions/dpopUtil.ts new file mode 100644 index 00000000..bdd61cd4 --- /dev/null +++ b/packages/client/lib/functions/dpopUtil.ts @@ -0,0 +1,33 @@ +import { dpopResourceAuthenticateError, dpopTokenRequestNonceError } from '@sphereon/oid4vc-common'; +import { OpenIDResponse } from 'oid4vci-common'; + +export function dPoPShouldRetryRequestWithNonce(response: OpenIDResponse) { + if (response.errorBody && response.errorBody.error === dpopTokenRequestNonceError) { + const dPoPNonce = response.errorBody.headers.get('DPoP-Nonce'); + if (!dPoPNonce) { + throw new Error('The DPoP nonce was not returned'); + } + + return { ok: true, dpopNonce: dPoPNonce } as const; + } + + return { ok: false } as const; +} + +export function dPoPShouldRetryResourceRequestWithNonce(response: OpenIDResponse) { + if (response.errorBody && response.origResponse.status === 401) { + const wwwAuthenticateHeader = response.errorBody.headers.get('WWW-Authenticate'); + if (!wwwAuthenticateHeader?.includes(dpopResourceAuthenticateError)) { + return { ok: false } as const; + } + + const dPoPNonce = response.errorBody.headers.get('DPoP-Nonce'); + if (!dPoPNonce) { + throw new Error('The DPoP nonce was not returned'); + } + + return { ok: true, dpopNonce: dPoPNonce } as const; + } + + return { ok: false } as const; +} diff --git a/packages/common/lib/dpop/DPoP.ts b/packages/common/lib/dpop/DPoP.ts index 1a397948..ed2d59e1 100644 --- a/packages/common/lib/dpop/DPoP.ts +++ b/packages/common/lib/dpop/DPoP.ts @@ -17,6 +17,9 @@ import { VerifyJwtCallbackBase, } from './../jwt'; +export const dpopTokenRequestNonceError = 'use_dpop_nonce'; +export const dpopResourceAuthenticateError = 'DPoP error="use_dpop_nonce", error_description="Resource server requires nonce in DPoP proof"'; + export interface DPoPJwtIssuerWithContext extends JwtIssuerJwk { type: 'dpop'; dPoPSigningAlgValuesSupported?: string[]; diff --git a/packages/did-auth-siop-adapter/lib/DidJwtAdapter.ts b/packages/did-auth-siop-adapter/lib/DidJwtAdapter.ts index 31fb1fdf..e49f3efb 100644 --- a/packages/did-auth-siop-adapter/lib/DidJwtAdapter.ts +++ b/packages/did-auth-siop-adapter/lib/DidJwtAdapter.ts @@ -1,6 +1,6 @@ -import { JwtHeader, JwtPayload } from '@sphereon/oid4vc-common' import { AuthorizationRequestPayload, IDTokenPayload, JwtIssuerWithContext, RequestObjectPayload } from '@sphereon/did-auth-siop' import { JwtVerifier } from '@sphereon/did-auth-siop' +import { JwtHeader, JwtPayload } from '@sphereon/oid4vc-common' import { Resolvable } from 'did-resolver' import { getAudience, getSubDidFromPayload, signIDTokenPayload, signRequestObjectPayload, validateLinkedDomainWithDid, verifyDidJWT } from './did' diff --git a/packages/did-auth-siop-adapter/lib/did/DidJWT.ts b/packages/did-auth-siop-adapter/lib/did/DidJWT.ts index 83be1769..13636373 100644 --- a/packages/did-auth-siop-adapter/lib/did/DidJWT.ts +++ b/packages/did-auth-siop-adapter/lib/did/DidJWT.ts @@ -1,4 +1,3 @@ -import { SigningAlgo } from '@sphereon/oid4vc-common' import { post } from '@sphereon/did-auth-siop' import { DEFAULT_EXPIRATION_TIME, @@ -10,6 +9,7 @@ import { SIOPResonse, VerifiedJWT, } from '@sphereon/did-auth-siop' +import { SigningAlgo } from '@sphereon/oid4vc-common' import { createJWT, decodeJWT, diff --git a/packages/oid4vci-common/lib/types/Authorization.types.ts b/packages/oid4vci-common/lib/types/Authorization.types.ts index 1a4ef8f4..050d5217 100644 --- a/packages/oid4vci-common/lib/types/Authorization.types.ts +++ b/packages/oid4vci-common/lib/types/Authorization.types.ts @@ -324,10 +324,15 @@ export interface AccessTokenRequest { [s: string]: unknown; } -export interface OpenIDResponse { +export interface OpenIDResponse { origResponse: Response; successBody?: T; errorBody?: ErrorResponse; + params?: P; +} + +export interface DPoPResponseParams { + dpop?: { dpopNonce: string }; } export interface AccessTokenResponse {