From 06c714252d877ee23e4505d159d8c3bf63a5a1b8 Mon Sep 17 00:00:00 2001 From: Andrey Zvonov Date: Tue, 29 Oct 2024 15:48:51 +0000 Subject: [PATCH 1/9] Add token authentication and authorization some polish upd --- .../aspell-ignore/en/aspell-dict.txt | 7 + contrib/jwt-cpp-cmake/CMakeLists.txt | 7 +- .../external-authenticators/index.md | 3 +- .../external-authenticators/tokens.md | 253 +++++++++++ src/Access/AccessControl.cpp | 20 + src/Access/AccessControl.h | 2 + src/Access/Authentication.cpp | 13 +- src/Access/AuthenticationData.cpp | 25 +- src/Access/AuthenticationData.h | 8 + src/Access/Common/JWKSProvider.cpp | 109 +++++ src/Access/Common/JWKSProvider.h | 73 ++++ src/Access/Credentials.cpp | 5 + src/Access/Credentials.h | 45 ++ src/Access/ExternalAuthenticators.cpp | 134 +++++- src/Access/ExternalAuthenticators.h | 22 + src/Access/IAccessStorage.cpp | 5 + src/Access/LDAPAccessStorage.cpp | 26 +- src/Access/LDAPAccessStorage.h | 3 +- src/Access/TokenAccessStorage.cpp | 405 +++++++++++++++++ src/Access/TokenAccessStorage.h | 82 ++++ src/Access/TokenProcessors.h | 200 +++++++++ src/Access/TokenProcessorsJWT.cpp | 410 ++++++++++++++++++ src/Access/TokenProcessorsOpaque.cpp | 387 +++++++++++++++++ src/Access/TokenProcessorsParse.cpp | 132 ++++++ src/Access/UsersConfigAccessStorage.cpp | 12 +- src/Parsers/Access/ASTAuthenticationData.cpp | 7 +- src/Parsers/Access/ASTCreateUserQuery.h | 4 +- src/Parsers/Access/ParserCreateUserQuery.cpp | 14 + src/Parsers/Access/ParserCreateUserQuery.h | 4 +- src/Parsers/CommonParsers.h | 1 + src/Server/HTTP/authenticateUserByHTTP.cpp | 20 +- src/Server/TCPHandler.cpp | 18 + src/Server/TCPHandler.h | 1 + .../System/StorageSystemBuildOptions.cpp.in | 1 + src/configure_config.cmake | 4 +- tests/integration/test_jwt_auth/__init__.py | 0 .../test_jwt_auth/configs/users.xml | 15 + .../test_jwt_auth/configs/validators.xml | 23 + .../helpers/generate_private_key.py | 21 + .../test_jwt_auth/helpers/jwt_jwk.py | 113 +++++ .../helpers/jwt_static_secret.py | 43 ++ .../test_jwt_auth/helpers/private_key_1 | 27 ++ .../test_jwt_auth/helpers/private_key_2 | 27 ++ .../test_jwt_auth/jwks_server/server.py | 33 ++ tests/integration/test_jwt_auth/test.py | 82 ++++ 45 files changed, 2805 insertions(+), 41 deletions(-) create mode 100644 docs/en/operations/external-authenticators/tokens.md create mode 100644 src/Access/Common/JWKSProvider.cpp create mode 100644 src/Access/Common/JWKSProvider.h create mode 100644 src/Access/TokenAccessStorage.cpp create mode 100644 src/Access/TokenAccessStorage.h create mode 100644 src/Access/TokenProcessors.h create mode 100644 src/Access/TokenProcessorsJWT.cpp create mode 100644 src/Access/TokenProcessorsOpaque.cpp create mode 100644 src/Access/TokenProcessorsParse.cpp create mode 100644 tests/integration/test_jwt_auth/__init__.py create mode 100644 tests/integration/test_jwt_auth/configs/users.xml create mode 100644 tests/integration/test_jwt_auth/configs/validators.xml create mode 100644 tests/integration/test_jwt_auth/helpers/generate_private_key.py create mode 100644 tests/integration/test_jwt_auth/helpers/jwt_jwk.py create mode 100644 tests/integration/test_jwt_auth/helpers/jwt_static_secret.py create mode 100644 tests/integration/test_jwt_auth/helpers/private_key_1 create mode 100644 tests/integration/test_jwt_auth/helpers/private_key_2 create mode 100644 tests/integration/test_jwt_auth/jwks_server/server.py create mode 100644 tests/integration/test_jwt_auth/test.py diff --git a/ci/jobs/scripts/check_style/aspell-ignore/en/aspell-dict.txt b/ci/jobs/scripts/check_style/aspell-ignore/en/aspell-dict.txt index 9fd8799053eb..6fcc43b0c674 100644 --- a/ci/jobs/scripts/check_style/aspell-ignore/en/aspell-dict.txt +++ b/ci/jobs/scripts/check_style/aspell-ignore/en/aspell-dict.txt @@ -291,6 +291,8 @@ DoubleDelta Doxygen Dresseler Durre +ECDSA +EdDSA ECMA ETag EachRow @@ -539,6 +541,8 @@ JoinAlgorithm JoinStrictness JumpConsistentHash Jupyter +jwks +JWKS KDevelop KafkaAssignedPartitions KafkaBackgroundReads @@ -3379,6 +3383,7 @@ uuid uuids uuidv vCPU +validators varPop varPopStable varSamp @@ -3396,6 +3401,8 @@ vectorscan vendoring verificationDepth verificationMode +verifier +verifiers versionedcollapsingmergetree vhost virtualized diff --git a/contrib/jwt-cpp-cmake/CMakeLists.txt b/contrib/jwt-cpp-cmake/CMakeLists.txt index 4cb8716bc68f..606c13d29de2 100644 --- a/contrib/jwt-cpp-cmake/CMakeLists.txt +++ b/contrib/jwt-cpp-cmake/CMakeLists.txt @@ -1,7 +1,4 @@ -set(ENABLE_JWT_CPP_DEFAULT OFF) -if(ENABLE_LIBRARIES AND CLICKHOUSE_CLOUD) - set(ENABLE_JWT_CPP_DEFAULT ON) -endif() +set(ENABLE_JWT_CPP_DEFAULT ON) option(ENABLE_JWT_CPP "Enable jwt-cpp library" ${ENABLE_JWT_CPP_DEFAULT}) @@ -20,4 +17,4 @@ set (JWT_CPP_INCLUDE_DIR "${ClickHouse_SOURCE_DIR}/contrib/jwt-cpp/include") add_library (_jwt-cpp INTERFACE) target_include_directories(_jwt-cpp SYSTEM BEFORE INTERFACE ${JWT_CPP_INCLUDE_DIR}) -add_library(ch_contrib::jwt-cpp ALIAS _jwt-cpp) +add_library(ch_contrib::jwt-cpp ALIAS _jwt-cpp) \ No newline at end of file diff --git a/docs/en/operations/external-authenticators/index.md b/docs/en/operations/external-authenticators/index.md index 5a28003ad6c5..1391b7d14ce9 100644 --- a/docs/en/operations/external-authenticators/index.md +++ b/docs/en/operations/external-authenticators/index.md @@ -18,4 +18,5 @@ The following external authenticators and directories are supported: - [LDAP](/operations/external-authenticators/ldap#ldap-external-authenticator) [Authenticator](./ldap.md#ldap-external-authenticator) and [Directory](./ldap.md#ldap-external-user-directory) - Kerberos [Authenticator](/operations/external-authenticators/kerberos#kerberos-as-an-external-authenticator-for-existing-users) - [SSL X.509 authentication](/operations/external-authenticators/ssl-x509) -- HTTP [Authenticator](./http.md) \ No newline at end of file +- HTTP [Authenticator](./http.md) +- Token-based [Authenticator](./tokens.md) diff --git a/docs/en/operations/external-authenticators/tokens.md b/docs/en/operations/external-authenticators/tokens.md new file mode 100644 index 000000000000..127fee7a7d5d --- /dev/null +++ b/docs/en/operations/external-authenticators/tokens.md @@ -0,0 +1,253 @@ +--- +slug: /en/operations/external-authenticators/oauth +title: "Token-based authentication" +--- +import SelfManaged from '@site/docs/en/_snippets/_self_managed_only_no_roadmap.md'; + + + +ClickHouse users can be authenticated using tokens. This works in two ways: + +- An existing user (defined in `users.xml` or in local access control paths) can be authenticated with a token if this user can be `IDENTIFIED WITH jwt`. +- Use the information from the token or from an external Identity Provider (IdP) as a source of user definitions and allow locally undefined users to be authenticated with a valid token. + +Although not all tokens are JWTs, under the hood both ways are treated as the same authentication method to maintain better compatibility. + +# Token Processors + +## Configuration +To use token-based authentication, add `token_processors` section to `config.xml` and define at least one token processor in it. +Its contents are different for different token processor types. + +**Common parameters** +- `type` -- type of token processor. Supported values: "jwt_static_key", "jwt_static_jwks", "jwt_dynamic_jwks", "azure", "openid". Mandatory. Case-insensitive. +- `token_cache_lifetime` -- maximum lifetime of cached token (in seconds). Optional, default: 3600. +- `username_claim` -- name of claim (field) that will be treated as ClickHouse username. Optional, default: "sub". +- `groups_claim` -- name of claim (field) that contains list of groups user belongs to. This claim will be looked up in the token itself (in case token is a valid JWT, e.g. in Keycloak) or in response from `/userinfo`. Optional, default: "groups". + +For each type, there are additional specific parameters (some of them are mandatory). +If some parameters that are not required for current processor type are specified, they are ignored. + +## JWT (JSON Web Token) + +JWT itself is a source of information about user. +It is decoded locally and its integrity is verified using either a local static key or JWKS (JSON Web Key Set), local or remote. + +### JWT with static key: +```xml + + + + jwt_static_key + HS256 + my_static_secret + + + +``` +**Parameters:** +- `algo` - Algorithm for signature validation. Mandatory. Supported values: + + | HMAC | RSA | ECDSA | PSS | EdDSA | + |-------| ----- | ------ | ----- | ------- | + | HS256 | RS256 | ES256 | PS256 | Ed25519 | + | HS384 | RS384 | ES384 | PS384 | Ed448 | + | HS512 | RS512 | ES512 | PS512 | | + | | | ES256K | | | + Also supports None (though not recommended). +`claims` - A string containing a JSON object that should be contained in the token payload. If this parameter is defined, token without corresponding payload will be considered invalid. Optional. +- `static_key` - key for symmetric algorithms. Mandatory for `HS*` family algorithms. +- `static_key_in_base64` - indicates if the `static_key` key is base64-encoded. Optional, default: `False`. +- `public_key` - public key for asymmetric algorithms. Mandatory except for `HS*` family algorithms and `None`. +- `private_key` - private key for asymmetric algorithms. Optional. +- `public_key_password` - public key password. Optional. +- `private_key_password` - private key password. Optional. + +### JWT with static JWKS +```xml + + + + jwt_static_jwks + {"keys": [{"kty": "RSA", "alg": "RS256", "kid": "mykid", "n": "_public_key_mod_", "e": "AQAB"}]} + + + +``` + +**Parameters:** + +- `static_jwks` - content of JWKS in JSON +- `static_jwks_file` - path to a file with JWKS +- `claims` - A string containing a JSON object that should be contained in the token payload. If this parameter is defined, token without corresponding payload will be considered invalid. Optional. +- `verifier_leeway` - Clock skew tolerance (seconds). Useful for handling small differences in system clocks between ClickHouse and the token issuer. Optional. + +:::note +Only one of `static_jwks` or `static_jwks_file` keys must be present in one verifier +::: + +:::note +Only RS* family algorithms are supported! +::: + +### JWT with remote JWKS +```xml + + + + jwt_dynamic_jwks + http://localhost:8000/.well-known/jwks.json + 3600 + + + +``` + +**Parameters:** + +- `uri` - JWKS endpoint. Mandatory. +- `jwks_cache_lifetime` - Period for resend request for refreshing JWKS. Optional, default: 3600. +- `claims` - A string containing a JSON object that should be contained in the token payload. If this parameter is defined, token without corresponding payload will be considered invalid. Optional. +- `verifier_leeway` - Clock skew tolerance (seconds). Useful for handling small differences in system clocks between ClickHouse and the token issuer. Optional. + + +## Processors with external providers + +Some tokens cannot be decoded and validated locally. External service is needed in this case. "Azure" and "OpenID" (a generic type) are supported now. + +### Azure +```xml + + + + azure + + + +``` + +No additional parameters are required. + +### OpenID +```xml + + + + openid + url/.well-known/openid-configuration + 60 + 3600 + + + openid + url/userinfo + url/tokeninfo + url/.well-known/jwks.json + 60 + 3600 + + + +``` + +:::note +Either `configuration_endpoint` or both `userinfo_endpoint` and `token_introspection_endpoint` (and, optionally, `jwks_uri`) shall be set. If none of them are set or all three are set, this is an invalid configuration that will not be parsed. +::: + +**Parameters:** + +- `configuration_endpoint` - URI of OpenID configuration (often ends with `.well-known/openid-configuration`); +- `userinfo_endpoint` - URI of endpoint that returns user information in exchange for a valid token; +- `token_introspection_endpoint` - URI of token introspection endpoint (returns information about a valid token); +- `jwks_uri` - URI of OpenID configuration (often ends with `.well-known/jwks.json`) +- `jwks_cache_lifetime` - Period for resend request for refreshing JWKS. Optional, default: 3600. +- `verifier_leeway` - Clock skew tolerance (seconds). Useful for handling small differences in system clocks between ClickHouse and the token issuer. Optional, default: 60 + +Sometimes a token is a valid JWT. In that case token will be decoded and validated locally if configuration endpoint returns JWKS URI (or `jwks_uri` is specified alongside `userinfo_endpoint` and `token_introspection_endpoint`). + +### Tokens cache +To reduce number of requests to IdP, tokens are cached internally for a maximum period of `token_cache_lifetime` seconds. +If token expires sooner than `token_cache_lifetime`, then cache entry for this token will only be valid while token is valid. +If token lifetime is longer than `token_cache_lifetime`, cache entry for this token will be valid for `token_cache_lifetime`. + +## Enabling token authentication for a user in `users.xml` {#enabling-jwt-auth-in-users-xml} + +In order to enable token-based authentication for the user, specify `jwt` section instead of `password` or other similar sections in the user definition. + +Parameters: +- `claims` - An optional string containing a json object that should be contained in the token payload. + +Example (goes into `users.xml`): +```xml + + + + {"resource_access":{"account": {"roles": ["view-profile"]}}} + + + +``` + +Here, the JWT payload must contain `["view-profile"]` on path `resource_access.account.roles`, otherwise authentication will not succeed even with a valid JWT. + +:::note +If `claims` is defined, this user will not be able to authenticate using opaque tokens, so, only JWT-based authentication will be available. +::: + +``` +{ +... + "resource_access": { + "account": { + "roles": ["view-profile"] + } + }, +... +} +``` + +:::note +A user cannot have JWT authentication together with any other authentication method. The presence of any other sections like `password` alongside `jwt` will force ClickHouse to shut down. +::: + +## Enabling token authentication using SQL {#enabling-jwt-auth-using-sql} + +Users with "JWT" authentication type cannot be created using SQL now. + +## Identity Provider as an External User Directory {#idp-external-user-directory} + +If there is no suitable user pre-defined in ClickHouse, authentication is still possible: Identity Provider can be used as source of user information. +To allow this, add `token` section to the `users_directories` section of the `config.xml` file. + +At each login attempt, ClickHouse tries to find the user definition locally and authenticate it as usual. +If a token is provided but the user is not defined, ClickHouse will treat the user as externally defined and will try to validate the token and get user information from the specified processor. +If validated successfully, the user will be considered existing and authenticated. The user will be assigned roles from the list specified in the `roles` section. +All this implies that the SQL-driven [Access Control and Account Management](/docs/en/guides/sre/user-management/index.md#access-control) is enabled and roles are created using the [CREATE ROLE](/docs/en/sql-reference/statements/create/role.md#create-role-statement) statement. + +**Example** + +```xml + + + + token_processor_name + + + + + \bclickhouse-[a-zA-Z0-9]+\b + + + + +``` + +:::note +For now, no more than one `token` section can be defined inside `user_directories`. This _may_ change in future. +::: + +**Parameters** + +- `processor` — Name of one of processors defined in `token_processors` config section described above. This parameter is mandatory and cannot be empty. +- `common_roles` — Section with a list of locally defined roles that will be assigned to each user retrieved from the IdP. Optional. +- `roles_filter` — Regex string for groups filtering. Only groups matching this regex will be mapped to roles. Optional. diff --git a/src/Access/AccessControl.cpp b/src/Access/AccessControl.cpp index 70818a25af16..4469bc83415a 100644 --- a/src/Access/AccessControl.cpp +++ b/src/Access/AccessControl.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -41,6 +42,7 @@ namespace ErrorCodes extern const int REQUIRED_PASSWORD; extern const int CANNOT_COMPILE_REGEXP; extern const int BAD_ARGUMENTS; + extern const int INVALID_CONFIG_PARAMETER; } namespace @@ -422,6 +424,12 @@ void AccessControl::addLDAPStorage(const String & storage_name_, const Poco::Uti LOG_DEBUG(getLogger(), "Added {} access storage '{}', LDAP server name: {}", String(new_storage->getStorageType()), new_storage->getStorageName(), new_storage->getLDAPServerName()); } +void AccessControl::addTokenStorage(const String & storage_name_, const Poco::Util::AbstractConfiguration & config_, const String & prefix_) +{ + auto new_storage = std::make_shared(storage_name_, *this, config_, prefix_); + addStorage(new_storage); + LOG_DEBUG(getLogger(), "Added {} access storage '{}'", String(new_storage->getStorageType()), new_storage->getStorageName()); +} void AccessControl::addStoragesFromUserDirectoriesConfig( const Poco::Util::AbstractConfiguration & config, @@ -434,6 +442,8 @@ void AccessControl::addStoragesFromUserDirectoriesConfig( Strings keys_in_user_directories; config.keys(key, keys_in_user_directories); + bool has_token_storage = false; + for (const String & key_in_user_directories : keys_in_user_directories) { String prefix = key + "." + key_in_user_directories; @@ -447,6 +457,8 @@ void AccessControl::addStoragesFromUserDirectoriesConfig( type = DiskAccessStorage::STORAGE_TYPE; else if (type == "ldap") type = LDAPAccessStorage::STORAGE_TYPE; + else if (type == "token") + type = TokenAccessStorage::STORAGE_TYPE; String name = config.getString(prefix + ".name", type); @@ -480,6 +492,14 @@ void AccessControl::addStoragesFromUserDirectoriesConfig( bool allow_backup = config.getBool(prefix + ".allow_backup", true); addReplicatedStorage(name, zookeeper_path, get_zookeeper_function, allow_backup); } + else if (type == TokenAccessStorage::STORAGE_TYPE) + { + if (has_token_storage) + throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "Only one `token` section can be defined."); + + addTokenStorage(name, config, prefix); + has_token_storage = true; + } else throw Exception(ErrorCodes::UNKNOWN_ELEMENT_IN_CONFIG, "Unknown storage type '{}' at {} in config", type, prefix); } diff --git a/src/Access/AccessControl.h b/src/Access/AccessControl.h index c1e32fc7c467..0af5168c761a 100644 --- a/src/Access/AccessControl.h +++ b/src/Access/AccessControl.h @@ -93,6 +93,8 @@ class AccessControl : public MultipleAccessStorage /// Adds LDAPAccessStorage which allows querying remote LDAP server for user info. void addLDAPStorage(const String & storage_name_, const Poco::Util::AbstractConfiguration & config_, const String & prefix_); + void addTokenStorage(const String & storage_name_, const Poco::Util::AbstractConfiguration & config_, const String & prefix_); + void addReplicatedStorage(const String & storage_name, const String & zookeeper_path, const zkutil::GetZooKeeper & get_zookeeper_function, diff --git a/src/Access/Authentication.cpp b/src/Access/Authentication.cpp index 03e47ed3b6d6..66cad391932c 100644 --- a/src/Access/Authentication.cpp +++ b/src/Access/Authentication.cpp @@ -298,7 +298,10 @@ bool Authentication::areCredentialsValid( const ClientInfo & client_info, SettingsChanges & settings) { - if (!credentials.isReady()) + /// It is OK for TokenCredentials to be not ready: + /// When auth request happens, we do not even know the username. + /// Token is resolved a bit later and the user information will be put in credentials + if (!typeid_cast(&credentials) && !credentials.isReady()) return false; if (const auto * gss_acceptor_context = typeid_cast(&credentials)) @@ -340,6 +343,14 @@ bool Authentication::areCredentialsValid( } #endif + if (const auto * token_credentials = typeid_cast(&credentials)) + { + if (authentication_method.getType() != AuthenticationType::JWT) + return false; + + return external_authenticators.checkTokenCredentials(*token_credentials); + } + if ([[maybe_unused]] const auto * always_allow_credentials = typeid_cast(&credentials)) return true; diff --git a/src/Access/AuthenticationData.cpp b/src/Access/AuthenticationData.cpp index c6760c4a0928..3e8e2e42a8d0 100644 --- a/src/Access/AuthenticationData.cpp +++ b/src/Access/AuthenticationData.cpp @@ -27,6 +27,10 @@ # include #endif +#if USE_JWT_CPP +#include +#endif + namespace DB { @@ -375,7 +379,10 @@ std::shared_ptr AuthenticationData::toAST() const } case AuthenticationType::JWT: { - throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "JWT is available only in ClickHouse Cloud"); + const auto & claims = getJWTClaims(); + if (!claims.empty()) + node->children.push_back(std::make_shared(claims)); + break; } case AuthenticationType::KERBEROS: { @@ -653,6 +660,22 @@ AuthenticationData AuthenticationData::fromAST(const ASTAuthenticationData & que auth_data.setHTTPAuthenticationServerName(server); auth_data.setHTTPAuthenticationScheme(scheme); } +#if USE_JWT_CPP + else if (query.type == AuthenticationType::JWT) + { + if (!args.empty()) + { + String value = checkAndGetLiteralArgument(args[0], "claims"); + picojson::value json_obj; + auto error = picojson::parse(json_obj, value); + if (!error.empty()) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Bad JWT claims: {}", error); + if (!json_obj.is()) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Bad JWT claims: is not an object"); + auth_data.setJWTClaims(value); + } + } +#endif else { throw Exception(ErrorCodes::LOGICAL_ERROR, "Unexpected ASTAuthenticationData structure"); diff --git a/src/Access/AuthenticationData.h b/src/Access/AuthenticationData.h index 187a17dca948..bcd7bd61270f 100644 --- a/src/Access/AuthenticationData.h +++ b/src/Access/AuthenticationData.h @@ -79,6 +79,12 @@ class AuthenticationData time_t getValidUntil() const { return valid_until; } void setValidUntil(time_t valid_until_) { valid_until = valid_until_; } + const String & getJWTClaims() const { return jwt_claims; } + void setJWTClaims(const String & jwt_claims_) { jwt_claims = jwt_claims_; } + + const String & getTokenProcessorName() const { return token_processor_name; } + void setTokenProcessorName(const String & token_processor_name_) { token_processor_name = token_processor_name_; } + friend bool operator ==(const AuthenticationData & lhs, const AuthenticationData & rhs); friend bool operator !=(const AuthenticationData & lhs, const AuthenticationData & rhs) { return !(lhs == rhs); } @@ -117,6 +123,8 @@ class AuthenticationData String http_auth_server_name; HTTPAuthenticationScheme http_auth_scheme = HTTPAuthenticationScheme::BASIC; time_t valid_until = 0; + String jwt_claims; + String token_processor_name; }; } diff --git a/src/Access/Common/JWKSProvider.cpp b/src/Access/Common/JWKSProvider.cpp new file mode 100644 index 000000000000..d9d29636c90f --- /dev/null +++ b/src/Access/Common/JWKSProvider.cpp @@ -0,0 +1,109 @@ +#include + +#if USE_JWT_CPP +#include +#include +#include +#include +#include +#include + + +namespace DB +{ + +namespace ErrorCodes +{ + extern const int AUTHENTICATION_FAILED; + extern const int INVALID_CONFIG_PARAMETER; +} + +JWKSType JWKSClient::getJWKS() +{ + std::shared_lock lock(mutex); + + auto now = std::chrono::high_resolution_clock::now(); + auto diff = std::chrono::duration(now - last_request_send).count(); + + if (diff < refresh_timeout) + { + jwt::jwks result(cached_jwks); + return result; + } + + Poco::Net::HTTPResponse response; + std::string response_string; + + Poco::Net::HTTPRequest request{Poco::Net::HTTPRequest::HTTP_GET, jwks_uri.getPathAndQuery()}; + + if (jwks_uri.getScheme() == "https") + { + Poco::Net::HTTPSClientSession session = Poco::Net::HTTPSClientSession(jwks_uri.getHost(), jwks_uri.getPort()); + session.sendRequest(request); + std::istream & response_stream = session.receiveResponse(response); + if (response.getStatus() != Poco::Net::HTTPResponse::HTTP_OK || !response_stream) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Failed to get user info by access token, code: {}, reason: {}", + response.getStatus(), response.getReason()); + Poco::StreamCopier::copyToString(response_stream, response_string); + } + else + { + Poco::Net::HTTPClientSession session = Poco::Net::HTTPClientSession(jwks_uri.getHost(), jwks_uri.getPort()); + session.sendRequest(request); + std::istream & response_stream = session.receiveResponse(response); + if (response.getStatus() != Poco::Net::HTTPResponse::HTTP_OK || !response_stream) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Failed to get user info by access token, code: {}, reason: {}", response.getStatus(), response.getReason()); + Poco::StreamCopier::copyToString(response_stream, response_string); + } + + last_request_send = std::chrono::high_resolution_clock::now(); + + JWKSType parsed_jwks; + + try + { + parsed_jwks = jwt::parse_jwks(response_string); + } + catch (const std::exception & e) + { + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Failed to parse JWKS: {}", e.what()); + } + + cached_jwks = std::move(parsed_jwks); + return cached_jwks; +} + +StaticJWKSParams::StaticJWKSParams(const std::string & static_jwks_, const std::string & static_jwks_file_) +{ + if (static_jwks_.empty() && static_jwks_file_.empty()) + throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, + "JWT validator misconfigured: `static_jwks` or `static_jwks_file` keys must be present in static JWKS validator configuration"); + if (!static_jwks_.empty() && !static_jwks_file_.empty()) + throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, + "JWT validator misconfigured: `static_jwks` and `static_jwks_file` keys cannot both be present in static JWKS validator configuration"); + + static_jwks = static_jwks_; + static_jwks_file = static_jwks_file_; +} + +StaticJWKS::StaticJWKS(const StaticJWKSParams & params) +{ + String content = String(params.static_jwks); + if (!params.static_jwks_file.empty()) + { + std::ifstream ifs(params.static_jwks_file); + Poco::StreamCopier::copyToString(ifs, content); + } + try + { + auto keys = jwt::parse_jwks(content); + jwks = std::move(keys); + } + catch (const std::exception & e) + { + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Failed to parse JWKS: {}", e.what()); + } +} + +} +#endif diff --git a/src/Access/Common/JWKSProvider.h b/src/Access/Common/JWKSProvider.h new file mode 100644 index 000000000000..6b2f41a58b72 --- /dev/null +++ b/src/Access/Common/JWKSProvider.h @@ -0,0 +1,73 @@ +#include + +#if USE_JWT_CPP +#include +#include +#include +#include + +#include + +namespace DB +{ + +using JWKSType = jwt::jwks; + +/// JWKS (JSON Web Key Set) is a kind of a set of public keys that are used to validate JWT authenticity locally. +/// They are usually exposed by identity providers (e.g. Keycloak) via a well-known URI (usually /.well-known/jwks.json) +/// This interface is responsible for managing JWKS. Retrieving, caching and refreshing of JWKS happens here. +/// JWKS can either be static (e.g. provided in config) or dynamic (fetched from a remote URI and). +class IJWKSProvider +{ +public: + virtual ~IJWKSProvider() = default; + + virtual JWKSType getJWKS() = 0; +}; + +class JWKSClient : public IJWKSProvider +{ +public: + explicit JWKSClient(const String & uri, const size_t refresh_ms_): refresh_timeout(refresh_ms_), jwks_uri(uri) {} + + ~JWKSClient() override = default; + JWKSClient(const JWKSClient &) = delete; + JWKSClient(JWKSClient &&) = delete; + JWKSClient &operator=(const JWKSClient &) = delete; + JWKSClient &operator=(JWKSClient &&) = delete; + + JWKSType getJWKS() override; + +private: + size_t refresh_timeout; + Poco::URI jwks_uri; + + std::shared_mutex mutex; + JWKSType cached_jwks; + std::chrono::time_point last_request_send; +}; + +struct StaticJWKSParams +{ + StaticJWKSParams(const std::string &static_jwks_, const std::string &static_jwks_file_); + + String static_jwks; + String static_jwks_file; +}; + +class StaticJWKS : public IJWKSProvider +{ +public: + explicit StaticJWKS(const StaticJWKSParams ¶ms); + +private: + JWKSType getJWKS() override + { + return jwks; + } + + JWKSType jwks; +}; + +} +#endif diff --git a/src/Access/Credentials.cpp b/src/Access/Credentials.cpp index 4887d0545656..c60fb3cfea67 100644 --- a/src/Access/Credentials.cpp +++ b/src/Access/Credentials.cpp @@ -2,6 +2,7 @@ #include #include #include +#include namespace DB { @@ -9,6 +10,7 @@ namespace DB namespace ErrorCodes { extern const int LOGICAL_ERROR; + extern const int AUTHENTICATION_FAILED; } Credentials::Credentials(const String & user_name_) @@ -100,4 +102,7 @@ const String & BasicCredentials::getPassword() const return password; } +/// Unless the token is validated, we will not use any data from it, including username. +TokenCredentials::TokenCredentials(const String & token_) : Credentials(""), token(token_), expires_at(std::chrono::system_clock::now() + std::chrono::hours(1)) {} + } diff --git a/src/Access/Credentials.h b/src/Access/Credentials.h index f98eb31ff0a2..bb81ef93a15c 100644 --- a/src/Access/Credentials.h +++ b/src/Access/Credentials.h @@ -16,6 +16,8 @@ namespace Poco::Net class SocketAddress; } +#include + namespace DB { @@ -195,4 +197,47 @@ class SSHPTYCredentials : public Credentials #endif +class TokenCredentials : public Credentials +{ +public: + explicit TokenCredentials(const String & token_); + + const String & getToken() const + { + if (token.empty()) + { + throwNotReady(); + } + return token; + } + void setUserName(const String & user_name_) + { + user_name = user_name_; + if (!user_name.empty()) + { + is_ready = true; + } + } + std::set getGroups() const + { + return groups; + } + void setGroups(const std::set & groups_) + { + groups = groups_; + } + std::optional getExpiresAt() const + { + return expires_at; + } + void setExpiresAt(std::chrono::system_clock::time_point expires_at_) + { + expires_at = expires_at_; + } +private: + String token; + std::set groups; + std::optional expires_at; +}; + } diff --git a/src/Access/ExternalAuthenticators.cpp b/src/Access/ExternalAuthenticators.cpp index ca61b55dc5dc..9e6a27cec715 100644 --- a/src/Access/ExternalAuthenticators.cpp +++ b/src/Access/ExternalAuthenticators.cpp @@ -1,7 +1,10 @@ +#include #include #include #include #include +#include "Common/Logger.h" +#include "Common/logger_useful.h" #include #include #include @@ -12,6 +15,8 @@ #include #include +#include +#include #include #include @@ -263,7 +268,6 @@ HTTPAuthClientParams parseHTTPAuthParams(const Poco::Util::AbstractConfiguration return http_auth_params; } - } void parseLDAPRoleSearchParams(LDAPClient::RoleSearchParams & params, const Poco::Util::AbstractConfiguration & config, const String & prefix) @@ -281,6 +285,7 @@ void ExternalAuthenticators::resetImpl() ldap_client_params_blueprint.clear(); ldap_caches.clear(); kerberos_params.reset(); + token_processors.clear(); } void ExternalAuthenticators::reset() @@ -289,6 +294,30 @@ void ExternalAuthenticators::reset() resetImpl(); } +void parseTokenProcessors(std::unordered_map> & token_processors, + const Poco::Util::AbstractConfiguration & config, + const String & token_processors_config, + LoggerPtr log) +{ + Poco::Util::AbstractConfiguration::Keys token_processors_keys; + config.keys(token_processors_config, token_processors_keys); + + token_processors.clear(); + + for (const auto & processor : token_processors_keys) + { + String prefix = fmt::format("{}.{}", token_processors_config, processor); + try + { + token_processors[processor] = ITokenProcessor::parseTokenProcessor(config, prefix, processor); + } + catch (...) + { + tryLogCurrentException(log, "Could not parse token processor" + backQuote(processor)); + } + } +} + void ExternalAuthenticators::setConfiguration(const Poco::Util::AbstractConfiguration & config, LoggerPtr log) { std::lock_guard lock(mutex); @@ -300,8 +329,12 @@ void ExternalAuthenticators::setConfiguration(const Poco::Util::AbstractConfigur std::size_t ldap_servers_key_count = 0; std::size_t kerberos_keys_count = 0; std::size_t http_auth_server_keys_count = 0; + std::size_t jwt_validators_count = 0; + std::size_t token_processors_count = 0; const String http_auth_servers_config = "http_authentication_servers"; + const String jwt_validators_config = "jwt_validators"; + const String token_processors_config = "token_processors"; for (auto key : all_keys) { @@ -314,6 +347,8 @@ void ExternalAuthenticators::setConfiguration(const Poco::Util::AbstractConfigur ldap_servers_key_count += (key == "ldap_servers"); kerberos_keys_count += (key == "kerberos"); http_auth_server_keys_count += (key == http_auth_servers_config); + jwt_validators_count += (key == jwt_validators_config); + token_processors_count += (key == token_processors_config); } if (ldap_servers_key_count > 1) @@ -325,6 +360,12 @@ void ExternalAuthenticators::setConfiguration(const Poco::Util::AbstractConfigur if (http_auth_server_keys_count > 1) throw Exception(ErrorCodes::BAD_ARGUMENTS, "Multiple http_authentication_servers sections are not allowed"); + if (jwt_validators_count > 1) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Multiple {} sections are not allowed", jwt_validators_config); + + if (token_processors_count > 1) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Multiple {} sections are not allowed", token_processors_config); + Poco::Util::AbstractConfiguration::Keys http_auth_server_names; config.keys(http_auth_servers_config, http_auth_server_names); http_auth_servers.clear(); @@ -379,6 +420,8 @@ void ExternalAuthenticators::setConfiguration(const Poco::Util::AbstractConfigur { tryLogCurrentException(log, "Could not parse Kerberos section"); } + + parseTokenProcessors(token_processors, config, token_processors_config, log); } static UInt128 computeParamsHash(const LDAPClient::Params & params, const LDAPClient::RoleSearchParamsList * role_search_params) @@ -547,7 +590,7 @@ GSSAcceptorContext::Params ExternalAuthenticators::getKerberosParams() const return kerberos_params.value(); } -HTTPAuthClientParams ExternalAuthenticators::getHTTPAuthenticationParams(const String& server) const +HTTPAuthClientParams ExternalAuthenticators::getHTTPAuthenticationParams(const String & server) const { std::lock_guard lock{mutex}; @@ -557,6 +600,93 @@ HTTPAuthClientParams ExternalAuthenticators::getHTTPAuthenticationParams(const S return it->second; } +bool ExternalAuthenticators::checkCredentialsAgainstProcessor(const ITokenProcessor & processor, + TokenCredentials & credentials) const +{ + if (processor.resolveAndValidate(credentials)) + { + TokenCacheEntry cache_entry; + cache_entry.user_name = credentials.getUserName(); + cache_entry.external_roles = credentials.getGroups(); + + auto default_expiration_ts = std::chrono::system_clock::now() + + std::chrono::minutes(processor.getTokenCacheLifetime()); + + if (credentials.getExpiresAt().has_value()) + { + if (credentials.getExpiresAt().value() < default_expiration_ts) + cache_entry.expires_at = credentials.getExpiresAt().value(); + else + LOG_TRACE(getLogger("AccessTokenAuthentication"), "Attempt to authenticate user {} with expired access token by {}", credentials.getUserName(), processor.getProcessorName()); + + } + else + { + cache_entry.expires_at = default_expiration_ts; + } + + LOG_DEBUG(getLogger("AccessTokenAuthentication"), "Authenticated user {} with access token by {}", credentials.getUserName(), processor.getProcessorName()); + + // CHeck if a cache entry for the same user but with another token exists -- old cache entry is considered outdated and removed + auto old_token_iter = username_to_access_token_cache.find(cache_entry.user_name); + if (old_token_iter != username_to_access_token_cache.end()) + { + access_token_to_username_cache.erase(old_token_iter->second); + username_to_access_token_cache.erase(old_token_iter); + } + + access_token_to_username_cache[credentials.getToken()] = cache_entry; + username_to_access_token_cache[cache_entry.user_name] = credentials.getToken(); + LOG_TRACE(getLogger("AccessTokenAuthentication"), "Cache entry for user {} added", cache_entry.user_name); + + return true; + } + LOG_TRACE(getLogger("AccessTokenAuthentication"), "Failed authentication with access token by {}", processor.getProcessorName()); + + return false; +} + +bool ExternalAuthenticators::checkTokenCredentials(const TokenCredentials & credentials, const String & processor_name) const +{ + std::lock_guard lock{mutex}; + + if (token_processors.empty()) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Token authentication is not configured"); + + /// lookup token in local cache if not expired. + auto cached_entry_iter = access_token_to_username_cache.find(credentials.getToken()); + if (cached_entry_iter != access_token_to_username_cache.end()) + { + if (cached_entry_iter->second.expires_at <= std::chrono::system_clock::now()) // Token found in cache, but already outdated -- need to remove it. + { + LOG_TRACE(getLogger("AccessTokenAuthentication"), "Cache entry for user {} expired, removing", cached_entry_iter->second.user_name); + access_token_to_username_cache.erase(cached_entry_iter); + username_to_access_token_cache.erase(cached_entry_iter->second.user_name); + } + else + { + const auto & user_data = cached_entry_iter->second; + const_cast(credentials).setUserName(user_data.user_name); + const_cast(credentials).setGroups(user_data.external_roles); + LOG_TRACE(getLogger("AccessTokenAuthentication"), "Cache entry for user {} found, using it to authenticate", cached_entry_iter->second.user_name); + return true; + } + } + + if (processor_name.empty()) + { + for (const auto & it: token_processors) + { + if (checkCredentialsAgainstProcessor(*it.second, const_cast(credentials))) + return true; + } + } + else + return token_processors.contains(processor_name) && checkCredentialsAgainstProcessor(*token_processors[processor_name], const_cast(credentials)); + + return false; +} + bool ExternalAuthenticators::checkHTTPBasicCredentials( const String & server, const BasicCredentials & credentials, const ClientInfo & client_info, SettingsChanges & settings) const { diff --git a/src/Access/ExternalAuthenticators.h b/src/Access/ExternalAuthenticators.h index 6aa26bb3842a..d24f64a552d9 100644 --- a/src/Access/ExternalAuthenticators.h +++ b/src/Access/ExternalAuthenticators.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -13,6 +14,7 @@ #include #include +#include #include #include #include @@ -45,6 +47,8 @@ class ExternalAuthenticators bool checkKerberosCredentials(const String & realm, const GSSAcceptorContext & credentials) const; bool checkHTTPBasicCredentials(const String & server, const BasicCredentials & credentials, const ClientInfo & client_info, SettingsChanges & settings) const; + bool checkTokenCredentials(const TokenCredentials & credentials, const String & processor_name = "") const; + GSSAcceptorContext::Params getKerberosParams() const; private: @@ -66,6 +70,24 @@ class ExternalAuthenticators mutable LDAPCaches ldap_caches TSA_GUARDED_BY(mutex) ; std::optional kerberos_params TSA_GUARDED_BY(mutex) ; std::unordered_map http_auth_servers TSA_GUARDED_BY(mutex) ; + mutable std::unordered_map> token_processors TSA_GUARDED_BY(mutex) ; + + struct TokenCacheEntry + { + std::chrono::system_clock::time_point expires_at; + String user_name; + std::set external_roles; + }; + + /// Home-made simple bi-mapping, needed to effectively clean up cache from old tokens. + using TokenToUsernameCache = std::unordered_map; // Access token -> cache entry + using UsernameToTokenCache = std::unordered_map; // User name -> access token + + mutable TokenToUsernameCache access_token_to_username_cache TSA_GUARDED_BY(mutex) ; + mutable UsernameToTokenCache username_to_access_token_cache TSA_GUARDED_BY(mutex) ; + + bool checkCredentialsAgainstProcessor(const ITokenProcessor & processor, + TokenCredentials & credentials) const TSA_REQUIRES(mutex); void resetImpl() TSA_REQUIRES(mutex); }; diff --git a/src/Access/IAccessStorage.cpp b/src/Access/IAccessStorage.cpp index 3ac24ff1e820..fdc96a6a9e0b 100644 --- a/src/Access/IAccessStorage.cpp +++ b/src/Access/IAccessStorage.cpp @@ -11,6 +11,7 @@ #include #include #include +#include "Access/Common/AuthenticationType.h" #include #include #include @@ -33,6 +34,7 @@ namespace ErrorCodes extern const int ACCESS_ENTITY_NOT_FOUND; extern const int ACCESS_STORAGE_READONLY; extern const int ACCESS_STORAGE_DOESNT_ALLOW_BACKUP; + extern const int AUTHENTICATION_FAILED; extern const int WRONG_PASSWORD; extern const int IP_ADDRESS_NOT_ALLOWED; extern const int LOGICAL_ERROR; @@ -538,6 +540,9 @@ std::optional IAccessStorage::authenticateImpl( bool allow_no_password, bool allow_plaintext_password) const { + if (typeid_cast(&credentials) && !typeid_cast(&credentials)->isReady()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Could not resolve username from token"); + if (auto id = find(credentials.getUserName())) { if (auto user = tryRead(*id)) diff --git a/src/Access/LDAPAccessStorage.cpp b/src/Access/LDAPAccessStorage.cpp index fc55c6b081b3..7f39effce778 100644 --- a/src/Access/LDAPAccessStorage.cpp +++ b/src/Access/LDAPAccessStorage.cpp @@ -84,7 +84,7 @@ void LDAPAccessStorage::setConfiguration(const Poco::Util::AbstractConfiguration role_search_params.swap(role_search_params_cfg); common_role_names.swap(common_roles_cfg); - external_role_hashes.clear(); + users_external_roles.clear(); users_per_roles.clear(); roles_per_users.clear(); granted_role_names.clear(); @@ -197,13 +197,6 @@ void LDAPAccessStorage::applyRoleChangeNoLock(bool grant, const UUID & role_id, void LDAPAccessStorage::assignRolesNoLock(User & user, const LDAPClient::SearchResultsList & external_roles) const -{ - const auto external_roles_hash = boost::hash{}(external_roles); - assignRolesNoLock(user, external_roles, external_roles_hash); -} - - -void LDAPAccessStorage::assignRolesNoLock(User & user, const LDAPClient::SearchResultsList & external_roles, std::size_t external_roles_hash) const { const auto & user_name = user.getName(); auto & granted_roles = user.granted_roles; @@ -232,7 +225,7 @@ void LDAPAccessStorage::assignRolesNoLock(User & user, const LDAPClient::SearchR } }; - external_role_hashes.erase(user_name); + users_external_roles.erase(user_name); granted_roles = {}; const auto old_role_names = std::move(roles_per_users[user_name]); @@ -280,32 +273,29 @@ void LDAPAccessStorage::assignRolesNoLock(User & user, const LDAPClient::SearchR granted_role_ids.erase(iit); } - // Actualize roles_per_users mapping and external_role_hashes cache. + // Actualize roles_per_users mapping and users_external_roles cache. if (local_role_names.empty()) roles_per_users.erase(user_name); else roles_per_users[user_name] = std::move(local_role_names); - external_role_hashes[user_name] = external_roles_hash; + users_external_roles[user_name] = external_roles; } void LDAPAccessStorage::updateAssignedRolesNoLock(const UUID & id, const String & user_name, const LDAPClient::SearchResultsList & external_roles) const { - // No need to include common_role_names in this hash each time, since they don't change. - const auto external_roles_hash = boost::hash{}(external_roles); - // Map and grant the roles from scratch only if the list of external role has changed. - const auto it = external_role_hashes.find(user_name); - if (it != external_role_hashes.end() && it->second == external_roles_hash) + const auto it = users_external_roles.find(user_name); + if (it != users_external_roles.end() && it->second == external_roles) return; - auto update_func = [this, &external_roles, external_roles_hash] (const AccessEntityPtr & entity_, const UUID &) -> AccessEntityPtr + auto update_func = [this, &external_roles] (const AccessEntityPtr & entity_, const UUID &) -> AccessEntityPtr { if (auto user = typeid_cast>(entity_)) { auto changed_user = typeid_cast>(user->clone()); - assignRolesNoLock(*changed_user, external_roles, external_roles_hash); + assignRolesNoLock(*changed_user, external_roles); return changed_user; } return entity_; diff --git a/src/Access/LDAPAccessStorage.h b/src/Access/LDAPAccessStorage.h index 7efc4704b07e..0c80d77a98d7 100644 --- a/src/Access/LDAPAccessStorage.h +++ b/src/Access/LDAPAccessStorage.h @@ -55,7 +55,6 @@ class LDAPAccessStorage : public IAccessStorage void applyRoleChangeNoLock(bool grant, const UUID & role_id, const String & role_name); void assignRolesNoLock(User & user, const LDAPClient::SearchResultsList & external_roles) const; - void assignRolesNoLock(User & user, const LDAPClient::SearchResultsList & external_roles, std::size_t external_roles_hash) const; void updateAssignedRolesNoLock(const UUID & id, const String & user_name, const LDAPClient::SearchResultsList & external_roles) const; std::set mapExternalRolesNoLock(const LDAPClient::SearchResultsList & external_roles) const; bool areLDAPCredentialsValidNoLock(const User & user, const Credentials & credentials, @@ -66,7 +65,7 @@ class LDAPAccessStorage : public IAccessStorage String ldap_server_name; LDAPClient::RoleSearchParamsList role_search_params; std::set common_role_names; // role name that should be granted to all users at all times - mutable std::map external_role_hashes; // user name -> LDAPClient::SearchResultsList hash (most recently retrieved and processed) + mutable std::map users_external_roles; // user name -> LDAPClient::SearchResultsList (most recently retrieved and processed) mutable std::map> users_per_roles; // role name -> user names (...it should be granted to; may but don't have to exist for common roles) mutable std::map> roles_per_users; // user name -> role names (...that should be granted to it; may but don't have to include common roles) mutable std::map granted_role_names; // (currently granted) role id -> its name diff --git a/src/Access/TokenAccessStorage.cpp b/src/Access/TokenAccessStorage.cpp new file mode 100644 index 000000000000..53a3173007b3 --- /dev/null +++ b/src/Access/TokenAccessStorage.cpp @@ -0,0 +1,405 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DB +{ +namespace ErrorCodes +{ + extern const int BAD_ARGUMENTS; +} + +TokenAccessStorage::TokenAccessStorage(const String & storage_name_, AccessControl & access_control_, const Poco::Util::AbstractConfiguration & config_, const String & prefix_) + : IAccessStorage(storage_name_), access_control(access_control_), config(config_), prefix(prefix_), + memory_storage(storage_name_, access_control.getChangesNotifier(), false) +{ + std::lock_guard lock(mutex); + + const String prefix_str = (prefix.empty() ? "" : prefix + "."); + + if (config.has(prefix_str + "roles_filter")) + roles_filter.emplace(config.getString(prefix_str + "roles_filter")); + + provider_name = config.getString(prefix_str + "processor"); + if (provider_name.empty()) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "'processor' must be specified for Token user directory"); + + std::set common_roles_cfg; + if (config.has(prefix_str + "common_roles")) + { + Poco::Util::AbstractConfiguration::Keys role_names; + config.keys(prefix_str + "common_roles", role_names); + + common_roles_cfg.insert(role_names.begin(), role_names.end()); + } + common_role_names.swap(common_roles_cfg); + + user_external_roles.clear(); + users_per_roles.clear(); + roles_per_users.clear(); + granted_role_names.clear(); + granted_role_ids.clear(); + + role_change_subscription = access_control.subscribeForChanges( + [this] (const UUID & id, const AccessEntityPtr & entity) + { + this->processRoleChange(id, entity); + } + ); +} + +void TokenAccessStorage::applyRoleChangeNoLock(bool grant, const UUID & role_id, const String & role_name) +{ + std::vector user_ids; + + // Build a list of ids of the relevant users. + if (common_role_names.contains(role_name)) + { + user_ids = memory_storage.findAll(); + } + else + { + const auto it = users_per_roles.find(role_name); + if (it != users_per_roles.end()) + { + const auto & user_names = it->second; + user_ids.reserve(user_names.size()); + + for (const auto & user_name : user_names) + { + if (const auto user_id = memory_storage.find(user_name)) + user_ids.emplace_back(*user_id); + } + } + } + + // Update the granted roles of the relevant users. + if (!user_ids.empty()) + { + auto update_func = [&role_id, &grant] (const AccessEntityPtr & entity_, const UUID &) -> AccessEntityPtr + { + if (auto user = typeid_cast>(entity_)) + { + auto changed_user = typeid_cast>(user->clone()); + if (grant) + changed_user->granted_roles.grant(role_id); + else + changed_user->granted_roles.revoke(role_id); + return changed_user; + } + return entity_; + }; + + memory_storage.update(user_ids, update_func); + } + + // Actualize granted_role_* mappings. + if (grant) + { + if (!user_ids.empty()) + { + granted_role_names.insert_or_assign(role_id, role_name); + granted_role_ids.insert_or_assign(role_name, role_id); + } + } + else + { + granted_role_ids.erase(role_name); + granted_role_names.erase(role_id); + } +} + +void TokenAccessStorage::processRoleChange(const UUID & id, const AccessEntityPtr & entity) +{ + std::lock_guard lock(mutex); + const auto role = typeid_cast>(entity); + const auto it = granted_role_names.find(id); + + if (role) // Added or renamed a role. + { + const auto & new_role_name = role->getName(); + if (it != granted_role_names.end()) // Renamed a granted role. + { + const auto & old_role_name = it->second; + if (new_role_name != old_role_name) + { + // Revoke the old role first, then grant the new role. + applyRoleChangeNoLock(false /* revoke */, id, old_role_name); + applyRoleChangeNoLock(true /* grant */, id, new_role_name); + } + } + else // Added a role. + { + applyRoleChangeNoLock(true /* grant */, id, new_role_name); + } + } + else // Removed a role. + { + if (it != granted_role_names.end()) // Removed a granted role. + { + const auto & old_role_name = it->second; + applyRoleChangeNoLock(false /* revoke */, id, old_role_name); + } + } +} + +const char * TokenAccessStorage::getStorageType() const +{ + return STORAGE_TYPE; +} + +bool TokenAccessStorage::exists(const UUID & id) const +{ + std::lock_guard lock(mutex); + return memory_storage.exists(id); +} + +String TokenAccessStorage::getStorageParamsJSON() const +{ + std::lock_guard lock(mutex); + Poco::JSON::Object params_json; + + params_json.set("provider", provider_name); + + Poco::JSON::Array common_role_names_json; + for (const auto & role : common_role_names) + { + common_role_names_json.add(role); + } + params_json.set("roles", common_role_names_json); + + std::ostringstream oss; // STYLE_CHECK_ALLOW_STD_STRING_STREAM + oss.exceptions(std::ios::failbit); + Poco::JSON::Stringifier::stringify(params_json, oss); + + return oss.str(); +} + +bool TokenAccessStorage::areTokenCredentialsValidNoLock(const User & user, const Credentials & credentials, const ExternalAuthenticators & external_authenticators) const +{ + if (!credentials.isReady()) + return false; + + if (credentials.getUserName() != user.getName()) + return false; + + if (const auto * token_credentials = dynamic_cast(&credentials)) + return external_authenticators.checkTokenCredentials(*token_credentials); + + return false; +} + +std::optional TokenAccessStorage::findImpl(AccessEntityType type, const String & name) const +{ + std::lock_guard lock(mutex); + return memory_storage.find(type, name); +} + + +std::vector TokenAccessStorage::findAllImpl(AccessEntityType type) const +{ + std::lock_guard lock(mutex); + return memory_storage.findAll(type); +} + +AccessEntityPtr TokenAccessStorage::readImpl(const UUID & id, bool throw_if_not_exists) const +{ + std::lock_guard lock(mutex); + return memory_storage.read(id, throw_if_not_exists); +} + +std::optional> TokenAccessStorage::readNameWithTypeImpl(const UUID & id, bool throw_if_not_exists) const +{ + std::lock_guard lock(mutex); + return memory_storage.readNameWithType(id, throw_if_not_exists); +} + +void TokenAccessStorage::assignRolesNoLock(User & user, const std::set & external_roles) const +{ + const auto & user_name = user.getName(); + auto & granted_roles = user.granted_roles; + + auto grant_role = [this, &user_name, &granted_roles] (const String & role_name, const bool common) + { + auto it = granted_role_ids.find(role_name); + if (it == granted_role_ids.end()) + { + if (const auto role_id = access_control.find(role_name)) + { + granted_role_names.insert_or_assign(*role_id, role_name); + it = granted_role_ids.insert_or_assign(role_name, *role_id).first; + } + } + + if (it != granted_role_ids.end()) + { + const auto & role_id = it->second; + granted_roles.grant(role_id); + } + else + { + LOG_TRACE(getLogger(), "Did not grant {} role '{}' to user '{}': role not found", (common ? "common" : "mapped"), role_name, user_name); + } + }; + + user_external_roles.erase(user_name); + granted_roles = {}; + const auto old_role_names = std::move(roles_per_users[user_name]); + + // Grant the common roles first. + for (const auto & role_name : common_role_names) + { + grant_role(role_name, true /* common */); + } + + // Grant the mapped external roles and actualize users_per_roles mapping. + // external_roles allowed to overlap with common_role_names. + for (const auto & role_name : external_roles) + { + grant_role(role_name, false /* mapped */); + users_per_roles[role_name].insert(user_name); + } + + // Cleanup users_per_roles and granted_role_* mappings. + for (const auto & old_role_name : old_role_names) + { + if (external_roles.contains(old_role_name)) + continue; + + const auto rit = users_per_roles.find(old_role_name); + if (rit == users_per_roles.end()) + continue; + + auto & user_names = rit->second; + user_names.erase(user_name); + + if (!user_names.empty()) + continue; + + users_per_roles.erase(rit); + + if (common_role_names.contains(old_role_name)) + continue; + + const auto iit = granted_role_ids.find(old_role_name); + if (iit == granted_role_ids.end()) + continue; + + const auto old_role_id = iit->second; + granted_role_names.erase(old_role_id); + granted_role_ids.erase(iit); + } + + // Actualize roles_per_users mapping and user_external_roles cache. + if (external_roles.empty()) + roles_per_users.erase(user_name); + else + roles_per_users[user_name] = external_roles; + + user_external_roles[user_name] = external_roles; +} + +void TokenAccessStorage::updateAssignedRolesNoLock(const UUID & id, const String & user_name, const std::set & external_roles) const +{ + // Map and grant the roles from scratch only if the list of external role has changed. + const auto it = user_external_roles.find(user_name); + if (it != user_external_roles.end() && it->second == external_roles) + return; + + auto update_func = [this, &external_roles] (const AccessEntityPtr & entity_, const UUID &) -> AccessEntityPtr + { + if (auto user = typeid_cast>(entity_)) + { + auto changed_user = typeid_cast>(user->clone()); + assignRolesNoLock(*changed_user, external_roles); + return changed_user; + } + return entity_; + }; + + memory_storage.update(id, update_func); +} + + +std::optional TokenAccessStorage::authenticateImpl( + const Credentials & credentials, + const Poco::Net::IPAddress & address, + const ExternalAuthenticators & external_authenticators, + const ClientInfo & /* client_info */, + bool throw_if_user_not_exists, + bool /* allow_no_password */, + bool /* allow_plaintext_password */) const +{ + std::lock_guard lock(mutex); + auto id = memory_storage.find(credentials.getUserName()); + UserPtr user = id ? memory_storage.read(*id) : nullptr; + + const auto & token_credentials = typeid_cast(credentials); + + if (!external_authenticators.checkTokenCredentials(token_credentials, provider_name)) + { + // Even though token itself may be valid (especially in case of a jwt token), authentication has just failed. + if (throw_if_user_not_exists) + throwNotFound(AccessEntityType::USER, credentials.getUserName(), getStorageName()); + + return {}; + } + + std::shared_ptr new_user; + if (!user) + { + // User does not exist, so we create one, and will add it if authentication is successful. + new_user = std::make_shared(); + new_user->setName(credentials.getUserName()); + new_user->authentication_methods.emplace_back(AuthenticationType::JWT); + user = new_user; + } + + if (!isAddressAllowed(*user, address)) + throwAddressNotAllowed(address); + + std::set external_roles; + if (roles_filter.has_value() && roles_filter.value().ok()) + { + LOG_TRACE(getLogger(), "{}: External role filter found, applying only matching groups", getStorageName()); + for (const auto & group: token_credentials.getGroups()) { + if (RE2::FullMatch(group, roles_filter.value())) + { + external_roles.insert(group); + LOG_TRACE(getLogger(), "{}: Granted role (group) {} to user", getStorageName(), user->getName()); + } + } + } + else + { + LOG_TRACE(getLogger(), "{}: No external role filtering set, applying all available groups", getStorageName()); + external_roles = token_credentials.getGroups(); + } + + if (new_user) + { + assignRolesNoLock(*new_user, external_roles); + id = memory_storage.insert(new_user); + } + else + { + // Just in case external_roles are changed. + updateAssignedRolesNoLock(*id, user->getName(), external_roles); + } + + if (id) + return AuthResult{ .user_id = *id, .authentication_data = AuthenticationData(AuthenticationType::JWT) }; + return std::nullopt; +} + + +} diff --git a/src/Access/TokenAccessStorage.h b/src/Access/TokenAccessStorage.h new file mode 100644 index 000000000000..ad40031ac7fd --- /dev/null +++ b/src/Access/TokenAccessStorage.h @@ -0,0 +1,82 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +namespace Poco +{ + namespace Util + { + class AbstractConfiguration; + } +} + + +namespace DB +{ +class AccessControl; + +/// Implementation of IAccessStorage which allows to import user data from oauth server using access token. +/// Normally, this should be unified with LDAPAccessStorage, but not done to minimize changes to code that is common with upstream. +class TokenAccessStorage : public IAccessStorage +{ +public: + static constexpr char STORAGE_TYPE[] = "token"; + + explicit TokenAccessStorage(const String & storage_name_, AccessControl & access_control_, const Poco::Util::AbstractConfiguration & config, const String & prefix); + ~TokenAccessStorage() override = default; + + // IAccessStorage implementations. + const char * getStorageType() const override; + String getStorageParamsJSON() const override; + bool isReadOnly() const override { return true; } + bool exists(const UUID & id) const override; + +private: + mutable std::recursive_mutex mutex; // Note: Reentrance possible by internal role lookup via access_control + AccessControl & access_control; + const Poco::Util::AbstractConfiguration & config; + const String & prefix; + + String provider_name; + std::optional roles_filter = std::nullopt; + + std::set common_role_names; // role name that should be granted to all users at all times + mutable std::map> user_external_roles; + mutable std::map> users_per_roles; // role name -> user names (...it should be granted to; may but don't have to exist for common roles) + mutable std::map> roles_per_users; // user name -> role names (...that should be granted to it; may but don't have to include common roles) + mutable std::map granted_role_names; // (currently granted) role id -> its name + mutable std::map granted_role_ids; // (currently granted) role name -> its id + mutable MemoryAccessStorage memory_storage; + scope_guard role_change_subscription; + + void processRoleChange(const UUID & id, const AccessEntityPtr & entity); + + bool areTokenCredentialsValidNoLock(const User & user, const Credentials & credentials, const ExternalAuthenticators & external_authenticators) const; + + void applyRoleChangeNoLock(bool grant, const UUID & role_id, const String & role_name); + void assignRolesNoLock(User & user, const std::set & external_roles) const; + void updateAssignedRolesNoLock(const UUID & id, const String & user_name, const std::set & external_roles) const; + +protected: + std::optional findImpl(AccessEntityType type, const String & name) const override; + std::vector findAllImpl(AccessEntityType type) const override; + AccessEntityPtr readImpl(const UUID & id, bool throw_if_not_exists) const override; + std::optional> readNameWithTypeImpl(const UUID & id, bool throw_if_not_exists) const override; + std::optional authenticateImpl(const Credentials & credentials, + const Poco::Net::IPAddress & address, + const ExternalAuthenticators & external_authenticators, + const ClientInfo & client_info, + bool throw_if_user_not_exists, + bool allow_no_password, + bool allow_plaintext_password) const override; +}; +} diff --git a/src/Access/TokenProcessors.h b/src/Access/TokenProcessors.h new file mode 100644 index 000000000000..902a99850588 --- /dev/null +++ b/src/Access/TokenProcessors.h @@ -0,0 +1,200 @@ +#pragma once + +#include +#include + +#if USE_JWT_CPP +#include +#include +#include +#endif + +namespace DB +{ + +namespace ErrorCodes +{ +extern const int NOT_IMPLEMENTED; +} + +class ITokenProcessor +{ +public: + explicit ITokenProcessor(const String & processor_name_, + UInt64 token_cache_lifetime_, + const String & username_claim_ = "sub", + const String & groups_claim_ = "groups") + : processor_name(processor_name_), token_cache_lifetime(token_cache_lifetime_), username_claim(username_claim_), groups_claim(groups_claim_) {} + virtual ~ITokenProcessor() = default; + + virtual bool resolveAndValidate(TokenCredentials &) const + { + throw Exception(ErrorCodes::NOT_IMPLEMENTED, "Not implemented for ITokenProcessor interface"); + } + + virtual bool checkClaims(const TokenCredentials &, const String &) { return true; } + + UInt64 getTokenCacheLifetime() const { return token_cache_lifetime; } + String getProcessorName() const { return processor_name; } + + static std::unique_ptr parseTokenProcessor( + const Poco::Util::AbstractConfiguration & config, + const String & prefix, + const String & processor_name); + +protected: + const String processor_name; + const UInt64 token_cache_lifetime; + const String username_claim; + const String groups_claim; +}; + +#if USE_JWT_CPP + +struct StaticKeyJwtParams +{ + /// Algorithm name (required). Supported: "none", "hs256", "hs384", "hs512", + /// "ps256", "ps384", "ps512", "ed25519", "ed448", "rs256", "rs384", "rs512", + /// "es256", "es256k", "es384", "es512" + String algo; + + /// For HS algorithms (hs256, hs384, hs512): symmetric key (required for HS algorithms) + String static_key; + + /// For HS algorithms: whether static_key is base64 encoded (optional, defaults to false) + bool static_key_in_base64 = false; + + /// For PS/ED/RSA/ES algorithms: public key (required for PS/ED/RSA/ES algorithms) + String public_key; + + /// For PS/ED/RSA/ES algorithms: private key (optional) + String private_key; + + /// For PS/ED/RSA/ES algorithms: public key password (optional) + String public_key_password; + + /// For PS/ED/RSA/ES algorithms: private key password (optional) + String private_key_password; + + /// JWT claims to validate (optional) + String claims; +}; + +class StaticKeyJwtProcessor : public ITokenProcessor +{ +public: + explicit StaticKeyJwtProcessor(const String & processor_name_, + UInt64 token_cache_lifetime_, + const String & username_claim_, + const String & groups_claim_, + const StaticKeyJwtParams & params); + + bool resolveAndValidate(TokenCredentials & credentials) const override; + bool checkClaims(const TokenCredentials & credentials, const String & claims_to_check) override; + +private: + const String claims; + jwt::verifier verifier = jwt::verify(); +}; + + +class JwksJwtProcessor : public ITokenProcessor +{ +public: + explicit JwksJwtProcessor(const String & processor_name_, + UInt64 token_cache_lifetime_, + const String & username_claim_, + const String & groups_claim_, + const String & claims_, + size_t verifier_leeway_, + std::shared_ptr provider_) + : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_), + claims(claims_), provider(provider_), verifier_leeway(verifier_leeway_) {} + + explicit JwksJwtProcessor(const String & processor_name_, + UInt64 token_cache_lifetime_, + const String & username_claim_, + const String & groups_claim_, + const String & claims_, + size_t verifier_leeway_, + const String & jwks_uri_, + size_t jwks_cache_lifetime_) + : JwksJwtProcessor(processor_name_, + token_cache_lifetime_, + username_claim_, + groups_claim_, + claims_, + verifier_leeway_, + std::make_shared(jwks_uri_, jwks_cache_lifetime_)) {} + + bool resolveAndValidate(TokenCredentials & credentials) const override; + bool checkClaims(const TokenCredentials & credentials, const String & claims_to_check) override; + +private: + const String claims; + mutable jwt::verifier verifier = jwt::verify(); + std::shared_ptr provider; + const size_t verifier_leeway; +}; + +/// Opaque tokens + +class GoogleTokenProcessor : public ITokenProcessor +{ +public: + GoogleTokenProcessor(const String & processor_name_, + UInt64 token_cache_lifetime_, + const String & username_claim_, + const String & groups_claim_) + : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_) {} + + bool resolveAndValidate(TokenCredentials & credentials) const override; +}; + +class AzureTokenProcessor : public ITokenProcessor +{ +public: + AzureTokenProcessor(const String & processor_name_, + UInt64 token_cache_lifetime_, + const String & username_claim_, + const String & groups_claim_) + : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_) {} + + bool resolveAndValidate(TokenCredentials & credentials) const override; +}; + +class OpenIdTokenProcessor : public ITokenProcessor +{ +public: + /// Specify endpoints manually + OpenIdTokenProcessor(const String & processor_name_, + UInt64 token_cache_lifetime_, + const String & username_claim_, + const String & groups_claim_, + const String & userinfo_endpoint_, + const String & token_introspection_endpoint_, + UInt64 verifier_leeway_, + const String & jwks_uri_, + UInt64 jwks_cache_lifetime_); + + /// Obtain endpoints from openid-configuration URL + OpenIdTokenProcessor(const String & processor_name_, + UInt64 token_cache_lifetime_, + const String & username_claim_, + const String & groups_claim_, + const String & openid_config_endpoint_, + UInt64 verifier_leeway_, + UInt64 jwks_cache_lifetime_); + + bool resolveAndValidate(TokenCredentials & credentials) const override; +private: + Poco::URI userinfo_endpoint; + Poco::URI token_introspection_endpoint; + + /// Access token is often a valid JWT, so we can validate it locally to avoid unnecesary network requests. + std::optional jwt_validator = std::nullopt; +}; + +#endif + +} diff --git a/src/Access/TokenProcessorsJWT.cpp b/src/Access/TokenProcessorsJWT.cpp new file mode 100644 index 000000000000..28181877bda8 --- /dev/null +++ b/src/Access/TokenProcessorsJWT.cpp @@ -0,0 +1,410 @@ +#include "TokenProcessors.h" + +#if USE_JWT_CPP +#include +#include +#include + +namespace DB { + +namespace ErrorCodes +{ + extern const int AUTHENTICATION_FAILED; + extern const int INVALID_CONFIG_PARAMETER; +} + +namespace +{ + +bool check_claims(const picojson::value & claims, const picojson::value & payload, const String & path); +bool check_claims(const picojson::value::object & claims, const picojson::value::object & payload, const String & path) +{ + for (const auto & it : claims) + { + const auto & payload_it = payload.find(it.first); + if (payload_it == payload.end()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "Key '{}.{}' not found in JWT payload", path, it.first); + return false; + } + if (!check_claims(it.second, payload_it->second, path + "." + it.first)) + { + return false; + } + } + return true; +} + +bool check_claims(const picojson::value::array & claims, const picojson::value::array & payload, const String & path) +{ + if (claims.size() > payload.size()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload too small for claims key '{}'", path); + return false; + } + for (size_t claims_i = 0; claims_i < claims.size(); ++claims_i) + { + bool found = false; + const auto & claims_val = claims.at(claims_i); + for (const auto & payload_val : payload) + { + if (!check_claims(claims_val, payload_val, path + "[" + std::to_string(claims_i) + "]")) + continue; + found = true; + } + if (!found) + { + LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not contain an object matching claims key '{}[{}]'", path, claims_i); + return false; + } + } + return true; +} + +bool check_claims(const picojson::value & claims, const picojson::value & payload, const String & path) +{ + if (claims.is()) + { + if (!payload.is()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match key type 'array' in claims '{}'", path); + return false; + } + return check_claims(claims.get(), payload.get(), path); + } + if (claims.is()) + { + if (!payload.is()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match key type 'object' in claims '{}'", path); + return false; + } + return check_claims(claims.get(), payload.get(), path); + } + if (claims.is()) + { + if (!payload.is()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match key type 'bool' in claims '{}'", path); + return false; + } + if (claims.get() != payload.get()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match the value in the '{}' assertions. Expected '{}' but given '{}'", path, claims.get(), payload.get()); + return false; + } + return true; + } + if (claims.is()) + { + if (!payload.is()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match key type 'double' in claims '{}'", path); + return false; + } + if (claims.get() != payload.get()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match the value in the '{}' assertions. Expected '{}' but given '{}'", path, claims.get(), payload.get()); + return false; + } + return true; + } + if (claims.is()) + { + if (!payload.is()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match key type 'std::string' in claims '{}'", path); + return false; + } + if (claims.get() != payload.get()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match the value in the '{}' assertions. Expected '{}' but given '{}'", path, claims.get(), payload.get()); + return false; + } + return true; + } +#ifdef PICOJSON_USE_INT64 + if (claims.is()) + { + if (!payload.is()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match key type 'int64_t' in claims '{}'", path); + return false; + } + if (claims.get() != payload.get()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "JWT payload does not match the value in claims '{}'. Expected '{}' but given '{}'", path, claims.get(), payload.get()); + return false; + } + return true; + } +#endif + LOG_ERROR(getLogger("TokenAuthentication"), "JWT claim '{}' does not match any known type", path); + return false; +} + +bool check_claims(const String & claims, const picojson::value::object & payload) +{ + if (claims.empty()) + return true; + picojson::value json; + auto errors = picojson::parse(json, claims); + if (!errors.empty()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Bad JWT claims: {}", errors); + if (!json.is()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Bad JWT claims: is not an object"); + return check_claims(json.get(), payload, ""); +} + +} + +namespace +{ +std::set parseGroupsFromJsonArray(picojson::array groups_array) +{ + std::set external_groups_names; + + for (const auto & group : groups_array) + { + if (group.is()) + external_groups_names.insert(group.get()); + } + + return external_groups_names; +} +} + +StaticKeyJwtProcessor::StaticKeyJwtProcessor(const String & processor_name_, + UInt64 token_cache_lifetime_, + const String & username_claim_, + const String & groups_claim_, + const StaticKeyJwtParams & params) + : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_), + claims(params.claims) +{ + const String & algo = params.algo; + const String & static_key = params.static_key; + bool static_key_in_base64 = params.static_key_in_base64; + const String & public_key = params.public_key; + const String & private_key = params.private_key; + const String & public_key_password = params.public_key_password; + const String & private_key_password = params.private_key_password; + + if (algo == "ps256" || + algo == "ps384" || + algo == "ps512" || + algo == "ed25519" || + algo == "ed448" || + algo == "rs256" || + algo == "rs384" || + algo == "rs512" || + algo == "es256" || + algo == "es256k" || + algo == "es384" || + algo == "es512" ) + { + if (public_key.empty()) + throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "{}: Invalid token processor definition, `public_key` parameter required for {}", processor_name, algo); + } + else if (algo == "hs256" || + algo == "hs384" || + algo == "hs512" ) + { + if (static_key.empty()) + throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "{}: Invalid token processor definition, `static_key` parameter required for {}", processor_name, algo); + } + else if (algo != "none") + throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "{}: Invalid token processor definition, unknown algorithm {}", processor_name, algo); + + if (algo == "none") + verifier = verifier.allow_algorithm(jwt::algorithm::none()); + else if (algo == "ps256") + verifier = verifier.allow_algorithm(jwt::algorithm::ps256(public_key, private_key, public_key_password, private_key_password)); + else if (algo == "ps384") + verifier = verifier.allow_algorithm(jwt::algorithm::ps384(public_key, private_key, public_key_password, private_key_password)); + else if (algo == "ps512") + verifier = verifier.allow_algorithm(jwt::algorithm::ps512(public_key, private_key, public_key_password, private_key_password)); + else if (algo == "ed25519") + verifier = verifier.allow_algorithm(jwt::algorithm::ed25519(public_key, private_key, public_key_password, private_key_password)); + else if (algo == "ed448") + verifier = verifier.allow_algorithm(jwt::algorithm::ed448(public_key, private_key, public_key_password, private_key_password)); + else if (algo == "rs256") + verifier = verifier.allow_algorithm(jwt::algorithm::rs256(public_key, private_key, public_key_password, private_key_password)); + else if (algo == "rs384") + verifier = verifier.allow_algorithm(jwt::algorithm::rs384(public_key, private_key, public_key_password, private_key_password)); + else if (algo == "rs512") + verifier = verifier.allow_algorithm(jwt::algorithm::rs512(public_key, private_key, public_key_password, private_key_password)); + else if (algo == "es256") + verifier = verifier.allow_algorithm(jwt::algorithm::es256(public_key, private_key, public_key_password, private_key_password)); + else if (algo == "es256k") + verifier = verifier.allow_algorithm(jwt::algorithm::es256k(public_key, private_key, public_key_password, private_key_password)); + else if (algo == "es384") + verifier = verifier.allow_algorithm(jwt::algorithm::es384(public_key, private_key, public_key_password, private_key_password)); + else if (algo == "es512") + verifier = verifier.allow_algorithm(jwt::algorithm::es512(public_key, private_key, public_key_password, private_key_password)); + else if (algo.starts_with("hs")) + { + auto key = static_key; + if (static_key_in_base64) + key = base64Decode(key); + if (algo == "hs256") + verifier = verifier.allow_algorithm(jwt::algorithm::hs256(key)); + else if (algo == "hs384") + verifier = verifier.allow_algorithm(jwt::algorithm::hs384(key)); + else if (algo == "hs512") + verifier = verifier.allow_algorithm(jwt::algorithm::hs512(key)); + else + throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "{}: Invalid token processor definition, unknown algorithm {}", processor_name, algo); + } + else + throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "{}: Invalid token processor definition, unknown algorithm {}", processor_name, algo); + +} + +namespace +{ +bool checkUserClaims(const TokenCredentials & credentials, const String & claims_to_check) +{ + try { + auto decoded_jwt = jwt::decode(credentials.getToken()); + return check_claims(claims_to_check, decoded_jwt.get_payload_json()); + } + catch (const std::exception &) + { + return false; + } +} +} + +bool StaticKeyJwtProcessor::checkClaims(const TokenCredentials & credentials, const String & claims_to_check) +{ + return checkUserClaims(credentials, claims_to_check); +} + +bool JwksJwtProcessor::checkClaims(const TokenCredentials & credentials, const String & claims_to_check) +{ + return checkUserClaims(credentials, claims_to_check); +} + +bool StaticKeyJwtProcessor::resolveAndValidate(TokenCredentials & credentials) const +{ + try + { + auto decoded_jwt = jwt::decode(credentials.getToken()); + verifier.verify(decoded_jwt); + + if (!check_claims(claims, decoded_jwt.get_payload_json())) + return false; + + if (!decoded_jwt.has_payload_claim(username_claim)) + { + LOG_ERROR(getLogger("TokenAuthentication"), "{}: Specified username_claim {} not found in token", processor_name, username_claim); + return false; + } + + credentials.setUserName(decoded_jwt.get_payload_claim(username_claim).as_string()); + + if (decoded_jwt.has_payload_claim(groups_claim)) + credentials.setGroups(parseGroupsFromJsonArray(decoded_jwt.get_payload_claim(groups_claim).as_array())); + else + LOG_TRACE(getLogger("TokenAuthentication"), "{}: Specified groups_claim {} not found in token, no external roles will be mapped", processor_name, groups_claim); + + return true; + } + catch (const std::exception & ex) + { + LOG_TRACE(getLogger("TokenAuthentication"), "{}: Failed to validate JWT: {}", processor_name, ex.what()); + return false; + } +} + +bool JwksJwtProcessor::resolveAndValidate(TokenCredentials & credentials) const +{ + auto decoded_jwt = jwt::decode(credentials.getToken()); + + if (!decoded_jwt.has_payload_claim(username_claim)) + { + LOG_ERROR(getLogger("TokenAuthentication"), "{}: Specified username_claim not found in token", processor_name); + return false; + } + + if (!decoded_jwt.has_key_id()) + { + LOG_ERROR(getLogger("TokenAuthentication"), "{}: 'kid' (key ID) claim not found in token", processor_name); + return false; + } + + auto jwk = provider->getJWKS().get_jwk(decoded_jwt.get_key_id()); + auto username = decoded_jwt.get_payload_claim(username_claim).as_string(); + + if (!decoded_jwt.has_algorithm()) + { + LOG_ERROR(getLogger("TokenAuthentication"), "{}: Algorithm not specified in token", processor_name); + return false; + } + auto algo = Poco::toLower(decoded_jwt.get_algorithm()); + + + String public_key; + + try + { + auto issuer = decoded_jwt.get_issuer(); + auto x5c = jwk.get_x5c_key_value(); + + if (!x5c.empty() && !issuer.empty()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "{}: Verifying {} with 'x5c' key", processor_name, username); + public_key = jwt::helper::convert_base64_der_to_pem(x5c); + } + } + catch (const jwt::error::claim_not_present_exception &) + { + LOG_TRACE(getLogger("TokenAuthentication"), "{}: issuer or x5c was not specified, skip verification against them", processor_name); + } + catch (const std::bad_cast &) + { + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: invalid claim value type found, claims must be strings"); + } + + if (public_key.empty()) + { + if (!(jwk.has_jwk_claim("n") && jwk.has_jwk_claim("e"))) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "{}: invalid JWK: 'n' or 'e' not found", processor_name); + LOG_TRACE(getLogger("TokenAuthentication"), "{}: `issuer` or `x5c` not present, verifying {} with RSA components", processor_name, username); + const auto modulus = jwk.get_jwk_claim("n").as_string(); + const auto exponent = jwk.get_jwk_claim("e").as_string(); + public_key = jwt::helper::create_public_key_from_rsa_components(modulus, exponent); + } + + if (jwk.has_algorithm() && Poco::toLower(jwk.get_algorithm()) != algo) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT validation error: `alg` in JWK does not match the algorithm used in JWT"); + + if (algo == "rs256") + verifier = verifier.allow_algorithm(jwt::algorithm::rs256(public_key, "", "", "")); + else if (algo == "rs384") + verifier = verifier.allow_algorithm(jwt::algorithm::rs384(public_key, "", "", "")); + else if (algo == "rs512") + verifier = verifier.allow_algorithm(jwt::algorithm::rs512(public_key, "", "", "")); + else + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: unknown algorithm {}", algo); + + verifier = verifier.leeway(verifier_leeway); + verifier.verify(decoded_jwt); + + if (!claims.empty() && !check_claims(claims, decoded_jwt.get_payload_json())) + return false; + + credentials.setUserName(decoded_jwt.get_payload_claim(username_claim).as_string()); + + if (decoded_jwt.has_payload_claim(groups_claim)) + credentials.setGroups(parseGroupsFromJsonArray(decoded_jwt.get_payload_claim(groups_claim).as_array())); + else + LOG_TRACE(getLogger("TokenAuthentication"), "{}: Specified groups_claim {} not found in token, no external roles will be mapped", processor_name, groups_claim); + + return true; +} + +} + +#endif diff --git a/src/Access/TokenProcessorsOpaque.cpp b/src/Access/TokenProcessorsOpaque.cpp new file mode 100644 index 000000000000..04b9ac9ddcb3 --- /dev/null +++ b/src/Access/TokenProcessorsOpaque.cpp @@ -0,0 +1,387 @@ +#include "TokenProcessors.h" + +#if USE_JWT_CPP +#include +#include +#include +#include +#include + +namespace DB { + +namespace ErrorCodes +{ + extern const int AUTHENTICATION_FAILED; + extern const int INVALID_CONFIG_PARAMETER; +} + +namespace +{ + /// The JSON reply from provider has only a few key-value pairs, so no need for any advanced parsing. + /// Reduce complexity by using picojson. + picojson::object parseJSON(const String & json_string) { + picojson::value jsonValue; + std::string err = picojson::parse(jsonValue, json_string); + + if (!err.empty()) { + throw std::runtime_error("JSON parsing error: " + err); + } + + if (!jsonValue.is()) { + throw std::runtime_error("JSON is not an object"); + } + + return jsonValue.get(); + } + + template + std::optional getValueByKey(const picojson::object & jsonObject, const std::string & key) { + auto it = jsonObject.find(key); // Find the key in the object + if (it == jsonObject.end()) + { + if constexpr (throw_on_exception) + throw std::runtime_error("Key not found: " + key); + else + return std::nullopt; + } + + const picojson::value & value = it->second; + if (!value.is()) { + if constexpr (throw_on_exception) + throw std::runtime_error("Value for key '" + key + "' has incorrect type."); + else + return std::nullopt; + } + + return value.get(); + } + + picojson::object getObjectFromURI(const Poco::URI & uri, const String & token = "") + { + Poco::Net::HTTPResponse response; + std::ostringstream responseString; + + Poco::Net::HTTPRequest request{Poco::Net::HTTPRequest::HTTP_GET, uri.getPathAndQuery()}; + if (!token.empty()) + request.add("Authorization", "Bearer " + token); + + if (uri.getScheme() == "https") { + Poco::Net::HTTPSClientSession session(uri.getHost(), uri.getPort()); + session.sendRequest(request); + Poco::StreamCopier::copyStream(session.receiveResponse(response), responseString); + } + else + { + Poco::Net::HTTPClientSession session(uri.getHost(), uri.getPort()); + session.sendRequest(request); + Poco::StreamCopier::copyStream(session.receiveResponse(response), responseString); + } + + if (response.getStatus() != Poco::Net::HTTPResponse::HTTP_OK) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, + "Failed to get user info by access token, code: {}, reason: {}", response.getStatus(), + response.getReason()); + + try + { + return parseJSON(responseString.str()); + } + catch (const std::runtime_error & e) + { + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Failed to parse server response: {}", e.what()); + } + } +} + +bool GoogleTokenProcessor::resolveAndValidate(TokenCredentials & credentials) const +{ + const String & token = credentials.getToken(); + + std::unordered_map user_info; + picojson::object user_info_json = getObjectFromURI(Poco::URI("https://www.googleapis.com/oauth2/v3/userinfo"), token); + + if (!user_info_json.contains("email")) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, + "{}: Specified username_claim {} not found in token", processor_name, username_claim); + + user_info["email"] = getValueByKey(user_info_json, "email").value_or(""); + + user_info[username_claim] = getValueByKey(user_info_json, username_claim).value(); + + String user_name = user_info[username_claim]; + + credentials.setUserName(user_name); + + auto token_info = getObjectFromURI(Poco::URI("https://www.googleapis.com/oauth2/v3/tokeninfo"), token); + if (token_info.contains("exp")) + credentials.setExpiresAt(std::chrono::system_clock::from_time_t((getValueByKey(token_info, "exp").value()))); + + /// Groups info can only be retrieved if user email is known. + /// If no email found in user info, we skip this step and there are no external roles for the user. + if (!user_info["email"].empty()) + { + std::set external_groups_names; + const Poco::URI get_groups_uri = Poco::URI("https://cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups?query=member_key_id==" + user_info["email"] + "'"); + + try + { + auto groups_response = getObjectFromURI(get_groups_uri, token); + + if (!groups_response.contains("memberships") || !groups_response["memberships"].is()) + { + LOG_TRACE(getLogger("TokenAuthentication"), + "{}: Failed to get Google groups: invalid content in response from server", processor_name); + return true; + } + + for (const auto & group: groups_response["memberships"].get()) + { + if (!group.is()) + { + LOG_TRACE(getLogger("TokenAuthentication"), + "{}: Failed to get Google groups: invalid content in response from server", processor_name); + continue; + } + + auto group_data = group.get(); + String group_name = getValueByKey(group_data["groupKey"].get(), "id").value_or(""); + if (!group_name.empty()) + { + external_groups_names.insert(group_name); + LOG_TRACE(getLogger("TokenAuthentication"), + "{}: User {}: new external group {}", processor_name, user_name, group_name); + } + } + + credentials.setGroups(external_groups_names); + } + catch (const Exception & e) + { + /// Could not get groups info. Log it and skip it. + LOG_TRACE(getLogger("TokenAuthentication"), + "{}: Failed to get Google groups, no external roles will be mapped. reason: {}", processor_name, e.what()); + return true; + } + } + + return true; +} + +bool AzureTokenProcessor::resolveAndValidate(TokenCredentials & credentials) const +{ + /// Token is a JWT in this case, but we cannot directly verify it against Azure AD JWKS. + /// We will not trust user data in this token except for 'exp' value to determine caching duration. + /// Explanation here: https://stackoverflow.com/questions/60778634/failing-signature-validation-of-jwt-tokens-from-azure-ad + /// Let Azure validate it: only valid tokens will be accepted. + /// Use GET https://graph.microsoft.com/oidc/userinfo to verify token and get user info at the same time + + const String & token = credentials.getToken(); + + try + { + picojson::object user_info_json = getObjectFromURI(Poco::URI("https://graph.microsoft.com/oidc/userinfo"), token); + String username = getValueByKey(user_info_json, username_claim).value(); + + if (!username.empty()) + credentials.setUserName(username); + else + LOG_TRACE(getLogger("TokenAuthentication"), "{}: Failed to get username with token", processor_name); + + } + catch (...) + { + return false; + } + + try + { + credentials.setExpiresAt(jwt::decode(token).get_expires_at()); + } + catch (...) { + LOG_TRACE(getLogger("TokenAuthentication"), + "{}: No expiration data found in a valid token, will use default cache lifetime", processor_name); + } + + std::set external_groups_names; + const Poco::URI get_groups_uri = Poco::URI("https://graph.microsoft.com/v1.0/me/memberOf"); + + try + { + auto groups_response = getObjectFromURI(get_groups_uri, token); + + if (!groups_response.contains("value") || !groups_response["value"].is()) + { + LOG_TRACE(getLogger("TokenAuthentication"), + "{}: Failed to get Azure groups: invalid content in response from server", processor_name); + return true; + } + + picojson::array groups_array = groups_response["value"].get(); + + for (const auto & group: groups_array) + { + /// Got some invalid response. Ignore this, log this. + if (!group.is()) + { + LOG_TRACE(getLogger("TokenAuthentication"), + "{}: Failed to get Azure groups: invalid content in response from server", processor_name); + continue; + } + + auto group_data = group.get(); + if (!group_data.contains("displayName")) + continue; + + String group_name = getValueByKey(group_data, "displayName").value_or(""); + if (!group_name.empty()) + { + external_groups_names.insert(group_name); + LOG_TRACE(getLogger("TokenAuthentication"), "{}: User {}: new external group {}", processor_name, credentials.getUserName(), group_name); + } + } + } + catch (const Exception & e) + { + /// Could not get groups info. Log it and skip it. + LOG_TRACE(getLogger("TokenAuthentication"), + "{}: Failed to get Azure groups, no external roles will be mapped. reason: {}", processor_name, e.what()); + return true; + } + + credentials.setGroups(external_groups_names); + return true; +} + +OpenIdTokenProcessor::OpenIdTokenProcessor(const String & processor_name_, + UInt64 token_cache_lifetime_, + const String & username_claim_, + const String & groups_claim_, + const String & userinfo_endpoint_, + const String & token_introspection_endpoint_, + UInt64 verifier_leeway_, + const String & jwks_uri_, + UInt64 jwks_cache_lifetime_) + : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_), + userinfo_endpoint(userinfo_endpoint_), token_introspection_endpoint(token_introspection_endpoint_) +{ + if (!jwks_uri_.empty()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "{}: JWKS URI set, local JWT processing will be attempted", processor_name_); + jwt_validator.emplace(processor_name_ + "jwks_val", + token_cache_lifetime_, + username_claim_, + groups_claim_, + "", + verifier_leeway_, + jwks_uri_, + jwks_cache_lifetime_); + } +} + +OpenIdTokenProcessor::OpenIdTokenProcessor(const String & processor_name_, + UInt64 token_cache_lifetime_, + const String & username_claim_, + const String & groups_claim_, + const String & openid_config_endpoint_, + UInt64 verifier_leeway_, + UInt64 jwks_cache_lifetime_) + : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_) +{ + const picojson::object openid_config = getObjectFromURI(Poco::URI(openid_config_endpoint_)); + + if (!openid_config.contains("userinfo_endpoint") || !openid_config.contains("introspection_endpoint")) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "{}: Cannot extract userinfo_endpoint or introspection_endpoint from OIDC configuration, consider manual configuration.", processor_name); + + if (openid_config.contains("jwks_uri")) + { + LOG_TRACE(getLogger("TokenAuthentication"), "{}: JWKS URI set, local JWT processing will be attempted", processor_name_); + jwt_validator.emplace(processor_name_ + "jwks_val", + token_cache_lifetime_, + username_claim_, + groups_claim_, + "", + verifier_leeway_, + getValueByKey(openid_config, "jwks_uri").value(), + jwks_cache_lifetime_); + } +} + +bool OpenIdTokenProcessor::resolveAndValidate(TokenCredentials & credentials) const +{ + const String & token = credentials.getToken(); + String username; + picojson::object user_info_json; + + if (jwt_validator.has_value() && jwt_validator.value().resolveAndValidate(credentials)) + { + try + { + auto decoded_token = jwt::decode(token); + user_info_json = decoded_token.get_payload_json(); + username = getValueByKey(user_info_json, username_claim).value(); + + /// TODO: Now we work only with Keycloak -- and it provides expires_at in token itself. Need to add actual token introspection logic for other OIDC providers. + if (decoded_token.has_expires_at()) + credentials.setExpiresAt(decoded_token.get_expires_at()); + } + catch (const std::exception & ex) + { + LOG_TRACE(getLogger("TokenAuthentication"), "{}: Failed to process token as JWT: {}", processor_name, ex.what()); + } + } + + /// If username or user info is empty -- local validation failed, trying introspection via provider + if (username.empty() || user_info_json.empty()) + { + try + { + user_info_json = getObjectFromURI(userinfo_endpoint, token); + username = getValueByKey(user_info_json, username_claim).value(); + } + catch (...) + { + return false; + } + } + + if (user_info_json.empty()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "{}: Failed to obtain user info", processor_name); + return false; + } + + if (username.empty()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "{}: Failed to get username", processor_name); + return false; + } + + credentials.setUserName(username); + + /// For now, list of groups is expected in a claim with specified name either in token itself or in userinfo response (Keycloak works this way) + /// TODO: add support for custom endpoints for retrieving groups. Keycloak lists groups in /userinfo and token itself, which is not always the case. + if (!groups_claim.empty() && user_info_json.contains(groups_claim)) + { + if (!user_info_json[groups_claim].is()) + { + LOG_TRACE(getLogger("TokenAuthentication"), + "{}: Failed to extract groups: invalid content in user data", processor_name); + return true; + } + + std::set external_groups_names; + + picojson::array groups_array = user_info_json[groups_claim].get(); + for (const auto & group: groups_array) + { + if (group.is()) + external_groups_names.insert(group.get()); + } + credentials.setGroups(external_groups_names); + } + + return true; +} + +} +#endif diff --git a/src/Access/TokenProcessorsParse.cpp b/src/Access/TokenProcessorsParse.cpp new file mode 100644 index 000000000000..a88cb69b2499 --- /dev/null +++ b/src/Access/TokenProcessorsParse.cpp @@ -0,0 +1,132 @@ +#include "TokenProcessors.h" + +#include +#include + +namespace DB { + +namespace ErrorCodes +{ + extern const int INVALID_CONFIG_PARAMETER; + extern const int SUPPORT_IS_DISABLED; +} + +#if USE_JWT_CPP +std::unique_ptr ITokenProcessor::parseTokenProcessor( + const Poco::Util::AbstractConfiguration & config, + const String & prefix, + const String & processor_name) +{ + if (!config.hasProperty(prefix + ".type")) + throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'type' parameter shall be specified in token_processor configuration.'"); + + auto provider_type = Poco::toLower(config.getString(prefix + ".type")); + + auto token_cache_lifetime = config.getUInt64(prefix + ".token_cache_lifetime", 3600); + auto username_claim = config.getString(prefix + ".username_claim", "sub"); + auto groups_claim = config.getString(prefix + ".groups_claim", "groups"); + + if (provider_type == "google") + { + return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim); + } + else if (provider_type == "azure") + { + return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim); + } + else if (provider_type == "openid") + { + auto verifier_leeway = config.getUInt64(prefix + ".verifier_leeway", 60); + auto jwks_cache_lifetime = config.getUInt64(prefix + ".jwks_cache_lifetime", 3600); + + bool externally_configured = config.hasProperty(prefix + ".configuration_endpoint") && !config.hasProperty(prefix + ".jwks_uri"); + bool locally_configured = config.hasProperty(prefix + ".userinfo_endpoint") && config.hasProperty(prefix + ".token_introspection_endpoint"); + + if (externally_configured && ! locally_configured) + { + return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, + config.getString(prefix + ".openid_config_endpoint"), + verifier_leeway, + jwks_cache_lifetime); + } + else if (locally_configured && !externally_configured) + { + return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, + config.getString(prefix + ".userinfo_endpoint"), + config.getString(prefix + ".token_introspection_endpoint"), + verifier_leeway, + config.getString(prefix + ".jwks_uri", ""), + jwks_cache_lifetime); + } + + throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "Either 'configuration_endpoint' or both 'userinfo_endpoint' and 'token_introspection_endpoint' (and, optionally, 'jwks_uri') must be specified for 'openid' processor"); + } + else if (provider_type == "jwt_static_key") + { + if (!config.hasProperty(prefix + ".static_key")) + throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'static_key' must be specified for 'jwt_static_key' processor"); + + if (!config.hasProperty(prefix + ".algo")) + throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'algo' must be specified for 'jwt_static_key' processor"); + + StaticKeyJwtParams params = {Poco::toLower(config.getString(prefix + ".algo")), + config.getString(prefix + ".static_key", ""), + config.getBool(prefix + ".static_key_in_base64", false), + config.getString(prefix + ".public_key", ""), + config.getString(prefix + ".private_key", ""), + config.getString(prefix + ".public_key_password", ""), + config.getString(prefix + ".private_key_password", ""), + config.getString(prefix + ".claims", "")}; + return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, params); + } + else if (provider_type == "jwt_static_jwks") + { + if (config.hasProperty(prefix + ".static_jwks") && config.hasProperty(prefix + ".static_jwks_file")) + throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'static_jwks' and 'static_jwks_file' cannot be specified simultaneously for 'jwt_static_jwks' processor"); + + if (!config.hasProperty(prefix + ".static_jwks") && !config.hasProperty(prefix + ".static_jwks_file")) + throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'static_jwks' or 'static_jwks_file' must be specified for 'jwt_static_jwks' processor"); + + if (config.hasProperty(prefix + ".jwks_uri")) + throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'jwks_uri' cannot be specified for 'jwt_static_jwks' processor"); + + StaticJWKSParams params + { + config.getString(prefix + ".static_jwks", ""), + config.getString(prefix + ".static_jwks_file", "") + }; + return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, + config.getString(prefix + ".claims", ""), + config.getUInt64(prefix + ".verifier_leeway", 0), + std::make_shared(params)); + } + if (provider_type == "jwt_dynamic_jwks") + { + if (config.hasProperty(prefix + ".static_jwks") || config.hasProperty(prefix + ".static_jwks_file")) + throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'static_jwks' and 'static_jwks_file' cannot be specified for 'jwt_dynamic_jwks' processor"); + if (!config.hasProperty(prefix + ".jwks_uri")) + throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'jwks_uri' must be specified for 'jwt_dynamic_jwks' processor"); + + return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, + config.getString(prefix + ".claims", ""), + config.getUInt64(prefix + ".verifier_leeway", 0), + config.getString(prefix + ".jwks_uri"), + config.getUInt(prefix + ".jwks_cache_lifetime", 3600)); + } + else + throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "Invalid type: {}", provider_type); + + // throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "Failed to parse token processor: {}", processor_name); +} + +#else +std::unique_ptr ITokenProcessor::parseTokenProcessor( + const Poco::Util::AbstractConfiguration &, + const String &, + const String &) +{ + throw DB::Exception(ErrorCodes::SUPPORT_IS_DISABLED, "Failed to parse token_processor, ClickHouse was built without JWT support."); +} +#endif + +} diff --git a/src/Access/UsersConfigAccessStorage.cpp b/src/Access/UsersConfigAccessStorage.cpp index fec9db829eb7..e0cab5b337e8 100644 --- a/src/Access/UsersConfigAccessStorage.cpp +++ b/src/Access/UsersConfigAccessStorage.cpp @@ -13,6 +13,7 @@ #include #include #include +#include "Access/Credentials.h" #include #include #include @@ -131,6 +132,7 @@ namespace bool has_password_double_sha1_hex = config.has(user_config + ".password_double_sha1_hex"); bool has_ldap = config.has(user_config + ".ldap"); bool has_kerberos = config.has(user_config + ".kerberos"); + bool has_jwt = config.has(user_config + ".jwt"); const auto certificates_config = user_config + ".ssl_certificates"; bool has_certificates = config.has(certificates_config); @@ -142,18 +144,18 @@ namespace bool has_http_auth = config.has(http_auth_config); size_t num_password_fields = has_no_password + has_password_plaintext + has_password_sha256_hex + has_password_double_sha1_hex - + has_ldap + has_kerberos + has_certificates + has_ssh_keys + has_http_auth + has_scram_password_sha256_hex; + + has_ldap + has_kerberos + has_certificates + has_ssh_keys + has_http_auth + has_scram_password_sha256_hex + has_jwt; if (num_password_fields > 1) throw Exception(ErrorCodes::BAD_ARGUMENTS, "More than one field of 'password', 'password_sha256_hex', " "'password_double_sha1_hex', 'no_password', 'ldap', 'kerberos', 'ssl_certificates', 'ssh_keys', " - "'http_authentication' are used to specify authentication info for user {}. " + "'http_authentication', 'jwt' are used to specify authentication info for user {}. " "Must be only one of them.", user_name); if (num_password_fields < 1) throw Exception(ErrorCodes::BAD_ARGUMENTS, "Either 'password' or 'password_sha256_hex' " "or 'password_double_sha1_hex' or 'no_password' or 'ldap' or 'kerberos " - "or 'ssl_certificates' or 'ssh_keys' or 'http_authentication' must be specified for user {}.", user_name); + "or 'ssl_certificates' or 'ssh_keys' or 'http_authentication' or 'jwt' must be specified for user {}.", user_name); if (has_password_plaintext) { @@ -277,6 +279,10 @@ namespace auto scheme = config.getString(http_auth_config + ".scheme"); user->authentication_methods.back().setHTTPAuthenticationScheme(parseHTTPAuthenticationScheme(scheme)); } + else if (has_jwt) + { + user->authentication_methods.emplace_back(AuthenticationType::JWT); + } else { user->authentication_methods.emplace_back(); diff --git a/src/Parsers/Access/ASTAuthenticationData.cpp b/src/Parsers/Access/ASTAuthenticationData.cpp index 35e7d2032d83..7fe8de9bdb5b 100644 --- a/src/Parsers/Access/ASTAuthenticationData.cpp +++ b/src/Parsers/Access/ASTAuthenticationData.cpp @@ -116,8 +116,11 @@ void ASTAuthenticationData::formatImpl(WriteBuffer & ostr, const FormatSettings } case AuthenticationType::JWT: { - prefix = "CLAIMS"; - parameter = true; + if (!children.empty()) + { + prefix = "CLAIMS"; + parameter = true; + } break; } case AuthenticationType::LDAP: diff --git a/src/Parsers/Access/ASTCreateUserQuery.h b/src/Parsers/Access/ASTCreateUserQuery.h index ff1ec4f12705..ae0485773489 100644 --- a/src/Parsers/Access/ASTCreateUserQuery.h +++ b/src/Parsers/Access/ASTCreateUserQuery.h @@ -17,7 +17,7 @@ class ASTAuthenticationData; /** CREATE USER [IF NOT EXISTS | OR REPLACE] name - * [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}] + * [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']|{WITH jwt [CLAIMS 'json_object']}}] * [HOST {LOCAL | NAME 'name' | REGEXP 'name_regexp' | IP 'address' | LIKE 'pattern'} [,...] | ANY | NONE] * [DEFAULT ROLE role [,...]] * [DEFAULT DATABASE database | NONE] @@ -26,7 +26,7 @@ class ASTAuthenticationData; * * ALTER USER [IF EXISTS] name * [RENAME TO new_name] - * [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}] + * [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']|{WITH jwt [CLAIMS 'json_object']}}] * [[ADD|DROP] HOST {LOCAL | NAME 'name' | REGEXP 'name_regexp' | IP 'address' | LIKE 'pattern'} [,...] | ANY | NONE] * [DEFAULT ROLE role [,...] | ALL | ALL EXCEPT role [,...] ] * [DEFAULT DATABASE database | NONE] diff --git a/src/Parsers/Access/ParserCreateUserQuery.cpp b/src/Parsers/Access/ParserCreateUserQuery.cpp index 1f3b0089410b..3c98fe81ae9b 100644 --- a/src/Parsers/Access/ParserCreateUserQuery.cpp +++ b/src/Parsers/Access/ParserCreateUserQuery.cpp @@ -83,6 +83,7 @@ namespace bool expect_ssl_cert_subjects = false; bool expect_public_ssh_key = false; bool expect_http_auth_server = false; + bool expect_claims = false; // NOLINT auto parse_non_password_based_type = [&](auto check_type) { @@ -105,6 +106,7 @@ namespace expect_http_auth_server = true; else if (check_type == AuthenticationType::JWT) throw Exception(ErrorCodes::BAD_ARGUMENTS, "CREATE USER is not supported for JWT"); + // expect_claims = true; else if (check_type != AuthenticationType::NO_PASSWORD) expect_password = true; @@ -165,6 +167,7 @@ namespace ASTPtr http_auth_scheme; ASTPtr ssl_cert_subjects; std::optional ssl_cert_subject_type; + ASTPtr jwt_claims; if (expect_password || expect_hash) { @@ -229,6 +232,14 @@ namespace return false; } } + else if (expect_claims) + { + if (ParserKeyword{Keyword::CLAIMS}.ignore(pos, expected)) + { + if (!ParserStringAndSubstitution{}.parse(pos, jwt_claims, expected)) + return false; + } + } auth_data = std::make_shared(); @@ -254,6 +265,9 @@ namespace if (http_auth_scheme) auth_data->children.push_back(std::move(http_auth_scheme)); + if (jwt_claims) + auth_data->children.push_back(std::move(jwt_claims)); + parseValidUntil(pos, expected, auth_data->valid_until); return true; diff --git a/src/Parsers/Access/ParserCreateUserQuery.h b/src/Parsers/Access/ParserCreateUserQuery.h index 4dfff8713d76..5f4cfcd6c45f 100644 --- a/src/Parsers/Access/ParserCreateUserQuery.h +++ b/src/Parsers/Access/ParserCreateUserQuery.h @@ -7,7 +7,7 @@ namespace DB { /** Parses queries like * CREATE USER [IF NOT EXISTS | OR REPLACE] name - * [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}] + * [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}|{WITH jwt}] * [HOST {LOCAL | NAME 'name' | REGEXP 'name_regexp' | IP 'address' | LIKE 'pattern'} [,...] | ANY | NONE] * [DEFAULT ROLE role [,...]] * [SETTINGS variable [= value] [MIN [=] min_value] [MAX [=] max_value] [CONST|READONLY|WRITABLE|CHANGEABLE_IN_READONLY] | PROFILE 'profile_name'] [,...] @@ -15,7 +15,7 @@ namespace DB * * ALTER USER [IF EXISTS] name * [RENAME TO new_name] - * [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}] + * [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}|{WITH jwt}] * [[ADD|DROP] HOST {LOCAL | NAME 'name' | REGEXP 'name_regexp' | IP 'address' | LIKE 'pattern'} [,...] | ANY | NONE] * [DEFAULT ROLE role [,...] | ALL | ALL EXCEPT role [,...] ] * [ADD|MODIFY SETTINGS variable [=value] [MIN [=] min_value] [MAX [=] max_value] [CONST|READONLY|WRITABLE|CHANGEABLE_IN_READONLY] [,...] ] diff --git a/src/Parsers/CommonParsers.h b/src/Parsers/CommonParsers.h index c846a12eab02..e364a48e55d9 100644 --- a/src/Parsers/CommonParsers.h +++ b/src/Parsers/CommonParsers.h @@ -85,6 +85,7 @@ namespace DB MR_MACROS(CHECK_TABLE, "CHECK TABLE") \ MR_MACROS(CHECK_GRANT, "CHECK GRANT") \ MR_MACROS(CHECK, "CHECK") \ + MR_MACROS(CLAIMS, "CLAIMS") \ MR_MACROS(CLEANUP, "CLEANUP") \ MR_MACROS(CLEAR_COLUMN, "CLEAR COLUMN") \ MR_MACROS(CLEAR_INDEX, "CLEAR INDEX") \ diff --git a/src/Server/HTTP/authenticateUserByHTTP.cpp b/src/Server/HTTP/authenticateUserByHTTP.cpp index c956ef4489c9..e714d6c43fb9 100644 --- a/src/Server/HTTP/authenticateUserByHTTP.cpp +++ b/src/Server/HTTP/authenticateUserByHTTP.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -16,7 +17,6 @@ # include #endif - namespace DB { @@ -77,6 +77,8 @@ bool authenticateUserByHTTP( bool has_http_credentials = request.hasCredentials() && request.get("Authorization") != "never"; bool has_credentials_in_query_params = params.has("user") || params.has("password"); + String bearer_token; + std::string spnego_challenge; #if USE_SSL X509Certificate::Subjects certificate_subjects; @@ -155,6 +157,12 @@ bool authenticateUserByHTTP( if (spnego_challenge.empty()) throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid authentication: SPNEGO challenge is empty"); } + else if (Poco::icompare(scheme, "Bearer") == 0) + { + bearer_token = auth_info; + if (bearer_token.empty()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid authentication: Bearer token is empty"); + } else { throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid authentication: '{}' HTTP Authorization scheme is not supported", scheme); @@ -212,6 +220,16 @@ bool authenticateUserByHTTP( } } #endif + else if (!bearer_token.empty()) + { + const auto token_credentials = TokenCredentials(bearer_token); + const auto & external_authenticators = global_context->getAccessControl().getExternalAuthenticators(); + + if (!external_authenticators.checkTokenCredentials(token_credentials)) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid authentication: Token could not be verified."); + + current_credentials = std::make_unique(token_credentials); + } else // I.e., now using user name and password strings ("Basic"). { if (!current_credentials) diff --git a/src/Server/TCPHandler.cpp b/src/Server/TCPHandler.cpp index 72061dcf187b..7ef1c387a49b 100644 --- a/src/Server/TCPHandler.cpp +++ b/src/Server/TCPHandler.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -1782,6 +1783,10 @@ void TCPHandler::receiveHello() if (is_ssh_based_auth) user.erase(0, std::string_view(EncodedUserInfo::SSH_KEY_AUTHENTICAION_MARKER).size()); + is_jwt_based_auth = user.starts_with(EncodedUserInfo::JWT_AUTHENTICAION_MARKER); + if (is_jwt_based_auth) + user.erase(0, std::string_view(EncodedUserInfo::JWT_AUTHENTICAION_MARKER).size()); + session = makeSession(); const auto & client_info = session->getClientInfo(); @@ -1869,6 +1874,19 @@ void TCPHandler::receiveHello() } #endif + if (is_jwt_based_auth) + { + auto credentials = TokenCredentials(password); + + const auto & external_authenticators = server.context()->getAccessControl().getExternalAuthenticators(); + + if (!external_authenticators.checkTokenCredentials(credentials)) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Token is invalid"); + + session->authenticate(credentials, getClientAddress(client_info)); + return; + } + session->authenticate(user, password, getClientAddress(client_info)); } diff --git a/src/Server/TCPHandler.h b/src/Server/TCPHandler.h index 36a2bf8eb583..08cabaee4a62 100644 --- a/src/Server/TCPHandler.h +++ b/src/Server/TCPHandler.h @@ -237,6 +237,7 @@ class TCPHandler : public Poco::Net::TCPServerConnection String default_database; bool is_ssh_based_auth = false; /// authentication is via SSH pub-key challenge + bool is_jwt_based_auth = false; /// authentication is via JWT /// For inter-server secret (remote_server.*.secret) bool is_interserver_mode = false; bool is_interserver_authenticated = false; diff --git a/src/Storages/System/StorageSystemBuildOptions.cpp.in b/src/Storages/System/StorageSystemBuildOptions.cpp.in index 111f90ea3a2c..3d1a1d525481 100644 --- a/src/Storages/System/StorageSystemBuildOptions.cpp.in +++ b/src/Storages/System/StorageSystemBuildOptions.cpp.in @@ -66,6 +66,7 @@ const char * auto_config_build[] "GIT_BRANCH", R"IRjaNsZIL9Yh7FQ4(@GIT_BRANCH@)IRjaNsZIL9Yh7FQ4", "GIT_DATE", "@GIT_DATE@", "GIT_COMMIT_SUBJECT", R"Gi17KJMlbGCjErEN(@GIT_COMMIT_SUBJECT@)Gi17KJMlbGCjErEN", + "USE_JWT_CPP", "@USE_JWT_CPP@", nullptr, nullptr }; diff --git a/src/configure_config.cmake b/src/configure_config.cmake index 36907faa70e5..f031d792e9d5 100644 --- a/src/configure_config.cmake +++ b/src/configure_config.cmake @@ -216,6 +216,8 @@ if (TARGET ch_contrib::sha3iuf) set(USE_SHA3IUF 1) endif() set (USE_YTSAURUS 1) - +if (TARGET ch_contrib::jwt-cpp) + set(USE_JWT_CPP 1) +endif() set(SOURCE_DIR ${PROJECT_SOURCE_DIR}) diff --git a/tests/integration/test_jwt_auth/__init__.py b/tests/integration/test_jwt_auth/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/integration/test_jwt_auth/configs/users.xml b/tests/integration/test_jwt_auth/configs/users.xml new file mode 100644 index 000000000000..b3d3372ebaa9 --- /dev/null +++ b/tests/integration/test_jwt_auth/configs/users.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + default + default + + + diff --git a/tests/integration/test_jwt_auth/configs/validators.xml b/tests/integration/test_jwt_auth/configs/validators.xml new file mode 100644 index 000000000000..d707ecc07827 --- /dev/null +++ b/tests/integration/test_jwt_auth/configs/validators.xml @@ -0,0 +1,23 @@ + + + + + jwt_static_key + HS256 + my_secret + false + + + + jwt_static_key + hs256 + other_secret + false + + + + jwt_dynamic_jwks + http://resolver:8080/.well-known/jwks.json + + + diff --git a/tests/integration/test_jwt_auth/helpers/generate_private_key.py b/tests/integration/test_jwt_auth/helpers/generate_private_key.py new file mode 100644 index 000000000000..7b54fa63368b --- /dev/null +++ b/tests/integration/test_jwt_auth/helpers/generate_private_key.py @@ -0,0 +1,21 @@ +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend + +# Generate RSA private key +private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, # Key size of 2048 bits + backend=default_backend() +) + +# Save the private key to a PEM file +pem_private_key = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() # You can add encryption if needed +) + +# Write the private key to a file +with open("new_private_key", "wb") as pem_file: + pem_file.write(pem_private_key) diff --git a/tests/integration/test_jwt_auth/helpers/jwt_jwk.py b/tests/integration/test_jwt_auth/helpers/jwt_jwk.py new file mode 100644 index 000000000000..265882efce76 --- /dev/null +++ b/tests/integration/test_jwt_auth/helpers/jwt_jwk.py @@ -0,0 +1,113 @@ +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization + +import base64 +import json +import jwt + + +""" +Only RS* family algorithms are supported!!! +""" +with open("./private_key_2", "rb") as key_file: + private_key = serialization.load_pem_private_key( + key_file.read(), + password=None, + ) + + +public_key = private_key.public_key() + + +def to_base64_url(data): + return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=") + + +def rsa_key_to_jwk(private_key=None, public_key=None): + if private_key: + # Convert the private key to its components + private_numbers = private_key.private_numbers() + public_numbers = private_key.public_key().public_numbers() + + jwk = { + "kty": "RSA", + "alg": "RS512", + "kid": "mykid", + "n": to_base64_url( + public_numbers.n.to_bytes( + (public_numbers.n.bit_length() + 7) // 8, byteorder="big" + ) + ), + "e": to_base64_url( + public_numbers.e.to_bytes( + (public_numbers.e.bit_length() + 7) // 8, byteorder="big" + ) + ), + "d": to_base64_url( + private_numbers.d.to_bytes( + (private_numbers.d.bit_length() + 7) // 8, byteorder="big" + ) + ), + "p": to_base64_url( + private_numbers.p.to_bytes( + (private_numbers.p.bit_length() + 7) // 8, byteorder="big" + ) + ), + "q": to_base64_url( + private_numbers.q.to_bytes( + (private_numbers.q.bit_length() + 7) // 8, byteorder="big" + ) + ), + "dp": to_base64_url( + private_numbers.dmp1.to_bytes( + (private_numbers.dmp1.bit_length() + 7) // 8, byteorder="big" + ) + ), + "dq": to_base64_url( + private_numbers.dmq1.to_bytes( + (private_numbers.dmq1.bit_length() + 7) // 8, byteorder="big" + ) + ), + "qi": to_base64_url( + private_numbers.iqmp.to_bytes( + (private_numbers.iqmp.bit_length() + 7) // 8, byteorder="big" + ) + ), + } + elif public_key: + # Convert the public key to its components + public_numbers = public_key.public_numbers() + + jwk = { + "kty": "RSA", + "alg": "RS512", + "kid": "mykid", + "n": to_base64_url( + public_numbers.n.to_bytes( + (public_numbers.n.bit_length() + 7) // 8, byteorder="big" + ) + ), + "e": to_base64_url( + public_numbers.e.to_bytes( + (public_numbers.e.bit_length() + 7) // 8, byteorder="big" + ) + ), + } + else: + raise ValueError("You must provide either a private or public key.") + + return jwk + + +# Convert to JWK +jwk_private = rsa_key_to_jwk(private_key=private_key) +jwk_public = rsa_key_to_jwk(public_key=public_key) + +print(f"Private JWK:\n{json.dumps(jwk_private)}\n") +print(f"Public JWK:\n{json.dumps(jwk_public)}\n") + +payload = {"sub": "jwt_user", "iss": "test_iss"} + +# Create a JWT +token = jwt.encode(payload, private_key, headers={"kid": "mykid"}, algorithm="RS512") +print(f"JWT:\n{token}") diff --git a/tests/integration/test_jwt_auth/helpers/jwt_static_secret.py b/tests/integration/test_jwt_auth/helpers/jwt_static_secret.py new file mode 100644 index 000000000000..5f1c7e0340af --- /dev/null +++ b/tests/integration/test_jwt_auth/helpers/jwt_static_secret.py @@ -0,0 +1,43 @@ +import jwt +import datetime + + +def create_jwt( + payload: dict, secret: str, algorithm: str = "HS256", expiration_minutes: int = None +) -> str: + """ + Create a JWT using a static secret and a specified encryption algorithm. + + :param payload: The payload to include in the JWT (as a dictionary). + :param secret: The secret key used to sign the JWT. + :param algorithm: The encryption algorithm to use (default is 'HS256'). + :param expiration_minutes: The time until the token expires (default is 60 minutes). + :return: The encoded JWT as a string. + """ + if expiration_minutes: + expiration = datetime.datetime.utcnow() + datetime.timedelta( + minutes=expiration_minutes + ) + payload["exp"] = expiration + + return jwt.encode(payload, secret, algorithm=algorithm) + + +if __name__ == "__main__": + secret = "my_secret" + payload = {"sub": "jwt_user"} # `sub` must contain user name + + """ + Supported algorithms: + | HMSC | RSA | ECDSA | PSS | EdDSA | + | ----- | ----- | ------ | ----- | ------- | + | HS256 | RS256 | ES256 | PS256 | Ed25519 | + | HS384 | RS384 | ES384 | PS384 | Ed448 | + | HS512 | RS512 | ES512 | PS512 | | + | | | ES256K | | | + And None + """ + algorithm = "HS256" + + token = create_jwt(payload, secret, algorithm) + print(f"Generated JWT: {token}") diff --git a/tests/integration/test_jwt_auth/helpers/private_key_1 b/tests/integration/test_jwt_auth/helpers/private_key_1 new file mode 100644 index 000000000000..a076a86e17a4 --- /dev/null +++ b/tests/integration/test_jwt_auth/helpers/private_key_1 @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAlICGC8S5pObyASih5qfmwuclG0oKsbzY2z9vgwqyhTYQOWcq +YcTjVV4aQ30qb6E0+5W6rJ+jx9zx6GuAEGMiG/aWJEdbUAMGp+L1kz4lrw5U6Glw +oZIvk4wqoRwsiyc+mnDMQAmiZLBNyt3wU6YnKgYmb4O1cSzcZ5HMbImJpj4tpYjq +nIazvYMn/9Pxjkl0ezLCr52av0UkWHro1H4QMVfuEoNmHuWPww9jgHn+I+La0xdO +hRpAa0XnJi65dXZd4330uWjeJwt413yz881uS4n1OLOGKG8ImDcNlwU/guyvk0n0 +aqT0zkOAPp9/yYo13MPWmiRCfOX8ozdN7VDIJwIDAQABAoIBADZfiLUuZrrWRK3f +7sfBmmCquY9wYNILT2uXooDcndjgnrgl6gK6UHKlbgBgB/WvlPK5NAyYtyMq5vgu +xEk7wvVyKC9IYUq+kOVP2JL9IlcibDxcvvypxfnETKeI5VZeHDH4MxEPdgJf+1vY +P3KhV52vestB8mFqB5l0bOEgyuGvO3/3D1JjOnFLS/K2vOj8D/KDRmwXRCcGHTxj +dj3wJH4UbCIsLgiaQBPkFmTteJDICb+7//6YQuB0t8sR/DZS9Z0GWcfy04Cp/m/E +4rRoTNz80MbbU9+k0Ly360SxPizcjpPYSRSD025i8Iqv8jvelq7Nzg69Kubc0KfN +mMrRdMECgYEAz4b7+OX+aO5o2ZQS+fHc8dyWc5umC+uT5xrUm22wZLYA5O8x0Rgj +vdO/Ho/XyN/GCyvNNV2rI2+CBTxez6NqesGDEmJ2n7TQ03xXLCVsnwVz694sPSMO +pzTbU6e42jvDo5DMPDv0Pg1CVQuM9ka6wb4DcolMyDql6QddY3iXHBkCgYEAtzAl +xEAABqdFAnCs3zRf9EZphGJiJ4gtoWmCxQs+IcrfyBNQCy6GqrzJOZ7fQiEoAeII +V0JmsNcnx3U1W0lp8N+1QNZoB4fOWXaX08BvOEe7gbJ6Xl5t52j792vQp1txpBhE +UDhz8m5R9i5qb3BzrYBiSTfak0Pq56Xw3jRDjj8CgYEAqX2QS07kQqT8gz85ZGOR +1QMY6aCks7WaXTR/kdW7K/Wts0xb/m7dugq3W+mVDh0c7UC/36b5v/4xTb9pm+HW +dB2ZxCkgwvz1VNSHiamjFhlo/Km+rcv1CsDTpHYmNi57cRowg71flFJV64l8fiN0 +IgnjXOcgC6RCnpiCQFxb5fkCgYB+Zq2YleSuspqOjXrrZPNU1YUXgN9jkbaSqwA9 +wH01ygvRvWm83XS0uSFMLhC1S7WUXwgMVdgP69YZ7glMHQMJ3wLtY0RS9eVvm8I1 +rZHQzsZWPvXqydOiGrHJzs4hvJpUdR4mEF4JCRBrAyoUDQ70yCKJjQ24EeQzxS/H +015N9wKBgB8DdFPvKXyygTMnBoZdpAhkE/x3TTi7DsLBxj7QxKmSHzlHGz0TubIB +m5/p9dGawQNzD4JwASuY5r4lKXmvYr+4TQPLq6c7EnoIZSwLdge+6PDhnDWJzvk1 +S/RuHWW4FKGzBStTmstG3m0xzxTMnQkV3kPimMim3I3VsxxeGEdq +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/test_jwt_auth/helpers/private_key_2 b/tests/integration/test_jwt_auth/helpers/private_key_2 new file mode 100644 index 000000000000..d0d1576f2017 --- /dev/null +++ b/tests/integration/test_jwt_auth/helpers/private_key_2 @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA0RRsKcZ5j9UckjioG4Phvav3dkg2sXP6tQ7ug0yowAo/u2Hf +fB+1OjKuhWTpA3E3YkMKj0RrT+tuUpmZEXqCAipEV7XcfCv3o7Poa7HTq1ti/abV +wT/KyfGjoNBBSJH4LTNAyo2J8ySKSDtpAEU52iL7s40Ra6I0vqp7/aRuPF5M4zcH +zN3zarG5EfSVSG1+gTkaRv8XJbra0IeIINmKv0F4++ww8ZxXTR6cvI+MsArUiAPw +zf7s5dMR4DNRG6YNTrPA0pTOqQE9sRPd62XsfU08plYm27naOUZO5avIPl1YO5I6 +Gi4kPdTvv3WFIy+QvoKoPhPCaD6EbdBpe8BbTQIDAQABAoIBABghJsCFfucKHdOE +RWZziHx22cblW6aML41wzTcLBFixdhx+lafCEwzF551OgZPbn5wwB4p0R3xAPAm9 +X0yEmnd8gEmtG+aavmg+rZ6sNbULhXenpvi4D4PR5uP61OX2rrEsvpgB0L9mYq0m +ah5VXvFdYzYcHDwTSsoMa+XgcbZ2qCW6Si3jnbBA1TPIJS5GjfPUQlu9g2FKQL5H +tlJ7L4Wq39zkueS6LH7kEXOoM+jHgA8F4f7MIrajmilYqnuXanVcMV3+K/6FvH2B +VBiLggG3CerhB3QyEvZBshvEvvcyRff2NK64CGr/xrAElj4cPHk/E499M1uvUXjE +boCrD+ECgYEA9LvLljf59h8WWF4bKQZGNKprgFdQIZ2iCEf+VGdGWt/mNg+LyXyn +3gS/vReON1eaMuEGklZM4Guh/ZPhsPaNmlu16PjmeYTIW1vQTHiO3KR7tAmWep70 +w+gVxDDzuRvBkuDF5oQsZnD3Ri9I7r+J5y9OhyZUsDXe/LJARivF3x0CgYEA2rRx +wl4mfuYmikvcO8I4vuKXcK1UyYmZQLhp6EHKfhSVgrt7XsstZX9AP2OxUUAocRks +e6vU/sKUSni7TQrZzAZHc8JXonDgmCqoMPBXIuUncvysGR1kmgVIbN8ISPKJuZoV +8Dbj3fQfHZ0g0R+mUcuZ+xBO5CKcjPWHZXZoxfECgYAQ/5o8bNbnyXD74k1wpAbs +UYn1+BqQuyot+RIpOqMgXLzYtGu5Kvdd7GaE88XlAiirsAWM1IGydMdjnYnniLh9 +KDGSZPddKWPhNJdbOGRz3tjYwHG7Qp8tnEkmv1+uU8c2NHaKdFPBKceDEHW4X4Vs +kVSa/oaTVqqOUrM0LIYp4QKBgQCW1aIriiGEnZhxAvbGJCJczAvkAzcZtBOFBmrM +ayuLnwiqXEEu1HPfr06RKWFuhxAdSF5cgNrqRSpe3jtXXCdvxdjbpmooNy8+4xSS +g/+kqmR1snvC6nmqnAAiTgP5w4RnBDUjMcggGLCpDOhIMkrT2Na+x7WRM6nCsceK +m4qREQKBgEWqdb/QkOMvvKAz2DPDeSrwlTyisrZu1G/86uE3ESb97DisPK+TF2Ts +r4RGUlKL79W3j5xjvIvqGEEDLC+8QKpay9OYXk3lbViPGB8akWMSP6Tw/8AedhVu +sjFqcBEFGOELwm7VjAcDeP6bXeXibFe+rysBrfFHUGllytCmNoAV +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/test_jwt_auth/jwks_server/server.py b/tests/integration/test_jwt_auth/jwks_server/server.py new file mode 100644 index 000000000000..96e07f02335e --- /dev/null +++ b/tests/integration/test_jwt_auth/jwks_server/server.py @@ -0,0 +1,33 @@ +import sys + +from bottle import response, route, run + + +@route("/.well-known/jwks.json") +def server(): + result = { + "keys": [ + { + "kty": "RSA", + "alg": "RS512", + "kid": "mykid", + "n": "0RRsKcZ5j9UckjioG4Phvav3dkg2sXP6tQ7ug0yowAo_u2HffB-1OjKuhWTpA3E3YkMKj0RrT-tuUpmZEXqCAipEV7XcfCv3o" + "7Poa7HTq1ti_abVwT_KyfGjoNBBSJH4LTNAyo2J8ySKSDtpAEU52iL7s40Ra6I0vqp7_aRuPF5M4zcHzN3zarG5EfSVSG1-gT" + "kaRv8XJbra0IeIINmKv0F4--ww8ZxXTR6cvI-MsArUiAPwzf7s5dMR4DNRG6YNTrPA0pTOqQE9sRPd62XsfU08plYm27naOUZ" + "O5avIPl1YO5I6Gi4kPdTvv3WFIy-QvoKoPhPCaD6EbdBpe8BbTQ", + "e": "AQAB"}, + ] + } + response.status = 200 + response.content_type = "application/json" + return result + + +@route("/") +def ping(): + response.content_type = "text/plain" + response.set_header("Content-Length", 2) + return "OK" + + +run(host="0.0.0.0", port=int(sys.argv[1])) diff --git a/tests/integration/test_jwt_auth/test.py b/tests/integration/test_jwt_auth/test.py new file mode 100644 index 000000000000..14d42ae08bde --- /dev/null +++ b/tests/integration/test_jwt_auth/test.py @@ -0,0 +1,82 @@ +import os +import pytest + +from helpers.cluster import ClickHouseCluster +from helpers.mock_servers import start_mock_servers + +SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__)) + +cluster = ClickHouseCluster(__file__) +instance = cluster.add_instance( + "instance", + main_configs=["configs/validators.xml"], + user_configs=["configs/users.xml"], + with_minio=True, + # We actually don't need minio, but we need to run dummy resolver + # (a shortcut not to change cluster.py in a more unclear way, TBC later). +) +client = cluster.add_instance( + "client", +) + + +def run_jwks_server(): + script_dir = os.path.join(os.path.dirname(__file__), "jwks_server") + start_mock_servers( + cluster, + script_dir, + [ + ("server.py", "resolver", "8080"), + ], + ) + + +@pytest.fixture(scope="module") +def started_cluster(): + try: + cluster.start() + run_jwks_server() + yield cluster + finally: + cluster.shutdown() + + +def curl_with_jwt(token, ip, https=False): + http_prefix = "https" if https else "http" + curl = f'curl -H "Authorization: Bearer {token}" "{http_prefix}://{ip}:8123/?query=SELECT%20currentUser()"' + return curl + + +# See helpers/ directory if you need to re-create tokens (or understand how they are created) +def test_static_key(started_cluster): + res = client.exec_in_container( + [ + "bash", + "-c", + curl_with_jwt( + token="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqd3RfdXNlciJ9." + "kfivQ8qD_oY0UvihydeadD7xvuiO3zSmhFOc_SGbEPQ", + ip=cluster.get_instance_ip(instance.name), + ), + ] + ) + assert res == "jwt_user\n" + + +def test_jwks_server(started_cluster): + res = client.exec_in_container( + [ + "bash", + "-c", + curl_with_jwt( + token="eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzUxMiIsImtpZCI6Im15a2lkIn0." + "eyJzdWIiOiJqd3RfdXNlciIsImlzcyI6InRlc3RfaXNzIn0.MjegqrrVyrMMpkxIM-J_q-" + "Sw68Vk5xZuFpxecLLMFs5qzvnh0jslWtyRfi-ANJeJTONPZM5m0yP1ITt8BExoHWobkkR11bXz0ylYEIOgwxqw" + "36XhL2GkE17p-wMvfhCPhGOVL3b7msDRUKXNN48aAJA-NxRbQFhMr-eEx3HsrZXy17Qc7z-" + "0dINe355kzAInGp6gMk3uksAlJ3vMODK8jE-WYFqXusr5GFhXubZXdE2mK0mIbMUGisOZhZLc4QVwvUsYDLBCgJ2RHr5vm" + "jp17j_ZArIedUJkjeC4o72ZMC97kLVnVw94QJwNvd4YisxL6A_mWLTRq9FqNLD4HmbcOQ", + ip=cluster.get_instance_ip(instance.name), + ), + ] + ) + assert res == "jwt_user\n" From 2ac83f1740f4b89c16991a2e55d8b6d7f15c29d1 Mon Sep 17 00:00:00 2001 From: Andrey Zvonov Date: Wed, 26 Nov 2025 19:14:44 +0100 Subject: [PATCH 2/9] add sed-style groups (roles) transform --- .../external-authenticators/tokens.md | 2 + src/Access/TokenAccessStorage.cpp | 139 +++++++++++++++++- src/Access/TokenAccessStorage.h | 3 + 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/docs/en/operations/external-authenticators/tokens.md b/docs/en/operations/external-authenticators/tokens.md index 127fee7a7d5d..127be625db41 100644 --- a/docs/en/operations/external-authenticators/tokens.md +++ b/docs/en/operations/external-authenticators/tokens.md @@ -237,6 +237,7 @@ All this implies that the SQL-driven [Access Control and Account Management](/do \bclickhouse-[a-zA-Z0-9]+\b + s/-/_/g @@ -251,3 +252,4 @@ For now, no more than one `token` section can be defined inside `user_directorie - `processor` — Name of one of processors defined in `token_processors` config section described above. This parameter is mandatory and cannot be empty. - `common_roles` — Section with a list of locally defined roles that will be assigned to each user retrieved from the IdP. Optional. - `roles_filter` — Regex string for groups filtering. Only groups matching this regex will be mapped to roles. Optional. +- `roles_transform` — Sed-style transform pattern to apply to group names before mapping to roles. Format: `s/pattern/replacement/flags`. The `g` flag applies the replacement globally (all occurrences). Example: `s/-/_/g` converts `clickhouse-grp-dba` to `clickhouse_grp_dba`. Optional. diff --git a/src/Access/TokenAccessStorage.cpp b/src/Access/TokenAccessStorage.cpp index 53a3173007b3..2a6af03eb455 100644 --- a/src/Access/TokenAccessStorage.cpp +++ b/src/Access/TokenAccessStorage.cpp @@ -18,6 +18,115 @@ namespace ErrorCodes extern const int BAD_ARGUMENTS; } +namespace +{ + struct ParsedTransform + { + String pattern; + String replacement; + bool global; + }; + + /// Unescape a string segment + String unescapeSegment(const String & str, size_t start, size_t end) + { + String result; + result.reserve(end - start); + bool escaped = false; + + for (size_t i = start; i < end; ++i) + { + if (escaped) + { + result += str[i]; + escaped = false; + } + else if (str[i] == '\\') + escaped = true; + else + result += str[i]; + } + + return result; + } + + /// Parse sed-style transform pattern: s/pattern/replacement/flags + ParsedTransform parseSedTransform(const String & transform) + { + if (transform.size() < 4 || transform[0] != 's' || transform[1] != '/') + { + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid roles_transform format. Expected sed-style pattern like 's/pattern/replacement/g'"); + } + + bool escaped = false; + size_t first_slash = 1; + size_t second_slash = String::npos; + size_t third_slash = String::npos; + + // Find delimiters using simple state machine + for (size_t i = first_slash + 1; i < transform.size(); ++i) + { + if (escaped) + { + escaped = false; + continue; + } + + if (transform[i] == '\\') + { + escaped = true; + continue; + } + + if (transform[i] == '/') + { + if (second_slash == String::npos) + second_slash = i; + else if (third_slash == String::npos) + third_slash = i; + else + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid roles_transform format. Too many unescaped slashes. Expected sed-style pattern like 's/pattern/replacement/g'"); + } + } + + if (second_slash == String::npos || third_slash == String::npos) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid roles_transform format. Expected sed-style pattern like 's/pattern/replacement/g'"); + + ParsedTransform result; + + result.pattern = unescapeSegment(transform, first_slash + 1, second_slash); + + size_t replacement_end = (third_slash != String::npos) ? third_slash : transform.size(); + result.replacement = unescapeSegment(transform, second_slash + 1, replacement_end); + + String flags = transform.substr(third_slash + 1); + result.global = (flags.find('g') != String::npos); + + return result; + } + + String applyTransform(const String & input, const String & pattern, const String & replacement, bool global) + { + if (pattern.empty()) + return input; + + re2::RE2 re(pattern); + if (!re.ok()) + return input; + + String result = input; + if (global) + { + RE2::GlobalReplace(&result, re, replacement); + } + else + { + RE2::Replace(&result, re, replacement); + } + return result; + } +} + TokenAccessStorage::TokenAccessStorage(const String & storage_name_, AccessControl & access_control_, const Poco::Util::AbstractConfiguration & config_, const String & prefix_) : IAccessStorage(storage_name_), access_control(access_control_), config(config_), prefix(prefix_), memory_storage(storage_name_, access_control.getChangesNotifier(), false) @@ -29,6 +138,15 @@ TokenAccessStorage::TokenAccessStorage(const String & storage_name_, AccessContr if (config.has(prefix_str + "roles_filter")) roles_filter.emplace(config.getString(prefix_str + "roles_filter")); + if (config.has(prefix_str + "roles_transform")) + { + String transform = config.getString(prefix_str + "roles_transform"); + ParsedTransform parsed = parseSedTransform(transform); + roles_transform_pattern = parsed.pattern; + roles_transform_replacement = parsed.replacement; + roles_transform_global = parsed.global; + } + provider_name = config.getString(prefix_str + "processor"); if (provider_name.empty()) throw Exception(ErrorCodes::BAD_ARGUMENTS, "'processor' must be specified for Token user directory"); @@ -374,15 +492,30 @@ std::optional TokenAccessStorage::authenticateImpl( for (const auto & group: token_credentials.getGroups()) { if (RE2::FullMatch(group, roles_filter.value())) { - external_roles.insert(group); - LOG_TRACE(getLogger(), "{}: Granted role (group) {} to user", getStorageName(), user->getName()); + String transformed_group = group; + if (roles_transform_pattern.has_value() && roles_transform_replacement.has_value()) + { + transformed_group = applyTransform(group, roles_transform_pattern.value(), roles_transform_replacement.value(), roles_transform_global); + LOG_TRACE(getLogger(), "{}: Transformed group '{}' to '{}'", getStorageName(), group, transformed_group); + } + external_roles.insert(transformed_group); + LOG_TRACE(getLogger(), "{}: Granted role (group) {} to user", getStorageName(), transformed_group); } } } else { LOG_TRACE(getLogger(), "{}: No external role filtering set, applying all available groups", getStorageName()); - external_roles = token_credentials.getGroups(); + for (const auto & group: token_credentials.getGroups()) + { + String transformed_group = group; + if (roles_transform_pattern.has_value() && roles_transform_replacement.has_value()) + { + transformed_group = applyTransform(group, roles_transform_pattern.value(), roles_transform_replacement.value(), roles_transform_global); + LOG_TRACE(getLogger(), "{}: Transformed group '{}' to '{}'", getStorageName(), group, transformed_group); + } + external_roles.insert(transformed_group); + } } if (new_user) diff --git a/src/Access/TokenAccessStorage.h b/src/Access/TokenAccessStorage.h index ad40031ac7fd..14f8d82cb31e 100644 --- a/src/Access/TokenAccessStorage.h +++ b/src/Access/TokenAccessStorage.h @@ -48,6 +48,9 @@ class TokenAccessStorage : public IAccessStorage String provider_name; std::optional roles_filter = std::nullopt; + std::optional roles_transform_pattern = std::nullopt; + std::optional roles_transform_replacement = std::nullopt; + bool roles_transform_global = false; std::set common_role_names; // role name that should be granted to all users at all times mutable std::map> user_external_roles; From 1018ab5f9b36a6a77a65773ddb6421ea4d7b4574 Mon Sep 17 00:00:00 2001 From: Andrey Zvonov Date: Wed, 26 Nov 2025 19:23:53 +0100 Subject: [PATCH 3/9] map external users to profile --- .../external-authenticators/tokens.md | 2 + src/Access/TokenAccessStorage.cpp | 53 +++++++++++++++++++ src/Access/TokenAccessStorage.h | 3 ++ 3 files changed, 58 insertions(+) diff --git a/docs/en/operations/external-authenticators/tokens.md b/docs/en/operations/external-authenticators/tokens.md index 127be625db41..d2a2f4ac0e7a 100644 --- a/docs/en/operations/external-authenticators/tokens.md +++ b/docs/en/operations/external-authenticators/tokens.md @@ -234,6 +234,7 @@ All this implies that the SQL-driven [Access Control and Account Management](/do + my_profile \bclickhouse-[a-zA-Z0-9]+\b @@ -251,5 +252,6 @@ For now, no more than one `token` section can be defined inside `user_directorie - `processor` — Name of one of processors defined in `token_processors` config section described above. This parameter is mandatory and cannot be empty. - `common_roles` — Section with a list of locally defined roles that will be assigned to each user retrieved from the IdP. Optional. +- `default_profile` — Name of a locally defined settings profile that will be assigned to each user retrieved from the IdP. If the profile does not exist, a warning will be logged and the user will be created without a profile. Optional. - `roles_filter` — Regex string for groups filtering. Only groups matching this regex will be mapped to roles. Optional. - `roles_transform` — Sed-style transform pattern to apply to group names before mapping to roles. Format: `s/pattern/replacement/flags`. The `g` flag applies the replacement globally (all occurrences). Example: `s/-/_/g` converts `clickhouse-grp-dba` to `clickhouse_grp_dba`. Optional. diff --git a/src/Access/TokenAccessStorage.cpp b/src/Access/TokenAccessStorage.cpp index 2a6af03eb455..e17bc7159cef 100644 --- a/src/Access/TokenAccessStorage.cpp +++ b/src/Access/TokenAccessStorage.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -161,6 +162,9 @@ TokenAccessStorage::TokenAccessStorage(const String & storage_name_, AccessContr } common_role_names.swap(common_roles_cfg); + if (config.has(prefix_str + "default_profile")) + default_profile_name = config.getString(prefix_str + "default_profile"); + user_external_roles.clear(); users_per_roles.clear(); roles_per_users.clear(); @@ -426,6 +430,42 @@ void TokenAccessStorage::assignRolesNoLock(User & user, const std::set & user_external_roles[user_name] = external_roles; } +void TokenAccessStorage::assignProfileNoLock(User & user) const +{ + if (default_profile_name.empty()) + return; + + const auto & user_name = user.getName(); + auto & settings = user.settings; + + // Look up the profile ID once + const auto profile_id = access_control.find(default_profile_name); + if (!profile_id) + { + LOG_TRACE(getLogger(), "Did not assign profile '{}' to user '{}': profile not found", default_profile_name, user_name); + return; + } + + // Check if profile is already assigned + bool profile_already_assigned = false; + for (const auto & element : settings) + { + if (element.parent_profile.has_value() && element.parent_profile == *profile_id) + { + profile_already_assigned = true; + break; + } + } + + if (!profile_already_assigned) + { + SettingsProfileElement profile_element; + profile_element.parent_profile = *profile_id; + settings.push_back(std::move(profile_element)); + LOG_TRACE(getLogger(), "Assigned profile '{}' to user '{}'", default_profile_name, user_name); + } +} + void TokenAccessStorage::updateAssignedRolesNoLock(const UUID & id, const String & user_name, const std::set & external_roles) const { // Map and grant the roles from scratch only if the list of external role has changed. @@ -521,12 +561,25 @@ std::optional TokenAccessStorage::authenticateImpl( if (new_user) { assignRolesNoLock(*new_user, external_roles); + assignProfileNoLock(*new_user); id = memory_storage.insert(new_user); } else { // Just in case external_roles are changed. updateAssignedRolesNoLock(*id, user->getName(), external_roles); + + // Also update profile if needed + memory_storage.update(*id, [this] (const AccessEntityPtr & entity_, const UUID &) -> AccessEntityPtr + { + if (auto user_entity = typeid_cast>(entity_)) + { + auto changed_user = typeid_cast>(user_entity->clone()); + assignProfileNoLock(*changed_user); + return changed_user; + } + return entity_; + }); } if (id) diff --git a/src/Access/TokenAccessStorage.h b/src/Access/TokenAccessStorage.h index 14f8d82cb31e..aedf8843f2b9 100644 --- a/src/Access/TokenAccessStorage.h +++ b/src/Access/TokenAccessStorage.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -53,6 +54,7 @@ class TokenAccessStorage : public IAccessStorage bool roles_transform_global = false; std::set common_role_names; // role name that should be granted to all users at all times + String default_profile_name; // settings profile name that should be assigned to all users mutable std::map> user_external_roles; mutable std::map> users_per_roles; // role name -> user names (...it should be granted to; may but don't have to exist for common roles) mutable std::map> roles_per_users; // user name -> role names (...that should be granted to it; may but don't have to include common roles) @@ -67,6 +69,7 @@ class TokenAccessStorage : public IAccessStorage void applyRoleChangeNoLock(bool grant, const UUID & role_id, const String & role_name); void assignRolesNoLock(User & user, const std::set & external_roles) const; + void assignProfileNoLock(User & user) const; void updateAssignedRolesNoLock(const UUID & id, const String & user_name, const std::set & external_roles) const; protected: From d7c8c372997eae576b5e77f6dd82a2a40798b83b Mon Sep 17 00:00:00 2001 From: Andrey Zvonov Date: Tue, 2 Dec 2025 17:01:35 +0100 Subject: [PATCH 4/9] Correct exception thrown when JWK not found --- src/Access/Common/JWKSProvider.cpp | 9 +++------ src/Access/Common/JWKSProvider.h | 2 +- src/Access/TokenProcessorsJWT.cpp | 3 +++ src/Access/TokenProcessorsParse.cpp | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Access/Common/JWKSProvider.cpp b/src/Access/Common/JWKSProvider.cpp index d9d29636c90f..40814ea5eb86 100644 --- a/src/Access/Common/JWKSProvider.cpp +++ b/src/Access/Common/JWKSProvider.cpp @@ -25,11 +25,8 @@ JWKSType JWKSClient::getJWKS() auto now = std::chrono::high_resolution_clock::now(); auto diff = std::chrono::duration(now - last_request_send).count(); - if (diff < refresh_timeout) - { - jwt::jwks result(cached_jwks); - return result; - } + if (diff < refresh_timeout && cached_jwks.has_value()) + return cached_jwks.value(); Poco::Net::HTTPResponse response; std::string response_string; @@ -70,7 +67,7 @@ JWKSType JWKSClient::getJWKS() } cached_jwks = std::move(parsed_jwks); - return cached_jwks; + return cached_jwks.value(); } StaticJWKSParams::StaticJWKSParams(const std::string & static_jwks_, const std::string & static_jwks_file_) diff --git a/src/Access/Common/JWKSProvider.h b/src/Access/Common/JWKSProvider.h index 6b2f41a58b72..566effd6e21e 100644 --- a/src/Access/Common/JWKSProvider.h +++ b/src/Access/Common/JWKSProvider.h @@ -43,7 +43,7 @@ class JWKSClient : public IJWKSProvider Poco::URI jwks_uri; std::shared_mutex mutex; - JWKSType cached_jwks; + std::optional cached_jwks; std::chrono::time_point last_request_send; }; diff --git a/src/Access/TokenProcessorsJWT.cpp b/src/Access/TokenProcessorsJWT.cpp index 28181877bda8..c13002c7ee59 100644 --- a/src/Access/TokenProcessorsJWT.cpp +++ b/src/Access/TokenProcessorsJWT.cpp @@ -334,6 +334,9 @@ bool JwksJwtProcessor::resolveAndValidate(TokenCredentials & credentials) const return false; } + if (!provider->getJWKS().has_jwk(decoded_jwt.get_key_id())) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWKS error: no JWK found for JWT"); + auto jwk = provider->getJWKS().get_jwk(decoded_jwt.get_key_id()); auto username = decoded_jwt.get_payload_claim(username_claim).as_string(); diff --git a/src/Access/TokenProcessorsParse.cpp b/src/Access/TokenProcessorsParse.cpp index a88cb69b2499..2e8912c2fd38 100644 --- a/src/Access/TokenProcessorsParse.cpp +++ b/src/Access/TokenProcessorsParse.cpp @@ -45,7 +45,7 @@ std::unique_ptr ITokenProcessor::parseTokenProcessor( if (externally_configured && ! locally_configured) { return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, - config.getString(prefix + ".openid_config_endpoint"), + config.getString(prefix + ".configuration_endpoint"), verifier_leeway, jwks_cache_lifetime); } From a6631a462259efa5894086ada2e16f4230e70c72 Mon Sep 17 00:00:00 2001 From: Andrey Zvonov Date: Tue, 9 Dec 2025 21:41:34 +0100 Subject: [PATCH 5/9] add setting to disable token auth --- .../external-authenticators/tokens.md | 9 +++++++++ src/Access/AccessControl.cpp | 20 ++++++++++++++++++- src/Access/AccessControl.h | 5 +++++ src/Access/ExternalAuthenticators.cpp | 19 ++++++++++++++++-- src/Access/ExternalAuthenticators.h | 6 +++++- src/Core/ServerSettings.cpp | 9 +++++++++ src/Server/HTTP/authenticateUserByHTTP.cpp | 6 +++++- src/Server/TCPHandler.cpp | 6 +++++- 8 files changed, 74 insertions(+), 6 deletions(-) diff --git a/docs/en/operations/external-authenticators/tokens.md b/docs/en/operations/external-authenticators/tokens.md index d2a2f4ac0e7a..cc29e13c5f03 100644 --- a/docs/en/operations/external-authenticators/tokens.md +++ b/docs/en/operations/external-authenticators/tokens.md @@ -16,6 +16,15 @@ Although not all tokens are JWTs, under the hood both ways are treated as the sa # Token Processors ## Configuration + +Token-based authentication is enabled by default. To disable it, set `enable_token_auth` to `0` in `config.xml`: + +```xml +0 +``` + +When disabled, token processors are not parsed, TokenAccessStorage is not available, and authentication via tokens (`--jwt` option or `Authorization: Bearer` header) is rejected. + To use token-based authentication, add `token_processors` section to `config.xml` and define at least one token processor in it. Its contents are different for different token processor types. diff --git a/src/Access/AccessControl.cpp b/src/Access/AccessControl.cpp index 4469bc83415a..ac1b0f5499e5 100644 --- a/src/Access/AccessControl.cpp +++ b/src/Access/AccessControl.cpp @@ -297,6 +297,8 @@ void AccessControl::setupFromMainConfig(const Poco::Util::AbstractConfiguration setDefaultPasswordTypeFromConfig(config_.getString("default_password_type", "sha256_password")); setPasswordComplexityRulesFromConfig(config_); + setTokenAuthEnabled(config_.getBool("enable_token_auth", true)); + setBcryptWorkfactor(config_.getInt("bcrypt_workfactor", 12)); /// Optional improvements in access control system. @@ -494,6 +496,12 @@ void AccessControl::addStoragesFromUserDirectoriesConfig( } else if (type == TokenAccessStorage::STORAGE_TYPE) { + if (!isTokenAuthEnabled()) + { + LOG_INFO(getLogger(), "Token authentication is disabled, skipping token user directory '{}'", name); + continue; + } + if (has_token_storage) throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "Only one `token` section can be defined."); @@ -677,7 +685,7 @@ void AccessControl::restoreFromBackup(RestorerFromBackup & restorer, const Strin void AccessControl::setExternalAuthenticatorsConfig(const Poco::Util::AbstractConfiguration & config) { - external_authenticators->setConfiguration(config, getLogger()); + external_authenticators->setConfiguration(config, getLogger(), isTokenAuthEnabled()); } @@ -956,4 +964,14 @@ bool AccessControl::getAllowBetaTierSettings() const { return allow_beta_tier_settings; } + +void AccessControl::setTokenAuthEnabled(bool enable) +{ + enable_token_auth = enable; +} + +bool AccessControl::isTokenAuthEnabled() const +{ + return enable_token_auth; +} } diff --git a/src/Access/AccessControl.h b/src/Access/AccessControl.h index 0af5168c761a..a246795d005f 100644 --- a/src/Access/AccessControl.h +++ b/src/Access/AccessControl.h @@ -261,6 +261,10 @@ class AccessControl : public MultipleAccessStorage bool getAllowExperimentalTierSettings() const; bool getAllowBetaTierSettings() const; + /// Controls whether token-based auth is enabled. + void setTokenAuthEnabled(bool enable); + bool isTokenAuthEnabled() const; + private: class ContextAccessCache; class CustomSettingsPrefixes; @@ -294,6 +298,7 @@ class AccessControl : public MultipleAccessStorage std::atomic_bool allow_beta_tier_settings = true; std::atomic_bool enable_user_name_access_type = true; std::atomic_bool enable_read_write_grants = false; + std::atomic_bool enable_token_auth = true; }; } diff --git a/src/Access/ExternalAuthenticators.cpp b/src/Access/ExternalAuthenticators.cpp index 9e6a27cec715..99e99c20f738 100644 --- a/src/Access/ExternalAuthenticators.cpp +++ b/src/Access/ExternalAuthenticators.cpp @@ -286,6 +286,8 @@ void ExternalAuthenticators::resetImpl() ldap_caches.clear(); kerberos_params.reset(); token_processors.clear(); + access_token_to_username_cache.clear(); + username_to_access_token_cache.clear(); } void ExternalAuthenticators::reset() @@ -318,10 +320,17 @@ void parseTokenProcessors(std::unordered_mapgetAccessControl(); + if (!access_control.isTokenAuthEnabled()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Token authentication is disabled"); + const auto token_credentials = TokenCredentials(bearer_token); - const auto & external_authenticators = global_context->getAccessControl().getExternalAuthenticators(); + const auto & external_authenticators = access_control.getExternalAuthenticators(); if (!external_authenticators.checkTokenCredentials(token_credentials)) throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid authentication: Token could not be verified."); diff --git a/src/Server/TCPHandler.cpp b/src/Server/TCPHandler.cpp index 7ef1c387a49b..3ac9753c8c5d 100644 --- a/src/Server/TCPHandler.cpp +++ b/src/Server/TCPHandler.cpp @@ -1876,9 +1876,13 @@ void TCPHandler::receiveHello() if (is_jwt_based_auth) { + const auto & access_control = server.context()->getAccessControl(); + if (!access_control.isTokenAuthEnabled()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Token authentication is disabled"); + auto credentials = TokenCredentials(password); - const auto & external_authenticators = server.context()->getAccessControl().getExternalAuthenticators(); + const auto & external_authenticators = access_control.getExternalAuthenticators(); if (!external_authenticators.checkTokenCredentials(credentials)) throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Token is invalid"); From 63d70f1d91a766f7fa35fd9a5ef973b3fba3c126 Mon Sep 17 00:00:00 2001 From: Andrey Zvonov Date: Thu, 18 Dec 2025 22:53:01 +0100 Subject: [PATCH 6/9] add optional iss and aud checks --- .../external-authenticators/tokens.md | 10 ++++++++- src/Access/TokenProcessors.h | 21 ++++++++++++++++++- src/Access/TokenProcessorsJWT.cpp | 21 +++++++++++++++---- src/Access/TokenProcessorsOpaque.cpp | 12 ++++++++++- src/Access/TokenProcessorsParse.cpp | 8 ++++++- 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/docs/en/operations/external-authenticators/tokens.md b/docs/en/operations/external-authenticators/tokens.md index cc29e13c5f03..39afc3585580 100644 --- a/docs/en/operations/external-authenticators/tokens.md +++ b/docs/en/operations/external-authenticators/tokens.md @@ -64,13 +64,15 @@ It is decoded locally and its integrity is verified using either a local static | HS512 | RS512 | ES512 | PS512 | | | | | ES256K | | | Also supports None (though not recommended). -`claims` - A string containing a JSON object that should be contained in the token payload. If this parameter is defined, token without corresponding payload will be considered invalid. Optional. +- `claims` - A string containing a JSON object that should be contained in the token payload. If this parameter is defined, token without corresponding payload will be considered invalid. Optional. - `static_key` - key for symmetric algorithms. Mandatory for `HS*` family algorithms. - `static_key_in_base64` - indicates if the `static_key` key is base64-encoded. Optional, default: `False`. - `public_key` - public key for asymmetric algorithms. Mandatory except for `HS*` family algorithms and `None`. - `private_key` - private key for asymmetric algorithms. Optional. - `public_key_password` - public key password. Optional. - `private_key_password` - private key password. Optional. +- `expected_issuer` - Expected value of the `iss` (issuer) claim in the JWT. If specified, tokens with a different issuer will be rejected. Optional. +- `expected_audience` - Expected value of the `aud` (audience) claim in the JWT. If specified, tokens with a different audience will be rejected. Optional. ### JWT with static JWKS ```xml @@ -90,6 +92,8 @@ It is decoded locally and its integrity is verified using either a local static - `static_jwks_file` - path to a file with JWKS - `claims` - A string containing a JSON object that should be contained in the token payload. If this parameter is defined, token without corresponding payload will be considered invalid. Optional. - `verifier_leeway` - Clock skew tolerance (seconds). Useful for handling small differences in system clocks between ClickHouse and the token issuer. Optional. +- `expected_issuer` - Expected value of the `iss` (issuer) claim in the JWT. If specified, tokens with a different issuer will be rejected. Optional. +- `expected_audience` - Expected value of the `aud` (audience) claim in the JWT. If specified, tokens with a different audience will be rejected. Optional. :::note Only one of `static_jwks` or `static_jwks_file` keys must be present in one verifier @@ -118,6 +122,8 @@ Only RS* family algorithms are supported! - `jwks_cache_lifetime` - Period for resend request for refreshing JWKS. Optional, default: 3600. - `claims` - A string containing a JSON object that should be contained in the token payload. If this parameter is defined, token without corresponding payload will be considered invalid. Optional. - `verifier_leeway` - Clock skew tolerance (seconds). Useful for handling small differences in system clocks between ClickHouse and the token issuer. Optional. +- `expected_issuer` - Expected value of the `iss` (issuer) claim in the JWT. If specified, tokens with a different issuer will be rejected. Optional. +- `expected_audience` - Expected value of the `aud` (audience) claim in the JWT. If specified, tokens with a different audience will be rejected. Optional. ## Processors with external providers @@ -171,6 +177,8 @@ Either `configuration_endpoint` or both `userinfo_endpoint` and `token_introspec - `jwks_uri` - URI of OpenID configuration (often ends with `.well-known/jwks.json`) - `jwks_cache_lifetime` - Period for resend request for refreshing JWKS. Optional, default: 3600. - `verifier_leeway` - Clock skew tolerance (seconds). Useful for handling small differences in system clocks between ClickHouse and the token issuer. Optional, default: 60 +- `expected_issuer` - Expected value of the `iss` (issuer) claim in the JWT. If specified, tokens with a different issuer will be rejected. Optional. +- `expected_audience` - Expected value of the `aud` (audience) claim in the JWT. If specified, tokens with a different audience will be rejected. Optional. Sometimes a token is a valid JWT. In that case token will be decoded and validated locally if configuration endpoint returns JWKS URI (or `jwks_uri` is specified alongside `userinfo_endpoint` and `token_introspection_endpoint`). diff --git a/src/Access/TokenProcessors.h b/src/Access/TokenProcessors.h index 902a99850588..cf5b80bbb931 100644 --- a/src/Access/TokenProcessors.h +++ b/src/Access/TokenProcessors.h @@ -87,6 +87,8 @@ class StaticKeyJwtProcessor : public ITokenProcessor UInt64 token_cache_lifetime_, const String & username_claim_, const String & groups_claim_, + const String & expected_issuer_, + const String & expected_audience_, const StaticKeyJwtParams & params); bool resolveAndValidate(TokenCredentials & credentials) const override; @@ -94,6 +96,8 @@ class StaticKeyJwtProcessor : public ITokenProcessor private: const String claims; + const String expected_issuer; + const String expected_audience; jwt::verifier verifier = jwt::verify(); }; @@ -105,16 +109,21 @@ class JwksJwtProcessor : public ITokenProcessor UInt64 token_cache_lifetime_, const String & username_claim_, const String & groups_claim_, + const String & expected_issuer_, + const String & expected_audience_, const String & claims_, size_t verifier_leeway_, std::shared_ptr provider_) : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_), - claims(claims_), provider(provider_), verifier_leeway(verifier_leeway_) {} + claims(claims_), expected_issuer(expected_issuer_), expected_audience(expected_audience_), + provider(provider_), verifier_leeway(verifier_leeway_) {} explicit JwksJwtProcessor(const String & processor_name_, UInt64 token_cache_lifetime_, const String & username_claim_, const String & groups_claim_, + const String & expected_issuer_, + const String & expected_audience_, const String & claims_, size_t verifier_leeway_, const String & jwks_uri_, @@ -123,6 +132,8 @@ class JwksJwtProcessor : public ITokenProcessor token_cache_lifetime_, username_claim_, groups_claim_, + expected_issuer_, + expected_audience_, claims_, verifier_leeway_, std::make_shared(jwks_uri_, jwks_cache_lifetime_)) {} @@ -132,6 +143,8 @@ class JwksJwtProcessor : public ITokenProcessor private: const String claims; + const String expected_issuer; + const String expected_audience; mutable jwt::verifier verifier = jwt::verify(); std::shared_ptr provider; const size_t verifier_leeway; @@ -171,6 +184,8 @@ class OpenIdTokenProcessor : public ITokenProcessor UInt64 token_cache_lifetime_, const String & username_claim_, const String & groups_claim_, + const String & expected_issuer_, + const String & expected_audience_, const String & userinfo_endpoint_, const String & token_introspection_endpoint_, UInt64 verifier_leeway_, @@ -182,12 +197,16 @@ class OpenIdTokenProcessor : public ITokenProcessor UInt64 token_cache_lifetime_, const String & username_claim_, const String & groups_claim_, + const String & expected_issuer_, + const String & expected_audience_, const String & openid_config_endpoint_, UInt64 verifier_leeway_, UInt64 jwks_cache_lifetime_); bool resolveAndValidate(TokenCredentials & credentials) const override; private: + const String expected_issuer; + const String expected_audience; Poco::URI userinfo_endpoint; Poco::URI token_introspection_endpoint; diff --git a/src/Access/TokenProcessorsJWT.cpp b/src/Access/TokenProcessorsJWT.cpp index c13002c7ee59..36f6e1f3d707 100644 --- a/src/Access/TokenProcessorsJWT.cpp +++ b/src/Access/TokenProcessorsJWT.cpp @@ -178,9 +178,11 @@ StaticKeyJwtProcessor::StaticKeyJwtProcessor(const String & processor_name_, UInt64 token_cache_lifetime_, const String & username_claim_, const String & groups_claim_, + const String & expected_issuer_, + const String & expected_audience_, const StaticKeyJwtParams & params) : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_), - claims(params.claims) + claims(params.claims), expected_issuer(expected_issuer_), expected_audience(expected_audience_) { const String & algo = params.algo; const String & static_key = params.static_key; @@ -259,6 +261,11 @@ StaticKeyJwtProcessor::StaticKeyJwtProcessor(const String & processor_name_, else throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "{}: Invalid token processor definition, unknown algorithm {}", processor_name, algo); + if (!expected_issuer.empty()) + verifier = verifier.with_issuer(expected_issuer); + + if (!expected_audience.empty()) + verifier = verifier.with_audience(expected_audience); } namespace @@ -352,10 +359,9 @@ bool JwksJwtProcessor::resolveAndValidate(TokenCredentials & credentials) const try { - auto issuer = decoded_jwt.get_issuer(); auto x5c = jwk.get_x5c_key_value(); - if (!x5c.empty() && !issuer.empty()) + if (!x5c.empty()) { LOG_TRACE(getLogger("TokenAuthentication"), "{}: Verifying {} with 'x5c' key", processor_name, username); public_key = jwt::helper::convert_base64_der_to_pem(x5c); @@ -363,7 +369,7 @@ bool JwksJwtProcessor::resolveAndValidate(TokenCredentials & credentials) const } catch (const jwt::error::claim_not_present_exception &) { - LOG_TRACE(getLogger("TokenAuthentication"), "{}: issuer or x5c was not specified, skip verification against them", processor_name); + LOG_TRACE(getLogger("TokenAuthentication"), "{}: x5c was not specified in JWK, will try RSA components", processor_name); } catch (const std::bad_cast &) { @@ -393,6 +399,13 @@ bool JwksJwtProcessor::resolveAndValidate(TokenCredentials & credentials) const throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "JWT cannot be validated: unknown algorithm {}", algo); verifier = verifier.leeway(verifier_leeway); + + if (!expected_issuer.empty()) + verifier = verifier.with_issuer(expected_issuer); + + if (!expected_audience.empty()) + verifier = verifier.with_audience(expected_audience); + verifier.verify(decoded_jwt); if (!claims.empty() && !check_claims(claims, decoded_jwt.get_payload_json())) diff --git a/src/Access/TokenProcessorsOpaque.cpp b/src/Access/TokenProcessorsOpaque.cpp index 04b9ac9ddcb3..1344b87f52d4 100644 --- a/src/Access/TokenProcessorsOpaque.cpp +++ b/src/Access/TokenProcessorsOpaque.cpp @@ -256,12 +256,15 @@ OpenIdTokenProcessor::OpenIdTokenProcessor(const String & processor_name_, UInt64 token_cache_lifetime_, const String & username_claim_, const String & groups_claim_, + const String & expected_issuer_, + const String & expected_audience_, const String & userinfo_endpoint_, const String & token_introspection_endpoint_, UInt64 verifier_leeway_, const String & jwks_uri_, UInt64 jwks_cache_lifetime_) : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_), + expected_issuer(expected_issuer_), expected_audience(expected_audience_), userinfo_endpoint(userinfo_endpoint_), token_introspection_endpoint(token_introspection_endpoint_) { if (!jwks_uri_.empty()) @@ -271,6 +274,8 @@ OpenIdTokenProcessor::OpenIdTokenProcessor(const String & processor_name_, token_cache_lifetime_, username_claim_, groups_claim_, + expected_issuer_, + expected_audience_, "", verifier_leeway_, jwks_uri_, @@ -282,10 +287,13 @@ OpenIdTokenProcessor::OpenIdTokenProcessor(const String & processor_name_, UInt64 token_cache_lifetime_, const String & username_claim_, const String & groups_claim_, + const String & expected_issuer_, + const String & expected_audience_, const String & openid_config_endpoint_, UInt64 verifier_leeway_, UInt64 jwks_cache_lifetime_) - : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_) + : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_), + expected_issuer(expected_issuer_), expected_audience(expected_audience_) { const picojson::object openid_config = getObjectFromURI(Poco::URI(openid_config_endpoint_)); @@ -299,6 +307,8 @@ OpenIdTokenProcessor::OpenIdTokenProcessor(const String & processor_name_, token_cache_lifetime_, username_claim_, groups_claim_, + expected_issuer_, + expected_audience_, "", verifier_leeway_, getValueByKey(openid_config, "jwks_uri").value(), diff --git a/src/Access/TokenProcessorsParse.cpp b/src/Access/TokenProcessorsParse.cpp index 2e8912c2fd38..24eef7ea699a 100644 --- a/src/Access/TokenProcessorsParse.cpp +++ b/src/Access/TokenProcessorsParse.cpp @@ -25,6 +25,8 @@ std::unique_ptr ITokenProcessor::parseTokenProcessor( auto token_cache_lifetime = config.getUInt64(prefix + ".token_cache_lifetime", 3600); auto username_claim = config.getString(prefix + ".username_claim", "sub"); auto groups_claim = config.getString(prefix + ".groups_claim", "groups"); + auto expected_issuer = config.getString(prefix + ".expected_issuer", ""); + auto expected_audience = config.getString(prefix + ".expected_audience", ""); if (provider_type == "google") { @@ -45,6 +47,7 @@ std::unique_ptr ITokenProcessor::parseTokenProcessor( if (externally_configured && ! locally_configured) { return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, + expected_issuer, expected_audience, config.getString(prefix + ".configuration_endpoint"), verifier_leeway, jwks_cache_lifetime); @@ -52,6 +55,7 @@ std::unique_ptr ITokenProcessor::parseTokenProcessor( else if (locally_configured && !externally_configured) { return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, + expected_issuer, expected_audience, config.getString(prefix + ".userinfo_endpoint"), config.getString(prefix + ".token_introspection_endpoint"), verifier_leeway, @@ -77,7 +81,7 @@ std::unique_ptr ITokenProcessor::parseTokenProcessor( config.getString(prefix + ".public_key_password", ""), config.getString(prefix + ".private_key_password", ""), config.getString(prefix + ".claims", "")}; - return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, params); + return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, expected_issuer, expected_audience, params); } else if (provider_type == "jwt_static_jwks") { @@ -96,6 +100,7 @@ std::unique_ptr ITokenProcessor::parseTokenProcessor( config.getString(prefix + ".static_jwks_file", "") }; return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, + expected_issuer, expected_audience, config.getString(prefix + ".claims", ""), config.getUInt64(prefix + ".verifier_leeway", 0), std::make_shared(params)); @@ -108,6 +113,7 @@ std::unique_ptr ITokenProcessor::parseTokenProcessor( throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'jwks_uri' must be specified for 'jwt_dynamic_jwks' processor"); return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, + expected_issuer, expected_audience, config.getString(prefix + ".claims", ""), config.getUInt64(prefix + ".verifier_leeway", 0), config.getString(prefix + ".jwks_uri"), From 5a685db893a95ec04ba79610c23b5da18a496dff Mon Sep 17 00:00:00 2001 From: Andrey Zvonov Date: Thu, 18 Dec 2025 22:54:30 +0100 Subject: [PATCH 7/9] discourage 'none' alg usage in docs --- docs/en/operations/external-authenticators/tokens.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/operations/external-authenticators/tokens.md b/docs/en/operations/external-authenticators/tokens.md index 39afc3585580..65ecb89454b6 100644 --- a/docs/en/operations/external-authenticators/tokens.md +++ b/docs/en/operations/external-authenticators/tokens.md @@ -63,7 +63,7 @@ It is decoded locally and its integrity is verified using either a local static | HS384 | RS384 | ES384 | PS384 | Ed448 | | HS512 | RS512 | ES512 | PS512 | | | | | ES256K | | | - Also supports None (though not recommended). + Also supports None (not recommended and must *NEVER* be used in production). - `claims` - A string containing a JSON object that should be contained in the token payload. If this parameter is defined, token without corresponding payload will be considered invalid. Optional. - `static_key` - key for symmetric algorithms. Mandatory for `HS*` family algorithms. - `static_key_in_base64` - indicates if the `static_key` key is base64-encoded. Optional, default: `False`. From 7dc1bd53364e73f87f5c507a7a2436fbd5875720 Mon Sep 17 00:00:00 2001 From: Andrey Zvonov Date: Fri, 19 Dec 2025 12:30:49 +0100 Subject: [PATCH 8/9] enforce expiration check by default --- .../operations/external-authenticators/tokens.md | 4 ++++ src/Access/TokenProcessors.h | 11 ++++++++++- src/Access/TokenProcessorsJWT.cpp | 16 +++++++++++++++- src/Access/TokenProcessorsOpaque.cpp | 8 +++++++- src/Access/TokenProcessorsParse.cpp | 11 ++++++----- 5 files changed, 42 insertions(+), 8 deletions(-) diff --git a/docs/en/operations/external-authenticators/tokens.md b/docs/en/operations/external-authenticators/tokens.md index 65ecb89454b6..ed9ebee2bd09 100644 --- a/docs/en/operations/external-authenticators/tokens.md +++ b/docs/en/operations/external-authenticators/tokens.md @@ -73,6 +73,7 @@ It is decoded locally and its integrity is verified using either a local static - `private_key_password` - private key password. Optional. - `expected_issuer` - Expected value of the `iss` (issuer) claim in the JWT. If specified, tokens with a different issuer will be rejected. Optional. - `expected_audience` - Expected value of the `aud` (audience) claim in the JWT. If specified, tokens with a different audience will be rejected. Optional. +- `allow_no_expiration` - If `true`, tokens without the `exp` (expiration) claim are accepted. Otherwise they are rejected. Optional, default: `false`. ### JWT with static JWKS ```xml @@ -94,6 +95,7 @@ It is decoded locally and its integrity is verified using either a local static - `verifier_leeway` - Clock skew tolerance (seconds). Useful for handling small differences in system clocks between ClickHouse and the token issuer. Optional. - `expected_issuer` - Expected value of the `iss` (issuer) claim in the JWT. If specified, tokens with a different issuer will be rejected. Optional. - `expected_audience` - Expected value of the `aud` (audience) claim in the JWT. If specified, tokens with a different audience will be rejected. Optional. +- `allow_no_expiration` - If `true`, tokens without the `exp` (expiration) claim are accepted. Otherwise they are rejected. Optional, default: `false`. :::note Only one of `static_jwks` or `static_jwks_file` keys must be present in one verifier @@ -124,6 +126,7 @@ Only RS* family algorithms are supported! - `verifier_leeway` - Clock skew tolerance (seconds). Useful for handling small differences in system clocks between ClickHouse and the token issuer. Optional. - `expected_issuer` - Expected value of the `iss` (issuer) claim in the JWT. If specified, tokens with a different issuer will be rejected. Optional. - `expected_audience` - Expected value of the `aud` (audience) claim in the JWT. If specified, tokens with a different audience will be rejected. Optional. +- `allow_no_expiration` - If `true`, tokens without the `exp` (expiration) claim are accepted. Otherwise they are rejected. Optional, default: `false`. ## Processors with external providers @@ -179,6 +182,7 @@ Either `configuration_endpoint` or both `userinfo_endpoint` and `token_introspec - `verifier_leeway` - Clock skew tolerance (seconds). Useful for handling small differences in system clocks between ClickHouse and the token issuer. Optional, default: 60 - `expected_issuer` - Expected value of the `iss` (issuer) claim in the JWT. If specified, tokens with a different issuer will be rejected. Optional. - `expected_audience` - Expected value of the `aud` (audience) claim in the JWT. If specified, tokens with a different audience will be rejected. Optional. +- `allow_no_expiration` - If `true`, tokens without the `exp` (expiration) claim are accepted. Otherwise they are rejected. Optional, default: `false`. Sometimes a token is a valid JWT. In that case token will be decoded and validated locally if configuration endpoint returns JWKS URI (or `jwks_uri` is specified alongside `userinfo_endpoint` and `token_introspection_endpoint`). diff --git a/src/Access/TokenProcessors.h b/src/Access/TokenProcessors.h index cf5b80bbb931..c898bfff15d4 100644 --- a/src/Access/TokenProcessors.h +++ b/src/Access/TokenProcessors.h @@ -89,6 +89,7 @@ class StaticKeyJwtProcessor : public ITokenProcessor const String & groups_claim_, const String & expected_issuer_, const String & expected_audience_, + bool allow_no_expiration_, const StaticKeyJwtParams & params); bool resolveAndValidate(TokenCredentials & credentials) const override; @@ -98,6 +99,7 @@ class StaticKeyJwtProcessor : public ITokenProcessor const String claims; const String expected_issuer; const String expected_audience; + const bool allow_no_expiration; jwt::verifier verifier = jwt::verify(); }; @@ -111,12 +113,13 @@ class JwksJwtProcessor : public ITokenProcessor const String & groups_claim_, const String & expected_issuer_, const String & expected_audience_, + bool allow_no_expiration_, const String & claims_, size_t verifier_leeway_, std::shared_ptr provider_) : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_), claims(claims_), expected_issuer(expected_issuer_), expected_audience(expected_audience_), - provider(provider_), verifier_leeway(verifier_leeway_) {} + allow_no_expiration(allow_no_expiration_), provider(provider_), verifier_leeway(verifier_leeway_) {} explicit JwksJwtProcessor(const String & processor_name_, UInt64 token_cache_lifetime_, @@ -124,6 +127,7 @@ class JwksJwtProcessor : public ITokenProcessor const String & groups_claim_, const String & expected_issuer_, const String & expected_audience_, + bool allow_no_expiration_, const String & claims_, size_t verifier_leeway_, const String & jwks_uri_, @@ -134,6 +138,7 @@ class JwksJwtProcessor : public ITokenProcessor groups_claim_, expected_issuer_, expected_audience_, + allow_no_expiration_, claims_, verifier_leeway_, std::make_shared(jwks_uri_, jwks_cache_lifetime_)) {} @@ -145,6 +150,7 @@ class JwksJwtProcessor : public ITokenProcessor const String claims; const String expected_issuer; const String expected_audience; + const bool allow_no_expiration; mutable jwt::verifier verifier = jwt::verify(); std::shared_ptr provider; const size_t verifier_leeway; @@ -186,6 +192,7 @@ class OpenIdTokenProcessor : public ITokenProcessor const String & groups_claim_, const String & expected_issuer_, const String & expected_audience_, + bool allow_no_expiration_, const String & userinfo_endpoint_, const String & token_introspection_endpoint_, UInt64 verifier_leeway_, @@ -199,6 +206,7 @@ class OpenIdTokenProcessor : public ITokenProcessor const String & groups_claim_, const String & expected_issuer_, const String & expected_audience_, + bool allow_no_expiration_, const String & openid_config_endpoint_, UInt64 verifier_leeway_, UInt64 jwks_cache_lifetime_); @@ -207,6 +215,7 @@ class OpenIdTokenProcessor : public ITokenProcessor private: const String expected_issuer; const String expected_audience; + const bool allow_no_expiration; Poco::URI userinfo_endpoint; Poco::URI token_introspection_endpoint; diff --git a/src/Access/TokenProcessorsJWT.cpp b/src/Access/TokenProcessorsJWT.cpp index 36f6e1f3d707..e041e4329b24 100644 --- a/src/Access/TokenProcessorsJWT.cpp +++ b/src/Access/TokenProcessorsJWT.cpp @@ -180,9 +180,11 @@ StaticKeyJwtProcessor::StaticKeyJwtProcessor(const String & processor_name_, const String & groups_claim_, const String & expected_issuer_, const String & expected_audience_, + bool allow_no_expiration_, const StaticKeyJwtParams & params) : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_), - claims(params.claims), expected_issuer(expected_issuer_), expected_audience(expected_audience_) + claims(params.claims), expected_issuer(expected_issuer_), expected_audience(expected_audience_), + allow_no_expiration(allow_no_expiration_) { const String & algo = params.algo; const String & static_key = params.static_key; @@ -300,6 +302,12 @@ bool StaticKeyJwtProcessor::resolveAndValidate(TokenCredentials & credentials) c auto decoded_jwt = jwt::decode(credentials.getToken()); verifier.verify(decoded_jwt); + if (!allow_no_expiration && !decoded_jwt.has_expires_at()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "{}: Token missing 'exp' claim, rejecting", processor_name); + return false; + } + if (!check_claims(claims, decoded_jwt.get_payload_json())) return false; @@ -329,6 +337,12 @@ bool JwksJwtProcessor::resolveAndValidate(TokenCredentials & credentials) const { auto decoded_jwt = jwt::decode(credentials.getToken()); + if (!allow_no_expiration && !decoded_jwt.has_expires_at()) + { + LOG_TRACE(getLogger("TokenAuthentication"), "{}: Token missing 'exp' claim, rejecting", processor_name); + return false; + } + if (!decoded_jwt.has_payload_claim(username_claim)) { LOG_ERROR(getLogger("TokenAuthentication"), "{}: Specified username_claim not found in token", processor_name); diff --git a/src/Access/TokenProcessorsOpaque.cpp b/src/Access/TokenProcessorsOpaque.cpp index 1344b87f52d4..6a8ced064c96 100644 --- a/src/Access/TokenProcessorsOpaque.cpp +++ b/src/Access/TokenProcessorsOpaque.cpp @@ -258,6 +258,7 @@ OpenIdTokenProcessor::OpenIdTokenProcessor(const String & processor_name_, const String & groups_claim_, const String & expected_issuer_, const String & expected_audience_, + bool allow_no_expiration_, const String & userinfo_endpoint_, const String & token_introspection_endpoint_, UInt64 verifier_leeway_, @@ -265,6 +266,7 @@ OpenIdTokenProcessor::OpenIdTokenProcessor(const String & processor_name_, UInt64 jwks_cache_lifetime_) : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_), expected_issuer(expected_issuer_), expected_audience(expected_audience_), + allow_no_expiration(allow_no_expiration_), userinfo_endpoint(userinfo_endpoint_), token_introspection_endpoint(token_introspection_endpoint_) { if (!jwks_uri_.empty()) @@ -276,6 +278,7 @@ OpenIdTokenProcessor::OpenIdTokenProcessor(const String & processor_name_, groups_claim_, expected_issuer_, expected_audience_, + allow_no_expiration_, "", verifier_leeway_, jwks_uri_, @@ -289,11 +292,13 @@ OpenIdTokenProcessor::OpenIdTokenProcessor(const String & processor_name_, const String & groups_claim_, const String & expected_issuer_, const String & expected_audience_, + bool allow_no_expiration_, const String & openid_config_endpoint_, UInt64 verifier_leeway_, UInt64 jwks_cache_lifetime_) : ITokenProcessor(processor_name_, token_cache_lifetime_, username_claim_, groups_claim_), - expected_issuer(expected_issuer_), expected_audience(expected_audience_) + expected_issuer(expected_issuer_), expected_audience(expected_audience_), + allow_no_expiration(allow_no_expiration_) { const picojson::object openid_config = getObjectFromURI(Poco::URI(openid_config_endpoint_)); @@ -309,6 +314,7 @@ OpenIdTokenProcessor::OpenIdTokenProcessor(const String & processor_name_, groups_claim_, expected_issuer_, expected_audience_, + allow_no_expiration_, "", verifier_leeway_, getValueByKey(openid_config, "jwks_uri").value(), diff --git a/src/Access/TokenProcessorsParse.cpp b/src/Access/TokenProcessorsParse.cpp index 24eef7ea699a..fa83c5fa6a34 100644 --- a/src/Access/TokenProcessorsParse.cpp +++ b/src/Access/TokenProcessorsParse.cpp @@ -27,6 +27,7 @@ std::unique_ptr ITokenProcessor::parseTokenProcessor( auto groups_claim = config.getString(prefix + ".groups_claim", "groups"); auto expected_issuer = config.getString(prefix + ".expected_issuer", ""); auto expected_audience = config.getString(prefix + ".expected_audience", ""); + auto allow_no_expiration = config.getBool(prefix + ".allow_no_expiration", false); if (provider_type == "google") { @@ -47,7 +48,7 @@ std::unique_ptr ITokenProcessor::parseTokenProcessor( if (externally_configured && ! locally_configured) { return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, - expected_issuer, expected_audience, + expected_issuer, expected_audience, allow_no_expiration, config.getString(prefix + ".configuration_endpoint"), verifier_leeway, jwks_cache_lifetime); @@ -55,7 +56,7 @@ std::unique_ptr ITokenProcessor::parseTokenProcessor( else if (locally_configured && !externally_configured) { return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, - expected_issuer, expected_audience, + expected_issuer, expected_audience, allow_no_expiration, config.getString(prefix + ".userinfo_endpoint"), config.getString(prefix + ".token_introspection_endpoint"), verifier_leeway, @@ -81,7 +82,7 @@ std::unique_ptr ITokenProcessor::parseTokenProcessor( config.getString(prefix + ".public_key_password", ""), config.getString(prefix + ".private_key_password", ""), config.getString(prefix + ".claims", "")}; - return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, expected_issuer, expected_audience, params); + return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, expected_issuer, expected_audience, allow_no_expiration, params); } else if (provider_type == "jwt_static_jwks") { @@ -100,7 +101,7 @@ std::unique_ptr ITokenProcessor::parseTokenProcessor( config.getString(prefix + ".static_jwks_file", "") }; return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, - expected_issuer, expected_audience, + expected_issuer, expected_audience, allow_no_expiration, config.getString(prefix + ".claims", ""), config.getUInt64(prefix + ".verifier_leeway", 0), std::make_shared(params)); @@ -113,7 +114,7 @@ std::unique_ptr ITokenProcessor::parseTokenProcessor( throw DB::Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "'jwks_uri' must be specified for 'jwt_dynamic_jwks' processor"); return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, - expected_issuer, expected_audience, + expected_issuer, expected_audience, allow_no_expiration, config.getString(prefix + ".claims", ""), config.getUInt64(prefix + ".verifier_leeway", 0), config.getString(prefix + ".jwks_uri"), From f909c330b0a78804d75a7c3927b150ce47d6072a Mon Sep 17 00:00:00 2001 From: Andrey Zvonov Date: Fri, 19 Dec 2025 12:34:17 +0100 Subject: [PATCH 9/9] fix test --- tests/integration/test_jwt_auth/configs/validators.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/test_jwt_auth/configs/validators.xml b/tests/integration/test_jwt_auth/configs/validators.xml index d707ecc07827..e6bb8a1d265e 100644 --- a/tests/integration/test_jwt_auth/configs/validators.xml +++ b/tests/integration/test_jwt_auth/configs/validators.xml @@ -6,6 +6,7 @@ HS256 my_secret false + true @@ -13,11 +14,13 @@ hs256 other_secret false + true jwt_dynamic_jwks http://resolver:8080/.well-known/jwks.json + true