UNPKG

@sphereon/oid4vci-issuer

Version:

OpenID 4 Verifiable Credential Issuance issuer REST endpoints

784 lines (741 loc) • 26.4 kB
import { uuidv4 } from '@sphereon/oid4vc-common' import { OpenID4VCIClientV1_0_13 } from '@sphereon/oid4vci-client' import { Alg, ALG_ERROR, CredentialConfigurationSupportedV1_0_13, CredentialOfferSession, IssuerCredentialSubjectDisplay, IssueStatus, STATE_MISSING_ERROR, } from '@sphereon/oid4vci-common' import { IProofPurpose, IProofType } from '@sphereon/ssi-types' import { VcIssuer } from '../VcIssuer' import { CredentialSupportedBuilderV1_13, VcIssuerBuilder } from '../builder' import { AuthorizationServerMetadataBuilder } from '../builder/AuthorizationServerMetadataBuilder' import { MemoryStates } from '../state-manager' const IDENTIPROOF_ISSUER_URL = 'https://issuer.research.identiproof.io' const verifiableCredential = { '@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: 'did:example:ebfeb1f712ebc6f1c276e12ec21', alumniOf: { id: 'did:example:c276e12ec21ebfeb1f712ebc6f1', name: 'Example University', }, }, } 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', }, }, } const authorizationServerMetadata = new AuthorizationServerMetadataBuilder() .withIssuer(IDENTIPROOF_ISSUER_URL) .withCredentialEndpoint('http://localhost:3456/test/credential-endpoint') .withTokenEndpoint('http://localhost:3456/test/token') .withAuthorizationEndpoint('https://token-endpoint.example.com/authorize') .withTokenEndpointAuthMethodsSupported(['none', 'client_secret_basic', 'client_secret_jwt', 'client_secret_post']) .withResponseTypesSupported(['code', 'token', 'id_token']) .withScopesSupported(['openid', 'abcdef']) .build() describe('VcIssuer', () => { 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<string, CredentialConfigurationSupportedV1_0_13> = new CredentialSupportedBuilderV1_13() .withCredentialSigningAlgValuesSupported('ES256K') .withCryptographicBindingMethod('did') .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<CredentialOfferSession>() await stateManager.set('previously-created-state', { issuerState, clientId, preAuthorizedCode, createdAt: +new Date(), lastUpdatedAt: +new Date(), status: IssueStatus.OFFER_CREATED, notification_id: uuidv4(), txCode: '123456', credentialOffer: { credential_offer: { credential_issuer: 'did:key:test', 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) .withAuthorizationMetadata(authorizationServerMetadata) .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: 'did:key:test', 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)) }) it.skip('should create credential offer', async () => { const { uri, ...rest } = await vcIssuer.createCredentialOfferURI({ offerMode: 'VALUE', grants: { authorization_code: { issuer_state: issuerState, }, 'urn:ietf:params:oauth:grant-type:pre-authorized_code': { 'pre-authorized_code': preAuthorizedCode, user_pin_required: true, }, }, scheme: 'http', baseUri: 'issuer-example.com', qrCodeOpts: { size: 400, colorDark: '#000000', colorLight: '#ffffff', correctLevel: 2, }, }) console.log(JSON.stringify(rest, null, 2)) expect(uri).toEqual( 'http://issuer-example.com?credential_offer=%7B%22grants%22%3A%7B%22authorization_code%22%3A%7B%22issuer_state%22%3A%22previously-created-state%22%7D%2C%22urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22pre-authorized_code%22%3A%22test_code%22%2C%22user_pin_required%22%3Atrue%7D%7D%2C%22credential_issuer%22%3A%22https%3A%2F%2Fissuer.research.identiproof.io%22%2C%22credentials%22%3A%5B%7B%22format%22%3A%22jwt_vc_json%22%2C%22types%22%3A%5B%22VerifiableCredential%22%5D%2C%22credentialSubject%22%3A%7B%22given_name%22%3A%7B%22name%22%3A%22given%20name%22%2C%22locale%22%3A%22en-US%22%7D%7D%2C%22cryptographic_suites_supported%22%3A%5B%22ES256K%22%5D%2C%22cryptographic_binding_methods_supported%22%3A%5B%22did%22%5D%2C%22id%22%3A%22UniversityDegree_JWT%22%2C%22display%22%3A%5B%7B%22name%22%3A%22University%20Credential%22%2C%22locale%22%3A%22en-US%22%2C%22logo%22%3A%7B%22url%22%3A%22https%3A%2F%2Fexampleuniversity.com%2Fpublic%2Flogo.png%22%2C%22alt_text%22%3A%22a%20square%20logo%20of%20a%20university%22%7D%2C%22background_color%22%3A%22%2312107c%22%2C%22text_color%22%3A%22%23FFFFFF%22%7D%5D%7D%5D%7D', ) const client = await OpenID4VCIClientV1_0_13.fromURI({ uri }) expect(client.credentialOffer).toEqual({ baseUrl: 'http://issuer-example.com', credential_offer: { credential_issuer: 'https://issuer.research.identiproof.io', credentials: [ { credentialSubject: { given_name: { locale: 'en-US', name: 'given name', }, }, cryptographic_binding_methods_supported: ['did'], cryptographic_suites_supported: ['ES256K'], display: [ { background_color: '#12107c', locale: 'en-US', logo: { alt_text: 'a square logo of a university', url: 'https://exampleuniversity.com/public/logo.png', }, name: 'University Credential', text_color: '#FFFFFF', }, ], format: 'jwt_vc_json', id: 'UniversityDegree_JWT', types: ['VerifiableCredential'], }, ], grants: { authorization_code: { issuer_state: 'previously-created-state', }, 'urn:ietf:params:oauth:grant-type:pre-authorized_code': { 'pre-authorized_code': 'test_code', user_pin_required: true, }, }, }, issuerState: 'previously-created-state', original_credential_offer: { credential_issuer: 'https://issuer.research.identiproof.io', credentials: [ { credentialSubject: { given_name: { locale: 'en-US', name: 'given name', }, }, cryptographic_binding_methods_supported: ['did'], cryptographic_suites_supported: ['ES256K'], display: [ { background_color: '#12107c', locale: 'en-US', logo: { alt_text: 'a square logo of a university', url: 'https://exampleuniversity.com/public/logo.png', }, name: 'University Credential', text_color: '#FFFFFF', }, ], format: 'jwt_vc_json', id: 'UniversityDegree_JWT', types: ['VerifiableCredential'], }, ], grants: { authorization_code: { issuer_state: 'previously-created-state', }, 'urn:ietf:params:oauth:grant-type:pre-authorized_code': { 'pre-authorized_code': 'test_code', user_pin_required: true, }, }, }, preAuthorizedCode: 'test_code', scheme: 'http', supportedFlows: ['Authorization Code Flow', 'Pre-Authorized Code Flow'], userPinRequired: true, version: 1011, }) }) it('should create credential offer uri', async () => { await expect( vcIssuer .createCredentialOfferURI({ offerMode: 'REFERENCE', credentialOfferUri: 'http://issuer-example.com/:id', grants: { authorization_code: { issuer_state: issuerState, }, }, scheme: 'http', baseUri: 'issuer-example.com', credential_configuration_ids: ['VerifiableCredential'], }) .then((response) => response.uri), ).resolves.toContain('http://issuer-example.com?credential_offer_uri=http%3A%2F%2Fissuer-example.com%2F') }) // 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({ did: 'did:example:1234', kid: 'did:example:1234#auth', alg: Alg.ES256K, didDocument: { '@context': 'https://www.w3.org/ns/did/v1', id: 'did:example:1234', }, jwt: { header: { typ: 'openid4vci-proof+jwt', alg: Alg.ES256K, kid: 'test-kid', }, payload: { aud: IDENTIPROOF_ISSUER_URL, iat: +new Date() / 1000, nonce: 'test-nonce', }, }, }) await expect( vcIssuer.issueCredential({ credentialRequest: { credential_identifier: 'VerifiableCredential', proof: { proof_type: 'jwt', jwt: 'ye.ye.ye', }, }, // issuerState: 'invalid state', }), ).rejects.toThrow(Error(STATE_MISSING_ERROR + ' (test-nonce)')) }) it.each([...Object.values<string>(Alg), 'CUSTOM'])('should issue %s signed credential if a valid state is passed in', async (alg: string) => { jwtVerifyCallback.mockResolvedValue({ did: 'did:example:1234', kid: 'did:example:1234#auth', alg: alg, didDocument: { '@context': 'https://www.w3.org/ns/did/v1', id: 'did:example:1234', }, jwt: { header: { typ: 'openid4vci-proof+jwt', alg: alg, kid: 'test-kid', }, 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: 'did:key:test', credentials: [], }, }, lastUpdatedAt: createdAt, status: IssueStatus.ACCESS_TOKEN_CREATED, }) expect( vcIssuer.issueCredential({ credential: verifiableCredential, credentialRequest: { credential_identifier: 'VerifiableCredential', 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: 'did:key:test', 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({ did: 'did:example:1234', kid: 'did:example:1234#auth', alg: undefined, didDocument: { '@context': 'https://www.w3.org/ns/did/v1', id: 'did:example:1234', }, jwt: { header: { typ: 'openid4vci-proof+jwt', alg: undefined, kid: 'test-kid', }, payload: { aud: IDENTIPROOF_ISSUER_URL, iat: +new Date() / 1000, nonce: 'test-nonce', }, }, }) expect( vcIssuer.issueCredential({ credentialRequest: { credential_identifier: 'VerifiableCredential', proof: { proof_type: 'jwt', jwt: 'ye.ye.ye', }, }, }), ).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<string, CredentialConfigurationSupportedV1_0_13> = 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<CredentialOfferSession>() await stateManager.set('previously-created-state', { issuerState, clientId, preAuthorizedCode, createdAt: +new Date(), lastUpdatedAt: +new Date(), status: IssueStatus.OFFER_CREATED, notification_id: uuidv4(), txCode: '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) .withAuthorizationMetadata(authorizationServerMetadata) .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', proof: { proof_type: 'jwt', jwt: 'ye.ye.ye', }, }, // issuerState: 'invalid state', }), ).rejects.toThrow(Error(STATE_MISSING_ERROR + ' (test-nonce)')) }) it.each([...Object.values<string>(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', 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', proof: { proof_type: 'jwt', jwt: 'ye.ye.ye', }, }, }), ).rejects.toThrow(Error(ALG_ERROR)) }) it('should create credential offer uri with REFERENCE mode', async () => { const result = await vcIssuer.createCredentialOfferURI({ offerMode: 'REFERENCE', credentialOfferUri: 'https://example.com/api/credentials/:id', grants: { authorization_code: { issuer_state: issuerState, }, }, scheme: 'http', baseUri: 'issuer-example.com', }) expect(result.uri).toMatch(/http:\/\/issuer-example\.com\?credential_offer_uri=https%3A%2F%2Fexample\.com%2Fapi%2Fcredentials%2F[\w-]+/) expect(result.session).toBeDefined() expect(result.session.credentialOffer.credential_offer_uri).toMatch(/https:\/\/example\.com\/api\/credentials\/[\w-]+/) }) it('should throw error if credential offer Uri is missing with REFERENCE mode', async () => { await expect( vcIssuer.createCredentialOfferURI({ offerMode: 'REFERENCE', grants: { authorization_code: { issuer_state: issuerState, }, }, }), ).rejects.toThrow('credentialOfferUri must be supplied for offerMode REFERENCE!') }) it('should get credential offer session by uri', async () => { const result = await vcIssuer.createCredentialOfferURI({ offerMode: 'REFERENCE', credentialOfferUri: 'https://example.com/api/credentials/:id', grants: { authorization_code: { issuer_state: issuerState, }, 'urn:ietf:params:oauth:grant-type:pre-authorized_code': { 'pre-authorized_code': 'preAuthCode', }, }, }) const session = await vcIssuer.getCredentialOfferSessionById(result.session.preAuthorizedCode!, ['uri']) expect(session).toBeDefined() expect(session.credentialOffer).toEqual(result.session.credentialOffer) }) it('should throw error when getting session with invalid uri', async () => { await expect(vcIssuer.getCredentialOfferSessionById('https://example.com/invalid-uri')).rejects.toThrow( 'no value found for id https://example.com/invalid-uri', ) }) it('should throw error when getting session by uri without uri state manager', async () => { // Create issuer without URI state manager const vcIssuerWithoutUriState = new VcIssuerBuilder() .withAuthorizationServers('https://authorization-server') .withCredentialEndpoint('https://credential-endpoint') .withCredentialIssuer(IDENTIPROOF_ISSUER_URL) .withAuthorizationMetadata(authorizationServerMetadata) .withCredentialConfigurationsSupported({}) .withCredentialOfferStateManager(new MemoryStates<CredentialOfferSession>()) .withInMemoryCNonceState() .build() await expect(vcIssuerWithoutUriState.getCredentialOfferSessionById('https://example.com/some-uri')).rejects.toThrow( 'no value found for id https://example.com/some-uri', ) }) })