From dac803679ab7c89ee28b6000b6d35c7d5b3bb054 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Mon, 2 Feb 2026 08:06:58 +0100 Subject: [PATCH 1/6] #3980: Support validation of DeziIDTokenCredential --- vcr/credential/resolver.go | 3 + vcr/credential/types.go | 2 + vcr/credential/validator.go | 59 ++++++++++++++++- vcr/verifier/verifier.go | 10 ++- vcr/verifier/verifier_test.go | 115 +++++++++++++++++++++++++++++++++- 5 files changed, 181 insertions(+), 8 deletions(-) diff --git a/vcr/credential/resolver.go b/vcr/credential/resolver.go index 182eda33e7..db30514a5c 100644 --- a/vcr/credential/resolver.go +++ b/vcr/credential/resolver.go @@ -22,6 +22,7 @@ package credential import ( "errors" "fmt" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/crypto" @@ -41,6 +42,8 @@ func FindValidator(credential vc.VerifiableCredential, pkiValidator pki.Validato return nutsAuthorizationCredentialValidator{} case X509CredentialType: return x509CredentialValidator{pkiValidator: pkiValidator} + case DeziIDTokenCredentialTypeURI.String(): + return deziIDTokenCredentialValidator{} } } } diff --git a/vcr/credential/types.go b/vcr/credential/types.go index 87da9fefeb..beb77cbeab 100644 --- a/vcr/credential/types.go +++ b/vcr/credential/types.go @@ -39,6 +39,8 @@ var ( NutsOrganizationCredentialTypeURI, _ = ssi.ParseURI(NutsOrganizationCredentialType) // NutsAuthorizationCredentialTypeURI is the VC type for a NutsAuthorizationCredentialType as URI NutsAuthorizationCredentialTypeURI, _ = ssi.ParseURI(NutsAuthorizationCredentialType) + // DeziIDTokenCredentialTypeURI is the VC type for a DeziIDTokenCredential + DeziIDTokenCredentialTypeURI = ssi.MustParseURI("DeziIDTokenCredential") // NutsV1ContextURI is the nuts V1 json-ld context as URI NutsV1ContextURI = ssi.MustParseURI(NutsV1Context) ) diff --git a/vcr/credential/validator.go b/vcr/credential/validator.go index ac2b481dae..0d62e8621d 100644 --- a/vcr/credential/validator.go +++ b/vcr/credential/validator.go @@ -25,7 +25,13 @@ import ( "encoding/json" "errors" "fmt" + "net/url" + "strings" + + "github.com/lestrrat-go/jwx/v2/cert" "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/crypto" @@ -33,8 +39,6 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/revocation" "github.com/nuts-foundation/nuts-node/vdr/didx509" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "net/url" - "strings" ) // Validator is the interface specific VC verification. @@ -383,3 +387,54 @@ func validatePolicyAssertions(issuer did.DID, credential vc.VerifiableCredential return nil } + +// DeziIDTokenCredentialValidator validates DeziIDTokenCredential, according to (TODO: add spec). +type deziIDTokenCredentialValidator struct { +} + +func (d deziIDTokenCredentialValidator) Validate(credential vc.VerifiableCredential) error { + type proofType struct { + Type string `json:"type"` + JWT string `json:"jwt"` + } + proofs := []proofType{} + if err := credential.UnmarshalProofValue(&proofs); err != nil { + return fmt.Errorf("%w: invalid proof format: %w", errValidation, err) + } + if len(proofs) != 1 { + return fmt.Errorf("%w: expected exactly one proof, got %d", errValidation, len(proofs)) + } + proof := proofs[0] + if proof.Type != "DeziIDJWT" { + return fmt.Errorf("%w: invalid proof type: expected 'DeziIDToken', got '%s'", errValidation, proof.Type) + } + if err := d.validateDeziToken(credential, proof.JWT); err != nil { + return fmt.Errorf("%w: invalid Dezi id_token: %w", errValidation, err) + } + return (defaultCredentialValidator{}).Validate(credential) +} + +func (d deziIDTokenCredentialValidator) validateDeziToken(credential vc.VerifiableCredential, serialized string) error { + headers, err := crypto.ExtractProtectedHeaders(serialized) + if err != nil { + return fmt.Errorf("invalid JWT headers: %w", err) + } + chain := cert.Chain{} + for i, s := range headers["x5c"].([]string) { + + } + + token, err := jwt.ParseString(serialized, jws.WithKeyProvider(jws.)) + if err != nil { + return err + } + // TODO: Verify deziToken signature + if !token.NotBefore().Equal(credential.IssuanceDate) { + return errors.New("id_token 'nbf' does not match credential 'issuanceDate'") + } + if !token.Expiration().Equal(*credential.ExpirationDate) { + return errors.New("id_token 'exp' does not match credential 'expirationDate'") + } + // TODO: implement rest of checks + return nil +} diff --git a/vcr/verifier/verifier.go b/vcr/verifier/verifier.go index 8cf4ba7328..eeb696be0a 100644 --- a/vcr/verifier/verifier.go +++ b/vcr/verifier/verifier.go @@ -22,11 +22,12 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/pki" - "github.com/nuts-foundation/nuts-node/vcr/revocation" "strings" "time" + "github.com/nuts-foundation/nuts-node/pki" + "github.com/nuts-foundation/nuts-node/vcr/revocation" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -122,7 +123,6 @@ func (v verifier) Verify(credentialToVerify vc.VerifiableCredential, allowUntrus if revoked { return types.ErrRevoked } - } // Check the credentialStatus if the credential is revoked @@ -162,6 +162,10 @@ func (v verifier) Verify(credentialToVerify vc.VerifiableCredential, allowUntrus } // Check signature + // DeziIDTokenCredential: signature is verified by Dezi id_token inside the credential. Signature verification is skipped here. + if credentialToVerify.IsType(credential.DeziIDTokenCredentialTypeURI) { + checkSignature = false + } if checkSignature { issuerDID, _ := did.ParseDID(credentialToVerify.Issuer.String()) metadata := resolver.ResolveMetadata{ResolveTime: validAt, AllowDeactivated: false} diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index 8c60c4b75c..3bb48ca544 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -21,10 +21,11 @@ package verifier import ( "context" "crypto" + "crypto/sha1" + "crypto/tls" + "crypto/x509" "encoding/json" "errors" - "github.com/nuts-foundation/nuts-node/storage/orm" - "github.com/nuts-foundation/nuts-node/test/pki" "net/http" "net/http/httptest" "os" @@ -33,6 +34,11 @@ import ( "testing" "time" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/nuts-foundation/nuts-node/storage/orm" + "github.com/nuts-foundation/nuts-node/test/pki" + "github.com/segmentio/asm/base64" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" ssi "github.com/nuts-foundation/go-did" @@ -305,7 +311,7 @@ func TestVerifier_Verify(t *testing.T) { assert.EqualError(t, err, "verifiable credential must list at most 2 types") }) - t.Run("verify x509", func(t *testing.T) { + t.Run("X509Credential", func(t *testing.T) { ura := "312312312" certs, keys, err := pki.BuildCertChain(nil, ura, nil) chain := pki.CertsToChain(certs) @@ -380,6 +386,15 @@ func TestVerifier_Verify(t *testing.T) { assert.ErrorIs(t, err, expectedError) }) }) + t.Run("DeziIDTokenCredential", func(t *testing.T) { + ctx := newMockContext(t) + validAt := time.Now() + + cred, _ := createDeziCredential(t, "did:web:example.com") + + err := ctx.verifier.Verify(*cred, false, true, &validAt) + assert.NoError(t, err) + }) } func Test_verifier_CheckAndStoreRevocation(t *testing.T) { @@ -858,3 +873,97 @@ func newMockContext(t *testing.T) mockContext { trustConfig: trustConfig, } } + +// createDeziIDToken creates a signed Dezi id_token according to https://www.dezi.nl/documenten/2024/05/08/koppelvlakspecificatie-dezi-online-koppelvlak-1_-platformleverancier +func createDeziCredential(t *testing.T, holderDID string) (*vc.VerifiableCredential, *x509.Certificate) { + keyPair, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") + require.NoError(t, err) + + key, err := jwk.FromRaw(keyPair.PrivateKey) + require.NoError(t, err) + + // Set the key ID and x5t (X.509 thumbprint) + x5t := sha1.Sum(keyPair.Leaf.Raw) + err = key.Set(jwk.KeyIDKey, base64.StdEncoding.EncodeToString(x5t[:])) + require.NoError(t, err) + err = key.Set(jwk.X509CertThumbprintKey, base64.StdEncoding.EncodeToString(x5t[:])) + require.NoError(t, err) + err = key.Set(jwk.AlgorithmKey, "RS256") + require.NoError(t, err) + + // Build the JWT token + token := jwt.New() + + // Set claims from the DeziIDTokenCredential payload + err = token.Set(jwt.AudienceKey, "006fbf34-a80b-4c81-b6e9-593600675fb2") + require.NoError(t, err) + err = token.Set(jwt.ExpirationKey, time.Unix(1701933697, 0)) + require.NoError(t, err) + err = token.Set(jwt.NotBeforeKey, time.Unix(1701933627, 0)) + require.NoError(t, err) + err = token.Set(jwt.IssuerKey, "https://max.proeftuin.Dezi-online.rdobeheer.nl") + require.NoError(t, err) + + // Set custom claims + err = token.Set("initials", "B.B.") + require.NoError(t, err) + err = token.Set("json_schema", "https://max.proeftuin.Dezi-online.rdobeheer.nl/json_schema.json") + require.NoError(t, err) + err = token.Set("loa_authn", "http://eidas.europa.eu/LoA/high") + require.NoError(t, err) + err = token.Set("loa_Dezi", "http://eidas.europa.eu/LoA/high") + require.NoError(t, err) + err = token.Set("relations", []map[string]interface{}{ + { + "entity_name": "Zorgaanbieder", + "roles": []string{"01.041", "30.000", "01.010", "01.011"}, + "ura": "87654321", + }, + }) + require.NoError(t, err) + err = token.Set("surname", "Jansen") + require.NoError(t, err) + err = token.Set("surname_prefix", "van der") + require.NoError(t, err) + err = token.Set("Dezi_id", "900000009") + require.NoError(t, err) + err = token.Set("x5c", []string{base64.StdEncoding.EncodeToString(keyPair.Leaf.Raw)}) + require.NoError(t, err) + + // Sign the token using jwt.Sign + signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, key)) + require.NoError(t, err) + + credentialMap := map[string]any{ + "@context": []any{ + "https://www.w3.org/2018/credentials/v1", + }, + "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, + "issuer": "https://max.proeftuin.Dezi-online.rdobeheer.nl", + "issuanceDate": token.NotBefore().Format(time.RFC3339Nano), + "expirationDate": token.Expiration().Format(time.RFC3339Nano), + "credentialSubject": map[string]any{ + "@type": "DeziIDTokenSubject", + "id": holderDID, + "identifier": "87654321", + "name": "Zorgaanbieder", + "employee": map[string]any{ + "@type": "HealthcareWorker", + "identifier": "900000009", + "initials": "B.B.", + "surnamePrefix": "van der", + "surname": "Jansen", + "roles": []string{"01.041", "30.000", "01.010", "01.011"}, + }, + }, + "proof": map[string]any{ + "type": "DeziIDJWT", + "jwt": string(signed), + }, + } + data, err := json.Marshal(credentialMap) + require.NoError(t, err) + cred, err := vc.ParseVerifiableCredential(string(data)) + require.NoError(t, err) + return cred, keyPair.Leaf +} From 2ebea32334ff18470ab07a750ba754d0db85fa11 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Mon, 2 Feb 2026 16:28:03 +0100 Subject: [PATCH 2/6] implemented e2e test --- auth/api/iam/api.go | 15 +- auth/api/iam/generated.go | 4 + auth/oauth/openid.go | 2 +- docs/_static/auth/v2.yaml | 6 + e2e-tests/browser/client/iam/generated.go | 4 + .../oauth-flow/dezi_idtoken/accesspolicy.json | 150 ++++++++++++ .../oauth-flow/dezi_idtoken/certs/README.md | 5 + .../dezi_idtoken/certs/dezi_signing.key | 28 +++ .../dezi_idtoken/certs/dezi_signing.pem | 19 ++ .../dezi_idtoken/certs/nodeA-chain.pem | 40 ++++ .../oauth-flow/dezi_idtoken/certs/nodeA.key | 28 +++ .../oauth-flow/dezi_idtoken/certs/nodeA.pem | 22 ++ .../dezi_idtoken/docker-compose.yml | 29 +++ .../oauth-flow/dezi_idtoken/generate-jwt.sh | 95 ++++++++ e2e-tests/oauth-flow/dezi_idtoken/run-test.sh | 120 ++++++++++ vcr/credential/dezi.go | 87 +++++++ vcr/credential/dezi_test.go | 28 +++ vcr/credential/util.go | 22 +- vcr/credential/validator.go | 43 ++-- vcr/pe/presentation_definition_test.go | 219 +++++++++++++++++- vcr/verifier/verifier_test.go | 11 +- 21 files changed, 941 insertions(+), 36 deletions(-) create mode 100644 e2e-tests/oauth-flow/dezi_idtoken/accesspolicy.json create mode 100644 e2e-tests/oauth-flow/dezi_idtoken/certs/README.md create mode 100644 e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.key create mode 100644 e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.pem create mode 100644 e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA-chain.pem create mode 100644 e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.key create mode 100644 e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.pem create mode 100644 e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml create mode 100755 e2e-tests/oauth-flow/dezi_idtoken/generate-jwt.sh create mode 100755 e2e-tests/oauth-flow/dezi_idtoken/run-test.sh create mode 100644 vcr/credential/dezi.go create mode 100644 vcr/credential/dezi_test.go diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index c3affbcf97..f2a6a11912 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -29,7 +29,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/core/to" "html/template" "net/http" "net/url" @@ -37,6 +36,9 @@ import ( "strings" "time" + "github.com/nuts-foundation/nuts-node/core/to" + "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/labstack/echo/v4" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" @@ -750,9 +752,18 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS if request.Body.Credentials != nil { credentials = *request.Body.Credentials } + + if request.Body.IdToken != nil { + idTokenCredential, err := credential.CreateDeziIDTokenCredential(*request.Body.IdToken) + if err != nil { + return nil, core.InvalidInputError("failed to create id_token credential: %w", err) + } + credentials = append(credentials, *idTokenCredential) + } + // assert that self-asserted credentials do not contain an issuer or credentialSubject.id. These values must be set // by the nuts-node to build the correct wallet for a DID. See https://github.com/nuts-foundation/nuts-node/issues/3696 - // As a sideeffect it is no longer possible to pass signed credentials to this API. + // As a side effect it is no longer possible to pass signed credentials to this API. for _, cred := range credentials { var credentialSubject []map[string]interface{} if err := cred.UnmarshalCredentialSubject(&credentialSubject); err != nil { diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 5dbe21544d..695e33d53e 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -146,6 +146,10 @@ type ServiceAccessTokenRequest struct { // - proof/signature (MUST be omitted; integrity protection is covered by the VP's proof/signature) Credentials *[]VerifiableCredential `json:"credentials,omitempty"` + // IdToken An optional ID Token (JWT) that represents the end-user. + // This ID token is included in the Verifiable Presentation that is used to request the access token. + IdToken *string `json:"id_token,omitempty"` + // Scope The scope that will be the service for which this access token can be used. Scope string `json:"scope"` diff --git a/auth/oauth/openid.go b/auth/oauth/openid.go index 97572de798..ec6803a07b 100644 --- a/auth/oauth/openid.go +++ b/auth/oauth/openid.go @@ -24,7 +24,7 @@ import ( // proofTypeValuesSupported contains a list of supported cipher suites for ldp_vc & ldp_vp presentation formats // Recommended list of options https://w3c-ccg.github.io/ld-cryptosuite-registry/ -var proofTypeValuesSupported = []string{"JsonWebSignature2020"} +var proofTypeValuesSupported = []string{"JsonWebSignature2020", "DeziIDJWT"} // DefaultOpenIDSupportedFormats returns the OpenID formats supported by the Nuts node and is used in the // - Authorization Server's metadata field `vp_formats_supported` diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index c032a1ff64..aead012f19 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -414,6 +414,12 @@ components: type: string description: The scope that will be the service for which this access token can be used. example: eOverdracht-sender + id_token: + type: string + description: | + An optional ID Token (JWT) that represents the end-user. + This ID token is included in the Verifiable Presentation that is used to request the access token. + It currently only supports Dezi ID tokens. credentials: type: array description: | diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 9ed0f8cbac..81b438d47e 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -140,6 +140,10 @@ type ServiceAccessTokenRequest struct { // - proof/signature (MUST be omitted; integrity protection is covered by the VP's proof/signature) Credentials *[]VerifiableCredential `json:"credentials,omitempty"` + // IdToken An optional ID Token (JWT) that represents the end-user. + // This ID token is included in the Verifiable Presentation that is used to request the access token. + IdToken *string `json:"id_token,omitempty"` + // Scope The scope that will be the service for which this access token can be used. Scope string `json:"scope"` diff --git a/e2e-tests/oauth-flow/dezi_idtoken/accesspolicy.json b/e2e-tests/oauth-flow/dezi_idtoken/accesspolicy.json new file mode 100644 index 0000000000..496b4b1c8e --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/accesspolicy.json @@ -0,0 +1,150 @@ +{ + "test": { + "organization": { + "format": { + "ldp_vc": { + "proof_type": [ + "DeziIDJWT" + ] + }, + "jwt_vc": { + "alg": [ + "PS256" + ] + }, + "jwt_vp": { + "alg": [ + "PS256" + ] + } + }, + "id": "pd_care_organization", + "input_descriptors": [ + { + "id": "id_x509credential", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "X509Credential" + } + }, + { + "path": [ + "$.issuer" + ], + "purpose": "Whe can only accept credentials from a trusted issuer", + "filter": { + "type": "string", + "pattern": "^did:x509:0:sha256:szqMaTpnD6GN0aRrT98eV4bhAoOgyItEZVyskYyL_Qc::.*$" + } + }, + { + "id": "organization_name", + "path": [ + "$.credentialSubject[0].subject.O" + ], + "filter": { + "type": "string" + } + }, + { + "id": "organization_ura", + "path": [ + "$.credentialSubject[0].san.otherName" + ], + "filter": { + "type": "string", + "pattern": "^[0-9.]+-\\d+-\\d+-S-(\\d+)-00\\.000-\\d+$" + } + }, + { + "id": "organization_city", + "path": [ + "$.credentialSubject[0].subject.L" + ], + "filter": { + "type": "string" + } + } + ] + } + }, + { + "id": "id_dezicredential", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "DeziIDTokenCredential" + } + }, + { + "id": "organization_ura_dezi", + "path": [ + "$.credentialSubject.identifier" + ], + "filter": { + "type": "string" + } + }, + { + "id": "user_uzi", + "path": [ + "$.credentialSubject.employee.identifier" + ], + "filter": { + "type": "string" + } + }, + { + "id": "user_initials", + "path": [ + "$.credentialSubject.employee.initials" + ], + "filter": { + "type": "string" + } + }, + { + "id": "user_surname", + "path": [ + "$.credentialSubject.employee.surname" + ], + "filter": { + "type": "string" + } + }, + { + "id": "user_surname_prefix", + "path": [ + "$.credentialSubject.employee.surnamePrefix" + ], + "filter": { + "type": "string" + } + }, + { + "id": "user_roles", + "path": [ + "$.credentialSubject.employee.roles" + ], + "filter": { + "type": "string" + } + } + ] + } + } + ] + } + } +} diff --git a/e2e-tests/oauth-flow/dezi_idtoken/certs/README.md b/e2e-tests/oauth-flow/dezi_idtoken/certs/README.md new file mode 100644 index 0000000000..ed75decc2b --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/certs/README.md @@ -0,0 +1,5 @@ +These files were generated using https://github.com/nuts-foundation/uzi-did-x509-issuer/tree/main/test_ca: + +```shell +./issue-cert.sh nodeA "Because We Care" "Healthland" 0 00001 0 +``` \ No newline at end of file diff --git a/e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.key b/e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.key new file mode 100644 index 0000000000..9804dd8870 --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDIfRO9Iy1xzWyQ +FthldErLm3DeKcqfQJZ6t4mVAiZMYgQyHrIi2BITimwPsGGvfv9erNEJXPBuiCoc +d5pKVPPfFjdtVicP8kc1Fqm3SZNIrHys39w4c5hi/GHAOYtc0JzM/HCH50RgbKF2 +Nm7aeG8v5LVYQLEmTvAFxuj9PDZE7IRC4WbSVca0y4/Xe2y5CU9tZgPh1nl7uoF+ +1RWcDZa+ew57cy4K1cq4ykBWVg0DUXsPsgE+MIoWR+74nZiT2sytxRQs2cXCWkPq +wTUl0d7pAGnWQuEEG3ybQOhpJyc8b1pIYexmo/Piny2FI4qZeqjSzFNVmOmQCa10 +9hF/0HvXAgMBAAECggEALOcGgrfcN77Ab80ODjrrfYqEzt0hSmWWzklJARyII1dY +hTkmwHMQKVw5M5JXbozM+RFPh/9OwhKxC8slvTwlmnNJWq2O9h1XIWbAABL0b7Rh +//3rPqF1IcZQxlKdCd6XH7nyIh4DzGzIBMfQMBIFJP7eNrPWeTP4wfJ4wC66INlJ +++U3QegPCc4RSbbKP4aGt9LbAsBS7r7tuPVR9pPHF+xPdHPy5ZEmDhXoCyjYsTDK +EQKr/ByDnjZF92md+mR0VnATRs2PzPWS2RRiuqTfoTiSxkRPH9sxsNT8Gr94E+x4 +ASeqyFrKbn3TxF86crTTpPCJOoEKidUyfVKB635XsQKBgQD8WGA+WkG8mGqIOLIa +vqYVIlUbYz+N5ZPPC3Louc5BUHO6w5XDMJ9wjRV0X6uT1dbh5eTro7P0uNeTfIiE +fiJ1E7teDWSu7AwdPKoBdTMX3RtWZGV6L5nahjRFxToB3e2afDKVVegpMjGzbGZX +FKeB948+AvjamSX6ENR6j+/alQKBgQDLZG553UiFSDTigm0F0yqlBsY2Amu08UQG +WB9TOJXP8OqzG4iYarpsLuqDUgG3VkPhlQQTfzM7JaoMnyVp9ulfrcYmUsoNM1jL +I07XnjWaZUtQya3eMaLZTNlXnQ/fyjadRVYYYbzBNrgns5kwRqSCHLWQMcL1EQ5A +Vz4IISlNuwKBgAWOYJge3qGrbXUQYoOKPRfsCJmwxr52FpoRc3dCWBNCFTpAgjSp +BmmxAY7taFa596BDspWpphW2WDDMJilcqZ+QTqjUfKoJUn72Tfv4O6bD3I07aqyV +DbstB0ud+xf9bfTf1TFKkfEORN/hfCNgtgt7ivDfmeEeTCLEahlEwBA9AoGAAWDA +ztqM7zo6AX7Ytj1kAJI3LY5+pE8uIszeCXZMrYf4TxZUqpOuh6UZuaIImPFgrFqS +GH+4HSJ4MHWzjzA5DIjk2sWc0NIUO+wVUKilvFILXJTBNMwpSkeXAVzzCpUYIaCi +oK+o07ZHMR2qYAVaf/cp07xCkd53tj/hD7UJzpkCgYEApLkf1bfRIQYTgQfdeNBo +XH6sAVmp1MQg5aNCIx5XdF5gwTksuOOk1GADN0vQkRoC7BTc8YJL4HyBRudDR8DW +/xbtApQwGCFB0mdwtHp7TLuCWy1hhMfACKqTo69heJxBPUdVqeupldoL/Z/IOSPu +7Mgoj5Y/8/OWNh0PDI9uTfQ= +-----END PRIVATE KEY----- diff --git a/e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.pem b/e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.pem new file mode 100644 index 0000000000..b6139cd20a --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/certs/dezi_signing.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUKR0JcvFFkswjBSv/5hjMYNrQTmUwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDIwMjE1MDY0OVoXDTI3MDIw +MjE1MDY0OVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAyH0TvSMtcc1skBbYZXRKy5tw3inKn0CWereJlQImTGIE +Mh6yItgSE4psD7Bhr37/XqzRCVzwbogqHHeaSlTz3xY3bVYnD/JHNRapt0mTSKx8 +rN/cOHOYYvxhwDmLXNCczPxwh+dEYGyhdjZu2nhvL+S1WECxJk7wBcbo/Tw2ROyE +QuFm0lXGtMuP13tsuQlPbWYD4dZ5e7qBftUVnA2WvnsOe3MuCtXKuMpAVlYNA1F7 +D7IBPjCKFkfu+J2Yk9rMrcUULNnFwlpD6sE1JdHe6QBp1kLhBBt8m0DoaScnPG9a +SGHsZqPz4p8thSOKmXqo0sxTVZjpkAmtdPYRf9B71wIDAQABo1MwUTAdBgNVHQ4E +FgQUxQzxiBl6/5+1bfA1BHmIzFUy/fMwHwYDVR0jBBgwFoAUxQzxiBl6/5+1bfA1 +BHmIzFUy/fMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAM4N +81O2G0p2AvpE7t0stwJDhclPYwxL+bsm3uYrNFTppI9xl7U2U98Jbtiiw3DjxKCJ +Ho5a01m5Q+31kYtavbLhKrHO8OYxR7WIg3eAZLy6N+3ZZZ5RnpdKbwkaGzTzeKrG +zN+nWVixzaICoI+OUL14DWZFhGbhDcBxkEzGJzeoEjJlf1IRzpouYvhy1WJLgrZV +olT4pJ0v/2xW3It+9mYktD/74LlK38GnCgGhYt8WWAjEPRty+MQJsA/PGadYtJen +OEPqehEQQ5m6YeNHEVBMvaaIHc4TZpoNRfy+5qz/M02fCb2l6oPWAVNLNRm/2Dbs +6ejzu+NdyxmRDSnbhQ== +-----END CERTIFICATE----- diff --git a/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA-chain.pem b/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA-chain.pem new file mode 100644 index 0000000000..4e4bfcccbb --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA-chain.pem @@ -0,0 +1,40 @@ +-----BEGIN CERTIFICATE----- +MIIC9jCCAd6gAwIBAgIURFCqPrL3QQdBNOqkwmXWNgx9pdQwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNDExMTExNDE1MTha +Fw0zNDExMDkxNDE1MThaMBsxGTAXBgNVBAMMEEZha2UgVVpJIFJvb3QgQ0EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDT5J8gKdyMJNi3cuAmJ+MILrMu +wrKyTRYhjUUFHHn5rcVaHN0hzB6v5t74Nt40xUXRNaomDcclBIOlwt8f62JA2p/j +83ENfdLrXvUu9NMThkqZwZ9dzRwK7l3UZBq8NTQUO74W4M2qx8nrXq31eWogxUUI +Fc1XORh5ecebeL5mUb2E6UlmDmNgm2fGeSmmis8zieI+KKYOhi/hYtyeixrg7rxP +4v0VRrEstcWAetRgXWQX0ElAxs0Vrsy6/vv3pEtXhx8wb2wi2xY14d9Ih8HdeNI+ ++3wIbZz6WVM3fD5QFHV2EZBH+soo0pfKj2tHsaDz3FPMuMzILt6U6PT4ALIdAgMB +AAGjMjAwMA8GA1UdEwQIMAYBAf8CAQAwHQYDVR0OBBYEFJuxz0XwN7PdeMhyJfcf +m7py1BK9MA0GCSqGSIb3DQEBCwUAA4IBAQAhlpkz68x2dGpOLX3FzAb8Ee+Y2OV+ +RWFpsME9ZVDU06JETPfPCj02PH82lgUnc4jeR81rPSsIt2ssqm2S4zb02Nip595c +AqCKvmBfEc9hPPW2ugpNxT8ZRU4LKrqpV4nJ6nBvDqmGuH5uq9Ng9l9SnM3eKmdZ +tJKc+ZNAPKxVAiueLTdr6W2UbmKoZARQQ0JLkFnZOxnUkr8pQfxUzEIUkHg2dWaa +I/4wo4Pni7xXggFoPDpVztu/iP33XBLqXJwxxHXhq9nc9JU/kEXDt7j8EgoyJo7J +jSKcjpRfpGkE5gqqB4Sa8wAsAPUK3jRreuytllAtQUZRbCtHbxclc9yA +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDmTCCAoGgAwIBAgIUFTPO+pUk32QWsYyLYdlLTmlRWVYwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNDEyMTgwOTE0NDZa +Fw0zNDEyMTYwOTE0NDZaMEsxDjAMBgNVBAMMBW5vZGVBMRgwFgYDVQQKDA9CZWNh +dXNlIFdlIENhcmUxEzARBgNVBAcMCkhlYWx0aGxhbmQxCjAIBgNVBAUTATAwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0LOkIXmq9QGpQsy+C+evhqMpL +ZKDpRYIxoKR4Vqp68s2eX+xqBiSaxDkSe3xKKfm0CWsoeQVLXl+9VppH4q5uzyyl +n/qQQEoErghULP99Ez/aDL0JX1XrEvjIePQ+E2rUfYp+HxQdKXc0kJsCv2fntK+T +s6stN8ZeojCc4Edx1nxOHZGZXu0n5DMMXyTB4R7DCEOCyqppSv6m6CexxL4Aw4wr +fHbO1dPmKV/jMxC3Y32SQ8ohJ80y3TnejYuzsAG155CZDm97+Za2G5BcNmwq7Qy7 +aVWhCpEW3fSOX1ZQBOwYFttd7wdcJla5QT6htJnKsWLFBBX4sGYFx1VQPRABAgMB +AAGjgaQwgaEwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMEAGA1UdEQQ5 +MDegNQYDVQUFoC4MLDIuMTYuNTI4LjEuMTAwNy45OS4yMTEwLTEtMC1TLTAwMDAx +LTAwLjAwMC0wMB0GA1UdDgQWBBSnq8XA3if+WQhRDgbOceZPm1NQDDAfBgNVHSME +GDAWgBSbsc9F8Dez3XjIciX3H5u6ctQSvTANBgkqhkiG9w0BAQsFAAOCAQEARp5Y +U1X34jvzdRzSWShluLN/sUSqgxJUmfhYi66lIZlQ4euaQNRFMzEwlQdzgcEBlJnr +IZGgB+MhiCrqAb3PbHBq4V4vDqYmSmtWtxyGDQm5POiN2Uzos1CSBusIyeRkXc1e +rKgXKcY16hzEagYRuJZN8cmeIKCLF0rh34xtEgdFzEw5xV4cWol9W0X9vNJJSVCH +EBA9jY4ULMxxLQY+cZE4GuCfxQ7OsCQQqusP57zeIRDRLs0c8I8J3vSGp6sA2fG0 +mNVrEgIpktVro29NCVEp3oc+7UBsxH2BS45okCLp1KwVW0TMrDH9UPM7ktdCzSmP +Xr+fIaVcs9sbT5qwGw== +-----END CERTIFICATE----- diff --git a/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.key b/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.key new file mode 100644 index 0000000000..70463bcdbf --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC0LOkIXmq9QGpQ +sy+C+evhqMpLZKDpRYIxoKR4Vqp68s2eX+xqBiSaxDkSe3xKKfm0CWsoeQVLXl+9 +VppH4q5uzyyln/qQQEoErghULP99Ez/aDL0JX1XrEvjIePQ+E2rUfYp+HxQdKXc0 +kJsCv2fntK+Ts6stN8ZeojCc4Edx1nxOHZGZXu0n5DMMXyTB4R7DCEOCyqppSv6m +6CexxL4Aw4wrfHbO1dPmKV/jMxC3Y32SQ8ohJ80y3TnejYuzsAG155CZDm97+Za2 +G5BcNmwq7Qy7aVWhCpEW3fSOX1ZQBOwYFttd7wdcJla5QT6htJnKsWLFBBX4sGYF +x1VQPRABAgMBAAECggEABlZdDpPZmWID/n/Ek4AMakth7PoM+3kb917N4ipN0UjF +VdIZOL2rrG9R8/xr1pgrrDsEYQmB5IQdH6w4sLLm5uCUUrGlLwBssHjzM78ob/ym +scBiDTIXmmh4Rf7hImZtV8Xs3BSzEN25D5xPFq8aVCjqExEnztpn69y0rO2Dl2im +xDBnUGPSy1ZCSGtES+BpaNT2GDGieaZmoNOH7TDLXIMYNjgnldeACQOiPvXYG+iQ +LKNSMGw193rR4hB+haBqaEO++845+2vr3TQKOMdFiP3+6LmxTncujSF6RtWj+7si +Zz1R7yqQKHsU6oYQrIJmdZg3AIwB3WhgeG27fZPkpQKBgQDXkOxoCSlvKym9e+r1 +M6Jz4ifaBWT4ys0HCOThEf47j8Qn2BwDIUqhrcARLMtVaEFTXhHWU8ceh529Fyoq +yKe5mpbmzKFd2RH2cyjIq6/e9qVFXDeK7SbypIhxtGjeNv9dGaTSt0Qw2264vMYn +aXHX7vdUfE4pt2R3RZepWKTOXQKBgQDV+JfwQPYFH8nMo9Juc+gzekUb31hZLn68 +Z6ZnvnxNShgazLslHKmAEZyokum0G1tZbiC5f6wI5a0GmFvPyFy1PklBjOatHVDG +byXoRAT1jmBdy1+nfdhd+6Ju2r/VU5tvfYYcKkB/11eBHHYdnSWJU3QGQkpi58Da +vlH2ry7F9QKBgQDEhX+wnOGkUqJb97PNVQR+Ryhzr8VMt35RMn+O3Nt8q2V1uaRY +CirC2OcoAUFiHIipmzIBxiDaqWJZt9ueY43dPJzjzpwyNaoVlwkQYM0WJJ+paxfL +1MZUIUGu/303UMZftvg3jhJhxDrdumOgHJZH+LiM0kJj76hswAoyvfiJlQKBgAGh +Ee8XX4gsdMnlGW4T3dm+fZY3viF3tClVFLRHhATGoqZZlrcyn6vE9o9mBveDGc/1 +gbRH35R1wzqAoHpViTcsETy5iOwahAnuwLgjBHKmMd+k88Z/s80LZHI5oipKp61S +pFnEjJcsmZL3F4MkNiv0gbamfJCCOTqxJkidjtqdAoGBAKSSTSXbkLo4sZeizzzJ +mdSN7MKrO+LZ0Btzyl86OIaSPQZ6rn2vqJi8hwUWSGvTFho7lMRLHrIBL4BehEa7 +xinPPrydLR3z4L7VCRvogFddLI6fqW5NnBepjoT4FQI12AJXeIvDrRYVMfrwW5QH +JCzdoyHTJ2Hk2vIjCctVAf/d +-----END PRIVATE KEY----- diff --git a/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.pem b/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.pem new file mode 100644 index 0000000000..4aa6fb0435 --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/certs/nodeA.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDmTCCAoGgAwIBAgIUFTPO+pUk32QWsYyLYdlLTmlRWVYwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNDEyMTgwOTE0NDZa +Fw0zNDEyMTYwOTE0NDZaMEsxDjAMBgNVBAMMBW5vZGVBMRgwFgYDVQQKDA9CZWNh +dXNlIFdlIENhcmUxEzARBgNVBAcMCkhlYWx0aGxhbmQxCjAIBgNVBAUTATAwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0LOkIXmq9QGpQsy+C+evhqMpL +ZKDpRYIxoKR4Vqp68s2eX+xqBiSaxDkSe3xKKfm0CWsoeQVLXl+9VppH4q5uzyyl +n/qQQEoErghULP99Ez/aDL0JX1XrEvjIePQ+E2rUfYp+HxQdKXc0kJsCv2fntK+T +s6stN8ZeojCc4Edx1nxOHZGZXu0n5DMMXyTB4R7DCEOCyqppSv6m6CexxL4Aw4wr +fHbO1dPmKV/jMxC3Y32SQ8ohJ80y3TnejYuzsAG155CZDm97+Za2G5BcNmwq7Qy7 +aVWhCpEW3fSOX1ZQBOwYFttd7wdcJla5QT6htJnKsWLFBBX4sGYFx1VQPRABAgMB +AAGjgaQwgaEwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMEAGA1UdEQQ5 +MDegNQYDVQUFoC4MLDIuMTYuNTI4LjEuMTAwNy45OS4yMTEwLTEtMC1TLTAwMDAx +LTAwLjAwMC0wMB0GA1UdDgQWBBSnq8XA3if+WQhRDgbOceZPm1NQDDAfBgNVHSME +GDAWgBSbsc9F8Dez3XjIciX3H5u6ctQSvTANBgkqhkiG9w0BAQsFAAOCAQEARp5Y +U1X34jvzdRzSWShluLN/sUSqgxJUmfhYi66lIZlQ4euaQNRFMzEwlQdzgcEBlJnr +IZGgB+MhiCrqAb3PbHBq4V4vDqYmSmtWtxyGDQm5POiN2Uzos1CSBusIyeRkXc1e +rKgXKcY16hzEagYRuJZN8cmeIKCLF0rh34xtEgdFzEw5xV4cWol9W0X9vNJJSVCH +EBA9jY4ULMxxLQY+cZE4GuCfxQ7OsCQQqusP57zeIRDRLs0c8I8J3vSGp6sA2fG0 +mNVrEgIpktVro29NCVEp3oc+7UBsxH2BS45okCLp1KwVW0TMrDH9UPM7ktdCzSmP +Xr+fIaVcs9sbT5qwGw== +-----END CERTIFICATE----- diff --git a/e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml b/e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml new file mode 100644 index 0000000000..f5dfd438f4 --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml @@ -0,0 +1,29 @@ +services: + nodeA-backend: + image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:local}" + ports: + - "18081:8081" + environment: + NUTS_URL: "https://nodeA" + NUTS_VERBOSITY: trace + NUTS_STRICTMODE: false + NUTS_HTTP_INTERNAL_ADDRESS: ":8081" + NUTS_AUTH_CONTRACTVALIDATORS: dummy + NUTS_POLICY_DIRECTORY: /opt/nuts/policies + NUTS_VDR_DIDMETHODS: web + volumes: + # did:web resolver uses the OS CA bundle, but e2e tests use a self-signed CA which can be found in truststore.pem + # So we need to mount that file to the OS CA bundle location, otherwise did:web resolving will fail due to untrusted certs. + - "../../tls-certs/truststore.pem:/etc/ssl/certs/Nuts_RootCA.pem:ro" + - "./accesspolicy.json:/opt/nuts/policies/accesspolicy.json:ro" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often + nodeA: + image: nginx:1.25.1 + ports: + - "10443:443" + volumes: + - "../../shared_config/nodeA-http-nginx.conf:/etc/nginx/conf.d/nuts-http.conf:ro" + - "../../tls-certs/nodeA-certificate.pem:/etc/nginx/ssl/server.pem:ro" + - "../../tls-certs/nodeA-certificate.pem:/etc/nginx/ssl/key.pem:ro" + - "../../tls-certs/truststore.pem:/etc/nginx/ssl/truststore.pem:ro" diff --git a/e2e-tests/oauth-flow/dezi_idtoken/generate-jwt.sh b/e2e-tests/oauth-flow/dezi_idtoken/generate-jwt.sh new file mode 100755 index 0000000000..80d6a31220 --- /dev/null +++ b/e2e-tests/oauth-flow/dezi_idtoken/generate-jwt.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +# Generate JWT ID Token signed with OpenSSL +# Usage: ./generate-jwt.sh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PRIVATE_KEY="$SCRIPT_DIR/certs/dezi_signing.key" +CERT_FILE="$SCRIPT_DIR/certs/dezi_signing.pem" + +# Base64 URL encode function +base64url_encode() { + openssl base64 -e -A | tr '+/' '-_' | tr -d '=' +} + +# Generate certificate if it doesn't exist +if [ ! -f "$CERT_FILE" ]; then + echo "Generating self-signed certificate..." + openssl req -new -x509 -key "$PRIVATE_KEY" -out "$CERT_FILE" -days 365 \ + -subj "/CN=localhost" +fi + +# Extract public key modulus for kid calculation +# Calculate SHA1 hash of the DER-encoded certificate and base64 encode it +KID=$(openssl x509 -in "$CERT_FILE" -outform DER | openssl dgst -sha1 -binary | base64) + +# Extract certificate for x5c (strip headers and newlines) +X5C=$(grep -v "BEGIN CERTIFICATE" "$CERT_FILE" | grep -v "END CERTIFICATE" | tr -d '\n') + +# Get current time and calculate exp/nbf +NOW=$(date +%s) +NBF=$NOW +EXP=$((NOW + 3600)) # 1 hour from now + +# JWT Header +HEADER=$(cat <&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + +echo "---------------------------------------" +echo "Perform OAuth 2.0 rfc021 flow..." +echo "---------------------------------------" + +# Run generate-jwt.sh, and read the input into a var, clean newlines +IDTOKEN=$(./generate-jwt.sh | tr -d '\n') + +REQUEST=$( +cat << EOF +{ + "authorization_server": "https://nodeA/oauth2/vendorA", + "token_type": "bearer", + "scope": "test", + "id_token": "$IDTOKEN" +} +EOF +) +# Request access token +RESPONSE=$(echo $REQUEST | curl -X POST -s --data-binary @- http://localhost:18081/internal/auth/v2/vendorA/request-service-access-token -H "Content-Type: application/json") +if echo $RESPONSE | grep -q "access_token"; then + ACCESS_TOKEN=$(echo $RESPONSE | jq -r .access_token) +else + echo "FAILED: Could not get access token from node-A" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +echo Access token: $ACCESS_TOKEN + +echo "------------------------------------" +echo "Introspect access token..." +echo "------------------------------------" +RESPONSE=$(curl -X POST -s --data "token=$ACCESS_TOKEN" http://localhost:18081/internal/auth/v2/accesstoken/introspect) +echo Introspection response: $RESPONSE + +# Check that it contains the following claims: +# - "organization_ura_dezi":"87654321" +# - "user_initials":"B.B." +# - "user_roles":["01.041","30.000","01.010","01.011"] +# - "user_surname":"Jansen" +# - "user_surname_prefix":"van der" +# - "user_uzi":"900000009" +if [ "$(echo $RESPONSE | jq -r .organization_ura_dezi)" != "87654321" ]; then + echo "FAILED: organization_ura_dezi invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_initials)" != "B.B." ]; then + echo "FAILED: user_initials invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +USER_ROLES=$(echo $RESPONSE | jq -r '.user_roles | sort | join(",")') +if [ "$USER_ROLES" != "01.010,01.011,01.041,30.000" ]; then + echo "FAILED: user_roles invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_surname)" != "Jansen" ]; then + echo "FAILED: user_surname invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_surname_prefix)" != "van der" ]; then + echo "FAILED: user_surname_prefix invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_uzi)" != "900000009" ]; then + echo "FAILED: user_uzi invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + +echo "------------------------------------" +echo "Stopping Docker containers..." +echo "------------------------------------" +docker compose down \ No newline at end of file diff --git a/vcr/credential/dezi.go b/vcr/credential/dezi.go new file mode 100644 index 0000000000..4a53006a4c --- /dev/null +++ b/vcr/credential/dezi.go @@ -0,0 +1,87 @@ +package credential + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/go-did/vc" +) + +func CreateDeziIDTokenCredential(idTokenSerialized string) (*vc.VerifiableCredential, error) { + idToken, err := jwt.Parse([]byte(idTokenSerialized), jwt.WithVerify(false), jwt.WithAcceptableSkew(time.Hour*24*365*10)) + if err != nil { + return nil, fmt.Errorf("parsing id_token: %w", err) + } + relationsRaw, _ := idToken.Get("relations") + relations, ok := relationsRaw.([]any) + if !ok || len(relations) != 1 { + return nil, fmt.Errorf("id_token 'relations' claim invalid or missing (expected array of objects with single item)") + } + relation, ok := relations[0].(map[string]any) + if !ok { + return nil, fmt.Errorf("id_token 'relations' claim invalid or missing (expected array of objects with single item)") + } + roles, ok := relation["roles"].([]any) + if !ok { + return nil, fmt.Errorf("id_token 'relations[0].roles' claim invalid or missing (expected array of strings)") + } + orgURA, ok := relation["ura"].(string) + if !ok || orgURA == "" { + return nil, fmt.Errorf("id_token 'relations[0].ura' claim invalid or missing (expected non-empty string)") + } + getString := func(claim string) string { + value, ok := idToken.Get(claim) + if !ok { + return "" + } + result, _ := value.(string) + return result + } + userID := getString("Dezi_id") + if userID == "" { + return nil, fmt.Errorf("id_token missing 'Dezi_id' claim") + } + initials := getString("initials") + if initials == "" { + return nil, fmt.Errorf("id_token missing 'initials' claim") + } + surname := getString("surname") + if surname == "" { + return nil, fmt.Errorf("id_token missing 'surname' claim") + } + surnamePrefix := getString("surname_prefix") + if surnamePrefix == "" { + return nil, fmt.Errorf("id_token missing 'surname_prefix' claim") + } + + credentialMap := map[string]any{ + "@context": []any{ + "https://www.w3.org/2018/credentials/v1", + // TODO: Create JSON-LD context? + }, + "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, + "issuanceDate": idToken.NotBefore().Format(time.RFC3339Nano), + "expirationDate": idToken.Expiration().Format(time.RFC3339Nano), + "credentialSubject": map[string]any{ + "@type": "DeziIDTokenSubject", + "identifier": orgURA, + "name": relation["entity_name"], + "employee": map[string]any{ + "@type": "HealthcareWorker", + "identifier": userID, + "initials": initials, + "surnamePrefix": surnamePrefix, + "surname": surname, + "roles": roles, + }, + }, + "proof": map[string]any{ + "type": "DeziIDJWT", + "jwt": idTokenSerialized, + }, + } + data, _ := json.Marshal(credentialMap) + return vc.ParseVerifiableCredential(string(data)) +} diff --git a/vcr/credential/dezi_test.go b/vcr/credential/dezi_test.go new file mode 100644 index 0000000000..ea9b970cab --- /dev/null +++ b/vcr/credential/dezi_test.go @@ -0,0 +1,28 @@ +package credential + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateDeziIDToken(t *testing.T) { + t.Run("ok", func(t *testing.T) { + const input = "eyJhbGciOiJSUzI1NiIsImtpZCI6IlVhd3AwY2lRck1PSENadW04MlA2dkphNU8xND0iLCJ0eXAiOiJKV1QifQ.eyJEZXppX2lkIjoiOTAwMDAwMDA5IiwiYXVkIjpbIjAwNmZiZjM0LWE4MGItNGM4MS1iNmU5LTU5MzYwMDY3NWZiMiJdLCJleHAiOjE3MDE5MzM2OTcsImluaXRpYWxzIjoiQi5CLiIsImlzcyI6Imh0dHBzOi8vbWF4LnByb2VmdHVpbi5EZXppLW9ubGluZS5yZG9iZWhlZXIubmwiLCJqc29uX3NjaGVtYSI6Imh0dHBzOi8vbWF4LnByb2VmdHVpbi5EZXppLW9ubGluZS5yZG9iZWhlZXIubmwvanNvbl9zY2hlbWEuanNvbiIsImxvYV9EZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImxvYV9hdXRobiI6Imh0dHA6Ly9laWRhcy5ldXJvcGEuZXUvTG9BL2hpZ2giLCJuYmYiOjE3MDE5MzM2MjcsInJlbGF0aW9ucyI6W3siZW50aXR5X25hbWUiOiJab3JnYWFuYmllZGVyIiwicm9sZXMiOlsiMDEuMDQxIiwiMzAuMDAwIiwiMDEuMDEwIiwiMDEuMDExIl0sInVyYSI6Ijg3NjU0MzIxIn1dLCJzdXJuYW1lIjoiSmFuc2VuIiwic3VybmFtZV9wcmVmaXgiOiJ2YW4gZGVyIiwieDVjIjpbIk1JSURWRENDQWp5Z0F3SUJBZ0lVRFdHVVJUNDJGWFdZbWR2Y1ExQXJxSUQrQUFBd0RRWUpLb1pJaHZjTkFRRUxCUUF3RWpFUU1BNEdBMVVFQXd3SFVtOXZkQ0JEUVRBZUZ3MHlOVEV5TWpReE16SXdOVEZhRncweU9EQXpNamd4TXpJd05URmFNQlF4RWpBUUJnTlZCQU1NQ1d4dlkyRnNhRzl6ZERDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTFJzeFRzaDhRTTdGTkFVUnRlb2pGTUhFNjdSd2dsL3NvdmJnWWlNMVZXTVZGYW9MT0UzYWxzaE9HMkh6d2ZkNUI4Q1VYOFg0K0ZudnZnazkrd0FrMU9vTENTSFA0Ri9LVGk1ZTJwNjRWdllzQUlXWDVOMjBldE5NeWFKRVBIM0lHMHl0OTNZbllZWHF0Y2hvUzNOV01lZjdMUFhJNnlOUXQvNnp0dzg5Qm5vY21lRDVqNmtKaWVtY0krTWttYmE2cHU0TitKT2dXS05ENTlVZlVuUmlDRlVtUEVoZ3VxRzd6WmJROGM3bFVkN1hPRVc1eGZQNUtmNjZ5eUsxaitUb3plUHlmelJIMVBuUHBDaDRWa2lmM1o2elJtc2t6Z0h6RDZjWmpaZDY0b1MwMExGOUczNFlMMkNuRElOdHNaUnZWTW5icmdpelJBY1lXOG83WUc5TzRjQ0F3RUFBYU9CbnpDQm5EQWZCZ05WSFNNRUdEQVdnQlF6VUFRZ0l4ZXBuWjhrSUhRQ0tLdWVIVUhaQ1RBSkJnTlZIUk1FQWpBQU1Bc0dBMVVkRHdRRUF3SUU4REFkQmdOVkhTVUVGakFVQmdnckJnRUZCUWNEQVFZSUt3WUJCUVVIQXdJd0l3WURWUjBSQkJ3d0dvSUpiRzlqWVd4b2IzTjBnZ2R1ZFhSekxtNXNod1IvQUFBQk1CMEdBMVVkRGdRV0JCUUZBY016U1pCYWdUQWNwZ2wvcmllSmV0S3J3VEFOQmdrcWhraUc5dzBCQVFzRkFBT0NBUUVBSzEvRHAxaWk2RWdhRk9NUmJFbWVLMWJrS1dvNDQyYVZHTUdXQzQ0TzUwTm9nSzM1aFM2eFdEdDNYb3B5eUEzcUFKd2FDeDc3ckNkNkVleXpSSlFucXdPRktjUGlqVERkL1hQa25xQzhIUTlqSGVqeWpUY2VaZjFOVVpvU1NteStiS3duK080NHBzdkhmWkpMajN0OFViSkRVd0ZWVm5tL0NMVlh0Q0tETHhnUU9LMWdKUkgrMmxvTmZvRHR0REE3c29TMGJrL2VFQ0FML3Bsdm9meE1LUUxBV0ZobWhLUkhnWC9TSVdORWhkdkcxYlNGVG5HOFVtTmh1RGkrU1dnV3RLTnE4SW5mL3hTSlRyT09ycndFU3V4Wk50aEp5bUVyaWFrT084VEN6VE9icGRoNVFydHo3SmRGV3l1ZDZ0ZnU3VnNVbHRHSEpSeW4zVU5TZCtIYlJnPT0iXX0.alxktj1V-sQHq-bCPdD5GB3E8YqKHgglPEXuYoATXIbo0CBUWARMP8x_Q-gyGdJvU3kpLHVhQK5Y7Y6N6Tpkw46jZiDEVmBEWPltbqKOdCjYGiEie4VB1Sw0lvKwXsiKP_xMGAI6LQ8nrS9_y8fT9JsRWBtqEYR8wb84B727FJiY3SVwks1lnqljW2-qHlS-Z-ecdT7GF_7VBsz5a5UITqQSX0-Q7ccvoXGl8QFtqYjkG9D0oYWRu46l6AtCqyrIc90Iq44nIQf38U46Fohz_ED-J0xSFoiQoWOuiv7vzkdDiO4RHnaGVJeCAiesa3-TdUzE69ZSiPzx8AV_pTRfiw" + + actual, err := CreateDeziIDTokenCredential(input) + require.NoError(t, err) + + require.Len(t, actual.CredentialSubject, 1) + subject := actual.CredentialSubject[0] + employee := subject["employee"].(map[string]interface{}) + assert.Equal(t, "87654321", subject["identifier"]) + assert.Equal(t, "Zorgaanbieder", subject["name"]) + assert.Equal(t, "900000009", employee["identifier"]) + assert.Equal(t, "B.B.", employee["initials"]) + assert.Equal(t, "Jansen", employee["surname"]) + assert.Equal(t, "van der", employee["surnamePrefix"]) + assert.Equal(t, []any{"01.041", "30.000", "01.010", "01.011"}, employee["roles"]) + }) +} diff --git a/vcr/credential/util.go b/vcr/credential/util.go index 41643548f7..25e299f19e 100644 --- a/vcr/credential/util.go +++ b/vcr/credential/util.go @@ -20,12 +20,14 @@ package credential import ( "errors" + "slices" + "time" + "github.com/google/uuid" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" - "slices" - "time" + "github.com/nuts-foundation/nuts-node/vcr/signature/proof" ) // ResolveSubjectDID resolves the subject DID from the given credentials. @@ -114,10 +116,22 @@ func PresentationExpirationDate(presentation vc.VerifiablePresentation) *time.Ti // AutoCorrectSelfAttestedCredential sets the required fields for a self-attested credential. // These are provided through the API, and for convenience we set the required fields, if not already set. -// It only does this for unsigned JSON-LD credentials. DO NOT USE THIS WITH JWT_VC CREDENTIALS. +// It only does this for unsigned JSON-LD credentials and DeziIDTokenCredentials (derived proof). DO NOT USE THIS WITH JWT_VC CREDENTIALS. func AutoCorrectSelfAttestedCredential(credential vc.VerifiableCredential, requester did.DID) vc.VerifiableCredential { if len(credential.Proof) > 0 { - return credential + var proof []proof.LDProof + _ = credential.UnmarshalProofValue(&proof) + isDeziTokenCredential := false + for _, p := range proof { + if p.Type == "DeziIDJWT" { + // derived proof, do the auto-correction + isDeziTokenCredential = true + break + } + } + if !isDeziTokenCredential { + return credential + } } if credential.ID == nil { credential.ID, _ = ssi.ParseURI(uuid.NewString()) diff --git a/vcr/credential/validator.go b/vcr/credential/validator.go index 0d62e8621d..962d041002 100644 --- a/vcr/credential/validator.go +++ b/vcr/credential/validator.go @@ -28,10 +28,7 @@ import ( "net/url" "strings" - "github.com/lestrrat-go/jwx/v2/cert" "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/crypto" @@ -415,26 +412,26 @@ func (d deziIDTokenCredentialValidator) Validate(credential vc.VerifiableCredent } func (d deziIDTokenCredentialValidator) validateDeziToken(credential vc.VerifiableCredential, serialized string) error { - headers, err := crypto.ExtractProtectedHeaders(serialized) - if err != nil { - return fmt.Errorf("invalid JWT headers: %w", err) - } - chain := cert.Chain{} - for i, s := range headers["x5c"].([]string) { - - } - - token, err := jwt.ParseString(serialized, jws.WithKeyProvider(jws.)) - if err != nil { - return err - } - // TODO: Verify deziToken signature - if !token.NotBefore().Equal(credential.IssuanceDate) { - return errors.New("id_token 'nbf' does not match credential 'issuanceDate'") - } - if !token.Expiration().Equal(*credential.ExpirationDate) { - return errors.New("id_token 'exp' does not match credential 'expirationDate'") - } + //headers, err := crypto.ExtractProtectedHeaders(serialized) + //if err != nil { + // return fmt.Errorf("invalid JWT headers: %w", err) + //} + //chain := cert.Chain{} + //for i, s := range headers["x5c"].([]string) { + // + //} + // + //token, err := jwt.ParseString(serialized, jws.WithKeyProvider(jws.)) + //if err != nil { + // return err + //} + //// TODO: Verify deziToken signature + //if !token.NotBefore().Equal(credential.IssuanceDate) { + // return errors.New("id_token 'nbf' does not match credential 'issuanceDate'") + //} + //if !token.Expiration().Equal(*credential.ExpirationDate) { + // return errors.New("id_token 'exp' does not match credential 'expirationDate'") + //} // TODO: implement rest of checks return nil } diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index 9c220c3bee..c8544dd893 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -24,11 +24,12 @@ import ( "crypto/rand" "embed" "encoding/json" + "strings" + "testing" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core/to" vcrTest "github.com/nuts-foundation/nuts-node/vcr/test" - "strings" - "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwt" @@ -102,6 +103,220 @@ func TestParsePresentationDefinition(t *testing.T) { }) } +func TestDeziIDTokenCredential(t *testing.T) { + // Create a simple Dezi id_token (JWT) + privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + token := jwt.New() + _ = token.Set(jwt.NotBeforeKey, 1701933627) + _ = token.Set(jwt.ExpirationKey, 1701933697) + _ = token.Set("initials", "B.B.") + _ = token.Set("surname", "Jansen") + _ = token.Set("surname_prefix", "van der") + _ = token.Set("Dezi_id", "900000009") + _ = token.Set("relations", []map[string]interface{}{ + { + "entity_name": "Zorgaanbieder", + "roles": []string{"01.041", "30.000"}, + "ura": "87654321", + }, + }) + signedToken, _ := jwt.Sign(token, jwt.WithKey(jwa.ES256, privateKey)) + + // Create DeziIDTokenCredential using the helper function + credentialMap := map[string]any{ + "@context": []any{ + "https://www.w3.org/2018/credentials/v1", + }, + "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, + "issuanceDate": token.NotBefore().Format("2006-01-02T15:04:05Z07:00"), + "expirationDate": token.Expiration().Format("2006-01-02T15:04:05Z07:00"), + "credentialSubject": map[string]any{ + "@type": "DeziIDTokenSubject", + "identifier": "87654321", + "name": "Zorgaanbieder", + "employee": map[string]any{ + "@type": "HealthcareWorker", + "identifier": "900000009", + "initials": "B.B.", + "surnamePrefix": "van der", + "surname": "Jansen", + "roles": []string{"01.041", "30.000"}, + }, + }, + "proof": map[string]any{ + "type": "DeziIDJWT", + "jwt": string(signedToken), + }, + } + data, _ := json.Marshal(credentialMap) + cred, err := vc.ParseVerifiableCredential(string(data)) + require.NoError(t, err) + + t.Run("matching credential", func(t *testing.T) { + // Create a presentation definition that matches DeziIDTokenCredential + pd, err := ParsePresentationDefinition([]byte(`{ + "id": "pd_dezi_id_token_credential", + "name": "Dezi ID Token", + "purpose": "Request a Dezi ID Token credential", + "input_descriptors": [ + { + "id": "id_dezi_credential", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "DeziIDTokenCredential" + } + }, + { + "id": "employee_identifier", + "path": [ + "$.credentialSubject.employee.identifier" + ], + "filter": { + "type": "string" + } + }, + { + "id": "employee_initials", + "path": [ + "$.credentialSubject.employee.initials" + ], + "filter": { + "type": "string" + } + } + ] + } + } + ] + }`)) + require.NoError(t, err) + + // Test matching + credentials, mappingObjects, err := pd.Match([]vc.VerifiableCredential{*cred}) + + require.NoError(t, err) + require.Len(t, credentials, 1) + require.Len(t, mappingObjects, 1) + + // Test field resolution + credMap := map[string]vc.VerifiableCredential{ + "id_dezi_credential": *cred, + } + fieldValues, err := pd.ResolveConstraintsFields(credMap) + require.NoError(t, err) + require.Len(t, fieldValues, 2) + assert.Equal(t, "900000009", fieldValues["employee_identifier"]) + assert.Equal(t, "B.B.", fieldValues["employee_initials"]) + }) + + t.Run("non-matching credential type", func(t *testing.T) { + pd, err := ParsePresentationDefinition([]byte(`{ + "id": "pd_other_credential", + "input_descriptors": [ + { + "id": "other_credential", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "SomeOtherCredential" + } + } + ] + } + } + ] + }`)) + require.NoError(t, err) + + credentials, mappingObjects, err := pd.Match([]vc.VerifiableCredential{*cred}) + + assert.Error(t, err) + assert.Empty(t, credentials) + assert.Empty(t, mappingObjects) + }) + + t.Run("matching with organization identifier", func(t *testing.T) { + pd, err := ParsePresentationDefinition([]byte(`{ + "id": "pd_dezi_with_org", + "input_descriptors": [ + { + "id": "dezi_org_credential", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "DeziIDTokenCredential" + } + }, + { + "id": "organization_identifier", + "path": ["$.credentialSubject.identifier"], + "filter": { + "type": "string", + "const": "87654321" + } + } + ] + } + } + ] + }`)) + require.NoError(t, err) + + credentials, mappingObjects, err := pd.Match([]vc.VerifiableCredential{*cred}) + + require.NoError(t, err) + require.Len(t, credentials, 1) + require.Len(t, mappingObjects, 1) + }) + + t.Run("matching employee roles", func(t *testing.T) { + pd, err := ParsePresentationDefinition([]byte(`{ + "id": "pd_dezi_with_roles", + "input_descriptors": [ + { + "id": "dezi_roles_credential", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "DeziIDTokenCredential" + } + }, + { + "id": "employee_roles", + "path": ["$.credentialSubject.employee.roles[*]"], + "filter": { + "type": "string" + } + } + ] + } + } + ] + }`)) + require.NoError(t, err) + + credentials, _, err := pd.Match([]vc.VerifiableCredential{*cred}) + + require.NoError(t, err) + require.Len(t, credentials, 1) + }) +} + func TestEmployeeCredential(t *testing.T) { pd, err := ParsePresentationDefinition([]byte(`{ "format": { diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index 3bb48ca544..45f79fd934 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -388,11 +388,12 @@ func TestVerifier_Verify(t *testing.T) { }) t.Run("DeziIDTokenCredential", func(t *testing.T) { ctx := newMockContext(t) - validAt := time.Now() + ctx.store.EXPECT().GetRevocations(gomock.Any()).Return(nil, ErrNotFound) + validAt := time.Date(2023, 12, 7, 7, 20, 27, 0, time.UTC) cred, _ := createDeziCredential(t, "did:web:example.com") - err := ctx.verifier.Verify(*cred, false, true, &validAt) + err := ctx.verifier.Verify(*cred, true, true, &validAt) assert.NoError(t, err) }) } @@ -934,17 +935,19 @@ func createDeziCredential(t *testing.T, holderDID string) (*vc.VerifiableCredent signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, key)) require.NoError(t, err) + println(string(signed)) + credentialMap := map[string]any{ "@context": []any{ "https://www.w3.org/2018/credentials/v1", }, "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, - "issuer": "https://max.proeftuin.Dezi-online.rdobeheer.nl", + "issuer": holderDID, + "id": holderDID + "#1", "issuanceDate": token.NotBefore().Format(time.RFC3339Nano), "expirationDate": token.Expiration().Format(time.RFC3339Nano), "credentialSubject": map[string]any{ "@type": "DeziIDTokenSubject", - "id": holderDID, "identifier": "87654321", "name": "Zorgaanbieder", "employee": map[string]any{ From 6552dfae10c2d7547a90c015f68d8187b471ddfe Mon Sep 17 00:00:00 2001 From: reinkrul Date: Mon, 2 Feb 2026 17:20:54 +0100 Subject: [PATCH 3/6] Update vcr/credential/validator.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- vcr/credential/validator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vcr/credential/validator.go b/vcr/credential/validator.go index 962d041002..1e4fb5e75c 100644 --- a/vcr/credential/validator.go +++ b/vcr/credential/validator.go @@ -403,7 +403,7 @@ func (d deziIDTokenCredentialValidator) Validate(credential vc.VerifiableCredent } proof := proofs[0] if proof.Type != "DeziIDJWT" { - return fmt.Errorf("%w: invalid proof type: expected 'DeziIDToken', got '%s'", errValidation, proof.Type) + return fmt.Errorf("%w: invalid proof type: expected 'DeziIDJWT', got '%s'", errValidation, proof.Type) } if err := d.validateDeziToken(credential, proof.JWT); err != nil { return fmt.Errorf("%w: invalid Dezi id_token: %w", errValidation, err) From ea7ffac170d272e8ce956f0c1b91ea733c79edce Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Mon, 2 Feb 2026 17:53:02 +0100 Subject: [PATCH 4/6] cleanup --- .../dezi_idtoken/docker-compose.yml | 2 +- vcr/credential/test.go | 55 ++++++++++++ vcr/pe/presentation_definition_test.go | 29 ++----- vcr/verifier/verifier_test.go | 83 +++---------------- 4 files changed, 76 insertions(+), 93 deletions(-) create mode 100644 vcr/credential/test.go diff --git a/e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml b/e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml index f5dfd438f4..c4f374168d 100644 --- a/e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml +++ b/e2e-tests/oauth-flow/dezi_idtoken/docker-compose.yml @@ -1,6 +1,6 @@ services: nodeA-backend: - image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:local}" + image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:master}" ports: - "18081:8081" environment: diff --git a/vcr/credential/test.go b/vcr/credential/test.go new file mode 100644 index 0000000000..ed69ef43a4 --- /dev/null +++ b/vcr/credential/test.go @@ -0,0 +1,55 @@ +package credential + +import ( + "crypto/sha1" + "crypto/tls" + "encoding/base64" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +func CreateTestDeziIDToken(issuedAt time.Time, validUntil time.Time) ([]byte, error) { + keyPair, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") + if err != nil { + return nil, err + } + key, err := jwk.FromRaw(keyPair.PrivateKey) + if err != nil { + return nil, err + } + x5t := sha1.Sum(keyPair.Leaf.Raw) + claims := map[string]any{ + jwk.KeyIDKey: base64.StdEncoding.EncodeToString(x5t[:]), + jwk.X509CertThumbprintKey: base64.StdEncoding.EncodeToString(x5t[:]), + jwk.AlgorithmKey: "RS256", + jwt.AudienceKey: "006fbf34-a80b-4c81-b6e9-593600675fb2", + jwt.ExpirationKey: validUntil.Unix(), + jwt.NotBeforeKey: issuedAt.Unix(), + jwt.IssuerKey: "https://max.proeftuin.Dezi-online.rdobeheer.nl", + "initials": "B.B.", + "surname": "Jansen", + "surname_prefix": "van der", + "Dezi_id": "900000009", + "json_schema": "https://max.proeftuin.Dezi-online.rdobeheer.nl/json_schema.json", + "loa_authn": "http://eidas.europa.eu/LoA/high", + "loa_Dezi": "http://eidas.europa.eu/LoA/high", + "x5c": []string{base64.StdEncoding.EncodeToString(keyPair.Leaf.Raw)}, + "relations": []map[string]interface{}{ + { + "entity_name": "Zorgaanbieder", + "roles": []string{"01.041", "30.000", "01.010", "01.011"}, + "ura": "87654321", + }, + }, + } + token := jwt.New() + for name, value := range claims { + if err := token.Set(name, value); err != nil { + return nil, err + } + } + return jwt.Sign(token, jwt.WithKey(jwa.RS256, key)) +} diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index c8544dd893..9e4378aa54 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -26,9 +26,11 @@ import ( "encoding/json" "strings" "testing" + "time" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core/to" + "github.com/nuts-foundation/nuts-node/vcr/credential" vcrTest "github.com/nuts-foundation/nuts-node/vcr/test" "github.com/lestrrat-go/jwx/v2/jwa" @@ -104,23 +106,10 @@ func TestParsePresentationDefinition(t *testing.T) { } func TestDeziIDTokenCredential(t *testing.T) { - // Create a simple Dezi id_token (JWT) - privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - token := jwt.New() - _ = token.Set(jwt.NotBeforeKey, 1701933627) - _ = token.Set(jwt.ExpirationKey, 1701933697) - _ = token.Set("initials", "B.B.") - _ = token.Set("surname", "Jansen") - _ = token.Set("surname_prefix", "van der") - _ = token.Set("Dezi_id", "900000009") - _ = token.Set("relations", []map[string]interface{}{ - { - "entity_name": "Zorgaanbieder", - "roles": []string{"01.041", "30.000"}, - "ura": "87654321", - }, - }) - signedToken, _ := jwt.Sign(token, jwt.WithKey(jwa.ES256, privateKey)) + iat := time.Unix(1701933627, 0) + exp := time.Unix(1701933697, 0) + token, err := credential.CreateTestDeziIDToken(iat, exp) + require.NoError(t, err) // Create DeziIDTokenCredential using the helper function credentialMap := map[string]any{ @@ -128,8 +117,8 @@ func TestDeziIDTokenCredential(t *testing.T) { "https://www.w3.org/2018/credentials/v1", }, "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, - "issuanceDate": token.NotBefore().Format("2006-01-02T15:04:05Z07:00"), - "expirationDate": token.Expiration().Format("2006-01-02T15:04:05Z07:00"), + "issuanceDate": iat.Format("2006-01-02T15:04:05Z07:00"), + "expirationDate": exp.Format("2006-01-02T15:04:05Z07:00"), "credentialSubject": map[string]any{ "@type": "DeziIDTokenSubject", "identifier": "87654321", @@ -145,7 +134,7 @@ func TestDeziIDTokenCredential(t *testing.T) { }, "proof": map[string]any{ "type": "DeziIDJWT", - "jwt": string(signedToken), + "jwt": string(token), }, } data, _ := json.Marshal(credentialMap) diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index 45f79fd934..ff1cc31f68 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -21,9 +21,6 @@ package verifier import ( "context" "crypto" - "crypto/sha1" - "crypto/tls" - "crypto/x509" "encoding/json" "errors" "net/http" @@ -34,11 +31,6 @@ import ( "testing" "time" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/nuts-foundation/nuts-node/storage/orm" - "github.com/nuts-foundation/nuts-node/test/pki" - "github.com/segmentio/asm/base64" - "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" ssi "github.com/nuts-foundation/go-did" @@ -47,7 +39,9 @@ import ( "github.com/nuts-foundation/nuts-node/crypto/storage/spi" "github.com/nuts-foundation/nuts-node/jsonld" "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/storage/orm" "github.com/nuts-foundation/nuts-node/test/io" + "github.com/nuts-foundation/nuts-node/test/pki" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/revocation" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" @@ -391,7 +385,7 @@ func TestVerifier_Verify(t *testing.T) { ctx.store.EXPECT().GetRevocations(gomock.Any()).Return(nil, ErrNotFound) validAt := time.Date(2023, 12, 7, 7, 20, 27, 0, time.UTC) - cred, _ := createDeziCredential(t, "did:web:example.com") + cred := createDeziCredential(t, "did:web:example.com") err := ctx.verifier.Verify(*cred, true, true, &validAt) assert.NoError(t, err) @@ -876,66 +870,11 @@ func newMockContext(t *testing.T) mockContext { } // createDeziIDToken creates a signed Dezi id_token according to https://www.dezi.nl/documenten/2024/05/08/koppelvlakspecificatie-dezi-online-koppelvlak-1_-platformleverancier -func createDeziCredential(t *testing.T, holderDID string) (*vc.VerifiableCredential, *x509.Certificate) { - keyPair, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") - require.NoError(t, err) - - key, err := jwk.FromRaw(keyPair.PrivateKey) - require.NoError(t, err) - - // Set the key ID and x5t (X.509 thumbprint) - x5t := sha1.Sum(keyPair.Leaf.Raw) - err = key.Set(jwk.KeyIDKey, base64.StdEncoding.EncodeToString(x5t[:])) - require.NoError(t, err) - err = key.Set(jwk.X509CertThumbprintKey, base64.StdEncoding.EncodeToString(x5t[:])) - require.NoError(t, err) - err = key.Set(jwk.AlgorithmKey, "RS256") - require.NoError(t, err) - - // Build the JWT token - token := jwt.New() - - // Set claims from the DeziIDTokenCredential payload - err = token.Set(jwt.AudienceKey, "006fbf34-a80b-4c81-b6e9-593600675fb2") - require.NoError(t, err) - err = token.Set(jwt.ExpirationKey, time.Unix(1701933697, 0)) - require.NoError(t, err) - err = token.Set(jwt.NotBeforeKey, time.Unix(1701933627, 0)) +func createDeziCredential(t *testing.T, holderDID string) *vc.VerifiableCredential { + exp := time.Unix(1701933697, 0) + iat := time.Unix(1701933627, 0) + idToken, err := credential.CreateTestDeziIDToken(iat, exp) require.NoError(t, err) - err = token.Set(jwt.IssuerKey, "https://max.proeftuin.Dezi-online.rdobeheer.nl") - require.NoError(t, err) - - // Set custom claims - err = token.Set("initials", "B.B.") - require.NoError(t, err) - err = token.Set("json_schema", "https://max.proeftuin.Dezi-online.rdobeheer.nl/json_schema.json") - require.NoError(t, err) - err = token.Set("loa_authn", "http://eidas.europa.eu/LoA/high") - require.NoError(t, err) - err = token.Set("loa_Dezi", "http://eidas.europa.eu/LoA/high") - require.NoError(t, err) - err = token.Set("relations", []map[string]interface{}{ - { - "entity_name": "Zorgaanbieder", - "roles": []string{"01.041", "30.000", "01.010", "01.011"}, - "ura": "87654321", - }, - }) - require.NoError(t, err) - err = token.Set("surname", "Jansen") - require.NoError(t, err) - err = token.Set("surname_prefix", "van der") - require.NoError(t, err) - err = token.Set("Dezi_id", "900000009") - require.NoError(t, err) - err = token.Set("x5c", []string{base64.StdEncoding.EncodeToString(keyPair.Leaf.Raw)}) - require.NoError(t, err) - - // Sign the token using jwt.Sign - signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, key)) - require.NoError(t, err) - - println(string(signed)) credentialMap := map[string]any{ "@context": []any{ @@ -944,8 +883,8 @@ func createDeziCredential(t *testing.T, holderDID string) (*vc.VerifiableCredent "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, "issuer": holderDID, "id": holderDID + "#1", - "issuanceDate": token.NotBefore().Format(time.RFC3339Nano), - "expirationDate": token.Expiration().Format(time.RFC3339Nano), + "issuanceDate": iat.Format(time.RFC3339Nano), + "expirationDate": exp.Format(time.RFC3339Nano), "credentialSubject": map[string]any{ "@type": "DeziIDTokenSubject", "identifier": "87654321", @@ -961,12 +900,12 @@ func createDeziCredential(t *testing.T, holderDID string) (*vc.VerifiableCredent }, "proof": map[string]any{ "type": "DeziIDJWT", - "jwt": string(signed), + "jwt": string(idToken), }, } data, err := json.Marshal(credentialMap) require.NoError(t, err) cred, err := vc.ParseVerifiableCredential(string(data)) require.NoError(t, err) - return cred, keyPair.Leaf + return cred } From 649b8392469b1970a24cdbf44c369bd7df5e1949 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 26 Feb 2026 16:17:25 +0100 Subject: [PATCH 5/6] wipo --- vcr/credential/dezi.go | 178 ++++++++++++++++---- vcr/credential/dezi_test.go | 218 ++++++++++++++++++++++++- vcr/credential/test.go | 68 ++++++-- vcr/credential/validator.go | 51 ------ vcr/pe/presentation_definition_test.go | 2 + vcr/verifier/verifier_test.go | 3 +- 6 files changed, 412 insertions(+), 108 deletions(-) diff --git a/vcr/credential/dezi.go b/vcr/credential/dezi.go index 4a53006a4c..d6d1b77a09 100644 --- a/vcr/credential/dezi.go +++ b/vcr/credential/dezi.go @@ -2,35 +2,23 @@ package credential import ( "encoding/json" + "errors" "fmt" + "net/http" "time" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/vc" ) func CreateDeziIDTokenCredential(idTokenSerialized string) (*vc.VerifiableCredential, error) { - idToken, err := jwt.Parse([]byte(idTokenSerialized), jwt.WithVerify(false), jwt.WithAcceptableSkew(time.Hour*24*365*10)) + // 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) } - relationsRaw, _ := idToken.Get("relations") - relations, ok := relationsRaw.([]any) - if !ok || len(relations) != 1 { - return nil, fmt.Errorf("id_token 'relations' claim invalid or missing (expected array of objects with single item)") - } - relation, ok := relations[0].(map[string]any) - if !ok { - return nil, fmt.Errorf("id_token 'relations' claim invalid or missing (expected array of objects with single item)") - } - roles, ok := relation["roles"].([]any) - if !ok { - return nil, fmt.Errorf("id_token 'relations[0].roles' claim invalid or missing (expected array of strings)") - } - orgURA, ok := relation["ura"].(string) - if !ok || orgURA == "" { - return nil, fmt.Errorf("id_token 'relations[0].ura' claim invalid or missing (expected non-empty string)") - } + getString := func(claim string) string { value, ok := idToken.Get(claim) if !ok { @@ -39,21 +27,80 @@ func CreateDeziIDTokenCredential(idTokenSerialized string) (*vc.VerifiableCreden result, _ := value.(string) return result } - userID := getString("Dezi_id") - if userID == "" { - return nil, fmt.Errorf("id_token missing 'Dezi_id' claim") - } - initials := getString("initials") - if initials == "" { - return nil, fmt.Errorf("id_token missing 'initials' claim") - } - surname := getString("surname") - if surname == "" { - return nil, fmt.Errorf("id_token missing 'surname' claim") - } - surnamePrefix := getString("surname_prefix") - if surnamePrefix == "" { - return nil, fmt.Errorf("id_token missing 'surname_prefix' claim") + + // Check if this is v0.7 format (has abonnee_nummer) or old format (has relations) + isV07 := getString("abonnee_nummer") != "" + + var orgURA, orgName, userID, initials, surname, surnamePrefix string + var roles []any + + if isV07 { + // v0.7 spec format + orgURA = getString("abonnee_nummer") + if orgURA == "" { + return nil, fmt.Errorf("id_token missing 'abonnee_nummer' claim") + } + orgName = getString("abonnee_naam") + if orgName == "" { + return nil, fmt.Errorf("id_token missing 'abonnee_naam' claim") + } + + userID = getString("dezi_nummer") + if userID == "" { + return nil, fmt.Errorf("id_token missing 'dezi_nummer' claim") + } + initials = getString("voorletters") + if initials == "" { + return nil, fmt.Errorf("id_token missing 'voorletters' claim") + } + surname = getString("achternaam") + if surname == "" { + return nil, fmt.Errorf("id_token missing 'achternaam' claim") + } + surnamePrefix = getString("voorvoegsel") // Can be null/empty in v0.7 + + // In v0.7, rol_code is a single string, not an array + rolCode := getString("rol_code") + if rolCode != "" { + roles = []any{rolCode} + } + } else { + // Old format with relations + relationsRaw, _ := idToken.Get("relations") + relations, ok := relationsRaw.([]any) + if !ok || len(relations) != 1 { + return nil, fmt.Errorf("id_token 'relations' claim invalid or missing (expected array of objects with single item)") + } + relation, ok := relations[0].(map[string]any) + if !ok { + return nil, fmt.Errorf("id_token 'relations' claim invalid or missing (expected array of objects with single item)") + } + roles, ok = relation["roles"].([]any) + if !ok { + return nil, fmt.Errorf("id_token 'relations[0].roles' claim invalid or missing (expected array of strings)") + } + orgURA, ok = relation["ura"].(string) + if !ok || orgURA == "" { + return nil, fmt.Errorf("id_token 'relations[0].ura' claim invalid or missing (expected non-empty string)") + } + orgName, _ = relation["entity_name"].(string) + + userID = getString("Dezi_id") + if userID == "" { + return nil, fmt.Errorf("id_token missing 'Dezi_id' claim") + } + initials = getString("initials") + if initials == "" { + return nil, fmt.Errorf("id_token missing 'initials' claim") + } + surname = getString("surname") + if surname == "" { + return nil, fmt.Errorf("id_token missing 'surname' claim") + } + surnamePrefix = getString("surname_prefix") + if surnamePrefix == "" { + return nil, fmt.Errorf("id_token missing 'surname_prefix' claim") + } } credentialMap := map[string]any{ @@ -62,12 +109,14 @@ func CreateDeziIDTokenCredential(idTokenSerialized string) (*vc.VerifiableCreden // TODO: Create JSON-LD context? }, "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, + "id": idToken.JwtID(), + "issuer": idToken.Issuer(), "issuanceDate": idToken.NotBefore().Format(time.RFC3339Nano), "expirationDate": idToken.Expiration().Format(time.RFC3339Nano), "credentialSubject": map[string]any{ "@type": "DeziIDTokenSubject", "identifier": orgURA, - "name": relation["entity_name"], + "name": orgName, "employee": map[string]any{ "@type": "HealthcareWorker", "identifier": userID, @@ -85,3 +134,64 @@ func CreateDeziIDTokenCredential(idTokenSerialized string) (*vc.VerifiableCreden data, _ := json.Marshal(credentialMap) return vc.ParseVerifiableCredential(string(data)) } + +// deziIDTokenCredentialValidator validates DeziIDTokenCredential, according to (TODO: add spec). +type deziIDTokenCredentialValidator struct { + clock func() time.Time + httpClient *http.Client // Optional HTTP client for fetching JWK Set (for testing) +} + +func (d deziIDTokenCredentialValidator) Validate(credential vc.VerifiableCredential) error { + type proofType struct { + Type string `json:"type"` + JWT string `json:"jwt"` + } + proofs := []proofType{} + if err := credential.UnmarshalProofValue(&proofs); err != nil { + return fmt.Errorf("%w: invalid proof format: %w", errValidation, err) + } + if len(proofs) != 1 { + return fmt.Errorf("%w: expected exactly one proof, got %d", errValidation, len(proofs)) + } + proof := proofs[0] + if proof.Type != "DeziIDJWT" { + return fmt.Errorf("%w: invalid proof type: expected 'DeziIDJWT', got '%s'", errValidation, proof.Type) + } + if err := d.validateDeziToken(credential, proof.JWT); err != nil { + return fmt.Errorf("%w: invalid Dezi id_token: %w", errValidation, err) + } + return (defaultCredentialValidator{}).Validate(credential) +} + +func (d deziIDTokenCredentialValidator) 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) + // - WithValidate(false) skips exp/nbf validation since we validate those against credential dates + fetchOptions := []jwk.FetchOption{jwk.WithFetchWhitelist(jwk.InsecureWhitelist{})} + 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.WithValidate(false), + ) + 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'") + } + // TODO: implement rest of checks (claims) + return nil +} diff --git a/vcr/credential/dezi_test.go b/vcr/credential/dezi_test.go index ea9b970cab..efe2206d69 100644 --- a/vcr/credential/dezi_test.go +++ b/vcr/credential/dezi_test.go @@ -1,15 +1,27 @@ package credential import ( + "context" + "crypto/sha1" + "crypto/tls" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" "testing" + "time" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCreateDeziIDToken(t *testing.T) { t.Run("ok", func(t *testing.T) { - const input = "eyJhbGciOiJSUzI1NiIsImtpZCI6IlVhd3AwY2lRck1PSENadW04MlA2dkphNU8xND0iLCJ0eXAiOiJKV1QifQ.eyJEZXppX2lkIjoiOTAwMDAwMDA5IiwiYXVkIjpbIjAwNmZiZjM0LWE4MGItNGM4MS1iNmU5LTU5MzYwMDY3NWZiMiJdLCJleHAiOjE3MDE5MzM2OTcsImluaXRpYWxzIjoiQi5CLiIsImlzcyI6Imh0dHBzOi8vbWF4LnByb2VmdHVpbi5EZXppLW9ubGluZS5yZG9iZWhlZXIubmwiLCJqc29uX3NjaGVtYSI6Imh0dHBzOi8vbWF4LnByb2VmdHVpbi5EZXppLW9ubGluZS5yZG9iZWhlZXIubmwvanNvbl9zY2hlbWEuanNvbiIsImxvYV9EZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImxvYV9hdXRobiI6Imh0dHA6Ly9laWRhcy5ldXJvcGEuZXUvTG9BL2hpZ2giLCJuYmYiOjE3MDE5MzM2MjcsInJlbGF0aW9ucyI6W3siZW50aXR5X25hbWUiOiJab3JnYWFuYmllZGVyIiwicm9sZXMiOlsiMDEuMDQxIiwiMzAuMDAwIiwiMDEuMDEwIiwiMDEuMDExIl0sInVyYSI6Ijg3NjU0MzIxIn1dLCJzdXJuYW1lIjoiSmFuc2VuIiwic3VybmFtZV9wcmVmaXgiOiJ2YW4gZGVyIiwieDVjIjpbIk1JSURWRENDQWp5Z0F3SUJBZ0lVRFdHVVJUNDJGWFdZbWR2Y1ExQXJxSUQrQUFBd0RRWUpLb1pJaHZjTkFRRUxCUUF3RWpFUU1BNEdBMVVFQXd3SFVtOXZkQ0JEUVRBZUZ3MHlOVEV5TWpReE16SXdOVEZhRncweU9EQXpNamd4TXpJd05URmFNQlF4RWpBUUJnTlZCQU1NQ1d4dlkyRnNhRzl6ZERDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTFJzeFRzaDhRTTdGTkFVUnRlb2pGTUhFNjdSd2dsL3NvdmJnWWlNMVZXTVZGYW9MT0UzYWxzaE9HMkh6d2ZkNUI4Q1VYOFg0K0ZudnZnazkrd0FrMU9vTENTSFA0Ri9LVGk1ZTJwNjRWdllzQUlXWDVOMjBldE5NeWFKRVBIM0lHMHl0OTNZbllZWHF0Y2hvUzNOV01lZjdMUFhJNnlOUXQvNnp0dzg5Qm5vY21lRDVqNmtKaWVtY0krTWttYmE2cHU0TitKT2dXS05ENTlVZlVuUmlDRlVtUEVoZ3VxRzd6WmJROGM3bFVkN1hPRVc1eGZQNUtmNjZ5eUsxaitUb3plUHlmelJIMVBuUHBDaDRWa2lmM1o2elJtc2t6Z0h6RDZjWmpaZDY0b1MwMExGOUczNFlMMkNuRElOdHNaUnZWTW5icmdpelJBY1lXOG83WUc5TzRjQ0F3RUFBYU9CbnpDQm5EQWZCZ05WSFNNRUdEQVdnQlF6VUFRZ0l4ZXBuWjhrSUhRQ0tLdWVIVUhaQ1RBSkJnTlZIUk1FQWpBQU1Bc0dBMVVkRHdRRUF3SUU4REFkQmdOVkhTVUVGakFVQmdnckJnRUZCUWNEQVFZSUt3WUJCUVVIQXdJd0l3WURWUjBSQkJ3d0dvSUpiRzlqWVd4b2IzTjBnZ2R1ZFhSekxtNXNod1IvQUFBQk1CMEdBMVVkRGdRV0JCUUZBY016U1pCYWdUQWNwZ2wvcmllSmV0S3J3VEFOQmdrcWhraUc5dzBCQVFzRkFBT0NBUUVBSzEvRHAxaWk2RWdhRk9NUmJFbWVLMWJrS1dvNDQyYVZHTUdXQzQ0TzUwTm9nSzM1aFM2eFdEdDNYb3B5eUEzcUFKd2FDeDc3ckNkNkVleXpSSlFucXdPRktjUGlqVERkL1hQa25xQzhIUTlqSGVqeWpUY2VaZjFOVVpvU1NteStiS3duK080NHBzdkhmWkpMajN0OFViSkRVd0ZWVm5tL0NMVlh0Q0tETHhnUU9LMWdKUkgrMmxvTmZvRHR0REE3c29TMGJrL2VFQ0FML3Bsdm9meE1LUUxBV0ZobWhLUkhnWC9TSVdORWhkdkcxYlNGVG5HOFVtTmh1RGkrU1dnV3RLTnE4SW5mL3hTSlRyT09ycndFU3V4Wk50aEp5bUVyaWFrT084VEN6VE9icGRoNVFydHo3SmRGV3l1ZDZ0ZnU3VnNVbHRHSEpSeW4zVU5TZCtIYlJnPT0iXX0.alxktj1V-sQHq-bCPdD5GB3E8YqKHgglPEXuYoATXIbo0CBUWARMP8x_Q-gyGdJvU3kpLHVhQK5Y7Y6N6Tpkw46jZiDEVmBEWPltbqKOdCjYGiEie4VB1Sw0lvKwXsiKP_xMGAI6LQ8nrS9_y8fT9JsRWBtqEYR8wb84B727FJiY3SVwks1lnqljW2-qHlS-Z-ecdT7GF_7VBsz5a5UITqQSX0-Q7ccvoXGl8QFtqYjkG9D0oYWRu46l6AtCqyrIc90Iq44nIQf38U46Fohz_ED-J0xSFoiQoWOuiv7vzkdDiO4RHnaGVJeCAiesa3-TdUzE69ZSiPzx8AV_pTRfiw" + const input = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjMyNWRlOWFiLTQzMzAtNGMwMS04MjRlLWQ5YmQwYzM3Y2NhMCIsImprdSI6Imh0dHBzOi8vW2V4dGVybiBlbmRwb2ludF0vandrcy5qc29uIiwidHlwIjoiSldUIn0.eyJqdGkiOiI2MWIxZmFmYy00ZWM3LTQ0ODktYTI4MC04ZDBhNTBhM2Q1YTkiLCJpc3MiOiJhYm9ubmVlLmRlemkubmwiLCJleHAiOjE3NDAxMzExNzYsIm5iZiI6MTczMjE4MjM3NiwianNvbl9zY2hlbWEiOiJodHRwczovL3d3dy5kZXppLm5sL2pzb25fc2NoZW1hcy92ZXJrbGFyaW5nX3YxLmpzb24iLCJsb2FfZGV6aSI6Imh0dHA6Ly9laWRhcy5ldXJvcGUuZXUvTG9BL2hpZ2giLCJ2ZXJrbGFyaW5nX2lkIjoiODUzOWY3NWQtNjM0Yy00N2RiLWJiNDEtMjg3OTFkZmQxZjhkIiwiZGV6aV9udW1tZXIiOiIxMjM0NTY3ODkiLCJ2b29ybGV0dGVycyI6IkEuQi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IlpvcmdtZWRld2Vya2VyIiwiYWJvbm5lZV9udW1tZXIiOiI4NzY1NDMyMSIsImFib25uZWVfbmFhbSI6IlpvcmdhYW5iaWVkZXIiLCJyb2xfY29kZSI6IjAxLjAwMCIsInJvbF9uYWFtIjoiQXJ0cyIsInJvbF9jb2RlX2Jyb24iOiJodHRwOi8vd3d3LmRlemkubmwvcm9sX2NvZGVfYnJvbi9iaWciLCJyZXZvY2F0aWVfY29udHJvbGVfdXJpIjoiaHR0cHM6Ly9hdXRoLmRlemkubmwvcmV2b2NhdGllLXN0YXR1cy92MS92ZXJrbGFyaW5nLzg1MzlmNzVkLTYzNGMtNDdkYi1iYjQxLTI4NzkxZGZkMWY4ZCJ9.vegszRMWJjE-SBpfPO9lxN_fEY814ezsXRYhLXorPq3j_B_wlv4A92saasdEWrTALbl9Shux0i6JvkbouqvZ_oJpOUfJxWFGFfGGCuiMhiz4k1zm665i98e2xTqFzqjQySu_gup3wYm24FmnzbHxy02RzM3pXvQCsk_jIfQ1YcUZmNmXa5hR4DEn4Z9STLHd2HwyL6IKafEGl-R_kgbAnArSHQvuLw0Fpx62QD0tr5d3PbzPirBdkuy4G1l0umb69EjZMZ5MyIl8Y_irhQ9IFomAeSlU_zZp6UojVIOnCY2gL5EMc_8B1PDC6R_C--quGoh14jiSOJAeYSf_9ETjgQ" actual, err := CreateDeziIDTokenCredential(input) require.NoError(t, err) @@ -19,10 +31,204 @@ func TestCreateDeziIDToken(t *testing.T) { employee := subject["employee"].(map[string]interface{}) assert.Equal(t, "87654321", subject["identifier"]) assert.Equal(t, "Zorgaanbieder", subject["name"]) - assert.Equal(t, "900000009", employee["identifier"]) - assert.Equal(t, "B.B.", employee["initials"]) - assert.Equal(t, "Jansen", employee["surname"]) - assert.Equal(t, "van der", employee["surnamePrefix"]) - assert.Equal(t, []any{"01.041", "30.000", "01.010", "01.011"}, employee["roles"]) + 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, []any{"01.000"}, employee["roles"]) + }) +} + +func TestDeziIDTokenCredentialValidator(t *testing.T) { + // Test constants + iat := time.Unix(1732182376, 0) // Nov 21, 2024 + exp := time.Unix(1740131176, 0) // Feb 21, 2025 + validAt := time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC) + + // Helper to create token with mocked JWK server + createTokenWithMockServer := func(t *testing.T, keySet jwk.Set) (string, *httptest.Server) { + // Create test token + tokenBytes, err := CreateTestDeziIDToken(iat, exp) + require.NoError(t, err) + + // Create mock HTTPS server (jku must be HTTPS) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(keySet) + })) + + // Parse token and update jku header + msg, err := jws.Parse(tokenBytes) + require.NoError(t, err) + + sig := msg.Signatures()[0] + headers := jws.NewHeaders() + for iter := sig.ProtectedHeaders().Iterate(context.Background()); iter.Next(context.Background()); { + pair := iter.Pair() + headers.Set(pair.Key.(string), pair.Value) + } + headers.Set("jku", server.URL+"/jwks.json") + + // Load key for re-signing + keyPair, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") + require.NoError(t, err) + privateKey, err := jwk.FromRaw(keyPair.PrivateKey) + require.NoError(t, err) + + // Parse original token claims + origToken, err := jwt.Parse(tokenBytes, jwt.WithVerify(false), jwt.WithValidate(false)) + require.NoError(t, err) + + // Re-sign with new headers + signedToken, err := jwt.Sign(origToken, jwt.WithKey(jwa.RS256, privateKey, jws.WithProtectedHeaders(headers))) + require.NoError(t, err) + + return string(signedToken), server + } + + // Helper to extract JWK set from token + extractJWKSet := func(t *testing.T) jwk.Set { + tokenBytes, err := CreateTestDeziIDToken(iat, exp) + require.NoError(t, err) + + token, err := jwt.Parse(tokenBytes, jwt.WithVerify(false), jwt.WithValidate(false)) + require.NoError(t, err) + + jwksRaw, ok := token.Get("jwks") + require.True(t, ok) + + jwksJSON, err := json.Marshal(jwksRaw) + require.NoError(t, err) + + keySet, err := jwk.Parse(jwksJSON) + require.NoError(t, err) + + return keySet + } + + t.Run("ok - valid signature and timestamps", func(t *testing.T) { + keySet := extractJWKSet(t) + tokenStr, server := createTokenWithMockServer(t, keySet) + defer server.Close() + + cred, err := CreateDeziIDTokenCredential(tokenStr) + require.NoError(t, err) + + err = deziIDTokenCredentialValidator{ + clock: func() time.Time { + return validAt + }, + httpClient: server.Client(), // Use test server's client to trust its certificate + }.Validate(*cred) + require.NoError(t, err) + }) + + t.Run("error - wrong exp", func(t *testing.T) { + keySet := extractJWKSet(t) + tokenStr, server := createTokenWithMockServer(t, keySet) + defer server.Close() + + cred, err := CreateDeziIDTokenCredential(tokenStr) + require.NoError(t, err) + + // Modify credential expiration to be different from token + wrongExp := exp.Add(time.Hour) + cred.ExpirationDate = &wrongExp + + err = deziIDTokenCredentialValidator{ + clock: func() time.Time { + return validAt + }, + httpClient: server.Client(), + }.Validate(*cred) + assert.Error(t, err) + assert.Contains(t, err.Error(), "'exp' does not match credential 'expirationDate'") + }) + + t.Run("error - wrong nbf", func(t *testing.T) { + keySet := extractJWKSet(t) + tokenStr, server := createTokenWithMockServer(t, keySet) + defer server.Close() + + cred, err := CreateDeziIDTokenCredential(tokenStr) + require.NoError(t, err) + + // Modify credential issuance date to be different from token + wrongNbf := iat.Add(-time.Hour) + cred.IssuanceDate = wrongNbf + + err = deziIDTokenCredentialValidator{ + clock: func() time.Time { + return validAt + }, + httpClient: server.Client(), + }.Validate(*cred) + assert.Error(t, err) + assert.Contains(t, err.Error(), "'nbf' does not match credential 'issuanceDate'") + }) + + t.Run("error - invalid signature (wrong key in JWK set)", func(t *testing.T) { + // Create a different key set (wrong keys) + wrongKeySet := jwk.NewSet() + wrongKey, _ := jwk.FromRaw([]byte("wrong-secret-key-data")) + x5t := sha1.Sum([]byte("wrong-cert")) + kid := base64.StdEncoding.EncodeToString(x5t[:]) + wrongKey.Set(jwk.KeyIDKey, kid) + wrongKeySet.AddKey(wrongKey) + + tokenStr, server := createTokenWithMockServer(t, wrongKeySet) + defer server.Close() + + cred, err := CreateDeziIDTokenCredential(tokenStr) + require.NoError(t, err) + + err = deziIDTokenCredentialValidator{ + clock: func() time.Time { + return validAt + }, + httpClient: server.Client(), + }.Validate(*cred) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to verify JWT signature") + }) + + t.Run("error - jku endpoint unreachable", func(t *testing.T) { + // Create token bytes + tokenBytes, err := CreateTestDeziIDToken(iat, exp) + require.NoError(t, err) + + // Parse and update jku to non-existent endpoint + msg, err := jws.Parse(tokenBytes) + require.NoError(t, err) + + sig := msg.Signatures()[0] + headers := jws.NewHeaders() + for iter := sig.ProtectedHeaders().Iterate(context.Background()); iter.Next(context.Background()); { + pair := iter.Pair() + headers.Set(pair.Key.(string), pair.Value) + } + headers.Set("jku", "https://localhost:9999/jwks.json") + + keyPair, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") + require.NoError(t, err) + privateKey, err := jwk.FromRaw(keyPair.PrivateKey) + require.NoError(t, err) + + origToken, err := jwt.Parse(tokenBytes, jwt.WithVerify(false), jwt.WithValidate(false)) + require.NoError(t, err) + + signedToken, err := jwt.Sign(origToken, jwt.WithKey(jwa.RS256, privateKey, jws.WithProtectedHeaders(headers))) + require.NoError(t, err) + + cred, err := CreateDeziIDTokenCredential(string(signedToken)) + require.NoError(t, err) + + err = deziIDTokenCredentialValidator{ + clock: func() time.Time { + return validAt + }, + }.Validate(*cred) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to verify JWT signature") }) } diff --git a/vcr/credential/test.go b/vcr/credential/test.go index ed69ef43a4..d96618bf75 100644 --- a/vcr/credential/test.go +++ b/vcr/credential/test.go @@ -4,10 +4,12 @@ import ( "crypto/sha1" "crypto/tls" "encoding/base64" + "encoding/json" "time" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jws" "github.com/lestrrat-go/jwx/v2/jwt" ) @@ -20,23 +22,49 @@ func CreateTestDeziIDToken(issuedAt time.Time, validUntil time.Time) ([]byte, er if err != nil { return nil, err } + + // Create public key for the JWK Set + // Extract the public key from the certificate + publicKey, err := jwk.FromRaw(keyPair.Leaf.PublicKey) + if err != nil { + return nil, err + } + + // Set the key ID x5t := sha1.Sum(keyPair.Leaf.Raw) + kid := base64.StdEncoding.EncodeToString(x5t[:]) + if err := publicKey.Set(jwk.KeyIDKey, kid); err != nil { + return nil, err + } + if err := publicKey.Set(jwk.AlgorithmKey, jwa.RS256); err != nil { + return nil, err + } + + // Create JWK Set with the public key + keySet := jwk.NewSet() + if err := keySet.AddKey(publicKey); err != nil { + return nil, err + } + + // Marshal the JWK Set to JSON + jwksJSON, err := json.Marshal(keySet) + if err != nil { + return nil, err + } + claims := map[string]any{ - jwk.KeyIDKey: base64.StdEncoding.EncodeToString(x5t[:]), - jwk.X509CertThumbprintKey: base64.StdEncoding.EncodeToString(x5t[:]), - jwk.AlgorithmKey: "RS256", - jwt.AudienceKey: "006fbf34-a80b-4c81-b6e9-593600675fb2", - jwt.ExpirationKey: validUntil.Unix(), - jwt.NotBeforeKey: issuedAt.Unix(), - jwt.IssuerKey: "https://max.proeftuin.Dezi-online.rdobeheer.nl", - "initials": "B.B.", - "surname": "Jansen", - "surname_prefix": "van der", - "Dezi_id": "900000009", - "json_schema": "https://max.proeftuin.Dezi-online.rdobeheer.nl/json_schema.json", - "loa_authn": "http://eidas.europa.eu/LoA/high", - "loa_Dezi": "http://eidas.europa.eu/LoA/high", - "x5c": []string{base64.StdEncoding.EncodeToString(keyPair.Leaf.Raw)}, + 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", + "jwks": json.RawMessage(jwksJSON), "relations": []map[string]interface{}{ { "entity_name": "Zorgaanbieder", @@ -51,5 +79,13 @@ func CreateTestDeziIDToken(issuedAt time.Time, validUntil time.Time) ([]byte, er return nil, err } } - return jwt.Sign(token, jwt.WithKey(jwa.RS256, key)) + + // Create headers with kid + headers := jws.NewHeaders() + if err := headers.Set(jwk.KeyIDKey, kid); err != nil { + return nil, err + } + + // Sign with the key ID in the header + return jwt.Sign(token, jwt.WithKey(jwa.RS256, key, jws.WithProtectedHeaders(headers))) } diff --git a/vcr/credential/validator.go b/vcr/credential/validator.go index 1e4fb5e75c..56be3476b3 100644 --- a/vcr/credential/validator.go +++ b/vcr/credential/validator.go @@ -384,54 +384,3 @@ func validatePolicyAssertions(issuer did.DID, credential vc.VerifiableCredential return nil } - -// DeziIDTokenCredentialValidator validates DeziIDTokenCredential, according to (TODO: add spec). -type deziIDTokenCredentialValidator struct { -} - -func (d deziIDTokenCredentialValidator) Validate(credential vc.VerifiableCredential) error { - type proofType struct { - Type string `json:"type"` - JWT string `json:"jwt"` - } - proofs := []proofType{} - if err := credential.UnmarshalProofValue(&proofs); err != nil { - return fmt.Errorf("%w: invalid proof format: %w", errValidation, err) - } - if len(proofs) != 1 { - return fmt.Errorf("%w: expected exactly one proof, got %d", errValidation, len(proofs)) - } - proof := proofs[0] - if proof.Type != "DeziIDJWT" { - return fmt.Errorf("%w: invalid proof type: expected 'DeziIDJWT', got '%s'", errValidation, proof.Type) - } - if err := d.validateDeziToken(credential, proof.JWT); err != nil { - return fmt.Errorf("%w: invalid Dezi id_token: %w", errValidation, err) - } - return (defaultCredentialValidator{}).Validate(credential) -} - -func (d deziIDTokenCredentialValidator) validateDeziToken(credential vc.VerifiableCredential, serialized string) error { - //headers, err := crypto.ExtractProtectedHeaders(serialized) - //if err != nil { - // return fmt.Errorf("invalid JWT headers: %w", err) - //} - //chain := cert.Chain{} - //for i, s := range headers["x5c"].([]string) { - // - //} - // - //token, err := jwt.ParseString(serialized, jws.WithKeyProvider(jws.)) - //if err != nil { - // return err - //} - //// TODO: Verify deziToken signature - //if !token.NotBefore().Equal(credential.IssuanceDate) { - // return errors.New("id_token 'nbf' does not match credential 'issuanceDate'") - //} - //if !token.Expiration().Equal(*credential.ExpirationDate) { - // return errors.New("id_token 'exp' does not match credential 'expirationDate'") - //} - // TODO: implement rest of checks - return nil -} diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index 9e4378aa54..c35310c926 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -117,6 +117,8 @@ func TestDeziIDTokenCredential(t *testing.T) { "https://www.w3.org/2018/credentials/v1", }, "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, + "id": "urn:uuid:test-credential", + "issuer": "https://max.proeftuin.Dezi-online.rdobeheer.nl", "issuanceDate": iat.Format("2006-01-02T15:04:05Z07:00"), "expirationDate": exp.Format("2006-01-02T15:04:05Z07:00"), "credentialSubject": map[string]any{ diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index ff1cc31f68..b986b3adf0 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -383,7 +383,8 @@ func TestVerifier_Verify(t *testing.T) { t.Run("DeziIDTokenCredential", func(t *testing.T) { ctx := newMockContext(t) ctx.store.EXPECT().GetRevocations(gomock.Any()).Return(nil, ErrNotFound) - validAt := time.Date(2023, 12, 7, 7, 20, 27, 0, time.UTC) + // Token is valid from 1701933627 (Dec 7, 2023 07:20:27) to 1701933697 (Dec 7, 2023 07:21:37) + validAt := time.Date(2023, 12, 7, 7, 21, 0, 0, time.UTC) cred := createDeziCredential(t, "did:web:example.com") From a622b57608fb4b8b8176f7d2e9037976c6749e74 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Fri, 27 Feb 2026 09:18:04 +0100 Subject: [PATCH 6/6] Update to 2026 version (wip) --- vcr/credential/dezi_test.go | 217 +++++++++---------------- vcr/credential/test.go | 60 ++----- vcr/pe/presentation_definition_test.go | 2 +- vcr/verifier/verifier_test.go | 2 +- 4 files changed, 93 insertions(+), 188 deletions(-) diff --git a/vcr/credential/dezi_test.go b/vcr/credential/dezi_test.go index efe2206d69..68f56426dc 100644 --- a/vcr/credential/dezi_test.go +++ b/vcr/credential/dezi_test.go @@ -1,24 +1,39 @@ package credential import ( - "context" - "crypto/sha1" + "bytes" "crypto/tls" - "encoding/base64" "encoding/json" + "io" "net/http" - "net/http/httptest" "testing" "time" - "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" "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 { + keySet jwk.Set +} + +func (s *stubbedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // Marshal the key set to JSON + jwksJSON, err := json.Marshal(s.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("ok", func(t *testing.T) { const input = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjMyNWRlOWFiLTQzMzAtNGMwMS04MjRlLWQ5YmQwYzM3Y2NhMCIsImprdSI6Imh0dHBzOi8vW2V4dGVybiBlbmRwb2ludF0vandrcy5qc29uIiwidHlwIjoiSldUIn0.eyJqdGkiOiI2MWIxZmFmYy00ZWM3LTQ0ODktYTI4MC04ZDBhNTBhM2Q1YTkiLCJpc3MiOiJhYm9ubmVlLmRlemkubmwiLCJleHAiOjE3NDAxMzExNzYsIm5iZiI6MTczMjE4MjM3NiwianNvbl9zY2hlbWEiOiJodHRwczovL3d3dy5kZXppLm5sL2pzb25fc2NoZW1hcy92ZXJrbGFyaW5nX3YxLmpzb24iLCJsb2FfZGV6aSI6Imh0dHA6Ly9laWRhcy5ldXJvcGUuZXUvTG9BL2hpZ2giLCJ2ZXJrbGFyaW5nX2lkIjoiODUzOWY3NWQtNjM0Yy00N2RiLWJiNDEtMjg3OTFkZmQxZjhkIiwiZGV6aV9udW1tZXIiOiIxMjM0NTY3ODkiLCJ2b29ybGV0dGVycyI6IkEuQi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IlpvcmdtZWRld2Vya2VyIiwiYWJvbm5lZV9udW1tZXIiOiI4NzY1NDMyMSIsImFib25uZWVfbmFhbSI6IlpvcmdhYW5iaWVkZXIiLCJyb2xfY29kZSI6IjAxLjAwMCIsInJvbF9uYWFtIjoiQXJ0cyIsInJvbF9jb2RlX2Jyb24iOiJodHRwOi8vd3d3LmRlemkubmwvcm9sX2NvZGVfYnJvbi9iaWciLCJyZXZvY2F0aWVfY29udHJvbGVfdXJpIjoiaHR0cHM6Ly9hdXRoLmRlemkubmwvcmV2b2NhdGllLXN0YXR1cy92MS92ZXJrbGFyaW5nLzg1MzlmNzVkLTYzNGMtNDdkYi1iYjQxLTI4NzkxZGZkMWY4ZCJ9.vegszRMWJjE-SBpfPO9lxN_fEY814ezsXRYhLXorPq3j_B_wlv4A92saasdEWrTALbl9Shux0i6JvkbouqvZ_oJpOUfJxWFGFfGGCuiMhiz4k1zm665i98e2xTqFzqjQySu_gup3wYm24FmnzbHxy02RzM3pXvQCsk_jIfQ1YcUZmNmXa5hR4DEn4Z9STLHd2HwyL6IKafEGl-R_kgbAnArSHQvuLw0Fpx62QD0tr5d3PbzPirBdkuy4G1l0umb69EjZMZ5MyIl8Y_irhQ9IFomAeSlU_zZp6UojVIOnCY2gL5EMc_8B1PDC6R_C--quGoh14jiSOJAeYSf_9ETjgQ" @@ -45,124 +60,69 @@ func TestDeziIDTokenCredentialValidator(t *testing.T) { exp := time.Unix(1740131176, 0) // Feb 21, 2025 validAt := time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC) - // Helper to create token with mocked JWK server - createTokenWithMockServer := func(t *testing.T, keySet jwk.Set) (string, *httptest.Server) { - // Create test token - tokenBytes, err := CreateTestDeziIDToken(iat, exp) - require.NoError(t, err) - - // Create mock HTTPS server (jku must be HTTPS) - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(keySet) - })) - - // Parse token and update jku header - msg, err := jws.Parse(tokenBytes) - require.NoError(t, err) - - sig := msg.Signatures()[0] - headers := jws.NewHeaders() - for iter := sig.ProtectedHeaders().Iterate(context.Background()); iter.Next(context.Background()); { - pair := iter.Pair() - headers.Set(pair.Key.(string), pair.Value) - } - headers.Set("jku", server.URL+"/jwks.json") - - // Load key for re-signing - keyPair, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") - require.NoError(t, err) - privateKey, err := jwk.FromRaw(keyPair.PrivateKey) - require.NoError(t, err) - - // Parse original token claims - origToken, err := jwt.Parse(tokenBytes, jwt.WithVerify(false), jwt.WithValidate(false)) - require.NoError(t, err) - - // Re-sign with new headers - signedToken, err := jwt.Sign(origToken, jwt.WithKey(jwa.RS256, privateKey, jws.WithProtectedHeaders(headers))) - require.NoError(t, err) - - return string(signedToken), server + // Load signing key, create JWK set + signingKeyCert, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") + require.NoError(t, err) + signingKey := signingKeyCert.PrivateKey + signingKeyJWK, err := jwk.FromRaw(signingKey) + require.NoError(t, err) + require.NoError(t, signingKeyJWK.Set(jwk.KeyIDKey, "1")) + + // Create JWK set with the public key + publicKeyJWK, err := jwk.FromRaw(signingKeyCert.Leaf.PublicKey) + require.NoError(t, err) + require.NoError(t, publicKeyJWK.Set(jwk.KeyIDKey, "1")) + keySet := jwk.NewSet() + require.NoError(t, keySet.AddKey(publicKeyJWK)) + + validator := deziIDTokenCredentialValidator{ + clock: func() time.Time { + return validAt + }, + httpClient: &http.Client{ + Transport: &stubbedRoundTripper{keySet: keySet}, + }, } - // Helper to extract JWK set from token - extractJWKSet := func(t *testing.T) jwk.Set { - tokenBytes, err := CreateTestDeziIDToken(iat, exp) - require.NoError(t, err) - - token, err := jwt.Parse(tokenBytes, jwt.WithVerify(false), jwt.WithValidate(false)) - require.NoError(t, err) - - jwksRaw, ok := token.Get("jwks") - require.True(t, ok) - - jwksJSON, err := json.Marshal(jwksRaw) - require.NoError(t, err) - - keySet, err := jwk.Parse(jwksJSON) + t.Run("ok", func(t *testing.T) { + tokenBytes, err := CreateTestDeziIDToken(iat, exp, signingKey) require.NoError(t, err) - return keySet - } - - t.Run("ok - valid signature and timestamps", func(t *testing.T) { - keySet := extractJWKSet(t) - tokenStr, server := createTokenWithMockServer(t, keySet) - defer server.Close() - - cred, err := CreateDeziIDTokenCredential(tokenStr) + cred, err := CreateDeziIDTokenCredential(string(tokenBytes)) require.NoError(t, err) - err = deziIDTokenCredentialValidator{ - clock: func() time.Time { - return validAt - }, - httpClient: server.Client(), // Use test server's client to trust its certificate - }.Validate(*cred) + err = validator.Validate(*cred) require.NoError(t, err) }) t.Run("error - wrong exp", func(t *testing.T) { - keySet := extractJWKSet(t) - tokenStr, server := createTokenWithMockServer(t, keySet) - defer server.Close() + tokenBytes, err := CreateTestDeziIDToken(iat, exp, signingKey) + require.NoError(t, err) - cred, err := CreateDeziIDTokenCredential(tokenStr) + cred, err := CreateDeziIDTokenCredential(string(tokenBytes)) require.NoError(t, err) // Modify credential expiration to be different from token wrongExp := exp.Add(time.Hour) cred.ExpirationDate = &wrongExp - err = deziIDTokenCredentialValidator{ - clock: func() time.Time { - return validAt - }, - httpClient: server.Client(), - }.Validate(*cred) + err = validator.Validate(*cred) assert.Error(t, err) assert.Contains(t, err.Error(), "'exp' does not match credential 'expirationDate'") }) t.Run("error - wrong nbf", func(t *testing.T) { - keySet := extractJWKSet(t) - tokenStr, server := createTokenWithMockServer(t, keySet) - defer server.Close() + tokenBytes, err := CreateTestDeziIDToken(iat, exp, signingKey) + require.NoError(t, err) - cred, err := CreateDeziIDTokenCredential(tokenStr) + cred, err := CreateDeziIDTokenCredential(string(tokenBytes)) require.NoError(t, err) // Modify credential issuance date to be different from token wrongNbf := iat.Add(-time.Hour) cred.IssuanceDate = wrongNbf - err = deziIDTokenCredentialValidator{ - clock: func() time.Time { - return validAt - }, - httpClient: server.Client(), - }.Validate(*cred) + err = validator.Validate(*cred) assert.Error(t, err) assert.Contains(t, err.Error(), "'nbf' does not match credential 'issuanceDate'") }) @@ -171,63 +131,48 @@ func TestDeziIDTokenCredentialValidator(t *testing.T) { // Create a different key set (wrong keys) wrongKeySet := jwk.NewSet() wrongKey, _ := jwk.FromRaw([]byte("wrong-secret-key-data")) - x5t := sha1.Sum([]byte("wrong-cert")) - kid := base64.StdEncoding.EncodeToString(x5t[:]) + kid := "wrong-kid" wrongKey.Set(jwk.KeyIDKey, kid) wrongKeySet.AddKey(wrongKey) - tokenStr, server := createTokenWithMockServer(t, wrongKeySet) - defer server.Close() - - cred, err := CreateDeziIDTokenCredential(tokenStr) - require.NoError(t, err) - - err = deziIDTokenCredentialValidator{ + validatorWithWrongKeys := deziIDTokenCredentialValidator{ clock: func() time.Time { return validAt }, - httpClient: server.Client(), - }.Validate(*cred) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to verify JWT signature") - }) + httpClient: &http.Client{ + Transport: &stubbedRoundTripper{keySet: wrongKeySet}, + }, + } - t.Run("error - jku endpoint unreachable", func(t *testing.T) { - // Create token bytes - tokenBytes, err := CreateTestDeziIDToken(iat, exp) + tokenBytes, err := CreateTestDeziIDToken(iat, exp, signingKey) require.NoError(t, err) - // Parse and update jku to non-existent endpoint - msg, err := jws.Parse(tokenBytes) + cred, err := CreateDeziIDTokenCredential(string(tokenBytes)) require.NoError(t, err) - sig := msg.Signatures()[0] - headers := jws.NewHeaders() - for iter := sig.ProtectedHeaders().Iterate(context.Background()); iter.Next(context.Background()); { - pair := iter.Pair() - headers.Set(pair.Key.(string), pair.Value) - } - headers.Set("jku", "https://localhost:9999/jwks.json") - - keyPair, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") - require.NoError(t, err) - privateKey, err := jwk.FromRaw(keyPair.PrivateKey) - require.NoError(t, err) + err = validatorWithWrongKeys.Validate(*cred) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to verify JWT signature") + }) - origToken, err := jwt.Parse(tokenBytes, jwt.WithVerify(false), jwt.WithValidate(false)) - require.NoError(t, err) + t.Run("error - jku endpoint unreachable", func(t *testing.T) { + // Use HTTP client that returns an error for any request + validatorWithBrokenClient := deziIDTokenCredentialValidator{ + clock: func() time.Time { + return validAt + }, + httpClient: &http.Client{ + Transport: &stubbedRoundTripper{keySet: nil}, // Will fail when trying to marshal nil + }, + } - signedToken, err := jwt.Sign(origToken, jwt.WithKey(jwa.RS256, privateKey, jws.WithProtectedHeaders(headers))) + tokenBytes, err := CreateTestDeziIDToken(iat, exp, signingKey) require.NoError(t, err) - cred, err := CreateDeziIDTokenCredential(string(signedToken)) + cred, err := CreateDeziIDTokenCredential(string(tokenBytes)) require.NoError(t, err) - err = deziIDTokenCredentialValidator{ - clock: func() time.Time { - return validAt - }, - }.Validate(*cred) + err = validatorWithBrokenClient.Validate(*cred) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to verify JWT signature") }) diff --git a/vcr/credential/test.go b/vcr/credential/test.go index d96618bf75..cf95e583cc 100644 --- a/vcr/credential/test.go +++ b/vcr/credential/test.go @@ -1,57 +1,15 @@ package credential import ( - "crypto/sha1" - "crypto/tls" - "encoding/base64" - "encoding/json" + "crypto" "time" "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jws" "github.com/lestrrat-go/jwx/v2/jwt" ) -func CreateTestDeziIDToken(issuedAt time.Time, validUntil time.Time) ([]byte, error) { - keyPair, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") - if err != nil { - return nil, err - } - key, err := jwk.FromRaw(keyPair.PrivateKey) - if err != nil { - return nil, err - } - - // Create public key for the JWK Set - // Extract the public key from the certificate - publicKey, err := jwk.FromRaw(keyPair.Leaf.PublicKey) - if err != nil { - return nil, err - } - - // Set the key ID - x5t := sha1.Sum(keyPair.Leaf.Raw) - kid := base64.StdEncoding.EncodeToString(x5t[:]) - if err := publicKey.Set(jwk.KeyIDKey, kid); err != nil { - return nil, err - } - if err := publicKey.Set(jwk.AlgorithmKey, jwa.RS256); err != nil { - return nil, err - } - - // Create JWK Set with the public key - keySet := jwk.NewSet() - if err := keySet.AddKey(publicKey); err != nil { - return nil, err - } - - // Marshal the JWK Set to JSON - jwksJSON, err := json.Marshal(keySet) - if err != nil { - return nil, err - } - +func CreateTestDeziIDToken(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(), @@ -64,7 +22,6 @@ func CreateTestDeziIDToken(issuedAt time.Time, validUntil time.Time) ([]byte, er "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", - "jwks": json.RawMessage(jwksJSON), "relations": []map[string]interface{}{ { "entity_name": "Zorgaanbieder", @@ -80,12 +37,15 @@ func CreateTestDeziIDToken(issuedAt time.Time, validUntil time.Time) ([]byte, er } } - // Create headers with kid headers := jws.NewHeaders() - if err := headers.Set(jwk.KeyIDKey, kid); err != nil { - return nil, err + 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 + } } - - // Sign with the key ID in the header return jwt.Sign(token, jwt.WithKey(jwa.RS256, key, jws.WithProtectedHeaders(headers))) } diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index c35310c926..e24cd25985 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -108,7 +108,7 @@ func TestParsePresentationDefinition(t *testing.T) { func TestDeziIDTokenCredential(t *testing.T) { iat := time.Unix(1701933627, 0) exp := time.Unix(1701933697, 0) - token, err := credential.CreateTestDeziIDToken(iat, exp) + token, err := credential.CreateTestDeziIDToken(iat, exp, nil) require.NoError(t, err) // Create DeziIDTokenCredential using the helper function diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index b986b3adf0..78844ac8b4 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -874,7 +874,7 @@ func newMockContext(t *testing.T) mockContext { func createDeziCredential(t *testing.T, holderDID string) *vc.VerifiableCredential { exp := time.Unix(1701933697, 0) iat := time.Unix(1701933627, 0) - idToken, err := credential.CreateTestDeziIDToken(iat, exp) + idToken, err := credential.CreateTestDeziIDToken(iat, exp, nil) require.NoError(t, err) credentialMap := map[string]any{