Skip to content

Commit

Permalink
Merge pull request #131 from auer-martin/dpop
Browse files Browse the repository at this point in the history
feat: dpop support
  • Loading branch information
nklomp authored Aug 2, 2024
2 parents 858a8ea + f25b7d6 commit 130b3db
Show file tree
Hide file tree
Showing 127 changed files with 13,380 additions and 11,041 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { KeyObject } from 'crypto'

import { uuidv4 } from '@sphereon/oid4vc-common'
import { CredentialRequestClientBuilder, ProofOfPossessionBuilder } from '@sphereon/oid4vci-client'
import {
Alg,
Expand All @@ -21,7 +22,6 @@ import { CredentialDataSupplierResult } from '@sphereon/oid4vci-issuer/dist/type
import { ICredential, IProofPurpose, IProofType, W3CVerifiableCredential } from '@sphereon/ssi-types'
import { DIDDocument } from 'did-resolver'
import * as jose from 'jose'
import { v4 } from 'uuid'

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

Expand Down Expand Up @@ -118,7 +118,7 @@ describe('issuerCallback', () => {
createdAt: +new Date(),
lastUpdatedAt: +new Date(),
status: IssueStatus.OFFER_CREATED,
notification_id: v4(),
notification_id: uuidv4(),
txCode: '123456',
credentialOffer: {
credential_offer: {
Expand Down
2 changes: 1 addition & 1 deletion packages/callback-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"build:clean": "tsc --build --clean && tsc --build"
},
"dependencies": {
"@sphereon/oid4vc-common": "workspace:*",
"@digitalcredentials/did-method-key": "^2.0.3",
"@digitalcredentials/ed25519-signature-2020": "^3.0.2",
"@digitalcredentials/ed25519-verification-key-2020": "^4.0.0",
Expand All @@ -26,7 +27,6 @@
"@babel/preset-env": "^7.21.4",
"@types/jest": "^29.5.0",
"@types/node": "^18.15.3",
"@types/uuid": "^9.0.1",
"did-resolver": "^4.1.0",
"expo": "^48.0.11",
"react": "^18.2.0",
Expand Down
50 changes: 43 additions & 7 deletions packages/client/lib/AccessTokenClient.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createDPoP, CreateDPoPClientOpts, getCreateDPoPOptions } from '@sphereon/oid4vc-common';
import {
AccessTokenRequest,
AccessTokenRequestOpts,
Expand All @@ -6,6 +7,7 @@ import {
AuthorizationServerOpts,
AuthzFlowType,
convertJsonToURI,
DPoPResponseParams,
EndpointMetadata,
formPost,
getIssuerFromCredentialOfferPayload,
Expand All @@ -24,11 +26,12 @@ import { ObjectUtils } from '@sphereon/ssi-types';

import { MetadataClientV1_0_13 } from './MetadataClientV1_0_13';
import { createJwtBearerClientAssertion } from './functions';
import { shouldRetryTokenRequestWithDPoPNonce } from './functions/dpopUtil';
import { LOG } from './types';

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

const credentialOffer = opts.credentialOffer ? await assertedUniformCredentialOffer(opts.credentialOffer) : undefined;
const pinMetadata: TxCodeAndPinRequired | undefined = credentialOffer && this.getPinMetadata(credentialOffer.credential_offer);
Expand Down Expand Up @@ -59,6 +62,7 @@ export class AccessTokenClient {
metadata,
asOpts,
issuerOpts,
createDPoPOpts: createDPoPOpts,
});
}

Expand All @@ -68,13 +72,15 @@ export class AccessTokenClient {
metadata,
asOpts,
issuerOpts,
createDPoPOpts,
}: {
accessTokenRequest: AccessTokenRequest;
pinMetadata?: TxCodeAndPinRequired;
metadata?: EndpointMetadata;
asOpts?: AuthorizationServerOpts;
issuerOpts?: IssuerOpts;
}): Promise<OpenIDResponse<AccessTokenResponse>> {
createDPoPOpts?: CreateDPoPClientOpts;
}): Promise<OpenIDResponse<AccessTokenResponse, DPoPResponseParams>> {
this.validate(accessTokenRequest, pinMetadata);

const requestTokenURL = AccessTokenClient.determineTokenURL({
Expand All @@ -87,10 +93,34 @@ export class AccessTokenClient {
: undefined,
});

return this.sendAuthCode(requestTokenURL, accessTokenRequest);
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: dPoP } } : undefined);

let nextDPoPNonce = createDPoPOpts?.jwtPayloadProps.nonce;
const retryWithNonce = shouldRetryTokenRequestWithDPoPNonce(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: dPoP } } : undefined);
const successDPoPNonce = response.origResponse.headers.get('DPoP-Nonce');

nextDPoPNonce = successDPoPNonce ?? retryWithNonce.dpopNonce;
}

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

return {
...response,
params: { ...(nextDPoPNonce && { dpop: { dpopNonce: nextDPoPNonce } }) },
};
}

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

private async sendAuthCode(requestTokenURL: string, accessTokenRequest: AccessTokenRequest): Promise<OpenIDResponse<AccessTokenResponse>> {
return await formPost(requestTokenURL, convertJsonToURI(accessTokenRequest, { mode: JsonURIMode.X_FORM_WWW_URLENCODED }));
private async sendAuthCode(
requestTokenURL: string,
accessTokenRequest: AccessTokenRequest,
opts?: { headers?: Record<string, string> },
): Promise<OpenIDResponse<AccessTokenResponse, DPoPResponseParams>> {
return await formPost(requestTokenURL, convertJsonToURI(accessTokenRequest, { mode: JsonURIMode.X_FORM_WWW_URLENCODED }), {
customHeaders: opts?.headers ? opts.headers : undefined,
});
}

public static determineTokenURL({
Expand Down
50 changes: 43 additions & 7 deletions packages/client/lib/AccessTokenClientV1_0_11.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createDPoP, CreateDPoPClientOpts, getCreateDPoPOptions } from '@sphereon/oid4vc-common';
import {
AccessTokenRequest,
AccessTokenRequestOpts,
Expand All @@ -8,6 +9,7 @@ import {
convertJsonToURI,
CredentialOfferV1_0_11,
CredentialOfferV1_0_13,
DPoPResponseParams,
EndpointMetadata,
formPost,
getIssuerFromCredentialOfferPayload,
Expand All @@ -27,12 +29,13 @@ import Debug from 'debug';

import { MetadataClientV1_0_13 } from './MetadataClientV1_0_13';
import { createJwtBearerClientAssertion } from './functions';
import { shouldRetryTokenRequestWithDPoPNonce } from './functions/dpopUtil';

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

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

const credentialOffer = opts.credentialOffer ? await assertedUniformCredentialOffer(opts.credentialOffer) : undefined;
const isPinRequired = credentialOffer && this.isPinRequiredValue(credentialOffer.credential_offer);
Expand Down Expand Up @@ -63,6 +66,7 @@ export class AccessTokenClientV1_0_11 {
metadata,
asOpts,
issuerOpts,
createDPoPOpts,
});
}

Expand All @@ -71,14 +75,16 @@ export class AccessTokenClientV1_0_11 {
isPinRequired,
metadata,
asOpts,
createDPoPOpts,
issuerOpts,
}: {
accessTokenRequest: AccessTokenRequest;
isPinRequired?: boolean;
metadata?: EndpointMetadata;
asOpts?: AuthorizationServerOpts;
issuerOpts?: IssuerOpts;
}): Promise<OpenIDResponse<AccessTokenResponse>> {
createDPoPOpts?: CreateDPoPClientOpts;
}): Promise<OpenIDResponse<AccessTokenResponse, DPoPResponseParams>> {
this.validate(accessTokenRequest, isPinRequired);

const requestTokenURL = AccessTokenClientV1_0_11.determineTokenURL({
Expand All @@ -91,10 +97,34 @@ export class AccessTokenClientV1_0_11 {
: undefined,
});

return this.sendAuthCode(requestTokenURL, accessTokenRequest);
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: dPoP } } : undefined);

let nextDPoPNonce = createDPoPOpts?.jwtPayloadProps.nonce;
const retryWithNonce = shouldRetryTokenRequestWithDPoPNonce(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: dPoP } } : undefined);
const successDPoPNonce = response.origResponse.headers.get('DPoP-Nonce');

nextDPoPNonce = successDPoPNonce ?? retryWithNonce.dpopNonce;
}

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

return {
...response,
params: { ...(nextDPoPNonce && { dpop: { dpopNonce: nextDPoPNonce } }) },
};
}

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

private async sendAuthCode(requestTokenURL: string, accessTokenRequest: AccessTokenRequest): Promise<OpenIDResponse<AccessTokenResponse>> {
return await formPost(requestTokenURL, convertJsonToURI(accessTokenRequest, { mode: JsonURIMode.X_FORM_WWW_URLENCODED }));
private async sendAuthCode(
requestTokenURL: string,
accessTokenRequest: AccessTokenRequest,
opts?: { headers?: Record<string, string> },
): Promise<OpenIDResponse<AccessTokenResponse>> {
return await formPost(requestTokenURL, convertJsonToURI(accessTokenRequest, { mode: JsonURIMode.X_FORM_WWW_URLENCODED }), {
customHeaders: opts?.headers ? opts.headers : undefined,
});
}

public static determineTokenURL({
Expand Down
3 changes: 1 addition & 2 deletions packages/client/lib/AuthorizationCodeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export const createAuthorizationRequestUrl = async ({
const client_id = clientId ?? authorizationRequest.clientId;

// Authorization server metadata takes precedence
const authorizationMetadata = endpointMetadata.authorizationServerMetadata ?? endpointMetadata.credentialIssuerMetadata
const authorizationMetadata = endpointMetadata.authorizationServerMetadata ?? endpointMetadata.credentialIssuerMetadata;

let { authorizationDetails } = authorizationRequest;
const parMode = authorizationMetadata?.require_pushed_authorization_requests
Expand Down Expand Up @@ -182,7 +182,6 @@ export const createAuthorizationRequestUrl = async ({
}
const parEndpoint = authorizationMetadata?.pushed_authorization_request_endpoint;


let queryObj: Record<string, any> | PushedAuthorizationResponse = {
response_type: ResponseType.AUTH_CODE,
...(!pkce.disabled && {
Expand Down
2 changes: 1 addition & 1 deletion packages/client/lib/AuthorizationCodeClientV1_0_11.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const createAuthorizationRequestUrlV1_0_11 = async ({

const parMode = endpointMetadata?.credentialIssuerMetadata?.require_pushed_authorization_requests
? PARMode.REQUIRE
: authorizationRequest.parMode ?? PARMode.AUTO;
: (authorizationRequest.parMode ?? PARMode.AUTO);
// Scope and authorization_details can be used in the same authorization request
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rar-23#name-relationship-to-scope-param
if (!scope && !authorizationDetails) {
Expand Down
43 changes: 38 additions & 5 deletions packages/client/lib/CredentialRequestClient.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { createDPoP, CreateDPoPClientOpts, getCreateDPoPOptions } from '@sphereon/oid4vc-common';
import {
acquireDeferredCredential,
CredentialRequestV1_0_13,
CredentialResponse,
DPoPResponseParams,
getCredentialRequestForVersion,
getUniformFormat,
isDeferredCredentialResponse,
Expand All @@ -21,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 { shouldRetryResourceRequestWithDPoPNonce } from './functions/dpopUtil';

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

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

const request = await this.createCredentialRequest({
Expand All @@ -101,12 +105,13 @@ export class CredentialRequestClient {
credentialIdentifier,
subjectIssuance,
});
return await this.acquireCredentialsUsingRequest(request);
return await this.acquireCredentialsUsingRequest(request, opts.createDPoPOpts);
}

public async acquireCredentialsUsingRequest(
uniformRequest: UniformCredentialRequest,
): Promise<OpenIDResponse<CredentialResponse> & { access_token: string }> {
createDPoPOpts?: CreateDPoPClientOpts,
): Promise<OpenIDResponse<CredentialResponse, DPoPResponseParams> & { 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.');
}
Expand All @@ -119,9 +124,33 @@ export class CredentialRequestClient {
debug(`Acquiring credential(s) from: ${credentialEndpoint}`);
debug(`request\n: ${JSON.stringify(request, null, 2)}`);
const requestToken: string = this.credentialRequestOpts.token;
let response = (await post(credentialEndpoint, JSON.stringify(request), { bearerToken: requestToken })) as OpenIDResponse<CredentialResponse> & {

let dPoP = createDPoPOpts ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, credentialEndpoint, { accessToken: requestToken })) : undefined;

let response = (await post(credentialEndpoint, JSON.stringify(request), {
bearerToken: requestToken,
customHeaders: { ...(dPoP && { dpop: dPoP }) },
})) as OpenIDResponse<CredentialResponse> & {
access_token: string;
};

let nextDPoPNonce = createDPoPOpts?.jwtPayloadProps.nonce;
const retryWithNonce = shouldRetryResourceRequestWithDPoPNonce(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: dPoP }) },
})) as OpenIDResponse<CredentialResponse> & {
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 });
Expand All @@ -134,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(
Expand Down
Loading

0 comments on commit 130b3db

Please sign in to comment.