Skip to content

Commit 7fad317

Browse files
committed
small fixes
1 parent bc7ac9a commit 7fad317

File tree

9 files changed

+126
-77
lines changed

9 files changed

+126
-77
lines changed

docs/en/operations/external-authenticators/tokens.md

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,21 @@ OAuth 2.0 access tokens can be used to authenticate ClickHouse users. This works
1313

1414
Though this authentication method is different from JWT authentication, it works under the same authentication method to maintain better compatibility.
1515

16-
For both of these approaches a definition of `access_token_processors` is mandatory.
16+
For both of these approaches a definition of `token_processors` is mandatory.
1717

1818
## Access Token Processors
1919

20-
To define an access token processor, add `access_token_processors` section to `config.xml`. Example:
20+
To define an access token processor, add `token_processors` section to `config.xml`. Example:
2121
```xml
2222
<clickhouse>
23-
<access_token_processors>
24-
<gogoogle>
25-
<provider>Google</provider>
26-
<email_filter>^[A-Za-z0-9._%+-]+@example\.com$</email_filter>
27-
<cache_lifetime>600</cache_lifetime>
28-
</gogoogle>
23+
<token_processors>
2924
<azuure>
3025
<provider>azure</provider>
26+
<username_claim>claim_name</username_claim>
3127
<client_id>CLIENT_ID</client_id>
3228
<tenant_id>TENANT_ID</tenant_id>
3329
</azuure>
34-
</access_token_processors>
30+
</token_processors>
3531
</clickhouse>
3632
```
3733

@@ -41,11 +37,21 @@ Different providers have different sets of parameters.
4137

4238
**Parameters**
4339

44-
- `provider` -- name of identity provider. Mandatory, case-insensitive. Supported options: "Google", "Azure".
40+
- `provider` -- name of identity provider. Mandatory, case-insensitive. Supported options: "Google", "Azure", "OpenID".
41+
- `username_claim` -- name of claim (field) that will be treated as ClickHouse user name. Optional, default: "sub".
4542
- `cache_lifetime` -- maximum lifetime of cached token (in seconds). Optional, default: 3600.
4643
- `email_filter` -- Regex for validation of user emails. Optional parameter, only for Google IdP.
47-
- `client_id` -- Azure AD (Entra ID) client ID. Optional parameter, only for Azure IdP.
48-
- `tenant_id` -- Azure AD (Entra ID) tenant ID. Optional parameter, only for Azure IdP.
44+
- `client_id` -- Azure AD (Entra ID) client ID. Optional parameter, used only for Azure IdP.
45+
- `tenant_id` -- Azure AD (Entra ID) tenant ID. Optional parameter, used only for Azure IdP.
46+
- `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 parameter.
47+
- `configuration_endpoint` -- URI of `.well-known/openid-configuration`. Optional parameter, useful only for OIDC-compliant providers (e.g. Keycloak).
48+
- `userinfo_endpoint` -- URI of userinfo endpoint. Optional parameter.
49+
- `token_introspection_endpoint` -- URI of token introspection endpoint. Optional parameter.
50+
51+
:::note
52+
Either `configuration_endpoint` or both `userinfo_endpoint` and `token_introspection_endpoint` shall be set. If none of them are set or all three are set, this is invalid configuration, it will not be parsed.
53+
:::
54+
4955

5056
### Tokens cache
5157
To reduce number of requests to IdP, tokens are cached internally for no longer then `cache_lifetime` seconds.
@@ -58,17 +64,20 @@ Locally defined users can be authenticated with an access token. To allow this,
5864

5965
```xml
6066
<clickhouse>
61-
<!- ... -->
6267
<users>
63-
<!- ... -->
6468
<my_user>
65-
<!- ... -->
69+
<jwt>
70+
<allowed_processors>
71+
<azuure />
72+
</allowed_processors>
6673
</jwt>
6774
</my_user>
6875
</users>
6976
</clickhouse>
7077
```
7178

79+
Inside `jwt` one or more specific access token processors names can be specified -- only those processors will be tried when authenticating. If no processors are specified, _all_ processors will be tried.
80+
7281
At each login attempt, ClickHouse will attempt to validate token and get user info against every defined access token provider.
7382

7483
When SQL-driven [Access Control and Account Management](/docs/en/guides/sre/user-management/index.md#access-control) is enabled, users that are authenticated with tokens can also be created using the [CREATE USER](/docs/en/sql-reference/statements/create/user.md#create-user-statement) statement.
@@ -85,24 +94,32 @@ If there is no suitable user pre-defined in ClickHouse, authentication is still
8594
To allow this, add `token` section to the `users_directories` section of the `config.xml` file.
8695

8796
At each login attempt, ClickHouse tries to find the user definition locally and authenticate it as usual.
88-
If the user is not defined, ClickHouse will treat user as externally defined, and will try to validate the token and get user information from the specified processor.
97+
If the user is not defined, ClickHouse will treat the user as externally defined and will try to validate the token and get user information from the specified processor.
8998
If validated successfully, the user will be considered existing and authenticated. The user will be assigned roles from the list specified in the `roles` section.
9099
All this implies that the SQL-driven [Access Control and Account Management](/docs/en/guides/sre/user-management/index.md#access-control) is enabled and roles are created using the [CREATE ROLE](/docs/en/sql-reference/statements/create/role.md#create-role-statement) statement.
91100

92101
**Example**
93102

94103
```xml
95104
<clickhouse>
96-
<token>
97-
<processor>gogoogle</processor>
98-
<roles>
99-
<token_test_role_1 />
100-
</roles>
101-
</token>
105+
<user_directories>
106+
<token>
107+
<processor>processor_name</processor>
108+
<common_roles>
109+
<token_test_role_1 />
110+
</common_roles>
111+
<roles_filter></roles_filter>
112+
</token>
113+
</user_directories>
102114
</clickhouse>
103115
```
104116

117+
:::note
118+
For now, no more than one `token` section can be defined inside `user_directories`. This _may_ change in future.
119+
:::
120+
105121
**Parameters**
106122

107-
- `server` — Name of one of processors defined in `access_token_processors` config section described above. This parameter is mandatory and cannot be empty.
108-
- `roles` — Section with a list of locally defined roles that will be assigned to each user retrieved from the IdP.
123+
- `processor` — Name of one of processors defined in `token_processors` config section described above. This parameter is mandatory and cannot be empty.
124+
- `common_roles` — Section with a list of locally defined roles that will be assigned to each user retrieved from the IdP. Optional.
125+
- `roles_filter` — Regex string for groups filtering. Only groups matching this regex will be mapped to roles. Optional.

src/Access/AccessControl.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ namespace ErrorCodes
4242
extern const int REQUIRED_PASSWORD;
4343
extern const int CANNOT_COMPILE_REGEXP;
4444
extern const int BAD_ARGUMENTS;
45+
extern const int INVALID_CONFIG_PARAMETER;
4546
}
4647

4748
namespace
@@ -440,6 +441,8 @@ void AccessControl::addStoragesFromUserDirectoriesConfig(
440441
Strings keys_in_user_directories;
441442
config.keys(key, keys_in_user_directories);
442443

444+
bool has_token_storage = false;
445+
443446
for (const String & key_in_user_directories : keys_in_user_directories)
444447
{
445448
String prefix = key + "." + key_in_user_directories;
@@ -490,7 +493,11 @@ void AccessControl::addStoragesFromUserDirectoriesConfig(
490493
}
491494
else if (type == TokenAccessStorage::STORAGE_TYPE)
492495
{
496+
if (has_token_storage)
497+
throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "Only one `token` section can be defined.");
498+
493499
addTokenStorage(name, config, prefix);
500+
has_token_storage = true;
494501
}
495502
else
496503
throw Exception(ErrorCodes::UNKNOWN_ELEMENT_IN_CONFIG, "Unknown storage type '{}' at {} in config", type, prefix);

src/Access/AccessTokenProcessor.cpp

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,14 @@ std::unique_ptr<IAccessTokenProcessor> IAccessTokenProcessor::parseTokenProcesso
9292
const String & prefix,
9393
const String & name)
9494
{
95+
/// TODO: maybe bind external user to the processor it was created with?
9596
if (config.hasProperty(prefix + ".provider"))
9697
{
9798
String provider = Poco::toLower(config.getString(prefix + ".provider"));
9899

99-
String email_regex_str = config.hasProperty(prefix + ".email_filter") ? config.getString(
100-
prefix + ".email_filter") : "";
101-
102-
UInt64 cache_lifetime = config.hasProperty(prefix + ".cache_lifetime") ? config.getUInt64(
103-
prefix + ".cache_lifetime") : 3600;
100+
String email_regex_str = config.getString(prefix + ".email_filter", "");
101+
UInt64 cache_lifetime = config.getUInt64(prefix + ".cache_lifetime", 3600);
102+
String username_claim = config.getString(prefix + ".username_claim", "sub");
104103

105104
if (provider == "google")
106105
{
@@ -129,11 +128,21 @@ std::unique_ptr<IAccessTokenProcessor> IAccessTokenProcessor::parseTokenProcesso
129128

130129
if (is_auto && !is_manual)
131130
{
132-
return std::make_unique<OpenIDAccessTokenProcessor>(name, cache_lifetime, email_regex_str, config.getString(prefix + ".configuration_endpoint"), config.getString(prefix + ".groups_claim_name", ""));
131+
return std::make_unique<OpenIDAccessTokenProcessor>(name,
132+
cache_lifetime,
133+
email_regex_str,
134+
config.getString(prefix + ".configuration_endpoint"),
135+
config.getString(prefix + ".groups_claim", ""));
133136
}
134137
else if (!is_auto && is_manual)
135138
{
136-
return std::make_unique<OpenIDAccessTokenProcessor>(name, cache_lifetime, email_regex_str, config.getString(prefix + ".userinfo_endpoint"), config.getString(prefix + ".token_introspection_endpoint"));
139+
return std::make_unique<OpenIDAccessTokenProcessor>(name,
140+
cache_lifetime,
141+
email_regex_str,
142+
config.getString(prefix + ".userinfo_endpoint"),
143+
config.getString(prefix + ".token_introspection_endpoint"),
144+
config.getString(prefix + ".jwks_uri"),
145+
config.getString(prefix + ".groups_claim", ""));
137146
}
138147

139148
throw Exception(ErrorCodes::INVALID_CONFIG_PARAMETER, "Could not parse access token processor {}: "
@@ -154,7 +163,7 @@ bool GoogleAccessTokenProcessor::resolveAndValidate(const TokenCredentials & cre
154163
const String & token = credentials.getToken();
155164

156165
auto user_info = getUserInfo(token);
157-
String user_name = user_info["sub"];
166+
String user_name = user_info[username_claim];
158167
bool has_email = user_info.contains("email");
159168

160169
if (email_regex.ok())
@@ -238,7 +247,7 @@ std::unordered_map<String, String> GoogleAccessTokenProcessor::getUserInfo(const
238247
try
239248
{
240249
user_info_map["email"] = getValueByKey(user_info_json, "email");
241-
user_info_map["sub"] = getValueByKey(user_info_json, "sub");
250+
user_info_map[username_claim] = getValueByKey(user_info_json, username_claim);
242251
return user_info_map;
243252
}
244253
catch (std::runtime_error & e)
@@ -254,7 +263,7 @@ bool AzureAccessTokenProcessor::resolveAndValidate(const TokenCredentials & cred
254263
/// We will not trust user data in this token except for 'exp' value to determine caching duration.
255264
/// Explanation here: https://stackoverflow.com/questions/60778634/failing-signature-validation-of-jwt-tokens-from-azure-ad
256265
/// Let Azure validate it: only valid tokens will be accepted.
257-
/// Use GET https://graph.microsoft.com/oidc/userinfo to verify token and get sub at the same time
266+
/// Use GET https://graph.microsoft.com/oidc/userinfo to verify token and get user info at the same time
258267

259268
const String & token = credentials.getToken();
260269

@@ -333,7 +342,7 @@ bool AzureAccessTokenProcessor::resolveAndValidate(const TokenCredentials & cred
333342
String AzureAccessTokenProcessor::validateTokenAndGetUsername(const String & token) const
334343
{
335344
picojson::object user_info_json = getObjectFromURI(user_info_uri, token);
336-
return getValueByKey(user_info_json, "sub");
345+
return getValueByKey(user_info_json, username_claim);
337346
}
338347

339348

@@ -343,12 +352,13 @@ OpenIDAccessTokenProcessor::OpenIDAccessTokenProcessor(const String & name_,
343352
const String & userinfo_endpoint_,
344353
const String & token_introspection_endpoint_,
345354
const String & jwks_uri_,
346-
const String & groups_claim_name_)
355+
const String & groups_claim_)
347356
: IAccessTokenProcessor(name_, cache_invalidation_interval_, email_regex_str),
348-
userinfo_endpoint(userinfo_endpoint_), token_introspection_endpoint(token_introspection_endpoint_), groups_claim_name(groups_claim_name_)
357+
userinfo_endpoint(userinfo_endpoint_), token_introspection_endpoint(token_introspection_endpoint_), groups_claim(groups_claim_)
349358
{
350359
if (!jwks_uri_.empty())
351360
{
361+
LOG_TRACE(getLogger("AccessTokenProcessor"), "{}: JWKS URI set, local JWT processing will be attempted", name);
352362
jwt_validator.emplace(name_ + "jwks_val", jwks_uri_, cache_invalidation_interval_);
353363
}
354364
}
@@ -357,7 +367,7 @@ OpenIDAccessTokenProcessor::OpenIDAccessTokenProcessor(const String & name_,
357367
const UInt64 cache_invalidation_interval_,
358368
const String & email_regex_str,
359369
const String & openid_config_endpoint_,
360-
const String & groups_claim_name_)
370+
const String & groups_claim_)
361371
: IAccessTokenProcessor(name_, cache_invalidation_interval_, email_regex_str)
362372
{
363373
const picojson::object openid_config = getObjectFromURI(Poco::URI(openid_config_endpoint_));
@@ -371,7 +381,7 @@ OpenIDAccessTokenProcessor::OpenIDAccessTokenProcessor(const String & name_,
371381
getValueByKey(openid_config, "userinfo_endpoint"),
372382
getValueByKey(openid_config, "introspection_endpoint"),
373383
openid_config.contains("jwks_uri") ? getValueByKey(openid_config, "jwks_uri") : "",
374-
groups_claim_name_);
384+
groups_claim_);
375385
}
376386

377387
bool OpenIDAccessTokenProcessor::resolveAndValidate(const TokenCredentials & credentials)
@@ -382,7 +392,6 @@ bool OpenIDAccessTokenProcessor::resolveAndValidate(const TokenCredentials & cre
382392

383393
if (jwt_validator.has_value() && jwt_validator.value().validate("", token, username))
384394
{
385-
386395
try
387396
{
388397
auto decoded_token = jwt::decode(token);
@@ -404,7 +413,7 @@ bool OpenIDAccessTokenProcessor::resolveAndValidate(const TokenCredentials & cre
404413
try
405414
{
406415
user_info_json = getObjectFromURI(userinfo_endpoint, token);
407-
username = getValueByKey(user_info_json, "sub");
416+
username = getValueByKey(user_info_json, username_claim);
408417
}
409418
catch (...)
410419
{
@@ -429,9 +438,9 @@ bool OpenIDAccessTokenProcessor::resolveAndValidate(const TokenCredentials & cre
429438

430439
/// For now, list of groups is expected in a claim with specified name either in token itself or in userinfo response (Keycloak works this way)
431440
/// TODO: add support for custom endpoints for retrieving groups. Keycloak lists groups in /userinfo and token itself, which is not always the case.
432-
if (!groups_claim_name.empty() && user_info_json.contains(groups_claim_name))
441+
if (!groups_claim.empty() && user_info_json.contains(groups_claim))
433442
{
434-
if (!user_info_json[groups_claim_name].is<picojson::array>())
443+
if (!user_info_json[groups_claim].is<picojson::array>())
435444
{
436445
LOG_TRACE(getLogger("AccessTokenProcessor"),
437446
"{}: Failed to extract groups: invalid content in user data", name);
@@ -440,7 +449,7 @@ bool OpenIDAccessTokenProcessor::resolveAndValidate(const TokenCredentials & cre
440449

441450
std::set<String> external_groups_names;
442451

443-
picojson::array groups_array = user_info_json[groups_claim_name].get<picojson::array>();
452+
picojson::array groups_array = user_info_json[groups_claim].get<picojson::array>();
444453
for (const auto & group: groups_array)
445454
{
446455
if (group.is<std::string>())

src/Access/AccessTokenProcessor.h

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class IAccessTokenProcessor
5555

5656
protected:
5757
const String name;
58+
const String username_claim = "sub";
5859
const UInt64 cache_invalidation_interval;
5960
re2::RE2 email_regex;
6061

@@ -107,7 +108,7 @@ class OpenIDAccessTokenProcessor : public IAccessTokenProcessor
107108
const UInt64 cache_invalidation_interval_,
108109
const String & email_regex_str,
109110
const String & openid_config_endpoint_,
110-
const String & groups_claim_name_);
111+
const String & groups_claim_);
111112

112113
/// Specify endpoints manually
113114
OpenIDAccessTokenProcessor(const String & name_,
@@ -116,7 +117,7 @@ class OpenIDAccessTokenProcessor : public IAccessTokenProcessor
116117
const String & userinfo_endpoint_,
117118
const String & token_introspection_endpoint_,
118119
const String & jwks_uri_,
119-
const String & groups_claim_name_);
120+
const String & groups_claim_);
120121

121122
bool resolveAndValidate(const TokenCredentials & credentials) override;
122123
private:
@@ -127,7 +128,7 @@ class OpenIDAccessTokenProcessor : public IAccessTokenProcessor
127128
std::optional<JWKSValidator> jwt_validator = std::nullopt;
128129

129130
/// groups are expected under /userinfo endpoint under specified name
130-
const String groups_claim_name;
131+
const String groups_claim;
131132
};
132133

133134
}

src/Access/AuthenticationData.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ class AuthenticationData
8282
const String & getJWTClaims() const { return jwt_claims; }
8383
void setJWTClaims(const String & jwt_claims_) { jwt_claims = jwt_claims_; }
8484

85+
const String & getTokenProcessorName() const { return token_processor_name; }
86+
void setTokenProcessorName(const String & token_processor_name_) { token_processor_name = token_processor_name_; }
87+
8588
friend bool operator ==(const AuthenticationData & lhs, const AuthenticationData & rhs);
8689
friend bool operator !=(const AuthenticationData & lhs, const AuthenticationData & rhs) { return !(lhs == rhs); }
8790

@@ -121,6 +124,7 @@ class AuthenticationData
121124
HTTPAuthenticationScheme http_auth_scheme = HTTPAuthenticationScheme::BASIC;
122125
time_t valid_until = 0;
123126
String jwt_claims;
127+
String token_processor_name;
124128
};
125129

126130
}

0 commit comments

Comments
 (0)