@sphereon/ssi-sdk.oid4vci-issuer-rest-api
Version:
275 lines (243 loc) • 11.3 kB
text/typescript
import { CredentialDataSupplier, CredentialSignerCallback, VcIssuer } from '@sphereon/oid4vci-issuer'
import { getBasePath, OID4VCIServer } from '@sphereon/oid4vci-issuer-server'
import { IOID4VCIServerOpts } from '@sphereon/oid4vci-issuer-server'
import { ExpressSupport } from '@sphereon/ssi-express-support'
import { isDidIdentifier, isIIdentifier } from '@sphereon/ssi-sdk-ext.identifier-resolution'
import { ensureRawDocument } from '@sphereon/ssi-sdk.data-store-types'
import { AddCredentialArgs, CredentialCorrelationType } from '@sphereon/ssi-sdk.credential-store'
import {
createAuthRequestUriCallback,
getAccessTokenSignerCallback,
IIssuerInstanceArgs,
IssuerInstance,
createVerifyAuthResponseCallback,
} from '@sphereon/ssi-sdk.oid4vci-issuer'
import { CredentialMapper, CredentialRole, OriginalVerifiableCredential } from '@sphereon/ssi-types'
import express, { Express, Request, Response, Router } from 'express'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { IRequiredContext } from './types'
import swaggerUi from 'swagger-ui-express'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
export interface IOID4VCIRestAPIOpts extends IOID4VCIServerOpts {
/**
* When `true`, every credential issued through this OID4VCI REST API is persisted as a
* {@link CredentialRole.ISSUER} `DigitalCredential` row via the `ICredentialStore` plugin
* (agent method `crsAddCredential`). Requires the `credential-store` plugin to be present
* on the agent. Defaults to `false`.
*
* Persistence failures are surfaced (fail-fast): when this flag is enabled and the store
* call throws, the enclosing issuance request fails with an error rather than silently
* dropping the row. That is intentional — a silent drop is exactly the defect DEV-35 fixes.
*/
persistIssuedCredentials?: boolean
}
export class OID4VCIRestAPI {
private readonly _expressSupport: ExpressSupport
private readonly _context: IRequiredContext
private readonly _opts?: IOID4VCIRestAPIOpts
private readonly _restApi: OID4VCIServer
private readonly _instance: IssuerInstance
private readonly _issuer: VcIssuer
private readonly _router: Router
private _baseUrl: URL
private _basePath: string
static async init(args: {
context: IRequiredContext
issuerInstanceArgs: IIssuerInstanceArgs
credentialDataSupplier?: CredentialDataSupplier
expressSupport: ExpressSupport
opts?: IOID4VCIRestAPIOpts
}): Promise<OID4VCIRestAPI> {
const { issuerInstanceArgs, context } = args
const opts = args.opts ?? {}
const expressSupport = args.expressSupport
const instance = await context.agent.oid4vciGetInstance(args.issuerInstanceArgs)
const wrapCredentialSignerCallback = opts.persistIssuedCredentials
? OID4VCIRestAPI.buildPersistenceSignerWrapper({ context, instance })
: undefined
const issuer = await instance.get({ context, credentialDataSupplier: args.credentialDataSupplier, wrapCredentialSignerCallback })
if (!opts.endpointOpts) {
opts.endpointOpts = {}
}
if (!opts.endpointOpts.tokenEndpointOpts) {
opts.endpointOpts.tokenEndpointOpts = {
accessTokenIssuer: instance.metadataOptions.credentialIssuer ?? issuer.issuerMetadata.credential_issuer,
}
}
if (
opts?.endpointOpts.tokenEndpointOpts?.tokenEndpointDisabled !== true &&
typeof opts?.endpointOpts.tokenEndpointOpts?.accessTokenSignerCallback !== 'function'
) {
const idOpts = instance.issuerOptions.idOpts
const tokenOpts = {
iss: opts.endpointOpts.tokenEndpointOpts.accessTokenIssuer ?? instance.metadataOptions.credentialIssuer,
didOpts: instance.issuerOptions.didOpts,
idOpts,
}
opts.endpointOpts.tokenEndpointOpts.accessTokenSignerCallback = await getAccessTokenSignerCallback(
{
...tokenOpts,
},
args.context,
)
}
if (opts?.endpointOpts.authorizationChallengeOpts?.enabled === true) {
if (!instance.issuerOptions.presentationDefinitionId) {
throw Error(`Unable to set createAuthRequestUriCallback. No presentationDefinitionId present in issuer options`)
}
if (typeof opts?.endpointOpts.authorizationChallengeOpts.createAuthRequestUriCallback !== 'function') {
if (!opts.endpointOpts.authorizationChallengeOpts?.createAuthRequestUriEndpointPath) {
throw Error(`Unable to set createAuthRequestUriCallback. No createAuthRequestUriEndpointPath present in options`)
}
opts.endpointOpts.authorizationChallengeOpts.createAuthRequestUriCallback = await createAuthRequestUriCallback({
path: opts.endpointOpts.authorizationChallengeOpts.createAuthRequestUriEndpointPath,
presentationDefinitionId: instance.issuerOptions.presentationDefinitionId,
})
}
if (typeof opts?.endpointOpts.authorizationChallengeOpts?.verifyAuthResponseCallback !== 'function') {
if (!opts.endpointOpts.authorizationChallengeOpts?.verifyAuthResponseEndpointPath) {
throw Error(`Unable to set verifyAuthResponseCallback. No createAuthRequestUriEndpointPath present in options`)
}
opts.endpointOpts.authorizationChallengeOpts.verifyAuthResponseCallback = await createVerifyAuthResponseCallback({
path: opts.endpointOpts.authorizationChallengeOpts.verifyAuthResponseEndpointPath,
presentationDefinitionId: instance.issuerOptions.presentationDefinitionId,
})
}
}
return new OID4VCIRestAPI({ context, issuerInstanceArgs, expressSupport, opts, instance, issuer })
}
/**
* Builds a {@link CredentialSignerCallback} wrapper that persists each issued credential as a
* {@link CredentialRole.ISSUER} `DigitalCredential` row after delegating the actual signing to
* the underlying signer. Issuer identity is resolved from the *signed* credential (so any
* issuer normalization performed by the signer is preserved in the persisted row). The
* correlation type is derived from the resolved issuer id (`did:` prefix -> DID, otherwise URL),
* matching the pattern used by the holder-side persistence in `OID4VCIHolder`.
*
* Persistence errors propagate to the caller (fail-fast). Callers that opt into
* `persistIssuedCredentials` are explicitly requesting the row; a silent drop here would
* reproduce the DEV-35 defect.
*/
private static buildPersistenceSignerWrapper(args: {
context: IRequiredContext
instance: IssuerInstance
}): (original: CredentialSignerCallback) => CredentialSignerCallback {
const { context, instance } = args
return (originalSigner: CredentialSignerCallback): CredentialSignerCallback => {
return async (signerArgs) => {
const signed = await originalSigner(signerArgs)
const rawDocument = ensureRawDocument(signed as OriginalVerifiableCredential)
const uniform = CredentialMapper.toUniformCredential(signed as OriginalVerifiableCredential)
let issuerId = CredentialMapper.issuerCorrelationIdFromIssuerType(uniform.issuer)
if (!issuerId) {
const fromIdOpts = instance.issuerOptions.idOpts?.identifier ?? instance.issuerOptions.didOpts?.idOpts?.identifier
if (typeof fromIdOpts === 'string') {
issuerId = fromIdOpts
}
}
if (!issuerId) {
return Promise.reject(Error('Cannot persist issued credential: unable to determine issuer identifier from signed credential'))
}
const identifier = await context.agent.identifierManagedGet({
identifier: issuerId,
issuer: issuerId,
vmRelationship: 'assertionMethod',
})
let issuerCorrelationId = identifier.issuer
if (!issuerCorrelationId && isDidIdentifier(identifier.identifier)) {
if (isIIdentifier(identifier.identifier)) {
issuerCorrelationId = identifier.identifier.did
} else if (typeof identifier.identifier === 'string') {
issuerCorrelationId = identifier.identifier
}
}
if (!issuerCorrelationId) {
issuerCorrelationId = issuerId
}
const dc: AddCredentialArgs = {
credential: {
credentialRole: CredentialRole.ISSUER,
kmsKeyRef: identifier.kmsKeyRef,
identifierMethod: identifier.method,
issuerCorrelationId,
issuerCorrelationType: issuerCorrelationId.startsWith('did:') ? CredentialCorrelationType.DID : CredentialCorrelationType.URL,
rawDocument,
},
}
await context.agent.crsAddCredential(dc)
return signed
}
}
}
private readonly OID4VCI_OPENAPI_FILE = path.join(__dirname, '..', 'oid4vci-openapi.yml')
private constructor(args: {
issuer: VcIssuer
instance: IssuerInstance
context: IRequiredContext
issuerInstanceArgs: IIssuerInstanceArgs
expressSupport: ExpressSupport
opts: IOID4VCIRestAPIOpts
}) {
const { context, opts, issuerInstanceArgs } = args
this._baseUrl = new URL(
opts?.baseUrl ??
process.env.BASE_URL ??
opts?.issuer?.issuerMetadata?.credential_issuer ??
issuerInstanceArgs.credentialIssuer ??
'http://localhost',
)
this._basePath = getBasePath(this._baseUrl)
this._context = context
this._opts = opts ?? {}
this._expressSupport = args.expressSupport
this._issuer = args.issuer
this._instance = args.instance
this._restApi = new OID4VCIServer(args.expressSupport, { ...opts, issuer: this._issuer })
// The above setups the generic OID4VCI management and wallet APIs from the OID4VCI lib.
// Below sets up the management of configurations
this._router = express.Router()
this.express.use(this._basePath, this._router)
this.setupSwaggerUi()
}
private setupSwaggerUi() {
const apiDocsPath = `/api-docs`
const specPath = `/api-docs/spec.yaml`
const fullSpecPath = `${this._basePath}${specPath}`
if (!fs.existsSync(this.OID4VCI_OPENAPI_FILE)) {
console.log(`[OID4VCI] OpenAPI spec not found at ${this.OID4VCI_OPENAPI_FILE}. Swagger UI disabled.`)
return
}
console.log(`[OID4VCI] API docs available at ${this._baseUrl.origin}${this._basePath}${apiDocsPath}`)
this.express.set('trust proxy', this.opts?.endpointOpts?.trustProxy ?? true)
// Serve spec from local file
this._router.get(specPath, (req: Request, res: Response): void => {
res.type('text/yaml').sendFile(this.OID4VCI_OPENAPI_FILE)
})
// Swagger UI
this._router.use(apiDocsPath, swaggerUi.serve, swaggerUi.setup(undefined, { swaggerOptions: { url: fullSpecPath } }))
}
get express(): Express {
return this._expressSupport.express
}
get context(): IRequiredContext {
return this._context
}
get opts(): IOID4VCIRestAPIOpts | undefined {
return this._opts
}
get restApi(): OID4VCIServer {
return this._restApi
}
get instance(): IssuerInstance {
return this._instance
}
get issuer(): VcIssuer {
return this._issuer
}
async stop(): Promise<boolean> {
return this._expressSupport.stop()
}
}