Skip to content
Open
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
7 changes: 5 additions & 2 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"crypto/tls"
"errors"
"github.com/nuts-foundation/nuts-node/auth/client/iam"
"github.com/nuts-foundation/nuts-node/policy"
"github.com/nuts-foundation/nuts-node/vdr"
"github.com/nuts-foundation/nuts-node/vdr/didjwk"
"github.com/nuts-foundation/nuts-node/vdr/didkey"
Expand Down Expand Up @@ -68,6 +69,7 @@ type Auth struct {
httpClientTimeout time.Duration
tlsConfig *tls.Config
subjectManager didsubject.Manager
policyBackend policy.PDPBackend
// configuredDIDMethods contains the DID methods that are configured in the Nuts node,
// of which VDR will create DIDs.
configuredDIDMethods []string
Expand Down Expand Up @@ -100,7 +102,7 @@ func (auth *Auth) ContractNotary() services.ContractNotary {

// NewAuthInstance accepts a Config with several Nuts Engines and returns an instance of Auth
func NewAuthInstance(config Config, vdrInstance vdr.VDR, subjectManager didsubject.Manager, vcr vcr.VCR, keyStore crypto.KeyStore,
serviceResolver didman.CompoundServiceResolver, jsonldManager jsonld.JSONLD, pkiProvider pki.Provider) *Auth {
serviceResolver didman.CompoundServiceResolver, jsonldManager jsonld.JSONLD, pkiProvider pki.Provider, policyBackend policy.PDPBackend) *Auth {
return &Auth{
config: config,
jsonldManager: jsonldManager,
Expand All @@ -110,6 +112,7 @@ func NewAuthInstance(config Config, vdrInstance vdr.VDR, subjectManager didsubje
vcr: vcr,
pkiProvider: pkiProvider,
serviceResolver: serviceResolver,
policyBackend: policyBackend,
shutdownFunc: func() {},
}
}
Expand All @@ -126,7 +129,7 @@ func (auth *Auth) RelyingParty() oauth.RelyingParty {

func (auth *Auth) IAMClient() iam.Client {
keyResolver := resolver.DIDKeyResolver{Resolver: auth.vdrInstance.Resolver()}
return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.strictMode, auth.httpClientTimeout)
return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.policyBackend, auth.strictMode, auth.httpClientTimeout)
}

// Configure the Auth struct by creating a validator and create an Irma server
Expand Down
6 changes: 3 additions & 3 deletions auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func TestAuth_Configure(t *testing.T) {
vdrInstance := vdr.NewMockVDR(ctrl)
vdrInstance.EXPECT().Resolver().AnyTimes()

i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock)
i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock, nil)

require.NoError(t, i.Configure(tlsServerConfig))
})
Expand All @@ -61,7 +61,7 @@ func TestAuth_Configure(t *testing.T) {
vdrInstance := vdr.NewMockVDR(ctrl)
vdrInstance.EXPECT().Resolver().AnyTimes()

i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock)
i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock, nil)

require.NoError(t, i.Configure(tlsServerConfig))
})
Expand Down Expand Up @@ -119,7 +119,7 @@ func TestAuth_IAMClient(t *testing.T) {
vdrInstance := vdr.NewMockVDR(ctrl)
vdrInstance.EXPECT().Resolver().AnyTimes()

i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, jsonld.NewTestJSONLDManager(t), pkiMock)
i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, jsonld.NewTestJSONLDManager(t), pkiMock, nil)

assert.NotNil(t, i.IAMClient())
})
Expand Down
34 changes: 18 additions & 16 deletions auth/client/iam/openid4vp.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"errors"
"fmt"
"github.com/nuts-foundation/nuts-node/http/client"
"github.com/nuts-foundation/nuts-node/policy"
"github.com/nuts-foundation/nuts-node/vcr/credential"
"github.com/nuts-foundation/nuts-node/vdr/didsubject"
"github.com/piprate/json-gold/ld"
Expand Down Expand Up @@ -60,23 +61,30 @@ type OpenID4VPClient struct {
wallet holder.Wallet
ldDocumentLoader ld.DocumentLoader
subjectManager didsubject.Manager
pdResolver PresentationDefinitionResolver
}

// NewClient returns an implementation of Holder
func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectManager didsubject.Manager, jwtSigner nutsCrypto.JWTSigner,
ldDocumentLoader ld.DocumentLoader, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient {
ldDocumentLoader ld.DocumentLoader, policyBackend policy.PDPBackend, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient {
httpClient := HTTPClient{
strictMode: strictMode,
httpClient: client.NewWithCache(httpClientTimeout),
keyResolver: keyResolver,
}
return &OpenID4VPClient{
httpClient: HTTPClient{
strictMode: strictMode,
httpClient: client.NewWithCache(httpClientTimeout),
keyResolver: keyResolver,
},
httpClient: httpClient,
keyResolver: keyResolver,
jwtSigner: jwtSigner,
ldDocumentLoader: ldDocumentLoader,
subjectManager: subjectManager,
strictMode: strictMode,
wallet: wallet,
pdResolver: PresentationDefinitionResolver{
httpClient: httpClient,
policyBackend: policyBackend,
strictMode: strictMode,
},
}
}

Expand Down Expand Up @@ -242,18 +250,12 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID
return nil, err
}

// get the presentation definition from the verifier
parsedURL, err := core.ParsePublicURL(metadata.PresentationDefinitionEndpoint, c.strictMode)
if err != nil {
return nil, err
}
presentationDefinitionURL := nutsHttp.AddQueryParams(*parsedURL, map[string]string{
"scope": scopes,
})
presentationDefinition, err := c.PresentationDefinition(ctx, presentationDefinitionURL.String())
// Resolve the presentation definition: from remote AS when available, local policy otherwise
resolved, err := c.pdResolver.Resolve(ctx, scopes, *metadata)
if err != nil {
return nil, err
}
presentationDefinition := &resolved.PresentationDefinition

params := holder.BuildParams{
Audience: authServerURL,
Expand Down Expand Up @@ -312,7 +314,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID
data.Set(oauth.GrantTypeParam, oauth.VpTokenGrantType)
data.Set(oauth.AssertionParam, assertion)
data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission))
data.Set(oauth.ScopeParam, scopes)
data.Set(oauth.ScopeParam, resolved.Scope)

// create DPoP header
var dpopHeader string
Expand Down
6 changes: 6 additions & 0 deletions auth/client/iam/openid4vp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,12 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon
},
jwtSigner: jwtSigner,
keyResolver: keyResolver,
pdResolver: PresentationDefinitionResolver{
httpClient: HTTPClient{
strictMode: false,
httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig),
},
},
},
jwtSigner: jwtSigner,
keyResolver: keyResolver,
Expand Down
108 changes: 108 additions & 0 deletions auth/client/iam/pd_resolver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* 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 iam

import (
"context"
"fmt"

"github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/core"
nutsHttp "github.com/nuts-foundation/nuts-node/http"
"github.com/nuts-foundation/nuts-node/policy"
"github.com/nuts-foundation/nuts-node/vcr/pe"
)

// ResolvedPresentationDefinition contains a resolved PD and the scope to use in the token request.
type ResolvedPresentationDefinition struct {
PresentationDefinition pe.PresentationDefinition
// Scope is the scope string to include in the token request.
// When resolved remotely, this is the original scope string (remote AS handles scope policy).
// When resolved locally, this depends on the configured scope policy.
Scope string
}

// PresentationDefinitionResolver resolves a PresentationDefinition for a given scope string.
// It uses the remote AS's PD endpoint when available, falling back to local policy resolution.
type PresentationDefinitionResolver struct {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is what the PDPBackend is for, don't need another abstraction layer (just implement another PDPBackend?)

httpClient HTTPClient
policyBackend policy.PDPBackend
strictMode bool
}

// Resolve resolves a PresentationDefinition for the given scope string.
// If the remote AS metadata advertises a PD endpoint, the PD is fetched remotely
// and the full scope string is returned (remote AS handles scope policy).
// If no PD endpoint is available, the local policy backend is used and scope policy is enforced.
func (r *PresentationDefinitionResolver) Resolve(ctx context.Context, scope string, metadata oauth.AuthorizationServerMetadata) (*ResolvedPresentationDefinition, error) {
if metadata.PresentationDefinitionEndpoint != "" {
return r.resolveRemote(ctx, scope, metadata)
}
return r.resolveLocal(ctx, scope)
}

func (r *PresentationDefinitionResolver) resolveRemote(ctx context.Context, scope string, metadata oauth.AuthorizationServerMetadata) (*ResolvedPresentationDefinition, error) {
parsedURL, err := core.ParsePublicURL(metadata.PresentationDefinitionEndpoint, r.strictMode)
if err != nil {
return nil, err
}
pdURL := nutsHttp.AddQueryParams(*parsedURL, map[string]string{
"scope": scope,
})
pd, err := r.httpClient.PresentationDefinition(ctx, pdURL)
if err != nil {
return nil, err
}
return &ResolvedPresentationDefinition{
PresentationDefinition: *pd,
Scope: scope,
}, nil
}

func (r *PresentationDefinitionResolver) resolveLocal(ctx context.Context, scope string) (*ResolvedPresentationDefinition, error) {
if r.policyBackend == nil {
return nil, fmt.Errorf("local PD resolution requires a policy backend, but none is configured")
}
match, err := r.policyBackend.FindCredentialProfile(ctx, scope)
if err != nil {
return nil, fmt.Errorf("local PD resolution failed: %w", err)
}
if match.ScopePolicy == policy.ScopePolicyProfileOnly && len(match.OtherScopes) > 0 {
return nil, oauth.OAuth2Error{
Code: oauth.InvalidScope,
Description: "scope policy 'profile-only' does not allow additional scopes",
}
}
// Select the organization PD (default for current single-VP flow).
// TODO: When #4080 adds two-VP support, this resolver will need to return multiple PDs.
pd, ok := match.WalletOwnerMapping[pe.WalletOwnerOrganization]
if !ok {
return nil, fmt.Errorf("no organization presentation definition for scope %q", match.CredentialProfileScope)
}
// For passthrough and dynamic, forward all scopes to the remote AS.
// The client does not evaluate dynamic scopes — the server handles PDP evaluation at token-grant time (PR #4179).
resolvedScope := scope
if match.ScopePolicy == policy.ScopePolicyProfileOnly {
resolvedScope = match.CredentialProfileScope
}
return &ResolvedPresentationDefinition{
PresentationDefinition: pd,
Scope: resolvedScope,
}, nil
}
Loading