Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion docs/en/interfaces/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,8 @@ All command-line options can be specified directly on the command line or as def
| `-d [ --database ] <database>` | Select the database to default to for this connection. | The current database from the server settings (`default` by default) |
| `-h [ --host ] <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 <value>` | Use JSON Web Token (JWT) for authentication. <br/><br/>Server JWT authorization is only available in ClickHouse Cloud. | - |
| `login` | Invokes the device grant OAuth flow in order to authenticate via an IDP. <br/><br/>For ClickHouse Cloud hosts, the OAuth variables are inferred otherwise they must be provided with `--oauth-url`, `--oauth-client-id` and `--oauth-audience`. | - |
| `--login[=<mode>]` | Authenticate via OAuth2. Bare `--login` (no `=<mode>`) 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>` | 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. | - |
| `--no-server-client-version-message` | Suppress server-client version mismatch message when the client connects to the server. | - |
| `--password <password>` | The password of the database user. You can also specify the password for a connection in the configuration file. If you do not specify the password, the client will ask for it. | - |
Expand All @@ -851,6 +852,34 @@ All command-line options can be specified directly on the command line or as def
Instead of the `--host`, `--port`, `--user` and `--password` options, the client also supports [connection strings](#connection_string).
:::

### OAuth credentials file {#oauth-credentials-file}

When using `--login=browser` or `--login=device` with a custom OpenID Connect provider, the client reads a credentials JSON file. The file uses the same format produced by the Google Cloud Console ("OAuth 2.0 Client IDs" → "Download JSON"):

```json
{
"installed": {
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"redirect_uris": ["http://127.0.0.1"]
}
}
```

The top-level key can be `installed` (desktop/CLI apps) or `web`. Required fields: `client_id`, `auth_uri`, `token_uri`. Optional fields:

| Field | Description |
|---|---|
| `client_secret` | Confidential-client secret. Omit (or leave empty) for OIDC public clients — the auth-code flow is always protected by PKCE and the device flow by the device code, so a secret is not required by the protocol. When the field is absent the client never sends a `client_secret` form parameter, which is the form public-client registrations require (Auth0, Microsoft Entra ID, Keycloak, Okta and others reject empty secrets with `invalid_client`). |
| `device_authorization_uri` | Device authorization endpoint. Discovered automatically via OIDC Discovery if absent. |
| `issuer` | OIDC issuer URL (e.g. `https://accounts.google.com`). Used to locate the discovery document when `device_authorization_uri` is not set. |

The default path is `~/.clickhouse-client/oauth_client.json`. Override it with `--oauth-credentials <path>`.

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.

### Query options {#command-line-options-query}

| Option | Description |
Expand Down
91 changes: 81 additions & 10 deletions programs/client/Client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

#include <Client/JWTProvider.h>
#include <Client/ClientBaseHelpers.h>
#include <Client/OAuthLogin.h>

#include <AggregateFunctions/registerAggregateFunctions.h>
#include <Formats/FormatFactory.h>
Expand Down Expand Up @@ -67,6 +68,7 @@ namespace ErrorCodes
extern const int AUTHENTICATION_FAILED;
extern const int REQUIRED_SECOND_FACTOR;
extern const int REQUIRED_PASSWORD;
extern const int SUPPORT_IS_DISABLED;
extern const int USER_EXPIRED;
}

Expand Down Expand Up @@ -282,7 +284,7 @@ void Client::initialize(Poco::Util::Application & self)
(loaded_config.configuration->has("user") || loaded_config.configuration->has("password")))
{
/// Config file has auth credentials, so disable the auto-added login flag
config().setBool("login", false);
config().setBool("cloud_oauth_pending", false);
}
#endif
}
Expand Down Expand Up @@ -372,7 +374,7 @@ try
}

#if USE_JWT_CPP && USE_SSL
if (config().getBool("login", false))
if (config().getBool("cloud_oauth_pending", false) && !config().has("jwt"))
{
login();
}
Expand Down Expand Up @@ -755,8 +757,15 @@ void Client::addExtraOptions(OptionsDescription & options_description)
("quota_key", po::value<std::string>(), "A string to differentiate quotas when the user have keyed quotas configured on server")
("jwt", po::value<std::string>(), "Use JWT for authentication")
("one-time-password", po::value<std::string>(), "Time-based one-time password (TOTP) for two-factor authentication")
("login", po::value<std::string>()->implicit_value(""),
"Authenticate via OAuth2. Optional mode: 'browser' (auth-code + PKCE, opens browser) "
"or 'device' (device flow, prints URL + code). "
"Example: --login=browser or --login=device. "
"Bare --login uses the ClickHouse Cloud auto-login path.")
("oauth-credentials", po::value<std::string>(),
"Path to OAuth credentials JSON file "
"(default: ~/.clickhouse-client/oauth_client.json)")
#if USE_JWT_CPP && USE_SSL
("login", po::bool_switch(), "Use OAuth 2.0 to login")
("oauth-url", po::value<std::string>(), "The base URL for the OAuth 2.0 authorization server")
("oauth-client-id", po::value<std::string>(), "The client ID for the OAuth 2.0 application")
("oauth-audience", po::value<std::string>(), "The audience for the OAuth 2.0 token")
Expand Down Expand Up @@ -922,16 +931,77 @@ void Client::processOptions(
config().setString("jwt", options["jwt"].as<std::string>());
config().setString("user", "");
}
#if USE_JWT_CPP && USE_SSL
if (options["login"].as<bool>())
if (options.count("oauth-credentials") && !options.count("login"))
throw Exception(
ErrorCodes::BAD_ARGUMENTS,
"--oauth-credentials requires --login=browser or --login=device");

if (options.count("login"))
{
if (!options["user"].defaulted())
throw Exception(ErrorCodes::BAD_ARGUMENTS, "User and login flags can't be specified together");
/// Reject mixed JWT + --login from any source. The --login branch below
/// ends up calling config().setString("jwt", jwt_provider->getJWT()),
/// which would silently overwrite a JWT supplied via --jwt or via the
/// XML config file. config().has("jwt") covers both: CLI --jwt was
/// already copied into config() above, and a <jwt> element in
/// ~/.clickhouse-client/config.xml is loaded into config() at startup.
if (config().has("jwt"))
throw Exception(ErrorCodes::BAD_ARGUMENTS, "JWT and login flags can't be specified together");
config().setBool("login", true);
config().setString("user", "");
throw Exception(
ErrorCodes::BAD_ARGUMENTS,
"--login cannot be combined with a JWT (provided via --jwt or in the config file)");

const std::string login_mode = options["login"].as<std::string>();
if (!login_mode.empty() && login_mode != "browser" && login_mode != "device")
throw Exception(
ErrorCodes::BAD_ARGUMENTS,
"--login value must be 'browser' or 'device', got '{}'",
login_mode);

#if USE_JWT_CPP && USE_SSL
if (!options["user"].defaulted())
throw Exception(ErrorCodes::BAD_ARGUMENTS, "--user and --login cannot both be specified");

// Bare --login (empty mode, including auto-added for *.clickhouse.cloud) → cloud path.
// Explicit --login=browser or --login=device (or --oauth-credentials) → credentials-file
// OIDC path. This prevents the credentials file from hijacking the cloud auto-login.
const bool use_credentials_file
= !login_mode.empty()
|| options.count("oauth-credentials");

if (use_credentials_file)
{
const char * home_path_cstr = getenv("HOME"); // NOLINT(concurrency-mt-unsafe)
const std::string default_creds_path = home_path_cstr
? std::string(home_path_cstr) + "/.clickhouse-client/oauth_client.json"
: "";

const std::string creds_path = options.count("oauth-credentials")
? options["oauth-credentials"].as<std::string>()
: default_creds_path;

auto creds = loadOAuthCredentials(creds_path);
const auto mode = (login_mode == "device") ? OAuthFlowMode::Device : OAuthFlowMode::AuthCode;

// createOAuthJWTProvider runs the initial flow (trying the cached
// refresh token first) and returns a provider that Connection can
// call to refresh the id_token transparently during long sessions.
jwt_provider = createOAuthJWTProvider(creds, mode);
config().setString("jwt", jwt_provider->getJWT());
config().setString("user", "");
}
else
{
// Cloud-specific login path — bare --login, including auto-added for
// *.clickhouse.cloud endpoints. Use a separate config key so that
// argsToConfig() overwriting config["login"] with the raw string value
// cannot cause getBool("login") to throw in main().
config().setBool("cloud_oauth_pending", true);
config().setString("user", "");
}
#else
throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "OAuth login requires a build with JWT and SSL support");
#endif
}
#if USE_JWT_CPP && USE_SSL
if (options.contains("oauth-url"))
config().setString("oauth-url", options["oauth-url"].as<std::string>());
if (options.contains("oauth-client-id"))
Expand Down Expand Up @@ -1105,6 +1175,7 @@ void Client::readArguments(
std::string_view arg(argv[i]);
if (arg.starts_with("--user") || arg.starts_with("--password") ||
arg.starts_with("--jwt") || arg.starts_with("--ssh-key-file") ||
arg == "--login" || arg.starts_with("--login=") ||
arg == "-u")
{
has_auth_in_cmdline = true;
Expand Down
17 changes: 16 additions & 1 deletion src/Access/TokenProcessorsOpaque.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
#include <Poco/Net/HTTPRequest.h>
#include <Poco/Net/HTTPResponse.h>

#include <cmath>
#include <limits>

namespace DB {

namespace ErrorCodes
Expand Down Expand Up @@ -114,7 +117,19 @@ bool GoogleTokenProcessor::resolveAndValidate(TokenCredentials & credentials) co

auto token_info = getObjectFromURI(Poco::URI("https://www.googleapis.com/oauth2/v3/tokeninfo"), token);
if (token_info.contains("exp"))
credentials.setExpiresAt(std::chrono::system_clock::from_time_t(static_cast<time_t>(getValueByKey<int64_t>(token_info, "exp").value())));
{
/// picojson stores all numerics as double; we need to validate the
/// value is a finite, positive Unix timestamp that fits in time_t
/// before casting.
const double exp = getValueByKey<double>(token_info, "exp").value();
if (!std::isfinite(exp) || exp <= 0.0
|| exp > static_cast<double>(std::numeric_limits<time_t>::max()))
throw Exception(
ErrorCodes::AUTHENTICATION_FAILED,
"{}: tokeninfo response contains an out-of-range 'exp' value: {}",
processor_name, exp);
credentials.setExpiresAt(std::chrono::system_clock::from_time_t(static_cast<time_t>(exp)));
}

/// Groups info can only be retrieved if user email is known.
/// If no email found in user info, we skip this step and there are no external roles for the user.
Expand Down
7 changes: 0 additions & 7 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -498,10 +498,6 @@ target_link_libraries(
Poco::Redis
)

if (TARGET ch_contrib::jwt-cpp)
target_link_libraries(clickhouse_common_io PUBLIC ch_contrib::jwt-cpp)
endif()

if (TARGET ch_contrib::mongocxx)
target_link_libraries(
dbms
Expand Down Expand Up @@ -799,6 +795,3 @@ if (ENABLE_TESTS)
endif()
endif ()

if (TARGET ch_contrib::jwt-cpp)
add_object_library(clickhouse_client_jwt Client/jwt)
endif()
Loading
Loading