From eacf9838e6d78baf5f60c345d7b4759f2496d8a0 Mon Sep 17 00:00:00 2001 From: Andrey Zvonov <32552679+zvonand@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:32:18 +0200 Subject: [PATCH] Merge pull request #2499 from Altinity/improvement/antalya-26.6/oauth-google Enable Google as IdP for token-based authentication --- .../external-authenticators/tokens.md | 48 +++++++++- src/Access/Common/JWKSProvider.cpp | 7 +- src/Access/TokenProcessorsOpaque.cpp | 87 ++++++------------- 3 files changed, 76 insertions(+), 66 deletions(-) diff --git a/docs/concepts/features/security/external-authenticators/tokens.md b/docs/concepts/features/security/external-authenticators/tokens.md index 4bc2151c24986..cf3294822678c 100644 --- a/docs/concepts/features/security/external-authenticators/tokens.md +++ b/docs/concepts/features/security/external-authenticators/tokens.md @@ -29,7 +29,7 @@ To use token-based authentication, add `token_processors` section to `config.xml Its contents are different for different token processor types. **Common parameters** -- `type` -- type of token processor. Supported values: `jwt_static_key`, `jwt_static_jwks`, `jwt_dynamic_jwks`, `entra` (`azure` is accepted as a back-compat alias and resolves to the same `entra` processor — see the [Entra](#entra) section), `openid`. Mandatory. Case-insensitive. +- `type` -- type of token processor. Supported values: `jwt_static_key`, `jwt_static_jwks`, `jwt_dynamic_jwks`, `entra` (`azure` is accepted as a back-compat alias and resolves to the same `entra` processor — see the [Entra](#entra) section), `google`, `openid`. Mandatory. Case-insensitive. - `token_cache_lifetime` -- maximum lifetime of cached token (in seconds). Optional, default: 3600. - `username_claim` -- name of claim (field) that will be treated as ClickHouse username. Optional, default: "sub". - `groups_claim` -- name of claim (field) that contains list of groups user belongs to. This claim will be looked up in the token itself (in case token is a valid JWT, e.g. in Keycloak) or in response from `/userinfo`. Optional, default: "groups". @@ -131,7 +131,7 @@ For JWKS-based validators (`jwt_static_jwks` and `jwt_dynamic_jwks`), RS* and ES ## IdP-specific presets and generic external providers -This section covers two related kinds of processor: per-IdP convenience presets built on top of the generic JWT processors (currently `entra`), and the generic `openid` processor that talks to an arbitrary OIDC-compliant identity provider. +This section covers per-IdP processors (`entra`, built on top of the generic JWT processors, and `google`), and the generic `openid` processor that talks to an arbitrary OIDC-compliant identity provider. :::note If the IdP issues access tokens that follow [RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068) (the *JSON Web Token Profile for OAuth 2.0 Access Tokens*), the access token is itself a verifiable JWT and is best handled by one of the JWT processors above (typically `jwt_dynamic_jwks`) — no `/userinfo` or `/tokeninfo` round-trip is needed. The processors in this section exist for IdPs whose access tokens are opaque (e.g. Google), or whose JWT access tokens you prefer to validate by asking the IdP rather than locally. @@ -225,6 +225,50 @@ Keep GUIDs in the token and translate them in the user-directory config (see [Id When switching from GUIDs to names, retune any `roles_filter` regex — for example `\bclickhouse-[a-zA-Z0-9]+\b` will not match strings like `ch_admin`. ::: +### Google {#google} + +`google` validates Google OAuth 2.0 access tokens. Google access tokens are opaque, so each token is checked against Google's `/tokeninfo` endpoint; `/userinfo` is additionally called only when `username_claim` is not returned by `/tokeninfo`. `expected_audience` and `username_claim` refer to fields of these endpoints' JSON responses, not of the token itself. Clients pass the access token via `--jwt` or the `Authorization: Bearer` header. + +Minimum configuration — no parameters are mandatory, but `expected_audience` should be set: + +```xml + + + + google + 1234567890-abcdef.apps.googleusercontent.com + + + +``` + +**Parameters:** + +- `expected_audience` — OAuth client ID the token must be issued to (the `aud` field returned by `/tokeninfo`). If unset, any valid Google access token is accepted regardless of the client it was issued to; a warning is logged at startup so the gap is visible. +- `username_claim` — Field to use as the ClickHouse username. Default: `sub` (the stable Google account ID). Common alternative: `email` (requires the `email` scope). Other fields (e.g. `hd`, or `name` with the `profile` scope) are read from `/userinfo`, which costs an extra request. +- `token_cache_lifetime` — Same as for other processors. + +#### Mapping groups to ClickHouse roles {#google-group-mapping} + +When the token carries the user's email, the processor looks up the user's direct Google Groups memberships via the Cloud Identity API and returns group email addresses as external groups. Requirements: + +- The token must include the `https://www.googleapis.com/auth/cloud-identity.groups.readonly` scope. +- The Cloud Identity API must be enabled in the Google Cloud project that owns the OAuth client. + +Only direct memberships are returned; groups inherited through nested groups are not. If the lookup fails, the user is still authenticated, without external groups. + +Since group identifiers are email addresses, use `roles_filter` and `roles_transform` to turn them into role names. For example, the following maps `ch-analysts@example.com` to the role `ch-analysts`: + +```xml + + + google_prod + ch-[a-z-]+@example\.com + s/@.*// + + +``` + ### OpenID The `openid` processor speaks the OIDC protocol surface — `/userinfo` for identity, plus (when discovered or configured) the local JWT fast-path against the IdP's JWKS and RFC 7662 token introspection. Two mutually-exclusive configuration shapes: diff --git a/src/Access/Common/JWKSProvider.cpp b/src/Access/Common/JWKSProvider.cpp index 5daa09abb54e6..fbf38f3cac926 100644 --- a/src/Access/Common/JWKSProvider.cpp +++ b/src/Access/Common/JWKSProvider.cpp @@ -84,8 +84,8 @@ JWKSType JWKSClient::getJWKS() session.sendRequest(request); std::istream & response_stream = session.receiveResponse(response); if (response.getStatus() != Poco::Net::HTTPResponse::HTTP_OK || !response_stream) - throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Failed to get user info by access token, code: {}, reason: {}", - response.getStatus(), response.getReason()); + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "GET to '{}' returned HTTP {} ({})", + jwks_uri.toString(), static_cast(response.getStatus()), response.getReason()); Poco::StreamCopier::copyToString(response_stream, response_string); } else @@ -95,7 +95,8 @@ JWKSType JWKSClient::getJWKS() session.sendRequest(request); std::istream & response_stream = session.receiveResponse(response); if (response.getStatus() != Poco::Net::HTTPResponse::HTTP_OK || !response_stream) - throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Failed to get user info by access token, code: {}, reason: {}", response.getStatus(), response.getReason()); + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "GET to '{}' returned HTTP {} ({})", + jwks_uri.toString(), static_cast(response.getStatus()), response.getReason()); Poco::StreamCopier::copyToString(response_stream, response_string); } diff --git a/src/Access/TokenProcessorsOpaque.cpp b/src/Access/TokenProcessorsOpaque.cpp index 03a9cd457ff11..48ac5e7bd1a09 100644 --- a/src/Access/TokenProcessorsOpaque.cpp +++ b/src/Access/TokenProcessorsOpaque.cpp @@ -2,6 +2,7 @@ #if USE_JWT_CPP #include +#include #include #include #include @@ -90,8 +91,8 @@ namespace if (response.getStatus() != Poco::Net::HTTPResponse::HTTP_OK) throw Exception(ErrorCodes::AUTHENTICATION_FAILED, - "Failed to get user info by access token, code: {}, reason: {}", response.getStatus(), - response.getReason()); + "GET to '{}' returned HTTP {} ({})", + uri.toString(), static_cast(response.getStatus()), response.getReason()); try { @@ -183,12 +184,6 @@ GoogleTokenProcessor::GoogleTokenProcessor(const String & processor_name_, , expected_audience(expected_audience_) , timeouts(timeouts_) { - /// Without an audience pin, this processor accepts any Google access token - /// that authenticates the user against Google -- including tokens minted for - /// completely unrelated OAuth clients (a classic confused-deputy scenario). - /// Operators who actually want token-based auth almost always want it bound - /// to their own client_id; surface this gap loudly at startup so it can't - /// stay silently un-enforced. if (expected_audience.empty()) LOG_WARNING(getLogger("TokenAuthentication"), "{}: 'expected_audience' is not configured for Google token processor. " @@ -201,28 +196,9 @@ bool GoogleTokenProcessor::resolveAndValidate(TokenCredentials & credentials) co { const String & token = credentials.getToken(); - std::unordered_map user_info; - picojson::object user_info_json = getObjectFromURI(Poco::URI("https://www.googleapis.com/oauth2/v3/userinfo"), timeouts, token); - - if (!user_info_json.contains("email")) - throw Exception(ErrorCodes::AUTHENTICATION_FAILED, - "{}: Specified username_claim {} not found in token", processor_name, username_claim); - - user_info["email"] = getValueByKey(user_info_json, "email").value_or(""); - - user_info[username_claim] = getValueByKey(user_info_json, username_claim).value(); - - String user_name = user_info[username_claim]; + /// /tokeninfo has `aud`, `exp`, `sub` and `email`; /userinfo is needed only for other claims. + const auto token_info = getObjectFromURI(Poco::URI("https://www.googleapis.com/oauth2/v3/tokeninfo"), timeouts, token); - auto token_info = getObjectFromURI(Poco::URI("https://www.googleapis.com/oauth2/v3/tokeninfo"), timeouts, token); - - /// Audience binding (H-10): the Google /tokeninfo endpoint authoritatively - /// reports the OAuth client_id the access token was issued for in its 'aud' - /// field. Without this check, a token minted for any other Google OAuth - /// client (the user's mobile app, a third-party tool) would authenticate - /// here too -- because Google /userinfo will happily honor any valid token. - /// Refusing tokens whose 'aud' does not match the configured client pin is - /// what makes the binding strict. if (!expected_audience.empty()) { const auto aud = getValueByKey(token_info, "aud").value_or(""); @@ -235,11 +211,18 @@ bool GoogleTokenProcessor::resolveAndValidate(TokenCredentials & credentials) co } } - /// Reject empty resolved username (M-27). `TokenCredentials::setUserName` - /// leaves `is_ready=false` for empty input but the function would still - /// return true; the cache would then accept an entry under user_name "", - /// collapsing every empty-username token across all IdPs into the same - /// dynamic ClickHouse user. + const picojson::object user_info = token_info.contains(username_claim) + ? token_info + : getObjectFromURI(Poco::URI("https://www.googleapis.com/oauth2/v3/userinfo"), timeouts, token); + + if (!user_info.contains(username_claim)) + throw Exception(ErrorCodes::AUTHENTICATION_FAILED, + "{}: Specified username_claim {} not found in token", processor_name, username_claim); + + const String user_name = getValueByKey(user_info, username_claim).value_or(""); + const String email = getValueByKey(user_info, "email").value_or(""); + + /// Otherwise all such tokens would share one cache entry under an empty user name. if (user_name.empty()) { LOG_TRACE(getLogger("TokenAuthentication"), @@ -249,27 +232,23 @@ bool GoogleTokenProcessor::resolveAndValidate(TokenCredentials & credentials) co credentials.setUserName(user_name); - if (token_info.contains("exp")) + if (const auto exp_it = token_info.find("exp"); exp_it != token_info.end()) { - /// 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(token_info, "exp").value(); - if (!std::isfinite(exp) || exp <= 0.0 - || exp > static_cast(std::numeric_limits::max())) + /// /tokeninfo returns `exp` as a decimal string, e.g. "1744687132". + Int64 exp = 0; + if (!exp_it->second.is() || !tryParse(exp, exp_it->second.get().c_str()) || exp <= 0) throw Exception( ErrorCodes::AUTHENTICATION_FAILED, - "{}: tokeninfo response contains an out-of-range 'exp' value: {}", - processor_name, exp); + "{}: tokeninfo response contains an invalid 'exp' value: {}", + processor_name, exp_it->second.serialize()); credentials.setExpiresAt(std::chrono::system_clock::from_time_t(static_cast(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. - if (!user_info["email"].empty()) + if (!email.empty()) { std::set external_groups_names; - const Poco::URI get_groups_uri = Poco::URI("https://cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups?query=member_key_id==" + user_info["email"] + "'"); + Poco::URI get_groups_uri("https://cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups"); + get_groups_uri.addQueryParameter("query", "member_key_id == '" + email + "'"); try { @@ -293,16 +272,6 @@ bool GoogleTokenProcessor::resolveAndValidate(TokenCredentials & credentials) co auto group_data = group.get(); - /// Guard against a missing or non-object `groupKey`. Without - /// these checks `group_data["groupKey"].get()` - /// would auto-insert a null `picojson::value` (because picojson - /// objects are `std::map` and `[]` - /// default-constructs on a missing key) and then throw - /// `std::bad_cast` on the `.get()` call -- - /// which the `catch (const Exception &)` below does NOT - /// catch (`std::bad_cast` is `std::exception`-derived, not - /// `DB::Exception`-derived). The uncaught exception used to - /// propagate out of `resolveAndValidate` and abort auth. auto group_key_it = group_data.find("groupKey"); if (group_key_it == group_data.end() || !group_key_it->second.is()) { @@ -325,10 +294,6 @@ bool GoogleTokenProcessor::resolveAndValidate(TokenCredentials & credentials) co } catch (const std::exception & e) { - /// Defense in depth: catch `std::exception` (not just `DB::Exception`) - /// so picojson's `std::bad_cast` and `std::runtime_error` -- and any - /// other future deviation -- degrade to "no roles mapped" rather - /// than aborting the whole authentication. LOG_TRACE(getLogger("TokenAuthentication"), "{}: Failed to get Google groups, no external roles will be mapped. reason: {}", processor_name, e.what()); return true;