Skip to content

feat(openid4vci): migrate to v1.0 spec - #4057

Merged
JorisHeadease merged 44 commits into
masterfrom
feature/openid4vci-v1
May 11, 2026
Merged

feat(openid4vci): migrate to v1.0 spec#4057
JorisHeadease merged 44 commits into
masterfrom
feature/openid4vci-v1

Conversation

@JorisHeadease

@JorisHeadease JorisHeadease commented Mar 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Migrate the user/browser OpenID4VCI flow in auth/iam to OpenID4VCI v1.0 (ID-1).

The internal node-to-node issuance flow in vcr/openid4vci is deliberately left on its existing draft-11-inspired wire format. That flow is used in production for Nuts node-to-node issuance and is not a standards-conformant Wallet implementation per OpenID4VCI v1.0 §2. The package godoc documents the divergence and points readers to the new auth/openid4vci package for the v1.0 user/browser flow.

v1.0 Spec Alignment

  • Add an isolated auth/openid4vci package for v1.0 protocol types, error handling, issuer metadata lookup, Nonce Endpoint calls, and Credential Requests.
  • Use the Credential Issuer Metadata document from /.well-known/openid-credential-issuer, including credential_endpoint, nonce_endpoint, and authorization_servers.
  • Fetch a fresh nonce from the Nonce Endpoint before building the proof JWT.
  • Retry the Credential Request once on invalid_nonce by fetching a fresh nonce.
  • Send Credential Requests with proofs.jwt and either:
    • credential_identifier when the Token Response contains matching authorization_details[].credential_identifiers, or
    • credential_configuration_id when the Authorization Server did not return authorization_details.
  • Parse Credential Responses using the v1.0 credentials[] wrapper and support both JSON string credentials (JWT/SD-JWT) and JSON object credentials (JSON-LD).
  • Use the Credential Issuer Identifier (credential_issuer) as the proof JWT aud, not the Authorization Server issuer.

Implementation Improvements

  • Move OpenID4VCI-specific client logic out of the OpenID4VP/IAM client and into auth/openid4vci.
  • Type authorization_details in the auth OpenAPI schema and require type=openid_credential plus credential_configuration_id.
  • Validate Credential Issuer metadata by checking that credential_issuer matches the requested issuer identifier.
  • Reject Credential Issuer Identifiers with query or fragment components.
  • Validate outbound OpenID4VCI target URLs before HTTP calls to satisfy the current CodeQL SSRF finding; this is intentionally local for now and can move into the shared HTTP client in a follow-up.
  • Reuse oauth.OAuth2Error for OpenID4VCI endpoint errors instead of introducing a duplicate error type.

Scope Notes

  • vcr/openid4vci stays on the existing internal draft-11-style flow. This PR only adds documentation clarifying that package's purpose and limitations.
  • Batch/deferred issuance, credential_offer_uri, notification endpoints, and issuer-side v1.0 support are out of scope.
  • The auth/iam flow stores only the first returned credential, matching the existing single-credential behavior.

@qltysh

qltysh Bot commented Mar 9, 2026

Copy link
Copy Markdown
Contributor

Qlty


Coverage Impact

⬇️ Merging this pull request will decrease total coverage on master by 0.03%.

Modified Files with Diff Coverage (5)

RatingFile% DiffUncovered Line #s
Coverage rating: C Coverage rating: D
auth/auth.go25.0%136-138
Coverage rating: B Coverage rating: B
auth/oauth/types.go0.0%122-127
Coverage rating: B Coverage rating: B
auth/api/iam/api.go100.0%
Coverage rating: B Coverage rating: A
auth/api/iam/openid4vci.go89.6%45-47, 259-260...
New Coverage rating: C
auth/openid4vci/client.go77.2%121-122, 125-126...
Total79.7%
🤖 Increase coverage with AI coding...
In the `feature/openid4vci-v1` branch, add test coverage for this new code:

- `auth/api/iam/openid4vci.go` -- Lines 45-47, 259-260, 267-268, 271, 274, and 281
- `auth/auth.go` -- Line 136-138
- `auth/oauth/types.go` -- Line 122-127
- `auth/openid4vci/client.go` -- Lines 121-122, 125-126, 129-130, 149-150, 153-154, 157-158, 165-166, 175-176, 191-192, 195-196, 202-203, 210-211, 221-222, and 235-236

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Migrates the Nuts Node OpenID4VCI implementation from draft-11 to the OpenID4VCI v1.0 (ID1) spec across issuer/holder flows, API surface, and related client integrations.

Changes:

  • Introduces the v1.0 Nonce Endpoint and moves nonce handling out of the token response (including retry-on-invalid_nonce logic).
  • Updates core OpenID4VCI types to v1.0 shapes (credential_configuration_id, proofs, credentials[], metadata map format, typed grants).
  • Refreshes issuer/holder/auth client logic, tests, and OpenAPI docs/codegen to match the new v1.0 contract.

Reviewed changes

Copilot reviewed 37 out of 37 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
vcr/test/openid4vci_integration_test.go Aligns integration test requests/headers and VC context/type usage.
vcr/openid4vci/wallet_client_test.go Updates offer payload assertions to v1.0 fields and typed grants.
vcr/openid4vci/validators_test.go Adjusts credentialSubject validation test setup.
vcr/openid4vci/validators.go Refines CredentialDefinition.Validate semantics and spec link.
vcr/openid4vci/types_test.go Adds v1.0 conformance tests for requests/offers/metadata/responses.
vcr/openid4vci/types.go Updates OpenID4VCI types for v1.0 (nonce endpoint, config IDs, proofs, credentials array).
vcr/openid4vci/test.go Updates test harness to return v1.0 credential responses and serve nonce endpoint.
vcr/openid4vci/issuer_client_test.go Updates issuer client tests and adds nonce request tests.
vcr/openid4vci/issuer_client_mock.go Extends mock issuer API client with RequestNonce.
vcr/openid4vci/issuer_client.go Adds nonce request support and structured error parsing for credential requests.
vcr/openid4vci/error.go Aligns error codes with v1.0 (e.g., invalid_nonce, invalid_credential_request).
vcr/issuer/test/valid/ExampleCredential.json Updates test credential definition to v1.0 fields and example context.
vcr/issuer/openid_test.go Updates issuer handler tests for config map metadata and nonce endpoint behavior.
vcr/issuer/openid_store_test.go Adds standalone nonce store/consume test coverage.
vcr/issuer/openid_store.go Implements standalone nonce storage/consumption in OpenID store.
vcr/issuer/openid_mock.go Updates mock OpenID handler interface (token no longer returns c_nonce; adds nonce handler).
vcr/issuer/openid.go Migrates issuer logic to v1.0 (config IDs, nonce endpoint, proof validation changes, config loading).
vcr/issuer/assets/definitions/NutsOrganizationCredential.json Adds proof type support and updates context URL.
vcr/issuer/assets/definitions/NutsAuthorizationCredential.json Adds proof type support and updates context URL.
vcr/holder/openid_test.go Updates holder tests for config-id driven requests and nonce endpoint + retry flows.
vcr/holder/openid.go Migrates holder flow to resolve config from metadata and use nonce endpoint + retry logic.
vcr/api/openid4vci/v0/issuer_test.go Updates issuer API wrapper tests and adds nonce endpoint test.
vcr/api/openid4vci/v0/issuer.go Updates credential response shape and adds nonce endpoint handler.
vcr/api/openid4vci/v0/holder_test.go Updates holder API wrapper test offer payload to v1.0 structure.
vcr/api/openid4vci/v0/generated.go Regenerates server interface/types/routes for nonce endpoint; removes 403 credential response type.
vcr/api/openid4vci/v0/api.go Exposes nonce response type alias.
docs/_static/vcr/openid4vci_v0.yaml Updates OpenAPI spec for v1.0 shapes and adds nonce endpoint definition.
codegen/configs/vcr_openid4vci_v0.yaml Includes NonceResponse in generated models.
auth/oauth/types.go Extends credential issuer metadata with nonce_endpoint.
auth/client/iam/openid4vp_test.go Updates IAM client tests for v1.0 credentials response and nonce endpoint.
auth/client/iam/openid4vp.go Adds nonce request method and updates credential retrieval signature.
auth/client/iam/mock.go Updates IAM client mock signatures and adds RequestNonce.
auth/client/iam/interface.go Extends IAM client interface to support nonce and config-id based credential requests.
auth/client/iam/client.go Implements nonce endpoint call and v1.0 credential request/response parsing + structured errors.
auth/api/iam/session.go Persists nonce endpoint + credential configuration ID for the OpenID4VCI flow.
auth/api/iam/openid4vci_test.go Updates callback flow tests for nonce endpoint usage and invalid_nonce retry behavior.
auth/api/iam/openid4vci.go Updates OpenID4VCI callback logic to fetch nonce, request credential by config ID, and retry on invalid_nonce.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread vcr/holder/openid.go Outdated
Comment thread vcr/openid4vci/types.go Outdated
Comment thread vcr/issuer/openid.go Outdated
@JorisHeadease
JorisHeadease marked this pull request as ready for review March 11, 2026 10:25
@JorisHeadease JorisHeadease linked an issue Mar 11, 2026 that may be closed by this pull request
@qltysh

qltysh Bot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

❌ 4 blocking issues (14 total)

Tool Category Rule Count
gofmt Style Incorrect formatting, autoformat by running qlty fmt. 4
qlty Structure Function with many returns (count = 13): RequestOpenid4VCICredentialIssuance 7
qlty Duplication Found 34 lines of identical code in 2 locations (mass = 79) 2
ripgrep Lint // TODO: this validation belongs on httpclient.StrictHTTPClient so every 1

@qltysh one-click actions:

  • Auto-fix formatting (qlty fmt && git push)

Comment thread auth/api/iam/openid4vci.go Outdated
Comment thread auth/api/iam/openid4vci.go Outdated
Comment thread auth/client/iam/openid4vp.go Outdated
Comment thread codegen/configs/vcr_openid4vci_v0.yaml Outdated
Comment thread docs/_static/vcr/openid4vci_v0.yaml Outdated
@reinkrul

Copy link
Copy Markdown
Member

There's an important disctinction, which I think I failed to mention.

We have an OpenID4VCI implementation in vcr/openid4vci, but that's actually Nuts nodes directly calling each other to issue VCs privately (NutsAuthorizationCredentials being issued over HTTP instead over the gRPC network). There's no browser/user involved, so it's actually not a valid use case for OpenID4VCI. We (I) built it based on incorrect understanding of OpenID4VCI. But, it's used in production, so I don't think we should change/update it to OpenID4VCI 1.0 right now.

That does not apply, however, to the OpenID4VCI client in the auth/iam package, which we actually do use to request credential issuance through the user/browser.

I believe we should only update the latter part (and document the first part better...)

Replace draft-era error codes (unsupported_credential_type,
unsupported_credential_format) with the complete set of 7 Credential
Endpoint error codes from OpenID4VCI v1.0 Section 8.3.1.2.
Core structural changes for OpenID4VCI v1.0 alignment:
- Metadata uses credential_configurations_supported map keyed by
  credential_configuration_id (replaces credentials_supported array)
- Credential offers reference configuration IDs instead of inline
  credential definitions (credential_configuration_ids field)
- Typed grant structs replace untyped maps in offers
- Credential requests use credential_configuration_id
- Issuer matches credentials to configurations via findCredentialConfigID
- Config IDs generated as {CredentialType}_{format}
- InvalidNonce used for nonce errors (was InvalidProof in draft)
- server_error returns HTTP 500 (was incorrectly 400)
Wallet-side changes for v1.0 alignment:
- Holder resolves credential_configuration_id from issuer metadata
  instead of using inline credential definitions from offers
- Credential requests use credential_configuration_id (v1.0 preferred)
- Typed grant structs replace untyped map access
- ServerError used for upstream failures (not InvalidRequest)
- API handler returns non-pointer Credential in response
- Update OpenAPI schemas for v1.0 field names and structures
- Update error code documentation for Credential Endpoint
- Remove PreAuthorizedGrantAnonymousAccessSupported from VP
  authorization server metadata (belongs in VCI issuer metadata
  only per Section 12.3)
Three spec compliance fixes found during detailed v1.0 review:

- Credential response: use `credentials` array of wrapper objects
  with `credential` key per Section 8.3
- Credential request: use `proofs` (plural) with
  `{"jwt": ["..."]}` structure per Section 8.2.1
- Error response: remove c_nonce/c_nonce_expires_in fields (wallet
  should use Nonce Endpoint), make c_nonce optional in holder
Update auth/ module OpenID4VCI client code for v1.0 compliance:
- Use Nonce Endpoint instead of c_nonce from token response
- Add credential_configuration_id to credential request and session
- Change CredentialResponseEntry.Credential to json.RawMessage
- Add invalid_nonce retry logic in callback
- Add RequestNonce to IAM client interface
- Remove c_nonce_expires_in from NonceResponse
- Reject non-string @context/type entries in holder metadata parsing
- Regenerate mocks and OpenAPI generated code
- Move nil body check before first field access in RequestOpenid4VCICredentialIssuance
- Add comma-ok assertion on nonce claim type in issuer validateProof
- Validate format field presence in holder resolveCredentialConfiguration
- Add iss claim to holder proof JWT for consistency with auth module
- Add tests: nil OwnDID, empty credentials, non-string nonce, missing format
…epcopyMap

Restore original comments for unchanged OAuth2 error codes. Replace
JSON round-trip deepcopy with direct map copy matching the master
approach.
Revert deepcopyMap to JSON round-trip: shallow copy is insufficient
for nested maps like credential_definition. Remove resolved TODO
about credential validation (already done via
ValidateDefinitionWithCredential).
- Restore defensive panics in deepcopyMap for unmarshalable data
- Make CredentialOffer.Grants a pointer with omitempty (OPTIONAL per
  Section 4.1.1)
- Return false for non-string types in matchesCredential instead of
  silently skipping
- Add iss claim validation in issuer proof verification per v1.0
  Appendix F.1, with dedicated test case
- Validate non-empty c_nonce from Nonce Endpoint responses
- Remove redundant WithContext in RequestNonce
Remove draft-era Format and CredentialDefinition fields from
CredentialRequest (v1.0 uses credential_configuration_id only).
Rename CredentialConfigurationId(s) to CredentialConfigurationID(s)
per Go naming convention for acronyms. JSON wire format unchanged.
Comment thread auth/openid4vci/client.go Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 4 comments.

Comment thread auth/api/iam/openid4vci.go
Comment thread auth/api/iam/openid4vci.go Outdated
Comment thread auth/openid4vci/client.go
Comment thread vcr/openid4vci/types.go
The Credential Issuer URL is user-controlled (via the OpenAPI request
body), and the Nonce/Credential Endpoints come transitively from the
issuer's metadata. CodeQL flagged this as uncontrolled data flowing into
a network request.

Two layers of protection, mirroring the master security stack:

- NewClient now takes a core.HTTPRequestDoer (in production:
  httpclient.StrictHTTPClient via NewWithCache). That gives us the
  HTTPS-in-strict check, User-Agent, and 1MB response body limit for free.
- A strictMode flag on the client validates each target URL via
  core.ParsePublicURL on entry to OpenIDCredentialIssuerMetadata,
  RequestNonce, and RequestCredential. Strict mode adds rejection of
  IP hosts and reserved hostnames on top of HTTPS-only.

The validateURL helper localises the validation pattern; if rules ever
change, one place to update.
Comment thread auth/api/iam/openid4vci.go
Comment thread auth/client/iam/client.go
Comment thread auth/openid4vci/client.go
Comment thread auth/openid4vci/error.go Outdated
Per OpenID4VCI 1.0 §8.2 a Credential Request needs either
credential_configuration_id or credential_identifier. The OpenAPI
schema required the authorization_details field but allowed an empty
array, which would let a request reach the issuer with neither
identifier set. Add minItems:1 to the array so the empty case is
rejected at request validation time.
…tifier

Per OpenID4VCI 1.0 §12.2.1, the Credential Issuer Identifier is a URL
that "contains scheme, host and, optionally, port number and path
components, but no query or fragment components".

core.ParsePublicURL did not enforce this; an issuer URL with a query or
fragment slipped through and was forwarded into the constructed
.well-known URL via url.URL.String(). Reject these explicitly in
OpenIDCredentialIssuerMetadata.
Per OpenID4VCI 1.0 §F.1, the proof JWT aud claim MUST be the Credential
Issuer Identifier. The handler stored authzServerMetadata.Issuer (the
Authorization Server issuer) and used it as the proof audience, which is
only correct when the metadata's authorization_servers field is empty
(implicit AS == CI). When the issuer delegates to a separate AS, the AS
issuer URL differs from the Credential Issuer Identifier and the proof
is rejected.

Persist credentialIssuerMetadata.CredentialIssuer separately on the
session and use it for the proof audience. IssuerURL keeps its meaning
as the AS issuer (used elsewhere in the OpenID4VP flow).
Drop the local Error/ErrorCode types in auth/openid4vci. The OpenID4VCI
spec error response shape (`{"error": ..., "error_description": ...}`)
matches RFC 6749 / oauth.OAuth2Error one-to-one. Add the OpenID4VCI-only
extension code (invalid_nonce) to oauth.ErrorCode and let the client and
its callers use the existing oauth types directly.

Per Rein's review of PR #4057.
…lient

Per Rein's review: this validation conceptually belongs on the shared
HTTP transport (httpclient.StrictHTTPClient) so every outbound call gets
the IP/reserved-host check, not just OpenID4VCI. The current placement
preserves parity with master (which validated via oauth.IssuerIdToWellKnown)
and addresses the CodeQL SSRF finding for this PR. Mark as a follow-up to
consolidate the check in the shared client.
Make the comment on the credential parsing block explicit about the
reason: json.RawMessage keeps the raw JSON encoding, which for a JWT
includes the surrounding quotes. ParseVerifiableCredential rejects
that as invalid base64, so the unmarshal-as-string step is required
to strip the quotes (and is a no-op for JSON-LD objects via the
error fallback).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 4 comments.

Comment thread auth/openid4vci/client.go Outdated
Comment thread auth/openid4vci/client.go
Comment thread auth/api/iam/openid4vci.go Outdated
Comment thread docs/_static/auth/v2.yaml
…known URL

credentialIssuerWellKnown was assigning u.EscapedPath() (already encoded)
back into u.Path; url.URL.String reescapes via EscapedPath, turning %2F
into %252F. Prepend the well-known segment to u.Path (decoded) and to
u.RawPath when the latter is set, so original encoding is preserved
without double-escaping.
The OpenAPI schema declares minItems: 1 but the StrictServer middleware
does not enforce minItems at runtime, so an empty array passed through
silently. The handler used a defensive 'if len > 0' guard that left
IssuerCredentialConfigurationID empty, which would later produce a
malformed Credential Request.

Replace the guard with an explicit core.InvalidInputError positioned
before the issuer/auth-server metadata fetches, so an invalid local
request does not trigger any outbound work. Update requestCredentials
test helper to populate AuthorizationDetails, and add a regression test.
The handler only consumes the first entry, so accepting an array with
multiple entries silently dropped extras. Add maxItems: 1 to the schema
to make the contract explicit, and tighten the handler guard from
'len > 0' to 'len == 1' so both bounds are checked at runtime (the
StrictServer middleware enforces neither).
Audit pass against the spec. Several section pointers in code and YAML
descriptions were imprecise or wrong:

- 'Section 8.2.1.1' for the JWT typ value -> Appendix F.1 (jwt Proof Type)
- 'Section 8.2.1' for proofs parameter -> Section 8.2 (with proof type
  formats listed in Appendix F)
- '§10' for notification_id -> §11 (Notification Endpoint)
- '§12.2' for the .well-known retrieval -> §12.2.2 (Credential Issuer
  Metadata Retrieval)
- '§5.1' for authorization_details usage in YAML descriptions -> §5.1.1
  (Using Authorization Details Parameter)

Documentation only; no behaviour change.
@JorisHeadease
JorisHeadease requested a review from reinkrul May 8, 2026 18:57
@reinkrul

Copy link
Copy Markdown
Member

Can you add OpenID4VCI to these docs: https://nuts-node.readthedocs.io/en/stable/pages/deployment/oauth.html

List OpenID4VCI 1.0 among the implemented specs and document the wallet
flow: Authorization Code with authorization_details, PKCE, Nonce
Endpoint, proof JWT, and invalid_nonce recovery. Note the unimplemented
operational features (deferred issuance, Notification Endpoint, multiple
credentials per call) and the unrelated draft-11 internal flow in
vcr/openid4vci.
@JorisHeadease
JorisHeadease merged commit 198367e into master May 11, 2026
10 of 11 checks passed
@JorisHeadease
JorisHeadease deleted the feature/openid4vci-v1 branch May 11, 2026 12:08
reinkrul added a commit that referenced this pull request May 11, 2026
OpenID4VCI 1.0 (#4057) moved the credential-issuer HTTP client from
auth/client/iam into a dedicated auth/openid4vci package and replaced
the map-based Credential Request body with typed structs (proofs array,
mutually-exclusive credential_configuration_id / credential_identifier).

This merge re-applies the credential_details forwarding feature on top
of that new base:

- RequestCredentialOpts gains a CredentialDetails map[string]any.
- RequestCredential merges those keys with the typed body (spec-defined
  fields always overlay the caller's, so credential_configuration_id /
  credential_identifier / proofs win).
- OAuthSession carries CredentialRequestDetails alongside the new
  IssuerNonceEndpoint / IssuerCredentialConfigurationID /
  IssuerCredentialIssuer fields master added.
- handleOpenID4VCICallback passes the session's CredentialRequestDetails
  through requestCredentialWithProof.
- v2.yaml keeps the experimental credential_details property; its
  description is updated to reflect the v1.0 typed overlay (no longer a
  flat "proof" overwrite).

Assisted by AI
reinkrul added a commit that referenced this pull request May 12, 2026
OpenID4VCI 1.0 (#4057) moved the credential-issuer HTTP client from
auth/client/iam into a dedicated auth/openid4vci package and replaced
the map-based Credential Request body with typed structs (proofs array,
mutually-exclusive credential_configuration_id / credential_identifier).

This merge re-applies the credential_details forwarding feature on top
of that new base:

- RequestCredentialOpts gains a CredentialDetails map[string]any.
- RequestCredential merges those keys with the typed body (spec-defined
  fields always overlay the caller's, so credential_configuration_id /
  credential_identifier / proofs win).
- OAuthSession carries CredentialRequestDetails alongside the new
  IssuerNonceEndpoint / IssuerCredentialConfigurationID /
  IssuerCredentialIssuer fields master added.
- handleOpenID4VCICallback passes the session's CredentialRequestDetails
  through requestCredentialWithProof.
- v2.yaml keeps the experimental credential_details property; its
  description is updated to reflect the v1.0 typed overlay (no longer a
  flat "proof" overwrite).

Assisted by AI
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.

Update OpenID4VCI implementation to spec v1.0

4 participants