Skip to content

Commit 51052ef

Browse files
feat: payment status (#127)
* feat: payment status Signed-off-by: Berend Sliedrecht <berend@animo.id> * fix: credential_metadata not in credential but in response Signed-off-by: Berend Sliedrecht <berend@animo.id> * feat(payments): correctly return status to caller Signed-off-by: Berend Sliedrecht <berend@animo.id> * fix: encode hash using base64url, not hex Signed-off-by: Berend Sliedrecht <berend@animo.id> * fix: await the update call Signed-off-by: Berend Sliedrecht <berend@animo.id> --------- Signed-off-by: Berend Sliedrecht <berend@animo.id>
1 parent 13c6077 commit 51052ef

9 files changed

Lines changed: 258 additions & 46 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
- name: Setup NodeJS
2121
uses: actions/setup-node@v6
2222
with:
23-
node-version: 20
23+
node-version: 22
2424
cache: "pnpm"
2525

2626
- name: Install dependencies

agent/src/app.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,36 @@
11
import cors from 'cors'
22
import express from 'express'
3+
import { credentialResponseMetadata } from './utils/credentialResponseMetadata.js'
34

45
export const app = express()
56
app.use(cors({ origin: '*' }))
67
app.use(express.json())
78
app.use(express.urlencoded())
9+
10+
// Intercept credential endpoint responses to inject credential_metadata set by
11+
// the credential mapper. Must be registered before agent.initialize() mounts
12+
// the OID4VCI routes.
13+
app.use('/oid4vci', (req, res, next) => {
14+
if (!req.path.endsWith('/credential')) return next()
15+
16+
credentialResponseMetadata.run(() => {
17+
const originalSend = res.send.bind(res)
18+
res.send = (body: unknown) => {
19+
const metadata = credentialResponseMetadata.get()
20+
if (typeof body === 'string' && metadata) {
21+
try {
22+
const parsed = JSON.parse(body) as Record<string, unknown>
23+
const existing =
24+
typeof parsed.credential_metadata === 'object' && parsed.credential_metadata !== null
25+
? (parsed.credential_metadata as Record<string, unknown>)
26+
: {}
27+
body = JSON.stringify({ ...parsed, credential_metadata: { ...existing, ...metadata } })
28+
} catch {
29+
// not JSON, leave body unchanged
30+
}
31+
}
32+
return originalSend(body)
33+
}
34+
next()
35+
})
36+
})

agent/src/endpoints.ts

Lines changed: 120 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {
2-
JsonEncoder,
2+
Hasher,
33
JsonTransformer,
44
Jwt,
55
MdocDeviceResponse,
@@ -20,6 +20,11 @@ import { agent } from './agent.js'
2020
import { funkeDeployedAccessCertificate, funkeDeployedRegistrationCertificate } from './eudiTrust.js'
2121
import { getIssuerIdForCredentialConfigurationId, type IssuanceMetadata } from './issuer.js'
2222
import { issuers } from './issuers/index.js'
23+
import {
24+
updatePaymentStatusForWeroCredential,
25+
weroScaConfiguration,
26+
weroScaThirdPartyConfiguration,
27+
} from './issuers/openHorizonBank.js'
2328
import { getX509DcsCertificate, getX509RootCertificate } from './keyMethods/index.js'
2429
import { oidcUrl } from './oidcProvider/provider.js'
2530
import { LimitedSizeCollection } from './utils/LimitedSizeCollection.js'
@@ -52,6 +57,28 @@ const deferIntervalMapping: Record<z.infer<typeof zCreateOfferRequest>['deferBy'
5257

5358
apiRouter.use(express.json())
5459
apiRouter.use(express.text())
60+
61+
// Extracts paymentTransactionId (hash of the raw transaction_data entry, matching
62+
// what the wallet computes per the transaction_data spec) and the amount from the
63+
// authorization request JWT. Returns undefined if no transaction_data is present.
64+
function extractPaymentTransactionData(
65+
authorizationRequestJwt: string | undefined
66+
): { paymentTransactionId: string; amount: number } | undefined {
67+
if (!authorizationRequestJwt) return undefined
68+
69+
const transactionData = Jwt.fromSerializedJwt(authorizationRequestJwt).payload.additionalClaims.transaction_data
70+
if (!Array.isArray(transactionData) || typeof transactionData[0] !== 'string') return undefined
71+
72+
const rawEntry = transactionData[0]
73+
const paymentTransactionId = TypedArrayEncoder.toBase64Url(
74+
Hasher.hash(TypedArrayEncoder.fromBase64Url(rawEntry), 'sha-256')
75+
)
76+
const decoded = JSON.parse(Buffer.from(rawEntry, 'base64url').toString()) as { payload?: { amount?: string } }
77+
const amount = parseFloat(decoded.payload?.amount ?? '0')
78+
79+
return { paymentTransactionId, amount }
80+
}
81+
5582
apiRouter.post('/offers/create', async (request: Request, response: Response) => {
5683
const createOfferRequest = zCreateOfferRequest.parse(request.body)
5784

@@ -174,6 +201,38 @@ apiRouter.get('/verifier', async (_, response: Response) => {
174201
})
175202
})
176203

204+
apiRouter.post('/transaction-status', async (request: Request, response: Response) => {
205+
const authHeader = request.headers.authorization
206+
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined
207+
if (!token) {
208+
return response.sendStatus(401)
209+
}
210+
211+
const parseResult = await z.object({ transaction: z.string() }).safeParseAsync(request.body)
212+
if (!parseResult.success) {
213+
agent.config.logger.warn('transaction-status: missing transactionId in request body')
214+
return response.sendStatus(401)
215+
}
216+
217+
const { transaction } = parseResult.data
218+
agent.config.logger.info(`transaction-status: looking up record for transaction ${transaction}`)
219+
const record = await agent.genericRecords.findById(`transaction-status-${transaction}`)
220+
221+
if (!record || record.content.transaction_status_token !== token || !('statusCode' in record.content)) {
222+
agent.config.logger.warn(
223+
`transaction-status: unauthorized - record ${record ? 'found but token mismatch or no statusCode' : 'not found'}`
224+
)
225+
return response.sendStatus(401)
226+
}
227+
228+
agent.config.logger.info(
229+
`transaction-status: returning status ${record.content.statusCode} for transaction ${transaction}`
230+
)
231+
return response.json({
232+
status_code: record.content.statusCode,
233+
})
234+
})
235+
177236
// apiRouter.post('/trust-chains', async (request: Request, response: Response) => {
178237
// const parseResult = await z
179238
// .object({
@@ -228,7 +287,6 @@ apiRouter.post('/requests/create', async (request: Request, response: Response)
228287
redirectUriBase,
229288
} = await zCreatePresentationRequestBody.parseAsync(request.body)
230289

231-
const _x509RootCertificate = getX509RootCertificate()
232290
const x509DcsCertificate = getX509DcsCertificate()
233291

234292
// Funke access certificate uses same key as the dcs certificate
@@ -262,13 +320,32 @@ apiRouter.post('/requests/create', async (request: Request, response: Response)
262320
}
263321
}
264322

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

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

270328
const responseCode = randomUUID()
271329
const redirectUri = redirectUriBase ? `${redirectUriBase}?response_code=${responseCode}` : undefined
330+
const paymentTransactionEntry =
331+
transactionAuthorizationType === 'payment'
332+
? {
333+
type: 'urn:eudi:sca:eu.europa.ec:payment:single:1',
334+
credential_ids: [credentialIds[credentialIds.length - 1]] as [string, ...string[]],
335+
transaction_data_hashes_alg: ['sha-256'] as [string, ...string[]],
336+
payload: {
337+
transaction_id: randomUUID(),
338+
amount: `${paymentAmount} EUR`,
339+
date_time: new Date().toISOString(),
340+
payee: {
341+
name: verifier.clientMetadata?.client_name ?? 'TODO: NAME',
342+
id: verifierId,
343+
logo: verifier.clientMetadata?.logo_uri ?? 'TODO: logo',
344+
website: 'https://playground.animo.id',
345+
},
346+
},
347+
}
348+
: undefined
272349

273350
// 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
274351
if (transactionAuthorizationType === 'payment') {
@@ -322,26 +399,8 @@ apiRouter.post('/requests/create', async (request: Request, response: Response)
322399
],
323400
},
324401
]
325-
: transactionAuthorizationType === 'payment'
326-
? [
327-
{
328-
type: 'urn:eudi:sca:eu.europa.ec:payment:single:1',
329-
// We pick the last credential id as we push the wero card
330-
credential_ids: [credentialIds[credentialIds.length - 1]] as [string, ...string[]],
331-
transaction_data_hashes_alg: ['sha-256'],
332-
payload: {
333-
transaction_id: randomUUID(),
334-
amount: `${paymentAmount} EUR`,
335-
date_time: new Date().toISOString(),
336-
payee: {
337-
name: verifier.clientMetadata?.client_name ?? 'TODO: NAME',
338-
id: verifierId,
339-
logo: verifier.clientMetadata?.logo_uri ?? 'TODO: logo',
340-
website: 'https://playground.animo.id',
341-
},
342-
},
343-
},
344-
]
402+
: transactionAuthorizationType === 'payment' && paymentTransactionEntry
403+
? [paymentTransactionEntry]
345404
: undefined,
346405
dcql: {
347406
query: queryLanguageDefinition,
@@ -358,24 +417,30 @@ apiRouter.post('/requests/create', async (request: Request, response: Response)
358417
responseCodeMap.set(responseCode, verificationSession.id)
359418
}
360419

420+
const paymentTransaction = extractPaymentTransactionData(verificationSession.authorizationRequestJwt)
421+
if (transactionAuthorizationType === 'payment' && paymentTransaction) {
422+
agent.config.logger.info(
423+
`requests/create: saving PDNG payment record for paymentTransactionId ${paymentTransaction.paymentTransactionId}`
424+
)
425+
await agent.genericRecords.save({
426+
id: `transaction-status-${paymentTransaction.paymentTransactionId}`,
427+
content: { statusCode: 'PDNG' },
428+
})
429+
}
430+
361431
const authorizationRequestJwt = verificationSession.authorizationRequestJwt
362432
? Jwt.fromSerializedJwt(verificationSession.authorizationRequestJwt)
363433
: undefined
364434
const authorizationRequestPayload = verificationSession.requestPayload
365435
const dcqlQuery = authorizationRequestPayload.dcql_query
366-
const transactionData = authorizationRequestPayload.verifier_info?.map((e) => ({
367-
...e,
368-
data: typeof e.data === 'string' ? JsonEncoder.fromBase64(e.data) : e.data,
369-
}))
370436

371-
console.log(JSON.stringify(authorizationRequestObject, null, 2))
437+
agent.config.logger.debug(JSON.stringify(authorizationRequestObject, null, 2))
372438
return response.json({
373439
authorizationRequestObject,
374440
authorizationRequestUri: authorizationRequest.replace('openid4vp://', requestScheme),
375441
verificationSessionId: verificationSession.id,
376442
responseStatus: verificationSession.state,
377443
dcqlQuery,
378-
transactionData,
379444
authorizationRequest: authorizationRequestJwt
380445
? {
381446
payload: authorizationRequestJwt.payload.toJson(),
@@ -402,14 +467,32 @@ async function getVerificationStatus(verificationSession: OpenId4VcVerificationS
402467
}
403468
const dcqlQuery = authorizationRequestPayload.dcql_query
404469

405-
const transactionData = authorizationRequestPayload.verifier_info?.map((e) => ({
406-
...e,
407-
data: typeof e.data === 'string' ? JsonEncoder.fromBase64(e.data) : e.data,
408-
}))
409-
410470
if (verificationSession.state === OpenId4VcVerificationSessionState.ResponseVerified) {
411471
const verified = await agent.openid4vc.verifier.getVerifiedAuthorizationResponse(verificationSession.id)
412-
console.log(verified.dcql?.presentationResult)
472+
agent.config.logger.debug(JSON.stringify(verified.dcql?.presentationResult))
473+
474+
// Find the Wero SCA presentation to extract the credential jti, then signal the issuer to update payment status.
475+
// The issuer owns the transaction_status_token — the verifier only passes the jti and payment details.
476+
const weroVcts: string[] = [weroScaConfiguration.vct, weroScaThirdPartyConfiguration.vct]
477+
const presentationsList = Object.values(verified.dcql?.presentations ?? {}).flat() as Array<{
478+
prettyClaims?: { vct?: string; jti?: string }
479+
}>
480+
const weroJti = presentationsList.find((p) => weroVcts.includes(p.prettyClaims?.vct ?? ''))?.prettyClaims?.jti
481+
482+
const paymentTransaction = extractPaymentTransactionData(verificationSession.authorizationRequestJwt)
483+
if (weroJti && paymentTransaction) {
484+
const { paymentTransactionId, amount } = paymentTransaction
485+
agent.config.logger.info(
486+
`getVerificationStatus: Wero credential jti ${weroJti}, paymentTransactionId ${paymentTransactionId}, amount ${amount}`
487+
)
488+
await updatePaymentStatusForWeroCredential(weroJti, paymentTransactionId, amount).catch((error) => {
489+
agent.config.logger.error(`getVerificationStatus: payment update failed for ${paymentTransactionId}`, error)
490+
})
491+
} else {
492+
agent.config.logger.debug(
493+
`getVerificationStatus: no payment update - weroJti ${weroJti ?? 'not found'}, paymentTransaction ${paymentTransaction ? 'found' : 'not found'}`
494+
)
495+
}
413496

414497
const presentations = await Promise.all(
415498
Object.values(verified.dcql?.presentations ?? {})
@@ -497,7 +580,7 @@ async function getVerificationStatus(verificationSession: OpenId4VcVerificationS
497580
}))
498581
: undefined
499582

500-
console.log('presentations', presentations)
583+
agent.config.logger.debug(`presentations ${JSON.stringify(presentations)}`)
501584

502585
return {
503586
verificationSessionId: verificationSession.id,
@@ -520,7 +603,6 @@ async function getVerificationStatus(verificationSession: OpenId4VcVerificationS
520603
responseStatus: verificationSession.state,
521604
error: verificationSession.errorMessage,
522605
authorizationRequest,
523-
transactionData,
524606
dcqlQuery,
525607
}
526608
}
@@ -559,7 +641,7 @@ apiRouter.get('/requests/:verificationSessionId', async (request, response) => {
559641
})
560642

561643
apiRouter.use((error: Error, _request: Request, response: Response, _next: NextFunction) => {
562-
console.error('Unhandled error', error)
644+
agent.config.logger.error(`Unhandled error ${error}`)
563645
return response.status(500).json({
564646
error: error.message,
565647
})

agent/src/issuer.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,17 @@ import {
2222
import { cborDecode, cborEncode } from '@owf/mdoc'
2323
import { randomUUID } from 'crypto'
2424
import { agent } from './agent.js'
25+
import { AGENT_HOST } from './constants.js'
2526
import { bdrIssuer } from './issuers/bdr.js'
2627
import { issuers, issuersCredentialsData } from './issuers/index.js'
2728
import { kolnIssuer } from './issuers/koln.js'
2829
import { krankenkasseIssuer } from './issuers/krankenkasse.js'
30+
import { weroScaConfiguration, weroScaThirdPartyConfiguration } from './issuers/openHorizonBank.js'
2931
import { steuernIssuer } from './issuers/steuern.js'
3032
import { telOrgIssuer } from './issuers/telOrg.js'
3133
import { getX509DcsCertificate } from './keyMethods/index.js'
3234
import type { StaticMdocSignInput, StaticSdJwtSignInput } from './types.js'
35+
import { credentialResponseMetadata } from './utils/credentialResponseMetadata.js'
3336
import { oneYearInMilliseconds, serverStartupTimeInMilliseconds, tenDaysInMilliseconds } from './utils/date.js'
3437
import { getVerifier } from './verifier.js'
3538
import { dcqlQueryFromRequest, pidMdocCredential, pidSdJwtCredential } from './verifiers/util.js'
@@ -504,9 +507,8 @@ export const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToC
504507
})),
505508
} satisfies SerializableMdocSignOptions
506509

507-
console.log(
508-
'decoded',
509-
cborDecode(Buffer.from(signOptions.credentials[0].namespaces, 'base64url'), { mapsAsObjects: true })
510+
agent.config.logger.debug(
511+
`decoded ${JSON.stringify(cborDecode(Buffer.from(signOptions.credentials[0].namespaces, 'base64url'), { mapsAsObjects: true }))}`
510512
)
511513
}
512514
}
@@ -545,6 +547,43 @@ export const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToC
545547
}
546548
}
547549

550+
// For Wero SCA SD-JWT credentials, inject a per-credential jti and schedule
551+
// credential_metadata to be added to the OID4VCI credential response (not the credential itself).
552+
if (
553+
signOptions &&
554+
credentialData.format === ClaimFormat.SdJwtDc &&
555+
(normalizedCredentialConfigurationId === weroScaConfiguration.scope ||
556+
normalizedCredentialConfigurationId === weroScaThirdPartyConfiguration.scope)
557+
) {
558+
const jti = randomUUID() as string
559+
const transaction_status_token = randomUUID() as string
560+
const sdJwtSignOptions = signOptions as SerializableSdJwtVcSignOptions
561+
562+
signOptions = {
563+
...sdJwtSignOptions,
564+
credentials: sdJwtSignOptions.credentials.map((credential) => ({
565+
...credential,
566+
payload: {
567+
...credential.payload,
568+
jti,
569+
},
570+
})),
571+
} satisfies SerializableSdJwtVcSignOptions
572+
573+
await agent.genericRecords.save({
574+
id: `wero-credential-token-${jti}`,
575+
content: { transaction_status_token },
576+
})
577+
agent.config.logger.info(`issuer: saved Wero SCA transaction status record for jti ${jti}`)
578+
579+
credentialResponseMetadata.set({
580+
'urn:eudi:sca:eu.europa.ec:payment': {
581+
transaction_status_url: `${AGENT_HOST}/api/transaction-status`,
582+
transaction_status_token,
583+
},
584+
})
585+
}
586+
548587
const issuanceMetadata: IssuanceMetadata = issuanceSession.issuanceMetadata ?? {}
549588
if (issuanceMetadata.deferInterval) {
550589
issuanceMetadata.signOptions = signOptions

0 commit comments

Comments
 (0)