From abb0dd7d8eed595c05452a3e86f8cbd7c8b7ef7e Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Tue, 28 Apr 2026 05:09:58 +0200 Subject: [PATCH 1/2] Configure clickhouse-client OAuth login via connection block + CLI flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the oauth_client.json credentials file with first-class fields in / blocks and matching --oauth-* command-line flags. Both sources merge into the same config layer; CLI wins on conflict (standard --user / --port behavior). Connection block / CLI fields: browser|device|"" --login=browser|device|"" ... --oauth-url=... ... --oauth-client-id=... ... --oauth-audience=... ... --oauth-client-secret=... ... --oauth-callback-port=... Bare (or bare --login) keeps the existing ClickHouse Cloud auto-login path. Explicit browser/device modes require oauth-url + oauth-client-id and run via the OAuthLogin id-token path that PR #1606 introduced for --login=device. Endpoint discovery: when is set the client fetches /.well-known/openid-configuration and fills authorization / token / device_authorization endpoints. Previously OIDC discovery only filled the device endpoint; OAuthProviderPolicy is refactored to share the discovery-document fetch and to publish a public populateEndpointsFromOIDCDiscovery() helper. Browser-flow loopback port: defaults to 0 ("any port"; kernel picks an ephemeral port at flow start). 0 is RFC 8252 §7.3 semantics — only IdPs that honor the §7.3 port-wildcard rule for loopback redirects accept it (currently Google). Auth0 does literal-string match on registered callback URLs and rejects any unregistered port; users with Auth0 must pin a non-zero port and register http://127.0.0.1:/callback. The doc page covers this explicitly. Removed: - loadOAuthCredentials() and oauth_client.json. The Google-format JSON loader is replaced by config/CLI fields plus OIDC discovery; the JSON file is no longer read or referenced anywhere. - --oauth-credentials CLI flag. Same. - gtest_oauth_login.cpp's JSON-loader test cases (kept the PKCE / base64 cases, which test the auth-code flow primitives). Auth-code flow success page is restyled (centered card, light/dark scheme, brand line) so the post-redirect tab is unmistakable instead of `Authentication successful.`. Docs: - New page docs/en/interfaces/cli-oauth-login.md covering modes, per-provider notes (Auth0, Google, Keycloak/Entra), server-side cross-reference to JWT validation, and the device-flow security considerations (RFC 8628 §5.3 Remote Phishing) explicitly noting the flow's suitability for testing/public services and unsuitability for sensitive DBMS access. - cli.md updated to point at the new doc and the obsolete "OAuth credentials file" section is removed. Stateless test 03749_cloud_endpoint_auth_precedence.sh updated for the new error messages. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Boris Tyshkevich --- docs/en/interfaces/cli-oauth-login.md | 200 +++++++++++++ docs/en/interfaces/cli.md | 31 +- programs/client/Client.cpp | 140 +++++---- src/Client/OAuthFlowRunner.cpp | 54 +++- src/Client/OAuthLogin.cpp | 85 ------ src/Client/OAuthLogin.h | 9 +- src/Client/OAuthProviderPolicy.cpp | 29 +- src/Client/OAuthProviderPolicy.h | 5 + src/Client/tests/gtest_oauth_login.cpp | 268 ------------------ .../Config/parseConnectionCredentials.cpp | 12 + .../Config/parseConnectionCredentials.h | 6 + .../03749_cloud_endpoint_auth_precedence.sh | 18 +- 12 files changed, 393 insertions(+), 464 deletions(-) create mode 100644 docs/en/interfaces/cli-oauth-login.md diff --git a/docs/en/interfaces/cli-oauth-login.md b/docs/en/interfaces/cli-oauth-login.md new file mode 100644 index 000000000000..fd4e8eef81ba --- /dev/null +++ b/docs/en/interfaces/cli-oauth-login.md @@ -0,0 +1,200 @@ +--- +description: 'OAuth2 / OpenID Connect login for clickhouse-client' +sidebar_label: 'OAuth login' +sidebar_position: 18 +slug: /interfaces/cli-oauth-login +--- + +# OAuth login for `clickhouse-client` + +`clickhouse-client` can authenticate to a server by obtaining an OpenID Connect ID token from a third-party identity provider (IdP) and presenting it as a JWT. Every setting can be supplied either as a `` field in `~/.clickhouse-client/config.xml` or as the equivalent CLI flag — flags win on conflict. The connection block is the recommended home for stable per-server settings; CLI flags are for one-off overrides. + +| XML field | CLI flag | +|---|---| +| `` | `--login[=browser\|device]` | +| `` | `--oauth-url` | +| `` | `--oauth-client-id` | +| `` | `--oauth-audience` | +| `` | `--oauth-client-secret` | +| `` | `--oauth-callback-port` | + +## Configuration model + +Inside ``, a single `` holds both the server endpoint and its OAuth settings: + +```xml + + + my-server + db.example.com + 9440 + 1 + + browser + https://idp.example.com + YOUR_CLIENT_ID + https://db.example.com/ + ... + 49152 + + +``` + +Then: + +```bash +clickhouse-client --connection my-server -q "SELECT 1" +``` + +| Field | Required | Description | +|---|---|---| +| `` | yes | `browser` (auth-code + PKCE), `device` (device flow), or empty (ClickHouse Cloud auto-login). | +| `` | for `browser` / `device` | OIDC issuer URL. Endpoints (`authorization_endpoint`, `token_endpoint`, `device_authorization_endpoint`) are fetched from `/.well-known/openid-configuration`. | +| `` | for `browser` / `device` | OAuth 2.0 client ID registered with the IdP. | +| `` | depends on IdP | Required when the IdP's access token would otherwise be opaque (Auth0). For pure id-token flows it can be omitted. | +| `` | rarely | Confidential-client secret. Omit for native/public clients (RFC 8252 §8.4) — PKCE and the device code provide client-binding. Sending an empty secret is rejected by Auth0/Entra ID/Keycloak/Okta as `invalid_client`. Some Google "Desktop app" registrations issue a secret that is not really secret; include it only if the IdP requires it. | +| `` | only for `browser` against IdPs that don't honor RFC 8252 §7.3 | Pin the loopback port the auth-code flow's local HTTP server binds to. The value `0` is reserved for **"any port"** semantics (kernel-picks an ephemeral port at flow start) and is the only value valid against RFC 8252 §7.3-compliant IdPs — currently that means Google. For Auth0 (and any other IdP that does literal-string match on the registered callback URL) you **must** pin a non-zero port and register `http://127.0.0.1:/callback` in the IdP's allowed callback URLs. Default: `0`. | + +After a successful login the refresh token is cached in `~/.clickhouse-client/oauth_cache.json` (file mode `0600`, keyed by `client_id`). Subsequent invocations reuse the cached token silently and only open the browser or print a device code when the refresh token has expired or been revoked. + +## Modes + +### `browser` — Authorization Code + PKCE + +The client starts a one-shot HTTP server on `127.0.0.1`, opens your browser at `http://127.0.0.1:/start`, which 302-redirects to the IdP's authorization endpoint with a fresh PKCE challenge and CSRF state. The user authenticates in the browser; the IdP redirects back to `http://127.0.0.1:/callback?code=...`; the client exchanges the code for an id_token at the IdP's token endpoint. The local server shuts down immediately after. + +The redirect URI you must register in the IdP is exactly: + +``` +http://127.0.0.1/callback # or http://127.0.0.1:/callback +``` + +PKCE state and the CSRF parameter are kept off the launcher's argv via the `/start` indirection — the browser sees only `http://127.0.0.1:/start` on its command line, never the full auth URL. + +### `device` — Device Authorization Grant (RFC 8628) + +The client requests a `device_code`/`user_code` pair from the IdP, prints them to the terminal, and polls the token endpoint until the user approves on a separate device. No callback URL or browser process on the client machine is required, so this is the correct mode in the absence of a desktop session (SSH-only servers, terminal-only environments, headless CI). + +``` +To authenticate, visit: + https://idp.example.com/activate +And enter code: ABCD-EFGH + +Waiting for authorization (this code expires in 900 seconds)... +Authentication successful. +``` + +### Bare `` (or `--login` on the CLI) — ClickHouse Cloud auto-login + +When the `` and `` fields are absent, the client defers to the cloud-only auto-detect path: the provider is inferred from the server (it must be a `*.clickhouse.cloud` / `*.clickhouse-staging.com` / `*.clickhouse-dev.com` endpoint, otherwise the client errors). This is the only OAuth form supported on the command line via plain `--login`. + +## Provider-specific notes + +### Auth0 + +| Setting | Value | +|---|---| +| Application Type | **Native** (required so Auth0 returns `device_authorization_endpoint` from OIDC discovery and so `audience` is honored) | +| `` | `https://YOUR_TENANT.auth0.com` (no trailing slash — the client appends paths) | +| `` | The exact API identifier registered in **Applications → APIs**, including any trailing slash. Auth0 matches the audience as a literal string, so `https://api.example.com/` and `https://api.example.com` are different. | +| `` | Omit. Native clients are public; sending a secret causes `invalid_client`. | +| `` | **Required for `browser`.** Auth0 [does not implement port wildcarding for loopback redirects](https://community.auth0.com/t/allow-wildcard-port-in-redirect-uri-as-per-rfc-8252/98409) (RFC 8252 §7.3), so you must pin a port and register the exact `http://127.0.0.1:/callback` in **Allowed Callback URLs**. Not needed for `device`. | + +### Google + +| Setting | Value | +|---|---| +| Application Type | **Desktop app** in Google Cloud Console → APIs & Services → Credentials | +| `` | `https://accounts.google.com` | +| `` | The "Client ID" from the Desktop app credentials | +| `` | The "Client secret" — Google's Desktop apps issue one, and the token endpoint requires it even though it is not secret in the cryptographic sense. This is a long-standing Google quirk; include it. | +| `` | Omit. Google id-tokens carry `aud = client_id`; ClickHouse's JWT validator should verify against the client_id. | +| Callback URL registration | None per port. Google honors RFC 8252 §7.3 and accepts any loopback port for Desktop apps. | + +### Keycloak / Entra ID / generic OIDC + +| Setting | Value | +|---|---| +| `` | The issuer URL exactly as it appears in the discovery document's `issuer` field | +| `` | Omit for clients registered as `public`, include only if the realm/app explicitly requires `client_authentication` | +| Discovery requirements | The discovery document at `/.well-known/openid-configuration` must publish `authorization_endpoint`, `token_endpoint`, and `device_authorization_endpoint`. Keycloak and Entra ID publish all three by default. | + +## Server-side configuration + +OAuth login on the client only obtains an id_token; the server still has to be configured to accept JWTs from your IdP. See [external authenticators – JWT](../operations/external-authenticators/jwt.md) for the server-side JWKS/audience/issuer configuration. The client's `` must match the audience the server validates against. + +## Security considerations + +### When to use which mode + +* **`browser`** is the right default for interactive desktop use. PKCE binds the auth code to the originating client without requiring a secret, and the loopback redirect keeps the code off any external network. +* **`device`** is the right choice for terminal-only sessions (SSH, containers, CI workers with a human operator on a different device). The user_code is short and presented out-of-band, so phishing risk is bounded by the IdP's own consent page. + +### Device flow risk for DBMS access + +The Device Authorization Grant trades a redirect URI for a short, transcribable user_code. That tradeoff is appropriate for read-only consumer surfaces (TVs, set-top boxes — the original RFC 8628 use case) but **deserves explicit threat modeling before being enabled against a database**. The risk is not theoretical — it is documented in the protocol specification itself: + +> **RFC 8628 §5.3 Remote Phishing.** *It is possible for the device flow to be initiated on a device in an attacker's possession. […] To mitigate such an attack, it is RECOMMENDED to inform the user that they are authorizing a device during the user-interaction step (see Section 3.3) and to confirm that the device is in their possession.* +> — [RFC 8628: OAuth 2.0 Device Authorization Grant, §5.3](https://www.rfc-editor.org/rfc/rfc8628.html#section-5.3) + +See also [§5.4 Session Spying](https://www.rfc-editor.org/rfc/rfc8628.html#section-5.4) and [§5.5 Non-Confidential Clients](https://www.rfc-editor.org/rfc/rfc8628.html#section-5.5). + +In practice this gives three concrete failure modes for DBMS access: + +1. **No origin binding.** Unlike the auth-code flow, the device flow has nothing tying the approval to the device that initiated it. Anyone in possession of the user_code who can reach the IdP's verification page can approve the session. +2. **Phishable by construction (RFC 8628 §5.3).** A polling client cannot distinguish a legitimately-obtained user_code from one solicited by an attacker who tricked the victim into running a separate `clickhouse-client --login=device` instance. The well-known "consent-phishing" pattern: attacker initiates the device flow against a permissive client, sends the verification URL/code to the victim by chat or email under any pretext, and receives a token bound to the victim's identity once they approve. The RFC's §5.3 mitigation (the IdP's consent page must show what is being authorized) is only as strong as the IdP's consent UI and the user's attention to it — it does not eliminate the risk, only narrows it. +3. **Internal services.** The risk surface is narrow when the audience is an internal database that already requires VPN or zero-trust network access — the attacker would need both a phished consent and network reach. It widens dramatically for databases exposed on the public internet. + +**Recommended posture:** + +* For production, internet-facing, or otherwise sensitive ClickHouse deployments, use `browser` and disable the device grant on the IdP for the relevant client (Auth0: Application → Advanced Settings → Grant Types → uncheck Device Code; Keycloak: per-client capability config). +* For testing, public datasets, or clusters reachable only from a hardened operational network, `device` is acceptable. +* Never enable the device grant for a client whose tokens grant write or DDL privileges on a production DBMS without compensating controls (short token TTL, MFA on the IdP, alerting on first-use of a device-flow token, network-level access policy). + +The `Authentication successful.` line in your terminal is not a substitute for verifying that the consent screen the operator approved was the one this `clickhouse-client` invocation initiated. + +### Refresh tokens at rest + +`~/.clickhouse-client/oauth_cache.json` is created mode `0600` and keyed by SHA-256 of `client_id`. Anyone who can read the file as your UID can resume a session until the IdP revokes the refresh token. Treat the file as the equivalent of a long-lived password and exclude it from backups, dotfile sync, and shared-host filesystems. + +## Example: full `~/.clickhouse-client/config.xml` + +```xml + + + + + analytics + analytics.example.com + 9440 + 1 + browser + https://example.auth0.com + abc123 + https://analytics.example.com/ + 49152 + + + + + warehouse-ci + warehouse.example.com + 9440 + 1 + device + https://accounts.google.com + 123456-abc.apps.googleusercontent.com + GOCSPX-... + + + + + cloud + tenant.us-east-1.aws.clickhouse.cloud + 9440 + 1 + + + + +``` diff --git a/docs/en/interfaces/cli.md b/docs/en/interfaces/cli.md index bfbe5dfbc07c..7b7438b55381 100644 --- a/docs/en/interfaces/cli.md +++ b/docs/en/interfaces/cli.md @@ -836,8 +836,7 @@ 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. | - | -| `--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` | +| `--login[=]` | Authenticate via OAuth2. Bare `--login` triggers ClickHouse Cloud auto-login. `--login=browser` or `--login=device` runs the Authorization Code or Device flow against any OpenID Connect provider; pair with `--oauth-url`, `--oauth-client-id`, and (per IdP) `--oauth-audience`, `--oauth-client-secret`, `--oauth-callback-port`. The same fields can be set in the connection block — see [OAuth login](./cli-oauth-login.md). | - | | `--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 ` | 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. | - | @@ -852,34 +851,6 @@ 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 `. - -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 | diff --git a/programs/client/Client.cpp b/programs/client/Client.cpp index e8b22071f43f..3c5c90b8c7fc 100644 --- a/programs/client/Client.cpp +++ b/programs/client/Client.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include @@ -274,6 +275,16 @@ void Client::initialize(Poco::Util::Application & self) configuration.setBool("accept-invalid-certificate", overrides.accept_invalid_certificate.value()); if (overrides.prompt.has_value()) configuration.setString("prompt", overrides.prompt.value()); + if (overrides.login.has_value()) + configuration.setString("login", overrides.login.value()); + if (overrides.oauth_url.has_value()) + configuration.setString("oauth-url", overrides.oauth_url.value()); + if (overrides.oauth_client_id.has_value()) + configuration.setString("oauth-client-id", overrides.oauth_client_id.value()); + if (overrides.oauth_audience.has_value()) + configuration.setString("oauth-audience", overrides.oauth_audience.value()); + if (overrides.oauth_client_secret.has_value()) + configuration.setString("oauth-client-secret", overrides.oauth_client_secret.value()); config().add(loaded_config.configuration); @@ -290,6 +301,57 @@ void Client::initialize(Poco::Util::Application & self) else if (config().has("connection")) throw Exception(ErrorCodes::BAD_ARGUMENTS, "--connection was specified, but config does not exist"); +#if USE_JWT_CPP && USE_SSL + /// OAuth login: unified across CLI flags and connection-block + /// fields. Both sources are merged into config() by this point (CLI in the + /// top layer, connection block in the file-loaded layer with lower + /// priority), so reading config() gives CLI > connection-block precedence. + if (config().has("login") && !config().has("jwt") && !config().getBool("cloud_oauth_pending", false)) + { + const std::string login_mode = config().getString("login"); + 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); + + const bool have_endpoints + = config().has("oauth-url") + && config().has("oauth-client-id"); + + if (login_mode.empty() && !have_endpoints) + { + /// Bare login (CLI --login or empty ) → cloud auto-detect. + config().setBool("cloud_oauth_pending", true); + config().setString("user", ""); + } + else if (have_endpoints) + { + OAuthCredentials creds; + creds.client_id = config().getString("oauth-client-id"); + creds.issuer = config().getString("oauth-url"); + if (config().has("oauth-client-secret")) + creds.client_secret = config().getString("oauth-client-secret"); + if (config().has("oauth-callback-port")) + creds.loopback_port = static_cast(config().getUInt("oauth-callback-port")); + populateEndpointsFromOIDCDiscovery(creds); + + const auto mode = (login_mode == "browser") ? OAuthFlowMode::AuthCode : OAuthFlowMode::Device; + jwt_provider = createOAuthJWTProvider(creds, mode); + config().setString("jwt", jwt_provider->getJWT()); + config().setString("user", ""); + } + else + { + throw Exception( + ErrorCodes::BAD_ARGUMENTS, + "login mode '{}' requires oauth-url and oauth-client-id (set via --oauth-url / " + "--oauth-client-id or / in the connection block)", + login_mode); + } + } +#endif + if (config().has("accept-invalid-certificate")) { config().setString("openSSL.client.invalidCertificateHandler.name", "AcceptCertificateHandler"); @@ -730,17 +792,16 @@ void Client::addExtraOptions(OptionsDescription & options_description) ("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") ("login", po::value()->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(), - "Path to OAuth credentials JSON file " - "(default: ~/.clickhouse-client/oauth_client.json)") + "Authenticate via OAuth2. Bare --login (no =mode) uses the ClickHouse Cloud auto-login path. " + "--login=browser runs the Authorization Code + PKCE flow, --login=device runs the Device " + "Authorization flow. The non-bare modes require --oauth-url and --oauth-client-id (or the " + "equivalent fields in the connection block).") #if USE_JWT_CPP && USE_SSL - ("oauth-url", po::value(), "The base URL for the OAuth 2.0 authorization server") - ("oauth-client-id", po::value(), "The client ID for the OAuth 2.0 application") - ("oauth-audience", po::value(), "The audience for the OAuth 2.0 token") + ("oauth-url", po::value(), "Base URL of the OAuth 2.0 authorization server (OIDC issuer)") + ("oauth-client-id", po::value(), "Client ID for the OAuth 2.0 application") + ("oauth-audience", po::value(), "Audience for the OAuth 2.0 access token") + ("oauth-client-secret", po::value(), "Client secret. Omit for native/public clients (RFC 8252 §8.4); include for IdPs that require it (Google Desktop apps).") + ("oauth-callback-port", po::value(), "Pin the loopback port for the auth-code flow (browser mode). Default 0 = kernel-picks. Required for IdPs that don't support port wildcarding for loopback redirects (Auth0).") #endif ("max_client_network_bandwidth", po::value(), @@ -900,19 +961,8 @@ void Client::processOptions( config().setString("jwt", options["jwt"].as()); config().setString("user", ""); } - 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")) { - /// 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 element in - /// ~/.clickhouse-client/config.xml is loaded into config() at startup. if (config().has("jwt")) throw Exception( ErrorCodes::BAD_ARGUMENTS, @@ -922,50 +972,18 @@ void Client::processOptions( if (!login_mode.empty() && login_mode != "browser" && login_mode != "device") throw Exception( ErrorCodes::BAD_ARGUMENTS, - "--login value must be 'browser' or 'device', got '{}'", + "--login value must be 'browser' or 'device' (or empty for ClickHouse Cloud auto-login), 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() - : 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", ""); - } + /// Stash the mode in config(); the OAuth flow itself runs later in + /// initialize(), after the connection block has been layered in. CLI + /// values override connection-block values via Poco's layer priority. + config().setString("login", login_mode); + config().setString("user", ""); #else throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "OAuth login requires a build with JWT and SSL support"); #endif @@ -977,6 +995,10 @@ void Client::processOptions( config().setString("oauth-client-id", options["oauth-client-id"].as()); if (options.contains("oauth-audience")) config().setString("oauth-audience", options["oauth-audience"].as()); + if (options.contains("oauth-client-secret")) + config().setString("oauth-client-secret", options["oauth-client-secret"].as()); + if (options.contains("oauth-callback-port")) + config().setUInt("oauth-callback-port", options["oauth-callback-port"].as()); #endif if (options.contains("accept-invalid-certificate")) { diff --git a/src/Client/OAuthFlowRunner.cpp b/src/Client/OAuthFlowRunner.cpp index ec9d673c2004..99eb38b18f53 100644 --- a/src/Client/OAuthFlowRunner.cpp +++ b/src/Client/OAuthFlowRunner.cpp @@ -306,12 +306,56 @@ class AuthCodeHandler : public Poco::Net::HTTPRequestHandler } response.setStatus(Poco::Net::HTTPResponse::HTTP_OK); - response.setContentType("text/html"); + response.setContentType("text/html; charset=utf-8"); auto & out = response.send(); - if (!code.empty()) - out << "Authentication successful. You may close this tab."; + const bool ok = !code.empty(); + out << R"HTML( + +)HTML" << (ok ? "Authenticated" : "Authentication failed") << R"HTML( + +
+)HTML"; + if (ok) + { + out << R"HTML(
A
+

Authenticated

+

You can close this tab and return to your terminal.

+)HTML"; + } else - out << "Authentication failed: " << htmlEscape(error) << ""; + { + out << R"HTML(
!
+

Authentication failed

+

)HTML" << htmlEscape(error) << R"HTML(

+

Return to your terminal for details.

+)HTML"; + } + out << R"HTML(
Antalya ClickHouse client
+
)HTML"; out.flush(); std::lock_guard lock(state.mtx); @@ -431,7 +475,7 @@ std::string runOAuthAuthCodeFlow(const OAuthCredentials & creds) } Poco::Net::ServerSocket server_socket; - server_socket.bind(Poco::Net::SocketAddress("127.0.0.1", 0), /*reuse_address=*/true); + server_socket.bind(Poco::Net::SocketAddress("127.0.0.1", creds.loopback_port), /*reuse_address=*/true); server_socket.listen(1); const uint16_t port = server_socket.address().port(); diff --git a/src/Client/OAuthLogin.cpp b/src/Client/OAuthLogin.cpp index d95dc0c00917..34d96f46a024 100644 --- a/src/Client/OAuthLogin.cpp +++ b/src/Client/OAuthLogin.cpp @@ -346,91 +346,6 @@ std::string tryRefreshToken(const OAuthCredentials & creds, const std::string & } -OAuthCredentials loadOAuthCredentials(const std::string & path) -{ - std::ifstream f(path); - if (!f.is_open()) - throw Exception( - ErrorCodes::BAD_ARGUMENTS, - "OAuth credentials file not found: '{}'\n" - "Place a Google-format credentials JSON at that path, or specify " - "--oauth-credentials /path/to/file.json", - path); - - std::string content((std::istreambuf_iterator(f)), std::istreambuf_iterator()); - - Poco::JSON::Parser parser; - Poco::Dynamic::Var parsed; - try - { - parsed = parser.parse(content); - } - catch (const std::exception & e) - { - throw Exception(ErrorCodes::BAD_ARGUMENTS, "Failed to parse OAuth credentials file '{}': {}", path, e.what()); - } - - auto root = parsed.extract(); - - Poco::JSON::Object::Ptr app; - if (root->has("installed")) - app = root->getObject("installed"); - else if (root->has("web")) - app = root->getObject("web"); - else - throw Exception( - ErrorCodes::BAD_ARGUMENTS, - "OAuth credentials file '{}' must have an 'installed' or 'web' top-level key", - path); - - auto require = [&](const std::string & key) -> std::string - { - if (!app->has(key)) - throw Exception( - ErrorCodes::BAD_ARGUMENTS, - "OAuth credentials file '{}' is missing required field '{}'", - path, - key); - return app->getValue(key); - }; - - OAuthCredentials creds; - creds.client_id = require("client_id"); - creds.auth_uri = require("auth_uri"); - creds.token_uri = require("token_uri"); - - /// client_secret is optional: per RFC 6749 §2.1 / RFC 8252 §8.4, native - /// OIDC clients are typically registered as "public" and have no secret; - /// PKCE (always used in the auth-code flow here) and the device_code - /// itself (in the device flow) provide the client-binding guarantee that a - /// secret would otherwise carry. An absent or empty value here causes the - /// downstream POST bodies to omit the client_secret form parameter - /// entirely — sending it with an empty value is treated by Auth0, Entra - /// ID, Keycloak and others as a malformed confidential-client credential - /// and rejected with invalid_client, so omission is required, not just - /// preferred. - if (app->has("client_secret")) - creds.client_secret = app->getValue("client_secret"); - - if (app->has("device_authorization_uri")) - creds.device_auth_uri = app->getValue("device_authorization_uri"); - if (app->has("issuer")) - creds.issuer = app->getValue("issuer"); - - auto warn_if_http = [&](const std::string & field, const std::string & uri) - { - if (uri.starts_with("http://")) - std::cerr << "Warning: OAuth credentials field '" << field << "' uses plain HTTP ('" - << uri << "'). Token exchanges over HTTP expose client credentials.\n"; - }; - warn_if_http("token_uri", creds.token_uri); - warn_if_http("auth_uri", creds.auth_uri); - if (!creds.device_auth_uri.empty()) - warn_if_http("device_authorization_uri", creds.device_auth_uri); - - return creds; -} - std::string obtainIDToken(const OAuthCredentials & creds, OAuthFlowMode mode) { const std::string cached_refresh = readCachedRefreshTokenImpl(creds.client_id); diff --git a/src/Client/OAuthLogin.h b/src/Client/OAuthLogin.h index 600e577fa8a4..11b332285873 100644 --- a/src/Client/OAuthLogin.h +++ b/src/Client/OAuthLogin.h @@ -23,12 +23,13 @@ struct OAuthCredentials std::string token_uri; // token_endpoint std::string device_auth_uri; // device_authorization_endpoint (discovered if empty) std::string issuer; // OIDC issuer URL (optional; used to locate discovery document) + /// Loopback port for the auth-code flow's redirect_uri. 0 = let the kernel + /// pick (RFC 8252 §7.3 compliant; works with Google). Pin to a fixed value + /// for IdPs that don't accept arbitrary ports (Auth0) and register + /// http://127.0.0.1:/callback in the IdP's allowed callbacks. + uint16_t loopback_port = 0; }; -/// Load from Google-format JSON credentials file. -/// Throws if file not found or malformed. -OAuthCredentials loadOAuthCredentials(const std::string & path); - /// Run OAuth flow, return ID token. Throws on failure. std::string obtainIDToken(const OAuthCredentials & creds, OAuthFlowMode mode); diff --git a/src/Client/OAuthProviderPolicy.cpp b/src/Client/OAuthProviderPolicy.cpp index 1bf589e95912..4d45a5b095d9 100644 --- a/src/Client/OAuthProviderPolicy.cpp +++ b/src/Client/OAuthProviderPolicy.cpp @@ -29,7 +29,7 @@ namespace constexpr int HTTP_TIMEOUT_SECONDS = 30; -std::string fetchDeviceEndpointFromIssuer(const std::string & issuer) +Poco::JSON::Object::Ptr fetchOIDCDiscoveryDocument(const std::string & issuer) { const std::string discovery_url = issuer + "/.well-known/openid-configuration"; Poco::URI disc_uri(discovery_url); @@ -66,13 +66,18 @@ std::string fetchDeviceEndpointFromIssuer(const std::string & issuer) Poco::JSON::Parser parser; auto result = parser.parse(body); - const auto & obj = result.extract(); + return result.extract(); +} + +std::string fetchDeviceEndpointFromIssuer(const std::string & issuer) +{ + const auto obj = fetchOIDCDiscoveryDocument(issuer); if (!obj->has("device_authorization_endpoint")) throw Exception( ErrorCodes::AUTHENTICATION_FAILED, - "OIDC discovery document at '{}' does not contain device_authorization_endpoint", - discovery_url); + "OIDC discovery document at '{}/.well-known/openid-configuration' does not contain device_authorization_endpoint", + issuer); return obj->getValue("device_authorization_endpoint"); } @@ -97,6 +102,22 @@ std::string inferIssuerFromTokenUri(const std::string & token_uri) } +void populateEndpointsFromOIDCDiscovery(OAuthCredentials & creds) +{ + if (creds.issuer.empty()) + throw Exception( + ErrorCodes::AUTHENTICATION_FAILED, + "Cannot populate OAuth endpoints from OIDC discovery: issuer is not set"); + + const auto obj = fetchOIDCDiscoveryDocument(creds.issuer); + if (creds.auth_uri.empty() && obj->has("authorization_endpoint")) + creds.auth_uri = obj->getValue("authorization_endpoint"); + if (creds.token_uri.empty() && obj->has("token_endpoint")) + creds.token_uri = obj->getValue("token_endpoint"); + if (creds.device_auth_uri.empty() && obj->has("device_authorization_endpoint")) + creds.device_auth_uri = obj->getValue("device_authorization_endpoint"); +} + std::unique_ptr IOAuthProviderPolicy::create(const OAuthCredentials & creds) { if (GoogleOAuthProviderPolicy::matches(creds)) diff --git a/src/Client/OAuthProviderPolicy.h b/src/Client/OAuthProviderPolicy.h index cbfb04e802c1..ca1e89d23495 100644 --- a/src/Client/OAuthProviderPolicy.h +++ b/src/Client/OAuthProviderPolicy.h @@ -29,6 +29,11 @@ class IOAuthProviderPolicy static std::unique_ptr create(const OAuthCredentials & creds); }; +/// Populate auth_uri / token_uri / device_auth_uri on an OAuthCredentials by +/// fetching the OIDC discovery document at /.well-known/openid-configuration. +/// `creds.issuer` must already be set; client_id / client_secret are not touched. +void populateEndpointsFromOIDCDiscovery(OAuthCredentials & creds); + class GoogleOAuthProviderPolicy final : public IOAuthProviderPolicy { public: diff --git a/src/Client/tests/gtest_oauth_login.cpp b/src/Client/tests/gtest_oauth_login.cpp index 2efcbc17aff8..504a35b5f5c7 100644 --- a/src/Client/tests/gtest_oauth_login.cpp +++ b/src/Client/tests/gtest_oauth_login.cpp @@ -4,281 +4,13 @@ #include #include -#include #include -#include #include #include -#include -#include using namespace DB; -namespace -{ - -namespace fs = std::filesystem; - -/// Write content to a temp file and return its path. The caller owns the file. -std::string writeTempFile(const std::string & content) -{ - const fs::path tmp = fs::temp_directory_path() / fs::path("gtest_oauth_XXXXXX"); - // std::tmpnam is deprecated — build a unique name with mkstemp. - std::string tmpl = tmp.string(); - int fd = mkstemp(tmpl.data()); - if (fd < 0) - throw std::runtime_error("mkstemp failed"); - close(fd); - - std::ofstream f(tmpl, std::ios::trunc); - f << content; - return tmpl; -} - -} // anonymous namespace - -// --------------------------------------------------------------------------- -// loadOAuthCredentials — valid "installed" format -// --------------------------------------------------------------------------- - -TEST(OAuthLogin, LoadInstalledFormat) -{ - const std::string json = R"({ - "installed": { - "client_id": "test-client-id", - "client_secret": "test-secret", - "auth_uri": "https://auth.example.com/auth", - "token_uri": "https://auth.example.com/token", - "redirect_uris": ["http://localhost"] - } - })"; - - auto path = writeTempFile(json); - SCOPE_EXIT({ fs::remove(path); }); - - auto creds = loadOAuthCredentials(path); - EXPECT_EQ(creds.client_id, "test-client-id"); - EXPECT_EQ(creds.client_secret, "test-secret"); - EXPECT_EQ(creds.auth_uri, "https://auth.example.com/auth"); - EXPECT_EQ(creds.token_uri, "https://auth.example.com/token"); - EXPECT_TRUE(creds.device_auth_uri.empty()); -} - -// --------------------------------------------------------------------------- -// loadOAuthCredentials — valid "web" format -// --------------------------------------------------------------------------- - -TEST(OAuthLogin, LoadWebFormat) -{ - const std::string json = R"({ - "web": { - "client_id": "web-client", - "client_secret": "web-secret", - "auth_uri": "https://web.example.com/auth", - "token_uri": "https://web.example.com/token" - } - })"; - - auto path = writeTempFile(json); - SCOPE_EXIT({ fs::remove(path); }); - - auto creds = loadOAuthCredentials(path); - EXPECT_EQ(creds.client_id, "web-client"); - EXPECT_EQ(creds.client_secret, "web-secret"); -} - -// --------------------------------------------------------------------------- -// loadOAuthCredentials — optional device_authorization_uri is loaded -// --------------------------------------------------------------------------- - -TEST(OAuthLogin, LoadDeviceAuthUri) -{ - const std::string json = R"({ - "installed": { - "client_id": "x", - "client_secret": "y", - "auth_uri": "https://a.example.com/auth", - "token_uri": "https://a.example.com/token", - "device_authorization_uri": "https://a.example.com/device" - } - })"; - - auto path = writeTempFile(json); - SCOPE_EXIT({ fs::remove(path); }); - - auto creds = loadOAuthCredentials(path); - EXPECT_EQ(creds.device_auth_uri, "https://a.example.com/device"); -} - -// --------------------------------------------------------------------------- -// loadOAuthCredentials — missing top-level key throws BAD_ARGUMENTS -// --------------------------------------------------------------------------- - -TEST(OAuthLogin, MissingTopLevelKey) -{ - const std::string json = R"({ "other_key": {} })"; - - auto path = writeTempFile(json); - SCOPE_EXIT({ fs::remove(path); }); - - EXPECT_THROW(loadOAuthCredentials(path), Exception); -} - -// --------------------------------------------------------------------------- -// loadOAuthCredentials — public-client config (no client_secret) loads OK -// -// Per RFC 6749 §2.1 / RFC 8252 §8.4 native OIDC clients are typically -// registered as public clients with no secret; the flow is protected by PKCE -// (auth-code) or the device_code (device flow). The credential loader must -// not hard-require client_secret, otherwise valid public-client registrations -// cannot be used. This is the regression guard for that policy: the absence -// of the field is silently accepted, and the in-memory secret stays empty so -// the downstream POST builders omit the parameter rather than sending an -// empty value (which several IdPs reject as invalid_client). -// --------------------------------------------------------------------------- - -TEST(OAuthLogin, LoadPublicClientNoSecret) -{ - const std::string json = R"({ - "installed": { - "client_id": "public-client-id", - "auth_uri": "https://auth.example.com/auth", - "token_uri": "https://auth.example.com/token" - } - })"; - - auto path = writeTempFile(json); - SCOPE_EXIT({ fs::remove(path); }); - - auto creds = loadOAuthCredentials(path); - EXPECT_EQ(creds.client_id, "public-client-id"); - EXPECT_TRUE(creds.client_secret.empty()); - EXPECT_EQ(creds.auth_uri, "https://auth.example.com/auth"); - EXPECT_EQ(creds.token_uri, "https://auth.example.com/token"); -} - -// Empty-string client_secret is treated identically to an absent field: load -// succeeds and the in-memory value is empty, so the downstream POST bodies -// omit the form parameter. Without this property a credential file written -// by a tool that defaults the field to "" would produce invalid_client at -// the IdP rather than a working public-client request. -TEST(OAuthLogin, LoadPublicClientEmptySecret) -{ - const std::string json = R"({ - "installed": { - "client_id": "public-client-id", - "client_secret": "", - "auth_uri": "https://auth.example.com/auth", - "token_uri": "https://auth.example.com/token" - } - })"; - - auto path = writeTempFile(json); - SCOPE_EXIT({ fs::remove(path); }); - - auto creds = loadOAuthCredentials(path); - EXPECT_TRUE(creds.client_secret.empty()); -} - -// --------------------------------------------------------------------------- -// loadOAuthCredentials — missing required field throws BAD_ARGUMENTS -// --------------------------------------------------------------------------- - -TEST(OAuthLogin, MissingClientId) -{ - const std::string json = R"({ - "installed": { - "client_secret": "s", - "auth_uri": "https://a.example.com/auth", - "token_uri": "https://a.example.com/token" - } - })"; - - auto path = writeTempFile(json); - SCOPE_EXIT({ fs::remove(path); }); - - EXPECT_THROW(loadOAuthCredentials(path), Exception); -} - -TEST(OAuthLogin, MissingTokenUri) -{ - const std::string json = R"({ - "installed": { - "client_id": "c", - "client_secret": "s", - "auth_uri": "https://a.example.com/auth" - } - })"; - - auto path = writeTempFile(json); - SCOPE_EXIT({ fs::remove(path); }); - - EXPECT_THROW(loadOAuthCredentials(path), Exception); -} - -// --------------------------------------------------------------------------- -// loadOAuthCredentials — file not found throws BAD_ARGUMENTS -// --------------------------------------------------------------------------- - -TEST(OAuthLogin, FileNotFound) -{ - EXPECT_THROW(loadOAuthCredentials("/nonexistent/path/oauth_client.json"), Exception); -} - -// --------------------------------------------------------------------------- -// loadOAuthCredentials — invalid JSON throws BAD_ARGUMENTS -// --------------------------------------------------------------------------- - -TEST(OAuthLogin, InvalidJson) -{ - auto path = writeTempFile("not valid json {{{"); - SCOPE_EXIT({ fs::remove(path); }); - - EXPECT_THROW(loadOAuthCredentials(path), Exception); -} - -// --------------------------------------------------------------------------- -// loadOAuthCredentials — optional "issuer" field is loaded -// --------------------------------------------------------------------------- - -TEST(OAuthLogin, LoadIssuerField) -{ - const std::string json = R"({ - "installed": { - "client_id": "x", - "client_secret": "y", - "auth_uri": "https://a.example.com/auth", - "token_uri": "https://a.example.com/token", - "issuer": "https://a.example.com" - } - })"; - - auto path = writeTempFile(json); - SCOPE_EXIT({ fs::remove(path); }); - - auto creds = loadOAuthCredentials(path); - EXPECT_EQ(creds.issuer, "https://a.example.com"); -} - -TEST(OAuthLogin, IssuerFieldAbsent) -{ - const std::string json = R"({ - "installed": { - "client_id": "x", - "client_secret": "y", - "auth_uri": "https://a.example.com/auth", - "token_uri": "https://a.example.com/token" - } - })"; - - auto path = writeTempFile(json); - SCOPE_EXIT({ fs::remove(path); }); - - auto creds = loadOAuthCredentials(path); - EXPECT_TRUE(creds.issuer.empty()); -} - // --------------------------------------------------------------------------- // PKCE building blocks // diff --git a/src/Common/Config/parseConnectionCredentials.cpp b/src/Common/Config/parseConnectionCredentials.cpp index 8e575de59b1f..9b3cef7b16e6 100644 --- a/src/Common/Config/parseConnectionCredentials.cpp +++ b/src/Common/Config/parseConnectionCredentials.cpp @@ -54,6 +54,18 @@ ConnectionsCredentials parseConnectionsCredentials(const Poco::Util::AbstractCon res.accept_invalid_certificate.emplace(config.getBool(prefix + ".accept-invalid-certificate")); if (config.has(prefix + ".prompt")) res.prompt.emplace(config.getString(prefix + ".prompt")); + if (config.has(prefix + ".login")) + res.login.emplace(config.getString(prefix + ".login")); + if (config.has(prefix + ".oauth-url")) + res.oauth_url.emplace(config.getString(prefix + ".oauth-url")); + if (config.has(prefix + ".oauth-client-id")) + res.oauth_client_id.emplace(config.getString(prefix + ".oauth-client-id")); + if (config.has(prefix + ".oauth-audience")) + res.oauth_audience.emplace(config.getString(prefix + ".oauth-audience")); + if (config.has(prefix + ".oauth-client-secret")) + res.oauth_client_secret.emplace(config.getString(prefix + ".oauth-client-secret")); + if (config.has(prefix + ".oauth-callback-port")) + res.oauth_callback_port.emplace(static_cast(config.getUInt(prefix + ".oauth-callback-port"))); } if (connection_name.has_value() && !connection_found) diff --git a/src/Common/Config/parseConnectionCredentials.h b/src/Common/Config/parseConnectionCredentials.h index a85e131c068b..cc97a6150693 100644 --- a/src/Common/Config/parseConnectionCredentials.h +++ b/src/Common/Config/parseConnectionCredentials.h @@ -22,6 +22,12 @@ struct ConnectionsCredentials std::optional history_max_entries; std::optional accept_invalid_certificate; std::optional prompt; + std::optional login; + std::optional oauth_url; + std::optional oauth_client_id; + std::optional oauth_audience; + std::optional oauth_client_secret; + std::optional oauth_callback_port; }; /// Parse section from client config. diff --git a/tests/queries/0_stateless/03749_cloud_endpoint_auth_precedence.sh b/tests/queries/0_stateless/03749_cloud_endpoint_auth_precedence.sh index 5bd5ff300449..3e0c6701b852 100755 --- a/tests/queries/0_stateless/03749_cloud_endpoint_auth_precedence.sh +++ b/tests/queries/0_stateless/03749_cloud_endpoint_auth_precedence.sh @@ -101,19 +101,19 @@ else echo "FAILED: $failed commands failed" fi -# Test 9: --login=device with no credentials file should fail with a clear file-not-found error -# (not a crash or confusing message) -echo "Test 9: --login=device with missing credentials file gives clear error" -MISSING_CREDS="/tmp/nonexistent_oauth_creds_$$.json" -output=$($CLICKHOUSE_CLIENT_BINARY --login=device --oauth-credentials "$MISSING_CREDS" --query "SELECT 1" 2>&1) -if echo "$output" | grep -qi "not found\|No such file\|cannot open\|BAD_ARGUMENTS"; then +# Test 9: --login=device with no OAuth endpoints configured must point the user +# at the missing parameters with a clear BAD_ARGUMENTS message rather than +# crashing or attempting a malformed flow. +echo "Test 9: --login=device without --oauth-url/--oauth-client-id gives clear error" +output=$($CLICKHOUSE_CLIENT_BINARY --login=device --query "SELECT 1" 2>&1) +if echo "$output" | grep -qi "requires.*oauth-url\|BAD_ARGUMENTS"; then echo "OK" else - echo "FAILED: expected file-not-found error, got: $output" + echo "FAILED: expected requires-oauth-url error, got: $output" fi -# Test 10: --login=invalid should give BAD_ARGUMENTS with descriptive message -echo "Test 10: --login=invalid should give BAD_ARGUMENTS" +# Test 10: --login=invalid must be rejected by the mode validator. +echo "Test 10: --login=invalid gives BAD_ARGUMENTS with descriptive message" output=$($CLICKHOUSE_CLIENT_BINARY --login=invalid --host="${CLICKHOUSE_HOST}" --port="${CLICKHOUSE_PORT_TCP}" --query "SELECT 1" 2>&1) if echo "$output" | grep -qi "must be.*browser.*device\|BAD_ARGUMENTS"; then echo "OK" From bcdff2b0762070322e3f657732ae772a35916f0e Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Tue, 28 Apr 2026 05:16:43 +0200 Subject: [PATCH 2/2] Plumb from connection block into config parseConnectionsCredentials parsed the field into ConnectionsCredentials::oauth_callback_port, but Client::initialize forgot to mirror it into config() the way it does for the other fields. The CLI plumbing went through correctly via config().setUInt(...), but the connection-block path silently dropped the value, so the loopback server kept binding port 0 (kernel-picks) and Auth0 rejected the unregistered port. Add the missing configuration.setUInt mirror, parallel to oauth-url / oauth-client-id / oauth-audience / oauth-client-secret. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Boris Tyshkevich --- programs/client/Client.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/programs/client/Client.cpp b/programs/client/Client.cpp index 3c5c90b8c7fc..0fc75f5d26d2 100644 --- a/programs/client/Client.cpp +++ b/programs/client/Client.cpp @@ -285,6 +285,8 @@ void Client::initialize(Poco::Util::Application & self) configuration.setString("oauth-audience", overrides.oauth_audience.value()); if (overrides.oauth_client_secret.has_value()) configuration.setString("oauth-client-secret", overrides.oauth_client_secret.value()); + if (overrides.oauth_callback_port.has_value()) + configuration.setUInt("oauth-callback-port", overrides.oauth_callback_port.value()); config().add(loaded_config.configuration);