diff --git a/CHANGELOG.md b/CHANGELOG.md index 28917105..afdd75ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ instead of a single proof on a credential). Each cryptosuite can also have `options` passed and doing so will additionally prevent clients from overriding them (e.g., `options.mandatoryPointers`). +- Add `/credentials/` endpoint for retrieving + previously issued VCs, provided that those VCs included `credentialStatus`. ### Changed - **BREAKING**: Management of status list index allocation has been rewritten diff --git a/lib/config.js b/lib/config.js index 0178a808..5c78fb69 100644 --- a/lib/config.js +++ b/lib/config.js @@ -21,6 +21,7 @@ cfg.documentLoader = { }; cfg.routes = { + credentials: '/credentials', credentialsIssue: '/credentials/issue' }; diff --git a/lib/http.js b/lib/http.js index 5568d645..bbd39305 100644 --- a/lib/http.js +++ b/lib/http.js @@ -6,11 +6,14 @@ import {metering, middleware} from '@bedrock/service-core'; import {asyncHandler} from '@bedrock/express'; import bodyParser from 'body-parser'; import cors from 'cors'; +import {getDocumentStore} from './helpers.js'; import {issue} from './issuer.js'; import {issueCredentialBody} from '../schemas/bedrock-vc-issuer.js'; import {logger} from './logger.js'; import {createValidateMiddleware as validate} from '@bedrock/validation'; +const {util: {BedrockError}} = bedrock; + // FIXME: remove and apply at top-level application bedrock.events.on('bedrock-express.configure.bodyParser', app => { app.use(bodyParser.json({ @@ -27,8 +30,8 @@ export async function addRoutes({app, service} = {}) { const cfg = bedrock.config['vc-issuer']; const baseUrl = `${routePrefix}/:localId`; const routes = { + credential: `${baseUrl}${cfg.routes.credentials}/:credentialId`, credentialsIssue: `${baseUrl}${cfg.routes.credentialsIssue}`, - credentialsStatus: `${baseUrl}${cfg.routes.credentialsStatus}`, publishSlc: `${baseUrl}${cfg.routes.publishSlc}`, publishTerseSlc: `${baseUrl}${cfg.routes.publishTerseSlc}`, slc: `${baseUrl}${cfg.routes.slc}`, @@ -41,6 +44,44 @@ export async function addRoutes({app, service} = {}) { uses HTTP signatures + capabilities or OAuth2, not cookies; CSRF is not possible. */ + // return a previously issued VC, if it has `credentialStatus` + app.options(routes.credential, cors()); + app.get( + routes.credential, + cors(), + getConfigMiddleware, + middleware.authorizeServiceObjectRequest(), + asyncHandler(async (req, res) => { + try { + const {config} = req.serviceObject; + const {credentialId} = req.params; + const {edvClient} = await getDocumentStore({config}); + const {documents: [doc]} = await edvClient.find({ + equals: {'meta.credentialId': credentialId} + }); + if(!doc) { + throw new BedrockError('Credential not found.', { + name: 'NotFoundError', + details: { + credentialId, + httpStatusCode: 404, + public: true + } + }); + } + const {content} = doc; + res.status(200).json({ + verifiableCredential: content + }); + } catch(error) { + logger.error(error.message, {error}); + throw error; + } + + // meter operation usage + metering.reportOperationUsage({req}); + })); + // issue a VC app.options(routes.credentialsIssue, cors()); app.post( diff --git a/test/mocha/20-credentials.js b/test/mocha/20-credentials.js index c2368a1a..afcd8ca7 100644 --- a/test/mocha/20-credentials.js +++ b/test/mocha/20-credentials.js @@ -1,6 +1,7 @@ /*! * Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved. */ +import * as assertions from './assertions.js'; import * as helpers from './helpers.js'; import {agent} from '@bedrock/https-agent'; import {createRequire} from 'node:module'; @@ -648,11 +649,11 @@ describe('issue APIs', () => { }); it('issues a valid credential w/ "credentialStatus" and ' + 'suspension status purpose', async () => { + const zcapClient = helpers.createZcapClient({capabilityAgent}); const credential = klona(mockCredential); let error; let result; try { - const zcapClient = helpers.createZcapClient({capabilityAgent}); result = await zcapClient.write({ url: `${bslSuspensionIssuerId}/credentials/issue`, capability: bslSuspensionRootZcap, @@ -679,6 +680,14 @@ describe('issue APIs', () => { should.exist(verifiableCredential.credentialStatus); should.exist(verifiableCredential.proof); verifiableCredential.proof.should.be.an('object'); + + await assertions.assertStoredCredential({ + configId: bslSuspensionIssuerId, + credentialId: verifiableCredential.id, + zcapClient, + capability: bslSuspensionRootZcap, + expectedCredential: verifiableCredential + }); }); it('issues a valid credential w/ terse "credentialStatus" for ' + 'both revocation and suspension status purpose', async () => { diff --git a/test/mocha/assertions.js b/test/mocha/assertions.js new file mode 100644 index 00000000..e7b14e3b --- /dev/null +++ b/test/mocha/assertions.js @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 Digital Bazaar, Inc. All rights reserved. + */ +import {CapabilityAgent} from '@digitalbazaar/webkms-client'; +import {createZcapClient} from './helpers.js'; + +export async function assertStoredCredential({ + configId, credentialId, zcapClient, capability, expectedCredential} = {}) { + const url = `${configId}/credentials/${encodeURIComponent(credentialId)}`; + + let error; + let result; + try { + result = await zcapClient.read({url, capability}); + } catch(e) { + error = e; + } + assertNoError(error); + should.exist(result.data); + should.exist(result.data.verifiableCredential); + result.data.verifiableCredential.should.deep.equal(expectedCredential); + + // fail to fetch using unauthorized party + { + let error; + let result; + try { + const secret = crypto.randomUUID(); + const handle = 'test'; + const capabilityAgent = await CapabilityAgent.fromSecret({ + secret, handle + }); + const zcapClient = createZcapClient({capabilityAgent}); + result = await zcapClient.read({url, capability}); + } catch(e) { + error = e; + } + should.not.exist(result); + should.exist(error?.data?.name); + error.status.should.equal(403); + error.data.name.should.equal('NotAllowedError'); + } +}