From d8cef07681b6bd0b156790b66b6aaff6e3da177a Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Fri, 28 Nov 2025 16:20:46 +0100 Subject: [PATCH 01/27] Read Presentation Definition from local PDP backend --- LSPxNuts_README.md | 8 +++++ auth/auth.go | 16 ++++++---- auth/auth_test.go | 9 +++--- auth/client/iam/openid4vp.go | 50 +++++++++++++++++++++---------- auth/client/iam/openid4vp_test.go | 39 ++++++++++++++++++++---- auth/test.go | 6 ++-- cmd/root.go | 11 +++---- discovery/api/v1/api_test.go | 10 +++++-- 8 files changed, 108 insertions(+), 41 deletions(-) create mode 100644 LSPxNuts_README.md diff --git a/LSPxNuts_README.md b/LSPxNuts_README.md new file mode 100644 index 0000000000..7aca966a45 --- /dev/null +++ b/LSPxNuts_README.md @@ -0,0 +1,8 @@ +# LSPxNuts Proof of Concept + +This is a branch that for the Proof of Concept of the LSPxNuts project. + +It adds or alters the following functionality versus the mainstream Nuts node: + +- OAuth2 `vp_bearer` token exchange: read presentation definition from local definitions instead of fetching it from the remote authorization server. + LSP doesn't support presentation definitions, meaning that we need to look it up locally. \ No newline at end of file diff --git a/auth/auth.go b/auth/auth.go index f135335c01..6244e478f6 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -21,7 +21,13 @@ package auth import ( "crypto/tls" "errors" + "net/url" + "path" + "slices" + "time" + "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" @@ -30,10 +36,6 @@ import ( "github.com/nuts-foundation/nuts-node/vdr/didweb" "github.com/nuts-foundation/nuts-node/vdr/didx509" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "net/url" - "path" - "slices" - "time" "github.com/nuts-foundation/nuts-node/auth/services" "github.com/nuts-foundation/nuts-node/auth/services/notary" @@ -58,6 +60,7 @@ type Auth struct { relyingParty oauth.RelyingParty contractNotary services.ContractNotary serviceResolver didman.CompoundServiceResolver + policyBackend policy.PDPBackend keyStore crypto.KeyStore vcr vcr.VCR pkiProvider pki.Provider @@ -100,12 +103,13 @@ 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, vdrInstance: vdrInstance, subjectManager: subjectManager, + policyBackend: policyBackend, keyStore: keyStore, vcr: vcr, pkiProvider: pkiProvider, @@ -126,7 +130,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 diff --git a/auth/auth_test.go b/auth/auth_test.go index 968ea61ef8..baf0fe4840 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -19,6 +19,8 @@ package auth import ( + "testing" + "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/jsonld" @@ -28,7 +30,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "testing" ) func TestAuth_Configure(t *testing.T) { @@ -47,7 +48,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)) }) @@ -61,7 +62,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)) }) @@ -119,7 +120,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()) }) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 0f9e370601..eda07e096d 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -24,16 +24,18 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/http/client" - "github.com/nuts-foundation/nuts-node/vcr/credential" - "github.com/nuts-foundation/nuts-node/vdr/didsubject" - "github.com/piprate/json-gold/ld" "maps" "net/http" "net/url" "slices" "time" + "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" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/log" @@ -60,11 +62,12 @@ type OpenID4VPClient struct { wallet holder.Wallet ldDocumentLoader ld.DocumentLoader subjectManager didsubject.Manager + policyBackend policy.PDPBackend } // 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 { return &OpenID4VPClient{ httpClient: HTTPClient{ strictMode: strictMode, @@ -77,6 +80,7 @@ func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectMa subjectManager: subjectManager, strictMode: strictMode, wallet: wallet, + policyBackend: policyBackend, } } @@ -242,17 +246,33 @@ 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()) - if err != nil { + // LSPxNuts: get the presentation definition from local definitions, if available + var presentationDefinition *pe.PresentationDefinition + presentationDefinitionMap, err := c.policyBackend.PresentationDefinitions(ctx, scopes) + if errors.Is(err, policy.ErrNotFound) { + // not found locally, get from verifier + // 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()) + if err != nil { + return nil, err + } + } else if err != nil { return nil, err + } else { + // found locally + if len(presentationDefinitionMap) != 1 { + return nil, fmt.Errorf("expected exactly one presentation definition for scope '%s', found %d", scopes, len(presentationDefinitionMap)) + } + for _, pd := range presentationDefinitionMap { + presentationDefinition = &pd + } } params := holder.BuildParams{ diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index e5c4ef6840..a50d637f9e 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -24,16 +24,18 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/http/client" - test2 "github.com/nuts-foundation/nuts-node/test" - "github.com/nuts-foundation/nuts-node/vcr/credential" - "github.com/nuts-foundation/nuts-node/vdr/didsubject" "net/http" "net/http/httptest" "net/url" "testing" "time" + "github.com/nuts-foundation/nuts-node/http/client" + "github.com/nuts-foundation/nuts-node/policy" + test2 "github.com/nuts-foundation/nuts-node/test" + "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vdr/didsubject" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -252,6 +254,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) @@ -264,6 +267,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, pe.ErrNoCredentials) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) @@ -274,6 +278,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.authzServerMetadata.DIDMethodsSupported = []string{"other"} ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) @@ -285,6 +290,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { t.Run("with additional credentials", func(t *testing.T) { ctx := createClientServerTestContext(t) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) credentials := []vc.VerifiableCredential{ { Context: []ssi.URI{ @@ -325,6 +331,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.jwtSigner.EXPECT().SignDPoP(context.Background(), gomock.Any(), primaryKID).Return("dpop", nil) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil) @@ -333,6 +340,18 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { assert.Equal(t, "token", response.AccessToken) assert.Equal(t, "bearer", response.TokenType) }) + t.Run("with Presentation Definition from local policy backend", func(t *testing.T) { + ctx := createClientServerTestContext(t) + pd := pe.PresentationDefinition{Name: "pd-id"} + ctx.clientTestContext.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pd, + }, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), pd, gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + assert.NoError(t, err) + require.NotNil(t, response) + }) t.Run("error - access denied", func(t *testing.T) { oauthError := oauth.OAuth2Error{ Code: "invalid_scope", @@ -347,6 +366,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { } ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) @@ -357,6 +377,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { }) t.Run("error - failed to get presentation definition", func(t *testing.T) { ctx := createClientServerTestContext(t) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) ctx.presentationDefinition = func(writer http.ResponseWriter) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusBadRequest) @@ -383,6 +404,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { }) t.Run("error - faulty presentation definition", func(t *testing.T) { ctx := createClientServerTestContext(t) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) ctx.presentationDefinition = func(writer http.ResponseWriter) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) @@ -397,6 +419,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { }) t.Run("error - failed to build vp", func(t *testing.T) { ctx := createClientServerTestContext(t) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, assert.AnError) @@ -477,6 +500,7 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon tlsConfig = &tls.Config{} } tlsConfig.InsecureSkipVerify = true + policyBackend := policy.NewMockPDPBackend(ctrl) return &clientTestContext{ audit: audit.TestContext(), @@ -488,13 +512,15 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon strictMode: false, httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig), }, - jwtSigner: jwtSigner, - keyResolver: keyResolver, + jwtSigner: jwtSigner, + keyResolver: keyResolver, + policyBackend: policyBackend, }, jwtSigner: jwtSigner, keyResolver: keyResolver, wallet: wallet, subjectManager: subjectManager, + policyBackend: policyBackend, } } @@ -506,6 +532,7 @@ type clientTestContext struct { keyResolver *resolver.MockKeyResolver wallet *holder.MockWallet subjectManager *didsubject.MockManager + policyBackend *policy.MockPDPBackend } type clientServerTestContext struct { diff --git a/auth/test.go b/auth/test.go index 4142046cdf..448668ad5c 100644 --- a/auth/test.go +++ b/auth/test.go @@ -19,9 +19,11 @@ package auth import ( + "testing" + + "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/didsubject" - "testing" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/pki" @@ -44,5 +46,5 @@ func testInstance(t *testing.T, cfg Config) *Auth { vdrInstance := vdr.NewMockVDR(ctrl) vdrInstance.EXPECT().Resolver().AnyTimes() subjectManager := didsubject.NewMockManager(ctrl) - return NewAuthInstance(cfg, vdrInstance, subjectManager, vcrInstance, cryptoInstance, nil, nil, pkiMock) + return NewAuthInstance(cfg, vdrInstance, subjectManager, vcrInstance, cryptoInstance, nil, nil, pkiMock, policy.NewMockPDPBackend(ctrl)) } diff --git a/cmd/root.go b/cmd/root.go index a14feab268..43898895c9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,13 +23,14 @@ import ( "context" "errors" "fmt" - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - "github.com/spf13/pflag" "io" "os" "runtime/pprof" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/nuts-foundation/nuts-node/auth" authAPIv1 "github.com/nuts-foundation/nuts-node/auth/api/auth/v1" authIAMAPI "github.com/nuts-foundation/nuts-node/auth/api/iam" @@ -199,11 +200,11 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { credentialInstance := vcr.NewVCRInstance(cryptoInstance, vdrInstance, networkInstance, jsonld, eventManager, storageInstance, pkiInstance) didmanInstance := didman.NewDidmanInstance(vdrInstance, credentialInstance, jsonld) discoveryInstance := discovery.New(storageInstance, credentialInstance, vdrInstance, vdrInstance) - authInstance := auth.NewAuthInstance(auth.DefaultConfig(), vdrInstance, vdrInstance, credentialInstance, cryptoInstance, didmanInstance, jsonld, pkiInstance) + policyInstance := policy.New() + authInstance := auth.NewAuthInstance(auth.DefaultConfig(), vdrInstance, vdrInstance, credentialInstance, cryptoInstance, didmanInstance, jsonld, pkiInstance, policyInstance) statusEngine := status.NewStatusEngine(system) metricsEngine := core.NewMetricsEngine() goldenHammer := golden_hammer.New(vdrInstance, didmanInstance) - policyInstance := policy.New() // Register HTTP routes didKeyResolver := resolver.DIDKeyResolver{Resolver: vdrInstance.Resolver()} diff --git a/discovery/api/v1/api_test.go b/discovery/api/v1/api_test.go index 3a831ac364..ec86b144b5 100644 --- a/discovery/api/v1/api_test.go +++ b/discovery/api/v1/api_test.go @@ -20,7 +20,12 @@ package v1 import ( "context" + "encoding/json" "errors" + "net/http" + "net/url" + "testing" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" @@ -30,9 +35,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "net/http" - "net/url" - "testing" ) const ( @@ -164,6 +166,8 @@ func TestWrapper_SearchPresentations(t *testing.T) { ServiceID: serviceID, }) + js, _ := json.MarshalIndent(response, "", " ") + println(string(js)) assert.NoError(t, err) assert.IsType(t, SearchPresentations200JSONResponse{}, response) actual := response.(SearchPresentations200JSONResponse) From eeb783a22d694bd61faa30d795b522e7f77bc236 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Mon, 1 Dec 2025 11:41:56 +0100 Subject: [PATCH 02/27] build Docker image for branch --- .github/workflows/build-images.yaml | 2 ++ development/lspxnuts/docker-compose.yml | 12 ++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 development/lspxnuts/docker-compose.yml diff --git a/.github/workflows/build-images.yaml b/.github/workflows/build-images.yaml index 4f5aa31cd6..b2201949e0 100644 --- a/.github/workflows/build-images.yaml +++ b/.github/workflows/build-images.yaml @@ -5,11 +5,13 @@ on: push: branches: - master + - lspxnuts tags: - 'v*' pull_request: branches: - master + - lspxnuts # cancel build action if superseded by new commit on same branch concurrency: diff --git a/development/lspxnuts/docker-compose.yml b/development/lspxnuts/docker-compose.yml new file mode 100644 index 0000000000..ab70709a07 --- /dev/null +++ b/development/lspxnuts/docker-compose.yml @@ -0,0 +1,12 @@ +services: + nutsnode: + image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:lspxnuts}" + pull_policy: always + ports: + - "18081:8081" + environment: + NUTS_STRICTMODE: false + #volumes: + # - "./definitions/:/opt/nuts/definitions:ro" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often \ No newline at end of file From 811fc8526245d5a13c160bba90139ae07f5c6dfc Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Mon, 1 Dec 2025 14:41:12 +0100 Subject: [PATCH 03/27] build docker image --- .github/workflows/build-images.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-images.yaml b/.github/workflows/build-images.yaml index b2201949e0..cb656ef160 100644 --- a/.github/workflows/build-images.yaml +++ b/.github/workflows/build-images.yaml @@ -53,7 +53,7 @@ jobs: images: nutsfoundation/nuts-node tags: | # generate 'master' tag for the master branch - type=ref,event=branch,enable={{is_default_branch}},prefix= + type=ref,event=branch,enable=true,prefix= # generate 5.2.1 tag type=semver,pattern={{version}} flavor: | From 262a7745474dc7f857cb3ee72816d49be71f6de3 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Tue, 2 Dec 2025 13:04:19 +0100 Subject: [PATCH 04/27] basic testing setup --- .../lspxnuts/certs/localhost-chain.pem | 40 ++++++++ development/lspxnuts/certs/localhost.key | 28 ++++++ development/lspxnuts/certs/localhost.pem | 22 +++++ development/lspxnuts/docker-compose.yml | 16 +++- development/lspxnuts/setup.sh | 94 +++++++++++++++++++ 5 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 development/lspxnuts/certs/localhost-chain.pem create mode 100644 development/lspxnuts/certs/localhost.key create mode 100644 development/lspxnuts/certs/localhost.pem create mode 100755 development/lspxnuts/setup.sh diff --git a/development/lspxnuts/certs/localhost-chain.pem b/development/lspxnuts/certs/localhost-chain.pem new file mode 100644 index 0000000000..95ba04353b --- /dev/null +++ b/development/lspxnuts/certs/localhost-chain.pem @@ -0,0 +1,40 @@ +-----BEGIN CERTIFICATE----- +MIIC9jCCAd6gAwIBAgIURFCqPrL3QQdBNOqkwmXWNgx9pdQwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNDExMTExNDE1MTha +Fw0zNDExMDkxNDE1MThaMBsxGTAXBgNVBAMMEEZha2UgVVpJIFJvb3QgQ0EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDT5J8gKdyMJNi3cuAmJ+MILrMu +wrKyTRYhjUUFHHn5rcVaHN0hzB6v5t74Nt40xUXRNaomDcclBIOlwt8f62JA2p/j +83ENfdLrXvUu9NMThkqZwZ9dzRwK7l3UZBq8NTQUO74W4M2qx8nrXq31eWogxUUI +Fc1XORh5ecebeL5mUb2E6UlmDmNgm2fGeSmmis8zieI+KKYOhi/hYtyeixrg7rxP +4v0VRrEstcWAetRgXWQX0ElAxs0Vrsy6/vv3pEtXhx8wb2wi2xY14d9Ih8HdeNI+ ++3wIbZz6WVM3fD5QFHV2EZBH+soo0pfKj2tHsaDz3FPMuMzILt6U6PT4ALIdAgMB +AAGjMjAwMA8GA1UdEwQIMAYBAf8CAQAwHQYDVR0OBBYEFJuxz0XwN7PdeMhyJfcf +m7py1BK9MA0GCSqGSIb3DQEBCwUAA4IBAQAhlpkz68x2dGpOLX3FzAb8Ee+Y2OV+ +RWFpsME9ZVDU06JETPfPCj02PH82lgUnc4jeR81rPSsIt2ssqm2S4zb02Nip595c +AqCKvmBfEc9hPPW2ugpNxT8ZRU4LKrqpV4nJ6nBvDqmGuH5uq9Ng9l9SnM3eKmdZ +tJKc+ZNAPKxVAiueLTdr6W2UbmKoZARQQ0JLkFnZOxnUkr8pQfxUzEIUkHg2dWaa +I/4wo4Pni7xXggFoPDpVztu/iP33XBLqXJwxxHXhq9nc9JU/kEXDt7j8EgoyJo7J +jSKcjpRfpGkE5gqqB4Sa8wAsAPUK3jRreuytllAtQUZRbCtHbxclc9yA +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDjTCCAnWgAwIBAgIUFTPO+pUk32QWsYyLYdlLTmlRWWgwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNTEyMDIwNzEwMDVa +Fw0yNjEyMDIwNzEwMDVaMEMxEjAQBgNVBAMMCWxvY2FsaG9zdDEQMA4GA1UECgwH +VGVzdE9yZzEPMA0GA1UEBwwGQXJuaGVtMQowCAYDVQQFEwExMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtltFIFPm4KjsJPNWIiq54j18TNl6Ouy1Y0P1 +ugXA8BCAYjNjJAq/o0g3XM2Qsp8l2UVUXwHHtFptMpkQleVOKPOMkuw4UopC8VIO +CQ2uwKEsxhapmFoxV1+SOnJRJxnA+C6ju8btW1vSTZSyEorzwXb5oyMAV02Kst2A +PfsSQkPNU1mB+cHZ9CEOG3gUXbQ5Q8UAwwr9TS2R7qFomjYM781W1GvmIdO7a+4m +Gk4Eiy0GZkV7EhPbpOkOmWReF5TXiqDgCoWSyG3CE1xmFbnbivIHG8cBMlrj82G8 +8UD4BWJcb2edt3PF7fxSW4ulMaBL7zE89s9VsmDylmYo/Zc3HQIDAQABo4GgMIGd +MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjA8BgNVHREENTAzoDEGA1UF +BaAqDCgyLjE2LjUyOC4xLjEwMDcuOTkuMjExMC0xLTEtUy0yLTAwLjAwMC0zMB0G +A1UdDgQWBBRMg47vcZ1skdvIXwh8FC/rv7MfTjAfBgNVHSMEGDAWgBSbsc9F8Dez +3XjIciX3H5u6ctQSvTANBgkqhkiG9w0BAQsFAAOCAQEACL8lz57c7UfGCV4btMuP +72qrkxSj5Ii+nrreUGc4uxR3G8FpBE++0W4PMK/wNp8IfvKFweujHH1DigQLKhRI +bHrFnsJdkZ7h/LtTEzxti/0OMLQ9J8DaZ9myPEdkO5Qn7zsoanyjzmNwCGXKJg2d +0cOxsO5Gys6wAkqkS2YsLO/kKI6IUTNvxyoziSap8kvKwIrAP2vAgTWCECT9fKJS +05kx7WFciN+STw6hkxEQNeStteAEgfXuLnrwQSeCPljrQSTNOYMy9B1uEs06C5QP +VHWmT43b9/YWEogxRQKJdM4toIvpFTGM8PonOpWyS77z6Ltnglaq8b5+QNjykYv7 +fw== +-----END CERTIFICATE----- diff --git a/development/lspxnuts/certs/localhost.key b/development/lspxnuts/certs/localhost.key new file mode 100644 index 0000000000..645c1a451e --- /dev/null +++ b/development/lspxnuts/certs/localhost.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQC2W0UgU+bgqOwk +81YiKrniPXxM2Xo67LVjQ/W6BcDwEIBiM2MkCr+jSDdczZCynyXZRVRfAce0Wm0y +mRCV5U4o84yS7DhSikLxUg4JDa7AoSzGFqmYWjFXX5I6clEnGcD4LqO7xu1bW9JN +lLISivPBdvmjIwBXTYqy3YA9+xJCQ81TWYH5wdn0IQ4beBRdtDlDxQDDCv1NLZHu +oWiaNgzvzVbUa+Yh07tr7iYaTgSLLQZmRXsSE9uk6Q6ZZF4XlNeKoOAKhZLIbcIT +XGYVuduK8gcbxwEyWuPzYbzxQPgFYlxvZ523c8Xt/FJbi6UxoEvvMTz2z1WyYPKW +Zij9lzcdAgMBAAECgf8UTRTlBHIvkJ65fl2YcClBhpbP92YkKTYIVwiELR/Nmgiw +5geje47aHrALJNd3C0Crb4x1Bz20VlzRxTiTd3O8G2EK+kFK7xmExB3L5DoQN+FE +LEG1NFVJ5Nnip9dhAvz4pDiWLw89nHtNJ8CrT8zTPOuNvdfL4FYQk5gzTkA6ICHA +7NWx9bd/OXS9SWbxQiLi+BeWh4EHmC8U7ZD3HZMTDXnTerebL2Wdtjt3n6atX3/M +gctFBx6L5spBcDpsrcyXPuJSzBrUL83XRCC9SpO/sWpqI5R7mIvOPIqiSXZcENeh +h2zvRHFoo29olkagiTSAzJE8OAhQq8iZlmtotdECgYEA9KNaZw/0LWS3YMZZ5oJ9 +F/lVtXrEfKKoDfG4ftietgNRYmhSmyb/pzAKSll0t9MqCVUH18UTWVm6Unp0vPB0 +kaySBKKBLWysYxApVvn4Nmy6+A0KHDmDJdJsMxZHme/ApNOGjpA+30QlOMZk0aey +5d7X9r5ETAVtDm1lGLItz8kCgYEAvtNqGdUxRaZlm7FA1nb3i8c+FN/i3nO9+6sQ +ASu/Zy6144vwe51jxS1wctiZW/sp0xoq1rXmjxmXgFLHWoZGrzv4oGZjWz4wdTh2 +tTvrtrj7MgVDrfvBBt+LHzrUkqmW67x5QOIbDYCwFKtoicGDHdpqmz70bC1ey0Ub +1fBr3rUCgYBETZ+eCuxICEjS8k6Dd4dpvCncA6z8h4WYbxbuA5k8hGyipzH5M8hJ +a7ZTz+owsPqZpG4OJm4iklTdVmdloVVKnv4d4Slj/2WaOxbvu9c7itwhCbL68mvV +kYy4Ls5LAo+s9YoqH8gOGj6yPWJEzye52qA9uh3jg9hRIOYLISR9UQKBgB+1baoB +PQC/1555Y7a/af72CqDZWw9v2B/bmvs208VHg73d4QYJbyyykj7jMwiPwbFsZbXr +3/XjYMNX/fxS16gCpRuyJ8xflxnDWiZfYJmqP0NekJJ2hOqpdqqn0e7U81kUpmlb +qPcjbR7iJKrPVwQ86P4HBgJ7v4azYx63ppUJAoGBAIGkZj8Klu4p5wEtM+n5ZY0B +Gm6mW77ukIF+M1U94CazxB1XXiQhPCnbLst6NkLLAYKb4Lkzu/ivlDYPXL18oNKZ +e0xBSnzMgExEnuJvwYNHPOF08MDKuSuCu2A0LLGB/CZoFMpvW0sHJlO2h3AY9nZR +TIh5VU9wuNH8zI0VvkRo +-----END PRIVATE KEY----- diff --git a/development/lspxnuts/certs/localhost.pem b/development/lspxnuts/certs/localhost.pem new file mode 100644 index 0000000000..216f8dd292 --- /dev/null +++ b/development/lspxnuts/certs/localhost.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDjTCCAnWgAwIBAgIUFTPO+pUk32QWsYyLYdlLTmlRWWgwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNTEyMDIwNzEwMDVa +Fw0yNjEyMDIwNzEwMDVaMEMxEjAQBgNVBAMMCWxvY2FsaG9zdDEQMA4GA1UECgwH +VGVzdE9yZzEPMA0GA1UEBwwGQXJuaGVtMQowCAYDVQQFEwExMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtltFIFPm4KjsJPNWIiq54j18TNl6Ouy1Y0P1 +ugXA8BCAYjNjJAq/o0g3XM2Qsp8l2UVUXwHHtFptMpkQleVOKPOMkuw4UopC8VIO +CQ2uwKEsxhapmFoxV1+SOnJRJxnA+C6ju8btW1vSTZSyEorzwXb5oyMAV02Kst2A +PfsSQkPNU1mB+cHZ9CEOG3gUXbQ5Q8UAwwr9TS2R7qFomjYM781W1GvmIdO7a+4m +Gk4Eiy0GZkV7EhPbpOkOmWReF5TXiqDgCoWSyG3CE1xmFbnbivIHG8cBMlrj82G8 +8UD4BWJcb2edt3PF7fxSW4ulMaBL7zE89s9VsmDylmYo/Zc3HQIDAQABo4GgMIGd +MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjA8BgNVHREENTAzoDEGA1UF +BaAqDCgyLjE2LjUyOC4xLjEwMDcuOTkuMjExMC0xLTEtUy0yLTAwLjAwMC0zMB0G +A1UdDgQWBBRMg47vcZ1skdvIXwh8FC/rv7MfTjAfBgNVHSMEGDAWgBSbsc9F8Dez +3XjIciX3H5u6ctQSvTANBgkqhkiG9w0BAQsFAAOCAQEACL8lz57c7UfGCV4btMuP +72qrkxSj5Ii+nrreUGc4uxR3G8FpBE++0W4PMK/wNp8IfvKFweujHH1DigQLKhRI +bHrFnsJdkZ7h/LtTEzxti/0OMLQ9J8DaZ9myPEdkO5Qn7zsoanyjzmNwCGXKJg2d +0cOxsO5Gys6wAkqkS2YsLO/kKI6IUTNvxyoziSap8kvKwIrAP2vAgTWCECT9fKJS +05kx7WFciN+STw6hkxEQNeStteAEgfXuLnrwQSeCPljrQSTNOYMy9B1uEs06C5QP +VHWmT43b9/YWEogxRQKJdM4toIvpFTGM8PonOpWyS77z6Ltnglaq8b5+QNjykYv7 +fw== +-----END CERTIFICATE----- diff --git a/development/lspxnuts/docker-compose.yml b/development/lspxnuts/docker-compose.yml index ab70709a07..b2ae118ccf 100644 --- a/development/lspxnuts/docker-compose.yml +++ b/development/lspxnuts/docker-compose.yml @@ -6,7 +6,17 @@ services: - "18081:8081" environment: NUTS_STRICTMODE: false - #volumes: - # - "./definitions/:/opt/nuts/definitions:ro" + NUTS_URL: "https://nuts.nl" + NUTS_AUTH_CONTRACTVALIDATORS: dummy + NUTS_HTTP_INTERNAL_ADDRESS: ":8081" healthcheck: - interval: 1s # Make test run quicker by checking health status more often \ No newline at end of file + interval: 1s # Make test run quicker by checking health status more often + + nutsadmin: + image: "nutsfoundation/nuts-admin:main" + environment: + - NUTS_NODE_ADDRESS=http://nutsnode:8081 + ports: + - "1405:1305" + depends_on: + - nutsnode diff --git a/development/lspxnuts/setup.sh b/development/lspxnuts/setup.sh new file mode 100755 index 0000000000..abe20c7d27 --- /dev/null +++ b/development/lspxnuts/setup.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +set -e + +# Configuration +NUTS_NODE_URL="http://localhost:18081" +SUBJECT_NAME="${SUBJECT_NAME:-testsubject}" +CERT_CHAIN="./certs/localhost-chain.pem" +CERT_KEY="./certs/localhost.key" +ISSUER_CN="${ISSUER_CN:-CN=Fake UZI Root CA}" + +echo "======================================" +echo "LSPxNuts Setup Script" +echo "======================================" +echo "" + +echo "------------------------------------" +echo "Creating Nuts subject..." +echo "------------------------------------" +REQUEST="{\"subject\":\"${SUBJECT_NAME}\"}" +RESPONSE=$(echo $REQUEST | curl -s -X POST --data-binary @- ${NUTS_NODE_URL}/internal/vdr/v2/subject --header "Content-Type: application/json") + +# Extract DID from response +DID=$(echo $RESPONSE | jq -r '.documents[0].id') + +if [ -z "$DID" ] || [ "$DID" = "null" ]; then + echo "ERROR: Failed to create subject or extract DID" + echo "Response: $RESPONSE" + exit 1 +fi + +echo "✓ Subject created successfully" +echo " Subject: ${SUBJECT_NAME}" +echo " DID: ${DID}" +echo "" + +echo "------------------------------------" +echo "Issuing X509Credential..." +echo "------------------------------------" + +# Check if certificate files exist +if [ ! -f "$CERT_CHAIN" ]; then + echo "ERROR: Certificate chain not found at $CERT_CHAIN" + exit 1 +fi + +if [ ! -f "$CERT_KEY" ]; then + echo "ERROR: Certificate key not found at $CERT_KEY" + exit 1 +fi + +# Issue X509 credential using go-didx509-toolkit Docker image +CREDENTIAL=$(docker run \ + --rm \ + -v "$(pwd)/${CERT_CHAIN}:/cert-chain.pem:ro" \ + -v "$(pwd)/${CERT_KEY}:/cert-key.key:ro" \ + nutsfoundation/go-didx509-toolkit:main \ + vc "/cert-chain.pem" "/cert-key.key" "${ISSUER_CN}" "${DID}") + +if [ -z "$CREDENTIAL" ]; then + echo "ERROR: Failed to generate X509Credential" + exit 1 +fi + +echo "✓ X509Credential generated" +echo "" + +echo "------------------------------------" +echo "Loading credential into wallet..." +echo "------------------------------------" + +# Store credential in wallet +HTTP_CODE=$(echo "\"${CREDENTIAL}\"" | curl -s -o /dev/null -w "%{http_code}" \ + -X POST --data-binary @- \ + ${NUTS_NODE_URL}/internal/vcr/v2/holder/${SUBJECT_NAME}/vc \ + -H "Content-Type:application/json") + +if [ "$HTTP_CODE" -eq 204 ]; then + echo "✓ X509Credential successfully stored in wallet" +else + echo "ERROR: Failed to load X509Credential in wallet (HTTP $HTTP_CODE)" + exit 1 +fi + +echo "" +echo "======================================" +echo "Setup completed successfully!" +echo "======================================" +echo "Subject: ${SUBJECT_NAME}" +echo "DID: ${DID}" +echo "" +echo "You can now use this credential for OAuth2 flows." +echo "" + From 921821f869743d90e290097ea05ec26b1553b254 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 4 Dec 2025 11:03:22 +0100 Subject: [PATCH 05/27] Issue mandaatcredential --- development/lspxnuts/setup.sh | 53 +++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/development/lspxnuts/setup.sh b/development/lspxnuts/setup.sh index abe20c7d27..5b9c33c23b 100755 --- a/development/lspxnuts/setup.sh +++ b/development/lspxnuts/setup.sh @@ -17,10 +17,12 @@ echo "" echo "------------------------------------" echo "Creating Nuts subject..." echo "------------------------------------" -REQUEST="{\"subject\":\"${SUBJECT_NAME}\"}" +#REQUEST="{\"subject\":\"${SUBJECT_NAME}\"}" +REQUEST="{}" RESPONSE=$(echo $REQUEST | curl -s -X POST --data-binary @- ${NUTS_NODE_URL}/internal/vdr/v2/subject --header "Content-Type: application/json") # Extract DID from response +SUBJECT_NAME=$(echo $RESPONSE | jq -r '.subject') DID=$(echo $RESPONSE | jq -r '.documents[0].id') if [ -z "$DID" ] || [ "$DID" = "null" ]; then @@ -82,6 +84,49 @@ else exit 1 fi +echo "" +echo "------------------------------------" +echo "Issuing MandaatCredential..." +echo "------------------------------------" + +# Issue a self-issued MandaatCredential +MANDAAT_REQUEST=$(cat < Date: Thu, 4 Dec 2025 13:49:47 +0100 Subject: [PATCH 06/27] #3953: add support for urn:ietf:params:oauth:grant-type:jwt-bearer for RFC021 flow --- auth/client/iam/openid4vp.go | 8 +++++++- auth/client/iam/openid4vp_test.go | 15 +++++++++++++++ auth/oauth/types.go | 5 ++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index eda07e096d..77cff91a3f 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -329,10 +329,16 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID presentationSubmission, _ := json.Marshal(submission) data := url.Values{} data.Set(oauth.ClientIDParam, clientID) - data.Set(oauth.GrantTypeParam, oauth.VpTokenGrantType) data.Set(oauth.AssertionParam, assertion) data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission)) data.Set(oauth.ScopeParam, scopes) + if slices.Contains(metadata.GrantTypesSupported, oauth.JWTBearerGrantType) { + // use JWT bearer grant type (e.g. authenticating at LSP GtK) + data.Set(oauth.GrantTypeParam, oauth.JWTBearerGrantType) + } else { + // use VP token grant type (as per Nuts RFC021) as default and fallback + data.Set(oauth.GrantTypeParam, oauth.VpTokenGrantType) + } // create DPoP header var dpopHeader string diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index a50d637f9e..f4559e07d7 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -325,6 +325,21 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { assert.Equal(t, "token", response.AccessToken) assert.Equal(t, "bearer", response.TokenType) }) + t.Run("grant_type urn:ietf:params:oauth:grant-type:jwt-bearer", func(t *testing.T) { + ctx := createClientServerTestContext(t) + // Set the authorization server to support JWT Bearer grant type + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JWTBearerGrantType} + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) + + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + + assert.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "token", response.AccessToken) + assert.Equal(t, "bearer", response.TokenType) + }) t.Run("ok with DPoPHeader", func(t *testing.T) { ctx := createClientServerTestContext(t) ctx.keyResolver.EXPECT().ResolveKey(primaryWalletDID, nil, resolver.NutsSigningKeyType).Return(primaryKID, nil, nil) diff --git a/auth/oauth/types.go b/auth/oauth/types.go index c0a6d769d2..f7f09c4703 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -21,9 +21,10 @@ package oauth import ( "encoding/json" + "net/url" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/nuts-foundation/nuts-node/core" - "net/url" ) // this file contains constants, variables and helper functions for OAuth related code @@ -205,6 +206,8 @@ const ( PreAuthorizedCodeGrantType = "urn:ietf:params:oauth:grant-type:pre-authorized_code" // VpTokenGrantType is the grant_type for the vp_token-bearer grant type. (RFC021) VpTokenGrantType = "vp_token-bearer" + // JWTBearerGrantType is the grant_type for the jwt-bearer grant type. (RFC7523) + JWTBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" ) // response types From 18d802378b620b4618648796153f72f82cb43dd3 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Tue, 16 Dec 2025 10:47:14 +0100 Subject: [PATCH 07/27] Relax did:x509 certificate key usage validation --- vdr/didx509/resolver.go | 4 +++- vdr/didx509/resolver_test.go | 25 +++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/vdr/didx509/resolver.go b/vdr/didx509/resolver.go index 38af9a321b..02b44126ce 100644 --- a/vdr/didx509/resolver.go +++ b/vdr/didx509/resolver.go @@ -23,11 +23,12 @@ import ( "crypto/x509" "errors" "fmt" + "strings" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "strings" ) const ( @@ -108,6 +109,7 @@ func (r Resolver) Resolve(id did.DID, metadata *resolver.ResolveMetadata) (*did. validatedChains, err := validationCert.Verify(x509.VerifyOptions{ Intermediates: core.NewCertPool(trustStore.IntermediateCAs), Roots: core.NewCertPool(trustStore.RootCAs), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, }) if err != nil { return nil, nil, fmt.Errorf("did:509 certificate chain validation failed: %w", err) diff --git a/vdr/didx509/resolver_test.go b/vdr/didx509/resolver_test.go index a9c56b3c50..f16beff4a8 100644 --- a/vdr/didx509/resolver_test.go +++ b/vdr/didx509/resolver_test.go @@ -23,6 +23,9 @@ import ( "crypto/x509" "encoding/base64" "fmt" + "strings" + "testing" + "github.com/lestrrat-go/jwx/v2/cert" "github.com/minio/sha256-simd" "github.com/nuts-foundation/go-did/did" @@ -30,8 +33,6 @@ import ( "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "strings" - "testing" ) func TestManager_Resolve(t *testing.T) { @@ -190,6 +191,26 @@ func TestManager_Resolve(t *testing.T) { _, _, err = didResolver.Resolve(rootDID, &metadata) require.ErrorContains(t, err, "did:509 certificate chain validation failed: x509: certificate signed by unknown authority") }) + t.Run("did:x509 from UZI card", func(t *testing.T) { + certsBase64 := []string{ + "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", + } + chain := new(cert.Chain) + for _, certB64 := range certsBase64 { + err := chain.Add([]byte(certB64)) + require.NoError(t, err) + } + uziDID := did.MustParseDID("did:x509:0:sha256:KY3NR_y2OphPtJev5NxWhxJ7A-4bNta8OTRnalCbIv4::subject:O:T%C3%A9st%20Zorginstelling%2001::san:otherName:2.16.528.1.1007.99.217-1-900030757-Z-90000380-01.015-00000000") + metadata := resolver.ResolveMetadata{} + metadata.JwtProtectedHeaders = make(map[string]interface{}) + metadata.JwtProtectedHeaders[X509CertChainHeader] = chain + _, _, err = didResolver.Resolve(uziDID, &metadata) + require.NoError(t, err) + + }) t.Run("x5c contains extra certs", func(t *testing.T) { metadata := resolver.ResolveMetadata{ JwtProtectedHeaders: map[string]interface{}{ From dccb5ed0dc5db3f0d2643bc725c75e911f565363 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Tue, 16 Dec 2025 10:53:49 +0100 Subject: [PATCH 08/27] Enable RS256 support --- auth/services/oauth/authz_server_test.go | 6 ++++-- crypto/jwx/algorithm.go | 2 +- crypto/jwx_test.go | 10 ++++++---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/auth/services/oauth/authz_server_test.go b/auth/services/oauth/authz_server_test.go index bb0cbc0771..335a85f810 100644 --- a/auth/services/oauth/authz_server_test.go +++ b/auth/services/oauth/authz_server_test.go @@ -27,11 +27,12 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/audit" - "github.com/nuts-foundation/nuts-node/vdr/resolver" "testing" "time" + "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jws" "github.com/lestrrat-go/jwx/v2/jwt" @@ -574,6 +575,7 @@ func TestService_parseAndValidateJwtBearerToken(t *testing.T) { }) t.Run("wrong signing algorithm", func(t *testing.T) { + t.Skip("LSPxNuts: enabled RS256 support") t.Setenv("GODEBUG", "rsa1024min=0") // minimum key-length has changed to 1024 -> https://pkg.go.dev/crypto/rsa#hdr-Minimum_key_size privateKey, err := rsa.GenerateKey(rand.Reader, 512) require.NoError(t, err) diff --git a/crypto/jwx/algorithm.go b/crypto/jwx/algorithm.go index 4f89d7684e..771d2829e2 100644 --- a/crypto/jwx/algorithm.go +++ b/crypto/jwx/algorithm.go @@ -27,7 +27,7 @@ import ( // ErrUnsupportedSigningKey is returned when an unsupported private key is used to sign. Currently only ecdsa and rsa keys are supported var ErrUnsupportedSigningKey = errors.New("signing key algorithm not supported") -var SupportedAlgorithms = []jwa.SignatureAlgorithm{jwa.ES256, jwa.EdDSA, jwa.ES384, jwa.ES512, jwa.PS256, jwa.PS384, jwa.PS512} +var SupportedAlgorithms = []jwa.SignatureAlgorithm{jwa.ES256, jwa.EdDSA, jwa.ES384, jwa.ES512, jwa.RS256, jwa.PS256, jwa.PS384, jwa.PS512} const DefaultRsaEncryptionAlgorithm = jwa.RSA_OAEP_256 const DefaultEcEncryptionAlgorithm = jwa.ECDH_ES_A256KW diff --git a/crypto/jwx_test.go b/crypto/jwx_test.go index b42e45a5dc..6dac69e449 100644 --- a/crypto/jwx_test.go +++ b/crypto/jwx_test.go @@ -29,13 +29,14 @@ import ( "encoding/json" "errors" "fmt" + "io" + "testing" + "time" + "github.com/nuts-foundation/nuts-node/crypto/jwx" "github.com/nuts-foundation/nuts-node/crypto/storage/spi" "github.com/nuts-foundation/nuts-node/storage/orm" "go.uber.org/mock/gomock" - "io" - "testing" - "time" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwe" @@ -120,6 +121,7 @@ func TestSignJWT(t *testing.T) { func TestParseJWT(t *testing.T) { t.Run("unsupported algorithm", func(t *testing.T) { + t.Skip("LSPxNuts: enabled RS256 support") rsaKey := test.GenerateRSAKey() token := jwt.New() signature, _ := jwt.Sign(token, jwt.WithKey(jwa.RS256, rsaKey)) @@ -581,7 +583,7 @@ func TestCrypto_convertHeaders(t *testing.T) { func Test_isAlgorithmSupported(t *testing.T) { assert.True(t, jwx.IsAlgorithmSupported(jwa.PS256)) - assert.False(t, jwx.IsAlgorithmSupported(jwa.RS256)) + assert.True(t, jwx.IsAlgorithmSupported(jwa.RS256)) assert.False(t, jwx.IsAlgorithmSupported("")) } From 206e5e1c480a89e5a50a33128c1a2977c199dc1e Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Tue, 16 Dec 2025 12:31:41 +0100 Subject: [PATCH 09/27] Add AortaGtK CA certs to OS trust bundle --- Dockerfile | 5 ++- development/lspxnuts/docker-compose.yml | 2 +- ...Public Server Authentication CA OV R36.crt | 36 +++++++++++++++++++ ... Public Server Authentication Root R46.crt | 32 +++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 pki/cacerts/Sectigo Public Server Authentication CA OV R36.crt create mode 100644 pki/cacerts/Sectigo Public Server Authentication Root R46.crt diff --git a/Dockerfile b/Dockerfile index c134ccab26..aa61a21d4f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,10 @@ FROM alpine:3.22.2 RUN apk update \ && apk add --no-cache \ tzdata \ - curl + curl \ + ca-certificates +COPY pki/cacerts/* /usr/local/share/ca-certificates/ +RUN update-ca-certificates COPY --from=builder /opt/nuts/nuts /usr/bin/nuts HEALTHCHECK --start-period=30s --timeout=5s --interval=10s \ diff --git a/development/lspxnuts/docker-compose.yml b/development/lspxnuts/docker-compose.yml index b2ae118ccf..9c04cdd98e 100644 --- a/development/lspxnuts/docker-compose.yml +++ b/development/lspxnuts/docker-compose.yml @@ -1,7 +1,7 @@ services: nutsnode: image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:lspxnuts}" - pull_policy: always + #pull_policy: always ports: - "18081:8081" environment: diff --git a/pki/cacerts/Sectigo Public Server Authentication CA OV R36.crt b/pki/cacerts/Sectigo Public Server Authentication CA OV R36.crt new file mode 100644 index 0000000000..4440a7378d --- /dev/null +++ b/pki/cacerts/Sectigo Public Server Authentication CA OV R36.crt @@ -0,0 +1,36 @@ +-----BEGIN CERTIFICATE----- +MIIGTDCCBDSgAwIBAgIQLBo8dulD3d3/GRsxiQrtcTANBgkqhkiG9w0BAQwFADBf +MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD +Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw +HhcNMjEwMzIyMDAwMDAwWhcNMzYwMzIxMjM1OTU5WjBgMQswCQYDVQQGEwJHQjEY +MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQDEy5TZWN0aWdvIFB1Ymxp +YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gQ0EgT1YgUjM2MIIBojANBgkqhkiG9w0B +AQEFAAOCAY8AMIIBigKCAYEApkMtJ3R06jo0fceI0M52B7K+TyMeGcv2BQ5AVc3j +lYt76TvHIu/nNe22W/RJXX9rWUD/2GE6GF5x0V4bsY7K3IeJ8E7+KzG/TGboySfD +u+F52jqQBbY62ofhYjMeiAbLI02+FqwHeM8uIrUtcX8b2RCxF358TB0NHVccAXZc +FYgZndZCeXxjuca7pJJ20LLUnXtgXcjAE1vY4WvbReW0W6mkeZyNGdmpTcFs5Y+s +yy6LtE5Zocji9J9NlNnReox2RWVyEXpA1ChZ4gqN+ZpVSIQ0HBorVFbBKyhdZyEX +gZgNSNtBRwxqwIzJePJhYd4ZUhO1vk+/uP3nwDk0p95q/j7naXNCSvESnrHPypaB +WRK066nKfPRPi9m9kIOhMdYfS8giFRTcdgL24Ycilj7ecAK9Trh0VbjwouJ4WH+x +bt47u68ZFCD/ac55I0DNHkCpaPruj6e9Rmr7K46wZDAYXuEAqB7tGG/jd6JAA+H2 +O44CV98NRsU213f1kScIZntNAgMBAAGjggGBMIIBfTAfBgNVHSMEGDAWgBRWc1hk +lfmSGrASKgRieaFAFYghSTAdBgNVHQ4EFgQU42Z0u3BojSxdTg6mSo+bNyKcgpIw +DgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYI +KwYBBQUHAwEGCCsGAQUFBwMCMBsGA1UdIAQUMBIwBgYEVR0gADAIBgZngQwBAgIw +VAYDVR0fBE0wSzBJoEegRYZDaHR0cDovL2NybC5zZWN0aWdvLmNvbS9TZWN0aWdv +UHVibGljU2VydmVyQXV0aGVudGljYXRpb25Sb290UjQ2LmNybDCBhAYIKwYBBQUH +AQEEeDB2ME8GCCsGAQUFBzAChkNodHRwOi8vY3J0LnNlY3RpZ28uY29tL1NlY3Rp +Z29QdWJsaWNTZXJ2ZXJBdXRoZW50aWNhdGlvblJvb3RSNDYucDdjMCMGCCsGAQUF +BzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG9w0BAQwFAAOCAgEA +BZXWDHWC3cubb/e1I1kzi8lPFiK/ZUoH09ufmVOrc5ObYH/XKkWUexSPqRkwKFKr +7r8OuG+p7VNB8rifX6uopqKAgsvZtZsq7iAFw04To6vNcxeBt1Eush3cQ4b8nbQR +MQLChgEAqwhuXp9P48T4QEBSksYav7+aFjNySsLYlPzNqVM3RNwvBdvp6vgDtGwc +xlKQZVuuNVIaoYyls8swhxDeSHKpRdxRauTLZ+pl+wGvy0pnrLEJGSz9mOEmfbod +e/XopR2NGqaHJ6bIjyxPu6UtyQGI26En7UAEozACrHz06Nx2jTAY9E6NeB6XuobE +wLK025ZRmvglcURG1BrV24tGHHTgxCe8M3oGlpUSMTKQ2dkgljZVYt+gKdFtWELZ +MuRdi+X3XsrR8LFz+aLUiDRfQqhmw3RxjIyVKvvu9UPYY1nsvxYmFnUSeM+2q1z/ +iPUry+xDY9MC6+IhleKT094VKdFVp7LXH42+wvU+17lRolQ2mK2N/nBLVBwaIhib +QXw4VYKwB86Bc6eS6iqsc94KEgD/U4VsjmgfhK+Xp4NM+VYzTTa3QeV3p8xOM0cw +q1p8oZFA+OBcz3FYWpDIe5j0NWKlw9hXsTyPY/HeZUV59akskSOSRSmDfe8wJDPX +58uB9/7lud0G3x0pxQAcffP0ayKavNwDTw4UfJ34cEw= +-----END CERTIFICATE----- diff --git a/pki/cacerts/Sectigo Public Server Authentication Root R46.crt b/pki/cacerts/Sectigo Public Server Authentication Root R46.crt new file mode 100644 index 0000000000..71afc161d9 --- /dev/null +++ b/pki/cacerts/Sectigo Public Server Authentication Root R46.crt @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFijCCA3KgAwIBAgIQdY39i658BwD6qSWn4cetFDANBgkqhkiG9w0BAQwFADBf +MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD +Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw +HhcNMjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1OTU5WjBfMQswCQYDVQQGEwJHQjEY +MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1Ymxp +YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQCTvtU2UnXYASOgHEdCSe5jtrch/cSV1UgrJnwUUxDa +ef0rty2k1Cz66jLdScK5vQ9IPXtamFSvnl0xdE8H/FAh3aTPaE8bEmNtJZlMKpnz +SDBh+oF8HqcIStw+KxwfGExxqjWMrfhu6DtK2eWUAtaJhBOqbchPM8xQljeSM9xf +iOefVNlI8JhD1mb9nxc4Q8UBUQvX4yMPFF1bFOdLvt30yNoDN9HWOaEhUTCDsG3X +ME6WW5HwcCSrv0WBZEMNvSE6Lzzpng3LILVCJ8zab5vuZDCQOc2TZYEhMbUjUDM3 +IuM47fgxMMxF/mL50V0yeUKH32rMVhlATc6qu/m1dkmU8Sf4kaWD5QazYw6A3OAS +VYCmO2a0OYctyPDQ0RTp5A1NDvZdV3LFOxxHVp3i1fuBYYzMTYCQNFu31xR13NgE +SJ/AwSiItOkcyqex8Va3e0lMWeUgFaiEAin6OJRpmkkGj80feRQXEgyDet4fsZfu ++Zd4KKTIRJLpfSYFplhym3kT2BFfrsU4YjRosoYwjviQYZ4ybPUHNs2iTG7sijbt +8uaZFURww3y8nDnAtOFr94MlI1fZEoDlSfB1D++N6xybVCi0ITz8fAr/73trdf+L +HaAZBav6+CuBQug4urv7qv094PPK306Xlynt8xhW6aWWrL3DkJiy4Pmi1KZHQ3xt +zwIDAQABo0IwQDAdBgNVHQ4EFgQUVnNYZJX5khqwEioEYnmhQBWIIUkwDgYDVR0P +AQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAC9c +mTz8Bl6MlC5w6tIyMY208FHVvArzZJ8HXtXBc2hkeqK5Duj5XYUtqDdFqij0lgVQ +YKlJfp/imTYpE0RHap1VIDzYm/EDMrraQKFz6oOht0SmDpkBm+S8f74TlH7Kph52 +gDY9hAaLMyZlbcp+nv4fjFg4exqDsQ+8FxG75gbMY/qB8oFM2gsQa6H61SilzwZA +Fv97fRheORKkU55+MkIQpiGRqRxOF3yEvJ+M0ejf5lG5Nkc/kLnHvALcWxxPDkjB +JYOcCj+esQMzEhonrPcibCTRAUH4WAP+JWgiH5paPHxsnnVI84HxZmduTILA7rpX +DhjvLpr3Etiga+kFpaHpaPi8TD8SHkXoUsCjvxInebnMMTzD9joiFgOgyY9mpFui +TdaBJQbpdqQACj7LzTWb4OE4y2BThihCQRxEV+ioratF4yUQvNs+ZUH7G6aXD+u5 +dHn5HrwdVw1Hr8Mvn4dGp+smWg9WY7ViYG4A++MnESLn/pmPNPW56MORcr3Ywx65 +LvKRRFHQV80MNNVIIb/bE/FmJUNS0nAiNs2fxBx1IK1jcmMGDw4nztJqDby1ORrp +0XZ60Vzk50lJLVU3aPAaOpg+VBeHVOmmJ1CJeyAvP/+/oYtKR5j/K3tJPsMpRmAY +QqszKbrAKbkTidOIijlBO8n9pu0f9GBj39ItVQGL +-----END CERTIFICATE----- From 219635f6403220d4857b15d84a4e57a73aad1df3 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Tue, 16 Dec 2025 13:04:54 +0100 Subject: [PATCH 10/27] Add EV intermediate CA to trusted certs --- development/lspxnuts/docker-compose.yml | 3 + development/lspxnuts/policies/lspxnuts.json | 225 ++++++++++++++++++ ...Public Server Authentication CA EV R36.crt | 36 +++ 3 files changed, 264 insertions(+) create mode 100644 development/lspxnuts/policies/lspxnuts.json create mode 100644 pki/cacerts/Sectigo Public Server Authentication CA EV R36.crt diff --git a/development/lspxnuts/docker-compose.yml b/development/lspxnuts/docker-compose.yml index 9c04cdd98e..8b8a824e2b 100644 --- a/development/lspxnuts/docker-compose.yml +++ b/development/lspxnuts/docker-compose.yml @@ -9,6 +9,9 @@ services: NUTS_URL: "https://nuts.nl" NUTS_AUTH_CONTRACTVALIDATORS: dummy NUTS_HTTP_INTERNAL_ADDRESS: ":8081" + NUTS_POLICY_DIRECTORY: /opt/nuts/policies + volumes: + - ./policies:/opt/nuts/policies:ro healthcheck: interval: 1s # Make test run quicker by checking health status more often diff --git a/development/lspxnuts/policies/lspxnuts.json b/development/lspxnuts/policies/lspxnuts.json new file mode 100644 index 0000000000..6c2ab9e157 --- /dev/null +++ b/development/lspxnuts/policies/lspxnuts.json @@ -0,0 +1,225 @@ +{ + "nuts-lsp": { + "organization": { + "format": { + "ldp_vc": { + "proof_type": [ + "JsonWebSignature2020" + ] + }, + "jwt_vc": { + "alg": [ + "PS256", + "RS256" + ] + }, + "jwt_vp": { + "alg": [ + "ES256" + ] + } + }, + "id": "pd_any_care_organization", + "name": "Care organization", + "purpose": "Finding a care organization", + "input_descriptors": [ + { + "id": "id_uzicert_uracredential", + "name": "Care organization identity from fake UZI-server certificate", + "purpose": "Finding a care organization for authorizing access to medical metadata.", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "X509Credential" + } + }, + { + "path": [ + "$.issuer" + ], + "purpose": "We can only accept credentials from a trusted issuer", + "filter": { + "type": "string", + "pattern": "^did:x509:0:sha256:GwlhBZuEFlSHXSRUXQuTs3_YpQxAahColwJJj35US1A[\\w\\-.:%]*$" + } + }, + { + "id": "organization_id", + "path": [ + "$.credentialSubject[0].id", + "$.credentialSubject.id" + ], + "filter": { + "type": "string", + "pattern": "^did:web:" + } + }, + { + "id": "organization_name", + "path": [ + "$.credentialSubject[0].subject.O", + "$.credentialSubject.subject.O" + ], + "filter": { + "type": "string" + } + }, + { + "id": "organization_ura", + "path": [ + "$.credentialSubject[0].san.otherName", + "$.credentialSubject.san.otherName" + ], + "filter": { + "type": "string", + "pattern": "^[0-9.]+-\\d+-\\d+-S-(\\d+)-00\\.000-\\d+$" + } + }, + { + "id": "organization_city", + "path": [ + "$.credentialSubject[0].subject.L", + "$.credentialSubject.subject.L" + ], + "filter": { + "type": "string" + } + } + ] + } + }, + { + "id": "id_patient_enrollment", + "name": "The patient enrollment credential", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "PatientEnrollmentCredential" + } + }, + { + "path": [ + "$.issuer" + ], + "purpose": "We can only accept credentials from a trusted issuer", + "filter": { + "type": "string", + "pattern": "^did:x509:0:sha256:KY3NR_y2OphPtJev5NxWhxJ7A-4bNta8OTRnalCbIv4[\\w\\-.:%]*$" + } + }, + { + "id": "organization_id", + "path": [ + "$.credentialSubject[0].id", + "$.credentialSubject.id" + ], + "filter": { + "type": "string", + "pattern": "^did:web:" + } + }, + { + "id": "patient_id", + "path": [ + "$.credentialSubject[0].patientId", + "$.credentialSubject.patientId" + ], + "filter": { + "type": "string" + } + }, + { + "id": "registered_by", + "path": [ + "$.credentialSubject[0].registeredBy", + "$.credentialSubject.registeredBy" + ], + "filter": { + "type": "string" + } + } + ] + } + }, + { + "id": "id_healthcare_professional_delegation", + "name": "The healthcare professional delegation credential", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "HealthCareProfessionalDelegationCredential" + } + }, + { + "path": [ + "$.issuer" + ], + "purpose": "We can only accept credentials from a trusted issuer", + "filter": { + "type": "string", + "pattern": "^did:x509:0:sha256:KY3NR_y2OphPtJev5NxWhxJ7A-4bNta8OTRnalCbIv4[\\w\\-.:%]*$" + } + }, + { + "id": "organization_id", + "path": [ + "$.credentialSubject[0].id", + "$.credentialSubject.id" + ], + "filter": { + "type": "string", + "pattern": "^did:web:" + } + }, + { + "id": "registered_by", + "path": [ + "$.credentialSubject[0].registeredBy", + "$.credentialSubject.registeredBy" + ], + "filter": { + "type": "string" + } + }, + { + "id": "role_code", + "path": [ + "$.credentialSubject[0].roleCode", + "$.credentialSubject.roleCode" + ], + "filter": { + "type": "string" + } + }, + { + "id": "authorization_rule", + "path": [ + "$.credentialSubject[0].authorizationRule", + "$.credentialSubject.authorizationRule" + ], + "filter": { + "type": "string" + } + } + ] + } + } + ] + } + } +} \ No newline at end of file diff --git a/pki/cacerts/Sectigo Public Server Authentication CA EV R36.crt b/pki/cacerts/Sectigo Public Server Authentication CA EV R36.crt new file mode 100644 index 0000000000..c53f9c8a70 --- /dev/null +++ b/pki/cacerts/Sectigo Public Server Authentication CA EV R36.crt @@ -0,0 +1,36 @@ +-----BEGIN CERTIFICATE----- +MIIGSzCCBDOgAwIBAgIQbU98rTNTd8jG4AHd4uLIjjANBgkqhkiG9w0BAQwFADBf +MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD +Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw +HhcNMjEwMzIyMDAwMDAwWhcNMzYwMzIxMjM1OTU5WjBgMQswCQYDVQQGEwJHQjEY +MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQDEy5TZWN0aWdvIFB1Ymxp +YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gQ0EgRVYgUjM2MIIBojANBgkqhkiG9w0B +AQEFAAOCAY8AMIIBigKCAYEApduwxkQH5noeb0k4yRXK47Fd+hVkH8twKWQF3FNm +HHShQrOH7S0L3p8Aaf8P7OqojwyZgyqD5o/Sb95N45P/NfmL+lfAOkQ1Bpvz0OcZ +VmwYhMWFYJe8parkz5w9Bk8mn/AtN65hIQF0NqBI6F+23/jzhlpl3E0zKkkUJSfT +dgvwaJvTiPgLQ7CpYyLJvMpgdPHf+nnvA5YJjhae6olh6BRllvRhzKOq0gd60BrO +Aos9QyOfYhUDG3eQovMA9fyiI2qNclCp6DY8hqt55lERoBS/Whza8Cx1bh6q8rTP +nno2uI6FZb2UavIYblyGDamXAW/jv3qsMB42xz/p2mUHg0wQzn/8KAiyu2ZbDs7E +OOwYx0V2ViNsCXGR/GCmJsUNPmhEEXBclOj6qF7rFf7nkST2bokSt9VF1NB1JgRl +djZin1+v2Vm88UEgFxerQPPrPjAEMqDx44vKl22sGWnl+jOyxf/H59DHKbIezrIY +rJpSnA3WEVaiXs1oIRy1ZziNAgMBAAGjggGAMIIBfDAfBgNVHSMEGDAWgBRWc1hk +lfmSGrASKgRieaFAFYghSTAdBgNVHQ4EFgQUmC1eHo/rVPS5/1WVrUzHfqSYrnsw +DgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYI +KwYBBQUHAwEGCCsGAQUFBwMCMBoGA1UdIAQTMBEwBgYEVR0gADAHBgVngQwBATBU +BgNVHR8ETTBLMEmgR6BFhkNodHRwOi8vY3JsLnNlY3RpZ28uY29tL1NlY3RpZ29Q +dWJsaWNTZXJ2ZXJBdXRoZW50aWNhdGlvblJvb3RSNDYuY3JsMIGEBggrBgEFBQcB +AQR4MHYwTwYIKwYBBQUHMAKGQ2h0dHA6Ly9jcnQuc2VjdGlnby5jb20vU2VjdGln +b1B1YmxpY1NlcnZlckF1dGhlbnRpY2F0aW9uUm9vdFI0Ni5wN2MwIwYIKwYBBQUH +MAGGF2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMA0GCSqGSIb3DQEBDAUAA4ICAQA2 +LRCehSXiyw56alKt2aqDwDLEDmyu74lUmDG4rI1Jw3+ivLz+OK3NpE466a8yXu7b +f326wkrrOCF2SVu3ItNjXqsgwXdIM9tapVEKjsEwbjcPUcxNeqzSpLSgHZIVhjJJ ++QtdEL2n1NhDxqewlN+ZKWWBLGB0LA/S7SvCl9s7rsppo97uv6J3h//q4b2SgzHu +KUu80Hofpjp2bILGHy4CqzKzyg3wn9sKFcX6Ufkcea/tuiWhIMPkiQCv5tXsRj+H +n3CZql+wRPbjhNqncCkzrwOBrt7Gov46EHhqMUP/Euf8gSw7X+CzWEUJ0Q+Vn8VL +ZDkiKcMSYMJI+6Trc5tVn9LQLI6tTzwagLhgcVtFpom3PVtEJRPN/d05L0ck7S1b +xPaXcQhKUqdzFd09pt6fLx2vMmylBKXjKosSxjB12KRzLvI2CUUVdpl7OTFA32Fk ++zQC2gJPGmPPCzRMRYfVOEIZxbG0nI9lpYZidLkQ/EyTtJ3JVj7cb3KRe/e1s+Xj +rnHqbUwoizicfogtcKm5giwj74f+Rpr0RRy4xum1bC662fNHRJaTt5ErVR6gHjHM +Zhp+tOeOvohq5XxIMCKuetnwcPeBsOzUBzmUPoxQNRVXEYbwV2rS3t2jJ0Zh/Dnn +O96biaAsEnxbQyu4fvMx6D4J4Zbkc7Zp75T3GE/UjQ== +-----END CERTIFICATE----- From 12f6e9ef4bd9ed644fa135b3a487e1487590c3d3 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Tue, 16 Dec 2025 13:46:33 +0100 Subject: [PATCH 11/27] Don't send presentation_submission --- auth/client/iam/openid4vp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 77cff91a3f..331c05e496 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -330,7 +330,6 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID data := url.Values{} data.Set(oauth.ClientIDParam, clientID) data.Set(oauth.AssertionParam, assertion) - data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission)) data.Set(oauth.ScopeParam, scopes) if slices.Contains(metadata.GrantTypesSupported, oauth.JWTBearerGrantType) { // use JWT bearer grant type (e.g. authenticating at LSP GtK) @@ -338,6 +337,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID } else { // use VP token grant type (as per Nuts RFC021) as default and fallback data.Set(oauth.GrantTypeParam, oauth.VpTokenGrantType) + data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission)) } // create DPoP header From 14358d96af7743c25ba12abced5e394db59e2230 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Tue, 16 Dec 2025 14:59:06 +0100 Subject: [PATCH 12/27] Introduce policy_id parameter --- auth/api/iam/api.go | 9 ++++-- auth/api/iam/api_test.go | 18 +++++------ auth/api/iam/generated.go | 6 ++++ auth/client/iam/interface.go | 3 +- auth/client/iam/mock.go | 8 ++--- auth/client/iam/openid4vp.go | 12 ++++--- auth/client/iam/openid4vp_test.go | 38 ++++++++++++++++------- docs/_static/auth/v2.yaml | 7 +++++ e2e-tests/browser/client/iam/generated.go | 6 ++++ 9 files changed, 75 insertions(+), 32 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index c3affbcf97..92a74427cf 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -29,7 +29,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/core/to" "html/template" "net/http" "net/url" @@ -37,6 +36,8 @@ import ( "strings" "time" + "github.com/nuts-foundation/nuts-node/core/to" + "github.com/labstack/echo/v4" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" @@ -774,7 +775,11 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS useDPoP = false } clientID := r.subjectToBaseURL(request.SubjectID) - tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials) + var policyId string + if request.Body.PolicyId != nil { + policyId = *request.Body.PolicyId + } + tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, policyId, useDPoP, credentials) if err != nil { // this can be an internal server error, a 400 oauth error or a 412 precondition failed if the wallet does not contain the required credentials return nil, err diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index ed973f375f..bb227c73d1 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -873,7 +873,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} request.Params.CacheControl = to.Ptr("no-cache") // Initial call to populate cache - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(response, nil).Times(2) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(response, nil).Times(2) token, err := ctx.client.RequestServiceAccessToken(nil, request) // Test call to check cache is bypassed @@ -894,7 +894,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { TokenType: "Bearer", ExpiresIn: to.Ptr(900), } - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(response, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(response, nil) token, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -933,7 +933,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { t.Run("cache expired", func(t *testing.T) { cacheKey := accessTokenRequestCacheKey(request) _ = ctx.client.accessTokenCache().Delete(cacheKey) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil) otherToken, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -950,7 +950,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { Scope: "first second", TokenType: &tokenTypeBearer, } - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", false, nil).Return(&oauth.TokenResponse{}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", false, nil).Return(&oauth.TokenResponse{}, nil) _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}) @@ -959,7 +959,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { t.Run("ok with expired cache by ttl", func(t *testing.T) { ctx := newTestClient(t) request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) _, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -968,7 +968,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { }) t.Run("error - no matching credentials", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(nil, pe.ErrNoCredentials) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(nil, pe.ErrNoCredentials) _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}) @@ -984,8 +984,8 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { ctx.client.storageEngine = mockStorage request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil) token1, err := ctx.client.RequestServiceAccessToken(nil, request) require.NoError(t, err) @@ -1010,7 +1010,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { {ID: to.Ptr(ssi.MustParseURI("not empty"))}, } request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, *body.Credentials).Return(response, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, *body.Credentials).Return(response, nil) _, err := ctx.client.RequestServiceAccessToken(nil, request) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 5dbe21544d..9e23229944 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -146,6 +146,12 @@ type ServiceAccessTokenRequest struct { // - proof/signature (MUST be omitted; integrity protection is covered by the VP's proof/signature) Credentials *[]VerifiableCredential `json:"credentials,omitempty"` + // PolicyId (Optional) The ID of the policy to use when requesting the access token. + // If set the presentation definition is resolved from the policy with this ID. + // This allows you to specify scopes that don't resolve to a presentation definition automatically. + // If not set, the scope is used to resolve the presentation definition. + PolicyId *string `json:"policy_id,omitempty"` + // Scope The scope that will be the service for which this access token can be used. Scope string `json:"scope"` diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 5016708d9d..3065de01f2 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -20,6 +20,7 @@ package iam import ( "context" + "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -44,7 +45,7 @@ type Client interface { // PresentationDefinition returns the presentation definition from the given endpoint. PresentationDefinition(ctx context.Context, endpoint string) (*pe.PresentationDefinition, error) // RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote OAuth2 Authorization Server using Nuts RFC021. - RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, + RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, policyId string, useDPoP bool, credentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) // OpenIdCredentialIssuerMetadata returns the metadata of the remote credential issuer. diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index add1a059cd..1d49452ebb 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -194,18 +194,18 @@ func (mr *MockClientMockRecorder) RequestObjectByPost(ctx, requestURI, walletMet } // RequestRFC021AccessToken mocks base method. -func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) { +func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes, policyId string, useDPoP bool, credentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials) + ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, policyId, useDPoP, credentials) ret0, _ := ret[0].(*oauth.TokenResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // RequestRFC021AccessToken indicates an expected call of RequestRFC021AccessToken. -func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials any) *gomock.Call { +func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, policyId, useDPoP, credentials any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, policyId, useDPoP, credentials) } // VerifiableCredentials mocks base method. diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 331c05e496..d3f54e62b2 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -239,16 +239,20 @@ func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEnd } func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, - useDPoP bool, additionalCredentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) { + policyId string, useDPoP bool, additionalCredentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) { iamClient := c.httpClient metadata, err := c.AuthorizationServerMetadata(ctx, authServerURL) if err != nil { return nil, err } + // if no policyId is provided, use the scopes as policyId + if policyId == "" { + policyId = scopes + } // LSPxNuts: get the presentation definition from local definitions, if available var presentationDefinition *pe.PresentationDefinition - presentationDefinitionMap, err := c.policyBackend.PresentationDefinitions(ctx, scopes) + presentationDefinitionMap, err := c.policyBackend.PresentationDefinitions(ctx, policyId) if errors.Is(err, policy.ErrNotFound) { // not found locally, get from verifier // get the presentation definition from the verifier @@ -257,7 +261,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID return nil, err } presentationDefinitionURL := nutsHttp.AddQueryParams(*parsedURL, map[string]string{ - "scope": scopes, + "scope": policyId, }) presentationDefinition, err = c.PresentationDefinition(ctx, presentationDefinitionURL.String()) if err != nil { @@ -268,7 +272,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID } else { // found locally if len(presentationDefinitionMap) != 1 { - return nil, fmt.Errorf("expected exactly one presentation definition for scope '%s', found %d", scopes, len(presentationDefinitionMap)) + return nil, fmt.Errorf("expected exactly one presentation definition for policy/scope '%s', found %d", policyId, len(presentationDefinitionMap)) } for _, pd := range presentationDefinitionMap { presentationDefinition = &pd diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index f4559e07d7..27bed126f3 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -256,20 +256,34 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) assert.NoError(t, err) require.NotNil(t, response) assert.Equal(t, "token", response.AccessToken) assert.Equal(t, "bearer", response.TokenType) }) + t.Run("ok with policy ID that differs from scope", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), "some-policy").Return(nil, policy.ErrNotFound) + + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "some-policy", false, nil) + + assert.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "token", response.AccessToken) + assert.Equal(t, "bearer", response.TokenType) + assert.Equal(t, "first second", *response.Scope) + }) t.Run("no DID fulfills the Presentation Definition", func(t *testing.T) { ctx := createClientServerTestContext(t) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, pe.ErrNoCredentials) ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) assert.ErrorIs(t, err, pe.ErrNoCredentials) assert.Nil(t, response) @@ -280,7 +294,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrPreconditionFailed) @@ -318,7 +332,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { return createdVP, &pe.PresentationSubmission{}, nil }) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, credentials) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, credentials) assert.NoError(t, err) require.NotNil(t, response) @@ -333,7 +347,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -348,7 +362,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", true, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -363,7 +377,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { }, nil) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), pd, gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) assert.NoError(t, err) require.NotNil(t, response) }) @@ -383,7 +397,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) require.Error(t, err) oauthError, ok := err.(oauth.OAuth2Error) @@ -401,7 +415,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { return } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) require.Error(t, err) assert.True(t, errors.As(err, &oauth.OAuth2Error{})) @@ -411,7 +425,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.metadata = nil - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrInvalidClientCall) @@ -426,7 +440,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { _, _ = writer.Write([]byte("{")) } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrBadGateway) @@ -438,7 +452,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, assert.AnError) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) assert.Error(t, err) }) diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index c032a1ff64..7e2c653248 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -410,6 +410,13 @@ components: used to locate the OAuth2 Authorization Server metadata. type: string example: https://example.com/oauth2 + policy_id: + type: string + description: | + (Optional) The ID of the policy to use when requesting the access token. + If set the presentation definition is resolved from the policy with this ID. + This allows you to specify scopes that don't resolve to a presentation definition automatically. + If not set, the scope is used to resolve the presentation definition. scope: type: string description: The scope that will be the service for which this access token can be used. diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 9ed0f8cbac..259b8a8b99 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -140,6 +140,12 @@ type ServiceAccessTokenRequest struct { // - proof/signature (MUST be omitted; integrity protection is covered by the VP's proof/signature) Credentials *[]VerifiableCredential `json:"credentials,omitempty"` + // PolicyId (Optional) The ID of the policy to use when requesting the access token. + // If set the presentation definition is resolved from the policy with this ID. + // This allows you to specify scopes that don't resolve to a presentation definition automatically. + // If not set, the scope is used to resolve the presentation definition. + PolicyId *string `json:"policy_id,omitempty"` + // Scope The scope that will be the service for which this access token can be used. Scope string `json:"scope"` From 708ad5a9bb41899a4979e7e96f696a6c185ac34a Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Tue, 16 Dec 2025 16:16:09 +0100 Subject: [PATCH 13/27] Try to marshal VPs as JWT, not JSON-LD --- vcr/holder/presenter.go | 10 ++++++---- vcr/holder/sql_wallet.go | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/vcr/holder/presenter.go b/vcr/holder/presenter.go index 182f434d41..f00f77db1f 100644 --- a/vcr/holder/presenter.go +++ b/vcr/holder/presenter.go @@ -23,6 +23,9 @@ import ( "encoding/json" "errors" "fmt" + "strings" + "time" + "github.com/google/uuid" "github.com/lestrrat-go/jwx/v2/jws" "github.com/lestrrat-go/jwx/v2/jwt" @@ -37,8 +40,6 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/piprate/json-gold/ld" - "strings" - "time" ) type presenter struct { @@ -128,15 +129,16 @@ func (p presenter) buildJWTPresentation(ctx context.Context, subjectDID did.DID, } id := did.DIDURL{DID: subjectDID} id.Fragment = strings.ToLower(uuid.NewString()) + type VPAlias vc.VerifiablePresentation claims := map[string]interface{}{ jwt.SubjectKey: subjectDID.String(), jwt.JwtIDKey: id.String(), - "vp": vc.VerifiablePresentation{ + "vp": VPAlias(vc.VerifiablePresentation{ Context: append([]ssi.URI{VerifiableCredentialLDContextV1}, options.AdditionalContexts...), Type: append([]ssi.URI{VerifiablePresentationLDType}, options.AdditionalTypes...), Holder: options.Holder, VerifiableCredential: credentials, - }, + }), } if options.ProofOptions.Nonce != nil { claims["nonce"] = *options.ProofOptions.Nonce diff --git a/vcr/holder/sql_wallet.go b/vcr/holder/sql_wallet.go index d837c273e0..f0e376f0f8 100644 --- a/vcr/holder/sql_wallet.go +++ b/vcr/holder/sql_wallet.go @@ -22,6 +22,8 @@ import ( "context" "errors" "fmt" + "time" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -39,7 +41,6 @@ import ( "github.com/nuts-foundation/nuts-node/vdr/resolver" "gorm.io/gorm" "gorm.io/gorm/schema" - "time" ) type sqlWallet struct { From 89527d332b105cfcbe67adb6a975bb4d583f6363 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Tue, 16 Dec 2025 17:21:21 +0100 Subject: [PATCH 14/27] Updated README --- LSPxNuts_README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/LSPxNuts_README.md b/LSPxNuts_README.md index 7aca966a45..e850b8739a 100644 --- a/LSPxNuts_README.md +++ b/LSPxNuts_README.md @@ -5,4 +5,12 @@ This is a branch that for the Proof of Concept of the LSPxNuts project. It adds or alters the following functionality versus the mainstream Nuts node: - OAuth2 `vp_bearer` token exchange: read presentation definition from local definitions instead of fetching it from the remote authorization server. - LSP doesn't support presentation definitions, meaning that we need to look it up locally. \ No newline at end of file + LSP doesn't support presentation definitions, meaning that we need to look it up locally. +- Add support for JWT bearer grant type. If the server supports this, it uses this grant type instead of the Nuts-specific vp_token-bearer grant type. +- Add CA certificates of Sectigo (root CA, OV and EV intermediate CA), because they're used by AORTA-LSP. +- Fix marshalling of Verifiable Presentations in JWT format; `type` was marshalled as JSON-LD (single-entry-array was replaced by string) +- Add `policy_id` field to access token request to specify the Presentation Definition that should be used. + The `scope` can then be specified as whatever the use case requires (e.g. SMART on FHIR-esque scopes). +- Relax `did:x509` key usage check: the certificate from UZI smart cards that is used to sign credentials, doesn't have `serverAuth` key usage, only `digitalSignature`. + This broke, since we didn't specify the key usage, but `x509.Verify()` expects key usage `serverAuth` to be present by default. +- Add support for `RS256` (RSA 2048) signatures, since that's what UZI smart cards produce. \ No newline at end of file From f07d0f62d82b06fff3df92c924b4eeb2f317c52a Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Tue, 16 Dec 2025 17:22:31 +0100 Subject: [PATCH 15/27] Updated README --- LSPxNuts_README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LSPxNuts_README.md b/LSPxNuts_README.md index e850b8739a..169f44b691 100644 --- a/LSPxNuts_README.md +++ b/LSPxNuts_README.md @@ -7,7 +7,7 @@ It adds or alters the following functionality versus the mainstream Nuts node: - OAuth2 `vp_bearer` token exchange: read presentation definition from local definitions instead of fetching it from the remote authorization server. LSP doesn't support presentation definitions, meaning that we need to look it up locally. - Add support for JWT bearer grant type. If the server supports this, it uses this grant type instead of the Nuts-specific vp_token-bearer grant type. -- Add CA certificates of Sectigo (root CA, OV and EV intermediate CA), because they're used by AORTA-LSP. +- Add CA certificates of Sectigo (root CA, OV and EV intermediate CA) to Docker image's OS CA bundle, because they're used by AORTA-LSP. - Fix marshalling of Verifiable Presentations in JWT format; `type` was marshalled as JSON-LD (single-entry-array was replaced by string) - Add `policy_id` field to access token request to specify the Presentation Definition that should be used. The `scope` can then be specified as whatever the use case requires (e.g. SMART on FHIR-esque scopes). From 6254059b16981876983430c7f8817d4f5d593b71 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Tue, 16 Dec 2025 17:36:07 +0100 Subject: [PATCH 16/27] test for VP type --- vcr/holder/presenter_test.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/vcr/holder/presenter_test.go b/vcr/holder/presenter_test.go index efc47e70b8..f4bb6242d5 100644 --- a/vcr/holder/presenter_test.go +++ b/vcr/holder/presenter_test.go @@ -20,6 +20,9 @@ package holder import ( "context" + "testing" + "time" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -39,8 +42,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "testing" - "time" ) func TestPresenter_buildPresentation(t *testing.T) { @@ -162,6 +163,12 @@ func TestPresenter_buildPresentation(t *testing.T) { assert.NotNil(t, result.JWT()) nonce, _ := result.JWT().Get("nonce") assert.Empty(t, nonce) + + t.Run("type must be an array", func(t *testing.T) { + rawVP := result.JWT().PrivateClaims()["vp"].(map[string]any) + typeProp := rawVP["type"].([]any) + assert.Equal(t, []any{"VerifiablePresentation"}, typeProp) + }) }) t.Run("ok - multiple VCs", func(t *testing.T) { ctrl := gomock.NewController(t) From 68a5e216a4e3d9c3cb46f26914ed1d9caa512c7a Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Wed, 17 Dec 2025 12:09:42 +0100 Subject: [PATCH 17/27] write vps to temp file --- vcr/holder/presenter.go | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/vcr/holder/presenter.go b/vcr/holder/presenter.go index f00f77db1f..a1744413cd 100644 --- a/vcr/holder/presenter.go +++ b/vcr/holder/presenter.go @@ -23,6 +23,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "strings" "time" @@ -35,6 +36,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vcr/log" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vcr/signature" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" @@ -110,16 +112,35 @@ func (p presenter) buildPresentation(ctx context.Context, signerDID *did.DID, cr return nil, fmt.Errorf("unable to resolve assertion key for signing VP (did=%s): %w", *signerDID, err) } + var vp *vc.VerifiablePresentation switch options.Format { case JWTPresentationFormat: - return p.buildJWTPresentation(ctx, *signerDID, credentials, options, kid) + vp, err = p.buildJWTPresentation(ctx, *signerDID, credentials, options, kid) + if err != nil { + return nil, err + } case "": fallthrough case JSONLDPresentationFormat: - return p.buildJSONLDPresentation(ctx, *signerDID, credentials, options, kid) + vp, err = p.buildJSONLDPresentation(ctx, *signerDID, credentials, options, kid) + if err != nil { + return nil, err + } default: return nil, fmt.Errorf("unsupported presentation proof format: %s", options.Format) } + + tmpFile, err := os.CreateTemp(os.TempDir(), "vp-*.txt") + if err != nil { + return nil, fmt.Errorf("unable to create temp file for VP debug output: %w", err) + } + defer tmpFile.Close() + _, err = tmpFile.WriteString(vp.Raw()) + if err != nil { + return nil, fmt.Errorf("unable to write VP debug output to temp file: %w", err) + } + log.Logger().Infof("Created VP stored in temp file: %s", tmpFile.Name()) + return vp, nil } // buildJWTPresentation builds a JWT presentation according to https://www.w3.org/TR/vc-data-model/#json-web-token From 2572c0931d7dc3862de9ebc204a7873385d4d448 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Wed, 17 Dec 2025 12:22:35 +0100 Subject: [PATCH 18/27] revert VC JWT fix --- vcr/holder/presenter.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vcr/holder/presenter.go b/vcr/holder/presenter.go index a1744413cd..e7635c58ae 100644 --- a/vcr/holder/presenter.go +++ b/vcr/holder/presenter.go @@ -154,12 +154,12 @@ func (p presenter) buildJWTPresentation(ctx context.Context, subjectDID did.DID, claims := map[string]interface{}{ jwt.SubjectKey: subjectDID.String(), jwt.JwtIDKey: id.String(), - "vp": VPAlias(vc.VerifiablePresentation{ + "vp": vc.VerifiablePresentation{ Context: append([]ssi.URI{VerifiableCredentialLDContextV1}, options.AdditionalContexts...), Type: append([]ssi.URI{VerifiablePresentationLDType}, options.AdditionalTypes...), Holder: options.Holder, VerifiableCredential: credentials, - }), + }, } if options.ProofOptions.Nonce != nil { claims["nonce"] = *options.ProofOptions.Nonce From 64cc71fd25f123dbf5684c46d358bce6e30950cc Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Wed, 17 Dec 2025 12:33:27 +0100 Subject: [PATCH 19/27] set fixed key ID --- vcr/holder/presenter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vcr/holder/presenter.go b/vcr/holder/presenter.go index e7635c58ae..f48b57cf4a 100644 --- a/vcr/holder/presenter.go +++ b/vcr/holder/presenter.go @@ -153,7 +153,7 @@ func (p presenter) buildJWTPresentation(ctx context.Context, subjectDID did.DID, type VPAlias vc.VerifiablePresentation claims := map[string]interface{}{ jwt.SubjectKey: subjectDID.String(), - jwt.JwtIDKey: id.String(), + jwt.JwtIDKey: "NjVBRjY5MDlCMUIwNzU4RTA2QzZFMDQ4QzQ2MDAyQjVDNjk1RTM2Qg", "vp": vc.VerifiablePresentation{ Context: append([]ssi.URI{VerifiableCredentialLDContextV1}, options.AdditionalContexts...), Type: append([]ssi.URI{VerifiablePresentationLDType}, options.AdditionalTypes...), From 724051f7dc88baf67d30e7ec58e1bd397f5e813f Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Wed, 17 Dec 2025 13:47:11 +0100 Subject: [PATCH 20/27] fix vp.type to array --- vcr/holder/presenter.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vcr/holder/presenter.go b/vcr/holder/presenter.go index f48b57cf4a..7ce3ff4798 100644 --- a/vcr/holder/presenter.go +++ b/vcr/holder/presenter.go @@ -154,12 +154,12 @@ func (p presenter) buildJWTPresentation(ctx context.Context, subjectDID did.DID, claims := map[string]interface{}{ jwt.SubjectKey: subjectDID.String(), jwt.JwtIDKey: "NjVBRjY5MDlCMUIwNzU4RTA2QzZFMDQ4QzQ2MDAyQjVDNjk1RTM2Qg", - "vp": vc.VerifiablePresentation{ + "vp": VPAlias(vc.VerifiablePresentation{ Context: append([]ssi.URI{VerifiableCredentialLDContextV1}, options.AdditionalContexts...), Type: append([]ssi.URI{VerifiablePresentationLDType}, options.AdditionalTypes...), Holder: options.Holder, VerifiableCredential: credentials, - }, + }), } if options.ProofOptions.Nonce != nil { claims["nonce"] = *options.ProofOptions.Nonce From 50625ab38834d28513c46e8769ae55c1a7b4ee2f Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Wed, 17 Dec 2025 16:55:47 +0100 Subject: [PATCH 21/27] Made token response parsing lenient --- auth/client/iam/client.go | 56 +++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 0020fea550..d998349310 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -24,16 +24,17 @@ import ( "encoding/json" "errors" "fmt" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" - "github.com/nuts-foundation/nuts-node/crypto" - "github.com/nuts-foundation/nuts-node/vdr/resolver" "io" "net/http" "net/url" "strings" "time" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -205,11 +206,22 @@ func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, data return token, oauthError } + // TODO: Remove this when Itzos fixed their Token Response + type LenientTokenResponse struct { + AccessToken string `json:"access_token"` + DPoPKid *string `json:"dpop_kid,omitempty"` + ExpiresAt *any `json:"expires_at,omitempty"` + ExpiresIn *any `json:"expires_in,omitempty"` + TokenType string `json:"token_type"` + Scope *string `json:"scope,omitempty"` + } + var responseData []byte if responseData, err = io.ReadAll(response.Body); err != nil { return token, fmt.Errorf("unable to read response: %w", err) } - if err = json.Unmarshal(responseData, &token); err != nil { + var lenientToken LenientTokenResponse + if err = json.Unmarshal(responseData, &lenientToken); err != nil { // Cut off the response body to 100 characters max to prevent logging of large responses responseBodyString := string(responseData) if len(responseBodyString) > core.HttpResponseBodyLogClipAt { @@ -217,9 +229,43 @@ func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, data } return token, fmt.Errorf("unable to unmarshal response: %w, %s", err, responseBodyString) } + token.AccessToken = lenientToken.AccessToken + token.DPoPKid = lenientToken.DPoPKid + token.TokenType = lenientToken.TokenType + token.Scope = lenientToken.Scope + token.ExpiresAt, err = toInt(lenientToken.ExpiresAt) + if err != nil { + return token, fmt.Errorf("unable to parse expires_at: %w", err) + } + token.ExpiresIn, err = toInt(lenientToken.ExpiresIn) + if err != nil { + return token, fmt.Errorf("unable to parse expires_in: %w", err) + } + return token, nil } +func toInt(value *any) (*int, error) { + // handle expires_in which can be int or string + if value == nil { + return nil, nil + } + switch v := (*value).(type) { + case float64: + intValue := int(v) + return &intValue, nil + case string: + var intValue int + _, err := fmt.Sscanf(v, "%d", &intValue) + if err != nil { + return nil, fmt.Errorf("unable to parse string to int: %w", err) + } + return &intValue, nil + default: + return nil, fmt.Errorf("unable to parse value of type %T to int", v) + } +} + // PostError posts an OAuth error to the redirect URL and returns the redirect URL with the error as query parameter. func (hb HTTPClient) PostError(ctx context.Context, err oauth.OAuth2Error, verifierCallbackURL url.URL) (string, error) { // initiate http client, create a POST request with x-www-form-urlencoded body and send it to the redirect URL From 9bd652ebf5ac93a19e89b02ee40af7392bdf96e6 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Wed, 17 Dec 2025 17:03:00 +0100 Subject: [PATCH 22/27] Reverted jwt ID --- vcr/holder/presenter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vcr/holder/presenter.go b/vcr/holder/presenter.go index 7ce3ff4798..a1744413cd 100644 --- a/vcr/holder/presenter.go +++ b/vcr/holder/presenter.go @@ -153,7 +153,7 @@ func (p presenter) buildJWTPresentation(ctx context.Context, subjectDID did.DID, type VPAlias vc.VerifiablePresentation claims := map[string]interface{}{ jwt.SubjectKey: subjectDID.String(), - jwt.JwtIDKey: "NjVBRjY5MDlCMUIwNzU4RTA2QzZFMDQ4QzQ2MDAyQjVDNjk1RTM2Qg", + jwt.JwtIDKey: id.String(), "vp": VPAlias(vc.VerifiablePresentation{ Context: append([]ssi.URI{VerifiableCredentialLDContextV1}, options.AdditionalContexts...), Type: append([]ssi.URI{VerifiablePresentationLDType}, options.AdditionalTypes...), From dac803679ab7c89ee28b6000b6d35c7d5b3bb054 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Mon, 2 Feb 2026 08:06:58 +0100 Subject: [PATCH 23/27] #3980: Support validation of DeziIDTokenCredential --- vcr/credential/resolver.go | 3 + vcr/credential/types.go | 2 + vcr/credential/validator.go | 59 ++++++++++++++++- vcr/verifier/verifier.go | 10 ++- vcr/verifier/verifier_test.go | 115 +++++++++++++++++++++++++++++++++- 5 files changed, 181 insertions(+), 8 deletions(-) diff --git a/vcr/credential/resolver.go b/vcr/credential/resolver.go index 182eda33e7..db30514a5c 100644 --- a/vcr/credential/resolver.go +++ b/vcr/credential/resolver.go @@ -22,6 +22,7 @@ package credential import ( "errors" "fmt" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/crypto" @@ -41,6 +42,8 @@ func FindValidator(credential vc.VerifiableCredential, pkiValidator pki.Validato return nutsAuthorizationCredentialValidator{} case X509CredentialType: return x509CredentialValidator{pkiValidator: pkiValidator} + case DeziIDTokenCredentialTypeURI.String(): + return deziIDTokenCredentialValidator{} } } } diff --git a/vcr/credential/types.go b/vcr/credential/types.go index 87da9fefeb..beb77cbeab 100644 --- a/vcr/credential/types.go +++ b/vcr/credential/types.go @@ -39,6 +39,8 @@ var ( NutsOrganizationCredentialTypeURI, _ = ssi.ParseURI(NutsOrganizationCredentialType) // NutsAuthorizationCredentialTypeURI is the VC type for a NutsAuthorizationCredentialType as URI NutsAuthorizationCredentialTypeURI, _ = ssi.ParseURI(NutsAuthorizationCredentialType) + // DeziIDTokenCredentialTypeURI is the VC type for a DeziIDTokenCredential + DeziIDTokenCredentialTypeURI = ssi.MustParseURI("DeziIDTokenCredential") // NutsV1ContextURI is the nuts V1 json-ld context as URI NutsV1ContextURI = ssi.MustParseURI(NutsV1Context) ) diff --git a/vcr/credential/validator.go b/vcr/credential/validator.go index ac2b481dae..0d62e8621d 100644 --- a/vcr/credential/validator.go +++ b/vcr/credential/validator.go @@ -25,7 +25,13 @@ import ( "encoding/json" "errors" "fmt" + "net/url" + "strings" + + "github.com/lestrrat-go/jwx/v2/cert" "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/crypto" @@ -33,8 +39,6 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/revocation" "github.com/nuts-foundation/nuts-node/vdr/didx509" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "net/url" - "strings" ) // Validator is the interface specific VC verification. @@ -383,3 +387,54 @@ func validatePolicyAssertions(issuer did.DID, credential vc.VerifiableCredential return nil } + +// DeziIDTokenCredentialValidator validates DeziIDTokenCredential, according to (TODO: add spec). +type deziIDTokenCredentialValidator struct { +} + +func (d deziIDTokenCredentialValidator) Validate(credential vc.VerifiableCredential) error { + type proofType struct { + Type string `json:"type"` + JWT string `json:"jwt"` + } + proofs := []proofType{} + if err := credential.UnmarshalProofValue(&proofs); err != nil { + return fmt.Errorf("%w: invalid proof format: %w", errValidation, err) + } + if len(proofs) != 1 { + return fmt.Errorf("%w: expected exactly one proof, got %d", errValidation, len(proofs)) + } + proof := proofs[0] + if proof.Type != "DeziIDJWT" { + return fmt.Errorf("%w: invalid proof type: expected 'DeziIDToken', got '%s'", errValidation, proof.Type) + } + if err := d.validateDeziToken(credential, proof.JWT); err != nil { + return fmt.Errorf("%w: invalid Dezi id_token: %w", errValidation, err) + } + return (defaultCredentialValidator{}).Validate(credential) +} + +func (d deziIDTokenCredentialValidator) validateDeziToken(credential vc.VerifiableCredential, serialized string) error { + headers, err := crypto.ExtractProtectedHeaders(serialized) + if err != nil { + return fmt.Errorf("invalid JWT headers: %w", err) + } + chain := cert.Chain{} + for i, s := range headers["x5c"].([]string) { + + } + + token, err := jwt.ParseString(serialized, jws.WithKeyProvider(jws.)) + if err != nil { + return err + } + // TODO: Verify deziToken signature + if !token.NotBefore().Equal(credential.IssuanceDate) { + return errors.New("id_token 'nbf' does not match credential 'issuanceDate'") + } + if !token.Expiration().Equal(*credential.ExpirationDate) { + return errors.New("id_token 'exp' does not match credential 'expirationDate'") + } + // TODO: implement rest of checks + return nil +} diff --git a/vcr/verifier/verifier.go b/vcr/verifier/verifier.go index 8cf4ba7328..eeb696be0a 100644 --- a/vcr/verifier/verifier.go +++ b/vcr/verifier/verifier.go @@ -22,11 +22,12 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/pki" - "github.com/nuts-foundation/nuts-node/vcr/revocation" "strings" "time" + "github.com/nuts-foundation/nuts-node/pki" + "github.com/nuts-foundation/nuts-node/vcr/revocation" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -122,7 +123,6 @@ func (v verifier) Verify(credentialToVerify vc.VerifiableCredential, allowUntrus if revoked { return types.ErrRevoked } - } // Check the credentialStatus if the credential is revoked @@ -162,6 +162,10 @@ func (v verifier) Verify(credentialToVerify vc.VerifiableCredential, allowUntrus } // Check signature + // DeziIDTokenCredential: signature is verified by Dezi id_token inside the credential. Signature verification is skipped here. + if credentialToVerify.IsType(credential.DeziIDTokenCredentialTypeURI) { + checkSignature = false + } if checkSignature { issuerDID, _ := did.ParseDID(credentialToVerify.Issuer.String()) metadata := resolver.ResolveMetadata{ResolveTime: validAt, AllowDeactivated: false} diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index 8c60c4b75c..3bb48ca544 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -21,10 +21,11 @@ package verifier import ( "context" "crypto" + "crypto/sha1" + "crypto/tls" + "crypto/x509" "encoding/json" "errors" - "github.com/nuts-foundation/nuts-node/storage/orm" - "github.com/nuts-foundation/nuts-node/test/pki" "net/http" "net/http/httptest" "os" @@ -33,6 +34,11 @@ import ( "testing" "time" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/nuts-foundation/nuts-node/storage/orm" + "github.com/nuts-foundation/nuts-node/test/pki" + "github.com/segmentio/asm/base64" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" ssi "github.com/nuts-foundation/go-did" @@ -305,7 +311,7 @@ func TestVerifier_Verify(t *testing.T) { assert.EqualError(t, err, "verifiable credential must list at most 2 types") }) - t.Run("verify x509", func(t *testing.T) { + t.Run("X509Credential", func(t *testing.T) { ura := "312312312" certs, keys, err := pki.BuildCertChain(nil, ura, nil) chain := pki.CertsToChain(certs) @@ -380,6 +386,15 @@ func TestVerifier_Verify(t *testing.T) { assert.ErrorIs(t, err, expectedError) }) }) + t.Run("DeziIDTokenCredential", func(t *testing.T) { + ctx := newMockContext(t) + validAt := time.Now() + + cred, _ := createDeziCredential(t, "did:web:example.com") + + err := ctx.verifier.Verify(*cred, false, true, &validAt) + assert.NoError(t, err) + }) } func Test_verifier_CheckAndStoreRevocation(t *testing.T) { @@ -858,3 +873,97 @@ func newMockContext(t *testing.T) mockContext { trustConfig: trustConfig, } } + +// createDeziIDToken creates a signed Dezi id_token according to https://www.dezi.nl/documenten/2024/05/08/koppelvlakspecificatie-dezi-online-koppelvlak-1_-platformleverancier +func createDeziCredential(t *testing.T, holderDID string) (*vc.VerifiableCredential, *x509.Certificate) { + keyPair, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") + require.NoError(t, err) + + key, err := jwk.FromRaw(keyPair.PrivateKey) + require.NoError(t, err) + + // Set the key ID and x5t (X.509 thumbprint) + x5t := sha1.Sum(keyPair.Leaf.Raw) + err = key.Set(jwk.KeyIDKey, base64.StdEncoding.EncodeToString(x5t[:])) + require.NoError(t, err) + err = key.Set(jwk.X509CertThumbprintKey, base64.StdEncoding.EncodeToString(x5t[:])) + require.NoError(t, err) + err = key.Set(jwk.AlgorithmKey, "RS256") + require.NoError(t, err) + + // Build the JWT token + token := jwt.New() + + // Set claims from the DeziIDTokenCredential payload + err = token.Set(jwt.AudienceKey, "006fbf34-a80b-4c81-b6e9-593600675fb2") + require.NoError(t, err) + err = token.Set(jwt.ExpirationKey, time.Unix(1701933697, 0)) + require.NoError(t, err) + err = token.Set(jwt.NotBeforeKey, time.Unix(1701933627, 0)) + require.NoError(t, err) + err = token.Set(jwt.IssuerKey, "https://max.proeftuin.Dezi-online.rdobeheer.nl") + require.NoError(t, err) + + // Set custom claims + err = token.Set("initials", "B.B.") + require.NoError(t, err) + err = token.Set("json_schema", "https://max.proeftuin.Dezi-online.rdobeheer.nl/json_schema.json") + require.NoError(t, err) + err = token.Set("loa_authn", "http://eidas.europa.eu/LoA/high") + require.NoError(t, err) + err = token.Set("loa_Dezi", "http://eidas.europa.eu/LoA/high") + require.NoError(t, err) + err = token.Set("relations", []map[string]interface{}{ + { + "entity_name": "Zorgaanbieder", + "roles": []string{"01.041", "30.000", "01.010", "01.011"}, + "ura": "87654321", + }, + }) + require.NoError(t, err) + err = token.Set("surname", "Jansen") + require.NoError(t, err) + err = token.Set("surname_prefix", "van der") + require.NoError(t, err) + err = token.Set("Dezi_id", "900000009") + require.NoError(t, err) + err = token.Set("x5c", []string{base64.StdEncoding.EncodeToString(keyPair.Leaf.Raw)}) + require.NoError(t, err) + + // Sign the token using jwt.Sign + signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, key)) + require.NoError(t, err) + + credentialMap := map[string]any{ + "@context": []any{ + "https://www.w3.org/2018/credentials/v1", + }, + "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, + "issuer": "https://max.proeftuin.Dezi-online.rdobeheer.nl", + "issuanceDate": token.NotBefore().Format(time.RFC3339Nano), + "expirationDate": token.Expiration().Format(time.RFC3339Nano), + "credentialSubject": map[string]any{ + "@type": "DeziIDTokenSubject", + "id": holderDID, + "identifier": "87654321", + "name": "Zorgaanbieder", + "employee": map[string]any{ + "@type": "HealthcareWorker", + "identifier": "900000009", + "initials": "B.B.", + "surnamePrefix": "van der", + "surname": "Jansen", + "roles": []string{"01.041", "30.000", "01.010", "01.011"}, + }, + }, + "proof": map[string]any{ + "type": "DeziIDJWT", + "jwt": string(signed), + }, + } + data, err := json.Marshal(credentialMap) + require.NoError(t, err) + cred, err := vc.ParseVerifiableCredential(string(data)) + require.NoError(t, err) + return cred, keyPair.Leaf +} From 2ebea32334ff18470ab07a750ba754d0db85fa11 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Mon, 2 Feb 2026 16:28:03 +0100 Subject: [PATCH 24/27] implemented e2e test --- auth/api/iam/api.go | 15 +- auth/api/iam/generated.go | 4 + auth/oauth/openid.go | 2 +- docs/_static/auth/v2.yaml | 6 + e2e-tests/browser/client/iam/generated.go | 4 + .../oauth-flow/dezi_idtoken/accesspolicy.json | 150 ++++++++++++ .../oauth-flow/dezi_idtoken/certs/README.md | 5 + .../dezi_idtoken/certs/dezi_signing.key | 28 +++ .../dezi_idtoken/certs/dezi_signing.pem | 19 ++ .../dezi_idtoken/certs/nodeA-chain.pem | 40 ++++ .../oauth-flow/dezi_idtoken/certs/nodeA.key | 28 +++ .../oauth-flow/dezi_idtoken/certs/nodeA.pem | 22 ++ .../dezi_idtoken/docker-compose.yml | 29 +++ .../oauth-flow/dezi_idtoken/generate-jwt.sh | 95 ++++++++ e2e-tests/oauth-flow/dezi_idtoken/run-test.sh | 120 ++++++++++ vcr/credential/dezi.go | 87 +++++++ vcr/credential/dezi_test.go | 28 +++ vcr/credential/util.go | 22 +- vcr/credential/validator.go | 43 ++-- vcr/pe/presentation_definition_test.go | 219 +++++++++++++++++- vcr/verifier/verifier_test.go | 11 +- 21 files changed, 941 insertions(+), 36 deletions(-) create mode 100644 e2e-tests/oauth-flow/dezi_idtoken/accesspolicy.json create mode 100644 e2e-tests/oauth-flow/dezi_idtoken/certs/README.md create mode 100644 e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.key create mode 100644 e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.pem create mode 100644 e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA-chain.pem create mode 100644 e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.key create mode 100644 e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.pem create mode 100644 e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml create mode 100755 e2e-tests/oauth-flow/dezi_idtoken/generate-jwt.sh create mode 100755 e2e-tests/oauth-flow/dezi_idtoken/run-test.sh create mode 100644 vcr/credential/dezi.go create mode 100644 vcr/credential/dezi_test.go diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index c3affbcf97..f2a6a11912 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -29,7 +29,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/core/to" "html/template" "net/http" "net/url" @@ -37,6 +36,9 @@ import ( "strings" "time" + "github.com/nuts-foundation/nuts-node/core/to" + "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/labstack/echo/v4" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" @@ -750,9 +752,18 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS if request.Body.Credentials != nil { credentials = *request.Body.Credentials } + + if request.Body.IdToken != nil { + idTokenCredential, err := credential.CreateDeziIDTokenCredential(*request.Body.IdToken) + if err != nil { + return nil, core.InvalidInputError("failed to create id_token credential: %w", err) + } + credentials = append(credentials, *idTokenCredential) + } + // assert that self-asserted credentials do not contain an issuer or credentialSubject.id. These values must be set // by the nuts-node to build the correct wallet for a DID. See https://github.com/nuts-foundation/nuts-node/issues/3696 - // As a sideeffect it is no longer possible to pass signed credentials to this API. + // As a side effect it is no longer possible to pass signed credentials to this API. for _, cred := range credentials { var credentialSubject []map[string]interface{} if err := cred.UnmarshalCredentialSubject(&credentialSubject); err != nil { diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 5dbe21544d..695e33d53e 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -146,6 +146,10 @@ type ServiceAccessTokenRequest struct { // - proof/signature (MUST be omitted; integrity protection is covered by the VP's proof/signature) Credentials *[]VerifiableCredential `json:"credentials,omitempty"` + // IdToken An optional ID Token (JWT) that represents the end-user. + // This ID token is included in the Verifiable Presentation that is used to request the access token. + IdToken *string `json:"id_token,omitempty"` + // Scope The scope that will be the service for which this access token can be used. Scope string `json:"scope"` diff --git a/auth/oauth/openid.go b/auth/oauth/openid.go index 97572de798..ec6803a07b 100644 --- a/auth/oauth/openid.go +++ b/auth/oauth/openid.go @@ -24,7 +24,7 @@ import ( // proofTypeValuesSupported contains a list of supported cipher suites for ldp_vc & ldp_vp presentation formats // Recommended list of options https://w3c-ccg.github.io/ld-cryptosuite-registry/ -var proofTypeValuesSupported = []string{"JsonWebSignature2020"} +var proofTypeValuesSupported = []string{"JsonWebSignature2020", "DeziIDJWT"} // DefaultOpenIDSupportedFormats returns the OpenID formats supported by the Nuts node and is used in the // - Authorization Server's metadata field `vp_formats_supported` diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index c032a1ff64..aead012f19 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -414,6 +414,12 @@ components: type: string description: The scope that will be the service for which this access token can be used. example: eOverdracht-sender + id_token: + type: string + description: | + An optional ID Token (JWT) that represents the end-user. + This ID token is included in the Verifiable Presentation that is used to request the access token. + It currently only supports Dezi ID tokens. credentials: type: array description: | diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 9ed0f8cbac..81b438d47e 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -140,6 +140,10 @@ type ServiceAccessTokenRequest struct { // - proof/signature (MUST be omitted; integrity protection is covered by the VP's proof/signature) Credentials *[]VerifiableCredential `json:"credentials,omitempty"` + // IdToken An optional ID Token (JWT) that represents the end-user. + // This ID token is included in the Verifiable Presentation that is used to request the access token. + IdToken *string `json:"id_token,omitempty"` + // Scope The scope that will be the service for which this access token can be used. Scope string `json:"scope"` diff --git a/e2e-tests/oauth-flow/dezi_idtoken/accesspolicy.json b/e2e-tests/oauth-flow/dezi_idtoken/accesspolicy.json new file mode 100644 index 0000000000..496b4b1c8e --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/accesspolicy.json @@ -0,0 +1,150 @@ +{ + "test": { + "organization": { + "format": { + "ldp_vc": { + "proof_type": [ + "DeziIDJWT" + ] + }, + "jwt_vc": { + "alg": [ + "PS256" + ] + }, + "jwt_vp": { + "alg": [ + "PS256" + ] + } + }, + "id": "pd_care_organization", + "input_descriptors": [ + { + "id": "id_x509credential", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "X509Credential" + } + }, + { + "path": [ + "$.issuer" + ], + "purpose": "Whe can only accept credentials from a trusted issuer", + "filter": { + "type": "string", + "pattern": "^did:x509:0:sha256:szqMaTpnD6GN0aRrT98eV4bhAoOgyItEZVyskYyL_Qc::.*$" + } + }, + { + "id": "organization_name", + "path": [ + "$.credentialSubject[0].subject.O" + ], + "filter": { + "type": "string" + } + }, + { + "id": "organization_ura", + "path": [ + "$.credentialSubject[0].san.otherName" + ], + "filter": { + "type": "string", + "pattern": "^[0-9.]+-\\d+-\\d+-S-(\\d+)-00\\.000-\\d+$" + } + }, + { + "id": "organization_city", + "path": [ + "$.credentialSubject[0].subject.L" + ], + "filter": { + "type": "string" + } + } + ] + } + }, + { + "id": "id_dezicredential", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "DeziIDTokenCredential" + } + }, + { + "id": "organization_ura_dezi", + "path": [ + "$.credentialSubject.identifier" + ], + "filter": { + "type": "string" + } + }, + { + "id": "user_uzi", + "path": [ + "$.credentialSubject.employee.identifier" + ], + "filter": { + "type": "string" + } + }, + { + "id": "user_initials", + "path": [ + "$.credentialSubject.employee.initials" + ], + "filter": { + "type": "string" + } + }, + { + "id": "user_surname", + "path": [ + "$.credentialSubject.employee.surname" + ], + "filter": { + "type": "string" + } + }, + { + "id": "user_surname_prefix", + "path": [ + "$.credentialSubject.employee.surnamePrefix" + ], + "filter": { + "type": "string" + } + }, + { + "id": "user_roles", + "path": [ + "$.credentialSubject.employee.roles" + ], + "filter": { + "type": "string" + } + } + ] + } + } + ] + } + } +} diff --git a/e2e-tests/oauth-flow/dezi_idtoken/certs/README.md b/e2e-tests/oauth-flow/dezi_idtoken/certs/README.md new file mode 100644 index 0000000000..ed75decc2b --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/certs/README.md @@ -0,0 +1,5 @@ +These files were generated using https://github.com/nuts-foundation/uzi-did-x509-issuer/tree/main/test_ca: + +```shell +./issue-cert.sh nodeA "Because We Care" "Healthland" 0 00001 0 +``` \ No newline at end of file diff --git a/e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.key b/e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.key new file mode 100644 index 0000000000..9804dd8870 --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDIfRO9Iy1xzWyQ +FthldErLm3DeKcqfQJZ6t4mVAiZMYgQyHrIi2BITimwPsGGvfv9erNEJXPBuiCoc +d5pKVPPfFjdtVicP8kc1Fqm3SZNIrHys39w4c5hi/GHAOYtc0JzM/HCH50RgbKF2 +Nm7aeG8v5LVYQLEmTvAFxuj9PDZE7IRC4WbSVca0y4/Xe2y5CU9tZgPh1nl7uoF+ +1RWcDZa+ew57cy4K1cq4ykBWVg0DUXsPsgE+MIoWR+74nZiT2sytxRQs2cXCWkPq +wTUl0d7pAGnWQuEEG3ybQOhpJyc8b1pIYexmo/Piny2FI4qZeqjSzFNVmOmQCa10 +9hF/0HvXAgMBAAECggEALOcGgrfcN77Ab80ODjrrfYqEzt0hSmWWzklJARyII1dY +hTkmwHMQKVw5M5JXbozM+RFPh/9OwhKxC8slvTwlmnNJWq2O9h1XIWbAABL0b7Rh +//3rPqF1IcZQxlKdCd6XH7nyIh4DzGzIBMfQMBIFJP7eNrPWeTP4wfJ4wC66INlJ +++U3QegPCc4RSbbKP4aGt9LbAsBS7r7tuPVR9pPHF+xPdHPy5ZEmDhXoCyjYsTDK +EQKr/ByDnjZF92md+mR0VnATRs2PzPWS2RRiuqTfoTiSxkRPH9sxsNT8Gr94E+x4 +ASeqyFrKbn3TxF86crTTpPCJOoEKidUyfVKB635XsQKBgQD8WGA+WkG8mGqIOLIa +vqYVIlUbYz+N5ZPPC3Louc5BUHO6w5XDMJ9wjRV0X6uT1dbh5eTro7P0uNeTfIiE +fiJ1E7teDWSu7AwdPKoBdTMX3RtWZGV6L5nahjRFxToB3e2afDKVVegpMjGzbGZX +FKeB948+AvjamSX6ENR6j+/alQKBgQDLZG553UiFSDTigm0F0yqlBsY2Amu08UQG +WB9TOJXP8OqzG4iYarpsLuqDUgG3VkPhlQQTfzM7JaoMnyVp9ulfrcYmUsoNM1jL +I07XnjWaZUtQya3eMaLZTNlXnQ/fyjadRVYYYbzBNrgns5kwRqSCHLWQMcL1EQ5A +Vz4IISlNuwKBgAWOYJge3qGrbXUQYoOKPRfsCJmwxr52FpoRc3dCWBNCFTpAgjSp +BmmxAY7taFa596BDspWpphW2WDDMJilcqZ+QTqjUfKoJUn72Tfv4O6bD3I07aqyV +DbstB0ud+xf9bfTf1TFKkfEORN/hfCNgtgt7ivDfmeEeTCLEahlEwBA9AoGAAWDA +ztqM7zo6AX7Ytj1kAJI3LY5+pE8uIszeCXZMrYf4TxZUqpOuh6UZuaIImPFgrFqS +GH+4HSJ4MHWzjzA5DIjk2sWc0NIUO+wVUKilvFILXJTBNMwpSkeXAVzzCpUYIaCi +oK+o07ZHMR2qYAVaf/cp07xCkd53tj/hD7UJzpkCgYEApLkf1bfRIQYTgQfdeNBo +XH6sAVmp1MQg5aNCIx5XdF5gwTksuOOk1GADN0vQkRoC7BTc8YJL4HyBRudDR8DW +/xbtApQwGCFB0mdwtHp7TLuCWy1hhMfACKqTo69heJxBPUdVqeupldoL/Z/IOSPu +7Mgoj5Y/8/OWNh0PDI9uTfQ= +-----END PRIVATE KEY----- diff --git a/e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.pem b/e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.pem new file mode 100644 index 0000000000..b6139cd20a --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUKR0JcvFFkswjBSv/5hjMYNrQTmUwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDIwMjE1MDY0OVoXDTI3MDIw +MjE1MDY0OVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAyH0TvSMtcc1skBbYZXRKy5tw3inKn0CWereJlQImTGIE +Mh6yItgSE4psD7Bhr37/XqzRCVzwbogqHHeaSlTz3xY3bVYnD/JHNRapt0mTSKx8 +rN/cOHOYYvxhwDmLXNCczPxwh+dEYGyhdjZu2nhvL+S1WECxJk7wBcbo/Tw2ROyE +QuFm0lXGtMuP13tsuQlPbWYD4dZ5e7qBftUVnA2WvnsOe3MuCtXKuMpAVlYNA1F7 +D7IBPjCKFkfu+J2Yk9rMrcUULNnFwlpD6sE1JdHe6QBp1kLhBBt8m0DoaScnPG9a +SGHsZqPz4p8thSOKmXqo0sxTVZjpkAmtdPYRf9B71wIDAQABo1MwUTAdBgNVHQ4E +FgQUxQzxiBl6/5+1bfA1BHmIzFUy/fMwHwYDVR0jBBgwFoAUxQzxiBl6/5+1bfA1 +BHmIzFUy/fMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAM4N +81O2G0p2AvpE7t0stwJDhclPYwxL+bsm3uYrNFTppI9xl7U2U98Jbtiiw3DjxKCJ +Ho5a01m5Q+31kYtavbLhKrHO8OYxR7WIg3eAZLy6N+3ZZZ5RnpdKbwkaGzTzeKrG +zN+nWVixzaICoI+OUL14DWZFhGbhDcBxkEzGJzeoEjJlf1IRzpouYvhy1WJLgrZV +olT4pJ0v/2xW3It+9mYktD/74LlK38GnCgGhYt8WWAjEPRty+MQJsA/PGadYtJen +OEPqehEQQ5m6YeNHEVBMvaaIHc4TZpoNRfy+5qz/M02fCb2l6oPWAVNLNRm/2Dbs +6ejzu+NdyxmRDSnbhQ== +-----END CERTIFICATE----- diff --git a/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA-chain.pem b/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA-chain.pem new file mode 100644 index 0000000000..4e4bfcccbb --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA-chain.pem @@ -0,0 +1,40 @@ +-----BEGIN CERTIFICATE----- +MIIC9jCCAd6gAwIBAgIURFCqPrL3QQdBNOqkwmXWNgx9pdQwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNDExMTExNDE1MTha +Fw0zNDExMDkxNDE1MThaMBsxGTAXBgNVBAMMEEZha2UgVVpJIFJvb3QgQ0EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDT5J8gKdyMJNi3cuAmJ+MILrMu +wrKyTRYhjUUFHHn5rcVaHN0hzB6v5t74Nt40xUXRNaomDcclBIOlwt8f62JA2p/j +83ENfdLrXvUu9NMThkqZwZ9dzRwK7l3UZBq8NTQUO74W4M2qx8nrXq31eWogxUUI +Fc1XORh5ecebeL5mUb2E6UlmDmNgm2fGeSmmis8zieI+KKYOhi/hYtyeixrg7rxP +4v0VRrEstcWAetRgXWQX0ElAxs0Vrsy6/vv3pEtXhx8wb2wi2xY14d9Ih8HdeNI+ ++3wIbZz6WVM3fD5QFHV2EZBH+soo0pfKj2tHsaDz3FPMuMzILt6U6PT4ALIdAgMB +AAGjMjAwMA8GA1UdEwQIMAYBAf8CAQAwHQYDVR0OBBYEFJuxz0XwN7PdeMhyJfcf +m7py1BK9MA0GCSqGSIb3DQEBCwUAA4IBAQAhlpkz68x2dGpOLX3FzAb8Ee+Y2OV+ +RWFpsME9ZVDU06JETPfPCj02PH82lgUnc4jeR81rPSsIt2ssqm2S4zb02Nip595c +AqCKvmBfEc9hPPW2ugpNxT8ZRU4LKrqpV4nJ6nBvDqmGuH5uq9Ng9l9SnM3eKmdZ +tJKc+ZNAPKxVAiueLTdr6W2UbmKoZARQQ0JLkFnZOxnUkr8pQfxUzEIUkHg2dWaa +I/4wo4Pni7xXggFoPDpVztu/iP33XBLqXJwxxHXhq9nc9JU/kEXDt7j8EgoyJo7J +jSKcjpRfpGkE5gqqB4Sa8wAsAPUK3jRreuytllAtQUZRbCtHbxclc9yA +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDmTCCAoGgAwIBAgIUFTPO+pUk32QWsYyLYdlLTmlRWVYwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNDEyMTgwOTE0NDZa +Fw0zNDEyMTYwOTE0NDZaMEsxDjAMBgNVBAMMBW5vZGVBMRgwFgYDVQQKDA9CZWNh +dXNlIFdlIENhcmUxEzARBgNVBAcMCkhlYWx0aGxhbmQxCjAIBgNVBAUTATAwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0LOkIXmq9QGpQsy+C+evhqMpL +ZKDpRYIxoKR4Vqp68s2eX+xqBiSaxDkSe3xKKfm0CWsoeQVLXl+9VppH4q5uzyyl +n/qQQEoErghULP99Ez/aDL0JX1XrEvjIePQ+E2rUfYp+HxQdKXc0kJsCv2fntK+T +s6stN8ZeojCc4Edx1nxOHZGZXu0n5DMMXyTB4R7DCEOCyqppSv6m6CexxL4Aw4wr +fHbO1dPmKV/jMxC3Y32SQ8ohJ80y3TnejYuzsAG155CZDm97+Za2G5BcNmwq7Qy7 +aVWhCpEW3fSOX1ZQBOwYFttd7wdcJla5QT6htJnKsWLFBBX4sGYFx1VQPRABAgMB +AAGjgaQwgaEwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMEAGA1UdEQQ5 +MDegNQYDVQUFoC4MLDIuMTYuNTI4LjEuMTAwNy45OS4yMTEwLTEtMC1TLTAwMDAx +LTAwLjAwMC0wMB0GA1UdDgQWBBSnq8XA3if+WQhRDgbOceZPm1NQDDAfBgNVHSME +GDAWgBSbsc9F8Dez3XjIciX3H5u6ctQSvTANBgkqhkiG9w0BAQsFAAOCAQEARp5Y +U1X34jvzdRzSWShluLN/sUSqgxJUmfhYi66lIZlQ4euaQNRFMzEwlQdzgcEBlJnr +IZGgB+MhiCrqAb3PbHBq4V4vDqYmSmtWtxyGDQm5POiN2Uzos1CSBusIyeRkXc1e +rKgXKcY16hzEagYRuJZN8cmeIKCLF0rh34xtEgdFzEw5xV4cWol9W0X9vNJJSVCH +EBA9jY4ULMxxLQY+cZE4GuCfxQ7OsCQQqusP57zeIRDRLs0c8I8J3vSGp6sA2fG0 +mNVrEgIpktVro29NCVEp3oc+7UBsxH2BS45okCLp1KwVW0TMrDH9UPM7ktdCzSmP +Xr+fIaVcs9sbT5qwGw== +-----END CERTIFICATE----- diff --git a/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.key b/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.key new file mode 100644 index 0000000000..70463bcdbf --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC0LOkIXmq9QGpQ +sy+C+evhqMpLZKDpRYIxoKR4Vqp68s2eX+xqBiSaxDkSe3xKKfm0CWsoeQVLXl+9 +VppH4q5uzyyln/qQQEoErghULP99Ez/aDL0JX1XrEvjIePQ+E2rUfYp+HxQdKXc0 +kJsCv2fntK+Ts6stN8ZeojCc4Edx1nxOHZGZXu0n5DMMXyTB4R7DCEOCyqppSv6m +6CexxL4Aw4wrfHbO1dPmKV/jMxC3Y32SQ8ohJ80y3TnejYuzsAG155CZDm97+Za2 +G5BcNmwq7Qy7aVWhCpEW3fSOX1ZQBOwYFttd7wdcJla5QT6htJnKsWLFBBX4sGYF +x1VQPRABAgMBAAECggEABlZdDpPZmWID/n/Ek4AMakth7PoM+3kb917N4ipN0UjF +VdIZOL2rrG9R8/xr1pgrrDsEYQmB5IQdH6w4sLLm5uCUUrGlLwBssHjzM78ob/ym +scBiDTIXmmh4Rf7hImZtV8Xs3BSzEN25D5xPFq8aVCjqExEnztpn69y0rO2Dl2im +xDBnUGPSy1ZCSGtES+BpaNT2GDGieaZmoNOH7TDLXIMYNjgnldeACQOiPvXYG+iQ +LKNSMGw193rR4hB+haBqaEO++845+2vr3TQKOMdFiP3+6LmxTncujSF6RtWj+7si +Zz1R7yqQKHsU6oYQrIJmdZg3AIwB3WhgeG27fZPkpQKBgQDXkOxoCSlvKym9e+r1 +M6Jz4ifaBWT4ys0HCOThEf47j8Qn2BwDIUqhrcARLMtVaEFTXhHWU8ceh529Fyoq +yKe5mpbmzKFd2RH2cyjIq6/e9qVFXDeK7SbypIhxtGjeNv9dGaTSt0Qw2264vMYn +aXHX7vdUfE4pt2R3RZepWKTOXQKBgQDV+JfwQPYFH8nMo9Juc+gzekUb31hZLn68 +Z6ZnvnxNShgazLslHKmAEZyokum0G1tZbiC5f6wI5a0GmFvPyFy1PklBjOatHVDG +byXoRAT1jmBdy1+nfdhd+6Ju2r/VU5tvfYYcKkB/11eBHHYdnSWJU3QGQkpi58Da +vlH2ry7F9QKBgQDEhX+wnOGkUqJb97PNVQR+Ryhzr8VMt35RMn+O3Nt8q2V1uaRY +CirC2OcoAUFiHIipmzIBxiDaqWJZt9ueY43dPJzjzpwyNaoVlwkQYM0WJJ+paxfL +1MZUIUGu/303UMZftvg3jhJhxDrdumOgHJZH+LiM0kJj76hswAoyvfiJlQKBgAGh +Ee8XX4gsdMnlGW4T3dm+fZY3viF3tClVFLRHhATGoqZZlrcyn6vE9o9mBveDGc/1 +gbRH35R1wzqAoHpViTcsETy5iOwahAnuwLgjBHKmMd+k88Z/s80LZHI5oipKp61S +pFnEjJcsmZL3F4MkNiv0gbamfJCCOTqxJkidjtqdAoGBAKSSTSXbkLo4sZeizzzJ +mdSN7MKrO+LZ0Btzyl86OIaSPQZ6rn2vqJi8hwUWSGvTFho7lMRLHrIBL4BehEa7 +xinPPrydLR3z4L7VCRvogFddLI6fqW5NnBepjoT4FQI12AJXeIvDrRYVMfrwW5QH +JCzdoyHTJ2Hk2vIjCctVAf/d +-----END PRIVATE KEY----- diff --git a/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.pem b/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.pem new file mode 100644 index 0000000000..4aa6fb0435 --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDmTCCAoGgAwIBAgIUFTPO+pUk32QWsYyLYdlLTmlRWVYwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNDEyMTgwOTE0NDZa +Fw0zNDEyMTYwOTE0NDZaMEsxDjAMBgNVBAMMBW5vZGVBMRgwFgYDVQQKDA9CZWNh +dXNlIFdlIENhcmUxEzARBgNVBAcMCkhlYWx0aGxhbmQxCjAIBgNVBAUTATAwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0LOkIXmq9QGpQsy+C+evhqMpL +ZKDpRYIxoKR4Vqp68s2eX+xqBiSaxDkSe3xKKfm0CWsoeQVLXl+9VppH4q5uzyyl +n/qQQEoErghULP99Ez/aDL0JX1XrEvjIePQ+E2rUfYp+HxQdKXc0kJsCv2fntK+T +s6stN8ZeojCc4Edx1nxOHZGZXu0n5DMMXyTB4R7DCEOCyqppSv6m6CexxL4Aw4wr +fHbO1dPmKV/jMxC3Y32SQ8ohJ80y3TnejYuzsAG155CZDm97+Za2G5BcNmwq7Qy7 +aVWhCpEW3fSOX1ZQBOwYFttd7wdcJla5QT6htJnKsWLFBBX4sGYFx1VQPRABAgMB +AAGjgaQwgaEwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMEAGA1UdEQQ5 +MDegNQYDVQUFoC4MLDIuMTYuNTI4LjEuMTAwNy45OS4yMTEwLTEtMC1TLTAwMDAx +LTAwLjAwMC0wMB0GA1UdDgQWBBSnq8XA3if+WQhRDgbOceZPm1NQDDAfBgNVHSME +GDAWgBSbsc9F8Dez3XjIciX3H5u6ctQSvTANBgkqhkiG9w0BAQsFAAOCAQEARp5Y +U1X34jvzdRzSWShluLN/sUSqgxJUmfhYi66lIZlQ4euaQNRFMzEwlQdzgcEBlJnr +IZGgB+MhiCrqAb3PbHBq4V4vDqYmSmtWtxyGDQm5POiN2Uzos1CSBusIyeRkXc1e +rKgXKcY16hzEagYRuJZN8cmeIKCLF0rh34xtEgdFzEw5xV4cWol9W0X9vNJJSVCH +EBA9jY4ULMxxLQY+cZE4GuCfxQ7OsCQQqusP57zeIRDRLs0c8I8J3vSGp6sA2fG0 +mNVrEgIpktVro29NCVEp3oc+7UBsxH2BS45okCLp1KwVW0TMrDH9UPM7ktdCzSmP +Xr+fIaVcs9sbT5qwGw== +-----END CERTIFICATE----- diff --git a/e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml b/e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml new file mode 100644 index 0000000000..f5dfd438f4 --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml @@ -0,0 +1,29 @@ +services: + nodeA-backend: + image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:local}" + ports: + - "18081:8081" + environment: + NUTS_URL: "https://nodeA" + NUTS_VERBOSITY: trace + NUTS_STRICTMODE: false + NUTS_HTTP_INTERNAL_ADDRESS: ":8081" + NUTS_AUTH_CONTRACTVALIDATORS: dummy + NUTS_POLICY_DIRECTORY: /opt/nuts/policies + NUTS_VDR_DIDMETHODS: web + volumes: + # did:web resolver uses the OS CA bundle, but e2e tests use a self-signed CA which can be found in truststore.pem + # So we need to mount that file to the OS CA bundle location, otherwise did:web resolving will fail due to untrusted certs. + - "../../tls-certs/truststore.pem:/etc/ssl/certs/Nuts_RootCA.pem:ro" + - "./accesspolicy.json:/opt/nuts/policies/accesspolicy.json:ro" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often + nodeA: + image: nginx:1.25.1 + ports: + - "10443:443" + volumes: + - "../../shared_config/nodeA-http-nginx.conf:/etc/nginx/conf.d/nuts-http.conf:ro" + - "../../tls-certs/nodeA-certificate.pem:/etc/nginx/ssl/server.pem:ro" + - "../../tls-certs/nodeA-certificate.pem:/etc/nginx/ssl/key.pem:ro" + - "../../tls-certs/truststore.pem:/etc/nginx/ssl/truststore.pem:ro" diff --git a/e2e-tests/oauth-flow/dezi_idtoken/generate-jwt.sh b/e2e-tests/oauth-flow/dezi_idtoken/generate-jwt.sh new file mode 100755 index 0000000000..80d6a31220 --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/generate-jwt.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +# Generate JWT ID Token signed with OpenSSL +# Usage: ./generate-jwt.sh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PRIVATE_KEY="$SCRIPT_DIR/certs/dezi_signing.key" +CERT_FILE="$SCRIPT_DIR/certs/dezi_signing.pem" + +# Base64 URL encode function +base64url_encode() { + openssl base64 -e -A | tr '+/' '-_' | tr -d '=' +} + +# Generate certificate if it doesn't exist +if [ ! -f "$CERT_FILE" ]; then + echo "Generating self-signed certificate..." + openssl req -new -x509 -key "$PRIVATE_KEY" -out "$CERT_FILE" -days 365 \ + -subj "/CN=localhost" +fi + +# Extract public key modulus for kid calculation +# Calculate SHA1 hash of the DER-encoded certificate and base64 encode it +KID=$(openssl x509 -in "$CERT_FILE" -outform DER | openssl dgst -sha1 -binary | base64) + +# Extract certificate for x5c (strip headers and newlines) +X5C=$(grep -v "BEGIN CERTIFICATE" "$CERT_FILE" | grep -v "END CERTIFICATE" | tr -d '\n') + +# Get current time and calculate exp/nbf +NOW=$(date +%s) +NBF=$NOW +EXP=$((NOW + 3600)) # 1 hour from now + +# JWT Header +HEADER=$(cat <&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + +echo "---------------------------------------" +echo "Perform OAuth 2.0 rfc021 flow..." +echo "---------------------------------------" + +# Run generate-jwt.sh, and read the input into a var, clean newlines +IDTOKEN=$(./generate-jwt.sh | tr -d '\n') + +REQUEST=$( +cat << EOF +{ + "authorization_server": "https://nodeA/oauth2/vendorA", + "token_type": "bearer", + "scope": "test", + "id_token": "$IDTOKEN" +} +EOF +) +# Request access token +RESPONSE=$(echo $REQUEST | curl -X POST -s --data-binary @- http://localhost:18081/internal/auth/v2/vendorA/request-service-access-token -H "Content-Type: application/json") +if echo $RESPONSE | grep -q "access_token"; then + ACCESS_TOKEN=$(echo $RESPONSE | jq -r .access_token) +else + echo "FAILED: Could not get access token from node-A" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +echo Access token: $ACCESS_TOKEN + +echo "------------------------------------" +echo "Introspect access token..." +echo "------------------------------------" +RESPONSE=$(curl -X POST -s --data "token=$ACCESS_TOKEN" http://localhost:18081/internal/auth/v2/accesstoken/introspect) +echo Introspection response: $RESPONSE + +# Check that it contains the following claims: +# - "organization_ura_dezi":"87654321" +# - "user_initials":"B.B." +# - "user_roles":["01.041","30.000","01.010","01.011"] +# - "user_surname":"Jansen" +# - "user_surname_prefix":"van der" +# - "user_uzi":"900000009" +if [ "$(echo $RESPONSE | jq -r .organization_ura_dezi)" != "87654321" ]; then + echo "FAILED: organization_ura_dezi invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_initials)" != "B.B." ]; then + echo "FAILED: user_initials invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +USER_ROLES=$(echo $RESPONSE | jq -r '.user_roles | sort | join(",")') +if [ "$USER_ROLES" != "01.010,01.011,01.041,30.000" ]; then + echo "FAILED: user_roles invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_surname)" != "Jansen" ]; then + echo "FAILED: user_surname invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_surname_prefix)" != "van der" ]; then + echo "FAILED: user_surname_prefix invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_uzi)" != "900000009" ]; then + echo "FAILED: user_uzi invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + +echo "------------------------------------" +echo "Stopping Docker containers..." +echo "------------------------------------" +docker compose down \ No newline at end of file diff --git a/vcr/credential/dezi.go b/vcr/credential/dezi.go new file mode 100644 index 0000000000..4a53006a4c --- /dev/null +++ b/vcr/credential/dezi.go @@ -0,0 +1,87 @@ +package credential + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/go-did/vc" +) + +func CreateDeziIDTokenCredential(idTokenSerialized string) (*vc.VerifiableCredential, error) { + idToken, err := jwt.Parse([]byte(idTokenSerialized), jwt.WithVerify(false), jwt.WithAcceptableSkew(time.Hour*24*365*10)) + if err != nil { + return nil, fmt.Errorf("parsing id_token: %w", err) + } + relationsRaw, _ := idToken.Get("relations") + relations, ok := relationsRaw.([]any) + if !ok || len(relations) != 1 { + return nil, fmt.Errorf("id_token 'relations' claim invalid or missing (expected array of objects with single item)") + } + relation, ok := relations[0].(map[string]any) + if !ok { + return nil, fmt.Errorf("id_token 'relations' claim invalid or missing (expected array of objects with single item)") + } + roles, ok := relation["roles"].([]any) + if !ok { + return nil, fmt.Errorf("id_token 'relations[0].roles' claim invalid or missing (expected array of strings)") + } + orgURA, ok := relation["ura"].(string) + if !ok || orgURA == "" { + return nil, fmt.Errorf("id_token 'relations[0].ura' claim invalid or missing (expected non-empty string)") + } + getString := func(claim string) string { + value, ok := idToken.Get(claim) + if !ok { + return "" + } + result, _ := value.(string) + return result + } + userID := getString("Dezi_id") + if userID == "" { + return nil, fmt.Errorf("id_token missing 'Dezi_id' claim") + } + initials := getString("initials") + if initials == "" { + return nil, fmt.Errorf("id_token missing 'initials' claim") + } + surname := getString("surname") + if surname == "" { + return nil, fmt.Errorf("id_token missing 'surname' claim") + } + surnamePrefix := getString("surname_prefix") + if surnamePrefix == "" { + return nil, fmt.Errorf("id_token missing 'surname_prefix' claim") + } + + credentialMap := map[string]any{ + "@context": []any{ + "https://www.w3.org/2018/credentials/v1", + // TODO: Create JSON-LD context? + }, + "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, + "issuanceDate": idToken.NotBefore().Format(time.RFC3339Nano), + "expirationDate": idToken.Expiration().Format(time.RFC3339Nano), + "credentialSubject": map[string]any{ + "@type": "DeziIDTokenSubject", + "identifier": orgURA, + "name": relation["entity_name"], + "employee": map[string]any{ + "@type": "HealthcareWorker", + "identifier": userID, + "initials": initials, + "surnamePrefix": surnamePrefix, + "surname": surname, + "roles": roles, + }, + }, + "proof": map[string]any{ + "type": "DeziIDJWT", + "jwt": idTokenSerialized, + }, + } + data, _ := json.Marshal(credentialMap) + return vc.ParseVerifiableCredential(string(data)) +} diff --git a/vcr/credential/dezi_test.go b/vcr/credential/dezi_test.go new file mode 100644 index 0000000000..ea9b970cab --- /dev/null +++ b/vcr/credential/dezi_test.go @@ -0,0 +1,28 @@ +package credential + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateDeziIDToken(t *testing.T) { + t.Run("ok", func(t *testing.T) { + const input = "eyJhbGciOiJSUzI1NiIsImtpZCI6IlVhd3AwY2lRck1PSENadW04MlA2dkphNU8xND0iLCJ0eXAiOiJKV1QifQ.eyJEZXppX2lkIjoiOTAwMDAwMDA5IiwiYXVkIjpbIjAwNmZiZjM0LWE4MGItNGM4MS1iNmU5LTU5MzYwMDY3NWZiMiJdLCJleHAiOjE3MDE5MzM2OTcsImluaXRpYWxzIjoiQi5CLiIsImlzcyI6Imh0dHBzOi8vbWF4LnByb2VmdHVpbi5EZXppLW9ubGluZS5yZG9iZWhlZXIubmwiLCJqc29uX3NjaGVtYSI6Imh0dHBzOi8vbWF4LnByb2VmdHVpbi5EZXppLW9ubGluZS5yZG9iZWhlZXIubmwvanNvbl9zY2hlbWEuanNvbiIsImxvYV9EZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImxvYV9hdXRobiI6Imh0dHA6Ly9laWRhcy5ldXJvcGEuZXUvTG9BL2hpZ2giLCJuYmYiOjE3MDE5MzM2MjcsInJlbGF0aW9ucyI6W3siZW50aXR5X25hbWUiOiJab3JnYWFuYmllZGVyIiwicm9sZXMiOlsiMDEuMDQxIiwiMzAuMDAwIiwiMDEuMDEwIiwiMDEuMDExIl0sInVyYSI6Ijg3NjU0MzIxIn1dLCJzdXJuYW1lIjoiSmFuc2VuIiwic3VybmFtZV9wcmVmaXgiOiJ2YW4gZGVyIiwieDVjIjpbIk1JSURWRENDQWp5Z0F3SUJBZ0lVRFdHVVJUNDJGWFdZbWR2Y1ExQXJxSUQrQUFBd0RRWUpLb1pJaHZjTkFRRUxCUUF3RWpFUU1BNEdBMVVFQXd3SFVtOXZkQ0JEUVRBZUZ3MHlOVEV5TWpReE16SXdOVEZhRncweU9EQXpNamd4TXpJd05URmFNQlF4RWpBUUJnTlZCQU1NQ1d4dlkyRnNhRzl6ZERDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTFJzeFRzaDhRTTdGTkFVUnRlb2pGTUhFNjdSd2dsL3NvdmJnWWlNMVZXTVZGYW9MT0UzYWxzaE9HMkh6d2ZkNUI4Q1VYOFg0K0ZudnZnazkrd0FrMU9vTENTSFA0Ri9LVGk1ZTJwNjRWdllzQUlXWDVOMjBldE5NeWFKRVBIM0lHMHl0OTNZbllZWHF0Y2hvUzNOV01lZjdMUFhJNnlOUXQvNnp0dzg5Qm5vY21lRDVqNmtKaWVtY0krTWttYmE2cHU0TitKT2dXS05ENTlVZlVuUmlDRlVtUEVoZ3VxRzd6WmJROGM3bFVkN1hPRVc1eGZQNUtmNjZ5eUsxaitUb3plUHlmelJIMVBuUHBDaDRWa2lmM1o2elJtc2t6Z0h6RDZjWmpaZDY0b1MwMExGOUczNFlMMkNuRElOdHNaUnZWTW5icmdpelJBY1lXOG83WUc5TzRjQ0F3RUFBYU9CbnpDQm5EQWZCZ05WSFNNRUdEQVdnQlF6VUFRZ0l4ZXBuWjhrSUhRQ0tLdWVIVUhaQ1RBSkJnTlZIUk1FQWpBQU1Bc0dBMVVkRHdRRUF3SUU4REFkQmdOVkhTVUVGakFVQmdnckJnRUZCUWNEQVFZSUt3WUJCUVVIQXdJd0l3WURWUjBSQkJ3d0dvSUpiRzlqWVd4b2IzTjBnZ2R1ZFhSekxtNXNod1IvQUFBQk1CMEdBMVVkRGdRV0JCUUZBY016U1pCYWdUQWNwZ2wvcmllSmV0S3J3VEFOQmdrcWhraUc5dzBCQVFzRkFBT0NBUUVBSzEvRHAxaWk2RWdhRk9NUmJFbWVLMWJrS1dvNDQyYVZHTUdXQzQ0TzUwTm9nSzM1aFM2eFdEdDNYb3B5eUEzcUFKd2FDeDc3ckNkNkVleXpSSlFucXdPRktjUGlqVERkL1hQa25xQzhIUTlqSGVqeWpUY2VaZjFOVVpvU1NteStiS3duK080NHBzdkhmWkpMajN0OFViSkRVd0ZWVm5tL0NMVlh0Q0tETHhnUU9LMWdKUkgrMmxvTmZvRHR0REE3c29TMGJrL2VFQ0FML3Bsdm9meE1LUUxBV0ZobWhLUkhnWC9TSVdORWhkdkcxYlNGVG5HOFVtTmh1RGkrU1dnV3RLTnE4SW5mL3hTSlRyT09ycndFU3V4Wk50aEp5bUVyaWFrT084VEN6VE9icGRoNVFydHo3SmRGV3l1ZDZ0ZnU3VnNVbHRHSEpSeW4zVU5TZCtIYlJnPT0iXX0.alxktj1V-sQHq-bCPdD5GB3E8YqKHgglPEXuYoATXIbo0CBUWARMP8x_Q-gyGdJvU3kpLHVhQK5Y7Y6N6Tpkw46jZiDEVmBEWPltbqKOdCjYGiEie4VB1Sw0lvKwXsiKP_xMGAI6LQ8nrS9_y8fT9JsRWBtqEYR8wb84B727FJiY3SVwks1lnqljW2-qHlS-Z-ecdT7GF_7VBsz5a5UITqQSX0-Q7ccvoXGl8QFtqYjkG9D0oYWRu46l6AtCqyrIc90Iq44nIQf38U46Fohz_ED-J0xSFoiQoWOuiv7vzkdDiO4RHnaGVJeCAiesa3-TdUzE69ZSiPzx8AV_pTRfiw" + + actual, err := CreateDeziIDTokenCredential(input) + require.NoError(t, err) + + require.Len(t, actual.CredentialSubject, 1) + subject := actual.CredentialSubject[0] + employee := subject["employee"].(map[string]interface{}) + assert.Equal(t, "87654321", subject["identifier"]) + assert.Equal(t, "Zorgaanbieder", subject["name"]) + assert.Equal(t, "900000009", employee["identifier"]) + assert.Equal(t, "B.B.", employee["initials"]) + assert.Equal(t, "Jansen", employee["surname"]) + assert.Equal(t, "van der", employee["surnamePrefix"]) + assert.Equal(t, []any{"01.041", "30.000", "01.010", "01.011"}, employee["roles"]) + }) +} diff --git a/vcr/credential/util.go b/vcr/credential/util.go index 41643548f7..25e299f19e 100644 --- a/vcr/credential/util.go +++ b/vcr/credential/util.go @@ -20,12 +20,14 @@ package credential import ( "errors" + "slices" + "time" + "github.com/google/uuid" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" - "slices" - "time" + "github.com/nuts-foundation/nuts-node/vcr/signature/proof" ) // ResolveSubjectDID resolves the subject DID from the given credentials. @@ -114,10 +116,22 @@ func PresentationExpirationDate(presentation vc.VerifiablePresentation) *time.Ti // AutoCorrectSelfAttestedCredential sets the required fields for a self-attested credential. // These are provided through the API, and for convenience we set the required fields, if not already set. -// It only does this for unsigned JSON-LD credentials. DO NOT USE THIS WITH JWT_VC CREDENTIALS. +// It only does this for unsigned JSON-LD credentials and DeziIDTokenCredentials (derived proof). DO NOT USE THIS WITH JWT_VC CREDENTIALS. func AutoCorrectSelfAttestedCredential(credential vc.VerifiableCredential, requester did.DID) vc.VerifiableCredential { if len(credential.Proof) > 0 { - return credential + var proof []proof.LDProof + _ = credential.UnmarshalProofValue(&proof) + isDeziTokenCredential := false + for _, p := range proof { + if p.Type == "DeziIDJWT" { + // derived proof, do the auto-correction + isDeziTokenCredential = true + break + } + } + if !isDeziTokenCredential { + return credential + } } if credential.ID == nil { credential.ID, _ = ssi.ParseURI(uuid.NewString()) diff --git a/vcr/credential/validator.go b/vcr/credential/validator.go index 0d62e8621d..962d041002 100644 --- a/vcr/credential/validator.go +++ b/vcr/credential/validator.go @@ -28,10 +28,7 @@ import ( "net/url" "strings" - "github.com/lestrrat-go/jwx/v2/cert" "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/crypto" @@ -415,26 +412,26 @@ func (d deziIDTokenCredentialValidator) Validate(credential vc.VerifiableCredent } func (d deziIDTokenCredentialValidator) validateDeziToken(credential vc.VerifiableCredential, serialized string) error { - headers, err := crypto.ExtractProtectedHeaders(serialized) - if err != nil { - return fmt.Errorf("invalid JWT headers: %w", err) - } - chain := cert.Chain{} - for i, s := range headers["x5c"].([]string) { - - } - - token, err := jwt.ParseString(serialized, jws.WithKeyProvider(jws.)) - if err != nil { - return err - } - // TODO: Verify deziToken signature - if !token.NotBefore().Equal(credential.IssuanceDate) { - return errors.New("id_token 'nbf' does not match credential 'issuanceDate'") - } - if !token.Expiration().Equal(*credential.ExpirationDate) { - return errors.New("id_token 'exp' does not match credential 'expirationDate'") - } + //headers, err := crypto.ExtractProtectedHeaders(serialized) + //if err != nil { + // return fmt.Errorf("invalid JWT headers: %w", err) + //} + //chain := cert.Chain{} + //for i, s := range headers["x5c"].([]string) { + // + //} + // + //token, err := jwt.ParseString(serialized, jws.WithKeyProvider(jws.)) + //if err != nil { + // return err + //} + //// TODO: Verify deziToken signature + //if !token.NotBefore().Equal(credential.IssuanceDate) { + // return errors.New("id_token 'nbf' does not match credential 'issuanceDate'") + //} + //if !token.Expiration().Equal(*credential.ExpirationDate) { + // return errors.New("id_token 'exp' does not match credential 'expirationDate'") + //} // TODO: implement rest of checks return nil } diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index 9c220c3bee..c8544dd893 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -24,11 +24,12 @@ import ( "crypto/rand" "embed" "encoding/json" + "strings" + "testing" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core/to" vcrTest "github.com/nuts-foundation/nuts-node/vcr/test" - "strings" - "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwt" @@ -102,6 +103,220 @@ func TestParsePresentationDefinition(t *testing.T) { }) } +func TestDeziIDTokenCredential(t *testing.T) { + // Create a simple Dezi id_token (JWT) + privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + token := jwt.New() + _ = token.Set(jwt.NotBeforeKey, 1701933627) + _ = token.Set(jwt.ExpirationKey, 1701933697) + _ = token.Set("initials", "B.B.") + _ = token.Set("surname", "Jansen") + _ = token.Set("surname_prefix", "van der") + _ = token.Set("Dezi_id", "900000009") + _ = token.Set("relations", []map[string]interface{}{ + { + "entity_name": "Zorgaanbieder", + "roles": []string{"01.041", "30.000"}, + "ura": "87654321", + }, + }) + signedToken, _ := jwt.Sign(token, jwt.WithKey(jwa.ES256, privateKey)) + + // Create DeziIDTokenCredential using the helper function + credentialMap := map[string]any{ + "@context": []any{ + "https://www.w3.org/2018/credentials/v1", + }, + "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, + "issuanceDate": token.NotBefore().Format("2006-01-02T15:04:05Z07:00"), + "expirationDate": token.Expiration().Format("2006-01-02T15:04:05Z07:00"), + "credentialSubject": map[string]any{ + "@type": "DeziIDTokenSubject", + "identifier": "87654321", + "name": "Zorgaanbieder", + "employee": map[string]any{ + "@type": "HealthcareWorker", + "identifier": "900000009", + "initials": "B.B.", + "surnamePrefix": "van der", + "surname": "Jansen", + "roles": []string{"01.041", "30.000"}, + }, + }, + "proof": map[string]any{ + "type": "DeziIDJWT", + "jwt": string(signedToken), + }, + } + data, _ := json.Marshal(credentialMap) + cred, err := vc.ParseVerifiableCredential(string(data)) + require.NoError(t, err) + + t.Run("matching credential", func(t *testing.T) { + // Create a presentation definition that matches DeziIDTokenCredential + pd, err := ParsePresentationDefinition([]byte(`{ + "id": "pd_dezi_id_token_credential", + "name": "Dezi ID Token", + "purpose": "Request a Dezi ID Token credential", + "input_descriptors": [ + { + "id": "id_dezi_credential", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "DeziIDTokenCredential" + } + }, + { + "id": "employee_identifier", + "path": [ + "$.credentialSubject.employee.identifier" + ], + "filter": { + "type": "string" + } + }, + { + "id": "employee_initials", + "path": [ + "$.credentialSubject.employee.initials" + ], + "filter": { + "type": "string" + } + } + ] + } + } + ] + }`)) + require.NoError(t, err) + + // Test matching + credentials, mappingObjects, err := pd.Match([]vc.VerifiableCredential{*cred}) + + require.NoError(t, err) + require.Len(t, credentials, 1) + require.Len(t, mappingObjects, 1) + + // Test field resolution + credMap := map[string]vc.VerifiableCredential{ + "id_dezi_credential": *cred, + } + fieldValues, err := pd.ResolveConstraintsFields(credMap) + require.NoError(t, err) + require.Len(t, fieldValues, 2) + assert.Equal(t, "900000009", fieldValues["employee_identifier"]) + assert.Equal(t, "B.B.", fieldValues["employee_initials"]) + }) + + t.Run("non-matching credential type", func(t *testing.T) { + pd, err := ParsePresentationDefinition([]byte(`{ + "id": "pd_other_credential", + "input_descriptors": [ + { + "id": "other_credential", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "SomeOtherCredential" + } + } + ] + } + } + ] + }`)) + require.NoError(t, err) + + credentials, mappingObjects, err := pd.Match([]vc.VerifiableCredential{*cred}) + + assert.Error(t, err) + assert.Empty(t, credentials) + assert.Empty(t, mappingObjects) + }) + + t.Run("matching with organization identifier", func(t *testing.T) { + pd, err := ParsePresentationDefinition([]byte(`{ + "id": "pd_dezi_with_org", + "input_descriptors": [ + { + "id": "dezi_org_credential", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "DeziIDTokenCredential" + } + }, + { + "id": "organization_identifier", + "path": ["$.credentialSubject.identifier"], + "filter": { + "type": "string", + "const": "87654321" + } + } + ] + } + } + ] + }`)) + require.NoError(t, err) + + credentials, mappingObjects, err := pd.Match([]vc.VerifiableCredential{*cred}) + + require.NoError(t, err) + require.Len(t, credentials, 1) + require.Len(t, mappingObjects, 1) + }) + + t.Run("matching employee roles", func(t *testing.T) { + pd, err := ParsePresentationDefinition([]byte(`{ + "id": "pd_dezi_with_roles", + "input_descriptors": [ + { + "id": "dezi_roles_credential", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "DeziIDTokenCredential" + } + }, + { + "id": "employee_roles", + "path": ["$.credentialSubject.employee.roles[*]"], + "filter": { + "type": "string" + } + } + ] + } + } + ] + }`)) + require.NoError(t, err) + + credentials, _, err := pd.Match([]vc.VerifiableCredential{*cred}) + + require.NoError(t, err) + require.Len(t, credentials, 1) + }) +} + func TestEmployeeCredential(t *testing.T) { pd, err := ParsePresentationDefinition([]byte(`{ "format": { diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index 3bb48ca544..45f79fd934 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -388,11 +388,12 @@ func TestVerifier_Verify(t *testing.T) { }) t.Run("DeziIDTokenCredential", func(t *testing.T) { ctx := newMockContext(t) - validAt := time.Now() + ctx.store.EXPECT().GetRevocations(gomock.Any()).Return(nil, ErrNotFound) + validAt := time.Date(2023, 12, 7, 7, 20, 27, 0, time.UTC) cred, _ := createDeziCredential(t, "did:web:example.com") - err := ctx.verifier.Verify(*cred, false, true, &validAt) + err := ctx.verifier.Verify(*cred, true, true, &validAt) assert.NoError(t, err) }) } @@ -934,17 +935,19 @@ func createDeziCredential(t *testing.T, holderDID string) (*vc.VerifiableCredent signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, key)) require.NoError(t, err) + println(string(signed)) + credentialMap := map[string]any{ "@context": []any{ "https://www.w3.org/2018/credentials/v1", }, "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, - "issuer": "https://max.proeftuin.Dezi-online.rdobeheer.nl", + "issuer": holderDID, + "id": holderDID + "#1", "issuanceDate": token.NotBefore().Format(time.RFC3339Nano), "expirationDate": token.Expiration().Format(time.RFC3339Nano), "credentialSubject": map[string]any{ "@type": "DeziIDTokenSubject", - "id": holderDID, "identifier": "87654321", "name": "Zorgaanbieder", "employee": map[string]any{ From 6552dfae10c2d7547a90c015f68d8187b471ddfe Mon Sep 17 00:00:00 2001 From: reinkrul Date: Mon, 2 Feb 2026 17:20:54 +0100 Subject: [PATCH 25/27] Update vcr/credential/validator.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- vcr/credential/validator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vcr/credential/validator.go b/vcr/credential/validator.go index 962d041002..1e4fb5e75c 100644 --- a/vcr/credential/validator.go +++ b/vcr/credential/validator.go @@ -403,7 +403,7 @@ func (d deziIDTokenCredentialValidator) Validate(credential vc.VerifiableCredent } proof := proofs[0] if proof.Type != "DeziIDJWT" { - return fmt.Errorf("%w: invalid proof type: expected 'DeziIDToken', got '%s'", errValidation, proof.Type) + return fmt.Errorf("%w: invalid proof type: expected 'DeziIDJWT', got '%s'", errValidation, proof.Type) } if err := d.validateDeziToken(credential, proof.JWT); err != nil { return fmt.Errorf("%w: invalid Dezi id_token: %w", errValidation, err) From ea7ffac170d272e8ce956f0c1b91ea733c79edce Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Mon, 2 Feb 2026 17:53:02 +0100 Subject: [PATCH 26/27] cleanup --- .../dezi_idtoken/docker-compose.yml | 2 +- vcr/credential/test.go | 55 ++++++++++++ vcr/pe/presentation_definition_test.go | 29 ++----- vcr/verifier/verifier_test.go | 83 +++---------------- 4 files changed, 76 insertions(+), 93 deletions(-) create mode 100644 vcr/credential/test.go diff --git a/e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml b/e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml index f5dfd438f4..c4f374168d 100644 --- a/e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml +++ b/e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml @@ -1,6 +1,6 @@ services: nodeA-backend: - image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:local}" + image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:master}" ports: - "18081:8081" environment: diff --git a/vcr/credential/test.go b/vcr/credential/test.go new file mode 100644 index 0000000000..ed69ef43a4 --- /dev/null +++ b/vcr/credential/test.go @@ -0,0 +1,55 @@ +package credential + +import ( + "crypto/sha1" + "crypto/tls" + "encoding/base64" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +func CreateTestDeziIDToken(issuedAt time.Time, validUntil time.Time) ([]byte, error) { + keyPair, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") + if err != nil { + return nil, err + } + key, err := jwk.FromRaw(keyPair.PrivateKey) + if err != nil { + return nil, err + } + x5t := sha1.Sum(keyPair.Leaf.Raw) + claims := map[string]any{ + jwk.KeyIDKey: base64.StdEncoding.EncodeToString(x5t[:]), + jwk.X509CertThumbprintKey: base64.StdEncoding.EncodeToString(x5t[:]), + jwk.AlgorithmKey: "RS256", + jwt.AudienceKey: "006fbf34-a80b-4c81-b6e9-593600675fb2", + jwt.ExpirationKey: validUntil.Unix(), + jwt.NotBeforeKey: issuedAt.Unix(), + jwt.IssuerKey: "https://max.proeftuin.Dezi-online.rdobeheer.nl", + "initials": "B.B.", + "surname": "Jansen", + "surname_prefix": "van der", + "Dezi_id": "900000009", + "json_schema": "https://max.proeftuin.Dezi-online.rdobeheer.nl/json_schema.json", + "loa_authn": "http://eidas.europa.eu/LoA/high", + "loa_Dezi": "http://eidas.europa.eu/LoA/high", + "x5c": []string{base64.StdEncoding.EncodeToString(keyPair.Leaf.Raw)}, + "relations": []map[string]interface{}{ + { + "entity_name": "Zorgaanbieder", + "roles": []string{"01.041", "30.000", "01.010", "01.011"}, + "ura": "87654321", + }, + }, + } + token := jwt.New() + for name, value := range claims { + if err := token.Set(name, value); err != nil { + return nil, err + } + } + return jwt.Sign(token, jwt.WithKey(jwa.RS256, key)) +} diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index c8544dd893..9e4378aa54 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -26,9 +26,11 @@ import ( "encoding/json" "strings" "testing" + "time" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core/to" + "github.com/nuts-foundation/nuts-node/vcr/credential" vcrTest "github.com/nuts-foundation/nuts-node/vcr/test" "github.com/lestrrat-go/jwx/v2/jwa" @@ -104,23 +106,10 @@ func TestParsePresentationDefinition(t *testing.T) { } func TestDeziIDTokenCredential(t *testing.T) { - // Create a simple Dezi id_token (JWT) - privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - token := jwt.New() - _ = token.Set(jwt.NotBeforeKey, 1701933627) - _ = token.Set(jwt.ExpirationKey, 1701933697) - _ = token.Set("initials", "B.B.") - _ = token.Set("surname", "Jansen") - _ = token.Set("surname_prefix", "van der") - _ = token.Set("Dezi_id", "900000009") - _ = token.Set("relations", []map[string]interface{}{ - { - "entity_name": "Zorgaanbieder", - "roles": []string{"01.041", "30.000"}, - "ura": "87654321", - }, - }) - signedToken, _ := jwt.Sign(token, jwt.WithKey(jwa.ES256, privateKey)) + iat := time.Unix(1701933627, 0) + exp := time.Unix(1701933697, 0) + token, err := credential.CreateTestDeziIDToken(iat, exp) + require.NoError(t, err) // Create DeziIDTokenCredential using the helper function credentialMap := map[string]any{ @@ -128,8 +117,8 @@ func TestDeziIDTokenCredential(t *testing.T) { "https://www.w3.org/2018/credentials/v1", }, "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, - "issuanceDate": token.NotBefore().Format("2006-01-02T15:04:05Z07:00"), - "expirationDate": token.Expiration().Format("2006-01-02T15:04:05Z07:00"), + "issuanceDate": iat.Format("2006-01-02T15:04:05Z07:00"), + "expirationDate": exp.Format("2006-01-02T15:04:05Z07:00"), "credentialSubject": map[string]any{ "@type": "DeziIDTokenSubject", "identifier": "87654321", @@ -145,7 +134,7 @@ func TestDeziIDTokenCredential(t *testing.T) { }, "proof": map[string]any{ "type": "DeziIDJWT", - "jwt": string(signedToken), + "jwt": string(token), }, } data, _ := json.Marshal(credentialMap) diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index 45f79fd934..ff1cc31f68 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -21,9 +21,6 @@ package verifier import ( "context" "crypto" - "crypto/sha1" - "crypto/tls" - "crypto/x509" "encoding/json" "errors" "net/http" @@ -34,11 +31,6 @@ import ( "testing" "time" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/nuts-foundation/nuts-node/storage/orm" - "github.com/nuts-foundation/nuts-node/test/pki" - "github.com/segmentio/asm/base64" - "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" ssi "github.com/nuts-foundation/go-did" @@ -47,7 +39,9 @@ import ( "github.com/nuts-foundation/nuts-node/crypto/storage/spi" "github.com/nuts-foundation/nuts-node/jsonld" "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/storage/orm" "github.com/nuts-foundation/nuts-node/test/io" + "github.com/nuts-foundation/nuts-node/test/pki" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/revocation" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" @@ -391,7 +385,7 @@ func TestVerifier_Verify(t *testing.T) { ctx.store.EXPECT().GetRevocations(gomock.Any()).Return(nil, ErrNotFound) validAt := time.Date(2023, 12, 7, 7, 20, 27, 0, time.UTC) - cred, _ := createDeziCredential(t, "did:web:example.com") + cred := createDeziCredential(t, "did:web:example.com") err := ctx.verifier.Verify(*cred, true, true, &validAt) assert.NoError(t, err) @@ -876,66 +870,11 @@ func newMockContext(t *testing.T) mockContext { } // createDeziIDToken creates a signed Dezi id_token according to https://www.dezi.nl/documenten/2024/05/08/koppelvlakspecificatie-dezi-online-koppelvlak-1_-platformleverancier -func createDeziCredential(t *testing.T, holderDID string) (*vc.VerifiableCredential, *x509.Certificate) { - keyPair, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") - require.NoError(t, err) - - key, err := jwk.FromRaw(keyPair.PrivateKey) - require.NoError(t, err) - - // Set the key ID and x5t (X.509 thumbprint) - x5t := sha1.Sum(keyPair.Leaf.Raw) - err = key.Set(jwk.KeyIDKey, base64.StdEncoding.EncodeToString(x5t[:])) - require.NoError(t, err) - err = key.Set(jwk.X509CertThumbprintKey, base64.StdEncoding.EncodeToString(x5t[:])) - require.NoError(t, err) - err = key.Set(jwk.AlgorithmKey, "RS256") - require.NoError(t, err) - - // Build the JWT token - token := jwt.New() - - // Set claims from the DeziIDTokenCredential payload - err = token.Set(jwt.AudienceKey, "006fbf34-a80b-4c81-b6e9-593600675fb2") - require.NoError(t, err) - err = token.Set(jwt.ExpirationKey, time.Unix(1701933697, 0)) - require.NoError(t, err) - err = token.Set(jwt.NotBeforeKey, time.Unix(1701933627, 0)) +func createDeziCredential(t *testing.T, holderDID string) *vc.VerifiableCredential { + exp := time.Unix(1701933697, 0) + iat := time.Unix(1701933627, 0) + idToken, err := credential.CreateTestDeziIDToken(iat, exp) require.NoError(t, err) - err = token.Set(jwt.IssuerKey, "https://max.proeftuin.Dezi-online.rdobeheer.nl") - require.NoError(t, err) - - // Set custom claims - err = token.Set("initials", "B.B.") - require.NoError(t, err) - err = token.Set("json_schema", "https://max.proeftuin.Dezi-online.rdobeheer.nl/json_schema.json") - require.NoError(t, err) - err = token.Set("loa_authn", "http://eidas.europa.eu/LoA/high") - require.NoError(t, err) - err = token.Set("loa_Dezi", "http://eidas.europa.eu/LoA/high") - require.NoError(t, err) - err = token.Set("relations", []map[string]interface{}{ - { - "entity_name": "Zorgaanbieder", - "roles": []string{"01.041", "30.000", "01.010", "01.011"}, - "ura": "87654321", - }, - }) - require.NoError(t, err) - err = token.Set("surname", "Jansen") - require.NoError(t, err) - err = token.Set("surname_prefix", "van der") - require.NoError(t, err) - err = token.Set("Dezi_id", "900000009") - require.NoError(t, err) - err = token.Set("x5c", []string{base64.StdEncoding.EncodeToString(keyPair.Leaf.Raw)}) - require.NoError(t, err) - - // Sign the token using jwt.Sign - signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, key)) - require.NoError(t, err) - - println(string(signed)) credentialMap := map[string]any{ "@context": []any{ @@ -944,8 +883,8 @@ func createDeziCredential(t *testing.T, holderDID string) (*vc.VerifiableCredent "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, "issuer": holderDID, "id": holderDID + "#1", - "issuanceDate": token.NotBefore().Format(time.RFC3339Nano), - "expirationDate": token.Expiration().Format(time.RFC3339Nano), + "issuanceDate": iat.Format(time.RFC3339Nano), + "expirationDate": exp.Format(time.RFC3339Nano), "credentialSubject": map[string]any{ "@type": "DeziIDTokenSubject", "identifier": "87654321", @@ -961,12 +900,12 @@ func createDeziCredential(t *testing.T, holderDID string) (*vc.VerifiableCredent }, "proof": map[string]any{ "type": "DeziIDJWT", - "jwt": string(signed), + "jwt": string(idToken), }, } data, err := json.Marshal(credentialMap) require.NoError(t, err) cred, err := vc.ParseVerifiableCredential(string(data)) require.NoError(t, err) - return cred, keyPair.Leaf + return cred } From ca4005deb07fd68f67c95e4db90030b744c6e764 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Tue, 3 Feb 2026 09:06:40 +0100 Subject: [PATCH 27/27] Push docker image --- .github/workflows/build-images.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-images.yaml b/.github/workflows/build-images.yaml index 81df310f99..a7c453806c 100644 --- a/.github/workflows/build-images.yaml +++ b/.github/workflows/build-images.yaml @@ -5,13 +5,13 @@ on: push: branches: - master - - lspxnuts + - project-gf tags: - 'v*' pull_request: branches: - master - - lspxnuts + - project-gf # cancel build action if superseded by new commit on same branch concurrency: