Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ nuts.yaml
# Local scratch directory and Claude Code session state
.scratch/
.claude/scheduled_tasks.lock
.claude/worktrees/
4 changes: 4 additions & 0 deletions auth/api/auth/v1/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
10 changes: 9 additions & 1 deletion auth/api/iam/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
13 changes: 6 additions & 7 deletions auth/api/iam/generated.go

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

27 changes: 15 additions & 12 deletions auth/api/iam/openid4vci.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
}

Expand Down
15 changes: 10 additions & 5 deletions auth/api/iam/openid4vci_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,30 +78,35 @@ 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)

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 - 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)
Expand Down
10 changes: 10 additions & 0 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
23 changes: 23 additions & 0 deletions auth/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.<name> 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"},
},
},
},
},
}
}
3 changes: 3 additions & 0 deletions auth/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions auth/mock.go

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

55 changes: 55 additions & 0 deletions auth/profiles_test.go
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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)
})
}
20 changes: 7 additions & 13 deletions docs/_static/auth/v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.authrequest).
Built-in: 'aet' for the AET UZI smartcard credential issuer. An unknown profile is rejected.
example: aet
redirect_uri:
type: string
description: |
Expand Down
13 changes: 6 additions & 7 deletions e2e-tests/browser/client/iam/generated.go

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

Loading