From 84a6f61913c11b2c9022b8927bf965f0b7590fbc Mon Sep 17 00:00:00 2001 From: Andrey Zvonov Date: Tue, 29 Oct 2024 15:48:51 +0000 Subject: [PATCH] Squashed all JWT auth Add aspell Enable jwt-cpp in fasttest Add test + some minor improvements reduce unneeded possible clash points fix build fix typo in docs --- .gitmodules | 3 + contrib/CMakeLists.txt | 2 +- contrib/jwt-cpp | 1 + contrib/jwt-cpp-cmake/CMakeLists.txt | 3 + docker/test/fasttest/run.sh | 1 + .../external-authenticators/index.md | 3 +- .../operations/external-authenticators/jwt.md | 204 +++++++ .../operations/external-authenticators/jwt.md | 206 ++++++++ programs/client/Client.cpp | 6 - src/Access/AccessControl.cpp | 5 + src/Access/AccessControl.h | 2 + src/Access/Authentication.cpp | 43 +- src/Access/AuthenticationData.cpp | 21 +- src/Access/AuthenticationData.h | 4 + src/Access/Credentials.cpp | 25 + src/Access/Credentials.h | 16 + src/Access/ExternalAuthenticators.cpp | 130 ++++- src/Access/ExternalAuthenticators.h | 7 + src/Access/IAccessStorage.cpp | 1 + src/Access/JWTValidator.cpp | 498 ++++++++++++++++++ src/Access/JWTValidator.h | 137 +++++ src/Access/UsersConfigAccessStorage.cpp | 12 +- src/CMakeLists.txt | 1 + src/Client/ClientBase.cpp | 6 +- src/Common/ErrorCodes.cpp | 2 + src/Parsers/Access/ASTAuthenticationData.cpp | 7 +- src/Parsers/Access/ASTCreateUserQuery.h | 4 +- src/Parsers/Access/ParserCreateUserQuery.cpp | 15 + src/Parsers/Access/ParserCreateUserQuery.h | 4 +- src/Parsers/CommonParsers.h | 1 + src/Server/HTTPHandler.cpp | 10 +- src/Server/TCPHandler.cpp | 11 + src/Server/TCPHandler.h | 1 + tests/integration/test_jwt_auth/__init__.py | 0 .../test_jwt_auth/configs/users.xml | 15 + .../configs/verify_static_key.xml | 24 + .../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 | 101 ++++ .../aspell-ignore/en/aspell-dict.txt | 8 + 44 files changed, 1774 insertions(+), 30 deletions(-) create mode 160000 contrib/jwt-cpp create mode 100644 contrib/jwt-cpp-cmake/CMakeLists.txt create mode 100644 docs/en/operations/external-authenticators/jwt.md create mode 100644 docs/ru/operations/external-authenticators/jwt.md create mode 100644 src/Access/JWTValidator.cpp create mode 100644 src/Access/JWTValidator.h 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/verify_static_key.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/.gitmodules b/.gitmodules index a618104f3642..d3270d85155c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -369,3 +369,6 @@ [submodule "contrib/idna"] path = contrib/idna url = https://github.com/ada-url/idna.git +[submodule "contrib/jwt-cpp"] + path = contrib/jwt-cpp + url = https://github.com/Thalhammer/jwt-cpp.git diff --git a/contrib/CMakeLists.txt b/contrib/CMakeLists.txt index c6d1dcb41e61..bc3cdc1a87f3 100644 --- a/contrib/CMakeLists.txt +++ b/contrib/CMakeLists.txt @@ -85,7 +85,7 @@ add_contrib (openldap-cmake openldap) add_contrib (grpc-cmake grpc) add_contrib (msgpack-c-cmake msgpack-c) add_contrib (libarchive-cmake libarchive) - +add_contrib (jwt-cpp-cmake jwt-cpp) add_contrib (corrosion-cmake corrosion) if (ENABLE_FUZZING) diff --git a/contrib/jwt-cpp b/contrib/jwt-cpp new file mode 160000 index 000000000000..a6927cb81408 --- /dev/null +++ b/contrib/jwt-cpp @@ -0,0 +1 @@ +Subproject commit a6927cb8140858c34e05d1a954626b9849fbcdfc diff --git a/contrib/jwt-cpp-cmake/CMakeLists.txt b/contrib/jwt-cpp-cmake/CMakeLists.txt new file mode 100644 index 000000000000..2ee0281348f2 --- /dev/null +++ b/contrib/jwt-cpp-cmake/CMakeLists.txt @@ -0,0 +1,3 @@ +add_library(_jwt-cpp INTERFACE) +target_include_directories(_jwt-cpp SYSTEM BEFORE INTERFACE "${ClickHouse_SOURCE_DIR}/contrib/jwt-cpp/include/") +add_library(ch_contrib::jwt-cpp ALIAS _jwt-cpp) diff --git a/docker/test/fasttest/run.sh b/docker/test/fasttest/run.sh index b7c98730253c..bf52b2922eb1 100755 --- a/docker/test/fasttest/run.sh +++ b/docker/test/fasttest/run.sh @@ -155,6 +155,7 @@ function clone_submodules contrib/libfiu contrib/incbin contrib/yaml-cpp + contrib/jwt-cpp ) git submodule sync diff --git a/docs/en/operations/external-authenticators/index.md b/docs/en/operations/external-authenticators/index.md index f644613641cc..2730389e1177 100644 --- a/docs/en/operations/external-authenticators/index.md +++ b/docs/en/operations/external-authenticators/index.md @@ -16,4 +16,5 @@ The following external authenticators and directories are supported: - [LDAP](./ldap.md#external-authenticators-ldap) [Authenticator](./ldap.md#ldap-external-authenticator) and [Directory](./ldap.md#ldap-external-user-directory) - Kerberos [Authenticator](./kerberos.md#external-authenticators-kerberos) - [SSL X.509 authentication](./ssl-x509.md#ssl-external-authentication) -- HTTP [Authenticator](./http.md) \ No newline at end of file +- HTTP [Authenticator](./http.md) +- JWT [Authenticator](./jwt.md) diff --git a/docs/en/operations/external-authenticators/jwt.md b/docs/en/operations/external-authenticators/jwt.md new file mode 100644 index 000000000000..bbb523566d0f --- /dev/null +++ b/docs/en/operations/external-authenticators/jwt.md @@ -0,0 +1,204 @@ +--- +slug: /en/operations/external-authenticators/jwt +--- +# JWT +import SelfManaged from '@site/docs/en/_snippets/_self_managed_only_no_roadmap.md'; + + + +Existing and properly configured ClickHouse users can be authenticated via JWT. + +Currently, JWT can only be used as an external authenticator for existing users, which are defined in `users.xml` or in local access control paths. +The username will be extracted from the JWT after validating the token expiration and against the signature. Signature can be validated by: +- static public key +- static JWKS +- received from the JWKS servers + +It is mandatory for a JWT to indicate the name of the ClickHouse user under `"sub"` claim, otherwise it will not be accepted. + +A JWT may additionally be verified by checking the JWT payload. +In this case, the occurrence of specified claims from the user settings in the JWT payload is checked. +See [Enabling JWT authentication in `users.xml`](#enabling-jwt-auth-in-users-xml) + +To use JWT authentication, JWT validators must be configured in ClickHouse config. + + +## Enabling JWT validators in ClickHouse {#enabling-jwt-validators-in-clickhouse} + +To enable JWT validators, add `jwt_validators` section in `config.xml`. This section may contain several JWT verifiers, minimum is 1. + +### Verifying JWT signature using static key {$verifying-jwt-signature-using-static-key} + +**Example** +```xml + + + + + HS256 + my_static_secret + + + +``` + +#### Parameters: + +- `algo` - Algorithm for validate signature. Supported: + + | HMSC | RSA | ECDSA | PSS | EdDSA | + | ----- | ----- | ------ | ----- | ------- | + | HS256 | RS256 | ES256 | PS256 | Ed25519 | + | HS384 | RS384 | ES384 | PS384 | Ed448 | + | HS512 | RS512 | ES512 | PS512 | | + | | | ES256K | | | + Also support None. +- `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. + +### Verifying JWT signature using static JWKS {$verifying-jwt-signature-using-static-jwks} + +:::note +Only RS* family algorithms are supported! +::: + +**Example** +```xml + + + + + {"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 file with JWKS + +:::note +Only one of `static_jwks` or `static_jwks_file` keys must be present in one verifier +::: + +### Verifying JWT signature using JWKS servers {$verifying-jwt-signature-using-static-jwks} + +**Example** +```xml + + + + + http://localhost:8000/.well-known/jwks.json + 1000 + 1000 + 1000 + 3 + 50 + 1000 + 300000 + + + +``` + +#### Parameters: + +- `uri` - JWKS endpoint. Mandatory. +- `refresh_ms` - Period for resend request for refreshing JWKS. Optional, default: 300000. + +Timeouts in milliseconds on the socket used for communicating with the server (optional): +- `connection_timeout_ms` - Default: 1000. +- `receive_timeout_ms` - Default: 1000. +- `send_timeout_ms` - Default: 1000. + +Retry parameters (optional): +- `max_tries` - The maximum number of attempts to make an authentication request. Default: 3. +- `retry_initial_backoff_ms` - The backoff initial interval on retry. Default: 50. +- `retry_max_backoff_ms` - The maximum backoff interval. Default: 1000. + +### Enabling JWT authentication in `users.xml` {#enabling-jwt-auth-in-users-xml} + +In order to enable JWT 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. + +``` +{ +... + "resource_access": { + "account": { + "roles": ["view-profile"] + } + }, +... +} +``` + +:::note +JWT authentication cannot be used together with any other authentication method. The presence of any other sections like `password` alongside `jwt` will force ClickHouse to shut down. +::: + +### Enabling JWT authentication using SQL {#enabling-jwt-auth-using-sql} + +When [SQL-driven Access Control and Account Management](/docs/en/guides/sre/user-management/index.md#access-control) is enabled in ClickHouse, users identified by JWT authentication can also be created using SQL statements. + +```sql +CREATE USER my_user IDENTIFIED WITH jwt CLAIMS '{"resource_access":{"account": {"roles": ["view-profile"]}}}' +``` + +Or without additional JWT payload checks: + +```sql +CREATE USER my_user IDENTIFIED WITH jwt +``` + +## JWT authentication examples {#jwt-authentication-examples} + +#### Console client + +``` +clickhouse-client -jwt +``` + +#### HTTP requests + +``` +curl 'http://localhost:8080/?' \ + -H 'Authorization: Bearer ' \ + -H 'Content type: text/plain;charset=UTF-8' \ + --data-raw 'SELECT current_user()' +``` +:::note +ClickHouse will look for a JWT token in (by priority): +1. `X-ClickHouse-JWT-Token` header. +2. `Authorization` header. +3. `token` request parameter. In this case, the "Bearer" prefix should not exist. +::: + +### Passing session settings {#passing-session-settings} + +If `settings_key` exists in the `jwt_validators` section or exists in the verifier section and the payload contains a sub-object of that `settings_key`, ClickHouse will attempt to parse its key:value pairs as string values ​​and set them as session settings for the currently authenticated user. If parsing fails, the JWT payload will be ignored. + +The `settings_key` in the verifier section takes precedence over the `settings_key` from the `jwt_validators` section. If `settings_key` in the verifier section does not exist, the `settings_key` from the `jwt_validators` section will be used. diff --git a/docs/ru/operations/external-authenticators/jwt.md b/docs/ru/operations/external-authenticators/jwt.md new file mode 100644 index 000000000000..55bd7f6d19df --- /dev/null +++ b/docs/ru/operations/external-authenticators/jwt.md @@ -0,0 +1,206 @@ +--- +slug: /ru/operations/external-authenticators/jwt +--- +# JWT +import SelfManaged from '@site/docs/en/_snippets/_self_managed_only_no_roadmap.md'; + + + +Существующие и корректно настроенные пользователи ClickHouse могут быть аутентифицированы с помощью JWT. + +Сейчас JWT работает только как внешний аутентификатор для уже существующих пользователей. +Имя пользователя будет извлечено из JWT после проверки срока действия токена и подписи. +Подпись может быть проверена с помощью: +- статического (указанного в конфигурации) открытого ключа, +- статического (указанного в конфигурации) JWKS или файла, содержащего JWKS, +- полученного от JWKS-сервера. + +Имя пользователя ClickHouse должно быть обязательно указано в поле (claim) `"sub"`, в противном случае токен не будет принят. + +Можно также дополнительно проверять JWT на наличие определённого содержимого (payload). +В этом случае проверяется наличие указанных полей (claims) из настроек пользователя в содержимом JWT. +Смотри [Настройка JWT аутентификации пользователя через `users.xml`](#enabling-jwt-auth-in-users-xml) и [Настройка JWT аутентификации пользователя через SQL](#enabling-jwt-auth-using-sql) + + +## Настройка JWT валидаторов {#enabling-jwt-validators} + +Для аутентификации с помощью JWT сконфигурировать как минимум один валидатор. +Это делается в секции `jwt_validators` в `config.xml`. Эта секция может содержать несколько JWT-верификаторов. + +### Проверка JWT с помощью статического ключа {$verifying-jwt-signature-using-static-key} + +**Пример** +```xml + + + + + HS256 + my_static_secret + + + +``` + +#### Параметры: + +- `algo` - Алгоритм для проверки подписи. Поддерживаемые алгоритмы: + + | HMSC | RSA | ECDSA | PSS | EdDSA | + | ----- | ----- | ------ | ----- | ------- | + | HS256 | RS256 | ES256 | PS256 | Ed25519 | + | HS384 | RS384 | ES384 | PS384 | Ed448 | + | HS512 | RS512 | ES512 | PS512 | | + | | | ES256K | | | + Можно не проверять подпись, указав `None` для этого параметра. +- `static_key` - ключ симметричного алгоритма. Обязателен для алгоритмов семейства `HS*`. +- `static_key_in_base64` - указывает, закодирован ли `static_key` в формате base64. Необязательный параметр, по умолчанию: `False`. +- `public_key` - открытый ключ для асимметричных алгоритмов. Обязателен для всех алгоритмов, кроме семейства `HS*` и `None`. +- `private_key` - закрытый ключ для асимметричных алгоритмов. Необязательный параметр. +- `public_key_password` - пароль открытого ключа, необязательный параметр. +- `private_key_password` - пароль закрытого ключа, необязательный параметр. + +### Проверка JWT с помощью статического JWKS {$verifying-jwt-signature-using-static-jwks} + +:::note +Проверка с помощью JWKS невозможна для алгоритмов семейства `HS*`. +::: + +**Пример** +```xml + + + + + {"keys": [{"kty": "RSA", "alg": "RS256", "kid": "mykid", "n": "_public_key_mod_", "e": "AQAB"}]} + + + +``` + +#### Параметры: +- `static_jwks` - содержимое JWKS в виде JSON. +- `static_jwks_file` - путь к файлу, содержащему JWKS. + +:::note +Должен быть указан один и только один из этих двух параметров. +::: + +### Проверка JWT с помощью JWKS сервера {$verifying-jwt-signature-using-static-jwks} + +:::note +Проверка с помощью JWKS невозможна для алгоритмов семейства `HS*`. +::: + +**Пример** +```xml + + + + + http://localhost:8000/.well-known/jwks.json + 1000 + 1000 + 1000 + 3 + 50 + 1000 + 300000 + + + +``` + +#### Параметры: + +- `uri` - адрес, по которому доступен JWKS. Обязательный параметр. +- `refresh_ms` - Период обновления JWKS. Необязательный параметр, по умолчанию: 300000. + +Таймауты в миллисекундах для сокета, используемого для связи с сервером (необязательные параметры): +- `connection_timeout_ms` - По умолчанию: 1000. +- `receive_timeout_ms` - По умолчанию: 1000. +- `send_timeout_ms` - По умолчанию: 1000. + +Настройка повторных попыток (необязательные параметры): +- `max_tries` - Максимальное количество попыток аутентификации. По умолчанию: 3. +- `retry_initial_backoff_ms` - Стартовый интервал между повторными попытками (backoff). По умолчанию: 50. +- `retry_max_backoff_ms` - Максимальный интервал между повторными попытками (backoff). По умолчанию: 1000. + +### Настройка JWT аутентификации пользователя в `users.xml` {#enabling-jwt-auth-in-users-xml} + +Чтобы включить аутентификацию с помощью JWT для пользователя, укажите секцию `jwt` вместо секции `password` и аналогичных секций. + +**Пример (`users.xml`)** +```xml + + + + + + {"resource_access":{"account": {"roles": ["view-profile"]}}} + + + +``` + +#### Параметры +- `claims` - строка, содержащая JSON, который должен присутствовать в содержимом токена. + +В данном случае содержимое JWT должно содержать значение ["view-profile"] по пути `resource_access.account.roles`, +в противном случае аутентификация не будет успешной, даже если в остальном JWT верный. + +**Пример payload** +```json +{ + "sub": "my_user", + "resource_access": { + "account": { + "roles": ["view-profile"] + } + }, +} +``` + +:::note +Аутентификация JWT не может использоваться вместе с другими методами аутентификации. Наличие любых других секций, таких как `password`, наряду с секцией `jwt` приведет к аварийному завершению работы. +::: + +### Настройка JWT аутентификации пользователя через SQL {#enabling-jwt-auth-using-sql} + +В случае если в ClickHouse включено управление доступом через SQL ([SQL-driven Access Control and Account Management](/docs/en/guides/sre/user-management/index.md#access-control)), +можно создать пользователя с аутентификацией через JWT с помощью SQL-запросов. + +**Без проверки содержимого JWT** +```sql +CREATE USER my_user IDENTIFIED WITH jwt +``` + +**С проверкой содержимого JWT** +```sql +CREATE USER my_user IDENTIFIED WITH jwt CLAIMS '{"resource_access":{"account": {"roles": ["view-profile"]}}}' +``` + +## Примеры аутентификации {#jwt-authentication-examples} + +#### `clickhouse-client` + +``` +clickhouse-client -jwt +``` + +#### HTTP + +``` +curl 'http://localhost:8080/?' \ + -H 'Authorization: Bearer ' \ + -H 'Content type: text/plain;charset=UTF-8' \ + --data-raw 'SELECT current_user()' +``` +:::note +ClickHouse ищет токен в следующих местах (по порядку): +1. Заголовок `X-ClickHouse-JWT-Token`. +2. Стандартный заголовок `Authorization`. +3. Параметр `token`. В этом случае параметр не должен содержать префикс `Bearer`. +::: + +### Передача параметров сессии {#passing-session-settings} diff --git a/programs/client/Client.cpp b/programs/client/Client.cpp index e795891af770..7537ade653e0 100644 --- a/programs/client/Client.cpp +++ b/programs/client/Client.cpp @@ -63,7 +63,6 @@ namespace ErrorCodes extern const int NETWORK_ERROR; extern const int AUTHENTICATION_FAILED; extern const int NO_ELEMENTS_IN_CONFIG; - extern const int USER_EXPIRED; } @@ -75,11 +74,6 @@ void Client::processError(const String & query) const server_version, getExceptionMessage(*server_exception, print_stack_trace, true)); - if (server_exception->code() == ErrorCodes::USER_EXPIRED) - { - server_exception->rethrow(); - } - if (is_interactive) { fmt::print(stderr, "\n"); diff --git a/src/Access/AccessControl.cpp b/src/Access/AccessControl.cpp index 368d8b881fef..53e89d7d10cb 100644 --- a/src/Access/AccessControl.cpp +++ b/src/Access/AccessControl.cpp @@ -688,6 +688,11 @@ bool AccessControl::isNoPasswordAllowed() const return allow_no_password; } +bool AccessControl::isJWTEnabled() const +{ + return external_authenticators->isJWTAllowed(); +} + void AccessControl::setPlaintextPasswordAllowed(bool allow_plaintext_password_) { allow_plaintext_password = allow_plaintext_password_; diff --git a/src/Access/AccessControl.h b/src/Access/AccessControl.h index fae60efe9836..a49252ffa332 100644 --- a/src/Access/AccessControl.h +++ b/src/Access/AccessControl.h @@ -148,6 +148,8 @@ class AccessControl : public MultipleAccessStorage void setNoPasswordAllowed(bool allow_no_password_); bool isNoPasswordAllowed() const; + bool isJWTEnabled() const; + /// Allows users with plaintext password (by default it's allowed). void setPlaintextPasswordAllowed(bool allow_plaintext_password_); bool isPlaintextPasswordAllowed() const; diff --git a/src/Access/Authentication.cpp b/src/Access/Authentication.cpp index 78ebc6937195..701d0e5cdf6c 100644 --- a/src/Access/Authentication.cpp +++ b/src/Access/Authentication.cpp @@ -108,7 +108,7 @@ bool Authentication::areCredentialsValid( throw Authentication::Require("ClickHouse Basic Authentication"); case AuthenticationType::JWT: - throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "JWT is available only in ClickHouse Cloud"); + throw Authentication::Require("ClickHouse JWT Authentication"); case AuthenticationType::KERBEROS: return external_authenticators.checkKerberosCredentials(auth_data.getKerberosRealm(), *gss_acceptor_context); @@ -148,7 +148,7 @@ bool Authentication::areCredentialsValid( throw Authentication::Require("ClickHouse X.509 Authentication"); case AuthenticationType::JWT: - throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "JWT is available only in ClickHouse Cloud"); + throw Authentication::Require("ClickHouse JWT Authentication"); case AuthenticationType::SSH_KEY: throw Authentication::Require("Ssh Keys Authentication"); @@ -187,7 +187,7 @@ bool Authentication::areCredentialsValid( throw Authentication::Require("Ssh Keys Authentication"); case AuthenticationType::JWT: - throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "JWT is available only in ClickHouse Cloud"); + throw Authentication::Require("ClickHouse JWT Authentication"); case AuthenticationType::BCRYPT_PASSWORD: return checkPasswordBcrypt(basic_credentials->getPassword(), auth_data.getPasswordHashBinary()); @@ -219,7 +219,7 @@ bool Authentication::areCredentialsValid( throw Authentication::Require("ClickHouse Basic Authentication"); case AuthenticationType::JWT: - throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "JWT is available only in ClickHouse Cloud"); + throw Authentication::Require("ClickHouse JWT Authentication"); case AuthenticationType::KERBEROS: throw Authentication::Require(auth_data.getKerberosRealm()); @@ -249,7 +249,7 @@ bool Authentication::areCredentialsValid( throw Authentication::Require("ClickHouse Basic Authentication"); case AuthenticationType::JWT: - throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "JWT is available only in ClickHouse Cloud"); + throw Authentication::Require("ClickHouse JWT Authentication"); case AuthenticationType::KERBEROS: throw Authentication::Require(auth_data.getKerberosRealm()); @@ -268,6 +268,39 @@ bool Authentication::areCredentialsValid( } } + + if (const auto * jwt_credentials = typeid_cast(&credentials)) + { + switch (auth_data.getType()) + { + case AuthenticationType::NO_PASSWORD: + case AuthenticationType::PLAINTEXT_PASSWORD: + case AuthenticationType::SHA256_PASSWORD: + case AuthenticationType::DOUBLE_SHA1_PASSWORD: + case AuthenticationType::BCRYPT_PASSWORD: + case AuthenticationType::LDAP: + case AuthenticationType::HTTP: + case AuthenticationType::KERBEROS: + throw Authentication::Require("ClickHouse Basic Authentication"); + + case AuthenticationType::JWT: + return external_authenticators.checkJWTCredentials(auth_data.getJWTClaims(), *jwt_credentials, settings); + + case AuthenticationType::SSL_CERTIFICATE: + throw Authentication::Require("ClickHouse X.509 Authentication"); + + case AuthenticationType::SSH_KEY: +#if USE_SSH + throw Authentication::Require("SSH Keys Authentication"); +#else + throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "SSH is disabled, because ClickHouse is built without libssh"); +#endif + + case AuthenticationType::MAX: + break; + } + } + 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 5dd8bbf9e00f..9d014d840a87 100644 --- a/src/Access/AuthenticationData.cpp +++ b/src/Access/AuthenticationData.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -14,6 +15,7 @@ #include #include #include +#include #include "config.h" @@ -306,7 +308,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: { @@ -510,6 +515,20 @@ AuthenticationData AuthenticationData::fromAST(const ASTAuthenticationData & que auth_data.setHTTPAuthenticationServerName(server); auth_data.setHTTPAuthenticationScheme(scheme); } + 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); + } + } else { throw Exception(ErrorCodes::LOGICAL_ERROR, "Unexpected ASTAuthenticationData structure"); diff --git a/src/Access/AuthenticationData.h b/src/Access/AuthenticationData.h index feef4d71d668..e52853a8c99d 100644 --- a/src/Access/AuthenticationData.h +++ b/src/Access/AuthenticationData.h @@ -68,6 +68,9 @@ class AuthenticationData const String & getHTTPAuthenticationServerName() const { return http_auth_server_name; } void setHTTPAuthenticationServerName(const String & name) { http_auth_server_name = name; } + const String & getJWTClaims() const { return jwt_claims; } + void setJWTClaims(const String & jwt_claims_) { jwt_claims = jwt_claims_; } + friend bool operator ==(const AuthenticationData & lhs, const AuthenticationData & rhs); friend bool operator !=(const AuthenticationData & lhs, const AuthenticationData & rhs) { return !(lhs == rhs); } @@ -98,6 +101,7 @@ class AuthenticationData /// HTTP authentication properties String http_auth_server_name; HTTPAuthenticationScheme http_auth_scheme = HTTPAuthenticationScheme::BASIC; + String jwt_claims; }; } diff --git a/src/Access/Credentials.cpp b/src/Access/Credentials.cpp index f9886c0182be..e423e4822756 100644 --- a/src/Access/Credentials.cpp +++ b/src/Access/Credentials.cpp @@ -1,5 +1,8 @@ #include #include +#include + +#include namespace DB @@ -97,4 +100,26 @@ const String & BasicCredentials::getPassword() const return password; } +namespace +{ +String extractSubjectFromToken(const String & token) +{ + try + { + auto decoded_jwt = jwt::decode(token); + return decoded_jwt.get_subject(); + } + catch (...) + { + throw Exception(ErrorCodes::LOGICAL_ERROR, "Failed to validate jwt"); + } +} +} + +JWTCredentials::JWTCredentials(const String & token_) + : Credentials(extractSubjectFromToken(token_)) + , token(token_) + { + is_ready = !user_name.empty(); + } } diff --git a/src/Access/Credentials.h b/src/Access/Credentials.h index 77b90eaaebce..696ee817f0cd 100644 --- a/src/Access/Credentials.h +++ b/src/Access/Credentials.h @@ -118,4 +118,20 @@ class SshCredentials : public Credentials String original; }; +class JWTCredentials: public Credentials +{ +public: + explicit JWTCredentials(const String & token_); + const String & getToken() const + { + if (!isReady()) + { + throwNotReady(); + } + return token; + } +private: + String token; +}; + } diff --git a/src/Access/ExternalAuthenticators.cpp b/src/Access/ExternalAuthenticators.cpp index 77812ac5eb5d..d4c0d0a1bbaf 100644 --- a/src/Access/ExternalAuthenticators.cpp +++ b/src/Access/ExternalAuthenticators.cpp @@ -2,14 +2,21 @@ #include #include #include +#include "Common/Logger.h" +#include "Common/logger_useful.h" #include #include #include #include +#include "Access/AccessControl.h" +#include "Access/Credentials.h" +#include "Access/JWTValidator.h" #include #include +#include +#include #include #include @@ -254,6 +261,72 @@ HTTPAuthClientParams parseHTTPAuthParams(const Poco::Util::AbstractConfiguration return http_auth_params; } +std::unique_ptr makeJWTValidator( + const Poco::Util::AbstractConfiguration & config, + const String & prefix, + const String &name, + const String &global_settings_key) +{ + auto settings_key = String(global_settings_key); + if (config.hasProperty(prefix + ".settings_key")) + settings_key = config.getString(prefix + ".settings_key"); + + if (config.hasProperty(prefix + ".algo")) + { + SimpleJWTValidatorParams params = {}; + params.settings_key = settings_key; + params.algo = config.getString(prefix + ".algo"); + params.static_key = config.getString(prefix + ".static_key", ""); + params.static_key_in_base64 = config.getBool(prefix + ".static_key_in_base64", false); + params.public_key = config.getString(prefix + ".public_key", ""); + params.private_key = config.getString(prefix + ".private_key", ""); + params.public_key_password = config.getString(prefix + ".public_key_password", ""); + params.private_key_password = config.getString(prefix + ".private_key_password", ""); + params.validate(); + auto result = std::make_unique(name); + result->init(params); + return result; + } + + std::shared_ptr provider; + if (config.hasProperty(prefix + ".uri")) + { + JWKSAuthClientParams params; + + params.uri = config.getString(prefix + ".uri"); + + size_t connection_timeout_ms = config.getInt(prefix + ".connection_timeout_ms", 1000); + size_t receive_timeout_ms = config.getInt(prefix + ".receive_timeout_ms", 1000); + size_t send_timeout_ms = config.getInt(prefix + ".send_timeout_ms", 1000); + params.timeouts = ConnectionTimeouts() + .withConnectionTimeout(Poco::Timespan(connection_timeout_ms * 1000)) + .withReceiveTimeout(Poco::Timespan(receive_timeout_ms * 1000)) + .withSendTimeout(Poco::Timespan(send_timeout_ms * 1000)); + + params.max_tries = config.getInt(prefix + ".max_tries", 3); + params.retry_initial_backoff_ms = config.getInt(prefix + ".retry_initial_backoff_ms", 50); + params.retry_max_backoff_ms = config.getInt(prefix + ".retry_max_backoff_ms", 1000); + params.refresh_ms = config.getInt(prefix + ".refrest_ms", 300000); + provider = std::make_shared(params); + } + else if (config.hasProperty(prefix + ".static_jwks") || config.hasProperty(prefix + ".static_jwks_file")) + { + StaticJWKSParams params; + params.static_jwks = config.getString(prefix + ".static_jwks", ""); + params.static_jwks_file = config.getString(prefix + ".static_jwks_file", ""); + params.validate(); + auto instance = std::make_shared(); + instance->init(params); + provider = instance; + } + else + throw DB::Exception(ErrorCodes::BAD_ARGUMENTS, "unsupported configuration"); + auto result = std::make_unique(name, provider); + JWTValidator params = {.settings_key = settings_key}; + result->init(params); + return result; +} + } void parseLDAPRoleSearchParams(LDAPClient::RoleSearchParams & params, const Poco::Util::AbstractConfiguration & config, const String & prefix) @@ -271,6 +344,13 @@ void ExternalAuthenticators::resetImpl() ldap_client_params_blueprint.clear(); ldap_caches.clear(); kerberos_params.reset(); + jwt_validators.clear(); +} + +bool ExternalAuthenticators::isJWTAllowed() const +{ + std::lock_guard lock(mutex); + return !jwt_validators.empty(); } void ExternalAuthenticators::reset() @@ -290,8 +370,10 @@ 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; const String http_auth_servers_config = "http_authentication_servers"; + const String jwt_validators_config = "jwt_validators"; for (auto key : all_keys) { @@ -304,6 +386,7 @@ 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); } if (ldap_servers_key_count > 1) @@ -315,6 +398,9 @@ 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 jwt_validators sections are not allowed"); + Poco::Util::AbstractConfiguration::Keys http_auth_server_names; config.keys(http_auth_servers_config, http_auth_server_names); http_auth_servers.clear(); @@ -369,6 +455,26 @@ void ExternalAuthenticators::setConfiguration(const Poco::Util::AbstractConfigur { tryLogCurrentException(log, "Could not parse Kerberos section"); } + + Poco::Util::AbstractConfiguration::Keys jwt_validators_keys; + config.keys(jwt_validators_config, jwt_validators_keys); + jwt_validators.clear(); + String jwt_validator_settings_key; + if (config.has(jwt_validators_config + ".settings_key")) + jwt_validator_settings_key = config.getString(jwt_validators_config + ".settings_key"); + for (const auto & jwt_validator : jwt_validators_keys) + { + if (jwt_validator == "settings_key") continue; + String prefix = fmt::format("{}.{}", jwt_validators_config, jwt_validator); + try + { + jwt_validators[jwt_validator] = makeJWTValidator(config, prefix, jwt_validator, jwt_validator_settings_key); + } + catch (...) + { + tryLogCurrentException(log, "Could not parse JWT validator" + backQuote(jwt_validator)); + } + } } UInt128 computeParamsHash(const LDAPClient::Params & params, const LDAPClient::RoleSearchParamsList * role_search_params) @@ -537,7 +643,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}; @@ -547,6 +653,28 @@ HTTPAuthClientParams ExternalAuthenticators::getHTTPAuthenticationParams(const S return it->second; } +bool ExternalAuthenticators::checkJWTCredentials(const String & claims, const JWTCredentials & credentials, SettingsChanges & settings) const +{ + std::lock_guard lock{mutex}; + + const auto token = String(credentials.getToken()); + const auto & user_name = credentials.getUserName(); + + if (jwt_validators.empty()) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "JWT authentication is not configured"); + + for (const auto & it : jwt_validators) + { + if (it.second->verify(claims, token, settings)) + { + LOG_DEBUG(getLogger("JWTAuth"), "Authenticated with JWT for {} by {}", user_name, it.first); + return true; + } + LOG_TRACE(getLogger("JWTAuth"), "Failed authentication with JWT for {} by {}", user_name, it.first); + } + return false; +} + bool ExternalAuthenticators::checkHTTPBasicCredentials( const String & server, const BasicCredentials & credentials, SettingsChanges & settings) const { diff --git a/src/Access/ExternalAuthenticators.h b/src/Access/ExternalAuthenticators.h index 3a710e6df26a..0055e490f267 100644 --- a/src/Access/ExternalAuthenticators.h +++ b/src/Access/ExternalAuthenticators.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -12,6 +13,7 @@ #include #include +#include #include #include #include @@ -31,6 +33,7 @@ namespace DB { class SettingsChanges; +class AccessControl; class ExternalAuthenticators { @@ -43,9 +46,12 @@ class ExternalAuthenticators const LDAPClient::RoleSearchParamsList * role_search_params = nullptr, LDAPClient::SearchResultsList * role_search_results = nullptr) const; bool checkKerberosCredentials(const String & realm, const GSSAcceptorContext & credentials) const; bool checkHTTPBasicCredentials(const String & server, const BasicCredentials & credentials, SettingsChanges & settings) const; + bool checkJWTCredentials(const String & claims, const JWTCredentials & credentials, SettingsChanges & settings) const; GSSAcceptorContext::Params getKerberosParams() const; + bool isJWTAllowed() const; + private: HTTPAuthClientParams getHTTPAuthenticationParams(const String& server) const; @@ -65,6 +71,7 @@ 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) ; + std::unordered_map> jwt_validators TSA_GUARDED_BY(mutex) ; void resetImpl() TSA_REQUIRES(mutex); }; diff --git a/src/Access/IAccessStorage.cpp b/src/Access/IAccessStorage.cpp index 1d6b8d99cd5f..27d66e38911d 100644 --- a/src/Access/IAccessStorage.cpp +++ b/src/Access/IAccessStorage.cpp @@ -7,6 +7,7 @@ #include #include #include +#include "Access/Common/AuthenticationType.h" #include #include #include diff --git a/src/Access/JWTValidator.cpp b/src/Access/JWTValidator.cpp new file mode 100644 index 000000000000..289e21c3cbc1 --- /dev/null +++ b/src/Access/JWTValidator.cpp @@ -0,0 +1,498 @@ +#include "JWTValidator.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include "Poco/StreamCopier.h" +#include + +#include "Common/Base64.h" +#include "Common/Exception.h" +#include "Common/logger_useful.h" +#include + +namespace DB +{ + +namespace ErrorCodes +{ + extern const int JWT_ERROR; +} + +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("JWTAuthentication"), "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("JWTAuthentication"), "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("JWTAuthentication"), "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("JWTAuthentication"), "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("JWTAuthentication"), "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("JWTAuthentication"), "JWT payload does not match key type 'bool' in claims '{}'", path); + return false; + } + if (claims.get() != payload.get()) + { + LOG_TRACE(getLogger("JWTAuthentication"), "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("JWTAuthentication"), "JWT payload does not match key type 'double' in claims '{}'", path); + return false; + } + if (claims.get() != payload.get()) + { + LOG_TRACE(getLogger("JWTAuthentication"), "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("JWTAuthentication"), "JWT payload does not match key type 'std::string' in claims '{}'", path); + return false; + } + if (claims.get() != payload.get()) + { + LOG_TRACE(getLogger("JWTAuthentication"), "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("JWTAuthentication"), "JWT payload does not match key type 'int64_t' in claims '{}'", path); + return false; + } + if (claims.get() != payload.get()) + { + LOG_TRACE(getLogger("JWTAuthentication"), "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("JWTAuthentication"), "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::JWT_ERROR, "Bad JWT claims: {}", errors); + if (!json.is()) + throw Exception(ErrorCodes::JWT_ERROR, "Bad JWT claims: is not an object"); + return check_claims(json.get(), payload, ""); +} + +std::map stringify_params(const picojson::value & params, const String & path); + +std::map stringify_params(const picojson::value::array & params, const String & path) +{ + std::map result; + for (size_t i = 0; i < params.size(); ++i) + { + const auto tmp_result = stringify_params(params.at(i), path + "[" + std::to_string(i) + "]"); + result.insert(tmp_result.begin(), tmp_result.end()); + } + return result; +} + +std::map stringify_params(const picojson::value::object & params, const String & path) +{ + auto add_path = String(path); + if (!add_path.empty()) + add_path = add_path + "."; + std::map result; + for (const auto & it : params) + { + const auto tmp_result = stringify_params(it.second, add_path + it.first); + result.insert(tmp_result.begin(), tmp_result.end()); + } + return result; +} + +std::map stringify_params(const picojson::value & params, const String & path) +{ + std::map result; + if (params.is()) + return stringify_params(params.get(), path); + if (params.is()) + return stringify_params(params.get(), path); + if (params.is()) + { + result[path] = Field(params.get()); + return result; + } + if (params.is()) + { + result[path] = Field(params.get()); + return result; + } + if (params.is()) + { + result[path] = Field(params.get()); + return result; + } + #ifdef PICOJSON_USE_INT64 + if (params.is()) + { + result[path] = Field(params.get()); + return result; + } + #endif + return result; +} +} + +void IJWTValidator::init(const JWTValidator & _params) +{ + params = _params; +} + +bool IJWTValidator::verify(const String & claims, const String & token, SettingsChanges & settings) const +{ + try + { + auto decoded_jwt = jwt::decode(token); + if (!verifyImpl(decoded_jwt)) + return false; + if (!check_claims(claims, decoded_jwt.get_payload_json())) + return false; + if (params.settings_key.empty()) + return true; + const auto & payload_obj = decoded_jwt.get_payload_json(); + const auto & payload_settings = payload_obj.at(params.settings_key); + const auto string_settings = stringify_params(payload_settings, ""); + for (const auto & it : string_settings) + settings.insertSetting(it.first, it.second); + return true; + } + catch (const std::exception & ex) + { + LOG_TRACE(getLogger("JWTAuthentication"), "{}: Failed to validate JWT: {}",name, ex.what()); + return false; + } +} + +void SimpleJWTValidatorParams::validate() const +{ + auto lower_algo = Poco::toLower(algo); + if (lower_algo == "none") + return; + + 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()) + return; + throw Exception(ErrorCodes::JWT_ERROR, "`public_key` parameter required for {}", algo); + } + + if (algo == "hs256" || + algo == "hs384" || + algo == "hs512" ) + { + if (!static_key.empty()) + return; + throw DB::Exception(ErrorCodes::JWT_ERROR, "`static_key` parameter required for {}", algo); + } + + throw DB::Exception(ErrorCodes::JWT_ERROR, "Unknown algorithm {}", algo); +} + +SimpleJWTValidator::SimpleJWTValidator(const String & _name) + : IJWTValidator(_name) + , verifier(jwt::verify()) +{} + +void SimpleJWTValidator::init(const SimpleJWTValidatorParams & _params) +{ + auto algo = Poco::toLower(_params.algo); + + IJWTValidator::init(_params); + verifier = jwt::verify(); + if (algo == "none") + verifier = verifier.allow_algorithm(jwt::algorithm::none()); + else if (algo == "ps256") + verifier = verifier.allow_algorithm(jwt::algorithm::ps256(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password)); + else if (algo == "ps384") + verifier = verifier.allow_algorithm(jwt::algorithm::ps384(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password)); + else if (algo == "ps512") + verifier = verifier.allow_algorithm(jwt::algorithm::ps512(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password)); + else if (algo == "ed25519") + verifier = verifier.allow_algorithm(jwt::algorithm::ed25519(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password)); + else if (algo == "ed448") + verifier = verifier.allow_algorithm(jwt::algorithm::ed448(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password)); + else if (algo == "rs256") + verifier = verifier.allow_algorithm(jwt::algorithm::rs256(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password)); + else if (algo == "rs384") + verifier = verifier.allow_algorithm(jwt::algorithm::rs384(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password)); + else if (algo == "rs512") + verifier = verifier.allow_algorithm(jwt::algorithm::rs512(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password)); + else if (algo == "es256") + verifier = verifier.allow_algorithm(jwt::algorithm::es256(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password)); + else if (algo == "es256k") + verifier = verifier.allow_algorithm(jwt::algorithm::es256k(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password)); + else if (algo == "es384") + verifier = verifier.allow_algorithm(jwt::algorithm::es384(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password)); + else if (algo == "es512") + verifier = verifier.allow_algorithm(jwt::algorithm::es512(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password)); + else if (algo.starts_with("hs")) + { + auto key = _params.static_key; + if (_params.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::JWT_ERROR, "Unknown algorithm {}", _params.algo); + } + else + throw Exception(ErrorCodes::JWT_ERROR, "Unknown algorithm {}", _params.algo); +} + +bool SimpleJWTValidator::verifyImpl(const jwt::decoded_jwt & token) const +{ + verifier.verify(token); + return true; +} + +JWKSValidator::JWKSValidator(const String & _name, std::shared_ptr _provider) + : IJWTValidator(_name) + , provider(_provider) +{} + +bool JWKSValidator::verifyImpl(const jwt::decoded_jwt & token) const +{ + auto jwk = provider->getJWKS().get_jwk(token.get_key_id()); + auto subject = token.get_subject(); + auto algo = Poco::toLower(token.get_algorithm()); + auto verifier = jwt::verify(); + String public_key; + + try + { + auto issuer = token.get_issuer(); + auto x5c = jwk.get_x5c_key_value(); + + if (!x5c.empty() && !issuer.empty()) + { + LOG_TRACE(getLogger("JWTAuthentication"), "{}: Verifying {} with 'x5c' key", name, subject); + public_key = jwt::helper::convert_base64_der_to_pem(x5c); + } + } + catch (const jwt::error::claim_not_present_exception &) + { + /// issuer or x5c was not specified, simply do not verify against them + } + catch (const std::bad_cast &) + { + throw Exception(ErrorCodes::JWT_ERROR, "Invalid claim value type: must be string"); + } + + if (public_key.empty()) + { + LOG_TRACE(getLogger("JWTAuthentication"), "{}: `issuer` or `x5c` not present, verifying {} with RSA components", name, subject); + 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 (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::JWT_ERROR, "Unknown algorithm {}", algo); + verifier = verifier.leeway(60UL); // value in seconds, add some to compensate timeout + verifier.verify(token); + return true; +} + +JWKSClient::JWKSClient(const JWKSAuthClientParams & params_) + : HTTPAuthClient(params_) + , m_refresh_ms(params_.refresh_ms) +{ +} + +JWKSClient::~JWKSClient() = default; + +jwt::jwks JWKSClient::getJWKS() +{ + { + std::shared_lock lock(m_update_mutex); + auto now = std::chrono::high_resolution_clock::now(); + auto diff = std::chrono::duration(now - m_last_request_send).count(); + if (diff < m_refresh_ms) + { + jwt::jwks result(m_jwks); + return result; + } + } + std::unique_lock lock(m_update_mutex); + auto now = std::chrono::high_resolution_clock::now(); + auto diff = std::chrono::duration(now - m_last_request_send).count(); + if (diff < m_refresh_ms) + { + jwt::jwks result(m_jwks); + return result; + } + Poco::Net::HTTPRequest request{Poco::Net::HTTPRequest::HTTP_GET, this->getURI().getPathAndQuery()}; + auto result = authenticateRequest(request); + m_jwks = std::move(result.keys); + if (result.is_ok) + { + m_last_request_send = std::chrono::high_resolution_clock::now(); + } + jwt::jwks results(m_jwks); + return results; +} + +JWKSResponseParser::Result +JWKSResponseParser::parse(const Poco::Net::HTTPResponse & response, std::istream * body_stream) const +{ + Result result; + + if (response.getStatus() != Poco::Net::HTTPResponse::HTTPStatus::HTTP_OK) + return result; + result.is_ok = true; + + if (!body_stream) + return result; + + try + { + String response_data; + Poco::StreamCopier::copyToString(*body_stream, response_data); + auto keys = jwt::parse_jwks(response_data); + result.keys = std::move(keys); + } + catch (...) + { + LOG_INFO(getLogger("JWKSAuthentication"), "Failed to parse jwks from authentication response. Skip it."); + } + return result; +} + +void StaticJWKSParams::validate() const +{ + if (static_jwks.empty() && static_jwks_file.empty()) + throw Exception(ErrorCodes::JWT_ERROR, "`static_jwks` or `static_jwks_file` keys must be present in configuration"); + if (!static_jwks.empty() && !static_jwks_file.empty()) + throw Exception(ErrorCodes::JWT_ERROR, "`static_jwks` and `static_jwks_file` keys cannot both be present in configuration"); +} + +void StaticJWKS::init(const StaticJWKSParams & params) +{ + params.validate(); + String content = String(params.static_jwks); + if (!params.static_jwks_file.empty()) + { + std::ifstream ifs(params.static_jwks_file); + content = String((std::istreambuf_iterator(ifs)), (std::istreambuf_iterator())); + } + auto keys = jwt::parse_jwks(content); + jwks = std::move(keys); +} + +} diff --git a/src/Access/JWTValidator.h b/src/Access/JWTValidator.h new file mode 100644 index 000000000000..017958e4a712 --- /dev/null +++ b/src/Access/JWTValidator.h @@ -0,0 +1,137 @@ +#pragma once + +#include + +#include +#include +#include + +#include +#include + +#include "Access/HTTPAuthClient.h" + +namespace DB +{ + +class SettingsChanges; + +struct JWTValidator +{ + String settings_key; +}; + +class IJWTValidator +{ +public: + explicit IJWTValidator(const String & name_) + : name(name_) + {} + void init(const JWTValidator & _params); + bool verify(const String & claims, const String & token, SettingsChanges & settings) const; + virtual ~IJWTValidator() = default; +protected: + virtual bool verifyImpl(const jwt::decoded_jwt & token) const = 0; + JWTValidator params; + const String name; +}; + +struct SimpleJWTValidatorParams : + public JWTValidator +{ + String algo; + String static_key; + bool static_key_in_base64; + String public_key; + String private_key; + String public_key_password; + String private_key_password; + void validate() const; +}; + +class SimpleJWTValidator : public IJWTValidator +{ +public: + explicit SimpleJWTValidator(const String & _name); + void init(const SimpleJWTValidatorParams & _params); +private: + bool verifyImpl(const jwt::decoded_jwt & token) const override; + jwt::verifier verifier; +}; + +class IJWKSProvider +{ +public: + virtual ~IJWKSProvider() = default; + virtual jwt::jwks getJWKS() = 0; +}; + +class JWKSValidator : public IJWTValidator +{ +public: + explicit JWKSValidator(const String & _name, std::shared_ptr _provider); +private: + bool verifyImpl(const jwt::decoded_jwt & token) const override; + + std::shared_ptr provider; +}; + +struct JWKSAuthClientParams: public HTTPAuthClientParams +{ + size_t refresh_ms; +}; + +class JWKSResponseParser +{ + static constexpr auto settings_key = "settings"; +public: + struct Result + { + bool is_ok = false; + jwt::jwks keys; + }; + + Result parse(const Poco::Net::HTTPResponse & response, std::istream * body_stream) const; +}; + +class JWKSClient: public IJWKSProvider, + private HTTPAuthClient +{ +public: + explicit JWKSClient(const JWKSAuthClientParams & params_); + ~JWKSClient() override; + + JWKSClient(const JWKSClient &) = delete; + JWKSClient(JWKSClient &&) = delete; + JWKSClient & operator= (const JWKSClient &) = delete; + JWKSClient & operator= (JWKSClient &&) = delete; +private: + jwt::jwks getJWKS() override; + + size_t m_refresh_ms; + + std::shared_mutex m_update_mutex; + jwt::jwks m_jwks; + std::chrono::time_point m_last_request_send; +}; + +struct StaticJWKSParams +{ + String static_jwks; + String static_jwks_file; + void validate() const; +}; + +class StaticJWKS: public IJWKSProvider +{ +public: + void init(const StaticJWKSParams & params); +private: + jwt::jwks getJWKS() override + { + return jwks; + } + jwt::jwks jwks; +}; + +} diff --git a/src/Access/UsersConfigAccessStorage.cpp b/src/Access/UsersConfigAccessStorage.cpp index b4b843fc77ea..8c973c935201 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 @@ -128,6 +129,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); @@ -139,18 +141,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_ldap + has_kerberos + has_certificates + has_ssh_keys + has_http_auth + 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) { @@ -259,6 +261,10 @@ namespace auto scheme = config.getString(http_auth_config + ".scheme"); user->auth_data.setHTTPAuthenticationScheme(parseHTTPAuthenticationScheme(scheme)); } + else if (has_jwt) + { + user->auth_data = AuthenticationData{AuthenticationType::JWT}; + } auto auth_type = user->auth_data.getType(); if (((auth_type == AuthenticationType::NO_PASSWORD) && !allow_no_password) || diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 73aa409e9958..5a0907ef3572 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -355,6 +355,7 @@ target_link_libraries(clickhouse_common_io ch_contrib::zlib pcg_random Poco::Foundation + ch_contrib::jwt-cpp ) if (TARGET ch_contrib::fiu) diff --git a/src/Client/ClientBase.cpp b/src/Client/ClientBase.cpp index bfe391f62e8f..7a3192d1d9cf 100644 --- a/src/Client/ClientBase.cpp +++ b/src/Client/ClientBase.cpp @@ -109,7 +109,6 @@ namespace ErrorCodes extern const int USER_SESSION_LIMIT_EXCEEDED; extern const int NOT_IMPLEMENTED; extern const int CANNOT_READ_FROM_FILE_DESCRIPTOR; - extern const int USER_EXPIRED; } } @@ -2244,7 +2243,7 @@ bool ClientBase::executeMultiQuery(const String & all_queries_text) catch (...) { // Surprisingly, this is a client error. A server error would - // have been reported without throwing (see onReceiveExceptionFromServer()). + // have been reported without throwing (see onReceiveSeverException()). client_exception = std::make_unique(getCurrentExceptionMessageAndPattern(print_stack_trace), getCurrentExceptionCode()); have_error = true; } @@ -2617,9 +2616,6 @@ void ClientBase::runInteractive() } catch (const Exception & e) { - if (e.code() == ErrorCodes::USER_EXPIRED) - break; - /// We don't need to handle the test hints in the interactive mode. std::cerr << "Exception on client:" << std::endl << getExceptionMessage(e, print_stack_trace, true) << std::endl << std::endl; client_exception.reset(e.clone()); diff --git a/src/Common/ErrorCodes.cpp b/src/Common/ErrorCodes.cpp index af609fabb8f3..56cd3c8faa35 100644 --- a/src/Common/ErrorCodes.cpp +++ b/src/Common/ErrorCodes.cpp @@ -598,6 +598,8 @@ M(717, EXPERIMENTAL_FEATURE_ERROR) \ M(718, TOO_SLOW_PARSING) \ \ + M(899, JWT_ERROR) \ + \ M(900, DISTRIBUTED_CACHE_ERROR) \ M(901, CANNOT_USE_DISTRIBUTED_CACHE) \ \ diff --git a/src/Parsers/Access/ASTAuthenticationData.cpp b/src/Parsers/Access/ASTAuthenticationData.cpp index 7e7df6fa1f28..13dfb3dfe140 100644 --- a/src/Parsers/Access/ASTAuthenticationData.cpp +++ b/src/Parsers/Access/ASTAuthenticationData.cpp @@ -91,8 +91,11 @@ void ASTAuthenticationData::formatImpl(const FormatSettings & settings, FormatSt } 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 4e14d86c4257..2bc0072f443f 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 d4729ab796a7..550c21e13604 100644 --- a/src/Parsers/Access/ParserCreateUserQuery.cpp +++ b/src/Parsers/Access/ParserCreateUserQuery.cpp @@ -68,6 +68,7 @@ namespace bool expect_common_names = false; bool expect_public_ssh_key = false; bool expect_http_auth_server = false; + bool expect_claims = false; if (ParserKeyword{Keyword::WITH}.ignore(pos, expected)) { @@ -89,6 +90,8 @@ namespace expect_http_auth_server = true; else if (check_type != AuthenticationType::NO_PASSWORD) expect_password = true; + else if (check_type != AuthenticationType::JWT) + expect_claims = true; break; } @@ -125,6 +128,7 @@ namespace ASTPtr common_names; ASTPtr public_ssh_keys; ASTPtr http_auth_scheme; + ASTPtr jwt_claims; if (expect_password || expect_hash) { @@ -182,6 +186,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(); @@ -204,6 +216,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)); + return true; }); } diff --git a/src/Parsers/Access/ParserCreateUserQuery.h b/src/Parsers/Access/ParserCreateUserQuery.h index 0cc8c9b6649d..19e673160719 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 [,...] ] * [SETTINGS variable [= value] [MIN [=] min_value] [MAX [=] max_value] [CONST|READONLY|WRITABLE|CHANGEABLE_IN_READONLY] | PROFILE 'profile_name'] [,...] diff --git a/src/Parsers/CommonParsers.h b/src/Parsers/CommonParsers.h index efc3b6564518..1b0ef9de1659 100644 --- a/src/Parsers/CommonParsers.h +++ b/src/Parsers/CommonParsers.h @@ -79,6 +79,7 @@ namespace DB MR_MACROS(CHECK_ALL_TABLES, "CHECK ALL TABLES") \ MR_MACROS(CHECK_TABLE, "CHECK TABLE") \ 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/HTTPHandler.cpp b/src/Server/HTTPHandler.cpp index fd9be9992762..1418378fc798 100644 --- a/src/Server/HTTPHandler.cpp +++ b/src/Server/HTTPHandler.cpp @@ -125,6 +125,8 @@ namespace ErrorCodes namespace { +const String BEARER_PREFIX = "bearer "; + bool tryAddHTTPOptionHeadersFromConfig(HTTPServerResponse & response, const Poco::Util::LayeredConfiguration & config) { if (config.has("http_options_response")) @@ -364,6 +366,8 @@ bool HTTPHandler::authenticateUser( bool has_http_credentials = request.hasCredentials(); bool has_credentials_in_query_params = params.has("user") || params.has("password") || params.has("quota_key"); + std::string jwt_token = request.get("X-ClickHouse-JWT-Token", request.get("Authorization", (params.has("token") ? BEARER_PREFIX + params.get("token") : ""))); + std::string spnego_challenge; std::string certificate_common_name; @@ -424,7 +428,7 @@ bool HTTPHandler::authenticateUser( if (spnego_challenge.empty()) throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid authentication: SPNEGO challenge is empty"); } - else + else if (Poco::icompare(scheme, "Bearer") < 0) { throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid authentication: '{}' HTTP Authorization scheme is not supported", scheme); } @@ -475,6 +479,10 @@ bool HTTPHandler::authenticateUser( return false; } } + else if (!jwt_token.empty() && Poco::toLower(jwt_token).starts_with(BEARER_PREFIX)) + { + request_credentials = std::make_unique(jwt_token.substr(BEARER_PREFIX.length())); + } else // I.e., now using user name and password strings ("Basic"). { if (!request_credentials) diff --git a/src/Server/TCPHandler.cpp b/src/Server/TCPHandler.cpp index 4dfc1ab60e72..3b9fcb6324b3 100644 --- a/src/Server/TCPHandler.cpp +++ b/src/Server/TCPHandler.cpp @@ -1472,6 +1472,10 @@ void TCPHandler::receiveHello() user.erase(0, String(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(); @@ -1529,6 +1533,13 @@ void TCPHandler::receiveHello() } #endif + if (is_jwt_based_auth) + { + auto cred = JWTCredentials(password); + session->authenticate(cred, getClientAddress(client_info)); + return; + } + session->authenticate(user, password, getClientAddress(client_info)); } diff --git a/src/Server/TCPHandler.h b/src/Server/TCPHandler.h index 28259d3a3257..663dc2627df5 100644 --- a/src/Server/TCPHandler.h +++ b/src/Server/TCPHandler.h @@ -217,6 +217,7 @@ class TCPHandler : public Poco::Net::TCPServerConnection String default_database; bool is_ssh_based_auth = false; + 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/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/verify_static_key.xml b/tests/integration/test_jwt_auth/configs/verify_static_key.xml new file mode 100644 index 000000000000..878ef9517557 --- /dev/null +++ b/tests/integration/test_jwt_auth/configs/verify_static_key.xml @@ -0,0 +1,24 @@ + + + + + hs256 + my_secret + false + + + + hs256 + other_secret + false + + + + {"keys": [{"kty": "RSA", "alg": "RS256", "kid": "mykid", "n": "lICGC8S5pObyASih5qfmwuclG0oKsbzY2z9vgwqyhTYQOWcqYcTjVV4aQ30qb6E0-5W6rJ-jx9zx6GuAEGMiG_aWJEdbUAMGp-L1kz4lrw5U6GlwoZIvk4wqoRwsiyc-mnDMQAmiZLBNyt3wU6YnKgYmb4O1cSzcZ5HMbImJpj4tpYjqnIazvYMn_9Pxjkl0ezLCr52av0UkWHro1H4QMVfuEoNmHuWPww9jgHn-I-La0xdOhRpAa0XnJi65dXZd4330uWjeJwt413yz881uS4n1OLOGKG8ImDcNlwU_guyvk0n0aqT0zkOAPp9_yYo13MPWmiRCfOX8ozdN7VDIJw", "e": "AQAB"}]} + + + + 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..77f1ece8742c --- /dev/null +++ b/tests/integration/test_jwt_auth/test.py @@ -0,0 +1,101 @@ +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/verify_static_key.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 "X-ClickHouse-JWT-Token: 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_static_jwks(started_cluster): + res = client.exec_in_container( + [ + "bash", + "-c", + curl_with_jwt( + token="eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Im15a2lkIn0." + "eyJzdWIiOiJqd3RfdXNlciIsImlzcyI6InRlc3RfaXNzIn0." + "CUioyRc_ms75YWkUwvPgLvaVk2Wmj8RzgqDALVd9LWUzCL5aU4yc_YaA3qnG_NoHd0uUF4FUjLxiocRoKNEgsE2jj7g_" + "wFMC5XHSHuFlfIZjovObXQEwGcKpXO2ser7ANu3k2jBC2FMpLfr_sZZ_GYSnqbp2WF6-l0uVQ0AHVwOy4x1Xkawiubkg" + "W2I2IosaEqT8QNuvvFWLWc1k-dgiNp8k6P-K4D4NBQub0rFlV0n7AEKNdV-_AEzaY_IqQT0sDeBSew_mdR0OH_N-6-" + "FmWWIroIn2DQ7pq93BkI7xdkqnxtt8RCWkCG8JLcoeJt8sHh7uTKi767loZJcPPNaxKA", + 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" diff --git a/utils/check-style/aspell-ignore/en/aspell-dict.txt b/utils/check-style/aspell-ignore/en/aspell-dict.txt index ee3ef1ae7950..0605decdb317 100644 --- a/utils/check-style/aspell-ignore/en/aspell-dict.txt +++ b/utils/check-style/aspell-ignore/en/aspell-dict.txt @@ -244,6 +244,8 @@ DockerHub DoubleDelta Doxygen Durre +ECDSA +EdDSA ECMA Ecto EdgeAngle @@ -343,6 +345,7 @@ Heredoc HexAreaKm HexAreaM HexRing +HMSC Holistics Homebrew Homebrew's @@ -434,6 +437,8 @@ Jitter Joda JumpConsistentHash Jupyter +jwks +JWKS KDevelop KafkaAssignedPartitions KafkaBackgroundReads @@ -2698,6 +2703,7 @@ userspace userver utils uuid +validators varPop varPopStable varSamp @@ -2712,6 +2718,8 @@ vectorized vectorscan verificationDepth verificationMode +verifier +verifiers versionedcollapsingmergetree vhost virtualized