Skip to content

Commit

Permalink
feat: add additional dpop retry mechanisms
Browse files Browse the repository at this point in the history
  • Loading branch information
auer-martin committed Jul 30, 2024
1 parent 668c53f commit a102854
Show file tree
Hide file tree
Showing 9 changed files with 148 additions and 35 deletions.
32 changes: 24 additions & 8 deletions packages/client/lib/AccessTokenClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
AuthorizationServerOpts,
AuthzFlowType,
convertJsonToURI,
DPoPResponseParams,
EndpointMetadata,
formPost,
getIssuerFromCredentialOfferPayload,
Expand All @@ -25,10 +26,11 @@ 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>> {
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;
Expand Down Expand Up @@ -78,7 +80,7 @@ export class AccessTokenClient {
asOpts?: AuthorizationServerOpts;
issuerOpts?: IssuerOpts;
createDPoPOpts?: CreateDPoPClientOpts;
}): Promise<OpenIDResponse<AccessTokenResponse>> {
}): Promise<OpenIDResponse<AccessTokenResponse, DPoPResponseParams>> {
this.validate(accessTokenRequest, pinMetadata);

const requestTokenURL = AccessTokenClient.determineTokenURL({
Expand All @@ -91,17 +93,31 @@ export class AccessTokenClient {
: undefined,
});

let dPoP: string | undefined;
if (createDPoPOpts?.dPoPSigningAlgValuesSupported && createDPoPOpts.dPoPSigningAlgValuesSupported.length > 0) {
dPoP = createDPoPOpts ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, requestTokenURL)) : undefined;
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 } } : 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 } } : undefined);
const successDPoPNonce = response.origResponse.headers.get('DPoP-Nonce');

nextDPoPNonce = successDPoPNonce ?? retryWithNonce.dpopNonce;
}
const response = await this.sendAuthCode(requestTokenURL, accessTokenRequest, dPoP ? { headers: { dPoP } } : undefined);

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;
return {
...response,
params: { ...(nextDPoPNonce && { dpop: { dpopNonce: nextDPoPNonce } }) },
};
}

public async createAccessTokenRequest(opts: Omit<AccessTokenRequestOpts, 'createDPoPOpts'>): Promise<AccessTokenRequest> {
Expand Down Expand Up @@ -239,7 +255,7 @@ export class AccessTokenClient {
requestTokenURL: string,
accessTokenRequest: AccessTokenRequest,
opts?: { headers?: Record<string, string> },
): Promise<OpenIDResponse<AccessTokenResponse>> {
): Promise<OpenIDResponse<AccessTokenResponse, DPoPResponseParams>> {
return await formPost(requestTokenURL, convertJsonToURI(accessTokenRequest, { mode: JsonURIMode.X_FORM_WWW_URLENCODED }), {
customHeaders: opts?.headers ? opts.headers : undefined,
});
Expand Down
32 changes: 23 additions & 9 deletions packages/client/lib/AccessTokenClientV1_0_11.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
convertJsonToURI,
CredentialOfferV1_0_11,
CredentialOfferV1_0_13,
DPoPResponseParams,
EndpointMetadata,
formPost,
getIssuerFromCredentialOfferPayload,
Expand All @@ -28,11 +29,12 @@ 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>> {
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;
Expand Down Expand Up @@ -82,7 +84,7 @@ export class AccessTokenClientV1_0_11 {
asOpts?: AuthorizationServerOpts;
issuerOpts?: IssuerOpts;
createDPoPOpts?: CreateDPoPClientOpts;
}): Promise<OpenIDResponse<AccessTokenResponse>> {
}): Promise<OpenIDResponse<AccessTokenResponse, DPoPResponseParams>> {
this.validate(accessTokenRequest, isPinRequired);

const requestTokenURL = AccessTokenClientV1_0_11.determineTokenURL({
Expand All @@ -95,18 +97,30 @@ export class AccessTokenClientV1_0_11 {
: undefined,
});

let dPoP: string | undefined;
if (createDPoPOpts?.dPoPSigningAlgValuesSupported && createDPoPOpts.dPoPSigningAlgValuesSupported.length > 0) {
dPoP = createDPoPOpts ? await createDPoP(getCreateDPoPOptions(createDPoPOpts, requestTokenURL)) : undefined;
}
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 } } : undefined);

const response = await this.sendAuthCode(requestTokenURL, accessTokenRequest, dPoP ? { headers: { 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 } } : 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;
return {
...response,
params: { ...(nextDPoPNonce && { dpop: { dpopNonce: nextDPoPNonce } }) },
};
}

public async createAccessTokenRequest(opts: Omit<AccessTokenRequestOpts, 'createDPoPOpts'>): Promise<AccessTokenRequest> {
Expand Down
37 changes: 29 additions & 8 deletions packages/client/lib/CredentialRequestClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
acquireDeferredCredential,
CredentialRequestV1_0_13,
CredentialResponse,
DPoPResponseParams,
getCredentialRequestForVersion,
getUniformFormat,
isDeferredCredentialResponse,
Expand All @@ -22,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 @@ -91,7 +93,7 @@ export class CredentialRequestClient {
format?: CredentialFormat | OID4VCICredentialFormat;
subjectIssuance?: ExperimentalSubjectIssuance;
createDPoPOpts?: CreateDPoPClientOpts;
}): Promise<OpenIDResponse<CredentialResponse> & { access_token: string }> {
}): Promise<OpenIDResponse<CredentialResponse, DPoPResponseParams> & { access_token: string }> {
const { credentialIdentifier, credentialTypes, proofInput, format, context, subjectIssuance } = opts;

const request = await this.createCredentialRequest({
Expand All @@ -109,7 +111,7 @@ export class CredentialRequestClient {
public async acquireCredentialsUsingRequest(
uniformRequest: UniformCredentialRequest,
createDPoPOpts?: CreateDPoPClientOpts,
): Promise<OpenIDResponse<CredentialResponse> & { access_token: string }> {
): 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 @@ -123,17 +125,32 @@ export class CredentialRequestClient {
debug(`request\n: ${JSON.stringify(request, null, 2)}`);
const requestToken: string = this.credentialRequestOpts.token;

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

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

let nextDPoPNonce = createDPoPOpts?.jwtPayloadProps.nonce;
const retryWithNonce = dPoPShouldRetryResourceRequestWithNonce(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 }) },
})) 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 @@ -146,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
35 changes: 28 additions & 7 deletions packages/client/lib/CredentialRequestClientV1_0_11.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createDPoP, CreateDPoPClientOpts, getCreateDPoPOptions } from '@sphereo
import {
acquireDeferredCredential,
CredentialResponse,
DPoPResponseParams,
getCredentialRequestForVersion,
getUniformFormat,
isDeferredCredentialResponse,
Expand All @@ -21,6 +22,7 @@ import Debug from 'debug';
import { buildProof } from './CredentialRequestClient';
import { CredentialRequestClientBuilderV1_0_11 } from './CredentialRequestClientBuilderV1_0_11';
import { ProofOfPossessionBuilder } from './ProofOfPossessionBuilder';
import { dPoPShouldRetryResourceRequestWithNonce } from './functions/dpopUtil';

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

Expand Down Expand Up @@ -66,7 +68,7 @@ export class CredentialRequestClientV1_0_11 {
context?: string[];
format?: CredentialFormat | OID4VCICredentialFormat;
createDPoPOpts?: CreateDPoPClientOpts;
}): Promise<OpenIDResponse<CredentialResponse> & { access_token: string }> {
}): Promise<OpenIDResponse<CredentialResponse, DPoPResponseParams> & { access_token: string }> {
const { credentialTypes, proofInput, format, context } = opts;

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

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

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

let nextDPoPNonce = createDPoPOpts?.jwtPayloadProps.nonce;
const retryWithNonce = dPoPShouldRetryResourceRequestWithNonce(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 }) },
})) 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 });
}
response.access_token = requestToken;

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
33 changes: 33 additions & 0 deletions packages/client/lib/functions/dpopUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { dpopResourceAuthenticateError, dpopTokenRequestNonceError } from '@sphereon/oid4vc-common';
import { OpenIDResponse } from 'oid4vci-common';

export function dPoPShouldRetryRequestWithNonce(response: OpenIDResponse<unknown, unknown>) {
if (response.errorBody && response.errorBody.error === dpopTokenRequestNonceError) {
const dPoPNonce = response.errorBody.headers.get('DPoP-Nonce');
if (!dPoPNonce) {
throw new Error('The DPoP nonce was not returned');
}

return { ok: true, dpopNonce: dPoPNonce } as const;
}

return { ok: false } as const;
}

export function dPoPShouldRetryResourceRequestWithNonce(response: OpenIDResponse<unknown, unknown>) {
if (response.errorBody && response.origResponse.status === 401) {
const wwwAuthenticateHeader = response.errorBody.headers.get('WWW-Authenticate');
if (!wwwAuthenticateHeader?.includes(dpopResourceAuthenticateError)) {
return { ok: false } as const;
}

const dPoPNonce = response.errorBody.headers.get('DPoP-Nonce');
if (!dPoPNonce) {
throw new Error('The DPoP nonce was not returned');
}

return { ok: true, dpopNonce: dPoPNonce } as const;
}

return { ok: false } as const;
}
3 changes: 3 additions & 0 deletions packages/common/lib/dpop/DPoP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import {
VerifyJwtCallbackBase,
} from './../jwt';

export const dpopTokenRequestNonceError = 'use_dpop_nonce';
export const dpopResourceAuthenticateError = 'DPoP error="use_dpop_nonce", error_description="Resource server requires nonce in DPoP proof"';

export interface DPoPJwtIssuerWithContext extends JwtIssuerJwk {
type: 'dpop';
dPoPSigningAlgValuesSupported?: string[];
Expand Down
2 changes: 1 addition & 1 deletion packages/did-auth-siop-adapter/lib/DidJwtAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { JwtHeader, JwtPayload } from '@sphereon/oid4vc-common'
import { AuthorizationRequestPayload, IDTokenPayload, JwtIssuerWithContext, RequestObjectPayload } from '@sphereon/did-auth-siop'
import { JwtVerifier } from '@sphereon/did-auth-siop'
import { JwtHeader, JwtPayload } from '@sphereon/oid4vc-common'
import { Resolvable } from 'did-resolver'

import { getAudience, getSubDidFromPayload, signIDTokenPayload, signRequestObjectPayload, validateLinkedDomainWithDid, verifyDidJWT } from './did'
Expand Down
2 changes: 1 addition & 1 deletion packages/did-auth-siop-adapter/lib/did/DidJWT.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { SigningAlgo } from '@sphereon/oid4vc-common'
import { post } from '@sphereon/did-auth-siop'
import {
DEFAULT_EXPIRATION_TIME,
Expand All @@ -10,6 +9,7 @@ import {
SIOPResonse,
VerifiedJWT,
} from '@sphereon/did-auth-siop'
import { SigningAlgo } from '@sphereon/oid4vc-common'
import {
createJWT,
decodeJWT,
Expand Down
7 changes: 6 additions & 1 deletion packages/oid4vci-common/lib/types/Authorization.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,10 +324,15 @@ export interface AccessTokenRequest {
[s: string]: unknown;
}

export interface OpenIDResponse<T> {
export interface OpenIDResponse<T, P = never> {
origResponse: Response;
successBody?: T;
errorBody?: ErrorResponse;
params?: P;
}

export interface DPoPResponseParams {
dpop?: { dpopNonce: string };
}

export interface AccessTokenResponse {
Expand Down

0 comments on commit a102854

Please sign in to comment.