diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b425a569..63b05f42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: matrix: # Available OS's: https://help.github.com/en/actions/reference/virtual-environments-for-github-hosted-runners os: [ubuntu-latest, windows-latest, macos-latest] - node-version: [20.x, 18.x, 16.x] + node-version: [20.x, 18.x] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} diff --git a/.github/workflows/e2e-node.yml b/.github/workflows/e2e-node.yml index 1550c47c..2c7ae695 100644 --- a/.github/workflows/e2e-node.yml +++ b/.github/workflows/e2e-node.yml @@ -14,7 +14,7 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest] - node-version: ["16.x", "18.x", "20.x"] + node-version: ["18.x", "20.x"] environment-name: ["ESS PodSpaces", "ESS Dev-2-1"] experimental: [false] include: diff --git a/CHANGELOG.md b/CHANGELOG.md index f77f9462..21cd8603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The following changes have been implemented but not released yet: ## Unreleased +### Breaking Changes + +- Parsing Verifiable Credentials. This allows the Verifiable Credential to be read using the RDF/JS DatasetCore API. This is a breaking change because the `VerifiableCredential` type now is also of type `DatasetCore`. Importantly, this dataset is not preserved when converting to verifiableCredentials a string and back doing `JSON.parse(JSON.stringify(verifiableCredential))`. We reccomend that developers set `returnLegacyJsonld` to `false` in functions such as `getVerifiableCredential` in order to avoid returning deprecated object properties. Instead developers should make use of the exported `getter` functions to get these attributes. +- Use the global `fetch` function instead of `@inrupt/universal-fetch`. This means this library now only works + with Node 18 and higher. + ## [0.7.4](https://github.com/inrupt/solid-client-vc-js/releases/tag/v0.7.4) - 2023-11-17 ### Internal Changes diff --git a/README.md b/README.md index 5036f4ed..4260e45a 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ module](https://www.npmjs.com/package/buffer). ### Node.js Support Our JavaScript Client Libraries track Node.js [LTS -releases](https://nodejs.org/en/about/releases/), and support 16.x, 18.x and 20.x. +releases](https://nodejs.org/en/about/releases/), and support 18.x and 20.x. ## Changelog diff --git a/e2e/node/e2e.test.ts b/e2e/node/e2e.test.ts index 1ddd6cd1..a688865c 100644 --- a/e2e/node/e2e.test.ts +++ b/e2e/node/e2e.test.ts @@ -18,22 +18,33 @@ // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // - -// FIXME: Remove when refactoring to test matrix -/* eslint-disable @typescript-eslint/no-non-null-assertion */ - -import { describe, it, expect, beforeAll, afterAll } from "@jest/globals"; -import type { Session } from "@inrupt/solid-client-authn-node"; import { - getNodeTestingEnvironment, getAuthenticatedSession, + getNodeTestingEnvironment, } from "@inrupt/internal-test-env"; +import type { Session } from "@inrupt/solid-client-authn-node"; +import { + describe, + it, + expect, + beforeAll, + afterAll, + beforeEach, +} from "@jest/globals"; +import type { VerifiablePresentationRequest } from "../../src/index"; import { + getCredentialSubject, getVerifiableCredentialAllFromShape, getVerifiableCredentialApiConfiguration, + getVerifiableCredential, issueVerifiableCredential, revokeVerifiableCredential, + isValidVc, + getId, + query, + isValidVerifiablePresentation, } from "../../src/index"; +import { concatenateContexts, defaultContext } from "../../src/common/common"; const validCredentialClaims = { "@context": [ @@ -41,8 +52,6 @@ const validCredentialClaims = { "https://schema.inrupt.com/credentials/v1.jsonld", ], type: ["SolidAccessRequest"], - "http://example.org/my/filtering/property": - "http://example.org/my/filtering/object", expirationDate: new Date(Date.now() + 15 * 60 * 1000).toISOString(), }; const validSubjectClaims = (options?: { @@ -94,6 +103,17 @@ describe("End-to-end verifiable credentials tests for environment", () => { let derivationService: string; let statusService: string; let verifierService: string; + let revokedObject: { + errors: string[]; + }; + + beforeEach(() => { + revokedObject = { + errors: [ + "credentialStatus validation has failed: credential has been revoked", + ], + }; + }); beforeAll(async () => { session = await getAuthenticatedSession(env); @@ -135,18 +155,152 @@ describe("End-to-end verifiable credentials tests for environment", () => { describe("issue a VC", () => { it("Successfully gets a VC from a valid issuer", async () => { - const credential = await issueVerifiableCredential( - issuerService, - validSubjectClaims(), - validCredentialClaims, - { + const [ + credential, + credentialWithSubject, + credentialWithSubjectNoLegacyJson, + credentialNoProperties, + ] = await Promise.all([ + issueVerifiableCredential( + issuerService, + validSubjectClaims(), + validCredentialClaims, + { + fetch: session.fetch, + }, + ), + issueVerifiableCredential( + issuerService, + "http://example.org/my/subject/id", + validSubjectClaims(), + validCredentialClaims, + + { + fetch: session.fetch, + }, + ), + issueVerifiableCredential( + issuerService, + "http://example.org/my/subject/id", + validSubjectClaims(), + validCredentialClaims, + + { + fetch: session.fetch, + returnLegacyJsonld: false, + }, + ), + issueVerifiableCredential( + issuerService, + validSubjectClaims(), + validCredentialClaims, + { + fetch: session.fetch, + returnLegacyJsonld: false, + }, + ), + ]); + + expect(credential.credentialSubject.id).toBe(vcSubject); + expect(getCredentialSubject(credential).value).toBe(vcSubject); + + expect(credentialWithSubject.credentialSubject.id).toBe(vcSubject); + expect(getCredentialSubject(credentialWithSubject).value).toBe(vcSubject); + + expect( + // @ts-expect-error the credentialSubject property should not exist if legacy json is disabled + credentialWithSubjectNoLegacyJson.credentialSubject, + ).toBeUndefined(); + expect( + getCredentialSubject(credentialWithSubjectNoLegacyJson).value, + ).toBe(vcSubject); + + // @ts-expect-error the credentialSubject property should not exist if legacy json is disabled + expect(credentialNoProperties.credentialSubject).toBeUndefined(); + expect(getCredentialSubject(credentialNoProperties).value).toBe( + vcSubject, + ); + + await Promise.all( + [ + credential.id, + credentialWithSubject.id, + credentialWithSubjectNoLegacyJson.id, + credentialNoProperties.id, + ].map((cred) => + revokeVerifiableCredential(statusService, cred, { + fetch: session.fetch, + }), + ), + ); + }); + + it("successfully performs isValidVc checks", async () => { + const purpose = `http://example.org/some/purpose/${Date.now()}`; + const [credential1, credential2] = await Promise.all([ + issueVerifiableCredential( + issuerService, + validSubjectClaims({ + resource: "https://example.org/some-resource", + purpose, + }), + validCredentialClaims, + { + fetch: session.fetch, + }, + ), + issueVerifiableCredential( + issuerService, + validSubjectClaims({ + resource: "https://example.org/another-resource", + purpose, + }), + validCredentialClaims, + { + fetch: session.fetch, + returnLegacyJsonld: false, + }, + ), + ]); + + const creds = [ + credential1, + credential2, + credential1.id, + credential2.id, + getId(credential1), + getId(credential2), + ]; + await Promise.all( + creds.map((cred) => + expect( + isValidVc(cred, { + fetch: session.fetch, + verificationEndpoint: verifierService, + }), + ).resolves.toMatchObject({ errors: [] }), + ), + ); + + await Promise.all([ + revokeVerifiableCredential(statusService, credential1.id, { fetch: session.fetch, - }, + }), + revokeVerifiableCredential(statusService, credential2.id, { + fetch: session.fetch, + }), + ]); + + await Promise.all( + creds.map((cred) => + expect( + isValidVc(cred, { + fetch: session.fetch, + verificationEndpoint: verifierService, + }), + ).resolves.toMatchObject(revokedObject), + ), ); - expect(credential.credentialSubject.id).toBe(vcSubject); - await revokeVerifiableCredential(statusService, credential.id, { - fetch: session.fetch, - }); }); // FIXME: based on configuration, the server may have one of two behaviors @@ -194,40 +348,186 @@ describe("End-to-end verifiable credentials tests for environment", () => { ), ]); - await expect( - getVerifiableCredentialAllFromShape( + expect(credential1.credentialSubject.id).toBe(vcSubject); + expect(getCredentialSubject(credential1).value).toBe(vcSubject); + expect(credential2.credentialSubject.id).toBe(vcSubject); + expect(getCredentialSubject(credential2).value).toBe(vcSubject); + + const matcher = { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://schema.inrupt.com/credentials/v1.jsonld", + ], + type: ["VerifiableCredential"], + credentialSubject: { + id: vcSubject, + hasConsent: { + forPurpose: purpose, + }, + }, + }; + + const [ + allDeprecated, + allNew, + verifiablePresentationLegacy, + verifiablePresentation, + ] = await Promise.all([ + getVerifiableCredentialAllFromShape(derivationService, matcher, { + fetch: session.fetch, + includeExpiredVc: false, + }), + getVerifiableCredentialAllFromShape(derivationService, matcher, { + fetch: session.fetch, + includeExpiredVc: false, + returnLegacyJsonld: false, + }), + query( derivationService, { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://schema.inrupt.com/credentials/v1.jsonld", - ], - type: ["VerifiableCredential"], - credentialSubject: { - id: vcSubject, - hasConsent: { - forPurpose: purpose, - }, + verifiableCredential: { + ...matcher, + "@context": concatenateContexts( + defaultContext, + matcher["@context"], + ), }, + } as unknown as VerifiablePresentationRequest, + { + fetch: session.fetch, }, + ), + query( + derivationService, + { + verifiableCredential: { + ...matcher, + "@context": concatenateContexts( + defaultContext, + matcher["@context"], + ), + }, + } as unknown as VerifiablePresentationRequest, { fetch: session.fetch, - includeExpiredVc: false, + returnLegacyJsonld: false, }, ), - ).resolves.toHaveLength(2); + ]); + + await Promise.all([ + expect( + isValidVerifiablePresentation( + verifierService, + verifiablePresentationLegacy, + { + fetch: session.fetch, + }, + ), + ).resolves.toMatchObject({ errors: [] }), + expect( + isValidVerifiablePresentation( + verifierService, + verifiablePresentation, + { + fetch: session.fetch, + }, + ), + ).resolves.toMatchObject({ errors: [] }), + ]); + + expect(allDeprecated).toHaveLength(2); await expect( + Promise.all( + allDeprecated.map((dep) => + isValidVc(dep, { verificationEndpoint: verifierService }), + ), + ), + ).resolves.toMatchObject([{ errors: [] }, { errors: [] }]); + + expect(allDeprecated[0].credentialSubject.id).toBe(vcSubject); + expect(getCredentialSubject(allDeprecated[0]).value).toBe(vcSubject); + expect(allDeprecated[1].credentialSubject.id).toBe(vcSubject); + expect(getCredentialSubject(allDeprecated[1]).value).toBe(vcSubject); + + expect(allNew).toHaveLength(2); + + // @ts-expect-error the credentialSubject property should not exist if legacy json is disabled + expect(allNew[0].credentialSubject).toBeUndefined(); + expect(getCredentialSubject(allNew[0]).value).toBe(vcSubject); + + // @ts-expect-error the credentialSubject property should not exist if legacy json is disabled + expect(allNew[1].credentialSubject).toBeUndefined(); + expect(getCredentialSubject(allNew[1]).value).toBe(vcSubject); + + const [queriedCredential1Legacy, queriedCredential1] = await Promise.all([ getVerifiableCredentialAllFromShape(derivationService, credential1, { fetch: session.fetch, }), - ).resolves.toHaveLength(1); + getVerifiableCredentialAllFromShape(derivationService, credential1, { + fetch: session.fetch, + returnLegacyJsonld: false, + }), + ]); - await expect( - getVerifiableCredentialAllFromShape(derivationService, credential2, { + expect(queriedCredential1Legacy).toHaveLength(1); + expect(queriedCredential1).toHaveLength(1); + + await Promise.all([ + expect( + getVerifiableCredentialAllFromShape(derivationService, credential2, { + fetch: session.fetch, + }), + ).resolves.toHaveLength(1), + expect( + getVerifiableCredentialAllFromShape(derivationService, credential1, { + fetch: session.fetch, + returnLegacyJsonld: false, + }), + ).resolves.toHaveLength(1), + expect( + getVerifiableCredentialAllFromShape(derivationService, credential2, { + fetch: session.fetch, + returnLegacyJsonld: false, + }), + ).resolves.toHaveLength(1), + ]); + + const [credential1FetchedLegacy, credential1Fetched] = await Promise.all([ + getVerifiableCredential(credential1.id, { fetch: session.fetch, }), - ).resolves.toHaveLength(1); + getVerifiableCredential(credential1.id, { + fetch: session.fetch, + returnLegacyJsonld: false, + }), + ]); + + // @ts-expect-error the credentialSubject property should not exist if legacy json is disabled + expect(credential1Fetched.credentialSubject).toBeUndefined(); + expect(getCredentialSubject(credential1Fetched).value).toBe(vcSubject); + expect(credential1FetchedLegacy.credentialSubject.id).toBe(vcSubject); + expect(getCredentialSubject(credential1FetchedLegacy).value).toBe( + vcSubject, + ); + + const creds = [ + credential1Fetched, + credential1FetchedLegacy, + queriedCredential1Legacy[0], + queriedCredential1[0], + ]; + await Promise.all( + creds.map((cred) => + expect( + isValidVc(cred, { + fetch: session.fetch, + verificationEndpoint: verifierService, + }), + ).resolves.toMatchObject({ errors: [] }), + ), + ); await Promise.all([ revokeVerifiableCredential(statusService, credential1.id, { @@ -237,7 +537,18 @@ describe("End-to-end verifiable credentials tests for environment", () => { fetch: session.fetch, }), ]); - }); + + await Promise.all( + creds.map((cred) => + expect( + isValidVc(cred, { + fetch: session.fetch, + verificationEndpoint: verifierService, + }), + ).resolves.toMatchObject(revokedObject), + ), + ); + }, 60_000); }); describe("revoke VCs", () => { diff --git a/jest.config.ts b/jest.config.ts index 9b2b8599..18c9066e 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -56,6 +56,7 @@ export default { roots: ["/e2e/node"], setupFiles: ["/e2e/node/jest.e2e.setup.ts"], slowTestThreshold: 30, + setupFilesAfterEnv: [], }, ], } as Config; diff --git a/jest.setup.ts b/jest.setup.ts index 39514e1d..51c79d5c 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -20,3 +20,6 @@ // import "@inrupt/jest-jsdom-polyfills"; +globalThis.fetch = () => { + throw new Error("Fetch should not be called in tests without being mocked"); +} diff --git a/package-lock.json b/package-lock.json index 50a8cad6..282d1f2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,13 @@ "license": "MIT", "dependencies": { "@inrupt/solid-client": "^1.25.2", - "@inrupt/universal-fetch": "^1.0.1" + "event-emitter-promisify": "^1.1.0", + "jsonld-context-parser": "^2.4.0", + "jsonld-streaming-parser": "^3.3.0", + "jsonld-streaming-serializer": "^2.1.0", + "md5": "^2.3.0", + "n3": "^1.17.0", + "rdf-namespaces": "^1.12.0" }, "devDependencies": { "@inrupt/eslint-config-lib": "^2.0.0", @@ -23,6 +29,8 @@ "@rushstack/eslint-patch": "^1.1.4", "@types/dotenv-flow": "^3.1.1", "@types/jest": "^29.2.2", + "@types/md5": "^2.3.4", + "@types/n3": "^1.16.0", "@types/node": "^20.1.2", "@types/rdfjs__dataset": "2.0.7", "dotenv-flow": "^3.2.0", @@ -30,6 +38,7 @@ "jest": "^29.3.0", "jest-environment-jsdom": "^29.3.0", "prettier": "^3.0.2", + "rdf-isomorphic": "^1.3.1", "rollup": "^3.1.0", "rollup-plugin-typescript2": "^0.35.0", "ts-jest": "^29.0.3", @@ -39,7 +48,7 @@ "typescript": "^5.0.4" }, "engines": { - "node": "^16.0.0 || ^18.0.0 || ^20.0.0" + "node": "^18.0.0 || ^20.0.0" }, "optionalDependencies": { "fsevents": "^2.3.2" @@ -1908,6 +1917,22 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/md5": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.5.tgz", + "integrity": "sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==", + "dev": true + }, + "node_modules/@types/n3": { + "version": "1.16.4", + "resolved": "https://registry.npmjs.org/@types/n3/-/n3-1.16.4.tgz", + "integrity": "sha512-6PmHRYCCdjbbBV2UVC/HjtL6/5Orx9ku2CQjuojucuHvNvPmnm6+02B18YGhHfvU25qmX2jPXyYPHsMNkn+w2w==", + "dev": true, + "dependencies": { + "@rdfjs/types": "^1.1.0", + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "20.10.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.3.tgz", @@ -2981,6 +3006,14 @@ "node": ">=10" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "engines": { + "node": "*" + } + }, "node_modules/chromium-bidi": { "version": "0.4.16", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.16.tgz", @@ -3212,6 +3245,14 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "engines": { + "node": "*" + } + }, "node_modules/crypto-js": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", @@ -4396,6 +4437,11 @@ "node": ">= 0.6" } }, + "node_modules/event-emitter-promisify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/event-emitter-promisify/-/event-emitter-promisify-1.1.0.tgz", + "integrity": "sha512-uyHG8gjwYGDlKoo0Txtx/u1HI1ubj0FK0rVqI4O0s1EymQm4iAEMbrS5B+XFlSaS8SZ3xzoKX+YHRZk8Nk/bXg==" + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -5167,6 +5213,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", @@ -5450,6 +5506,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -6562,13 +6623,12 @@ } }, "node_modules/jsonld-context-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/jsonld-context-parser/-/jsonld-context-parser-2.3.0.tgz", - "integrity": "sha512-c6w2GE57O26eWFjcPX6k6G86ootsIfpuVwhZKjCll0bVoDGBxr1P4OuU+yvgfnh1GJhAGErolfC7W1BklLjWMg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jsonld-context-parser/-/jsonld-context-parser-2.4.0.tgz", + "integrity": "sha512-ZYOfvh525SdPd9ReYY58dxB3E2RUEU4DJ6ZibO8AitcowPeBH4L5rCAitE2om5G1P+HMEgYEYEr4EZKbVN4tpA==", "dependencies": { "@types/http-link-header": "^1.0.1", "@types/node": "^18.0.0", - "canonicalize": "^1.0.1", "cross-fetch": "^3.0.6", "http-link-header": "^1.0.2", "relative-to-absolute-iri": "^1.0.5" @@ -6583,9 +6643,9 @@ "integrity": "sha512-xNbS75FxH6P4UXTPUJp/zNPq6/xsfdJKussCWNOnz4aULWIRwMgP1LgaB5RiBnMX1DPCYenuqGZfnIAx5mbFLA==" }, "node_modules/jsonld-streaming-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonld-streaming-parser/-/jsonld-streaming-parser-3.2.0.tgz", - "integrity": "sha512-lJR1SCT364PGpFrOQaY+ZQ7qDWqqiT3IMK+AvZ83fo0LvltFn8/UyXvIFc3RO7YcaEjLahAF0otCi8vOq21NtQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/jsonld-streaming-parser/-/jsonld-streaming-parser-3.3.0.tgz", + "integrity": "sha512-6aWiAsWGZioTB/vNQ3KenREz9ddEOliZoEETi+jLrlL7+vkgMeHjnxyFlGe4UOCU7SVUNPhz/lgLGZjnxgVYtA==", "dependencies": { "@bergos/jsonparse": "^1.4.0", "@rdfjs/types": "*", @@ -6594,11 +6654,23 @@ "buffer": "^6.0.3", "canonicalize": "^1.0.1", "http-link-header": "^1.0.2", - "jsonld-context-parser": "^2.3.0", + "jsonld-context-parser": "^2.4.0", "rdf-data-factory": "^1.1.0", "readable-stream": "^4.0.0" } }, + "node_modules/jsonld-streaming-serializer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/jsonld-streaming-serializer/-/jsonld-streaming-serializer-2.1.0.tgz", + "integrity": "sha512-COHdLoeMTnrqHMoFhN3PoAwqnrKrpPC7/ACb0WbELYvt+HSOIFN3v4IJP7fOtLNQ4GeaeYkvbeWJ7Jo4EjxMDw==", + "dependencies": { + "@rdfjs/types": "*", + "@types/readable-stream": "^2.3.13", + "buffer": "^6.0.3", + "jsonld-context-parser": "^2.0.0", + "readable-stream": "^4.0.0" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6720,6 +6792,16 @@ "node": ">= 12" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -6814,6 +6896,12 @@ "node": ">=6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -7912,6 +8000,18 @@ "@rdfjs/types": "*" } }, + "node_modules/rdf-isomorphic": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/rdf-isomorphic/-/rdf-isomorphic-1.3.1.tgz", + "integrity": "sha512-6uIhsXTVp2AtO6f41PdnRV5xZsa0zVZQDTBdn0br+DZuFf5M/YD+T6m8hKDUnALI6nFL/IujTMLgEs20MlNidQ==", + "dev": true, + "dependencies": { + "@rdfjs/types": "*", + "hash.js": "^1.1.7", + "rdf-string": "^1.6.0", + "rdf-terms": "^1.7.0" + } + }, "node_modules/rdf-js": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/rdf-js/-/rdf-js-4.0.2.tgz", @@ -7920,6 +8020,32 @@ "@rdfjs/types": "*" } }, + "node_modules/rdf-namespaces": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/rdf-namespaces/-/rdf-namespaces-1.12.0.tgz", + "integrity": "sha512-Fk48ltssXTmyXeoLqC0y85CEAhhWH+wvu7bkr9WxsKUyFDcKwWSHOK7CvRq3XRampy1qhSrOsIQ8U1gQDCh5MA==" + }, + "node_modules/rdf-string": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/rdf-string/-/rdf-string-1.6.3.tgz", + "integrity": "sha512-HIVwQ2gOqf+ObsCLSUAGFZMIl3rh9uGcRf1KbM85UDhKqP+hy6qj7Vz8FKt3GA54RiThqK3mNcr66dm1LP0+6g==", + "dev": true, + "dependencies": { + "@rdfjs/types": "*", + "rdf-data-factory": "^1.1.0" + } + }, + "node_modules/rdf-terms": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.11.0.tgz", + "integrity": "sha512-iKlVgnMopRKl9pHVNrQrax7PtZKRCT/uJIgYqvuw1VVQb88zDvurtDr1xp0rt7N9JtKtFwUXoIQoEsjyRo20qQ==", + "dev": true, + "dependencies": { + "@rdfjs/types": "*", + "rdf-data-factory": "^1.1.0", + "rdf-string": "^1.6.0" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", diff --git a/package.json b/package.json index e1bff03a..ad1596db 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,8 @@ "@rushstack/eslint-patch": "^1.1.4", "@types/dotenv-flow": "^3.1.1", "@types/jest": "^29.2.2", + "@types/md5": "^2.3.4", + "@types/n3": "^1.16.0", "@types/node": "^20.1.2", "@types/rdfjs__dataset": "2.0.7", "dotenv-flow": "^3.2.0", @@ -71,6 +73,7 @@ "jest": "^29.3.0", "jest-environment-jsdom": "^29.3.0", "prettier": "^3.0.2", + "rdf-isomorphic": "^1.3.1", "rollup": "^3.1.0", "rollup-plugin-typescript2": "^0.35.0", "ts-jest": "^29.0.3", @@ -84,12 +87,18 @@ }, "dependencies": { "@inrupt/solid-client": "^1.25.2", - "@inrupt/universal-fetch": "^1.0.1" + "event-emitter-promisify": "^1.1.0", + "jsonld-context-parser": "^2.4.0", + "jsonld-streaming-parser": "^3.3.0", + "jsonld-streaming-serializer": "^2.1.0", + "md5": "^2.3.0", + "n3": "^1.17.0", + "rdf-namespaces": "^1.12.0" }, "publishConfig": { "access": "public" }, "engines": { - "node": "^16.0.0 || ^18.0.0 || ^20.0.0" + "node": "^18.0.0 || ^20.0.0" } } diff --git a/src/common/common.mock.ts b/src/common/common.mock.ts index 4befe6f9..3ac3e40b 100644 --- a/src/common/common.mock.ts +++ b/src/common/common.mock.ts @@ -21,7 +21,7 @@ import type { Iri, - VerifiableCredential, + VerifiableCredentialBase, VerifiablePresentation, } from "./common"; import { defaultCredentialTypes } from "./common"; @@ -45,9 +45,15 @@ export type CredentialClaims = VerifiableClaims & { }; export const defaultVerifiableClaims: VerifiableClaims = { - "@context": { ex: "https://example.org/ns/" }, - id: "ex:someCredentialInstance", - type: [...defaultCredentialTypes, "ex:spaceDogCertificate"], + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://schema.inrupt.com/credentials/v1.jsonld", + ], + id: "https://example.org/ns/someCredentialInstance", + type: [ + ...defaultCredentialTypes, + "https://example.org/ns/spaceDogCertificate", + ], proofType: "Ed25519Signature2018", proofCreated: "2021-08-19T16:08:31Z", proofVerificationMethod: @@ -63,16 +69,19 @@ export const defaultCredentialClaims: CredentialClaims = { issuanceDate: "1960-08-19T16:08:31Z", subjectId: "https://some.webid.provider/strelka", subjectClaims: { - "ex:status": "https://example.org/ns/GoodDog", - "ex:passengerOf": "https://example.org/ns/Korabl-Sputnik2", + "https://example.org/ns/status": "https://example.org/ns/GoodDog", + "https://example.org/ns/passengerOf": + "https://example.org/ns/Korabl-Sputnik2", }, }; export const mockPartialCredential = ( claims?: Partial, + id?: string, ): Record => { return { - id: claims?.id, + "@context": claims?.["@context"], + id: id ?? claims?.id, type: claims?.type, issuer: claims?.issuer, issuanceDate: claims?.issuanceDate, @@ -90,21 +99,52 @@ export const mockPartialCredential = ( }; }; +export const mockPartialCredential2Proofs = ( + claims?: Partial, + id?: string, +): Record => { + return { + ...mockPartialCredential(claims, id), + proof: [ + mockPartialCredential(claims, id).proof, + mockPartialCredential(claims, id).proof, + ], + }; +}; + export const mockCredential = ( claims: CredentialClaims, -): VerifiableCredential => { - return mockPartialCredential(claims) as VerifiableCredential; +): VerifiableCredentialBase => { + return mockPartialCredential(claims) as VerifiableCredentialBase; }; -export const mockDefaultCredential = (): VerifiableCredential => { - return mockPartialCredential(defaultCredentialClaims) as VerifiableCredential; +export const mockDefaultCredential = ( + id?: string, +): VerifiableCredentialBase => { + return mockPartialCredential( + defaultCredentialClaims, + id, + ) as VerifiableCredentialBase; +}; + +export const mockDefaultCredential2Proofs = ( + id?: string, +): VerifiableCredentialBase => { + return mockPartialCredential2Proofs( + defaultCredentialClaims, + id, + ) as VerifiableCredentialBase; }; export const mockPartialPresentation = ( - credentials: VerifiableCredential[], + credentials?: VerifiableCredentialBase[], claims?: Partial, ): Record => { return { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://schema.inrupt.com/credentials/v1.jsonld", + ], id: claims?.id, type: claims?.type, verifiableCredential: credentials, @@ -119,10 +159,65 @@ export const mockPartialPresentation = ( }; export const mockDefaultPresentation = ( - vc: VerifiableCredential[] = [mockDefaultCredential()], + vc: VerifiableCredentialBase[] = [mockDefaultCredential()], ): VerifiablePresentation => { return mockPartialPresentation( vc, defaultVerifiableClaims, ) as VerifiablePresentation; }; + +export const mockAnonymousDefaultPresentation = ( + vc: VerifiableCredentialBase[] = [mockDefaultCredential()], +): VerifiablePresentation => { + return mockPartialPresentation(vc, { + ...defaultVerifiableClaims, + id: undefined, + }) as VerifiablePresentation; +}; + +export const mockAccessGrant = () => ({ + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: "VerifiablePresentation", + holder: "https://vc.inrupt.com", + verifiableCredential: [ + { + id: "https://example.org/ns/someCredentialInstance", + type: ["SolidAccessRequest", "VerifiableCredential"], + proof: { + type: "Ed25519Signature2020", + created: "2023-12-05T00:11:29.159Z", + domain: "solid", + proofPurpose: "assertionMethod", + proofValue: "z4pPdpe9iQyFm2opvCJeoiW61Kajx8LqZQUFYLd", + verificationMethod: "https://example.org/verificationMethod/keys/1", + }, + credentialStatus: { + id: "https://vc.inrupt.com/status/At3i#0", + type: "RevocationList2020Status", + revocationListCredential: "https://vc.inrupt.com/status/At3i", + revocationListIndex: "0", + }, + credentialSubject: { + id: "https://some.webid.provider/strelka", + hasConsent: { + mode: "Read", + forPersonalData: "https://example.org/another-resource", + forPurpose: "http://example.org/some/purpose/1701735088943", + hasStatus: "ConsentStatusRequested", + isConsentForDataSubject: "https://some.webid/resource-owner", + }, + }, + issuanceDate: "1960-08-19T16:08:31Z", + issuer: "https://some.vc.issuer/in-ussr", + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://schema.inrupt.com/credentials/v1.jsonld", + "https://w3id.org/security/data-integrity/v1", + "https://w3id.org/vc-revocation-list-2020/v1", + "https://w3id.org/vc/status-list/2021/v1", + "https://w3id.org/security/suites/ed25519-2020/v1", + ], + }, + ], +}); diff --git a/src/common/common.test.ts b/src/common/common.test.ts index 96e1ab74..ef902d15 100644 --- a/src/common/common.test.ts +++ b/src/common/common.test.ts @@ -18,39 +18,55 @@ // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // - -import { jest, describe, it, expect } from "@jest/globals"; -import { Response } from "@inrupt/universal-fetch"; -import type * as UniversalFetch from "@inrupt/universal-fetch"; +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { IJsonLdContext } from "jsonld-context-parser"; +import { DataFactory, Store } from "n3"; +import { isomorphic } from "rdf-isomorphic"; +import { jsonLdStringToStore } from "../parser/jsonld"; import type { VerifiableCredential } from "./common"; import { concatenateContexts, getVerifiableCredential, isVerifiableCredential, isVerifiablePresentation, + normalizeVc, + verifiableCredentialToDataset, } from "./common"; import { defaultCredentialClaims, - mockPartialCredential, + defaultVerifiableClaims, mockDefaultCredential, + mockDefaultCredential2Proofs, mockDefaultPresentation, + mockPartialCredential, mockPartialPresentation, - defaultVerifiableClaims, } from "./common.mock"; +import { cred, rdf } from "./constants"; +import isRdfjsVerifiableCredential from "./isRdfjsVerifiableCredential"; +import isRdfjsVerifiablePresentation from "./isRdfjsVerifiablePresentation"; + +const { namedNode, quad, blankNode } = DataFactory; + +const spiedFetch = jest.spyOn(globalThis, "fetch").mockImplementation(() => { + throw new Error("Unexpected fetch call"); +}); -jest.mock("@inrupt/universal-fetch", () => { - const fetchModule = jest.requireActual( - "@inrupt/universal-fetch", - ) as typeof UniversalFetch; - return { - ...fetchModule, - fetch: jest.fn<(typeof UniversalFetch)["fetch"]>(), - }; +describe("normalizeVc", () => { + it("returns the same object when normalization is impossible", () => { + const obj = {}; + expect(normalizeVc(obj)).toEqual(obj); + }); }); describe("isVerifiableCredential", () => { - it("returns true if all the expected fields are present in the credential", () => { + it("returns true if all the expected fields are present in the credential", async () => { expect(isVerifiableCredential(mockDefaultCredential())).toBe(true); + expect( + isRdfjsVerifiableCredential( + await verifiableCredentialToDataset(mockDefaultCredential()), + namedNode(mockDefaultCredential().id), + ), + ).toBe(true); }); describe("returns false if", () => { @@ -65,7 +81,36 @@ describe("isVerifiableCredential", () => { ["proofVerificationMethod"], ["proofPurpose"], ["proofValue"], - ])("is missing field %s", (entry) => { + ])("is missing field %s", async (entry) => { + if (entry !== "id") { + // eslint-disable-next-line jest/no-conditional-expect + expect( + isRdfjsVerifiableCredential( + await verifiableCredentialToDataset( + mockPartialCredential({ + ...defaultCredentialClaims, + [`${entry}`]: undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ), + namedNode(mockDefaultCredential().id), + ), + ).toBe(false); + } else { + // eslint-disable-next-line jest/no-conditional-expect + await expect( + verifiableCredentialToDataset( + mockPartialCredential({ + ...defaultCredentialClaims, + [`${entry}`]: undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ), + ).rejects.toThrow( + "Expected vc.id to be a string, found [undefined] of type [undefined]", + ); + } + expect( isVerifiableCredential( mockPartialCredential({ @@ -76,13 +121,19 @@ describe("isVerifiableCredential", () => { ).toBe(false); }); - it("misses a credential subject", () => { + it("misses a credential subject", async () => { const mockedCredential = mockDefaultCredential(); delete ( mockedCredential as { credentialSubject: undefined | Record; } ).credentialSubject; + expect( + isRdfjsVerifiableCredential( + await verifiableCredentialToDataset(mockedCredential), + namedNode(mockDefaultCredential().id), + ), + ).toBe(false); expect(isVerifiableCredential(mockedCredential)).toBe(false); }); @@ -96,7 +147,19 @@ describe("isVerifiableCredential", () => { expect(isVerifiableCredential(mockedCredential)).toBe(false); }); - it("has an unexpected date format for the issuance", () => { + it("has an unexpected date format for the issuance", async () => { + expect( + isRdfjsVerifiableCredential( + await verifiableCredentialToDataset( + mockPartialCredential({ + ...defaultCredentialClaims, + issuanceDate: "Not a date", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ), + namedNode(mockDefaultCredential().id), + ), + ).toBe(false); expect( isVerifiableCredential( mockPartialCredential({ @@ -107,7 +170,19 @@ describe("isVerifiableCredential", () => { ).toBe(false); }); - it("has an unexpected date format for the proof creation", () => { + it("has an unexpected date format for the proof creation", async () => { + expect( + isRdfjsVerifiableCredential( + await verifiableCredentialToDataset( + mockPartialCredential({ + ...defaultCredentialClaims, + proofCreated: "Not a date", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ), + namedNode(mockDefaultCredential().id), + ), + ).toBe(false); expect( isVerifiableCredential( mockPartialCredential({ @@ -122,80 +197,114 @@ describe("isVerifiableCredential", () => { describe("isVerifiablePresentation", () => { describe("returns true", () => { - it("has all the expected fields are present in the credential", () => { + it("has all the expected fields are present in the credential", async () => { expect(isVerifiablePresentation(mockDefaultPresentation())).toBe(true); + expect( + isRdfjsVerifiablePresentation( + await verifiableCredentialToDataset( + mockDefaultPresentation() as { id: string }, + ), + namedNode(mockDefaultPresentation().id!), + ), + ).toBe(true); }); - it("has no associated credentials", () => { + it("has no associated credentials", async () => { expect(isVerifiablePresentation(mockDefaultPresentation([]))).toBe(true); + expect( + isRdfjsVerifiablePresentation( + await verifiableCredentialToDataset( + mockDefaultPresentation([]) as { id: string }, + ), + namedNode(mockDefaultPresentation([]).id!), + ), + ).toBe(true); }); - it("has an URL shaped holder", () => { + it("has an URL shaped holder", async () => { const mockedPresentation = mockDefaultPresentation(); mockedPresentation.holder = "https://some.holder"; expect(isVerifiablePresentation(mockedPresentation)).toBe(true); + expect( + isRdfjsVerifiablePresentation( + await verifiableCredentialToDataset( + mockedPresentation as { id: string }, + ), + namedNode(mockedPresentation.id!), + ), + ).toBe(true); }); - it("is passed a correct VP with a single type", () => { - const vp = JSON.parse(`{ - "@context": [ - "https://www.w3.org/2018/credentials/v1" - ], - "holder": "https://vc.dev-next.inrupt.com", - "type": "VerifiablePresentation", - "verifiableCredential": [ - { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://w3id.org/security/suites/ed25519-2020/v1", - "https://w3id.org/vc-revocation-list-2020/v1", - "https://consent.pod.inrupt.com/credentials/v1" + it("is passed a correct VP with a single type", async () => { + const vp = { + "@context": ["https://www.w3.org/2018/credentials/v1"], + id: "https://example.org/ns/someCredentialInstance", + holder: "https://vc.dev-next.inrupt.com", + type: "VerifiablePresentation", + verifiableCredential: [ + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/security/suites/ed25519-2020/v1", + "https://w3id.org/vc-revocation-list-2020/v1", + "https://consent.pod.inrupt.com/credentials/v1", + ], + credentialStatus: { + id: "https://vc.dev-next.inrupt.com/status/niiL#0", + revocationListCredential: + "https://vc.dev-next.inrupt.com/status/niiL", + revocationListIndex: "0", + type: "RevocationList2020Status", + }, + credentialSubject: { + providedConsent: { + mode: ["http://www.w3.org/ns/auth/acl#Read"], + hasStatus: + "https://w3id.org/GConsent#ConsentStatusExplicitlyGiven", + forPersonalData: [ + "https://storage.dev-next.inrupt.com/8c6a313e-98ae-4eb2-9ab3-2df201d81a02/bookmarks/", ], - "credentialStatus": { - "id": "https://vc.dev-next.inrupt.com/status/niiL#0", - "revocationListCredential": "https://vc.dev-next.inrupt.com/status/niiL", - "revocationListIndex": "0", - "type": "RevocationList2020Status" - }, - "credentialSubject": { - "providedConsent": { - "mode": [ - "http://www.w3.org/ns/auth/acl#Read" - ], - "hasStatus": "https://w3id.org/GConsent#ConsentStatusExplicitlyGiven", - "forPersonalData": [ - "https://storage.dev-next.inrupt.com/8c6a313e-98ae-4eb2-9ab3-2df201d81a02/bookmarks/" - ], - "forPurpose": "https://example.org/someSpecificPurpose", - "isProvidedTo": "https://pod.inrupt.com/womenofsolid/profile/card#me" - }, - "id": "https://id.dev-next.inrupt.com/virginia", - "inbox": "https://pod.inrupt.com/womenofsolid/inbox/" - }, - "id": "https://vc.dev-next.inrupt.com/vc/9f0855b1-7494-4770-8c49-c3fe91d82f93", - "issuanceDate": "2021-10-20T07:29:20.062Z", - "issuer": "https://vc.dev-next.inrupt.com", - "proof": { - "created": "2021-10-20T07:31:08.898Z", - "domain": "solid", - "proofPurpose": "assertionMethod", - "proofValue": "Q4-x1J0RqnIYBsW-O4IPskIeN_SOyUqtO8nZQdHlvz-PTwAe-L5lv2QhQHdUZpel1pEfdnll1rRD0vBdJ_svBg", - "type": "Ed25519Signature2020", - "verificationMethod": "https://vc.dev-next.inrupt.com/key/3eb16a3d-d31e-4f6e-b1ca-581257c69412" - }, - "type": [ - "VerifiableCredential", - "SolidAccessGrant" - ] - } - ] - }`); + forPurpose: "https://example.org/someSpecificPurpose", + isProvidedTo: + "https://pod.inrupt.com/womenofsolid/profile/card#me", + }, + id: "https://id.dev-next.inrupt.com/virginia", + inbox: "https://pod.inrupt.com/womenofsolid/inbox/", + }, + id: "https://vc.dev-next.inrupt.com/vc/9f0855b1-7494-4770-8c49-c3fe91d82f93", + issuanceDate: "2021-10-20T07:29:20.062Z", + issuer: "https://vc.dev-next.inrupt.com", + proof: { + created: "2021-10-20T07:31:08.898Z", + domain: "solid", + proofPurpose: "assertionMethod", + proofValue: + "Q4-x1J0RqnIYBsW-O4IPskIeN_SOyUqtO8nZQdHlvz-PTwAe-L5lv2QhQHdUZpel1pEfdnll1rRD0vBdJ_svBg", + type: "Ed25519Signature2020", + verificationMethod: + "https://vc.dev-next.inrupt.com/key/3eb16a3d-d31e-4f6e-b1ca-581257c69412", + }, + type: ["VerifiableCredential", "SolidAccessGrant"], + }, + ], + }; expect(isVerifiablePresentation(vp)).toBe(true); + expect( + isRdfjsVerifiablePresentation( + await verifiableCredentialToDataset(vp), + namedNode(vp.id), + ), + ).toBe(true); }); }); describe("returns false if", () => { - it.each([["type"]])("is missing field %s", (entry) => { + it.each([["type"]])("is missing field %s", async (entry) => { + const vp = mockPartialPresentation([], { + ...defaultVerifiableClaims, + [`${entry}`]: undefined, + }); + expect( isVerifiablePresentation( mockPartialPresentation([], { @@ -204,19 +313,105 @@ describe("isVerifiablePresentation", () => { }), ), ).toBe(false); + expect( + isRdfjsVerifiablePresentation( + await verifiableCredentialToDataset(vp), + // @ts-expect-error id is of type unknown + namedNode(vp.id), + ), + ).toBe(false); }); - it("has a malformed credential", () => { + it("has a malformed credential that is not parsed into json-ld", async () => { const mockedPresentation = mockDefaultPresentation([ {} as VerifiableCredential, ]); + + const mockedPresentationAsDataset = await verifiableCredentialToDataset( + mockedPresentation as { id: string }, + ); + expect( + mockedPresentationAsDataset.match(null, cred.verifiableCredential, null) + .size, + ).toBe(0); expect(isVerifiablePresentation(mockedPresentation)).toBe(false); + expect( + isRdfjsVerifiablePresentation( + mockedPresentationAsDataset, + namedNode(mockedPresentation.id!), + ), + ).toBe(true); + + // Should return false when we artifically add a blank node to the dataset + expect( + isRdfjsVerifiablePresentation( + new Store([ + ...mockedPresentationAsDataset, + quad( + namedNode(mockedPresentation.id!), + cred.verifiableCredential, + blankNode(), + ), + ]), + namedNode(mockedPresentation.id!), + ), + ).toBe(false); + // Should return false when we artifically add a named node with invalid url to the dataset + expect( + isRdfjsVerifiablePresentation( + new Store([ + ...mockedPresentationAsDataset, + quad( + namedNode(mockedPresentation.id!), + cred.verifiableCredential, + namedNode("http://example.org/incomplete/vc"), + ), + ]), + namedNode(mockedPresentation.id!), + ), + ).toBe(false); }); - it("has a non-URL shaped holder", () => { + it("has a non-URL shaped holder", async () => { const mockedPresentation = mockDefaultPresentation(); mockedPresentation.holder = "some non-URL holder"; expect(isVerifiablePresentation(mockedPresentation)).toBe(false); + + const presentationAsDataset = await verifiableCredentialToDataset( + mockedPresentation as { id: string }, + ); + expect(presentationAsDataset.match(null, cred.holder, null).size).toBe(0); + expect( + isRdfjsVerifiablePresentation( + presentationAsDataset, + namedNode(mockedPresentation.id!), + ), + ).toBe(true); + + // Should return false when we artifically add a blank node to the dataset + expect( + isRdfjsVerifiablePresentation( + new Store([ + ...presentationAsDataset, + quad(namedNode(mockedPresentation.id!), cred.holder, blankNode()), + ]), + namedNode(mockedPresentation.id!), + ), + ).toBe(false); + // Should return false when we artifically add a named node with invalid url to the dataset + expect( + isRdfjsVerifiablePresentation( + new Store([ + ...presentationAsDataset, + quad( + namedNode(mockedPresentation.id!), + cred.holder, + namedNode("not a valid url"), + ), + ]), + namedNode(mockedPresentation.id!), + ), + ).toBe(false); }); }); }); @@ -260,91 +455,797 @@ describe("concatenateContexts", () => { }); describe("getVerifiableCredential", () => { - it("defaults to an unauthenticated fetch", async () => { - const mockedFetchModule = jest.requireMock( - "@inrupt/universal-fetch", - ) as jest.Mocked; - mockedFetchModule.fetch.mockResolvedValueOnce( - new Response(JSON.stringify(mockDefaultCredential())), - ); + describe("defaults to an unauthenticated fetch", () => { + let mockedFetch: jest.Spied; + + beforeEach(() => { + mockedFetch = jest.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify(mockDefaultCredential()), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + const redirectUrl = new URL("https://redirect.url"); + redirectUrl.searchParams.append( + "requestVcUrl", + encodeURI("https://example.org/ns/someCredentialInstance"), + ); + redirectUrl.searchParams.append( + "redirectUrl", + encodeURI("https://requestor.redirect.url"), + ); + }); + + it("returnLegacyJsonld: true", async () => { + await getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + ); + expect(mockedFetch).toHaveBeenCalledWith( + "https://example.org/ns/someCredentialInstance", + ); + }); + it("returnLegacyJsonld: false", async () => { + await getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + returnLegacyJsonld: false, + }, + ); + expect(mockedFetch).toHaveBeenCalledWith( + "https://example.org/ns/someCredentialInstance", + ); + }); + }); - const redirectUrl = new URL("https://redirect.url"); - redirectUrl.searchParams.append( - "requestVcUrl", - encodeURI("https://some.vc"), + describe("uses the provided fetch if any", () => { + let mockedFetch: jest.MockedFunction; + + beforeEach(() => { + mockedFetch = jest.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mockDefaultCredential()), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + }); + + it.each([[true], [false]])( + "returnLegacyJsonld: %s", + async (returnLegacyJsonld) => { + await getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ); + expect(mockedFetch).toHaveBeenCalledWith( + "https://example.org/ns/someCredentialInstance", + ); + }, ); - redirectUrl.searchParams.append( - "redirectUrl", - encodeURI("https://requestor.redirect.url"), + }); + + describe("throws if the VC ID cannot be dereferenced", () => { + let mockedFetch: jest.MockedFunction; + + beforeEach(() => { + mockedFetch = jest.fn().mockResolvedValueOnce( + new Response(undefined, { + status: 401, + statusText: "Unauthenticated", + }), + ); + }); + + it.each([[true], [false]])( + "returnLegacyJsonld: %s", + async (returnLegacyJsonld) => { + await expect( + getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ), + ).rejects.toThrow( + "Fetching the Verifiable Credential [https://example.org/ns/someCredentialInstance] failed: 401 Unauthenticated", + ); + }, ); + }); + + describe("throws if the dereferenced data is invalid JSON", () => { + let mockedFetch: jest.MockedFunction; + + beforeEach(() => { + mockedFetch = jest + .fn() + .mockResolvedValueOnce(new Response("Not JSON")); + }); - await getVerifiableCredential("https://some.vc"); - expect(mockedFetchModule.fetch).toHaveBeenCalledWith("https://some.vc"); + it.each([[true], [false]])( + "returnLegacyJsonld: %s", + async (returnLegacyJsonld) => { + await expect( + getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ), + ).rejects.toThrow( + "Parsing the Verifiable Credential [https://example.org/ns/someCredentialInstance] as JSON failed: SyntaxError:", + ); + }, + ); }); - it("uses the provided fetch if any", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValueOnce( - new Response(JSON.stringify(mockDefaultCredential())), - ); + describe("throws if the dereferenced data is not a VC", () => { + let mockedFetch: jest.MockedFunction; - await getVerifiableCredential("https://some.vc", { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + beforeEach(() => { + mockedFetch = jest + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ something: "but not a VC" })), + ); }); - expect(mockedFetch).toHaveBeenCalledWith("https://some.vc"); + + it.each([[true], [false]])( + "returnLegacyJsonld: %s", + async (returnLegacyJsonld) => { + await expect( + getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ), + ).rejects.toThrow( + returnLegacyJsonld + ? "The value received from [https://example.org/ns/someCredentialInstance] is not a Verifiable Credential" + : "Verifiable credential is not an object, or does not have an id", + ); + }, + ); }); - it("throws if the VC ID cannot be dereferenced", async () => { + // FIXME: Enable this when we add content type checks in the next major version + // see https://github.com/inrupt/solid-client-vc-js/pull/849#discussion_r1414124524 + it.skip("throws if the dereferenced data has an unsupported content type", async () => { const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() + .fn() .mockResolvedValueOnce( - new Response(undefined, { status: 401, statusText: "Unauthenticated" }), + new Response(JSON.stringify(mockDefaultCredential())), ); await expect( getVerifiableCredential("https://some.vc", { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, }), - ).rejects.toThrow(/https:\/\/some.vc.*401.*Unauthenticated/); + ).rejects.toThrow(/unsupported Content-Type/); }); - it("throws if the dereferenced data is invalid JSON", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValueOnce(new Response("Not JSON.")); + it.each([[true], [false]])( + "throws if the dereferenced data is emptyreturnLegacyJsonld: [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + const mockedFetch = jest.fn( + async () => + new Response(JSON.stringify({}), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); - await expect( - getVerifiableCredential("https://some.vc", { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], - }), - ).rejects.toThrow(/https:\/\/some.vc.*JSON/); + await expect( + getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ), + ).rejects.toThrow( + returnLegacyJsonld + ? "The value received from [https://example.org/ns/someCredentialInstance] is not a Verifiable Credential" + : "Verifiable credential is not an object, or does not have an id", + ); + }, + ); + + it.each([[true], [false]])( + "throws if the vc is a blank node: [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + const mockedFetch = jest.fn( + async () => + new Response( + JSON.stringify({ + "@type": + "https://www.w3.org/2018/credentials#VerifiableCredential", + }), + { + headers: new Headers([["content-type", "application/json"]]), + }, + ), + ); + + await expect( + getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + returnLegacyJsonld, + }), + ).rejects.toThrow( + returnLegacyJsonld + ? "The value received from [https://some.vc] is not a Verifiable Credential" + : "Verifiable credential is not an object, or does not have an id", + ); + }, + ); + + it.each([[true], [false]])( + "throws if the vc has a type that is a literal: [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + const mockedFetch = jest.fn( + async () => + new Response( + JSON.stringify({ + "@context": "https://www.w3.org/2018/credentials/v1", + "@id": "http://example.org/my/vc", + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type": [ + { + "@id": + "https://www.w3.org/2018/credentials#VerifiableCredential", + }, + "str", + ], + }), + { + headers: new Headers([["content-type", "application/json"]]), + }, + ), + ); + + await expect( + getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + returnLegacyJsonld, + }), + ).rejects.toThrow( + returnLegacyJsonld + ? "The value received from [https://some.vc] is not a Verifiable Credential" + : "Verifiable credential is not an object, or does not have an id", + ); + }, + ); + + it.each([[true], [false]])( + "throws if the dereferenced data has 2 vcs: [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + const mockedFetch = jest.fn( + async () => + new Response( + JSON.stringify([ + mockDefaultCredential(), + mockDefaultCredential("http://example.org/mockVC2"), + ]), + { + headers: new Headers([["content-type", "application/json"]]), + }, + ), + ); + + await expect( + getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + returnLegacyJsonld, + }), + ).rejects.toThrow( + returnLegacyJsonld + ? "The value received from [https://some.vc] is not a Verifiable Credential" + : "Verifiable credential is not an object, or does not have an id", + ); + }, + ); + + it.each([[true], [false]])( + "throws if the dereferenced data has 2 proofs: [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + const mockedFetch = jest.fn( + async () => + new Response(JSON.stringify(mockDefaultCredential2Proofs()), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + await expect( + getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + returnLegacyJsonld, + }), + ).rejects.toThrow( + "The value received from [https://some.vc] is not a Verifiable Credential", + ); + }, + ); + + it.each([[true], [false]])( + "throws if the date field is not a valid xsd:dateTime: [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + const mocked = mockDefaultCredential(); + mocked.issuanceDate = "http://example.org/not/a/date"; + + const mockedFetch = jest.fn( + async () => + new Response(JSON.stringify(mocked), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + await expect( + getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ), + ).rejects.toThrow( + "The value received from [https://example.org/ns/someCredentialInstance] is not a Verifiable Credential", + ); + }, + ); + + it.each([[true], [false]])( + "throws if the date field is a string: [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + const mocked = mockDefaultCredential(); + // @ts-expect-error issuanceDate is required on the VC type + delete mocked.issuanceDate; + mocked["https://www.w3.org/2018/credentials#issuanceDate"] = + "http://example.org/not/a/date"; + + const mockedFetch = jest.fn( + async () => + new Response(JSON.stringify(mocked), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + await expect( + getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ), + ).rejects.toThrow( + "The value received from [https://example.org/ns/someCredentialInstance] is not a Verifiable Credential", + ); + }, + ); + + it.each([[true], [false]])( + "throws if the date field is an IRI: [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + const mocked = mockDefaultCredential(); + // @ts-expect-error issuanceDate is required on the VC type + delete mocked.issuanceDate; + mocked["https://www.w3.org/2018/credentials#issuanceDate"] = { + "@id": "http://example.org/not/a/date", + }; + + const mockedFetch = jest.fn( + async () => + new Response(JSON.stringify(mocked), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + await expect( + getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ), + ).rejects.toThrow( + "The value received from [https://example.org/ns/someCredentialInstance] is not a Verifiable Credential", + ); + }, + ); + + it.each([[true], [false]])( + "throws if the issuer is a string: [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + const mocked = mockDefaultCredential(); + // @ts-expect-error issuer is of type string on the VC type + mocked.issuer = { "@value": "my string" }; + + const mockedFetch = jest.fn( + async () => + new Response(JSON.stringify(mocked), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + await expect( + getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ), + ).rejects.toThrow( + "The value received from [https://example.org/ns/someCredentialInstance] is not a Verifiable Credential", + ); + }, + ); + + it("should handle credential subjects with multiple objects", async () => { + const mocked = mockDefaultCredential(); + mocked.credentialSubject = { + ...mocked.credentialSubject, + "https://example.org/ns/passengerOf": [ + { "@id": "http://example.org/v1" }, + { "@id": "http://example.org/v2" }, + ], + "https://example.org/my/predicate/i": { + "https://example.org/my/predicate": "object", + }, + }; + + const mockedFetch = jest.fn( + async () => + new Response(JSON.stringify(mocked), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + const vc = await getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + }, + ); + const vcNoLegacy = await getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + returnLegacyJsonld: false, + }, + ); + + // Since we have dataset properties in vc it should match the result + // but won't equal + expect(vc).toMatchObject(mocked); + expect(vcNoLegacy).toMatchObject({ id: mocked.id }); + expect(vcNoLegacy).not.toMatchObject(mocked); + // However we DO NOT want these properties showing up when we stringify + // the VC + expect(JSON.parse(JSON.stringify(vc))).toEqual(mocked); + expect(JSON.parse(JSON.stringify(vcNoLegacy))).toEqual(mocked); }); - it("throws if the dereferenced data is not a VC", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValueOnce( - new Response(JSON.stringify({ something: "but not a VC" })), + it("should handle credential subjects with multiple objects and a custom context", async () => { + const mocked = mockDefaultCredential(); + mocked["@context"] = [ + ...(mocked["@context"] as (IJsonLdContext | string)[]), + { + ex: "http://example.org/", + }, + ]; + mocked.credentialSubject = { + ...mocked.credentialSubject, + "https://example.org/ns/passengerOf": [ + { "@id": "http://example.org/v1" }, + { "@id": "http://example.org/v2" }, + ], + "https://example.org/my/predicate/i": { + "https://example.org/my/predicate": "object", + }, + }; + + const mockedFetch = jest.fn( + async () => + new Response(JSON.stringify(mocked), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + const vc = await getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + }, + ); + + const vcNoLegacy = await getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + returnLegacyJsonld: false, + }, + ); + + // Since we have dataset properties in vc it should match the result + // but won't equal + expect(vc).toMatchObject(mocked); + expect(vcNoLegacy).toMatchObject({ id: mocked.id }); + expect(vcNoLegacy).not.toMatchObject(mocked); + // However we DO NOT want these properties showing up when we stringify + // the VC + expect(JSON.parse(JSON.stringify(vc))).toEqual(mocked); + expect(JSON.parse(JSON.stringify(vcNoLegacy))).toEqual(mocked); + }); + + it.each([[true], [false]])( + "throws if there are 2 proof values", + async (returnLegacyJsonld) => { + const mocked = mockDefaultCredential(); + // @ts-expect-error proofValue is a string not string[] in VC type + mocked.proof.proofValue = [mocked.proof.proofValue, "abc"]; + + const mockedFetch = jest.fn( + async () => + new Response(JSON.stringify(mocked), { + headers: new Headers([["content-type", "application/json"]]), + }), ); - await expect( - getVerifiableCredential("https://some.vc", { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + await expect( + getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ), + ).rejects.toThrow( + "The value received from [https://example.org/ns/someCredentialInstance] is not a Verifiable Credential", + ); + }, + ); + + it("returns the fetched VC and the redirect URL", async () => { + const mockedFetch = jest.fn( + async () => + new Response(JSON.stringify(mockDefaultCredential()), { + headers: new Headers([["content-type", "application/json"]]), + }), + ); + + const vc = await getVerifiableCredential("https://some.vc", { + fetch: mockedFetch, + }); + + const res = await jsonLdStringToStore( + JSON.stringify(mockDefaultCredential()), + ); + expect(vc).toMatchObject( + Object.assign(mockDefaultCredential(), { + size: 13, }), - ).rejects.toThrow(/https:\/\/some.vc.*Verifiable Credential/); + ); + + const meaninglessQuad = DataFactory.quad( + DataFactory.namedNode("http://example.org/a"), + DataFactory.namedNode("http://example.org/b"), + DataFactory.namedNode("http://example.org/c"), + ); + + const issuerQuad = DataFactory.quad( + DataFactory.namedNode("https://example.org/ns/someCredentialInstance"), + DataFactory.namedNode("https://www.w3.org/2018/credentials#issuer"), + DataFactory.namedNode("https://some.vc.issuer/in-ussr"), + ); + + expect(isomorphic([...vc], [...res])).toBe(true); + expect(isomorphic([...vc.match()], [...res])).toBe(true); + expect(() => vc.add(meaninglessQuad)).toThrow("Cannot mutate this dataset"); + expect(() => vc.delete(meaninglessQuad)).toThrow( + "Cannot mutate this dataset", + ); + expect(vc.has(meaninglessQuad)).toBe(false); + expect(vc.has(issuerQuad)).toBe(true); + expect(vc.size).toBe(13); + expect( + vc.match( + DataFactory.namedNode("https://example.org/ns/someCredentialInstance"), + ).size, + ).toBe(6); }); - it("returns the fetched VC and the redirect URL", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValueOnce( - new Response(JSON.stringify(mockDefaultCredential())), + describe("with non cached contexts", () => { + const mockCredential = { + ...mockDefaultCredential(), + "@context": [ + ...(mockDefaultCredential()["@context"] as string[]), + "http://example.org/my/sample/context", + ], + }; + let mockedFetch: jest.Mock; + + beforeEach(() => { + mockedFetch = jest.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mockCredential), { + headers: new Headers([["content-type", "application/json"]]), + }), ); + }); - const vc = await getVerifiableCredential("https://some.vc", { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + it.each([[true], [false]])( + "errors if the context contains an IRI that is not cached: [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + await expect( + getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ), + ).rejects.toThrow( + "Unexpected context requested [http://example.org/my/sample/context]", + ); + }, + ); + + it.each([[true], [false]])( + "resolves if allowContextFetching is enabled and the context can be fetched: [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + (fetch as jest.Mock).mockResolvedValueOnce( + new Response( + JSON.stringify({ + "@context": {}, + }), + { + headers: new Headers([["content-type", "application/ld+json"]]), + }, + ), + ); + + await expect( + getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + allowContextFetching: true, + returnLegacyJsonld, + }, + ), + ).resolves.toMatchObject( + returnLegacyJsonld + ? mockCredential + : { + id: "https://example.org/ns/someCredentialInstance", + }, + ); + }, + ); + + it("can apply normalization of the response before parsing and returning it", async () => { + (fetch as jest.Mock).mockResolvedValueOnce( + new Response( + JSON.stringify({ + "@context": {}, + }), + { + headers: new Headers([["content-type", "application/ld+json"]]), + }, + ), + ); + + const res = await getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + allowContextFetching: true, + normalize: (data) => ({ + ...data, + type: [...data.type, "http://example.org/my/custom/added/type"], + }), + }, + ); + + expect(res).toMatchObject({ + ...mockCredential, + type: [ + ...mockDefaultCredential().type, + "http://example.org/my/custom/added/type", + ], + }); + + expect( + res.has( + quad( + namedNode("https://example.org/ns/someCredentialInstance"), + rdf.type, + namedNode("http://example.org/my/custom/added/type"), + ), + ), + ).toBe(true); + }); + + it("resolves if allowContextFetching is enabled and the context can be fetched [returnLegacyJsonld: false]", async () => { + spiedFetch.mockResolvedValue( + new Response( + JSON.stringify({ + "@context": {}, + }), + { + headers: new Headers([["content-type", "application/ld+json"]]), + }, + ), + ); + + mockedFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + "@context": {}, + }), + { + headers: new Headers([["content-type", "application/ld+json"]]), + }, + ), + ); + + await expect( + getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + allowContextFetching: true, + returnLegacyJsonld: false, + }, + ), + ).resolves.toMatchObject({ + id: "https://example.org/ns/someCredentialInstance", + }); + + // Should use the authenticated fetch to get the credential + expect(mockedFetch).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledWith( + "https://example.org/ns/someCredentialInstance", + ); + + // Should use the unauthenticated fetch to fetch contexts + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + "http://example.org/my/sample/context", + expect.anything(), + ); }); - expect(vc).toStrictEqual(mockDefaultCredential()); + + it.each([[true], [false]])( + "resolves if the context is cached: [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + await expect( + getVerifiableCredential( + "https://example.org/ns/someCredentialInstance", + { + fetch: mockedFetch, + allowContextFetching: true, + returnLegacyJsonld, + contexts: { + "http://example.org/my/sample/context": { + "@context": {}, + }, + }, + }, + ), + ).resolves.toMatchObject( + returnLegacyJsonld + ? mockCredential + : { + id: "https://example.org/ns/someCredentialInstance", + }, + ); + }, + ); }); }); diff --git a/src/common/common.ts b/src/common/common.ts index c8e49120..d0635553 100644 --- a/src/common/common.ts +++ b/src/common/common.ts @@ -26,61 +26,103 @@ import type { UrlString } from "@inrupt/solid-client"; import { getIri, + getJsonLdParser, getSolidDataset, getThingAll, - getJsonLdParser, } from "@inrupt/solid-client"; -import { fetch as uniFetch } from "@inrupt/universal-fetch"; +import type { DatasetCore, Quad } from "@rdfjs/types"; +import { DataFactory } from "n3"; +import type { ParseOptions } from "../parser/jsonld"; +import { jsonLdToStore } from "../parser/jsonld"; +import isRdfjsVerifiableCredential from "./isRdfjsVerifiableCredential"; + +const { namedNode } = DataFactory; + +export type DatasetWithId = DatasetCore & { id: string }; export type Iri = string; /** * A JSON-LD document is a JSON document including an @context entry. The other * fields may contain any value. + * @deprecated Use RDFJS API instead */ export type JsonLd = { "@context": unknown; [property: string]: unknown; }; +/** + * @deprecated Use RDFJS API instead + */ type Proof = { + /** + * @deprecated Use RDFJS API instead + */ type: string; /** * ISO-8601 formatted date */ created: string; + /** + * @deprecated Use RDFJS API instead + */ verificationMethod: string; + /** + * @deprecated Use RDFJS API instead + */ proofPurpose: string; + /** + * @deprecated Use RDFJS API instead + */ proofValue: string; }; /** * A Verifiable Credential JSON-LD document, as specified by the W3C VC HTTP API. + * @deprecated Use RDFJS API instead */ -export type VerifiableCredential = JsonLd & { +export type VerifiableCredentialBase = JsonLd & { id: Iri; + /** + * @deprecated Use RDFJS API instead + */ type: Iri[]; + /** + * @deprecated Use RDFJS API instead + */ issuer: Iri; /** * ISO-8601 formatted date + * @deprecated Use RDFJS API instead */ issuanceDate: string; /** * Entity the credential makes claim about. + * @deprecated Use RDFJS API instead */ credentialSubject: { + /** + * @deprecated Use RDFJS API instead + */ id: Iri; /** * The claim set is open, as any RDF graph is suitable for a set of claims. + * @deprecated Use RDFJS API instead */ [property: string]: unknown; }; + /** + * @deprecated Use RDFJS API instead + */ proof: Proof; }; +export type VerifiableCredential = VerifiableCredentialBase & DatasetCore; + export type VerifiablePresentation = JsonLd & { id?: string; type: string | string[]; - verifiableCredential?: VerifiableCredential[]; + verifiableCredential?: VerifiableCredentialBase[]; holder?: string; proof?: Proof; }; @@ -152,49 +194,53 @@ export function normalizeVp(vpJson: T): T { * schema we expect. * @param data The JSON-LD payload * @returns true is the payload matches our expectation. + * @deprecated Use isRdfjsVerifiableCredential instead */ export function isVerifiableCredential( - data: unknown | VerifiableCredential, -): data is VerifiableCredential { + data: unknown | VerifiableCredentialBase, +): data is VerifiableCredentialBase { let dataIsVc = true; - dataIsVc = typeof (data as VerifiableCredential).id === "string"; - dataIsVc = dataIsVc && Array.isArray((data as VerifiableCredential).type); + dataIsVc = typeof (data as VerifiableCredentialBase).id === "string"; + dataIsVc = dataIsVc && Array.isArray((data as VerifiableCredentialBase).type); dataIsVc = - dataIsVc && typeof (data as VerifiableCredential).issuer === "string"; + dataIsVc && typeof (data as VerifiableCredentialBase).issuer === "string"; dataIsVc = - dataIsVc && typeof (data as VerifiableCredential).issuanceDate === "string"; + dataIsVc && + typeof (data as VerifiableCredentialBase).issuanceDate === "string"; dataIsVc = dataIsVc && - !Number.isNaN(Date.parse((data as VerifiableCredential).issuanceDate)); + !Number.isNaN(Date.parse((data as VerifiableCredentialBase).issuanceDate)); dataIsVc = dataIsVc && - typeof (data as VerifiableCredential).credentialSubject === "object"; + typeof (data as VerifiableCredentialBase).credentialSubject === "object"; dataIsVc = dataIsVc && - typeof (data as VerifiableCredential).credentialSubject.id === "string"; + typeof (data as VerifiableCredentialBase).credentialSubject.id === "string"; dataIsVc = - dataIsVc && typeof (data as VerifiableCredential).proof === "object"; + dataIsVc && typeof (data as VerifiableCredentialBase).proof === "object"; dataIsVc = dataIsVc && - typeof (data as VerifiableCredential).proof.created === "string"; + typeof (data as VerifiableCredentialBase).proof.created === "string"; dataIsVc = dataIsVc && - !Number.isNaN(Date.parse((data as VerifiableCredential).proof.created)); + !Number.isNaN(Date.parse((data as VerifiableCredentialBase).proof.created)); dataIsVc = dataIsVc && - typeof (data as VerifiableCredential).proof.proofPurpose === "string"; + typeof (data as VerifiableCredentialBase).proof.proofPurpose === "string"; dataIsVc = dataIsVc && - typeof (data as VerifiableCredential).proof.proofValue === "string"; + typeof (data as VerifiableCredentialBase).proof.proofValue === "string"; dataIsVc = - dataIsVc && typeof (data as VerifiableCredential).proof.type === "string"; + dataIsVc && + typeof (data as VerifiableCredentialBase).proof.type === "string"; dataIsVc = dataIsVc && - typeof (data as VerifiableCredential).proof.verificationMethod === "string"; + typeof (data as VerifiableCredentialBase).proof.verificationMethod === + "string"; return dataIsVc; } -function isUrl(url: string): boolean { +export function isUrl(url: string): boolean { try { // If url is not URL-shaped, this will throw. // eslint-disable-next-line no-new @@ -205,6 +251,9 @@ function isUrl(url: string): boolean { } } +/** + * @deprecated Use isRdfjsVerifiableCredential instead + */ export function isVerifiablePresentation( vp: unknown | VerifiablePresentation, ): vp is VerifiablePresentation { @@ -239,7 +288,7 @@ export function concatenateContexts(...contexts: unknown[]): unknown { contexts.forEach((additionalContext) => { // Case when the context is an array of IRIs and/or inline contexts if (Array.isArray(additionalContext)) { - additionalContext.forEach((context) => result.add(context)); + additionalContext.forEach((contextEntry) => result.add(contextEntry)); } else if (additionalContext !== null && additionalContext !== undefined) { // Case when the context is a single remote URI or a single inline context result.add(additionalContext); @@ -308,9 +357,9 @@ async function discoverLegacyEndpoints( }); // The dataset should have a single blank node subject of all its triples. - const wellKnownRootBlankNode = getThingAll(vcConfigData, { + const [wellKnownRootBlankNode] = getThingAll(vcConfigData, { acceptBlankNodes: true, - })[0]; + }); return { derivationService: @@ -396,43 +445,308 @@ export async function getVerifiableCredentialApiConfiguration( }; } +// eslint-disable-next-line camelcase +export function internal_applyDataset( + vc: T, + store: DatasetCore, + options?: ParseOptions & { + includeVcProperties?: boolean; + additionalProperties?: Record; + requireId?: boolean; + }, +): DatasetCore { + return Object.freeze({ + ...(options?.requireId !== false && { id: vc.id }), + ...(options?.includeVcProperties && vc), + ...options?.additionalProperties, + // Make this a DatasetCore without polluting the object with + // all of the properties present in the N3.Store + [Symbol.iterator]() { + return store[Symbol.iterator](); + }, + has(quad: Quad) { + return store.has(quad); + }, + match(...args: Parameters) { + return store.match(...args); + }, + add() { + throw new Error("Cannot mutate this dataset"); + }, + delete() { + throw new Error("Cannot mutate this dataset"); + }, + get size() { + return store.size; + }, + // For backwards compatibility the dataset properties + // SHOULD NOT be included when we JSON.stringify the object + toJSON() { + return vc; + }, + }); +} + +/** + * @hidden + */ +export async function verifiableCredentialToDataset( + vc: T, + options?: ParseOptions & { + includeVcProperties: true; + additionalProperties?: Record; + requireId?: true; + }, +): Promise; +export async function verifiableCredentialToDataset( + vc: T, + options?: ParseOptions & { + includeVcProperties?: boolean; + additionalProperties?: Record; + requireId?: true; + }, +): Promise; +export async function verifiableCredentialToDataset( + vc: T, + options: ParseOptions & { + includeVcProperties: true; + additionalProperties?: Record; + requireId: false; + }, +): Promise; +export async function verifiableCredentialToDataset( + vc: T, + options?: ParseOptions & { + includeVcProperties?: boolean; + additionalProperties?: Record; + requireId?: boolean; + }, +): Promise; +export async function verifiableCredentialToDataset( + vc: T, + options?: ParseOptions & { + includeVcProperties?: boolean; + additionalProperties?: Record; + requireId?: boolean; + }, +): Promise { + let store: DatasetCore; + try { + store = await jsonLdToStore(vc, options); + } catch (e) { + throw new Error( + `Parsing the Verifiable Credential as JSON-LD failed: ${e}`, + ); + } + + if (options?.requireId !== false && typeof vc.id !== "string") { + throw new Error( + `Expected vc.id to be a string, found [${ + vc.id + }] of type [${typeof vc.id}] on ${JSON.stringify(vc, null, 2)}`, + ); + } + + return internal_applyDataset(vc as { id: string }, store, options); +} + +export function hasId(vc: unknown): vc is { id: string } { + return ( + typeof vc === "object" && + vc !== null && + typeof (vc as { id: unknown }).id === "string" + ); +} + +/** + * @hidden + */ +// eslint-disable-next-line camelcase +export async function internal_getVerifiableCredentialFromResponse( + vcUrl: UrlString | undefined, + response: Response, + options: ParseOptions & { + returnLegacyJsonld: false; + skipValidation?: boolean; + }, +): Promise; +/** + * @deprecated Deprecated in favour of setting returnLegacyJsonld: false. This will be the default value in future + * versions of this library. + */ +export async function internal_getVerifiableCredentialFromResponse( + vcUrl: UrlString | undefined, + response: Response, + options?: ParseOptions & { + returnLegacyJsonld?: true; + skipValidation?: boolean; + normalize?: (object: VerifiableCredentialBase) => VerifiableCredentialBase; + }, +): Promise; +/** + * @deprecated Deprecated in favour of setting returnLegacyJsonld: false. This will be the default value in future + * versions of this library. + */ +export async function internal_getVerifiableCredentialFromResponse( + vcUrl: UrlString | undefined, + response: Response, + options?: ParseOptions & { + returnLegacyJsonld?: boolean; + skipValidation?: boolean; + noVerify?: boolean; + }, +): Promise; +export async function internal_getVerifiableCredentialFromResponse( + vcUrlInput: UrlString | undefined, + response: Response, + options?: ParseOptions & { + returnLegacyJsonld?: boolean; + skipValidation?: boolean; + normalize?: (object: VerifiableCredentialBase) => VerifiableCredentialBase; + }, +): Promise { + const returnLegacy = options?.returnLegacyJsonld !== false; + let vc: unknown | VerifiableCredentialBase; + let vcUrl = vcUrlInput; + try { + vc = await response.json(); + + if (typeof vcUrl !== "string") { + if (!isUnknownObject(vc) || !("id" in vc) || typeof vc.id !== "string") { + throw new Error("Cannot establish id of verifiable credential"); + } + vcUrl = vc.id; + } + + // If you're wondering why this is not inside the if (returnLegacy) condition outside this try/catch statement + // see https://github.com/inrupt/solid-client-vc-js/pull/849#discussion_r1405853022 + if (returnLegacy) { + vc = normalizeVc(vc); + } + } catch (e) { + throw new Error( + `Parsing the Verifiable Credential [${vcUrl}] as JSON failed: ${e}`, + ); + } + + if (returnLegacy) { + if (!options?.skipValidation && !isVerifiableCredential(vc)) { + throw new Error( + `The value received from [${vcUrl}] is not a Verifiable Credential`, + ); + } + if (options?.normalize) { + vc = options.normalize(vc as VerifiableCredentialBase); + } + return verifiableCredentialToDataset(vc as VerifiableCredentialBase, { + allowContextFetching: options?.allowContextFetching, + baseIRI: options?.baseIRI, + contexts: options?.contexts, + includeVcProperties: true, + }); + } + + if (!hasId(vc)) { + throw new Error( + "Verifiable credential is not an object, or does not have an id", + ); + } + const parsedVc = await verifiableCredentialToDataset(vc, { + allowContextFetching: options.allowContextFetching, + baseIRI: options.baseIRI, + contexts: options.contexts, + includeVcProperties: false, + }); + + if ( + !options.skipValidation && + !isRdfjsVerifiableCredential(parsedVc, namedNode(parsedVc.id)) + ) { + throw new Error( + `The value received from [${vcUrl}] is not a Verifiable Credential`, + ); + } + return parsedVc; +} + /** * Dereference a VC URL, and verify that the resulting content is valid. * * @param vcUrl The URL of the VC. * @param options Options to customize the function behavior. * - options.fetch: Specify a WHATWG-compatible authenticated fetch. + * - options.returnLegacyJsonld: Include the normalized JSON-LD in the response * @returns The dereferenced VC if valid. Throws otherwise. * @since 0.4.0 */ export async function getVerifiableCredential( vcUrl: UrlString, - options?: Partial<{ - fetch: typeof fetch; - }>, -): Promise { - const authFetch = options?.fetch ?? uniFetch; - return authFetch(vcUrl as string) - .then(async (response) => { - if (!response.ok) { - throw new Error( - `Fetching the Verifiable Credential [${vcUrl}] failed: ${response.status} ${response.statusText}`, - ); - } - try { - return normalizeVc(await response.json()); - } catch (e) { - throw new Error( - `Parsing the Verifiable Credential [${vcUrl}] as JSON failed: ${e}`, - ); - } - }) - .then((vc) => { - if (!isVerifiableCredential(vc)) { - throw new Error( - `The value received from [${vcUrl}] is not a Verifiable Credential`, - ); - } - return vc; - }); + options: ParseOptions & { + fetch?: typeof fetch; + skipValidation?: boolean; + returnLegacyJsonld: false; + normalize?: (object: VerifiableCredentialBase) => VerifiableCredentialBase; + }, +): Promise; +/** + * Dereference a VC URL, and verify that the resulting content is valid. + * + * @param vcUrl The URL of the VC. + * @param options Options to customize the function behavior. + * - options.fetch: Specify a WHATWG-compatible authenticated fetch. + * - options.returnLegacyJsonld: Include the normalized JSON-LD in the response + * @returns The dereferenced VC if valid. Throws otherwise. + * @since 0.4.0 + * @deprecated Deprecated in favour of setting returnLegacyJsonld: false. This will be the default value in future + * versions of this library. + */ +export async function getVerifiableCredential( + vcUrl: UrlString, + options?: ParseOptions & { + fetch?: typeof fetch; + skipValidation?: boolean; + returnLegacyJsonld?: true; + normalize?: (object: VerifiableCredentialBase) => VerifiableCredentialBase; + }, +): Promise; +/** + * Dereference a VC URL, and verify that the resulting content is valid. + * + * @param vcUrl The URL of the VC. + * @param options Options to customize the function behavior. + * - options.fetch: Specify a WHATWG-compatible authenticated fetch. + * - options.returnLegacyJsonld: Include the normalized JSON-LD in the response + * @returns The dereferenced VC if valid. Throws otherwise. + * @since 0.4.0 + * @deprecated Deprecated in favour of setting returnLegacyJsonld: false. This will be the default value in future + * versions of this library. + */ +export async function getVerifiableCredential( + vcUrl: UrlString, + options?: ParseOptions & { + fetch?: typeof fetch; + skipValidation?: boolean; + returnLegacyJsonld?: boolean; + normalize?: (object: VerifiableCredentialBase) => VerifiableCredentialBase; + }, +): Promise; +export async function getVerifiableCredential( + vcUrl: UrlString, + options?: ParseOptions & { + fetch?: typeof fetch; + skipValidation?: boolean; + returnLegacyJsonld?: boolean; + normalize?: (object: VerifiableCredentialBase) => VerifiableCredentialBase; + }, +): Promise { + const authFetch = options?.fetch ?? fetch; + const response = await authFetch(vcUrl); + + if (!response.ok) { + throw new Error( + `Fetching the Verifiable Credential [${vcUrl}] failed: ${response.status} ${response.statusText}`, + ); + } + + return internal_getVerifiableCredentialFromResponse(vcUrl, response, options); } diff --git a/src/common/configuration.test.ts b/src/common/configuration.test.ts index f50f607d..7d25c943 100644 --- a/src/common/configuration.test.ts +++ b/src/common/configuration.test.ts @@ -29,8 +29,6 @@ import { import type * as SolidClient from "@inrupt/solid-client"; import { getVerifiableCredentialApiConfiguration } from "./common"; -jest.mock("@inrupt/universal-fetch"); - jest.mock("@inrupt/solid-client", () => { const solidClientModule = jest.requireActual( "@inrupt/solid-client", diff --git a/src/common/constants.ts b/src/common/constants.ts new file mode 100644 index 00000000..e2846ad3 --- /dev/null +++ b/src/common/constants.ts @@ -0,0 +1,59 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +import { DataFactory } from "n3"; +import { rdf as _rdf } from "rdf-namespaces"; + +const { namedNode } = DataFactory; + +const SEC = "https://w3id.org/security#"; +const CRED = "https://www.w3.org/2018/credentials#"; +const XSD = "http://www.w3.org/2001/XMLSchema#"; +const DC = "http://purl.org/dc/terms/"; + +export const rdf = { + type: namedNode(_rdf.type), +}; + +export const xsd = { + boolean: namedNode(`${XSD}boolean`), + dateTime: namedNode(`${XSD}dateTime`), +}; + +export const cred = { + issuanceDate: namedNode(`${CRED}issuanceDate`), + expirationDate: namedNode(`${CRED}expirationDate`), + issuer: namedNode(`${CRED}issuer`), + credentialSubject: namedNode(`${CRED}credentialSubject`), + verifiableCredential: namedNode(`${CRED}verifiableCredential`), + holder: namedNode(`${CRED}holder`), + VerifiableCredential: namedNode(`${CRED}VerifiableCredential`), + VerifiablePresentation: namedNode(`${CRED}VerifiablePresentation`), +}; +export const sec = { + proof: namedNode(`${SEC}proof`), + proofPurpose: namedNode(`${SEC}proofPurpose`), + proofValue: namedNode(`${SEC}proofValue`), + verificationMethod: namedNode(`${SEC}verificationMethod`), +}; + +export const dc = { + created: namedNode(`${DC}created`), +}; diff --git a/src/common/getters.test.ts b/src/common/getters.test.ts new file mode 100644 index 00000000..5ee9b263 --- /dev/null +++ b/src/common/getters.test.ts @@ -0,0 +1,260 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import { beforeAll, describe, expect, it } from "@jest/globals"; +import { Store, DataFactory } from "n3"; +import type { VerifiableCredential, DatasetWithId } from "./common"; +import { verifiableCredentialToDataset } from "./common"; +import { cred, xsd } from "./constants"; +import { mockDefaultCredential } from "./common.mock"; +import { + getCredentialSubject, + getExpirationDate, + getId, + getIssuanceDate, + getIssuer, +} from "./getters"; + +const { quad, namedNode, blankNode, literal } = DataFactory; + +describe("getters", () => { + let defaultCredential: VerifiableCredential; + let defaultCredentialNoProperties: DatasetWithId; + + beforeAll(async () => { + defaultCredential = await verifiableCredentialToDataset( + mockDefaultCredential(), + { + includeVcProperties: true, + }, + ); + defaultCredentialNoProperties = await verifiableCredentialToDataset( + mockDefaultCredential(), + ); + }); + + it("getId", () => { + expect(getId(defaultCredential)).toBe(defaultCredential.id); + expect(getId(defaultCredentialNoProperties)).toBe(defaultCredential.id); + }); + + it("getIssuanceDate", () => { + expect(getIssuanceDate(defaultCredential)).toStrictEqual( + new Date(defaultCredential.issuanceDate), + ); + expect(getIssuanceDate(defaultCredentialNoProperties)).toStrictEqual( + new Date(defaultCredential.issuanceDate), + ); + }); + + it("getIssuanceDate errors if issuance date is not a literal", () => { + const results = Object.assign( + new Store([ + ...[...defaultCredential].filter( + (quadTerm) => !quadTerm.predicate.equals(cred.issuanceDate), + ), + quad( + namedNode(defaultCredential.id), + cred.issuanceDate, + namedNode("http://example.org/not/a/date"), + ), + ]), + { id: defaultCredential.id }, + ); + + expect(() => getIssuanceDate(results)).toThrow( + "Expected [http://example.org/not/a/date] to be a Literal. Found [NamedNode]", + ); + }); + + it("getIssuanceDate errors if issuance date is not of type dateTime", () => { + const results = Object.assign( + new Store([ + ...[...defaultCredential].filter( + (quadTerm) => !quadTerm.predicate.equals(cred.issuanceDate), + ), + quad( + namedNode(defaultCredential.id), + cred.issuanceDate, + literal("not a dateTime"), + ), + ]), + { id: defaultCredential.id }, + ); + + expect(() => getIssuanceDate(results)).toThrow( + "Expected date to be a dateTime; recieved [http://www.w3.org/2001/XMLSchema#string]", + ); + }); + + it("getIssuanceDate errors if issuance date is a dateTime but has an invalid value for date", () => { + const results = Object.assign( + new Store([ + ...[...defaultCredential].filter( + (quadTerm) => !quadTerm.predicate.equals(cred.issuanceDate), + ), + quad( + namedNode(defaultCredential.id), + cred.issuanceDate, + literal("not a dateTime", xsd.dateTime), + ), + ]), + { id: defaultCredential.id }, + ); + + expect(() => + getIssuanceDate(results as unknown as VerifiableCredential), + ).toThrow("Found invalid value for date: [not a dateTime]"); + }); + + describe("getExpirationDate", () => { + it("returns undefined if there is no expiration date", () => { + expect(getExpirationDate(defaultCredential)).toBeUndefined(); + expect(getExpirationDate(defaultCredentialNoProperties)).toBeUndefined(); + }); + + it("gets the access expiration date", async () => { + const expirationDate = new Date(Date.now()).toString(); + const credential = await verifiableCredentialToDataset({ + ...mockDefaultCredential(), + expirationDate, + }); + expect(getExpirationDate(credential)).toStrictEqual( + new Date(expirationDate), + ); + }); + + it("errors if the expiration date is a NamedNode", () => { + const store = Object.assign(new Store([...defaultCredential]), { + id: defaultCredential.id, + }); + + store.addQuad( + namedNode(getId(defaultCredential)), + cred.expirationDate, + namedNode("http://example.org/this/is/a/date"), + ); + + expect(() => getExpirationDate(store)).toThrow( + "Expected expiration date to be a Literal. Found [http://example.org/this/is/a/date] of type [NamedNode].", + ); + }); + + it("errors if there are multiple expiration dates", () => { + const store = Object.assign(new Store([...defaultCredential]), { + id: defaultCredential.id, + }); + + store.addQuad( + namedNode(getId(defaultCredential)), + cred.expirationDate, + literal(new Date(1700820377111).toString(), xsd.dateTime), + ); + + store.addQuad( + namedNode(getId(defaultCredential)), + cred.expirationDate, + literal(new Date(1700820300000).toString(), xsd.dateTime), + ); + + expect(() => getExpirationDate(store)).toThrow( + "Expected 0 or 1 expiration date. Found 2.", + ); + }); + + it("errors if the expiration date is a literal without xsd:type", async () => { + const store = Object.assign(new Store([...defaultCredential]), { + id: defaultCredential.id, + }); + + store.addQuad( + namedNode(getId(defaultCredential)), + cred.expirationDate, + literal("boo"), + ); + + expect(() => getExpirationDate(store)).toThrow( + "Expected date to be a dateTime; recieved [http://www.w3.org/2001/XMLSchema#string]", + ); + }); + }); + + it("getIssuer", () => { + expect(getIssuer(defaultCredential)).toStrictEqual( + defaultCredential.issuer, + ); + expect(getIssuer(defaultCredentialNoProperties)).toStrictEqual( + defaultCredential.issuer, + ); + }); + + it("getCredentialSubject", () => { + expect(getCredentialSubject(defaultCredential).value).toStrictEqual( + defaultCredential.credentialSubject.id, + ); + expect(getCredentialSubject(defaultCredential).termType).toBe("NamedNode"); + + expect( + getCredentialSubject(defaultCredentialNoProperties).value, + ).toStrictEqual(defaultCredential.credentialSubject.id); + expect(getCredentialSubject(defaultCredentialNoProperties).termType).toBe( + "NamedNode", + ); + }); + + it("getCredentialSubject errors if there are multiple credential subjects", () => { + const results = Object.assign( + new Store([ + ...defaultCredential, + quad( + namedNode(defaultCredential.id), + cred.credentialSubject, + namedNode("http://example.org/my/second/subject"), + ), + ]), + { id: defaultCredential.id }, + ); + + expect(() => + getCredentialSubject(results as unknown as VerifiableCredential), + ).toThrow("Expected exactly one result. Found 2."); + }); + + it("getCredentialSubject errors if object is a Blank Node", () => { + const results = Object.assign( + new Store([ + ...[...defaultCredential].filter( + (quadTerm) => !quadTerm.predicate.equals(cred.credentialSubject), + ), + quad( + namedNode(defaultCredential.id), + cred.credentialSubject, + blankNode(), + ), + ]), + { id: defaultCredential.id }, + ); + + expect(() => + getCredentialSubject(results as unknown as VerifiableCredential), + ).toThrow("Expected [n3-0] to be a NamedNode. Found [BlankNode]"); + }); +}); diff --git a/src/common/getters.ts b/src/common/getters.ts new file mode 100644 index 00000000..9976517c --- /dev/null +++ b/src/common/getters.ts @@ -0,0 +1,191 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +import type { BlankNode, DatasetCore, Literal, NamedNode } from "@rdfjs/types"; +import { DataFactory } from "n3"; +import { type DatasetWithId } from "./common"; +import { cred, dc, rdf, sec, xsd } from "./constants"; +import { getSingleObject, lenientSingle } from "./rdfjs"; + +const { namedNode, defaultGraph } = DataFactory; + +/** + * Get the ID (URL) of a Verifiable Credential. + * + * @example + * + * ``` + * const id = getId(vc); + * ``` + * + * @param vc The Verifiable Credential + * @returns The VC ID URL + */ +export function getId(vc: DatasetWithId): string { + return vc.id; +} + +/** + * Get the subject of a Verifiable Credential. + * + * @example + * + * ``` + * const subject = getCredentialSubject(vc); + * ``` + * + * @param vc The Verifiable Credential + * @returns The VC subject + */ +export function getCredentialSubject(vc: DatasetWithId): NamedNode { + return getSingleObject( + vc, + namedNode(getId(vc)), + cred.credentialSubject, + "NamedNode", + ); +} + +/** + * Get the issuer of a Verifiable Credential. + * + * @example + * + * ``` + * const issuer = getIssuer(vc); + * ``` + * + * @param vc The Verifiable Credential + * @returns The VC issuer + */ +export function getIssuer(vc: DatasetWithId): string { + return getSingleObject(vc, namedNode(getId(vc)), cred.issuer, "NamedNode") + .value; +} + +/** + * @internal + */ +function wrapDate(date: Literal) { + if ( + !date.datatype.equals( + namedNode("http://www.w3.org/2001/XMLSchema#dateTime"), + ) + ) { + throw new Error( + `Expected date to be a dateTime; recieved [${date.datatype.value}]`, + ); + } + if (Number.isNaN(Date.parse(date.value))) { + throw new Error(`Found invalid value for date: [${date.value}]`); + } + return new Date(date.value); +} + +/** + * Get the issuance date of a Verifiable Credential. + * + * @example + * + * ``` + * const date = getIssuanceDate(vc); + * ``` + * + * @param vc The Verifiable Credential + * @returns The issuance date + */ +export function getIssuanceDate(vc: DatasetWithId): Date { + return wrapDate( + getSingleObject(vc, namedNode(getId(vc)), cred.issuanceDate, "Literal"), + ); +} + +/** + * Get the expiration date of a Verifiable Credential. + * + * @example + * + * ``` + * const date = getExpirationDate(vc); + * ``` + * + * @param vc The Verifiable Credential + * @returns The expiration date, or undefined if none is found. + */ +export function getExpirationDate(vc: DatasetWithId): Date | undefined { + const res = [ + ...vc.match( + namedNode(getId(vc)), + cred.expirationDate, + undefined, + defaultGraph(), + ), + ]; + + if (res.length === 0) return undefined; + + if (res.length !== 1) + throw new Error(`Expected 0 or 1 expiration date. Found ${res.length}.`); + + if (res[0].object.termType !== "Literal") + throw new Error( + `Expected expiration date to be a Literal. Found [${res[0].object.value}] of type [${res[0].object.termType}].`, + ); + + return wrapDate(res[0].object); +} + +/** + * @internal + */ +export function isDate(literal?: Literal): boolean { + return ( + !!literal && + literal.datatype.equals(xsd.dateTime) && + !Number.isNaN(Date.parse(literal.value)) + ); +} + +export function isValidProof( + dataset: DatasetCore, + proof: NamedNode | BlankNode, +): boolean { + return ( + isDate( + lenientSingle(dataset.match(null, dc.created, null, proof), [ + "Literal", + ]), + ) && + lenientSingle(dataset.match(null, sec.proofValue, null, proof), [ + "Literal", + ]) !== undefined && + lenientSingle( + dataset.match(null, sec.proofPurpose, null, proof), + ["NamedNode"], + ) !== undefined && + lenientSingle( + dataset.match(null, sec.verificationMethod, null, proof), + ["NamedNode"], + ) !== undefined && + lenientSingle(dataset.match(null, rdf.type, null, proof), [ + "NamedNode", + ]) !== undefined + ); +} diff --git a/src/common/isRdfjsVerifiableCredential.ts b/src/common/isRdfjsVerifiableCredential.ts new file mode 100644 index 00000000..4e2dae68 --- /dev/null +++ b/src/common/isRdfjsVerifiableCredential.ts @@ -0,0 +1,63 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +import type { BlankNode, DatasetCore, Literal, NamedNode } from "@rdfjs/types"; +import { DataFactory } from "n3"; +import { cred, rdf, sec } from "./constants"; +import { lenientSingle } from "./rdfjs"; +import { isValidProof, isDate } from "./getters"; + +const { defaultGraph, quad } = DataFactory; + +/** + * Verifies that a given JSON-LD payload conforms to the Verifiable Credential + * schema we expect. + * @param data The JSON-LD payload as an RDFJS dataset + * @param id The id of the VerifiableCredential as a Named Node + * @returns true is the payload matches our expectation. + * @deprecated Use isRdfjsVerifiableCredential instead + */ +export default function isRdfjsVerifiableCredential( + dataset: DatasetCore, + id: NamedNode, +): boolean { + const proof = lenientSingle( + dataset.match(id, sec.proof, null, defaultGraph()), + ); + return ( + !!proof && + isValidProof(dataset, proof) && + !!lenientSingle( + dataset.match(id, cred.issuer, null, defaultGraph()), + ["NamedNode"], + ) && + isDate( + lenientSingle( + dataset.match(id, cred.issuanceDate, null, defaultGraph()), + ["Literal"], + ), + ) && + !!lenientSingle( + dataset.match(id, cred.credentialSubject, null, defaultGraph()), + ["NamedNode"], + ) && + dataset.has(quad(id, rdf.type, cred.VerifiableCredential, defaultGraph())) + ); +} diff --git a/src/common/isRdfjsVerifiablePresentation.ts b/src/common/isRdfjsVerifiablePresentation.ts new file mode 100644 index 00000000..75bdb492 --- /dev/null +++ b/src/common/isRdfjsVerifiablePresentation.ts @@ -0,0 +1,93 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +import type { BlankNode, DatasetCore, NamedNode } from "@rdfjs/types"; +import { DataFactory } from "n3"; +import { isUrl } from "./common"; +import { cred, rdf } from "./constants"; +import isRdfjsVerifiableCredential from "./isRdfjsVerifiableCredential"; + +const { defaultGraph } = DataFactory; + +export function getHolder( + dataset: DatasetCore, + id: NamedNode | BlankNode, +): string { + const holder = [...dataset.match(id, cred.holder, null, defaultGraph())]; + if ( + holder.length === 1 && + holder[0].object.termType === "NamedNode" && + isUrl(holder[0].object.value) + ) { + return holder[0].object.value; + } + + throw new Error("Could not find a valid holder"); +} + +export function getVpSubject(data: DatasetCore) { + const presentations = [ + ...data.match(null, rdf.type, cred.VerifiablePresentation, defaultGraph()), + ]; + if (presentations.length !== 1) { + throw new Error( + `Expected exactly one Verifiable Presentation. Found ${presentations.length}.`, + ); + } + + const { subject } = presentations[0]; + if (subject.termType !== "BlankNode" && subject.termType !== "NamedNode") { + throw new Error( + `Expected VP subject to be NamedNode or BlankNode. Instead found [${subject.value}] with termType [${subject.termType}]`, + ); + } + + return subject; +} + +export default function isRdfjsVerifiablePresentation( + dataset: DatasetCore, + id: NamedNode | BlankNode, +): boolean { + for (const { object } of dataset.match( + id, + cred.verifiableCredential, + null, + defaultGraph(), + )) { + if ( + object.termType !== "NamedNode" || + !isRdfjsVerifiableCredential(dataset, object) + ) { + return false; + } + } + + const holder = [...dataset.match(id, cred.holder, null, defaultGraph())]; + return ( + (holder.length === 0 || + (holder.length === 1 && + holder[0].object.termType === "NamedNode" && + isUrl(holder[0].object.value))) && + // dataset.has(quad(id, rdf.type, cred.VerifiablePresentation, defaultGraph())) + // FIXME: Replace with the above condition + dataset.match(id, rdf.type, null, defaultGraph()).size >= 1 + ); +} diff --git a/src/common/rdfjs.ts b/src/common/rdfjs.ts new file mode 100644 index 00000000..90d06600 --- /dev/null +++ b/src/common/rdfjs.ts @@ -0,0 +1,71 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +import type { DatasetCore, Term } from "@rdfjs/types"; +import { DataFactory } from "n3"; + +const { defaultGraph } = DataFactory; + +/** + * @internal + */ +export function getSingleObject( + vc: DatasetCore, + subject: Term, + predicate: Term, + type: Term["termType"], +): T; +export function getSingleObject( + vc: DatasetCore, + subject: Term, + predicate: Term, + type?: T["termType"], +): T { + const results = [...vc.match(subject, predicate, null, defaultGraph())]; + + if (results.length !== 1) { + throw new Error(`Expected exactly one result. Found ${results.length}.`); + } + + const [{ object }] = results; + const expectedTypes = [type]; + if (!expectedTypes.includes(object.termType)) { + throw new Error( + `Expected [${object.value}] to be a ${expectedTypes.join( + " or ", + )}. Found [${object.termType}]`, + ); + } + + return object as T; +} + +/** + * @internal + */ +export function lenientSingle( + dataset: DatasetCore, + termTypes: T["termType"][] = ["NamedNode", "BlankNode"], +): T | undefined { + const array = [...dataset]; + return array.length === 1 && termTypes.includes(array[0].object.termType) + ? (array[0].object as T) + : undefined; +} diff --git a/src/index.test.ts b/src/index.test.ts index d7732fa0..f105e97d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -26,11 +26,21 @@ import { isVerifiablePresentation, getVerifiableCredential, getVerifiableCredentialApiConfiguration, + verifiableCredentialToDataset, } from "./common/common"; +import { + getId, + getCredentialSubject, + getExpirationDate, + getIssuanceDate, + getIssuer, +} from "./common/getters"; import getVerifiableCredentialAllFromShape from "./lookup/derive"; import revokeVerifiableCredential from "./revoke/revoke"; import { isValidVc, isValidVerifiablePresentation } from "./verify/verify"; import { query } from "./lookup/query"; +import isRdfjsVerifiableCredential from "./common/isRdfjsVerifiableCredential"; +import isRdfjsVerifiablePresentation from "./common/isRdfjsVerifiablePresentation"; describe("exports", () => { it("includes all of the expected functions", () => { @@ -40,11 +50,19 @@ describe("exports", () => { "isVerifiablePresentation", "getVerifiableCredential", "getVerifiableCredentialApiConfiguration", + "verifiableCredentialToDataset", "getVerifiableCredentialAllFromShape", "query", "revokeVerifiableCredential", "isValidVc", "isValidVerifiablePresentation", + "getId", + "getIssuanceDate", + "getIssuer", + "getCredentialSubject", + "getExpirationDate", + "isRdfjsVerifiableCredential", + "isRdfjsVerifiablePresentation", ]); expect(packageExports.issueVerifiableCredential).toBe( issueVerifiableCredential, @@ -62,6 +80,9 @@ describe("exports", () => { expect(packageExports.getVerifiableCredentialAllFromShape).toBe( getVerifiableCredentialAllFromShape, ); + expect(packageExports.verifiableCredentialToDataset).toBe( + verifiableCredentialToDataset, + ); expect(packageExports.revokeVerifiableCredential).toBe( revokeVerifiableCredential, ); @@ -70,5 +91,16 @@ describe("exports", () => { isValidVerifiablePresentation, ); expect(packageExports.query).toBe(query); + expect(packageExports.getId).toBe(getId); + expect(packageExports.getIssuanceDate).toBe(getIssuanceDate); + expect(packageExports.getIssuer).toBe(getIssuer); + expect(packageExports.getExpirationDate).toBe(getExpirationDate); + expect(packageExports.getCredentialSubject).toBe(getCredentialSubject); + expect(packageExports.isRdfjsVerifiableCredential).toBe( + isRdfjsVerifiableCredential, + ); + expect(packageExports.isRdfjsVerifiablePresentation).toBe( + isRdfjsVerifiablePresentation, + ); }); }); diff --git a/src/index.ts b/src/index.ts index e98d64f9..55020408 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,12 +20,22 @@ // export { default as issueVerifiableCredential } from "./issue/issue"; -export type { Iri, JsonLd, VerifiableCredential } from "./common/common"; +export type { + Iri, + JsonLd, + VerifiableCredential, + VerifiableCredentialBase, +} from "./common/common"; +export type { DatasetWithId } from "./common/common"; export { isVerifiableCredential, isVerifiablePresentation, getVerifiableCredential, getVerifiableCredentialApiConfiguration, + /** + * @hidden @deprecated + */ + verifiableCredentialToDataset, } from "./common/common"; export { default as getVerifiableCredentialAllFromShape } from "./lookup/derive"; export { query } from "./lookup/query"; @@ -35,3 +45,12 @@ export type { } from "./lookup/query"; export { revokeVerifiableCredential } from "./revoke/revoke"; export { isValidVc, isValidVerifiablePresentation } from "./verify/verify"; +export { + getId, + getIssuanceDate, + getIssuer, + getCredentialSubject, + getExpirationDate, +} from "./common/getters"; +export { default as isRdfjsVerifiableCredential } from "./common/isRdfjsVerifiableCredential"; +export { default as isRdfjsVerifiablePresentation } from "./common/isRdfjsVerifiablePresentation"; diff --git a/src/issue/issue.test.ts b/src/issue/issue.test.ts index a7e8dda7..5b7c7c40 100644 --- a/src/issue/issue.test.ts +++ b/src/issue/issue.test.ts @@ -20,34 +20,22 @@ // import { jest, describe, it, expect } from "@jest/globals"; -import { Response } from "@inrupt/universal-fetch"; -import type * as UniversalFetch from "@inrupt/universal-fetch"; import { defaultContext, defaultCredentialTypes } from "../common/common"; import { mockDefaultCredential } from "../common/common.mock"; import defaultIssueVerifiableCredential, { issueVerifiableCredential, } from "./issue"; -jest.mock("@inrupt/universal-fetch", () => { - const fetchModule = jest.requireActual( - "@inrupt/universal-fetch", - ) as jest.Mocked; - return { - ...fetchModule, - fetch: jest.fn<(typeof UniversalFetch)["fetch"]>(), - }; -}); - describe("issueVerifiableCredential", () => { it("uses the provided fetch if any", async () => { - const mockedFetch = jest.fn() as (typeof UniversalFetch)["fetch"]; + const mockedFetch = jest.fn() as typeof fetch; try { await issueVerifiableCredential( "https://some.endpoint", { "@context": ["https://some.context"] }, { "@context": ["https://some.context"] }, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, }, ); // eslint-disable-next-line no-empty @@ -56,9 +44,9 @@ describe("issueVerifiableCredential", () => { }); it("defaults to an unauthenticated fetch if no fetch is provided", async () => { - const mockedFetchModule = jest.requireMock( - "@inrupt/universal-fetch", - ) as jest.Mocked; + const spiedFetch = jest + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response()); try { await issueVerifiableCredential( "https://some.endpoint", @@ -67,24 +55,22 @@ describe("issueVerifiableCredential", () => { ); // eslint-disable-next-line no-empty } catch (_e) {} - expect(mockedFetchModule.fetch).toHaveBeenCalled(); + expect(spiedFetch).toHaveBeenCalled(); }); it("throws if the issuer returns an error", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValueOnce( - new Response(undefined, { - status: 400, - statusText: "Bad request", - }), - ); + const mockedFetch = jest.fn().mockResolvedValueOnce( + new Response(undefined, { + status: 400, + statusText: "Bad request", + }), + ); await expect( issueVerifiableCredential( "https://some.endpoint", { "@context": ["https://some.context"] }, { "@context": ["https://some.context"] }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, + { fetch: mockedFetch }, ), ).rejects.toThrow( /https:\/\/some\.endpoint.*could not successfully issue a VC.*400.*Bad request/, @@ -92,47 +78,51 @@ describe("issueVerifiableCredential", () => { }); it("throws if the returned value does not conform to the shape we expect", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValueOnce( - new Response(JSON.stringify({ someField: "Not a credential" }), { - status: 201, - }), - ); + const mockedFetch = jest.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ someField: "Not a credential" }), { + status: 201, + }), + ); await expect( issueVerifiableCredential( "https://some.endpoint", { "@context": ["https://some.context"] }, { "@context": ["https://some.context"] }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, + { fetch: mockedFetch }, ), - ).rejects.toThrow("unexpected object: "); + ).rejects.toThrow( + "Parsing the Verifiable Credential [undefined] as JSON failed: Error: Cannot establish id of verifiable credential", + ); }); it("returns the VC issued by the target issuer", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValueOnce( - new Response(JSON.stringify(mockDefaultCredential()), { status: 201 }), - ); - await expect( - issueVerifiableCredential( - "https://some.endpoint", - { "@context": ["https://some.context"] }, - { "@context": ["https://some.context"] }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, - ), - ).resolves.toEqual(mockDefaultCredential()); + const mockedFetch = jest.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mockDefaultCredential()), { + status: 201, + headers: new Headers([["content-type", "application/ld+json"]]), + }), + ); + + const vc = await issueVerifiableCredential( + "https://some.endpoint", + { "@context": ["https://some.context"] }, + { "@context": ["https://some.context"] }, + { fetch: mockedFetch }, + ); + + expect(vc).toMatchObject({ ...mockDefaultCredential(), size: 13 }); + + expect(JSON.parse(JSON.stringify(vc))).toEqual(mockDefaultCredential()); }); it("sends a request to the specified issuer", async () => { - const mockedFetch = jest.fn<(typeof UniversalFetch)["fetch"]>(); + const mockedFetch = jest.fn(); try { await issueVerifiableCredential( "https://some.endpoint", { "@context": ["https://some.context"] }, { "@context": ["https://some.context"] }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, + { fetch: mockedFetch }, ); // eslint-disable-next-line no-empty } catch (_e) {} @@ -143,13 +133,13 @@ describe("issueVerifiableCredential", () => { }); it("sends a POST request with the appropriate headers", async () => { - const mockedFetch = jest.fn<(typeof UniversalFetch)["fetch"]>(); + const mockedFetch = jest.fn(); try { await issueVerifiableCredential( "https://some.endpoint", { "@context": ["https://some.context"] }, { "@context": ["https://some.context"] }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, + { fetch: mockedFetch }, ); // eslint-disable-next-line no-empty } catch (_e) {} @@ -165,13 +155,13 @@ describe("issueVerifiableCredential", () => { }); it("includes the subject and subject claims in the request body", async () => { - const mockedFetch = jest.fn<(typeof UniversalFetch)["fetch"]>(); + const mockedFetch = jest.fn(); try { await issueVerifiableCredential( "https://some.endpoint", { "@context": ["https://some-subject.context"], aClaim: "a value" }, undefined, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, + { fetch: mockedFetch }, ); // eslint-disable-next-line no-empty } catch (_e) {} @@ -198,7 +188,7 @@ describe("issueVerifiableCredential", () => { "https://some.endpoint", { "@context": ["https://some-subject.context"] }, { "@context": ["https://some-credential.context"], aClaim: "a value" }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, + { fetch: mockedFetch }, ); // eslint-disable-next-line no-empty } catch (_e) {} @@ -222,13 +212,13 @@ describe("issueVerifiableCredential", () => { }); it("includes the credential type in the request body", async () => { - const mockedFetch = jest.fn<(typeof UniversalFetch)["fetch"]>(); + const mockedFetch = jest.fn(); try { await issueVerifiableCredential( "https://some.endpoint", { "@context": ["https://some-subject.context"] }, { "@context": ["https://some-credential.context"], type: "some-type" }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, + { fetch: mockedFetch }, ); // eslint-disable-next-line no-empty } catch (_e) {} @@ -251,7 +241,7 @@ describe("issueVerifiableCredential", () => { }); it("supports credentials with multiple types", async () => { - const mockedFetch = jest.fn<(typeof UniversalFetch)["fetch"]>(); + const mockedFetch = jest.fn(); try { await issueVerifiableCredential( "https://some.endpoint", @@ -261,7 +251,7 @@ describe("issueVerifiableCredential", () => { type: ["some-type", "some-other-type"], }, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, }, ); // eslint-disable-next-line no-empty @@ -285,7 +275,7 @@ describe("issueVerifiableCredential", () => { }); it("handles inline contexts for the claims", async () => { - const mockedFetch = jest.fn<(typeof UniversalFetch)["fetch"]>(); + const mockedFetch = jest.fn(); try { await issueVerifiableCredential( "https://some.endpoint", @@ -298,7 +288,7 @@ describe("issueVerifiableCredential", () => { }, { "@context": ["https://some-credential.context"] }, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, }, ); // eslint-disable-next-line no-empty @@ -324,7 +314,7 @@ describe("issueVerifiableCredential", () => { }); it("doesn't include the subject ID when using the deprecated signature", async () => { - const mockedFetch = jest.fn<(typeof UniversalFetch)["fetch"]>(); + const mockedFetch = jest.fn(); try { await issueVerifiableCredential( "https://some.endpoint", @@ -332,7 +322,7 @@ describe("issueVerifiableCredential", () => { { "@context": ["https://some-subject.context"], aClaim: "a value" }, undefined, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, }, ); // eslint-disable-next-line no-empty @@ -355,14 +345,14 @@ describe("issueVerifiableCredential", () => { }); it("doesn't include the subject ID when using the deprecated default signature", async () => { - const mockedFetch = jest.fn<(typeof UniversalFetch)["fetch"]>(); + const mockedFetch = jest.fn(); try { await defaultIssueVerifiableCredential( "https://some.endpoint", "https://some.subject", { "@context": ["https://some-subject.context"], aClaim: "a value" }, undefined, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, + { fetch: mockedFetch }, ); // eslint-disable-next-line no-empty } catch (_e) {} @@ -384,7 +374,7 @@ describe("issueVerifiableCredential", () => { }); it("normalizes the issued VC", async () => { - const mockedVc = mockDefaultCredential(); + const mockedVc = mockDefaultCredential("http://example.org/my/sample/id"); // Force unexpected VC shapes to check normalization. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -396,6 +386,7 @@ describe("issueVerifiableCredential", () => { const mockedFetch = jest.fn().mockResolvedValueOnce( new Response(JSON.stringify(mockedVc), { status: 201, + headers: new Headers([["content-type", "application/json"]]), }), ); const resultVc = await issueVerifiableCredential( @@ -406,7 +397,7 @@ describe("issueVerifiableCredential", () => { type: ["some-type", "some-other-type"], }, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, }, ); expect(resultVc.proof.proofValue).toBe( diff --git a/src/issue/issue.ts b/src/issue/issue.ts index c8a1fd13..067f84df 100644 --- a/src/issue/issue.ts +++ b/src/issue/issue.ts @@ -23,34 +23,78 @@ * @module issue */ -import { fetch as fallbackFetch } from "@inrupt/universal-fetch"; - -import type { Iri, JsonLd, VerifiableCredential } from "../common/common"; +import type { + Iri, + JsonLd, + VerifiableCredential, + VerifiableCredentialBase, + DatasetWithId, +} from "../common/common"; import { - isVerifiableCredential, concatenateContexts, defaultContext, defaultCredentialTypes, - normalizeVc, + // eslint-disable-next-line camelcase + internal_getVerifiableCredentialFromResponse, } from "../common/common"; +import type { ParseOptions } from "../parser/jsonld"; type OptionsType = { - fetch?: typeof fallbackFetch; + fetch?: typeof fetch; + returnLegacyJsonld?: boolean; }; // The following extracts the logic of issueVerifiableCredential to separate it // from the deprecation logic. It can be merged back in issueVerifiableCredential // when the deprecated signature is removed altogether. // eslint-disable-next-line camelcase +async function internal_issueVerifiableCredential( + issuerEndpoint: Iri, + subjectClaims: JsonLd, + credentialClaims: JsonLd, + options: { + fetch?: typeof fetch; + returnLegacyJsonld: false; + } & ParseOptions, +): Promise; +/** + * @deprecated Deprecated in favour of setting returnLegacyJsonld: false. This will be the default value in future + * versions of this library. + */ async function internal_issueVerifiableCredential( issuerEndpoint: Iri, subjectClaims: JsonLd, credentialClaims?: JsonLd, - options?: OptionsType, -): Promise { + options?: { + fetch?: typeof fetch; + returnLegacyJsonld?: true; + } & ParseOptions, +): Promise; +/** + * @deprecated Deprecated in favour of setting returnLegacyJsonld: false. This will be the default value in future + * versions of this library. + */ +async function internal_issueVerifiableCredential( + issuerEndpoint: Iri, + subjectClaims: JsonLd, + credentialClaims?: JsonLd, + options?: { + fetch?: typeof fetch; + returnLegacyJsonld?: boolean; + } & ParseOptions, +): Promise; +async function internal_issueVerifiableCredential( + issuerEndpoint: Iri, + subjectClaims: JsonLd, + credentialClaims?: JsonLd, + options?: { + fetch?: typeof fetch; + returnLegacyJsonld?: boolean; + } & ParseOptions, +): Promise { const internalOptions = { ...options }; if (internalOptions.fetch === undefined) { - internalOptions.fetch = fallbackFetch; + internalOptions.fetch = fetch; } // credentialClaims should contain all the claims, but not the context. @@ -101,16 +145,11 @@ async function internal_issueVerifiableCredential( `The VC issuing endpoint [${issuerEndpoint}] could not successfully issue a VC: ${response.status} ${response.statusText}`, ); } - const jsonData = normalizeVc(await response.json()); - if (isVerifiableCredential(jsonData)) { - return jsonData; - } - throw new Error( - `The VC issuing endpoint [${issuerEndpoint}] returned an unexpected object: ${JSON.stringify( - jsonData, - null, - " ", - )}`, + + return internal_getVerifiableCredentialFromResponse( + undefined, + response, + options, ); } @@ -122,19 +161,82 @@ async function internal_issueVerifiableCredential( * @param subjectId The identifier of the VC claims' subject. * @param subjectClaims Claims about the subject that will be attested by the VC. * @param credentialClaims Claims about the credential itself, rather than its subject, e.g. credential type or expiration. - * @param options Optional parameter `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters). + * @param options + * - options.fetch: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters). * This can be typically used for authentication. Note that if it is omitted, and * `@inrupt/solid-client-authn-browser` is in your dependencies, the default session * is picked up. + * - options.returnLegacyJsonld: Include the normalized JSON-LD in the response * @returns the VC returned by the Issuer if the request is successful. Otherwise, an error is thrown. * @since 0.1.0 */ +export async function issueVerifiableCredential( + issuerEndpoint: Iri, + subjectClaims: JsonLd, + credentialClaims: JsonLd, + options: { + fetch?: typeof fetch; + returnLegacyJsonld: false; + }, +): Promise; +/** + * Request that a given Verifiable Credential (VC) Issuer issues a VC containing + * the provided claims. The VC Issuer is expected to implement the [W3C VC Issuer HTTP API](https://w3c-ccg.github.io/vc-api/issuer.html). + * + * @param issuerEndpoint The `/issue` endpoint of the VC Issuer. + * @param subjectId The identifier of the VC claims' subject. + * @param subjectClaims Claims about the subject that will be attested by the VC. + * @param credentialClaims Claims about the credential itself, rather than its subject, e.g. credential type or expiration. + * @param options + * - options.fetch: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters). + * This can be typically used for authentication. Note that if it is omitted, and + * `@inrupt/solid-client-authn-browser` is in your dependencies, the default session + * is picked up. + * - options.returnLegacyJsonld: Include the normalized JSON-LD in the response + * @returns the VC returned by the Issuer if the request is successful. Otherwise, an error is thrown. + * @since 0.1.0 + * @deprecated Deprecated in favour of setting returnLegacyJsonld: false. This will be the default value in future + * versions of this library. + */ export async function issueVerifiableCredential( issuerEndpoint: Iri, subjectClaims: JsonLd, credentialClaims?: JsonLd, - options?: OptionsType, + options?: { + fetch?: typeof fetch; + returnLegacyJsonld?: true; + normalize?: (object: VerifiableCredentialBase) => VerifiableCredentialBase; + }, ): Promise; +/** + * Request that a given Verifiable Credential (VC) Issuer issues a VC containing + * the provided claims. The VC Issuer is expected to implement the [W3C VC Issuer HTTP API](https://w3c-ccg.github.io/vc-api/issuer.html). + * + * @param issuerEndpoint The `/issue` endpoint of the VC Issuer. + * @param subjectId The identifier of the VC claims' subject. + * @param subjectClaims Claims about the subject that will be attested by the VC. + * @param credentialClaims Claims about the credential itself, rather than its subject, e.g. credential type or expiration. + * @param options + * - options.fetch: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters). + * This can be typically used for authentication. Note that if it is omitted, and + * `@inrupt/solid-client-authn-browser` is in your dependencies, the default session + * is picked up. + * - options.returnLegacyJsonld: Include the normalized JSON-LD in the response + * @returns the VC returned by the Issuer if the request is successful. Otherwise, an error is thrown. + * @since 0.1.0 + * @deprecated Deprecated in favour of setting returnLegacyJsonld: false. This will be the default value in future + * versions of this library. + */ +export async function issueVerifiableCredential( + issuerEndpoint: Iri, + subjectClaims: JsonLd, + credentialClaims?: JsonLd, + options?: { + fetch?: typeof fetch; + returnLegacyJsonld?: boolean; + normalize?: (object: VerifiableCredentialBase) => VerifiableCredentialBase; + }, +): Promise; /** * @deprecated Please remove the `subjectId` parameter */ @@ -143,8 +245,22 @@ export async function issueVerifiableCredential( subjectId: Iri, subjectClaims: JsonLd, credentialClaims?: JsonLd, - options?: OptionsType, + options?: { + fetch?: typeof fetch; + returnLegacyJsonld?: true; + normalize?: (object: VerifiableCredentialBase) => VerifiableCredentialBase; + }, ): Promise; +/** + * @deprecated Please remove the `subjectId` parameter + */ +export async function issueVerifiableCredential( + issuerEndpoint: Iri, + subjectId: Iri, + subjectClaims: JsonLd, + credentialClaims?: JsonLd, + options?: OptionsType, +): Promise; // The signature of the implementation here is a bit confusing, but it avoid // breaking changes until we remove the `subjectId` completely from the API export async function issueVerifiableCredential( @@ -153,7 +269,7 @@ export async function issueVerifiableCredential( subjectOrCredentialClaims: JsonLd | undefined, credentialClaimsOrOptions?: JsonLd | OptionsType, options?: OptionsType, -): Promise { +): Promise { if (typeof subjectIdOrClaims === "string") { // The function has been called with the deprecated signature, and the // subjectOrCredentialClaims parameter should be ignored. diff --git a/src/lookup/derive.test.ts b/src/lookup/derive.test.ts index 014efb99..5e7b478c 100644 --- a/src/lookup/derive.test.ts +++ b/src/lookup/derive.test.ts @@ -19,41 +19,86 @@ // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import { jest, describe, it, expect } from "@jest/globals"; -import { Response } from "@inrupt/universal-fetch"; -import type * as UniversalFetch from "@inrupt/universal-fetch"; -import { mockDefaultPresentation } from "../common/common.mock"; +import { beforeAll, describe, expect, it, jest } from "@jest/globals"; +import { + mockAccessGrant, + mockDefaultPresentation, +} from "../common/common.mock"; +import { getCredentialSubject } from "../common/getters"; import defaultGetVerifilableCredentialAllFromShape, { getVerifiableCredentialAllFromShape, } from "./derive"; import type * as QueryModule from "./query"; -jest.mock("@inrupt/universal-fetch", () => { - const fetchModule = jest.requireActual( - "@inrupt/universal-fetch", - ) as typeof UniversalFetch; - return { - ...fetchModule, - fetch: jest.fn<(typeof UniversalFetch)["fetch"]>(), - }; -}); +const mockDeriveEndpointDefaultResponse = (anoymous = false) => + new Response( + JSON.stringify(anoymous ? mockAccessGrant() : mockDefaultPresentation()), + { + status: 200, + statusText: "OK", + }, + ); + +describe.each([ + ["named response", mockDeriveEndpointDefaultResponse], + ["anonymous response", () => mockDeriveEndpointDefaultResponse(true)], +])("getVerifiableCredentialAllFromShape [%s]", (_, mockResponse) => { + let spiedFetch: jest.Spied; + + beforeAll(() => { + spiedFetch = jest.spyOn(globalThis, "fetch"); -const mockDeriveEndpointDefaultResponse = () => - new Response(JSON.stringify(mockDefaultPresentation()), { - status: 200, - statusText: "OK", + spiedFetch.mockImplementation(() => { + throw new Error("Unexpected fetch call"); + }); }); -describe("getVerifiableCredentialAllFromShape", () => { describe("legacy derive endpoint", () => { it("exposes a default export", async () => { expect(defaultGetVerifilableCredentialAllFromShape).toBe( getVerifiableCredentialAllFromShape, ); }); - it("uses the provided fetch if any", async () => { - const mockedFetch = jest.fn() as typeof fetch; - try { + + it.each([[true], [false]])( + "uses the provided fetch if any [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + const mockedFetch = jest.fn() as typeof fetch; + try { + await getVerifiableCredentialAllFromShape( + "https://some.endpoint", + { + "@context": ["https://some.context"], + credentialSubject: { id: "https://some.subject/" }, + }, + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ); + // eslint-disable-next-line no-empty + } catch (_e) {} + expect(mockedFetch).toHaveBeenCalled(); + }, + ); + + it.each([[true], [false]])( + "defaults to an unauthenticated fetch if no fetch is provided [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + spiedFetch.mockResolvedValue(mockResponse()); + await getVerifiableCredentialAllFromShape("https://some.endpoint", { + "@context": ["https://some.context"], + credentialSubject: { id: "https://some.subject/" }, + returnLegacyJsonld, + }); + expect(spiedFetch).toHaveBeenCalled(); + }, + ); + + it.each([[true], [false]])( + "includes the expired VC options if requested [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + const mockedFetch = jest.fn(async () => mockResponse()); await getVerifiableCredentialAllFromShape( "https://some.endpoint", { @@ -61,157 +106,170 @@ describe("getVerifiableCredentialAllFromShape", () => { credentialSubject: { id: "https://some.subject/" }, }, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + includeExpiredVc: true, + fetch: mockedFetch, + returnLegacyJsonld, }, ); - // eslint-disable-next-line no-empty - } catch (_e) {} - expect(mockedFetch).toHaveBeenCalled(); - }); + expect(mockedFetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + body: expect.stringContaining("ExpiredVerifiableCredential"), + }), + ); + }, + ); - it("defaults to an unauthenticated fetch if no fetch is provided", async () => { - const mockedFetch = jest.requireMock( - "@inrupt/universal-fetch", - ) as jest.Mocked; - mockedFetch.fetch.mockResolvedValue(mockDeriveEndpointDefaultResponse()); - await getVerifiableCredentialAllFromShape("https://some.endpoint", { - "@context": ["https://some.context"], - credentialSubject: { id: "https://some.subject/" }, - }); - expect(mockedFetch.fetch).toHaveBeenCalled(); - }); + it.each([[true], [false]])( + "builds a legacy VP request from the provided VC shape", + async (returnLegacyJsonld) => { + const mockedFetch = jest + .fn() + .mockResolvedValue(mockResponse()); + const queryModule = jest.requireActual("./query") as typeof QueryModule; + const spiedQuery = jest.spyOn(queryModule, "query"); + await getVerifiableCredentialAllFromShape( + "https://some.endpoint", + { + "@context": ["https://some.context"], + credentialSubject: { id: "https://some.subject/" }, + }, + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ); + expect(spiedQuery).toHaveBeenCalledWith( + "https://some.endpoint", + expect.objectContaining({ + verifiableCredential: { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://some.context", + ], + credentialSubject: { id: "https://some.subject/" }, + }, + }), + expect.anything(), + ); + }, + ); - it("includes the expired VC options if requested", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValue(mockDeriveEndpointDefaultResponse()); - await getVerifiableCredentialAllFromShape( + it("returns the VCs from the obtained VP on a successful response", async () => { + const vc = await getVerifiableCredentialAllFromShape( "https://some.endpoint", { "@context": ["https://some.context"], credentialSubject: { id: "https://some.subject/" }, }, - { - includeExpiredVc: true, - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], - }, + { fetch: async () => mockResponse() }, ); - expect(mockedFetch).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - body: expect.stringContaining("ExpiredVerifiableCredential"), - }), + + expect(vc).toMatchObject( + (await mockResponse().json()).verifiableCredential, + ); + + expect(JSON.parse(JSON.stringify(vc))).toEqual( + (await mockResponse().json()).verifiableCredential, ); }); - it("builds a legacy VP request from the provided VC shape", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValue(mockDeriveEndpointDefaultResponse()); - const queryModule = jest.requireActual("./query") as typeof QueryModule; - const spiedQuery = jest.spyOn(queryModule, "query"); - await getVerifiableCredentialAllFromShape( + it("returns the VCs from the obtained VP on a successful response [returnLegacyJsonld: false]", async () => { + const vc = await getVerifiableCredentialAllFromShape( "https://some.endpoint", { "@context": ["https://some.context"], - credentialSubject: { id: "https://some.subject/" }, + credentialSubject: { id: "https://some.webid.provider/strelka" }, + }, + { + fetch: async () => mockResponse(), + returnLegacyJsonld: false, }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, ); - expect(spiedQuery).toHaveBeenCalledWith( - "https://some.endpoint", - expect.objectContaining({ - verifiableCredential: { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://some.context", - ], - credentialSubject: { id: "https://some.subject/" }, - }, - }), - expect.anything(), + + expect(vc).toMatchObject( + mockDefaultPresentation().verifiableCredential!.map(() => ({ + id: "https://example.org/ns/someCredentialInstance", + })), ); - }); - it("returns the VCs from the obtained VP on a successful response", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValue(mockDeriveEndpointDefaultResponse()); - await expect( - getVerifiableCredentialAllFromShape( - "https://some.endpoint", - { - "@context": ["https://some.context"], - credentialSubject: { id: "https://some.subject/" }, - }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, + expect(vc.map((v) => getCredentialSubject(v).value)).toEqual( + mockDefaultPresentation().verifiableCredential?.map( + () => "https://some.webid.provider/strelka", ), - ).resolves.toEqual(mockDefaultPresentation().verifiableCredential); + ); + + expect(JSON.parse(JSON.stringify(vc))).toEqual( + (await mockResponse().json()).verifiableCredential, + ); }); - it("returns an empty array if the VP contains no VCs", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValue( - new Response( - JSON.stringify({ - ...mockDefaultPresentation(), - verifiableCredential: undefined, - }), + it.each([[true], [false]])( + "returns an empty array if the VP contains no VCs [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + await expect( + getVerifiableCredentialAllFromShape( + "https://some.endpoint", + { + "@context": ["https://some.context"], + credentialSubject: { id: "https://some.subject/" }, + }, { - status: 200, - statusText: "OK", + fetch: async () => + new Response( + JSON.stringify({ + ...mockDefaultPresentation(), + verifiableCredential: undefined, + }), + { + status: 200, + statusText: "OK", + }, + ), + returnLegacyJsonld, }, ), - ); - await expect( - getVerifiableCredentialAllFromShape( - "https://some.endpoint", - { - "@context": ["https://some.context"], - credentialSubject: { id: "https://some.subject/" }, - }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, - ), - ).resolves.toEqual([]); - }); + ).resolves.toEqual([]); + }, + ); }); describe("standard query endpoint", () => { - it("builds a standard VP request by example from the provided VC shape", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValue(mockDeriveEndpointDefaultResponse()); - const queryModule = jest.requireActual("./query") as typeof QueryModule; - const spiedQuery = jest.spyOn(queryModule, "query"); - const VC_SHAPE = { - "@context": ["https://some.context"], - credentialSubject: { id: "https://some.subject/" }, - }; - await getVerifiableCredentialAllFromShape( - "https://some.endpoint/query", - VC_SHAPE, - { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], - }, - ); + it.each([[true], [false]])( + "builds a standard VP request by example from the provided VC shape [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + const queryModule = jest.requireActual("./query") as typeof QueryModule; + const spiedQuery = jest.spyOn(queryModule, "query"); + const VC_SHAPE = { + "@context": ["https://some.context"], + credentialSubject: { id: "https://some.subject/" }, + }; + await getVerifiableCredentialAllFromShape( + "https://some.endpoint/query", + VC_SHAPE, + { + fetch: async () => mockResponse(), + returnLegacyJsonld, + }, + ); - expect(spiedQuery).toHaveBeenCalledWith( - "https://some.endpoint/query", - expect.objectContaining({ - query: [ - { - type: "QueryByExample", - credentialQuery: [ - { - example: VC_SHAPE, - }, - ], - }, - ], - }), - expect.anything(), - ); - }); + expect(spiedQuery).toHaveBeenCalledWith( + "https://some.endpoint/query", + expect.objectContaining({ + query: [ + { + type: "QueryByExample", + credentialQuery: [ + { + example: VC_SHAPE, + }, + ], + }, + ], + }), + expect.anything(), + ); + }, + ); }); }); diff --git a/src/lookup/derive.ts b/src/lookup/derive.ts index 78ea973a..90a308ec 100644 --- a/src/lookup/derive.ts +++ b/src/lookup/derive.ts @@ -19,8 +19,12 @@ // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import { fetch as fallbackFetch } from "@inrupt/universal-fetch"; -import type { Iri, VerifiableCredential } from "../common/common"; +import type { + Iri, + VerifiableCredential, + VerifiableCredentialBase, + DatasetWithId, +} from "../common/common"; import { concatenateContexts, defaultContext } from "../common/common"; import type { VerifiablePresentationRequest } from "./query"; import { query } from "./query"; @@ -36,7 +40,7 @@ const INCLUDE_EXPIRED_VC_OPTION = "ExpiredVerifiableCredential" as const; * @returns A legacy object expected by the /derive endpoint of the ESS 2.0 VC service */ function buildLegacyQuery( - vcShape: Partial, + vcShape: Partial, includeExpiredVc: boolean, ) { // credentialClaims should contain all the claims, but not the context. @@ -59,7 +63,7 @@ function buildLegacyQuery( * @returns A Query by Example VP Request based on the provided example. */ function buildQueryByExample( - vcShape: Partial, + vcShape: Partial, ): VerifiablePresentationRequest { return { query: [ @@ -89,22 +93,93 @@ function buildQueryByExample( * is picked up. * - `options.includeExpiredVc`: include expired VC matching the shape in the * result set. + * - `options.returnLegacyJsonld`: : Include the normalized JSON-LD in the response * @returns A list of VCs matching the given VC shape. The list may be empty if * the holder does not hold any matching VC. * @since 0.1.0 */ export async function getVerifiableCredentialAllFromShape( holderEndpoint: Iri, - vcShape: Partial, - options?: Partial<{ - fetch: typeof fallbackFetch; - includeExpiredVc: boolean; - }>, -): Promise { - const internalOptions = { ...options }; - if (internalOptions.fetch === undefined) { - internalOptions.fetch = fallbackFetch; - } + vcShape: Partial, + options: { + fetch?: typeof fetch; + includeExpiredVc?: boolean; + returnLegacyJsonld: false; + }, +): Promise; +/** + * Look up VCs from a given holder according to a subset of their claims, such as + * the VC type, or any property associated to the subject in the VC. The holder + * is expected to implement the [W3C VC Holder HTTP API](https://w3c-ccg.github.io/vc-api/holder.html). + * + * @param holderEndpoint The `/derive` endpoint of the holder. + * @param vcShape The subset of claims you expect the matching VCs to contain. + * @param options Optional parameter: + * - `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters). + * This can be typically used for authentication. Note that if it is omitted, and + * `@inrupt/solid-client-authn-browser` is in your dependencies, the default session + * is picked up. + * - `options.includeExpiredVc`: include expired VC matching the shape in the + * result set. + * - `options.returnLegacyJsonld`: : Include the normalized JSON-LD in the response + * @returns A list of VCs matching the given VC shape. The list may be empty if + * the holder does not hold any matching VC. + * @since 0.1.0 + * @deprecated Deprecated in favour of setting returnLegacyJsonld: false. This will be the default value in future + * versions of this library. + */ +export async function getVerifiableCredentialAllFromShape( + holderEndpoint: Iri, + vcShape: Partial, + options?: { + fetch?: typeof fetch; + includeExpiredVc?: boolean; + returnLegacyJsonld?: true; + normalize?: (vc: VerifiableCredentialBase) => VerifiableCredentialBase; + }, +): Promise; +/** + * Look up VCs from a given holder according to a subset of their claims, such as + * the VC type, or any property associated to the subject in the VC. The holder + * is expected to implement the [W3C VC Holder HTTP API](https://w3c-ccg.github.io/vc-api/holder.html). + * + * @param holderEndpoint The `/derive` endpoint of the holder. + * @param vcShape The subset of claims you expect the matching VCs to contain. + * @param options Optional parameter: + * - `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters). + * This can be typically used for authentication. Note that if it is omitted, and + * `@inrupt/solid-client-authn-browser` is in your dependencies, the default session + * is picked up. + * - `options.includeExpiredVc`: include expired VC matching the shape in the + * result set. + * - `options.returnLegacyJsonld`: : Include the normalized JSON-LD in the response + * @returns A list of VCs matching the given VC shape. The list may be empty if + * the holder does not hold any matching VC. + * @since 0.1.0 + * @deprecated Deprecated in favour of setting returnLegacyJsonld: false. This will be the default value in future + * versions of this library. + */ +export async function getVerifiableCredentialAllFromShape( + holderEndpoint: Iri, + vcShape: Partial, + options?: { + fetch?: typeof fetch; + includeExpiredVc?: boolean; + returnLegacyJsonld?: boolean; + normalize?: (vc: VerifiableCredentialBase) => VerifiableCredentialBase; + }, +): Promise; +export async function getVerifiableCredentialAllFromShape( + holderEndpoint: Iri, + vcShape: Partial, + options?: { + fetch?: typeof fetch; + includeExpiredVc?: boolean; + returnLegacyJsonld?: boolean; + normalize?: (vc: VerifiableCredentialBase) => VerifiableCredentialBase; + }, +): Promise { + const fetchFn = options?.fetch ?? fetch; // The request payload depends on the target endpoint. const vpRequest = holderEndpoint.endsWith("/query") ? // The target endpoint is spec-compliant, and uses a standard VP request. @@ -117,10 +192,13 @@ export async function getVerifiableCredentialAllFromShape( options?.includeExpiredVc ?? false, // The legacy proprietary format is casted as a VP request to be passed to the `query` function. ) as unknown as VerifiablePresentationRequest); + const vp = await query(holderEndpoint, vpRequest, { - fetch: options?.fetch ?? fallbackFetch, + fetch: fetchFn, + returnLegacyJsonld: options?.returnLegacyJsonld, + normalize: options?.normalize, }); - return vp.verifiableCredential ?? []; + return vp.verifiableCredential; } export default getVerifiableCredentialAllFromShape; diff --git a/src/lookup/query.test.ts b/src/lookup/query.test.ts index d61bade1..8b5ccaad 100644 --- a/src/lookup/query.test.ts +++ b/src/lookup/query.test.ts @@ -19,26 +19,20 @@ // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import { jest, it, describe, expect } from "@jest/globals"; -import { Response } from "@inrupt/universal-fetch"; -import type * as UniversalFetch from "@inrupt/universal-fetch"; -import type { QueryByExample } from "./query"; -import { query } from "./query"; +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { DataFactory } from "n3"; import { + defaultVerifiableClaims, + mockAccessGrant, mockDefaultCredential, mockDefaultPresentation, + mockPartialPresentation, } from "../common/common.mock"; +import { cred, rdf } from "../common/constants"; +import type { QueryByExample } from "./query"; +import { query } from "./query"; -jest.mock("@inrupt/universal-fetch", () => { - const fetchModule = jest.requireActual( - "@inrupt/universal-fetch", - ) as typeof UniversalFetch; - return { - ...fetchModule, - fetch: jest.fn<(typeof UniversalFetch)["fetch"]>(), - }; -}); - +const { namedNode } = DataFactory; const mockRequest: QueryByExample = { type: "QueryByExample", credentialQuery: [ @@ -52,126 +46,352 @@ const mockRequest: QueryByExample = { }; describe("query", () => { + let spiedFetch: jest.Spied; + + beforeEach(() => { + spiedFetch = jest.spyOn(globalThis, "fetch"); + + spiedFetch.mockImplementation(() => { + throw new Error("Unexpected fetch call"); + }); + }); + describe("by example", () => { - it("uses the provided fetch if any", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValueOnce( - new Response(JSON.stringify(mockDefaultPresentation()), { - status: 200, - }), + describe("uses the provided fetch if any", () => { + let mockedFetch: jest.MockedFunction; + + beforeEach(() => { + mockedFetch = jest.fn( + async () => + new Response(JSON.stringify(mockDefaultPresentation()), { + status: 200, + }), + ) as jest.MockedFunction; + }); + + it("returnLegacyJsonld: true", async () => { + await query( + "https://some.endpoint/query", + { query: [mockRequest] }, + { fetch: mockedFetch }, ); - await query( - "https://some.endpoint/query", - { query: [mockRequest] }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, - ); - expect(mockedFetch).toHaveBeenCalled(); + expect(mockedFetch).toHaveBeenCalled(); + }); + it("returnLegacyJsonld: false", async () => { + await query( + "https://some.endpoint/query", + { query: [mockRequest] }, + { + fetch: mockedFetch, + returnLegacyJsonld: false, + }, + ); + expect(mockedFetch).toHaveBeenCalled(); + }); }); - it("defaults to an unauthenticated fetch if no fetch is provided", async () => { - const mockedFetch = jest.requireMock( - "@inrupt/universal-fetch", - ) as jest.Mocked; - mockedFetch.fetch.mockResolvedValueOnce( - new Response(JSON.stringify(mockDefaultPresentation()), { - status: 200, - }), - ); - await query("https://some.endpoint/query", { query: [mockRequest] }); - expect(mockedFetch.fetch).toHaveBeenCalled(); + describe("resolves when the VP contains no VCs", () => { + let mockedFetch: jest.MockedFunction; + + beforeEach(() => { + mockedFetch = jest.fn( + async () => + new Response( + JSON.stringify( + mockPartialPresentation(undefined, defaultVerifiableClaims), + ), + { + status: 200, + }, + ), + ) as jest.MockedFunction; + }); + + it("returnLegacyJsonld: true", async () => { + await expect( + query( + "https://some.endpoint/query", + { query: [mockRequest] }, + { fetch: mockedFetch }, + ), + ).resolves.toMatchObject({ + id: "https://example.org/ns/someCredentialInstance", + }); + expect(mockedFetch).toHaveBeenCalled(); + }); + it("returnLegacyJsonld: false", async () => { + await expect( + query( + "https://some.endpoint/query", + { query: [mockRequest] }, + { + fetch: mockedFetch, + returnLegacyJsonld: false, + }, + ), + ).resolves.toMatchObject({ + id: "https://example.org/ns/someCredentialInstance", + }); + expect(mockedFetch).toHaveBeenCalled(); + }); }); - it("throws if the given endpoint returns an error", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValueOnce( - new Response(undefined, { - status: 404, + describe.each([ + ["null", [null]], + ["string", ["not a VC"]], + ["empty object", [{}]], + [ + "non empty object missing required VC properties", + [ + { + ...mockDefaultCredential(), + proof: undefined, + }, + ], + ], + ])( + "errors if the presentation contains invalid verifiable credentials [%s]", + (_, elems) => { + const mockedFetch = jest.fn( + async () => + // @ts-expect-error we are intentionall passing invalid VC types here to test for errors + new Response(JSON.stringify(mockDefaultPresentation(elems)), { + status: 200, + }), + ) as jest.MockedFunction; + + it.each([[true], [false]])( + "[returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + await expect( + query( + "https://some.endpoint/query", + { query: [mockRequest] }, + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ), + ).rejects.toThrow(); + }, + ); + }, + ); + + it.each([ + [{ returnLegacyJsonld: true }], + [{ returnLegacyJsonld: false }], + [undefined], + ])( + "defaults to an unauthenticated fetch if no fetch is provided [args: %s]", + async (arg?: { returnLegacyJsonld?: boolean }) => { + spiedFetch.mockResolvedValueOnce( + new Response(JSON.stringify(mockDefaultPresentation()), { + status: 200, }), ); - await expect(() => - query( - "https://example.org/query", + await query( + "https://some.endpoint/query", { query: [mockRequest] }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, - ), - ).rejects.toThrow(); - }); + arg, + ); + expect(spiedFetch).toHaveBeenCalled(); + }, + ); + + it.each([[true], [false]])( + "throws if the given endpoint returns an error [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + const mockedFetch = jest.fn( + async () => + new Response(undefined, { + status: 404, + }), + ); + await expect(() => + query( + "https://example.org/query", + { query: [mockRequest] }, + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ), + ).rejects.toThrow(); + }, + ); - it("throws if the endpoint responds with a non-JSON payload", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValueOnce( - new Response("Not JSON", { + it.each([[true], [false]])( + "throws if the endpoint responds with a non-JSON payload [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + const mockedFetch = jest.fn( + async () => + new Response("Not JSON", { + status: 200, + }), + ); + await expect(() => + query( + "https://example.org/query", + { query: [mockRequest] }, + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ), + ).rejects.toThrow(); + }, + ); + + it.each([[true], [false]])( + "throws if the endpoint responds with a non-VP payload [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + await expect(() => + query( + "https://example.org/query", + { query: [mockRequest] }, + { + fetch: async () => + new Response(JSON.stringify({ json: "but not a VP" }), { + status: 200, + }), + returnLegacyJsonld, + }, + ), + ).rejects.toThrow(); + }, + ); + + it.each([[true], [false]])( + "posts a request with the appropriate media type [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + const mockedFetch = jest.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mockDefaultPresentation()), { status: 200, }), ); - await expect(() => - query( - "https://example.org/query", + await query( + "https://some.endpoint/query", { query: [mockRequest] }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, - ), - ).rejects.toThrow(); - }); + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ); + expect(mockedFetch).toHaveBeenCalledWith( + "https://some.endpoint/query", + expect.objectContaining({ + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }), + ); + }, + ); - it("throws if the endpoint responds with a non-VP payload", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValueOnce( - new Response(JSON.stringify({ json: "but not a VP" }), { + it.each([[true], [false]])( + "errors if no presentations exist [returnLegacyJsonld: %s]", + async (returnLegacyJsonld) => { + const mockedFetch = jest.fn().mockResolvedValueOnce( + new Response(JSON.stringify([]), { status: 200, }), ); - await expect(() => - query( - "https://example.org/query", - { query: [mockRequest] }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, - ), - ).rejects.toThrow(); - }); + await expect( + query( + "https://some.endpoint/query", + { query: [mockRequest] }, + { + fetch: mockedFetch, + returnLegacyJsonld, + }, + ), + ).rejects.toThrow(); + expect(mockedFetch).toHaveBeenCalledWith( + "https://some.endpoint/query", + expect.objectContaining({ + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }), + ); + }, + ); - it("posts a request with the appropriate media type", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValueOnce( + it("returns the VP sent by the endpoint", async () => { + const mockedFetch = jest.fn( + async () => new Response(JSON.stringify(mockDefaultPresentation()), { status: 200, }), - ); - await query( - "https://some.endpoint/query", + ); + + const vp = await query( + "https://example.org/query", { query: [mockRequest] }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, + { fetch: mockedFetch }, ); - expect(mockedFetch).toHaveBeenCalledWith( - "https://some.endpoint/query", - expect.objectContaining({ - headers: { - "Content-Type": "application/json", - }, - method: "POST", - }), + const vpNoLegacy = await query( + "https://example.org/query", + { query: [mockRequest] }, + { + fetch: mockedFetch, + returnLegacyJsonld: false, + }, + ); + expect(vp).toMatchObject(mockDefaultPresentation()); + expect(JSON.parse(JSON.stringify(vp))).toEqual(mockDefaultPresentation()); + expect(JSON.parse(JSON.stringify(vpNoLegacy))).toEqual( + mockDefaultPresentation(), ); }); - it("returns the VP sent by the endpoint", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValueOnce( - new Response(JSON.stringify(mockDefaultPresentation()), { + it("returns the VP sent by the endpoint [using mock access grant]", async () => { + const mockedFetch = jest.fn( + async () => + new Response(JSON.stringify(mockAccessGrant()), { status: 200, }), - ); - await expect( - query( - "https://example.org/query", - { query: [mockRequest] }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, - ), - ).resolves.toStrictEqual(mockDefaultPresentation()); + ); + + const vp = await query( + "https://example.org/query", + { query: [mockRequest] }, + { fetch: mockedFetch }, + ); + const vpNoLegacy = await query( + "https://example.org/query", + { query: [mockRequest] }, + { + fetch: mockedFetch, + returnLegacyJsonld: false, + }, + ); + expect(vp).toMatchObject(mockAccessGrant()); + expect(JSON.parse(JSON.stringify(vp))).toEqual(mockAccessGrant()); + expect(JSON.parse(JSON.stringify(vpNoLegacy))).toEqual(mockAccessGrant()); + + expect(vp.holder).toBe("https://vc.inrupt.com"); + expect(vp.type).toBe("VerifiablePresentation"); + // @ts-expect-error the `holder` property does not exist in the newer version of the API + expect(vpNoLegacy.holder).toBeUndefined(); + // @ts-expect-error the `type` property does not exist in the newer version of the API + expect(vpNoLegacy.type).toBeUndefined(); + + expect( + vp.match( + null, + rdf.type, + namedNode( + "https://www.w3.org/2018/credentials#VerifiablePresentation", + ), + ).size, + ).toBe(1); + expect( + vp.match(null, cred.holder, namedNode("https://vc.inrupt.com")).size, + ).toBe(1); }); it("normalizes the VP sent by the endpoint", async () => { @@ -184,17 +404,15 @@ describe("query", () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore delete mockedVc.proof.proofValue; - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValueOnce( - new Response(JSON.stringify(mockDefaultPresentation([mockedVc])), { - status: 200, - }), - ); + const mockedFetch = jest.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mockDefaultPresentation([mockedVc])), { + status: 200, + }), + ); const resultVp = await query( "https://example.org/query", { query: [mockRequest] }, - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, + { fetch: mockedFetch }, ); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion expect(resultVp.verifiableCredential![0].proof.proofValue).toBe( @@ -209,5 +427,39 @@ describe("query", () => { ], ).toBeUndefined(); }); + + it("applies additional normalisation to the vc's according to the normalize function", async () => { + const mockedVc = mockDefaultCredential(); + // Force unexpected VC shapes to check normalization. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + mockedVc.proof["https://w3id.org/security#proofValue"] = + mockedVc.proof.proofValue; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete mockedVc.proof.proofValue; + const mockedFetch = jest.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mockDefaultPresentation([mockedVc])), { + status: 200, + }), + ); + const resultVp = await query( + "https://example.org/query", + { query: [mockRequest] }, + { + fetch: mockedFetch, + normalize(vc) { + return { + ...vc, + type: [...vc.type, "http://example.org/my/extra/type"], + }; + }, + }, + ); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(resultVp.verifiableCredential![0].type).toContain( + "http://example.org/my/extra/type", + ); + }); }); }); diff --git a/src/lookup/query.ts b/src/lookup/query.ts index 7d225be1..db909632 100644 --- a/src/lookup/query.ts +++ b/src/lookup/query.ts @@ -19,13 +19,27 @@ // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import { fetch as fallbackFetch } from "@inrupt/universal-fetch"; +import type { DatasetCore } from "@rdfjs/types"; +import { DataFactory } from "n3"; import type { + DatasetWithId, Iri, VerifiableCredential, + VerifiableCredentialBase, VerifiablePresentation, } from "../common/common"; -import { isVerifiablePresentation, normalizeVp } from "../common/common"; +import { + isVerifiablePresentation, + normalizeVp, + verifiableCredentialToDataset, +} from "../common/common"; +import isRdfjsVerifiableCredential from "../common/isRdfjsVerifiableCredential"; +import isRdfjsVerifiablePresentation, { + getVpSubject, +} from "../common/isRdfjsVerifiablePresentation"; +import { type ParseOptions } from "../parser/jsonld"; + +const { namedNode } = DataFactory; /** * Based on https://w3c-ccg.github.io/vp-request-spec/#query-by-example. @@ -35,7 +49,7 @@ export type QueryByExample = { credentialQuery: { required?: boolean; reason?: string; - example: Partial & { + example: Partial & { credentialSchema?: { id: string; type: string; @@ -61,6 +75,19 @@ export type VerifiablePresentationRequest = { domain?: string; }; +/** + * @hidden + */ +export interface ParsedVerifiablePresentation + extends VerifiablePresentation, + DatasetCore { + verifiableCredential: VerifiableCredential[]; +} + +export type MinimalPresentation = { + verifiableCredential: DatasetWithId[]; +} & DatasetCore; + /** * Send a Verifiable Presentation Request to a query endpoint in order to retrieve * all Verifiable Credentials matching the query, wrapped in a single Presentation. @@ -96,13 +123,49 @@ export type VerifiablePresentationRequest = { export async function query( queryEndpoint: Iri, vpRequest: VerifiablePresentationRequest, - options?: Partial<{ - fetch: typeof fallbackFetch; - }>, -): Promise { + options: ParseOptions & { + fetch?: typeof fetch; + returnLegacyJsonld: false; + normalize?: (vc: VerifiableCredentialBase) => VerifiableCredentialBase; + }, +): Promise; +/** + * @deprecated Use RDFJS API instead of relying on the JSON structure by setting `returnLegacyJsonld` to false + */ +export async function query( + queryEndpoint: Iri, + vpRequest: VerifiablePresentationRequest, + options?: ParseOptions & { + fetch?: typeof fetch; + returnLegacyJsonld?: true; + normalize?: (vc: VerifiableCredentialBase) => VerifiableCredentialBase; + }, +): Promise; +/** + * @deprecated Use RDFJS API instead of relying on the JSON structure by setting `returnLegacyJsonld` to false + */ +export async function query( + queryEndpoint: Iri, + vpRequest: VerifiablePresentationRequest, + options?: ParseOptions & { + fetch?: typeof fetch; + returnLegacyJsonld?: boolean; + normalize?: (vc: VerifiableCredentialBase) => VerifiableCredentialBase; + }, +): Promise; +export async function query( + queryEndpoint: Iri, + vpRequest: VerifiablePresentationRequest, + options: ParseOptions & + Partial<{ + fetch: typeof fetch; + returnLegacyJsonld?: boolean; + normalize?: (vc: VerifiableCredentialBase) => VerifiableCredentialBase; + }> = {}, +): Promise { const internalOptions = { ...options }; if (internalOptions.fetch === undefined) { - internalOptions.fetch = fallbackFetch; + internalOptions.fetch = fetch; } const response = await internalOptions.fetch(queryEndpoint, { headers: { @@ -117,20 +180,146 @@ export async function query( ); } - let data; + // Return to this approach once https://github.com/rubensworks/jsonld-streaming-parser.js/issues/122 is resolved + // if (options?.returnLegacyJsonld === false) { + // try { + // const vpJson = await response.json(); + // console.log(JSON.stringify(vpJson, null, 2), null, 2) + // const store = await jsonLdToStore(vpJson); + // const vp = [...store.match(null, rdf.type, cred.VerifiablePresentation, defaultGraph())] + + // if (vp.length !== 1) { + // throw new Error(`Expected exactly 1 Verifiable Presentation. Found ${vp.length}.`) + // } + + // const [{ subject }] = vp; + + // if (subject.termType !== 'BlankNode' && subject.termType !== 'NamedNode') { + // throw new Error(`Expected VP to be a Blank Node or Named Node. Found [${subject.value}] of type [${subject.termType}].`) + // } + + // if (!isRdfjsVerifiablePresentation(store, subject)) { + // throw new Error( + // `The holder [${queryEndpoint}] did not return a Verifiable Presentation: ${JSON.stringify( + // vpJson, null, 2 + // )}`, + // ); + // } + + // // In the future we want to get rid of this and get the verifiableCredential ids from the store + // // the reason we need this for now is because we need the verifiableCredential JSON object for + // // the toJSON method. + // const verifiableCredential: DatasetWithId[] = vpJson.verifiableCredential.map((vc: unknown) => { + // if (vc === null || typeof vc !== 'object') { + // throw new Error(`Verifiable Credential entry is not an object`); + // } + + // if (!('id' in vc) || typeof vc.id !== 'string') { + // throw new Error(`Verifiable credential is missing a string id`); + // } + + // const c = internal_applyDataset(vc as { id: string }, store, options) + + // if (!isRdfjsVerifiableCredential(store, namedNode(c.id))) { + // throw new Error(`[${c.id}] is not a valid Verifiable Credential`); + // } + // }); + // return internal_applyDataset(vpJson, store, { + // ...options, + // additionalProperties: { + // verifiableCredential + // } + // }); + // } catch (e) { + // throw new Error( + // `The holder [${queryEndpoint}] did not return a valid JSON response: parsing failed with error ${e}`, + // ); + // } + // } + + // All code below here should is deprecated + let data: VerifiablePresentation & DatasetCore; + let rawData: VerifiablePresentation; try { - data = normalizeVp(await response.json()); + rawData = await response.json(); + + if (options.returnLegacyJsonld !== false) { + rawData = normalizeVp(rawData); + } + data = (await verifiableCredentialToDataset( + rawData, + { + includeVcProperties: options.returnLegacyJsonld !== false, + additionalProperties: + typeof rawData.id === "string" ? { id: rawData.id } : {}, + requireId: false, + // This is a lie depending on how returnLegacyJsonld is set + }, + )) as VerifiablePresentation & DatasetCore; } catch (e) { throw new Error( `The holder [${queryEndpoint}] did not return a valid JSON response: parsing failed with error ${e}`, ); } - if (!isVerifiablePresentation(data)) { + + const subject = + typeof data.id === "string" ? namedNode(data.id) : getVpSubject(data); + if ( + options.returnLegacyJsonld === false + ? !isRdfjsVerifiablePresentation(data, subject) + : !isVerifiablePresentation(data) + ) { throw new Error( `The holder [${queryEndpoint}] did not return a Verifiable Presentation: ${JSON.stringify( data, )}`, ); } - return data; + + const newVerifiableCredential: DatasetWithId[] = []; + if ( + rawData.verifiableCredential && + Array.isArray(rawData.verifiableCredential) + ) { + for (let i = 0; i < rawData.verifiableCredential.length; i += 100) { + newVerifiableCredential.push( + // Limit concurrency to avoid memory overflows. For details see + // https://github.com/inrupt/solid-client-vc-js/pull/849#discussion_r1377400688 + // eslint-disable-next-line no-await-in-loop + ...(await Promise.all( + rawData.verifiableCredential + .slice(i, i + 100) + .map(async (_vc: VerifiableCredentialBase) => { + let vc = _vc; + if (typeof vc !== "object" || vc === null) { + throw new Error(`Verifiable Credentail is an invalid object`); + } + + if (options.normalize) { + vc = options.normalize(vc); + } + + const res = await verifiableCredentialToDataset(vc, { + ...options, + includeVcProperties: options.returnLegacyJsonld !== false, + }); + + if (!isRdfjsVerifiableCredential(res, namedNode(res.id))) { + throw new Error( + `[${res.id}] is not a Valid Verifiable Credential`, + ); + } + + return res; + }), + )), + ); + } + } + return { + ...data, + verifiableCredential: newVerifiableCredential, + } as + | ParsedVerifiablePresentation + | ({ verifiableCredential: DatasetWithId[] } & DatasetCore); } diff --git a/src/parser/contexts/data-integrity.ts b/src/parser/contexts/data-integrity.ts new file mode 100644 index 00000000..dabb64ba --- /dev/null +++ b/src/parser/contexts/data-integrity.ts @@ -0,0 +1,98 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +/** + * @see https://w3id.org/security/data-integrity/v1 + */ +export default { + "@context": { + id: "@id", + type: "@type", + "@protected": true, + proof: { + "@id": "https://w3id.org/security#proof", + "@type": "@id", + "@container": "@graph", + }, + DataIntegrityProof: { + "@id": "https://w3id.org/security#DataIntegrityProof", + "@context": { + "@protected": true, + id: "@id", + type: "@type", + challenge: "https://w3id.org/security#challenge", + created: { + "@id": "http://purl.org/dc/terms/created", + "@type": "http://www.w3.org/2001/XMLSchema#dateTime", + }, + domain: "https://w3id.org/security#domain", + expires: { + "@id": "https://w3id.org/security#expiration", + "@type": "http://www.w3.org/2001/XMLSchema#dateTime", + }, + nonce: "https://w3id.org/security#nonce", + proofPurpose: { + "@id": "https://w3id.org/security#proofPurpose", + "@type": "@vocab", + "@context": { + "@protected": true, + id: "@id", + type: "@type", + assertionMethod: { + "@id": "https://w3id.org/security#assertionMethod", + "@type": "@id", + "@container": "@set", + }, + authentication: { + "@id": "https://w3id.org/security#authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + capabilityInvocation: { + "@id": "https://w3id.org/security#capabilityInvocationMethod", + "@type": "@id", + "@container": "@set", + }, + capabilityDelegation: { + "@id": "https://w3id.org/security#capabilityDelegationMethod", + "@type": "@id", + "@container": "@set", + }, + keyAgreement: { + "@id": "https://w3id.org/security#keyAgreementMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + cryptosuite: "https://w3id.org/security#cryptosuite", + proofValue: { + "@id": "https://w3id.org/security#proofValue", + "@type": "https://w3id.org/security#multibase", + }, + verificationMethod: { + "@id": "https://w3id.org/security#verificationMethod", + "@type": "@id", + }, + }, + }, + }, +}; diff --git a/src/parser/contexts/ed25519-2020.ts b/src/parser/contexts/ed25519-2020.ts new file mode 100644 index 00000000..6b55a9cb --- /dev/null +++ b/src/parser/contexts/ed25519-2020.ts @@ -0,0 +1,117 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +/** + * @see https://w3id.org/security/suites/ed25519-2020/v1 + */ +export default { + "@context": { + id: "@id", + type: "@type", + "@protected": true, + proof: { + "@id": "https://w3id.org/security#proof", + "@type": "@id", + "@container": "@graph", + }, + Ed25519VerificationKey2020: { + "@id": "https://w3id.org/security#Ed25519VerificationKey2020", + "@context": { + "@protected": true, + id: "@id", + type: "@type", + controller: { + "@id": "https://w3id.org/security#controller", + "@type": "@id", + }, + revoked: { + "@id": "https://w3id.org/security#revoked", + "@type": "http://www.w3.org/2001/XMLSchema#dateTime", + }, + publicKeyMultibase: { + "@id": "https://w3id.org/security#publicKeyMultibase", + "@type": "https://w3id.org/security#multibase", + }, + }, + }, + Ed25519Signature2020: { + "@id": "https://w3id.org/security#Ed25519Signature2020", + "@context": { + "@protected": true, + id: "@id", + type: "@type", + challenge: "https://w3id.org/security#challenge", + created: { + "@id": "http://purl.org/dc/terms/created", + "@type": "http://www.w3.org/2001/XMLSchema#dateTime", + }, + domain: "https://w3id.org/security#domain", + expires: { + "@id": "https://w3id.org/security#expiration", + "@type": "http://www.w3.org/2001/XMLSchema#dateTime", + }, + nonce: "https://w3id.org/security#nonce", + proofPurpose: { + "@id": "https://w3id.org/security#proofPurpose", + "@type": "@vocab", + "@context": { + "@protected": true, + id: "@id", + type: "@type", + assertionMethod: { + "@id": "https://w3id.org/security#assertionMethod", + "@type": "@id", + "@container": "@set", + }, + authentication: { + "@id": "https://w3id.org/security#authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + capabilityInvocation: { + "@id": "https://w3id.org/security#capabilityInvocationMethod", + "@type": "@id", + "@container": "@set", + }, + capabilityDelegation: { + "@id": "https://w3id.org/security#capabilityDelegationMethod", + "@type": "@id", + "@container": "@set", + }, + keyAgreement: { + "@id": "https://w3id.org/security#keyAgreementMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + proofValue: { + "@id": "https://w3id.org/security#proofValue", + "@type": "https://w3id.org/security#multibase", + }, + verificationMethod: { + "@id": "https://w3id.org/security#verificationMethod", + "@type": "@id", + }, + }, + }, + }, +}; diff --git a/src/parser/contexts/index.ts b/src/parser/contexts/index.ts new file mode 100644 index 00000000..384f8ded --- /dev/null +++ b/src/parser/contexts/index.ts @@ -0,0 +1,43 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +import VC from "./vc"; +import Inrupt from "./inrupt"; +import vc from "./inrupt-vc"; +import integrity from "./data-integrity"; +import ed25519 from "./ed25519-2020"; +import revocation from "./revocation-list"; +import statusList from "./status-list"; + +const contextDefinitions = { + "https://www.w3.org/2018/credentials/v1": VC, + "https://schema.inrupt.com/credentials/v1.jsonld": Inrupt, +} as const; + +export const cachedContexts = { + "https://vc.inrupt.com/credentials/v1": vc, + "https://w3id.org/security/data-integrity/v1": integrity, + "https://w3id.org/vc-revocation-list-2020/v1": revocation, + "https://w3id.org/vc/status-list/2021/v1": statusList, + "https://w3id.org/security/suites/ed25519-2020/v1": ed25519, +}; + +export const context = Object.keys(contextDefinitions); +export default contextDefinitions; diff --git a/src/parser/contexts/inrupt-vc.ts b/src/parser/contexts/inrupt-vc.ts new file mode 100644 index 00000000..cc3aa60b --- /dev/null +++ b/src/parser/contexts/inrupt-vc.ts @@ -0,0 +1,88 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +/** + * @see https://vc.inrupt.com/credentials/v1 + */ +export default { + "@context": { + "@version": 1.1, + "@protected": true, + ldp: "http://www.w3.org/ns/ldp#", + acl: "http://www.w3.org/ns/auth/acl#", + gc: "https://w3id.org/GConsent#", + vc: "http://www.w3.org/ns/solid/vc#", + xsd: "http://www.w3.org/2001/XMLSchema#", + issuerService: { "@id": "vc:issuerService", "@type": "@id" }, + statusService: { "@id": "vc:statusService", "@type": "@id" }, + verifierService: { "@id": "vc:verifierService", "@type": "@id" }, + derivationService: { "@id": "vc:derivationService", "@type": "@id" }, + proofService: { "@id": "vc:proofService", "@type": "@id" }, + availabilityService: { "@id": "vc:availabilityService", "@type": "@id" }, + submissionService: { "@id": "vc:submissionService", "@type": "@id" }, + supportedSignatureTypes: { + "@id": "vc:supportedSignatureTypes", + "@type": "@id", + }, + include: { "@id": "vc:include", "@type": "@id" }, + SolidAccessGrant: "vc:SolidAccessGrant", + SolidAccessRequest: "vc:SolidAccessRequest", + ExpiredVerifiableCredential: "vc:ExpiredVerifiableCredential", + inbox: { "@id": "ldp:inbox", "@type": "@id" }, + Read: "acl:Read", + Write: "acl:Write", + Append: "acl:Append", + mode: { "@id": "acl:mode", "@type": "@vocab" }, + Consent: "gc:Consent", + ConsentStatusExpired: "gc:ConsentStatusExpired", + ConsentStatusExplicitlyGiven: "gc:ConsentStatusExplicitlyGiven", + ConsentStatusGivenByDelegation: "gc:ConsentStatusGivenByDelegation", + ConsentStatusImplicitlyGiven: "gc:ConsentStatusImplicitlyGiven", + ConsentStatusInvalidated: "gc:ConsentStatusInvalidated", + ConsentStatusNotGiven: "gc:ConsentStatusNotGiven", + ConsentStatusRefused: "gc:ConsentStatusRefused", + ConsentStatusRequested: "gc:ConsentStatusRequested", + ConsentStatusUnknown: "gc:ConsentStatusUnknown", + ConsentStatusWithdrawn: "gc:ConsentStatusWithdrawn", + forPersonalData: { "@id": "gc:forPersonalData", "@type": "@id" }, + forProcessing: { "@id": "gc:forProcessing", "@type": "@id" }, + forPurpose: { "@id": "gc:forPurpose", "@type": "@id" }, + hasConsent: { "@id": "gc:hasConsent", "@type": "@id" }, + hasContext: { "@id": "gc:hasContext", "@type": "@id" }, + hasStatus: { "@id": "gc:hasStatus", "@type": "@vocab" }, + inMedium: { "@id": "gc:inMedium", "@type": "@id" }, + isConsentForDataSubject: { + "@id": "gc:isConsentForDataSubject", + "@type": "@id", + }, + isProvidedTo: { "@id": "gc:isProvidedTo", "@type": "@id" }, + isProvidedToPerson: { "@id": "gc:isProvidedToPerson", "@type": "@id" }, + isProvidedToController: { + "@id": "gc:isProvidedToController", + "@type": "@id", + }, + providedConsent: { "@id": "gc:providedConsent", "@type": "@id" }, + inherit: { + "@id": "urn:uuid:71ab2f68-a68b-4452-b968-dd23e0570227", + "@type": "xsd:boolean", + }, + }, +}; diff --git a/src/parser/contexts/inrupt.ts b/src/parser/contexts/inrupt.ts new file mode 100644 index 00000000..51e2841f --- /dev/null +++ b/src/parser/contexts/inrupt.ts @@ -0,0 +1,149 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +/** + * The custom Inrupt context for verifiable credentials + * @see https://schema.inrupt.com/credentials/v1.jsonld + */ +export default { + "@context": { + "@version": 1.1, + "@protected": true, + ldp: "http://www.w3.org/ns/ldp#", + acl: "http://www.w3.org/ns/auth/acl#", + gc: "https://w3id.org/GConsent#", + vc: "http://www.w3.org/ns/solid/vc#", + xsd: "http://www.w3.org/2001/XMLSchema#", + issuerService: { + "@id": "vc:issuerService", + "@type": "@id", + }, + statusService: { + "@id": "vc:statusService", + "@type": "@id", + }, + verifierService: { + "@id": "vc:verifierService", + "@type": "@id", + }, + derivationService: { + "@id": "vc:derivationService", + "@type": "@id", + }, + proofService: { + "@id": "vc:proofService", + "@type": "@id", + }, + availabilityService: { + "@id": "vc:availabilityService", + "@type": "@id", + }, + submissionService: { + "@id": "vc:submissionService", + "@type": "@id", + }, + supportedSignatureTypes: { + "@id": "vc:supportedSignatureTypes", + "@type": "@id", + }, + include: { + "@id": "vc:include", + "@type": "@id", + }, + SolidAccessGrant: "vc:SolidAccessGrant", + SolidAccessRequest: "vc:SolidAccessRequest", + ExpiredVerifiableCredential: "vc:ExpiredVerifiableCredential", + inbox: { + "@id": "ldp:inbox", + "@type": "@id", + }, + Read: "acl:Read", + Write: "acl:Write", + Append: "acl:Append", + mode: { + "@id": "acl:mode", + "@type": "@vocab", + }, + Consent: "gc:Consent", + ConsentStatusExpired: "gc:ConsentStatusExpired", + ConsentStatusExplicitlyGiven: "gc:ConsentStatusExplicitlyGiven", + ConsentStatusGivenByDelegation: "gc:ConsentStatusGivenByDelegation", + ConsentStatusImplicitlyGiven: "gc:ConsentStatusImplicitlyGiven", + ConsentStatusInvalidated: "gc:ConsentStatusInvalidated", + ConsentStatusNotGiven: "gc:ConsentStatusNotGiven", + ConsentStatusRefused: "gc:ConsentStatusRefused", + ConsentStatusRequested: "gc:ConsentStatusRequested", + ConsentStatusUnknown: "gc:ConsentStatusUnknown", + ConsentStatusWithdrawn: "gc:ConsentStatusWithdrawn", + forPersonalData: { + "@id": "gc:forPersonalData", + "@type": "@id", + }, + forProcessing: { + "@id": "gc:forProcessing", + "@type": "@id", + }, + forPurpose: { + "@id": "gc:forPurpose", + "@type": "@id", + }, + hasConsent: { + "@id": "gc:hasConsent", + "@type": "@id", + }, + hasContext: { + "@id": "gc:hasContext", + "@type": "@id", + }, + hasStatus: { + "@id": "gc:hasStatus", + "@type": "@vocab", + }, + inMedium: { + "@id": "gc:inMedium", + "@type": "@id", + }, + isConsentForDataSubject: { + "@id": "gc:isConsentForDataSubject", + "@type": "@id", + }, + isProvidedTo: { + "@id": "gc:isProvidedTo", + "@type": "@id", + }, + isProvidedToPerson: { + "@id": "gc:isProvidedToPerson", + "@type": "@id", + }, + isProvidedToController: { + "@id": "gc:isProvidedToController", + "@type": "@id", + }, + providedConsent: { + "@id": "gc:providedConsent", + "@type": "@id", + }, + inherit: { + "@id": "urn:uuid:71ab2f68-a68b-4452-b968-dd23e0570227", + "@type": "xsd:boolean", + }, + }, +} as const; diff --git a/src/parser/contexts/revocation-list.ts b/src/parser/contexts/revocation-list.ts new file mode 100644 index 00000000..037be293 --- /dev/null +++ b/src/parser/contexts/revocation-list.ts @@ -0,0 +1,72 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +/** + * @see https://w3id.org/vc-revocation-list-2020/v1 + */ +export default { + "@context": { + "@protected": true, + RevocationList2020Credential: { + "@id": + "https://w3id.org/vc-revocation-list-2020#RevocationList2020Credential", + "@context": { + "@protected": true, + + id: "@id", + type: "@type", + + description: "http://schema.org/description", + name: "http://schema.org/name", + }, + }, + RevocationList2020: { + "@id": "https://w3id.org/vc-revocation-list-2020#RevocationList2020", + "@context": { + "@protected": true, + + id: "@id", + type: "@type", + + encodedList: "https://w3id.org/vc-revocation-list-2020#encodedList", + }, + }, + + RevocationList2020Status: { + "@id": + "https://w3id.org/vc-revocation-list-2020#RevocationList2020Status", + "@context": { + "@protected": true, + + id: "@id", + type: "@type", + + revocationListCredential: { + "@id": + "https://w3id.org/vc-revocation-list-2020#revocationListCredential", + "@type": "@id", + }, + revocationListIndex: + "https://w3id.org/vc-revocation-list-2020#revocationListIndex", + }, + }, + }, +}; diff --git a/src/parser/contexts/status-list.ts b/src/parser/contexts/status-list.ts new file mode 100644 index 00000000..f7756e26 --- /dev/null +++ b/src/parser/contexts/status-list.ts @@ -0,0 +1,72 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +/** + * @see https://w3id.org/vc/status-list/2021/v1 + */ +export default { + "@context": { + "@protected": true, + + StatusList2021Credential: { + "@id": "https://w3id.org/vc/status-list#StatusList2021Credential", + "@context": { + "@protected": true, + + id: "@id", + type: "@type", + + description: "http://schema.org/description", + name: "http://schema.org/name", + }, + }, + + StatusList2021: { + "@id": "https://w3id.org/vc/status-list#StatusList2021", + "@context": { + "@protected": true, + + id: "@id", + type: "@type", + + statusPurpose: "https://w3id.org/vc/status-list#statusPurpose", + encodedList: "https://w3id.org/vc/status-list#encodedList", + }, + }, + + StatusList2021Entry: { + "@id": "https://w3id.org/vc/status-list#StatusList2021Entry", + "@context": { + "@protected": true, + + id: "@id", + type: "@type", + + statusPurpose: "https://w3id.org/vc/status-list#statusPurpose", + statusListIndex: "https://w3id.org/vc/status-list#statusListIndex", + statusListCredential: { + "@id": "https://w3id.org/vc/status-list#statusListCredential", + "@type": "@id", + }, + }, + }, + }, +}; diff --git a/src/parser/contexts/vc.ts b/src/parser/contexts/vc.ts new file mode 100644 index 00000000..36f6b852 --- /dev/null +++ b/src/parser/contexts/vc.ts @@ -0,0 +1,275 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +/** + * The Verifiable Credentials context. + * @see https://www.w3.org/2018/credentials/v1 + */ +export default { + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + VerifiableCredential: { + "@id": "https://www.w3.org/2018/credentials#VerifiableCredential", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + cred: "https://www.w3.org/2018/credentials#", + sec: "https://w3id.org/security#", + xsd: "http://www.w3.org/2001/XMLSchema#", + credentialSchema: { + "@id": "cred:credentialSchema", + "@type": "@id", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + cred: "https://www.w3.org/2018/credentials#", + JsonSchemaValidator2018: "cred:JsonSchemaValidator2018", + }, + }, + credentialStatus: { "@id": "cred:credentialStatus", "@type": "@id" }, + credentialSubject: { "@id": "cred:credentialSubject", "@type": "@id" }, + evidence: { "@id": "cred:evidence", "@type": "@id" }, + expirationDate: { + "@id": "cred:expirationDate", + "@type": "xsd:dateTime", + }, + holder: { "@id": "cred:holder", "@type": "@id" }, + issued: { "@id": "cred:issued", "@type": "xsd:dateTime" }, + issuer: { "@id": "cred:issuer", "@type": "@id" }, + issuanceDate: { "@id": "cred:issuanceDate", "@type": "xsd:dateTime" }, + proof: { "@id": "sec:proof", "@type": "@id", "@container": "@graph" }, + refreshService: { + "@id": "cred:refreshService", + "@type": "@id", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + cred: "https://www.w3.org/2018/credentials#", + ManualRefreshService2018: "cred:ManualRefreshService2018", + }, + }, + termsOfUse: { "@id": "cred:termsOfUse", "@type": "@id" }, + validFrom: { "@id": "cred:validFrom", "@type": "xsd:dateTime" }, + validUntil: { "@id": "cred:validUntil", "@type": "xsd:dateTime" }, + }, + }, + VerifiablePresentation: { + "@id": "https://www.w3.org/2018/credentials#VerifiablePresentation", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + cred: "https://www.w3.org/2018/credentials#", + sec: "https://w3id.org/security#", + holder: { "@id": "cred:holder", "@type": "@id" }, + proof: { "@id": "sec:proof", "@type": "@id", "@container": "@graph" }, + verifiableCredential: { + "@id": "cred:verifiableCredential", + "@type": "@id", + "@container": "@graph", + }, + }, + }, + EcdsaSecp256k1Signature2019: { + "@id": "https://w3id.org/security#EcdsaSecp256k1Signature2019", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + sec: "https://w3id.org/security#", + xsd: "http://www.w3.org/2001/XMLSchema#", + challenge: "sec:challenge", + created: { + "@id": "http://purl.org/dc/terms/created", + "@type": "xsd:dateTime", + }, + domain: "sec:domain", + expires: { "@id": "sec:expiration", "@type": "xsd:dateTime" }, + jws: "sec:jws", + nonce: "sec:nonce", + proofPurpose: { + "@id": "sec:proofPurpose", + "@type": "@vocab", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + sec: "https://w3id.org/security#", + assertionMethod: { + "@id": "sec:assertionMethod", + "@type": "@id", + "@container": "@set", + }, + authentication: { + "@id": "sec:authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + proofValue: "sec:proofValue", + verificationMethod: { "@id": "sec:verificationMethod", "@type": "@id" }, + }, + }, + EcdsaSecp256r1Signature2019: { + "@id": "https://w3id.org/security#EcdsaSecp256r1Signature2019", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + sec: "https://w3id.org/security#", + xsd: "http://www.w3.org/2001/XMLSchema#", + challenge: "sec:challenge", + created: { + "@id": "http://purl.org/dc/terms/created", + "@type": "xsd:dateTime", + }, + domain: "sec:domain", + expires: { "@id": "sec:expiration", "@type": "xsd:dateTime" }, + jws: "sec:jws", + nonce: "sec:nonce", + proofPurpose: { + "@id": "sec:proofPurpose", + "@type": "@vocab", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + sec: "https://w3id.org/security#", + assertionMethod: { + "@id": "sec:assertionMethod", + "@type": "@id", + "@container": "@set", + }, + authentication: { + "@id": "sec:authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + proofValue: "sec:proofValue", + verificationMethod: { "@id": "sec:verificationMethod", "@type": "@id" }, + }, + }, + Ed25519Signature2018: { + "@id": "https://w3id.org/security#Ed25519Signature2018", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + sec: "https://w3id.org/security#", + xsd: "http://www.w3.org/2001/XMLSchema#", + challenge: "sec:challenge", + created: { + "@id": "http://purl.org/dc/terms/created", + "@type": "xsd:dateTime", + }, + domain: "sec:domain", + expires: { "@id": "sec:expiration", "@type": "xsd:dateTime" }, + jws: "sec:jws", + nonce: "sec:nonce", + proofPurpose: { + "@id": "sec:proofPurpose", + "@type": "@vocab", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + sec: "https://w3id.org/security#", + assertionMethod: { + "@id": "sec:assertionMethod", + "@type": "@id", + "@container": "@set", + }, + authentication: { + "@id": "sec:authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + proofValue: "sec:proofValue", + verificationMethod: { "@id": "sec:verificationMethod", "@type": "@id" }, + }, + }, + RsaSignature2018: { + "@id": "https://w3id.org/security#RsaSignature2018", + "@context": { + "@version": 1.1, + "@protected": true, + challenge: "sec:challenge", + created: { + "@id": "http://purl.org/dc/terms/created", + "@type": "xsd:dateTime", + }, + domain: "sec:domain", + expires: { "@id": "sec:expiration", "@type": "xsd:dateTime" }, + jws: "sec:jws", + nonce: "sec:nonce", + proofPurpose: { + "@id": "sec:proofPurpose", + "@type": "@vocab", + "@context": { + "@version": 1.1, + "@protected": true, + id: "@id", + type: "@type", + sec: "https://w3id.org/security#", + assertionMethod: { + "@id": "sec:assertionMethod", + "@type": "@id", + "@container": "@set", + }, + authentication: { + "@id": "sec:authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + proofValue: "sec:proofValue", + verificationMethod: { "@id": "sec:verificationMethod", "@type": "@id" }, + }, + }, + proof: { + "@id": "https://w3id.org/security#proof", + "@type": "@id", + "@container": "@graph", + }, + }, +} as const; diff --git a/src/parser/jsonld.test.ts b/src/parser/jsonld.test.ts new file mode 100644 index 00000000..26f92cab --- /dev/null +++ b/src/parser/jsonld.test.ts @@ -0,0 +1,154 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import { describe, expect, it } from "@jest/globals"; +import { DataFactory as DF } from "n3"; +import { isomorphic } from "rdf-isomorphic"; +import { + CachedFetchDocumentLoader, + CachingContextParser, + jsonLdToStore, +} from "./jsonld"; + +const data = { + "@context": "https://www.w3.org/2018/credentials/v1", + id: "https://some.example#credential", + type: ["VerifiableCredential"], + issuer: "https://some.example", +}; + +const result = [ + DF.quad( + DF.namedNode("https://some.example#credential"), + DF.namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + DF.namedNode("https://www.w3.org/2018/credentials#VerifiableCredential"), + ), + DF.quad( + DF.namedNode("https://some.example#credential"), + DF.namedNode("https://www.w3.org/2018/credentials#issuer"), + DF.namedNode("https://some.example"), + ), +]; + +describe("jsonLdToStore", () => { + it("converting valid POJO to store", async () => { + expect(isomorphic([...(await jsonLdToStore(data))], result)).toBe(true); + }); +}); + +describe("CachingContextParser", () => { + it("should return the same object parsing the same context multiple times", async () => { + const contextParser = new CachingContextParser({ + documentLoader: new CachedFetchDocumentLoader(), + }); + await expect( + contextParser.parse("https://w3id.org/vc/status-list/2021/v1"), + ).resolves.toEqual( + await contextParser.parse("https://w3id.org/vc/status-list/2021/v1"), + ); + + await expect( + contextParser.parse({ ex: "http://example.org" }), + ).resolves.toEqual(await contextParser.parse({ ex: "http://example.org" })); + + await expect( + contextParser.parse([ + "https://w3id.org/vc/status-list/2021/v1", + { ex: "http://example.org" }, + ]), + ).resolves.toEqual( + await contextParser.parse([ + "https://w3id.org/vc/status-list/2021/v1", + { ex: "http://example.org" }, + ]), + ); + + await expect( + contextParser.parse( + [ + "https://w3id.org/vc/status-list/2021/v1", + { ex: "http://example.org" }, + ], + { + parentContext: undefined, + }, + ), + ).resolves.toEqual( + await contextParser.parse([ + "https://w3id.org/vc/status-list/2021/v1", + { ex: "http://example.org" }, + ]), + ); + + await expect( + contextParser.parse( + [ + "https://w3id.org/vc/status-list/2021/v1", + { ex: "http://example.org" }, + ], + { + parentContext: {}, + }, + ), + ).resolves.toEqual( + await contextParser.parse([ + "https://w3id.org/vc/status-list/2021/v1", + { ex: "http://example.org" }, + ]), + ); + + await expect( + contextParser.parse([ + "https://w3id.org/vc/status-list/2021/v1", + { ex: "http://example.org" }, + ]), + ).resolves.toEqual( + await contextParser.parse( + [ + "https://w3id.org/vc/status-list/2021/v1", + { ex: "http://example.org" }, + ], + { + parentContext: {}, + }, + ), + ); + + await expect( + contextParser.parse([ + "https://w3id.org/vc/status-list/2021/v1", + { ex: "http://example.org" }, + ]), + ).resolves.not.toEqual( + await contextParser.parse( + [ + "https://w3id.org/vc/status-list/2021/v1", + { ex: "http://example.org" }, + ], + { + parentContext: { + "@base": "http://example.org/test", + }, + }, + ), + ); + }); +}); diff --git a/src/parser/jsonld.ts b/src/parser/jsonld.ts new file mode 100644 index 00000000..e6490d43 --- /dev/null +++ b/src/parser/jsonld.ts @@ -0,0 +1,198 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +/* eslint-disable max-classes-per-file */ +import { promisifyEventEmitter } from "event-emitter-promisify"; +import type { + IJsonLdContext, + IParseOptions, + JsonLdContext, + JsonLdContextNormalized, +} from "jsonld-context-parser"; +import { ContextParser, FetchDocumentLoader } from "jsonld-context-parser"; +import { JsonLdParser } from "jsonld-streaming-parser"; +import { Store } from "n3"; +import md5 from "md5"; +import type { JsonLd } from "../common/common"; +import CONTEXTS, { cachedContexts } from "./contexts"; + +/** + * A JSON-LD document loader with the standard context for VCs pre-loaded + */ +export class CachedFetchDocumentLoader extends FetchDocumentLoader { + private contexts: Record; + + constructor( + contexts?: Record, + private readonly allowContextFetching = false, + ...args: ConstructorParameters + ) { + super(args[0] ?? fetch); + this.contexts = { ...contexts, ...cachedContexts, ...CONTEXTS }; + } + + public async load(url: string): Promise { + if (Object.keys(this.contexts).includes(url)) { + return this.contexts[url as keyof typeof CONTEXTS]; + } + if (!this.allowContextFetching) { + throw new Error(`Unexpected context requested [${url}]`); + } + return super.load(url); + } +} + +export interface ParseOptions { + baseIRI?: string; + contexts?: Record; + allowContextFetching?: boolean; +} + +function hashOptions(options: IParseOptions | undefined) { + const opts = { ...options, parentContext: undefined }; + for (const key of Object.keys(opts)) { + if (typeof opts[key as keyof typeof opts] === "undefined") { + delete opts[key as keyof typeof opts]; + } + } + + return md5(JSON.stringify(opts, Object.keys(opts).sort())); +} + +function hashContext( + context: JsonLdContext, + cmap: (c: IJsonLdContext) => number, +): string { + if (Array.isArray(context)) { + return md5( + JSON.stringify(context.map((c) => (typeof c === "string" ? c : cmap(c)))), + ); + } + return typeof context === "string" ? md5(context) : cmap(context).toString(); +} + +// This is a workaround until https://github.com/rubensworks/jsonld-context-parser.js/pull/70 is closed +export class CachingContextParser extends ContextParser { + private cachedParsing: Record> = {}; + + private contextMap: Map = new Map(); + + private contextHashMap: Map = new Map(); + + private mapIndex = 1; + + private cmap = (context: IJsonLdContext) => { + if (!this.contextMap.has(context)) { + const hash = md5(JSON.stringify(context)); + if (!this.contextHashMap.has(hash)) { + this.contextHashMap.set(hash, (this.mapIndex += 1)); + } + this.contextMap.set(context, this.contextHashMap.get(hash)!); + } + return this.contextMap.get(context)!; + }; + + async parse( + context: JsonLdContext, + options?: IParseOptions, + ): Promise { + let hash = hashOptions(options); + + if ( + options?.parentContext && + Object.keys(options.parentContext).length !== 0 + ) { + hash = md5(hash + this.cmap(options.parentContext)); + } + + // eslint-disable-next-line no-return-assign + return (this.cachedParsing[md5(hash + hashContext(context, this.cmap))] ??= + super.parse(context, options)); + } +} + +let reusableDocumentLoader: CachedFetchDocumentLoader; +let reusableContextParser: CachingContextParser; + +/** + * Our internal JsonLd Parser with a cached VC context + */ +export class CachedJsonLdParser extends JsonLdParser { + constructor(options?: ParseOptions) { + let documentLoader: CachedFetchDocumentLoader; + + if (!options?.contexts && !options?.allowContextFetching) { + reusableDocumentLoader ??= new CachedFetchDocumentLoader( + undefined, + undefined, + fetch, + ); + documentLoader = reusableDocumentLoader; + } else { + documentLoader = new CachedFetchDocumentLoader( + options.contexts, + options.allowContextFetching, + fetch, + ); + } + + super({ + documentLoader, + baseIRI: options?.baseIRI, + }); + + if (!options?.contexts && !options?.allowContextFetching) { + reusableContextParser ??= new CachingContextParser({ + documentLoader: reusableDocumentLoader, + }); + // @ts-expect-error parsingContext is an internal property + this.parsingContext.contextParser = reusableContextParser; + } + } +} + +/** + * Gets an N3 store from a JSON-LD string + * @param response A JSON-LD string + * @param options An optional fetch function for dereferencing remote contexts + * @returns A store containing the Quads in the JSON-LD response + */ +export async function jsonLdStringToStore( + data: string, + options?: ParseOptions, +) { + const parser = new CachedJsonLdParser(options); + const store = new Store(); + const storePromise = promisifyEventEmitter(store.import(parser), store); + parser.write(data); + parser.end(); + return storePromise; +} + +/** + * Gets an N3 store from a JSON-LD as an Object + * @param response JSON-LD as an Object + * @param options An optional fetch function for dereferencing remote contexts + * @returns A store containing the Quads in the JSON-LD response + */ +export function jsonLdToStore(data: unknown, options?: ParseOptions) { + return jsonLdStringToStore(JSON.stringify(data), options); +} diff --git a/src/revoke/revoke.test.ts b/src/revoke/revoke.test.ts index 63aad910..9197170d 100644 --- a/src/revoke/revoke.test.ts +++ b/src/revoke/revoke.test.ts @@ -20,20 +20,12 @@ // import { jest, it, describe, expect } from "@jest/globals"; -import { Response } from "@inrupt/universal-fetch"; -import type * as UniversalFetch from "@inrupt/universal-fetch"; import defaultRevokeVerifiableCredential, { revokeVerifiableCredential, } from "./revoke"; -jest.mock("@inrupt/universal-fetch", () => { - const fetchModule = jest.requireActual( - "@inrupt/universal-fetch", - ) as typeof UniversalFetch; - return { - ...fetchModule, - fetch: jest.fn<(typeof UniversalFetch)["fetch"]>(), - }; +const spiedFetch = jest.spyOn(globalThis, "fetch").mockImplementation(() => { + throw new Error("Unexpected fetch call"); }); describe("revokeVerifiableCredential", () => { @@ -47,7 +39,7 @@ describe("revokeVerifiableCredential", () => { "https://some.endpoint", "https://some.example#credential", { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, }, ); // eslint-disable-next-line no-empty @@ -56,9 +48,6 @@ describe("revokeVerifiableCredential", () => { }); it("defaults to an unauthenticated fetch if no fetch is provided", async () => { - const mockedFetch = jest.requireMock( - "@inrupt/universal-fetch", - ) as jest.Mocked; try { await revokeVerifiableCredential( "https://some.endpoint", @@ -66,41 +55,37 @@ describe("revokeVerifiableCredential", () => { ); // eslint-disable-next-line no-empty } catch (_e) {} - expect(mockedFetch.fetch).toHaveBeenCalled(); + expect(spiedFetch).toHaveBeenCalled(); }); it("throws if the issuer returns an error", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValue( - new Response(undefined, { - status: 400, - statusText: "Bad request", - }), - ); + const mockedFetch = jest.fn().mockResolvedValue( + new Response(undefined, { + status: 400, + statusText: "Bad request", + }), + ); await expect( revokeVerifiableCredential( "https://some.endpoint", "https://some.example#credential", - { fetch: mockedFetch as (typeof UniversalFetch)["fetch"] }, + { fetch: mockedFetch }, ), ).rejects.toThrow(/some.endpoint.*400.*Bad request/); }); it("sends an appropriate revocation request to the issuer", async () => { - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValue( - new Response(undefined, { - status: 200, - statusText: "OK", - }), - ); + const mockedFetch = jest.fn().mockResolvedValue( + new Response(undefined, { + status: 200, + statusText: "OK", + }), + ); await revokeVerifiableCredential( "https://some.endpoint", "https://some.example#credential", { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, }, ); expect(mockedFetch).toHaveBeenCalledWith( diff --git a/src/revoke/revoke.ts b/src/revoke/revoke.ts index 8148df12..a898d818 100644 --- a/src/revoke/revoke.ts +++ b/src/revoke/revoke.ts @@ -22,7 +22,6 @@ /** * @module revoke */ -import { fetch as fallbackFetch } from "@inrupt/universal-fetch"; import type { Iri } from "../common/common"; /** @@ -48,7 +47,7 @@ export async function revokeVerifiableCredential( ): Promise { const internalOptions = { ...options }; if (internalOptions.fetch === undefined) { - internalOptions.fetch = fallbackFetch; + internalOptions.fetch = fetch; } const response = await internalOptions.fetch(issuerEndpoint, { method: "POST", diff --git a/src/verify/verify.test.ts b/src/verify/verify.test.ts index 2086b5ee..d60b3fb5 100644 --- a/src/verify/verify.test.ts +++ b/src/verify/verify.test.ts @@ -19,32 +19,52 @@ // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import { jest, describe, it, expect } from "@jest/globals"; +import { describe, expect, it, jest } from "@jest/globals"; import { mocked } from "jest-mock"; -import { Response } from "@inrupt/universal-fetch"; -import type * as UniversalFetch from "@inrupt/universal-fetch"; +import { DataFactory, Store } from "n3"; +import type * as Common from "../common/common"; import { + getVerifiableCredential, getVerifiableCredentialApiConfiguration, isVerifiableCredential, isVerifiablePresentation, } from "../common/common"; import { isValidVc, isValidVerifiablePresentation } from "./verify"; +import { jsonLdStringToStore } from "../parser/jsonld"; +import { cred, rdf } from "../common/constants"; +import { + getHolder, + getVpSubject, +} from "../common/isRdfjsVerifiablePresentation"; + +const { quad, namedNode, literal } = DataFactory; -jest.mock("../common/common"); -jest.mock("@inrupt/universal-fetch", () => { - const fetchModule = jest.requireActual( - "@inrupt/universal-fetch", - ) as typeof UniversalFetch; +jest.mock("../common/common", () => { return { - ...fetchModule, - fetch: jest.fn<(typeof UniversalFetch)["fetch"]>(), + verifiableCredentialToDataset: + jest.requireActual("../common/common") + .verifiableCredentialToDataset, + isVerifiablePresentation: jest.fn(), + isVerifiableCredential: jest.fn(), + getVerifiableCredential: jest.fn( + jest.requireActual("../common/common") + .getVerifiableCredential, + ), + isUrl: jest.requireActual("../common/common").isUrl, + hasId: jest.requireActual("../common/common").hasId, + getVerifiableCredentialApiConfiguration: jest.fn(), }; }); +const spiedFetch = jest.spyOn(globalThis, "fetch").mockImplementation(() => { + throw new Error("Unexpected fetch call"); +}); + const MOCK_VC = { "@context": [ "https://www.w3.org/2018/credentials/v1", - "https://consent.pod.inrupt.com/credentials/v1", + "https://schema.inrupt.com/credentials/v1.jsonld", + // "https://consent.pod.inrupt.com/credentials/v1", ], id: "https://example.com/id", issuer: "https://example.com/issuer", @@ -65,20 +85,17 @@ const MOCK_VC = { created: "2021-05-26T16:40:03.009Z", proofPurpose: "assertionMethod", proofValue: "eqp8h_kL1DwJCpn65z-d1Arnysx6b11...jb8j0MxUCc1uDQ", - type: "Ed25519Signature2020", + type: "Ed25519Signature2018", verificationMethod: "https://consent.pod.inrupt.com/key/396f686b", }, }; + describe("isValidVc", () => { const MOCK_VERIFY_ENDPOINT = "https://consent.example.com"; const MOCK_VERIFY_RESPONSE = { checks: [], warning: [], errors: [] }; it("falls back to an unauthenticated fetch if none is provided", async () => { - const mockedFetch = jest.requireMock( - "@inrupt/universal-fetch", - ) as jest.Mocked; - mocked(isVerifiableCredential).mockReturnValueOnce(true); - mockedFetch.fetch.mockResolvedValueOnce( + spiedFetch.mockResolvedValueOnce( new Response(JSON.stringify(MOCK_VERIFY_RESPONSE), { status: 200, }), @@ -87,17 +104,12 @@ describe("isValidVc", () => { verificationEndpoint: MOCK_VERIFY_ENDPOINT, }); - expect(mockedFetch.fetch).toHaveBeenCalled(); + expect(spiedFetch).toHaveBeenCalled(); }); it("discovers the verification endpoint if none is provided", async () => { - // Use the fetch fallback on purpose to check that no option may be passed - // to the function. - const mockedFetch = jest.requireMock( - "@inrupt/universal-fetch", - ) as jest.Mocked; // First, the VC is fetche - mockedFetch.fetch + spiedFetch .mockResolvedValueOnce( new Response(JSON.stringify(MOCK_VC), { status: 200 }), ) @@ -114,42 +126,61 @@ describe("isValidVc", () => { legacy: {}, specCompliant: {}, }); - mocked(isVerifiableCredential).mockReturnValueOnce(true); await isValidVc(MOCK_VC); expect(mockedDiscovery).toHaveBeenCalledWith(MOCK_VC.issuer); }); it("uses the provided fetch if any", async () => { - const mockedFetch = jest.fn(global.fetch).mockResolvedValueOnce( + const mockedFetch = jest.fn().mockResolvedValueOnce( new Response(JSON.stringify(MOCK_VERIFY_RESPONSE), { status: 200, }), ); - mocked(isVerifiableCredential).mockReturnValueOnce(true); + await isValidVc(MOCK_VC, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, verificationEndpoint: MOCK_VERIFY_ENDPOINT, }); expect(mockedFetch).toHaveBeenCalled(); }); + it("uses the provided fetch if any on RDFJS input", async () => { + const mockedFetch = jest.fn().mockResolvedValueOnce( + new Response(JSON.stringify(MOCK_VERIFY_RESPONSE), { + status: 200, + }), + ); + + await isValidVc( + Object.assign(await jsonLdStringToStore(JSON.stringify(MOCK_VC)), { + id: MOCK_VC.id, + }), + { + fetch: mockedFetch, + verificationEndpoint: MOCK_VERIFY_ENDPOINT, + }, + ); + + expect(mockedFetch).toHaveBeenCalled(); + }); + it("sends the given vc to the verify endpoint", async () => { mocked(getVerifiableCredentialApiConfiguration).mockResolvedValueOnce({ verifierService: "https://some.vc.verifier", legacy: {}, specCompliant: {}, }); - mocked(isVerifiableCredential).mockReturnValueOnce(true); - const mockedFetch = jest.fn(global.fetch).mockResolvedValueOnce( + + const mockedFetch = jest.fn().mockResolvedValueOnce( new Response(JSON.stringify(MOCK_VERIFY_RESPONSE), { status: 200, }), ); await isValidVc(MOCK_VC, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, }); expect(mockedFetch).toHaveBeenCalledWith( @@ -173,13 +204,19 @@ describe("isValidVc", () => { status: 200, }), ); - mocked(isVerifiableCredential).mockReturnValueOnce(true); + await isValidVc("https://example.com/someVc", { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"] as typeof fetch, + fetch: mockedFetch as typeof fetch, verificationEndpoint: MOCK_VERIFY_ENDPOINT, }); - expect(mockedFetch).toHaveBeenCalledWith("https://example.com/someVc"); + expect(getVerifiableCredential).toHaveBeenCalledWith( + "https://example.com/someVc", + { + fetch: mockedFetch, + verificationEndpoint: "https://consent.example.com", + }, + ); }); it("throws if looking up the passed url fails", async () => { @@ -190,10 +227,31 @@ describe("isValidVc", () => { ); await expect( isValidVc("https://example.com/someVc", { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"] as typeof fetch, + fetch: mockedFetch as typeof fetch, verificationEndpoint: MOCK_VERIFY_ENDPOINT, }), - ).rejects.toThrow(/example.com\/someVc.*400 Failed/); + ).rejects.toThrow( + "Fetching the Verifiable Credential [https://example.com/someVc] failed: 400 Failed", + ); + }); + + it("throws if the VC does not have an ID", async () => { + const mockedFetch = jest + .fn(global.fetch) + .mockResolvedValueOnce( + new Response(undefined, { status: 400, statusText: "Failed" }), + ); + await expect( + isValidVc( + { ...MOCK_VC, id: undefined as unknown as string }, + { + fetch: mockedFetch as typeof fetch, + verificationEndpoint: MOCK_VERIFY_ENDPOINT, + }, + ), + ).rejects.toThrow( + "Expected vc.id to be a string, found [undefined] of type [undefined] on", + ); }); it("throws if looking up the passed url doesn't resolve to JSON", async () => { @@ -202,10 +260,12 @@ describe("isValidVc", () => { .mockResolvedValueOnce(new Response("Not a valid JSON.")); await expect( isValidVc("https://example.com/someVc", { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"] as typeof fetch, + fetch: mockedFetch as typeof fetch, verificationEndpoint: MOCK_VERIFY_ENDPOINT, }), - ).rejects.toThrow(/Parsing.*example.com\/someVc/); + ).rejects.toThrow( + "Parsing the Verifiable Credential [https://example.com/someVc] as JSON failed:", + ); }); it("throws if the passed url returns a non-vc", async () => { @@ -218,24 +278,23 @@ describe("isValidVc", () => { await expect( isValidVc("https://example.com/someVc", { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"] as typeof fetch, + fetch: mockedFetch as typeof fetch, verificationEndpoint: MOCK_VERIFY_ENDPOINT, }), ).rejects.toThrow( - "The request to [https://example.com/someVc] returned an unexpected response:", + "The value received from [https://example.com/someVc] is not a Verifiable Credential", ); }); it("uses the provided verification endpoint if any", async () => { - const mockedFetch = jest.fn(global.fetch).mockResolvedValueOnce( + const mockedFetch = jest.fn().mockResolvedValueOnce( new Response(JSON.stringify(MOCK_VERIFY_RESPONSE), { status: 200, }), ); - mocked(isVerifiableCredential).mockReturnValueOnce(true); await isValidVc(MOCK_VC, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, verificationEndpoint: "https://some.verification.api", }); expect(mockedFetch).toHaveBeenCalledWith( @@ -250,7 +309,7 @@ describe("isValidVc", () => { legacy: {}, specCompliant: {}, }); - mocked(isVerifiableCredential).mockReturnValueOnce(true); + const mockedFetch = jest .fn(global.fetch) // First, the VC is fetched @@ -266,7 +325,7 @@ describe("isValidVc", () => { await expect( isValidVc(MOCK_VC, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, }), ).rejects.toThrow( `The VC service provider ${MOCK_VC.issuer} does not advertize for a verifier service in its .well-known/vc-configuration document`, @@ -274,17 +333,16 @@ describe("isValidVc", () => { }); it("throws if the verification endpoint returns an error", async () => { - const mockedFetch = jest.fn(global.fetch).mockResolvedValueOnce( + const mockedFetch = jest.fn().mockResolvedValueOnce( new Response(undefined, { status: 400, statusText: "Bad request", }), ); - mocked(isVerifiableCredential).mockReturnValueOnce(true); await expect( isValidVc(MOCK_VC, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"] as typeof fetch, + fetch: mockedFetch as typeof fetch, verificationEndpoint: MOCK_VERIFY_ENDPOINT, }), ).rejects.toThrow(/consent\.example\.com.*400 Bad request/); @@ -294,27 +352,25 @@ describe("isValidVc", () => { const mockedFetch = jest .fn(global.fetch) .mockResolvedValueOnce(new Response("Not a valid JSON")); - mocked(isVerifiableCredential).mockReturnValueOnce(true); await expect( isValidVc(MOCK_VC, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"] as typeof fetch, + fetch: mockedFetch as typeof fetch, verificationEndpoint: MOCK_VERIFY_ENDPOINT, }), ).rejects.toThrow(/Parsing.*consent\.example\.com/); }); it("returns the validation result from the access endpoint", async () => { - const mockedFetch = jest.fn(global.fetch).mockResolvedValueOnce( + const mockedFetch = jest.fn().mockResolvedValueOnce( new Response(JSON.stringify(MOCK_VERIFY_RESPONSE), { status: 200, }), ); - mocked(isVerifiableCredential).mockReturnValueOnce(true); await expect( isValidVc(MOCK_VC, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, verificationEndpoint: "https://some.verification.api", }), ).resolves.toEqual({ checks: [], errors: [], warning: [] }); @@ -331,29 +387,27 @@ describe("isValidVerifiable Presentation", () => { const MOCK_VERIFY_RESPONSE = { checks: [], warning: [], errors: [] }; it("falls back to the embedded fetch if none is provided", async () => { - const mockedFetch = jest.requireMock( - "@inrupt/universal-fetch", - ) as jest.Mocked; - mocked(isVerifiablePresentation).mockReturnValueOnce(true); - mockedFetch.fetch.mockResolvedValueOnce( + spiedFetch.mockResolvedValueOnce( new Response(JSON.stringify(MOCK_VERIFY_RESPONSE), { status: 200, }), ); - await isValidVerifiablePresentation(MOCK_VERIFY_ENDPOINT, MOCK_VP); + await expect( + isValidVerifiablePresentation(MOCK_VERIFY_ENDPOINT, MOCK_VP), + ).resolves.toMatchObject({ errors: [] }); - expect(mockedFetch.fetch).toHaveBeenCalled(); + expect(spiedFetch).toHaveBeenCalled(); }); it("uses the provided fetch if any", async () => { - const mockedFetch = jest.fn(global.fetch).mockResolvedValueOnce( + const mockedFetch = jest.fn().mockResolvedValueOnce( new Response(JSON.stringify(MOCK_VERIFY_RESPONSE), { status: 200, }), ); mocked(isVerifiablePresentation).mockReturnValueOnce(true); await isValidVerifiablePresentation(MOCK_VERIFY_ENDPOINT, MOCK_VP, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, domain: "domain", challenge: "challenge", }); @@ -363,14 +417,14 @@ describe("isValidVerifiable Presentation", () => { it("sends the given vp to the verify endpoint", async () => { mocked(isVerifiablePresentation).mockReturnValueOnce(true); - const mockedFetch = jest.fn(global.fetch).mockResolvedValueOnce( + const mockedFetch = jest.fn().mockResolvedValueOnce( new Response(JSON.stringify(MOCK_VERIFY_RESPONSE), { status: 200, }), ); await isValidVerifiablePresentation(MOCK_VERIFY_ENDPOINT, MOCK_VP, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, domain: "domain", challenge: "challenge", }); @@ -387,7 +441,7 @@ describe("isValidVerifiable Presentation", () => { }); it("uses the provided verification endpoint if any", async () => { - const mockedFetch = jest.fn(global.fetch).mockResolvedValueOnce( + const mockedFetch = jest.fn().mockResolvedValueOnce( new Response(JSON.stringify(MOCK_VERIFY_RESPONSE), { status: 200, }), @@ -398,7 +452,7 @@ describe("isValidVerifiable Presentation", () => { "https://some.verification.api", MOCK_VP, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, }, ); expect(mockedFetch).toHaveBeenCalledWith( @@ -418,16 +472,14 @@ describe("isValidVerifiable Presentation", () => { }); mocked(isVerifiablePresentation).mockReturnValueOnce(true); - const mockedFetch = jest - .fn<(typeof UniversalFetch)["fetch"]>() - .mockResolvedValueOnce( - new Response(JSON.stringify(MOCK_VERIFY_RESPONSE), { - status: 200, - }), - ); + const mockedFetch = jest.fn().mockResolvedValueOnce( + new Response(JSON.stringify(MOCK_VERIFY_RESPONSE), { + status: 200, + }), + ); await isValidVerifiablePresentation(null, MOCK_VP, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, }); expect(mockedDiscovery).toHaveBeenCalledWith(MOCK_VP.holder); }); @@ -438,7 +490,7 @@ describe("isValidVerifiable Presentation", () => { specCompliant: {}, }); mocked(isVerifiablePresentation).mockReturnValueOnce(true); - const mockedFetch = jest.fn(global.fetch).mockResolvedValueOnce( + const mockedFetch = jest.fn().mockResolvedValueOnce( new Response(JSON.stringify(MOCK_VERIFY_RESPONSE), { status: 200, }), @@ -446,32 +498,153 @@ describe("isValidVerifiable Presentation", () => { await expect( isValidVerifiablePresentation(null, MOCK_VP, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"], + fetch: mockedFetch, }), ).rejects.toThrow( `The VC service provider ${MOCK_VP.holder} does not advertize for a verifier service in its .well-known/vc-configuration document`, ); }); - it("throws if passed VP is not a verifiable presentation", async () => { - const mockedFetch = jest.fn(global.fetch); + it("throws if passed VP is not a verifiable presentation [because the VC has no id]", async () => { + const mockedFetch = jest.fn(); mocked(isVerifiablePresentation).mockReturnValueOnce(false); + const MOCK_VP_NO_ID = { + ...MOCK_VP, + verifiableCredential: [ + { + ...MOCK_VC, + id: undefined as unknown as string, + }, + ], + }; + await expect( - isValidVerifiablePresentation(MOCK_VERIFY_ENDPOINT, MOCK_VP, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"] as typeof fetch, + isValidVerifiablePresentation(MOCK_VERIFY_ENDPOINT, MOCK_VP_NO_ID, { + fetch: mockedFetch as typeof fetch, }), ).rejects.toThrow( - `The request to [${MOCK_VP}] returned an unexpected response: ${JSON.stringify( - MOCK_VP, + `Expected vc.id to be a string, found [undefined] of type [undefined] on ${JSON.stringify( + { + ...MOCK_VC, + id: undefined as unknown as string, + }, null, " ", )}`, ); }); + it("throws if passed VP is not a verifiable presentation [because it is the wrong type]", async () => { + const mockedFetch = jest.fn(); + mocked(isVerifiablePresentation).mockReturnValueOnce(false); + + const MOCK_VP_NO_ID = Object.assign( + await jsonLdStringToStore(JSON.stringify(MOCK_VP)), + { verifiableCredential: [] }, + ); + for (const quadToDelete of MOCK_VP_NO_ID.match( + null, + rdf.type, + null, + null, + )) { + MOCK_VP_NO_ID.delete(quadToDelete); + } + + await expect( + isValidVerifiablePresentation(MOCK_VERIFY_ENDPOINT, MOCK_VP_NO_ID, { + fetch: mockedFetch as typeof fetch, + }), + ).rejects.toThrow("Expected exactly one Verifiable Presentation. Found 0."); + }); + + it("throws if passed VP is not a verifiable presentation [because it has too many holders]", async () => { + const mockedFetch = jest.fn(); + mocked(isVerifiablePresentation).mockReturnValueOnce(false); + + const MOCK_VP_EXTRA_HOLDER = Object.assign( + await jsonLdStringToStore(JSON.stringify(MOCK_VP)), + { verifiableCredential: [] }, + ); + MOCK_VP_EXTRA_HOLDER.add( + quad( + getVpSubject(MOCK_VP_EXTRA_HOLDER), + cred.holder, + namedNode("http://example.org/another/holder"), + ), + ); + + expect(() => + getHolder(MOCK_VP_EXTRA_HOLDER, getVpSubject(MOCK_VP_EXTRA_HOLDER)), + ).toThrow("Could not find a valid holder"); + await expect( + isValidVerifiablePresentation( + MOCK_VERIFY_ENDPOINT, + MOCK_VP_EXTRA_HOLDER, + { + fetch: mockedFetch as typeof fetch, + }, + ), + ).rejects.toThrow( + "The request to [[object Object]] returned an unexpected response", + ); + }); + + it("throws if passed VP is not a verifiable presentation [included VC is missing type]", async () => { + const mockedFetch = jest.fn(); + mocked(isVerifiablePresentation).mockReturnValueOnce(false); + + const VC_STORE = await jsonLdStringToStore(JSON.stringify(MOCK_VC)); + VC_STORE.delete( + quad(namedNode(MOCK_VC.id), rdf.type, cred.VerifiableCredential), + ); + + const MOCK_VP_EXTRA_HOLDER = Object.assign( + await jsonLdStringToStore(JSON.stringify(MOCK_VP)), + { verifiableCredential: [Object.assign(VC_STORE, { id: MOCK_VC.id })] }, + ); + await expect( + isValidVerifiablePresentation( + MOCK_VERIFY_ENDPOINT, + MOCK_VP_EXTRA_HOLDER, + { + fetch: mockedFetch as typeof fetch, + }, + ), + ).rejects.toThrow( + "The request to [[object Object]] returned an unexpected response", + ); + await expect( + isValidVc(Object.assign(VC_STORE, { id: MOCK_VC.id }), { + fetch: mockedFetch as typeof fetch, + }), + ).rejects.toThrow( + "The request to [[object Object]] returned an unexpected response", + ); + }); + + it("throws if passed VP is not a verifiable presentation [because it has an invalid subject]", async () => { + const mockedFetch = jest.fn(); + mocked(isVerifiablePresentation).mockReturnValueOnce(false); + + const MOCK_VP_NO_TYPE = new Store([ + // @ts-expect-error literals should not be subjects of triples + quad(literal("vp subpect"), rdf.type, cred.VerifiablePresentation), + ]); + + await expect( + // @ts-expect-error the vp is also missing the verifiableCredential property + isValidVerifiablePresentation(MOCK_VERIFY_ENDPOINT, MOCK_VP_NO_TYPE, { + fetch: mockedFetch as typeof fetch, + }), + ).rejects.toThrow( + "Expected VP subject to be NamedNode or BlankNode. Instead found [vp subpect] with termType [Literal]", + ); + }); + it("throws if response is not valid JSON", async () => { - const mockedFetch = jest.fn(global.fetch).mockResolvedValueOnce( + const mockedFetch = jest.fn().mockResolvedValueOnce( new Response(`some non-JSON response`, { status: 200, }), @@ -480,7 +653,7 @@ describe("isValidVerifiable Presentation", () => { await expect( isValidVerifiablePresentation(MOCK_VERIFY_ENDPOINT, MOCK_VP, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"] as typeof fetch, + fetch: mockedFetch as typeof fetch, }), ).rejects.toThrow( `Parsing the response of the verification service hosted at [${MOCK_VERIFY_ENDPOINT}] as JSON failed:`, @@ -488,7 +661,7 @@ describe("isValidVerifiable Presentation", () => { }); it("throws if the verification endpoint returns an error", async () => { - const mockedFetch = jest.fn(global.fetch).mockResolvedValueOnce( + const mockedFetch = jest.fn().mockResolvedValueOnce( new Response(undefined, { status: 400, statusText: "Bad request", @@ -498,22 +671,21 @@ describe("isValidVerifiable Presentation", () => { await expect( isValidVerifiablePresentation(MOCK_VERIFY_ENDPOINT, MOCK_VP, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"] as typeof fetch, + fetch: mockedFetch as typeof fetch, }), ).rejects.toThrow(/consent\.example\.com.*400 Bad request/); }); it("returns the validation result from the verification endpoint", async () => { - const mockedFetch = jest.fn(global.fetch).mockResolvedValueOnce( + const mockedFetch = jest.fn().mockResolvedValueOnce( new Response(JSON.stringify(MOCK_VERIFY_RESPONSE), { status: 200, }), ); mocked(isVerifiablePresentation).mockReturnValueOnce(true); - await expect( isValidVerifiablePresentation(MOCK_VERIFY_ENDPOINT, MOCK_VP, { - fetch: mockedFetch as (typeof UniversalFetch)["fetch"] as typeof fetch, + fetch: mockedFetch as typeof fetch, }), ).resolves.toEqual({ checks: [], errors: [], warning: [] }); }); diff --git a/src/verify/verify.ts b/src/verify/verify.ts index 57b87f71..41779503 100644 --- a/src/verify/verify.ts +++ b/src/verify/verify.ts @@ -24,47 +24,50 @@ */ import type { UrlString } from "@inrupt/solid-client"; -import { fetch as fallbackFetch } from "@inrupt/universal-fetch"; +import type { DatasetCore } from "@rdfjs/types"; +import { DataFactory } from "n3"; import type { - VerifiableCredential, + DatasetWithId, + VerifiableCredentialBase, VerifiablePresentation, } from "../common/common"; import { + getVerifiableCredential, getVerifiableCredentialApiConfiguration, - isVerifiableCredential, - isVerifiablePresentation, - normalizeVc, + hasId, + verifiableCredentialToDataset, } from "../common/common"; +import { getId, getIssuer } from "../common/getters"; +import isRdfjsVerifiableCredential from "../common/isRdfjsVerifiableCredential"; +import isRdfjsVerifiablePresentation, { + getHolder, + getVpSubject, +} from "../common/isRdfjsVerifiablePresentation"; +import type { + MinimalPresentation, + ParsedVerifiablePresentation, +} from "../lookup/query"; +import type { ParseOptions } from "../parser/jsonld"; + +const { namedNode } = DataFactory; async function dereferenceVc( - vc: VerifiableCredential | URL | UrlString, - fetcher: typeof fallbackFetch, -): Promise { + vc: VerifiableCredentialBase | DatasetWithId | URL | UrlString, + options?: ParseOptions & { + requireId?: boolean; + }, +): Promise { // This test passes for both URL and UrlString if (!vc.toString().startsWith("http")) { - return vc as VerifiableCredential; - } - // vc is either an IRI-shaped string or a URL object. In both - // cases, vc.toString() is an IRI. - const vcResponse = await fetcher(vc.toString()); - if (!vcResponse.ok) { - throw new Error( - `Dereferencing [${vc.toString()}] failed: ${vcResponse.status} ${ - vcResponse.statusText - }`, - ); - } - try { - return normalizeVc(await vcResponse.json()); - } catch (e) { - throw new Error( - `Parsing the value obtained when dereferencing [${vc.toString()}] as JSON failed: ${ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (e as any).toString() - }`, - ); + if (typeof (vc as DatasetCore).match === "function") { + return vc as DatasetCore; + } + return verifiableCredentialToDataset(vc as VerifiableCredentialBase, { + requireId: true, + }); } + return getVerifiableCredential(vc.toString(), options); } /** @@ -86,17 +89,21 @@ async function dereferenceVc( * @since 0.3.0 */ export async function isValidVc( - vc: VerifiableCredential | URL | UrlString, - options: Partial<{ + vc: VerifiableCredentialBase | DatasetWithId | URL | UrlString, + options?: Partial<{ fetch?: typeof fetch; verificationEndpoint?: UrlString; - }> = {}, + }> & + ParseOptions, ): Promise<{ checks: string[]; warnings: string[]; errors: string[] }> { - const fetcher = options.fetch ?? fallbackFetch; + const fetcher = options?.fetch ?? fetch; - const vcObject = await dereferenceVc(vc, fetcher); + const vcObject = await dereferenceVc(vc, options); - if (!isVerifiableCredential(vcObject)) { + if ( + !hasId(vcObject) || + !isRdfjsVerifiableCredential(vcObject, namedNode(getId(vcObject))) + ) { throw new Error( `The request to [${vc}] returned an unexpected response: ${JSON.stringify( vcObject, @@ -108,13 +115,15 @@ export async function isValidVc( // Discover the consent endpoint from the resource part of the Access Grant. const verifierEndpoint = - options.verificationEndpoint ?? - (await getVerifiableCredentialApiConfiguration(vcObject.issuer)) + options?.verificationEndpoint ?? + (await getVerifiableCredentialApiConfiguration(getIssuer(vcObject))) .verifierService; if (verifierEndpoint === undefined) { throw new Error( - `The VC service provider ${vcObject.issuer} does not advertize for a verifier service in its .well-known/vc-configuration document`, + `The VC service provider ${getIssuer( + vcObject, + )} does not advertize for a verifier service in its .well-known/vc-configuration document`, ); } @@ -146,6 +155,25 @@ export async function isValidVc( } } +async function asDataset( + data: DatasetCore | VerifiablePresentation, + requireId: true, +): Promise; +async function asDataset( + data: DatasetCore | VerifiablePresentation, + requireId: boolean, +): Promise; +async function asDataset( + data: DatasetCore | VerifiablePresentation, + requireId: boolean, +): Promise { + return typeof (data as DatasetCore).match === "function" + ? (data as DatasetCore) + : verifiableCredentialToDataset(data as VerifiablePresentation, { + requireId, + }); +} + /** * Verify that a VP is valid and content has not ben tampered with. * @@ -163,36 +191,62 @@ export async function isValidVc( */ export async function isValidVerifiablePresentation( verificationEndpoint: string | null, - verifiablePresentation: VerifiablePresentation, + verifiablePresentation: + | VerifiablePresentation + | MinimalPresentation + | ParsedVerifiablePresentation, options: Partial<{ fetch: typeof fetch; domain: string; challenge: string; }> = {}, ): Promise<{ checks: string[]; warnings: string[]; errors: string[] }> { - const fetcher = options.fetch ?? fallbackFetch; + const fetcher = options.fetch ?? fetch; + const dataset = await asDataset(verifiablePresentation, false); + const subject = getVpSubject(dataset); - if (!isVerifiablePresentation(verifiablePresentation)) { + if (!isRdfjsVerifiablePresentation(dataset, subject)) { throw new Error( - `The request to [${verifiablePresentation}] returned an unexpected response: ${JSON.stringify( - verifiablePresentation, + `The request to [${dataset}] returned an unexpected response: ${JSON.stringify( + dataset, null, " ", )}`, ); } + if (verifiablePresentation.verifiableCredential) { + const datasets = await Promise.all( + verifiablePresentation.verifiableCredential.map(async (vc) => { + const vcDataset = await asDataset(vc, true); + return isRdfjsVerifiableCredential( + vcDataset, + namedNode(getId(vcDataset)), + ); + }), + ); + if (datasets.some((vc) => vc === false)) { + throw new Error( + `The request to [${dataset}] returned an unexpected response: ${JSON.stringify( + dataset, + null, + " ", + )}`, + ); + } + } + const verifierEndpoint = verificationEndpoint ?? - ( - await getVerifiableCredentialApiConfiguration( - verifiablePresentation.holder as string, - ) - ).verifierService; + (await getVerifiableCredentialApiConfiguration(getHolder(dataset, subject))) + .verifierService; if (verifierEndpoint === undefined) { throw new Error( - `The VC service provider ${verifiablePresentation.holder} does not advertize for a verifier service in its .well-known/vc-configuration document`, + `The VC service provider ${getHolder( + dataset, + subject, + )} does not advertize for a verifier service in its .well-known/vc-configuration document`, ); } @@ -202,7 +256,7 @@ export async function isValidVerifiablePresentation( }, method: "POST", body: JSON.stringify({ - verifiablePresentation, + verifiablePresentation: dataset, options: { domain: options.domain, challenge: options.challenge,