Skip to content

Commit c04bdf4

Browse files
authored
Merge pull request #138 from Sphereon-Opensource/develop
New release with DPoP support and hash changes
2 parents 45c4672 + d361b31 commit c04bdf4

132 files changed

Lines changed: 7161 additions & 7988 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: 44 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
@@ -105,6 +135,7 @@ export class AccessTokenClient {
105135
if (credentialOfferRequest?.supportedFlows.includes(AuthzFlowType.PRE_AUTHORIZED_CODE_FLOW)) {
106136
this.assertAlphanumericPin(opts.pinMetadata, pin);
107137
request.user_pin = pin;
138+
request.tx_code = pin;
108139

109140
request.grant_type = GrantTypes.PRE_AUTHORIZED_CODE;
110141
// we actually know it is there because of the isPreAuthCode call
@@ -221,8 +252,14 @@ export class AccessTokenClient {
221252
}
222253
}
223254

224-
private async sendAuthCode(requestTokenURL: string, accessTokenRequest: AccessTokenRequest): Promise<OpenIDResponse<AccessTokenResponse>> {
225-
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+
});
226263
}
227264

228265
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: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,16 @@ export const createAuthorizationRequestUrl = async ({
113113
const { redirectUri, requestObjectOpts = { requestObjectMode: CreateRequestObjectMode.NONE } } = authorizationRequest;
114114
const client_id = clientId ?? authorizationRequest.clientId;
115115

116-
let { scope, authorizationDetails } = authorizationRequest;
117-
const parMode = endpointMetadata?.credentialIssuerMetadata?.require_pushed_authorization_requests
116+
// Authorization server metadata takes precedence
117+
const authorizationMetadata = endpointMetadata.authorizationServerMetadata ?? endpointMetadata.credentialIssuerMetadata;
118+
119+
let { authorizationDetails } = authorizationRequest;
120+
const parMode = authorizationMetadata?.require_pushed_authorization_requests
118121
? PARMode.REQUIRE
119-
: authorizationRequest.parMode ?? (client_id ? PARMode.AUTO : PARMode.NEVER);
122+
: (authorizationRequest.parMode ?? (client_id ? PARMode.AUTO : PARMode.NEVER));
120123
// Scope and authorization_details can be used in the same authorization request
121124
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rar-23#name-relationship-to-scope-param
122-
if (!scope && !authorizationDetails) {
125+
if (!authorizationRequest.scope && !authorizationDetails) {
123126
if (!credentialOffer) {
124127
throw Error('Please provide a scope or authorization_details if no credential offer is present');
125128
}
@@ -177,12 +180,7 @@ export const createAuthorizationRequestUrl = async ({
177180
if (!endpointMetadata?.authorization_endpoint) {
178181
throw Error('Server metadata does not contain authorization endpoint');
179182
}
180-
const parEndpoint = endpointMetadata.credentialIssuerMetadata?.pushed_authorization_request_endpoint;
181-
182-
// add 'openid' scope if not present
183-
if (!scope?.includes('openid')) {
184-
scope = ['openid', scope].filter((s) => !!s).join(' ');
185-
}
183+
const parEndpoint = authorizationMetadata?.pushed_authorization_request_endpoint;
186184

187185
let queryObj: Record<string, any> | PushedAuthorizationResponse = {
188186
response_type: ResponseType.AUTH_CODE,
@@ -194,7 +192,7 @@ export const createAuthorizationRequestUrl = async ({
194192
...(redirectUri && { redirect_uri: redirectUri }),
195193
...(client_id && { client_id }),
196194
...(credentialOffer?.issuerState && { issuer_state: credentialOffer.issuerState }),
197-
scope,
195+
scope: authorizationRequest.scope,
198196
};
199197

200198
if (!parEndpoint && parMode === PARMode.REQUIRE) {
@@ -210,11 +208,11 @@ export const createAuthorizationRequestUrl = async ({
210208
{ contentType: 'application/x-www-form-urlencoded', accept: 'application/json' },
211209
);
212210
if (parResponse.errorBody || !parResponse.successBody) {
213-
console.log(JSON.stringify(parResponse.errorBody));
214-
console.log('Falling back to regular request URI, since PAR failed');
215211
if (parMode === PARMode.REQUIRE) {
216212
throw Error(`PAR error: ${parResponse.origResponse.statusText}`);
217213
}
214+
215+
debug('Falling back to regular request URI, since PAR failed', JSON.stringify(parResponse.errorBody));
218216
} else {
219217
debug(`PAR response: ${JSON.stringify(parResponse.successBody, null, 2)}`);
220218
queryObj = { /*response_type: ResponseType.AUTH_CODE,*/ client_id, request_uri: parResponse.successBody.request_uri };

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) {

0 commit comments

Comments
 (0)