diff --git a/README.rst b/README.rst index 0f4b18cfdd..a28f6cb47d 100644 --- a/README.rst +++ b/README.rst @@ -217,14 +217,16 @@ The following options can be configured on the server: storage.session.redis.sentinel.username Username for authenticating to Redis Sentinels. storage.session.redis.tls.truststorefile PEM file containing the trusted CA certificate(s) for authenticating remote Redis session servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). storage.sql.connection Connection string for the SQL database. If not set it, defaults to a SQLite database stored inside the configured data directory. Note: using SQLite is not recommended in production environments. If using SQLite anyways, remember to enable foreign keys ('_foreign_keys=on') and the write-ahead-log ('_journal_mode=WAL'). + storage.sql.rdsiam.dbuser Database username for IAM authentication. If not specified, the username from the connection string will be used. The database user must be created with IAM authentication enabled. storage.sql.rdsiam.enabled false Enable AWS RDS IAM authentication for the SQL database connection. When enabled, the node will use temporary IAM tokens instead of passwords. Requires the connection string to be a PostgreSQL or MySQL RDS endpoint without a password. storage.sql.rdsiam.region AWS region where the RDS instance is located (e.g., 'us-east-1). Required when RDS IAM authentication is enabled. - storage.sql.rdsiam.dbuser Database username for IAM authentication. If not specified, the username from the connection string will be used. The database user must be created with IAM authentication enabled. - storage.sql.rdsiam.tokenrefreshinterval 14m0s Interval at which to refresh the IAM authentication token. RDS tokens are valid for 15 minutes, so the default is 14 minutes to ensure tokens are refreshed before expiry. Specified as Golang duration (e.g. 10m, 1h). + storage.sql.rdsiam.tokenrefreshinterval 14m0s Interval at which to refresh the IAM authentication token. RDS tokens are valid for 15 minutes, so set this to ensure tokens are refreshed before expiry. Specified as Golang duration (e.g. 10m, 1h). **Tracing** tracing.endpoint OTLP collector endpoint for OpenTelemetry tracing (e.g., 'localhost:4318'). When empty, tracing is disabled. tracing.insecure false Disable TLS for the OTLP connection. tracing.servicename Service name reported to the tracing backend. Defaults to 'nuts-node'. + **VCR** + vcr.dezi.allowedjku [] List of allowed JKU URLs for fetching Dezi attestation keys. If not set, defaults to production (https://auth.dezi.nl/dezi/jwks.json), and in non-strict mode also acceptance (https://acceptatie.auth.dezi.nl/dezi/jwks.json). **policy** policy.directory ./config/policy Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================ diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 339d4b0086..4a95e73ca7 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,10 +752,26 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS if request.Body.Credentials != nil { credentials = *request.Body.Credentials } + + idTokenCredentialIdx := -1 + if request.Body.IdToken != nil { + idTokenCredential, err := credential.CreateDeziUserCredential(*request.Body.IdToken) + if err != nil { + return nil, core.InvalidInputError("failed to create id_token credential: %w", err) + } + credentials = append(credentials, *idTokenCredential) + idTokenCredentialIdx = len(credentials) - 1 + } + // 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. - for _, cred := range credentials { + // As a side effect it is no longer possible to pass signed credentials to this API. + for i, cred := range credentials { + // But not for id_token credentials, these are externally signed, meaning they have an issuer + if i == idTokenCredentialIdx { + continue + } + var credentialSubject []map[string]interface{} if err := cred.UnmarshalCredentialSubject(&credentialSubject); err != nil { // extremely unlikely diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 35efca16da..8ffda9f099 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -979,6 +979,34 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { require.NoError(t, err) assert.False(t, ctx.client.accessTokenCache().Exists(accessTokenRequestCacheKey(request))) }) + t.Run("with Dezi id_token", func(t *testing.T) { + ctx := newTestClient(t) + idToken := "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFlNDY4MjlkLWM4ZTgtNDhhMC1iZDZhLTIxYjhhMDdiOGNiMiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYWNjZXB0YXRpZS5hdXRoLmRlemkubmwvZGV6aS9qd2tzLmpzb24ifQ.eyJqc29uX3NjaGVtYSI6Imh0dHBzOi8vd3d3LmRlemkubmwvanNvbl9zY2hlbWFzL3YxL3ZlcmtsYXJpbmcuanNvbiIsImxvYV9kZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImp0aSI6ImY0MTBiMjU1LTZiMDctNDE4Mi1hYzVjLWM0MWYwMmJkMzk5NSIsInZlcmtsYXJpbmdfaWQiOiIwZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJkZXppX251bW1lciI6IjkwMDAyMjE1OSIsInZvb3JsZXR0ZXJzIjoiSi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IjkwMDE3MzYyIiwiYWJvbm5lZV9udW1tZXIiOiI5MDAwMDM4MCIsImFib25uZWVfbmFhbSI6IlTDqXN0IFpvcmdpbnN0ZWxsaW5nIDAxIiwicm9sX2NvZGUiOiI5Mi4wMDAiLCJyb2xfbmFhbSI6Ik1vbmRoeWdpw6tuaXN0Iiwicm9sX2NvZGVfYnJvbiI6Imh0dHA6Ly93d3cuZGV6aS5ubC9yb2xfYnJvbi9iaWciLCJzdGF0dXNfdXJpIjoiaHR0cHM6Ly9hY2NlcHRhdGllLmF1dGguZGV6aS5ubC9zdGF0dXMvdjEvdmVya2xhcmluZy8wZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJuYmYiOjE3NzI2NjUyMDAsImV4cCI6MTc4MDYxMDQwMCwiaXNzIjoiaHR0cHM6Ly9hYm9ubmVlLmRlemkubmwifQ.ipR4stqmO8MOmmapukeQxIOVpwO_Ipjgy5BHjUsdCvuFObhVrj48AQCndtV48D_Ol1hXO4s9p4b-1epjEiobjEmEO0JQNU0BAOGG0eWl8MujfhzlDnmwo5AEtvdgTjlnBaLReVu1BJ8KYgc1DT7JhCukq9z5wZLqU1aqtETleX2-s-dNdTdwrUjJa1DvIgO-DQ_rCp-1tcfkr2rtyW16ztyI88Q2YdBkNGcG0if5aYZHpcQ4-121WBObUa0FhswS7EHni5Ru8KwZNq0HC8OLWw3YqLrYHTFe2K0GQjMtEO6zNxApbMXWKlgeWdf7Ry2rPpe2l9Z5NuMrFiB8JChZsQ" + request := RequestServiceAccessTokenRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestServiceAccessTokenJSONRequestBody{ + AuthorizationServer: verifierURL.String(), + Scope: "first second", + IdToken: to.Ptr(idToken), + }, + } + + // Expect that the id_token is converted to a Dezi credential and passed to RequestRFC021AccessToken + ctx.iamClient.EXPECT().RequestRFC021AccessToken( + nil, + holderClientID, + holderSubjectID, + verifierURL.String(), + "first second", + true, + gomock.Any(), // The id_token is converted to a DeziUserCredential + ).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) + + _, err := ctx.client.RequestServiceAccessToken(nil, request) + + require.NoError(t, err) + assert.False(t, ctx.client.accessTokenCache().Exists(accessTokenRequestCacheKey(request))) + }) 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) 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/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index e5c4ef6840..5f93e4d417 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -24,16 +24,17 @@ 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" + 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" @@ -319,6 +320,38 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { assert.Equal(t, "token", response.AccessToken) assert.Equal(t, "bearer", response.TokenType) }) + t.Run("with Dezi credential", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) + + // Create a Dezi credential from an id_token + idToken := "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFlNDY4MjlkLWM4ZTgtNDhhMC1iZDZhLTIxYjhhMDdiOGNiMiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYWNjZXB0YXRpZS5hdXRoLmRlemkubmwvZGV6aS9qd2tzLmpzb24ifQ.eyJqc29uX3NjaGVtYSI6Imh0dHBzOi8vd3d3LmRlemkubmwvanNvbl9zY2hlbWFzL3YxL3ZlcmtsYXJpbmcuanNvbiIsImxvYV9kZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImp0aSI6ImY0MTBiMjU1LTZiMDctNDE4Mi1hYzVjLWM0MWYwMmJkMzk5NSIsInZlcmtsYXJpbmdfaWQiOiIwZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJkZXppX251bW1lciI6IjkwMDAyMjE1OSIsInZvb3JsZXR0ZXJzIjoiSi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IjkwMDE3MzYyIiwiYWJvbm5lZV9udW1tZXIiOiI5MDAwMDM4MCIsImFib25uZWVfbmFhbSI6IlTDqXN0IFpvcmdpbnN0ZWxsaW5nIDAxIiwicm9sX2NvZGUiOiI5Mi4wMDAiLCJyb2xfbmFhbSI6Ik1vbmRoeWdpw6tuaXN0Iiwicm9sX2NvZGVfYnJvbiI6Imh0dHA6Ly93d3cuZGV6aS5ubC9yb2xfYnJvbi9iaWciLCJzdGF0dXNfdXJpIjoiaHR0cHM6Ly9hY2NlcHRhdGllLmF1dGguZGV6aS5ubC9zdGF0dXMvdjEvdmVya2xhcmluZy8wZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJuYmYiOjE3NzI2NjUyMDAsImV4cCI6MTc4MDYxMDQwMCwiaXNzIjoiaHR0cHM6Ly9hYm9ubmVlLmRlemkubmwifQ.ipR4stqmO8MOmmapukeQxIOVpwO_Ipjgy5BHjUsdCvuFObhVrj48AQCndtV48D_Ol1hXO4s9p4b-1epjEiobjEmEO0JQNU0BAOGG0eWl8MujfhzlDnmwo5AEtvdgTjlnBaLReVu1BJ8KYgc1DT7JhCukq9z5wZLqU1aqtETleX2-s-dNdTdwrUjJa1DvIgO-DQ_rCp-1tcfkr2rtyW16ztyI88Q2YdBkNGcG0if5aYZHpcQ4-121WBObUa0FhswS7EHni5Ru8KwZNq0HC8OLWw3YqLrYHTFe2K0GQjMtEO6zNxApbMXWKlgeWdf7Ry2rPpe2l9Z5NuMrFiB8JChZsQ" + deziCredential, err := credential.CreateDeziUserCredential(idToken) + require.NoError(t, err) + + credentials := []vc.VerifiableCredential{*deziCredential} + + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + // Assert Dezi credentials are NOT self-attested (they have an issuer) + require.Len(t, additionalCredentials, 2) + require.Len(t, additionalCredentials[primaryWalletDID], 1) + // Dezi credentials have their own issuer, not the wallet DID + assert.Equal(t, "https://abonnee.dezi.nl", additionalCredentials[primaryWalletDID][0].Issuer.String()) + assert.Contains(t, additionalCredentials[primaryWalletDID][0].Type, ssi.MustParseURI("DeziUserCredential")) + require.Len(t, additionalCredentials[secondaryWalletDID], 1) + assert.Equal(t, "https://abonnee.dezi.nl", additionalCredentials[secondaryWalletDID][0].Issuer.String()) + assert.Contains(t, additionalCredentials[secondaryWalletDID][0].Type, ssi.MustParseURI("DeziUserCredential")) + return createdVP, &pe.PresentationSubmission{}, nil + }) + + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, credentials) + + 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/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/auth/services/oauth/relying_party.go b/auth/services/oauth/relying_party.go index e78160a86c..50fede79b6 100644 --- a/auth/services/oauth/relying_party.go +++ b/auth/services/oauth/relying_party.go @@ -22,11 +22,12 @@ import ( "context" "crypto/tls" "fmt" - "github.com/nuts-foundation/nuts-node/pki" "net/url" "strings" "time" + "github.com/nuts-foundation/nuts-node/pki" + "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/auth/api/auth/v1/client" diff --git a/core/tls.go b/core/tls.go index 3ef10babaf..e36fe6a0d5 100644 --- a/core/tls.go +++ b/core/tls.go @@ -71,6 +71,14 @@ type TrustStore struct { certificates []*x509.Certificate } +// PinCertificate adds the given certificate to the trust store as root certificate, without checking whether it forms a valid chain to some root. +// This is useful for trusting pinned certificates. +func (store *TrustStore) PinCertificate(certificate *x509.Certificate) { + store.certificates = append(store.certificates, certificate) + store.RootCAs = append(store.RootCAs, certificate) + store.CertPool.AddCert(certificate) +} + // Certificates returns a copy of the certificates within the CertPool func (store *TrustStore) Certificates() []*x509.Certificate { return store.certificates[:] 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/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index 4863fce1bf..13d4b787f2 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -58,10 +58,16 @@ storage.session.redis.sentinel.username Username for authenticating to Redis Sentinels. storage.session.redis.tls.truststorefile PEM file containing the trusted CA certificate(s) for authenticating remote Redis session servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). storage.sql.connection Connection string for the SQL database. If not set it, defaults to a SQLite database stored inside the configured data directory. Note: using SQLite is not recommended in production environments. If using SQLite anyways, remember to enable foreign keys ('_foreign_keys=on') and the write-ahead-log ('_journal_mode=WAL'). + storage.sql.rdsiam.dbuser Database username for IAM authentication. If not specified, the username from the connection string will be used. The database user must be created with IAM authentication enabled. + storage.sql.rdsiam.enabled false Enable AWS RDS IAM authentication for the SQL database connection. When enabled, the node will use temporary IAM tokens instead of passwords. Requires the connection string to be a PostgreSQL or MySQL RDS endpoint without a password. + storage.sql.rdsiam.region AWS region where the RDS instance is located (e.g., 'us-east-1). Required when RDS IAM authentication is enabled. + storage.sql.rdsiam.tokenrefreshinterval 14m0s Interval at which to refresh the IAM authentication token. RDS tokens are valid for 15 minutes, so set this to ensure tokens are refreshed before expiry. Specified as Golang duration (e.g. 10m, 1h). **Tracing** tracing.endpoint OTLP collector endpoint for OpenTelemetry tracing (e.g., 'localhost:4318'). When empty, tracing is disabled. tracing.insecure false Disable TLS for the OTLP connection. tracing.servicename Service name reported to the tracing backend. Defaults to 'nuts-node'. + **VCR** + vcr.dezi.allowedjku [] List of allowed JKU URLs for fetching Dezi attestation keys. If not set, defaults to production (https://auth.dezi.nl/dezi/jwks.json), and in non-strict mode also acceptance (https://acceptatie.auth.dezi.nl/dezi/jwks.json). **policy** policy.directory ./config/policy Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================ 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..3d1a45ac10 --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/accesspolicy.json @@ -0,0 +1,151 @@ +{ + "test": { + "organization": { + "format": { + "ldp_vc": { + "proof_type": [ + "DeziIDJWT2024", + "DeziIDJWT07" + ] + }, + "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": "DeziUserCredential" + } + }, + { + "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_role", + "path": [ + "$.credentialSubject.employee.role" + ], + "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..e5a87e017f --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml @@ -0,0 +1,30 @@ +services: + nodeA-backend: + image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:master}" + 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 + NUTS_AUTH_DEZI_ALLOWEDJKU: "https://acceptatie.auth.dezi.nl/dezi/jwks.json" + 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..27aa9cd10c --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/generate-jwt.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# Output Dezi v0.7 format id_token (verklaring) +# This is a real Dezi token from their acceptance environment +# Usage: ./generate-jwt.sh + +echo "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFlNDY4MjlkLWM4ZTgtNDhhMC1iZDZhLTIxYjhhMDdiOGNiMiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYWNjZXB0YXRpZS5hdXRoLmRlemkubmwvZGV6aS9qd2tzLmpzb24ifQ.eyJqc29uX3NjaGVtYSI6Imh0dHBzOi8vd3d3LmRlemkubmwvanNvbl9zY2hlbWFzL3YxL3ZlcmtsYXJpbmcuanNvbiIsImxvYV9kZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImp0aSI6ImY0MTBiMjU1LTZiMDctNDE4Mi1hYzVjLWM0MWYwMmJkMzk5NSIsInZlcmtsYXJpbmdfaWQiOiIwZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJkZXppX251bW1lciI6IjkwMDAyMjE1OSIsInZvb3JsZXR0ZXJzIjoiSi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IjkwMDE3MzYyIiwiYWJvbm5lZV9udW1tZXIiOiI5MDAwMDM4MCIsImFib25uZWVfbmFhbSI6IlTDqXN0IFpvcmdpbnN0ZWxsaW5nIDAxIiwicm9sX2NvZGUiOiI5Mi4wMDAiLCJyb2xfbmFhbSI6Ik1vbmRoeWdpw6tuaXN0Iiwicm9sX2NvZGVfYnJvbiI6Imh0dHA6Ly93d3cuZGV6aS5ubC9yb2xfYnJvbi9iaWciLCJzdGF0dXNfdXJpIjoiaHR0cHM6Ly9hY2NlcHRhdGllLmF1dGguZGV6aS5ubC9zdGF0dXMvdjEvdmVya2xhcmluZy8wZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJuYmYiOjE3NzI2NjUyMDAsImV4cCI6MTc4MDYxMDQwMCwiaXNzIjoiaHR0cHM6Ly9hYm9ubmVlLmRlemkubmwifQ.ipR4stqmO8MOmmapukeQxIOVpwO_Ipjgy5BHjUsdCvuFObhVrj48AQCndtV48D_Ol1hXO4s9p4b-1epjEiobjEmEO0JQNU0BAOGG0eWl8MujfhzlDnmwo5AEtvdgTjlnBaLReVu1BJ8KYgc1DT7JhCukq9z5wZLqU1aqtETleX2-s-dNdTdwrUjJa1DvIgO-DQ_rCp-1tcfkr2rtyW16ztyI88Q2YdBkNGcG0if5aYZHpcQ4-121WBObUa0FhswS7EHni5Ru8KwZNq0HC8OLWw3YqLrYHTFe2K0GQjMtEO6zNxApbMXWKlgeWdf7Ry2rPpe2l9Z5NuMrFiB8JChZsQ" diff --git a/e2e-tests/oauth-flow/dezi_idtoken/run-test.sh b/e2e-tests/oauth-flow/dezi_idtoken/run-test.sh new file mode 100755 index 0000000000..3ee0ec72e0 --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/run-test.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +source ../../util.sh + +echo "------------------------------------" +echo "Cleaning up running Docker containers and volumes, and key material..." +echo "------------------------------------" +docker compose down --remove-orphans +docker compose rm -f -v + +echo "------------------------------------" +echo "Starting Docker containers..." +echo "------------------------------------" +docker compose up -d +docker compose up --wait nodeA nodeA-backend + +echo "------------------------------------" +echo "Registering vendors..." +echo "------------------------------------" +# Register Vendor A +REQUEST="{\"subject\":\"vendorA\"}" +VENDOR_A_DIDDOC=$(echo $REQUEST | curl -X POST --data-binary @- http://localhost:18081/internal/vdr/v2/subject --header "Content-Type: application/json") +VENDOR_A_DID=$(echo $VENDOR_A_DIDDOC | jq -r .documents[0].id) +echo Vendor A DID: $VENDOR_A_DID + +echo "------------------------------------" +echo "Issuing X509Credential..." +echo "------------------------------------" +CREDENTIAL=$(docker run \ + --rm \ + -v "$(pwd)/certs/nodeA-chain.pem:/cert-chain.pem:ro" \ + -v "$(pwd)/certs/nodeA.key:/cert-key.key:ro" \ + nutsfoundation/go-didx509-toolkit:main \ + vc "/cert-chain.pem" "/cert-key.key" "CN=Fake UZI Root CA" "${VENDOR_A_DID}") + +RESPONSE=$(echo "\"${CREDENTIAL}\"" | curl -s -o /dev/null -w "%{http_code}" -X POST --data-binary @- http://localhost:18081/internal/vcr/v2/holder/vendorA/vc -H "Content-Type:application/json") +if [ $RESPONSE -eq 204 ]; then + echo "VC stored in wallet" +else + echo "FAILED: Could not load X509Credential in wallet" 1>&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 from the Dezi token: +# Token contains: +# - "abonnee_nummer":"90000380" -> organization_ura_dezi +# - "dezi_nummer":"900022159" -> user_uzi +# - "voorletters":"J." -> user_initials +# - "achternaam":"90017362" -> user_surname +# - "voorvoegsel":null -> user_surname_prefix (empty) +# - "rol_code":"92.000" -> user_role +if [ "$(echo $RESPONSE | jq -r .organization_ura_dezi)" != "90000380" ]; then + echo "FAILED: organization_ura_dezi invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_initials)" != "J." ]; then + echo "FAILED: user_initials invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_role)" != "92.000" ]; then + echo "FAILED: user_role invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_surname)" != "90017362" ]; then + echo "FAILED: user_surname invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +# voorvoegsel is null in the token, so user_surname_prefix should be empty or not present +USER_SURNAME_PREFIX=$(echo $RESPONSE | jq -r .user_surname_prefix) +if [ "$USER_SURNAME_PREFIX" != "" ] && [ "$USER_SURNAME_PREFIX" != "null" ]; then + echo "FAILED: user_surname_prefix should be empty, got: $USER_SURNAME_PREFIX" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_uzi)" != "900022159" ]; 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/cmd/cmd.go b/vcr/cmd/cmd.go index 01db27af37..be05ffe732 100644 --- a/vcr/cmd/cmd.go +++ b/vcr/cmd/cmd.go @@ -21,11 +21,12 @@ package cmd import ( "encoding/json" "fmt" + "strings" + "time" + "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/spf13/pflag" - "strings" - "time" "github.com/nuts-foundation/nuts-node/core" api "github.com/nuts-foundation/nuts-node/vcr/api/vcr/v2" @@ -40,6 +41,7 @@ func FlagSet() *pflag.FlagSet { flagSet.String("vcr.openid4vci.definitionsdir", defs.OpenID4VCI.DefinitionsDIR, "Directory with the additional credential definitions the node could issue (experimental, may change without notice).") flagSet.Bool("vcr.openid4vci.enabled", defs.OpenID4VCI.Enabled, "Enable issuing and receiving credentials over OpenID4VCI.") flagSet.Duration("vcr.openid4vci.timeout", time.Second*30, "Time-out for OpenID4VCI HTTP client operations.") + flagSet.StringSlice("vcr.dezi.allowedjku", defs.Dezi.AllowedJKU, "List of allowed JKU URLs for fetching Dezi attestation keys. If not set, defaults to production (https://auth.dezi.nl/dezi/jwks.json), and in non-strict mode also acceptance (https://acceptatie.auth.dezi.nl/dezi/jwks.json).") return flagSet } diff --git a/vcr/config.go b/vcr/config.go index 614ddf7291..05f6399060 100644 --- a/vcr/config.go +++ b/vcr/config.go @@ -20,8 +20,9 @@ package vcr import ( - "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "time" + + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" ) // ModuleName is the name of this module. @@ -31,6 +32,14 @@ const ModuleName = "VCR" type Config struct { // OpenID4VCI holds the config for the OpenID4VCI credential issuer and wallet OpenID4VCI openid4vci.Config `koanf:"openid4vci"` + Dezi DeziConfig `koanf:"dezi"` +} + +type DeziConfig struct { + // AllowedJKU contains the list of JKU URLs from which Dezi attestation keys are allowed to be fetched. + // If not configured, defaults to production environment (https://auth.dezi.nl/dezi/jwks.json). + // In non-strict mode, acceptance environment is also allowed (https://acceptatie.auth.dezi.nl/dezi/jwks.json). + AllowedJKU []string `koanf:"allowedjku"` } // DefaultConfig returns a fresh Config filled with default values diff --git a/vcr/credential/Koppelvlakspecificatie_2024-DEZI-Online+koppelvlak+1_+platformleverancier.pdf b/vcr/credential/Koppelvlakspecificatie_2024-DEZI-Online+koppelvlak+1_+platformleverancier.pdf new file mode 100644 index 0000000000..aa7c8bb645 Binary files /dev/null and b/vcr/credential/Koppelvlakspecificatie_2024-DEZI-Online+koppelvlak+1_+platformleverancier.pdf differ diff --git a/vcr/credential/cert.pem b/vcr/credential/cert.pem new file mode 100644 index 0000000000..2d67b94830 --- /dev/null +++ b/vcr/credential/cert.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID1jCCAb4CCQD0FlvJsbcrvTANBgkqhkiG9w0BAQsFADAoMQswCQYDVQQDDAJV +UzEZMBcGA1UEAwwQaW5nZS02LXV6aXBvYy1jYTAeFw0yMzEyMDExMjMzMzBaFw0y +NTA0MTQxMjMzMzBaMDIxCzAJBgNVBAYTAlVTMSMwIQYDVQQDDBpubC11emlwb2Mt +cGhwLWxhcmF2ZWwtZGVtbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AKbCCGYa70/EeKgpsCtBtGRiHYRrodVActlrOMHEd26hjRvctLfNiyt0QyBFlzCv +0VDXR779eoSQ6mgOaDc71kb/sGWqn8LdQ74JtY5gI5qG7n3RX3EQZLEtb16jzYdN +K1Nf2oF+KMWkvyc/V9R5e267rN2iRIGBSJQ1ffcxDqTfrMVlchV2fgVT7YO47Snj +L1wC+FxqxSG757Nz8yeyPgr2Zk1oiaztxPcXWFUiNIFZoJS9iW7HM6rCm8Z7/mRc +4Bndl/pnFe25kfhOg9JIUMo1or9ml6CIszRoZ/hS8vB9Gn6WTKNBaH110zJz8ysd +6qs8ZJBaDbkJgI6L6Vm/wt8CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAizxBfaAa +DNQN9hXKFJePz7dTPLwHY/ZjC49dQrtyWzfgr1LKi4o1ixjkdg2FXU9t+xMuWgxX +FdbjOJLdcQYWEYb+W7KmkglIcP8bOkRDWfplgskpaTogRK095S1CuMM9v0bKC/ts +dHb3UfqW4U4Zko38/Ue9fRF8ra2p71QFs8+nt/BAwCkzrNLzaxMY8//TiFU+ZEYL +zPIQBjKaYB8yVh0Wh3qaieB2BzKUan+Eysh2bUc9TplQykIdk4z6T+FO5KTj5eVk +6zpHflWWCT61y15mu3xAEb83rOf+zFpoNGiDssiko0OeLK7Flqh7HuCP26NNnwsb +VGwkg60pDu+ASG2am3TPif3JpI7skzABFw4vbvPUpIk6Im3ycC98GyXowQujI0ZT +16dXfh1E38psRUeO5o+uxY6MUPXNSioYZ0mf3BARLahN41rqxKXz5ML1DSZnIOZK +F3peSggaZoRi1h0r6W14WEcYvxdHDkVR6M1qW0i7YeIBk6kaXEkwCmFz3hk5w9an +WJDjnMqSRgRsFVcIL/Ezi/Elubk21f4LHTEQmsjzzd1G+d09fjdI6JrhYMftGuYZ +4jOZZWpzoMH1TiZZ+JkBdyRwEdbqzW+v+/0BZQy6HRaZlombcOmS9MSjFRDTyUGW +D9F1eUIqKct0yyJPPXH3lDkzqqtX4DLcopo= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/vcr/credential/dezi.go b/vcr/credential/dezi.go new file mode 100644 index 0000000000..b887b1e996 --- /dev/null +++ b/vcr/credential/dezi.go @@ -0,0 +1,490 @@ +package credential + +import ( + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "slices" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/core" +) + +const DeziIDJWT07ProofType = "DeziIDJWT07" +const DeziIDJWT2024ProofType = "DeziIDJWT2024" + +func DeziIDJWTProofTypes() []string { + return []string{DeziIDJWT07ProofType, DeziIDJWT2024ProofType} +} + +type DeziIDTokenSubject struct { + Identifier string `json:"identifier"` + Name string `json:"name,omitempty"` + Employee HealthcareWorker `json:"employee"` +} + +func (d DeziIDTokenSubject) MarshalJSON() ([]byte, error) { + type Alias DeziIDTokenSubject + aux := struct { + Alias + Type string `json:"@type"` + }{ + Alias: Alias(d), + Type: "DeziIDTokenSubject", + } + return json.Marshal(aux) +} + +type HealthcareWorker struct { + Identifier string `json:"identifier"` + Initials string `json:"initials"` + SurnamePrefix string `json:"surnamePrefix"` + Surname string `json:"surname"` + Role string `json:"role,omitempty"` + RoleRegistry string `json:"role_registry,omitempty"` + RoleName string `json:"role_name,omitempty"` +} + +func (d HealthcareWorker) MarshalJSON() ([]byte, error) { + type Alias HealthcareWorker + aux := struct { + Alias + Type string `json:"@type"` + }{ + Alias: Alias(d), + Type: "HealthcareWorker", + } + return json.Marshal(aux) +} + +// CreateDeziUserCredential creates a Verifiable Credential from a Dezi id_token JWT. It supports the following spec versions: +// - april 2024 +// - 15 jan 2026/v0.7: https://www.dezi.nl/documenten/2024/04/24/koppelvlakspecificatie-dezi-voor-platform--en-softwareleveranciers +func CreateDeziUserCredential(idTokenSerialized string) (*vc.VerifiableCredential, error) { + // Parse without signature or time validation - those are validated elsewhere + idToken, err := jwt.Parse([]byte(idTokenSerialized), jwt.WithVerify(false), jwt.WithValidate(false)) + if err != nil { + return nil, fmt.Errorf("parsing id_token: %w", err) + } + + subject, version, err := extractDeziIDTokenSubject(idToken) + if err != nil { + return nil, err + } + + // Determine proof type based on version + var proofTypeName string + switch version { + case "2024": + proofTypeName = DeziIDJWT2024ProofType + case "0.7": + proofTypeName = DeziIDJWT07ProofType + default: + return nil, fmt.Errorf("unsupported Dezi id_token version: %s", version) + } + + credentialMap := map[string]any{ + "@context": []any{ + "https://www.w3.org/2018/credentials/v1", + // TODO: Create JSON-LD context? + }, + "type": []string{"VerifiableCredential", "DeziUserCredential"}, + "id": idToken.JwtID(), + "issuer": idToken.Issuer(), + "issuanceDate": idToken.NotBefore().Format(time.RFC3339Nano), + "expirationDate": idToken.Expiration().Format(time.RFC3339Nano), + "credentialSubject": subject, + "proof": deziProofType{ + Type: proofTypeName, + JWT: idTokenSerialized, + }, + } + data, _ := json.Marshal(credentialMap) + return vc.ParseVerifiableCredential(string(data)) +} + +var _ Validator = DeziUserCredentialValidator{} + +type DeziUserCredentialValidator struct { + trustStore *core.TrustStore + // AllowedJKU is a list of allowed jku URLs for fetching JWK Sets (for v0.7 tokens), used to verify Dezi attestations. + AllowedJKU []string +} + +func (d DeziUserCredentialValidator) Validate(credential vc.VerifiableCredential) error { + _, version, err := parseDeziProofType(credential) + if err != nil { + return err + } + switch version { + case "2024": + return deziIDToken2024CredentialValidator{ + clock: time.Now, + trustStore: d.trustStore, + }.Validate(credential) + case "0.7": + return deziIDToken07CredentialValidator{ + clock: time.Now, + allowedJKU: d.AllowedJKU, + }.Validate(credential) + default: + return fmt.Errorf("%w: unsupported Dezi id_token version: %s", errValidation, version) + } +} + +var _ Validator = deziIDToken2024CredentialValidator{} + +// deziIDToken2024CredentialValidator validates DeziIDTokenCredential, +// according to spec of april 2024 (uses x5c in JWT payload instead of jku header) +type deziIDToken2024CredentialValidator struct { + clock func() time.Time + trustStore *core.TrustStore +} + +func (d deziIDToken2024CredentialValidator) Validate(credential vc.VerifiableCredential) error { + proof, _, err := parseDeziProofType(credential) + if err != nil { + return fmt.Errorf("%w: %w", errValidation, err) + } + + idToken, err := d.validateIDToken(credential, proof.JWT) + if err != nil { + return fmt.Errorf("%w: invalid Dezi id_token: %w", errValidation, err) + } + + // Validate that token timestamps match credential dates + if !idToken.NotBefore().Equal(credential.IssuanceDate) { + return errors.New("id_token 'nbf' does not match credential 'issuanceDate'") + } + if !idToken.Expiration().Equal(*credential.ExpirationDate) { + return errors.New("id_token 'exp' does not match credential 'expirationDate'") + } + + // Validate that the + + return (defaultCredentialValidator{}).Validate(credential) +} + +func (d deziIDToken2024CredentialValidator) validateIDToken(credential vc.VerifiableCredential, serialized string) (jwt.Token, error) { + // Parse without verification first to extract x5c from payload + token, err := jwt.Parse([]byte(serialized), jwt.WithVerify(false), jwt.WithValidate(false)) + if err != nil { + return nil, fmt.Errorf("parse JWT: %w", err) + } + + // After signature has been validated, token can be considered a valid JWT + err = d.validateSignature(token, err, serialized) + if err != nil { + return nil, fmt.Errorf("signature: %w", err) + } + return token, nil +} + +func (d deziIDToken2024CredentialValidator) validateSignature(token jwt.Token, err error, serialized string) error { + // Extract x5c claim from payload (not header - this is non-standard but per 2024 spec) + x5cRaw, ok := token.Get("x5c") + if !ok { + return errors.New("missing 'x5c' claim in JWT payload") + } + + var x5c []any + switch v := x5cRaw.(type) { + case []any: + x5c = v + case string: + x5c = []any{v} + default: + return errors.New("'x5c' claim must be either a string or an array of strings") + } + + // Parse the certificate chain + var certChain [][]byte + for i, certData := range x5c { + certStr, ok := certData.(string) + if !ok { + return fmt.Errorf("'x5c[%d]' must be a string", i) + } + // x5c contains base64-encoded DER certificates + certBytes, err := base64.StdEncoding.DecodeString(certStr) + if err != nil { + return fmt.Errorf("decode 'x5c[%d]': %w", i, err) + } + certChain = append(certChain, certBytes) + } + + if len(certChain) == 0 { + return errors.New("'x5c' certificate chain is empty") + } + + // Parse the leaf certificate (first in chain) + leafCert, err := x509.ParseCertificate(certChain[0]) + if err != nil { + return fmt.Errorf("parse signing certificate: %w", err) + } + + _, err = leafCert.Verify(x509.VerifyOptions{ + Roots: core.NewCertPool(d.trustStore.RootCAs), + CurrentTime: d.clock(), + Intermediates: core.NewCertPool(d.trustStore.IntermediateCAs), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, // TODO: use more specific key usage if possible + }) + if err != nil { + return fmt.Errorf("verify Dezi certificate chain: %w", err) + } + + // Verify the JWT signature using the leaf certificate's public key + _, err = jwt.Parse([]byte(serialized), jwt.WithKey(jwa.RS256, leafCert.PublicKey), jwt.WithValidate(true), jwt.WithClock(jwt.ClockFunc(d.clock))) + if err != nil { + return err + } + return nil +} + +// deziIDToken07CredentialValidator validates DeziUserCredential, +// according to v0.7 spec of 15-01-2026 (https://www.dezi.nl/documenten/2025/12/15/koppelvlakspecificatie-dezi-voor-platform--en-softwareleveranciers) +type deziIDToken07CredentialValidator struct { + clock func() time.Time + httpClient *http.Client // Optional HTTP client for fetching JWK Set (for testing) + allowedJKU []string // List of allowed jku URLs +} + +func (d deziIDToken07CredentialValidator) Validate(credential vc.VerifiableCredential) error { + proof, _, err := parseDeziProofType(credential) + if err != nil { + return fmt.Errorf("%w: %w", errValidation, err) + } + 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 deziIDToken07CredentialValidator) validateDeziToken(credential vc.VerifiableCredential, serialized string) error { + // Parse and verify the JWT + // - WithVerifyAuto(nil, ...) uses default jwk.Fetch and automatically fetches the JWK Set from the jku header URL + // - WithFetchWhitelist allows fetching from any https:// URL (Dezi endpoints) + // - WithHTTPClient allows using a custom HTTP client (for testing with self-signed certs) + fetchOptions := []jwk.FetchOption{jwk.WithFetchWhitelist(jwk.WhitelistFunc(func(requestedURL string) bool { + return slices.Contains(d.allowedJKU, requestedURL) + }))} + if d.httpClient != nil { + fetchOptions = append(fetchOptions, jwk.WithHTTPClient(d.httpClient)) + } + + // TODO: Only allow specific domains for the jku + // TODO: make sure it's signed with a jku + token, err := jwt.Parse( + []byte(serialized), + jwt.WithVerifyAuto(nil, fetchOptions...), + jwt.WithClock(jwt.ClockFunc(d.clock)), + ) + if err != nil { + return fmt.Errorf("failed to verify JWT signature: %w", err) + } + + // Validate that token timestamps match credential dates + if !token.NotBefore().Equal(credential.IssuanceDate) { + return errors.New("'nbf' does not match credential 'issuanceDate'") + } + if !token.Expiration().Equal(*credential.ExpirationDate) { + return errors.New("'exp' does not match credential 'expirationDate'") + } + + var credentialSubject []DeziIDTokenSubject + if err = credential.UnmarshalCredentialSubject(&credentialSubject); err != nil { + return fmt.Errorf("invalid credential subject format: %w", err) + } + if len(credentialSubject) != 1 { + return fmt.Errorf("expected exactly one credential subject, got %d", len(credentialSubject)) + } + + subjectFromToken, _, err := extractDeziIDTokenSubject(token) + if err != nil { + return fmt.Errorf("invalid id_token claims: %w", err) + } + if !reflect.DeepEqual(credentialSubject[0], subjectFromToken) { + return errors.New("credential subject does not match id_token claims") + } + + // TODO: check id_token revocation + return nil +} + +type deziProofType struct { + Type string `json:"type"` + JWT string `json:"jwt"` +} + +func parseDeziProofType(credential vc.VerifiableCredential) (*deziProofType, string, error) { + var proofs []deziProofType + if err := credential.UnmarshalProofValue(&proofs); err != nil { + return nil, "", fmt.Errorf("invalid proof format: %w", err) + } + if len(proofs) != 1 { + return nil, "", fmt.Errorf("expected exactly one proof, got %d", len(proofs)) + } + proof := &proofs[0] + + // Derive version from proof type + var version string + switch proof.Type { + case "DeziIDJWT2024": + version = "2024" + case "DeziIDJWT07": + version = "0.7" + default: + return nil, "", fmt.Errorf("invalid proof type: expected 'DeziIDJWT2024' or 'DeziIDJWT07', got '%s'", proof.Type) + } + + return proof, version, nil +} + +// extractDeziIDTokenSubject extracts and validates the subject information from a Dezi id_token JWT. +// It returns the DeziIDTokenSubject, the detected version ("2024" or "0.7"), and any error encountered. +func extractDeziIDTokenSubject(idToken jwt.Token) (DeziIDTokenSubject, string, error) { + // Check if this is v0.7 format (has abonnee_nummer) or 2024 format (has relations) + var version string + { + _, hasRelations := idToken.Get("relations") + if hasRelations { + version = "2024" + } else { + version = "0.7" + } + } + + switch version { + case "0.7": + return extractDezi07Subject(idToken) + case "2024": + return extractDezi2024Subject(idToken) + default: + return DeziIDTokenSubject{}, "", fmt.Errorf("unsupported Dezi id_token version: %s", version) + } +} + +// extractDezi07Subject extracts the subject from a v0.7 Dezi id_token +func extractDezi07Subject(idToken jwt.Token) (DeziIDTokenSubject, string, error) { + getString := func(claim string) string { + value, ok := idToken.Get(claim) + if !ok { + return "" + } + result, _ := value.(string) + return result + } + + orgURA := getString("abonnee_nummer") + if orgURA == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'abonnee_nummer' claim") + } + orgName := getString("abonnee_naam") + if orgName == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'abonnee_naam' claim") + } + + userID := getString("dezi_nummer") + if userID == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'dezi_nummer' claim") + } + initials := getString("voorletters") + if initials == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'voorletters' claim") + } + surname := getString("achternaam") + if surname == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'achternaam' claim") + } + surnamePrefix := getString("voorvoegsel") // Can be null/empty in v0.7 + + role := getString("rol_code") + roleRegistry := getString("rol_code_bron") + roleName := getString("rol_naam") + + return DeziIDTokenSubject{ + Identifier: orgURA, + Name: orgName, + Employee: HealthcareWorker{ + Identifier: userID, + Initials: initials, + SurnamePrefix: surnamePrefix, + Surname: surname, + Role: role, + RoleRegistry: roleRegistry, + RoleName: roleName, + }, + }, "0.7", nil +} + +// extractDezi2024Subject extracts the subject from a 2024 Dezi id_token +func extractDezi2024Subject(idToken jwt.Token) (DeziIDTokenSubject, string, error) { + getString := func(claim string) string { + value, ok := idToken.Get(claim) + if !ok { + return "" + } + result, _ := value.(string) + return result + } + + relationsRaw, _ := idToken.Get("relations") + relations, ok := relationsRaw.([]any) + if !ok || len(relations) != 1 { + return DeziIDTokenSubject{}, "", 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 DeziIDTokenSubject{}, "", fmt.Errorf("id_token 'relations' claim invalid or missing (expected array of objects with single item)") + } + + orgURA, ok := relation["ura"].(string) + if !ok || orgURA == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token 'relations[0].ura' claim invalid or missing (expected non-empty string)") + } + orgName, _ := relation["entity_name"].(string) + + userID := getString("dezi_nummer") + if userID == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'dezi_nummer' claim") + } + initials := getString("initials") + if initials == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'initials' claim") + } + surname := getString("surname") + if surname == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'surname' claim") + } + surnamePrefix := getString("surname_prefix") + if surnamePrefix == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'surname_prefix' claim") + } + + // In 2024 format, roles is an array - we'll take the first role for now + // TODO: Clarify how to handle multiple roles + var role string + rolesAny, ok := relation["roles"].([]any) + if ok && len(rolesAny) > 0 { + role, _ = rolesAny[0].(string) + } + + return DeziIDTokenSubject{ + Identifier: orgURA, + Name: orgName, + Employee: HealthcareWorker{ + Identifier: userID, + Initials: initials, + SurnamePrefix: surnamePrefix, + Surname: surname, + Role: role, + }, + }, "2024", nil +} diff --git a/vcr/credential/dezi_test.go b/vcr/credential/dezi_test.go new file mode 100644 index 0000000000..d320d66f16 --- /dev/null +++ b/vcr/credential/dezi_test.go @@ -0,0 +1,409 @@ +package credential + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/core/to" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubbedRoundTripper is a test helper that returns a mock JWK Set for any HTTP request +type stubbedRoundTripper struct { + keySets map[string]jwk.Set +} + +func (s *stubbedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + keySet, ok := s.keySets[req.URL.String()] + if !ok { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewReader([]byte(`{"error": "not found"}`))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + } + + // Marshal the key set to JSON + jwksJSON, err := json.Marshal(keySet) + if err != nil { + return nil, err + } + + // Return a mock HTTP response with the JWK Set + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(jwksJSON)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil +} + +func TestCreateDeziIDToken(t *testing.T) { + t.Run("version 0.7", func(t *testing.T) { + const input = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjMyNWRlOWFiLTQzMzAtNGMwMS04MjRlLWQ5YmQwYzM3Y2NhMCIsImprdSI6Imh0dHBzOi8vW2V4dGVybiBlbmRwb2ludF0vandrcy5qc29uIiwidHlwIjoiSldUIn0.eyJqdGkiOiI2MWIxZmFmYy00ZWM3LTQ0ODktYTI4MC04ZDBhNTBhM2Q1YTkiLCJpc3MiOiJhYm9ubmVlLmRlemkubmwiLCJleHAiOjE3NDAxMzExNzYsIm5iZiI6MTczMjE4MjM3NiwianNvbl9zY2hlbWEiOiJodHRwczovL3d3dy5kZXppLm5sL2pzb25fc2NoZW1hcy92ZXJrbGFyaW5nX3YxLmpzb24iLCJsb2FfZGV6aSI6Imh0dHA6Ly9laWRhcy5ldXJvcGUuZXUvTG9BL2hpZ2giLCJ2ZXJrbGFyaW5nX2lkIjoiODUzOWY3NWQtNjM0Yy00N2RiLWJiNDEtMjg3OTFkZmQxZjhkIiwiZGV6aV9udW1tZXIiOiIxMjM0NTY3ODkiLCJ2b29ybGV0dGVycyI6IkEuQi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IlpvcmdtZWRld2Vya2VyIiwiYWJvbm5lZV9udW1tZXIiOiI4NzY1NDMyMSIsImFib25uZWVfbmFhbSI6IlpvcmdhYW5iaWVkZXIiLCJyb2xfY29kZSI6IjAxLjAwMCIsInJvbF9uYWFtIjoiQXJ0cyIsInJvbF9jb2RlX2Jyb24iOiJodHRwOi8vd3d3LmRlemkubmwvcm9sX2NvZGVfYnJvbi9iaWciLCJyZXZvY2F0aWVfY29udHJvbGVfdXJpIjoiaHR0cHM6Ly9hdXRoLmRlemkubmwvcmV2b2NhdGllLXN0YXR1cy92MS92ZXJrbGFyaW5nLzg1MzlmNzVkLTYzNGMtNDdkYi1iYjQxLTI4NzkxZGZkMWY4ZCJ9.vegszRMWJjE-SBpfPO9lxN_fEY814ezsXRYhLXorPq3j_B_wlv4A92saasdEWrTALbl9Shux0i6JvkbouqvZ_oJpOUfJxWFGFfGGCuiMhiz4k1zm665i98e2xTqFzqjQySu_gup3wYm24FmnzbHxy02RzM3pXvQCsk_jIfQ1YcUZmNmXa5hR4DEn4Z9STLHd2HwyL6IKafEGl-R_kgbAnArSHQvuLw0Fpx62QD0tr5d3PbzPirBdkuy4G1l0umb69EjZMZ5MyIl8Y_irhQ9IFomAeSlU_zZp6UojVIOnCY2gL5EMc_8B1PDC6R_C--quGoh14jiSOJAeYSf_9ETjgQ" + + actual, err := CreateDeziUserCredential(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, "123456789", employee["identifier"]) + assert.Equal(t, "A.B.", employee["initials"]) + assert.Equal(t, "Zorgmedewerker", employee["surname"]) + assert.Equal(t, "", employee["surnamePrefix"]) // voorvoegsel is null in this token + assert.Equal(t, "01.000", employee["role"]) + assert.Equal(t, "http://www.dezi.nl/rol_code_bron/big", employee["role_registry"]) + assert.Equal(t, "Arts", employee["role_name"]) + + t.Run("from online test environment", func(t *testing.T) { + // Payload: + // { + // "json_schema": "https://www.dezi.nl/json_schemas/v1/verklaring.json", + // "loa_dezi": "http://eidas.europa.eu/LoA/high", + // "jti": "f410b255-6b07-4182-ac5c-c41f02bd3995", + // "verklaring_id": "0e970fcb-530c-482e-ba28-47b461d4dcb5", + // "dezi_nummer": "900022159", + // "voorletters": "J.", + // "voorvoegsel": null, + // "achternaam": "90017362", + // "abonnee_nummer": "90000380", + // "abonnee_naam": "Tést Zorginstelling 01", + // "rol_code": "92.000", + // "rol_naam": "Mondhygiënist", + // "rol_code_bron": "http://www.dezi.nl/rol_bron/big", + // "status_uri": "https://acceptatie.auth.dezi.nl/status/v1/verklaring/0e970fcb-530c-482e-ba28-47b461d4dcb5", + // "nbf": 1772665200, + // "exp": 1780610400, + // "iss": "https://abonnee.dezi.nl" + //} + const input = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFlNDY4MjlkLWM4ZTgtNDhhMC1iZDZhLTIxYjhhMDdiOGNiMiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYWNjZXB0YXRpZS5hdXRoLmRlemkubmwvZGV6aS9qd2tzLmpzb24ifQ.eyJqc29uX3NjaGVtYSI6Imh0dHBzOi8vd3d3LmRlemkubmwvanNvbl9zY2hlbWFzL3YxL3ZlcmtsYXJpbmcuanNvbiIsImxvYV9kZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImp0aSI6ImY0MTBiMjU1LTZiMDctNDE4Mi1hYzVjLWM0MWYwMmJkMzk5NSIsInZlcmtsYXJpbmdfaWQiOiIwZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJkZXppX251bW1lciI6IjkwMDAyMjE1OSIsInZvb3JsZXR0ZXJzIjoiSi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IjkwMDE3MzYyIiwiYWJvbm5lZV9udW1tZXIiOiI5MDAwMDM4MCIsImFib25uZWVfbmFhbSI6IlTDqXN0IFpvcmdpbnN0ZWxsaW5nIDAxIiwicm9sX2NvZGUiOiI5Mi4wMDAiLCJyb2xfbmFhbSI6Ik1vbmRoeWdpw6tuaXN0Iiwicm9sX2NvZGVfYnJvbiI6Imh0dHA6Ly93d3cuZGV6aS5ubC9yb2xfYnJvbi9iaWciLCJzdGF0dXNfdXJpIjoiaHR0cHM6Ly9hY2NlcHRhdGllLmF1dGguZGV6aS5ubC9zdGF0dXMvdjEvdmVya2xhcmluZy8wZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJuYmYiOjE3NzI2NjUyMDAsImV4cCI6MTc4MDYxMDQwMCwiaXNzIjoiaHR0cHM6Ly9hYm9ubmVlLmRlemkubmwifQ.ipR4stqmO8MOmmapukeQxIOVpwO_Ipjgy5BHjUsdCvuFObhVrj48AQCndtV48D_Ol1hXO4s9p4b-1epjEiobjEmEO0JQNU0BAOGG0eWl8MujfhzlDnmwo5AEtvdgTjlnBaLReVu1BJ8KYgc1DT7JhCukq9z5wZLqU1aqtETleX2-s-dNdTdwrUjJa1DvIgO-DQ_rCp-1tcfkr2rtyW16ztyI88Q2YdBkNGcG0if5aYZHpcQ4-121WBObUa0FhswS7EHni5Ru8KwZNq0HC8OLWw3YqLrYHTFe2K0GQjMtEO6zNxApbMXWKlgeWdf7Ry2rPpe2l9Z5NuMrFiB8JChZsQ" + + actual, err := CreateDeziUserCredential(input) + require.NoError(t, err) + + require.Len(t, actual.CredentialSubject, 1) + subject := actual.CredentialSubject[0] + employee := subject["employee"].(map[string]interface{}) + assert.Equal(t, "90000380", subject["identifier"]) + assert.Equal(t, "Tést Zorginstelling 01", subject["name"]) + assert.Equal(t, "900022159", employee["identifier"]) + assert.Equal(t, "J.", employee["initials"]) + assert.Equal(t, "90017362", employee["surname"]) + assert.Equal(t, "", employee["surnamePrefix"]) // voorvoegsel is null in this token + assert.Equal(t, "92.000", employee["role"]) + assert.Equal(t, "http://www.dezi.nl/rol_bron/big", employee["role_registry"]) + assert.Equal(t, "Mondhygiënist", employee["role_name"]) + }) + }) +} + +func TestDeziIDToken07CredentialValidator(t *testing.T) { + iat := time.Unix(1732182376, 0) + exp := time.Unix(1740131176, 0) + validAt := time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC) + + signingKeyCert, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") + require.NoError(t, err) + + publicKeyJWK, err := jwk.FromRaw(signingKeyCert.Leaf.PublicKey) + require.NoError(t, err) + require.NoError(t, publicKeyJWK.Set(jwk.KeyIDKey, "1")) + correctKeySet := jwk.NewSet() + require.NoError(t, correctKeySet.AddKey(publicKeyJWK)) + // KeySet taken from https://acceptatie.auth.dezi.nl/dezi/jwks.json, copied to make the test deterministic + accKeySet := jwk.NewSet() + err = json.Unmarshal([]byte(`{ + "keys" : [ + { + "kty": "RSA", + "kid": "ae46829d-c8e8-48a0-bd6a-21b8a07b8cb2", + "x5c": [ + "MIIHkDCCBXigAwIBAgIUES0kUHe2pwozJovpJk70I3HdiPAwDQYJKoZIhvcNAQELBQAweDELMAkGA1UEBhMCTkwxETAPBgNVBAoMCEtQTiBCLlYuMRcwFQYDVQRhDA5OVFJOTC0yNzEyNDcwMTE9MDsGA1UEAww0VEVTVCBLUE4gQlYgUEtJb3ZlcmhlaWQgT3JnYW5pc2F0aWUgU2VydmljZXMgQ0EgLSBHMzAeFw0yNTA5MjQxMzIxMjZaFw0yODA5MjMxMzIxMjZaMFIxFzAVBgNVBGEMDk5UUk5MLTUwMDAwNTM1MQswCQYDVQQGEwJOTDENMAsGA1UECgwEQ0lCRzEbMBkGA1UEAwwSVEVTVCBEZXppLXJlZ2lzdGVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4ObWRH19nPSyFsuKIQ/HG3FrlAqoBiij4mAYsAl7EWduHCGj92jkkGE4z6CNPgcdVK3J2WllhyKj7kDf1aZoCvfkVrQHpS/GnEBHME+5Vo3a8Z+1AfVxxSbVLlXFu793tx83U/mB8PVxHhzf6pW449fjZrSNc0cnluXoYRFgNGxD0hlL5JahMuOoWGpKJ5XVZp6bZjbIuHc2rC589THQl1N1V11QcpoCnQsFkX92JTtgtDl+jehrqr/P2+EXRhAZl59MAk6BAZXBJWDFY/gbjYW3j4q+ITBG5iGc8tYK3JxOCdK4K3Ql3QoNEptU32ET1zrRux5D5MRiC09MKoJ4bQIDAQABo4IDNjCCAzIwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBQWWJucdaG2S0/GyJM+ywKQ5vzHbjCBpAYIKwYBBQUHAQEEgZcwgZQwYwYIKwYBBQUHMAKGV2h0dHA6Ly9jZXJ0LXRlc3QubWFuYWdlZHBraS5jb20vQ0FjZXJ0cy9URVNUS1BOQlZQS0lvdmVyaGVpZE9yZ2FuaXNhdGllU2VydmljZXNDQUczLmNlcjAtBggrBgEFBQcwAYYhaHR0cDovL2czb2NzcC10ZXN0Lm1hbmFnZWRwa2kuY29tMFUGA1UdEQROMEygSgYKKwYBBAGCNxQCA6A8DDoxMzlmYzYxOGM2YzU2MTkyOTEwMjQ5NWQ5ZTMyYTBkZkAyLjE2LjUyOC4xLjEwMDMuMS4zLjUuOS4xMIG2BgNVHSAEga4wgaswgZ0GCmCEEAGHawECBQcwgY4wNgYIKwYBBQUHAgEWKmh0dHA6Ly9jZXJ0aWZpY2FhdC5rcG4uY29tL3BraW92ZXJoZWlkL2NwczBUBggrBgEFBQcCAjBIDEZPcCBkaXQgY2VydGlmaWNhYXQgaXMgaGV0IENQUyBQS0lvdmVyaGVpZCB2YW4gS1BOIE5JRVQgdmFuIHRvZXBhc3NpbmcuMAkGBwQAi+xAAQMwHwYDVR0lBBgwFgYIKwYBBQUHAwQGCisGAQQBgjcKAwwwgY4GCCsGAQUFBwEDBIGBMH8wFQYIKwYBBQUHCwIwCQYHBACL7EkBAjAIBgYEAI5GAQEwCAYGBACORgEEMBMGBgQAjkYBBjAJBgcEAI5GAQYCMD0GBgQAjkYBBTAzMDEWK2h0dHBzOi8vY2VydGlmaWNhYXQua3BuLmNvbS9wa2lvdmVyaGVpZC9wZHMTAmVuMGkGA1UdHwRiMGAwXqBcoFqGWGh0dHA6Ly9jcmwtdGVzdC5tYW5hZ2VkcGtpLmNvbS9URVNUS1BOQlZQS0lvdmVyaGVpZE9yZ2FuaXNhdGllU2VydmljZXNDQUczL0xhdGVzdENSTC5jcmwwHQYDVR0OBBYEFJ2to1DMI8+gNKLrBcxV0ozA14GtMA4GA1UdDwEB/wQEAwIGQDANBgkqhkiG9w0BAQsFAAOCAgEAAfFUej0y+D6MSUlXT+Q2NjQDUpz3SP3xKwHj6M3ht+z5EVZD/0ayfR3d5qMIlc+ILxHzlSUy8D1xF3UkeQNjRVFlTNP+Bi/zAxwPI/KueoJkfajfPqEQBzNzsaeKXhgraFHKTQ1GWMsL8vHhTR93IwGc2bu0PZeVYO+x2InJoBSonMOjg+rBo4b1HKSvOCTe+S2W+S2BBk1qaQzhXP2xmcpiQ4BguvAnE8c5voW3gEUhzUsOYVN7M+z7y+k+fTydK1cjwD8j516RiEDKrZuv6C0Id7n1UZqjppPwzPQ6UC+Rkfsejo/ZRoz43HmbK3uxVCgGsFpeaKylW+N0TbyBkBTDD8le0AiL3YqLQfo8OS0mObfTpnR9LDSGk5KimtF5pVXYRH7UGW0pUPHSAzRX+Qou9O2jDYrnPyQ7Kum03VvfDGjPl5+4kYPbt+cAPRr9dFD/enZYHVj/VkUh+LCPe6VsEGcFr8204buh6O+CEX2LNYxWWy7u5pYlWl7VivGOeGZi4Y2kAlxxEQUVG88nsDgp2K2NFtE0G+zZgG7ejgvnz4p3Hx9xdw2ARYv2/5ycJeHNPI+CK0P2H9ZdL2uUHBGSAkFZ6D0Q/7lxJ6VvKKUQnau4rxy+no+n008l8MLz8NKCDo1x3TJSkcRxFVWSOdUVzayWp0DfVisvS1X9gxc=" + ], + "x5t": "mlPsZptNN2Bo8A8A6keBROJ6Q_U", + "x5t#S256": "UHZTsA9YMQnGRd24MZLxZabWczwuZn1PE9iV7j-oDm8", + "n": "4ObWRH19nPSyFsuKIQ_HG3FrlAqoBiij4mAYsAl7EWduHCGj92jkkGE4z6CNPgcdVK3J2WllhyKj7kDf1aZoCvfkVrQHpS_GnEBHME-5Vo3a8Z-1AfVxxSbVLlXFu793tx83U_mB8PVxHhzf6pW449fjZrSNc0cnluXoYRFgNGxD0hlL5JahMuOoWGpKJ5XVZp6bZjbIuHc2rC589THQl1N1V11QcpoCnQsFkX92JTtgtDl-jehrqr_P2-EXRhAZl59MAk6BAZXBJWDFY_gbjYW3j4q-ITBG5iGc8tYK3JxOCdK4K3Ql3QoNEptU32ET1zrRux5D5MRiC09MKoJ4bQ", + "e": "AQAB" + } +]}`), &accKeySet) + require.NoError(t, err) + + wrongKeySet := jwk.NewSet() + wrongKey, _ := jwk.FromRaw([]byte("wrong-secret-key-data")) + wrongKey.Set(jwk.KeyIDKey, "wrong-kid") + wrongKeySet.AddKey(wrongKey) + + tests := []struct { + name string + deziAttestation string + keySet jwk.Set + clock *time.Time + modifyCred func(*vc.VerifiableCredential) + allowedJKU []string + wantErr string + }{ + { + name: "ok", + keySet: correctKeySet, + }, + { + name: "from test environment", + deziAttestation: "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFlNDY4MjlkLWM4ZTgtNDhhMC1iZDZhLTIxYjhhMDdiOGNiMiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYWNjZXB0YXRpZS5hdXRoLmRlemkubmwvZGV6aS9qd2tzLmpzb24ifQ.eyJqc29uX3NjaGVtYSI6Imh0dHBzOi8vd3d3LmRlemkubmwvanNvbl9zY2hlbWFzL3YxL3ZlcmtsYXJpbmcuanNvbiIsImxvYV9kZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImp0aSI6ImY0MTBiMjU1LTZiMDctNDE4Mi1hYzVjLWM0MWYwMmJkMzk5NSIsInZlcmtsYXJpbmdfaWQiOiIwZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJkZXppX251bW1lciI6IjkwMDAyMjE1OSIsInZvb3JsZXR0ZXJzIjoiSi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IjkwMDE3MzYyIiwiYWJvbm5lZV9udW1tZXIiOiI5MDAwMDM4MCIsImFib25uZWVfbmFhbSI6IlTDqXN0IFpvcmdpbnN0ZWxsaW5nIDAxIiwicm9sX2NvZGUiOiI5Mi4wMDAiLCJyb2xfbmFhbSI6Ik1vbmRoeWdpw6tuaXN0Iiwicm9sX2NvZGVfYnJvbiI6Imh0dHA6Ly93d3cuZGV6aS5ubC9yb2xfYnJvbi9iaWciLCJzdGF0dXNfdXJpIjoiaHR0cHM6Ly9hY2NlcHRhdGllLmF1dGguZGV6aS5ubC9zdGF0dXMvdjEvdmVya2xhcmluZy8wZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJuYmYiOjE3NzI2NjUyMDAsImV4cCI6MTc4MDYxMDQwMCwiaXNzIjoiaHR0cHM6Ly9hYm9ubmVlLmRlemkubmwifQ.ipR4stqmO8MOmmapukeQxIOVpwO_Ipjgy5BHjUsdCvuFObhVrj48AQCndtV48D_Ol1hXO4s9p4b-1epjEiobjEmEO0JQNU0BAOGG0eWl8MujfhzlDnmwo5AEtvdgTjlnBaLReVu1BJ8KYgc1DT7JhCukq9z5wZLqU1aqtETleX2-s-dNdTdwrUjJa1DvIgO-DQ_rCp-1tcfkr2rtyW16ztyI88Q2YdBkNGcG0if5aYZHpcQ4-121WBObUa0FhswS7EHni5Ru8KwZNq0HC8OLWw3YqLrYHTFe2K0GQjMtEO6zNxApbMXWKlgeWdf7Ry2rPpe2l9Z5NuMrFiB8JChZsQ", + clock: to.Ptr(time.Date(2026, 3, 11, 8, 0, 0, 0, time.UTC)), + }, + { + name: "wrong exp", + keySet: correctKeySet, + modifyCred: func(c *vc.VerifiableCredential) { + wrongExp := exp.Add(time.Hour) + c.ExpirationDate = &wrongExp + }, + wantErr: "'exp' does not match credential 'expirationDate'", + }, + { + name: "wrong nbf", + keySet: correctKeySet, + modifyCred: func(c *vc.VerifiableCredential) { + c.IssuanceDate = iat.Add(-time.Hour) + }, + wantErr: "'nbf' does not match credential 'issuanceDate'", + }, + { + name: "invalid signature", + keySet: wrongKeySet, + wantErr: "failed to verify JWT signature", + }, + { + name: "JWK set endpoint unreachable", + keySet: nil, + wantErr: "failed to verify JWT signature", + }, + { + name: "token claims differ from credential subject", + keySet: correctKeySet, + modifyCred: func(c *vc.VerifiableCredential) { + c.CredentialSubject[0]["identifier"] = "different-identifier" + }, + wantErr: "credential subject does not match id_token claims", + }, + { + name: "JKU not allowed", + allowedJKU: []string{"https://example.com/other"}, + wantErr: "rejected by whitelist", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deziAttestation := tt.deziAttestation + if tt.deziAttestation == "" { + tokenBytes, err := CreateTestDezi07IDToken(iat, exp, signingKeyCert.PrivateKey) + require.NoError(t, err) + deziAttestation = string(tokenBytes) + } + + cred, err := CreateDeziUserCredential(deziAttestation) + require.NoError(t, err) + + if tt.modifyCred != nil { + tt.modifyCred(cred) + } + + validator := deziIDToken07CredentialValidator{ + clock: func() time.Time { return validAt }, + httpClient: &http.Client{Transport: &stubbedRoundTripper{keySets: map[string]jwk.Set{ + "https://acceptatie.auth.dezi.nl/dezi/jwks.json": accKeySet, + "https://example.com/jwks.json": tt.keySet, + }}}, + allowedJKU: []string{ + "https://acceptatie.auth.dezi.nl/dezi/jwks.json", + "https://example.com/jwks.json", + }, + } + if tt.clock != nil { + validator.clock = func() time.Time { return *tt.clock } + } + if tt.allowedJKU != nil { + validator.allowedJKU = tt.allowedJKU + } + + err = validator.Validate(*cred) + if tt.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestDeziIDToken2024CredentialValidator(t *testing.T) { + t.Skip("TODO: implement or remove") + const exampleToken = `eyJhbGciOiJSUzI1NiIsImtpZCI6IjFNY2p3cjgxMGpOVUZHVHR6T21MeTRTNnN5cVJ1aVZ1YVM0UmZyWmZwOEk9IiwieDV0Ijoibk4xTVdBeFRZTUgxOE45cFBWMlVIYlVZVDVOWTByT19TaHQyLWZVWF9nOCJ9.eyJhdWQiOiIwMDZmYmYzNC1hODBiLTRjODEtYjZlOS01OTM2MDA2NzVmYjIiLCJleHAiOjE3MDE5MzM2OTcsImluaXRpYWxzIjoiQi5CLiIsImlzcyI6Imh0dHBzOi8vbG9jYWxob3N0OjgwMDYiLCJqc29uX3NjaGVtYSI6Imh0dHBzOi8vbG9jYWxob3N0OjgwMDYvanNvbl9zY2hlbWEuanNvbiIsImxvYV9hdXRobiI6Imh0dHA6Ly9laWRhcy5ldXJvcGEuZXUvTG9BL2hpZ2giLCJsb2FfdXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsIm5iZiI6MTcwMTkzMzYyNywicmVsYXRpb25zIjpbeyJlbnRpdHlfbmFtZSI6IlpvcmdhYW5iaWVkZXIiLCJyb2xlcyI6WyIwMS4wNDEiLCIzMC4wMDAiLCIwMS4wMTAiLCIwMS4wMTEiXSwidXJhIjoiODc2NTQzMjEifV0sInN1cm5hbWUiOiJKYW5zZW4iLCJzdXJuYW1lX3ByZWZpeCI6InZhbiBkZXIiLCJ1emlfaWQiOiI5MDAwMDAwMDkiLCJ4NWMiOiJNSUlEMWpDQ0FiNENDUUQwRmx2SnNiY3J2VEFOQmdrcWhraUc5dzBCQVFzRkFEQW9NUXN3Q1FZRFZRUUREQUpWXG5VekVaTUJjR0ExVUVBd3dRYVc1blpTMDJMWFY2YVhCdll5MWpZVEFlRncweU16RXlNREV4TWpNek16QmFGdzB5XG5OVEEwTVRReE1qTXpNekJhTURJeEN6QUpCZ05WQkFZVEFsVlRNU013SVFZRFZRUUREQnB1YkMxMWVtbHdiMk10XG5jR2h3TFd4aGNtRjJaV3d0WkdWdGJ6Q0NBU0l3RFFZSktvWklodmNOQVFFQkJRQURnZ0VQQURDQ0FRb0NnZ0VCXG5BS2JDQ0dZYTcwL0VlS2dwc0N0QnRHUmlIWVJyb2RWQWN0bHJPTUhFZDI2aGpSdmN0TGZOaXl0MFF5QkZsekN2XG4wVkRYUjc3OWVvU1E2bWdPYURjNzFrYi9zR1dxbjhMZFE3NEp0WTVnSTVxRzduM1JYM0VRWkxFdGIxNmp6WWROXG5LMU5mMm9GK0tNV2t2eWMvVjlSNWUyNjdyTjJpUklHQlNKUTFmZmN4RHFUZnJNVmxjaFYyZmdWVDdZTzQ3U25qXG5MMXdDK0Z4cXhTRzc1N056OHlleVBncjJaazFvaWF6dHhQY1hXRlVpTklGWm9KUzlpVzdITTZyQ204WjcvbVJjXG40Qm5kbC9wbkZlMjVrZmhPZzlKSVVNbzFvcjltbDZDSXN6Um9aL2hTOHZCOUduNldUS05CYUgxMTB6Sno4eXNkXG42cXM4WkpCYURia0pnSTZMNlZtL3d0OENBd0VBQVRBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQWdFQWl6eEJmYUFhXG5ETlFOOWhYS0ZKZVB6N2RUUEx3SFkvWmpDNDlkUXJ0eVd6ZmdyMUxLaTRvMWl4amtkZzJGWFU5dCt4TXVXZ3hYXG5GZGJqT0pMZGNRWVdFWWIrVzdLbWtnbEljUDhiT2tSRFdmcGxnc2twYVRvZ1JLMDk1UzFDdU1NOXYwYktDL3RzXG5kSGIzVWZxVzRVNFprbzM4L1VlOWZSRjhyYTJwNzFRRnM4K250L0JBd0NrenJOTHpheE1ZOC8vVGlGVStaRVlMXG56UElRQmpLYVlCOHlWaDBXaDNxYWllQjJCektVYW4rRXlzaDJiVWM5VHBsUXlrSWRrNHo2VCtGTzVLVGo1ZVZrXG42enBIZmxXV0NUNjF5MTVtdTN4QUViODNyT2YrekZwb05HaURzc2lrbzBPZUxLN0ZscWg3SHVDUDI2Tk5ud3NiXG5WR3drZzYwcER1K0FTRzJhbTNUUGlmM0pwSTdza3pBQkZ3NHZidlBVcElrNkltM3ljQzk4R3lYb3dRdWpJMFpUXG4xNmRYZmgxRTM4cHNSVWVPNW8rdXhZNk1VUFhOU2lvWVowbWYzQkFSTGFoTjQxcnF4S1h6NU1MMURTWm5JT1pLXG5GM3BlU2dnYVpvUmkxaDByNlcxNFdFY1l2eGRIRGtWUjZNMXFXMGk3WWVJQms2a2FYRWt3Q21GejNoazV3OWFuXG5XSkRqbk1xU1JnUnNGVmNJTC9FemkvRWx1YmsyMWY0TEhURVFtc2p6emQxRytkMDlmamRJNkpyaFlNZnRHdVlaXG40ak9aWldwem9NSDFUaVpaK0prQmR5UndFZGJxelcrdisvMEJaUXk2SFJhWmxvbWJjT21TOU1TakZSRFR5VUdXXG5EOUYxZVVJcUtjdDB5eUpQUFhIM2xEa3pxcXRYNERMY29wbz0ifQ.VvzIXZ8FCIwxvP3Wc4kLvIgQChJZAhS-DcKKvkiZg677w-ZRciIFCWUH5oXLqG-emyV4f87tIoWnp4TY3gGFNljNrtlTVCv3zXaTCxHwzL6q2QCs1liBus2uPv0kjBtzeve2G5_Owst3ndeUcwLJPnTIoYRLvbjjaPkFTg49K5ZTpN8E9dl7Gimwgv_rZ1fOH7XrAwlTY-jF34wsR_K17wHI5Zp237_HcAPqnMI8P3U7u74Vu-3mqCePubVBDnT4bGcd4flZCFH-LTDhew9BO4cBkBxafAev7OB5A9qGOKEtRynTDAOkazyb8_qwJAGnyCAVxBQ4VFRB1-cE576TLQ` + const exampleCertificateDERBase64 = `MIID1jCCAb4CCQD0FlvJsbcrvTANBgkqhkiG9w0BAQsFADAoMQswCQYDVQQDDAJV +UzEZMBcGA1UEAwwQaW5nZS02LXV6aXBvYy1jYTAeFw0yMzEyMDExMjMzMzBaFw0y +NTA0MTQxMjMzMzBaMDIxCzAJBgNVBAYTAlVTMSMwIQYDVQQDDBpubC11emlwb2Mt +cGhwLWxhcmF2ZWwtZGVtbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AKbCCGYa70/EeKgpsCtBtGRiHYRrodVActlrOMHEd26hjRvctLfNiyt0QyBFlzCv +0VDXR779eoSQ6mgOaDc71kb/sGWqn8LdQ74JtY5gI5qG7n3RX3EQZLEtb16jzYdN +K1Nf2oF+KMWkvyc/V9R5e267rN2iRIGBSJQ1ffcxDqTfrMVlchV2fgVT7YO47Snj +L1wC+FxqxSG757Nz8yeyPgr2Zk1oiaztxPcXWFUiNIFZoJS9iW7HM6rCm8Z7/mRc +4Bndl/pnFe25kfhOg9JIUMo1or9ml6CIszRoZ/hS8vB9Gn6WTKNBaH110zJz8ysd +6qs8ZJBaDbkJgI6L6Vm/wt8CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAizxBfaAa +DNQN9hXKFJePz7dTPLwHY/ZjC49dQrtyWzfgr1LKi4o1ixjkdg2FXU9t+xMuWgxX +FdbjOJLdcQYWEYb+W7KmkglIcP8bOkRDWfplgskpaTogRK095S1CuMM9v0bKC/ts +dHb3UfqW4U4Zko38/Ue9fRF8ra2p71QFs8+nt/BAwCkzrNLzaxMY8//TiFU+ZEYL +zPIQBjKaYB8yVh0Wh3qaieB2BzKUan+Eysh2bUc9TplQykIdk4z6T+FO5KTj5eVk +6zpHflWWCT61y15mu3xAEb83rOf+zFpoNGiDssiko0OeLK7Flqh7HuCP26NNnwsb +VGwkg60pDu+ASG2am3TPif3JpI7skzABFw4vbvPUpIk6Im3ycC98GyXowQujI0ZT +16dXfh1E38psRUeO5o+uxY6MUPXNSioYZ0mf3BARLahN41rqxKXz5ML1DSZnIOZK +F3peSggaZoRi1h0r6W14WEcYvxdHDkVR6M1qW0i7YeIBk6kaXEkwCmFz3hk5w9an +WJDjnMqSRgRsFVcIL/Ezi/Elubk21f4LHTEQmsjzzd1G+d09fjdI6JrhYMftGuYZ +4jOZZWpzoMH1TiZZ+JkBdyRwEdbqzW+v+/0BZQy6HRaZlombcOmS9MSjFRDTyUGW +D9F1eUIqKct0yyJPPXH3lDkzqqtX4DLcopo=` + certificateDER, err := base64.StdEncoding.DecodeString(exampleCertificateDERBase64) + require.NoError(t, err) + exampleCertificate, err := x509.ParseCertificate(certificateDER) + require.NoError(t, err) + // Setup trust store with the Dezi certificate as root CA + exampleTrustStore := core.BuildTrustStore([]*x509.Certificate{}) + exampleTrustStore.RootCAs = append(exampleTrustStore.RootCAs, exampleCertificate) + + // Load a signing key pair for creating test tokens + // Note: In real scenarios, the signing key would match the cert in x5c + signingKeyCert, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") + require.NoError(t, err) + signingKeyCert.Leaf, err = x509.ParseCertificate(signingKeyCert.Certificate[0]) + require.NoError(t, err) + trustStore := core.BuildTrustStore([]*x509.Certificate{}) + trustStore.RootCAs = append(trustStore.RootCAs, signingKeyCert.Leaf) + + // Use a validation time within the Dezi certificate validity period + validAt := time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC) + + // createToken returns a factory function that creates a JWT with the given x5c value + // If x5cValue is a *x509.Certificate, it encodes that certificate in x5c + // If x5cValue is any other type (string, []string, nil), it's used directly as the x5c claim + createToken := func(x5cValue any, nbf *time.Time, exp *time.Time) func(t *testing.T) []byte { + if nbf == nil { + nbf = new(time.Time) + *nbf = time.Unix(1732182376, 0) // Nov 21, 2024 + } + if exp == nil { + exp = new(time.Time) + *exp = time.Unix(1740131176, 0) // Feb 21, 2025 + } + return func(t *testing.T) []byte { + token := jwt.New() + claims := map[string]any{ + jwt.AudienceKey: "006fbf34-a80b-4c81-b6e9-593600675fb2", + jwt.ExpirationKey: exp.Unix(), + jwt.NotBeforeKey: nbf.Unix(), + jwt.IssuerKey: "https://max.proeftuin.Dezi-online.rdobeheer.nl", + jwt.JwtIDKey: "test-jwt-id", + "dezi_nummer": "900000009", + "initials": "B.B.", + "surname": "Jansen", + "surname_prefix": "van der", + "relations": []map[string]interface{}{ + {"entity_name": "Zorgaanbieder", "roles": []string{"01.041"}, "ura": "87654321"}, + }, + } + + // Handle x5c based on type + if cert, ok := x5cValue.(*x509.Certificate); ok { + // Encode the provided certificate in x5c + x5cArray := []string{base64.StdEncoding.EncodeToString(cert.Raw)} + claims["x5c"] = x5cArray + } else if x5cValue != nil { + // Use x5cValue directly (for testing invalid formats) + claims["x5c"] = x5cValue + } + // If x5cValue is nil, don't add x5c claim (for testing missing x5c) + + for k, v := range claims { + require.NoError(t, token.Set(k, v)) + } + signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, signingKeyCert.PrivateKey)) + require.NoError(t, err) + return signed + } + } + + tests := []struct { + name string + createToken func(t *testing.T) []byte + modifyCred func(*vc.VerifiableCredential) + trustStore *core.TrustStore + wantErr string + }{ + { + name: "ok", + createToken: func(t *testing.T) []byte { + return []byte(exampleToken) + }, + trustStore: exampleTrustStore, + }, + { + name: "missing x5c", + createToken: createToken(nil, nil, nil), + wantErr: "missing 'x5c' claim", + }, + { + name: "invalid certificate", + createToken: createToken([]string{"invalid-base64!!!"}, nil, nil), + wantErr: "decode 'x5c", + }, + { + name: "credential's nbf does not match token's nbf", + createToken: createToken([]string{base64.StdEncoding.EncodeToString(signingKeyCert.Leaf.Raw)}, nil, nil), + modifyCred: func(c *vc.VerifiableCredential) { + c.IssuanceDate = time.Date(2024, 11, 1, 0, 0, 0, 0, time.UTC) + }, + wantErr: "'nbf' does not match credential 'issuanceDate'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tokenBytes := tt.createToken(t) + cred, err := CreateDeziUserCredential(string(tokenBytes)) + require.NoError(t, err) + + if tt.modifyCred != nil { + tt.modifyCred(cred) + } + validator := deziIDToken2024CredentialValidator{ + clock: func() time.Time { return validAt }, + trustStore: trustStore, + } + if tt.trustStore != nil { + validator.trustStore = tt.trustStore + } + + err = validator.Validate(*cred) + if tt.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/vcr/credential/koppelvlakspecificatie_07-dezi-voor-platform-en-softwareleveranciers-v0-7.pdf b/vcr/credential/koppelvlakspecificatie_07-dezi-voor-platform-en-softwareleveranciers-v0-7.pdf new file mode 100644 index 0000000000..6cc845d2f7 Binary files /dev/null and b/vcr/credential/koppelvlakspecificatie_07-dezi-voor-platform-en-softwareleveranciers-v0-7.pdf differ diff --git a/vcr/credential/resolver.go b/vcr/credential/resolver.go index 182eda33e7..45b9a8b7d2 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" @@ -29,6 +30,8 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/signature/proof" ) +var DefaultDeziUserCredentialValidator = DeziUserCredentialValidator{} + // FindValidator finds the Validator the provided credential based on its Type // When no additional type is provided, it returns the default validator func FindValidator(credential vc.VerifiableCredential, pkiValidator pki.Validator) Validator { @@ -41,6 +44,11 @@ func FindValidator(credential vc.VerifiableCredential, pkiValidator pki.Validato return nutsAuthorizationCredentialValidator{} case X509CredentialType: return x509CredentialValidator{pkiValidator: pkiValidator} + case DeziUserCredentialTypeURI.String(): + // TODO: This is an ugly pattern, and FindValidator() should probably be moved to the Verifier, but that's a big refactor. + // As long as it's non-production/PoC code, this is fine. + // Make nice when merging to master. + return DefaultDeziUserCredentialValidator } } } diff --git a/vcr/credential/test.go b/vcr/credential/test.go new file mode 100644 index 0000000000..bfd11a341d --- /dev/null +++ b/vcr/credential/test.go @@ -0,0 +1,91 @@ +package credential + +import ( + "crypto" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +func CreateTestDezi07IDToken(issuedAt time.Time, validUntil time.Time, key crypto.PrivateKey) ([]byte, error) { + claims := map[string]any{ + jwt.JwtIDKey: "test-jwt-id-07", + jwt.ExpirationKey: validUntil.Unix(), + jwt.NotBeforeKey: issuedAt.Unix(), + jwt.IssuerKey: "abonnee.dezi.nl", + "json_schema": "https://www.dezi.nl/json_schemas/verklaring_v1.json", + "loa_dezi": "http://eidas.europa.eu/LoA/high", + "verklaring_id": "test-verklaring-id", + // v0.7 format claims + "dezi_nummer": "123456789", + "voorletters": "A.B.", + "voorvoegsel": "van der", + "achternaam": "Zorgmedewerker", + "abonnee_nummer": "87654321", + "abonnee_naam": "Zorgaanbieder", + "rol_code": "01.000", + "rol_naam": "Arts", + "rol_code_bron": "http://www.dezi.nl/rol_code_bron/big", + } + token := jwt.New() + for name, value := range claims { + if err := token.Set(name, value); err != nil { + return nil, err + } + } + + headers := jws.NewHeaders() + for k, v := range map[string]any{ + "alg": "RS256", + "kid": "1", + "jku": "https://example.com/jwks.json", + } { + if err := headers.Set(k, v); err != nil { + return nil, err + } + } + return jwt.Sign(token, jwt.WithKey(jwa.RS256, key, jws.WithProtectedHeaders(headers))) +} + +func CreateTestDezi2024IDToken(issuedAt time.Time, validUntil time.Time, key crypto.PrivateKey) ([]byte, error) { + claims := map[string]any{ + 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", + "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 + } + } + + headers := jws.NewHeaders() + for k, v := range map[string]any{ + "alg": "RS256", + "kid": "1", + "jku": "https://example.com/jwks.json", + } { + if err := headers.Set(k, v); err != nil { + return nil, err + } + } + return jwt.Sign(token, jwt.WithKey(jwa.RS256, key, jws.WithProtectedHeaders(headers))) +} diff --git a/vcr/credential/types.go b/vcr/credential/types.go index 87da9fefeb..b7bb5b9283 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) + // DeziUserCredentialTypeURI is the VC type for a DeziUserCredential + DeziUserCredentialTypeURI = ssi.MustParseURI("DeziUserCredential") // NutsV1ContextURI is the nuts V1 json-ld context as URI NutsV1ContextURI = ssi.MustParseURI(NutsV1Context) ) diff --git a/vcr/credential/util.go b/vcr/credential/util.go index 41643548f7..0895718898 100644 --- a/vcr/credential/util.go +++ b/vcr/credential/util.go @@ -20,18 +20,20 @@ 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" ) // ResolveSubjectDID resolves the subject DID from the given credentials. +// It skips credentials that don't have a credentialSubject.id (e.g., DeziUserCredential). // It returns an error if: // - the credentials do not have the same subject DID. -// - the credentials do not have a subject DID. +// - none of the credentials have a subject DID. func ResolveSubjectDID(credentials ...vc.VerifiableCredential) (*did.DID, error) { var subjectID did.DID for _, credential := range credentials { @@ -114,10 +116,20 @@ 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 DeziUserCredentials (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 + proofs, _ := credential.Proofs() + requiresCorrection := false + for _, p := range proofs { + if slices.Contains(DeziIDJWTProofTypes(), string(p.Type)) { + requiresCorrection = true + break + } + } + if !requiresCorrection { + 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 ac2b481dae..56be3476b3 100644 --- a/vcr/credential/validator.go +++ b/vcr/credential/validator.go @@ -25,6 +25,9 @@ import ( "encoding/json" "errors" "fmt" + "net/url" + "strings" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -33,8 +36,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. diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index 9c220c3bee..8e1f4669dd 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" diff --git a/vcr/vcr.go b/vcr/vcr.go index 6cf9876b89..1b161d95bd 100644 --- a/vcr/vcr.go +++ b/vcr/vcr.go @@ -24,6 +24,12 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" + "net/http" + "path" + "strings" + "time" + "github.com/nuts-foundation/go-leia/v4" "github.com/nuts-foundation/nuts-node/http/client" "github.com/nuts-foundation/nuts-node/pki" @@ -32,11 +38,6 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/revocation" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "io/fs" - "net/http" - "path" - "strings" - "time" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" @@ -165,6 +166,23 @@ func (c *vcr) Configure(config core.ServerConfig) error { // copy strictmode for openid4vci usage c.strictmode = config.Strictmode + // Configure Dezi JKU allowlist + { + var allowedJKU []string + if len(c.config.Dezi.AllowedJKU) > 0 { + // Use configured values + allowedJKU = c.config.Dezi.AllowedJKU + } else { + // Default behavior: production URL always allowed + allowedJKU = []string{"https://auth.dezi.nl/dezi/jwks.json"} + if !c.strictmode { + // In non-strict mode, also allow acceptance environment + allowedJKU = append(allowedJKU, "https://acceptatie.auth.dezi.nl/dezi/jwks.json") + } + } + credential.DefaultDeziUserCredentialValidator = credential.DeziUserCredentialValidator{AllowedJKU: allowedJKU} + } + // create issuer store (to revoke) issuerStorePath := path.Join(c.datadir, "vcr", "issued-credentials.db") issuerBackupStore, err := c.storageClient.GetProvider(ModuleName).GetKVStore("backup-issued-credentials", storage.PersistentStorageClass) diff --git a/vcr/vcr_test.go b/vcr/vcr_test.go index d4833943a9..bbc42b7467 100644 --- a/vcr/vcr_test.go +++ b/vcr/vcr_test.go @@ -43,6 +43,7 @@ import ( "github.com/nuts-foundation/nuts-node/events" "github.com/nuts-foundation/nuts-node/jsonld" + "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/verifier" "go.etcd.io/bbolt" @@ -108,6 +109,73 @@ func TestVCR_Configure(t *testing.T) { assert.ErrorContains(t, err, "http request error: strictmode is enabled, but request is not over HTTPS") }) + t.Run("Dezi AllowedJKU configuration", func(t *testing.T) { + t.Run("uses configured AllowedJKU", func(t *testing.T) { + testDirectory := io.TestDirectory(t) + ctrl := gomock.NewController(t) + vdrInstance := vdr.NewMockVDR(ctrl) + vdrInstance.EXPECT().Resolver().AnyTimes() + pkiProvider := pki.NewMockProvider(ctrl) + pkiProvider.EXPECT().CreateTLSConfig(gomock.Any()).Return(nil, nil).AnyTimes() + networkInstance := network.NewMockTransactions(ctrl) + networkInstance.EXPECT().Disabled().AnyTimes() + instance := NewVCRInstance(nil, vdrInstance, networkInstance, jsonld.NewTestJSONLDManager(t), nil, storage.NewTestStorageEngine(t), pkiProvider).(*vcr) + + // Configure custom AllowedJKU + instance.config.Dezi.AllowedJKU = []string{"https://custom.dezi.nl/jwks.json"} + + err := instance.Configure(core.TestServerConfig(func(config *core.ServerConfig) { + config.Datadir = testDirectory + })) + + require.NoError(t, err) + // Verify that the configured value is used + assert.Equal(t, []string{"https://custom.dezi.nl/jwks.json"}, credential.DefaultDeziUserCredentialValidator.AllowedJKU) + }) + t.Run("default: production only in strict mode", func(t *testing.T) { + testDirectory := io.TestDirectory(t) + ctrl := gomock.NewController(t) + vdrInstance := vdr.NewMockVDR(ctrl) + vdrInstance.EXPECT().Resolver().AnyTimes() + pkiProvider := pki.NewMockProvider(ctrl) + pkiProvider.EXPECT().CreateTLSConfig(gomock.Any()).Return(nil, nil).AnyTimes() + networkInstance := network.NewMockTransactions(ctrl) + networkInstance.EXPECT().Disabled().AnyTimes() + instance := NewVCRInstance(nil, vdrInstance, networkInstance, jsonld.NewTestJSONLDManager(t), nil, storage.NewTestStorageEngine(t), pkiProvider).(*vcr) + + err := instance.Configure(core.TestServerConfig(func(config *core.ServerConfig) { + config.Datadir = testDirectory + config.Strictmode = true + })) + + require.NoError(t, err) + // Verify that only production is allowed + assert.Equal(t, []string{"https://auth.dezi.nl/dezi/jwks.json"}, credential.DefaultDeziUserCredentialValidator.AllowedJKU) + }) + t.Run("default: production and acceptance in non-strict mode", func(t *testing.T) { + testDirectory := io.TestDirectory(t) + ctrl := gomock.NewController(t) + vdrInstance := vdr.NewMockVDR(ctrl) + vdrInstance.EXPECT().Resolver().AnyTimes() + pkiProvider := pki.NewMockProvider(ctrl) + pkiProvider.EXPECT().CreateTLSConfig(gomock.Any()).Return(nil, nil).AnyTimes() + networkInstance := network.NewMockTransactions(ctrl) + networkInstance.EXPECT().Disabled().AnyTimes() + instance := NewVCRInstance(nil, vdrInstance, networkInstance, jsonld.NewTestJSONLDManager(t), nil, storage.NewTestStorageEngine(t), pkiProvider).(*vcr) + + err := instance.Configure(core.TestServerConfig(func(config *core.ServerConfig) { + config.Datadir = testDirectory + config.Strictmode = false + })) + + require.NoError(t, err) + // Verify that both production and acceptance are allowed + assert.Equal(t, []string{ + "https://auth.dezi.nl/dezi/jwks.json", + "https://acceptatie.auth.dezi.nl/dezi/jwks.json", + }, credential.DefaultDeziUserCredentialValidator.AllowedJKU) + }) + }) } func TestVCR_Start(t *testing.T) { diff --git a/vcr/verifier/verifier.go b/vcr/verifier/verifier.go index 59f833327e..3ff19e471f 100644 --- a/vcr/verifier/verifier.go +++ b/vcr/verifier/verifier.go @@ -152,6 +152,10 @@ func (v verifier) Verify(credentialToVerify vc.VerifiableCredential, allowUntrus } // Check signature + // DeziUserCredential: signature is verified by Dezi attestation ("verklaring") inside the credential. Signature verification is skipped here. + if credentialToVerify.IsType(credential.DeziUserCredentialTypeURI) { + 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 3466d519e4..006695a74d 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -307,7 +307,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)