diff --git a/docs/en/interfaces/cli.md b/docs/en/interfaces/cli.md index bfbe5dfbc07c..fe1c02937456 100644 --- a/docs/en/interfaces/cli.md +++ b/docs/en/interfaces/cli.md @@ -836,6 +836,8 @@ All command-line options can be specified directly on the command line or as def | `-d [ --database ] ` | Select the database to default to for this connection. | The current database from the server settings (`default` by default) | | `-h [ --host ] ` | The hostname of the ClickHouse server to connect to. Can either be a hostname or an IPv4 or IPv6 address. Multiple hosts can be passed via multiple arguments. | `localhost` | | `--jwt ` | Use JSON Web Token (JWT) for authentication.

Server JWT authorization is only available in ClickHouse Cloud. | - | +| `--jwt-command ` | Shell command whose stdout is used as the JWT. Invoked at startup and on every (re)connect. See [`--jwt-command` details](#jwt-command-details) below. | - | +| `--jwt-command-timeout ` | Timeout for `--jwt-command`. Also settable as `` in the config file; CLI wins. | `30` | | `--login[=]` | Authenticate via OAuth2. Bare `--login` (no `=`) triggers ClickHouse Cloud automatic login — the provider is inferred from the server. To authenticate against a custom OpenID Connect provider, supply a `mode` and `--oauth-credentials`: `--login=browser` runs the Authorization Code + PKCE flow (opens a browser), `--login=device` runs the Device Authorization flow (prints a URL and short code — no browser needed). | - | | `--oauth-credentials ` | Path to an OAuth2 credentials JSON file (Google Cloud Console format). Required when using `--login=browser` or `--login=device` with a custom OpenID Connect provider. See [OAuth credentials file format](#oauth-credentials-file) below. Refresh tokens are cached in `~/.clickhouse-client/oauth_cache.json` (mode `0600`). | `~/.clickhouse-client/oauth_client.json` | | `--no-warnings` | Disable showing warnings from `system.warnings` when the client connects to the server. | - | @@ -880,6 +882,16 @@ The default path is `~/.clickhouse-client/oauth_client.json`. Override it with ` After a successful login the obtained refresh token is cached in `~/.clickhouse-client/oauth_cache.json` (file mode `0600`). Subsequent runs reuse the cached token silently and only open the browser or print a device code when the refresh token has expired. +### `--jwt-command` details {#jwt-command-details} + +The command is executed via `/bin/sh -c` and stdout is taken as the JWT (one trailing newline is stripped). Stderr is forwarded to the client's stderr; stdin is closed. The command runs at client startup and on every (re)connection to the server — the token is treated as opaque, so caching/refresh is the script's responsibility. + +```bash +clickhouse-client --jwt-command "curl -sS https://idp.example/token | jq -r .access_token" +``` + +Cannot be combined with `--jwt`, `--login`, or a non-default `--user`. Non-zero exit, empty output, or exceeding `--jwt-command-timeout` (default `30`s, overridable via `` in `~/.clickhouse-client/config.xml`) fails authentication. On timeout the entire helper subprocess tree is terminated. + ### Query options {#command-line-options-query} | Option | Description | diff --git a/programs/client/Client.cpp b/programs/client/Client.cpp index ff0abe34d655..0710d5c1d5d0 100644 --- a/programs/client/Client.cpp +++ b/programs/client/Client.cpp @@ -26,6 +26,7 @@ #include #include +#include #include #include @@ -374,6 +375,17 @@ try } #if USE_JWT_CPP && USE_SSL + if (config().has("jwt-command") && !config().has("jwt")) + { + int timeout = config().getInt("jwt-command-timeout", DEFAULT_JWT_COMMAND_TIMEOUT_SECONDS); + if (timeout <= 0) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "jwt-command-timeout must be positive, got {}", timeout); + + auto provider = std::make_shared(config().getString("jwt-command"), timeout); + config().setString("jwt", provider->getJWT()); + jwt_provider = std::move(provider); + } + if (config().getBool("cloud_oauth_pending", false) && !config().has("jwt")) { login(); @@ -742,6 +754,10 @@ void Client::printHelpMessage(const OptionsDescription & options_description) void Client::addExtraOptions(OptionsDescription & options_description) { + static const std::string jwt_command_timeout_help = + "Timeout in seconds for --jwt-command. Default: " + std::to_string(DEFAULT_JWT_COMMAND_TIMEOUT_SECONDS) + + ". Also configurable as in the client config file."; + /// Main commandline options related to client functionality and all parameters from Settings. options_description.main_description->add_options() ("config,c", po::value(), "config-file path (another shorthand)") @@ -756,6 +772,9 @@ void Client::addExtraOptions(OptionsDescription & options_description) ("ssh-key-passphrase", po::value(), "Passphrase for the SSH private key specified by --ssh-key-file.") ("quota_key", po::value(), "A string to differentiate quotas when the user have keyed quotas configured on server") ("jwt", po::value(), "Use JWT for authentication") + ("jwt-command", po::value(), + "Shell command whose stdout is used as the JWT. Invoked at startup and on every (re)connect.") + ("jwt-command-timeout", po::value(), jwt_command_timeout_help.c_str()) ("one-time-password", po::value(), "Time-based one-time password (TOTP) for two-factor authentication") ("login", po::value()->implicit_value(""), "Authenticate via OAuth2. Optional mode: 'browser' (auth-code + PKCE, opens browser) " @@ -931,6 +950,27 @@ void Client::processOptions( config().setString("jwt", options["jwt"].as()); config().setString("user", ""); } + if (options.contains("jwt-command-timeout")) + config().setInt("jwt-command-timeout", options["jwt-command-timeout"].as()); + + if (options.contains("jwt-command")) + { +#if USE_JWT_CPP && USE_SSL + if (options.contains("jwt")) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "--jwt-command and --jwt cannot both be specified"); + if (options.contains("login")) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "--jwt-command and --login cannot both be specified"); + if (!options["user"].defaulted()) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "User and JWT flags can't be specified together"); + + /// Defer execution to Client::main, after processConfig has loaded the XML config. + /// Reading config().getInt("jwt-command-timeout", ...) here would miss the XML value. + config().setString("jwt-command", options["jwt-command"].as()); + config().setString("user", ""); +#else + throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "JWT is disabled, because ClickHouse is built without JWT or SSL support"); +#endif + } if (options.count("oauth-credentials") && !options.count("login")) throw Exception( ErrorCodes::BAD_ARGUMENTS, diff --git a/src/Client/CommandJWTProvider.cpp b/src/Client/CommandJWTProvider.cpp new file mode 100644 index 000000000000..509126c649d6 --- /dev/null +++ b/src/Client/CommandJWTProvider.cpp @@ -0,0 +1,128 @@ +#include + +#if USE_JWT_CPP && USE_SSL +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace DB +{ + +namespace ErrorCodes +{ + extern const int AUTHENTICATION_FAILED; +} + +CommandJWTProvider::CommandJWTProvider(std::string command_, int timeout_seconds_) + : JWTProvider(/*auth_url=*/"", /*client_id=*/"", /*audience=*/"", std::cout, std::cerr) + , command(std::move(command_)) + , timeout_seconds(timeout_seconds_) +{ +} + +std::string CommandJWTProvider::getJWT() +{ + ShellCommand::Config config(command); + config.new_process_group = true; // so the watchdog can kill the whole tree, not just /bin/sh + auto child = ShellCommand::execute(config); + child->in.close(); // we don't write to the script's stdin; close so reads see EOF + const pid_t pid = child->getPid(); + + std::mutex mutex; + std::condition_variable cv; + bool finished = false; + std::atomic timed_out{false}; + + /// Default-construct the threads first and install the cleanup guard before assigning, + /// so a thread-constructor failure mid-assignment cannot leave a joinable thread that + /// would call std::terminate on destruction. + std::thread watchdog; + std::thread stderr_forwarder; + SCOPE_EXIT({ + { + std::lock_guard lock(mutex); + finished = true; + } + cv.notify_all(); + if (stderr_forwarder.joinable()) stderr_forwarder.join(); + if (watchdog.joinable()) watchdog.join(); + }); + + watchdog = std::thread([&, pid]() + { + std::unique_lock lock(mutex); + if (!cv.wait_for(lock, std::chrono::seconds(timeout_seconds), [&]{ return finished; })) + { + timed_out = true; + ::kill(-pid, SIGKILL); + } + }); + + /// Drain stderr on a separate thread so the child doesn't block on a full pipe. + stderr_forwarder = std::thread([&child]() + { + try + { + WriteBufferFromOStream wb(std::cerr); + copyData(child->err, wb); + wb.finalize(); + } + catch (...) {} + }); + + std::string token; + readStringUntilEOF(token, child->out); + + /// Drain stderr fully before tryWait, since tryWait closes child->err and reading + /// a buffer whose fd has just been closed from another thread is UB. + stderr_forwarder.join(); + + /// Cancel the watchdog before tryWait. After tryWait reaps the child, the kernel + /// may recycle the pid; if the watchdog then fires kill(-pid, ...) it could hit an + /// unrelated process group. + { + std::lock_guard lock(mutex); + finished = true; + } + cv.notify_all(); + watchdog.join(); + + /// Reap with a catch: on timeout the child is signaled, and we want our own + /// error message rather than the noisy CHILD_WAS_NOT_EXITED_NORMALLY one. + int retcode = 0; + try { retcode = child->tryWait(); } + catch (...) { if (!timed_out.load()) throw; } + + if (timed_out.load()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, + "--jwt-command timed out after {} seconds", timeout_seconds); + + if (retcode != 0) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, + "--jwt-command exited with non-zero status {}", retcode); + + if (!token.empty() && token.back() == '\n') + token.pop_back(); + + if (token.empty()) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "--jwt-command produced empty output"); + + return token; +} + +} + +#endif diff --git a/src/Client/CommandJWTProvider.h b/src/Client/CommandJWTProvider.h new file mode 100644 index 000000000000..0e8805415063 --- /dev/null +++ b/src/Client/CommandJWTProvider.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +namespace DB +{ + +inline constexpr int DEFAULT_JWT_COMMAND_TIMEOUT_SECONDS = 30; + +} + +#if USE_JWT_CPP && USE_SSL + +#include + +namespace DB +{ + +class CommandJWTProvider : public JWTProvider +{ +public: + CommandJWTProvider(std::string command_, int timeout_seconds_); + + std::string getJWT() override; + +private: + std::string command; + int timeout_seconds; +}; + +} + +#endif diff --git a/src/Client/Connection.cpp b/src/Client/Connection.cpp index 0967b5b729d8..dc9056ed1004 100644 --- a/src/Client/Connection.cpp +++ b/src/Client/Connection.cpp @@ -146,6 +146,11 @@ void Connection::connect(const ConnectionTimeouts & timeouts) /// if connection was broken it is necessary to cancel it before reconnecting disconnect(); +#if USE_JWT_CPP && USE_SSL + if (jwt_provider) + jwt = jwt_provider->getJWT(); +#endif + ProfileEvents::increment(ProfileEvents::DistributedConnectionConnectCount); try { @@ -848,23 +853,6 @@ void Connection::sendQuery( client_info = &new_client_info; } -#if USE_JWT_CPP && USE_SSL - if (jwt_provider && !jwt.empty()) - { - if (JWTProvider::getJwtExpiry(jwt) < (Poco::Timestamp() + Poco::Timespan(30, 0))) - { - String new_jwt = jwt_provider->getJWT(); - if (!new_jwt.empty()) - { - jwt = new_jwt; - // We have a new token, so we need to reconnect. - // The current connection is still using the old token. - disconnect(); - } - } - } -#endif - if (!connected) connect(timeouts); diff --git a/src/Common/ShellCommand.cpp b/src/Common/ShellCommand.cpp index ccee2943fcd6..ce76781237d0 100644 --- a/src/Common/ShellCommand.cpp +++ b/src/Common/ShellCommand.cpp @@ -27,6 +27,7 @@ namespace CANNOT_EXEC = 0x55555558, CANNOT_DUP_READ_DESCRIPTOR = 0x55555559, CANNOT_DUP_WRITE_DESCRIPTOR = 0x55555560, + CANNOT_SETPGID = 0x55555561, }; } @@ -218,6 +219,9 @@ std::unique_ptr ShellCommand::executeImpl( sigprocmask(0, nullptr, &mask); // NOLINT(concurrency-mt-unsafe) sigprocmask(SIG_UNBLOCK, &mask, nullptr); // NOLINT(concurrency-mt-unsafe) + if (config.new_process_group && setpgid(0, 0) != 0) + _exit(static_cast(ReturnCodes::CANNOT_SETPGID)); + execv(filename, argv); /// If the process is running, then `execv` does not return here. @@ -385,6 +389,8 @@ void ShellCommand::handleProcessRetcode(int retcode) const throw Exception(ErrorCodes::CANNOT_CREATE_CHILD_PROCESS, "Cannot dup2 read descriptor of child process"); case static_cast(ReturnCodes::CANNOT_DUP_WRITE_DESCRIPTOR): throw Exception(ErrorCodes::CANNOT_CREATE_CHILD_PROCESS, "Cannot dup2 write descriptor of child process"); + case static_cast(ReturnCodes::CANNOT_SETPGID): + throw Exception(ErrorCodes::CANNOT_CREATE_CHILD_PROCESS, "Cannot setpgid in child process"); default: throw Exception(ErrorCodes::CHILD_WAS_NOT_EXITED_NORMALLY, "Child process was exited with return code {}", toString(retcode)); } diff --git a/src/Common/ShellCommand.h b/src/Common/ShellCommand.h index 7d8d12bb3131..e39703e47f76 100644 --- a/src/Common/ShellCommand.h +++ b/src/Common/ShellCommand.h @@ -65,6 +65,10 @@ class ShellCommand final bool pipe_stdin_only = false; + /// Put the child in its own process group, so that a single `kill(-pid, ...)` + /// from the parent terminates the entire subprocess tree. + bool new_process_group = false; + DestructorStrategy terminate_in_destructor_strategy = DestructorStrategy(false, 0); }; diff --git a/tests/queries/0_stateless/04206_jwt_command.reference b/tests/queries/0_stateless/04206_jwt_command.reference new file mode 100644 index 000000000000..031c6306cebd --- /dev/null +++ b/tests/queries/0_stateless/04206_jwt_command.reference @@ -0,0 +1,25 @@ +Test 1: --jwt and --jwt-command together should give BAD_ARGUMENTS +OK +Test 2: --jwt-command with non-default --user should give BAD_ARGUMENTS +OK +Test 3: --jwt-command with --login should give BAD_ARGUMENTS +OK +Test 4: --jwt-command with empty stdout should fail with AUTHENTICATION_FAILED +OK +Test 5: --jwt-command exiting with non-zero status should fail with AUTHENTICATION_FAILED +OK +Test 6: --jwt-command stderr should be forwarded to client stderr +OK +Test 7: --jwt-command-timeout kills a hanging script +OK +Test 8: --jwt-command-timeout=0 should be rejected +OK +Test 9: --jwt-command is actually executed +OK +Test 10: --jwt-command-timeout from XML config file takes effect +OK +Test 11: stdin-reading script completes promptly (stdin is closed) +OK +Test 12: CLI --jwt-command-timeout overrides XML config +OK +All tests completed diff --git a/tests/queries/0_stateless/04206_jwt_command.sh b/tests/queries/0_stateless/04206_jwt_command.sh new file mode 100755 index 000000000000..c9f36bc05fb1 --- /dev/null +++ b/tests/queries/0_stateless/04206_jwt_command.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Tags: no-fasttest +# Tag no-fasttest: --jwt-command requires a build with JWT and SSL support + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +# Well-formed JWT with far-future exp; server will reject, but the client must accept its shape. +SAMPLE_JWT="eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjk5OTk5OTk5OTksInN1YiI6InRlc3QifQ.fake" + +echo "Test 1: --jwt and --jwt-command together should give BAD_ARGUMENTS" +output=$($CLICKHOUSE_CLIENT_BINARY --jwt "$SAMPLE_JWT" --jwt-command "echo $SAMPLE_JWT" --query "SELECT 1" 2>&1) +if echo "$output" | grep -qi "cannot both be specified\|BAD_ARGUMENTS"; then + echo "OK" +else + echo "FAILED: expected BAD_ARGUMENTS, got: $output" +fi + +echo "Test 2: --jwt-command with non-default --user should give BAD_ARGUMENTS" +output=$($CLICKHOUSE_CLIENT_BINARY --user alice --jwt-command "echo $SAMPLE_JWT" --query "SELECT 1" 2>&1) +if echo "$output" | grep -qi "User and JWT flags\|BAD_ARGUMENTS"; then + echo "OK" +else + echo "FAILED: expected BAD_ARGUMENTS, got: $output" +fi + +echo "Test 3: --jwt-command with --login should give BAD_ARGUMENTS" +output=$($CLICKHOUSE_CLIENT_BINARY --login=device --jwt-command "echo $SAMPLE_JWT" --query "SELECT 1" 2>&1) +if echo "$output" | grep -qi "cannot both be specified\|BAD_ARGUMENTS"; then + echo "OK" +else + echo "FAILED: expected BAD_ARGUMENTS, got: $output" +fi + +echo "Test 4: --jwt-command with empty stdout should fail with AUTHENTICATION_FAILED" +output=$($CLICKHOUSE_CLIENT_BINARY --jwt-command "true" --query "SELECT 1" 2>&1) +if echo "$output" | grep -qi "empty output.*AUTHENTICATION_FAILED\|AUTHENTICATION_FAILED.*empty output"; then + echo "OK" +else + echo "FAILED: expected AUTHENTICATION_FAILED for empty output, got: $output" +fi + +echo "Test 5: --jwt-command exiting with non-zero status should fail with AUTHENTICATION_FAILED" +output=$($CLICKHOUSE_CLIENT_BINARY --jwt-command "exit 42" --query "SELECT 1" 2>&1) +if echo "$output" | grep -qi "non-zero status 42.*AUTHENTICATION_FAILED\|AUTHENTICATION_FAILED.*non-zero status 42"; then + echo "OK" +else + echo "FAILED: expected AUTHENTICATION_FAILED with retcode 42, got: $output" +fi + +echo "Test 6: --jwt-command stderr should be forwarded to client stderr" +MARKER="forwarded-from-script-stderr" +output=$($CLICKHOUSE_CLIENT_BINARY --jwt-command "echo $MARKER 1>&2; echo $SAMPLE_JWT" --host nonexistent.invalid --query "SELECT 1" 2>&1) +if echo "$output" | grep -q "$MARKER"; then + echo "OK" +else + echo "FAILED: expected stderr marker '$MARKER' in output, got: $output" +fi + +echo "Test 7: --jwt-command-timeout kills a hanging script" +start=$SECONDS +output=$($CLICKHOUSE_CLIENT_BINARY --jwt-command "sleep 30; echo $SAMPLE_JWT" --jwt-command-timeout 1 --query "SELECT 1" 2>&1) +elapsed=$((SECONDS - start)) +if echo "$output" | grep -qi "timed out after 1 seconds.*AUTHENTICATION_FAILED\|AUTHENTICATION_FAILED.*timed out after 1 seconds" && [ "$elapsed" -lt 10 ]; then + echo "OK" +else + echo "FAILED: expected AUTHENTICATION_FAILED timeout under 10s, elapsed=${elapsed}s, got: $output" +fi + +echo "Test 8: --jwt-command-timeout=0 should be rejected" +output=$($CLICKHOUSE_CLIENT_BINARY --jwt-command "echo $SAMPLE_JWT" --jwt-command-timeout 0 --query "SELECT 1" 2>&1) +if echo "$output" | grep -qi "must be positive.*BAD_ARGUMENTS\|BAD_ARGUMENTS.*must be positive"; then + echo "OK" +else + echo "FAILED: expected BAD_ARGUMENTS for non-positive timeout, got: $output" +fi + +echo "Test 9: --jwt-command is actually executed" +MARKER_FILE="${CLICKHOUSE_TMP}/04206_jwt_command_marker_$$" +rm -f "$MARKER_FILE" +$CLICKHOUSE_CLIENT_BINARY --jwt-command "echo ran > '$MARKER_FILE'; echo $SAMPLE_JWT" --host nonexistent.invalid --query "SELECT 1" > /dev/null 2>&1 +if [ -f "$MARKER_FILE" ]; then + echo "OK" +else + echo "FAILED: marker file not created, command did not run" +fi +rm -f "$MARKER_FILE" + +echo "Test 10: --jwt-command-timeout from XML config file takes effect" +CFG="${CLICKHOUSE_TMP}/04206_jwt_command_cfg_$$.xml" +cat > "$CFG" < + 1 + +EOF +start=$SECONDS +output=$($CLICKHOUSE_CLIENT_BINARY --config-file "$CFG" --jwt-command "sleep 30; echo $SAMPLE_JWT" --query "SELECT 1" 2>&1) +elapsed=$((SECONDS - start)) +if echo "$output" | grep -qi "timed out after 1 seconds" && [ "$elapsed" -lt 10 ]; then + echo "OK" +else + echo "FAILED: expected timeout under 10s from XML config, elapsed=${elapsed}s, got: $output" +fi +rm -f "$CFG" + +echo "Test 11: stdin-reading script completes promptly (stdin is closed)" +# If the child's stdin is closed by the parent, 'read X' returns immediately on EOF and +# the JWT is echoed before the 1s watchdog fires. If stdin were left open, 'read X' would +# block and the watchdog would surface 'timed out after 1 seconds'. We assert on that +# message rather than wall-clock time so the test is not flaky under loaded CI runs. +output=$($CLICKHOUSE_CLIENT_BINARY --jwt-command "read X; echo $SAMPLE_JWT" --jwt-command-timeout 1 --host nonexistent.invalid --query "SELECT 1" 2>&1) +if echo "$output" | grep -qi "timed out"; then + echo "FAILED: jwt-command child's stdin was not closed (got: $output)" +else + echo "OK" +fi + +echo "Test 12: CLI --jwt-command-timeout overrides XML config" +CFG="${CLICKHOUSE_TMP}/04206_jwt_command_cfg_override_$$.xml" +cat > "$CFG" < + 30 + +EOF +start=$SECONDS +output=$($CLICKHOUSE_CLIENT_BINARY --config-file "$CFG" --jwt-command "sleep 30; echo $SAMPLE_JWT" --jwt-command-timeout 1 --query "SELECT 1" 2>&1) +elapsed=$((SECONDS - start)) +if echo "$output" | grep -qi "timed out after 1 seconds" && [ "$elapsed" -lt 10 ]; then + echo "OK" +else + echo "FAILED: expected CLI(1) to override XML(30), elapsed=${elapsed}s, got: $output" +fi +rm -f "$CFG" + +echo "All tests completed"