Skip to content

Commit 130b3db

Browse files
authored
Merge pull request #131 from auer-martin/dpop
feat: dpop support
2 parents 858a8ea + f25b7d6 commit 130b3db

127 files changed

Lines changed: 13380 additions & 11041 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/callback-example/lib/__tests__/issuerCallback.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { KeyObject } from 'crypto'
22

3+
import { uuidv4 } from '@sphereon/oid4vc-common'
34
import { CredentialRequestClientBuilder, ProofOfPossessionBuilder } from '@sphereon/oid4vci-client'
45
import {
56
Alg,
@@ -21,7 +22,6 @@ import { CredentialDataSupplierResult } from '@sphereon/oid4vci-issuer/dist/type
2122
import { ICredential, IProofPurpose, IProofType, W3CVerifiableCredential } from '@sphereon/ssi-types'
2223
import { DIDDocument } from 'did-resolver'
2324
import * as jose from 'jose'
24-
import { v4 } from 'uuid'
2525

2626
import { generateDid, getIssuerCallbackV1_0_11, getIssuerCallbackV1_0_13, verifyCredential } from '../IssuerCallback'
2727

@@ -118,7 +118,7 @@ describe('issuerCallback', () => {
118118
createdAt: +new Date(),
119119
lastUpdatedAt: +new Date(),
120120
status: IssueStatus.OFFER_CREATED,
121-
notification_id: v4(),
121+
notification_id: uuidv4(),
122122
txCode: '123456',
123123
credentialOffer: {
124124
credential_offer: {

packages/callback-example/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"build:clean": "tsc --build --clean && tsc --build"
1111
},
1212
"dependencies": {
13+
"@sphereon/oid4vc-common": "workspace:*",
1314
"@digitalcredentials/did-method-key": "^2.0.3",
1415
"@digitalcredentials/ed25519-signature-2020": "^3.0.2",
1516
"@digitalcredentials/ed25519-verification-key-2020": "^4.0.0",
@@ -26,7 +27,6 @@
2627
"@babel/preset-env": "^7.21.4",
2728
"@types/jest": "^29.5.0",
2829
"@types/node": "^18.15.3",
29-
"@types/uuid": "^9.0.1",
3030
"did-resolver": "^4.1.0",
3131
"expo": "^48.0.11",
3232
"react": "^18.2.0",

packages/client/lib/AccessTokenClient.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createDPoP, CreateDPoPClientOpts, getCreateDPoPOptions } from '@sphereon/oid4vc-common';
12
import {
23
AccessTokenRequest,
34
AccessTokenRequestOpts,
@@ -6,6 +7,7 @@ import {
67
AuthorizationServerOpts,
78
AuthzFlowType,
89
convertJsonToURI,
10+
DPoPResponseParams,
911
EndpointMetadata,
1012
formPost,
1113
getIssuerFromCredentialOfferPayload,
@@ -24,11 +26,12 @@ import { ObjectUtils } from '@sphereon/ssi-types';
2426

2527
import { MetadataClientV1_0_13 } from './MetadataClientV1_0_13';
2628
import { createJwtBearerClientAssertion } from './functions';
29+
import { shouldRetryTokenRequestWithDPoPNonce } from './functions/dpopUtil';
2730
import { LOG } from './types';
2831

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

3336
const credentialOffer = opts.credentialOffer ? await assertedUniformCredentialOffer(opts.credentialOffer) : undefined;
3437
const pinMetadata: TxCodeAndPinRequired | undefined = credentialOffer && this.getPinMetadata(credentialOffer.credential_offer);
@@ -59,6 +62,7 @@ export class AccessTokenClient {
5962
metadata,
6063
asOpts,
6164
issuerOpts,
65+
createDPoPOpts: createDPoPOpts,
6266
});
6367
}
6468

@@ -68,13 +72,15 @@ export class AccessTokenClient {
6872
metadata,
6973
asOpts,
7074
issuerOpts,
75+
createDPoPOpts,
7176
}: {
7277
accessTokenRequest: AccessTokenRequest;
7378
pinMetadata?: TxCodeAndPinRequired;
7479
metadata?: EndpointMetadata;
7580
asOpts?: AuthorizationServerOpts;
7681
issuerOpts?: IssuerOpts;
77-
}): Promise<OpenIDResponse<AccessTokenResponse>> {
82+
createDPoPOpts?: CreateDPoPClientOpts;
83+
}): Promise<OpenIDResponse<AccessTokenResponse, DPoPResponseParams>> {
7884
this.validate(accessTokenRequest, pinMetadata);
7985

8086
const requestTokenURL = AccessTokenClient.determineTokenURL({
@@ -87,10 +93,34 @@ export class AccessTokenClient {
8793
: undefined,
8894
});
8995

90-
return this.sendAuthCode(requestTokenURL, accessTokenRequest);
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: dPoP } } : undefined);
100+
101+
let nextDPoPNonce = createDPoPOpts?.jwtPayloadProps.nonce;
102+
const retryWithNonce = shouldRetryTokenRequestWithDPoPNonce(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: dPoP } } : undefined);
108+
const successDPoPNonce = response.origResponse.headers.get('DPoP-Nonce');
109+
110+
nextDPoPNonce = successDPoPNonce ?? retryWithNonce.dpopNonce;
111+
}
112+
113+
if (response.successBody && createDPoPOpts && response.successBody.token_type !== 'DPoP') {
114+
throw new Error('Invalid token type returned. Expected DPoP. Received: ' + response.successBody.token_type);
115+
}
116+
117+
return {
118+
...response,
119+
params: { ...(nextDPoPNonce && { dpop: { dpopNonce: nextDPoPNonce } }) },
120+
};
91121
}
92122

93-
public async createAccessTokenRequest(opts: AccessTokenRequestOpts): Promise<AccessTokenRequest> {
123+
public async createAccessTokenRequest(opts: Omit<AccessTokenRequestOpts, 'createDPoPOpts'>): Promise<AccessTokenRequest> {
94124
const { asOpts, pin, codeVerifier, code, redirectUri } = opts;
95125
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
96126
// @ts-ignore
@@ -222,8 +252,14 @@ export class AccessTokenClient {
222252
}
223253
}
224254

225-
private async sendAuthCode(requestTokenURL: string, accessTokenRequest: AccessTokenRequest): Promise<OpenIDResponse<AccessTokenResponse>> {
226-
return await formPost(requestTokenURL, convertJsonToURI(accessTokenRequest, { mode: JsonURIMode.X_FORM_WWW_URLENCODED }));
255+
private async sendAuthCode(
256+
requestTokenURL: string,
257+
accessTokenRequest: AccessTokenRequest,
258+
opts?: { headers?: Record<string, string> },
259+
): Promise<OpenIDResponse<AccessTokenResponse, DPoPResponseParams>> {
260+
return await formPost(requestTokenURL, convertJsonToURI(accessTokenRequest, { mode: JsonURIMode.X_FORM_WWW_URLENCODED }), {
261+
customHeaders: opts?.headers ? opts.headers : undefined,
262+
});
227263
}
228264

229265
public static determineTokenURL({

packages/client/lib/AccessTokenClientV1_0_11.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createDPoP, CreateDPoPClientOpts, getCreateDPoPOptions } from '@sphereon/oid4vc-common';
12
import {
23
AccessTokenRequest,
34
AccessTokenRequestOpts,
@@ -8,6 +9,7 @@ import {
89
convertJsonToURI,
910
CredentialOfferV1_0_11,
1011
CredentialOfferV1_0_13,
12+
DPoPResponseParams,
1113
EndpointMetadata,
1214
formPost,
1315
getIssuerFromCredentialOfferPayload,
@@ -27,12 +29,13 @@ import Debug from 'debug';
2729

2830
import { MetadataClientV1_0_13 } from './MetadataClientV1_0_13';
2931
import { createJwtBearerClientAssertion } from './functions';
32+
import { shouldRetryTokenRequestWithDPoPNonce } from './functions/dpopUtil';
3033

3134
const debug = Debug('sphereon:oid4vci:token');
3235

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

3740
const credentialOffer = opts.credentialOffer ? await assertedUniformCredentialOffer(opts.credentialOffer) : undefined;
3841
const isPinRequired = credentialOffer && this.isPinRequiredValue(credentialOffer.credential_offer);
@@ -63,6 +66,7 @@ export class AccessTokenClientV1_0_11 {
6366
metadata,
6467
asOpts,
6568
issuerOpts,
69+
createDPoPOpts,
6670
});
6771
}
6872

@@ -71,14 +75,16 @@ export class AccessTokenClientV1_0_11 {
7175
isPinRequired,
7276
metadata,
7377
asOpts,
78+
createDPoPOpts,
7479
issuerOpts,
7580
}: {
7681
accessTokenRequest: AccessTokenRequest;
7782
isPinRequired?: boolean;
7883
metadata?: EndpointMetadata;
7984
asOpts?: AuthorizationServerOpts;
8085
issuerOpts?: IssuerOpts;
81-
}): Promise<OpenIDResponse<AccessTokenResponse>> {
86+
createDPoPOpts?: CreateDPoPClientOpts;
87+
}): Promise<OpenIDResponse<AccessTokenResponse, DPoPResponseParams>> {
8288
this.validate(accessTokenRequest, isPinRequired);
8389

8490
const requestTokenURL = AccessTokenClientV1_0_11.determineTokenURL({
@@ -91,10 +97,34 @@ export class AccessTokenClientV1_0_11 {
9197
: undefined,
9298
});
9399

94-
return this.sendAuthCode(requestTokenURL, accessTokenRequest);
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: dPoP } } : undefined);
104+
105+
let nextDPoPNonce = createDPoPOpts?.jwtPayloadProps.nonce;
106+
const retryWithNonce = shouldRetryTokenRequestWithDPoPNonce(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: dPoP } } : undefined);
112+
const successDPoPNonce = response.origResponse.headers.get('DPoP-Nonce');
113+
114+
nextDPoPNonce = successDPoPNonce ?? retryWithNonce.dpopNonce;
115+
}
116+
117+
if (response.successBody && createDPoPOpts && response.successBody.token_type !== 'DPoP') {
118+
throw new Error('Invalid token type returned. Expected DPoP. Received: ' + response.successBody.token_type);
119+
}
120+
121+
return {
122+
...response,
123+
params: { ...(nextDPoPNonce && { dpop: { dpopNonce: nextDPoPNonce } }) },
124+
};
95125
}
96126

97-
public async createAccessTokenRequest(opts: AccessTokenRequestOpts): Promise<AccessTokenRequest> {
127+
public async createAccessTokenRequest(opts: Omit<AccessTokenRequestOpts, 'createDPoPOpts'>): Promise<AccessTokenRequest> {
98128
const { asOpts, pin, codeVerifier, code, redirectUri } = opts;
99129
const credentialOfferRequest = opts.credentialOffer
100130
? await toUniformCredentialOfferRequest(opts.credentialOffer as CredentialOfferV1_0_11 | CredentialOfferV1_0_13)
@@ -204,8 +234,14 @@ export class AccessTokenClientV1_0_11 {
204234
}
205235
}
206236

207-
private async sendAuthCode(requestTokenURL: string, accessTokenRequest: AccessTokenRequest): Promise<OpenIDResponse<AccessTokenResponse>> {
208-
return await formPost(requestTokenURL, convertJsonToURI(accessTokenRequest, { mode: JsonURIMode.X_FORM_WWW_URLENCODED }));
237+
private async sendAuthCode(
238+
requestTokenURL: string,
239+
accessTokenRequest: AccessTokenRequest,
240+
opts?: { headers?: Record<string, string> },
241+
): Promise<OpenIDResponse<AccessTokenResponse>> {
242+
return await formPost(requestTokenURL, convertJsonToURI(accessTokenRequest, { mode: JsonURIMode.X_FORM_WWW_URLENCODED }), {
243+
customHeaders: opts?.headers ? opts.headers : undefined,
244+
});
209245
}
210246

211247
public static determineTokenURL({

packages/client/lib/AuthorizationCodeClient.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export const createAuthorizationRequestUrl = async ({
114114
const client_id = clientId ?? authorizationRequest.clientId;
115115

116116
// Authorization server metadata takes precedence
117-
const authorizationMetadata = endpointMetadata.authorizationServerMetadata ?? endpointMetadata.credentialIssuerMetadata
117+
const authorizationMetadata = endpointMetadata.authorizationServerMetadata ?? endpointMetadata.credentialIssuerMetadata;
118118

119119
let { authorizationDetails } = authorizationRequest;
120120
const parMode = authorizationMetadata?.require_pushed_authorization_requests
@@ -182,7 +182,6 @@ export const createAuthorizationRequestUrl = async ({
182182
}
183183
const parEndpoint = authorizationMetadata?.pushed_authorization_request_endpoint;
184184

185-
186185
let queryObj: Record<string, any> | PushedAuthorizationResponse = {
187186
response_type: ResponseType.AUTH_CODE,
188187
...(!pkce.disabled && {

packages/client/lib/AuthorizationCodeClientV1_0_11.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export const createAuthorizationRequestUrlV1_0_11 = async ({
4040

4141
const parMode = endpointMetadata?.credentialIssuerMetadata?.require_pushed_authorization_requests
4242
? PARMode.REQUIRE
43-
: authorizationRequest.parMode ?? PARMode.AUTO;
43+
: (authorizationRequest.parMode ?? PARMode.AUTO);
4444
// Scope and authorization_details can be used in the same authorization request
4545
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rar-23#name-relationship-to-scope-param
4646
if (!scope && !authorizationDetails) {

packages/client/lib/CredentialRequestClient.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import { createDPoP, CreateDPoPClientOpts, getCreateDPoPOptions } from '@sphereon/oid4vc-common';
12
import {
23
acquireDeferredCredential,
34
CredentialRequestV1_0_13,
45
CredentialResponse,
6+
DPoPResponseParams,
57
getCredentialRequestForVersion,
68
getUniformFormat,
79
isDeferredCredentialResponse,
@@ -21,6 +23,7 @@ import Debug from 'debug';
2123
import { CredentialRequestClientBuilderV1_0_11 } from './CredentialRequestClientBuilderV1_0_11';
2224
import { CredentialRequestClientBuilderV1_0_13 } from './CredentialRequestClientBuilderV1_0_13';
2325
import { ProofOfPossessionBuilder } from './ProofOfPossessionBuilder';
26+
import { shouldRetryResourceRequestWithDPoPNonce } from './functions/dpopUtil';
2427

2528
const debug = Debug('sphereon:oid4vci:credential');
2629

@@ -89,7 +92,8 @@ export class CredentialRequestClient {
8992
context?: string[];
9093
format?: CredentialFormat | OID4VCICredentialFormat;
9194
subjectIssuance?: ExperimentalSubjectIssuance;
92-
}): Promise<OpenIDResponse<CredentialResponse> & { access_token: string }> {
95+
createDPoPOpts?: CreateDPoPClientOpts;
96+
}): Promise<OpenIDResponse<CredentialResponse, DPoPResponseParams> & { access_token: string }> {
9397
const { credentialIdentifier, credentialTypes, proofInput, format, context, subjectIssuance } = opts;
9498

9599
const request = await this.createCredentialRequest({
@@ -101,12 +105,13 @@ export class CredentialRequestClient {
101105
credentialIdentifier,
102106
subjectIssuance,
103107
});
104-
return await this.acquireCredentialsUsingRequest(request);
108+
return await this.acquireCredentialsUsingRequest(request, opts.createDPoPOpts);
105109
}
106110

107111
public async acquireCredentialsUsingRequest(
108112
uniformRequest: UniformCredentialRequest,
109-
): Promise<OpenIDResponse<CredentialResponse> & { access_token: string }> {
113+
createDPoPOpts?: CreateDPoPClientOpts,
114+
): Promise<OpenIDResponse<CredentialResponse, DPoPResponseParams> & { access_token: string }> {
110115
if (this.version() < OpenId4VCIVersion.VER_1_0_13) {
111116
throw new Error('Versions below v1.0.13 (draft 13) are not supported by the V13 credential request client.');
112117
}
@@ -119,9 +124,33 @@ export class CredentialRequestClient {
119124
debug(`Acquiring credential(s) from: ${credentialEndpoint}`);
120125
debug(`request\n: ${JSON.stringify(request, null, 2)}`);
121126
const requestToken: string = this.credentialRequestOpts.token;
122-
let response = (await post(credentialEndpoint, JSON.stringify(request), { bearerToken: requestToken })) as OpenIDResponse<CredentialResponse> & {
127+
128+
let dPoP = createDPoPOpts ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, credentialEndpoint, { accessToken: requestToken })) : undefined;
129+
130+
let response = (await post(credentialEndpoint, JSON.stringify(request), {
131+
bearerToken: requestToken,
132+
customHeaders: { ...(dPoP && { dpop: dPoP }) },
133+
})) as OpenIDResponse<CredentialResponse> & {
123134
access_token: string;
124135
};
136+
137+
let nextDPoPNonce = createDPoPOpts?.jwtPayloadProps.nonce;
138+
const retryWithNonce = shouldRetryResourceRequestWithDPoPNonce(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: 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+
125154
this._isDeferred = isDeferredCredentialResponse(response);
126155
if (this.isDeferred() && this.credentialRequestOpts.deferredCredentialAwait && response.successBody) {
127156
response = await this.acquireDeferredCredential(response.successBody, { bearerToken: this.credentialRequestOpts.token });
@@ -134,7 +163,11 @@ export class CredentialRequestClient {
134163
}
135164
}
136165
debug(`Credential endpoint ${credentialEndpoint} response:\r\n${JSON.stringify(response, null, 2)}`);
137-
return response;
166+
167+
return {
168+
...response,
169+
params: { ...(nextDPoPNonce && { dpop: { dpopNonce: nextDPoPNonce } }) },
170+
};
138171
}
139172

140173
public async acquireDeferredCredential(

0 commit comments

Comments
 (0)