From 27bc1d9522fa74d8016dced63fa415efb6c4eebc Mon Sep 17 00:00:00 2001 From: sksadjad Date: Thu, 20 Jun 2024 20:49:42 +0200 Subject: [PATCH] feat: added x5c support and made sure that we support request-responses without dids --- .../CredentialRequestClientBuilder.spec.ts | 37 +++ .../CredentialRequestClientV1_0_11.spec.ts | 96 +++++++ packages/client/lib/__tests__/IT.spec.ts | 138 ++++++++- .../ProofOfPossessionBuilder.spec.ts | 85 ++++++ packages/client/lib/__tests__/SdJwt.spec.ts | 103 +++++++ packages/common/lib/functions/ProofUtil.ts | 7 +- packages/issuer/lib/VcIssuer.ts | 9 +- .../issuer/lib/__tests__/VcIssuer.spec.ts | 265 ++++++++++++++++++ .../lib/__tests__/VcIssuerBuilder.spec.ts | 59 ++++ 9 files changed, 792 insertions(+), 7 deletions(-) diff --git a/packages/client/lib/__tests__/CredentialRequestClientBuilder.spec.ts b/packages/client/lib/__tests__/CredentialRequestClientBuilder.spec.ts index 703f117f..b29576fc 100644 --- a/packages/client/lib/__tests__/CredentialRequestClientBuilder.spec.ts +++ b/packages/client/lib/__tests__/CredentialRequestClientBuilder.spec.ts @@ -16,6 +16,7 @@ import { CredentialRequestClientBuilder, ProofOfPossessionBuilder } from '..'; import { IDENTIPROOF_ISSUER_URL, IDENTIPROOF_OID4VCI_METADATA, INITIATION_TEST_URI, WALT_ISSUER_URL, WALT_OID4VCI_METADATA } from './MetadataMocks'; const partialJWT = 'eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJkaWQ6ZXhhbXBsZTplYmZlYjFmN'; +const partialJWT_withoutDid = 'eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJlYmZlYjFmNzEyZWJjNmYxYzI3N'; /*const jwtv1_0_08: Jwt = { header: { alg: Alg.ES256, kid: 'did:example:ebfeb1f712ebc6f1c276e12ec21/keys/1', typ: 'jwt' }, @@ -27,8 +28,15 @@ const jwtv1_0_11: Jwt = { payload: { iss: 'sphereon:wallet', nonce: 'tZignsnFbp', jti: 'tZignsnFbp223', aud: IDENTIPROOF_ISSUER_URL }, }; +const jwtv1_0_13_withoutDid: Jwt = { + header: { alg: Alg.ES256, kid: 'ebfeb1f712ebc6f1c276e12ec21/keys/1', typ: 'openid4vci-proof+jwt' }, + payload: { iss: 'sphereon:wallet', nonce: 'tZignsnFbp', jti: 'tZignsnFbp223', aud: IDENTIPROOF_ISSUER_URL }, +}; + const kid = 'did:example:ebfeb1f712ebc6f1c276e12ec21/keys/1'; +const kid_withoutDid = 'ebfeb1f712ebc6f1c276e12ec21/keys/1'; + let keypair: KeyPair; beforeAll(async () => { @@ -115,6 +123,35 @@ describe('Credential Request Client Builder', () => { } }); + it('should build credential request correctly without did', async () => { + const credReqClient = (await CredentialRequestClientBuilder.fromURI({ uri: INITIATION_TEST_URI })) + .withCredentialEndpoint('https://oidc4vci.demo.spruceid.com/credential') + .withFormat('jwt_vc') + .withCredentialType('OpenBadgeCredential') + .build(); + const proof: ProofOfPossession = await ProofOfPossessionBuilder.fromJwt({ + jwt: jwtv1_0_13_withoutDid, + callbacks: { + signCallback: proofOfPossessionCallbackFunction, + verifyCallback: proofOfPossessionVerifierCallbackFunction, + }, + version: OpenId4VCIVersion.VER_1_0_13, + }) + .withClientId('sphereon:wallet') + .withKid(kid_withoutDid) + .build(); + await proofOfPossessionVerifierCallbackFunction({ ...proof, kid: kid_withoutDid }); + const credentialRequest: CredentialRequestV1_0_13 = await credReqClient.createCredentialRequest({ + proofInput: proof, + credentialTypes: 'OpenBadgeCredential', + version: OpenId4VCIVersion.VER_1_0_13, + }); + expect(credentialRequest.proof?.jwt).toContain(partialJWT_withoutDid); + if ('types' in credentialRequest) { + expect(credentialRequest.types).toStrictEqual(['OpenBadgeCredential']); + } + }); + it('should build correctly from metadata', async () => { const credReqClient = ( await CredentialRequestClientBuilder.fromURI({ diff --git a/packages/client/lib/__tests__/CredentialRequestClientV1_0_11.spec.ts b/packages/client/lib/__tests__/CredentialRequestClientV1_0_11.spec.ts index 216a2ac3..a11f417e 100644 --- a/packages/client/lib/__tests__/CredentialRequestClientV1_0_11.spec.ts +++ b/packages/client/lib/__tests__/CredentialRequestClientV1_0_11.spec.ts @@ -28,14 +28,22 @@ import { import { getMockData } from './data/VciDataFixtures'; const partialJWT = 'eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJkaWQ6ZXhhbXBsZTplYmZlYjFmN'; +const partialJWT_withoutDid = 'eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJlYmZlYjFmNzEyZWJjNmYxYzI3N'; const jwt: Jwt = { header: { alg: Alg.ES256, kid: 'did:example:ebfeb1f712ebc6f1c276e12ec21/keys/1', typ: 'jwt' }, payload: { iss: 'sphereon:wallet', nonce: 'tZignsnFbp', jti: 'tZignsnFbp223', aud: IDENTIPROOF_ISSUER_URL }, }; +const jwt_withoutDid: Jwt = { + header: { alg: Alg.ES256, kid: 'ebfeb1f712ebc6f1c276e12ec21/keys/1', typ: 'jwt' }, + payload: { iss: 'sphereon:wallet', nonce: 'tZignsnFbp', jti: 'tZignsnFbp223', aud: IDENTIPROOF_ISSUER_URL }, +}; + const kid = 'did:example:ebfeb1f712ebc6f1c276e12ec21/keys/1'; +const kid_withoutDid = 'ebfeb1f712ebc6f1c276e12ec21/keys/1'; + let keypair: KeyPair; async function proofOfPossessionCallbackFunction(args: Jwt, kid?: string): Promise { @@ -102,6 +110,36 @@ describe('Credential Request Client ', () => { expect(result?.errorBody?.error).toBe('unsupported_format'); }); + it('should get a failed credential response with an unsupported format and without did', async function () { + const basePath = 'https://sphereonjunit2022101301.com/'; + nock(basePath).post(/.*/).reply(500, { + error: 'unsupported_format', + error_description: 'This is a mock error message', + }); + + const credReqClient = CredentialRequestClientBuilderV1_0_11.fromCredentialOffer({ credentialOffer: INITIATION_TEST_V1_0_08 }) + .withCredentialEndpoint(basePath + '/credential') + .withFormat('ldp_vc') + .withCredentialType('https://imsglobal.github.io/openbadges-specification/ob_v3p0.html#OpenBadgeCredential') + .build(); + const proof: ProofOfPossession = await ProofOfPossessionBuilder.fromJwt({ + jwt: jwt_withoutDid, + callbacks: { + signCallback: proofOfPossessionCallbackFunction, + }, + version: OpenId4VCIVersion.VER_1_0_08, + }) + // .withEndpointMetadata(metadata) + .withClientId('sphereon:wallet') + .withKid(kid_withoutDid) + .build(); + expect(credReqClient.getCredentialEndpoint()).toEqual(basePath + '/credential'); + const credentialRequest = await credReqClient.createCredentialRequest({ proofInput: proof, version: OpenId4VCIVersion.VER_1_0_08 }); + expect(credentialRequest.proof?.jwt?.includes(partialJWT_withoutDid)).toBeTruthy(); + const result = await credReqClient.acquireCredentialsUsingRequest(credentialRequest); + expect(result?.errorBody?.error).toBe('unsupported_format'); + }); + it('should get success credential response', async function () { const mockedVC = 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2YyI6eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIsImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL2V4YW1wbGVzL3YxIl0sImlkIjoiaHR0cDovL2V4YW1wbGUuZWR1L2NyZWRlbnRpYWxzLzM3MzIiLCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIiwiVW5pdmVyc2l0eURlZ3JlZUNyZWRlbnRpYWwiXSwiaXNzdWVyIjoiaHR0cHM6Ly9leGFtcGxlLmVkdS9pc3N1ZXJzLzU2NTA0OSIsImlzc3VhbmNlRGF0ZSI6IjIwMTAtMDEtMDFUMDA6MDA6MDBaIiwiY3JlZGVudGlhbFN1YmplY3QiOnsiaWQiOiJkaWQ6ZXhhbXBsZTplYmZlYjFmNzEyZWJjNmYxYzI3NmUxMmVjMjEiLCJkZWdyZWUiOnsidHlwZSI6IkJhY2hlbG9yRGVncmVlIiwibmFtZSI6IkJhY2hlbG9yIG9mIFNjaWVuY2UgYW5kIEFydHMifX19LCJpc3MiOiJodHRwczovL2V4YW1wbGUuZWR1L2lzc3VlcnMvNTY1MDQ5IiwibmJmIjoxMjYyMzA0MDAwLCJqdGkiOiJodHRwOi8vZXhhbXBsZS5lZHUvY3JlZGVudGlhbHMvMzczMiIsInN1YiI6ImRpZDpleGFtcGxlOmViZmViMWY3MTJlYmM2ZjFjMjc2ZTEyZWMyMSJ9.z5vgMTK1nfizNCg5N-niCOL3WUIAL7nXy-nGhDZYO_-PNGeE-0djCpWAMH8fD8eWSID5PfkPBYkx_dfLJnQ7NA'; @@ -138,6 +176,42 @@ describe('Credential Request Client ', () => { expect(result?.successBody?.credential).toEqual(mockedVC); }); + it('should get success credential response without did', async function () { + const mockedVC = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2YyI6eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIsImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL2V4YW1wbGVzL3YxIl0sImlkIjoiaHR0cDovL2V4YW1wbGUuZWR1L2NyZWRlbnRpYWxzLzM3MzIiLCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIiwiVW5pdmVyc2l0eURlZ3JlZUNyZWRlbnRpYWwiXSwiaXNzdWVyIjoiaHR0cHM6Ly9leGFtcGxlLmVkdS9pc3N1ZXJzLzU2NTA0OSIsImlzc3VhbmNlRGF0ZSI6IjIwMTAtMDEtMDFUMDA6MDA6MDBaIiwiY3JlZGVudGlhbFN1YmplY3QiOnsiaWQiOiJlYmZlYjFmNzEyZWJjNmYxYzI3NmUxMmVjMjEiLCJkZWdyZWUiOnsidHlwZSI6IkJhY2hlbG9yRGVncmVlIiwibmFtZSI6IkJhY2hlbG9yIG9mIFNjaWVuY2UgYW5kIEFydHMifX19LCJpc3MiOiJodHRwczovL2V4YW1wbGUuZWR1L2lzc3VlcnMvNTY1MDQ5IiwibmJmIjoxMjYyMzA0MDAwLCJqdGkiOiJodHRwOi8vZXhhbXBsZS5lZHUvY3JlZGVudGlhbHMvMzczMiIsInN1YiI6ImViZmViMWY3MTJlYmM2ZjFjMjc2ZTEyZWMyMSIsImlhdCI6MTcxODM1NzcxOH0.7iiOTuIjQRyrIincYyDW6m0nBYmDoYfXcTYFrywsKEY'; + nock('https://oidc4vci.demo.spruceid.com') + .post(/credential/) + .reply(200, { + format: 'jwt-vc', + credential: mockedVC, + }); + const credReqClient = CredentialRequestClientBuilderV1_0_11.fromCredentialOfferRequest({ request: INITIATION_TEST }) + .withCredentialEndpoint('https://oidc4vci.demo.spruceid.com/credential') + .withFormat('jwt_vc') + .withCredentialType('https://imsglobal.github.io/openbadges-specification/ob_v3p0.html#OpenBadgeCredential') + .build(); + const proof: ProofOfPossession = await ProofOfPossessionBuilder.fromJwt({ + jwt: jwt_withoutDid, + callbacks: { + signCallback: proofOfPossessionCallbackFunction, + }, + version: OpenId4VCIVersion.VER_1_0_08, + }) + // .withEndpointMetadata(metadata) + .withKid(kid_withoutDid) + .withClientId('sphereon:wallet') + .build(); + const credentialRequest = await credReqClient.createCredentialRequest({ + proofInput: proof, + format: 'jwt', + version: OpenId4VCIVersion.VER_1_0_08, + }); + expect(credentialRequest.proof?.jwt?.includes(partialJWT_withoutDid)).toBeTruthy(); + expect(credentialRequest.format).toEqual('jwt_vc'); + const result = await credReqClient.acquireCredentialsUsingRequest(credentialRequest); + expect(result?.successBody?.credential).toEqual(mockedVC); + }); + it('should fail with invalid url', async () => { const credReqClient = CredentialRequestClientBuilderV1_0_11.fromCredentialOfferRequest({ request: INITIATION_TEST }) .withCredentialEndpoint('httpsf://oidc4vci.demo.spruceid.com/credential') @@ -159,6 +233,28 @@ describe('Credential Request Client ', () => { Error(URL_NOT_VALID), ); }); + + it('should fail with invalid url without did', async () => { + const credReqClient = CredentialRequestClientBuilderV1_0_11.fromCredentialOfferRequest({ request: INITIATION_TEST }) + .withCredentialEndpoint('httpsf://oidc4vci.demo.spruceid.com/credential') + .withFormat('jwt_vc') + .withCredentialType('https://imsglobal.github.io/openbadges-specification/ob_v3p0.html#OpenBadgeCredential') + .build(); + const proof: ProofOfPossession = await ProofOfPossessionBuilder.fromJwt({ + jwt: jwt_withoutDid, + callbacks: { + signCallback: proofOfPossessionCallbackFunction, + }, + version: OpenId4VCIVersion.VER_1_0_08, + }) + // .withEndpointMetadata(metadata) + .withKid(kid_withoutDid) + .withClientId('sphereon:wallet') + .build(); + await expect(credReqClient.acquireCredentialsUsingRequest({ format: 'jwt_vc_json', types: ['random'], proof })).rejects.toThrow( + Error(URL_NOT_VALID), + ); + }); }); describe('Credential Request Client with Walt.id ', () => { diff --git a/packages/client/lib/__tests__/IT.spec.ts b/packages/client/lib/__tests__/IT.spec.ts index 0a0f70e4..5ee67f70 100644 --- a/packages/client/lib/__tests__/IT.spec.ts +++ b/packages/client/lib/__tests__/IT.spec.ts @@ -29,11 +29,16 @@ import { IDENTIPROOF_AS_METADATA, IDENTIPROOF_AS_URL, IDENTIPROOF_ISSUER_URL, ID export const UNIT_TEST_TIMEOUT = 30000; const ISSUER_URL = 'https://issuer.research.identiproof.io'; -const jwt = { +const jwtDid = { header: { alg: Alg.ES256, kid: 'did:example:ebfeb1f712ebc6f1c276e12ec21/keys/1', typ: 'openid4vci-proof+jwt' }, payload: { iss: 'test-clientId', nonce: 'tZignsnFbp', jti: 'tZignsnFbp223', aud: ISSUER_URL }, }; +const jwtWithoutDid = { + header: { alg: Alg.ES256, kid: 'ebfeb1f712ebc6f1c276e12ec21/keys/1', typ: 'openid4vci-proof+jwt' }, + payload: { iss: 'test-clientId', nonce: 'tZignsnFbp', jti: 'tZignsnFbp223', aud: ISSUER_URL }, +}; + describe('OID4VCI-Client should', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars async function proofOfPossessionCallbackFunction(_args: Jwt, _kid?: string): Promise { @@ -202,7 +207,7 @@ describe('OID4VCI-Client should', () => { // Types of parameters 'args' and 'args' are incompatible. // Property 'kid' is missing in type '{ header: unknown; payload: unknown; }' but required in type 'ProofOfPossessionCallbackArgs'. const proof: ProofOfPossession = await ProofOfPossessionBuilder.fromJwt({ - jwt, + jwt: jwtDid, callbacks: { signCallback: proofOfPossessionCallbackFunction, }, @@ -221,6 +226,66 @@ describe('OID4VCI-Client should', () => { UNIT_TEST_TIMEOUT, ); + it( + 'succeed with a full flow with a not-did-kid without the client v1_0_11', + async () => { + /* Convert the URI into an object */ + const credentialOffer: CredentialOfferRequestWithBaseUrl = await CredentialOfferClientV1_0_11.fromURI(INITIATE_QR_V1_0_08); + + expect(credentialOffer.baseUrl).toEqual('openid-initiate-issuance://'); + expect(credentialOffer.original_credential_offer).toEqual({ + credential_type: ['OpenBadgeCredentialUrl'], + issuer: ISSUER_URL, + 'pre-authorized_code': + '4jLs9xZHEfqcoow0kHE7d1a8hUk6Sy-5bVSV2MqBUGUgiFFQi-ImL62T-FmLIo8hKA1UdMPH0lM1xAgcFkJfxIw9L-lI3mVs0hRT8YVwsEM1ma6N3wzuCdwtMU4bcwKp', + user_pin_required: 'true', + }); + + nock(ISSUER_URL) + .post(/token.*/) + .reply(200, JSON.stringify(mockedAccessTokenResponse)); + + /* The actual access token calls */ + const accessTokenClient: AccessTokenClientV1_0_11 = new AccessTokenClientV1_0_11(); + const accessTokenResponse = await accessTokenClient.acquireAccessToken({ credentialOffer: credentialOffer, pin: '1234' }); + expect(accessTokenResponse.successBody).toEqual(mockedAccessTokenResponse); + // Get the credential + nock(ISSUER_URL) + .post(/credential/) + .reply(200, { + format: 'jwt-vc', + credential: mockedVC, + }); + const credReqClient = CredentialRequestClientBuilderV1_0_11.fromCredentialOffer({ credentialOffer: credentialOffer }) + .withFormat('jwt_vc') + + .withTokenFromResponse(accessTokenResponse.successBody!) + .build(); + + //TS2322: Type '(args: ProofOfPossessionCallbackArgs) => Promise' + // is not assignable to type 'ProofOfPossessionCallback'. + // Types of parameters 'args' and 'args' are incompatible. + // Property 'kid' is missing in type '{ header: unknown; payload: unknown; }' but required in type 'ProofOfPossessionCallbackArgs'. + const proof: ProofOfPossession = await ProofOfPossessionBuilder.fromJwt({ + jwt: jwtWithoutDid, + callbacks: { + signCallback: proofOfPossessionCallbackFunction, + }, + version: OpenId4VCIVersion.VER_1_0_11, + }) + .withEndpointMetadata({ + issuer: 'https://issuer.research.identiproof.io', + credential_endpoint: 'https://issuer.research.identiproof.io/credential', + token_endpoint: 'https://issuer.research.identiproof.io/token', + }) + .withKid('ebfeb1f712ebc6f1c276e12ec21/keys/1') + .build(); + const credResponse = await credReqClient.acquireCredentialsUsingProof({ proofInput: proof }); + expect(credResponse.successBody?.credential).toEqual(mockedVC); + }, + UNIT_TEST_TIMEOUT, + ); + it( 'succeed with a full flow without the client v1_0_13', async () => { @@ -266,7 +331,7 @@ describe('OID4VCI-Client should', () => { // Types of parameters 'args' and 'args' are incompatible. // Property 'kid' is missing in type '{ header: unknown; payload: unknown; }' but required in type 'ProofOfPossessionCallbackArgs'. const proof: ProofOfPossession = await ProofOfPossessionBuilder.fromJwt({ - jwt, + jwt: jwtDid, callbacks: { signCallback: proofOfPossessionCallbackFunction, }, @@ -287,6 +352,73 @@ describe('OID4VCI-Client should', () => { }, UNIT_TEST_TIMEOUT, ); + + it( + 'succeed with a full flow with a not-did-kid without the client v1_0_13', + async () => { + /* Convert the URI into an object */ + const credentialOffer: CredentialOfferRequestWithBaseUrl = await CredentialOfferClient.fromURI(INITIATE_QR_V1_0_13); + const preAuthorizedCode = 'oaKazRN8I0IbtZ0C7JuMn5'; + expect(credentialOffer.baseUrl).toEqual('openid-credential-offer://'); + expect((credentialOffer.credential_offer as CredentialOfferPayloadV1_0_13).credential_configuration_ids).toEqual(['OpenBadgeCredentialUrl']); + expect(credentialOffer.original_credential_offer.grants).toEqual({ + 'urn:ietf:params:oauth:grant-type:pre-authorized_code': { + 'pre-authorized_code': preAuthorizedCode, + tx_code: { + input_mode: 'text', + description: 'Please enter the serial number of your physical drivers license', + length: preAuthorizedCode.length, + }, + }, + }); + + nock(ISSUER_URL) + .post(/token.*/) + .reply(200, JSON.stringify(mockedAccessTokenResponse)); + + /* The actual access token calls */ + const accessTokenClient: AccessTokenClient = new AccessTokenClient(); + const accessTokenResponse = await accessTokenClient.acquireAccessToken({ credentialOffer: credentialOffer, pin: '1234' }); + expect(accessTokenResponse.successBody).toEqual(mockedAccessTokenResponse); + // Get the credential + nock(ISSUER_URL) + .post(/credential/) + .reply(200, { + format: 'jwt-vc', + credential: mockedVC, + }); + const credReqClient = CredentialRequestClientBuilder.fromCredentialOffer({ credentialOffer: credentialOffer }) + .withFormat('jwt_vc') + + .withTokenFromResponse(accessTokenResponse.successBody!) + .build(); + + //TS2322: Type '(args: ProofOfPossessionCallbackArgs) => Promise' + // is not assignable to type 'ProofOfPossessionCallback'. + // Types of parameters 'args' and 'args' are incompatible. + // Property 'kid' is missing in type '{ header: unknown; payload: unknown; }' but required in type 'ProofOfPossessionCallbackArgs'. + const proof: ProofOfPossession = await ProofOfPossessionBuilder.fromJwt({ + jwt: jwtWithoutDid, + callbacks: { + signCallback: proofOfPossessionCallbackFunction, + }, + version: OpenId4VCIVersion.VER_1_0_11, + }) + .withEndpointMetadata({ + issuer: 'https://issuer.research.identiproof.io', + credential_endpoint: 'https://issuer.research.identiproof.io/credential', + token_endpoint: 'https://issuer.research.identiproof.io/token', + }) + .withKid('ebfeb1f712ebc6f1c276e12ec21/keys/1') + .build(); + const credResponse = await credReqClient.acquireCredentialsUsingProof({ + proofInput: proof, + credentialTypes: credentialOffer.original_credential_offer.credential_configuration_ids, + }); + expect(credResponse.successBody?.credential).toEqual(mockedVC); + }, + UNIT_TEST_TIMEOUT, + ); }); describe('OIDVCI-Client for v1_0_13 should', () => { diff --git a/packages/client/lib/__tests__/ProofOfPossessionBuilder.spec.ts b/packages/client/lib/__tests__/ProofOfPossessionBuilder.spec.ts index fcb112ef..54693daf 100644 --- a/packages/client/lib/__tests__/ProofOfPossessionBuilder.spec.ts +++ b/packages/client/lib/__tests__/ProofOfPossessionBuilder.spec.ts @@ -12,8 +12,15 @@ const jwt: Jwt = { payload: { iss: 'sphereon:wallet', nonce: 'tZignsnFbp', jti: 'tZignsnFbp223', aud: IDENTIPROOF_ISSUER_URL, iat: Date.now() / 1000 }, }; +const jwt_withoutDid: Jwt = { + header: { alg: Alg.ES256, kid: 'ebfeb1f712ebc6f1c276e12ec21/keys/1', typ: 'jwt' }, + payload: { iss: 'sphereon:wallet', nonce: 'tZignsnFbp', jti: 'tZignsnFbp223', aud: IDENTIPROOF_ISSUER_URL, iat: Date.now() / 1000 }, +}; + const kid = 'did:example:ebfeb1f712ebc6f1c276e12ec21/keys/1'; +const kid_withoutDid = 'ebfeb1f712ebc6f1c276e12ec21/keys/1'; + let keypair: KeyPair; async function proofOfPossessionCallbackFunction(args: Jwt, kid?: string): Promise { @@ -52,6 +59,16 @@ describe('ProofOfPossession Builder ', () => { ).rejects.toThrow(Error(PROOF_CANT_BE_CONSTRUCTED)); }); + it('should fail without supplied proof or callbacks and with kid without did', async function () { + await expect( + ProofOfPossessionBuilder.fromProof(undefined as never, OpenId4VCIVersion.VER_1_0_13) + .withIssuer(IDENTIPROOF_ISSUER_URL) + .withClientId('sphereon:wallet') + .withKid(kid_withoutDid) + .build(), + ).rejects.toThrow(Error(PROOF_CANT_BE_CONSTRUCTED)); + }); + it('should fail wit undefined jwt supplied', async function () { await expect(() => ProofOfPossessionBuilder.fromJwt({ jwt, callbacks: { signCallback: proofOfPossessionCallbackFunction }, version: OpenId4VCIVersion.VER_1_0_08 }) @@ -63,6 +80,21 @@ describe('ProofOfPossession Builder ', () => { ).toThrow(Error(NO_JWT_PROVIDED)); }); + it('should fail with undefined jwt supplied and kid without did', async function () { + await expect(() => + ProofOfPossessionBuilder.fromJwt({ + jwt: jwt_withoutDid, + callbacks: { signCallback: proofOfPossessionCallbackFunction }, + version: OpenId4VCIVersion.VER_1_0_08, + }) + .withJwt(undefined as never) + .withIssuer(IDENTIPROOF_ISSUER_URL) + .withClientId('sphereon:wallet') + .withKid(kid_withoutDid) + .build(), + ).toThrow(Error(NO_JWT_PROVIDED)); + }); + it('should build a proof with all required params present', async function () { const proof: ProofOfPossession = await ProofOfPossessionBuilder.fromJwt({ jwt, @@ -78,6 +110,21 @@ describe('ProofOfPossession Builder ', () => { expect(proof).toBeDefined(); }); + it('should build a proof with all required params present without did', async function () { + const proof: ProofOfPossession = await ProofOfPossessionBuilder.fromJwt({ + jwt: jwt_withoutDid, + callbacks: { + signCallback: proofOfPossessionCallbackFunction, + }, + version: OpenId4VCIVersion.VER_1_0_08, + }) + .withIssuer(IDENTIPROOF_ISSUER_URL) + .withKid(kid_withoutDid) + .withClientId('sphereon:wallet') + .build(); + expect(proof).toBeDefined(); + }); + it('should fail creating a proof of possession with simple verification', async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars async function proofOfPossessionCallbackFunction(_args: Jwt, _kid?: string): Promise { @@ -93,6 +140,25 @@ describe('ProofOfPossession Builder ', () => { ).rejects.toThrow(Error(JWS_NOT_VALID)); }); + it('should fail creating a proof of possession with simple verification and without did', async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async function proofOfPossessionCallbackFunction(_args: Jwt, _kid?: string): Promise { + throw new Error(JWS_NOT_VALID); + } + + await expect( + ProofOfPossessionBuilder.fromJwt({ + jwt: jwt_withoutDid, + callbacks: { signCallback: proofOfPossessionCallbackFunction }, + version: OpenId4VCIVersion.VER_1_0_08, + }) + .withIssuer(IDENTIPROOF_ISSUER_URL) + .withClientId('sphereon:wallet') + .withKid(kid_withoutDid) + .build(), + ).rejects.toThrow(Error(JWS_NOT_VALID)); + }); + it('should fail creating a proof of possession without verify callback', async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars async function proofOfPossessionCallbackFunction(_args: Jwt, _kid?: string): Promise { @@ -107,4 +173,23 @@ describe('ProofOfPossession Builder ', () => { .build(), ).rejects.toThrow(Error(JWS_NOT_VALID)); }); + + it('should fail creating a proof of possession without verify callback and without did', async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async function proofOfPossessionCallbackFunction(_args: Jwt, _kid?: string): Promise { + throw new Error(JWS_NOT_VALID); + } + + await expect( + ProofOfPossessionBuilder.fromJwt({ + jwt: jwt_withoutDid, + callbacks: { signCallback: proofOfPossessionCallbackFunction }, + version: OpenId4VCIVersion.VER_1_0_08, + }) + .withIssuer(IDENTIPROOF_ISSUER_URL) + .withClientId('sphereon:wallet') + .withKid(kid_withoutDid) + .build(), + ).rejects.toThrow(Error(JWS_NOT_VALID)); + }); }); diff --git a/packages/client/lib/__tests__/SdJwt.spec.ts b/packages/client/lib/__tests__/SdJwt.spec.ts index c3abc296..aa137210 100644 --- a/packages/client/lib/__tests__/SdJwt.spec.ts +++ b/packages/client/lib/__tests__/SdJwt.spec.ts @@ -166,4 +166,107 @@ describe('sd-jwt vc', () => { }, UNIT_TEST_TIMEOUT, ); + + it( + 'succeed with a full flow without did', + async () => { + const offerUri = await vcIssuer.createCredentialOfferURI({ + grants: { + 'urn:ietf:params:oauth:grant-type:pre-authorized_code': { + tx_code: { + input_mode: 'text', + length: 3, + }, + 'pre-authorized_code': '123', + }, + }, + credential_configuration_ids: ['SdJwtCredential'], + }); + + nock(vcIssuer.issuerMetadata.credential_issuer).get('/.well-known/openid-credential-issuer').reply(200, JSON.stringify(issuerMetadata)); + nock(vcIssuer.issuerMetadata.credential_issuer).get('/.well-known/openid-configuration').reply(404); + nock(vcIssuer.issuerMetadata.credential_issuer).get('/.well-known/oauth-authorization-server').reply(404); + + expect(offerUri.uri).toEqual( + 'openid-credential-offer://?credential_offer=%7B%22grants%22%3A%7B%22urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22pre-authorized_code%22%3A%22123%22%2C%22tx_code%22%3A%7B%22input_mode%22%3A%22text%22%2C%22length%22%3A3%7D%7D%7D%2C%22credential_configuration_ids%22%3A%5B%22SdJwtCredential%22%5D%2C%22credential_issuer%22%3A%22https%3A%2F%2Fexample.com%22%7D', + ); + + const client = await OpenID4VCIClientV1_0_13.fromURI({ + uri: offerUri.uri, + }); + + expect(client.credentialOffer?.credential_offer).toEqual({ + credential_issuer: 'https://example.com', + credential_configuration_ids: ['SdJwtCredential'], + grants: { + 'urn:ietf:params:oauth:grant-type:pre-authorized_code': { + 'pre-authorized_code': '123', + tx_code: { + input_mode: 'text', + length: 3, + }, + }, + }, + }); + + const supported = client.getCredentialsSupported('vc+sd-jwt'); + expect(supported).toEqual({ SdJwtCredentialId: { format: 'vc+sd-jwt', id: 'SdJwtCredentialId', vct: 'SdJwtCredentialId' } }); + + const offered = supported['SdJwtCredentialId'] as CredentialSupportedSdJwtVc; + + nock(issuerMetadata.token_endpoint as string) + .post('/') + .reply(200, async (_, body: string) => { + const parsedBody = Object.fromEntries(body.split('&').map((x) => x.split('='))); + return createAccessTokenResponse(parsedBody as AccessTokenRequest, { + credentialOfferSessions: vcIssuer.credentialOfferSessions, + accessTokenIssuer: 'https://issuer.example.com', + cNonces: vcIssuer.cNonces, + cNonce: 'a-c-nonce', + accessTokenSignerCallback: async () => 'ey.val.ue', + tokenExpiresIn: 500, + }); + }); + + await client.acquireAccessToken({ pin: '123' }); + nock(issuerMetadata.credential_endpoint as string) + .post('/') + .reply(200, async (_, body) => + vcIssuer.issueCredential({ + credentialRequest: { ...(body as CredentialRequestV1_0_13), credential_identifier: offered.vct }, + credential: { + vct: 'Hello', + iss: 'example.com', + iat: 123, + // Defines what can be disclosed (optional) + __disclosureFrame: { + name: true, + }, + }, + newCNonce: 'new-c-nonce', + }), + ); + + const credentials = await client.acquireCredentials({ + credentialIdentifier: offered.vct, + // format: 'vc+sd-jwt', + alg, + jwk, + proofCallbacks: { + // When using sd-jwt for real, this jwt should include a jwk + signCallback: async () => 'ey.ja.ja', + }, + }); + + expect(credentials).toEqual({ + notification_id: expect.any(String), + access_token: 'ey.val.ue', + c_nonce: 'new-c-nonce', + c_nonce_expires_in: 300, + credential: 'sd-jwt', + // format: 'vc+sd-jwt', + }); + }, + UNIT_TEST_TIMEOUT, + ); }); diff --git a/packages/common/lib/functions/ProofUtil.ts b/packages/common/lib/functions/ProofUtil.ts index 65091139..a3f2b550 100644 --- a/packages/common/lib/functions/ProofUtil.ts +++ b/packages/common/lib/functions/ProofUtil.ts @@ -115,6 +115,7 @@ export interface JwtProps { typ?: Typ; kid?: string; jwk?: JWK; + x5c?: string[]; aud?: string | string[]; issuer?: string; clientId?: string; @@ -134,12 +135,13 @@ const createJWT = (mode: PoPMode, jwtProps?: JwtProps, existingJwt?: Jwt): Jwt = // : getJwtProperty('iss', false, jwtProps?.issuer, existingJwt?.payload?.iss); const client_id = mode === 'jwt' ? getJwtProperty('client_id', false, jwtProps?.clientId, existingJwt?.payload?.client_id) : undefined; const jti = getJwtProperty('jti', false, jwtProps?.jti, existingJwt?.payload?.jti); - const typ = getJwtProperty('typ', true, jwtProps?.typ, existingJwt?.header?.typ, 'jwt'); + const typ = getJwtProperty('typ', true, jwtProps?.typ, existingJwt?.header?.typ, 'openid4vci-proof+jwt'); const nonce = getJwtProperty('nonce', false, jwtProps?.nonce, existingJwt?.payload?.nonce); // Officially this is required, but some implementations don't have it // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const alg = getJwtProperty('alg', false, jwtProps?.alg, existingJwt?.header?.alg, 'ES256')!; const kid = getJwtProperty('kid', false, jwtProps?.kid, existingJwt?.header?.kid); const jwk = getJwtProperty('jwk', false, jwtProps?.jwk, existingJwt?.header?.jwk); + const x5c = getJwtProperty('x5c', false, jwtProps?.x5c, existingJwt?.header.x5c); const jwt: Partial = existingJwt ? existingJwt : {}; const now = +new Date(); const jwtPayload: Partial = { @@ -157,6 +159,7 @@ const createJWT = (mode: PoPMode, jwtProps?: JwtProps, existingJwt?: Jwt): Jwt = alg, ...(kid && { kid }), ...(jwk && { jwk }), + ...(x5c && { x5c }), }; return { payload: { ...jwt.payload, ...jwtPayload }, @@ -171,7 +174,7 @@ const getJwtProperty = ( jwtProperty?: T, defaultValue?: T, ): T | undefined => { - if (typeof option === 'string' && option && jwtProperty && option !== jwtProperty) { + if ((typeof option === 'string' || Array.isArray(option)) && option && jwtProperty && option !== jwtProperty) { throw Error(`Cannot have a property '${propertyName}' with value '${option}' and different JWT value '${jwtProperty}' at the same time`); } let result = (jwtProperty ? jwtProperty : option) as T | undefined; diff --git a/packages/issuer/lib/VcIssuer.ts b/packages/issuer/lib/VcIssuer.ts index e56527ca..2dc7e9dd 100644 --- a/packages/issuer/lib/VcIssuer.ts +++ b/packages/issuer/lib/VcIssuer.ts @@ -498,8 +498,13 @@ export class VcIssuer { // only 1 is allowed, but need to look into whether jwk and x5c are allowed together throw Error(KID_JWK_X5C_ERROR) } else if (kid && !did) { - // Make sure the callback function extracts the DID from the kid - throw Error(KID_DID_NO_DID_ERROR) + if (!jwk && !x5c) { + // Make sure the callback function extracts the DID from the kid + throw Error(KID_DID_NO_DID_ERROR) + } else { + // If JWK or x5c is present, log the information and proceed + console.log(`KID present but no DID, using JWK or x5c`) + } } else if (did && !didDocument) { // Make sure the callback function does DID resolution when a did is present throw Error(DID_NO_DIDDOC_ERROR) diff --git a/packages/issuer/lib/__tests__/VcIssuer.spec.ts b/packages/issuer/lib/__tests__/VcIssuer.spec.ts index aa037c77..f30c58a7 100644 --- a/packages/issuer/lib/__tests__/VcIssuer.spec.ts +++ b/packages/issuer/lib/__tests__/VcIssuer.spec.ts @@ -33,6 +33,21 @@ const verifiableCredential = { }, } +const verifiableCredential_withoutDid = { + '@context': ['https://www.w3.org/2018/credentials/v1', 'https://w3id.org/security/suites/jws-2020/v1'], + id: 'http://university.example/credentials/1872', + type: ['VerifiableCredential', 'ExampleAlumniCredential'], + issuer: 'https://university.example/issuers/565049', + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: 'ebfeb1f712ebc6f1c276e12ec21', + alumniOf: { + id: 'c276e12ec21ebfeb1f712ebc6f1', + name: 'Example University', + }, + }, +} + describe('VcIssuer', () => { let vcIssuer: VcIssuer const issuerState = 'previously-created-state' @@ -434,3 +449,253 @@ describe('VcIssuer', () => { ).rejects.toThrow(Error(ALG_ERROR)) }) }) + +describe('VcIssuer without did', () => { + let vcIssuer: VcIssuer + const issuerState = 'previously-created-state' + const clientId = 'sphereon:wallet' + const preAuthorizedCode = 'test_code' + + const jwtVerifyCallback: jest.Mock = jest.fn() + + beforeEach(async () => { + jest.clearAllMocks() + const credentialsSupported: Record = new CredentialSupportedBuilderV1_13() + .withCredentialSigningAlgValuesSupported('ES256K') + .withCryptographicBindingMethod('jwk') + .withFormat('jwt_vc_json') + .withCredentialName('UniversityDegree_JWT') + .withCredentialDefinition({ + type: ['VerifiableCredential', 'UniversityDegree_JWT'], + }) + .withCredentialSupportedDisplay({ + name: 'University Credential', + locale: 'en-US', + logo: { + url: 'https://exampleuniversity.com/public/logo.png', + alt_text: 'a square logo of a university', + }, + background_color: '#12107c', + text_color: '#FFFFFF', + }) + .addCredentialSubjectPropertyDisplay('given_name', { + name: 'given name', + locale: 'en-US', + } as IssuerCredentialSubjectDisplay) + .build() + const stateManager = new MemoryStates() + await stateManager.set('previously-created-state', { + issuerState, + clientId, + preAuthorizedCode, + createdAt: +new Date(), + lastUpdatedAt: +new Date(), + status: IssueStatus.OFFER_CREATED, + notification_id: v4(), + userPin: '123456', + credentialOffer: { + credential_offer: { + credential_issuer: 'test.com', + credentials: [ + { + format: 'ldp_vc', + credential_definition: { + types: ['VerifiableCredential'], + '@context': ['https://www.w3.org/2018/credentials/v1'], + credentialSubject: {}, + }, + }, + ], + grants: { + authorization_code: { issuer_state: issuerState }, + 'urn:ietf:params:oauth:grant-type:pre-authorized_code': { + 'pre-authorized_code': preAuthorizedCode, + tx_code: { + input_mode: 'text', + length: 4, + }, + }, + }, + }, + }, + }) + vcIssuer = new VcIssuerBuilder() + .withAuthorizationServers('https://authorization-server') + .withCredentialEndpoint('https://credential-endpoint') + .withCredentialIssuer(IDENTIPROOF_ISSUER_URL) + .withIssuerDisplay({ + name: 'example issuer', + locale: 'en-US', + }) + .withCredentialConfigurationsSupported(credentialsSupported) + .withCredentialOfferStateManager(stateManager) + .withInMemoryCNonceState() + .withInMemoryCredentialOfferURIState() + .withCredentialSignerCallback(() => + Promise.resolve({ + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential'], + issuer: 'test.com', + issuanceDate: new Date().toISOString(), + credentialSubject: {}, + proof: { + type: IProofType.JwtProof2020, + jwt: 'ye.ye.ye', + created: new Date().toISOString(), + proofPurpose: IProofPurpose.assertionMethod, + verificationMethod: 'sdfsdfasdfasdfasdfasdfassdfasdf', + }, + }), + ) + .withJWTVerifyCallback(jwtVerifyCallback) + .build() + }) + + afterAll(async () => { + jest.clearAllMocks() + // await new Promise((resolve) => setTimeout((v: void) => resolve(v), 500)) + }) + + // Of course this doesn't work. The state is part of the proof to begin with + it('should fail issuing credential if an invalid state is used', async () => { + jwtVerifyCallback.mockResolvedValue({ + alg: Alg.ES256K, + jwt: { + header: { + typ: 'openid4vci-proof+jwt', + alg: Alg.ES256K, + x5c: ['12', '34', '56'], + }, + payload: { + aud: IDENTIPROOF_ISSUER_URL, + iat: +new Date() / 1000, + nonce: 'test-nonce', + }, + }, + }) + + await expect( + vcIssuer.issueCredential({ + credentialRequest: { + credential_identifier: 'VerifiableCredential', + format: 'jwt_vc_json', + proof: { + proof_type: 'jwt', + jwt: 'ye.ye.ye', + }, + }, + // issuerState: 'invalid state', + }), + ).rejects.toThrow(Error(STATE_MISSING_ERROR + ' (test-nonce)')) + }) + + it.each([...Object.values(Alg), 'CUSTOM'])('should issue %s signed credential if a valid state is passed in', async (alg: string) => { + jwtVerifyCallback.mockResolvedValue({ + alg: alg, + jwt: { + header: { + typ: 'openid4vci-proof+jwt', + alg: alg, + x5c: ['12', '34', '56'], + }, + payload: { + aud: IDENTIPROOF_ISSUER_URL, + iat: +new Date() / 1000, + nonce: 'test-nonce', + }, + }, + }) + + const createdAt = +new Date() + await vcIssuer.cNonces.set('test-nonce', { + cNonce: 'test-nonce', + preAuthorizedCode: 'test-pre-authorized-code', + createdAt: createdAt, + }) + await vcIssuer.credentialOfferSessions.set('test-pre-authorized-code', { + createdAt: createdAt, + notification_id: '43243', + preAuthorizedCode: 'test-pre-authorized-code', + credentialOffer: { + credential_offer: { + credential_issuer: 'test.com', + credentials: [], + }, + }, + lastUpdatedAt: createdAt, + status: IssueStatus.ACCESS_TOKEN_CREATED, + }) + + expect( + vcIssuer.issueCredential({ + credential: verifiableCredential_withoutDid, + credentialRequest: { + credential_identifier: 'VerifiableCredential', + format: 'jwt_vc_json', + proof: { + proof_type: 'jwt', + jwt: 'ye.ye.ye', + }, + }, + newCNonce: 'new-test-nonce', + }), + ).resolves.toEqual({ + c_nonce: 'new-test-nonce', + c_nonce_expires_in: 300, + notification_id: '43243', + credential: { + '@context': ['https://www.w3.org/2018/credentials/v1'], + credentialSubject: {}, + issuanceDate: expect.any(String), + issuer: 'test.com', + proof: { + created: expect.any(String), + jwt: 'ye.ye.ye', + proofPurpose: 'assertionMethod', + type: 'JwtProof2020', + verificationMethod: 'sdfsdfasdfasdfasdfasdfassdfasdf', + }, + type: ['VerifiableCredential'], + }, + // format: 'jwt_vc_json', + }) + }) + + it('should fail issuing credential if the signing algorithm is missing', async () => { + const createdAt = +new Date() + await vcIssuer.cNonces.set('test-nonce', { + cNonce: 'test-nonce', + preAuthorizedCode: 'test-pre-authorized-code', + createdAt: createdAt, + }) + + jwtVerifyCallback.mockResolvedValue({ + alg: undefined, + jwt: { + header: { + typ: 'openid4vci-proof+jwt', + alg: undefined, + x5c: ['12', '34', '56'], + }, + payload: { + aud: IDENTIPROOF_ISSUER_URL, + iat: +new Date() / 1000, + nonce: 'test-nonce', + }, + }, + }) + + expect( + vcIssuer.issueCredential({ + credentialRequest: { + credential_identifier: 'VerifiableCredential', + format: 'jwt_vc_json', + proof: { + proof_type: 'jwt', + jwt: 'ye.ye.ye', + }, + }, + }), + ).rejects.toThrow(Error(ALG_ERROR)) + }) +}) diff --git a/packages/issuer/lib/__tests__/VcIssuerBuilder.spec.ts b/packages/issuer/lib/__tests__/VcIssuerBuilder.spec.ts index fbf67db7..34b4c298 100644 --- a/packages/issuer/lib/__tests__/VcIssuerBuilder.spec.ts +++ b/packages/issuer/lib/__tests__/VcIssuerBuilder.spec.ts @@ -150,4 +150,63 @@ describe('VcIssuer builder should', () => { credentialOffer: { credential_offer: { credentials: ['test_credential'], credential_issuer: 'test_issuer' } }, }) }) + + it('should successfully attach an instance of the ICredentialOfferStateManager to the VcIssuer instance without did', async () => { + const credentialsSupported: Record = new CredentialSupportedBuilderV1_13() + .withCredentialSigningAlgValuesSupported('ES256K') + .withCryptographicBindingMethod('jwk') + .withFormat('jwt_vc_json') + .withCredentialName('UniversityDegree_JWT') + .withCredentialDefinition({ + type: ['VerifiableCredential', 'UniversityDegree_JWT'], + }) + .withCredentialSupportedDisplay({ + name: 'University Credential', + locale: 'en-US', + logo: { + url: 'https://exampleuniversity.com/public/logo.png', + alt_text: 'a square logo of a university', + }, + background_color: '#12107c', + text_color: '#FFFFFF', + }) + .addCredentialSubjectPropertyDisplay('given_name', { + name: 'given name', + locale: 'en-US', + } as IssuerCredentialSubjectDisplay) + .build() + const vcIssuer = new VcIssuerBuilder() + .withAuthorizationServers('https://authorization-server') + .withCredentialEndpoint('https://credential-endpoint') + .withCredentialIssuer('https://credential-issuer') + .withIssuerDisplay({ + name: 'example issuer', + locale: 'en-US', + }) + .withCredentialConfigurationsSupported(credentialsSupported) + .withInMemoryCredentialOfferState() + .withInMemoryCNonceState() + .build() + expect(vcIssuer).toBeDefined() + const preAuthorizedCodecreatedAt = +new Date() + await vcIssuer.credentialOfferSessions?.set('test', { + notification_id: v4(), + issuerState: v4(), + lastUpdatedAt: preAuthorizedCodecreatedAt, + status: IssueStatus.OFFER_CREATED, + clientId: 'test_client', + createdAt: preAuthorizedCodecreatedAt, + userPin: '123456', + credentialOffer: { credential_offer: { credentials: ['test_credential'], credential_issuer: 'test_issuer' } }, + }) + await expect(vcIssuer.credentialOfferSessions?.get('test')).resolves.toMatchObject({ + clientId: 'test_client', + userPin: '123456', + status: IssueStatus.OFFER_CREATED, + lastUpdatedAt: preAuthorizedCodecreatedAt, + createdAt: preAuthorizedCodecreatedAt, + credentialOffer: { credential_offer: { credentials: ['test_credential'], credential_issuer: 'test_issuer' } }, + }) + }) + })