@sphereon/oid4vci-client
Version:
OpenID for Verifiable Credential Issuance (OpenID4VCI) client
414 lines (379 loc) • 19 kB
text/typescript
import { KeyObject } from 'crypto';
import {
Alg,
EndpointMetadata,
getCredentialRequestForVersion,
getIssuerFromCredentialOfferPayload,
Jwt,
OpenId4VCIVersion,
ProofOfPossession,
URL_NOT_VALID,
WellKnownEndpoints,
} from '@sphereon/oid4vci-common';
import * as jose from 'jose';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import nock from 'nock';
import { CredentialOfferClientV1_0_11, CredentialRequestClientBuilderV1_0_11, MetadataClientV1_0_13, ProofOfPossessionBuilder } from '..';
import {
IDENTIPROOF_ISSUER_URL,
IDENTIPROOF_OID4VCI_METADATA,
INITIATION_TEST,
INITIATION_TEST_V1_0_08,
WALT_OID4VCI_METADATA,
} from './MetadataMocks';
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<string> {
if (!args.payload.aud) {
throw Error('aud required');
} else if (!kid) {
throw Error('kid required');
}
return await new jose.SignJWT({ ...args.payload })
.setProtectedHeader({ alg: 'ES256' })
.setIssuedAt()
.setIssuer(kid)
.setAudience(args.payload.aud)
.setExpirationTime('2h')
.sign(keypair.privateKey);
}
interface KeyPair {
publicKey: KeyObject;
privateKey: KeyObject;
}
beforeAll(async () => {
const { privateKey, publicKey } = await jose.generateKeyPair('ES256');
keypair = { publicKey: publicKey as KeyObject, privateKey: privateKey as KeyObject };
});
beforeEach(async () => {
nock.cleanAll();
nock(IDENTIPROOF_ISSUER_URL).get(WellKnownEndpoints.OPENID4VCI_ISSUER).reply(200, JSON.stringify(IDENTIPROOF_OID4VCI_METADATA));
});
afterEach(async () => {
nock.cleanAll();
});
describe('Credential Request Client ', () => {
it('should get a failed credential response with an unsupported format', 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,
callbacks: {
signCallback: proofOfPossessionCallbackFunction,
},
version: OpenId4VCIVersion.VER_1_0_08,
})
// .withEndpointMetadata(metadata)
.withClientId('sphereon:wallet')
.withKid(kid)
.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)).toBeTruthy();
const result = await credReqClient.acquireCredentialsUsingRequest(credentialRequest);
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';
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,
callbacks: {
signCallback: proofOfPossessionCallbackFunction,
},
version: OpenId4VCIVersion.VER_1_0_08,
})
// .withEndpointMetadata(metadata)
.withKid(kid)
.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)).toBeTruthy();
expect(credentialRequest.format).toEqual('jwt_vc');
const result = await credReqClient.acquireCredentialsUsingRequest(credentialRequest);
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')
.withFormat('jwt_vc')
.withCredentialType('https://imsglobal.github.io/openbadges-specification/ob_v3p0.html#OpenBadgeCredential')
.build();
const proof: ProofOfPossession = await ProofOfPossessionBuilder.fromJwt({
jwt,
callbacks: {
signCallback: proofOfPossessionCallbackFunction,
},
version: OpenId4VCIVersion.VER_1_0_08,
})
// .withEndpointMetadata(metadata)
.withKid(kid)
.withClientId('sphereon:wallet')
.build();
await expect(credReqClient.acquireCredentialsUsingRequest({ format: 'jwt_vc_json', types: ['random'], proof })).rejects.toThrow(
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 ', () => {
beforeEach(() => {
nock.cleanAll();
});
afterEach(() => {
nock.cleanAll();
});
it.skip('should have correct metadata endpoints', async function () {
nock.cleanAll();
const WALT_IRR_URI =
'openid-initiate-issuance://?issuer=https%3A%2F%2Fjff.walt.id%2Fissuer-api%2Foidc%2F&credential_type=OpenBadgeCredential&pre-authorized_code=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhOTUyZjUxNi1jYWVmLTQ4YjMtODIxYy00OTRkYzgyNjljZjAiLCJwcmUtYXV0aG9yaXplZCI6dHJ1ZX0.YE5DlalcLC2ChGEg47CQDaN1gTxbaQqSclIVqsSAUHE&user_pin_required=false';
const credentialOffer = await CredentialOfferClientV1_0_11.fromURI(WALT_IRR_URI);
const request = credentialOffer.credential_offer;
const metadata = await MetadataClientV1_0_13.retrieveAllMetadata(getIssuerFromCredentialOfferPayload(request) as string);
expect(metadata.credential_endpoint).toEqual(WALT_OID4VCI_METADATA.credential_endpoint);
expect(metadata.token_endpoint).toEqual(WALT_OID4VCI_METADATA.token_endpoint);
const credReqClient = CredentialRequestClientBuilderV1_0_11.fromCredentialOffer({
credentialOffer,
metadata,
}).build();
expect(credReqClient.credentialRequestOpts.credentialEndpoint).toBe(WALT_OID4VCI_METADATA.credential_endpoint);
});
});
describe('Credential Request Client with different issuers ', () => {
beforeEach(() => {
nock.cleanAll();
});
afterEach(() => {
nock.cleanAll();
});
it('should create correct CredentialRequest for Spruce', async () => {
const IRR_URI =
'openid-initiate-issuance://?issuer=https%3A%2F%2Fngi%2Doidc4vci%2Dtest%2Espruceid%2Exyz&credential_type=OpenBadgeCredential&pre-authorized_code=eyJhbGciOiJFUzI1NiJ9.eyJjcmVkZW50aWFsX3R5cGUiOlsiT3BlbkJhZGdlQ3JlZGVudGlhbCJdLCJleHAiOiIyMDIzLTA0LTIwVDA5OjA0OjM2WiIsIm5vbmNlIjoibWFibmVpT0VSZVB3V3BuRFFweEt3UnRsVVRFRlhGUEwifQ.qOZRPN8sTv_knhp7WaWte2-aDULaPZX--2i9unF6QDQNUllqDhvxgIHMDCYHCV8O2_Gj-T2x1J84fDMajE3asg&user_pin_required=false';
const credentialRequest = await (
await CredentialRequestClientBuilderV1_0_11.fromURI({
uri: IRR_URI,
metadata: getMockData('spruce')?.metadata as unknown as EndpointMetadata,
})
)
.build()
.createCredentialRequest({
proofInput: {
proof_type: 'jwt',
jwt: getMockData('spruce')?.credential.request.proof.jwt as string,
},
credentialTypes: ['OpenBadgeCredential'],
format: 'jwt_vc',
version: OpenId4VCIVersion.VER_1_0_08,
});
const draft8CredentialRequest = getCredentialRequestForVersion(credentialRequest, OpenId4VCIVersion.VER_1_0_08);
expect(draft8CredentialRequest).toEqual(getMockData('spruce')?.credential.request);
});
it('should create correct CredentialRequest for Walt', async () => {
nock.cleanAll();
const IRR_URI =
'openid-initiate-issuance://?issuer=https%3A%2F%2Fjff.walt.id%2Fissuer-api%2Fdefault%2Foidc%2F&credential_type=OpenBadgeCredential&pre-authorized_code=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIwMTc4OTNjYy04ZTY3LTQxNzItYWZlOS1lODcyYmYxNDBlNWMiLCJwcmUtYXV0aG9yaXplZCI6dHJ1ZX0.ODfq2AIhOcB61dAb3zMrXBJjPJaf53zkeHh_AssYyYA&user_pin_required=false';
const credentialOffer = await (
await CredentialRequestClientBuilderV1_0_11.fromURI({
uri: IRR_URI,
metadata: getMockData('walt')?.metadata as unknown as EndpointMetadata,
})
)
.build()
.createCredentialRequest({
proofInput: {
proof_type: 'jwt',
jwt: getMockData('walt')?.credential.request.proof.jwt as string,
},
credentialTypes: ['OpenBadgeCredential'],
format: 'jwt_vc',
version: OpenId4VCIVersion.VER_1_0_08,
});
expect(credentialOffer).toEqual(getMockData('walt')?.credential.request);
});
// Missing the issuer required property
xit('should create correct CredentialRequest for uniissuer', async () => {
const IRR_URI =
'https://oidc4vc.uniissuer.io/?credential_type=OpenBadgeCredential&pre-authorized_code=0ApoI8rxVmdQ44RIpuDbFIURIIkOhyek&user_pin_required=false';
const credentialOffer = await (
await CredentialRequestClientBuilderV1_0_11.fromURI({
uri: IRR_URI,
metadata: getMockData('uniissuer')?.metadata as unknown as EndpointMetadata,
})
)
.build()
.createCredentialRequest({
proofInput: {
proof_type: 'jwt',
jwt: getMockData('uniissuer')?.credential.request.proof.jwt as string,
},
credentialTypes: ['OpenBadgeCredential'],
format: 'jwt_vc',
version: OpenId4VCIVersion.VER_1_0_08,
});
expect(credentialOffer).toEqual(getMockData('uniissuer')?.credential.request);
});
it('should create correct CredentialRequest for mattr', async () => {
const IRR_URI =
'openid-initiate-issuance://?issuer=https://launchpad.mattrlabs.com&credential_type=OpenBadgeCredential&pre-authorized_code=g0UCOj6RAN5AwHU6gczm_GzB4_lH6GW39Z0Dl2DOOiO';
const credentialOffer = await (
await CredentialRequestClientBuilderV1_0_11.fromURI({
uri: IRR_URI,
metadata: getMockData('mattr')?.metadata as unknown as EndpointMetadata,
})
)
.build()
.createCredentialRequest({
proofInput: {
proof_type: 'jwt',
jwt: getMockData('mattr')?.credential.request.proof.jwt as string,
},
credentialTypes: ['OpenBadgeCredential'],
format: 'ldp_vc',
version: OpenId4VCIVersion.VER_1_0_08,
});
const credentialRequest = getCredentialRequestForVersion(credentialOffer, OpenId4VCIVersion.VER_1_0_08);
expect(credentialRequest).toEqual(getMockData('mattr')?.credential.request);
});
it('should create correct CredentialRequest for diwala', async () => {
const IRR_URI =
'openid-initiate-issuance://?issuer=https://oidc4vc.diwala.io&credential_type=OpenBadgeCredential&pre-authorized_code=eyJhbGciOiJIUzI1NiJ9.eyJjcmVkZW50aWFsX3R5cGUiOiJPcGVuQmFkZ2VDcmVkZW50aWFsIiwiZXhwIjoxNjgxOTg0NDY3fQ.fEAHKz2nuWfiYHw406iNxr-81pWkNkbi31bWsYSf6Ng';
const credentialOffer = await (
await CredentialRequestClientBuilderV1_0_11.fromURI({
uri: IRR_URI,
metadata: getMockData('diwala')?.metadata as unknown as EndpointMetadata,
})
)
.build()
.createCredentialRequest({
proofInput: {
proof_type: 'jwt',
jwt: getMockData('diwala')?.credential.request.proof.jwt as string,
},
credentialTypes: ['OpenBadgeCredential'],
format: 'ldp_vc',
version: OpenId4VCIVersion.VER_1_0_08,
});
// createCredentialRequest returns uniform format in draft 11
const credentialRequest = getCredentialRequestForVersion(credentialOffer, OpenId4VCIVersion.VER_1_0_08);
expect(credentialRequest).toEqual(getMockData('diwala')?.credential.request);
});
});