Skip to content

Commit

Permalink
Resolution and Validation of VC CredentialSchema (#737)
Browse files Browse the repository at this point in the history
* update pex

* add changeset

* add resolution and validation of credentialSchema

* lint

* update schema

* test coverage

* update to real kyc link

* lint

* updates

* merge

* did ion not supported

* lint

* update

* update
  • Loading branch information
nitro-neal authored Jul 19, 2024
1 parent 57ac130 commit 4d01119
Show file tree
Hide file tree
Showing 8 changed files with 299 additions and 63 deletions.
5 changes: 5 additions & 0 deletions .changeset/famous-shrimps-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@web5/credentials": minor
---

Adding resolution and validation of credential schemas
1 change: 1 addition & 0 deletions packages/credentials/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"@web5/common": "1.0.1",
"@web5/crypto": "1.0.1",
"@web5/dids": "1.1.1",
"jsonschema": "1.4.1",
"pako": "^2.1.0"
},
"devDependencies": {
Expand Down
35 changes: 35 additions & 0 deletions packages/credentials/src/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,21 @@ import type {
ICredentialSubject
} from '@sphereon/ssi-types';

import { Validator as JsonSchemaValidator } from 'jsonschema';

import {
CredentialSchema,
DEFAULT_VC_CONTEXT,
DEFAULT_VC_TYPE,
VcDataModel,
VerifiableCredential
} from './verifiable-credential.js';

import { isValidRFC3339Timestamp, isValidXmlSchema112Timestamp } from './utils.js';
import { DEFAULT_VP_TYPE } from './verifiable-presentation.js';

const jsonSchemaValidator = new JsonSchemaValidator();

export class SsiValidator {
static validateCredentialPayload(vc: VerifiableCredential): void {
this.validateContext(vc.vcDataModel['@context']);
Expand Down Expand Up @@ -54,6 +60,35 @@ export class SsiValidator {
}
}

static async validateCredentialSchema(vcDataModel: VcDataModel): Promise<void> {
const credentialSchema = vcDataModel.credentialSchema as CredentialSchema | CredentialSchema[];

if (!credentialSchema || (Array.isArray(credentialSchema) && credentialSchema.length === 0)) {
throw new Error('Credential schema is missing or empty');
}

const schemaId = Array.isArray(credentialSchema) ? credentialSchema[0].id : credentialSchema.id;

let jsonSchema;
try {
const response = await fetch(schemaId);

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

jsonSchema = await response.json();
} catch (error: any) {
throw new Error(`Failed to fetch schema from ${schemaId}: ${error.message}`);
}

const validationResult = jsonSchemaValidator.validate(vcDataModel, jsonSchema);

if (!validationResult.valid) {
throw new Error(`Schema Validation Errors: ${JSON.stringify(validationResult.errors)}`);
}
}

static asArray(arg: any | any[]): any[] {
return Array.isArray(arg) ? arg : [arg];
}
Expand Down
2 changes: 2 additions & 0 deletions packages/credentials/src/verifiable-credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ export class VerifiableCredential {

validatePayload(vcTyped);

if (vcTyped.credentialSchema) await SsiValidator.validateCredentialSchema(vcTyped);

return {
/** The issuer of the VC. */
issuer : payload.iss!,
Expand Down
129 changes: 128 additions & 1 deletion packages/credentials/tests/ssi-validator.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { expect } from 'chai';

import sinon from 'sinon';

import { SsiValidator } from '../src/validators.js';
import { DEFAULT_VC_CONTEXT, DEFAULT_VC_TYPE } from '../src/verifiable-credential.js';
import { DEFAULT_VC_CONTEXT, DEFAULT_VC_TYPE, VcDataModel } from '../src/verifiable-credential.js';
import { DEFAULT_VP_TYPE } from '../src/verifiable-presentation.js';

describe('SsiValidator', () => {


describe('validateContext', () => {
it('should throw an error if the default context is missing', () => {
expect(() => SsiValidator.validateContext(['http://example.com'])).throw(`@context is missing default context "${DEFAULT_VC_CONTEXT}"`);
Expand All @@ -25,6 +29,28 @@ describe('SsiValidator', () => {
});
});

describe('validateVpType', () => {
it('should throw an error if the default VP type is missing', () => {
expect(() => SsiValidator.validateVpType(['CustomType'])).to.throw(`type is missing default "${DEFAULT_VP_TYPE}"`);
});

it('should not throw an error if the default VP type is present', () => {
expect(() => SsiValidator.validateVpType([DEFAULT_VP_TYPE, 'CustomType'])).not.to.throw();
});

it('should throw an error if the input array is empty', () => {
expect(() => SsiValidator.validateVpType([])).to.throw(`type is missing default "${DEFAULT_VP_TYPE}"`);
});

it('should throw an error if the input is not an array and does not contain the default VP type', () => {
expect(() => SsiValidator.validateVpType('CustomType')).to.throw(`type is missing default "${DEFAULT_VP_TYPE}"`);
});

it('should not throw an error if the input is not an array but contains the default VP type', () => {
expect(() => SsiValidator.validateVpType(DEFAULT_VP_TYPE)).not.to.throw();
});
});

describe('validateCredentialSubject', () => {
it('should throw an error if the credential subject is empty', () => {
expect(() => SsiValidator.validateCredentialSubject({})).throw('credentialSubject must not be empty');
Expand All @@ -45,4 +71,105 @@ describe('SsiValidator', () => {
expect(() => SsiValidator.validateTimestamp(validTimestamp)).not.throw();
});
});

describe('validateCredentialSchema', () => {
// Mock VcDataModel and CredentialSchema
const validVcDataModel = {
credentialSchema: {
id : 'https://schema.org/PFI',
type : 'JsonSchema'
}
} as VcDataModel;

let fetchStub: sinon.SinonStub;

beforeEach(() => {
fetchStub = sinon.stub(globalThis, 'fetch');
});

afterEach(() => {
fetchStub.restore();
});

it('should throw an error if credential schema is missing', async () => {
const invalidVcDataModel = { ...validVcDataModel, credentialSchema: undefined };

try {
await SsiValidator.validateCredentialSchema(invalidVcDataModel);
expect.fail();
} catch (error: any) {
expect(error.message).to.equal('Credential schema is missing or empty');
}
});

it('should throw an error if credential schema is an empty array', async () => {
const invalidVcDataModel = { ...validVcDataModel, credentialSchema: [] };

try {
await SsiValidator.validateCredentialSchema(invalidVcDataModel);
expect.fail();
} catch (error: any) {
expect(error.message).to.equal('Credential schema is missing or empty');
}
});

it('should throw an error if fetch fails', async () => {
fetchStub.rejects(new Error('Network error'));

try {
await SsiValidator.validateCredentialSchema(validVcDataModel);
expect.fail();
} catch (error: any) {
expect(error.message).to.equal('Failed to fetch schema from https://schema.org/PFI: Network error');
}
});

it('should throw an error if fetch returns non-200 status', async () => {
fetchStub.resolves(new Response(null, { status: 404 }));

try {
await SsiValidator.validateCredentialSchema(validVcDataModel);
expect.fail();
} catch (error: any) {
expect(error.message).to.contain('Failed to fetch schema from https://schema.org/PFI');
}
});

it('should throw an error if schema validation fails', async () => {
const mockSchema = {
'$schema' : 'http://json-schema.org/draft-07/schema#',
'type' : 'object',
'properties' : {
'credentialSubject': {
'type' : 'object',
'properties' : {
'id': {
'type': 'string'
},
'country_of_residence': {
'type' : 'string',
'pattern' : '^[A-Z]{2}$'
},
},
'required': [
'id',
'country_of_residence'
]
}
},
'required': [
'issuer',
]
};

fetchStub.resolves(new Response(JSON.stringify(mockSchema), { status: 200 }));

try {
await SsiValidator.validateCredentialSchema(validVcDataModel);
expect.fail();
} catch (error: any) {
expect(error.message).to.contain('Schema Validation Errors:');
}
});
});
});
Loading

0 comments on commit 4d01119

Please sign in to comment.