diff --git a/packages/client/lib/AccessTokenClient.ts b/packages/client/lib/AccessTokenClient.ts index 47fb7a39..d085089d 100644 --- a/packages/client/lib/AccessTokenClient.ts +++ b/packages/client/lib/AccessTokenClient.ts @@ -195,7 +195,7 @@ export class AccessTokenClient { this.assertNonEmptyCode(accessTokenRequest); this.assertNonEmptyRedirectUri(accessTokenRequest); } else { - this.throwNotSupportedFlow; + this.throwNotSupportedFlow(); } } diff --git a/packages/client/lib/CredentialRequestClient.ts b/packages/client/lib/CredentialRequestClient.ts index fe2966c7..5d2f7afd 100644 --- a/packages/client/lib/CredentialRequestClient.ts +++ b/packages/client/lib/CredentialRequestClient.ts @@ -1,7 +1,9 @@ import { + acquireDeferredCredential, CredentialResponse, getCredentialRequestForVersion, getUniformFormat, + isDeferredCredentialResponse, OID4VCICredentialFormat, OpenId4VCIVersion, OpenIDResponse, @@ -19,7 +21,10 @@ 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; @@ -27,17 +32,46 @@ export interface CredentialRequestOpts { version: OpenId4VCIVersion; } +export async function buildProof( + proofInput: ProofOfPossessionBuilder | 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`); + //decodeJwt(proofInput.jwt). + } + 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; + 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 }; } @@ -63,11 +97,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 = await post(credentialEndpoint, JSON.stringify(request), { bearerToken: requestToken }); + let response: OpenIDResponse = 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, + opts?: { + bearerToken?: string; + }, + ): Promise> { + 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(opts: { proofInput: ProofOfPossessionBuilder | ProofOfPossession; credentialTypes?: string | string[]; @@ -93,11 +156,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') { diff --git a/packages/client/lib/CredentialRequestClientBuilder.ts b/packages/client/lib/CredentialRequestClientBuilder.ts index 8d953825..a73205df 100644 --- a/packages/client/lib/CredentialRequestClientBuilder.ts +++ b/packages/client/lib/CredentialRequestClientBuilder.ts @@ -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; @@ -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; } @@ -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? @@ -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; } diff --git a/packages/client/lib/MetadataClient.ts b/packages/client/lib/MetadataClient.ts index 7679d207..1e58786e 100644 --- a/packages/client/lib/MetadataClient.ts +++ b/packages/client/lib/MetadataClient.ts @@ -45,6 +45,7 @@ export class MetadataClient { public static async retrieveAllMetadata(issuer: string, opts?: { errorOnNotFound: boolean }): Promise { 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; @@ -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; } @@ -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) { @@ -148,6 +159,7 @@ export class MetadataClient { issuer, token_endpoint, credential_endpoint, + deferred_credential_endpoint, authorization_server, authorization_endpoint, authorizationServerType, diff --git a/packages/client/lib/OpenID4VCIClient.ts b/packages/client/lib/OpenID4VCIClient.ts index 2d7a8698..ce244aea 100644 --- a/packages/client/lib/OpenID4VCIClient.ts +++ b/packages/client/lib/OpenID4VCIClient.ts @@ -354,6 +354,8 @@ export class OpenID4VCIClient { kid, alg, jti, + deferredCredentialAwait, + deferredCredentialIntervalInMS, }: { credentialTypes: string | string[]; proofCallbacks: ProofOfPossessionCallbacks; @@ -361,6 +363,8 @@ export class OpenID4VCIClient { kid?: string; alg?: Alg | string; jti?: string; + deferredCredentialAwait?: boolean; + deferredCredentialIntervalInMS?: number; }): Promise { if (alg) { this._alg = alg; @@ -382,6 +386,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]; @@ -550,6 +555,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`); diff --git a/packages/client/lib/__tests__/EBSIE2E.spec.test.ts b/packages/client/lib/__tests__/EBSIE2E.spec.test.ts index 400b2d1e..1d32a9dd 100644 --- a/packages/client/lib/__tests__/EBSIE2E.spec.test.ts +++ b/packages/client/lib/__tests__/EBSIE2E.spec.test.ts @@ -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({ @@ -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(); @@ -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 { +async function getCredentialOffer( + credentialType: 'CTWalletCrossPreAuthorisedInTime' | 'CTWalletCrossAuthorisedInTime' | 'CTWalletCrossPreAuthorisedDeferred', +): Promise { 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`, { diff --git a/packages/client/lib/__tests__/MetadataClient.spec.ts b/packages/client/lib/__tests__/MetadataClient.spec.ts index e278a525..46b4910f 100644 --- a/packages/client/lib/__tests__/MetadataClient.spec.ts +++ b/packages/client/lib/__tests__/MetadataClient.spec.ts @@ -211,7 +211,8 @@ describe('Metadataclient with Walt-id should', () => { }); }); -describe('Metadataclient with SpruceId should', () => { +// Spruce gives back 404's these days, so test is disabled +describe.skip('Metadataclient with SpruceId should', () => { beforeAll(() => { nock.cleanAll(); }); diff --git a/packages/common/lib/functions/CredentialResponseUtil.ts b/packages/common/lib/functions/CredentialResponseUtil.ts new file mode 100644 index 00000000..8574f2a8 --- /dev/null +++ b/packages/common/lib/functions/CredentialResponseUtil.ts @@ -0,0 +1,90 @@ +import { CredentialResponse, OpenIDResponse } from '../types'; + +import { post } from './HttpUtils'; + +export function isDeferredCredentialResponse(credentialResponse: OpenIDResponse) { + const orig = credentialResponse.successBody; + // Specs mention 202, but some implementations like EBSI return 200 + return credentialResponse.origResponse.status % 200 <= 2 && !!orig && !orig.credential && (!!orig.acceptance_token || !!orig.transaction_id); +} +function assertNonFatalError(credentialResponse: OpenIDResponse) { + if (credentialResponse.origResponse.status === 400 && credentialResponse.errorBody?.error) { + if (credentialResponse.errorBody.error === 'invalid_transaction_id' || credentialResponse.errorBody.error.includes('acceptance_token')) { + throw Error('Invalid transaction id. Probably the deferred credential request expired'); + } + } +} + +export function isDeferredCredentialIssuancePending(credentialResponse: OpenIDResponse) { + if (isDeferredCredentialResponse(credentialResponse)) { + return !!credentialResponse?.successBody?.transaction_id ?? !!credentialResponse?.successBody?.acceptance_token; + } + if (credentialResponse.origResponse.status === 400 && credentialResponse.errorBody?.error) { + if (credentialResponse.errorBody.error === 'issuance_pending') { + return true; + } else if (credentialResponse.errorBody.error_description?.toLowerCase().includes('not available yet')) { + return true; + } + } + return false; +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export async function acquireDeferredCredential({ + bearerToken, + transactionId, + deferredCredentialEndpoint, + deferredCredentialIntervalInMS, + deferredCredentialAwait, +}: { + bearerToken: string; + transactionId?: string; + deferredCredentialIntervalInMS?: number; + deferredCredentialAwait?: boolean; + deferredCredentialEndpoint: string; +}): Promise> { + let credentialResponse: OpenIDResponse = await acquireDeferredCredentialImpl({ + bearerToken, + transactionId, + deferredCredentialEndpoint, + }); + + const DEFAULT_SLEEP_IN_MS = 5000; + while (!credentialResponse.successBody?.credential && deferredCredentialAwait) { + assertNonFatalError(credentialResponse); + const pending = isDeferredCredentialIssuancePending(credentialResponse); + console.log(`Issuance still pending?: ${pending}`); + if (!pending) { + throw Error(`Issuance isn't pending anymore: ${credentialResponse}`); + } + + await sleep(deferredCredentialIntervalInMS ?? DEFAULT_SLEEP_IN_MS); + credentialResponse = await acquireDeferredCredentialImpl({ bearerToken, transactionId, deferredCredentialEndpoint }); + } + return credentialResponse; +} + +async function acquireDeferredCredentialImpl({ + bearerToken, + transactionId, + deferredCredentialEndpoint, +}: { + bearerToken: string; + transactionId?: string; + deferredCredentialEndpoint: string; +}): Promise> { + const response: OpenIDResponse = await post( + deferredCredentialEndpoint, + JSON.stringify(transactionId ? { transaction_id: transactionId } : ''), + { bearerToken }, + ); + console.log(JSON.stringify(response, null, 2)); + assertNonFatalError(response); + + return response; +} diff --git a/packages/common/lib/functions/index.ts b/packages/common/lib/functions/index.ts index 83f69014..92b097fa 100644 --- a/packages/common/lib/functions/index.ts +++ b/packages/common/lib/functions/index.ts @@ -1,4 +1,5 @@ export * from './CredentialRequestUtil'; +export * from './CredentialResponseUtil'; export * from './CredentialOfferUtil'; export * from './Encoding'; export * from './TypeConversionUtils'; diff --git a/packages/common/lib/types/CredentialIssuance.types.ts b/packages/common/lib/types/CredentialIssuance.types.ts index bf43a4cf..97bc51f6 100644 --- a/packages/common/lib/types/CredentialIssuance.types.ts +++ b/packages/common/lib/types/CredentialIssuance.types.ts @@ -10,7 +10,8 @@ import { CredentialOfferPayloadV1_0_11, CredentialOfferV1_0_11 } from './v1_0_11 export interface CredentialResponse { credential?: W3CVerifiableCredential; // OPTIONAL. Contains issued Credential. MUST be present when acceptance_token is not returned. MAY be a JSON string or a JSON object, depending on the Credential format. See Appendix E for the Credential format specific encoding requirements format: OID4VCICredentialFormat /* | OID4VCICredentialFormat[]*/; // REQUIRED. JSON string denoting the format of the issued Credential - acceptance_token?: string; // OPTIONAL. A JSON string containing a security token subsequently used to obtain a Credential. MUST be present when credential is not returned + transaction_id?: string; //OPTIONAL. A string identifying a Deferred Issuance transaction. This claim is contained in the response if the Credential Issuer was unable to immediately issue the credential. The value is subsequently used to obtain the respective Credential with the Deferred Credential Endpoint (see Section 9). It MUST be present when the credential parameter is not returned. It MUST be invalidated after the credential for which it was meant has been obtained by the Wallet. + acceptance_token?: string; //deprecated // OPTIONAL. A JSON string containing a security token subsequently used to obtain a Credential. MUST be present when credential is not returned c_nonce?: string; // OPTIONAL. JSON string containing a nonce to be used to create a proof of possession of key material when requesting a Credential (see Section 7.2). When received, the Wallet MUST use this nonce value for its subsequent credential requests until the Credential Issuer provides a fresh nonce c_nonce_expires_in?: number; // OPTIONAL. JSON integer denoting the lifetime in seconds of the c_nonce } diff --git a/packages/common/lib/types/ServerMetadata.ts b/packages/common/lib/types/ServerMetadata.ts index b2e01d3e..190b0031 100644 --- a/packages/common/lib/types/ServerMetadata.ts +++ b/packages/common/lib/types/ServerMetadata.ts @@ -49,6 +49,7 @@ export interface AuthorizationServerMetadata { // VCI values. In case an AS provides a credential_endpoint itself credential_endpoint?: string; + deferred_credential_endpoint?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any [x: string]: any; //We use any, so you can access properties if you know the structure @@ -66,6 +67,7 @@ export interface EndpointMetadata { issuer: string; token_endpoint: string; credential_endpoint: string; + deferred_credential_endpoint?: string; authorization_server?: string; authorization_endpoint?: string; // Can be undefined in pre-auth flow }