Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
1b64027
feat(verifier): send EC TS12 payment transaction_data from /ui/intera…
leifj Oct 15, 2026
a668e14
feat(verifier): validate EC TS12 SCA responses (hashes, amr, jti, res…
leifj Oct 15, 2026
b89c280
feat(verifier): operator test page for EC TS12 payment requests
leifj Oct 15, 2026
eef938a
fix(verifier): tighten TS12 payment input validation (review)
leifj Oct 15, 2026
8a82c8a
Merge branch 'feat/ts12-payment-request' into feat/ts12-response-vali…
leifj Oct 15, 2026
5a6f052
Merge branch 'feat/ts12-payment-request' into feat/ts12-operator-page
leifj Oct 15, 2026
4fdd069
fix(verifier): payment page hides panels, resets state, retries polli…
leifj Oct 15, 2026
07af6eb
feat(verifier): record issued response modes on the TS12 session
leifj Oct 15, 2026
7ba62d2
fix(verifier): bind SCA presentation to the requested vct and the iss…
leifj Oct 15, 2026
bf9e960
Merge branch 'feat/ts12-payment-request' into feat/ts12-operator-page
leifj Oct 15, 2026
aed5ba6
fix(verifier): payment page clears the previous result and retries re…
leifj Oct 15, 2026
8bbbbe4
fix(verifier): payment refusals render as 400, resume stays within is…
leifj Oct 15, 2026
e5c1cfe
fix(verifier): claim the SCA jti only after the issuer is authenticat…
leifj Oct 15, 2026
84e5345
Merge branch 'feat/ts12-payment-request' into feat/ts12-operator-page
leifj Oct 15, 2026
465380e
fix(verifier): payment page retries a transient /ui/result error (rev…
leifj Oct 15, 2026
481b04e
fix(verifier): a non-numeric payment amount is a 400, not a status-ma…
leifj Oct 15, 2026
c5b80b6
Merge branch 'feat/ts12-payment-request' into feat/ts12-response-vali…
leifj Oct 15, 2026
5b47ccd
Merge branch 'feat/ts12-payment-request' into feat/ts12-operator-page
leifj Oct 15, 2026
5ff4aca
test(verifier): adapt payment binding test to PaymentAmount
leifj Oct 15, 2026
b8bccf5
fix(verifier): payment page does not report an accepted response as r…
leifj Oct 15, 2026
b86f4e7
fix(openid4vp): clear PresentedJTI at the start of each Validate
leifj Oct 19, 2026
9641323
fix(verifier): a malformed /ui/interaction request is a structured 400
leifj Oct 19, 2026
45bb219
fix(verifier): the payment page must not swallow a decode failure
leifj Oct 19, 2026
b00c203
Merge branch 'feat/ts12-payment-request' into feat/ts12-response-vali…
leifj Oct 19, 2026
099514d
Merge branch 'feat/ts12-response-validation' into feat/ts12-operator-…
leifj Oct 19, 2026
647bafb
fix(verifier): the payment page's wallet link marks the session same-…
leifj Oct 19, 2026
83a57ef
fix(verifier): catch the handoff's rejection on the payment link
leifj Oct 19, 2026
adc93db
fix(verifier): a request carrying no JSON is a 400, and never a panic
leifj Oct 19, 2026
bb0c6c3
Merge remote-tracking branch 'origin/feat/ts12-payment-request' into …
leifj Oct 19, 2026
40cfa38
test(openid4vp): make the multi-entry hash-alg tests able to fail
leifj Oct 19, 2026
6d3e19c
Merge remote-tracking branch 'origin/feat/ts12-response-validation' i…
leifj Oct 19, 2026
7db3d77
fix(verifier): TS12 refusals are 400s and the jti is claimed last
masv3971 Oct 21, 2026
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
11 changes: 11 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -1530,6 +1530,7 @@ Configuration for the Verifier service that verifies credentials and acts as an
| `outbound` | `object` | Outbound groups outbound identity assertion | - | - | No |
| `digital_credentials` | `object` | W3C Digital Credentials API configuration | - | - | No |
| `authorization_page_css` | `object` | Authorization page styling configuration | - | - | No |
| `ts12` | `object` | EC TS12 payment SCA (transaction_data) support for the standalone verifier. Off by default. | - | - | No |
| `credential_display` | `object` | Credential display settings | - | - | No |
| `trust` | `object` | Trust evaluation configuration | - | - | No |
| `federation` | `object` | OpenID Federation entity configuration. When enabled, serves /.well-known/openid-federation as a self-signed JWT. | - | - | No |
Expand Down Expand Up @@ -1730,6 +1731,16 @@ These clients are checked in addition to dynamically registered clients stored i
| `title` | `string` | Title overrides the page title (default: "Wallet Authorization") | - | - | No |
| `subtitle` | `string` | Subtitle overrides the page subtitle | - | - | No |

### `ts12`

> **Path:** `.verifier.ts12`

| Field | Type | Description | Example | Default | Required |
| ---------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------- | -------- |
| `enable` | `bool` | Enable lets /ui/interaction accept a "payment" object and send a urn:eudi:sca:payment:1 transaction_data entry with the request. It also serves the operator test page at /payment. Intended for test and PSP verifiers: anyone who can reach /ui/interaction can then start a payment request, so leave it off on a general-purpose verifier. | - | `false` | No |
| `sca_vct_values` | `[]string` | Vct values of the SCA attestation the operator test page requests by default. Optional; the page lets the operator type one. | - | - | No |
| `hash_algs` | `[]string` | Transaction_data_hashes_alg list sent with each payment request, in order of preference. Default ["sha-256"] (OpenID4VP 1.0 B.3.3.1 requires every implementation to support it; TS12 4.2 makes the member REQUIRED so it is always sent). | - | - | No |

### `credential_display`

> **Path:** `.verifier.credential_display`
Expand Down
56 changes: 56 additions & 0 deletions docs/TS12_PAYMENT_SCA.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# EC TS12 payment SCA in the verifier

The standalone verifier can drive a payment Strong Customer Authentication
test against an EUDI wallet (EC TS12 v1.0.1, `urn:eudi:sca:payment:1`).

## Enable

```yaml
verifier:
ts12:
enable: true # default false
sca_vct_values: # optional: pre-fills the test page
- "https://psp.example/sca-attestation"
hash_algs: ["sha-256"] # optional; sent as transaction_data_hashes_alg
```

`enable` turns on the `payment` member of `POST /ui/interaction` and the test
page. Anyone who can reach `/ui/interaction` can then start a payment request,
so use it on test and PSP verifiers, not on a general-purpose one.

## Start a payment request

Browser: open `https://<verifier>/payment`, fill in amount, currency, payee
and the SCA attestation's `vct`, and scan the QR code (or follow the link) with
the wallet. The page waits for the wallet and shows whether the response was
accepted.

Without a browser:

```sh
curl -s https://<verifier>/ui/interaction -H 'Content-Type: application/json' -d '{
"dcql_query": {"credentials": [{"id": "sca", "format": "dc+sd-jwt",
"meta": {"vct_values": ["https://psp.example/sca-attestation"]}}]},
"payment": {"amount": "49.99", "currency": "EUR",
"payee_name": "Shop AB", "payee_id": "SE1234567890",
"credential_id": "sca"}
}'
# -> {"session_id": ..., "authorization_request": "openid4vp://...", "qr_code": "<base64 png>"}
curl -s "https://<verifier>/ui/completion?session_id=<id>" # {"status":"pending"} | {"status":"complete","response_code":...}
curl -s "https://<verifier>/ui/result?response_code=<code>" # the accepted presentation's claims
```

`credential_id` may be omitted when the DCQL query has a single credential
query. `transaction_id` defaults to a fresh UUID. The credential query must be
`dc+sd-jwt` with holder binding (TS12 v1.0.1 covers SD-JWT VC only).

## What is checked on the response

For each credential query a `transaction_data` entry references: the KB-JWT
`transaction_data_hashes` (over the strings as sent, in request order, with a
`transaction_data_hashes_alg` the request allowed), `nonce`, `aud`, `iat`,
`sd_hash`, and for TS12 types `amr` (>= 2 different categories, TS12
vocabulary), `response_mode` (one the request was issued with) and `jti`
(present, never seen before). Anything that cannot be checked is refused with
`invalid_transaction_data`; a refused response leaves the page waiting, and the
reason is in the verifier log.
225 changes: 225 additions & 0 deletions internal/verifier/apiv1/handler_ts12.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package apiv1

import (
"context"
"encoding/json"
"fmt"
"net/http"
"slices"

"github.com/SUNET/vc/pkg/cache"
"github.com/SUNET/vc/pkg/oauth2"
"github.com/SUNET/vc/pkg/openid4vp"

"github.com/google/uuid"
)

// UIPaymentRequest asks /ui/interaction to attach an EC TS12 payment
// transaction_data entry (urn:eudi:sca:payment:1) to the request. It is the
// operator-facing shape; the wire entry is built from it by
// buildPaymentTransactionData, never the other way round.
type UIPaymentRequest struct {
// Amount is the payment amount in major currency units, as a JSON number or a numeric string ("49.99"). Must be greater than zero.
Amount PaymentAmount `json:"amount" validate:"required"`
// Currency is an ISO 4217 alpha-3 code.
Currency string `json:"currency" validate:"required"`
// PayeeName and PayeeID are REQUIRED by the TS12 payment schema.
PayeeName string `json:"payee_name" validate:"required,max=256"`
PayeeID string `json:"payee_id" validate:"required,max=256"`
PayeeWebsite string `json:"payee_website,omitempty" validate:"omitempty,max=512"`
// TransactionID is the relying party's identifier of the interaction (1..36 characters). A fresh UUID when omitted.
TransactionID string `json:"transaction_id,omitempty" validate:"omitempty,max=36"`
// CredentialID is the DCQL credential query id of the SCA attestation that authorizes the payment. Required when the DCQL query has more than one credential query.
CredentialID string `json:"credential_id,omitempty" validate:"omitempty,max=128"`
}

// PaymentAmount is the amount as the caller wrote it: a JSON number or a
// string. Decoding keeps the literal text and judges nothing, so that a bad
// amount ("token", true, {}) is refused by buildPaymentTransactionData as a
// 400 invalid_request with a description, instead of failing inside the JSON
// decoder as an error the status mapper cannot classify.
type PaymentAmount string

// UnmarshalJSON implements json.Unmarshaler.
func (a *PaymentAmount) UnmarshalJSON(b []byte) error {
if len(b) > 0 && b[0] == '"' {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
*a = PaymentAmount(s)
return nil
}
if string(b) == "null" {
*a = ""
return nil
}
*a = PaymentAmount(b)
return nil
}

// invalidInteraction refuses an interaction request with a structured
// 400, carrying the payment prefix when the request WAS a payment one so
// that a payment refused before buildPaymentTransactionData runs reads
// like every other payment refusal rather than like a different class of
// failure.
func invalidInteraction(payment bool, format string, args ...any) error {
if payment {
return invalidPayment(format, args...)
}
return invalidInteractionRequest(format, args...)
}

func invalidPayment(format string, args ...any) error {
return invalidInteractionRequest("%s", "payment: "+fmt.Sprintf(format, args...))
}

// invalidInteractionRequest returns a structured 400 for an interaction
// request the caller got wrong.
//
// Structured because an unstructured error carries no status: it reaches
// httphelpers.StatusCode, which INFERS one from the message text. A
// credential query the caller named "token" produces "credential query
// \"token\" is invalid: ...", which matches that inference's
// "unauthorized, authentication, token" case - so a malformed request is
// answered 401, before the "invalid" case is ever reached, and the
// description is dropped on the way out. A caller-chosen identifier must
// not be able to choose the status code.
func invalidInteractionRequest(format string, args ...any) error {
// The shared type: RegEndpoint renders pkg/oauth2.OAuthError (status and
// description); the verifier-local OAuthError would fall through to the
// generic handler and lose both.
return &oauth2.OAuthError{
ErrorCode: ErrCodeInvalidRequest,
ErrorDescription: fmt.Sprintf(format, args...),
HTTPStatus: http.StatusBadRequest,
}
}

// buildPaymentTransactionData validates a payment request against the DCQL
// query and returns the base64url transaction_data strings to send. It fails
// closed: TS12 v1.0.1 covers SD-JWT VC only, so anything that cannot be bound
// to a holder-bound SD-JWT VC credential query is refused rather than sent.
func (c *Client) buildPaymentTransactionData(p *UIPaymentRequest, dcql *openid4vp.DCQL) ([]string, error) {

Check failure on line 103 in internal/verifier/apiv1/handler_ts12.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 22 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=SUNET_vc&issues=AaFgFCWeafvxqAky8S30&open=AaFgFCWeafvxqAky8S30&pullRequest=843
if !c.cfg.Verifier.TS12.Enable {
return nil, invalidPayment("TS12 payment requests are not enabled on this verifier (verifier.ts12.enable)")
}
if dcql == nil || len(dcql.Credentials) == 0 {
return nil, invalidPayment("a DCQL query with the SCA attestation is required")
}

var target *openid4vp.CredentialQuery
if p.CredentialID == "" {
if len(dcql.Credentials) != 1 {
return nil, invalidPayment("credential_id is required when the DCQL query has more than one credential query")
}
target = &dcql.Credentials[0]
} else {
for i := range dcql.Credentials {
if dcql.Credentials[i].ID == p.CredentialID {
if target != nil {
// DCQL requires unique ids; two matches leave it unclear which query's format and holder binding apply.
return nil, invalidPayment("credential_id %q names more than one credential query", p.CredentialID)
}
target = &dcql.Credentials[i]
}
}
if target == nil {
return nil, invalidPayment("credential_id %q does not name a credential query", p.CredentialID)
}
}
if target.Format != openid4vp.FormatSDJWTVC {
return nil, invalidPayment("credential query %q has format %q; TS12 payment SCA requires %q", target.ID, target.Format, openid4vp.FormatSDJWTVC)
}
if len(target.Meta.VCTValues) == 0 {
// Without a vct constraint any credential the trust policy accepts
// could authorize the payment: the response check compares the
// presented credential's vct against these values.
return nil, invalidPayment("credential query %q must name the SCA attestation with meta.vct_values", target.ID)
}
if !target.RequiresCryptographicHolderBinding() {
return nil, invalidPayment("credential query %q disables cryptographic holder binding, which transaction_data requires", target.ID)
}

txID := p.TransactionID
if txID == "" {
txID = uuid.NewString()
}
td, err := openid4vp.NewTS12PaymentTransactionData(openid4vp.TS12PaymentPayload{
TransactionID: txID,
Payee: openid4vp.TS12Payee{Name: p.PayeeName, ID: p.PayeeID, Website: p.PayeeWebsite},
Currency: p.Currency,
Amount: openid4vp.RawNumber(p.Amount),
}, []string{target.ID}, c.cfg.Verifier.TS12.HashAlgs)
if err != nil {
return nil, invalidPayment("%v", err)
}
encoded, err := td.Base64Encode()
if err != nil {
return nil, fmt.Errorf("encoding transaction_data: %w", err)
}
return []string{encoded}, nil
}

// issuedResponseModes are the response_mode values the verifier puts in the
// requests it issues for /ui/interaction: direct_post.jwt behind the QR code
// and link, and dc_api.jwt for the Digital Credentials API variant when that
// is enabled. The response arrives at one endpoint whichever was used, so a
// TS12 KB-JWT may echo either. Recorded on the session at issuance.
// TS12 KB-JWT may echo either.
func (c *Client) issuedResponseModes() []string {
modes := []string{openid4vp.ResponseModeDirectPostJWT}
if c.cfg.Verifier.DigitalCredentials.Enable {
modes = append(modes, openid4vp.ResponseModeDCAPIJWT)
}
return modes
}

// invalidTransactionData refuses a wallet response as a structured 400; an
// unstructured error would have its status guessed from its text (401/404).
func invalidTransactionData(format string, args ...any) *oauth2.OAuthError {
return &oauth2.OAuthError{
ErrorCode: openid4vp.ErrorInvalidTransactionData,
ErrorDescription: fmt.Sprintf(format, args...),
HTTPStatus: http.StatusBadRequest,
}
}

// claimJTI records a presented jti, refusing a repeat. With no replay cache it
// refuses: jti uniqueness cannot be honoured, so the presentation cannot be.
func (c *Client) claimJTI(ctx context.Context, jti string) error {
if c.cacheService == nil || c.cacheService.UsedJTI == nil {
return invalidTransactionData("cannot verify jti freshness: no replay store configured")
}
// Atomic: a concurrent second submission of the same KB-JWT loses SetNX.
fresh, err := c.cacheService.UsedJTI.SetNX(ctx, jti, true)
if err != nil {
return invalidTransactionData("cannot verify jti freshness: %v", err)
}
if !fresh {
return invalidTransactionData("jti has been used before")
}
return nil
}

// checkBoundCredentialType refuses a presented credential whose vct is not one
// the bound credential query asked for. Fails closed: when the query cannot be
// found for this session (for example the DCQL is not available on this
// replica) the type cannot be compared and the presentation is refused.
func (c *Client) checkBoundCredentialType(authCtx *cache.AuthorizationContext, scopeQueryIDs map[string]string, scope string, claims map[string]any) error {
query, ok := c.requestedQueryExact(authCtx, scopeQueryIDs, scope)
if !ok {
return invalidTransactionData("cannot compare the credential type: the request's credential query is not available")
}
if len(query.Meta.VCTValues) == 0 {
return invalidTransactionData("cannot compare the credential type: the request named no vct_values")
}
vct, _ := claims["vct"].(string)
if vct == "" {
return invalidTransactionData("credential has no vct")
}
if !slices.Contains(query.Meta.VCTValues, vct) {
return invalidTransactionData("credential vct %q is not one the request asked for", vct)
}
return nil
}
Loading
Loading