Skip to content

feat(openid4vp): serve sessions over the W3C Digital Credentials API - #648

Draft
dobby-coder[bot] wants to merge 2 commits into
masterfrom
feat/openid4vp-dc-api-677
Draft

feat(openid4vp): serve sessions over the W3C Digital Credentials API#648
dobby-coder[bot] wants to merge 2 commits into
masterfrom
feat/openid4vp-dc-api-677

Conversation

@dobby-coder

@dobby-coder dobby-coder Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Why

privacybydesign/irmamobile#677 asks for Yivi to serve OpenID4VP presentation requests through the W3C Digital Credentials API, so the OS credential chooser routes a request straight to the wallet instead of a custom scheme or a universal link.

That issue flags one open question: does irmago support the DC API request/response profile? It does not. eudi/openid4vp knows only direct_post and direct_post.jwt, always fetches the request object from a request_uri, and always POSTs the response itself. This PR adds the core half (OpenID4VP 1.0 Appendix A) so the Android and Dart work can build on it.

What it adds

  • openid4vp.Client.NewDcApiSession(request, handler) starts a session from the protocol identifier, the caller origin and the raw data member the platform reported, instead of from a URL.
  • Response modes dc_api and dc_api.jwt. The response is built as the JSON data object the platform hands back to the verifier and delivered through the new Handler.DeliverDcApiResponse. Nothing is POSTed.
  • Unsigned requests (openid4vp-v1-unsigned): the request parameters arrive as members of data. client_id and expected_origins are dropped, because the verifier authenticated neither. The verifier is shown by its origin host and never as verified.
  • Signed requests (openid4vp-v1-signed): the JWS in data.request goes through the existing X.509 and DID trust models. The caller origin must match one of the signed expected_origins, or the session fails.
  • Presentations are bound to origin:<origin> for both request types, so a Key Binding JWT's aud is the origin and never the client identifier (Appendix A.4).
  • response_uri and redirect_uri are dropped during parsing, so no code further down can transmit the response anywhere but back through the platform.
  • On the wallet-facing client API: SessionRequestData.DcApi starts the session, SessionState.DcApiResponse carries the response back out.

direct_post and direct_post.jwt behaviour is unchanged. The JWE builder is now shared between direct_post.jwt and dc_api.jwt, with state passed in as a response-mode-specific member (state is not defined for the DC API, so the DC API responses omit it).

Breaking, internal API: openid4vp.Handler gains DeliverDcApiResponse(response string). The one implementer in this repo is updated.

Not in this PR

  • The Android Credential Manager provider, the credential registry and its matcher, and the Dart/native bridge. Those live in irmamobile and need this merged plus an irmago pin bump first. androidx.credentials.registry:registry-digitalcredentials-sdjwtvc (1.0.0-alpha04) looks like the right matcher for Yivi's SD-JWT VC credentials, but that is the app repo's call.
  • openid4vp-v1-multisigned (JWS JSON Serialization), which needs multi-client-identifier verification. Rejected with an explicit error rather than silently misparsed.
  • The Appendix A.4 protocol error response object. How wallet errors map onto the Section 8.5 codes is easier to settle once the native provider exists; today Failure and Cancelled tell the app what happened and the app decides what to return to the platform.
  • iOS.

Testing

eudi/openid4vp/dc_api_test.go adds 20 test functions (43 cases counting table subtests):

  • request parsing and Appendix A.2 validation for both protocols, including the negative cases for response_type, response_mode, nonce, scope and an empty dcql_query
  • that client_id, expected_origins, response_uri and redirect_uri never survive an unsigned request
  • origin comparison, including ports, scheme, case and the non-URL origins Android reports for native callers
  • response building for both response modes, decrypting the dc_api.jwt JWE and asserting the vp_token is not also sent in the clear and that state is absent
  • that direct_post.jwt still carries state
  • two runs through Client.NewDcApiSession asserting the response comes back via DeliverDcApiResponse and the audience is the origin, for a signed request as well as an unsigned one

Ran locally on go1.26.4: go test ./eudi/... ./client/... ./common/..., gofmt -d -e ., go vet ./..., go fix -diff ./..., staticcheck and ineffassign are all clean. The Postgres/SMTP/Redis-dependent packages under internal/sessiontest were not run locally; they compile (go vet ./internal/...) and CI covers the rest.

Part of privacybydesign/irmamobile#677

🤖 Generated with Claude Code

Adds the OpenID4VP 1.0 Appendix A profile to the OpenID4VP client, so a
wallet can answer a presentation request the OS handed it through the
platform credential chooser rather than through a custom scheme or a
universal link.

- Client.NewDcApiSession starts a session from the protocol identifier,
  the caller origin and the raw `data` member the platform reported,
  instead of from a URL with a request_uri to fetch.
- Both openid4vp-v1-unsigned (parameters as members of `data`) and
  openid4vp-v1-signed (a JWS in `data.request`, verified against the
  existing trust models) are accepted. openid4vp-v1-multisigned is
  rejected with an explicit error.
- Response modes dc_api and dc_api.jwt build the JSON the wallet hands
  back to the platform and deliver it through the new
  Handler.DeliverDcApiResponse; nothing is POSTed to a response_uri.
- Signed requests must list the caller origin in expected_origins, and
  presentations are bound to `origin:<origin>` for both request types,
  so a Key Binding JWT's aud is never the client identifier.
- client_id and expected_origins in an unsigned request, and response_uri
  and redirect_uri in either, are dropped during parsing.

On the wallet-facing client API, SessionRequestData.DcApi starts such a
session and SessionState.DcApiResponse carries the response back out.

Part of privacybydesign/irmamobile#677

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dobby-coder
dobby-coder Bot requested a review from DibranMulder July 28, 2026 12:05
Resolved against the locale-aware client (#633):

- eudi/openid4vp/client.go: master's requestor-building block in
  handleSessionAsync moved into verifySignedAuthorizationRequest by this
  branch. Kept the extraction and adopted master's change inside it, so
  the requestor name is resolved via clientmodels.Resolve against
  client.currentLocale.
- unsignedDcApiRequestor now sets TrustedParty.Name to a plain resolved
  string. client_name is a single string and an origin is not localized,
  so there is nothing for the locale to resolve there.
- CHANGELOG.md: kept both Unreleased > Added entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dobby-coder

dobby-coder Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Merged master after #633 (locale-aware client) landed. Two conflicts, both in code this branch had already touched:

  • eudi/openid4vp/client.go — this branch moved the requestor-building block out of handleSessionAsync into a new verifySignedAuthorizationRequest, which the DC API path reuses for signed requests; Locale-aware client — resolve translations and logos in irmago #633 edited that block in its old home. Kept the extraction and applied Locale-aware client — resolve translations and logos in irmago #633's change inside it, so the requestor name is resolved with clientmodels.Resolve against client.currentLocale.
  • unsignedDcApiRequestor needed the same treatment for TrustedParty.Name, now a resolved string rather than a TranslatedString. It sets the name directly: client_name is a single string and an origin is not localized, so there is nothing for the locale to resolve.
  • CHANGELOG.md — both Unreleased > Added entries kept.

go test ./eudi/... ./client/... ./common/..., gofmt, go vet ./..., go fix -diff ./..., staticcheck and ineffassign are clean on the merge commit.

@dobby-coder

dobby-coder Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

/dobby fix rebase this PR onto the latest master and resolve any conflicts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants