Skip to content

Commit

Permalink
Merge pull request #82 from Sphereon-Opensource/feature/VDX-316
Browse files Browse the repository at this point in the history
Feature/vdx 316  deferred credential support
  • Loading branch information
nklomp authored Jan 23, 2024
2 parents 89f78b3 + a8ea635 commit e3c1601
Show file tree
Hide file tree
Showing 13 changed files with 231 additions and 25 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"publish:unstable": "lerna publish --conventional-prerelease --force-publish --canary --no-git-tag-version --include-merged-tags --preid unstable --pre-dist-tag unstable --yes --registry https://registry.npmjs.org"
},
"engines": {
"node": ">=16"
"node": ">=18"
},
"resolutions": {
"node-fetch": "2.6.12"
Expand Down
2 changes: 1 addition & 1 deletion packages/client/lib/AccessTokenClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ export class AccessTokenClient {
this.assertNonEmptyCode(accessTokenRequest);
this.assertNonEmptyRedirectUri(accessTokenRequest);
} else {
this.throwNotSupportedFlow;
this.throwNotSupportedFlow();
}
}

Expand Down
70 changes: 64 additions & 6 deletions packages/client/lib/CredentialRequestClient.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {
acquireDeferredCredential,
CredentialResponse,
getCredentialRequestForVersion,
getUniformFormat,
isDeferredCredentialResponse,
OID4VCICredentialFormat,
OpenId4VCIVersion,
OpenIDResponse,
Expand All @@ -19,25 +21,56 @@ import { isValidURL, post } from './functions';
const debug = Debug('sphereon:oid4vci:credential');

export interface CredentialRequestOpts {
deferredCredentialAwait?: boolean;
deferredCredentialIntervalInMS?: number;
credentialEndpoint: string;
deferredCredentialEndpoint?: string;
credentialTypes: string[];
format?: CredentialFormat | OID4VCICredentialFormat;
proof: ProofOfPossession;
token: string;
version: OpenId4VCIVersion;
}

export async function buildProof<DIDDoc>(
proofInput: ProofOfPossessionBuilder<DIDDoc> | ProofOfPossession,
opts: {
version: OpenId4VCIVersion;
cNonce?: string;
},
) {
if ('proof_type' in proofInput) {
if (opts.cNonce) {
throw Error(`Cnonce param is only supported when using a Proof of Posession builder`);
}
return await ProofOfPossessionBuilder.fromProof(proofInput as ProofOfPossession, opts.version).build();
}
if (opts.cNonce) {
proofInput.withAccessTokenNonce(opts.cNonce);
}
return await proofInput.build();
}

export class CredentialRequestClient {
private readonly _credentialRequestOpts: Partial<CredentialRequestOpts>;
private _isDeferred = false;

get credentialRequestOpts(): CredentialRequestOpts {
return this._credentialRequestOpts as CredentialRequestOpts;
}

public isDeferred(): boolean {
return this._isDeferred;
}

public getCredentialEndpoint(): string {
return this.credentialRequestOpts.credentialEndpoint;
}

public getDeferredCredentialEndpoint(): string | undefined {
return this.credentialRequestOpts.deferredCredentialEndpoint;
}

public constructor(builder: CredentialRequestClientBuilder) {
this._credentialRequestOpts = { ...builder };
}
Expand All @@ -63,11 +96,40 @@ export class CredentialRequestClient {
debug(`Acquiring credential(s) from: ${credentialEndpoint}`);
debug(`request\n: ${JSON.stringify(request, null, 2)}`);
const requestToken: string = this.credentialRequestOpts.token;
const response: OpenIDResponse<CredentialResponse> = await post(credentialEndpoint, JSON.stringify(request), { bearerToken: requestToken });
let response: OpenIDResponse<CredentialResponse> = await post(credentialEndpoint, JSON.stringify(request), { bearerToken: requestToken });
this._isDeferred = isDeferredCredentialResponse(response);
if (this.isDeferred() && this.credentialRequestOpts.deferredCredentialAwait && response.successBody) {
response = await this.acquireDeferredCredential(response.successBody, { bearerToken: this.credentialRequestOpts.token });
}

debug(`Credential endpoint ${credentialEndpoint} response:\r\n${JSON.stringify(response, null, 2)}`);
return response;
}

public async acquireDeferredCredential(
response: Pick<CredentialResponse, 'transaction_id' | 'acceptance_token' | 'c_nonce'>,
opts?: {
bearerToken?: string;
},
): Promise<OpenIDResponse<CredentialResponse>> {
const transactionId = response.transaction_id;
const bearerToken = response.acceptance_token ?? opts?.bearerToken;
const deferredCredentialEndpoint = this.getDeferredCredentialEndpoint();
if (!deferredCredentialEndpoint) {
throw Error(`No deferred credential endpoint supplied.`);
} else if (!bearerToken) {
throw Error(`No bearer token present and refresh for defered endpoint not supported yet`);
// todo updated bearer token with new c_nonce
}
return await acquireDeferredCredential({
bearerToken,
transactionId,
deferredCredentialEndpoint,
deferredCredentialAwait: this.credentialRequestOpts.deferredCredentialAwait,
deferredCredentialIntervalInMS: this.credentialRequestOpts.deferredCredentialIntervalInMS,
});
}

public async createCredentialRequest<DIDDoc>(opts: {
proofInput: ProofOfPossessionBuilder<DIDDoc> | ProofOfPossession;
credentialTypes?: string | string[];
Expand All @@ -93,11 +155,7 @@ export class CredentialRequestClient {
else if (!this.isV11OrHigher() && types.length !== 1) {
throw Error('Only a single credential type is supported for V8/V9');
}

const proof =
'proof_type' in proofInput
? await ProofOfPossessionBuilder.fromProof(proofInput as ProofOfPossession, opts.version).build()
: await proofInput.build();
const proof = await buildProof(proofInput, opts);

// TODO: we should move format specific logic
if (format === 'jwt_vc_json' || format === 'jwt_vc') {
Expand Down
39 changes: 32 additions & 7 deletions packages/client/lib/CredentialRequestClientBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { CredentialRequestClient } from './CredentialRequestClient';

export class CredentialRequestClientBuilder {
credentialEndpoint?: string;
deferredCredentialEndpoint?: string;
deferredCredentialAwait = false;
deferredCredentialIntervalInMS = 5000;
credentialTypes: string[] = [];
format?: CredentialFormat | OID4VCICredentialFormat;
token?: string;
Expand All @@ -38,6 +41,9 @@ export class CredentialRequestClientBuilder {
const builder = new CredentialRequestClientBuilder();
builder.withVersion(version ?? OpenId4VCIVersion.VER_1_0_11);
builder.withCredentialEndpoint(metadata?.credential_endpoint ?? (issuer.endsWith('/') ? `${issuer}credential` : `${issuer}/credential`));
if (metadata?.deferred_credential_endpoint) {
builder.withDeferredCredentialEndpoint(metadata.deferred_credential_endpoint);
}
builder.withCredentialType(credentialTypes);
return builder;
}
Expand All @@ -60,6 +66,9 @@ export class CredentialRequestClientBuilder {
const issuer = getIssuerFromCredentialOfferPayload(request.credential_offer) ?? (metadata?.issuer as string);
builder.withVersion(version);
builder.withCredentialEndpoint(metadata?.credential_endpoint ?? (issuer.endsWith('/') ? `${issuer}credential` : `${issuer}/credential`));
if (metadata?.deferred_credential_endpoint) {
builder.withDeferredCredentialEndpoint(metadata.deferred_credential_endpoint);
}

if (version <= OpenId4VCIVersion.VER_1_0_08) {
//todo: This basically sets all types available during initiation. Probably the user only wants a subset. So do we want to do this?
Expand All @@ -86,37 +95,53 @@ export class CredentialRequestClientBuilder {
});
}

public withCredentialEndpointFromMetadata(metadata: CredentialIssuerMetadata): CredentialRequestClientBuilder {
public withCredentialEndpointFromMetadata(metadata: CredentialIssuerMetadata): this {
this.credentialEndpoint = metadata.credential_endpoint;
return this;
}

public withCredentialEndpoint(credentialEndpoint: string): CredentialRequestClientBuilder {
public withCredentialEndpoint(credentialEndpoint: string): this {
this.credentialEndpoint = credentialEndpoint;
return this;
}

public withCredentialType(credentialTypes: string | string[]): CredentialRequestClientBuilder {
public withDeferredCredentialEndpointFromMetadata(metadata: CredentialIssuerMetadata): this {
this.deferredCredentialEndpoint = metadata.deferred_credential_endpoint;
return this;
}

public withDeferredCredentialEndpoint(deferredCredentialEndpoint: string): this {
this.deferredCredentialEndpoint = deferredCredentialEndpoint;
return this;
}

public withDeferredCredentialAwait(deferredCredentialAwait: boolean, deferredCredentialIntervalInMS?: number): this {
this.deferredCredentialAwait = deferredCredentialAwait;
this.deferredCredentialIntervalInMS = deferredCredentialIntervalInMS ?? 5000;
return this;
}

public withCredentialType(credentialTypes: string | string[]): this {
this.credentialTypes = Array.isArray(credentialTypes) ? credentialTypes : [credentialTypes];
return this;
}

public withFormat(format: CredentialFormat | OID4VCICredentialFormat): CredentialRequestClientBuilder {
public withFormat(format: CredentialFormat | OID4VCICredentialFormat): this {
this.format = format;
return this;
}

public withToken(accessToken: string): CredentialRequestClientBuilder {
public withToken(accessToken: string): this {
this.token = accessToken;
return this;
}

public withTokenFromResponse(response: AccessTokenResponse): CredentialRequestClientBuilder {
public withTokenFromResponse(response: AccessTokenResponse): this {
this.token = response.access_token;
return this;
}

public withVersion(version: OpenId4VCIVersion): CredentialRequestClientBuilder {
public withVersion(version: OpenId4VCIVersion): this {
this.version = version;
return this;
}
Expand Down
14 changes: 13 additions & 1 deletion packages/client/lib/MetadataClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export class MetadataClient {
public static async retrieveAllMetadata(issuer: string, opts?: { errorOnNotFound: boolean }): Promise<EndpointMetadataResult> {
let token_endpoint: string | undefined;
let credential_endpoint: string | undefined;
let deferred_credential_endpoint: string | undefined;
let authorization_endpoint: string | undefined;
let authorizationServerType: AuthorizationServerType = 'OID4VCI';
let authorization_server: string = issuer;
Expand All @@ -53,6 +54,7 @@ export class MetadataClient {
if (credentialIssuerMetadata) {
debug(`Issuer ${issuer} OID4VCI well-known server metadata\r\n${JSON.stringify(credentialIssuerMetadata)}`);
credential_endpoint = credentialIssuerMetadata.credential_endpoint;
deferred_credential_endpoint = credentialIssuerMetadata.deferred_credential_endpoint;
if (credentialIssuerMetadata.token_endpoint) {
token_endpoint = credentialIssuerMetadata.token_endpoint;
}
Expand Down Expand Up @@ -111,12 +113,21 @@ export class MetadataClient {
if (authMetadata.credential_endpoint) {
if (credential_endpoint && authMetadata.credential_endpoint !== credential_endpoint) {
debug(
`Credential issuer has a different credential_endpoint (${credential_endpoint}) from the Authorization Server (${authMetadata.token_endpoint}). Will use the issuer value`,
`Credential issuer has a different credential_endpoint (${credential_endpoint}) from the Authorization Server (${authMetadata.credential_endpoint}). Will use the issuer value`,
);
} else {
credential_endpoint = authMetadata.credential_endpoint;
}
}
if (authMetadata.deferred_credential_endpoint) {
if (deferred_credential_endpoint && authMetadata.deferred_credential_endpoint !== deferred_credential_endpoint) {
debug(
`Credential issuer has a different deferred_credential_endpoint (${deferred_credential_endpoint}) from the Authorization Server (${authMetadata.deferred_credential_endpoint}). Will use the issuer value`,
);
} else {
deferred_credential_endpoint = authMetadata.deferred_credential_endpoint;
}
}
}

if (!authorization_endpoint) {
Expand Down Expand Up @@ -148,6 +159,7 @@ export class MetadataClient {
issuer,
token_endpoint,
credential_endpoint,
deferred_credential_endpoint,
authorization_server,
authorization_endpoint,
authorizationServerType,
Expand Down
12 changes: 12 additions & 0 deletions packages/client/lib/OpenID4VCIClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,8 @@ export class OpenID4VCIClient {
jwk,
alg,
jti,
deferredCredentialAwait,
deferredCredentialIntervalInMS,
}: {
credentialTypes: string | string[];
proofCallbacks: ProofOfPossessionCallbacks<any>;
Expand All @@ -366,6 +368,8 @@ export class OpenID4VCIClient {
jwk?: JWK;
alg?: Alg | string;
jti?: string;
deferredCredentialAwait?: boolean;
deferredCredentialIntervalInMS?: number;
}): Promise<CredentialResponse> {
if ([jwk, kid].filter((v) => v !== undefined).length > 1) {
throw new Error(KID_JWK_X5C_ERROR + `. jwk: ${jwk !== undefined}, kid: ${kid !== undefined}`);
Expand All @@ -388,6 +392,7 @@ export class OpenID4VCIClient {
});

requestBuilder.withTokenFromResponse(this.accessTokenResponse);
requestBuilder.withDeferredCredentialAwait(deferredCredentialAwait ?? false, deferredCredentialIntervalInMS);
if (this.endpointMetadata?.credentialIssuerMetadata) {
const metadata = this.endpointMetadata.credentialIssuerMetadata;
const types = Array.isArray(credentialTypes) ? [...credentialTypes].sort() : [credentialTypes];
Expand Down Expand Up @@ -562,6 +567,13 @@ export class OpenID4VCIClient {
return this.endpointMetadata ? this.endpointMetadata.credential_endpoint : `${this.getIssuer()}/credential`;
}

public hasDeferredCredentialEndpoint(): boolean {
return !!this.getAccessTokenEndpoint();
}
public getDeferredCredentialEndpoint(): string {
this.assertIssuerData();
return this.endpointMetadata ? this.endpointMetadata.credential_endpoint : `${this.getIssuer()}/credential`;
}
private assertIssuerData(): void {
if (!this._credentialOffer && this.issuerSupportedFlowTypes().includes(AuthzFlowType.PRE_AUTHORIZED_CODE_FLOW)) {
throw Error(`No issuance initiation or credential offer present`);
Expand Down
11 changes: 8 additions & 3 deletions packages/client/lib/__tests__/EBSIE2E.spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const kid = `${DID}#z2dmzD81cgPx8Vki7JbuuMmFYrWPgYoytykUZ3eyqht1j9Kbrm54tL4pRrDD

// const jw = jose.importKey()
describe('OID4VCI-Client using Sphereon issuer should', () => {
async function test(credentialType: 'CTWalletCrossPreAuthorisedInTime' | 'CTWalletCrossAuthorisedInTime') {
async function test(credentialType: 'CTWalletCrossPreAuthorisedInTime' | 'CTWalletCrossPreAuthorisedDeferred' | 'CTWalletCrossAuthorisedInTime') {
debug.enable('*');
const offer = await getCredentialOffer(credentialType);
const client = await OpenID4VCIClient.fromURI({
Expand Down Expand Up @@ -93,6 +93,8 @@ describe('OID4VCI-Client using Sphereon issuer should', () => {
signCallback: proofOfPossessionCallbackFunction,
},
kid,
deferredCredentialAwait: true,
deferredCredentialIntervalInMS: 5000,
});
console.log(JSON.stringify(credentialResponse, null, 2));
expect(credentialResponse.credential).toBeDefined();
Expand All @@ -102,17 +104,20 @@ describe('OID4VCI-Client using Sphereon issuer should', () => {

// Current conformance tests is not stable as changes are being applied it seems

it.skip(
it(
'succeed in a full flow with the client using OpenID4VCI version 11 and jwt_vc_json',
async () => {
await test('CTWalletCrossPreAuthorisedInTime');
await test('CTWalletCrossPreAuthorisedDeferred');
// await test('CTWalletCrossAuthorisedInTime');
},
UNIT_TEST_TIMEOUT,
);
});

async function getCredentialOffer(credentialType: 'CTWalletCrossPreAuthorisedInTime' | 'CTWalletCrossAuthorisedInTime'): Promise<string> {
async function getCredentialOffer(
credentialType: 'CTWalletCrossPreAuthorisedInTime' | 'CTWalletCrossAuthorisedInTime' | 'CTWalletCrossPreAuthorisedDeferred',
): Promise<string> {
const credentialOffer = await fetch(
`https://conformance-test.ebsi.eu/conformance/v3/issuer-mock/initiate-credential-offer?credential_type=${credentialType}&client_id=${DID_URL_ENCODED}&credential_offer_endpoint=openid-credential-offer%3A%2F%2F`,
{
Expand Down
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"@types/node": "^18.17.4",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@sphereon/ssi-sdk-ext.key-utils": "^0.15.1-next.7",
"@sphereon/ssi-sdk-ext.key-utils": "^0.16.0",
"codecov": "^3.8.3",
"dotenv": "^16.3.1",
"eslint": "^8.46.0",
Expand Down
Loading

0 comments on commit e3c1601

Please sign in to comment.