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
30 changes: 29 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,33 @@ 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://localhost"]
}
}
```

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

| Field | Description |
|---|---|
| `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
82 changes: 72 additions & 10 deletions programs/client/Client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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 @@ -65,6 +66,7 @@ namespace ErrorCodes
extern const int NETWORK_ERROR;
extern const int AUTHENTICATION_FAILED;
extern const int REQUIRED_PASSWORD;
extern const int SUPPORT_IS_DISABLED;
extern const int USER_EXPIRED;
}

Expand Down Expand Up @@ -281,7 +283,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 @@ -371,7 +373,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 @@ -727,8 +729,15 @@ void Client::addExtraOptions(OptionsDescription & options_description)
("ssh-key-passphrase", po::value<std::string>(), "Passphrase for the SSH private key specified by --ssh-key-file.")
("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")
("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 @@ -884,23 +893,75 @@ void Client::processOptions(
config().setBool("no-server-client-version-message", true);
if (options.contains("fake-drop"))
config().setString("ignore_drop_queries_probability", "1");
if (options.count("jwt") && options.count("login"))
throw Exception(ErrorCodes::BAD_ARGUMENTS, "--jwt and --login cannot both be specified");
if (options.contains("jwt"))
{
if (!options["user"].defaulted())
throw Exception(ErrorCodes::BAD_ARGUMENTS, "User and JWT flags can't be specified together");
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"))
{
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 flags can't be specified together");
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, "--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 @@ -1074,6 +1135,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
2 changes: 1 addition & 1 deletion src/Access/TokenProcessorsOpaque.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ 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((getValueByKey<time_t>(token_info, "exp").value())));
credentials.setExpiresAt(std::chrono::system_clock::from_time_t(static_cast<time_t>(getValueByKey<double>(token_info, "exp").value())));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exp means the number of seconds since Unix epoch. Usually it is integer, and it is expected to be integer, isn\t it?
At least, Azure and Keycloak stick to integer values.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// picojson only has one numeric type: double
typedef double number_type;

So value.is() returns false, value.is<time_t>() returns false, but value.is() returns true — for any JSON number, including 1719500000.

The original code getValueByKey<time_t>(token_info, "exp") would always fail with "Value for key 'exp' has incorrect type" because picojson doesn't have time_t as a native type. The double fix is actually the
correct fix for picojson's type system.

So I retract my earlier comment. Using double is not "accidentally working" — it's the only way to extract a number from picojson. The static_cast<time_t> then truncates to integer, which is fine since the
value was integer to begin with (JSON 1719500000 → picojson double(1719500000.0) → time_t(1719500000)).

The commit message and the fix are correct. If anything, a brief comment explaining picojson's type model would help future readers:

// picojson stores all JSON numbers as double; cast to time_t for epoch seconds.

But that's a style nit, not a bug.


/// 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 @@ -484,10 +484,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 @@ -775,6 +771,3 @@ if (ENABLE_TESTS)
endif()
endif ()

if (TARGET ch_contrib::jwt-cpp)
add_object_library(clickhouse_client_jwt Client/jwt)
endif()
64 changes: 64 additions & 0 deletions src/Client/OAuthJWTProvider.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#include <config.h>

#if USE_JWT_CPP && USE_SSL

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

#include <Poco/Timespan.h>
#include <Poco/Timestamp.h>

#include <iostream>
#include <memory>

namespace DB
{

/// JWTProvider subclass for the credentials-file OIDC path (--login=browser /
/// --login=device). Extends JWTProvider so that Connection::sendQuery can call
/// getJWT() transparently to refresh the id_token before it expires, eliminating
/// the 1-hour session limit that arises when the token is obtained only once at
/// startup.
///
/// getJWT() delegates to obtainIDToken() which already handles the full lifecycle:
/// 1. try cached refresh token from disk
/// 2. run interactive flow (browser or device) if the refresh token is absent
/// or rejected
class OAuthJWTProvider : public JWTProvider
{
public:
OAuthJWTProvider(OAuthCredentials creds, OAuthFlowMode mode)
: JWTProvider("", creds.client_id, "", std::cerr, std::cerr)
, creds_(std::move(creds))
, mode_(mode)
{}

std::string getJWT() override
{
constexpr int EXPIRY_BUFFER_SECONDS = 30;

if (!idp_access_token.empty()
&& Poco::Timestamp() < idp_access_token_expires_at - Poco::Timespan(EXPIRY_BUFFER_SECONDS, 0))
return idp_access_token;

// obtainIDToken tries the disk-cached refresh token first and falls back
// to an interactive flow only when necessary.
idp_access_token = obtainIDToken(creds_, mode_);
idp_access_token_expires_at = getJwtExpiry(idp_access_token);
return idp_access_token;
}

private:
OAuthCredentials creds_;
OAuthFlowMode mode_;
};

std::shared_ptr<JWTProvider> createOAuthJWTProvider(
const OAuthCredentials & creds, OAuthFlowMode mode)
{
return std::make_shared<OAuthJWTProvider>(creds, mode);
}

} // namespace DB

#endif // USE_JWT_CPP && USE_SSL
Loading
Loading