Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolution and Validation of VC CredentialSchema #737

Merged
merged 16 commits into from
Jul 19, 2024
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)) {
thehenrytsai marked this conversation as resolved.
Show resolved Hide resolved
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)}`);
thehenrytsai marked this conversation as resolved.
Show resolved Hide resolved
}
}

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
Loading