From 275a3b58e69b13ef8bb95fe726e01c4d824ad0ba Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 08:48:46 +0200 Subject: [PATCH 01/11] feat(auth/iam): select credential by type in request-credential request-credential took a caller-built authorization_details array with raw OpenID4VCI/RFC 9396 internals. Replace it with a single credential_type field: the node resolves the issuer's credential_configuration_id by matching credential_type against the issuer's Credential Issuer Metadata, and picks the authorization-stage flow (Authorization Details vs. Credential Configuration ID) from the Authorization Server metadata. The issued credential's type is verified against the request before it's stored. Breaking change to this experimental API. Fixes #4372 Assisted by AI --- auth/api/iam/generated.go | 38 ++---- auth/api/iam/openid4vci.go | 59 +++++---- auth/api/iam/openid4vci_test.go | 147 +++++++++++++++++---- auth/api/iam/session.go | 3 + auth/oauth/types.go | 12 ++ auth/openid4vci/types.go | 105 ++++++++++++++- auth/openid4vci/types_test.go | 152 ++++++++++++++++++++++ docs/_static/auth/v2.yaml | 45 ++----- docs/pages/deployment/oauth.rst | 19 ++- docs/pages/release_notes.rst | 1 + e2e-tests/browser/client/iam/generated.go | 38 ++---- 11 files changed, 465 insertions(+), 154 deletions(-) create mode 100644 auth/openid4vci/types_test.go diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 13e0eeedad..e537b3eb60 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -19,11 +19,6 @@ const ( JwtBearerAuthScopes = "jwtBearerAuth.Scopes" ) -// Defines values for AuthorizationDetailType. -const ( - OpenidCredential AuthorizationDetailType = "openid_credential" -) - // Defines values for ServiceAccessTokenRequestTokenType. const ( ServiceAccessTokenRequestTokenTypeBearer ServiceAccessTokenRequestTokenType = "Bearer" @@ -36,26 +31,6 @@ 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. @@ -256,11 +231,6 @@ type Cnf struct { // RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. type RequestOpenid4VCICredentialIssuanceJSONBody struct { - // 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"` - // AuthorizationRequestParams Optional key/value pairs added to the OpenID4VCI authorization request (the redirect to the // Authorization Server's authorization_endpoint). These may only add parameters; they must not // override the OpenID4VCI parameters set by the node (the request is rejected if they do). @@ -275,6 +245,14 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // responsible for the resulting wire shape (§8.2 mutual exclusivity, proof binding, etc.). CredentialRequestParams *map[string]interface{} `json:"credential_request_params,omitempty"` + // CredentialType The type of Verifiable Credential to request (e.g. its `credential_definition.type` or `vct` value). + // The node resolves this to the issuer's `credential_configuration_id` by matching it against the + // issuer's Credential Issuer Metadata (`credential_configurations_supported`, OpenID4VCI 1.0 §12.2), + // and picks the authorization-stage flow (RFC 9396 `authorization_details` vs. `credential_configuration_id` + // at the Credential Request) from the Authorization Server metadata. The current implementation + // processes a single credential issuance per call. + CredentialType string `json:"credential_type"` + // 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. Issuer string `json:"issuer"` diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index c5817da2c8..434cb8311b 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -28,6 +28,8 @@ import ( "time" "github.com/lestrrat-go/jwx/v2/jwt" + + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -59,14 +61,9 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques 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") + credentialType := request.Body.CredentialType + if credentialType == "" { + return nil, core.InvalidInputError("credential_type is empty") } // Fetch metadata containing the endpoints credentialIssuerMetadata, authzServerMetadata, err := r.openid4vciMetadata(ctx, request.Body.Issuer) @@ -85,12 +82,12 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques clientID := r.subjectToBaseURL(request.SubjectID) - // 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 + // Resolve the caller-supplied credential_type to a credential_configuration_id by matching it + // against the issuer's credential_configurations_supported metadata (§12.2). + credentialConfigID, err := credentialIssuerMetadata.ResolveCredentialConfigurationID(credentialType) + if err != nil { + return nil, core.InvalidInputError("%s", err.Error()) + } // Capture optional credential_request_params, used as the base body of the Credential Request later in the flow. var credentialRequestParams map[string]any if request.Body.CredentialRequestParams != nil { @@ -116,6 +113,7 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, IssuerNonceEndpoint: credentialIssuerMetadata.NonceEndpoint, IssuerCredentialConfigurationID: credentialConfigID, + IssuerCredentialType: credentialType, IssuerCredentialIssuer: credentialIssuerMetadata.CredentialIssuer, CredentialRequestParams: credentialRequestParams, }) @@ -128,14 +126,24 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques return nil, fmt.Errorf("failed to parse the authorization_endpoint: %w", err) } authzParams := map[string]string{ - oauth.ResponseTypeParam: oauth.CodeResponseType, - oauth.StateParam: state, - oauth.ClientIDParam: clientID.String(), - oauth.ClientIDSchemeParam: entityClientIDScheme, - oauth.AuthorizationDetailsParam: string(authorizationDetails), - oauth.RedirectURIParam: r.callbackURL().String(), - oauth.CodeChallengeParam: pkceParams.Challenge, - oauth.CodeChallengeMethodParam: pkceParams.ChallengeMethod, + oauth.ResponseTypeParam: oauth.CodeResponseType, + oauth.StateParam: state, + oauth.ClientIDParam: clientID.String(), + oauth.ClientIDSchemeParam: entityClientIDScheme, + oauth.RedirectURIParam: r.callbackURL().String(), + oauth.CodeChallengeParam: pkceParams.Challenge, + oauth.CodeChallengeMethodParam: pkceParams.ChallengeMethod, + } + // Authorization-stage flow selection (no probing): if the AS advertises support for the + // openid_credential authorization_details type, use the Authorization Details flow. Otherwise, + // omit authorization_details entirely (Credential Configuration ID flow) — the resolved + // credential_configuration_id is identified later, at the Credential Request (§8.2). + if authzServerMetadata.SupportsAuthorizationDetailsType(openid4vci.AuthorizationDetailsTypeOpenIDCredential) { + authorizationDetails, _ := json.Marshal([]openid4vci.AuthorizationDetail{{ + Type: openid4vci.AuthorizationDetailsTypeOpenIDCredential, + CredentialConfigurationID: credentialConfigID, + }}) + authzParams[oauth.AuthorizationDetailsParam] = string(authorizationDetails) } // Optional caller-supplied authorization request parameters, for issuers that need extras // (e.g. auth_method=SmartCard). These may only add parameters; they must not override the @@ -231,6 +239,11 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode 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) } + // Guard against an issuer returning a credential of a different type than requested, before + // it is stored in the wallet. + if !credential.IsType(ssi.MustParseURI(oauthSession.IssuerCredentialType)) { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("issued credential does not have the requested type %q", oauthSession.IssuerCredentialType)), appCallbackURI) + } 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) @@ -283,7 +296,7 @@ func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse, credentialC return "", fmt.Errorf("token response authorization_details malformed: %w", err) } for _, d := range details { - if d.Type != "openid_credential" { + if d.Type != openid4vci.AuthorizationDetailsTypeOpenIDCredential { continue } if d.CredentialConfigurationID != credentialConfigurationID { diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index da39e4122b..421713977d 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -26,8 +26,6 @@ import ( "testing" "time" - "github.com/nuts-foundation/nuts-node/core/to" - "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/crypto" @@ -44,11 +42,18 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { CredentialIssuer: "issuer", CredentialEndpoint: "endpoint", AuthorizationServers: []string{authServer}, + CredentialConfigurationsSupported: map[string]openid4vci.CredentialConfiguration{ + "UniversityDegreeCredential_jwt_vc_json": { + Format: "jwt_vc_json", + CredentialDefinition: &openid4vci.CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + }, + }, } authzMetadata := oauth.AuthorizationServerMetadata{ - AuthorizationEndpoint: "https://auth.server/authorize", - TokenEndpoint: "https://auth.server/token", - ClientIdSchemesSupported: clientIdSchemesSupported, + AuthorizationEndpoint: "https://auth.server/authorize", + TokenEndpoint: "https://auth.server/token", + ClientIdSchemesSupported: clientIdSchemesSupported, + AuthorizationDetailsTypesSupported: []string{"openid_credential"}, } t.Run("ok", func(t *testing.T) { ctx := newTestClient(t) @@ -57,10 +62,10 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: holderSubjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []AuthorizationDetail{{Type: "openid_credential", CredentialConfigurationId: "UniversityDegreeCredential", Format: to.Ptr("vc+sd-jwt")}}, - Issuer: issuerClientID, - RedirectUri: redirectURI, - WalletDid: holderDID.String(), + CredentialType: "UniversityDegreeCredential", + Issuer: issuerClientID, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), }, }) require.NoError(t, err) @@ -75,7 +80,7 @@ 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, `[{"credential_configuration_id":"UniversityDegreeCredential","format":"vc+sd-jwt","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) + assert.Equal(t, `[{"type":"openid_credential","credential_configuration_id":"UniversityDegreeCredential_jwt_vc_json"}]`, redirectUri.Query().Get("authorization_details")) }) t.Run("ok - authorization_request_params merged into authorization request", func(t *testing.T) { ctx := newTestClient(t) @@ -157,32 +162,103 @@ 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. + t.Run("error - empty credential_type", func(t *testing.T) { req := requestCredentials(holderSubjectID, issuerClientID, redirectURI) - req.Body.AuthorizationDetails = []AuthorizationDetail{} + req.Body.CredentialType = "" 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") + assert.EqualError(t, err, "credential_type is empty") }) - t.Run("error - multiple authorization_details", func(t *testing.T) { - // Schema declares maxItems: 1; same StrictServer gap as minItems. + t.Run("error - issuer does not offer requested credential_type", func(t *testing.T) { + ctx := newTestClient(t) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) req := requestCredentials(holderSubjectID, issuerClientID, redirectURI) - req.Body.AuthorizationDetails = []AuthorizationDetail{ - {Type: "openid_credential", CredentialConfigurationId: "First"}, - {Type: "openid_credential", CredentialConfigurationId: "Second"}, - } + req.Body.CredentialType = "UnknownCredential" + + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) + + assert.EqualError(t, err, `issuer does not offer a credential of type "UnknownCredential"`) + }) + t.Run("error - credential_type only offered in a format the node does not support", func(t *testing.T) { ctx := newTestClient(t) + unsupportedFormatMetadata := metadata + unsupportedFormatMetadata.CredentialConfigurationsSupported = map[string]openid4vci.CredentialConfiguration{ + "MdocOnlyCredential_mso_mdoc": { + Format: "mso_mdoc", + CredentialDefinition: &openid4vci.CredentialDefinition{Type: []string{"VerifiableCredential", "MdocOnlyCredential"}}, + }, + } + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&unsupportedFormatMetadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) + req := requestCredentials(holderSubjectID, issuerClientID, redirectURI) + req.Body.CredentialType = "MdocOnlyCredential" _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) - assert.ErrorContains(t, err, "must contain exactly one entry") + assert.EqualError(t, err, `issuer offers "MdocOnlyCredential" only in format(s): mso_mdoc`) + }) + t.Run("ok - multiple matches in supported formats resolved by sorted candidate ID", func(t *testing.T) { + ctx := newTestClient(t) + multiFormatMetadata := metadata + multiFormatMetadata.CredentialConfigurationsSupported = map[string]openid4vci.CredentialConfiguration{ + "ZZZ_UniversityDegreeCredential_jwt_vc_json": { + Format: "jwt_vc_json", + CredentialDefinition: &openid4vci.CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + }, + "AAA_UniversityDegreeCredential_ldp_vc": { + Format: "ldp_vc", + CredentialDefinition: &openid4vci.CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + }, + } + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&multiFormatMetadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) + + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + + require.NoError(t, err) + redirectUri, _ := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + // Both formats are supported (oauth.DefaultOpenIDSupportedFormats); there is no format + // ranking between them, so the lexicographically smallest candidate ID wins. + assert.Contains(t, redirectUri.Query().Get("authorization_details"), `"credential_configuration_id":"AAA_UniversityDegreeCredential_ldp_vc"`) + }) + t.Run("error - credential_type only offered via vct in an unsupported format", func(t *testing.T) { + ctx := newTestClient(t) + // The node does not support SD-JWT VCs; vct is still matched so this produces the more + // useful "only offered in format(s)" error instead of "does not offer this type at all". + sdJwtOnlyMetadata := metadata + sdJwtOnlyMetadata.CredentialConfigurationsSupported = map[string]openid4vci.CredentialConfiguration{ + "UniversityDegreeCredential_sd_jwt": { + Format: "vc+sd-jwt", + Vct: "UniversityDegreeCredential", + }, + } + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&sdJwtOnlyMetadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) + + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + + assert.EqualError(t, err, `issuer offers "UniversityDegreeCredential" only in format(s): vc+sd-jwt`) + }) + t.Run("ok - Credential Configuration ID flow when AS does not support authorization_details", func(t *testing.T) { + ctx := newTestClient(t) + authzMetadataNoDetails := authzMetadata + authzMetadataNoDetails.AuthorizationDetailsTypesSupported = nil + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadataNoDetails, nil) + + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + + require.NoError(t, err) + redirectUri, _ := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + assert.False(t, redirectUri.Query().Has("authorization_details")) + var stored OAuthSession + require.NoError(t, ctx.client.oauthClientStateStore().Get(redirectUri.Query().Get("state"), &stored)) + assert.Equal(t, "UniversityDegreeCredential_jwt_vc_json", stored.IssuerCredentialConfigurationID) }) t.Run("error - invalid authorization endpoint in metadata", func(t *testing.T) { ctx := newTestClient(t) @@ -231,10 +307,10 @@ func requestCredentials(subjectID string, issuer string, redirectURI string) Req return RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: subjectID, Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []AuthorizationDetail{{Type: "openid_credential", CredentialConfigurationId: "UniversityDegreeCredential"}}, - Issuer: issuer, - RedirectUri: redirectURI, - WalletDid: holderDID.String(), + CredentialType: "UniversityDegreeCredential", + Issuer: issuer, + RedirectUri: redirectURI, + WalletDid: holderDID.String(), }, } } @@ -268,6 +344,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { IssuerCredentialEndpoint: credEndpoint, IssuerNonceEndpoint: nonceEndpoint, IssuerCredentialConfigurationID: credentialConfigID, + IssuerCredentialType: "ExampleType", IssuerCredentialIssuer: issuerClientID, } sessionWithoutNonce := session @@ -575,4 +652,20 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { assert.Nil(t, callback) assert.ErrorContains(t, err, "credential response does not contain any credentials") }) + t.Run("error - issued credential type does not match requested credential_type", func(t *testing.T) { + ctx := newTestClient(t) + sessionWithDifferentType := session + sessionWithDifferentType.IssuerCredentialType = "SomeOtherCredentialType" + 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(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionWithDifferentType) + + assert.Nil(t, callback) + assert.EqualError(t, err, `server_error - issued credential does not have the requested type "SomeOtherCredentialType"`) + }) } diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index c7ad01d3ae..ccf81b9d69 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -59,6 +59,9 @@ type OAuthSession struct { 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"` + // IssuerCredentialType is the credential_type requested by the caller, used to verify the issued + // credential's type before it is stored in the wallet. + IssuerCredentialType string `json:"issuer_credential_type,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 diff --git a/auth/oauth/types.go b/auth/oauth/types.go index e1e3d8d580..573375ad4e 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -22,6 +22,7 @@ package oauth import ( "encoding/json" "net/url" + "slices" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/nuts-foundation/nuts-node/core" @@ -330,6 +331,11 @@ type AuthorizationServerMetadata struct { // DPoPSigningAlgValuesSupported is a JSON array containing a list of the DPoP proof JWS signing algorithms ("alg" values) supported by the token endpoint. DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` + // AuthorizationDetailsTypesSupported is a JSON array containing the authorization_details + // "type" values (RFC 9396) supported by this Authorization Server. OpenID4VCI wallets use this + // to decide whether the "openid_credential" type is supported for the Authorization Request (§5.1.1). + AuthorizationDetailsTypesSupported []string `json:"authorization_details_types_supported,omitempty"` + /* ******** JWT-Secured Authorization Request RFC9101 & OpenID Connect Core v1.0: §6. Passing Request Parameters as JWTs ******** */ // RequireSignedRequestObject specifies if the authorization server requires the use of signed request objects. @@ -362,6 +368,12 @@ func (m AuthorizationServerMetadata) SupportsClientIDScheme(scheme string) bool return false } +// SupportsAuthorizationDetailsType checks if the Authorization Server supports the given +// authorization_details type (RFC 9396). +func (m AuthorizationServerMetadata) SupportsAuthorizationDetailsType(t string) bool { + return slices.Contains(m.AuthorizationDetailsTypesSupported, t) +} + // OAuthClientMetadata defines the OAuth Client metadata. // Specified by https://www.rfc-editor.org/rfc/rfc7591.html and elsewhere. type OAuthClientMetadata struct { diff --git a/auth/openid4vci/types.go b/auth/openid4vci/types.go index c523a63080..cc6cf15369 100644 --- a/auth/openid4vci/types.go +++ b/auth/openid4vci/types.go @@ -32,6 +32,10 @@ package openid4vci import ( "encoding/json" + "fmt" + "slices" + "sort" + "strings" "github.com/nuts-foundation/nuts-node/auth/oauth" ) @@ -40,15 +44,61 @@ import ( // proofs (Appendix F.1). const JWTTypeOpenID4VCIProof = "openid4vci-proof+jwt" +// AuthorizationDetailsTypeOpenIDCredential is the RFC 9396 authorization_details +// "type" value used for OpenID4VCI credential issuance (§5.1.1). +const AuthorizationDetailsTypeOpenIDCredential = "openid_credential" + // 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"` + CredentialIssuer string `json:"credential_issuer"` + CredentialEndpoint string `json:"credential_endpoint"` + NonceEndpoint string `json:"nonce_endpoint,omitempty"` + AuthorizationServers []string `json:"authorization_servers,omitempty"` + CredentialConfigurationsSupported map[string]CredentialConfiguration `json:"credential_configurations_supported,omitempty"` +} + +// CredentialConfiguration is one entry of credential_configurations_supported +// in the Credential Issuer Metadata (§12.2). Only the fields needed to resolve +// a credential_type to a credential_configuration_id are modeled: the +// type-locating field depends on the format (CredentialDefinition.Type for +// jwt_vc_json/ldp_vc, Vct for vc+sd-jwt/dc+sd-jwt). +type CredentialConfiguration struct { + Format string `json:"format"` + CredentialDefinition *CredentialDefinition `json:"credential_definition,omitempty"` + Vct string `json:"vct,omitempty"` +} + +// MatchesType checks whether this credential_configurations_supported entry is of the given +// credential type, using the type-locating field per format (§12.2): credential_definition.type +// (jwt_vc_json, ldp_vc; ignoring the base "VerifiableCredential" entry) or vct (vc+sd-jwt, +// dc+sd-jwt). vct is still matched here, even though the node doesn't support those formats +// (oauth.DefaultOpenIDSupportedFormats), so such entries produce the more useful "only offered in +// unsupported format(s)" error instead of "does not offer this type at all". +func (c CredentialConfiguration) MatchesType(credentialType string) bool { + if credentialType == "VerifiableCredential" { + return false + } + if c.CredentialDefinition != nil && slices.Contains(c.CredentialDefinition.Type, credentialType) { + return true + } + return c.Vct != "" && c.Vct == credentialType +} + +// CredentialDefinition carries the credential type array used by the +// jwt_vc_json and ldp_vc formats. +type CredentialDefinition struct { + Type []string `json:"type"` +} + +// AuthorizationDetail is a single authorization_details entry (RFC 9396) sent +// on the Authorization Request when the Authorization Server supports the +// openid_credential type (§5.1.1). +type AuthorizationDetail struct { + Type string `json:"type"` + CredentialConfigurationID string `json:"credential_configuration_id"` } // GetIssuer returns the credential issuer identifier, for metadata discovery @@ -63,6 +113,53 @@ func (m OpenIDCredentialIssuerMetadata) WellKnownPath() string { return oauth.OpenIdCredIssuerWellKnown } +// ResolveCredentialConfigurationID matches credentialType against +// CredentialConfigurationsSupported (§12.2) and returns the matching credential_configuration_id. +// Matching is done on the type-locating field, not the map key: the spec lets +// credential_configuration_id be an arbitrary issuer-chosen string. +// +// - 0 matches: the issuer does not offer this credential type at all. +// - matches only in formats the node does not support (oauth.DefaultOpenIDSupportedFormats): the +// type exists, but the node cannot request it. +// - 1+ matches in a supported format: candidate IDs are sorted so the pick is deterministic +// (never Go map order); the smallest ID wins. +func (m OpenIDCredentialIssuerMetadata) ResolveCredentialConfigurationID(credentialType string) (string, error) { + type candidate struct { + id string + format string + } + var matches []candidate + for id, config := range m.CredentialConfigurationsSupported { + if config.MatchesType(credentialType) { + matches = append(matches, candidate{id: id, format: config.Format}) + } + } + if len(matches) == 0 { + return "", fmt.Errorf("issuer does not offer a credential of type %q", credentialType) + } + supportedFormats := oauth.DefaultOpenIDSupportedFormats() + var supportedMatches []candidate + for _, c := range matches { + if _, ok := supportedFormats[c.format]; ok { + supportedMatches = append(supportedMatches, c) + } + } + if len(supportedMatches) == 0 { + seenFormats := make(map[string]bool, len(matches)) + unsupportedFormats := make([]string, 0, len(matches)) + for _, c := range matches { + if !seenFormats[c.format] { + seenFormats[c.format] = true + unsupportedFormats = append(unsupportedFormats, c.format) + } + } + sort.Strings(unsupportedFormats) + return "", fmt.Errorf("issuer offers %q only in format(s): %s", credentialType, strings.Join(unsupportedFormats, ", ")) + } + sort.Slice(supportedMatches, func(i, j int) bool { return supportedMatches[i].id < supportedMatches[j].id }) + return supportedMatches[0].id, nil +} + // NonceResponse is the body returned by the Nonce Endpoint (Section 7.2). type NonceResponse struct { CNonce string `json:"c_nonce"` diff --git a/auth/openid4vci/types_test.go b/auth/openid4vci/types_test.go new file mode 100644 index 0000000000..2a3619bfa5 --- /dev/null +++ b/auth/openid4vci/types_test.go @@ -0,0 +1,152 @@ +/* + * 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 ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenIDCredentialIssuerMetadata_ResolveCredentialConfigurationID(t *testing.T) { + t.Run("ok - single match", func(t *testing.T) { + metadata := OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]CredentialConfiguration{ + "UniversityDegreeCredential_jwt_vc_json": { + Format: "jwt_vc_json", + CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + }, + }, + } + + id, err := metadata.ResolveCredentialConfigurationID("UniversityDegreeCredential") + + require.NoError(t, err) + assert.Equal(t, "UniversityDegreeCredential_jwt_vc_json", id) + }) + t.Run("ok - matches on vct for vc+sd-jwt/dc+sd-jwt formats", func(t *testing.T) { + metadata := OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]CredentialConfiguration{ + "UniversityDegreeCredential_dc_sd_jwt": { + Format: "dc+sd-jwt", + Vct: "UniversityDegreeCredential", + }, + }, + } + + // Not a node-supported format, but the type is still located and reported accordingly. + _, err := metadata.ResolveCredentialConfigurationID("UniversityDegreeCredential") + + assert.EqualError(t, err, `issuer offers "UniversityDegreeCredential" only in format(s): dc+sd-jwt`) + }) + t.Run("ok - multiple matches in supported formats resolved by sorted candidate ID", func(t *testing.T) { + metadata := OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]CredentialConfiguration{ + "ZZZ_UniversityDegreeCredential_jwt_vc_json": { + Format: "jwt_vc_json", + CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + }, + "AAA_UniversityDegreeCredential_ldp_vc": { + Format: "ldp_vc", + CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + }, + }, + } + + // Both formats are supported; there is no format ranking between them, so the + // lexicographically smallest candidate ID wins. + id, err := metadata.ResolveCredentialConfigurationID("UniversityDegreeCredential") + + require.NoError(t, err) + assert.Equal(t, "AAA_UniversityDegreeCredential_ldp_vc", id) + }) + t.Run("error - no matches at all", func(t *testing.T) { + metadata := OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]CredentialConfiguration{ + "UniversityDegreeCredential_jwt_vc_json": { + Format: "jwt_vc_json", + CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + }, + }, + } + + _, err := metadata.ResolveCredentialConfigurationID("UnknownCredential") + + assert.EqualError(t, err, `issuer does not offer a credential of type "UnknownCredential"`) + }) + t.Run("error - matches only in unsupported format(s)", func(t *testing.T) { + metadata := OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]CredentialConfiguration{ + "MdocOnlyCredential_mso_mdoc": { + Format: "mso_mdoc", + CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential", "MdocOnlyCredential"}}, + }, + }, + } + + _, err := metadata.ResolveCredentialConfigurationID("MdocOnlyCredential") + + assert.EqualError(t, err, `issuer offers "MdocOnlyCredential" only in format(s): mso_mdoc`) + }) + t.Run("error - unsupported formats are listed sorted and deduplicated", func(t *testing.T) { + metadata := OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]CredentialConfiguration{ + "1": {Format: "mso_mdoc", CredentialDefinition: &CredentialDefinition{Type: []string{"SomeCredential"}}}, + "2": {Format: "vc+sd-jwt", Vct: "SomeCredential"}, + "3": {Format: "vc+sd-jwt", Vct: "SomeCredential"}, + }, + } + + _, err := metadata.ResolveCredentialConfigurationID("SomeCredential") + + assert.EqualError(t, err, `issuer offers "SomeCredential" only in format(s): mso_mdoc, vc+sd-jwt`) + }) +} + +func TestCredentialConfiguration_MatchesType(t *testing.T) { + t.Run("matches credential_definition.type", func(t *testing.T) { + config := CredentialConfiguration{ + Format: "jwt_vc_json", + CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + } + + assert.True(t, config.MatchesType("UniversityDegreeCredential")) + assert.False(t, config.MatchesType("OtherCredential")) + }) + t.Run("ignores the base VerifiableCredential type", func(t *testing.T) { + config := CredentialConfiguration{ + Format: "jwt_vc_json", + CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential"}}, + } + + assert.False(t, config.MatchesType("VerifiableCredential")) + }) + t.Run("matches vct", func(t *testing.T) { + config := CredentialConfiguration{Format: "vc+sd-jwt", Vct: "UniversityDegreeCredential"} + + assert.True(t, config.MatchesType("UniversityDegreeCredential")) + assert.False(t, config.MatchesType("OtherCredential")) + }) + t.Run("no credential_definition and no vct", func(t *testing.T) { + config := CredentialConfiguration{Format: "jwt_vc_json"} + + assert.False(t, config.MatchesType("UniversityDegreeCredential")) + }) +} diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 254a6cd446..b8633e6c1b 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -150,7 +150,7 @@ paths: schema: required: - issuer - - authorization_details + - credential_type - redirect_uri - wallet_did properties: @@ -164,16 +164,16 @@ paths: 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. example: did:web:issuer.example.com - authorization_details: + credential_type: + type: string 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: - $ref: '#/components/schemas/AuthorizationDetail' + The type of Verifiable Credential to request (e.g. its `credential_definition.type` or `vct` value). + The node resolves this to the issuer's `credential_configuration_id` by matching it against the + issuer's Credential Issuer Metadata (`credential_configurations_supported`, OpenID4VCI 1.0 §12.2), + and picks the authorization-stage flow (RFC 9396 `authorization_details` vs. `credential_configuration_id` + at the Credential Request) from the Authorization Server metadata. The current implementation + processes a single credential issuance per call. + example: HealthcareProviderRoleTypeCredential credential_request_params: type: object additionalProperties: true @@ -759,31 +759,6 @@ 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 71b7dcbb6c..e8a53aa7ef 100644 --- a/docs/pages/deployment/oauth.rst +++ b/docs/pages/deployment/oauth.rst @@ -54,14 +54,23 @@ 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). +On behalf of a user, the node requests a Verifiable Credential from a remote Credential Issuer over the Authorization Code Flow. +The caller supplies a ``credential_type`` (a VC concept); the node resolves it to the issuer's +``credential_configuration_id`` by matching it against the Credential Issuer Metadata's +``credential_configurations_supported`` (§12.2), and picks the authorization-stage flow from the +Authorization Server metadata, without any caller involvement: + +- Authorization Request: if the Authorization Server advertises ``authorization_details_types_supported`` + containing ``openid_credential``, the request includes ``authorization_details`` of type ``openid_credential`` + with the resolved ``credential_configuration_id`` (RFC 9396 / OpenID4VCI §5.1.1). Otherwise, ``authorization_details`` + is omitted and the resolved ``credential_configuration_id`` is used only at the Credential Request. - 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). +- Token Response with ``credential_identifiers`` per the requested ``credential_configuration_id`` (§6.2 and §3.3.4), + when the Authorization Details flow was used. - 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). +- After the credential is issued, the node verifies its type matches the requested ``credential_type`` before storing it. The relevant API: @@ -71,7 +80,7 @@ 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. +- Multiple credentials per call: ``credential_type`` accepts a single value 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. diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index ac9c1b36cd..d0944010b8 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -12,6 +12,7 @@ Unreleased * #4078: Add the experimental RFC 7523 ``jwt-bearer`` two-VP token request flow, gated behind ``auth.experimental.jwtbearerclient`` (default ``false``, subject to change) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4227 * #4078: Expose the experimental two-VP flow on ``POST /internal/auth/v2/{subjectID}/request-service-access-token`` via the optional ``service_provider_subject_id`` body field by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4228 * #4233: ``request-credential`` API gains an optional ``credential_request_params`` JSON object overlaid on top of the OpenID4VCI Credential Request body sent to the issuer. Lets the wallet talk to issuers that accept additional fields, or to override the credential request entirely. +* #4372: ``request-credential`` API replaces ``authorization_details`` with a ``credential_type`` field; the node resolves ``credential_configuration_id`` from the issuer's metadata and picks the authorization-stage flow (RFC 9396 ``authorization_details`` vs. ``credential_configuration_id`` at the Credential Request) from the Authorization Server metadata. Breaking change to this experimental API. **************** Peanut (v6.2.8) diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 8eb4c6a337..6d50a2ec30 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -20,11 +20,6 @@ const ( JwtBearerAuthScopes = "jwtBearerAuth.Scopes" ) -// Defines values for AuthorizationDetailType. -const ( - OpenidCredential AuthorizationDetailType = "openid_credential" -) - // Defines values for ServiceAccessTokenRequestTokenType. const ( ServiceAccessTokenRequestTokenTypeBearer ServiceAccessTokenRequestTokenType = "Bearer" @@ -37,26 +32,6 @@ 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. @@ -250,11 +225,6 @@ type Cnf struct { // RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. type RequestOpenid4VCICredentialIssuanceJSONBody struct { - // 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"` - // AuthorizationRequestParams Optional key/value pairs added to the OpenID4VCI authorization request (the redirect to the // Authorization Server's authorization_endpoint). These may only add parameters; they must not // override the OpenID4VCI parameters set by the node (the request is rejected if they do). @@ -269,6 +239,14 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // responsible for the resulting wire shape (§8.2 mutual exclusivity, proof binding, etc.). CredentialRequestParams *map[string]interface{} `json:"credential_request_params,omitempty"` + // CredentialType The type of Verifiable Credential to request (e.g. its `credential_definition.type` or `vct` value). + // The node resolves this to the issuer's `credential_configuration_id` by matching it against the + // issuer's Credential Issuer Metadata (`credential_configurations_supported`, OpenID4VCI 1.0 §12.2), + // and picks the authorization-stage flow (RFC 9396 `authorization_details` vs. `credential_configuration_id` + // at the Credential Request) from the Authorization Server metadata. The current implementation + // processes a single credential issuance per call. + CredentialType string `json:"credential_type"` + // 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. Issuer string `json:"issuer"` From cf7dfcb21a2b9a81a9889b26663f151201fbef22 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 14:47:05 +0200 Subject: [PATCH 02/11] docs(auth/iam): simplify credential_type field description Rewrite the OpenAPI description for request-credential's credential_type in plain language with numbered steps, instead of leading with OpenID4VCI/RFC 9396 jargon. Assisted by AI --- auth/api/iam/generated.go | 15 +++++++++------ docs/_static/auth/v2.yaml | 15 +++++++++------ e2e-tests/browser/client/iam/generated.go | 15 +++++++++------ 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index e537b3eb60..7856609f46 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -245,12 +245,15 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // responsible for the resulting wire shape (§8.2 mutual exclusivity, proof binding, etc.). CredentialRequestParams *map[string]interface{} `json:"credential_request_params,omitempty"` - // CredentialType The type of Verifiable Credential to request (e.g. its `credential_definition.type` or `vct` value). - // The node resolves this to the issuer's `credential_configuration_id` by matching it against the - // issuer's Credential Issuer Metadata (`credential_configurations_supported`, OpenID4VCI 1.0 §12.2), - // and picks the authorization-stage flow (RFC 9396 `authorization_details` vs. `credential_configuration_id` - // at the Credential Request) from the Authorization Server metadata. The current implementation - // processes a single credential issuance per call. + // CredentialType The type of Verifiable Credential to request, e.g. "HealthcareProviderRoleTypeCredential". + // The node takes care of the OpenID4VCI details: + // 1. It looks up this type in the issuer's published metadata to find the matching + // credential configuration. + // 2. It checks the Authorization Server's metadata to decide how to ask for that + // credential, since different issuers expect this differently. + // 3. Once the credential is issued, it checks the returned credential actually has + // the requested type before storing it. + // Only one credential can be requested per call. CredentialType string `json:"credential_type"` // Issuer The OAuth Authorization Server's identifier, that issues the Verifiable Credentials, as specified in RFC 8414 (section 2), diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index b8633e6c1b..143a005351 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -167,12 +167,15 @@ paths: credential_type: type: string description: | - The type of Verifiable Credential to request (e.g. its `credential_definition.type` or `vct` value). - The node resolves this to the issuer's `credential_configuration_id` by matching it against the - issuer's Credential Issuer Metadata (`credential_configurations_supported`, OpenID4VCI 1.0 §12.2), - and picks the authorization-stage flow (RFC 9396 `authorization_details` vs. `credential_configuration_id` - at the Credential Request) from the Authorization Server metadata. The current implementation - processes a single credential issuance per call. + The type of Verifiable Credential to request, e.g. "HealthcareProviderRoleTypeCredential". + The node takes care of the OpenID4VCI details: + 1. It looks up this type in the issuer's published metadata to find the matching + credential configuration. + 2. It checks the Authorization Server's metadata to decide how to ask for that + credential, since different issuers expect this differently. + 3. Once the credential is issued, it checks the returned credential actually has + the requested type before storing it. + Only one credential can be requested per call. example: HealthcareProviderRoleTypeCredential credential_request_params: type: object diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 6d50a2ec30..64a275cdaf 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -239,12 +239,15 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // responsible for the resulting wire shape (§8.2 mutual exclusivity, proof binding, etc.). CredentialRequestParams *map[string]interface{} `json:"credential_request_params,omitempty"` - // CredentialType The type of Verifiable Credential to request (e.g. its `credential_definition.type` or `vct` value). - // The node resolves this to the issuer's `credential_configuration_id` by matching it against the - // issuer's Credential Issuer Metadata (`credential_configurations_supported`, OpenID4VCI 1.0 §12.2), - // and picks the authorization-stage flow (RFC 9396 `authorization_details` vs. `credential_configuration_id` - // at the Credential Request) from the Authorization Server metadata. The current implementation - // processes a single credential issuance per call. + // CredentialType The type of Verifiable Credential to request, e.g. "HealthcareProviderRoleTypeCredential". + // The node takes care of the OpenID4VCI details: + // 1. It looks up this type in the issuer's published metadata to find the matching + // credential configuration. + // 2. It checks the Authorization Server's metadata to decide how to ask for that + // credential, since different issuers expect this differently. + // 3. Once the credential is issued, it checks the returned credential actually has + // the requested type before storing it. + // Only one credential can be requested per call. CredentialType string `json:"credential_type"` // Issuer The OAuth Authorization Server's identifier, that issues the Verifiable Credentials, as specified in RFC 8414 (section 2), From ccb75b487205014cb235e27f87b8e24e7d7dd595 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 14:50:44 +0200 Subject: [PATCH 03/11] docs(auth/iam): name the exact metadata fields in credential_type description Reference credential_definition.type/vct, credential_configurations_supported, authorization_details_types_supported, and credential_configuration_id by name in each step, instead of paraphrasing them away. Assisted by AI --- auth/api/iam/generated.go | 15 +++++++++------ docs/_static/auth/v2.yaml | 15 +++++++++------ e2e-tests/browser/client/iam/generated.go | 15 +++++++++------ 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 7856609f46..6d06ad8584 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -247,12 +247,15 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // CredentialType The type of Verifiable Credential to request, e.g. "HealthcareProviderRoleTypeCredential". // The node takes care of the OpenID4VCI details: - // 1. It looks up this type in the issuer's published metadata to find the matching - // credential configuration. - // 2. It checks the Authorization Server's metadata to decide how to ask for that - // credential, since different issuers expect this differently. - // 3. Once the credential is issued, it checks the returned credential actually has - // the requested type before storing it. + // 1. It matches this type against each entry's `credential_definition.type` or `vct` + // in the issuer's `credential_configurations_supported` metadata (§12.2), to find + // the matching `credential_configuration_id`. + // 2. It checks the Authorization Server's `authorization_details_types_supported` + // metadata to decide whether to send that `credential_configuration_id` via an + // `authorization_details` parameter (RFC 9396) on the authorization request, or via + // `credential_configuration_id` on the Credential Request. + // 3. Once the credential is issued, it checks the returned credential's type matches + // what was requested, before storing it. // Only one credential can be requested per call. CredentialType string `json:"credential_type"` diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 143a005351..1161829bc4 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -169,12 +169,15 @@ paths: description: | The type of Verifiable Credential to request, e.g. "HealthcareProviderRoleTypeCredential". The node takes care of the OpenID4VCI details: - 1. It looks up this type in the issuer's published metadata to find the matching - credential configuration. - 2. It checks the Authorization Server's metadata to decide how to ask for that - credential, since different issuers expect this differently. - 3. Once the credential is issued, it checks the returned credential actually has - the requested type before storing it. + 1. It matches this type against each entry's `credential_definition.type` or `vct` + in the issuer's `credential_configurations_supported` metadata (§12.2), to find + the matching `credential_configuration_id`. + 2. It checks the Authorization Server's `authorization_details_types_supported` + metadata to decide whether to send that `credential_configuration_id` via an + `authorization_details` parameter (RFC 9396) on the authorization request, or via + `credential_configuration_id` on the Credential Request. + 3. Once the credential is issued, it checks the returned credential's type matches + what was requested, before storing it. Only one credential can be requested per call. example: HealthcareProviderRoleTypeCredential credential_request_params: diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 64a275cdaf..3aacf1035d 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -241,12 +241,15 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // CredentialType The type of Verifiable Credential to request, e.g. "HealthcareProviderRoleTypeCredential". // The node takes care of the OpenID4VCI details: - // 1. It looks up this type in the issuer's published metadata to find the matching - // credential configuration. - // 2. It checks the Authorization Server's metadata to decide how to ask for that - // credential, since different issuers expect this differently. - // 3. Once the credential is issued, it checks the returned credential actually has - // the requested type before storing it. + // 1. It matches this type against each entry's `credential_definition.type` or `vct` + // in the issuer's `credential_configurations_supported` metadata (§12.2), to find + // the matching `credential_configuration_id`. + // 2. It checks the Authorization Server's `authorization_details_types_supported` + // metadata to decide whether to send that `credential_configuration_id` via an + // `authorization_details` parameter (RFC 9396) on the authorization request, or via + // `credential_configuration_id` on the Credential Request. + // 3. Once the credential is issued, it checks the returned credential's type matches + // what was requested, before storing it. // Only one credential can be requested per call. CredentialType string `json:"credential_type"` From f8d01411bef976f42575babee403e5fea14a79ae Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 14:54:31 +0200 Subject: [PATCH 04/11] fix(auth/iam): clean up resolution error wrapping; docs credit Credential Issuer Metadata Use the existing core.InvalidInputError("%w", err) convention (already used elsewhere in this package) instead of "%s", err.Error(). Also name the Credential Issuer Metadata document explicitly as the source of credential_configurations_supported in the credential_type description. Assisted by AI --- auth/api/iam/generated.go | 7 ++++--- auth/api/iam/openid4vci.go | 2 +- docs/_static/auth/v2.yaml | 7 ++++--- e2e-tests/browser/client/iam/generated.go | 7 ++++--- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 6d06ad8584..a5be7886a5 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -247,9 +247,10 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // CredentialType The type of Verifiable Credential to request, e.g. "HealthcareProviderRoleTypeCredential". // The node takes care of the OpenID4VCI details: - // 1. It matches this type against each entry's `credential_definition.type` or `vct` - // in the issuer's `credential_configurations_supported` metadata (§12.2), to find - // the matching `credential_configuration_id`. + // 1. It reads the issuer's Credential Issuer Metadata (`/.well-known/openid-credential-issuer`, + // §12.2) and matches this type against each entry's `credential_definition.type` or + // `vct` in `credential_configurations_supported`, to find the matching + // `credential_configuration_id`. // 2. It checks the Authorization Server's `authorization_details_types_supported` // metadata to decide whether to send that `credential_configuration_id` via an // `authorization_details` parameter (RFC 9396) on the authorization request, or via diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 434cb8311b..8225eaeac9 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -86,7 +86,7 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques // against the issuer's credential_configurations_supported metadata (§12.2). credentialConfigID, err := credentialIssuerMetadata.ResolveCredentialConfigurationID(credentialType) if err != nil { - return nil, core.InvalidInputError("%s", err.Error()) + return nil, core.InvalidInputError("%w", err) } // Capture optional credential_request_params, used as the base body of the Credential Request later in the flow. var credentialRequestParams map[string]any diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 1161829bc4..6d4f2c9b29 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -169,9 +169,10 @@ paths: description: | The type of Verifiable Credential to request, e.g. "HealthcareProviderRoleTypeCredential". The node takes care of the OpenID4VCI details: - 1. It matches this type against each entry's `credential_definition.type` or `vct` - in the issuer's `credential_configurations_supported` metadata (§12.2), to find - the matching `credential_configuration_id`. + 1. It reads the issuer's Credential Issuer Metadata (`/.well-known/openid-credential-issuer`, + §12.2) and matches this type against each entry's `credential_definition.type` or + `vct` in `credential_configurations_supported`, to find the matching + `credential_configuration_id`. 2. It checks the Authorization Server's `authorization_details_types_supported` metadata to decide whether to send that `credential_configuration_id` via an `authorization_details` parameter (RFC 9396) on the authorization request, or via diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 3aacf1035d..4b21b8eacd 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -241,9 +241,10 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // CredentialType The type of Verifiable Credential to request, e.g. "HealthcareProviderRoleTypeCredential". // The node takes care of the OpenID4VCI details: - // 1. It matches this type against each entry's `credential_definition.type` or `vct` - // in the issuer's `credential_configurations_supported` metadata (§12.2), to find - // the matching `credential_configuration_id`. + // 1. It reads the issuer's Credential Issuer Metadata (`/.well-known/openid-credential-issuer`, + // §12.2) and matches this type against each entry's `credential_definition.type` or + // `vct` in `credential_configurations_supported`, to find the matching + // `credential_configuration_id`. // 2. It checks the Authorization Server's `authorization_details_types_supported` // metadata to decide whether to send that `credential_configuration_id` via an // `authorization_details` parameter (RFC 9396) on the authorization request, or via From 11970e27950c16aa2976f075ee44b818c3acc538 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 14:58:05 +0200 Subject: [PATCH 05/11] refactor(auth/openid4vci): dedup unsupported formats via maps.Keys Replace the manual seen-map + append loop with a set collected via slices.Collect(maps.Keys(...)). Assisted by AI --- auth/openid4vci/types.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/auth/openid4vci/types.go b/auth/openid4vci/types.go index cc6cf15369..540b955e79 100644 --- a/auth/openid4vci/types.go +++ b/auth/openid4vci/types.go @@ -33,6 +33,7 @@ package openid4vci import ( "encoding/json" "fmt" + "maps" "slices" "sort" "strings" @@ -146,13 +147,10 @@ func (m OpenIDCredentialIssuerMetadata) ResolveCredentialConfigurationID(credent } if len(supportedMatches) == 0 { seenFormats := make(map[string]bool, len(matches)) - unsupportedFormats := make([]string, 0, len(matches)) for _, c := range matches { - if !seenFormats[c.format] { - seenFormats[c.format] = true - unsupportedFormats = append(unsupportedFormats, c.format) - } + seenFormats[c.format] = true } + unsupportedFormats := slices.Collect(maps.Keys(seenFormats)) sort.Strings(unsupportedFormats) return "", fmt.Errorf("issuer offers %q only in format(s): %s", credentialType, strings.Join(unsupportedFormats, ", ")) } From 251546c920700a5a15a1915a0d63b52e70ae0af0 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 14:59:42 +0200 Subject: [PATCH 06/11] refactor(auth/openid4vci): dedup unsupported formats via sort+slices.Compact Simpler than building a set map: extract formats into a slice, sort, then slices.Compact removes the now-adjacent duplicates in one call. Assisted by AI --- auth/openid4vci/types.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/auth/openid4vci/types.go b/auth/openid4vci/types.go index 540b955e79..87c0a5ad37 100644 --- a/auth/openid4vci/types.go +++ b/auth/openid4vci/types.go @@ -33,7 +33,6 @@ package openid4vci import ( "encoding/json" "fmt" - "maps" "slices" "sort" "strings" @@ -146,12 +145,12 @@ func (m OpenIDCredentialIssuerMetadata) ResolveCredentialConfigurationID(credent } } if len(supportedMatches) == 0 { - seenFormats := make(map[string]bool, len(matches)) - for _, c := range matches { - seenFormats[c.format] = true + unsupportedFormats := make([]string, len(matches)) + for i, c := range matches { + unsupportedFormats[i] = c.format } - unsupportedFormats := slices.Collect(maps.Keys(seenFormats)) sort.Strings(unsupportedFormats) + unsupportedFormats = slices.Compact(unsupportedFormats) return "", fmt.Errorf("issuer offers %q only in format(s): %s", credentialType, strings.Join(unsupportedFormats, ", ")) } sort.Slice(supportedMatches, func(i, j int) bool { return supportedMatches[i].id < supportedMatches[j].id }) From 43cfb6b7d8ff72fcdb73df3548ac50285d2fbc72 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 15:01:17 +0200 Subject: [PATCH 07/11] refactor(auth/openid4vci): merge into a single pass over CredentialConfigurationsSupported Collect matched formats and supported IDs while iterating the map once, instead of a first pass to build a candidate list and a second pass to split it into supported/unsupported. Drops the now-unneeded candidate struct entirely. Assisted by AI --- auth/openid4vci/types.go | 41 ++++++++++++++++------------------------ 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/auth/openid4vci/types.go b/auth/openid4vci/types.go index 87c0a5ad37..e9616efba0 100644 --- a/auth/openid4vci/types.go +++ b/auth/openid4vci/types.go @@ -124,37 +124,28 @@ func (m OpenIDCredentialIssuerMetadata) WellKnownPath() string { // - 1+ matches in a supported format: candidate IDs are sorted so the pick is deterministic // (never Go map order); the smallest ID wins. func (m OpenIDCredentialIssuerMetadata) ResolveCredentialConfigurationID(credentialType string) (string, error) { - type candidate struct { - id string - format string - } - var matches []candidate + supportedFormats := oauth.DefaultOpenIDSupportedFormats() + var matchedFormats []string + var supportedIDs []string for id, config := range m.CredentialConfigurationsSupported { - if config.MatchesType(credentialType) { - matches = append(matches, candidate{id: id, format: config.Format}) + if !config.MatchesType(credentialType) { + continue + } + matchedFormats = append(matchedFormats, config.Format) + if _, ok := supportedFormats[config.Format]; ok { + supportedIDs = append(supportedIDs, id) } } - if len(matches) == 0 { + if len(matchedFormats) == 0 { return "", fmt.Errorf("issuer does not offer a credential of type %q", credentialType) } - supportedFormats := oauth.DefaultOpenIDSupportedFormats() - var supportedMatches []candidate - for _, c := range matches { - if _, ok := supportedFormats[c.format]; ok { - supportedMatches = append(supportedMatches, c) - } - } - if len(supportedMatches) == 0 { - unsupportedFormats := make([]string, len(matches)) - for i, c := range matches { - unsupportedFormats[i] = c.format - } - sort.Strings(unsupportedFormats) - unsupportedFormats = slices.Compact(unsupportedFormats) - return "", fmt.Errorf("issuer offers %q only in format(s): %s", credentialType, strings.Join(unsupportedFormats, ", ")) + if len(supportedIDs) == 0 { + sort.Strings(matchedFormats) + matchedFormats = slices.Compact(matchedFormats) + return "", fmt.Errorf("issuer offers %q only in format(s): %s", credentialType, strings.Join(matchedFormats, ", ")) } - sort.Slice(supportedMatches, func(i, j int) bool { return supportedMatches[i].id < supportedMatches[j].id }) - return supportedMatches[0].id, nil + sort.Strings(supportedIDs) + return supportedIDs[0], nil } // NonceResponse is the body returned by the Nonce Endpoint (Section 7.2). From 6d1f887235827246c79818e33b7d3c6b09f3a12d Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 15:08:54 +0200 Subject: [PATCH 08/11] refactor(auth/openid4vci): reject base VerifiableCredential type once, in the resolver Move the "VerifiableCredential" guard out of CredentialConfiguration.MatchesType (called once per candidate) into ResolveCredentialConfigurationID, checked once up front. MatchesType is now a plain membership check. Assisted by AI --- auth/openid4vci/types.go | 18 +++++++++++------- auth/openid4vci/types_test.go | 23 +++++++++++++++++++++-- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/auth/openid4vci/types.go b/auth/openid4vci/types.go index e9616efba0..c8ff52b77f 100644 --- a/auth/openid4vci/types.go +++ b/auth/openid4vci/types.go @@ -73,14 +73,13 @@ type CredentialConfiguration struct { // MatchesType checks whether this credential_configurations_supported entry is of the given // credential type, using the type-locating field per format (§12.2): credential_definition.type -// (jwt_vc_json, ldp_vc; ignoring the base "VerifiableCredential" entry) or vct (vc+sd-jwt, -// dc+sd-jwt). vct is still matched here, even though the node doesn't support those formats -// (oauth.DefaultOpenIDSupportedFormats), so such entries produce the more useful "only offered in -// unsupported format(s)" error instead of "does not offer this type at all". +// (jwt_vc_json, ldp_vc) or vct (vc+sd-jwt, dc+sd-jwt). vct is still matched here, even though the +// node doesn't support those formats (oauth.DefaultOpenIDSupportedFormats), so such entries +// produce the more useful "only offered in unsupported format(s)" error instead of "does not offer +// this type at all". Callers resolving a caller-supplied credential_type should reject the base +// "VerifiableCredential" type themselves (see ResolveCredentialConfigurationID) — every entry's +// credential_definition.type contains it, so it is not a useful match here. func (c CredentialConfiguration) MatchesType(credentialType string) bool { - if credentialType == "VerifiableCredential" { - return false - } if c.CredentialDefinition != nil && slices.Contains(c.CredentialDefinition.Type, credentialType) { return true } @@ -118,12 +117,17 @@ func (m OpenIDCredentialIssuerMetadata) WellKnownPath() string { // Matching is done on the type-locating field, not the map key: the spec lets // credential_configuration_id be an arbitrary issuer-chosen string. // +// - credentialType is the base "VerifiableCredential" type: rejected upfront, since every +// credential_definition.type array contains it and it does not identify a specific credential. // - 0 matches: the issuer does not offer this credential type at all. // - matches only in formats the node does not support (oauth.DefaultOpenIDSupportedFormats): the // type exists, but the node cannot request it. // - 1+ matches in a supported format: candidate IDs are sorted so the pick is deterministic // (never Go map order); the smallest ID wins. func (m OpenIDCredentialIssuerMetadata) ResolveCredentialConfigurationID(credentialType string) (string, error) { + if credentialType == "VerifiableCredential" { + return "", fmt.Errorf("issuer does not offer a credential of type %q", credentialType) + } supportedFormats := oauth.DefaultOpenIDSupportedFormats() var matchedFormats []string var supportedIDs []string diff --git a/auth/openid4vci/types_test.go b/auth/openid4vci/types_test.go index 2a3619bfa5..1b6fcfc64c 100644 --- a/auth/openid4vci/types_test.go +++ b/auth/openid4vci/types_test.go @@ -118,6 +118,22 @@ func TestOpenIDCredentialIssuerMetadata_ResolveCredentialConfigurationID(t *test assert.EqualError(t, err, `issuer offers "SomeCredential" only in format(s): mso_mdoc, vc+sd-jwt`) }) + t.Run("error - rejects the base VerifiableCredential type upfront", func(t *testing.T) { + metadata := OpenIDCredentialIssuerMetadata{ + CredentialConfigurationsSupported: map[string]CredentialConfiguration{ + "UniversityDegreeCredential_jwt_vc_json": { + Format: "jwt_vc_json", + CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential", "UniversityDegreeCredential"}}, + }, + }, + } + + // Every credential_definition.type array contains "VerifiableCredential"; without this + // guard it would resolve to an arbitrary, unrelated credential_configuration_id. + _, err := metadata.ResolveCredentialConfigurationID("VerifiableCredential") + + assert.EqualError(t, err, `issuer does not offer a credential of type "VerifiableCredential"`) + }) } func TestCredentialConfiguration_MatchesType(t *testing.T) { @@ -130,13 +146,16 @@ func TestCredentialConfiguration_MatchesType(t *testing.T) { assert.True(t, config.MatchesType("UniversityDegreeCredential")) assert.False(t, config.MatchesType("OtherCredential")) }) - t.Run("ignores the base VerifiableCredential type", func(t *testing.T) { + t.Run("matches the base VerifiableCredential type like any other entry", func(t *testing.T) { + // Excluding "VerifiableCredential" as a resolvable type is the caller's + // responsibility (see ResolveCredentialConfigurationID); MatchesType itself does a + // plain membership check. config := CredentialConfiguration{ Format: "jwt_vc_json", CredentialDefinition: &CredentialDefinition{Type: []string{"VerifiableCredential"}}, } - assert.False(t, config.MatchesType("VerifiableCredential")) + assert.True(t, config.MatchesType("VerifiableCredential")) }) t.Run("matches vct", func(t *testing.T) { config := CredentialConfiguration{Format: "vc+sd-jwt", Vct: "UniversityDegreeCredential"} From 9214cc14810f57ca1bf7891ed83680a3041b3a5b Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 15:09:02 +0200 Subject: [PATCH 09/11] refactor(auth): inline single-use SupportsAuthorizationDetailsType; rename IssuerCredentialType - Drop AuthorizationServerMetadata.SupportsAuthorizationDetailsType: it had one call site, so inline it as slices.Contains at that call site instead. - Rename OAuthSession.IssuerCredentialType to RequestedCredentialType: it holds the credential_type the caller requested, not a property of the issuer, so the Issuer* prefix was misleading next to the other Issuer* session fields. Assisted by AI --- auth/api/iam/openid4vci.go | 9 +++++---- auth/api/iam/openid4vci_test.go | 4 ++-- auth/api/iam/session.go | 6 +++--- auth/oauth/types.go | 7 ------- 4 files changed, 10 insertions(+), 16 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 8225eaeac9..9a200a0522 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -25,6 +25,7 @@ import ( "fmt" "net/http" "net/url" + "slices" "time" "github.com/lestrrat-go/jwx/v2/jwt" @@ -113,7 +114,7 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, IssuerNonceEndpoint: credentialIssuerMetadata.NonceEndpoint, IssuerCredentialConfigurationID: credentialConfigID, - IssuerCredentialType: credentialType, + RequestedCredentialType: credentialType, IssuerCredentialIssuer: credentialIssuerMetadata.CredentialIssuer, CredentialRequestParams: credentialRequestParams, }) @@ -138,7 +139,7 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques // openid_credential authorization_details type, use the Authorization Details flow. Otherwise, // omit authorization_details entirely (Credential Configuration ID flow) — the resolved // credential_configuration_id is identified later, at the Credential Request (§8.2). - if authzServerMetadata.SupportsAuthorizationDetailsType(openid4vci.AuthorizationDetailsTypeOpenIDCredential) { + if slices.Contains(authzServerMetadata.AuthorizationDetailsTypesSupported, openid4vci.AuthorizationDetailsTypeOpenIDCredential) { authorizationDetails, _ := json.Marshal([]openid4vci.AuthorizationDetail{{ Type: openid4vci.AuthorizationDetailsTypeOpenIDCredential, CredentialConfigurationID: credentialConfigID, @@ -241,8 +242,8 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode } // Guard against an issuer returning a credential of a different type than requested, before // it is stored in the wallet. - if !credential.IsType(ssi.MustParseURI(oauthSession.IssuerCredentialType)) { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("issued credential does not have the requested type %q", oauthSession.IssuerCredentialType)), appCallbackURI) + if !credential.IsType(ssi.MustParseURI(oauthSession.RequestedCredentialType)) { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("issued credential does not have the requested type %q", oauthSession.RequestedCredentialType)), appCallbackURI) } err = r.vcr.Wallet().Put(ctx, *credential) if err != nil { diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 421713977d..e85f2c99f3 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -344,7 +344,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { IssuerCredentialEndpoint: credEndpoint, IssuerNonceEndpoint: nonceEndpoint, IssuerCredentialConfigurationID: credentialConfigID, - IssuerCredentialType: "ExampleType", + RequestedCredentialType: "ExampleType", IssuerCredentialIssuer: issuerClientID, } sessionWithoutNonce := session @@ -655,7 +655,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("error - issued credential type does not match requested credential_type", func(t *testing.T) { ctx := newTestClient(t) sessionWithDifferentType := session - sessionWithDifferentType.IssuerCredentialType = "SomeOtherCredentialType" + sessionWithDifferentType.RequestedCredentialType = "SomeOtherCredentialType" 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) diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index ccf81b9d69..a63ab73f5d 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -59,9 +59,9 @@ type OAuthSession struct { 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"` - // IssuerCredentialType is the credential_type requested by the caller, used to verify the issued - // credential's type before it is stored in the wallet. - IssuerCredentialType string `json:"issuer_credential_type,omitempty"` + // RequestedCredentialType is the credential_type requested by the caller, used to verify the + // issued credential's type before it is stored in the wallet. + RequestedCredentialType string `json:"requested_credential_type,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 diff --git a/auth/oauth/types.go b/auth/oauth/types.go index 573375ad4e..d5d4740c49 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -22,7 +22,6 @@ package oauth import ( "encoding/json" "net/url" - "slices" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/nuts-foundation/nuts-node/core" @@ -368,12 +367,6 @@ func (m AuthorizationServerMetadata) SupportsClientIDScheme(scheme string) bool return false } -// SupportsAuthorizationDetailsType checks if the Authorization Server supports the given -// authorization_details type (RFC 9396). -func (m AuthorizationServerMetadata) SupportsAuthorizationDetailsType(t string) bool { - return slices.Contains(m.AuthorizationDetailsTypesSupported, t) -} - // OAuthClientMetadata defines the OAuth Client metadata. // Specified by https://www.rfc-editor.org/rfc/rfc7591.html and elsewhere. type OAuthClientMetadata struct { From 114e8df63e22d8eecbc85503b05093aebfa53fd6 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 15:14:50 +0200 Subject: [PATCH 10/11] fix(auth/iam): extractCredentialIdentifier falls back on irrelevant authorization_details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only error when the Token Response has an openid_credential authorization_details entry that doesn't match the requested credential_configuration_id — a genuine mismatch between what was requested and what the AS granted. When the Token Response carries authorization_details but none of type openid_credential (the Credential Configuration ID flow never asked for one), fall back to credential_configuration_id at the Credential Request instead of erroring. Assisted by AI --- auth/api/iam/openid4vci.go | 17 ++++++++++++++--- auth/api/iam/openid4vci_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 9a200a0522..242be81437 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -276,9 +276,15 @@ func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *O // 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). +// to credential_configuration_id is not allowed. Returns ("", nil) when the +// Token Response carries no entry of type openid_credential at all — either +// because authorization_details is absent entirely, or because it only +// carries entries unrelated to credential issuance (the Credential +// Configuration ID flow, which never requests authorization_details of type +// openid_credential, is expected to hit this path). It is only an error when +// an openid_credential entry is present but for a different +// credential_configuration_id than requested — that is a genuine mismatch +// between what was requested and what the AS granted. func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse, credentialConfigurationID string) (string, error) { raw, ok := tokenResponse.GetAny(oauth.AuthorizationDetailsParam) if !ok { @@ -296,10 +302,12 @@ func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse, credentialC if err := json.Unmarshal(bytes, &details); err != nil { return "", fmt.Errorf("token response authorization_details malformed: %w", err) } + sawOpenIDCredential := false for _, d := range details { if d.Type != openid4vci.AuthorizationDetailsTypeOpenIDCredential { continue } + sawOpenIDCredential = true if d.CredentialConfigurationID != credentialConfigurationID { continue } @@ -308,6 +316,9 @@ func extractCredentialIdentifier(tokenResponse *oauth.TokenResponse, credentialC } return d.CredentialIdentifiers[0], nil } + if !sawOpenIDCredential { + return "", nil + } return "", fmt.Errorf("token response authorization_details has no entry for credential_configuration_id %q", credentialConfigurationID) } diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index e85f2c99f3..ab9e413677 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -542,6 +542,30 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "credential_identifiers") }) + t.Run("ok - falls back to credential_configuration_id when authorization_details has no openid_credential entry", func(t *testing.T) { + ctx := newTestClient(t) + // Credential Configuration ID flow: the wallet never requested openid_credential + // authorization_details, but the AS may still return an authorization_details array for + // unrelated purposes. That must not be treated as "no entry for my + // credential_configuration_id" — it should fall back to credential_configuration_id, same + // as when authorization_details is absent entirely. + tokenResponseWithUnrelatedDetails := (&oauth.TokenResponse{AccessToken: accessToken, TokenType: "Bearer"}). + With(oauth.AuthorizationDetailsParam, []map[string]interface{}{{ + "type": "some_other_type", + }}) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponseWithUnrelatedDetails, 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(&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 - 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) From be91a20a0d65f8df69e24f72a1fc1e2ce545adb1 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 15:19:04 +0200 Subject: [PATCH 11/11] fix(auth/iam): requestCredentialWithProof sets exactly one of credential_identifier/credential_configuration_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per §8.2, credential_identifier and credential_configuration_id are mutually exclusive: one MUST be present and the other MUST NOT be. The call site previously always passed both to RequestCredentialOpts, relying on client.RequestCredential's precedence switch to silently drop the redundant one before serialization. Have the call site decide instead, based on whether extractCredentialIdentifier found a credential_identifier. Assisted by AI --- auth/api/iam/openid4vci.go | 24 ++++++++++++++++-------- auth/api/iam/openid4vci_test.go | 12 +++++++----- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 242be81437..ea8796a7b3 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -261,14 +261,22 @@ func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *O 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, - CredentialRequestParams: oauthSession.CredentialRequestParams, - }) + opts := openid4vci.RequestCredentialOpts{ + CredentialEndpoint: oauthSession.IssuerCredentialEndpoint, + AccessToken: accessToken, + ProofJWT: proofJWT, + CredentialRequestParams: oauthSession.CredentialRequestParams, + } + // Per §8.2, credential_identifier and credential_configuration_id are mutually exclusive: + // exactly one MUST be present. Use credential_identifier when extractCredentialIdentifier + // found one in the Token Response (Authorization Details flow); otherwise identify the + // credential via the resolved credential_configuration_id (Credential Configuration ID flow). + if credentialIdentifier != "" { + opts.CredentialIdentifier = credentialIdentifier + } else { + opts.CredentialConfigurationID = oauthSession.IssuerCredentialConfigurationID + } + return r.auth.OpenID4VCIClient().RequestCredential(ctx, opts) } // extractCredentialIdentifier reads authorization_details from the Token diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index ab9e413677..8338f2d99d 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -509,12 +509,14 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { 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) + // Per §8.2, credential_identifier and credential_configuration_id are mutually + // exclusive: credential_configuration_id MUST NOT be present when + // credential_identifier is used. ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{ - CredentialEndpoint: credEndpoint, - AccessToken: accessToken, - CredentialConfigurationID: credentialConfigID, - CredentialIdentifier: "CivilEngineeringDegree-2023", - ProofJWT: "signed-proof", + CredentialEndpoint: credEndpoint, + AccessToken: accessToken, + CredentialIdentifier: "CivilEngineeringDegree-2023", + ProofJWT: "signed-proof", }).Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(nil, *verifiableCredential)