@sphereon/ssi-sdk.ebsi-support
Version:
276 lines (256 loc) • 11.1 kB
text/typescript
import { PresentationDefinitionLocation, PresentationDefinitionWithLocation, SupportedVersion } from '@sphereon/did-auth-siop'
import { CreateRequestObjectMode } from '@sphereon/oid4vci-common'
import { IPEXFilterResult } from '@sphereon/ssi-sdk.presentation-exchange'
import { CredentialMapper, PresentationSubmission } from '@sphereon/ssi-types'
import { IAgentPlugin } from '@veramo/core'
import fetch from 'cross-fetch'
import { CreateEbsiDidOnLedgerResult, CreateEbsiDidParams } from '../did'
import { determineWellknownEndpoint, ebsiCreateDidOnLedger as ebsiCreateDidOnLedgerFunction, ebsiGetIssuerMock } from '../did/functions'
import { ebsiCreateAttestationAuthRequestURL, ebsiGetAttestation } from '../functions'
import {
ApiOpts,
EBSIAuthAccessTokenGetArgs,
EbsiOpenIDMetadata,
GetAccessTokenResult,
GetPresentationDefinitionSuccessResponse,
IRequiredContext,
schema,
WellknownOpts,
} from '../index'
import {
ExceptionResponse,
GetAccessTokenArgs,
GetAccessTokenResponse,
GetOIDProviderJwksResponse,
GetOIDProviderMetadataResponse,
GetPresentationDefinitionArgs,
GetPresentationDefinitionResponse,
IEbsiSupport,
} from '../types/IEbsiSupport'
import { v4 } from 'uuid'
import { CheckLinkedDomain } from '@sphereon/did-auth-siop-adapter'
import { defaultHasher } from '@sphereon/ssi-sdk.core'
export const ebsiSupportMethods: Array<string> = [
'ebsiCreateDidOnLedger',
'ebsiWellknownMetadata',
'ebsiAuthorizationServerJwks',
'ebsiPresentationDefinitionGet',
'ebsiAccessTokenGet',
'ebsiCreateAttestationAuthRequestURL',
'ebsiGetAttestation',
]
export class EbsiSupport implements IAgentPlugin {
readonly schema = schema.IEbsiSupport
readonly methods: IEbsiSupport = {
ebsiCreateDidOnLedger: this.ebsiCreateDidOnLedger.bind(this),
ebsiWellknownMetadata: this.ebsiWellknownMetadata.bind(this),
ebsiAuthorizationServerJwks: this.ebsiAuthorizationServerJwks.bind(this),
ebsiPresentationDefinitionGet: this.ebsiPresentationDefinitionGet.bind(this),
ebsiAccessTokenGet: this.ebsiAccessTokenGet.bind(this),
ebsiCreateAttestationAuthRequestURL: ebsiCreateAttestationAuthRequestURL.bind(this),
ebsiGetAttestation: ebsiGetAttestation.bind(this),
}
private async ebsiCreateDidOnLedger(args: CreateEbsiDidParams, context: IRequiredContext): Promise<CreateEbsiDidOnLedgerResult> {
return await ebsiCreateDidOnLedgerFunction(args, context)
}
private async ebsiWellknownMetadata(args: WellknownOpts): Promise<GetOIDProviderMetadataResponse> {
const url = determineWellknownEndpoint(args)
return await (
await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
},
})
).json()
}
private async ebsiAuthorizationServerJwks(args: ApiOpts): Promise<GetOIDProviderJwksResponse | ExceptionResponse> {
const discoveryMetadata: EbsiOpenIDMetadata = await this.ebsiWellknownMetadata({
...args,
type: 'openid-configuration',
})
return await (
await fetch(`${discoveryMetadata.jwks_uri}`, {
method: 'GET',
headers: {
Accept: 'application/jwk-set+json',
},
})
).json()
}
private async ebsiPresentationDefinitionGet(args: GetPresentationDefinitionArgs): Promise<GetPresentationDefinitionResponse> {
const { scope, apiOpts, openIDMetadata } = args
const discoveryMetadata: EbsiOpenIDMetadata =
openIDMetadata ??
(await this.ebsiWellknownMetadata({
...apiOpts,
type: 'openid-configuration',
system: apiOpts?.mock ? 'authorisation' : apiOpts?.system,
version: apiOpts?.version ?? 'v4',
}))
return (await (
await fetch(`${discoveryMetadata.presentation_definition_endpoint}?scope=openid%20${scope}`, {
method: 'GET',
headers: {
Accept: 'application/json',
},
})
).json()) satisfies GetPresentationDefinitionSuccessResponse
}
private async ebsiAccessTokenGet(args: EBSIAuthAccessTokenGetArgs, context: IRequiredContext): Promise<GetAccessTokenResult> {
const { scope, idOpts, jwksUri, clientId, allVerifiableCredentials, redirectUri, environment, skipDidResolution = false } = args
const identifier = await context.agent.identifierManagedGetByDid(idOpts)
console.log(`Getting access token for ${identifier.did}, scope ${scope} and clientId=${clientId}, skipDidResolution=${skipDidResolution}...`)
const openIDMetadata = await this.ebsiWellknownMetadata({
environment,
version: 'v4',
mock: undefined,
system: 'authorisation',
type: 'openid-configuration',
})
const definitionResponse = await this.ebsiPresentationDefinitionGet({
...args,
openIDMetadata,
apiOpts: { environment, version: 'v4', type: 'openid-configuration' },
})
const hasInputDescriptors = definitionResponse.input_descriptors.length > 0
console.log(`PD response`, definitionResponse)
if (!hasInputDescriptors) {
// Yes EBSI expects VPs without a VC in some situations. This is not according to the PEX spec!
// They probably should have used SIOP in these cases. We need to go through hoops as our libs do not expect PDs/VPs without VCs :(
console.warn(`No INPUT descriptor returned for scope ${scope}`)
}
let attestationCredential = args.attestationCredential
if (hasInputDescriptors && !attestationCredential) {
if (allVerifiableCredentials && allVerifiableCredentials.length > 0) {
const pexResult = await context.agent.pexDefinitionFilterCredentials({
presentationDefinition: definitionResponse,
credentialFilterOpts: { credentialRole: args.credentialRole, verifiableCredentials: allVerifiableCredentials },
})
if (pexResult.filteredCredentials.length > 0) {
const filtered = pexResult.filteredCredentials
.map((cred) => CredentialMapper.toUniformCredential(cred, { hasher: defaultHasher }))
.filter((cred) => {
if (!cred.expirationDate) {
return cred
} else if (new Date(cred.expirationDate!).getDate() >= Date.now()) {
return cred
}
return undefined
})
.filter((cred) => !!cred)
if (filtered.length > 0) {
attestationCredential = filtered[0]
}
}
}
if (!attestationCredential) {
console.log(`No attestation credential present. Will get one from within access token method!`)
const credentialIssuer = args.credentialIssuer ?? ebsiGetIssuerMock({ environment })
const authReqResult = await context.agent.ebsiCreateAttestationAuthRequestURL({
credentialIssuer,
idOpts,
formats: ['jwt_vc'],
clientId,
redirectUri,
requestObjectOpts: {
iss: clientId,
requestObjectMode: CreateRequestObjectMode.REQUEST_OBJECT,
jwksUri,
},
credentialType: 'VerifiableAuthorisationToOnboard',
})
const attestationResult = await context.agent.ebsiGetAttestation({
authReqResult,
clientId,
opts: { timeout: 30_000 },
})
// @ts-ignore
attestationCredential = attestationResult.credentials[0]!.rawVerifiableCredential! as W3CVerifiableCredential
}
}
const definition = {
definition: definitionResponse,
location: PresentationDefinitionLocation.TOPLEVEL_PRESENTATION_DEF,
version: SupportedVersion.SIOPv2_D11,
} satisfies PresentationDefinitionWithLocation
const pexResult = hasInputDescriptors
? await context.agent.pexDefinitionFilterCredentials({
presentationDefinition: definitionResponse,
credentialFilterOpts: { credentialRole: args.credentialRole, verifiableCredentials: [attestationCredential!] },
})
: ({
// LOL, let's see whether we can trick PEX to create a VP without VCs
filteredCredentials: [],
id: definitionResponse.id,
selectResults: { verifiableCredential: [], areRequiredCredentialsPresent: 'info' },
} satisfies IPEXFilterResult)
const opSession = await context.agent.siopRegisterOPSession({
requestJwtOrUri: '', // Siop assumes we use an auth request, which we don't have in this case
op: { checkLinkedDomains: CheckLinkedDomain.NEVER },
providedPresentationDefinitions: [definition],
})
const oid4vp = await opSession.getOID4VP({ allIdentifiers: [identifier.did] })
const vp = await oid4vp.createVerifiablePresentation(
args.credentialRole,
{ definition, credentials: pexResult.filteredCredentials },
{
proofOpts: { domain: openIDMetadata.issuer, nonce: v4(), created: new Date(Date.now() - 120_000).toString() },
holder: identifier.did,
idOpts: idOpts,
skipDidResolution,
forceNoCredentialsInVP: !hasInputDescriptors,
},
)
const presentationSubmission = hasInputDescriptors
? vp.presentationSubmission
: ({ id: v4(), definition_id: definitionResponse.id, descriptor_map: [] } satisfies PresentationSubmission)
console.log(`Presentation submission`, presentationSubmission)
const tokenRequestArgs = {
grant_type: 'vp_token',
vp_token: CredentialMapper.toCompactJWT(vp.verifiablePresentations[0]), // FIXME How are we going to send multiple presentations in a vp_token?
scope,
presentation_submission: presentationSubmission,
apiOpts: { environment, version: 'v4' },
openIDMetadata,
} satisfies GetAccessTokenArgs
console.log(`Access token request:\r\n${JSON.stringify(tokenRequestArgs)}`)
const accessTokenResponse = await this.getAccessToken(tokenRequestArgs)
console.log(`Access token response:\r\n${JSON.stringify(accessTokenResponse)}`)
if (!('access_token' in accessTokenResponse)) {
throw Error(`Error response: ${JSON.stringify(accessTokenResponse)}`)
}
return {
accessTokenResponse,
// vp,
scope,
// definition,
identifier: identifier,
}
}
private async getAccessToken(args: GetAccessTokenArgs): Promise<GetAccessTokenResponse> {
const { grant_type = 'vp_token', scope, vp_token, presentation_submission, apiOpts, openIDMetadata } = args
const discoveryMetadata: EbsiOpenIDMetadata =
openIDMetadata ??
(await this.ebsiWellknownMetadata({
...apiOpts,
type: 'openid-configuration',
}))
const request = {
grant_type,
scope: `openid ${scope}`,
vp_token,
presentation_submission: JSON.stringify(presentation_submission),
}
return await (
await fetch(`${discoveryMetadata.token_endpoint}`, {
method: 'POST',
headers: {
ContentType: 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: new URLSearchParams(request),
})
).json()
}
}