diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go
index 3310ad2ba9..c4c309869c 100644
--- a/auth/api/auth/v1/api_test.go
+++ b/auth/api/auth/v1/api_test.go
@@ -28,6 +28,7 @@ import (
pkg2 "github.com/nuts-foundation/nuts-node/auth"
"github.com/nuts-foundation/nuts-node/auth/client/iam"
"github.com/nuts-foundation/nuts-node/auth/contract"
+ "github.com/nuts-foundation/nuts-node/auth/openid4vci"
oauth2 "github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/auth/services"
"github.com/nuts-foundation/nuts-node/auth/services/dummy"
@@ -86,6 +87,10 @@ func (m *mockAuthClient) IAMClient() iam.Client {
return m.iamClient
}
+func (m *mockAuthClient) OpenID4VCIClient() openid4vci.Client {
+ return nil
+}
+
func (m *mockAuthClient) ContractNotary() services.ContractNotary {
return m.contractNotary
}
diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go
index bedbba113d..6ef8405d44 100644
--- a/auth/api/iam/api.go
+++ b/auth/api/iam/api.go
@@ -47,6 +47,7 @@ import (
iamclient "github.com/nuts-foundation/nuts-node/auth/client/iam"
"github.com/nuts-foundation/nuts-node/auth/log"
"github.com/nuts-foundation/nuts-node/auth/oauth"
+ "github.com/nuts-foundation/nuts-node/auth/openid4vci"
"github.com/nuts-foundation/nuts-node/core"
nutsCrypto "github.com/nuts-foundation/nuts-node/crypto"
nutsHttp "github.com/nuts-foundation/nuts-node/http"
@@ -867,8 +868,8 @@ func (r Wrapper) StatusList(ctx context.Context, request StatusListRequestObject
return StatusList200JSONResponse(*cred), nil
}
-func (r Wrapper) openid4vciMetadata(ctx context.Context, issuer string) (*oauth.OpenIDCredentialIssuerMetadata, *oauth.AuthorizationServerMetadata, error) {
- credentialIssuerMetadata, err := r.auth.IAMClient().OpenIdCredentialIssuerMetadata(ctx, issuer)
+func (r Wrapper) openid4vciMetadata(ctx context.Context, issuer string) (*openid4vci.OpenIDCredentialIssuerMetadata, *oauth.AuthorizationServerMetadata, error) {
+ credentialIssuerMetadata, err := r.auth.OpenID4VCIClient().OpenIDCredentialIssuerMetadata(ctx, issuer)
if err != nil {
return nil, nil, err
}
diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go
index 5be4de20e1..57fe97570b 100644
--- a/auth/api/iam/api_test.go
+++ b/auth/api/iam/api_test.go
@@ -43,6 +43,7 @@ import (
"github.com/nuts-foundation/nuts-node/auth"
"github.com/nuts-foundation/nuts-node/auth/client/iam"
"github.com/nuts-foundation/nuts-node/auth/oauth"
+ "github.com/nuts-foundation/nuts-node/auth/openid4vci"
oauthServices "github.com/nuts-foundation/nuts-node/auth/services/oauth"
"github.com/nuts-foundation/nuts-node/core"
"github.com/nuts-foundation/nuts-node/core/to"
@@ -1588,6 +1589,7 @@ type testCtx struct {
wallet *holder.MockWallet
subjectManager *didsubject.MockManager
jar *MockJAR
+ openid4vciClient *openid4vci.MockClient
}
func newTestClient(t testing.TB) *testCtx {
@@ -1605,6 +1607,7 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b
vcIssuer := issuer.NewMockIssuer(ctrl)
vcVerifier := verifier.NewMockVerifier(ctrl)
iamClient := iam.NewMockClient(ctrl)
+ openid4vciClient := openid4vci.NewMockClient(ctrl)
mockDocumentOwner := didsubject.NewMockDocumentOwner(ctrl)
subjectManager := didsubject.NewMockManager(ctrl)
mockVCR := vcr.NewMockVCR(ctrl)
@@ -1620,6 +1623,7 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b
mockVCR.EXPECT().Verifier().Return(vcVerifier).AnyTimes()
mockVCR.EXPECT().Wallet().Return(mockWallet).AnyTimes()
authnServices.EXPECT().IAMClient().Return(iamClient).AnyTimes()
+ authnServices.EXPECT().OpenID4VCIClient().Return(openid4vciClient).AnyTimes()
authnServices.EXPECT().AuthorizationEndpointEnabled().Return(authEndpointEnabled).AnyTimes()
subjectManager.EXPECT().ListDIDs(gomock.Any(), holderSubjectID).Return([]did.DID{holderDID}, nil).AnyTimes()
@@ -1657,5 +1661,6 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b
jwtSigner: jwtSigner,
jar: mockJAR,
client: client,
+ openid4vciClient: openid4vciClient,
}
}
diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go
index 859cff5efd..9e214e72fc 100644
--- a/auth/api/iam/generated.go
+++ b/auth/api/iam/generated.go
@@ -19,6 +19,11 @@ const (
JwtBearerAuthScopes = "jwtBearerAuth.Scopes"
)
+// Defines values for AuthorizationDetailType.
+const (
+ OpenidCredential AuthorizationDetailType = "openid_credential"
+)
+
// Defines values for ServiceAccessTokenRequestTokenType.
const (
ServiceAccessTokenRequestTokenTypeBearer ServiceAccessTokenRequestTokenType = "Bearer"
@@ -31,6 +36,26 @@ const (
UserAccessTokenRequestTokenTypeDPoP UserAccessTokenRequestTokenType = "DPoP"
)
+// AuthorizationDetail A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1.1.
+// Only the fields used by the user/browser issuance flow are modeled.
+type AuthorizationDetail struct {
+ // CredentialConfigurationId References a credential configuration from the issuer's
+ // credential_configurations_supported metadata. REQUIRED for
+ // type=openid_credential per §5.1.1.
+ CredentialConfigurationId string `json:"credential_configuration_id"`
+
+ // Format Optional credential format hint (e.g. "vc+sd-jwt").
+ Format *string `json:"format,omitempty"`
+
+ // Type The authorization details type. For OpenID4VCI flows this MUST
+ // be "openid_credential" per §5.1.1.
+ Type AuthorizationDetailType `json:"type"`
+}
+
+// AuthorizationDetailType The authorization details type. For OpenID4VCI flows this MUST
+// be "openid_credential" per §5.1.1.
+type AuthorizationDetailType string
+
// DPoPRequest defines model for DPoPRequest.
type DPoPRequest struct {
// Htm The HTTP method for which the DPoP proof is requested.
@@ -106,7 +131,7 @@ type ExtendedTokenIntrospectionResponse struct {
// PresentationSubmissions Mapping of Presentation Definition IDs that were fulfilled to Presentation Submissions.
PresentationSubmissions *map[string]PresentationSubmission `json:"presentation_submissions,omitempty"`
- // Scope granted scopes
+ // Scope Granted scopes, as a space-separated list.
Scope *string `json:"scope,omitempty"`
Vps *[]VerifiablePresentation `json:"vps,omitempty"`
AdditionalProperties map[string]interface{} `json:"-"`
@@ -135,12 +160,15 @@ type ServiceAccessTokenRequest struct {
AuthorizationServer string `json:"authorization_server"`
// CredentialSelection Optional key-value mapping for credential selection when the wallet contains multiple
- // credentials matching a single input descriptor. Each key must match a field id declared
+ // credentials matching a single input descriptor. Each key must match a field ID declared
// in the Presentation Definition's input descriptor constraints. The value narrows the
// match to credentials where that field equals the given value.
//
// The selection must narrow to exactly one credential per input descriptor.
// Zero matches or multiple matches will result in an error.
+ //
+ // When omitted and multiple credentials match an input descriptor,
+ // the first matching credential is used.
CredentialSelection *map[string]string `json:"credential_selection,omitempty"`
// Credentials Additional credentials to present (if required by the authorizer), in addition to those in the requester's wallet.
@@ -197,7 +225,7 @@ type UserAccessTokenRequestTokenType string
// UserDetails Claims about the authorized user.
type UserDetails struct {
- // Id Machine-readable identifier, uniquely identifying the user in the issuing system.
+ // Id Machine-readable identifier, uniquely identifying the user in the issuing system. The format is not specified; it could be a username, email address, employee number, etc.
Id string `json:"id"`
// Name Human-readable name of the user.
@@ -215,7 +243,10 @@ type Cnf struct {
// RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance.
type RequestOpenid4VCICredentialIssuanceJSONBody struct {
- AuthorizationDetails []map[string]interface{} `json:"authorization_details"`
+ // AuthorizationDetails Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1.1.
+ // The current implementation processes a single credential
+ // issuance per call and only consumes the first entry.
+ AuthorizationDetails []AuthorizationDetail `json:"authorization_details"`
// Issuer The OAuth Authorization Server's identifier, that issues the Verifiable Credentials, as specified in RFC 8414 (section 2),
// used to locate the OAuth2 Authorization Server metadata.
diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go
index 021c1b7463..c799895cd1 100644
--- a/auth/api/iam/openid4vci.go
+++ b/auth/api/iam/openid4vci.go
@@ -31,6 +31,7 @@ import (
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/auth/oauth"
+ "github.com/nuts-foundation/nuts-node/auth/openid4vci"
"github.com/nuts-foundation/nuts-node/core"
"github.com/nuts-foundation/nuts-node/crypto"
nutsHttp "github.com/nuts-foundation/nuts-node/http"
@@ -39,10 +40,11 @@ import (
var timeFunc = time.Now
-// jwtTypeOpenID4VCIProof defines the OpenID4VCI JWT-subtype (used as typ claim in the JWT).
-const jwtTypeOpenID4VCIProof = "openid4vci-proof+jwt"
-
func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, request RequestOpenid4VCICredentialIssuanceRequestObject) (RequestOpenid4VCICredentialIssuanceResponseObject, error) {
+ if request.Body == nil {
+ // why did oapi-codegen generate a pointer for the body??
+ return nil, core.InvalidInputError("missing request body")
+ }
walletDID, err := did.ParseDID(request.Body.WalletDid)
if err != nil {
return nil, core.InvalidInputError("invalid wallet DID")
@@ -52,16 +54,20 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques
} else if !owned {
return nil, core.InvalidInputError("wallet DID does not belong to the subject")
}
-
- if request.Body == nil {
- // why did oapi-codegen generate a pointer for the body??
- return nil, core.InvalidInputError("missing request body")
- }
// Parse the issuer
issuer := request.Body.Issuer
if issuer == "" {
return nil, core.InvalidInputError("issuer is empty")
}
+ // Per §5.1.1 the openid_credential authorization_details flow requires at
+ // least one entry; the current implementation issues a single credential
+ // per call and only consumes the first entry. The OpenAPI schema declares
+ // both minItems: 1 and maxItems: 1, but the StrictServer middleware does
+ // not enforce array bounds at runtime, so reject here before any outbound
+ // metadata fetches.
+ if len(request.Body.AuthorizationDetails) != 1 {
+ return nil, core.InvalidInputError("authorization_details must contain exactly one entry")
+ }
// Fetch metadata containing the endpoints
credentialIssuerMetadata, authzServerMetadata, err := r.openid4vciMetadata(ctx, request.Body.Issuer)
if err != nil {
@@ -79,11 +85,12 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques
clientID := r.subjectToBaseURL(request.SubjectID)
- // Read and parse the authorization details
- authorizationDetails := []byte("[]")
- if len(request.Body.AuthorizationDetails) > 0 {
- authorizationDetails, _ = json.Marshal(request.Body.AuthorizationDetails)
- }
+ // Per §5.1.1, type and credential_configuration_id are required on each
+ // openid_credential authorization_details entry; the OpenAPI schema
+ // enforces both. Non-emptiness was checked above before any outbound
+ // metadata fetches.
+ authorizationDetails, _ := json.Marshal(request.Body.AuthorizationDetails)
+ credentialConfigID := request.Body.AuthorizationDetails[0].CredentialConfigurationId
// Generate the state and PKCE
state := crypto.GenerateNonce()
pkceParams := generatePKCEParams()
@@ -100,9 +107,12 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques
PKCEParams: pkceParams,
// OpenID4VCI issuers may use multiple Authorization Servers
// We must use the token_endpoint that corresponds to the same Authorization Server used for the authorization_endpoint
- TokenEndpoint: authzServerMetadata.TokenEndpoint,
- IssuerURL: authzServerMetadata.Issuer,
- IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint,
+ TokenEndpoint: authzServerMetadata.TokenEndpoint,
+ IssuerURL: authzServerMetadata.Issuer,
+ IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint,
+ IssuerNonceEndpoint: credentialIssuerMetadata.NonceEndpoint,
+ IssuerCredentialConfigurationID: credentialConfigID,
+ IssuerCredentialIssuer: credentialIssuerMetadata.CredentialIssuer,
})
if err != nil {
return nil, fmt.Errorf("failed to store session: %w", err)
@@ -129,8 +139,6 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques
}
func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode string, oauthSession *OAuthSession) (CallbackResponseObject, error) {
- // extract callback URI at calling app from OAuthSession
- // this is the URI where the user-agent will be redirected to
appCallbackURI := oauthSession.redirectURI()
baseURL := r.subjectToBaseURL(*oauthSession.OwnSubject)
@@ -142,31 +150,72 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode
}
// use code to request access token from remote token endpoint
- response, err := r.auth.IAMClient().AccessToken(ctx, authorizationCode, oauthSession.TokenEndpoint, checkURL.String(), *oauthSession.OwnSubject, clientID, oauthSession.PKCEParams.Verifier, false)
+ tokenResponse, err := r.auth.IAMClient().AccessToken(ctx, authorizationCode, oauthSession.TokenEndpoint, checkURL.String(), *oauthSession.OwnSubject, clientID, oauthSession.PKCEParams.Verifier, false)
if err != nil {
return nil, withCallbackURI(oauthError(oauth.AccessDenied, fmt.Sprintf("error while fetching the access_token from endpoint: %s, error: %s", oauthSession.TokenEndpoint, err.Error())), appCallbackURI)
}
- // make proof and collect credential
- proofJWT, err := r.openid4vciProof(ctx, *oauthSession.OwnDID, oauthSession.IssuerURL, response.Get(oauth.CNonceParam))
+ // Per §3.3.4 / §8.2: when the Token Response carries authorization_details
+ // with credential_identifiers, the Credential Request MUST use a
+ // credential_identifier (not credential_configuration_id). When the AS
+ // did not return authorization_details, fall back to
+ // credential_configuration_id (§3.3.4 scope-flow alternative).
+ credentialIdentifier, err := extractCredentialIdentifier(tokenResponse, oauthSession.IssuerCredentialConfigurationID)
if err != nil {
- return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error building proof to fetch the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI)
+ return nil, withCallbackURI(oauthError(oauth.ServerError, err.Error()), appCallbackURI)
+ }
+
+ // fetch nonce from the Nonce Endpoint (v1.0 Section 7)
+ var nonce string
+ if oauthSession.IssuerNonceEndpoint != "" {
+ nonce, err = r.auth.OpenID4VCIClient().RequestNonce(ctx, oauthSession.IssuerNonceEndpoint)
+ if err != nil {
+ return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error fetching nonce from %s: %s", oauthSession.IssuerNonceEndpoint, err.Error())), appCallbackURI)
+ }
}
- credentials, err := r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, response.AccessToken, proofJWT)
+
+ // build proof and request credential
+ credentialResponse, err := r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, credentialIdentifier, nonce)
if err != nil {
- return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI)
+ // Per OpenID4VCI 1.0 §8.3.1.2: on invalid_nonce the wallet retrieves a
+ // new c_nonce. Retrying once is local policy to bound recovery; a
+ // second invalid_nonce surfaces as a generic ServerError below.
+ var oauthErr oauth.OAuth2Error
+ if errors.As(err, &oauthErr) && oauthErr.Code == oauth.InvalidNonce && oauthSession.IssuerNonceEndpoint != "" {
+ nonce, err = r.auth.OpenID4VCIClient().RequestNonce(ctx, oauthSession.IssuerNonceEndpoint)
+ if err != nil {
+ return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error fetching nonce for retry from %s: %s", oauthSession.IssuerNonceEndpoint, err.Error())), appCallbackURI)
+ }
+ credentialResponse, err = r.requestCredentialWithProof(ctx, oauthSession, tokenResponse.AccessToken, credentialIdentifier, nonce)
+ }
+ if err != nil {
+ return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI)
+ }
}
- // validate credential
- // TODO: check that issued credential is bound to DID that requested it (OwnDID)???
- credential, err := vc.ParseVerifiableCredential(credentials.Credential)
+ if len(credentialResponse.Credentials) == 0 {
+ return nil, withCallbackURI(oauthError(oauth.ServerError, "credential response does not contain any credentials"), appCallbackURI)
+ }
+
+ // Per OpenID4VCI 1.0 §8.3 the credential field is either a JSON string
+ // (JWT-VC, SD-JWT-VC) or a JSON object (JSON-LD). Because Credential is
+ // typed as json.RawMessage, the field keeps the raw JSON encoding — for
+ // a JWT that includes the surrounding quotes, which ParseVerifiableCredential
+ // would reject as invalid base64. Unmarshal the bytes as a Go string first
+ // to strip those quotes; on failure (the JSON-LD object case) fall back to
+ // the raw bytes as-is.
+ rawCredential := credentialResponse.Credentials[0].Credential
+ var credentialJSON string
+ if err := json.Unmarshal(rawCredential, &credentialJSON); err != nil {
+ credentialJSON = string(rawCredential)
+ }
+ credential, err := vc.ParseVerifiableCredential(credentialJSON)
if err != nil {
- return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while parsing the credential: %s, error: %s", credentials.Credential, err.Error())), appCallbackURI)
+ return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while parsing the credential: %s, error: %s", credentialJSON, err.Error())), appCallbackURI)
}
err = r.vcr.Verifier().Verify(*credential, true, true, nil)
if err != nil {
return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while verifying the credential from issuer: %s, error: %s", credential.Issuer.String(), err.Error())), appCallbackURI)
}
- // store credential in wallet
err = r.vcr.Wallet().Put(ctx, *credential)
if err != nil {
return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while storing credential with id: %s, error: %s", credential.ID, err.Error())), appCallbackURI)
@@ -176,18 +225,70 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode
}, nil
}
+func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *OAuthSession, accessToken string, credentialIdentifier string, nonce string) (*openid4vci.CredentialResponse, error) {
+ // Per §F.1, the proof JWT `aud` MUST be the Credential Issuer Identifier,
+ // not the Authorization Server issuer URL.
+ proofJWT, err := r.openid4vciProof(ctx, *oauthSession.OwnDID, oauthSession.IssuerCredentialIssuer, nonce)
+ if err != nil {
+ return nil, fmt.Errorf("error building proof: %w", err)
+ }
+ return r.auth.OpenID4VCIClient().RequestCredential(ctx, openid4vci.RequestCredentialOpts{
+ CredentialEndpoint: oauthSession.IssuerCredentialEndpoint,
+ AccessToken: accessToken,
+ CredentialConfigurationID: oauthSession.IssuerCredentialConfigurationID,
+ CredentialIdentifier: credentialIdentifier,
+ ProofJWT: proofJWT,
+ })
+}
+
+// extractCredentialIdentifier reads authorization_details from the Token
+// Response and returns a credential_identifier matching the requested
+// configuration. Per OpenID4VCI 1.0 §3.3.4 / §8.2, when the AS returns
+// authorization_details with credential_identifiers, the wallet MUST use a
+// credential_identifier in the Credential Request — silently falling back
+// to credential_configuration_id is not allowed. Returns ("", nil) only
+// when the Token Response did not carry authorization_details at all
+// (which permits the §3.3.4 scope-flow fallback to credential_configuration_id).
+func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse, credentialConfigurationID string) (string, error) {
+ raw, ok := tokenResponse.GetAny(oauth.AuthorizationDetailsParam)
+ if !ok {
+ return "", nil
+ }
+ bytes, err := json.Marshal(raw)
+ if err != nil {
+ return "", fmt.Errorf("token response authorization_details: %w", err)
+ }
+ var details []struct {
+ Type string `json:"type"`
+ CredentialConfigurationID string `json:"credential_configuration_id"`
+ CredentialIdentifiers []string `json:"credential_identifiers"`
+ }
+ if err := json.Unmarshal(bytes, &details); err != nil {
+ return "", fmt.Errorf("token response authorization_details malformed: %w", err)
+ }
+ for _, d := range details {
+ if d.Type != "openid_credential" {
+ continue
+ }
+ if d.CredentialConfigurationID != credentialConfigurationID {
+ continue
+ }
+ if len(d.CredentialIdentifiers) == 0 {
+ return "", fmt.Errorf("token response authorization_details for %q is missing credential_identifiers", credentialConfigurationID)
+ }
+ return d.CredentialIdentifiers[0], nil
+ }
+ return "", fmt.Errorf("token response authorization_details has no entry for credential_configuration_id %q", credentialConfigurationID)
+}
+
func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audience string, nonce string) (string, error) {
kid, _, err := r.keyResolver.ResolveKey(holderDid, nil, resolver.AssertionMethod)
if err != nil {
return "", fmt.Errorf("failed to resolve key for did (%s): %w", holderDid.String(), err)
}
headers := map[string]interface{}{
- "typ": jwtTypeOpenID4VCIProof, // MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725].
- "kid": kid, // JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to.
- }
- if err != nil {
- // can't fail or would have failed before
- return "", err
+ "typ": openid4vci.JWTTypeOpenID4VCIProof, // MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725].
+ "kid": kid, // JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to.
}
claims := map[string]interface{}{
jwt.IssuerKey: holderDid.String(),
diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go
index 957595cbc8..4e03d107c2 100644
--- a/auth/api/iam/openid4vci_test.go
+++ b/auth/api/iam/openid4vci_test.go
@@ -20,6 +20,7 @@ package iam
import (
"context"
+ "encoding/json"
"errors"
"net/url"
"testing"
@@ -27,8 +28,8 @@ import (
"github.com/nuts-foundation/nuts-node/core/to"
- "github.com/nuts-foundation/nuts-node/auth/client/iam"
"github.com/nuts-foundation/nuts-node/auth/oauth"
+ "github.com/nuts-foundation/nuts-node/auth/openid4vci"
"github.com/nuts-foundation/nuts-node/crypto"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
"github.com/stretchr/testify/assert"
@@ -39,7 +40,7 @@ import (
func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) {
redirectURI := "https://test.test/iam/123/cb"
authServer := "https://auth.server/"
- metadata := oauth.OpenIDCredentialIssuerMetadata{
+ metadata := openid4vci.OpenIDCredentialIssuerMetadata{
CredentialIssuer: "issuer",
CredentialEndpoint: "endpoint",
AuthorizationServers: []string{authServer},
@@ -52,12 +53,12 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) {
}
t.Run("ok", func(t *testing.T) {
ctx := newTestClient(t)
- ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil)
+ ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil)
ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil)
response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, RequestOpenid4VCICredentialIssuanceRequestObject{
SubjectID: holderSubjectID,
Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{
- AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "format": "vc+sd-jwt"}},
+ AuthorizationDetails: []AuthorizationDetail{{Type: "openid_credential", CredentialConfigurationId: "UniversityDegreeCredential", Format: to.Ptr("vc+sd-jwt")}},
Issuer: issuerClientID,
RedirectUri: redirectURI,
WalletDid: holderDID.String(),
@@ -75,19 +76,18 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) {
assert.Equal(t, holderClientID, redirectUri.Query().Get("client_id"))
assert.Equal(t, "S256", redirectUri.Query().Get("code_challenge_method"))
assert.Equal(t, "code", redirectUri.Query().Get("response_type"))
- assert.Equal(t, `[{"format":"vc+sd-jwt","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details"))
- println(redirectUri.String())
+ assert.Equal(t, `[{"credential_configuration_id":"UniversityDegreeCredential","format":"vc+sd-jwt","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details"))
})
t.Run("openid4vciMetadata", func(t *testing.T) {
t.Run("ok - fallback to issuerDID on empty AuthorizationServers", func(t *testing.T) {
ctx := newTestClient(t)
- metadata := oauth.OpenIDCredentialIssuerMetadata{
+ metadata := openid4vci.OpenIDCredentialIssuerMetadata{
CredentialIssuer: "issuer",
CredentialEndpoint: "endpoint",
AuthorizationServers: []string{}, // empty
Display: nil,
}
- ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil)
+ ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil)
ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, issuerClientID).Return(nil, assert.AnError)
_, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI))
assert.ErrorIs(t, err, assert.AnError)
@@ -95,7 +95,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) {
t.Run("error - none of the authorization servers can be reached", func(t *testing.T) {
ctx := newTestClient(t)
- ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil)
+ ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil)
ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, issuerClientID).Return(nil, assert.AnError)
ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(nil, assert.AnError)
@@ -104,7 +104,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) {
})
t.Run("error - fetching credential issuer metadata fails", func(t *testing.T) {
ctx := newTestClient(t)
- ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(nil, assert.AnError)
+ ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(nil, assert.AnError)
_, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI))
assert.ErrorIs(t, err, assert.AnError)
})
@@ -118,9 +118,36 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) {
assert.EqualError(t, err, "issuer is empty")
})
+ t.Run("error - empty authorization_details", func(t *testing.T) {
+ // Schema declares minItems: 1 but the StrictServer middleware does not
+ // enforce array bounds at runtime; the handler must reject empty arrays
+ // before any outbound metadata fetches.
+ req := requestCredentials(holderSubjectID, issuerClientID, redirectURI)
+ req.Body.AuthorizationDetails = []AuthorizationDetail{}
+ ctx := newTestClient(t)
+ // Deliberately no mock expectations: rejection must happen before
+ // metadata is fetched.
+
+ _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req)
+
+ assert.ErrorContains(t, err, "must contain exactly one entry")
+ })
+ t.Run("error - multiple authorization_details", func(t *testing.T) {
+ // Schema declares maxItems: 1; same StrictServer gap as minItems.
+ req := requestCredentials(holderSubjectID, issuerClientID, redirectURI)
+ req.Body.AuthorizationDetails = []AuthorizationDetail{
+ {Type: "openid_credential", CredentialConfigurationId: "First"},
+ {Type: "openid_credential", CredentialConfigurationId: "Second"},
+ }
+ ctx := newTestClient(t)
+
+ _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req)
+
+ assert.ErrorContains(t, err, "must contain exactly one entry")
+ })
t.Run("error - invalid authorization endpoint in metadata", func(t *testing.T) {
ctx := newTestClient(t)
- ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil)
+ ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil)
invalidAuthzMetadata := oauth.AuthorizationServerMetadata{
AuthorizationEndpoint: ":",
TokenEndpoint: "https://auth.server/token",
@@ -136,7 +163,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) {
ctx := newTestClient(t)
metadata := metadata
metadata.CredentialEndpoint = ""
- ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil)
+ ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil)
ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil)
_, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI))
assert.EqualError(t, err, "no credential_endpoint found")
@@ -145,7 +172,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) {
ctx := newTestClient(t)
authzMetadata := authzMetadata
authzMetadata.AuthorizationEndpoint = ""
- ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil)
+ ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil)
ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil)
_, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI))
assert.EqualError(t, err, "no authorization_endpoint found")
@@ -154,7 +181,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) {
ctx := newTestClient(t)
authzMetadata := authzMetadata
authzMetadata.TokenEndpoint = ""
- ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil)
+ ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil)
ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil)
_, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI))
assert.EqualError(t, err, "no token_endpoint found")
@@ -165,9 +192,10 @@ func requestCredentials(subjectID string, issuer string, redirectURI string) Req
return RequestOpenid4VCICredentialIssuanceRequestObject{
SubjectID: subjectID,
Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{
- Issuer: issuer,
- RedirectUri: redirectURI,
- WalletDid: holderDID.String(),
+ AuthorizationDetails: []AuthorizationDetail{{Type: "openid_credential", CredentialConfigurationId: "UniversityDegreeCredential"}},
+ Issuer: issuer,
+ RedirectUri: redirectURI,
+ WalletDid: holderDID.String(),
},
}
}
@@ -176,6 +204,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) {
redirectURI := "https://example.com/oauth2/holder/callback"
authServer := "https://auth.server"
tokenEndpoint := authServer + "/token"
+ nonceEndpoint := authServer + "/nonce"
cNonce := crypto.GenerateNonce()
credEndpoint := authServer + "/credz"
pkceParams := generatePKCEParams()
@@ -185,30 +214,38 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) {
verifiableCredential := createIssuerCredential(issuerDID, holderDID)
redirectUrl := "https://client.service/issuance_is_done"
+ credentialConfigID := "NutsOrganizationCredential_ldp_vc"
session := OAuthSession{
AuthorizationServerMetadata: &oauth.AuthorizationServerMetadata{
ClientIdSchemesSupported: clientIdSchemesSupported,
},
- ClientFlow: "openid4vci_credential_request",
- OwnSubject: &holderSubjectID,
- OwnDID: &holderDID,
- RedirectURI: redirectUrl,
- PKCEParams: pkceParams,
- TokenEndpoint: tokenEndpoint,
- IssuerURL: issuerClientID,
- IssuerCredentialEndpoint: credEndpoint,
+ ClientFlow: "openid4vci_credential_request",
+ OwnSubject: &holderSubjectID,
+ OwnDID: &holderDID,
+ RedirectURI: redirectUrl,
+ PKCEParams: pkceParams,
+ TokenEndpoint: tokenEndpoint,
+ IssuerURL: issuerClientID,
+ IssuerCredentialEndpoint: credEndpoint,
+ IssuerNonceEndpoint: nonceEndpoint,
+ IssuerCredentialConfigurationID: credentialConfigID,
+ IssuerCredentialIssuer: issuerClientID,
}
- tokenResponse := (&oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"}).With("c_nonce", cNonce)
- credentialResponse := iam.CredentialResponse{
- Credential: verifiableCredential.Raw(),
+ sessionWithoutNonce := session
+ sessionWithoutNonce.IssuerNonceEndpoint = ""
+
+ tokenResponse := &oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"}
+ credentialResponse := openid4vci.CredentialResponse{
+ Credentials: []openid4vci.CredentialResponseEntry{{Credential: json.RawMessage(verifiableCredential.Raw())}},
}
now := time.Now()
timeFunc = func() time.Time { return now }
defer func() { timeFunc = time.Now }()
- t.Run("ok", func(t *testing.T) {
+ t.Run("ok - with nonce endpoint", func(t *testing.T) {
ctx := newTestClient(t)
require.NoError(t, ctx.client.oauthClientStateStore().Put(state, &session))
ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil)
+ ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil)
ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil)
ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").DoAndReturn(func(_ context.Context, claims map[string]interface{}, headers map[string]interface{}, key interface{}) (string, error) {
assert.Equal(t, map[string]interface{}{"typ": "openid4vci-proof+jwt", "kid": "kid"}, headers)
@@ -221,7 +258,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) {
assert.Equal(t, expectedClaims, claims)
return "signed-proof", nil
})
- ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&credentialResponse, nil)
+ ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&credentialResponse, nil)
ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil)
ctx.wallet.EXPECT().Put(nil, *verifiableCredential)
@@ -238,6 +275,141 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) {
actual := callback.(Callback302Response)
assert.Equal(t, redirectUrl, actual.Headers.Location)
})
+ t.Run("ok - no nonce endpoint", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil)
+ ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil)
+ ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").DoAndReturn(func(_ context.Context, claims map[string]interface{}, headers map[string]interface{}, key interface{}) (string, error) {
+ _, hasNonce := claims["nonce"]
+ assert.False(t, hasNonce, "nonce should not be set when no nonce endpoint is configured")
+ return "signed-proof", nil
+ })
+ ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&credentialResponse, nil)
+ ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil)
+ ctx.wallet.EXPECT().Put(nil, *verifiableCredential)
+
+ callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionWithoutNonce)
+
+ require.NoError(t, err)
+ assert.NotNil(t, callback)
+ })
+ t.Run("ok - invalid_nonce retry succeeds", func(t *testing.T) {
+ ctx := newTestClient(t)
+ freshNonce := "fresh-nonce"
+ invalidNonceErr := oauth.OAuth2Error{Code: oauth.InvalidNonce}
+
+ ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil)
+ ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil)
+ // first attempt fails with invalid_nonce
+ ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil).Times(2)
+ ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-1", nil)
+ ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof-1"}).Return(nil, invalidNonceErr)
+ // retry with fresh nonce
+ ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(freshNonce, nil)
+ ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-2", nil)
+ ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof-2"}).Return(&credentialResponse, nil)
+ ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil)
+ ctx.wallet.EXPECT().Put(nil, *verifiableCredential)
+
+ callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session)
+
+ require.NoError(t, err)
+ assert.NotNil(t, callback)
+ })
+ t.Run("error - invalid_nonce retry also fails", func(t *testing.T) {
+ ctx := newTestClient(t)
+ invalidNonceErr := oauth.OAuth2Error{Code: oauth.InvalidNonce}
+
+ ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil)
+ ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil)
+ ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil).Times(2)
+ ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-1", nil)
+ ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof-1"}).Return(nil, invalidNonceErr)
+ // retry also fails
+ ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("fresh-nonce", nil)
+ ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-2", nil)
+ ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof-2"}).Return(nil, errors.New("still failing"))
+
+ callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session)
+
+ assert.Nil(t, callback)
+ assert.ErrorContains(t, err, "error while fetching the credential from endpoint")
+ })
+ t.Run("error - nonce endpoint fails during retry", func(t *testing.T) {
+ ctx := newTestClient(t)
+ invalidNonceErr := oauth.OAuth2Error{Code: oauth.InvalidNonce}
+
+ ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil)
+ ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil)
+ ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil)
+ ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil)
+ ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(nil, invalidNonceErr)
+ // retry nonce fetch fails
+ ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint down"))
+
+ callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session)
+
+ assert.Nil(t, callback)
+ assert.ErrorContains(t, err, "error fetching nonce for retry")
+ })
+ t.Run("ok - uses credential_identifier from token response authorization_details", func(t *testing.T) {
+ ctx := newTestClient(t)
+ // Per §3.3.4 / §8.2: when the AS returns authorization_details with
+ // credential_identifiers, the wallet MUST send credential_identifier
+ // in the Credential Request.
+ tokenResponseWithAuthDetails := (&oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"}).
+ With(oauth.AuthorizationDetailsParam, []map[string]interface{}{{
+ "type": "openid_credential",
+ "credential_configuration_id": credentialConfigID,
+ "credential_identifiers": []string{"CivilEngineeringDegree-2023"},
+ }})
+ ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponseWithAuthDetails, nil)
+ ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil)
+ ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil)
+ ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil)
+ ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{
+ CredentialEndpoint: credEndpoint,
+ AccessToken: accessToken,
+ CredentialConfigurationID: credentialConfigID,
+ CredentialIdentifier: "CivilEngineeringDegree-2023",
+ ProofJWT: "signed-proof",
+ }).Return(&credentialResponse, nil)
+ ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil)
+ ctx.wallet.EXPECT().Put(nil, *verifiableCredential)
+
+ callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session)
+
+ require.NoError(t, err)
+ require.NotNil(t, callback)
+ })
+ t.Run("error - authorization_details present but missing credential_identifiers", func(t *testing.T) {
+ ctx := newTestClient(t)
+ // Per §6.2 / §8.2: when the AS returns authorization_details for the
+ // requested credential_configuration_id, credential_identifiers is
+ // REQUIRED. Silent fallback to credential_configuration_id is not
+ // permitted; the wallet must surface an error.
+ tokenResponseWithBadDetails := (&oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"}).
+ With(oauth.AuthorizationDetailsParam, []map[string]interface{}{{
+ "type": "openid_credential",
+ "credential_configuration_id": credentialConfigID,
+ // credential_identifiers omitted
+ }})
+ ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponseWithBadDetails, nil)
+
+ _, err := ctx.client.handleOpenID4VCICallback(nil, code, &session)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "credential_identifiers")
+ })
+ t.Run("error - initial nonce request fails", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil)
+ ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint unavailable"))
+
+ callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session)
+
+ assert.Nil(t, callback)
+ assert.ErrorContains(t, err, "error fetching nonce from")
+ })
t.Run("fail_access_token", func(t *testing.T) {
ctx := newTestClient(t)
ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(nil, errors.New("FAIL"))
@@ -251,9 +423,10 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) {
t.Run("fail_credential_response", func(t *testing.T) {
ctx := newTestClient(t)
ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil)
+ ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil)
ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil)
ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil)
- ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(nil, errors.New("FAIL"))
+ ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(nil, errors.New("FAIL"))
callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session)
@@ -263,23 +436,25 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) {
t.Run("err - invalid credential", func(t *testing.T) {
ctx := newTestClient(t)
ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil)
+ ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil)
ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil)
ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil)
- ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&iam.CredentialResponse{
- Credential: "super invalid",
+ ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&openid4vci.CredentialResponse{
+ Credentials: []openid4vci.CredentialResponseEntry{{Credential: json.RawMessage(`"super invalid"`)}},
}, nil)
callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session)
assert.Nil(t, callback)
- assert.EqualError(t, err, "server_error - error while parsing the credential: super invalid, error: invalid JWT")
+ assert.ErrorContains(t, err, "error while parsing the credential")
})
t.Run("fail_verify", func(t *testing.T) {
ctx := newTestClient(t)
ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil)
+ ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil)
ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil)
ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil)
- ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&credentialResponse, nil)
+ ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&credentialResponse, nil)
ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil).Return(errors.New("FAIL"))
callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session)
@@ -290,6 +465,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) {
t.Run("error - key not found", func(t *testing.T) {
ctx := newTestClient(t)
ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil)
+ ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil)
ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("", nil, resolver.ErrKeyNotFound)
callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session)
@@ -300,6 +476,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) {
t.Run("error - signature failure", func(t *testing.T) {
ctx := newTestClient(t)
ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil)
+ ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil)
ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil)
ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("", errors.New("signature failed"))
@@ -308,4 +485,29 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) {
assert.Nil(t, callback)
assert.ErrorContains(t, err, "failed to sign the JWT with kid (kid): signature failed")
})
+ t.Run("error - nil OwnDID in session", func(t *testing.T) {
+ ctx := newTestClient(t)
+ sessionNilDID := session
+ sessionNilDID.OwnDID = nil
+
+ callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionNilDID)
+
+ assert.Nil(t, callback)
+ assert.ErrorContains(t, err, "missing wallet DID in session")
+ })
+ t.Run("error - empty credentials array", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil)
+ ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil)
+ ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil)
+ ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil)
+ ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&openid4vci.CredentialResponse{
+ Credentials: []openid4vci.CredentialResponseEntry{},
+ }, nil)
+
+ callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session)
+
+ assert.Nil(t, callback)
+ assert.ErrorContains(t, err, "credential response does not contain any credentials")
+ })
}
diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go
index 2f4626429b..00c71b4544 100644
--- a/auth/api/iam/session.go
+++ b/auth/api/iam/session.go
@@ -55,6 +55,15 @@ 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"`
+ // IssuerCredentialIssuer is the Credential Issuer Identifier (`credential_issuer`
+ // from the metadata, §12.2.1). It is used as the `aud` claim in the proof JWT
+ // per §F.1; this can differ from IssuerURL (the AS issuer) when the metadata
+ // declares `authorization_servers`.
+ IssuerCredentialIssuer string `json:"issuer_credential_issuer,omitempty"`
}
// oauthClientFlow is used by a client to identify the flow a particular callback is part of
diff --git a/auth/auth.go b/auth/auth.go
index f135335c01..52528e4f0e 100644
--- a/auth/auth.go
+++ b/auth/auth.go
@@ -22,6 +22,7 @@ import (
"crypto/tls"
"errors"
"github.com/nuts-foundation/nuts-node/auth/client/iam"
+ "github.com/nuts-foundation/nuts-node/auth/openid4vci"
"github.com/nuts-foundation/nuts-node/vdr"
"github.com/nuts-foundation/nuts-node/vdr/didjwk"
"github.com/nuts-foundation/nuts-node/vdr/didkey"
@@ -41,6 +42,7 @@ import (
"github.com/nuts-foundation/nuts-node/core"
"github.com/nuts-foundation/nuts-node/crypto"
"github.com/nuts-foundation/nuts-node/didman"
+ httpclient "github.com/nuts-foundation/nuts-node/http/client"
"github.com/nuts-foundation/nuts-node/jsonld"
"github.com/nuts-foundation/nuts-node/pki"
"github.com/nuts-foundation/nuts-node/vcr"
@@ -68,6 +70,7 @@ type Auth struct {
httpClientTimeout time.Duration
tlsConfig *tls.Config
subjectManager didsubject.Manager
+ openID4VCIClient openid4vci.Client
// configuredDIDMethods contains the DID methods that are configured in the Nuts node,
// of which VDR will create DIDs.
configuredDIDMethods []string
@@ -129,6 +132,11 @@ func (auth *Auth) IAMClient() iam.Client {
return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.strictMode, auth.httpClientTimeout)
}
+// OpenID4VCIClient returns the OpenID4VCI 1.0 HTTP client.
+func (auth *Auth) OpenID4VCIClient() openid4vci.Client {
+ return auth.openID4VCIClient
+}
+
// Configure the Auth struct by creating a validator and create an Irma server
func (auth *Auth) Configure(config core.ServerConfig) error {
if auth.config.Irma.SchemeManager == "" {
@@ -173,6 +181,7 @@ func (auth *Auth) Configure(config core.ServerConfig) error {
// auth.http.config got deprecated in favor of httpclient.timeout
auth.httpClientTimeout = config.HTTPClient.Timeout
}
+ auth.openID4VCIClient = openid4vci.NewClient(httpclient.NewWithCache(auth.httpClientTimeout), auth.strictMode)
// V1 API related stuff
accessTokenLifeSpan := time.Duration(auth.config.AccessTokenLifeSpan) * time.Second
auth.authzServer = oauth.NewAuthorizationServer(auth.vdrInstance.Resolver(), auth.vcr, auth.vcr.Verifier(), auth.serviceResolver,
diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go
index 348adaf354..3112ede9bf 100644
--- a/auth/client/iam/client.go
+++ b/auth/client/iam/client.go
@@ -19,7 +19,6 @@
package iam
import (
- "bytes"
"context"
"encoding/json"
"errors"
@@ -241,19 +240,6 @@ func (hb HTTPClient) PostAuthorizationResponse(ctx context.Context, vp vc.Verifi
return hb.postFormExpectRedirect(ctx, data, verifierResponseURI)
}
-func (hb HTTPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) {
- metadataURL, err := oauth.IssuerIdToWellKnown(oauthIssuerURI, oauth.OpenIdCredIssuerWellKnown, hb.strictMode)
- if err != nil {
- return nil, err
- }
- var metadata oauth.OpenIDCredentialIssuerMetadata
- err = hb.doGet(ctx, metadataURL.String(), &metadata)
- if err != nil {
- return nil, err
- }
- return &metadata, err
-}
-
func (hb HTTPClient) OpenIDConfiguration(ctx context.Context, issuerURL string) (*oauth.OpenIDConfiguration, error) {
metadataURL, err := oauth.IssuerIdToWellKnown(issuerURL, oauth.OpenIdConfigurationWellKnown, hb.strictMode)
if err != nil {
@@ -307,65 +293,6 @@ 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) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, proofJwt string) (*CredentialResponse, error) {
- credentialEndpointURL, err := url.Parse(credentialEndpoint)
- if err != nil {
- return nil, err
- }
-
- credentialRequest := CredentialRequest{
- Proof: CredentialRequestProof{
- ProofType: "jwt",
- Jwt: proofJwt,
- },
- }
- jsonBody, _ := json.Marshal(credentialRequest)
- request, err := http.NewRequestWithContext(ctx, http.MethodPost, credentialEndpointURL.String(), bytes.NewBuffer(jsonBody))
- if err != nil {
- return nil, err
- }
- request.Header.Add("Accept", "application/json")
- request.Header.Add("Content-Type", "application/json")
- request.Header.Add("Authorization", "Bearer "+accessToken)
-
- response, err := hb.httpClient.Do(request.WithContext(ctx))
- if err != nil {
- return nil, fmt.Errorf("failed to call endpoint: %w", err)
- }
- defer func(Body io.ReadCloser) {
- err := Body.Close()
- if err != nil {
- log.Logger().WithError(err).Warn("Trouble closing reader")
- }
- }(response.Body)
- if err = core.TestResponseCode(http.StatusOK, response); err != nil {
- return nil, err
- }
- var credential CredentialResponse
- if err = json.NewDecoder(response.Body).Decode(&credential); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
- }
- return &credential, 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()))
if err != nil {
diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go
index 5ccf9caaf5..b5ab97a03b 100644
--- a/auth/client/iam/interface.go
+++ b/auth/client/iam/interface.go
@@ -20,6 +20,7 @@ package iam
import (
"context"
+
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/vcr/pe"
@@ -49,13 +50,8 @@ type Client interface {
RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool,
credentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error)
- // OpenIdCredentialIssuerMetadata returns the metadata of the remote credential issuer.
- // oauthIssuer is the URL of the issuer as specified by RFC 8414 (OAuth 2.0 Authorization Server Metadata).
- 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)
- // VerifiableCredentials requests Verifiable Credentials from the issuer at the given endpoint.
- VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, proofJWT string) (*CredentialResponse, 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)
diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go
index b6ad933a61..6587d7f3af 100644
--- a/auth/client/iam/mock.go
+++ b/auth/client/iam/mock.go
@@ -103,21 +103,6 @@ func (mr *MockClientMockRecorder) OpenIDConfiguration(ctx, issuer any) *gomock.C
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenIDConfiguration", reflect.TypeOf((*MockClient)(nil).OpenIDConfiguration), ctx, issuer)
}
-// OpenIdCredentialIssuerMetadata mocks base method.
-func (m *MockClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "OpenIdCredentialIssuerMetadata", ctx, oauthIssuerURI)
- ret0, _ := ret[0].(*oauth.OpenIDCredentialIssuerMetadata)
- ret1, _ := ret[1].(error)
- return ret0, ret1
-}
-
-// OpenIdCredentialIssuerMetadata indicates an expected call of OpenIdCredentialIssuerMetadata.
-func (mr *MockClientMockRecorder) OpenIdCredentialIssuerMetadata(ctx, oauthIssuerURI any) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenIdCredentialIssuerMetadata", reflect.TypeOf((*MockClient)(nil).OpenIdCredentialIssuerMetadata), ctx, oauthIssuerURI)
-}
-
// PostAuthorizationResponse mocks base method.
func (m *MockClient) PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI, state string) (string, error) {
m.ctrl.T.Helper()
@@ -207,18 +192,3 @@ func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjec
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection)
}
-
-// VerifiableCredentials mocks base method.
-func (m *MockClient) VerifiableCredentials(ctx context.Context, credentialEndpoint, accessToken, proofJWT string) (*CredentialResponse, error) {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "VerifiableCredentials", ctx, credentialEndpoint, accessToken, proofJWT)
- ret0, _ := ret[0].(*CredentialResponse)
- ret1, _ := ret[1].(error)
- return ret0, ret1
-}
-
-// VerifiableCredentials indicates an expected call of VerifiableCredentials.
-func (mr *MockClientMockRecorder) VerifiableCredentials(ctx, credentialEndpoint, accessToken, proofJWT any) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifiableCredentials", reflect.TypeOf((*MockClient)(nil).VerifiableCredentials), ctx, credentialEndpoint, accessToken, proofJWT)
-}
diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go
index bf7f8fef68..7116dec09d 100644
--- a/auth/client/iam/openid4vp.go
+++ b/auth/client/iam/openid4vp.go
@@ -346,24 +346,6 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID
return &tokenResponse, nil
}
-func (c *OpenID4VPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) {
- iamClient := c.httpClient
- rsp, err := iamClient.OpenIdCredentialIssuerMetadata(ctx, oauthIssuerURI)
- if err != nil {
- return nil, fmt.Errorf("failed to retrieve Openid credential issuer metadata: %w", err)
- }
- return rsp, nil
-}
-
-func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, proofJWT string) (*CredentialResponse, error) {
- iamClient := c.httpClient
- rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, proofJWT)
- if err != nil {
- return nil, fmt.Errorf("remote server: failed to retrieve credentials: %w", err)
- }
- return rsp, nil
-}
-
func (c *OpenID4VPClient) dpop(ctx context.Context, requester did.DID, request http.Request) (string, string, error) {
// find the key to sign the DPoP token with
keyID, _, err := c.keyResolver.ResolveKey(requester, nil, resolver.AssertionMethod)
diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go
index f4a725a09c..99cc6f305d 100644
--- a/auth/client/iam/openid4vp_test.go
+++ b/auth/client/iam/openid4vp_test.go
@@ -39,6 +39,7 @@ import (
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/audit"
"github.com/nuts-foundation/nuts-node/auth/oauth"
+ "github.com/nuts-foundation/nuts-node/auth/openid4vci"
"github.com/nuts-foundation/nuts-node/crypto"
http2 "github.com/nuts-foundation/nuts-node/test/http"
"github.com/nuts-foundation/nuts-node/vcr/holder"
@@ -512,7 +513,7 @@ type clientTestContext struct {
type clientServerTestContext struct {
*clientTestContext
authzServerMetadata *oauth.AuthorizationServerMetadata
- openIDCredentialIssuerMetadata *oauth.OpenIDCredentialIssuerMetadata
+ openIDCredentialIssuerMetadata *openid4vci.OpenIDCredentialIssuerMetadata
handler http.HandlerFunc
tlsServer *httptest.Server
verifierDID did.DID
@@ -524,12 +525,13 @@ type clientServerTestContext struct {
presentationDefinition func(writer http.ResponseWriter)
response func(writer http.ResponseWriter)
token func(writer http.ResponseWriter)
+ nonce func(writer http.ResponseWriter)
credentials func(writer http.ResponseWriter)
requestObjectJWT func(writer http.ResponseWriter)
}
func createClientServerTestContext(t *testing.T) *clientServerTestContext {
- credentialIssuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{}
+ credentialIssuerMetadata := &openid4vci.OpenIDCredentialIssuerMetadata{}
metadata := &oauth.AuthorizationServerMetadata{VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), DIDMethodsSupported: []string{"test"}}
ctx := &clientServerTestContext{
clientTestContext: createClientTestContext(t, nil),
@@ -578,10 +580,16 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext {
_, _ = writer.Write([]byte(`{"access_token": "token", "token_type": "bearer"}`))
return
},
+ nonce: func(writer http.ResponseWriter) {
+ writer.Header().Add("Content-Type", "application/json")
+ writer.WriteHeader(http.StatusOK)
+ _, _ = writer.Write([]byte(`{"c_nonce": "server-nonce"}`))
+ return
+ },
credentials: func(writer http.ResponseWriter) {
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
- _, _ = writer.Write([]byte(`{"format": "format", "credential": "credential"}`))
+ _, _ = writer.Write([]byte(`{"credentials": [{"credential": {"type": "VerifiableCredential"}}]}`))
return
},
requestObjectJWT: func(writer http.ResponseWriter) {
@@ -628,6 +636,11 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext {
ctx.token(writer)
return
}
+ case "/nonce":
+ if ctx.nonce != nil {
+ ctx.nonce(writer)
+ return
+ }
case "/credentials":
if ctx.credentials != nil {
ctx.credentials(writer)
@@ -659,64 +672,3 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext {
return ctx
}
-func TestIAMClient_OpenIdCredentialIssuerMetadata(t *testing.T) {
- t.Run("ok", func(t *testing.T) {
- ctx := createClientServerTestContext(t)
-
- metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer")
-
- require.NoError(t, err)
- require.NotNil(t, metadata)
- assert.Equal(t, *ctx.openIDCredentialIssuerMetadata, *metadata)
- })
- t.Run("error - failed to get metadata", func(t *testing.T) {
- ctx := createClientServerTestContext(t)
- ctx.credentialIssuerMetadata = nil
-
- response, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer")
-
- require.Error(t, err)
- assert.Nil(t, response)
- assert.EqualError(t, err, "failed to retrieve Openid credential issuer metadata: server returned HTTP 404 (expected: 200)")
- })
-}
-
-func TestIAMClient_VerifiableCredentials(t *testing.T) {
- accessToken := "code"
- proowJWT := "top secret"
-
- t.Run("ok", func(t *testing.T) {
- ctx := createClientServerTestContext(t)
-
- response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, proowJWT)
-
- require.NoError(t, err)
- require.NotNil(t, response)
- assert.Equal(t, "credential", response.Credential)
- })
- t.Run("error - failed to get access token", func(t *testing.T) {
- ctx := createClientServerTestContext(t)
-
- ctx.credentials = nil
-
- response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, proowJWT)
-
- assert.EqualError(t, err, "remote server: failed to retrieve credentials: server returned HTTP 404 (expected: 200)")
- assert.Nil(t, response)
- })
- t.Run("error - invalid access token", func(t *testing.T) {
- ctx := createClientServerTestContext(t)
-
- ctx.credentials = func(writer http.ResponseWriter) {
- writer.Header().Add("Content-Type", "application/json")
- writer.WriteHeader(http.StatusOK)
- _, _ = writer.Write([]byte(`{"format": "format", "credential": fail}`))
- return
- }
-
- response, err := ctx.client.VerifiableCredentials(context.Background(), ctx.openIDCredentialIssuerMetadata.CredentialEndpoint, accessToken, proowJWT)
-
- assert.Error(t, err)
- assert.Nil(t, response)
- })
-}
diff --git a/auth/interface.go b/auth/interface.go
index 6a0cd7eecb..894911652a 100644
--- a/auth/interface.go
+++ b/auth/interface.go
@@ -20,6 +20,7 @@ package auth
import (
"github.com/nuts-foundation/nuts-node/auth/client/iam"
+ "github.com/nuts-foundation/nuts-node/auth/openid4vci"
"github.com/nuts-foundation/nuts-node/auth/services"
"github.com/nuts-foundation/nuts-node/auth/services/oauth"
"net/url"
@@ -34,6 +35,8 @@ type AuthenticationServices interface {
AuthzServer() oauth.AuthorizationServer
// IAMClient returns the IAM Client API
IAMClient() iam.Client
+ // OpenID4VCIClient returns the OpenID4VCI 1.0 HTTP client.
+ OpenID4VCIClient() openid4vci.Client
// RelyingParty returns the oauth.RelyingParty
RelyingParty() oauth.RelyingParty
// ContractNotary returns an instance of ContractNotary
diff --git a/auth/mock.go b/auth/mock.go
index e92db3f34a..a04365018a 100644
--- a/auth/mock.go
+++ b/auth/mock.go
@@ -14,6 +14,7 @@ import (
reflect "reflect"
iam "github.com/nuts-foundation/nuts-node/auth/client/iam"
+ openid4vci "github.com/nuts-foundation/nuts-node/auth/openid4vci"
services "github.com/nuts-foundation/nuts-node/auth/services"
oauth "github.com/nuts-foundation/nuts-node/auth/services/oauth"
gomock "go.uber.org/mock/gomock"
@@ -99,6 +100,20 @@ func (mr *MockAuthenticationServicesMockRecorder) IAMClient() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IAMClient", reflect.TypeOf((*MockAuthenticationServices)(nil).IAMClient))
}
+// OpenID4VCIClient mocks base method.
+func (m *MockAuthenticationServices) OpenID4VCIClient() openid4vci.Client {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "OpenID4VCIClient")
+ ret0, _ := ret[0].(openid4vci.Client)
+ return ret0
+}
+
+// OpenID4VCIClient indicates an expected call of OpenID4VCIClient.
+func (mr *MockAuthenticationServicesMockRecorder) OpenID4VCIClient() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenID4VCIClient", reflect.TypeOf((*MockAuthenticationServices)(nil).OpenID4VCIClient))
+}
+
// PublicURL mocks base method.
func (m *MockAuthenticationServices) PublicURL() *url.URL {
m.ctrl.T.Helper()
diff --git a/auth/oauth/error.go b/auth/oauth/error.go
index 905a67798e..fcb92da65e 100644
--- a/auth/oauth/error.go
+++ b/auth/oauth/error.go
@@ -63,6 +63,10 @@ const (
InvalidRequestURI ErrorCode = "invalid_request_uri"
// InvalidRequestURIMethod is returned when the request_uri_method is not 'get' or 'post'. (OpenID4VP)
InvalidRequestURIMethod ErrorCode = "invalid_request_uri_method"
+ // InvalidNonce is returned when at least one of the key proofs in a Credential
+ // Request contains an invalid c_nonce. The wallet should fetch a new c_nonce
+ // from the Nonce Endpoint (OpenID4VCI 1.0 §8.3.1.2).
+ InvalidNonce ErrorCode = "invalid_nonce"
)
// Make sure the error implements core.HTTPStatusCodeError, so the HTTP request logger can log the correct status code.
diff --git a/auth/oauth/types.go b/auth/oauth/types.go
index c0a6d769d2..f039884c92 100644
--- a/auth/oauth/types.go
+++ b/auth/oauth/types.go
@@ -116,6 +116,17 @@ func (t TokenResponse) Get(key string) string {
return ""
}
+// GetAny returns the value of the additional parameter with the given key, untyped.
+// Use this for structured extension parameters such as authorization_details (RFC 9396).
+// The boolean indicates whether the key was present.
+func (t TokenResponse) GetAny(key string) (interface{}, bool) {
+ if t.additionalParams == nil {
+ return nil, false
+ }
+ val, ok := t.additionalParams[key]
+ return val, ok
+}
+
const (
// AccessTokenRequestStatusPending is the status for a pending access token
AccessTokenRequestStatusPending = "pending"
@@ -403,18 +414,6 @@ type Redirect struct {
RedirectURI string `json:"redirect_uri"`
}
-// OpenIDCredentialIssuerMetadata represents the metadata of an OpenID credential issuer
-type OpenIDCredentialIssuerMetadata struct {
- // - CredentialIssuer: an url representing the credential issuer
- CredentialIssuer string `json:"credential_issuer"`
- // - CredentialEndpoint: an url representing the credential endpoint
- CredentialEndpoint string `json:"credential_endpoint"`
- // - AuthorizationServers: a slice of urls representing the authorization servers (optional)
- AuthorizationServers []string `json:"authorization_servers,omitempty"`
- // - Display: a slice of maps where each map represents the display information (optional)
- Display []map[string]string `json:"display,omitempty"`
-}
-
// OpenIDConfiguration represents the OpenID configuration
// It contains the minimal information required for OpenID4VP, the required `jwks` is also omitted
// see https://openid.net/specs/openid-connect-federation-1_0-29.html#entity-statement
diff --git a/auth/openid4vci/client.go b/auth/openid4vci/client.go
new file mode 100644
index 0000000000..23f34b70e0
--- /dev/null
+++ b/auth/openid4vci/client.go
@@ -0,0 +1,245 @@
+/*
+ * Nuts node
+ * Copyright (C) 2026 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package openid4vci
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+
+ "github.com/nuts-foundation/nuts-node/auth/oauth"
+ "github.com/nuts-foundation/nuts-node/core"
+)
+
+// wellKnownPath is the path segment defined in OpenID4VCI 1.0 §12.2.2 for
+// retrieving the Credential Issuer Metadata document.
+const wellKnownPath = "/.well-known/openid-credential-issuer"
+
+// RequestCredentialOpts carries all parameters for a Credential Request.
+//
+// CredentialIdentifier and CredentialConfigurationID are mutually exclusive
+// per §8.2: when the Token Response carried authorization_details with
+// credential_identifiers, the wallet MUST set CredentialIdentifier (and
+// CredentialConfigurationID MUST NOT be present); otherwise the wallet sets
+// CredentialConfigurationID. If both are non-empty, CredentialIdentifier
+// takes precedence to enforce the spec rule.
+type RequestCredentialOpts struct {
+ CredentialEndpoint string
+ AccessToken string
+ CredentialConfigurationID string
+ CredentialIdentifier string
+ ProofJWT string
+}
+
+// Client is the OpenID4VCI 1.0 HTTP client interface.
+// It covers the three wire interactions a wallet makes against a Credential
+// Issuer: fetching issuer metadata, obtaining a fresh nonce, and requesting
+// a credential.
+type Client interface {
+ // OpenIDCredentialIssuerMetadata fetches and parses the Credential Issuer
+ // Metadata document. The well-known URL is constructed from issuerURL per
+ // RFC 8615 (well-known segment inserted at the authority root, with the
+ // issuer path appended after).
+ OpenIDCredentialIssuerMetadata(ctx context.Context, issuerURL string) (*OpenIDCredentialIssuerMetadata, error)
+
+ // RequestNonce retrieves a fresh c_nonce from the Nonce Endpoint (§7.2).
+ RequestNonce(ctx context.Context, nonceEndpoint string) (string, error)
+
+ // RequestCredential posts a Credential Request (§8.2) and returns the
+ // Credential Response (§8.3). On non-2xx the method returns a structured
+ // Error when the body is a valid OpenID4VCI error object; otherwise a
+ // generic error.
+ RequestCredential(ctx context.Context, opts RequestCredentialOpts) (*CredentialResponse, error)
+}
+
+// NewClient returns a Client backed by the provided HTTP request doer.
+// In production callers should pass *httpclient.StrictHTTPClient so the
+// shared transport policies apply (HTTPS-in-strict, body size limit,
+// User-Agent header).
+//
+// When strictMode is true, target URLs are additionally validated via
+// core.ParsePublicURL: HTTPS scheme, no IP hosts, no reserved hostnames.
+func NewClient(httpClient core.HTTPRequestDoer, strictMode bool) Client {
+ return &client{httpClient: httpClient, strictMode: strictMode}
+}
+
+type client struct {
+ httpClient core.HTTPRequestDoer
+ strictMode bool
+}
+
+// validateURL guards against SSRF by rejecting target URLs that fail
+// core.ParsePublicURL (in strict mode: HTTPS only, no IP/reserved hosts).
+// Called at the entry of every method that makes outbound HTTP.
+//
+// TODO: this validation belongs on httpclient.StrictHTTPClient so every
+// outbound HTTP call (not just OpenID4VCI) gets the IP/reserved-host check,
+// not only the HTTPS scheme check that StrictHTTPClient.Do enforces today.
+// Placed here for now to preserve parity with master, where the equivalent
+// caller (auth/client/iam.HTTPClient) validated via oauth.IssuerIdToWellKnown
+// → core.ParsePublicURL before issuing the request, and to address a CodeQL
+// SSRF finding on this PR. Tracked as a follow-up to consolidate the check
+// in the shared HTTP client.
+func (c *client) validateURL(name, target string) error {
+ if _, err := core.ParsePublicURL(target, c.strictMode); err != nil {
+ return fmt.Errorf("openid4vci: invalid %s URL: %w", name, err)
+ }
+ return nil
+}
+
+func (c *client) OpenIDCredentialIssuerMetadata(ctx context.Context, issuerURL string) (*OpenIDCredentialIssuerMetadata, error) {
+ if err := c.validateURL("issuer", issuerURL); err != nil {
+ return nil, err
+ }
+ // Per §12.2.1, the Credential Issuer Identifier MUST NOT contain query
+ // or fragment components.
+ if parsed, _ := url.Parse(issuerURL); parsed != nil && (parsed.RawQuery != "" || parsed.Fragment != "") {
+ return nil, fmt.Errorf("openid4vci: invalid issuer URL: query and fragment components are not allowed")
+ }
+ wellKnownURL, err := credentialIssuerWellKnown(issuerURL)
+ if err != nil {
+ return nil, fmt.Errorf("openid4vci: invalid issuer URL: %w", err)
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL, http.NoBody)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode > 299 {
+ return nil, fmt.Errorf("openid4vci: fetching issuer metadata returned status %d", resp.StatusCode)
+ }
+ var metadata OpenIDCredentialIssuerMetadata
+ if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
+ return nil, fmt.Errorf("openid4vci: decoding issuer metadata: %w", err)
+ }
+ // Per §12.2.4: the credential_issuer value MUST match the issuer identifier
+ // the metadata document was retrieved for. Mismatched metadata MUST NOT be used.
+ if metadata.CredentialIssuer != issuerURL {
+ return nil, fmt.Errorf("openid4vci: credential_issuer %q does not match requested issuer %q", metadata.CredentialIssuer, issuerURL)
+ }
+ return &metadata, nil
+}
+
+func (c *client) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) {
+ if err := c.validateURL("nonce endpoint", nonceEndpoint); err != nil {
+ return "", err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, nonceEndpoint, http.NoBody)
+ if err != nil {
+ return "", err
+ }
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode > 299 {
+ return "", fmt.Errorf("openid4vci: nonce endpoint returned status %d", resp.StatusCode)
+ }
+ var nonceResp NonceResponse
+ if err := json.NewDecoder(resp.Body).Decode(&nonceResp); err != nil {
+ return "", fmt.Errorf("openid4vci: decoding nonce response: %w", err)
+ }
+ if nonceResp.CNonce == "" {
+ return "", fmt.Errorf("openid4vci: nonce endpoint returned empty c_nonce")
+ }
+ return nonceResp.CNonce, nil
+}
+
+func (c *client) RequestCredential(ctx context.Context, opts RequestCredentialOpts) (*CredentialResponse, error) {
+ if err := c.validateURL("credential endpoint", opts.CredentialEndpoint); err != nil {
+ return nil, err
+ }
+ body := CredentialRequest{
+ Proofs: &CredentialRequestProofs{
+ JWT: []string{opts.ProofJWT},
+ },
+ }
+ // Per §8.2: CredentialIdentifier and CredentialConfigurationID are mutually
+ // exclusive. CredentialIdentifier wins when set.
+ if opts.CredentialIdentifier != "" {
+ body.CredentialIdentifier = opts.CredentialIdentifier
+ } else {
+ body.CredentialConfigurationID = opts.CredentialConfigurationID
+ }
+ bodyBytes, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, opts.CredentialEndpoint, bytes.NewReader(bodyBytes))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+opts.AccessToken)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ // Buffer the body once so the non-2xx path can attempt structured-error
+ // parsing before falling back to a generic error.
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode > 299 {
+ var oauthErr oauth.OAuth2Error
+ if jsonErr := json.Unmarshal(respBody, &oauthErr); jsonErr == nil && oauthErr.Code != "" {
+ return nil, oauthErr
+ }
+ return nil, fmt.Errorf("openid4vci: credential endpoint returned status %d", resp.StatusCode)
+ }
+ var credResp CredentialResponse
+ if err := json.Unmarshal(respBody, &credResp); err != nil {
+ return nil, fmt.Errorf("openid4vci: decoding credential response: %w", err)
+ }
+ return &credResp, nil
+}
+
+// credentialIssuerWellKnown returns the Credential Issuer Metadata URL for
+// the given issuer identifier per RFC 8615: the well-known segment is
+// inserted at the authority root, and the issuer's path is appended after.
+//
+// Example: https://example.com/oauth2/alice
+// -> https://example.com/.well-known/openid-credential-issuer/oauth2/alice
+func credentialIssuerWellKnown(issuerURL string) (string, error) {
+ u, err := url.Parse(issuerURL)
+ if err != nil {
+ return "", err
+ }
+ // Prepend the well-known segment to both Path (decoded) and RawPath
+ // (encoded) when the latter is set, so u.String() does not double-escape
+ // pre-encoded characters like %2F via EscapedPath's reescaping pass.
+ u.Path = wellKnownPath + u.Path
+ if u.RawPath != "" {
+ u.RawPath = wellKnownPath + u.RawPath
+ }
+ return u.String(), nil
+}
diff --git a/auth/openid4vci/client_test.go b/auth/openid4vci/client_test.go
new file mode 100644
index 0000000000..c88034d64e
--- /dev/null
+++ b/auth/openid4vci/client_test.go
@@ -0,0 +1,293 @@
+/*
+ * Nuts node
+ * Copyright (C) 2026 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package openid4vci
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/nuts-foundation/nuts-node/auth/oauth"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ---- RequestNonce ----
+
+func TestClient_RequestNonce(t *testing.T) {
+ t.Run("returns c_nonce from response", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(NonceResponse{CNonce: "test-nonce-123"})
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.Client(), false)
+ nonce, err := client.RequestNonce(context.Background(), srv.URL)
+ require.NoError(t, err)
+ assert.Equal(t, "test-nonce-123", nonce)
+ })
+
+ t.Run("error on non-2xx", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.Client(), false)
+ _, err := client.RequestNonce(context.Background(), srv.URL)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "500")
+ })
+
+ t.Run("error on empty c_nonce", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(NonceResponse{CNonce: ""})
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.Client(), false)
+ _, err := client.RequestNonce(context.Background(), srv.URL)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "empty c_nonce")
+ })
+}
+
+// ---- OpenIDCredentialIssuerMetadata ----
+
+func TestClient_OpenIDCredentialIssuerMetadata(t *testing.T) {
+ t.Run("fetches and parses metadata from well-known path", func(t *testing.T) {
+ var srv *httptest.Server
+ srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/.well-known/openid-credential-issuer", r.URL.Path)
+ assert.Equal(t, http.MethodGet, r.Method)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(OpenIDCredentialIssuerMetadata{
+ CredentialIssuer: srv.URL,
+ CredentialEndpoint: srv.URL + "/credential",
+ NonceEndpoint: srv.URL + "/nonce",
+ })
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.Client(), false)
+ metadata, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL)
+ require.NoError(t, err)
+ require.NotNil(t, metadata)
+ assert.Equal(t, srv.URL, metadata.CredentialIssuer)
+ assert.Equal(t, srv.URL+"/credential", metadata.CredentialEndpoint)
+ assert.Equal(t, srv.URL+"/nonce", metadata.NonceEndpoint)
+ })
+
+ t.Run("appends issuer path after well-known segment per RFC 8615", func(t *testing.T) {
+ var capturedPath string
+ var srv *httptest.Server
+ srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedPath = r.URL.Path
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(OpenIDCredentialIssuerMetadata{CredentialIssuer: srv.URL + "/oauth2/alice"})
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.Client(), false)
+ _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL+"/oauth2/alice")
+ require.NoError(t, err)
+ assert.Equal(t, "/.well-known/openid-credential-issuer/oauth2/alice", capturedPath)
+ })
+
+ t.Run("preserves percent-encoded path segments without double-escaping", func(t *testing.T) {
+ var capturedRawPath string
+ var srv *httptest.Server
+ srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedRawPath = r.URL.EscapedPath()
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(OpenIDCredentialIssuerMetadata{CredentialIssuer: srv.URL + "/foo%2Fbar"})
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.Client(), false)
+ _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL+"/foo%2Fbar")
+ require.NoError(t, err)
+ assert.Equal(t, "/.well-known/openid-credential-issuer/foo%2Fbar", capturedRawPath)
+ })
+
+ t.Run("rejects metadata when credential_issuer mismatches requested issuer", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(OpenIDCredentialIssuerMetadata{
+ CredentialIssuer: "https://attacker.example/",
+ })
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.Client(), false)
+ _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "credential_issuer")
+ assert.Contains(t, err.Error(), "does not match")
+ })
+
+ t.Run("error on non-2xx", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "not found", http.StatusNotFound)
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.Client(), false)
+ _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "404")
+ })
+
+ t.Run("rejects non-https issuer URL in strict mode", func(t *testing.T) {
+ client := NewClient(http.DefaultClient, true)
+ _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), "http://issuer.example/")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid issuer URL")
+ })
+
+ t.Run("rejects issuer URL with query or fragment per §12.2.1", func(t *testing.T) {
+ client := NewClient(http.DefaultClient, false)
+ _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), "https://issuer.example/?foo=bar")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "query and fragment")
+
+ _, err = client.OpenIDCredentialIssuerMetadata(context.Background(), "https://issuer.example/#section")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "query and fragment")
+ })
+
+ t.Run("error on bad JSON body", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte("{not valid json"))
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.Client(), false)
+ _, err := client.OpenIDCredentialIssuerMetadata(context.Background(), srv.URL)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "decoding issuer metadata")
+ })
+}
+
+// ---- RequestCredential ----
+
+func TestClient_RequestCredential(t *testing.T) {
+ t.Run("posts request and parses response", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+ assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
+ assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
+
+ var credReq CredentialRequest
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&credReq))
+ assert.Equal(t, "SomeCredentialConfig", credReq.CredentialConfigurationID)
+ require.NotNil(t, credReq.Proofs)
+ assert.Equal(t, []string{"proof-jwt-value"}, credReq.Proofs.JWT)
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(CredentialResponse{
+ Credentials: []CredentialResponseEntry{
+ {Credential: json.RawMessage(`"eyJhbGciOiJFUzI1NiJ9"`)},
+ },
+ })
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.Client(), false)
+ resp, err := client.RequestCredential(context.Background(), RequestCredentialOpts{
+ CredentialEndpoint: srv.URL,
+ AccessToken: "test-token",
+ CredentialConfigurationID: "SomeCredentialConfig",
+ ProofJWT: "proof-jwt-value",
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Len(t, resp.Credentials, 1)
+ assert.JSONEq(t, `"eyJhbGciOiJFUzI1NiJ9"`, string(resp.Credentials[0].Credential))
+ })
+
+ t.Run("uses credential_identifier when provided and omits credential_configuration_id", func(t *testing.T) {
+ var credReq CredentialRequest
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&credReq))
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(CredentialResponse{
+ Credentials: []CredentialResponseEntry{{Credential: json.RawMessage(`"vc"`)}},
+ })
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.Client(), false)
+ _, err := client.RequestCredential(context.Background(), RequestCredentialOpts{
+ CredentialEndpoint: srv.URL,
+ AccessToken: "t",
+ CredentialConfigurationID: "ignored-when-identifier-set",
+ CredentialIdentifier: "CivilEngineeringDegree-2023",
+ ProofJWT: "p",
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "CivilEngineeringDegree-2023", credReq.CredentialIdentifier)
+ assert.Empty(t, credReq.CredentialConfigurationID, "credential_configuration_id MUST NOT be present when credential_identifier is used (§8.2)")
+ })
+
+ t.Run("returns structured oauth.OAuth2Error on invalid_nonce", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(oauth.OAuth2Error{Code: oauth.InvalidNonce})
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.Client(), false)
+ _, err := client.RequestCredential(context.Background(), RequestCredentialOpts{
+ CredentialEndpoint: srv.URL,
+ AccessToken: "test-token",
+ })
+ require.Error(t, err)
+
+ var oauthErr oauth.OAuth2Error
+ require.True(t, errors.As(err, &oauthErr))
+ assert.Equal(t, oauth.InvalidNonce, oauthErr.Code)
+ })
+
+ t.Run("returns generic error on non-2xx with no structured body", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "something went wrong", http.StatusServiceUnavailable)
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.Client(), false)
+ _, err := client.RequestCredential(context.Background(), RequestCredentialOpts{
+ CredentialEndpoint: srv.URL,
+ AccessToken: "test-token",
+ })
+ require.Error(t, err)
+
+ var oauthErr oauth.OAuth2Error
+ assert.False(t, errors.As(err, &oauthErr))
+ assert.Contains(t, err.Error(), "503")
+ })
+}
diff --git a/auth/openid4vci/mock.go b/auth/openid4vci/mock.go
new file mode 100644
index 0000000000..85369a8fee
--- /dev/null
+++ b/auth/openid4vci/mock.go
@@ -0,0 +1,86 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: auth/openid4vci/client.go
+//
+// Generated by this command:
+//
+// mockgen -destination=auth/openid4vci/mock.go -package=openid4vci -source=auth/openid4vci/client.go
+//
+
+// Package openid4vci is a generated GoMock package.
+package openid4vci
+
+import (
+ context "context"
+ reflect "reflect"
+
+ gomock "go.uber.org/mock/gomock"
+)
+
+// MockClient is a mock of Client interface.
+type MockClient struct {
+ ctrl *gomock.Controller
+ recorder *MockClientMockRecorder
+ isgomock struct{}
+}
+
+// MockClientMockRecorder is the mock recorder for MockClient.
+type MockClientMockRecorder struct {
+ mock *MockClient
+}
+
+// NewMockClient creates a new mock instance.
+func NewMockClient(ctrl *gomock.Controller) *MockClient {
+ mock := &MockClient{ctrl: ctrl}
+ mock.recorder = &MockClientMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockClient) EXPECT() *MockClientMockRecorder {
+ return m.recorder
+}
+
+// OpenIDCredentialIssuerMetadata mocks base method.
+func (m *MockClient) OpenIDCredentialIssuerMetadata(ctx context.Context, issuerURL string) (*OpenIDCredentialIssuerMetadata, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "OpenIDCredentialIssuerMetadata", ctx, issuerURL)
+ ret0, _ := ret[0].(*OpenIDCredentialIssuerMetadata)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// OpenIDCredentialIssuerMetadata indicates an expected call of OpenIDCredentialIssuerMetadata.
+func (mr *MockClientMockRecorder) OpenIDCredentialIssuerMetadata(ctx, issuerURL any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenIDCredentialIssuerMetadata", reflect.TypeOf((*MockClient)(nil).OpenIDCredentialIssuerMetadata), ctx, issuerURL)
+}
+
+// RequestCredential mocks base method.
+func (m *MockClient) RequestCredential(ctx context.Context, opts RequestCredentialOpts) (*CredentialResponse, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "RequestCredential", ctx, opts)
+ ret0, _ := ret[0].(*CredentialResponse)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// RequestCredential indicates an expected call of RequestCredential.
+func (mr *MockClientMockRecorder) RequestCredential(ctx, opts any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestCredential", reflect.TypeOf((*MockClient)(nil).RequestCredential), ctx, opts)
+}
+
+// RequestNonce mocks base method.
+func (m *MockClient) RequestNonce(ctx context.Context, nonceEndpoint string) (string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "RequestNonce", ctx, nonceEndpoint)
+ ret0, _ := ret[0].(string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// RequestNonce indicates an expected call of RequestNonce.
+func (mr *MockClientMockRecorder) RequestNonce(ctx, nonceEndpoint any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestNonce", reflect.TypeOf((*MockClient)(nil).RequestNonce), ctx, nonceEndpoint)
+}
diff --git a/auth/openid4vci/types.go b/auth/openid4vci/types.go
new file mode 100644
index 0000000000..c763664246
--- /dev/null
+++ b/auth/openid4vci/types.go
@@ -0,0 +1,98 @@
+/*
+ * Nuts node
+ * Copyright (C) 2026 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+// Package openid4vci implements the OpenID for Verifiable Credential Issuance
+// 1.0 (ID-1) protocol surface used by the user/browser flow in auth/api/iam.
+//
+// This package owns the v1.0 protocol types, error codes, and the HTTP client
+// used to talk to a Credential Issuer. Consumers in auth/api/iam (HTTP
+// handlers) and auth/client/iam (low-level HTTP plumbing) import from here.
+//
+// This package is independent of vcr/openid4vci, which is an internal
+// node-to-node draft-11 issuance flow that diverges from v1.0 in several
+// material ways and is not consumed from auth/.
+//
+// Reference: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html
+package openid4vci
+
+import (
+ "encoding/json"
+)
+
+// JWTTypeOpenID4VCIProof is the JWT typ claim value used in OpenID4VCI key
+// proofs (Appendix F.1).
+const JWTTypeOpenID4VCIProof = "openid4vci-proof+jwt"
+
+// OpenIDCredentialIssuerMetadata describes the OpenID4VCI Credential Issuer
+// Metadata document published at /.well-known/openid-credential-issuer
+// (Section 12.2). The document is OpenID4VCI-defined; it is not an OAuth
+// authorization-server metadata document.
+type OpenIDCredentialIssuerMetadata struct {
+ CredentialIssuer string `json:"credential_issuer"`
+ CredentialEndpoint string `json:"credential_endpoint"`
+ NonceEndpoint string `json:"nonce_endpoint,omitempty"`
+ AuthorizationServers []string `json:"authorization_servers,omitempty"`
+ Display []map[string]string `json:"display,omitempty"`
+}
+
+// NonceResponse is the body returned by the Nonce Endpoint (Section 7.2).
+type NonceResponse struct {
+ CNonce string `json:"c_nonce"`
+}
+
+// CredentialRequest is the body of a Credential Request (Section 8.2).
+//
+// Either CredentialConfigurationID or CredentialIdentifier identifies the
+// requested credential — see §5.1.1: when the Token Response carried
+// authorization_details with credential_identifiers, the wallet sends
+// CredentialIdentifier; otherwise it sends CredentialConfigurationID.
+// Today the auth-side flow only emits CredentialConfigurationID; the field
+// for CredentialIdentifier is present so future support is non-breaking.
+type CredentialRequest struct {
+ CredentialConfigurationID string `json:"credential_configuration_id,omitempty"`
+ CredentialIdentifier string `json:"credential_identifier,omitempty"`
+ Proofs *CredentialRequestProofs `json:"proofs,omitempty"`
+}
+
+// CredentialRequestProofs carries one or more key proofs in a Credential
+// Request (the proofs parameter defined in Section 8.2; proof type formats
+// are listed in Appendix F).
+type CredentialRequestProofs struct {
+ JWT []string `json:"jwt,omitempty"`
+}
+
+// CredentialResponse is the body returned by the Credential Endpoint
+// (Section 8.3).
+//
+// TransactionID, Interval, and NotificationID are present for forward
+// compatibility (deferred issuance via HTTP 202 with a transaction id, and
+// notification ids consumed by the Notification Endpoint in §11). The
+// auth-side flow today consumes only Credentials; the other fields are
+// populated when the issuer sends them so they are available without a
+// wire-format change later.
+type CredentialResponse struct {
+ Credentials []CredentialResponseEntry `json:"credentials,omitempty"`
+ TransactionID string `json:"transaction_id,omitempty"`
+ Interval int `json:"interval,omitempty"`
+ NotificationID string `json:"notification_id,omitempty"`
+}
+
+// CredentialResponseEntry is one issued credential in a Credential Response.
+type CredentialResponseEntry struct {
+ Credential json.RawMessage `json:"credential"`
+}
diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml
index 01cf6ba826..ce1419e30f 100644
--- a/docs/_static/auth/v2.yaml
+++ b/docs/_static/auth/v2.yaml
@@ -165,19 +165,15 @@ paths:
used to locate the OAuth2 Authorization Server metadata.
example: did:web:issuer.example.com
authorization_details:
+ description: |
+ Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1.1.
+ The current implementation processes a single credential
+ issuance per call and only consumes the first entry.
type: array
+ minItems: 1
+ maxItems: 1
items:
- type: object
- description: |
- The request parameter authorization_details defined in Section 2 of [RFC9396] MUST be used to convey the details about the Credentials the Wallet wants to obtain.
- See the RFC9396/OpenID4VCI for the format of an authorization_details object, and consult the Credential Issuer for requestable credentials.
- example: |
- [
- {
- "type": "openid_credential",
- "credential_configuration_id": "UniversityDegreeCredential"
- }
- ]
+ $ref: '#/components/schemas/AuthorizationDetail'
redirect_uri:
type: string
description: |
@@ -721,6 +717,31 @@ components:
description: |
Presentation Definitions, as described in Presentation Exchange specification, fulfilled to obtain the access token
The map key is the wallet owner (user/organization)
+ AuthorizationDetail:
+ description: |
+ A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1.1.
+ Only the fields used by the user/browser issuance flow are modeled.
+ type: object
+ required:
+ - type
+ - credential_configuration_id
+ properties:
+ type:
+ type: string
+ enum: [openid_credential]
+ description: |
+ The authorization details type. For OpenID4VCI flows this MUST
+ be "openid_credential" per §5.1.1.
+ credential_configuration_id:
+ type: string
+ description: |
+ References a credential configuration from the issuer's
+ credential_configurations_supported metadata. REQUIRED for
+ type=openid_credential per §5.1.1.
+ format:
+ type: string
+ description: |
+ Optional credential format hint (e.g. "vc+sd-jwt").
securitySchemes:
jwtBearerAuth:
type: http
diff --git a/docs/pages/deployment/oauth.rst b/docs/pages/deployment/oauth.rst
index 61891dae61..71b7dcbb6c 100644
--- a/docs/pages/deployment/oauth.rst
+++ b/docs/pages/deployment/oauth.rst
@@ -14,6 +14,7 @@ The Nuts node implements (parts of) the following RFCs:
- `RFC 9449 `_ - OAuth 2.0 Demonstrating Proof of Possession (DPoP)
- `Nuts RFC021 `_ - RFC021 VP Token Grant Type
- `OpenID4VP `_ - OpenID for Verifiable Presentations - draft 20
+- `OpenID4VCI `_ - OpenID for Verifiable Credential Issuance 1.0 (ID-1)
- `StatusList2021 `_ - Status List 2021
- `Presentation Exchange `_ - Presentation Exchange
@@ -49,6 +50,31 @@ The Nuts node implements the following:
DPoP is optional, usage is determined by the client.
+OpenID4VCI
+**********
+
+The Nuts node implements the OpenID for Verifiable Credential Issuance 1.0 wallet flow.
+On behalf of a user, the node requests a Verifiable Credential from a remote Credential Issuer over the Authorization Code Flow:
+
+- Authorization Request with ``authorization_details`` of type ``openid_credential`` (RFC 9396 / OpenID4VCI §5.1.1).
+- PKCE for the authorization code, as in the OpenID4VP flow.
+- Token Response with ``credential_identifiers`` per the requested ``credential_configuration_id`` (§6.2 and §3.3.4).
+- Nonce Endpoint to obtain a fresh ``c_nonce`` before requesting a Credential (§7).
+- Credential Request with a key proof JWT bound to the holder's DID (Appendix F.1).
+- On an ``invalid_nonce`` response, the wallet fetches a fresh ``c_nonce`` and retries the Credential Request once (§8.3.1.2 prescribes fetching a new ``c_nonce``; retrying once is local policy).
+
+The relevant API:
+
+- ``POST /internal/auth/v2/{subjectID}/request-credential``
+
+Not implemented:
+
+- Deferred issuance (HTTP 202 with ``transaction_id`` / ``interval``).
+- The Notification Endpoint (§11). ``notification_id`` returned by the issuer is ignored.
+- Multiple credentials per call: only a single ``authorization_details`` entry is accepted and only the first credential in the response is processed.
+
+Note: the unrelated internal flow in the ``vcr/openid4vci`` package is used by Nuts nodes to issue ``NutsAuthorizationCredential`` to each other over HTTP. That flow is based on a subset of OpenID4VCI draft-11 and is not a wallet implementation per §2.
+
DPoP
****
diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go
index 51c1f31772..95a69b3541 100644
--- a/e2e-tests/browser/client/iam/generated.go
+++ b/e2e-tests/browser/client/iam/generated.go
@@ -20,6 +20,11 @@ const (
JwtBearerAuthScopes = "jwtBearerAuth.Scopes"
)
+// Defines values for AuthorizationDetailType.
+const (
+ OpenidCredential AuthorizationDetailType = "openid_credential"
+)
+
// Defines values for ServiceAccessTokenRequestTokenType.
const (
ServiceAccessTokenRequestTokenTypeBearer ServiceAccessTokenRequestTokenType = "Bearer"
@@ -32,6 +37,26 @@ const (
UserAccessTokenRequestTokenTypeDPoP UserAccessTokenRequestTokenType = "DPoP"
)
+// AuthorizationDetail A single authorization_details entry per RFC 9396 / OpenID4VCI 1.0 §5.1.1.
+// Only the fields used by the user/browser issuance flow are modeled.
+type AuthorizationDetail struct {
+ // CredentialConfigurationId References a credential configuration from the issuer's
+ // credential_configurations_supported metadata. REQUIRED for
+ // type=openid_credential per §5.1.1.
+ CredentialConfigurationId string `json:"credential_configuration_id"`
+
+ // Format Optional credential format hint (e.g. "vc+sd-jwt").
+ Format *string `json:"format,omitempty"`
+
+ // Type The authorization details type. For OpenID4VCI flows this MUST
+ // be "openid_credential" per §5.1.1.
+ Type AuthorizationDetailType `json:"type"`
+}
+
+// AuthorizationDetailType The authorization details type. For OpenID4VCI flows this MUST
+// be "openid_credential" per §5.1.1.
+type AuthorizationDetailType string
+
// DPoPRequest defines model for DPoPRequest.
type DPoPRequest struct {
// Htm The HTTP method for which the DPoP proof is requested.
@@ -107,7 +132,7 @@ type ExtendedTokenIntrospectionResponse struct {
// PresentationSubmissions Mapping of Presentation Definition IDs that were fulfilled to Presentation Submissions.
PresentationSubmissions *map[string]PresentationSubmission `json:"presentation_submissions,omitempty"`
- // Scope granted scopes
+ // Scope Granted scopes, as a space-separated list.
Scope *string `json:"scope,omitempty"`
Vps *[]VerifiablePresentation `json:"vps,omitempty"`
AdditionalProperties map[string]interface{} `json:"-"`
@@ -129,12 +154,15 @@ type ServiceAccessTokenRequest struct {
AuthorizationServer string `json:"authorization_server"`
// CredentialSelection Optional key-value mapping for credential selection when the wallet contains multiple
- // credentials matching a single input descriptor. Each key must match a field id declared
+ // credentials matching a single input descriptor. Each key must match a field ID declared
// in the Presentation Definition's input descriptor constraints. The value narrows the
// match to credentials where that field equals the given value.
//
// The selection must narrow to exactly one credential per input descriptor.
// Zero matches or multiple matches will result in an error.
+ //
+ // When omitted and multiple credentials match an input descriptor,
+ // the first matching credential is used.
CredentialSelection *map[string]string `json:"credential_selection,omitempty"`
// Credentials Additional credentials to present (if required by the authorizer), in addition to those in the requester's wallet.
@@ -191,7 +219,7 @@ type UserAccessTokenRequestTokenType string
// UserDetails Claims about the authorized user.
type UserDetails struct {
- // Id Machine-readable identifier, uniquely identifying the user in the issuing system.
+ // Id Machine-readable identifier, uniquely identifying the user in the issuing system. The format is not specified; it could be a username, email address, employee number, etc.
Id string `json:"id"`
// Name Human-readable name of the user.
@@ -209,7 +237,10 @@ type Cnf struct {
// RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance.
type RequestOpenid4VCICredentialIssuanceJSONBody struct {
- AuthorizationDetails []map[string]interface{} `json:"authorization_details"`
+ // AuthorizationDetails Authorization details per RFC 9396 / OpenID4VCI 1.0 §5.1.1.
+ // The current implementation processes a single credential
+ // issuance per call and only consumes the first entry.
+ AuthorizationDetails []AuthorizationDetail `json:"authorization_details"`
// Issuer The OAuth Authorization Server's identifier, that issues the Verifiable Credentials, as specified in RFC 8414 (section 2),
// used to locate the OAuth2 Authorization Server metadata.
diff --git a/makefile b/makefile
index fd9a0cfcac..bec1275f5a 100644
--- a/makefile
+++ b/makefile
@@ -14,6 +14,7 @@ gen-mocks:
mockgen -destination=auth/api/iam/jar_mock.go -package=iam -source=auth/api/iam/jar.go
mockgen -destination=auth/contract/signer_mock.go -package=contract -source=auth/contract/signer.go
mockgen -destination=auth/client/iam/mock.go -package=iam -source=auth/client/iam/interface.go
+ mockgen -destination=auth/openid4vci/mock.go -package=openid4vci -source=auth/openid4vci/client.go
mockgen -destination=auth/services/mock.go -package=services -source=auth/services/services.go
mockgen -destination=auth/services/oauth/mock.go -package=oauth -source=auth/services/oauth/interface.go
mockgen -destination=auth/services/selfsigned/types/mock.go -package=types -source=auth/services/selfsigned/types/types.go
diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go
index e5d030b005..e425f0e6c0 100644
--- a/vcr/openid4vci/types.go
+++ b/vcr/openid4vci/types.go
@@ -16,8 +16,28 @@
*
*/
-// This file defines types specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html
-
+// Package openid4vci implements an internal node-to-node credential
+// issuance flow between Nuts nodes. It is the HTTP replacement of the
+// Nuts v5 gRPC network for issuing NutsAuthorizationCredentials.
+//
+// This package is based on a subset of OpenID for Verifiable Credential
+// Issuance draft-11. It is NOT a standards-conformant Wallet implementation
+// per OpenID4VCI 1.0 §2 and diverges from v1.0 in several ways:
+//
+// - Single credential per request only.
+// - No credential_offer_uri (offers are inline only).
+// - No Nonce Endpoint (c_nonce delivered with token/credential responses,
+// draft-11 style).
+// - No authorization_details with type "openid_credential".
+// - Draft-11 wire format for Credential Request and Response.
+//
+// For the standards-conformant OpenID4VCI 1.0 user/browser flow, see
+// the auth/openid4vci package and auth/api/iam/openid4vci.go.
+//
+// This package is in production. Do not extend or migrate it without a
+// deliberate decision — the next migration is the right time to also
+// question whether OpenID4VCI is the right protocol for an internal
+// node-to-node flow at all.
package openid4vci
import (