Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
29 changes: 29 additions & 0 deletions agent/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,36 @@
import cors from 'cors'
import express from 'express'
import { credentialResponseMetadata } 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 set by
// the credential mapper. Must be registered before agent.initialize() mounts
// the OID4VCI routes.
app.use('/oid4vci', (req, res, next) => {
if (!req.path.endsWith('/credential')) return next()

credentialResponseMetadata.run(() => {
const originalSend = res.send.bind(res)
res.send = (body: unknown) => {
const metadata = credentialResponseMetadata.get()
if (typeof body === 'string' && metadata) {
try {
const parsed = JSON.parse(body) as Record<string, unknown>
const existing =
typeof parsed.credential_metadata === 'object' && parsed.credential_metadata !== null
? (parsed.credential_metadata as Record<string, unknown>)
: {}
body = JSON.stringify({ ...parsed, credential_metadata: { ...existing, ...metadata } })
} catch {
// not JSON, leave body unchanged
}
}
return originalSend(body)
}
next()
})
})
158 changes: 120 additions & 38 deletions agent/src/endpoints.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
JsonEncoder,
Hasher,
JsonTransformer,
Jwt,
MdocDeviceResponse,
Expand All @@ -20,6 +20,11 @@ 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 @@ -52,6 +57,28 @@ const deferIntervalMapping: Record<z.infer<typeof zCreateOfferRequest>['deferBy'

apiRouter.use(express.json())
apiRouter.use(express.text())

// Extracts paymentTransactionId (hash of the raw transaction_data entry, matching
// what the wallet computes per the transaction_data spec) and the amount from the
// authorization request JWT. Returns undefined if no transaction_data is present.
function extractPaymentTransactionData(
authorizationRequestJwt: string | undefined
): { paymentTransactionId: string; amount: number } | undefined {
if (!authorizationRequestJwt) return undefined

const transactionData = Jwt.fromSerializedJwt(authorizationRequestJwt).payload.additionalClaims.transaction_data
if (!Array.isArray(transactionData) || typeof transactionData[0] !== 'string') return undefined

const rawEntry = transactionData[0]
const paymentTransactionId = TypedArrayEncoder.toHex(
Hasher.hash(TypedArrayEncoder.fromBase64Url(rawEntry), 'sha-256')
)
const decoded = JSON.parse(Buffer.from(rawEntry, 'base64url').toString()) as { payload?: { amount?: string } }
const amount = parseFloat(decoded.payload?.amount ?? '0')

return { paymentTransactionId, amount }
}

apiRouter.post('/offers/create', async (request: Request, response: Response) => {
const createOfferRequest = zCreateOfferRequest.parse(request.body)

Expand Down Expand Up @@ -174,6 +201,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.sendStatus(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.sendStatus(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.sendStatus(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 @@ -228,7 +287,6 @@ apiRouter.post('/requests/create', async (request: Request, response: Response)
redirectUriBase,
} = await zCreatePresentationRequestBody.parseAsync(request.body)

const _x509RootCertificate = getX509RootCertificate()
const x509DcsCertificate = getX509DcsCertificate()

// Funke access certificate uses same key as the dcs certificate
Expand Down Expand Up @@ -262,13 +320,32 @@ 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

// 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 +399,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 +417,30 @@ apiRouter.post('/requests/create', async (request: Request, response: Response)
responseCodeMap.set(responseCode, verificationSession.id)
}

const paymentTransaction = extractPaymentTransactionData(verificationSession.authorizationRequestJwt)
if (transactionAuthorizationType === 'payment' && paymentTransaction) {
agent.config.logger.info(
`requests/create: saving PDNG payment record for paymentTransactionId ${paymentTransaction.paymentTransactionId}`
)
await agent.genericRecords.save({
id: `transaction-status-${paymentTransaction.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 All @@ -402,14 +467,32 @@ async function getVerificationStatus(verificationSession: OpenId4VcVerificationS
}
const dcqlQuery = authorizationRequestPayload.dcql_query

const transactionData = authorizationRequestPayload.verifier_info?.map((e) => ({
...e,
data: typeof e.data === 'string' ? JsonEncoder.fromBase64(e.data) : e.data,
}))

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: string[] = [weroScaConfiguration.vct, weroScaThirdPartyConfiguration.vct]
const presentationsList = Object.values(verified.dcql?.presentations ?? {}).flat() as Array<{
prettyClaims?: { vct?: string; jti?: string }
}>
const weroJti = presentationsList.find((p) => weroVcts.includes(p.prettyClaims?.vct ?? ''))?.prettyClaims?.jti

const paymentTransaction = extractPaymentTransactionData(verificationSession.authorizationRequestJwt)
if (weroJti && paymentTransaction) {
const { paymentTransactionId, amount } = paymentTransaction
agent.config.logger.info(
`getVerificationStatus: Wero credential jti ${weroJti}, paymentTransactionId ${paymentTransactionId}, amount ${amount}`
)
updatePaymentStatusForWeroCredential(weroJti, paymentTransactionId, amount).catch((error) => {
Comment thread
berendsliedrecht marked this conversation as resolved.
Outdated
agent.config.logger.error(`getVerificationStatus: payment update failed for ${paymentTransactionId}`, error)
})
} else {
agent.config.logger.debug(
`getVerificationStatus: no payment update - weroJti ${weroJti ?? 'not found'}, paymentTransaction ${paymentTransaction ? 'found' : 'not found'}`
)
}

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

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

return {
verificationSessionId: verificationSession.id,
Expand All @@ -520,7 +603,6 @@ async function getVerificationStatus(verificationSession: OpenId4VcVerificationS
responseStatus: verificationSession.state,
error: verificationSession.errorMessage,
authorizationRequest,
transactionData,
dcqlQuery,
}
}
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
45 changes: 42 additions & 3 deletions agent/src/issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,17 @@ import {
import { cborDecode, cborEncode } from '@owf/mdoc'
import { 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 { credentialResponseMetadata } 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 @@ -504,9 +507,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 +547,43 @@ 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}`)

credentialResponseMetadata.set({
'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