Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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.toBase64Url(
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}`
)
await updatePaymentStatusForWeroCredential(weroJti, paymentTransactionId, amount).catch((error) => {
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