Skip to content

Commit a102854

Browse files
committed
feat: add additional dpop retry mechanisms
1 parent 668c53f commit a102854

9 files changed

Lines changed: 148 additions & 35 deletions

File tree

packages/client/lib/AccessTokenClient.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
AuthorizationServerOpts,
88
AuthzFlowType,
99
convertJsonToURI,
10+
DPoPResponseParams,
1011
EndpointMetadata,
1112
formPost,
1213
getIssuerFromCredentialOfferPayload,
@@ -25,10 +26,11 @@ import { ObjectUtils } from '@sphereon/ssi-types';
2526

2627
import { MetadataClientV1_0_13 } from './MetadataClientV1_0_13';
2728
import { createJwtBearerClientAssertion } from './functions';
29+
import { dPoPShouldRetryRequestWithNonce } from './functions/dpopUtil';
2830
import { LOG } from './types';
2931

3032
export class AccessTokenClient {
31-
public async acquireAccessToken(opts: AccessTokenRequestOpts): Promise<OpenIDResponse<AccessTokenResponse>> {
33+
public async acquireAccessToken(opts: AccessTokenRequestOpts): Promise<OpenIDResponse<AccessTokenResponse, DPoPResponseParams>> {
3234
const { asOpts, pin, codeVerifier, code, redirectUri, metadata, createDPoPOpts } = opts;
3335

3436
const credentialOffer = opts.credentialOffer ? await assertedUniformCredentialOffer(opts.credentialOffer) : undefined;
@@ -78,7 +80,7 @@ export class AccessTokenClient {
7880
asOpts?: AuthorizationServerOpts;
7981
issuerOpts?: IssuerOpts;
8082
createDPoPOpts?: CreateDPoPClientOpts;
81-
}): Promise<OpenIDResponse<AccessTokenResponse>> {
83+
}): Promise<OpenIDResponse<AccessTokenResponse, DPoPResponseParams>> {
8284
this.validate(accessTokenRequest, pinMetadata);
8385

8486
const requestTokenURL = AccessTokenClient.determineTokenURL({
@@ -91,17 +93,31 @@ export class AccessTokenClient {
9193
: undefined,
9294
});
9395

94-
let dPoP: string | undefined;
95-
if (createDPoPOpts?.dPoPSigningAlgValuesSupported && createDPoPOpts.dPoPSigningAlgValuesSupported.length > 0) {
96-
dPoP = createDPoPOpts ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, requestTokenURL)) : undefined;
96+
const useDpop = createDPoPOpts?.dPoPSigningAlgValuesSupported && createDPoPOpts.dPoPSigningAlgValuesSupported.length > 0;
97+
let dPoP = useDpop ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, requestTokenURL)) : undefined;
98+
99+
let response = await this.sendAuthCode(requestTokenURL, accessTokenRequest, dPoP ? { headers: { dPoP } } : undefined);
100+
101+
let nextDPoPNonce = createDPoPOpts?.jwtPayloadProps.nonce;
102+
const retryWithNonce = dPoPShouldRetryRequestWithNonce(response);
103+
if (retryWithNonce.ok && createDPoPOpts) {
104+
createDPoPOpts.jwtPayloadProps.nonce = retryWithNonce.dpopNonce;
105+
106+
dPoP = await createDPoP(getCreateDPoPOptions(createDPoPOpts, requestTokenURL));
107+
response = await this.sendAuthCode(requestTokenURL, accessTokenRequest, dPoP ? { headers: { dPoP } } : undefined);
108+
const successDPoPNonce = response.origResponse.headers.get('DPoP-Nonce');
109+
110+
nextDPoPNonce = successDPoPNonce ?? retryWithNonce.dpopNonce;
97111
}
98-
const response = await this.sendAuthCode(requestTokenURL, accessTokenRequest, dPoP ? { headers: { dPoP } } : undefined);
99112

100113
if (response.successBody && createDPoPOpts && createDPoPOpts && response.successBody.token_type !== 'DPoP') {
101114
throw new Error('Invalid token type returned. Expected DPoP. Received: ' + response.successBody.token_type);
102115
}
103116

104-
return response;
117+
return {
118+
...response,
119+
params: { ...(nextDPoPNonce && { dpop: { dpopNonce: nextDPoPNonce } }) },
120+
};
105121
}
106122

107123
public async createAccessTokenRequest(opts: Omit<AccessTokenRequestOpts, 'createDPoPOpts'>): Promise<AccessTokenRequest> {
@@ -239,7 +255,7 @@ export class AccessTokenClient {
239255
requestTokenURL: string,
240256
accessTokenRequest: AccessTokenRequest,
241257
opts?: { headers?: Record<string, string> },
242-
): Promise<OpenIDResponse<AccessTokenResponse>> {
258+
): Promise<OpenIDResponse<AccessTokenResponse, DPoPResponseParams>> {
243259
return await formPost(requestTokenURL, convertJsonToURI(accessTokenRequest, { mode: JsonURIMode.X_FORM_WWW_URLENCODED }), {
244260
customHeaders: opts?.headers ? opts.headers : undefined,
245261
});

packages/client/lib/AccessTokenClientV1_0_11.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
convertJsonToURI,
1010
CredentialOfferV1_0_11,
1111
CredentialOfferV1_0_13,
12+
DPoPResponseParams,
1213
EndpointMetadata,
1314
formPost,
1415
getIssuerFromCredentialOfferPayload,
@@ -28,11 +29,12 @@ import Debug from 'debug';
2829

2930
import { MetadataClientV1_0_13 } from './MetadataClientV1_0_13';
3031
import { createJwtBearerClientAssertion } from './functions';
32+
import { dPoPShouldRetryRequestWithNonce } from './functions/dpopUtil';
3133

3234
const debug = Debug('sphereon:oid4vci:token');
3335

3436
export class AccessTokenClientV1_0_11 {
35-
public async acquireAccessToken(opts: AccessTokenRequestOpts): Promise<OpenIDResponse<AccessTokenResponse>> {
37+
public async acquireAccessToken(opts: AccessTokenRequestOpts): Promise<OpenIDResponse<AccessTokenResponse, DPoPResponseParams>> {
3638
const { asOpts, pin, codeVerifier, code, redirectUri, metadata, createDPoPOpts } = opts;
3739

3840
const credentialOffer = opts.credentialOffer ? await assertedUniformCredentialOffer(opts.credentialOffer) : undefined;
@@ -82,7 +84,7 @@ export class AccessTokenClientV1_0_11 {
8284
asOpts?: AuthorizationServerOpts;
8385
issuerOpts?: IssuerOpts;
8486
createDPoPOpts?: CreateDPoPClientOpts;
85-
}): Promise<OpenIDResponse<AccessTokenResponse>> {
87+
}): Promise<OpenIDResponse<AccessTokenResponse, DPoPResponseParams>> {
8688
this.validate(accessTokenRequest, isPinRequired);
8789

8890
const requestTokenURL = AccessTokenClientV1_0_11.determineTokenURL({
@@ -95,18 +97,30 @@ export class AccessTokenClientV1_0_11 {
9597
: undefined,
9698
});
9799

98-
let dPoP: string | undefined;
99-
if (createDPoPOpts?.dPoPSigningAlgValuesSupported && createDPoPOpts.dPoPSigningAlgValuesSupported.length > 0) {
100-
dPoP = createDPoPOpts ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, requestTokenURL)) : undefined;
101-
}
100+
const useDpop = createDPoPOpts?.dPoPSigningAlgValuesSupported && createDPoPOpts.dPoPSigningAlgValuesSupported.length > 0;
101+
let dPoP = useDpop ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, requestTokenURL)) : undefined;
102+
103+
let response = await this.sendAuthCode(requestTokenURL, accessTokenRequest, dPoP ? { headers: { dPoP } } : undefined);
102104

103-
const response = await this.sendAuthCode(requestTokenURL, accessTokenRequest, dPoP ? { headers: { dPoP } } : undefined);
105+
let nextDPoPNonce = createDPoPOpts?.jwtPayloadProps.nonce;
106+
const retryWithNonce = dPoPShouldRetryRequestWithNonce(response);
107+
if (retryWithNonce.ok && createDPoPOpts) {
108+
createDPoPOpts.jwtPayloadProps.nonce = retryWithNonce.dpopNonce;
109+
110+
dPoP = await createDPoP(getCreateDPoPOptions(createDPoPOpts, requestTokenURL));
111+
response = await this.sendAuthCode(requestTokenURL, accessTokenRequest, dPoP ? { headers: { dPoP } } : undefined);
112+
const successDPoPNonce = response.origResponse.headers.get('DPoP-Nonce');
113+
114+
nextDPoPNonce = successDPoPNonce ?? retryWithNonce.dpopNonce;
115+
}
104116

105117
if (response.successBody && createDPoPOpts && createDPoPOpts && response.successBody.token_type !== 'DPoP') {
106118
throw new Error('Invalid token type returned. Expected DPoP. Received: ' + response.successBody.token_type);
107119
}
108-
109-
return response;
120+
return {
121+
...response,
122+
params: { ...(nextDPoPNonce && { dpop: { dpopNonce: nextDPoPNonce } }) },
123+
};
110124
}
111125

112126
public async createAccessTokenRequest(opts: Omit<AccessTokenRequestOpts, 'createDPoPOpts'>): Promise<AccessTokenRequest> {

packages/client/lib/CredentialRequestClient.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
acquireDeferredCredential,
44
CredentialRequestV1_0_13,
55
CredentialResponse,
6+
DPoPResponseParams,
67
getCredentialRequestForVersion,
78
getUniformFormat,
89
isDeferredCredentialResponse,
@@ -22,6 +23,7 @@ import Debug from 'debug';
2223
import { CredentialRequestClientBuilderV1_0_11 } from './CredentialRequestClientBuilderV1_0_11';
2324
import { CredentialRequestClientBuilderV1_0_13 } from './CredentialRequestClientBuilderV1_0_13';
2425
import { ProofOfPossessionBuilder } from './ProofOfPossessionBuilder';
26+
import { dPoPShouldRetryResourceRequestWithNonce } from './functions/dpopUtil';
2527

2628
const debug = Debug('sphereon:oid4vci:credential');
2729

@@ -91,7 +93,7 @@ export class CredentialRequestClient {
9193
format?: CredentialFormat | OID4VCICredentialFormat;
9294
subjectIssuance?: ExperimentalSubjectIssuance;
9395
createDPoPOpts?: CreateDPoPClientOpts;
94-
}): Promise<OpenIDResponse<CredentialResponse> & { access_token: string }> {
96+
}): Promise<OpenIDResponse<CredentialResponse, DPoPResponseParams> & { access_token: string }> {
9597
const { credentialIdentifier, credentialTypes, proofInput, format, context, subjectIssuance } = opts;
9698

9799
const request = await this.createCredentialRequest({
@@ -109,7 +111,7 @@ export class CredentialRequestClient {
109111
public async acquireCredentialsUsingRequest(
110112
uniformRequest: UniformCredentialRequest,
111113
createDPoPOpts?: CreateDPoPClientOpts,
112-
): Promise<OpenIDResponse<CredentialResponse> & { access_token: string }> {
114+
): Promise<OpenIDResponse<CredentialResponse, DPoPResponseParams> & { access_token: string }> {
113115
if (this.version() < OpenId4VCIVersion.VER_1_0_13) {
114116
throw new Error('Versions below v1.0.13 (draft 13) are not supported by the V13 credential request client.');
115117
}
@@ -123,17 +125,32 @@ export class CredentialRequestClient {
123125
debug(`request\n: ${JSON.stringify(request, null, 2)}`);
124126
const requestToken: string = this.credentialRequestOpts.token;
125127

126-
let dPoP: string | undefined;
127-
if (createDPoPOpts) {
128-
dPoP = createDPoPOpts ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, credentialEndpoint, { accessToken: requestToken })) : undefined;
129-
}
128+
let dPoP = createDPoPOpts ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, credentialEndpoint, { accessToken: requestToken })) : undefined;
130129

131130
let response = (await post(credentialEndpoint, JSON.stringify(request), {
132131
bearerToken: requestToken,
133-
customHeaders: { ...(createDPoPOpts && { dpop: dPoP }) },
132+
customHeaders: { ...(dPoP && { dPoP }) },
134133
})) as OpenIDResponse<CredentialResponse> & {
135134
access_token: string;
136135
};
136+
137+
let nextDPoPNonce = createDPoPOpts?.jwtPayloadProps.nonce;
138+
const retryWithNonce = dPoPShouldRetryResourceRequestWithNonce(response);
139+
if (retryWithNonce.ok && createDPoPOpts) {
140+
createDPoPOpts.jwtPayloadProps.nonce = retryWithNonce.dpopNonce;
141+
dPoP = await createDPoP(getCreateDPoPOptions(createDPoPOpts, credentialEndpoint, { accessToken: requestToken }));
142+
143+
response = (await post(credentialEndpoint, JSON.stringify(request), {
144+
bearerToken: requestToken,
145+
customHeaders: { ...(createDPoPOpts && { dPoP }) },
146+
})) as OpenIDResponse<CredentialResponse> & {
147+
access_token: string;
148+
};
149+
150+
const successDPoPNonce = response.origResponse.headers.get('DPoP-Nonce');
151+
nextDPoPNonce = successDPoPNonce ?? retryWithNonce.dpopNonce;
152+
}
153+
137154
this._isDeferred = isDeferredCredentialResponse(response);
138155
if (this.isDeferred() && this.credentialRequestOpts.deferredCredentialAwait && response.successBody) {
139156
response = await this.acquireDeferredCredential(response.successBody, { bearerToken: this.credentialRequestOpts.token });
@@ -146,7 +163,11 @@ export class CredentialRequestClient {
146163
}
147164
}
148165
debug(`Credential endpoint ${credentialEndpoint} response:\r\n${JSON.stringify(response, null, 2)}`);
149-
return response;
166+
167+
return {
168+
...response,
169+
params: { ...(nextDPoPNonce && { dpop: { dpopNonce: nextDPoPNonce } }) },
170+
};
150171
}
151172

152173
public async acquireDeferredCredential(

packages/client/lib/CredentialRequestClientV1_0_11.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createDPoP, CreateDPoPClientOpts, getCreateDPoPOptions } from '@sphereo
22
import {
33
acquireDeferredCredential,
44
CredentialResponse,
5+
DPoPResponseParams,
56
getCredentialRequestForVersion,
67
getUniformFormat,
78
isDeferredCredentialResponse,
@@ -21,6 +22,7 @@ import Debug from 'debug';
2122
import { buildProof } from './CredentialRequestClient';
2223
import { CredentialRequestClientBuilderV1_0_11 } from './CredentialRequestClientBuilderV1_0_11';
2324
import { ProofOfPossessionBuilder } from './ProofOfPossessionBuilder';
25+
import { dPoPShouldRetryResourceRequestWithNonce } from './functions/dpopUtil';
2426

2527
const debug = Debug('sphereon:oid4vci:credential');
2628

@@ -66,7 +68,7 @@ export class CredentialRequestClientV1_0_11 {
6668
context?: string[];
6769
format?: CredentialFormat | OID4VCICredentialFormat;
6870
createDPoPOpts?: CreateDPoPClientOpts;
69-
}): Promise<OpenIDResponse<CredentialResponse> & { access_token: string }> {
71+
}): Promise<OpenIDResponse<CredentialResponse, DPoPResponseParams> & { access_token: string }> {
7072
const { credentialTypes, proofInput, format, context } = opts;
7173

7274
const request = await this.createCredentialRequest({ proofInput, credentialTypes, context, format, version: this.version() });
@@ -76,7 +78,7 @@ export class CredentialRequestClientV1_0_11 {
7678
public async acquireCredentialsUsingRequest(
7779
uniformRequest: UniformCredentialRequest,
7880
createDPoPOpts?: CreateDPoPClientOpts,
79-
): Promise<OpenIDResponse<CredentialResponse> & { access_token: string }> {
81+
): Promise<OpenIDResponse<CredentialResponse, DPoPResponseParams> & { access_token: string }> {
8082
const request = getCredentialRequestForVersion(uniformRequest, this.version());
8183
const credentialEndpoint: string = this.credentialRequestOpts.credentialEndpoint;
8284
if (!isValidURL(credentialEndpoint)) {
@@ -87,25 +89,44 @@ export class CredentialRequestClientV1_0_11 {
8789
debug(`request\n: ${JSON.stringify(request, null, 2)}`);
8890
const requestToken: string = this.credentialRequestOpts.token;
8991

90-
let dPoP: string | undefined;
91-
if (createDPoPOpts) {
92-
dPoP = createDPoPOpts ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, credentialEndpoint, { accessToken: requestToken })) : undefined;
93-
}
92+
let dPoP = createDPoPOpts ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, credentialEndpoint, { accessToken: requestToken })) : undefined;
9493

9594
let response = (await post(credentialEndpoint, JSON.stringify(request), {
9695
bearerToken: requestToken,
9796
customHeaders: { ...(createDPoPOpts && { dpop: dPoP }) },
9897
})) as OpenIDResponse<CredentialResponse> & {
9998
access_token: string;
10099
};
100+
101+
let nextDPoPNonce = createDPoPOpts?.jwtPayloadProps.nonce;
102+
const retryWithNonce = dPoPShouldRetryResourceRequestWithNonce(response);
103+
if (retryWithNonce.ok && createDPoPOpts) {
104+
createDPoPOpts.jwtPayloadProps.nonce = retryWithNonce.dpopNonce;
105+
dPoP = await createDPoP(getCreateDPoPOptions(createDPoPOpts, credentialEndpoint, { accessToken: requestToken }));
106+
107+
response = (await post(credentialEndpoint, JSON.stringify(request), {
108+
bearerToken: requestToken,
109+
customHeaders: { ...(createDPoPOpts && { dPoP }) },
110+
})) as OpenIDResponse<CredentialResponse> & {
111+
access_token: string;
112+
};
113+
114+
const successDPoPNonce = response.origResponse.headers.get('DPoP-Nonce');
115+
nextDPoPNonce = successDPoPNonce ?? retryWithNonce.dpopNonce;
116+
}
117+
101118
this._isDeferred = isDeferredCredentialResponse(response);
102119
if (this.isDeferred() && this.credentialRequestOpts.deferredCredentialAwait && response.successBody) {
103120
response = await this.acquireDeferredCredential(response.successBody, { bearerToken: this.credentialRequestOpts.token });
104121
}
105122
response.access_token = requestToken;
106123

107124
debug(`Credential endpoint ${credentialEndpoint} response:\r\n${JSON.stringify(response, null, 2)}`);
108-
return response;
125+
126+
return {
127+
...response,
128+
params: { ...(nextDPoPNonce && { dpop: { dpopNonce: nextDPoPNonce } }) },
129+
};
109130
}
110131

111132
public async acquireDeferredCredential(
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { dpopResourceAuthenticateError, dpopTokenRequestNonceError } from '@sphereon/oid4vc-common';
2+
import { OpenIDResponse } from 'oid4vci-common';
3+
4+
export function dPoPShouldRetryRequestWithNonce(response: OpenIDResponse<unknown, unknown>) {
5+
if (response.errorBody && response.errorBody.error === dpopTokenRequestNonceError) {
6+
const dPoPNonce = response.errorBody.headers.get('DPoP-Nonce');
7+
if (!dPoPNonce) {
8+
throw new Error('The DPoP nonce was not returned');
9+
}
10+
11+
return { ok: true, dpopNonce: dPoPNonce } as const;
12+
}
13+
14+
return { ok: false } as const;
15+
}
16+
17+
export function dPoPShouldRetryResourceRequestWithNonce(response: OpenIDResponse<unknown, unknown>) {
18+
if (response.errorBody && response.origResponse.status === 401) {
19+
const wwwAuthenticateHeader = response.errorBody.headers.get('WWW-Authenticate');
20+
if (!wwwAuthenticateHeader?.includes(dpopResourceAuthenticateError)) {
21+
return { ok: false } as const;
22+
}
23+
24+
const dPoPNonce = response.errorBody.headers.get('DPoP-Nonce');
25+
if (!dPoPNonce) {
26+
throw new Error('The DPoP nonce was not returned');
27+
}
28+
29+
return { ok: true, dpopNonce: dPoPNonce } as const;
30+
}
31+
32+
return { ok: false } as const;
33+
}

packages/common/lib/dpop/DPoP.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import {
1717
VerifyJwtCallbackBase,
1818
} from './../jwt';
1919

20+
export const dpopTokenRequestNonceError = 'use_dpop_nonce';
21+
export const dpopResourceAuthenticateError = 'DPoP error="use_dpop_nonce", error_description="Resource server requires nonce in DPoP proof"';
22+
2023
export interface DPoPJwtIssuerWithContext extends JwtIssuerJwk {
2124
type: 'dpop';
2225
dPoPSigningAlgValuesSupported?: string[];

packages/did-auth-siop-adapter/lib/DidJwtAdapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { JwtHeader, JwtPayload } from '@sphereon/oid4vc-common'
21
import { AuthorizationRequestPayload, IDTokenPayload, JwtIssuerWithContext, RequestObjectPayload } from '@sphereon/did-auth-siop'
32
import { JwtVerifier } from '@sphereon/did-auth-siop'
3+
import { JwtHeader, JwtPayload } from '@sphereon/oid4vc-common'
44
import { Resolvable } from 'did-resolver'
55

66
import { getAudience, getSubDidFromPayload, signIDTokenPayload, signRequestObjectPayload, validateLinkedDomainWithDid, verifyDidJWT } from './did'

packages/did-auth-siop-adapter/lib/did/DidJWT.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { SigningAlgo } from '@sphereon/oid4vc-common'
21
import { post } from '@sphereon/did-auth-siop'
32
import {
43
DEFAULT_EXPIRATION_TIME,
@@ -10,6 +9,7 @@ import {
109
SIOPResonse,
1110
VerifiedJWT,
1211
} from '@sphereon/did-auth-siop'
12+
import { SigningAlgo } from '@sphereon/oid4vc-common'
1313
import {
1414
createJWT,
1515
decodeJWT,

packages/oid4vci-common/lib/types/Authorization.types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,10 +324,15 @@ export interface AccessTokenRequest {
324324
[s: string]: unknown;
325325
}
326326

327-
export interface OpenIDResponse<T> {
327+
export interface OpenIDResponse<T, P = never> {
328328
origResponse: Response;
329329
successBody?: T;
330330
errorBody?: ErrorResponse;
331+
params?: P;
332+
}
333+
334+
export interface DPoPResponseParams {
335+
dpop?: { dpopNonce: string };
331336
}
332337

333338
export interface AccessTokenResponse {

0 commit comments

Comments
 (0)