Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: dpop support #131

Merged
merged 21 commits into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 { dPoPShouldRetryRequestWithNonce } 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 = 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: 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,
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 @@ -221,8 +251,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
49 changes: 42 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 { dPoPShouldRetryRequestWithNonce } 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,33 @@ 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 = dPoPShouldRetryRequestWithNonce(response);
if (retryWithNonce.ok && createDPoPOpts) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if retryWithNonce.false && 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 && createDPoPOpts && response.successBody.token_type !== 'DPoP') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This for sure will ensures the DPoPOpts is present :D

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (response.successBody && createDPoPOpts && createDPoPOpts && response.successBody.token_type !== 'DPoP') {
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 +233,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
2 changes: 1 addition & 1 deletion packages/client/lib/AuthorizationCodeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export const createAuthorizationRequestUrl = async ({
let { scope, authorizationDetails } = authorizationRequest;
const parMode = endpointMetadata?.credentialIssuerMetadata?.require_pushed_authorization_requests
? PARMode.REQUIRE
: authorizationRequest.parMode ?? (client_id ? PARMode.AUTO : PARMode.NEVER);
: (authorizationRequest.parMode ?? (client_id ? PARMode.AUTO : PARMode.NEVER));
// 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
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 { dPoPShouldRetryResourceRequestWithNonce } 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 = dPoPShouldRetryResourceRequestWithNonce(response);
if (retryWithNonce.ok && createDPoPOpts) {
nklomp marked this conversation as resolved.
Show resolved Hide resolved
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
Loading