From 4d60c388242fee159951985d0b377e3d594cc468 Mon Sep 17 00:00:00 2001
From: Andrey Zvonov <32552679+zvonand@users.noreply.github.com>
Date: Tue, 22 Sep 2026 12:22:39 +0200
Subject: [PATCH 1/3] Add direct AWS IAM Identity Center access-token
authentication
Add the `aws_sso` token processor, validating assigned account access with
`GetRoleCredentials` and resolving the caller through signed
`GetCallerIdentity` requests. Use the full role-session ARN as the username
and cap cached validation by a short configurable lifetime.
Document AWS setup, token acquisition, and identity and cache limitations.
Add nine focused tests for validation, rejection, and configuration.
Validation: unit-test build succeeded; all nine AWS SSO tests passed.
---
.../external-authenticators/tokens.md | 39 ++-
src/Access/AwsSSOTokenProcessor.cpp | 250 ++++++++++++++++++
src/Access/AwsSSOTokenProcessor.h | 45 ++++
src/Access/TokenProcessorsParse.cpp | 29 ++
4 files changed, 359 insertions(+), 4 deletions(-)
create mode 100644 src/Access/AwsSSOTokenProcessor.cpp
create mode 100644 src/Access/AwsSSOTokenProcessor.h
diff --git a/docs/en/operations/external-authenticators/tokens.md b/docs/en/operations/external-authenticators/tokens.md
index 4bc2151c2498..0a701ac7206a 100644
--- a/docs/en/operations/external-authenticators/tokens.md
+++ b/docs/en/operations/external-authenticators/tokens.md
@@ -29,13 +29,13 @@ 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.
-- `token_cache_lifetime` -- maximum lifetime of cached token (in seconds). Optional, default: 3600.
+- `type` -- type of token processor. Supported values: `jwt_static_key`, `jwt_static_jwks`, `jwt_dynamic_jwks`, `aws_sso`, `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.
+- `token_cache_lifetime` -- maximum lifetime of cached token (in seconds). Optional, default: 3600 (60 for `aws_sso`).
- `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".
For each type, there are additional specific parameters (some of them are mandatory).
-If some parameters that are not required for current processor type are specified, they are ignored.
+Unsupported parameters may be rejected; see the rules for each processor type.
## JWT (JSON Web Token)
@@ -131,12 +131,43 @@ 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 presets built on the generic JWT processors (`entra`), direct AWS IAM Identity Center validation (`aws_sso`), and the generic `openid` processor for OIDC-compliant identity providers.
:::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.
:::
+### AWS IAM Identity Center (AWS SSO) {#aws-sso}
+
+`aws_sso` validates IAM Identity Center access tokens through AWS `GetRoleCredentials`, then signs an STS `GetCallerIdentity` request with the returned temporary credentials. It checks the account and permission-set role and uses the full STS assumed-role ARN as the ClickHouse username. The server does not require its own AWS credentials.
+
+```xml
+
+
+
+ aws_sso
+ eu-central-1
+ 123456789012
+ ClickHouseAccess
+ 60
+
+
+
+```
+
+**Parameters:**
+
+- `region` — Region hosting IAM Identity Center, such as `eu-central-1`. Mandatory.
+- `account_id` — The 12-digit AWS account assigned to the user. Mandatory.
+- `role_name` — Assigned permission-set name, such as `ClickHouseAccess`. Use the name reported by AWS `ListAccountRoles`, not the generated `AWSReservedSSO_...` IAM role name or an ARN. Mandatory.
+- `token_cache_lifetime` — Maximum validation-cache and authenticated-session lifetime in seconds, capped by the returned role-credential expiry. Optional, default: 60. Range: 1–3600. Cached validations can remain usable until this deadline after access-token expiry, revocation, or assignment removal.
+
+The processor rejects `username_claim`, `groups_claim`, JWT claim restrictions, issuer/audience settings, and OIDC/JWKS endpoint settings. AWS responses provide no directory groups; use `common_roles` in the [external user directory](#idp-external-user-directory) to assign ClickHouse permissions.
+
+Obtain an IAM Identity Center access token with the `sso:account:access` scope for the configured region and an AWS account assignment. Pass it using `Authorization: Bearer` or `clickhouse-client --jwt`; the caller handles acquisition and refresh. Such tokens can obtain credentials for the user's AWS account assignments and cannot be restricted to a ClickHouse-specific audience by this processor; send them only to trusted servers over TLS.
+
+If `remote_url_allow_hosts` is configured, allow both `portal.sso..amazonaws.com` and `sts..amazonaws.com` (use `amazonaws.com.cn` for China regions). Endpoints are derived from the region. The build requires JWT, SSL, and AWS SDK support.
+
### Entra (Microsoft Entra ID, pure OIDC) {#entra}
`entra` is a preset for Microsoft Entra ID built on top of `jwt_dynamic_jwks`. Tokens are validated **locally** against Entra's per-tenant JWKS — no Microsoft Graph call, no userinfo round trip, no OIDC discovery fetch. `username_claim` and `groups_claim` are read directly from the JWT payload. Use this when the access token's `aud` is your own app (registered via Entra's *Expose an API* blade), not `https://graph.microsoft.com`.
diff --git a/src/Access/AwsSSOTokenProcessor.cpp b/src/Access/AwsSSOTokenProcessor.cpp
new file mode 100644
index 000000000000..1476243690b7
--- /dev/null
+++ b/src/Access/AwsSSOTokenProcessor.cpp
@@ -0,0 +1,250 @@
+#include
+
+#if USE_JWT_CPP && USE_AWS_S3 && USE_SSL
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+namespace DB
+{
+namespace ErrorCodes
+{
+ extern const int AUTHENTICATION_FAILED;
+ extern const int INVALID_CONFIG_PARAMETER;
+}
+
+namespace
+{
+constexpr size_t max_response_size = 64 * 1024;
+
+bool isSafeHeader(const String & value)
+{
+ return !value.empty() && value.size() <= max_response_size
+ && std::ranges::all_of(value, [](unsigned char c)
+ {
+ return c > 32 && c < 127;
+ });
+}
+
+const picojson::value & requiredField(const picojson::value & object, const char * name)
+{
+ if (!object.is() || !object.contains(name))
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS SSO response is missing field '{}'", name);
+ return object.get(name);
+}
+
+String requiredString(const picojson::value & object, const char * name)
+{
+ const auto & value = requiredField(object, name);
+ if (!value.is() || !isSafeHeader(value.get()))
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS SSO response has an invalid '{}' field", name);
+ return value.get();
+}
+
+String xmlField(const Aws::Utils::Xml::XmlNode & parent, const char * name)
+{
+ auto node = parent.FirstChild(name);
+ if (node.IsNull() || !node.NextNode(name).IsNull() || !node.FirstChild().IsNull())
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS STS response has an invalid '{}' field", name);
+ const auto value = Aws::Utils::Xml::DecodeEscapedXmlText(node.GetText());
+ if (!isSafeHeader(value))
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS STS response has an empty or invalid '{}' field", name);
+ return value;
+}
+}
+
+AwsSSOTokenProcessor::AwsSSOTokenProcessor(
+ const String & name, UInt64 cache_lifetime, const String & region_, const String & account_id_,
+ const String & role_name_, const ConnectionTimeouts & timeouts_, Transport transport_)
+ : ITokenProcessor(name, cache_lifetime)
+ , region(region_)
+ , account_id(account_id_)
+ , role_name(role_name_)
+ , partition(region.starts_with("cn-") ? "aws-cn" : region.starts_with("us-gov-") ? "aws-us-gov" : "aws")
+ , domain(region.starts_with("cn-") ? "amazonaws.com.cn" : "amazonaws.com")
+ , timeouts(timeouts_)
+ , transport(std::move(transport_))
+{
+ if (region.empty() || region.size() > 32 || region.front() == '-' || region.back() < '0' || region.back() > '9'
+ || !std::ranges::all_of(region, [](char c)
+ {
+ return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-';
+ })
+ || region.starts_with("us-iso"))
+ throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "AWS SSO requires a valid commercial, China, or GovCloud region");
+ if (account_id.size() != 12 || !std::ranges::all_of(account_id, [](char c)
+ {
+ return c >= '0' && c <= '9';
+ }))
+ throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "AWS SSO requires a 12-digit account_id");
+ if (role_name.empty() || role_name.size() > 32 || !std::ranges::all_of(role_name, [](unsigned char c)
+ {
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || std::string_view("_+=,.@-").contains(c);
+ }))
+ throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "AWS SSO requires a permission-set role_name (1 to 32 characters)");
+ if (cache_lifetime == 0 || cache_lifetime > 3600)
+ throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "AWS SSO token_cache_lifetime must be between 1 and 3600 seconds");
+}
+
+String AwsSSOTokenProcessor::getPortalEndpoint() const
+{
+ return "https://portal.sso." + region + "." + domain;
+}
+
+String AwsSSOTokenProcessor::getSTSEndpoint() const
+{
+ return "https://sts." + region + "." + domain + "/?Action=GetCallerIdentity&Version=2011-06-15";
+}
+
+AwsSSOTokenProcessor::Response AwsSSOTokenProcessor::request(const String & url, const Headers & headers) const
+{
+ Response result;
+ if (transport)
+ result = transport(url, headers);
+ else
+ {
+ const Poco::URI uri(url);
+ /// Authentication must not inherit permissive server TLS settings.
+ Poco::Net::Context::Ptr tls_context = new Poco::Net::Context(
+ Poco::Net::Context::CLIENT_USE, "", Poco::Net::Context::VERIFY_STRICT, 9, true);
+ tls_context->enableExtendedCertificateVerification();
+ SSL_CTX_set_verify(tls_context->sslContext(), SSL_VERIFY_PEER, nullptr);
+ Poco::Net::HTTPSClientSession session(uri.getHost(), uri.getPort(), tls_context);
+ setTimeouts(session, timeouts);
+ Poco::Net::HTTPRequest http_request(Poco::Net::HTTPRequest::HTTP_GET, uri.getPathAndQuery());
+ for (const auto & [name, value] : headers)
+ http_request.set(name, value);
+ session.sendRequest(http_request);
+ Poco::Net::HTTPResponse response;
+ auto & body = session.receiveResponse(response);
+ result.status = response.getStatus();
+ if (result.status == 200)
+ {
+ std::array buffer;
+ while (body.read(buffer.data(), buffer.size()) || body.gcount())
+ {
+ if (result.body.size() + body.gcount() > max_response_size)
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS SSO response exceeds size limit");
+ result.body.append(buffer.data(), body.gcount());
+ }
+ if (body.bad())
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Cannot read AWS SSO response");
+ }
+ }
+ if (result.body.size() > max_response_size)
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS SSO response exceeds size limit");
+ /// Response bodies may contain AWS credentials; never include them in errors.
+ if (result.status != 200 && result.status != 401 && result.status != 403 && result.status != 404)
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS SSO validation failed with HTTP status {}", result.status);
+ return result;
+}
+
+bool AwsSSOTokenProcessor::resolveAndValidate(TokenCredentials & credentials) const
+{
+ /// Auto-discovery in `ExternalAuthenticators::checkTokenCredentials` tries processors in turn,
+ /// and an exception out of one aborts the loop, so AWS and transport failures must deny only this processor.
+ try
+ {
+ if (!isSafeHeader(credentials.getToken()))
+ return false;
+
+ Poco::URI portal(getPortalEndpoint() + "/federation/credentials");
+ portal.addQueryParameter("account_id", account_id);
+ portal.addQueryParameter("role_name", role_name);
+ const auto role_response = request(portal.toString(), {{"x-amz-sso_bearer_token", credentials.getToken()}, {"Accept", "application/json"}});
+ if (role_response.status != 200)
+ return false;
+
+ picojson::value json;
+ if (!parseWholeJSON(json, role_response.body).empty())
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid AWS SSO JSON response");
+ const auto & role_credentials = requiredField(json, "roleCredentials");
+ const auto access_key = requiredString(role_credentials, "accessKeyId");
+ const auto secret_key = requiredString(role_credentials, "secretAccessKey");
+ const auto session_token = requiredString(role_credentials, "sessionToken");
+ const auto & expiration = requiredField(role_credentials, "expiration");
+ if (!expiration.is())
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid AWS SSO credential expiration");
+ const auto expiration_ms = expiration.get();
+ const auto now = std::chrono::system_clock::now();
+ const auto now_ms = std::chrono::duration_cast(now.time_since_epoch()).count();
+ if (expiration_ms <= now_ms)
+ return false;
+
+ /// Initialize the shared AWS SDK before signing.
+ S3::ClientFactory::instance();
+ auto provider = std::make_shared(access_key, secret_key, session_token);
+ Aws::Client::AWSAuthV4Signer signer(provider, "sts", region);
+ Aws::Http::Standard::StandardHttpRequest sts_request(Aws::Http::URI(getSTSEndpoint()), Aws::Http::HttpMethod::HTTP_GET);
+ if (!signer.SignRequest(sts_request))
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Cannot sign AWS STS identity request");
+ Headers headers;
+ for (const auto & [name, value] : sts_request.GetHeaders())
+ headers.emplace_back(name, value);
+ const auto identity_response = request(getSTSEndpoint(), headers);
+ if (identity_response.status != 200)
+ return false;
+
+ auto document = Aws::Utils::Xml::XmlDocument::CreateFromXmlString(identity_response.body);
+ if (!document.WasParseSuccessful())
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid AWS STS XML response");
+ auto root = document.GetRootElement();
+ if (root.IsNull() || root.GetName() != "GetCallerIdentityResponse")
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid AWS STS identity response");
+ auto result = root.FirstChild("GetCallerIdentityResult");
+ if (result.IsNull() || !result.NextNode("GetCallerIdentityResult").IsNull())
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid AWS STS identity response");
+ const auto arn = xmlField(result, "Arn");
+ const auto user_id = xmlField(result, "UserId");
+ if (xmlField(result, "Account") != account_id)
+ return false;
+ const auto prefix = "arn:" + partition + ":sts::" + account_id + ":assumed-role/AWSReservedSSO_" + role_name + "_";
+ if (!arn.starts_with(prefix))
+ return false;
+ const auto suffix = arn.substr(prefix.size());
+ const auto slash = suffix.find('/');
+ if (slash != 16 || suffix.size() <= slash + 1 || suffix.find('/', slash + 1) != String::npos
+ || !std::all_of(suffix.begin(), suffix.begin() + 16, [](char c)
+ {
+ return (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') || (c >= '0' && c <= '9');
+ }))
+ return false;
+ const auto session_name = suffix.substr(slash + 1);
+ const auto colon = user_id.find(':');
+ if (!user_id.starts_with("AROA") || colon == String::npos || user_id.substr(colon + 1) != session_name)
+ return false;
+
+ /// AWS reports role-credential expiry, not access-token expiry; cap the session by the cache TTL.
+ const auto valid_for_ms = std::min(expiration_ms - now_ms, token_cache_lifetime * 1000);
+ const auto expires_at = now + std::chrono::milliseconds(valid_for_ms);
+ if (expires_at <= std::chrono::system_clock::now())
+ return false;
+ credentials.setUserName(arn);
+ credentials.setGroups({});
+ credentials.setExpiresAt(expires_at);
+ return true;
+ }
+ catch (const std::exception & ex)
+ {
+ LOG_TRACE(getLogger("TokenAuthentication"), "{}: Failed to validate AWS SSO access token: {}", processor_name, ex.what());
+ return false;
+ }
+}
+
+}
+
+#endif
diff --git a/src/Access/AwsSSOTokenProcessor.h b/src/Access/AwsSSOTokenProcessor.h
new file mode 100644
index 000000000000..69da54127092
--- /dev/null
+++ b/src/Access/AwsSSOTokenProcessor.h
@@ -0,0 +1,45 @@
+#pragma once
+
+#include
+
+#if USE_JWT_CPP && USE_AWS_S3 && USE_SSL
+
+#include
+
+namespace DB
+{
+
+class AwsSSOTokenProcessor : public ITokenProcessor
+{
+public:
+ using Headers = std::vector>;
+ struct Response
+ {
+ int status;
+ String body;
+ };
+ using Transport = std::function;
+
+ AwsSSOTokenProcessor(const String & name, UInt64 cache_lifetime, const String & region_,
+ const String & account_id_, const String & role_name_,
+ const ConnectionTimeouts & timeouts_, Transport transport_ = {});
+
+ bool resolveAndValidate(TokenCredentials & credentials) const override;
+ String getPortalEndpoint() const;
+ String getSTSEndpoint() const;
+
+private:
+ const String region;
+ const String account_id;
+ const String role_name;
+ const String partition;
+ const String domain;
+ const ConnectionTimeouts timeouts;
+ const Transport transport;
+
+ Response request(const String & url, const Headers & headers) const;
+};
+
+}
+
+#endif
diff --git a/src/Access/TokenProcessorsParse.cpp b/src/Access/TokenProcessorsParse.cpp
index 0b0273e8433c..0f829218d17e 100644
--- a/src/Access/TokenProcessorsParse.cpp
+++ b/src/Access/TokenProcessorsParse.cpp
@@ -1,4 +1,5 @@
#include "TokenProcessors.h"
+#include
#include
#include
@@ -87,6 +88,34 @@ std::unique_ptr ITokenProcessor::parseTokenProcessor(
{
return std::make_unique(processor_name, token_cache_lifetime, username_claim, groups_claim, expected_audience, timeouts);
}
+ else if (provider_type == "aws_sso")
+ {
+#if USE_AWS_S3 && USE_SSL
+ for (const auto * key : {"region", "account_id", "role_name"})
+ {
+ if (!config.hasProperty(prefix + "." + key))
+ throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER,
+ "Token processor '{}': '{}' must be specified for 'aws_sso' processor", processor_name, key);
+ }
+ for (const auto * key : {"username_claim", "groups_claim", "expected_issuer", "expected_audience", "expected_typ",
+ "claims", "allow_no_expiration", "jwks_uri", "configuration_endpoint", "userinfo_endpoint",
+ "token_introspection_endpoint"})
+ {
+ if (config.hasProperty(prefix + "." + key))
+ throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER,
+ "Token processor '{}': '{}' is not supported by aws_sso", processor_name, key);
+ }
+ auto processor = std::make_unique(
+ processor_name, config.getUInt64(prefix + ".token_cache_lifetime", 60),
+ config.getString(prefix + ".region"), config.getString(prefix + ".account_id"),
+ config.getString(prefix + ".role_name"), timeouts);
+ require_allowed_url(processor->getPortalEndpoint(), "region");
+ require_allowed_url(processor->getSTSEndpoint(), "region");
+ return processor;
+#else
+ throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "AWS SSO requires AWS SDK and SSL support");
+#endif
+ }
else if (provider_type == "openid")
{
bool externally_configured = config.hasProperty(prefix + ".configuration_endpoint");
From ee31a251acb517d737e72e2b2598386824c3b84c Mon Sep 17 00:00:00 2001
From: Andrey Zvonov <32552679+zvonand@users.noreply.github.com>
Date: Thu, 24 Sep 2026 16:00:49 +0200
Subject: [PATCH 2/3] Simplify `AwsSSOTokenProcessor` HTTP path
Remove the unused transport injection and keep request details private.
Related: https://github.com/Altinity/ClickHouse/pull/2414
---
src/Access/AwsSSOTokenProcessor.cpp | 57 +++++++++++++----------------
src/Access/AwsSSOTokenProcessor.h | 20 ++++------
2 files changed, 33 insertions(+), 44 deletions(-)
diff --git a/src/Access/AwsSSOTokenProcessor.cpp b/src/Access/AwsSSOTokenProcessor.cpp
index 1476243690b7..5e8f59a0fb05 100644
--- a/src/Access/AwsSSOTokenProcessor.cpp
+++ b/src/Access/AwsSSOTokenProcessor.cpp
@@ -69,7 +69,7 @@ String xmlField(const Aws::Utils::Xml::XmlNode & parent, const char * name)
AwsSSOTokenProcessor::AwsSSOTokenProcessor(
const String & name, UInt64 cache_lifetime, const String & region_, const String & account_id_,
- const String & role_name_, const ConnectionTimeouts & timeouts_, Transport transport_)
+ const String & role_name_, const ConnectionTimeouts & timeouts_)
: ITokenProcessor(name, cache_lifetime)
, region(region_)
, account_id(account_id_)
@@ -77,7 +77,6 @@ AwsSSOTokenProcessor::AwsSSOTokenProcessor(
, partition(region.starts_with("cn-") ? "aws-cn" : region.starts_with("us-gov-") ? "aws-us-gov" : "aws")
, domain(region.starts_with("cn-") ? "amazonaws.com.cn" : "amazonaws.com")
, timeouts(timeouts_)
- , transport(std::move(transport_))
{
if (region.empty() || region.size() > 32 || region.front() == '-' || region.back() < '0' || region.back() > '9'
|| !std::ranges::all_of(region, [](char c)
@@ -113,37 +112,32 @@ String AwsSSOTokenProcessor::getSTSEndpoint() const
AwsSSOTokenProcessor::Response AwsSSOTokenProcessor::request(const String & url, const Headers & headers) const
{
Response result;
- if (transport)
- result = transport(url, headers);
- else
+ const Poco::URI uri(url);
+ /// Authentication must not inherit permissive server TLS settings.
+ Poco::Net::Context::Ptr tls_context = new Poco::Net::Context(
+ Poco::Net::Context::CLIENT_USE, "", Poco::Net::Context::VERIFY_STRICT, 9, true);
+ tls_context->enableExtendedCertificateVerification();
+ SSL_CTX_set_verify(tls_context->sslContext(), SSL_VERIFY_PEER, nullptr);
+ Poco::Net::HTTPSClientSession session(uri.getHost(), uri.getPort(), tls_context);
+ setTimeouts(session, timeouts);
+ Poco::Net::HTTPRequest http_request(Poco::Net::HTTPRequest::HTTP_GET, uri.getPathAndQuery());
+ for (const auto & [name, value] : headers)
+ http_request.set(name, value);
+ session.sendRequest(http_request);
+ Poco::Net::HTTPResponse response;
+ auto & body = session.receiveResponse(response);
+ result.status = response.getStatus();
+ if (result.status == 200)
{
- const Poco::URI uri(url);
- /// Authentication must not inherit permissive server TLS settings.
- Poco::Net::Context::Ptr tls_context = new Poco::Net::Context(
- Poco::Net::Context::CLIENT_USE, "", Poco::Net::Context::VERIFY_STRICT, 9, true);
- tls_context->enableExtendedCertificateVerification();
- SSL_CTX_set_verify(tls_context->sslContext(), SSL_VERIFY_PEER, nullptr);
- Poco::Net::HTTPSClientSession session(uri.getHost(), uri.getPort(), tls_context);
- setTimeouts(session, timeouts);
- Poco::Net::HTTPRequest http_request(Poco::Net::HTTPRequest::HTTP_GET, uri.getPathAndQuery());
- for (const auto & [name, value] : headers)
- http_request.set(name, value);
- session.sendRequest(http_request);
- Poco::Net::HTTPResponse response;
- auto & body = session.receiveResponse(response);
- result.status = response.getStatus();
- if (result.status == 200)
+ std::array buffer;
+ while (body.read(buffer.data(), buffer.size()) || body.gcount())
{
- std::array buffer;
- while (body.read(buffer.data(), buffer.size()) || body.gcount())
- {
- if (result.body.size() + body.gcount() > max_response_size)
- throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS SSO response exceeds size limit");
- result.body.append(buffer.data(), body.gcount());
- }
- if (body.bad())
- throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Cannot read AWS SSO response");
+ if (result.body.size() + body.gcount() > max_response_size)
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS SSO response exceeds size limit");
+ result.body.append(buffer.data(), body.gcount());
}
+ if (body.bad())
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Cannot read AWS SSO response");
}
if (result.body.size() > max_response_size)
throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS SSO response exceeds size limit");
@@ -155,8 +149,7 @@ AwsSSOTokenProcessor::Response AwsSSOTokenProcessor::request(const String & url,
bool AwsSSOTokenProcessor::resolveAndValidate(TokenCredentials & credentials) const
{
- /// Auto-discovery in `ExternalAuthenticators::checkTokenCredentials` tries processors in turn,
- /// and an exception out of one aborts the loop, so AWS and transport failures must deny only this processor.
+ /// Auto-discovery stops on exceptions, so AWS and transport failures must deny only this processor.
try
{
if (!isSafeHeader(credentials.getToken()))
diff --git a/src/Access/AwsSSOTokenProcessor.h b/src/Access/AwsSSOTokenProcessor.h
index 69da54127092..86d590cea884 100644
--- a/src/Access/AwsSSOTokenProcessor.h
+++ b/src/Access/AwsSSOTokenProcessor.h
@@ -4,38 +4,34 @@
#if USE_JWT_CPP && USE_AWS_S3 && USE_SSL
-#include
-
namespace DB
{
class AwsSSOTokenProcessor : public ITokenProcessor
{
public:
- using Headers = std::vector>;
- struct Response
- {
- int status;
- String body;
- };
- using Transport = std::function;
-
AwsSSOTokenProcessor(const String & name, UInt64 cache_lifetime, const String & region_,
const String & account_id_, const String & role_name_,
- const ConnectionTimeouts & timeouts_, Transport transport_ = {});
+ const ConnectionTimeouts & timeouts_);
bool resolveAndValidate(TokenCredentials & credentials) const override;
String getPortalEndpoint() const;
String getSTSEndpoint() const;
private:
+ using Headers = std::vector>;
+ struct Response
+ {
+ int status;
+ String body;
+ };
+
const String region;
const String account_id;
const String role_name;
const String partition;
const String domain;
const ConnectionTimeouts timeouts;
- const Transport transport;
Response request(const String & url, const Headers & headers) const;
};
From 2017e030cbc3bd70fd143cfc5f1b5f4010cab5ff Mon Sep 17 00:00:00 2001
From: Andrey Zvonov <32552679+zvonand@users.noreply.github.com>
Date: Thu, 24 Sep 2026 16:43:30 +0200
Subject: [PATCH 3/3] Support AWS Identity Store groups in token roles mapping
Resolve IAM Identity Center users through `GetUserId` and load their paginated direct memberships through `ListGroupMembershipsForMember` when `identity_store_id` is configured. Pass stable group IDs to `roles_mapping` and fail authentication closed when the lookup fails.
Add response-parser tests and document the configuration, IAM permissions, and endpoint allowlist.
Related: https://github.com/Altinity/ClickHouse/pull/2414
---
.../external-authenticators/tokens.md | 17 +-
src/Access/AwsSSOTokenProcessor.cpp | 162 +++++++++++++++++-
src/Access/AwsSSOTokenProcessor.h | 27 ++-
src/Access/TokenProcessorsParse.cpp | 8 +-
.../tests/gtest_aws_sso_token_processor.cpp | 49 ++++++
5 files changed, 251 insertions(+), 12 deletions(-)
create mode 100644 src/Access/tests/gtest_aws_sso_token_processor.cpp
diff --git a/docs/en/operations/external-authenticators/tokens.md b/docs/en/operations/external-authenticators/tokens.md
index 0a701ac7206a..edcbe0d3095a 100644
--- a/docs/en/operations/external-authenticators/tokens.md
+++ b/docs/en/operations/external-authenticators/tokens.md
@@ -149,6 +149,7 @@ If the IdP issues access tokens that follow [RFC 9068](https://datatracker.ietf.
eu-central-1
123456789012
ClickHouseAccess
+ d-1234567890
60
@@ -160,13 +161,25 @@ If the IdP issues access tokens that follow [RFC 9068](https://datatracker.ietf.
- `region` — Region hosting IAM Identity Center, such as `eu-central-1`. Mandatory.
- `account_id` — The 12-digit AWS account assigned to the user. Mandatory.
- `role_name` — Assigned permission-set name, such as `ClickHouseAccess`. Use the name reported by AWS `ListAccountRoles`, not the generated `AWSReservedSSO_...` IAM role name or an ARN. Mandatory.
+- `identity_store_id` — IAM Identity Center Identity Store ID, such as `d-1234567890`. When set, the processor resolves the AWS-issued role-session name with Identity Store `GetUserId`, loads all direct memberships with `ListGroupMembershipsForMember`, and exposes their stable `GroupId` values to the external user directory. Optional.
- `token_cache_lifetime` — Maximum validation-cache and authenticated-session lifetime in seconds, capped by the returned role-credential expiry. Optional, default: 60. Range: 1–3600. Cached validations can remain usable until this deadline after access-token expiry, revocation, or assignment removal.
-The processor rejects `username_claim`, `groups_claim`, JWT claim restrictions, issuer/audience settings, and OIDC/JWKS endpoint settings. AWS responses provide no directory groups; use `common_roles` in the [external user directory](#idp-external-user-directory) to assign ClickHouse permissions.
+The processor rejects `username_claim`, `groups_claim`, JWT claim restrictions, issuer/audience settings, and OIDC/JWKS endpoint settings. Without `identity_store_id`, no external groups are returned; use `common_roles` in the [external user directory](#idp-external-user-directory) to assign ClickHouse permissions.
+
+With `identity_store_id`, grant the configured permission set `identitystore:GetUserId` and `identitystore:ListGroupMembershipsForMember`. The lookup is signed with the user's short-lived role credentials, so the ClickHouse server still needs no AWS credentials of its own. Group lookup fails closed: an Identity Store error rejects authentication rather than creating a session without roles. Use the returned group IDs as the `from` values in [`roles_mapping`](#idp-external-user-directory), for example:
+
+```xml
+
+
+
+```
Obtain an IAM Identity Center access token with the `sso:account:access` scope for the configured region and an AWS account assignment. Pass it using `Authorization: Bearer` or `clickhouse-client --jwt`; the caller handles acquisition and refresh. Such tokens can obtain credentials for the user's AWS account assignments and cannot be restricted to a ClickHouse-specific audience by this processor; send them only to trusted servers over TLS.
-If `remote_url_allow_hosts` is configured, allow both `portal.sso..amazonaws.com` and `sts..amazonaws.com` (use `amazonaws.com.cn` for China regions). Endpoints are derived from the region. The build requires JWT, SSL, and AWS SDK support.
+If `remote_url_allow_hosts` is configured, allow `portal.sso..amazonaws.com`, `sts..amazonaws.com`, and, when `identity_store_id` is set, `identitystore..amazonaws.com` (use `amazonaws.com.cn` for China regions). Endpoints are derived from the region. The build requires JWT, SSL, and AWS SDK support.
### Entra (Microsoft Entra ID, pure OIDC) {#entra}
diff --git a/src/Access/AwsSSOTokenProcessor.cpp b/src/Access/AwsSSOTokenProcessor.cpp
index 5e8f59a0fb05..1b43b3232c58 100644
--- a/src/Access/AwsSSOTokenProcessor.cpp
+++ b/src/Access/AwsSSOTokenProcessor.cpp
@@ -13,11 +13,13 @@
#include
#include
#include
+#include
#include
#include
#include
#include
+#include
namespace DB
{
@@ -30,6 +32,7 @@ namespace ErrorCodes
namespace
{
constexpr size_t max_response_size = 64 * 1024;
+constexpr size_t max_group_pages = 100;
bool isSafeHeader(const String & value)
{
@@ -65,15 +68,77 @@ String xmlField(const Aws::Utils::Xml::XmlNode & parent, const char * name)
throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS STS response has an empty or invalid '{}' field", name);
return value;
}
+
+bool isHex(char c)
+{
+ return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
+}
+
+bool isValidIdentityStoreId(const String & value)
+{
+ if (value.size() == 12 && value.starts_with("d-"))
+ return std::ranges::all_of(value.begin() + 2, value.end(), isHex);
+ if (value.size() != 36)
+ return false;
+ for (size_t i = 0; i < value.size(); ++i)
+ {
+ if (i == 8 || i == 13 || i == 18 || i == 23)
+ {
+ if (value[i] != '-')
+ return false;
+ }
+ else if (!isHex(value[i]))
+ return false;
+ }
+ return true;
+}
+}
+
+namespace AwsSSO
+{
+
+String parseUserId(const String & response)
+{
+ picojson::value json;
+ if (!parseWholeJSON(json, response).empty())
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid AWS Identity Store JSON response");
+ return requiredString(json, "UserId");
+}
+
+GroupMembershipsPage parseGroupMemberships(const String & response)
+{
+ picojson::value json;
+ if (!parseWholeJSON(json, response).empty())
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid AWS Identity Store JSON response");
+
+ const auto & memberships = requiredField(json, "GroupMemberships");
+ if (!memberships.is())
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS Identity Store response has an invalid 'GroupMemberships' field");
+
+ GroupMembershipsPage page;
+ for (const auto & membership : memberships.get())
+ page.group_ids.insert(requiredString(membership, "GroupId"));
+
+ if (json.contains("NextToken"))
+ {
+ const auto & next_token = json.get("NextToken");
+ if (!next_token.is() || (!next_token.get().empty() && !isSafeHeader(next_token.get())))
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS Identity Store response has an invalid 'NextToken' field");
+ page.next_token = next_token.get();
+ }
+ return page;
+}
+
}
AwsSSOTokenProcessor::AwsSSOTokenProcessor(
const String & name, UInt64 cache_lifetime, const String & region_, const String & account_id_,
- const String & role_name_, const ConnectionTimeouts & timeouts_)
+ const String & role_name_, const String & identity_store_id_, const ConnectionTimeouts & timeouts_)
: ITokenProcessor(name, cache_lifetime)
, region(region_)
, account_id(account_id_)
, role_name(role_name_)
+ , identity_store_id(identity_store_id_)
, partition(region.starts_with("cn-") ? "aws-cn" : region.starts_with("us-gov-") ? "aws-us-gov" : "aws")
, domain(region.starts_with("cn-") ? "amazonaws.com.cn" : "amazonaws.com")
, timeouts(timeouts_)
@@ -95,6 +160,8 @@ AwsSSOTokenProcessor::AwsSSOTokenProcessor(
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || std::string_view("_+=,.@-").contains(c);
}))
throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "AWS SSO requires a permission-set role_name (1 to 32 characters)");
+ if (!identity_store_id.empty() && !isValidIdentityStoreId(identity_store_id))
+ throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "AWS SSO requires a valid identity_store_id");
if (cache_lifetime == 0 || cache_lifetime > 3600)
throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "AWS SSO token_cache_lifetime must be between 1 and 3600 seconds");
}
@@ -109,7 +176,13 @@ String AwsSSOTokenProcessor::getSTSEndpoint() const
return "https://sts." + region + "." + domain + "/?Action=GetCallerIdentity&Version=2011-06-15";
}
-AwsSSOTokenProcessor::Response AwsSSOTokenProcessor::request(const String & url, const Headers & headers) const
+String AwsSSOTokenProcessor::getIdentityStoreEndpoint() const
+{
+ return "https://identitystore." + region + "." + domain + "/";
+}
+
+AwsSSOTokenProcessor::Response AwsSSOTokenProcessor::request(
+ const String & method, const String & url, const Headers & headers, const String & request_body) const
{
Response result;
const Poco::URI uri(url);
@@ -120,10 +193,17 @@ AwsSSOTokenProcessor::Response AwsSSOTokenProcessor::request(const String & url,
SSL_CTX_set_verify(tls_context->sslContext(), SSL_VERIFY_PEER, nullptr);
Poco::Net::HTTPSClientSession session(uri.getHost(), uri.getPort(), tls_context);
setTimeouts(session, timeouts);
- Poco::Net::HTTPRequest http_request(Poco::Net::HTTPRequest::HTTP_GET, uri.getPathAndQuery());
+ Poco::Net::HTTPRequest http_request(method, uri.getPathAndQuery());
for (const auto & [name, value] : headers)
http_request.set(name, value);
- session.sendRequest(http_request);
+ if (!request_body.empty())
+ http_request.setContentLength(request_body.size());
+ auto & output = session.sendRequest(http_request);
+ if (!request_body.empty())
+ {
+ output.write(request_body.data(), request_body.size());
+ output.flush();
+ }
Poco::Net::HTTPResponse response;
auto & body = session.receiveResponse(response);
result.status = response.getStatus();
@@ -147,6 +227,72 @@ AwsSSOTokenProcessor::Response AwsSSOTokenProcessor::request(const String & url,
return result;
}
+String AwsSSOTokenProcessor::identityStoreRequest(
+ const String & target, const String & body, const std::shared_ptr & provider) const
+{
+ Aws::Client::AWSAuthV4Signer signer(provider, "identitystore", region);
+ Aws::Http::Standard::StandardHttpRequest aws_request(
+ Aws::Http::URI(getIdentityStoreEndpoint()), Aws::Http::HttpMethod::HTTP_POST);
+ aws_request.SetHeaderValue("content-type", "application/x-amz-json-1.1");
+ aws_request.SetHeaderValue("x-amz-target", target.c_str());
+ auto body_stream = Aws::MakeShared("AwsSSOTokenProcessor");
+ body_stream->write(body.data(), body.size());
+ body_stream->seekg(0);
+ aws_request.AddContentBody(body_stream);
+ if (!signer.SignRequest(aws_request, region.c_str(), "identitystore", true))
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Cannot sign AWS Identity Store request");
+
+ Headers headers;
+ for (const auto & [name, value] : aws_request.GetHeaders())
+ headers.emplace_back(name, value);
+ const auto response = request(Poco::Net::HTTPRequest::HTTP_POST, getIdentityStoreEndpoint(), headers, body);
+ if (response.status != 200)
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS Identity Store request failed with HTTP status {}", response.status);
+ return response.body;
+}
+
+std::set AwsSSOTokenProcessor::getGroupIds(
+ const String & user_name, const std::shared_ptr & provider) const
+{
+ Aws::Utils::Json::JsonValue unique_attribute;
+ unique_attribute.WithString("AttributePath", "userName");
+ unique_attribute.WithString("AttributeValue", user_name.c_str());
+ Aws::Utils::Json::JsonValue alternate_identifier;
+ alternate_identifier.WithObject("UniqueAttribute", std::move(unique_attribute));
+ Aws::Utils::Json::JsonValue user_request;
+ user_request.WithString("IdentityStoreId", identity_store_id.c_str());
+ user_request.WithObject("AlternateIdentifier", std::move(alternate_identifier));
+ const auto user_request_body = user_request.View().WriteCompact();
+ const auto user_id = AwsSSO::parseUserId(
+ identityStoreRequest("AWSIdentityStore.GetUserId", String(user_request_body.c_str(), user_request_body.size()), provider));
+
+ std::set group_ids;
+ String next_token;
+ for (size_t page_number = 0; page_number < max_group_pages; ++page_number)
+ {
+ Aws::Utils::Json::JsonValue member_id;
+ member_id.WithString("UserId", user_id.c_str());
+ Aws::Utils::Json::JsonValue groups_request;
+ groups_request.WithString("IdentityStoreId", identity_store_id.c_str());
+ groups_request.WithObject("MemberId", std::move(member_id));
+ groups_request.WithInteger("MaxResults", 100);
+ if (!next_token.empty())
+ groups_request.WithString("NextToken", next_token.c_str());
+
+ const auto groups_request_body = groups_request.View().WriteCompact();
+ auto page = AwsSSO::parseGroupMemberships(identityStoreRequest(
+ "AWSIdentityStore.ListGroupMembershipsForMember",
+ String(groups_request_body.c_str(), groups_request_body.size()), provider));
+ group_ids.insert(page.group_ids.begin(), page.group_ids.end());
+ if (page.next_token.empty())
+ return group_ids;
+ if (page.next_token == next_token)
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS Identity Store returned a repeated pagination token");
+ next_token = std::move(page.next_token);
+ }
+ throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "AWS Identity Store group membership exceeds {} pages", max_group_pages);
+}
+
bool AwsSSOTokenProcessor::resolveAndValidate(TokenCredentials & credentials) const
{
/// Auto-discovery stops on exceptions, so AWS and transport failures must deny only this processor.
@@ -158,7 +304,9 @@ bool AwsSSOTokenProcessor::resolveAndValidate(TokenCredentials & credentials) co
Poco::URI portal(getPortalEndpoint() + "/federation/credentials");
portal.addQueryParameter("account_id", account_id);
portal.addQueryParameter("role_name", role_name);
- const auto role_response = request(portal.toString(), {{"x-amz-sso_bearer_token", credentials.getToken()}, {"Accept", "application/json"}});
+ const auto role_response = request(
+ Poco::Net::HTTPRequest::HTTP_GET, portal.toString(),
+ {{"x-amz-sso_bearer_token", credentials.getToken()}, {"Accept", "application/json"}});
if (role_response.status != 200)
return false;
@@ -188,7 +336,7 @@ bool AwsSSOTokenProcessor::resolveAndValidate(TokenCredentials & credentials) co
Headers headers;
for (const auto & [name, value] : sts_request.GetHeaders())
headers.emplace_back(name, value);
- const auto identity_response = request(getSTSEndpoint(), headers);
+ const auto identity_response = request(Poco::Net::HTTPRequest::HTTP_GET, getSTSEndpoint(), headers);
if (identity_response.status != 200)
return false;
@@ -227,7 +375,7 @@ bool AwsSSOTokenProcessor::resolveAndValidate(TokenCredentials & credentials) co
if (expires_at <= std::chrono::system_clock::now())
return false;
credentials.setUserName(arn);
- credentials.setGroups({});
+ credentials.setGroups(identity_store_id.empty() ? std::set{} : getGroupIds(session_name, provider));
credentials.setExpiresAt(expires_at);
return true;
}
diff --git a/src/Access/AwsSSOTokenProcessor.h b/src/Access/AwsSSOTokenProcessor.h
index 86d590cea884..5f0cff3941a3 100644
--- a/src/Access/AwsSSOTokenProcessor.h
+++ b/src/Access/AwsSSOTokenProcessor.h
@@ -4,19 +4,37 @@
#if USE_JWT_CPP && USE_AWS_S3 && USE_SSL
+namespace Aws::Auth
+{
+class AWSCredentialsProvider;
+}
+
namespace DB
{
+namespace AwsSSO
+{
+struct GroupMembershipsPage
+{
+ std::set group_ids;
+ String next_token;
+};
+
+String parseUserId(const String & response);
+GroupMembershipsPage parseGroupMemberships(const String & response);
+}
+
class AwsSSOTokenProcessor : public ITokenProcessor
{
public:
AwsSSOTokenProcessor(const String & name, UInt64 cache_lifetime, const String & region_,
- const String & account_id_, const String & role_name_,
+ const String & account_id_, const String & role_name_, const String & identity_store_id_,
const ConnectionTimeouts & timeouts_);
bool resolveAndValidate(TokenCredentials & credentials) const override;
String getPortalEndpoint() const;
String getSTSEndpoint() const;
+ String getIdentityStoreEndpoint() const;
private:
using Headers = std::vector>;
@@ -29,11 +47,16 @@ class AwsSSOTokenProcessor : public ITokenProcessor
const String region;
const String account_id;
const String role_name;
+ const String identity_store_id;
const String partition;
const String domain;
const ConnectionTimeouts timeouts;
- Response request(const String & url, const Headers & headers) const;
+ Response request(const String & method, const String & url, const Headers & headers, const String & body = {}) const;
+ String identityStoreRequest(
+ const String & target, const String & body, const std::shared_ptr & provider) const;
+ std::set getGroupIds(
+ const String & user_name, const std::shared_ptr & provider) const;
};
}
diff --git a/src/Access/TokenProcessorsParse.cpp b/src/Access/TokenProcessorsParse.cpp
index 0f829218d17e..842f52587416 100644
--- a/src/Access/TokenProcessorsParse.cpp
+++ b/src/Access/TokenProcessorsParse.cpp
@@ -105,12 +105,18 @@ std::unique_ptr ITokenProcessor::parseTokenProcessor(
throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER,
"Token processor '{}': '{}' is not supported by aws_sso", processor_name, key);
}
+ const auto identity_store_id = config.getString(prefix + ".identity_store_id", "");
+ if (config.hasProperty(prefix + ".identity_store_id") && identity_store_id.empty())
+ throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER,
+ "Token processor '{}': 'identity_store_id' must not be empty", processor_name);
auto processor = std::make_unique(
processor_name, config.getUInt64(prefix + ".token_cache_lifetime", 60),
config.getString(prefix + ".region"), config.getString(prefix + ".account_id"),
- config.getString(prefix + ".role_name"), timeouts);
+ config.getString(prefix + ".role_name"), identity_store_id, timeouts);
require_allowed_url(processor->getPortalEndpoint(), "region");
require_allowed_url(processor->getSTSEndpoint(), "region");
+ if (!identity_store_id.empty())
+ require_allowed_url(processor->getIdentityStoreEndpoint(), "region");
return processor;
#else
throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "AWS SSO requires AWS SDK and SSL support");
diff --git a/src/Access/tests/gtest_aws_sso_token_processor.cpp b/src/Access/tests/gtest_aws_sso_token_processor.cpp
new file mode 100644
index 000000000000..8c5048e62da3
--- /dev/null
+++ b/src/Access/tests/gtest_aws_sso_token_processor.cpp
@@ -0,0 +1,49 @@
+#include
+
+#if USE_JWT_CPP && USE_AWS_S3 && USE_SSL
+
+#include
+
+#include
+
+namespace DB
+{
+namespace
+{
+
+TEST(AwsSSOTokenProcessor, ParsesIdentityStoreUserId)
+{
+ EXPECT_EQ(AwsSSO::parseUserId(R"({"IdentityStoreId":"d-1234567890","UserId":"1234567890-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"})"),
+ "1234567890-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
+ EXPECT_THROW(AwsSSO::parseUserId(R"({"IdentityStoreId":"d-1234567890"})"), Exception);
+ EXPECT_THROW(AwsSSO::parseUserId(R"({"UserId":"invalid\nuser"})"), Exception);
+}
+
+TEST(AwsSSOTokenProcessor, ParsesIdentityStoreGroupMemberships)
+{
+ const auto page = AwsSSO::parseGroupMemberships(R"({
+ "GroupMemberships":[
+ {"GroupId":"1234567890-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"},
+ {"GroupId":"1234567890-11111111-2222-3333-4444-555555555555"},
+ {"GroupId":"1234567890-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}
+ ],
+ "NextToken":"next-page"
+ })");
+
+ EXPECT_EQ(page.group_ids, (std::set{
+ "1234567890-11111111-2222-3333-4444-555555555555",
+ "1234567890-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}));
+ EXPECT_EQ(page.next_token, "next-page");
+}
+
+TEST(AwsSSOTokenProcessor, RejectsInvalidIdentityStoreGroupMemberships)
+{
+ EXPECT_THROW(AwsSSO::parseGroupMemberships(R"({"GroupMemberships":{}})"), Exception);
+ EXPECT_THROW(AwsSSO::parseGroupMemberships(R"({"GroupMemberships":[{}]})"), Exception);
+ EXPECT_THROW(AwsSSO::parseGroupMemberships(R"({"GroupMemberships":[],"NextToken":42})"), Exception);
+}
+
+}
+}
+
+#endif