Skip to content

Commit e3c1601

Browse files
authored
Merge pull request #82 from Sphereon-Opensource/feature/VDX-316
Feature/vdx 316 deferred credential support
2 parents 89f78b3 + a8ea635 commit e3c1601

13 files changed

Lines changed: 231 additions & 25 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"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"
2121
},
2222
"engines": {
23-
"node": ">=16"
23+
"node": ">=18"
2424
},
2525
"resolutions": {
2626
"node-fetch": "2.6.12"

packages/client/lib/AccessTokenClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ export class AccessTokenClient {
195195
this.assertNonEmptyCode(accessTokenRequest);
196196
this.assertNonEmptyRedirectUri(accessTokenRequest);
197197
} else {
198-
this.throwNotSupportedFlow;
198+
this.throwNotSupportedFlow();
199199
}
200200
}
201201

packages/client/lib/CredentialRequestClient.ts

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {
2+
acquireDeferredCredential,
23
CredentialResponse,
34
getCredentialRequestForVersion,
45
getUniformFormat,
6+
isDeferredCredentialResponse,
57
OID4VCICredentialFormat,
68
OpenId4VCIVersion,
79
OpenIDResponse,
@@ -19,25 +21,56 @@ import { isValidURL, post } from './functions';
1921
const debug = Debug('sphereon:oid4vci:credential');
2022

2123
export interface CredentialRequestOpts {
24+
deferredCredentialAwait?: boolean;
25+
deferredCredentialIntervalInMS?: number;
2226
credentialEndpoint: string;
27+
deferredCredentialEndpoint?: string;
2328
credentialTypes: string[];
2429
format?: CredentialFormat | OID4VCICredentialFormat;
2530
proof: ProofOfPossession;
2631
token: string;
2732
version: OpenId4VCIVersion;
2833
}
2934

35+
export async function buildProof<DIDDoc>(
36+
proofInput: ProofOfPossessionBuilder<DIDDoc> | ProofOfPossession,
37+
opts: {
38+
version: OpenId4VCIVersion;
39+
cNonce?: string;
40+
},
41+
) {
42+
if ('proof_type' in proofInput) {
43+
if (opts.cNonce) {
44+
throw Error(`Cnonce param is only supported when using a Proof of Posession builder`);
45+
}
46+
return await ProofOfPossessionBuilder.fromProof(proofInput as ProofOfPossession, opts.version).build();
47+
}
48+
if (opts.cNonce) {
49+
proofInput.withAccessTokenNonce(opts.cNonce);
50+
}
51+
return await proofInput.build();
52+
}
53+
3054
export class CredentialRequestClient {
3155
private readonly _credentialRequestOpts: Partial<CredentialRequestOpts>;
56+
private _isDeferred = false;
3257

3358
get credentialRequestOpts(): CredentialRequestOpts {
3459
return this._credentialRequestOpts as CredentialRequestOpts;
3560
}
3661

62+
public isDeferred(): boolean {
63+
return this._isDeferred;
64+
}
65+
3766
public getCredentialEndpoint(): string {
3867
return this.credentialRequestOpts.credentialEndpoint;
3968
}
4069

70+
public getDeferredCredentialEndpoint(): string | undefined {
71+
return this.credentialRequestOpts.deferredCredentialEndpoint;
72+
}
73+
4174
public constructor(builder: CredentialRequestClientBuilder) {
4275
this._credentialRequestOpts = { ...builder };
4376
}
@@ -63,11 +96,40 @@ export class CredentialRequestClient {
6396
debug(`Acquiring credential(s) from: ${credentialEndpoint}`);
6497
debug(`request\n: ${JSON.stringify(request, null, 2)}`);
6598
const requestToken: string = this.credentialRequestOpts.token;
66-
const response: OpenIDResponse<CredentialResponse> = await post(credentialEndpoint, JSON.stringify(request), { bearerToken: requestToken });
99+
let response: OpenIDResponse<CredentialResponse> = await post(credentialEndpoint, JSON.stringify(request), { bearerToken: requestToken });
100+
this._isDeferred = isDeferredCredentialResponse(response);
101+
if (this.isDeferred() && this.credentialRequestOpts.deferredCredentialAwait && response.successBody) {
102+
response = await this.acquireDeferredCredential(response.successBody, { bearerToken: this.credentialRequestOpts.token });
103+
}
104+
67105
debug(`Credential endpoint ${credentialEndpoint} response:\r\n${JSON.stringify(response, null, 2)}`);
68106
return response;
69107
}
70108

109+
public async acquireDeferredCredential(
110+
response: Pick<CredentialResponse, 'transaction_id' | 'acceptance_token' | 'c_nonce'>,
111+
opts?: {
112+
bearerToken?: string;
113+
},
114+
): Promise<OpenIDResponse<CredentialResponse>> {
115+
const transactionId = response.transaction_id;
116+
const bearerToken = response.acceptance_token ?? opts?.bearerToken;
117+
const deferredCredentialEndpoint = this.getDeferredCredentialEndpoint();
118+
if (!deferredCredentialEndpoint) {
119+
throw Error(`No deferred credential endpoint supplied.`);
120+
} else if (!bearerToken) {
121+
throw Error(`No bearer token present and refresh for defered endpoint not supported yet`);
122+
// todo updated bearer token with new c_nonce
123+
}
124+
return await acquireDeferredCredential({
125+
bearerToken,
126+
transactionId,
127+
deferredCredentialEndpoint,
128+
deferredCredentialAwait: this.credentialRequestOpts.deferredCredentialAwait,
129+
deferredCredentialIntervalInMS: this.credentialRequestOpts.deferredCredentialIntervalInMS,
130+
});
131+
}
132+
71133
public async createCredentialRequest<DIDDoc>(opts: {
72134
proofInput: ProofOfPossessionBuilder<DIDDoc> | ProofOfPossession;
73135
credentialTypes?: string | string[];
@@ -93,11 +155,7 @@ export class CredentialRequestClient {
93155
else if (!this.isV11OrHigher() && types.length !== 1) {
94156
throw Error('Only a single credential type is supported for V8/V9');
95157
}
96-
97-
const proof =
98-
'proof_type' in proofInput
99-
? await ProofOfPossessionBuilder.fromProof(proofInput as ProofOfPossession, opts.version).build()
100-
: await proofInput.build();
158+
const proof = await buildProof(proofInput, opts);
101159

102160
// TODO: we should move format specific logic
103161
if (format === 'jwt_vc_json' || format === 'jwt_vc') {

packages/client/lib/CredentialRequestClientBuilder.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import { CredentialRequestClient } from './CredentialRequestClient';
1818

1919
export class CredentialRequestClientBuilder {
2020
credentialEndpoint?: string;
21+
deferredCredentialEndpoint?: string;
22+
deferredCredentialAwait = false;
23+
deferredCredentialIntervalInMS = 5000;
2124
credentialTypes: string[] = [];
2225
format?: CredentialFormat | OID4VCICredentialFormat;
2326
token?: string;
@@ -38,6 +41,9 @@ export class CredentialRequestClientBuilder {
3841
const builder = new CredentialRequestClientBuilder();
3942
builder.withVersion(version ?? OpenId4VCIVersion.VER_1_0_11);
4043
builder.withCredentialEndpoint(metadata?.credential_endpoint ?? (issuer.endsWith('/') ? `${issuer}credential` : `${issuer}/credential`));
44+
if (metadata?.deferred_credential_endpoint) {
45+
builder.withDeferredCredentialEndpoint(metadata.deferred_credential_endpoint);
46+
}
4147
builder.withCredentialType(credentialTypes);
4248
return builder;
4349
}
@@ -60,6 +66,9 @@ export class CredentialRequestClientBuilder {
6066
const issuer = getIssuerFromCredentialOfferPayload(request.credential_offer) ?? (metadata?.issuer as string);
6167
builder.withVersion(version);
6268
builder.withCredentialEndpoint(metadata?.credential_endpoint ?? (issuer.endsWith('/') ? `${issuer}credential` : `${issuer}/credential`));
69+
if (metadata?.deferred_credential_endpoint) {
70+
builder.withDeferredCredentialEndpoint(metadata.deferred_credential_endpoint);
71+
}
6372

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

89-
public withCredentialEndpointFromMetadata(metadata: CredentialIssuerMetadata): CredentialRequestClientBuilder {
98+
public withCredentialEndpointFromMetadata(metadata: CredentialIssuerMetadata): this {
9099
this.credentialEndpoint = metadata.credential_endpoint;
91100
return this;
92101
}
93102

94-
public withCredentialEndpoint(credentialEndpoint: string): CredentialRequestClientBuilder {
103+
public withCredentialEndpoint(credentialEndpoint: string): this {
95104
this.credentialEndpoint = credentialEndpoint;
96105
return this;
97106
}
98107

99-
public withCredentialType(credentialTypes: string | string[]): CredentialRequestClientBuilder {
108+
public withDeferredCredentialEndpointFromMetadata(metadata: CredentialIssuerMetadata): this {
109+
this.deferredCredentialEndpoint = metadata.deferred_credential_endpoint;
110+
return this;
111+
}
112+
113+
public withDeferredCredentialEndpoint(deferredCredentialEndpoint: string): this {
114+
this.deferredCredentialEndpoint = deferredCredentialEndpoint;
115+
return this;
116+
}
117+
118+
public withDeferredCredentialAwait(deferredCredentialAwait: boolean, deferredCredentialIntervalInMS?: number): this {
119+
this.deferredCredentialAwait = deferredCredentialAwait;
120+
this.deferredCredentialIntervalInMS = deferredCredentialIntervalInMS ?? 5000;
121+
return this;
122+
}
123+
124+
public withCredentialType(credentialTypes: string | string[]): this {
100125
this.credentialTypes = Array.isArray(credentialTypes) ? credentialTypes : [credentialTypes];
101126
return this;
102127
}
103128

104-
public withFormat(format: CredentialFormat | OID4VCICredentialFormat): CredentialRequestClientBuilder {
129+
public withFormat(format: CredentialFormat | OID4VCICredentialFormat): this {
105130
this.format = format;
106131
return this;
107132
}
108133

109-
public withToken(accessToken: string): CredentialRequestClientBuilder {
134+
public withToken(accessToken: string): this {
110135
this.token = accessToken;
111136
return this;
112137
}
113138

114-
public withTokenFromResponse(response: AccessTokenResponse): CredentialRequestClientBuilder {
139+
public withTokenFromResponse(response: AccessTokenResponse): this {
115140
this.token = response.access_token;
116141
return this;
117142
}
118143

119-
public withVersion(version: OpenId4VCIVersion): CredentialRequestClientBuilder {
144+
public withVersion(version: OpenId4VCIVersion): this {
120145
this.version = version;
121146
return this;
122147
}

packages/client/lib/MetadataClient.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export class MetadataClient {
4545
public static async retrieveAllMetadata(issuer: string, opts?: { errorOnNotFound: boolean }): Promise<EndpointMetadataResult> {
4646
let token_endpoint: string | undefined;
4747
let credential_endpoint: string | undefined;
48+
let deferred_credential_endpoint: string | undefined;
4849
let authorization_endpoint: string | undefined;
4950
let authorizationServerType: AuthorizationServerType = 'OID4VCI';
5051
let authorization_server: string = issuer;
@@ -53,6 +54,7 @@ export class MetadataClient {
5354
if (credentialIssuerMetadata) {
5455
debug(`Issuer ${issuer} OID4VCI well-known server metadata\r\n${JSON.stringify(credentialIssuerMetadata)}`);
5556
credential_endpoint = credentialIssuerMetadata.credential_endpoint;
57+
deferred_credential_endpoint = credentialIssuerMetadata.deferred_credential_endpoint;
5658
if (credentialIssuerMetadata.token_endpoint) {
5759
token_endpoint = credentialIssuerMetadata.token_endpoint;
5860
}
@@ -111,12 +113,21 @@ export class MetadataClient {
111113
if (authMetadata.credential_endpoint) {
112114
if (credential_endpoint && authMetadata.credential_endpoint !== credential_endpoint) {
113115
debug(
114-
`Credential issuer has a different credential_endpoint (${credential_endpoint}) from the Authorization Server (${authMetadata.token_endpoint}). Will use the issuer value`,
116+
`Credential issuer has a different credential_endpoint (${credential_endpoint}) from the Authorization Server (${authMetadata.credential_endpoint}). Will use the issuer value`,
115117
);
116118
} else {
117119
credential_endpoint = authMetadata.credential_endpoint;
118120
}
119121
}
122+
if (authMetadata.deferred_credential_endpoint) {
123+
if (deferred_credential_endpoint && authMetadata.deferred_credential_endpoint !== deferred_credential_endpoint) {
124+
debug(
125+
`Credential issuer has a different deferred_credential_endpoint (${deferred_credential_endpoint}) from the Authorization Server (${authMetadata.deferred_credential_endpoint}). Will use the issuer value`,
126+
);
127+
} else {
128+
deferred_credential_endpoint = authMetadata.deferred_credential_endpoint;
129+
}
130+
}
120131
}
121132

122133
if (!authorization_endpoint) {
@@ -148,6 +159,7 @@ export class MetadataClient {
148159
issuer,
149160
token_endpoint,
150161
credential_endpoint,
162+
deferred_credential_endpoint,
151163
authorization_server,
152164
authorization_endpoint,
153165
authorizationServerType,

packages/client/lib/OpenID4VCIClient.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,8 @@ export class OpenID4VCIClient {
358358
jwk,
359359
alg,
360360
jti,
361+
deferredCredentialAwait,
362+
deferredCredentialIntervalInMS,
361363
}: {
362364
credentialTypes: string | string[];
363365
proofCallbacks: ProofOfPossessionCallbacks<any>;
@@ -366,6 +368,8 @@ export class OpenID4VCIClient {
366368
jwk?: JWK;
367369
alg?: Alg | string;
368370
jti?: string;
371+
deferredCredentialAwait?: boolean;
372+
deferredCredentialIntervalInMS?: number;
369373
}): Promise<CredentialResponse> {
370374
if ([jwk, kid].filter((v) => v !== undefined).length > 1) {
371375
throw new Error(KID_JWK_X5C_ERROR + `. jwk: ${jwk !== undefined}, kid: ${kid !== undefined}`);
@@ -388,6 +392,7 @@ export class OpenID4VCIClient {
388392
});
389393

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

570+
public hasDeferredCredentialEndpoint(): boolean {
571+
return !!this.getAccessTokenEndpoint();
572+
}
573+
public getDeferredCredentialEndpoint(): string {
574+
this.assertIssuerData();
575+
return this.endpointMetadata ? this.endpointMetadata.credential_endpoint : `${this.getIssuer()}/credential`;
576+
}
565577
private assertIssuerData(): void {
566578
if (!this._credentialOffer && this.issuerSupportedFlowTypes().includes(AuthzFlowType.PRE_AUTHORIZED_CODE_FLOW)) {
567579
throw Error(`No issuance initiation or credential offer present`);

packages/client/lib/__tests__/EBSIE2E.spec.test.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ const kid = `${DID}#z2dmzD81cgPx8Vki7JbuuMmFYrWPgYoytykUZ3eyqht1j9Kbrm54tL4pRrDD
5353

5454
// const jw = jose.importKey()
5555
describe('OID4VCI-Client using Sphereon issuer should', () => {
56-
async function test(credentialType: 'CTWalletCrossPreAuthorisedInTime' | 'CTWalletCrossAuthorisedInTime') {
56+
async function test(credentialType: 'CTWalletCrossPreAuthorisedInTime' | 'CTWalletCrossPreAuthorisedDeferred' | 'CTWalletCrossAuthorisedInTime') {
5757
debug.enable('*');
5858
const offer = await getCredentialOffer(credentialType);
5959
const client = await OpenID4VCIClient.fromURI({
@@ -93,6 +93,8 @@ describe('OID4VCI-Client using Sphereon issuer should', () => {
9393
signCallback: proofOfPossessionCallbackFunction,
9494
},
9595
kid,
96+
deferredCredentialAwait: true,
97+
deferredCredentialIntervalInMS: 5000,
9698
});
9799
console.log(JSON.stringify(credentialResponse, null, 2));
98100
expect(credentialResponse.credential).toBeDefined();
@@ -102,17 +104,20 @@ describe('OID4VCI-Client using Sphereon issuer should', () => {
102104

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

105-
it.skip(
107+
it(
106108
'succeed in a full flow with the client using OpenID4VCI version 11 and jwt_vc_json',
107109
async () => {
108110
await test('CTWalletCrossPreAuthorisedInTime');
111+
await test('CTWalletCrossPreAuthorisedDeferred');
109112
// await test('CTWalletCrossAuthorisedInTime');
110113
},
111114
UNIT_TEST_TIMEOUT,
112115
);
113116
});
114117

115-
async function getCredentialOffer(credentialType: 'CTWalletCrossPreAuthorisedInTime' | 'CTWalletCrossAuthorisedInTime'): Promise<string> {
118+
async function getCredentialOffer(
119+
credentialType: 'CTWalletCrossPreAuthorisedInTime' | 'CTWalletCrossAuthorisedInTime' | 'CTWalletCrossPreAuthorisedDeferred',
120+
): Promise<string> {
116121
const credentialOffer = await fetch(
117122
`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`,
118123
{

packages/client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"@types/node": "^18.17.4",
2626
"@typescript-eslint/eslint-plugin": "^5.62.0",
2727
"@typescript-eslint/parser": "^5.62.0",
28-
"@sphereon/ssi-sdk-ext.key-utils": "^0.15.1-next.7",
28+
"@sphereon/ssi-sdk-ext.key-utils": "^0.16.0",
2929
"codecov": "^3.8.3",
3030
"dotenv": "^16.3.1",
3131
"eslint": "^8.46.0",

0 commit comments

Comments
 (0)