diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 339d4b0086..09ce9032ff 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/auth/services/oauth/relying_party.go b/auth/services/oauth/relying_party.go index e78160a86c..50fede79b6 100644 --- a/auth/services/oauth/relying_party.go +++ b/auth/services/oauth/relying_party.go @@ -22,11 +22,12 @@ import ( "context" "crypto/tls" "fmt" - "github.com/nuts-foundation/nuts-node/pki" "net/url" "strings" "time" + "github.com/nuts-foundation/nuts-node/pki" + "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/auth/api/auth/v1/client" diff --git a/core/tls.go b/core/tls.go index 3ef10babaf..e36fe6a0d5 100644 --- a/core/tls.go +++ b/core/tls.go @@ -71,6 +71,14 @@ type TrustStore struct { certificates []*x509.Certificate } +// PinCertificate adds the given certificate to the trust store as root certificate, without checking whether it forms a valid chain to some root. +// This is useful for trusting pinned certificates. +func (store *TrustStore) PinCertificate(certificate *x509.Certificate) { + store.certificates = append(store.certificates, certificate) + store.RootCAs = append(store.RootCAs, certificate) + store.CertPool.AddCert(certificate) +} + // Certificates returns a copy of the certificates within the CertPool func (store *TrustStore) Certificates() []*x509.Certificate { return store.certificates[:] diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index c032a1ff64..aead012f19 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -414,6 +414,12 @@ components: type: string description: The scope that will be the service for which this access token can be used. example: eOverdracht-sender + id_token: + type: string + description: | + An optional ID Token (JWT) that represents the end-user. + This ID token is included in the Verifiable Presentation that is used to request the access token. + It currently only supports Dezi ID tokens. credentials: type: array description: | diff --git a/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..c4f374168d --- /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:master}" + ports: + - "18081:8081" + environment: + NUTS_URL: "https://nodeA" + NUTS_VERBOSITY: trace + NUTS_STRICTMODE: false + NUTS_HTTP_INTERNAL_ADDRESS: ":8081" + NUTS_AUTH_CONTRACTVALIDATORS: dummy + NUTS_POLICY_DIRECTORY: /opt/nuts/policies + NUTS_VDR_DIDMETHODS: web + 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/Koppelvlakspecificatie_2024-DEZI-Online+koppelvlak+1_+platformleverancier.pdf b/vcr/credential/Koppelvlakspecificatie_2024-DEZI-Online+koppelvlak+1_+platformleverancier.pdf new file mode 100644 index 0000000000..aa7c8bb645 Binary files /dev/null and b/vcr/credential/Koppelvlakspecificatie_2024-DEZI-Online+koppelvlak+1_+platformleverancier.pdf differ diff --git a/vcr/credential/cert.pem b/vcr/credential/cert.pem new file mode 100644 index 0000000000..2d67b94830 --- /dev/null +++ b/vcr/credential/cert.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID1jCCAb4CCQD0FlvJsbcrvTANBgkqhkiG9w0BAQsFADAoMQswCQYDVQQDDAJV +UzEZMBcGA1UEAwwQaW5nZS02LXV6aXBvYy1jYTAeFw0yMzEyMDExMjMzMzBaFw0y +NTA0MTQxMjMzMzBaMDIxCzAJBgNVBAYTAlVTMSMwIQYDVQQDDBpubC11emlwb2Mt +cGhwLWxhcmF2ZWwtZGVtbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AKbCCGYa70/EeKgpsCtBtGRiHYRrodVActlrOMHEd26hjRvctLfNiyt0QyBFlzCv +0VDXR779eoSQ6mgOaDc71kb/sGWqn8LdQ74JtY5gI5qG7n3RX3EQZLEtb16jzYdN +K1Nf2oF+KMWkvyc/V9R5e267rN2iRIGBSJQ1ffcxDqTfrMVlchV2fgVT7YO47Snj +L1wC+FxqxSG757Nz8yeyPgr2Zk1oiaztxPcXWFUiNIFZoJS9iW7HM6rCm8Z7/mRc +4Bndl/pnFe25kfhOg9JIUMo1or9ml6CIszRoZ/hS8vB9Gn6WTKNBaH110zJz8ysd +6qs8ZJBaDbkJgI6L6Vm/wt8CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAizxBfaAa +DNQN9hXKFJePz7dTPLwHY/ZjC49dQrtyWzfgr1LKi4o1ixjkdg2FXU9t+xMuWgxX +FdbjOJLdcQYWEYb+W7KmkglIcP8bOkRDWfplgskpaTogRK095S1CuMM9v0bKC/ts +dHb3UfqW4U4Zko38/Ue9fRF8ra2p71QFs8+nt/BAwCkzrNLzaxMY8//TiFU+ZEYL +zPIQBjKaYB8yVh0Wh3qaieB2BzKUan+Eysh2bUc9TplQykIdk4z6T+FO5KTj5eVk +6zpHflWWCT61y15mu3xAEb83rOf+zFpoNGiDssiko0OeLK7Flqh7HuCP26NNnwsb +VGwkg60pDu+ASG2am3TPif3JpI7skzABFw4vbvPUpIk6Im3ycC98GyXowQujI0ZT +16dXfh1E38psRUeO5o+uxY6MUPXNSioYZ0mf3BARLahN41rqxKXz5ML1DSZnIOZK +F3peSggaZoRi1h0r6W14WEcYvxdHDkVR6M1qW0i7YeIBk6kaXEkwCmFz3hk5w9an +WJDjnMqSRgRsFVcIL/Ezi/Elubk21f4LHTEQmsjzzd1G+d09fjdI6JrhYMftGuYZ +4jOZZWpzoMH1TiZZ+JkBdyRwEdbqzW+v+/0BZQy6HRaZlombcOmS9MSjFRDTyUGW +D9F1eUIqKct0yyJPPXH3lDkzqqtX4DLcopo= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/vcr/credential/dezi.go b/vcr/credential/dezi.go new file mode 100644 index 0000000000..e1126ffbb0 --- /dev/null +++ b/vcr/credential/dezi.go @@ -0,0 +1,438 @@ +package credential + +import ( + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "slices" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/core" +) + +type DeziIDTokenSubject struct { + Identifier string `json:"identifier"` + Name string `json:"name,omitempty"` + Employee HealthcareWorker `json:"employee"` +} + +func (d DeziIDTokenSubject) MarshalJSON() ([]byte, error) { + type Alias DeziIDTokenSubject + aux := struct { + Alias + Type string `json:"@type"` + }{ + Alias: Alias(d), + Type: "DeziIDTokenSubject", + } + return json.Marshal(aux) +} + +type HealthcareWorker struct { + Identifier string `json:"identifier"` + Initials string `json:"initials"` + SurnamePrefix string `json:"surnamePrefix"` + Surname string `json:"surname"` + Roles []string `json:"roles"` +} + +func (d HealthcareWorker) MarshalJSON() ([]byte, error) { + type Alias HealthcareWorker + aux := struct { + Alias + Type string `json:"@type"` + }{ + Alias: Alias(d), + Type: "HealthcareWorker", + } + return json.Marshal(aux) +} + +// CreateDeziIDTokenCredential creates a Verifiable Credential from a Dezi id_token JWT. It supports the following spec versions: +// - april 2024 +// - 15 jan 2026/v0.7: https://www.dezi.nl/documenten/2024/04/24/koppelvlakspecificatie-dezi-voor-platform--en-softwareleveranciers +func CreateDeziIDTokenCredential(idTokenSerialized string) (*vc.VerifiableCredential, error) { + // Parse without signature or time validation - those are validated elsewhere + idToken, err := jwt.Parse([]byte(idTokenSerialized), jwt.WithVerify(false), jwt.WithValidate(false)) + if err != nil { + return nil, fmt.Errorf("parsing id_token: %w", err) + } + + subject, version, err := extractDeziIDTokenSubject(idToken) + if err != nil { + return nil, err + } + + credentialMap := map[string]any{ + "@context": []any{ + "https://www.w3.org/2018/credentials/v1", + // 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": subject, + "proof": deziProofType{ + Type: "DeziIDJWT", + JWT: idTokenSerialized, + Version: version, + }, + } + data, _ := json.Marshal(credentialMap) + return vc.ParseVerifiableCredential(string(data)) +} + +var _ Validator = DeziIDTokenCredentialValidator{} + +type DeziIDTokenCredentialValidator struct { + trustStore *core.TrustStore + // AllowedJKU is a list of allowed jku URLs for fetching JWK Sets (for v0.7 tokens), used to verify Dezi attestations. + AllowedJKU []string +} + +func (d DeziIDTokenCredentialValidator) Validate(credential vc.VerifiableCredential) error { + proofType, err := parseDeziProofType(credential) + if err != nil { + return err + } + switch proofType.Version { + case "2024": + return deziIDToken2024CredentialValidator{ + clock: time.Now, + trustStore: d.trustStore, + }.Validate(credential) + case "0.7": + return deziIDToken07CredentialValidator{ + clock: time.Now, + allowedJKU: d.AllowedJKU, + }.Validate(credential) + default: + return fmt.Errorf("%w: unsupported Dezi id_token version: %s", errValidation, proofType.Version) + } +} + +var _ Validator = deziIDToken2024CredentialValidator{} + +// deziIDToken2024CredentialValidator validates DeziIDTokenCredential, +// according to spec of april 2024 (uses x5c in JWT payload instead of jku header) +type deziIDToken2024CredentialValidator struct { + clock func() time.Time + trustStore *core.TrustStore +} + +func (d deziIDToken2024CredentialValidator) Validate(credential vc.VerifiableCredential) error { + proof, err := parseDeziProofType(credential) + if err != nil { + return fmt.Errorf("%w: %w", errValidation, err) + } + + idToken, err := d.validateIDToken(credential, proof.JWT) + if err != nil { + return fmt.Errorf("%w: invalid Dezi id_token: %w", errValidation, err) + } + + // Validate that token timestamps match credential dates + if !idToken.NotBefore().Equal(credential.IssuanceDate) { + return errors.New("id_token 'nbf' does not match credential 'issuanceDate'") + } + if !idToken.Expiration().Equal(*credential.ExpirationDate) { + return errors.New("id_token 'exp' does not match credential 'expirationDate'") + } + + // Validate that the + + return (defaultCredentialValidator{}).Validate(credential) +} + +func (d deziIDToken2024CredentialValidator) validateIDToken(credential vc.VerifiableCredential, serialized string) (jwt.Token, error) { + // Parse without verification first to extract x5c from payload + token, err := jwt.Parse([]byte(serialized), jwt.WithVerify(false), jwt.WithValidate(false)) + if err != nil { + return nil, fmt.Errorf("parse JWT: %w", err) + } + + // After signature has been validated, token can be considered a valid JWT + err = d.validateSignature(token, err, serialized) + if err != nil { + return nil, fmt.Errorf("signature: %w", err) + } + return token, nil +} + +func (d deziIDToken2024CredentialValidator) validateSignature(token jwt.Token, err error, serialized string) error { + // Extract x5c claim from payload (not header - this is non-standard but per 2024 spec) + x5cRaw, ok := token.Get("x5c") + if !ok { + return errors.New("missing 'x5c' claim in JWT payload") + } + + var x5c []any + switch v := x5cRaw.(type) { + case []any: + x5c = v + case string: + x5c = []any{v} + default: + return errors.New("'x5c' claim must be either a string or an array of strings") + } + + // Parse the certificate chain + var certChain [][]byte + for i, certData := range x5c { + certStr, ok := certData.(string) + if !ok { + return fmt.Errorf("'x5c[%d]' must be a string", i) + } + // x5c contains base64-encoded DER certificates + certBytes, err := base64.StdEncoding.DecodeString(certStr) + if err != nil { + return fmt.Errorf("decode 'x5c[%d]': %w", i, err) + } + certChain = append(certChain, certBytes) + } + + if len(certChain) == 0 { + return errors.New("'x5c' certificate chain is empty") + } + + // Parse the leaf certificate (first in chain) + leafCert, err := x509.ParseCertificate(certChain[0]) + if err != nil { + return fmt.Errorf("parse signing certificate: %w", err) + } + + _, err = leafCert.Verify(x509.VerifyOptions{ + Roots: core.NewCertPool(d.trustStore.RootCAs), + CurrentTime: d.clock(), + Intermediates: core.NewCertPool(d.trustStore.IntermediateCAs), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, // TODO: use more specific key usage if possible + }) + if err != nil { + return fmt.Errorf("verify Dezi certificate chain: %w", err) + } + + // Verify the JWT signature using the leaf certificate's public key + _, err = jwt.Parse([]byte(serialized), jwt.WithKey(jwa.RS256, leafCert.PublicKey), jwt.WithValidate(true), jwt.WithClock(jwt.ClockFunc(d.clock))) + if err != nil { + return err + } + return nil +} + +// deziIDToken07CredentialValidator validates DeziIDTokenCredential, +// according to v0.7 spec of 15-01-2026 (https://www.dezi.nl/documenten/2025/12/15/koppelvlakspecificatie-dezi-voor-platform--en-softwareleveranciers) +type deziIDToken07CredentialValidator struct { + clock func() time.Time + httpClient *http.Client // Optional HTTP client for fetching JWK Set (for testing) + allowedJKU []string // List of allowed jku URLs +} + +func (d deziIDToken07CredentialValidator) Validate(credential vc.VerifiableCredential) error { + proof, err := parseDeziProofType(credential) + if err != nil { + return fmt.Errorf("%w: %w", errValidation, err) + } + if err := d.validateDeziToken(credential, proof.JWT); err != nil { + return fmt.Errorf("%w: invalid Dezi id_token: %w", errValidation, err) + } + return (defaultCredentialValidator{}).Validate(credential) +} + +func (d deziIDToken07CredentialValidator) validateDeziToken(credential vc.VerifiableCredential, serialized string) error { + // Parse and verify the JWT + // - WithVerifyAuto(nil, ...) uses default jwk.Fetch and automatically fetches the JWK Set from the jku header URL + // - WithFetchWhitelist allows fetching from any https:// URL (Dezi endpoints) + // - WithHTTPClient allows using a custom HTTP client (for testing with self-signed certs) + fetchOptions := []jwk.FetchOption{jwk.WithFetchWhitelist(jwk.WhitelistFunc(func(requestedURL string) bool { + return slices.Contains(d.allowedJKU, requestedURL) + }))} + if d.httpClient != nil { + fetchOptions = append(fetchOptions, jwk.WithHTTPClient(d.httpClient)) + } + + // TODO: Only allow specific domains for the jku + // TODO: make sure it's signed with a jku + token, err := jwt.Parse( + []byte(serialized), + jwt.WithVerifyAuto(nil, fetchOptions...), + jwt.WithClock(jwt.ClockFunc(d.clock)), + ) + if err != nil { + return fmt.Errorf("failed to verify JWT signature: %w", err) + } + + // Validate that token timestamps match credential dates + if !token.NotBefore().Equal(credential.IssuanceDate) { + return errors.New("'nbf' does not match credential 'issuanceDate'") + } + if !token.Expiration().Equal(*credential.ExpirationDate) { + return errors.New("'exp' does not match credential 'expirationDate'") + } + + var credentialSubject []DeziIDTokenSubject + if err = credential.UnmarshalCredentialSubject(&credentialSubject); err != nil { + return fmt.Errorf("invalid credential subject format: %w", err) + } + if len(credentialSubject) != 1 { + return fmt.Errorf("expected exactly one credential subject, got %d", len(credentialSubject)) + } + + subjectFromToken, _, err := extractDeziIDTokenSubject(token) + if err != nil { + return fmt.Errorf("invalid id_token claims: %w", err) + } + if !reflect.DeepEqual(credentialSubject[0], subjectFromToken) { + return errors.New("credential subject does not match id_token claims") + } + + // TODO: check id_token revocation + return nil +} + +type deziProofType struct { + Type string `json:"type"` + JWT string `json:"jwt"` + Version string `json:"version"` +} + +func parseDeziProofType(credential vc.VerifiableCredential) (*deziProofType, error) { + var proofs []deziProofType + if err := credential.UnmarshalProofValue(&proofs); err != nil { + return nil, fmt.Errorf("invalid proof format: %w", err) + } + if len(proofs) != 1 { + return nil, fmt.Errorf("expected exactly one proof, got %d", len(proofs)) + } + proof := &proofs[0] + if proof.Type != "DeziIDJWT" { + return nil, fmt.Errorf("invalid proof type: expected 'DeziIDJWT', got '%s'", proof.Type) + } + return proof, nil +} + +// extractDeziIDTokenSubject extracts and validates the subject information from a Dezi id_token JWT. +// It returns the DeziIDTokenSubject, the detected version ("2024" or "0.7"), and any error encountered. +func extractDeziIDTokenSubject(idToken jwt.Token) (DeziIDTokenSubject, string, error) { + getString := func(claim string) string { + value, ok := idToken.Get(claim) + if !ok { + return "" + } + result, _ := value.(string) + return result + } + + // Check if this is v0.7 format (has abonnee_nummer) or 2024 format (has relations) + var version string + { + _, hasRelations := idToken.Get("relations") + if hasRelations { + version = "2024" + } else { + version = "0.7" + } + } + + var orgURA, orgName, userID, initials, surname, surnamePrefix string + var roles []string + + switch version { + case "0.7": + orgURA = getString("abonnee_nummer") + if orgURA == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'abonnee_nummer' claim") + } + orgName = getString("abonnee_naam") + if orgName == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'abonnee_naam' claim") + } + + userID = getString("dezi_nummer") + if userID == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'dezi_nummer' claim") + } + initials = getString("voorletters") + if initials == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'voorletters' claim") + } + surname = getString("achternaam") + if surname == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'achternaam' claim") + } + surnamePrefix = getString("voorvoegsel") // Can be null/empty in v0.7 + + // In v0.7, rol_code is a single string, not an array + rolCode := getString("rol_code") + if rolCode != "" { + roles = []string{rolCode} + } + case "2024": + relationsRaw, _ := idToken.Get("relations") + relations, ok := relationsRaw.([]any) + if !ok || len(relations) != 1 { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token 'relations' claim invalid or missing (expected array of objects with single item)") + } + relation, ok := relations[0].(map[string]any) + if !ok { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token 'relations' claim invalid or missing (expected array of objects with single item)") + } + rolesAny, ok := relation["roles"].([]any) + if !ok { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token 'relations[0].roles' claim invalid or missing (expected array of strings)") + } + for i, roleAny := range rolesAny { + role, ok := roleAny.(string) + if !ok { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token 'relations[0].roles[%d]' claim invalid (expected string)", i) + } + roles = append(roles, role) + } + + orgURA, ok = relation["ura"].(string) + if !ok || orgURA == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token 'relations[0].ura' claim invalid or missing (expected non-empty string)") + } + orgName, _ = relation["entity_name"].(string) + + userID = getString("dezi_nummer") + if userID == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'dezi_nummer' claim") + } + initials = getString("initials") + if initials == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'initials' claim") + } + surname = getString("surname") + if surname == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'surname' claim") + } + surnamePrefix = getString("surname_prefix") + if surnamePrefix == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'surname_prefix' claim") + } + default: + return DeziIDTokenSubject{}, "", fmt.Errorf("unsupported Dezi id_token version: %s", version) + } + + return DeziIDTokenSubject{ + Identifier: orgURA, + Name: orgName, + Employee: HealthcareWorker{ + Identifier: userID, + Initials: initials, + SurnamePrefix: surnamePrefix, + Surname: surname, + Roles: roles, + }, + }, version, nil +} diff --git a/vcr/credential/dezi_test.go b/vcr/credential/dezi_test.go new file mode 100644 index 0000000000..92e887953d --- /dev/null +++ b/vcr/credential/dezi_test.go @@ -0,0 +1,405 @@ +package credential + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/core/to" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubbedRoundTripper is a test helper that returns a mock JWK Set for any HTTP request +type stubbedRoundTripper struct { + keySets map[string]jwk.Set +} + +func (s *stubbedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + keySet, ok := s.keySets[req.URL.String()] + if !ok { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewReader([]byte(`{"error": "not found"}`))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + } + + // Marshal the key set to JSON + jwksJSON, err := json.Marshal(keySet) + if err != nil { + return nil, err + } + + // Return a mock HTTP response with the JWK Set + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(jwksJSON)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil +} + +func TestCreateDeziIDToken(t *testing.T) { + t.Run("version 0.7", func(t *testing.T) { + const input = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjMyNWRlOWFiLTQzMzAtNGMwMS04MjRlLWQ5YmQwYzM3Y2NhMCIsImprdSI6Imh0dHBzOi8vW2V4dGVybiBlbmRwb2ludF0vandrcy5qc29uIiwidHlwIjoiSldUIn0.eyJqdGkiOiI2MWIxZmFmYy00ZWM3LTQ0ODktYTI4MC04ZDBhNTBhM2Q1YTkiLCJpc3MiOiJhYm9ubmVlLmRlemkubmwiLCJleHAiOjE3NDAxMzExNzYsIm5iZiI6MTczMjE4MjM3NiwianNvbl9zY2hlbWEiOiJodHRwczovL3d3dy5kZXppLm5sL2pzb25fc2NoZW1hcy92ZXJrbGFyaW5nX3YxLmpzb24iLCJsb2FfZGV6aSI6Imh0dHA6Ly9laWRhcy5ldXJvcGUuZXUvTG9BL2hpZ2giLCJ2ZXJrbGFyaW5nX2lkIjoiODUzOWY3NWQtNjM0Yy00N2RiLWJiNDEtMjg3OTFkZmQxZjhkIiwiZGV6aV9udW1tZXIiOiIxMjM0NTY3ODkiLCJ2b29ybGV0dGVycyI6IkEuQi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IlpvcmdtZWRld2Vya2VyIiwiYWJvbm5lZV9udW1tZXIiOiI4NzY1NDMyMSIsImFib25uZWVfbmFhbSI6IlpvcmdhYW5iaWVkZXIiLCJyb2xfY29kZSI6IjAxLjAwMCIsInJvbF9uYWFtIjoiQXJ0cyIsInJvbF9jb2RlX2Jyb24iOiJodHRwOi8vd3d3LmRlemkubmwvcm9sX2NvZGVfYnJvbi9iaWciLCJyZXZvY2F0aWVfY29udHJvbGVfdXJpIjoiaHR0cHM6Ly9hdXRoLmRlemkubmwvcmV2b2NhdGllLXN0YXR1cy92MS92ZXJrbGFyaW5nLzg1MzlmNzVkLTYzNGMtNDdkYi1iYjQxLTI4NzkxZGZkMWY4ZCJ9.vegszRMWJjE-SBpfPO9lxN_fEY814ezsXRYhLXorPq3j_B_wlv4A92saasdEWrTALbl9Shux0i6JvkbouqvZ_oJpOUfJxWFGFfGGCuiMhiz4k1zm665i98e2xTqFzqjQySu_gup3wYm24FmnzbHxy02RzM3pXvQCsk_jIfQ1YcUZmNmXa5hR4DEn4Z9STLHd2HwyL6IKafEGl-R_kgbAnArSHQvuLw0Fpx62QD0tr5d3PbzPirBdkuy4G1l0umb69EjZMZ5MyIl8Y_irhQ9IFomAeSlU_zZp6UojVIOnCY2gL5EMc_8B1PDC6R_C--quGoh14jiSOJAeYSf_9ETjgQ" + + actual, err := 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, "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"]) + + t.Run("from online test environment", func(t *testing.T) { + // Payload: + // { + // "json_schema": "https://www.dezi.nl/json_schemas/v1/verklaring.json", + // "loa_dezi": "http://eidas.europa.eu/LoA/high", + // "jti": "f410b255-6b07-4182-ac5c-c41f02bd3995", + // "verklaring_id": "0e970fcb-530c-482e-ba28-47b461d4dcb5", + // "dezi_nummer": "900022159", + // "voorletters": "J.", + // "voorvoegsel": null, + // "achternaam": "90017362", + // "abonnee_nummer": "90000380", + // "abonnee_naam": "Tést Zorginstelling 01", + // "rol_code": "92.000", + // "rol_naam": "Mondhygiënist", + // "rol_code_bron": "http://www.dezi.nl/rol_bron/big", + // "status_uri": "https://acceptatie.auth.dezi.nl/status/v1/verklaring/0e970fcb-530c-482e-ba28-47b461d4dcb5", + // "nbf": 1772665200, + // "exp": 1780610400, + // "iss": "https://abonnee.dezi.nl" + //} + const input = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFlNDY4MjlkLWM4ZTgtNDhhMC1iZDZhLTIxYjhhMDdiOGNiMiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYWNjZXB0YXRpZS5hdXRoLmRlemkubmwvZGV6aS9qd2tzLmpzb24ifQ.eyJqc29uX3NjaGVtYSI6Imh0dHBzOi8vd3d3LmRlemkubmwvanNvbl9zY2hlbWFzL3YxL3ZlcmtsYXJpbmcuanNvbiIsImxvYV9kZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImp0aSI6ImY0MTBiMjU1LTZiMDctNDE4Mi1hYzVjLWM0MWYwMmJkMzk5NSIsInZlcmtsYXJpbmdfaWQiOiIwZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJkZXppX251bW1lciI6IjkwMDAyMjE1OSIsInZvb3JsZXR0ZXJzIjoiSi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IjkwMDE3MzYyIiwiYWJvbm5lZV9udW1tZXIiOiI5MDAwMDM4MCIsImFib25uZWVfbmFhbSI6IlTDqXN0IFpvcmdpbnN0ZWxsaW5nIDAxIiwicm9sX2NvZGUiOiI5Mi4wMDAiLCJyb2xfbmFhbSI6Ik1vbmRoeWdpw6tuaXN0Iiwicm9sX2NvZGVfYnJvbiI6Imh0dHA6Ly93d3cuZGV6aS5ubC9yb2xfYnJvbi9iaWciLCJzdGF0dXNfdXJpIjoiaHR0cHM6Ly9hY2NlcHRhdGllLmF1dGguZGV6aS5ubC9zdGF0dXMvdjEvdmVya2xhcmluZy8wZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJuYmYiOjE3NzI2NjUyMDAsImV4cCI6MTc4MDYxMDQwMCwiaXNzIjoiaHR0cHM6Ly9hYm9ubmVlLmRlemkubmwifQ.ipR4stqmO8MOmmapukeQxIOVpwO_Ipjgy5BHjUsdCvuFObhVrj48AQCndtV48D_Ol1hXO4s9p4b-1epjEiobjEmEO0JQNU0BAOGG0eWl8MujfhzlDnmwo5AEtvdgTjlnBaLReVu1BJ8KYgc1DT7JhCukq9z5wZLqU1aqtETleX2-s-dNdTdwrUjJa1DvIgO-DQ_rCp-1tcfkr2rtyW16ztyI88Q2YdBkNGcG0if5aYZHpcQ4-121WBObUa0FhswS7EHni5Ru8KwZNq0HC8OLWw3YqLrYHTFe2K0GQjMtEO6zNxApbMXWKlgeWdf7Ry2rPpe2l9Z5NuMrFiB8JChZsQ" + + actual, err := 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, "90000380", subject["identifier"]) + assert.Equal(t, "Tést Zorginstelling 01", subject["name"]) + assert.Equal(t, "900022159", employee["identifier"]) + assert.Equal(t, "J.", employee["initials"]) + assert.Equal(t, "90017362", employee["surname"]) + assert.Equal(t, "", employee["surnamePrefix"]) // voorvoegsel is null in this token + assert.Equal(t, []any{"92.000"}, employee["roles"]) + }) + }) +} + +func TestDeziIDToken07CredentialValidator(t *testing.T) { + iat := time.Unix(1732182376, 0) + exp := time.Unix(1740131176, 0) + validAt := time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC) + + signingKeyCert, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") + require.NoError(t, err) + + publicKeyJWK, err := jwk.FromRaw(signingKeyCert.Leaf.PublicKey) + require.NoError(t, err) + require.NoError(t, publicKeyJWK.Set(jwk.KeyIDKey, "1")) + correctKeySet := jwk.NewSet() + require.NoError(t, correctKeySet.AddKey(publicKeyJWK)) + // KeySet taken from https://acceptatie.auth.dezi.nl/dezi/jwks.json, copied to make the test deterministic + accKeySet := jwk.NewSet() + err = json.Unmarshal([]byte(`{ + "keys" : [ + { + "kty": "RSA", + "kid": "ae46829d-c8e8-48a0-bd6a-21b8a07b8cb2", + "x5c": [ + "MIIHkDCCBXigAwIBAgIUES0kUHe2pwozJovpJk70I3HdiPAwDQYJKoZIhvcNAQELBQAweDELMAkGA1UEBhMCTkwxETAPBgNVBAoMCEtQTiBCLlYuMRcwFQYDVQRhDA5OVFJOTC0yNzEyNDcwMTE9MDsGA1UEAww0VEVTVCBLUE4gQlYgUEtJb3ZlcmhlaWQgT3JnYW5pc2F0aWUgU2VydmljZXMgQ0EgLSBHMzAeFw0yNTA5MjQxMzIxMjZaFw0yODA5MjMxMzIxMjZaMFIxFzAVBgNVBGEMDk5UUk5MLTUwMDAwNTM1MQswCQYDVQQGEwJOTDENMAsGA1UECgwEQ0lCRzEbMBkGA1UEAwwSVEVTVCBEZXppLXJlZ2lzdGVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4ObWRH19nPSyFsuKIQ/HG3FrlAqoBiij4mAYsAl7EWduHCGj92jkkGE4z6CNPgcdVK3J2WllhyKj7kDf1aZoCvfkVrQHpS/GnEBHME+5Vo3a8Z+1AfVxxSbVLlXFu793tx83U/mB8PVxHhzf6pW449fjZrSNc0cnluXoYRFgNGxD0hlL5JahMuOoWGpKJ5XVZp6bZjbIuHc2rC589THQl1N1V11QcpoCnQsFkX92JTtgtDl+jehrqr/P2+EXRhAZl59MAk6BAZXBJWDFY/gbjYW3j4q+ITBG5iGc8tYK3JxOCdK4K3Ql3QoNEptU32ET1zrRux5D5MRiC09MKoJ4bQIDAQABo4IDNjCCAzIwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBQWWJucdaG2S0/GyJM+ywKQ5vzHbjCBpAYIKwYBBQUHAQEEgZcwgZQwYwYIKwYBBQUHMAKGV2h0dHA6Ly9jZXJ0LXRlc3QubWFuYWdlZHBraS5jb20vQ0FjZXJ0cy9URVNUS1BOQlZQS0lvdmVyaGVpZE9yZ2FuaXNhdGllU2VydmljZXNDQUczLmNlcjAtBggrBgEFBQcwAYYhaHR0cDovL2czb2NzcC10ZXN0Lm1hbmFnZWRwa2kuY29tMFUGA1UdEQROMEygSgYKKwYBBAGCNxQCA6A8DDoxMzlmYzYxOGM2YzU2MTkyOTEwMjQ5NWQ5ZTMyYTBkZkAyLjE2LjUyOC4xLjEwMDMuMS4zLjUuOS4xMIG2BgNVHSAEga4wgaswgZ0GCmCEEAGHawECBQcwgY4wNgYIKwYBBQUHAgEWKmh0dHA6Ly9jZXJ0aWZpY2FhdC5rcG4uY29tL3BraW92ZXJoZWlkL2NwczBUBggrBgEFBQcCAjBIDEZPcCBkaXQgY2VydGlmaWNhYXQgaXMgaGV0IENQUyBQS0lvdmVyaGVpZCB2YW4gS1BOIE5JRVQgdmFuIHRvZXBhc3NpbmcuMAkGBwQAi+xAAQMwHwYDVR0lBBgwFgYIKwYBBQUHAwQGCisGAQQBgjcKAwwwgY4GCCsGAQUFBwEDBIGBMH8wFQYIKwYBBQUHCwIwCQYHBACL7EkBAjAIBgYEAI5GAQEwCAYGBACORgEEMBMGBgQAjkYBBjAJBgcEAI5GAQYCMD0GBgQAjkYBBTAzMDEWK2h0dHBzOi8vY2VydGlmaWNhYXQua3BuLmNvbS9wa2lvdmVyaGVpZC9wZHMTAmVuMGkGA1UdHwRiMGAwXqBcoFqGWGh0dHA6Ly9jcmwtdGVzdC5tYW5hZ2VkcGtpLmNvbS9URVNUS1BOQlZQS0lvdmVyaGVpZE9yZ2FuaXNhdGllU2VydmljZXNDQUczL0xhdGVzdENSTC5jcmwwHQYDVR0OBBYEFJ2to1DMI8+gNKLrBcxV0ozA14GtMA4GA1UdDwEB/wQEAwIGQDANBgkqhkiG9w0BAQsFAAOCAgEAAfFUej0y+D6MSUlXT+Q2NjQDUpz3SP3xKwHj6M3ht+z5EVZD/0ayfR3d5qMIlc+ILxHzlSUy8D1xF3UkeQNjRVFlTNP+Bi/zAxwPI/KueoJkfajfPqEQBzNzsaeKXhgraFHKTQ1GWMsL8vHhTR93IwGc2bu0PZeVYO+x2InJoBSonMOjg+rBo4b1HKSvOCTe+S2W+S2BBk1qaQzhXP2xmcpiQ4BguvAnE8c5voW3gEUhzUsOYVN7M+z7y+k+fTydK1cjwD8j516RiEDKrZuv6C0Id7n1UZqjppPwzPQ6UC+Rkfsejo/ZRoz43HmbK3uxVCgGsFpeaKylW+N0TbyBkBTDD8le0AiL3YqLQfo8OS0mObfTpnR9LDSGk5KimtF5pVXYRH7UGW0pUPHSAzRX+Qou9O2jDYrnPyQ7Kum03VvfDGjPl5+4kYPbt+cAPRr9dFD/enZYHVj/VkUh+LCPe6VsEGcFr8204buh6O+CEX2LNYxWWy7u5pYlWl7VivGOeGZi4Y2kAlxxEQUVG88nsDgp2K2NFtE0G+zZgG7ejgvnz4p3Hx9xdw2ARYv2/5ycJeHNPI+CK0P2H9ZdL2uUHBGSAkFZ6D0Q/7lxJ6VvKKUQnau4rxy+no+n008l8MLz8NKCDo1x3TJSkcRxFVWSOdUVzayWp0DfVisvS1X9gxc=" + ], + "x5t": "mlPsZptNN2Bo8A8A6keBROJ6Q_U", + "x5t#S256": "UHZTsA9YMQnGRd24MZLxZabWczwuZn1PE9iV7j-oDm8", + "n": "4ObWRH19nPSyFsuKIQ_HG3FrlAqoBiij4mAYsAl7EWduHCGj92jkkGE4z6CNPgcdVK3J2WllhyKj7kDf1aZoCvfkVrQHpS_GnEBHME-5Vo3a8Z-1AfVxxSbVLlXFu793tx83U_mB8PVxHhzf6pW449fjZrSNc0cnluXoYRFgNGxD0hlL5JahMuOoWGpKJ5XVZp6bZjbIuHc2rC589THQl1N1V11QcpoCnQsFkX92JTtgtDl-jehrqr_P2-EXRhAZl59MAk6BAZXBJWDFY_gbjYW3j4q-ITBG5iGc8tYK3JxOCdK4K3Ql3QoNEptU32ET1zrRux5D5MRiC09MKoJ4bQ", + "e": "AQAB" + } +]}`), &accKeySet) + require.NoError(t, err) + + wrongKeySet := jwk.NewSet() + wrongKey, _ := jwk.FromRaw([]byte("wrong-secret-key-data")) + wrongKey.Set(jwk.KeyIDKey, "wrong-kid") + wrongKeySet.AddKey(wrongKey) + + tests := []struct { + name string + deziAttestation string + keySet jwk.Set + clock *time.Time + modifyCred func(*vc.VerifiableCredential) + allowedJKU []string + wantErr string + }{ + { + name: "ok", + keySet: correctKeySet, + }, + { + name: "from test environment", + deziAttestation: "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFlNDY4MjlkLWM4ZTgtNDhhMC1iZDZhLTIxYjhhMDdiOGNiMiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYWNjZXB0YXRpZS5hdXRoLmRlemkubmwvZGV6aS9qd2tzLmpzb24ifQ.eyJqc29uX3NjaGVtYSI6Imh0dHBzOi8vd3d3LmRlemkubmwvanNvbl9zY2hlbWFzL3YxL3ZlcmtsYXJpbmcuanNvbiIsImxvYV9kZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImp0aSI6ImY0MTBiMjU1LTZiMDctNDE4Mi1hYzVjLWM0MWYwMmJkMzk5NSIsInZlcmtsYXJpbmdfaWQiOiIwZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJkZXppX251bW1lciI6IjkwMDAyMjE1OSIsInZvb3JsZXR0ZXJzIjoiSi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IjkwMDE3MzYyIiwiYWJvbm5lZV9udW1tZXIiOiI5MDAwMDM4MCIsImFib25uZWVfbmFhbSI6IlTDqXN0IFpvcmdpbnN0ZWxsaW5nIDAxIiwicm9sX2NvZGUiOiI5Mi4wMDAiLCJyb2xfbmFhbSI6Ik1vbmRoeWdpw6tuaXN0Iiwicm9sX2NvZGVfYnJvbiI6Imh0dHA6Ly93d3cuZGV6aS5ubC9yb2xfYnJvbi9iaWciLCJzdGF0dXNfdXJpIjoiaHR0cHM6Ly9hY2NlcHRhdGllLmF1dGguZGV6aS5ubC9zdGF0dXMvdjEvdmVya2xhcmluZy8wZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJuYmYiOjE3NzI2NjUyMDAsImV4cCI6MTc4MDYxMDQwMCwiaXNzIjoiaHR0cHM6Ly9hYm9ubmVlLmRlemkubmwifQ.ipR4stqmO8MOmmapukeQxIOVpwO_Ipjgy5BHjUsdCvuFObhVrj48AQCndtV48D_Ol1hXO4s9p4b-1epjEiobjEmEO0JQNU0BAOGG0eWl8MujfhzlDnmwo5AEtvdgTjlnBaLReVu1BJ8KYgc1DT7JhCukq9z5wZLqU1aqtETleX2-s-dNdTdwrUjJa1DvIgO-DQ_rCp-1tcfkr2rtyW16ztyI88Q2YdBkNGcG0if5aYZHpcQ4-121WBObUa0FhswS7EHni5Ru8KwZNq0HC8OLWw3YqLrYHTFe2K0GQjMtEO6zNxApbMXWKlgeWdf7Ry2rPpe2l9Z5NuMrFiB8JChZsQ", + clock: to.Ptr(time.Date(2026, 3, 11, 8, 0, 0, 0, time.UTC)), + }, + { + name: "wrong exp", + keySet: correctKeySet, + modifyCred: func(c *vc.VerifiableCredential) { + wrongExp := exp.Add(time.Hour) + c.ExpirationDate = &wrongExp + }, + wantErr: "'exp' does not match credential 'expirationDate'", + }, + { + name: "wrong nbf", + keySet: correctKeySet, + modifyCred: func(c *vc.VerifiableCredential) { + c.IssuanceDate = iat.Add(-time.Hour) + }, + wantErr: "'nbf' does not match credential 'issuanceDate'", + }, + { + name: "invalid signature", + keySet: wrongKeySet, + wantErr: "failed to verify JWT signature", + }, + { + name: "JWK set endpoint unreachable", + keySet: nil, + wantErr: "failed to verify JWT signature", + }, + { + name: "token claims differ from credential subject", + keySet: correctKeySet, + modifyCred: func(c *vc.VerifiableCredential) { + c.CredentialSubject[0]["identifier"] = "different-identifier" + }, + wantErr: "credential subject does not match id_token claims", + }, + { + name: "JKU not allowed", + allowedJKU: []string{"https://example.com/other"}, + wantErr: "rejected by whitelist", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deziAttestation := tt.deziAttestation + if tt.deziAttestation == "" { + tokenBytes, err := CreateTestDezi07IDToken(iat, exp, signingKeyCert.PrivateKey) + require.NoError(t, err) + deziAttestation = string(tokenBytes) + } + + cred, err := CreateDeziIDTokenCredential(deziAttestation) + require.NoError(t, err) + + if tt.modifyCred != nil { + tt.modifyCred(cred) + } + + validator := deziIDToken07CredentialValidator{ + clock: func() time.Time { return validAt }, + httpClient: &http.Client{Transport: &stubbedRoundTripper{keySets: map[string]jwk.Set{ + "https://acceptatie.auth.dezi.nl/dezi/jwks.json": accKeySet, + "https://example.com/jwks.json": tt.keySet, + }}}, + allowedJKU: []string{ + "https://acceptatie.auth.dezi.nl/dezi/jwks.json", + "https://example.com/jwks.json", + }, + } + if tt.clock != nil { + validator.clock = func() time.Time { return *tt.clock } + } + if tt.allowedJKU != nil { + validator.allowedJKU = tt.allowedJKU + } + + err = validator.Validate(*cred) + if tt.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestDeziIDToken2024CredentialValidator(t *testing.T) { + t.Skip("TODO: implement or remove") + const exampleToken = `eyJhbGciOiJSUzI1NiIsImtpZCI6IjFNY2p3cjgxMGpOVUZHVHR6T21MeTRTNnN5cVJ1aVZ1YVM0UmZyWmZwOEk9IiwieDV0Ijoibk4xTVdBeFRZTUgxOE45cFBWMlVIYlVZVDVOWTByT19TaHQyLWZVWF9nOCJ9.eyJhdWQiOiIwMDZmYmYzNC1hODBiLTRjODEtYjZlOS01OTM2MDA2NzVmYjIiLCJleHAiOjE3MDE5MzM2OTcsImluaXRpYWxzIjoiQi5CLiIsImlzcyI6Imh0dHBzOi8vbG9jYWxob3N0OjgwMDYiLCJqc29uX3NjaGVtYSI6Imh0dHBzOi8vbG9jYWxob3N0OjgwMDYvanNvbl9zY2hlbWEuanNvbiIsImxvYV9hdXRobiI6Imh0dHA6Ly9laWRhcy5ldXJvcGEuZXUvTG9BL2hpZ2giLCJsb2FfdXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsIm5iZiI6MTcwMTkzMzYyNywicmVsYXRpb25zIjpbeyJlbnRpdHlfbmFtZSI6IlpvcmdhYW5iaWVkZXIiLCJyb2xlcyI6WyIwMS4wNDEiLCIzMC4wMDAiLCIwMS4wMTAiLCIwMS4wMTEiXSwidXJhIjoiODc2NTQzMjEifV0sInN1cm5hbWUiOiJKYW5zZW4iLCJzdXJuYW1lX3ByZWZpeCI6InZhbiBkZXIiLCJ1emlfaWQiOiI5MDAwMDAwMDkiLCJ4NWMiOiJNSUlEMWpDQ0FiNENDUUQwRmx2SnNiY3J2VEFOQmdrcWhraUc5dzBCQVFzRkFEQW9NUXN3Q1FZRFZRUUREQUpWXG5VekVaTUJjR0ExVUVBd3dRYVc1blpTMDJMWFY2YVhCdll5MWpZVEFlRncweU16RXlNREV4TWpNek16QmFGdzB5XG5OVEEwTVRReE1qTXpNekJhTURJeEN6QUpCZ05WQkFZVEFsVlRNU013SVFZRFZRUUREQnB1YkMxMWVtbHdiMk10XG5jR2h3TFd4aGNtRjJaV3d0WkdWdGJ6Q0NBU0l3RFFZSktvWklodmNOQVFFQkJRQURnZ0VQQURDQ0FRb0NnZ0VCXG5BS2JDQ0dZYTcwL0VlS2dwc0N0QnRHUmlIWVJyb2RWQWN0bHJPTUhFZDI2aGpSdmN0TGZOaXl0MFF5QkZsekN2XG4wVkRYUjc3OWVvU1E2bWdPYURjNzFrYi9zR1dxbjhMZFE3NEp0WTVnSTVxRzduM1JYM0VRWkxFdGIxNmp6WWROXG5LMU5mMm9GK0tNV2t2eWMvVjlSNWUyNjdyTjJpUklHQlNKUTFmZmN4RHFUZnJNVmxjaFYyZmdWVDdZTzQ3U25qXG5MMXdDK0Z4cXhTRzc1N056OHlleVBncjJaazFvaWF6dHhQY1hXRlVpTklGWm9KUzlpVzdITTZyQ204WjcvbVJjXG40Qm5kbC9wbkZlMjVrZmhPZzlKSVVNbzFvcjltbDZDSXN6Um9aL2hTOHZCOUduNldUS05CYUgxMTB6Sno4eXNkXG42cXM4WkpCYURia0pnSTZMNlZtL3d0OENBd0VBQVRBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQWdFQWl6eEJmYUFhXG5ETlFOOWhYS0ZKZVB6N2RUUEx3SFkvWmpDNDlkUXJ0eVd6ZmdyMUxLaTRvMWl4amtkZzJGWFU5dCt4TXVXZ3hYXG5GZGJqT0pMZGNRWVdFWWIrVzdLbWtnbEljUDhiT2tSRFdmcGxnc2twYVRvZ1JLMDk1UzFDdU1NOXYwYktDL3RzXG5kSGIzVWZxVzRVNFprbzM4L1VlOWZSRjhyYTJwNzFRRnM4K250L0JBd0NrenJOTHpheE1ZOC8vVGlGVStaRVlMXG56UElRQmpLYVlCOHlWaDBXaDNxYWllQjJCektVYW4rRXlzaDJiVWM5VHBsUXlrSWRrNHo2VCtGTzVLVGo1ZVZrXG42enBIZmxXV0NUNjF5MTVtdTN4QUViODNyT2YrekZwb05HaURzc2lrbzBPZUxLN0ZscWg3SHVDUDI2Tk5ud3NiXG5WR3drZzYwcER1K0FTRzJhbTNUUGlmM0pwSTdza3pBQkZ3NHZidlBVcElrNkltM3ljQzk4R3lYb3dRdWpJMFpUXG4xNmRYZmgxRTM4cHNSVWVPNW8rdXhZNk1VUFhOU2lvWVowbWYzQkFSTGFoTjQxcnF4S1h6NU1MMURTWm5JT1pLXG5GM3BlU2dnYVpvUmkxaDByNlcxNFdFY1l2eGRIRGtWUjZNMXFXMGk3WWVJQms2a2FYRWt3Q21GejNoazV3OWFuXG5XSkRqbk1xU1JnUnNGVmNJTC9FemkvRWx1YmsyMWY0TEhURVFtc2p6emQxRytkMDlmamRJNkpyaFlNZnRHdVlaXG40ak9aWldwem9NSDFUaVpaK0prQmR5UndFZGJxelcrdisvMEJaUXk2SFJhWmxvbWJjT21TOU1TakZSRFR5VUdXXG5EOUYxZVVJcUtjdDB5eUpQUFhIM2xEa3pxcXRYNERMY29wbz0ifQ.VvzIXZ8FCIwxvP3Wc4kLvIgQChJZAhS-DcKKvkiZg677w-ZRciIFCWUH5oXLqG-emyV4f87tIoWnp4TY3gGFNljNrtlTVCv3zXaTCxHwzL6q2QCs1liBus2uPv0kjBtzeve2G5_Owst3ndeUcwLJPnTIoYRLvbjjaPkFTg49K5ZTpN8E9dl7Gimwgv_rZ1fOH7XrAwlTY-jF34wsR_K17wHI5Zp237_HcAPqnMI8P3U7u74Vu-3mqCePubVBDnT4bGcd4flZCFH-LTDhew9BO4cBkBxafAev7OB5A9qGOKEtRynTDAOkazyb8_qwJAGnyCAVxBQ4VFRB1-cE576TLQ` + const exampleCertificateDERBase64 = `MIID1jCCAb4CCQD0FlvJsbcrvTANBgkqhkiG9w0BAQsFADAoMQswCQYDVQQDDAJV +UzEZMBcGA1UEAwwQaW5nZS02LXV6aXBvYy1jYTAeFw0yMzEyMDExMjMzMzBaFw0y +NTA0MTQxMjMzMzBaMDIxCzAJBgNVBAYTAlVTMSMwIQYDVQQDDBpubC11emlwb2Mt +cGhwLWxhcmF2ZWwtZGVtbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AKbCCGYa70/EeKgpsCtBtGRiHYRrodVActlrOMHEd26hjRvctLfNiyt0QyBFlzCv +0VDXR779eoSQ6mgOaDc71kb/sGWqn8LdQ74JtY5gI5qG7n3RX3EQZLEtb16jzYdN +K1Nf2oF+KMWkvyc/V9R5e267rN2iRIGBSJQ1ffcxDqTfrMVlchV2fgVT7YO47Snj +L1wC+FxqxSG757Nz8yeyPgr2Zk1oiaztxPcXWFUiNIFZoJS9iW7HM6rCm8Z7/mRc +4Bndl/pnFe25kfhOg9JIUMo1or9ml6CIszRoZ/hS8vB9Gn6WTKNBaH110zJz8ysd +6qs8ZJBaDbkJgI6L6Vm/wt8CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAizxBfaAa +DNQN9hXKFJePz7dTPLwHY/ZjC49dQrtyWzfgr1LKi4o1ixjkdg2FXU9t+xMuWgxX +FdbjOJLdcQYWEYb+W7KmkglIcP8bOkRDWfplgskpaTogRK095S1CuMM9v0bKC/ts +dHb3UfqW4U4Zko38/Ue9fRF8ra2p71QFs8+nt/BAwCkzrNLzaxMY8//TiFU+ZEYL +zPIQBjKaYB8yVh0Wh3qaieB2BzKUan+Eysh2bUc9TplQykIdk4z6T+FO5KTj5eVk +6zpHflWWCT61y15mu3xAEb83rOf+zFpoNGiDssiko0OeLK7Flqh7HuCP26NNnwsb +VGwkg60pDu+ASG2am3TPif3JpI7skzABFw4vbvPUpIk6Im3ycC98GyXowQujI0ZT +16dXfh1E38psRUeO5o+uxY6MUPXNSioYZ0mf3BARLahN41rqxKXz5ML1DSZnIOZK +F3peSggaZoRi1h0r6W14WEcYvxdHDkVR6M1qW0i7YeIBk6kaXEkwCmFz3hk5w9an +WJDjnMqSRgRsFVcIL/Ezi/Elubk21f4LHTEQmsjzzd1G+d09fjdI6JrhYMftGuYZ +4jOZZWpzoMH1TiZZ+JkBdyRwEdbqzW+v+/0BZQy6HRaZlombcOmS9MSjFRDTyUGW +D9F1eUIqKct0yyJPPXH3lDkzqqtX4DLcopo=` + certificateDER, err := base64.StdEncoding.DecodeString(exampleCertificateDERBase64) + require.NoError(t, err) + exampleCertificate, err := x509.ParseCertificate(certificateDER) + require.NoError(t, err) + // Setup trust store with the Dezi certificate as root CA + exampleTrustStore := core.BuildTrustStore([]*x509.Certificate{}) + exampleTrustStore.RootCAs = append(exampleTrustStore.RootCAs, exampleCertificate) + + // Load a signing key pair for creating test tokens + // Note: In real scenarios, the signing key would match the cert in x5c + signingKeyCert, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") + require.NoError(t, err) + signingKeyCert.Leaf, err = x509.ParseCertificate(signingKeyCert.Certificate[0]) + require.NoError(t, err) + trustStore := core.BuildTrustStore([]*x509.Certificate{}) + trustStore.RootCAs = append(trustStore.RootCAs, signingKeyCert.Leaf) + + // Use a validation time within the Dezi certificate validity period + validAt := time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC) + + // createToken returns a factory function that creates a JWT with the given x5c value + // If x5cValue is a *x509.Certificate, it encodes that certificate in x5c + // If x5cValue is any other type (string, []string, nil), it's used directly as the x5c claim + createToken := func(x5cValue any, nbf *time.Time, exp *time.Time) func(t *testing.T) []byte { + if nbf == nil { + nbf = new(time.Time) + *nbf = time.Unix(1732182376, 0) // Nov 21, 2024 + } + if exp == nil { + exp = new(time.Time) + *exp = time.Unix(1740131176, 0) // Feb 21, 2025 + } + return func(t *testing.T) []byte { + token := jwt.New() + claims := map[string]any{ + jwt.AudienceKey: "006fbf34-a80b-4c81-b6e9-593600675fb2", + jwt.ExpirationKey: exp.Unix(), + jwt.NotBeforeKey: nbf.Unix(), + jwt.IssuerKey: "https://max.proeftuin.Dezi-online.rdobeheer.nl", + jwt.JwtIDKey: "test-jwt-id", + "dezi_nummer": "900000009", + "initials": "B.B.", + "surname": "Jansen", + "surname_prefix": "van der", + "relations": []map[string]interface{}{ + {"entity_name": "Zorgaanbieder", "roles": []string{"01.041"}, "ura": "87654321"}, + }, + } + + // Handle x5c based on type + if cert, ok := x5cValue.(*x509.Certificate); ok { + // Encode the provided certificate in x5c + x5cArray := []string{base64.StdEncoding.EncodeToString(cert.Raw)} + claims["x5c"] = x5cArray + } else if x5cValue != nil { + // Use x5cValue directly (for testing invalid formats) + claims["x5c"] = x5cValue + } + // If x5cValue is nil, don't add x5c claim (for testing missing x5c) + + for k, v := range claims { + require.NoError(t, token.Set(k, v)) + } + signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, signingKeyCert.PrivateKey)) + require.NoError(t, err) + return signed + } + } + + tests := []struct { + name string + createToken func(t *testing.T) []byte + modifyCred func(*vc.VerifiableCredential) + trustStore *core.TrustStore + wantErr string + }{ + { + name: "ok", + createToken: func(t *testing.T) []byte { + return []byte(exampleToken) + }, + trustStore: exampleTrustStore, + }, + { + name: "missing x5c", + createToken: createToken(nil, nil, nil), + wantErr: "missing 'x5c' claim", + }, + { + name: "invalid certificate", + createToken: createToken([]string{"invalid-base64!!!"}, nil, nil), + wantErr: "decode 'x5c", + }, + { + name: "credential's nbf does not match token's nbf", + createToken: createToken([]string{base64.StdEncoding.EncodeToString(signingKeyCert.Leaf.Raw)}, nil, nil), + modifyCred: func(c *vc.VerifiableCredential) { + c.IssuanceDate = time.Date(2024, 11, 1, 0, 0, 0, 0, time.UTC) + }, + wantErr: "'nbf' does not match credential 'issuanceDate'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tokenBytes := tt.createToken(t) + cred, err := CreateDeziIDTokenCredential(string(tokenBytes)) + require.NoError(t, err) + + if tt.modifyCred != nil { + tt.modifyCred(cred) + } + validator := deziIDToken2024CredentialValidator{ + clock: func() time.Time { return validAt }, + trustStore: trustStore, + } + if tt.trustStore != nil { + validator.trustStore = tt.trustStore + } + + err = validator.Validate(*cred) + if tt.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/vcr/credential/koppelvlakspecificatie_07-dezi-voor-platform-en-softwareleveranciers-v0-7.pdf b/vcr/credential/koppelvlakspecificatie_07-dezi-voor-platform-en-softwareleveranciers-v0-7.pdf new file mode 100644 index 0000000000..6cc845d2f7 Binary files /dev/null and b/vcr/credential/koppelvlakspecificatie_07-dezi-voor-platform-en-softwareleveranciers-v0-7.pdf differ diff --git a/vcr/credential/resolver.go b/vcr/credential/resolver.go index 182eda33e7..290f9756ab 100644 --- a/vcr/credential/resolver.go +++ b/vcr/credential/resolver.go @@ -22,6 +22,7 @@ package credential import ( "errors" "fmt" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/crypto" @@ -29,6 +30,8 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/signature/proof" ) +var DefaultDeziIDTokenCredentialValidator = DeziIDTokenCredentialValidator{} + // FindValidator finds the Validator the provided credential based on its Type // When no additional type is provided, it returns the default validator func FindValidator(credential vc.VerifiableCredential, pkiValidator pki.Validator) Validator { @@ -41,6 +44,11 @@ func FindValidator(credential vc.VerifiableCredential, pkiValidator pki.Validato return nutsAuthorizationCredentialValidator{} case X509CredentialType: return x509CredentialValidator{pkiValidator: pkiValidator} + case DeziIDTokenCredentialTypeURI.String(): + // TODO: This is an ugly pattern, and FindValidator() should probably be moved to the Verifier, but that's a big refactor. + // As long as it's non-production/PoC code, this is fine. + // Make nice when merging to master. + return DefaultDeziIDTokenCredentialValidator } } } diff --git a/vcr/credential/test.go b/vcr/credential/test.go new file mode 100644 index 0000000000..bfd11a341d --- /dev/null +++ b/vcr/credential/test.go @@ -0,0 +1,91 @@ +package credential + +import ( + "crypto" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +func CreateTestDezi07IDToken(issuedAt time.Time, validUntil time.Time, key crypto.PrivateKey) ([]byte, error) { + claims := map[string]any{ + jwt.JwtIDKey: "test-jwt-id-07", + jwt.ExpirationKey: validUntil.Unix(), + jwt.NotBeforeKey: issuedAt.Unix(), + jwt.IssuerKey: "abonnee.dezi.nl", + "json_schema": "https://www.dezi.nl/json_schemas/verklaring_v1.json", + "loa_dezi": "http://eidas.europa.eu/LoA/high", + "verklaring_id": "test-verklaring-id", + // v0.7 format claims + "dezi_nummer": "123456789", + "voorletters": "A.B.", + "voorvoegsel": "van der", + "achternaam": "Zorgmedewerker", + "abonnee_nummer": "87654321", + "abonnee_naam": "Zorgaanbieder", + "rol_code": "01.000", + "rol_naam": "Arts", + "rol_code_bron": "http://www.dezi.nl/rol_code_bron/big", + } + token := jwt.New() + for name, value := range claims { + if err := token.Set(name, value); err != nil { + return nil, err + } + } + + headers := jws.NewHeaders() + for k, v := range map[string]any{ + "alg": "RS256", + "kid": "1", + "jku": "https://example.com/jwks.json", + } { + if err := headers.Set(k, v); err != nil { + return nil, err + } + } + return jwt.Sign(token, jwt.WithKey(jwa.RS256, key, jws.WithProtectedHeaders(headers))) +} + +func CreateTestDezi2024IDToken(issuedAt time.Time, validUntil time.Time, key crypto.PrivateKey) ([]byte, error) { + claims := map[string]any{ + jwt.AudienceKey: "006fbf34-a80b-4c81-b6e9-593600675fb2", + jwt.ExpirationKey: validUntil.Unix(), + jwt.NotBeforeKey: issuedAt.Unix(), + jwt.IssuerKey: "https://max.proeftuin.Dezi-online.rdobeheer.nl", + "initials": "B.B.", + "surname": "Jansen", + "surname_prefix": "van der", + "Dezi_id": "900000009", + "json_schema": "https://max.proeftuin.Dezi-online.rdobeheer.nl/json_schema.json", + "loa_authn": "http://eidas.europa.eu/LoA/high", + "loa_Dezi": "http://eidas.europa.eu/LoA/high", + "relations": []map[string]interface{}{ + { + "entity_name": "Zorgaanbieder", + "roles": []string{"01.041", "30.000", "01.010", "01.011"}, + "ura": "87654321", + }, + }, + } + token := jwt.New() + for name, value := range claims { + if err := token.Set(name, value); err != nil { + return nil, err + } + } + + headers := jws.NewHeaders() + for k, v := range map[string]any{ + "alg": "RS256", + "kid": "1", + "jku": "https://example.com/jwks.json", + } { + if err := headers.Set(k, v); err != nil { + return nil, err + } + } + return jwt.Sign(token, jwt.WithKey(jwa.RS256, key, jws.WithProtectedHeaders(headers))) +} diff --git a/vcr/credential/types.go b/vcr/credential/types.go index 87da9fefeb..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/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 ac2b481dae..56be3476b3 100644 --- a/vcr/credential/validator.go +++ b/vcr/credential/validator.go @@ -25,6 +25,9 @@ import ( "encoding/json" "errors" "fmt" + "net/url" + "strings" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -33,8 +36,6 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/revocation" "github.com/nuts-foundation/nuts-node/vdr/didx509" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "net/url" - "strings" ) // Validator is the interface specific VC verification. diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index 9c220c3bee..8e1f4669dd 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -24,11 +24,12 @@ import ( "crypto/rand" "embed" "encoding/json" + "strings" + "testing" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core/to" vcrTest "github.com/nuts-foundation/nuts-node/vcr/test" - "strings" - "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwt" diff --git a/vcr/vcr.go b/vcr/vcr.go index 6cf9876b89..971a124717 100644 --- a/vcr/vcr.go +++ b/vcr/vcr.go @@ -24,6 +24,12 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" + "net/http" + "path" + "strings" + "time" + "github.com/nuts-foundation/go-leia/v4" "github.com/nuts-foundation/nuts-node/http/client" "github.com/nuts-foundation/nuts-node/pki" @@ -32,11 +38,6 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/revocation" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "io/fs" - "net/http" - "path" - "strings" - "time" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" @@ -165,6 +166,19 @@ func (c *vcr) Configure(config core.ServerConfig) error { // copy strictmode for openid4vci usage c.strictmode = config.Strictmode + // TODO: Make this configurable when PoC phase using Dezi is over/merging to master. + // This is now an ugly shortcut. + { + // This configures from which URLs we allow fetching keys to validate Dezi attestations. + // It's important that in production environments, only keys from the Dezi production environment are allowed. + allowedJKU := []string{"https://auth.dezi.nl/dezi/jwks.json"} + if !c.strictmode { + // in non-strict mode, allow Dezi attestations from acceptance environment + allowedJKU = append(allowedJKU, "https://acceptatie.auth.dezi.nl/dezi/jwks.json") + } + credential.DefaultDeziIDTokenCredentialValidator = credential.DeziIDTokenCredentialValidator{AllowedJKU: allowedJKU} + } + // create issuer store (to revoke) issuerStorePath := path.Join(c.datadir, "vcr", "issued-credentials.db") issuerBackupStore, err := c.storageClient.GetProvider(ModuleName).GetKVStore("backup-issued-credentials", storage.PersistentStorageClass) diff --git a/vcr/verifier/verifier.go b/vcr/verifier/verifier.go index 59f833327e..a1490dbff8 100644 --- a/vcr/verifier/verifier.go +++ b/vcr/verifier/verifier.go @@ -152,6 +152,10 @@ func (v verifier) Verify(credentialToVerify vc.VerifiableCredential, allowUntrus } // Check signature + // 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 3466d519e4..006695a74d 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -307,7 +307,7 @@ func TestVerifier_Verify(t *testing.T) { assert.EqualError(t, err, "verifiable credential must list at most 2 types") }) - t.Run("verify x509", func(t *testing.T) { + t.Run("X509Credential", func(t *testing.T) { ura := "312312312" certs, keys, err := pki.BuildCertChain(nil, ura, nil) chain := pki.CertsToChain(certs)