Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
de75f43
feat(openid4vci): align error codes with v1.0 Section 8.3.1.2
JorisHeadease Feb 24, 2026
8c02553
feat(openid4vci): update types and issuer for v1.0 metadata and offer
JorisHeadease Feb 24, 2026
15654bb
feat(openid4vci): update holder and API handler for v1.0
JorisHeadease Feb 24, 2026
a8b0af6
feat(openid4vci): update OpenAPI spec and remove stale VP metadata
JorisHeadease Feb 24, 2026
6ee11b2
fix(openid4vci): align wire formats with v1.0 spec review
JorisHeadease Mar 6, 2026
a09c50a
feat(openid4vci): align auth module with v1.0 spec
JorisHeadease Mar 9, 2026
1aa3837
fix(openid4vci): harden input validation and add missing tests
JorisHeadease Mar 9, 2026
77b932d
Merge remote-tracking branch 'origin/master' into feature/openid4vci-v1
JorisHeadease Mar 9, 2026
906dd0f
fix(openid4vci): restore PreAuthorizedGrantAnonymousAccessSupported i…
JorisHeadease Mar 9, 2026
e0dfecb
docs(openid4vci): improve OpenAPI spec v1.0 accuracy
JorisHeadease Mar 10, 2026
301be91
fix(openid4vci): correct holder error code for unsupported format
JorisHeadease Mar 10, 2026
a0b4a31
test(openid4vci): fix auth header bug and add missing test coverage
JorisHeadease Mar 10, 2026
1d66b58
refactor(openid4vci): restore original error comments and simplify de…
JorisHeadease Mar 10, 2026
c0e8347
fix(openid4vci): restore JSON deep copy and remove resolved TODO
JorisHeadease Mar 10, 2026
9cae8c8
fix(openid4vci): harden validation and fix spec compliance issues
JorisHeadease Mar 10, 2026
355330a
refactor(openid4vci): clean up CredentialRequest and rename Id to ID
JorisHeadease Mar 10, 2026
dfc9f6c
fix(openid4vci): use json.RawMessage for CredentialResponseEntry
JorisHeadease Mar 10, 2026
276bf0a
refactor(openid4vci): unify duplicate types across packages
JorisHeadease Mar 11, 2026
2474568
qlty fmt
qltysh[bot] Mar 11, 2026
74bfc60
feat(openid4vci): validate authorization_details against metadata
JorisHeadease Mar 11, 2026
a2e5267
feat(openid4vci): validate proof_signing_alg_values_supported
JorisHeadease Mar 11, 2026
cbfd342
feat(openid4vci): detect deferred credential issuance
JorisHeadease Mar 11, 2026
26242a3
feat(openid4vci): add PAR support (RFC 9126)
JorisHeadease Mar 11, 2026
aaef59b
feat(openid4vci): support credential_identifiers in token response
JorisHeadease Mar 12, 2026
8643a64
feat(openid4vci): support scope-based credential requests
JorisHeadease Mar 16, 2026
68a9a74
feat(openid4vci): verify signed_metadata in issuer metadata
JorisHeadease Mar 16, 2026
6b9902b
fix(openid4vci): use credential issuer identifier as proof audience
JorisHeadease Mar 16, 2026
ba667e5
fix(openid4vci): require credential_endpoint in signed_metadata
JorisHeadease Mar 16, 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
7 changes: 6 additions & 1 deletion auth/api/iam/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

283 changes: 235 additions & 48 deletions auth/api/iam/openid4vci.go

Large diffs are not rendered by default.

745 changes: 677 additions & 68 deletions auth/api/iam/openid4vci_test.go

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions auth/api/iam/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ type OAuthSession struct {
UseDPoP bool `json:"use_dpop,omitempty"`
// IssuerCredentialEndpoint: endpoint to exchange the access_token for a credential in the OpenID4VCI flow
IssuerCredentialEndpoint string `json:"issuer_credential_endpoint,omitempty"`
// IssuerNonceEndpoint: endpoint to request a fresh c_nonce in the OpenID4VCI flow (v1.0 Section 7)
IssuerNonceEndpoint string `json:"issuer_nonce_endpoint,omitempty"`
// IssuerCredentialConfigurationID: the credential_configuration_id for the credential request in the OpenID4VCI flow
IssuerCredentialConfigurationID string `json:"issuer_credential_configuration_id,omitempty"`
// ProofSigningAlgValuesSupported: algorithms the issuer accepts for proof JWTs (v1.0 Appendix F.1)
ProofSigningAlgValuesSupported []string `json:"proof_signing_alg_values_supported,omitempty"`
}

// oauthClientFlow is used by a client to identify the flow a particular callback is part of
Expand Down
159 changes: 131 additions & 28 deletions auth/client/iam/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package iam
import (
"bytes"
"context"
stdcrypto "crypto"
"encoding/json"
"errors"
"fmt"
Expand All @@ -38,6 +39,7 @@ import (
"github.com/nuts-foundation/nuts-node/auth/log"
"github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/core"
"github.com/nuts-foundation/nuts-node/vcr/openid4vci"
"github.com/nuts-foundation/nuts-node/vcr/pe"
)

Expand Down Expand Up @@ -242,6 +244,35 @@ func (hb HTTPClient) PostAuthorizationResponse(ctx context.Context, vp vc.Verifi
return hb.postFormExpectRedirect(ctx, data, verifierResponseURI)
}

func (hb HTTPClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodPost, nonceEndpoint, http.NoBody)
if err != nil {
return "", err
}
response, err := hb.httpClient.Do(request)
if err != nil {
return "", fmt.Errorf("nonce request failed: %w", err)
}
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
return "", fmt.Errorf("unable to read nonce response: %w", err)
}
if response.StatusCode < 200 || response.StatusCode > 299 {
return "", fmt.Errorf("nonce endpoint returned status %d", response.StatusCode)
}
var nonceResponse struct {
CNonce string `json:"c_nonce"`
}
if err = json.Unmarshal(data, &nonceResponse); err != nil {
return "", fmt.Errorf("unable to unmarshal nonce response: %w", err)
}
if nonceResponse.CNonce == "" {
return "", errors.New("nonce endpoint returned empty c_nonce")
}
return nonceResponse.CNonce, nil
}

func (hb HTTPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) {
metadataURL, err := oauth.IssuerIdToWellKnown(oauthIssuerURI, oauth.OpenIdCredIssuerWellKnown, hb.strictMode)
if err != nil {
Expand All @@ -252,7 +283,56 @@ func (hb HTTPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIs
if err != nil {
return nil, err
}
return &metadata, err
if metadata.SignedMetadata != "" {
if err = hb.verifySignedMetadata(ctx, &metadata); err != nil {
return nil, fmt.Errorf("signed_metadata verification failed: %w", err)
}
}
return &metadata, nil
}

// verifySignedMetadata verifies the signed_metadata JWT against the issuer's key (v1.0 Section 12.2.3).
// It validates the JWT signature, typ header, required claims (sub, iat), and compares
// key metadata claims (credential_issuer, credential_endpoint) against the unsigned metadata.
func (hb HTTPClient) verifySignedMetadata(ctx context.Context, metadata *oauth.OpenIDCredentialIssuerMetadata) error {
// Verify typ header to prevent JWT type confusion attacks
typ, err := crypto.JWTTyp(metadata.SignedMetadata)
if err != nil {
return fmt.Errorf("invalid JWT: %w", err)
}
if typ != "openidvci-issuer-metadata+jwt" {
return fmt.Errorf("typ header must be openidvci-issuer-metadata+jwt, got %q", typ)
}
// Parse, verify signature, and validate standard claims using shared infrastructure
token, err := crypto.ParseJWT(metadata.SignedMetadata, func(kid string) (stdcrypto.PublicKey, error) {
return hb.keyResolver.ResolveKeyByID(kid, nil, resolver.AssertionMethod)
}, jwt.WithValidate(true), jwt.WithAcceptableSkew(5*time.Second))
if err != nil {
return fmt.Errorf("invalid JWT: %w", err)
}
// sub is REQUIRED, must match credential_issuer. iss is OPTIONAL per spec.
if token.Subject() != metadata.CredentialIssuer {
return fmt.Errorf("sub %q does not match credential_issuer %q", token.Subject(), metadata.CredentialIssuer)
}
if token.IssuedAt().IsZero() {
return fmt.Errorf("iat claim is required")
}
// Compare metadata claims from JWT payload against unsigned metadata
claims, err := token.AsMap(ctx)
if err != nil {
return fmt.Errorf("failed to extract claims: %w", err)
}
if ci, _ := claims["credential_issuer"].(string); ci != metadata.CredentialIssuer {
return fmt.Errorf("credential_issuer claim %q does not match metadata %q", ci, metadata.CredentialIssuer)
}
ce, _ := claims["credential_endpoint"].(string)
if ce == "" {
return fmt.Errorf("credential_endpoint claim is required in signed metadata")
}
if ce != metadata.CredentialEndpoint {
return fmt.Errorf("credential_endpoint claim %q does not match metadata %q", ce, metadata.CredentialEndpoint)
}
return nil
}

func (hb HTTPClient) OpenIDConfiguration(ctx context.Context, issuerURL string) (*oauth.OpenIDConfiguration, error) {
Expand Down Expand Up @@ -308,34 +388,49 @@ func (hb HTTPClient) KeyProvider() jws.KeyProviderFunc {
}
}

// CredentialRequest represents ths request to fetch a credential, the JSON object holds the proof as
// CredentialRequestProof.
type CredentialRequest struct {
Proof CredentialRequestProof `json:"proof"`
}

// CredentialRequestProof holds the ProofType and Jwt for a credential request
type CredentialRequestProof struct {
ProofType string `json:"proof_type"`
Jwt string `json:"jwt"`
}

// CredentialResponse represents the response of a verifiable credential request.
// It contains the Format and the actual Credential in JSON format.
type CredentialResponse struct {
Credential string `json:"credential"`
func (hb HTTPClient) PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parEndpoint, strings.NewReader(params.Encode()))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response, err := hb.httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("PAR request failed: %w", err)
}
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("unable to read PAR response: %w", err)
}
if response.StatusCode != http.StatusCreated {
bodySnippet := string(data)
if len(bodySnippet) > core.HttpResponseBodyLogClipAt {
bodySnippet = bodySnippet[:core.HttpResponseBodyLogClipAt] + "...(clipped)"
}
return nil, fmt.Errorf("PAR endpoint returned HTTP %d (expected: 201): %s", response.StatusCode, bodySnippet)
}
var parResponse PARResponse
if err = json.Unmarshal(data, &parResponse); err != nil {
return nil, fmt.Errorf("unable to unmarshal PAR response: %w", err)
}
if !strings.HasPrefix(parResponse.RequestURI, "urn:ietf:params:oauth:request_uri:") {
return nil, fmt.Errorf("PAR response contains invalid request_uri: %q", parResponse.RequestURI)
}
return &parResponse, nil
}

func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, proofJwt string) (*CredentialResponse, error) {
func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, credentialIdentifier string, proofJwt string) (*openid4vci.CredentialResponse, error) {
credentialEndpointURL, err := url.Parse(credentialEndpoint)
if err != nil {
return nil, err
}

credentialRequest := CredentialRequest{
Proof: CredentialRequestProof{
ProofType: "jwt",
Jwt: proofJwt,
credentialRequest := openid4vci.CredentialRequest{
CredentialConfigurationID: credentialConfigID,
CredentialIdentifier: credentialIdentifier,
Proofs: &openid4vci.CredentialRequestProofs{
Jwt: []string{proofJwt},
},
}
jsonBody, _ := json.Marshal(credentialRequest)
Expand All @@ -357,15 +452,23 @@ func (hb HTTPClient) VerifiableCredentials(ctx context.Context, credentialEndpoi
log.Logger().WithError(err).Warn("Trouble closing reader")
}
}(response.Body)
if err = core.TestResponseCode(http.StatusOK, response); err != nil {
return nil, err
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var credential CredentialResponse
if err = json.NewDecoder(response.Body).Decode(&credential); err != nil {
if response.StatusCode < 200 || response.StatusCode > 299 {
var oidcError openid4vci.Error
if json.Unmarshal(responseBody, &oidcError) == nil && oidcError.Code != "" {
oidcError.StatusCode = response.StatusCode
return nil, oidcError
}
return nil, fmt.Errorf("credential request failed (status %d)", response.StatusCode)
}
var credentialResponse openid4vci.CredentialResponse
if err = json.Unmarshal(responseBody, &credentialResponse); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &credential, nil

return &credentialResponse, nil
}
func (hb HTTPClient) postFormExpectRedirect(ctx context.Context, form url.Values, redirectURL url.URL) (string, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodPost, redirectURL.String(), strings.NewReader(form.Encode()))
Expand Down
16 changes: 15 additions & 1 deletion auth/client/iam/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,20 @@ package iam

import (
"context"
"net/url"

"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/vcr/openid4vci"
"github.com/nuts-foundation/nuts-node/vcr/pe"
)

// PARResponse holds the response from a Pushed Authorization Request (RFC 9126).
type PARResponse struct {
RequestURI string `json:"request_uri"`
ExpiresIn int `json:"expires_in"`
}

// Client defines OpenID4VP client methods using the IAM OpenAPI Spec.
type Client interface {
// AccessToken requests an access token at the oauth2 token endpoint.
Expand Down Expand Up @@ -52,8 +61,13 @@ type Client interface {
OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error)
// OpenIDConfiguration returns the OpenID Configuration of the remote wallet.
OpenIDConfiguration(ctx context.Context, issuer string) (*oauth.OpenIDConfiguration, error)
// RequestNonce requests a fresh c_nonce from the issuer's Nonce Endpoint (v1.0 Section 7).
RequestNonce(ctx context.Context, nonceEndpoint string) (string, error)
// VerifiableCredentials requests Verifiable Credentials from the issuer at the given endpoint.
VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, proofJWT string) (*CredentialResponse, error)
// Either credentialConfigID or credentialIdentifier must be non-empty (mutually exclusive per v1.0 Section 8.2).
VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, credentialIdentifier string, proofJWT string) (*openid4vci.CredentialResponse, error)
// PushedAuthorizationRequest sends a Pushed Authorization Request (RFC 9126) to the given endpoint.
PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, error)
// RequestObjectByGet retrieves the RequestObjectByGet from the authorization request's 'request_uri' endpoint using a GET method as defined in RFC9101/OpenID4VP.
// This method is used when there is no 'request_uri_method', or its value is 'get'.
RequestObjectByGet(ctx context.Context, requestURI string) (string, error)
Expand Down
43 changes: 37 additions & 6 deletions auth/client/iam/mock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 16 additions & 3 deletions auth/client/iam/openid4vp.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import (
"github.com/nuts-foundation/nuts-node/crypto/dpop"
nutsHttp "github.com/nuts-foundation/nuts-node/http"
"github.com/nuts-foundation/nuts-node/vcr/holder"
"github.com/nuts-foundation/nuts-node/vcr/openid4vci"
"github.com/nuts-foundation/nuts-node/vcr/pe"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
)
Expand Down Expand Up @@ -355,11 +356,23 @@ func (c *OpenID4VPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oa
return rsp, nil
}

func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, proofJWT string) (*CredentialResponse, error) {
func (c *OpenID4VPClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) {
return c.httpClient.RequestNonce(ctx, nonceEndpoint)
}

func (c *OpenID4VPClient) PushedAuthorizationRequest(ctx context.Context, parEndpoint string, params url.Values) (*PARResponse, error) {
parsedURL, err := core.ParsePublicURL(parEndpoint, c.strictMode)
if err != nil {
return nil, fmt.Errorf("invalid PAR endpoint: %w", err)
}
return c.httpClient.PushedAuthorizationRequest(ctx, parsedURL.String(), params)
}

func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, credentialConfigID string, credentialIdentifier string, proofJWT string) (*openid4vci.CredentialResponse, error) {
iamClient := c.httpClient
rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, proofJWT)
rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, credentialConfigID, credentialIdentifier, proofJWT)
if err != nil {
return nil, fmt.Errorf("remote server: failed to retrieve credentials: %w", err)
return nil, err
}
return rsp, nil
}
Expand Down
Loading