diff --git a/.gitignore b/.gitignore index 07ee438fd..d80483f4d 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 a4970f89a..fc8d9c0e2 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 b3af7d5e1..6a923fb6e 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 70dba4939..8f8994b9b 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 @@ -279,6 +272,12 @@ 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. + 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 ec4b18737..86dbb0258 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,25 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques oauth.CodeChallengeParam: pkceParams.Challenge, oauth.CodeChallengeMethodParam: pkceParams.ChallengeMethod, } - // 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 { - return nil, core.InvalidInputError("authorization_request_params may not override the '%s' parameter set by the node", key) - } - authzParams[key] = value + authzQuery := authorizationEndpoint.Query() + for key, value := range authzParams { + authzQuery.Set(key, value) + } + // 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 { + return nil, core.InvalidInputError("unknown profile: %s", *request.Body.Profile) + } + for key, values := range profileParams { + authzQuery[key] = append([]string(nil), values...) } } - 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 50803d406..1d9080eed 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -78,12 +78,15 @@ 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) { + 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.AuthorizationRequestParams = &map[string]string{"auth_method": "SmartCard"} + req.Body.Profile = to.Ptr("aet") response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) @@ -91,17 +94,19 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { 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 - authorization_request_params may not override a node parameter", func(t *testing.T) { + 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.AuthorizationRequestParams = &map[string]string{oauth.ClientIDParam: "attacker"} + req.Body.Profile = to.Ptr("does-not-exist") _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) - assert.ErrorContains(t, err, "authorization_request_params may not override the 'client_id' parameter") + 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) diff --git a/auth/auth.go b/auth/auth.go index c64235dfc..7da0f690e 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. +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 78cf9ea95..df311dc70 100644 --- a/auth/config.go +++ b/auth/config.go @@ -41,6 +41,17 @@ 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. + Profiles map[string]ProfileConfig `koanf:"profile"` +} + +// ProfileConfig is a named bundle of request parameters for outbound OpenID4VCI flows. +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 +89,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 894911652..114e7ef91 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. + 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 a04365018..4c2d58dd7 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 000000000..fb3e2e783 --- /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 254a6cd44..c0182a757 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -187,20 +187,14 @@ paths: { "some-requested-credential-attribute": "900184590" } - authorization_request_params: - type: object - additionalProperties: - type: string + profile: + 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" - } + 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. + 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 8eb4c6a33..70dcfd0e8 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 @@ -273,6 +266,12 @@ 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. + 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"`