Skip to content

Commit

Permalink
Merge branch 'develop' into fix/redirect_uri
Browse files Browse the repository at this point in the history
  • Loading branch information
nklomp authored Aug 2, 2024
2 parents 42c8ddd + 8af5f49 commit 4720956
Show file tree
Hide file tree
Showing 132 changed files with 13,419 additions and 11,116 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
51 changes: 44 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 All @@ -105,6 +135,7 @@ export class AccessTokenClient {
if (credentialOfferRequest?.supportedFlows.includes(AuthzFlowType.PRE_AUTHORIZED_CODE_FLOW)) {
this.assertAlphanumericPin(opts.pinMetadata, pin);
request.user_pin = pin;
request.tx_code = pin;

request.grant_type = GrantTypes.PRE_AUTHORIZED_CODE;
// we actually know it is there because of the isPreAuthCode call
Expand Down Expand Up @@ -221,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
24 changes: 11 additions & 13 deletions packages/client/lib/AuthorizationCodeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,16 @@ export const createAuthorizationRequestUrl = async ({
const { redirectUri, requestObjectOpts = { requestObjectMode: CreateRequestObjectMode.NONE } } = authorizationRequest;
const client_id = clientId ?? authorizationRequest.clientId;

let { scope, authorizationDetails } = authorizationRequest;
const parMode = endpointMetadata?.credentialIssuerMetadata?.require_pushed_authorization_requests
// Authorization server metadata takes precedence
const authorizationMetadata = endpointMetadata.authorizationServerMetadata ?? endpointMetadata.credentialIssuerMetadata;

let { authorizationDetails } = authorizationRequest;
const parMode = authorizationMetadata?.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) {
if (!authorizationRequest.scope && !authorizationDetails) {
if (!credentialOffer) {
throw Error('Please provide a scope or authorization_details if no credential offer is present');
}
Expand Down Expand Up @@ -177,12 +180,7 @@ export const createAuthorizationRequestUrl = async ({
if (!endpointMetadata?.authorization_endpoint) {
throw Error('Server metadata does not contain authorization endpoint');
}
const parEndpoint = endpointMetadata.credentialIssuerMetadata?.pushed_authorization_request_endpoint;

// add 'openid' scope if not present
if (!scope?.includes('openid')) {
scope = ['openid', scope].filter((s) => !!s).join(' ');
}
const parEndpoint = authorizationMetadata?.pushed_authorization_request_endpoint;

let queryObj: Record<string, any> | PushedAuthorizationResponse = {
response_type: ResponseType.AUTH_CODE,
Expand All @@ -194,7 +192,7 @@ export const createAuthorizationRequestUrl = async ({
...(redirectUri && { redirect_uri: redirectUri }),
...(client_id && { client_id }),
...(credentialOffer?.issuerState && { issuer_state: credentialOffer.issuerState }),
scope,
scope: authorizationRequest.scope,
};

if (!parEndpoint && parMode === PARMode.REQUIRE) {
Expand All @@ -210,11 +208,11 @@ export const createAuthorizationRequestUrl = async ({
{ contentType: 'application/x-www-form-urlencoded', accept: 'application/json' },
);
if (parResponse.errorBody || !parResponse.successBody) {
console.log(JSON.stringify(parResponse.errorBody));
console.log('Falling back to regular request URI, since PAR failed');
if (parMode === PARMode.REQUIRE) {
throw Error(`PAR error: ${parResponse.origResponse.statusText}`);
}

debug('Falling back to regular request URI, since PAR failed', JSON.stringify(parResponse.errorBody));
} else {
debug(`PAR response: ${JSON.stringify(parResponse.successBody, null, 2)}`);
queryObj = { /*response_type: ResponseType.AUTH_CODE,*/ client_id, request_uri: parResponse.successBody.request_uri };
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
Loading

0 comments on commit 4720956

Please sign in to comment.