@sphereon/ssi-sdk.uni-resolver-registrar-api
Version:
330 lines (306 loc) • 13 kB
text/typescript
import { DIDResolutionResult } from '@sphereon/did-uni-client'
import { getAgentDIDMethods, toDidDocument, toDidResolutionResult } from '@sphereon/ssi-sdk-ext.did-utils'
import { JwkKeyUse } from '@sphereon/ssi-sdk-ext.key-utils'
import { checkAuth, ISingleEndpointOpts, sendErrorResponse } from '@sphereon/ssi-express-support'
import { LinkedVPServiceEntry } from '@sphereon/ssi-sdk.linked-vp'
import { parseDid } from '@sphereon/ssi-types'
import { IIdentifier } from '@veramo/core'
import { Service } from 'did-resolver'
import { Request, Response, Router } from 'express'
import { v4 } from 'uuid'
import {
CreateState,
DidRegistrationCreateRequest,
DidRegistrationDeactivateRequest,
DidStateValue,
ICreateDidEndpointOpts,
IGlobalDidWebEndpointOpts,
IRequiredContext,
IResolveEndpointOpts,
} from './types'
import Debug from 'debug'
const debug = Debug('sphereon:ssi-sdk:uni-resolver-registrar')
export function createDidEndpoint(router: Router, context: IRequiredContext, opts?: ICreateDidEndpointOpts) {
if (opts?.enabled === false) {
console.log(`create DID endpoint is disabled`)
return
}
const path = opts?.path ?? '/identifiers'
router.post(path, checkAuth(opts?.endpoint), async (request: Request, response: Response) => {
try {
const createRequest: DidRegistrationCreateRequest = request.body
if (!createRequest) {
return sendErrorResponse(response, 400, 'No DID create request present')
}
const did = createRequest.did
const didMethod = (request.query.method as string) ?? (did ? parseDid(did).method : opts?.defaultMethod)
const allDidMethods = await getAgentDIDMethods(context)
if (!didMethod) {
return sendErrorResponse(response, 400, 'No DID method supplied or deductible')
} else if (did && parseDid(did).method != didMethod) {
return sendErrorResponse(response, 400, 'DID method did not match method param')
} else if (!allDidMethods.includes(didMethod)) {
return sendErrorResponse(response, 400, 'DID method not supported')
}
const provider = `did:${didMethod}`
const jobId = createRequest.jobId ?? v4()
let alias: string | undefined = undefined
if (didMethod === 'web') {
if (!did) {
return sendErrorResponse(response, 400, 'Please provide a value for "did" in the request body when creating a DID web')
}
alias = parseDid(did).id
if (!alias) {
return sendErrorResponse(response, 400, 'Could not determine alias from did:web DID value: ' + did)
}
}
let identifier: IIdentifier | undefined
let state: DidStateValue | undefined
if (opts?.noErrorOnExistingDid && did) {
try {
identifier = await context.agent.didManagerGet({ did })
state = 'exists'
} catch (e) {
// Okay, since we will create a new one
}
}
if (identifier === undefined) {
if (createRequest.options.storeSecrets === false) {
return sendErrorResponse(response, 400, 'Only storeSecrets mode is supported currently')
/*const memoryKMS = new SphereonKeyManager({
store: new MemoryKeyStore(),
kms: {'mem': new KeyManagementSystem(new MemoryPrivateKeyStore())}
})
identifier = await memoryKMS..didManagerCreate({provider, alias, kms: opts?.kms})*/
} else if (createRequest.options.storeSecrets || opts?.storeSecrets) {
identifier = await context.agent.didManagerCreate({ provider, alias, kms: opts?.kms })
state = 'finished'
} else {
return sendErrorResponse(response, 400, 'Only storeSecrets mode is supported currently')
}
}
if (!identifier || !state) {
return sendErrorResponse(response, 400, 'An identifier and did state should be present at this point')
}
const didDocument = toDidDocument(identifier, { did, use: [JwkKeyUse.Signature, JwkKeyUse.Encryption] })
const createState: CreateState = {
jobId,
didState: {
did: identifier.did,
state,
didDocument,
},
}
response.statusCode = 200
return response.send(createState)
} catch (e) {
return sendErrorResponse(response, 500, e.message as string, e)
}
})
}
export function getDidMethodsEndpoint(router: Router, context: IRequiredContext, opts?: ISingleEndpointOpts) {
if (opts?.enabled === false) {
console.log(`Get DID methods endpoint is disabled`)
return
}
const path = opts?.path ?? '/methods'
router.get(path, checkAuth(opts?.endpoint), async (request: Request, response: Response) => {
try {
const methods = await getAgentDIDMethods(context) // these are already without the 'did:' prefix
response.statusCode = 200
return response.send(methods)
} catch (e) {
return sendErrorResponse(response, 500, e.message as string, e)
}
})
}
async function agentDidToResolutionResult(context: IRequiredContext, did: string) {
try {
const identifier = await context.agent.didManagerGet({ did })
debug(JSON.stringify(identifier, null, 2))
return toDidResolutionResult(identifier, {
did,
supportedMethods: await getAgentDIDMethods(context),
})
} catch (error) {
console.log(JSON.stringify(error.message))
return {
didDocument: null,
didResolutionMetadata: {
error: 'notFound',
},
didDocumentMetadata: {},
}
}
}
export function resolveDidEndpoint(router: Router, context: IRequiredContext, opts?: IResolveEndpointOpts) {
if (opts?.enabled === false) {
console.log(`Resolve DID endpoint is disabled`)
return
}
const path = opts?.path ?? '/identifiers/:identifier'
router.get(path, checkAuth(opts?.endpoint), async (request: Request, response: Response) => {
try {
const did = request.params.identifier
if (!did) {
return sendErrorResponse(response, 400, 'no identifier provided')
}
const mode = request.query.mode?.toString().toLowerCase() ?? opts?.mode?.toLowerCase() ?? 'hybrid'
let resolutionResult: DIDResolutionResult | undefined
if (mode === 'local' || mode === 'hybrid') {
resolutionResult = await agentDidToResolutionResult(context, did)
}
if (mode !== 'local' && !resolutionResult?.didDocument) {
resolutionResult = await context.agent.resolveDid({ didUrl: did })
}
response.statusCode = 200
return response.send(resolutionResult)
} catch (e) {
return sendErrorResponse(response, 500, e.message as string, e)
}
})
}
/**
* @param router
* @param context
* @param opts
*/
export function deleteDidEndpoint(router: Router, context: IRequiredContext, opts?: ISingleEndpointOpts) {
if (opts?.enabled === false) {
console.log(`Deactivate DID endpoint is disabled`)
return
}
router.delete(opts?.path ?? '/identifiers/:identifier', checkAuth(opts?.endpoint), async (request: Request, response: Response) => {
try {
const did = request.params.identifier
if (!did) {
return sendErrorResponse(response, 400, 'no DID provided')
}
const result = await context.agent.didManagerDelete({ did })
if (!result) {
return sendErrorResponse(response, 404, `id ${did} not found`)
}
response.statusCode = 200
return response.send()
} catch (e) {
return sendErrorResponse(response, 500, e.message as string, e)
}
})
}
export function deactivateDidEndpoint(router: Router, context: IRequiredContext, opts?: ISingleEndpointOpts) {
if (opts?.enabled === false) {
console.log('Deactivate DID endpoint is disabled')
return
}
router.post(opts?.path ?? '/deactivate', checkAuth(opts?.endpoint), async (request: Request, response: Response) => {
try {
const deactivateRequest: DidRegistrationDeactivateRequest = request.body
if (!deactivateRequest) {
return sendErrorResponse(response, 400, 'Invalid request body', { state: 'failed' })
}
const { did, jobId = v4() } = deactivateRequest
if (!did) {
return sendErrorResponse(response, 400, 'No DID provided', { state: 'failed' })
}
const result = await context.agent.didManagerDelete({ did })
if (!result) {
return sendErrorResponse(response, 404, `DID ${did} not found`, { state: 'failed' })
}
response.status(200).json({
state: 'finished',
did,
jobId,
})
return response.send()
} catch (e) {
return sendErrorResponse(response, 500, e.message as string, { state: 'failed', errorDetails: e })
}
})
}
/**
* Endpoint that eases DID web resolution, by mapping did-web paths to stored agent DIDs.
*
* Typically, you will have a reverse proxy or load balancer in front of this endpoint.
*
* Some examples of how did:web behaves:
* did:web:example.com resolves to https://example.com/.well-known/did.json
* did:web:example.com:sub:paths resolves to https://example.com/sub/paths/did.json
*
* This endpoint translate both forms by looking at the paths that end in /did.json.
*
* @param router
* @param context
* @param opts
*/
export function didWebDomainEndpoint(router: Router, context: IRequiredContext, opts?: IGlobalDidWebEndpointOpts) {
if (opts?.enabled === false) {
console.log(`DID Web domain resolution endpoint is disabled`)
return
}
router.get(opts?.path ?? ':path(*)/did.json', checkAuth(opts?.endpoint), async (request: Request, response: Response) => {
try {
const path = request.params.path
if (!path || path.length === 0) {
return sendErrorResponse(response, 404, 'Not found')
}
let did: string
did = `did:web:${opts?.hostname?.replace('https://', '')?.replace('http://', '') ?? request.hostname}`
if (path !== '/.well-known') {
if (opts?.disableSubPaths) {
return sendErrorResponse(response, 404, 'Not found')
}
const suffix = path.replace(/\//g, ':').replace(/%2F/g, ':')
if (!suffix.startsWith(':')) {
did += ':'
}
did += suffix
} else if (opts?.disableWellKnown) {
return sendErrorResponse(response, 404, 'Not found')
}
const resolutionResult = await agentDidToResolutionResult(context, did)
if (!resolutionResult || !resolutionResult.didDocument || resolutionResult?.didResolutionMetadata?.error === 'notFound') {
return sendErrorResponse(response, 404, 'Not found')
}
const serviceEntries: Array<LinkedVPServiceEntry> = await context.agent.lvpGetServiceEntries({ subjectDid: did })
if (resolutionResult?.didDocument && serviceEntries) {
// Preserve existing services that are not LinkedVerifiablePresentation type
const existingServices = resolutionResult.didDocument.service || []
const nonLVPServices = existingServices.filter((service) => service.type !== 'LinkedVerifiablePresentation')
// Combine non-LVP services with the new LVP service entries
resolutionResult.didDocument.service = [...nonLVPServices, ...serviceEntries] as Array<Service>
}
// Enrich services with metadata from the database (for eInvoicing services)
// Also remove null/undefined values from service objects
const cleanService = (svc: Service): Service => {
return Object.fromEntries(Object.entries(svc).filter(([_, v]) => v !== null && v !== undefined)) as Service
}
if (resolutionResult?.didDocument?.service && typeof context.agent.getServiceMetadata === 'function') {
const enrichedServices = await Promise.all(
resolutionResult.didDocument.service.map(async (service: Service) => {
try {
const metadata = await context.agent.getServiceMetadata({
serviceId: service.id as string,
did,
})
if (metadata?.einvoice) {
return cleanService({ ...service, einvoice: metadata.einvoice })
}
} catch (e) {
// Service metadata not available, continue with basic service
debug(`No metadata found for service ${service.id}`)
}
return cleanService(service)
}),
)
resolutionResult.didDocument.service = enrichedServices as Array<Service>
} else if (resolutionResult?.didDocument?.service) {
// Clean services even without metadata enrichment
resolutionResult.didDocument.service = resolutionResult.didDocument.service.map(cleanService) as Array<Service>
}
response.statusCode = 200
return response.send(resolutionResult.didDocument)
} catch (e) {
return sendErrorResponse(response, 500, e.message as string, e)
}
})
}