From 533c6b1a87f27a3ad75dbb373d15bd3cc3eae2ff Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Wed, 10 Jun 2026 07:49:34 +0200 Subject: [PATCH 1/3] feat(auth): configurable OpenID4VCI request profiles with built-in AET profile Add named request profiles selected via a new 'profile' field on the requestCredential API. A profile (auth.experimental.profile..authrequest, map[string][]string) sets authorization request parameters. The built-in 'aet' profile (AET UZI smartcard issuer) ships via DefaultConfig with auth_method=SmartCard and scope='openid profile api'; operator config of the same name is layered over it. Apply order on the authorization request: node parameters -> profile authrequest (trusted config, may override node parameters, multi-valued) -> authorization_request_params (may only add; still cannot override node parameters). Unknown profile is rejected with 400. Also adds .claude/worktrees/ to .gitignore. Implements #4338. Assisted by AI --- .gitignore | 1 + auth/api/auth/v1/api_test.go | 4 ++ auth/api/iam/api_test.go | 10 ++++- auth/api/iam/generated.go | 7 +++ auth/api/iam/openid4vci.go | 28 +++++++++--- auth/api/iam/openid4vci_test.go | 30 +++++++++++++ auth/auth.go | 10 +++++ auth/config.go | 27 +++++++++++ auth/interface.go | 3 ++ auth/mock.go | 15 +++++++ auth/profiles_test.go | 55 +++++++++++++++++++++++ docs/_static/auth/v2.yaml | 9 ++++ e2e-tests/browser/client/iam/generated.go | 7 +++ 13 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 auth/profiles_test.go diff --git a/.gitignore b/.gitignore index 07ee438fdc..d80483f4d7 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ nuts.yaml # Local scratch directory and Claude Code session state .scratch/ .claude/scheduled_tasks.lock +.claude/worktrees/ diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go index a4970f89ad..fc8d9c0e24 100644 --- a/auth/api/auth/v1/api_test.go +++ b/auth/api/auth/v1/api_test.go @@ -91,6 +91,10 @@ func (m *mockAuthClient) OpenID4VCIClient() openid4vci.Client { return nil } +func (m *mockAuthClient) AuthorizationRequestProfile(_ string) (map[string][]string, bool) { + return nil, false +} + func (m *mockAuthClient) ContractNotary() services.ContractNotary { return m.contractNotary } diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index b3af7d5e17..6a923fb6e9 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -1649,6 +1649,8 @@ type testCtx struct { subjectManager *didsubject.MockManager jar *MockJAR openid4vciClient *openid4vci.MockClient + // authorizationRequestProfiles maps profile name to its authrequest params, returned by the auth mock's AuthorizationRequestProfile. + authorizationRequestProfiles map[string]map[string][]string } func newTestClient(t testing.TB) *testCtx { @@ -1704,7 +1706,7 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b jwtSigner: jwtSigner, jar: mockJAR, } - return &testCtx{ + result := &testCtx{ ctrl: ctrl, authnServices: authnServices, policy: policyInstance, @@ -1724,4 +1726,10 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b client: client, openid4vciClient: openid4vciClient, } + // By default no request profiles exist. A test can set result.authorizationRequestProfiles to exercise the profile path. + authnServices.EXPECT().AuthorizationRequestProfile(gomock.Any()).DoAndReturn(func(name string) (map[string][]string, bool) { + profile, ok := result.authorizationRequestProfiles[name] + return profile, ok + }).AnyTimes() + return result } diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 70dba49398..d2eb1aff09 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -279,6 +279,13 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // used to locate the OAuth2 Authorization Server metadata. Issuer string `json:"issuer"` + // Profile Optional name of a request profile to apply to this flow. A profile is a curated, named bundle of + // request parameters, so callers don't have to spell out issuer-specific parameters themselves. + // Currently a profile sets authorization request parameters (see auth.experimental.profile..authrequest). + // Built-in: 'aet' for the AET UZI smartcard credential issuer. An unknown profile is rejected. + // EXPERIMENTAL: subject to change without notice. + Profile *string `json:"profile,omitempty"` + // RedirectUri The URL to which the user-agent will be redirected after the authorization request. RedirectUri string `json:"redirect_uri"` diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index ec4b187370..63cdbdc44a 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -34,7 +34,6 @@ import ( "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" - nutsHttp "github.com/nuts-foundation/nuts-node/http" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -138,21 +137,40 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques oauth.CodeChallengeParam: pkceParams.Challenge, oauth.CodeChallengeMethodParam: pkceParams.ChallengeMethod, } + // Record the node-controlled parameters and seed the query. The raw authorization_request_params below may + // not override these; a request profile may (it is trusted built-in/operator config). + nodeParamKeys := make(map[string]struct{}, len(authzParams)) + authzQuery := authorizationEndpoint.Query() + for key, value := range authzParams { + nodeParamKeys[key] = struct{}{} + authzQuery.Set(key, value) + } + // EXPERIMENTAL: apply the selected request profile (built-in default merged with operator config). Profiles are + // trusted config and may override node parameters; their authorization request parameters may be multi-valued. + if request.Body.Profile != nil && *request.Body.Profile != "" { + profileParams, ok := r.auth.AuthorizationRequestProfile(*request.Body.Profile) + if !ok { + return nil, core.InvalidInputError("unknown profile: %s", *request.Body.Profile) + } + for key, values := range profileParams { + authzQuery[key] = append([]string(nil), values...) + } + } // 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 // OpenID4VCI parameters set by the node above, which are essential to the flow. if request.Body.AuthorizationRequestParams != nil { for key, value := range *request.Body.AuthorizationRequestParams { - if _, isNodeParam := authzParams[key]; isNodeParam { + if _, isNodeParam := nodeParamKeys[key]; isNodeParam { return nil, core.InvalidInputError("authorization_request_params may not override the '%s' parameter set by the node", key) } - authzParams[key] = value + authzQuery.Set(key, value) } } - redirectUrl := nutsHttp.AddQueryParams(*authorizationEndpoint, authzParams) + authorizationEndpoint.RawQuery = authzQuery.Encode() return RequestOpenid4VCICredentialIssuance200JSONResponse{ - RedirectURI: redirectUrl.String(), + RedirectURI: authorizationEndpoint.String(), }, nil } diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 50803d4065..49024b049a 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -103,6 +103,36 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { assert.ErrorContains(t, err, "authorization_request_params may not override the 'client_id' parameter") }) + t.Run("ok - profile applies its authorization request parameters", func(t *testing.T) { + ctx := newTestClient(t) + ctx.authorizationRequestProfiles = map[string]map[string][]string{ + "aet": {"auth_method": {"SmartCard"}, "scope": {"openid profile api"}}, + } + 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.Profile = to.Ptr("aet") + + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) + + require.NoError(t, err) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.Equal(t, "SmartCard", redirectUri.Query().Get("auth_method")) + assert.Equal(t, "openid profile api", redirectUri.Query().Get("scope")) + assert.Equal(t, "code", redirectUri.Query().Get("response_type")) // node parameters remain intact + }) + t.Run("error - unknown profile", 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.Profile = to.Ptr("does-not-exist") + + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) + + assert.ErrorContains(t, err, "unknown profile: does-not-exist") + }) t.Run("ok - credential_request_params persisted into session", func(t *testing.T) { ctx := newTestClient(t) ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) diff --git a/auth/auth.go b/auth/auth.go index c64235dfcb..aa24c66c2f 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -150,6 +150,16 @@ func (auth *Auth) OpenID4VCIClient() openid4vci.Client { return auth.openID4VCIClient } +// AuthorizationRequestProfile returns the authorization request parameters of the named profile, or false when no +// profile (built-in default or operator-configured) exists for the name. EXPERIMENTAL. +func (auth *Auth) AuthorizationRequestProfile(name string) (map[string][]string, bool) { + profile, ok := auth.config.Experimental.Profiles[name] + if !ok { + return nil, false + } + return profile.AuthorizationRequest, true +} + // Configure the Auth struct by creating a validator and create an Irma server func (auth *Auth) Configure(config core.ServerConfig) error { if auth.config.Irma.SchemeManager == "" { diff --git a/auth/config.go b/auth/config.go index 78cf9ea95f..6f81ab6fd0 100644 --- a/auth/config.go +++ b/auth/config.go @@ -41,6 +41,21 @@ type ExperimentalConfig struct { // JwtBearerClient enables the RFC 7523 jwt-bearer two-VP token request flow. // While disabled (the default), requests carrying a service-provider subject identifier are rejected. JwtBearerClient bool `koanf:"jwtbearerclient"` + // Profiles are named bundles of request parameters for outbound flows against external issuers/authorization + // servers. A request selects a profile by name. Built-in profiles (e.g. "aet") are provided via DefaultConfig; + // operator config under the same name is layered over the default. + // + // EXPERIMENTAL: this configuration may change or be removed without further notice. + Profiles map[string]ProfileConfig `koanf:"profile"` +} + +// ProfileConfig is a named bundle of request parameters for outbound OpenID4VCI flows. +// +// EXPERIMENTAL: this configuration may change or be removed without further notice. +type ProfileConfig struct { + // AuthorizationRequest sets parameters on the OpenID4VCI authorization request. Each key may have multiple + // values (rendered as repeated query parameters; a single value is rendered as one parameter). + AuthorizationRequest map[string][]string `koanf:"authrequest"` } type AuthorizationEndpointConfig struct { @@ -78,5 +93,17 @@ func DefaultConfig() Config { selfsigned.ContractFormat, }, AccessTokenLifeSpan: 60, // seconds, as specced in RFC003 + Experimental: ExperimentalConfig{ + // Built-in profiles. Operator config under auth.experimental.profile. is layered over these. + Profiles: map[string]ProfileConfig{ + // aet targets the AET ZORG-ID issuer, which requires smartcard auth and a specific scope on the authorization request. + "aet": { + AuthorizationRequest: map[string][]string{ + "auth_method": {"SmartCard"}, + "scope": {"openid profile api"}, + }, + }, + }, + }, } } diff --git a/auth/interface.go b/auth/interface.go index 894911652a..148014f45b 100644 --- a/auth/interface.go +++ b/auth/interface.go @@ -37,6 +37,9 @@ type AuthenticationServices interface { IAMClient() iam.Client // OpenID4VCIClient returns the OpenID4VCI 1.0 HTTP client. OpenID4VCIClient() openid4vci.Client + // AuthorizationRequestProfile returns the authorization request parameters of the named profile (built-in + // merged with operator config), or false if the name is unknown. EXPERIMENTAL. + AuthorizationRequestProfile(name string) (map[string][]string, bool) // RelyingParty returns the oauth.RelyingParty RelyingParty() oauth.RelyingParty // ContractNotary returns an instance of ContractNotary diff --git a/auth/mock.go b/auth/mock.go index a04365018a..4c2d58dd72 100644 --- a/auth/mock.go +++ b/auth/mock.go @@ -58,6 +58,21 @@ func (mr *MockAuthenticationServicesMockRecorder) AuthorizationEndpointEnabled() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthorizationEndpointEnabled", reflect.TypeOf((*MockAuthenticationServices)(nil).AuthorizationEndpointEnabled)) } +// AuthorizationRequestProfile mocks base method. +func (m *MockAuthenticationServices) AuthorizationRequestProfile(name string) (map[string][]string, bool) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AuthorizationRequestProfile", name) + ret0, _ := ret[0].(map[string][]string) + ret1, _ := ret[1].(bool) + return ret0, ret1 +} + +// AuthorizationRequestProfile indicates an expected call of AuthorizationRequestProfile. +func (mr *MockAuthenticationServicesMockRecorder) AuthorizationRequestProfile(name any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthorizationRequestProfile", reflect.TypeOf((*MockAuthenticationServices)(nil).AuthorizationRequestProfile), name) +} + // AuthzServer mocks base method. func (m *MockAuthenticationServices) AuthzServer() oauth.AuthorizationServer { m.ctrl.T.Helper() diff --git a/auth/profiles_test.go b/auth/profiles_test.go new file mode 100644 index 0000000000..fb3e2e783e --- /dev/null +++ b/auth/profiles_test.go @@ -0,0 +1,55 @@ +/* + * 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 auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuth_AuthorizationRequestProfile(t *testing.T) { + t.Run("built-in aet (from DefaultConfig)", func(t *testing.T) { + a := &Auth{config: DefaultConfig()} + params, ok := a.AuthorizationRequestProfile("aet") + require.True(t, ok) + assert.Equal(t, []string{"SmartCard"}, params["auth_method"]) + assert.Equal(t, []string{"openid profile api"}, params["scope"]) + }) + t.Run("unknown profile returns false", func(t *testing.T) { + a := &Auth{config: DefaultConfig()} + params, ok := a.AuthorizationRequestProfile("does-not-exist") + assert.False(t, ok) + assert.Nil(t, params) + }) + t.Run("operator-configured profile", func(t *testing.T) { + a := &Auth{config: Config{Experimental: ExperimentalConfig{Profiles: map[string]ProfileConfig{ + "custom": {AuthorizationRequest: map[string][]string{"foo": {"bar", "baz"}}}, + }}}} + params, ok := a.AuthorizationRequestProfile("custom") + require.True(t, ok) + assert.Equal(t, []string{"bar", "baz"}, params["foo"]) + }) + t.Run("no profiles configured returns false", func(t *testing.T) { + params, ok := (&Auth{}).AuthorizationRequestProfile("aet") + assert.False(t, ok) + assert.Nil(t, params) + }) +} diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 254a6cd446..950fbc9653 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -201,6 +201,15 @@ paths: { "auth_method": "SmartCard" } + profile: + type: string + description: | + Optional name of a request profile to apply to this flow. A profile is a curated, named bundle of + request parameters, so callers don't have to spell out issuer-specific parameters themselves. + Currently a profile sets authorization request parameters (see auth.experimental.profile..authrequest). + Built-in: 'aet' for the AET UZI smartcard credential issuer. An unknown profile is rejected. + EXPERIMENTAL: subject to change without notice. + example: aet redirect_uri: type: string description: | diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 8eb4c6a337..98fff2cb8b 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -273,6 +273,13 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // used to locate the OAuth2 Authorization Server metadata. Issuer string `json:"issuer"` + // Profile Optional name of a request profile to apply to this flow. A profile is a curated, named bundle of + // request parameters, so callers don't have to spell out issuer-specific parameters themselves. + // Currently a profile sets authorization request parameters (see auth.experimental.profile..authrequest). + // Built-in: 'aet' for the AET UZI smartcard credential issuer. An unknown profile is rejected. + // EXPERIMENTAL: subject to change without notice. + Profile *string `json:"profile,omitempty"` + // RedirectUri The URL to which the user-agent will be redirected after the authorization request. RedirectUri string `json:"redirect_uri"` From 26954a345b2d808760fdff9e89201db3d3586d31 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Mon, 6 Jul 2026 09:29:27 +0200 Subject: [PATCH 2/3] feat(auth): drop authorization_request_params, superseded by profiles Profiles (see previous commit) replace the raw authorization_request_params escape hatch: issuers that need extra authorization parameters get a named, curated profile instead of callers spelling out parameters themselves. Removes the field, its handling, and its tests, folding in the change from #4349. --- auth/api/iam/generated.go | 7 ------- auth/api/iam/openid4vci.go | 15 -------------- auth/api/iam/openid4vci_test.go | 25 ----------------------- docs/_static/auth/v2.yaml | 14 ------------- e2e-tests/browser/client/iam/generated.go | 7 ------- 5 files changed, 68 deletions(-) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index d2eb1aff09..f5d6bf43f7 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -261,13 +261,6 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // 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). - // Prefer authorization_details (RFC 9396) where the issuer supports it; use this only for issuers - // that require non-standard authorization parameters (e.g. auth_method for AET smartcards). - AuthorizationRequestParams *map[string]string `json:"authorization_request_params,omitempty"` - // CredentialRequestParams Optional JSON object overlaid on top of the OpenID4VCI Credential Request body sent to // the issuer's credential endpoint. Any field supplied here overrides the node's default — // including credential_configuration_id, credential_identifier and proofs. Use this for diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 63cdbdc44a..97d727cdbd 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -137,12 +137,8 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques oauth.CodeChallengeParam: pkceParams.Challenge, oauth.CodeChallengeMethodParam: pkceParams.ChallengeMethod, } - // Record the node-controlled parameters and seed the query. The raw authorization_request_params below may - // not override these; a request profile may (it is trusted built-in/operator config). - nodeParamKeys := make(map[string]struct{}, len(authzParams)) authzQuery := authorizationEndpoint.Query() for key, value := range authzParams { - nodeParamKeys[key] = struct{}{} authzQuery.Set(key, value) } // EXPERIMENTAL: apply the selected request profile (built-in default merged with operator config). Profiles are @@ -156,17 +152,6 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques authzQuery[key] = append([]string(nil), values...) } } - // 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 - // OpenID4VCI parameters set by the node above, which are essential to the flow. - if request.Body.AuthorizationRequestParams != nil { - for key, value := range *request.Body.AuthorizationRequestParams { - if _, isNodeParam := nodeParamKeys[key]; isNodeParam { - return nil, core.InvalidInputError("authorization_request_params may not override the '%s' parameter set by the node", key) - } - authzQuery.Set(key, value) - } - } authorizationEndpoint.RawQuery = authzQuery.Encode() return RequestOpenid4VCICredentialIssuance200JSONResponse{ diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 49024b049a..1d9080eeda 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -78,31 +78,6 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { 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")) }) - t.Run("ok - authorization_request_params merged into authorization request", 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.AuthorizationRequestParams = &map[string]string{"auth_method": "SmartCard"} - - response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) - - require.NoError(t, err) - redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) - require.NoError(t, err) - assert.Equal(t, "SmartCard", redirectUri.Query().Get("auth_method")) - }) - t.Run("error - authorization_request_params may not override a node parameter", 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.AuthorizationRequestParams = &map[string]string{oauth.ClientIDParam: "attacker"} - - _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) - - assert.ErrorContains(t, err, "authorization_request_params may not override the 'client_id' parameter") - }) t.Run("ok - profile applies its authorization request parameters", func(t *testing.T) { ctx := newTestClient(t) ctx.authorizationRequestProfiles = map[string]map[string][]string{ diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 950fbc9653..2f4c14d74e 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -187,20 +187,6 @@ paths: { "some-requested-credential-attribute": "900184590" } - authorization_request_params: - type: object - additionalProperties: - type: string - description: | - 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). - Prefer authorization_details (RFC 9396) where the issuer supports it; use this only for issuers - that require non-standard authorization parameters (e.g. auth_method for AET smartcards). - example: | - { - "auth_method": "SmartCard" - } profile: type: string description: | diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 98fff2cb8b..aee14079bc 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -255,13 +255,6 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // 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). - // Prefer authorization_details (RFC 9396) where the issuer supports it; use this only for issuers - // that require non-standard authorization parameters (e.g. auth_method for AET smartcards). - AuthorizationRequestParams *map[string]string `json:"authorization_request_params,omitempty"` - // CredentialRequestParams Optional JSON object overlaid on top of the OpenID4VCI Credential Request body sent to // the issuer's credential endpoint. Any field supplied here overrides the node's default — // including credential_configuration_id, credential_identifier and proofs. Use this for From 023b1657bb4a6faaf3d8f76ebfa0189008f4fed9 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Mon, 6 Jul 2026 09:33:51 +0200 Subject: [PATCH 3/3] chore(auth): drop redundant EXPERIMENTAL callouts on profiles The OpenID4VCI request flow is already marked EXPERIMENTAL at the API and config level, so repeating it on individual fields/comments/doc strings added noise. Also drops the now-stale "node parameters" framing from the authorization-request comment, which only made sense as a contrast with the removed authorization_request_params field. --- auth/api/iam/generated.go | 1 - auth/api/iam/openid4vci.go | 4 ++-- auth/auth.go | 2 +- auth/config.go | 4 ---- auth/interface.go | 2 +- docs/_static/auth/v2.yaml | 1 - e2e-tests/browser/client/iam/generated.go | 1 - 7 files changed, 4 insertions(+), 11 deletions(-) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index f5d6bf43f7..8f8994b9bd 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -276,7 +276,6 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // request parameters, so callers don't have to spell out issuer-specific parameters themselves. // Currently a profile sets authorization request parameters (see auth.experimental.profile..authrequest). // Built-in: 'aet' for the AET UZI smartcard credential issuer. An unknown profile is rejected. - // EXPERIMENTAL: subject to change without notice. Profile *string `json:"profile,omitempty"` // RedirectUri The URL to which the user-agent will be redirected after the authorization request. diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 97d727cdbd..86dbb0258b 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -141,8 +141,8 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques for key, value := range authzParams { authzQuery.Set(key, value) } - // EXPERIMENTAL: apply the selected request profile (built-in default merged with operator config). Profiles are - // trusted config and may override node parameters; their authorization request parameters may be multi-valued. + // Apply the selected request profile (built-in default merged with operator config). A profile is trusted + // config and may override the parameters set above; its authorization request parameters may be multi-valued. if request.Body.Profile != nil && *request.Body.Profile != "" { profileParams, ok := r.auth.AuthorizationRequestProfile(*request.Body.Profile) if !ok { diff --git a/auth/auth.go b/auth/auth.go index aa24c66c2f..7da0f690e7 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -151,7 +151,7 @@ func (auth *Auth) OpenID4VCIClient() openid4vci.Client { } // AuthorizationRequestProfile returns the authorization request parameters of the named profile, or false when no -// profile (built-in default or operator-configured) exists for the name. EXPERIMENTAL. +// profile (built-in default or operator-configured) exists for the name. func (auth *Auth) AuthorizationRequestProfile(name string) (map[string][]string, bool) { profile, ok := auth.config.Experimental.Profiles[name] if !ok { diff --git a/auth/config.go b/auth/config.go index 6f81ab6fd0..df311dc705 100644 --- a/auth/config.go +++ b/auth/config.go @@ -44,14 +44,10 @@ type ExperimentalConfig struct { // Profiles are named bundles of request parameters for outbound flows against external issuers/authorization // servers. A request selects a profile by name. Built-in profiles (e.g. "aet") are provided via DefaultConfig; // operator config under the same name is layered over the default. - // - // EXPERIMENTAL: this configuration may change or be removed without further notice. Profiles map[string]ProfileConfig `koanf:"profile"` } // ProfileConfig is a named bundle of request parameters for outbound OpenID4VCI flows. -// -// EXPERIMENTAL: this configuration may change or be removed without further notice. type ProfileConfig struct { // AuthorizationRequest sets parameters on the OpenID4VCI authorization request. Each key may have multiple // values (rendered as repeated query parameters; a single value is rendered as one parameter). diff --git a/auth/interface.go b/auth/interface.go index 148014f45b..114e7ef91e 100644 --- a/auth/interface.go +++ b/auth/interface.go @@ -38,7 +38,7 @@ type AuthenticationServices interface { // OpenID4VCIClient returns the OpenID4VCI 1.0 HTTP client. OpenID4VCIClient() openid4vci.Client // AuthorizationRequestProfile returns the authorization request parameters of the named profile (built-in - // merged with operator config), or false if the name is unknown. EXPERIMENTAL. + // merged with operator config), or false if the name is unknown. AuthorizationRequestProfile(name string) (map[string][]string, bool) // RelyingParty returns the oauth.RelyingParty RelyingParty() oauth.RelyingParty diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 2f4c14d74e..c0182a7578 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -194,7 +194,6 @@ paths: request parameters, so callers don't have to spell out issuer-specific parameters themselves. Currently a profile sets authorization request parameters (see auth.experimental.profile..authrequest). Built-in: 'aet' for the AET UZI smartcard credential issuer. An unknown profile is rejected. - EXPERIMENTAL: subject to change without notice. example: aet redirect_uri: type: string diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index aee14079bc..70dcfd0e8d 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -270,7 +270,6 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // request parameters, so callers don't have to spell out issuer-specific parameters themselves. // Currently a profile sets authorization request parameters (see auth.experimental.profile..authrequest). // Built-in: 'aet' for the AET UZI smartcard credential issuer. An unknown profile is rejected. - // EXPERIMENTAL: subject to change without notice. Profile *string `json:"profile,omitempty"` // RedirectUri The URL to which the user-agent will be redirected after the authorization request.