Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- name: Setup NodeJS
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
cache: "pnpm"

- name: Install dependencies
Expand Down
32 changes: 32 additions & 0 deletions agent/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,39 @@
import cors from 'cors'
import { createHash } from 'crypto'
import express from 'express'
import { popPendingCredentialResponseMetadata } from './utils/credentialResponseMetadata.js'

export const app = express()
app.use(cors({ origin: '*' }))
app.use(express.json())
app.use(express.urlencoded())

// Intercept credential endpoint responses to inject credential_metadata.
// The credential mapper sets pending metadata keyed by sha256(access_token).
// This middleware must be registered before agent.initialize() mounts the OID4VCI routes.
app.use('/oid4vci', (req, res, next) => {
if (!req.path.endsWith('/credential')) return next()

const token = req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice(7) : undefined

if (!token) return next()

const originalSend = res.send.bind(res)
res.send = (body: unknown) => {
if (typeof body === 'string') {
try {
const parsed = JSON.parse(body) as Record<string, unknown>
const hash = createHash('sha256').update(token).digest('hex')
Comment thread
berendsliedrecht marked this conversation as resolved.
Outdated
const credentialMetadata = popPendingCredentialResponseMetadata(hash)
if (credentialMetadata) {
body = JSON.stringify({ ...parsed, credential_metadata: credentialMetadata })
}
} catch {
// not JSON, leave body unchanged
}
}
return originalSend(body)
}

next()
})
144 changes: 113 additions & 31 deletions agent/src/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,18 @@ import {
X509ModuleConfig,
} from '@credo-ts/core'
import { type OpenId4VcVerificationSessionRecord, OpenId4VcVerificationSessionState } from '@credo-ts/openid4vc'
import { randomUUID } from 'crypto'
import { createHash, randomUUID } from 'crypto'
import express, { type NextFunction, type Request, type Response } from 'express'
import z from 'zod'
import { agent } from './agent.js'
import { funkeDeployedAccessCertificate, funkeDeployedRegistrationCertificate } from './eudiTrust.js'
import { getIssuerIdForCredentialConfigurationId, type IssuanceMetadata } from './issuer.js'
import { issuers } from './issuers/index.js'
import {
updatePaymentStatusForWeroCredential,
weroScaConfiguration,
weroScaThirdPartyConfiguration,
} from './issuers/openHorizonBank.js'
import { getX509DcsCertificate, getX509RootCertificate } from './keyMethods/index.js'
import { oidcUrl } from './oidcProvider/provider.js'
import { LimitedSizeCollection } from './utils/LimitedSizeCollection.js'
Expand Down Expand Up @@ -174,6 +179,38 @@ apiRouter.get('/verifier', async (_, response: Response) => {
})
})

apiRouter.post('/transaction-status', async (request: Request, response: Response) => {
const authHeader = request.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined
Comment thread
berendsliedrecht marked this conversation as resolved.
if (!token) {
return response.status(401)
}

const parseResult = await z.object({ transaction: z.string() }).safeParseAsync(request.body)
if (!parseResult.success) {
agent.config.logger.warn('transaction-status: missing transactionId in request body')
return response.status(401)
}

const { transaction } = parseResult.data
agent.config.logger.info(`transaction-status: looking up record for transaction ${transaction}`)
const record = await agent.genericRecords.findById(`transaction-status-${transaction}`)

if (!record || record.content.transaction_status_token !== token || !('statusCode' in record.content)) {
agent.config.logger.warn(
`transaction-status: unauthorized - record ${record ? 'found but token mismatch or no statusCode' : 'not found'}`
)
return response.status(401)
}

agent.config.logger.info(
`transaction-status: returning status ${record.content.statusCode} for transaction ${transaction}`
)
return response.json({
status_code: record.content.statusCode,
})
})

// apiRouter.post('/trust-chains', async (request: Request, response: Response) => {
// const parseResult = await z
// .object({
Expand Down Expand Up @@ -262,13 +299,35 @@ apiRouter.post('/requests/create', async (request: Request, response: Response)
}
}

console.log('Requesting definition', JSON.stringify(definition, null, 2))
agent.config.logger.debug(`Requesting definition ${JSON.stringify(definition, null, 2)}`)

const queryLanguageDefinition = dcqlQueryFromRequest(definition, purpose)
const credentialIds = queryLanguageDefinition.credentials.map((query) => query.id)

const responseCode = randomUUID()
const redirectUri = redirectUriBase ? `${redirectUriBase}?response_code=${responseCode}` : undefined
const paymentTransactionEntry =
transactionAuthorizationType === 'payment'
? {
type: 'urn:eudi:sca:eu.europa.ec:payment:single:1',
credential_ids: [credentialIds[credentialIds.length - 1]] as [string, ...string[]],
transaction_data_hashes_alg: ['sha-256'] as [string, ...string[]],
payload: {
transaction_id: randomUUID(),
amount: `${paymentAmount} EUR`,
date_time: new Date().toISOString(),
payee: {
name: verifier.clientMetadata?.client_name ?? 'TODO: NAME',
id: verifierId,
logo: verifier.clientMetadata?.logo_uri ?? 'TODO: logo',
website: 'https://playground.animo.id',
},
},
}
: undefined
const paymentTransactionId = paymentTransactionEntry
? createHash('sha256').update(JsonEncoder.toBase64Url(paymentTransactionEntry)).digest('hex')
: undefined

// When credential_sets is used we need to add the wero card to each group so that it will always be requested when also requesting a payment
if (transactionAuthorizationType === 'payment') {
Expand Down Expand Up @@ -322,26 +381,8 @@ apiRouter.post('/requests/create', async (request: Request, response: Response)
],
},
]
: transactionAuthorizationType === 'payment'
? [
{
type: 'urn:eudi:sca:eu.europa.ec:payment:single:1',
// We pick the last credential id as we push the wero card
credential_ids: [credentialIds[credentialIds.length - 1]] as [string, ...string[]],
transaction_data_hashes_alg: ['sha-256'],
payload: {
transaction_id: randomUUID(),
amount: `${paymentAmount} EUR`,
date_time: new Date().toISOString(),
payee: {
name: verifier.clientMetadata?.client_name ?? 'TODO: NAME',
id: verifierId,
logo: verifier.clientMetadata?.logo_uri ?? 'TODO: logo',
website: 'https://playground.animo.id',
},
},
},
]
: transactionAuthorizationType === 'payment' && paymentTransactionEntry
? [paymentTransactionEntry]
: undefined,
dcql: {
query: queryLanguageDefinition,
Expand All @@ -358,24 +399,29 @@ apiRouter.post('/requests/create', async (request: Request, response: Response)
responseCodeMap.set(responseCode, verificationSession.id)
}

if (transactionAuthorizationType === 'payment' && paymentTransactionId) {
agent.config.logger.info(
`requests/create: saving PDNG payment record for paymentTransactionId ${paymentTransactionId}`
)
await agent.genericRecords.save({
id: `transaction-status-${paymentTransactionId}`,
content: { statusCode: 'PDNG' },
})
}

const authorizationRequestJwt = verificationSession.authorizationRequestJwt
? Jwt.fromSerializedJwt(verificationSession.authorizationRequestJwt)
: undefined
const authorizationRequestPayload = verificationSession.requestPayload
const dcqlQuery = authorizationRequestPayload.dcql_query
const transactionData = authorizationRequestPayload.verifier_info?.map((e) => ({
...e,
data: typeof e.data === 'string' ? JsonEncoder.fromBase64(e.data) : e.data,
}))

console.log(JSON.stringify(authorizationRequestObject, null, 2))
agent.config.logger.debug(JSON.stringify(authorizationRequestObject, null, 2))
return response.json({
authorizationRequestObject,
authorizationRequestUri: authorizationRequest.replace('openid4vp://', requestScheme),
verificationSessionId: verificationSession.id,
responseStatus: verificationSession.state,
dcqlQuery,
transactionData,
authorizationRequest: authorizationRequestJwt
? {
payload: authorizationRequestJwt.payload.toJson(),
Expand Down Expand Up @@ -409,7 +455,43 @@ async function getVerificationStatus(verificationSession: OpenId4VcVerificationS

if (verificationSession.state === OpenId4VcVerificationSessionState.ResponseVerified) {
const verified = await agent.openid4vc.verifier.getVerifiedAuthorizationResponse(verificationSession.id)
console.log(verified.dcql?.presentationResult)
agent.config.logger.debug(JSON.stringify(verified.dcql?.presentationResult))

// Find the Wero SCA presentation to extract the credential jti, then signal the issuer to update payment status.
// The issuer owns the transaction_status_token — the verifier only passes the jti and payment details.
const weroVcts = [weroScaConfiguration.vct, weroScaThirdPartyConfiguration.vct]
const weroPresentation = Object.values(verified.dcql?.presentations ?? {})
.flat()
.find((p) => weroVcts.includes((p as { prettyClaims?: Record<string, unknown> }).prettyClaims?.vct as string))
const weroJti = (weroPresentation as { prettyClaims?: Record<string, unknown> } | undefined)?.prettyClaims?.jti as
| string
| undefined

const rawPaymentEntry = (authorizationRequestPayload.transaction_data as string[] | undefined)?.find((data) => {
try {
return (
(JSON.parse(Buffer.from(data, 'base64url').toString()) as { type?: string }).type ===
Comment thread
berendsliedrecht marked this conversation as resolved.
Outdated
'urn:eudi:sca:eu.europa.ec:payment:single:1'
)
} catch {
return false
}
})
if (rawPaymentEntry && weroJti) {
const decoded = JSON.parse(Buffer.from(rawPaymentEntry, 'base64url').toString()) as {
payload?: { amount?: string }
}
const paymentTransactionId = createHash('sha256').update(rawPaymentEntry).digest('hex')
const amount = parseFloat(decoded.payload?.amount ?? '0')
agent.config.logger.info(
`getVerificationStatus: Wero credential jti ${weroJti}, paymentTransactionId ${paymentTransactionId}, amount ${amount}`
)
await updatePaymentStatusForWeroCredential(weroJti, paymentTransactionId, amount)
} else {
agent.config.logger.debug(
`getVerificationStatus: no payment update - weroJti ${weroJti ?? 'not found'}, rawPaymentEntry ${rawPaymentEntry ? 'found' : 'not found'}`
)
}

const presentations = await Promise.all(
Object.values(verified.dcql?.presentations ?? {})
Expand Down Expand Up @@ -497,7 +579,7 @@ async function getVerificationStatus(verificationSession: OpenId4VcVerificationS
}))
: undefined

console.log('presentations', presentations)
agent.config.logger.debug(`presentations ${JSON.stringify(presentations)}`)

return {
verificationSessionId: verificationSession.id,
Expand Down Expand Up @@ -559,7 +641,7 @@ apiRouter.get('/requests/:verificationSessionId', async (request, response) => {
})

apiRouter.use((error: Error, _request: Request, response: Response, _next: NextFunction) => {
console.error('Unhandled error', error)
agent.config.logger.error(`Unhandled error ${error}`)
return response.status(500).json({
error: error.message,
})
Expand Down
49 changes: 45 additions & 4 deletions agent/src/issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,19 @@ import {
OpenId4VcVerifierApi,
} from '@credo-ts/openid4vc'
import { cborDecode, cborEncode } from '@owf/mdoc'
import { randomUUID } from 'crypto'
import { createHash, randomUUID } from 'crypto'
import { agent } from './agent.js'
import { AGENT_HOST } from './constants.js'
import { bdrIssuer } from './issuers/bdr.js'
import { issuers, issuersCredentialsData } from './issuers/index.js'
import { kolnIssuer } from './issuers/koln.js'
import { krankenkasseIssuer } from './issuers/krankenkasse.js'
import { weroScaConfiguration, weroScaThirdPartyConfiguration } from './issuers/openHorizonBank.js'
import { steuernIssuer } from './issuers/steuern.js'
import { telOrgIssuer } from './issuers/telOrg.js'
import { getX509DcsCertificate } from './keyMethods/index.js'
import type { StaticMdocSignInput, StaticSdJwtSignInput } from './types.js'
import { setPendingCredentialResponseMetadata } from './utils/credentialResponseMetadata.js'
import { oneYearInMilliseconds, serverStartupTimeInMilliseconds, tenDaysInMilliseconds } from './utils/date.js'
import { getVerifier } from './verifier.js'
import { dcqlQueryFromRequest, pidMdocCredential, pidSdJwtCredential } from './verifiers/util.js'
Expand Down Expand Up @@ -304,6 +307,7 @@ export const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToC
credentialConfigurationId,
verification,
issuanceSession,
authorization,
}): Promise<OpenId4VciSignCredentials | OpenId4VciDeferredCredentials> => {
const normalizedCredentialConfigurationId = credentialConfigurationId.replace('-key-attestations', '')
const credentialData = issuersCredentialsData[normalizedCredentialConfigurationId]
Expand Down Expand Up @@ -504,9 +508,8 @@ export const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToC
})),
} satisfies SerializableMdocSignOptions

console.log(
'decoded',
cborDecode(Buffer.from(signOptions.credentials[0].namespaces, 'base64url'), { mapsAsObjects: true })
agent.config.logger.debug(
`decoded ${JSON.stringify(cborDecode(Buffer.from(signOptions.credentials[0].namespaces, 'base64url'), { mapsAsObjects: true }))}`
)
}
}
Expand Down Expand Up @@ -545,6 +548,44 @@ export const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToC
}
}

// For Wero SCA SD-JWT credentials, inject a per-credential jti and schedule
// credential_metadata to be added to the OID4VCI credential response (not the credential itself).
if (
signOptions &&
credentialData.format === ClaimFormat.SdJwtDc &&
(normalizedCredentialConfigurationId === weroScaConfiguration.scope ||
normalizedCredentialConfigurationId === weroScaThirdPartyConfiguration.scope)
) {
const jti = randomUUID() as string
const transaction_status_token = randomUUID() as string
const sdJwtSignOptions = signOptions as SerializableSdJwtVcSignOptions

signOptions = {
...sdJwtSignOptions,
credentials: sdJwtSignOptions.credentials.map((credential) => ({
...credential,
payload: {
...credential.payload,
jti,
},
})),
} satisfies SerializableSdJwtVcSignOptions

await agent.genericRecords.save({
id: `wero-credential-token-${jti}`,
content: { transaction_status_token },
})
agent.config.logger.info(`issuer: saved Wero SCA transaction status record for jti ${jti}`)

const accessTokenHash = createHash('sha256').update(authorization.accessToken.value).digest('hex')
setPendingCredentialResponseMetadata(accessTokenHash, {
'urn:eudi:sca:eu.europa.ec:payment': {
transaction_status_url: `${AGENT_HOST}/api/transaction-status`,
transaction_status_token,
},
})
}

const issuanceMetadata: IssuanceMetadata = issuanceSession.issuanceMetadata ?? {}
if (issuanceMetadata.deferInterval) {
issuanceMetadata.signOptions = signOptions
Expand Down
Loading
Loading