From 8d2f033233dd36285cf5be7ee900ffef0873ad3c Mon Sep 17 00:00:00 2001 From: Max Tropets Date: Wed, 22 May 2024 14:48:45 +0000 Subject: [PATCH] JWT WIP --- doc/audit/builtin_maps.rst | 9 + doc/schemas/gov_openapi.json | 50 +++++- include/ccf/crypto/jwk.h | 3 +- include/ccf/service/tables/jwt.h | 14 ++ src/endpoints/authentication/jwt_auth.cpp | 95 ++++++++++- src/http/http_jwt.h | 4 +- src/node/gov/handlers/service_state.h | 12 +- src/node/jwt_key_auto_refresh.h | 30 +++- src/node/rpc/jwt_management.h | 90 +++++++--- src/node/rpc/member_frontend.h | 30 ++-- src/service/network_tables.h | 5 +- tests/infra/jwt_issuer.py | 18 +- .../custom_authorization.py | 154 +++++++++++++++++- 13 files changed, 459 insertions(+), 55 deletions(-) diff --git a/doc/audit/builtin_maps.rst b/doc/audit/builtin_maps.rst index 347ba46b3d64..e3bf679668a2 100644 --- a/doc/audit/builtin_maps.rst +++ b/doc/audit/builtin_maps.rst @@ -381,6 +381,15 @@ JWT signing key to Issuer mapping. **Value** JWT issuer URL, represented as a string. +``jwt.public_signing_key_issuers`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +JWT signing key to CHANGE-ME mapping. + +**Key** JWT Key ID, represented as a string. + +**Value** CHANGE-ME. + ``constitution`` ~~~~~~~~~~~~~~~~ diff --git a/doc/schemas/gov_openapi.json b/doc/schemas/gov_openapi.json index 35493b397e3b..b4546c2b557e 100644 --- a/doc/schemas/gov_openapi.json +++ b/doc/schemas/gov_openapi.json @@ -394,6 +394,26 @@ ], "type": "object" }, + "JwtIssuerWithConstraint": { + "properties": { + "constraint": { + "$ref": "#/components/schemas/string" + }, + "issuer": { + "$ref": "#/components/schemas/string" + } + }, + "required": [ + "issuer" + ], + "type": "object" + }, + "JwtIssuerWithConstraint_array": { + "items": { + "$ref": "#/components/schemas/JwtIssuerWithConstraint" + }, + "type": "array" + }, "KeyIdInfo": { "properties": { "cert": { @@ -1262,6 +1282,12 @@ }, "type": "object" }, + "string_to_JwtIssuerWithConstraint_array": { + "additionalProperties": { + "$ref": "#/components/schemas/JwtIssuerWithConstraint_array" + }, + "type": "object" + }, "string_to_KeyIdInfo": { "additionalProperties": { "$ref": "#/components/schemas/KeyIdInfo" @@ -1335,7 +1361,7 @@ "info": { "description": "This API is used to submit and query proposals which affect CCF's public governance tables.", "title": "CCF Governance API", - "version": "4.1.6" + "version": "4.1.7" }, "openapi": "3.0.0", "paths": { @@ -1766,6 +1792,28 @@ } } }, + "/gov/kv/jwt/public_signing_key_issuers": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/string_to_JwtIssuerWithConstraint_array" + } + } + }, + "description": "Default response description" + }, + "default": { + "$ref": "#/components/responses/default" + } + }, + "x-ccf-forwarding": { + "$ref": "#/components/x-ccf-forwarding/sometimes" + } + } + }, "/gov/kv/jwt/public_signing_keys": { "get": { "responses": { diff --git a/include/ccf/crypto/jwk.h b/include/ccf/crypto/jwk.h index 2a797de631e2..42556572a4d0 100644 --- a/include/ccf/crypto/jwk.h +++ b/include/ccf/crypto/jwk.h @@ -27,12 +27,13 @@ namespace crypto JsonWebKeyType kty; std::optional kid = std::nullopt; std::optional> x5c = std::nullopt; + std::optional issuer = std::nullopt; bool operator==(const JsonWebKey&) const = default; }; DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(JsonWebKey); DECLARE_JSON_REQUIRED_FIELDS(JsonWebKey, kty); - DECLARE_JSON_OPTIONAL_FIELDS(JsonWebKey, kid, x5c); + DECLARE_JSON_OPTIONAL_FIELDS(JsonWebKey, kid, x5c, issuer); enum class JsonWebKeyECCurve { diff --git a/include/ccf/service/tables/jwt.h b/include/ccf/service/tables/jwt.h index 2457439f003d..5b09fed4e0b4 100644 --- a/include/ccf/service/tables/jwt.h +++ b/include/ccf/service/tables/jwt.h @@ -58,10 +58,21 @@ namespace ccf using JwtKeyId = std::string; using Cert = std::vector; + struct JwtIssuerWithConstraint + { + JwtIssuer issuer; + std::optional constraint; + }; + DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(JwtIssuerWithConstraint); + DECLARE_JSON_REQUIRED_FIELDS(JwtIssuerWithConstraint, issuer); + DECLARE_JSON_OPTIONAL_FIELDS(JwtIssuerWithConstraint, constraint); + using JwtIssuers = ServiceMap; using JwtPublicSigningKeys = kv::RawCopySerialisedMap; using JwtPublicSigningKeyIssuer = kv::RawCopySerialisedMap; + using JwtPublicSigningKeyIssuers = + ServiceMap>; namespace Tables { @@ -70,6 +81,9 @@ namespace ccf "public:ccf.gov.jwt.public_signing_keys"; static constexpr auto JWT_PUBLIC_SIGNING_KEY_ISSUER = "public:ccf.gov.jwt.public_signing_key_issuer"; + static constexpr auto JWT_PUBLIC_SIGNING_KEY_ISSUERS = + "public:ccf.gov.jwt.public_signing_key_issuers"; + } struct JsonWebKeySet diff --git a/src/endpoints/authentication/jwt_auth.cpp b/src/endpoints/authentication/jwt_auth.cpp index ef17ffb9b0bc..2d879177acf2 100644 --- a/src/endpoints/authentication/jwt_auth.cpp +++ b/src/endpoints/authentication/jwt_auth.cpp @@ -3,6 +3,7 @@ #include "ccf/endpoints/authentication/jwt_auth.h" +#include "ccf/ds/nonstd.h" #include "ccf/pal/locking.h" #include "ccf/rpc_context.h" #include "ccf/service/tables/jwt.h" @@ -10,6 +11,79 @@ #include "enclave/enclave_time.h" #include "http/http_jwt.h" +namespace +{ + static const std::string multitenancy_indicator{"{tenantid}"}; + static const std::string microsoft_entra_domain{"login.microsoftonline.com"}; + + std::optional first_non_empty_chunk( + const std::vector& chunks) + { + for (auto chunk : chunks) + { + if (!chunk.empty()) + { + return chunk; + } + } + return std::nullopt; + } + + bool validate_issuer( + const http::JwtVerifier::Token& token, std::string issuer) + { + LOG_INFO_FMT( + "Verify token.iss {} and token.tid {} against published key issuer {}", + token.payload_typed.iss, + token.payload_typed.tid, + issuer); + + const bool is_microsoft_entra = + issuer.find(microsoft_entra_domain) != std::string::npos; + if (!is_microsoft_entra) + { + return token.payload_typed.iss == issuer; + } + + // Specify tenant if working with multi-tenant endpoint. + const auto pos = issuer.find(multitenancy_indicator); + if (pos != std::string::npos) + { + issuer.replace( + pos, multitenancy_indicator.size(), token.payload_typed.tid); + } + + // Step 1. Verify the token issuer against the key issuer. + if (token.payload_typed.iss != issuer) + { + return false; + } + + // Step 2. Verify that token.tid is served as a part of token.iss. According + // to the documentation, we only accept this format: + // + // https://domain.com/tenant_id/something_else + // + // Here url.path == "/tenant_id/something_else". + + const auto url = http::parse_url_full(token.payload_typed.iss); + const auto tenant_id = first_non_empty_chunk(nonstd::split(url.path, "/")); + + return (tenant_id && token.payload_typed.tid == tenant_id.value()); + } + + bool validate_issuers( + const http::JwtVerifier::Token& token, + const std::vector& issuers) + { + return std::any_of(issuers.begin(), issuers.end(), [&](const auto& issuer) { + return issuer.constraint.has_value() && + validate_issuer(token, issuer.constraint.value()); + }); + } + +} + namespace ccf { struct VerifiersCache @@ -59,10 +133,11 @@ namespace ccf auto& token = token_opt.value(); auto keys = tx.ro(ccf::Tables::JWT_PUBLIC_SIGNING_KEYS); - auto key_issuers = tx.ro( - ccf::Tables::JWT_PUBLIC_SIGNING_KEY_ISSUER); + auto key_issuers = tx.ro( + ccf::Tables::JWT_PUBLIC_SIGNING_KEY_ISSUERS); const auto key_id = token.header_typed.kid; const auto token_key = keys->get(key_id); + const auto issuers = key_issuers->get(key_id); if (!token_key.has_value()) { @@ -96,10 +171,24 @@ namespace ccf time_now, token.payload_typed.exp); } + else if (issuers->empty()) + { + error_reason = fmt::format( + "Issuer validation failure for kid {} - issuer not found", + key_id); + } + else if (!validate_issuers(token, issuers.value())) + { + error_reason = fmt::format( + "Token issuer for kid {} failed validation agains the key issuer", + key_id); + } else { auto identity = std::make_unique(); - identity->key_issuer = key_issuers->get(key_id).value(); + // TODO(#same-kid-different-issuer) back-propagate the proper issuer + // we checked against in case there are many. + identity->key_issuer = issuers->front().issuer; identity->header = std::move(token.header); identity->payload = std::move(token.payload); return identity; diff --git a/src/http/http_jwt.h b/src/http/http_jwt.h index 64e731d3fa05..e31d490cc065 100644 --- a/src/http/http_jwt.h +++ b/src/http/http_jwt.h @@ -32,9 +32,11 @@ namespace http { size_t nbf; size_t exp; + std::string iss; + std::string tid; }; DECLARE_JSON_TYPE(JwtPayload) - DECLARE_JSON_REQUIRED_FIELDS(JwtPayload, nbf, exp) + DECLARE_JSON_REQUIRED_FIELDS(JwtPayload, nbf, exp, iss, tid) class JwtVerifier { diff --git a/src/node/gov/handlers/service_state.h b/src/node/gov/handlers/service_state.h index 12657e24a0a2..af5d48037eb6 100644 --- a/src/node/gov/handlers/service_state.h +++ b/src/node/gov/handlers/service_state.h @@ -468,8 +468,8 @@ namespace ccf::gov::endpoints ctx.tx.template ro( ccf::Tables::JWT_PUBLIC_SIGNING_KEYS); auto jwt_key_issuers_handle = - ctx.tx.template ro( - ccf::Tables::JWT_PUBLIC_SIGNING_KEY_ISSUER); + ctx.tx.template ro( + ccf::Tables::JWT_PUBLIC_SIGNING_KEY_ISSUERS); jwt_keys_handle->foreach( [&keys, jwt_key_issuers_handle]( @@ -480,10 +480,12 @@ namespace ccf::gov::endpoints const auto cert_pem = crypto::cert_der_to_pem(cert); key_info["certificate"] = cert_pem.str(); - const auto issuer = jwt_key_issuers_handle->get(kid); - if (issuer.has_value()) + // TODO(#same-kid-different-issuer) we must either populate all + // issuers or choose one. To be discussed later on. + const auto issuers = jwt_key_issuers_handle->get(kid); + if (issuers.has_value() && !issuers->empty()) { - key_info["issuer"] = issuer.value(); + key_info["issuer"] = issuers->front().issuer; } else { diff --git a/src/node/jwt_key_auto_refresh.h b/src/node/jwt_key_auto_refresh.h index 9b986d927af8..3b727bc9210f 100644 --- a/src/node/jwt_key_auto_refresh.h +++ b/src/node/jwt_key_auto_refresh.h @@ -137,6 +137,7 @@ namespace ccf void handle_jwt_jwks_response( const std::string& issuer, + const std::optional& issuer_constraint, http_status status, std::vector&& data) { @@ -173,6 +174,20 @@ namespace ccf // call internal endpoint to update keys auto msg = SetJwtPublicSigningKeys{issuer, jwks}; + + // For each key we leave the specified issuer constraint or set a common + // one otherwise (if present). + if (issuer_constraint.has_value()) + { + for (auto& key : jwks.keys) + { + if (!key.issuer.has_value()) + { + key.issuer = issuer_constraint; + } + } + } + send_refresh_jwt_keys(msg); } @@ -201,9 +216,10 @@ namespace ccf issuer); std::string jwks_url_str; + nlohmann::json metadata; try { - auto metadata = nlohmann::json::parse(data); + metadata = nlohmann::json::parse(data); jwks_url_str = metadata.at("jwks_uri").get(); } catch (const std::exception& e) @@ -235,6 +251,13 @@ namespace ccf auto ca_cert = std::make_shared( ca, std::nullopt, std::nullopt, jwks_url.host); + std::optional issuer_constraint{std::nullopt}; + const auto constraint = metadata.find("issuer"); + if (constraint != metadata.end()) + { + issuer_constraint = *constraint; + } + LOG_DEBUG_FMT( "JWT key auto-refresh: Requesting JWKS at https://{}:{}{}", jwks_url.host, @@ -246,9 +269,10 @@ namespace ccf http_client->connect( std::string(jwks_url.host), std::string(jwks_url_port), - [this, issuer]( + [this, issuer, issuer_constraint]( http_status status, http::HeaderMap&&, std::vector&& data) { - handle_jwt_jwks_response(issuer, status, std::move(data)); + handle_jwt_jwks_response( + issuer, issuer_constraint, status, std::move(data)); return true; }); http::Request r(jwks_url.path, HTTP_GET); diff --git a/src/node/rpc/jwt_management.h b/src/node/rpc/jwt_management.h index 012ea43a3f98..c3ec3e493580 100644 --- a/src/node/rpc/jwt_management.h +++ b/src/node/rpc/jwt_management.h @@ -24,15 +24,17 @@ namespace ccf static void remove_jwt_public_signing_keys(kv::Tx& tx, std::string issuer) { auto keys = tx.rw(Tables::JWT_PUBLIC_SIGNING_KEYS); - auto key_issuer = - tx.rw(Tables::JWT_PUBLIC_SIGNING_KEY_ISSUER); + auto key_issuers = + tx.rw(Tables::JWT_PUBLIC_SIGNING_KEY_ISSUERS); - key_issuer->foreach( - [&issuer, &keys, &key_issuer](const auto& k, const auto& v) { - if (v == issuer) + // TODO(#same-kid-different-issuer) - we must only delete kids for the + // specific issuer + key_issuers->foreach( + [&issuer, &keys, &key_issuers](const auto& k, const auto& v) { + if (v.front().issuer == issuer) { keys->remove(k); - key_issuer->remove(k); + key_issuers->remove(k); } return true; }); @@ -62,8 +64,8 @@ namespace ccf const JsonWebKeySet& jwks) { auto keys = tx.rw(Tables::JWT_PUBLIC_SIGNING_KEYS); - auto key_issuer = - tx.rw(Tables::JWT_PUBLIC_SIGNING_KEY_ISSUER); + auto key_issuers = + tx.rw(Tables::JWT_PUBLIC_SIGNING_KEY_ISSUERS); auto log_prefix = proposal_id.empty() ? "JWT key auto-refresh" : @@ -76,6 +78,7 @@ namespace ccf return false; } std::map> new_keys; + std::map issuer_constraints; for (auto& jwk : jwks.keys) { if (!jwk.kid.has_value()) @@ -84,14 +87,27 @@ namespace ccf return false; } auto const& kid = jwk.kid.value(); - - if (keys->has(kid) && key_issuer->get(kid).value() != issuer) + if (keys->has(kid)) { - LOG_FAIL_FMT( - "{}: key id {} already added for different issuer", log_prefix, kid); - return false; + // TODO(#same-kid-different-issuer) relax this, because we now support + // same kids from different issuers, but check that public key (x5c) is + // the same. + const auto issuers = key_issuers->get(kid); + + // It may happen that the issuer not present if it was added to an old + // table version on previous SW revision. In this case we will go on and + // overwrite it as it had been never added before. + if (issuers.has_value() && issuers->front().issuer != issuer) + { + LOG_FAIL_FMT( + "{}: key id {} already added for different issuer", + log_prefix, + kid); + return false; + } } - if (!jwk.x5c.has_value() && jwk.x5c->empty()) + + if (!jwk.x5c.has_value() && jwk.x5c->empty()) // TODO it seems like a bug { LOG_FAIL_FMT("{}: JWKS is invalid (empty x5c)", log_prefix); return false; @@ -192,6 +208,11 @@ namespace ccf } LOG_INFO_FMT("{}: Storing JWT signing key with kid {}", log_prefix, kid); new_keys.emplace(kid, der); + + if (jwk.issuer.has_value()) + { + issuer_constraints.emplace(kid, jwk.issuer.value()); + } } if (new_keys.empty()) { @@ -200,11 +221,14 @@ namespace ccf } std::set existing_kids; - key_issuer->foreach( - [&existing_kids, &issuer](const auto& kid, const auto& issuer_) { - if (issuer_ == issuer) + key_issuers->foreach( + [&existing_kids, &issuer](const auto& kid, const auto& issuers) { + for (const auto& issuer_with_constraint : issuers) { - existing_kids.insert(kid); + if (issuer == issuer_with_constraint.issuer) + { + existing_kids.insert(kid); + } } return true; }); @@ -214,7 +238,32 @@ namespace ccf if (!existing_kids.contains(kid)) { keys->put(kid, der); - key_issuer->put(kid, issuer); + + // Find the constraint + JwtIssuerWithConstraint value{issuer, std::nullopt}; + const auto it = issuer_constraints.find(kid); + if (it != issuer_constraints.end()) + { + value.constraint = it->second; + } + + LOG_INFO_FMT( + "Save JWT issuer for kid {} where issuer: {}, issuer constraint: {}", + kid, + value.issuer, + value.constraint); + + // Update the vector + auto issuers_with_constraints = key_issuers->get(kid); + if (!issuers_with_constraints.has_value()) + { + key_issuers->put(kid, std::vector{value}); + } + else + { + issuers_with_constraints->push_back(std::move(value)); + key_issuers->put(kid, *issuers_with_constraints); + } } } @@ -223,7 +272,8 @@ namespace ccf if (!new_keys.contains(kid)) { keys->remove(kid); - key_issuer->remove(kid); + key_issuers->remove(kid); // TODO(#same-kid-different-issuer) - we must + // only delete kids for the specific issuer } } diff --git a/src/node/rpc/member_frontend.h b/src/node/rpc/member_frontend.h index 287c0bb74d6d..2789b75d0c1d 100644 --- a/src/node/rpc/member_frontend.h +++ b/src/node/rpc/member_frontend.h @@ -590,7 +590,7 @@ namespace ccf openapi_info.description = "This API is used to submit and query proposals which affect CCF's " "public governance tables."; - openapi_info.document_version = "4.1.6"; + openapi_info.document_version = "4.1.7"; } static std::optional get_caller_member_id( @@ -1082,20 +1082,24 @@ namespace ccf auto get_jwt_keys = [this](auto& ctx, nlohmann::json&& body) { auto keys = ctx.tx.ro(network.jwt_public_signing_keys); - auto keys_to_issuer = ctx.tx.ro(network.jwt_public_signing_key_issuer); + auto keys_to_issuers = + ctx.tx.ro(network.jwt_public_signing_key_issuers); JWTKeyMap kmap; - keys->foreach( - [&kmap, &keys_to_issuer](const auto& kid, const auto& kpem) { - auto issuer = keys_to_issuer->get(kid); - if (!issuer.has_value()) - { - throw std::logic_error(fmt::format("kid {} has no issuer", kid)); - } - kmap.emplace( - kid, KeyIdInfo{issuer.value(), crypto::cert_der_to_pem(kpem)}); - return true; - }); + keys->foreach([&kmap, + &keys_to_issuers](const auto& kid, const auto& kpem) { + auto issuers = keys_to_issuers->get(kid); + if (!issuers.has_value()) + { + throw std::logic_error(fmt::format("kid {} has no issuer", kid)); + } + // TODO(#same-kid-different-issuer) - figure out what to do with + // multiple issuers. + kmap.emplace( + kid, + KeyIdInfo{issuers->front().issuer, crypto::cert_der_to_pem(kpem)}); + return true; + }); return make_success(kmap); }; diff --git a/src/service/network_tables.h b/src/service/network_tables.h index 16ef3548df61..8fbad7ad3ea2 100644 --- a/src/service/network_tables.h +++ b/src/service/network_tables.h @@ -159,6 +159,8 @@ namespace ccf Tables::JWT_PUBLIC_SIGNING_KEYS}; const JwtPublicSigningKeyIssuer jwt_public_signing_key_issuer = { Tables::JWT_PUBLIC_SIGNING_KEY_ISSUER}; + const JwtPublicSigningKeyIssuers jwt_public_signing_key_issuers = { + Tables::JWT_PUBLIC_SIGNING_KEY_ISSUERS}; inline auto get_all_jwt_tables() const { @@ -166,7 +168,8 @@ namespace ccf ca_cert_bundles, jwt_issuers, jwt_public_signing_keys, - jwt_public_signing_key_issuer); + jwt_public_signing_key_issuer, + jwt_public_signing_key_issuers); } // diff --git a/tests/infra/jwt_issuer.py b/tests/infra/jwt_issuer.py index 122f71e061bd..6e6db6636b8f 100644 --- a/tests/infra/jwt_issuer.py +++ b/tests/infra/jwt_issuer.py @@ -133,6 +133,13 @@ def __init__( self.refresh_keys() else: self.cert_pem = cert + + @property + def issuer_url(self): + name = f"{self.name}" + if self.server: + name += f":{self.server.bind_port}" + return name def refresh_keys(self, kid=None): if not kid: @@ -148,7 +155,7 @@ def _create_jwks(self, kid, test_invalid_is_key=False): if not test_invalid_is_key else infra.crypto.pub_key_pem_to_der(self.key_pub_pem) ).decode("ascii") - return {"kty": "RSA", "kid": kid, "x5c": [der_b64]} + return {"kty": "RSA", "kid": kid, "x5c": [der_b64], "issuer": self.name} def create_jwks(self, kid=None, test_invalid_is_key=False): kid_ = kid or self.default_kid @@ -175,9 +182,8 @@ def register(self, network, kid=None, ca_bundle_name=TEST_CA_BUNDLE_NAME): primary, ca_bundle_name, ca_cert_bundle_fp.name ) - full_name = f"{self.name}:{self.server.bind_port}" if self.server else self.name with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as metadata_fp: - issuer = {"issuer": full_name, "auto_refresh": self.auto_refresh} + issuer = {"issuer": self.issuer_url, "auto_refresh": self.auto_refresh} if self.auto_refresh: issuer.update({"ca_cert_bundle_name": ca_bundle_name}) json.dump(issuer, metadata_fp) @@ -188,7 +194,7 @@ def register(self, network, kid=None, ca_bundle_name=TEST_CA_BUNDLE_NAME): json.dump(self.create_jwks(kid_), jwks_fp) jwks_fp.flush() network.consortium.set_jwt_public_signing_keys( - primary, full_name, jwks_fp.name + primary, self.issuer_url, jwks_fp.name ) def start_openid_server(self, port=0, kid=None): @@ -209,6 +215,10 @@ def issue_jwt(self, kid=None, claims=None): if "exp" not in claims: # Insert default Expiration Time claim, valid for ~1hr claims["exp"] = now + 3600 + if "iss" not in claims: + claims["iss"] = self.name + if "tid" not in claims: + claims["tid"] = "example.tenant.com" return infra.crypto.create_jwt(claims, self.key_priv_pem, kid_) def wait_for_refresh(self, network, args, kid=None): diff --git a/tests/js-custom-authorization/custom_authorization.py b/tests/js-custom-authorization/custom_authorization.py index 56bd7b3fc4a7..d850b8859f9d 100644 --- a/tests/js-custom-authorization/custom_authorization.py +++ b/tests/js-custom-authorization/custom_authorization.py @@ -301,8 +301,7 @@ def create_keypair(local_id, valid_from, validity_days): return network - -@reqs.description("JWT authentication") +@reqs.description("JWT authentication as by OpenID spec") def test_jwt_auth(network, args): primary, _ = network.find_nodes() @@ -316,7 +315,7 @@ def test_jwt_auth(network, args): der_b64 = base64.b64encode(jwt_cert_der).decode("ascii") data = { "issuer": issuer.name, - "jwks": {"keys": [{"kty": "RSA", "kid": jwt_kid, "x5c": [der_b64]}]}, + "jwks": {"keys": [{"kty": "RSA", "kid": jwt_kid, "x5c": [der_b64], "issuer": issuer.name}]}, } json.dump(data, metadata_fp) metadata_fp.flush() @@ -356,9 +355,156 @@ def test_jwt_auth(network, args): ) assert r.status_code == HTTPStatus.UNAUTHORIZED, r.status_code + network.consortium.remove_jwt_issuer(primary, issuer.name) return network +@reqs.description("JWT authentication as by MSFT Entra (single tenant)") +def test_jwt_auth_msft_single_tenant(network, args): + primary, _ = network.find_nodes() + + TENANT_ID = "9188050d-6c67-4c5b-b112-36a304b66da" + ISSUER_TENANT = "https://login.microsoftonline.com/9188050d-6c67-4c5b-b112-36a304b66da/v2.0" + + issuer = infra.jwt_issuer.JwtIssuer(name="https://login.microsoftonline.com") + + jwt_kid = "my_key_id" + with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as metadata_fp: + jwt_cert_der = infra.crypto.cert_pem_to_der(issuer.cert_pem) + der_b64 = base64.b64encode(jwt_cert_der).decode("ascii") + data = { + "issuer": issuer.name, + "auto_refresh": False, + "jwks": {"keys": [{"kty": "RSA", + "kid": jwt_kid, + "x5c": [der_b64], + "issuer": ISSUER_TENANT}]}, + } + json.dump(data, metadata_fp) + metadata_fp.flush() + network.consortium.set_jwt_issuer(primary, metadata_fp.name) + + with primary.client("user0") as c: + LOG.info("Calling JWT for single-tenancy with garbage tenant") + r = c.get( + "/app/jwt", + headers=infra.jwt_issuer.make_bearer_header(issuer.issue_jwt(jwt_kid, + claims={"iss": ISSUER_TENANT, + "tid": "garbage_tenant"})), + ) + assert r.status_code == HTTPStatus.UNAUTHORIZED, r.status_code + + with primary.client("user0") as c: + LOG.info("Calling JWT for single-tenancy with {tenantid} must fail because we only can use common tenant as pattern in published key, not in the token") + r = c.get( + "/app/jwt", + headers=infra.jwt_issuer.make_bearer_header(issuer.issue_jwt(jwt_kid, + claims={"iss": ISSUER_TENANT, + "tid": "{tenantid}"})), + ) + assert r.status_code == HTTPStatus.UNAUTHORIZED, r.status_code + + with primary.client("user0") as c: + LOG.info("Calling JWT for single-tenancy with microsoft tenant") + r = c.get( + "/app/jwt", + headers=infra.jwt_issuer.make_bearer_header(issuer.issue_jwt(jwt_kid, + claims={"iss": ISSUER_TENANT, + "tid": TENANT_ID})), + ) + assert r.status_code == HTTPStatus.OK, r.status_code + + network.consortium.remove_jwt_issuer(primary, issuer.name) + return network + +@reqs.description("JWT authentication as by MSFT Entra (multiple tenants)") +def test_jwt_auth_msft_multitenancy(network, args): + primary, _ = network.find_nodes() + + COMMNON_ISSUER = "https://login.microsoftonline.com/{tenantid}/v2.0" + TENANT_ID = "9188050d-6c67-4c5b-b112-36a304b66da" + ISSUER_TENANT = f"https://login.microsoftonline.com/{TENANT_ID}/v2.0" + ANOTHER_TENANT_ID = "ANOTHER-6c67-4c5b-b112-36a304b66da" + ISSUER_ANOTHER = f"https://login.microsoftonline.com/{ANOTHER_TENANT_ID}/v2.0" + + issuer = infra.jwt_issuer.JwtIssuer(name="https://login.microsoftonline.com") + + jwt_kid_1 = "my_key_id_1" + jwt_kid_2 = "my_key_id_2" + + with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as metadata_fp: + jwt_cert_der = infra.crypto.cert_pem_to_der(issuer.cert_pem) + der_b64 = base64.b64encode(jwt_cert_der).decode("ascii") + data = { + "issuer": issuer.issuer_url, + "auto_refresh": False, + "jwks": {"keys": [{"kty": "RSA", + "kid": jwt_kid_1, + "x5c": [der_b64], + "issuer": COMMNON_ISSUER}, + {"kty": "RSA", + "kid": jwt_kid_2, + "x5c": [der_b64], + "issuer": ISSUER_TENANT}, + ]}, + } + json.dump(data, metadata_fp) + metadata_fp.flush() + network.consortium.set_jwt_issuer(primary, metadata_fp.name) + + with primary.client("user0") as c: + LOG.info("TODO") + r = c.get( + "/app/jwt", + headers=infra.jwt_issuer.make_bearer_header(issuer.issue_jwt(jwt_kid_1, + claims={"iss": ISSUER_TENANT, + "tid": TENANT_ID})), + ) + assert r.status_code == HTTPStatus.OK, r.status_code + + with primary.client("user0") as c: + LOG.info("TODO") + r = c.get( + "/app/jwt", + headers=infra.jwt_issuer.make_bearer_header(issuer.issue_jwt(jwt_kid_1, + claims={"iss": ISSUER_ANOTHER, + "tid": ANOTHER_TENANT_ID})), + ) + assert r.status_code == HTTPStatus.OK, r.status_code + + with primary.client("user0") as c: + LOG.info("TODO") + r = c.get( + "/app/jwt", + headers=infra.jwt_issuer.make_bearer_header(issuer.issue_jwt(jwt_kid_1, + claims={"iss": ISSUER_TENANT, + "tid": ANOTHER_TENANT_ID})), + ) + assert r.status_code == HTTPStatus.UNAUTHORIZED, r.status_code + + with primary.client("user0") as c: + LOG.info("TODO") + r = c.get( + "/app/jwt", + headers=infra.jwt_issuer.make_bearer_header(issuer.issue_jwt(jwt_kid_2, + claims={"iss": ISSUER_TENANT, + "tid": TENANT_ID})), + ) + assert r.status_code == HTTPStatus.OK, r.status_code + + with primary.client("user0") as c: + LOG.info("TODO") + r = c.get( + "/app/jwt", + headers=infra.jwt_issuer.make_bearer_header(issuer.issue_jwt(jwt_kid_2, + claims={"iss": ISSUER_ANOTHER, + "tid": ANOTHER_TENANT_ID})), + ) + assert r.status_code == HTTPStatus.UNAUTHORIZED, r.status_code + + network.consortium.remove_jwt_issuer(primary, issuer.name) + return network + @reqs.description("Role-based access") def test_role_based_access(network, args): primary, _ = network.find_nodes() @@ -452,6 +598,8 @@ def run_authn(args): network.start_and_open(args) network = test_cert_auth(network, args) network = test_jwt_auth(network, args) + network = test_jwt_auth_msft_single_tenant(network, args) + network = test_jwt_auth_msft_multitenancy(network, args) network = test_role_based_access(network, args)