|
1 | 1 | #include <Access/AccessTokenProcessor.h> |
2 | 2 | #include <Common/logger_useful.h> |
| 3 | +#include <Poco/StreamCopier.h> |
3 | 4 | #include <picojson/picojson.h> |
4 | 5 | #include <jwt-cpp/jwt.h> |
5 | 6 |
|
@@ -128,7 +129,7 @@ std::unique_ptr<IAccessTokenProcessor> IAccessTokenProcessor::parseTokenProcesso |
128 | 129 |
|
129 | 130 | if (is_auto && !is_manual) |
130 | 131 | { |
131 | | - return std::make_unique<OpenIDAccessTokenProcessor>(name, cache_lifetime, email_regex_str, config.getString(prefix + ".configuration_endpoint")); |
| 132 | + return std::make_unique<OpenIDAccessTokenProcessor>(name, cache_lifetime, email_regex_str, config.getString(prefix + ".configuration_endpoint"), config.getString(prefix + ".groups_claim_name", "")); |
132 | 133 | } |
133 | 134 | else if (!is_auto && is_manual) |
134 | 135 | { |
@@ -335,57 +336,120 @@ String AzureAccessTokenProcessor::validateTokenAndGetUsername(const String & tok |
335 | 336 | return getValueByKey(user_info_json, "sub"); |
336 | 337 | } |
337 | 338 |
|
| 339 | + |
| 340 | +OpenIDAccessTokenProcessor::OpenIDAccessTokenProcessor(const String & name_, |
| 341 | + const UInt64 cache_invalidation_interval_, |
| 342 | + const String & email_regex_str, |
| 343 | + const String & userinfo_endpoint_, |
| 344 | + const String & token_introspection_endpoint_, |
| 345 | + const String & jwks_uri_, |
| 346 | + const String & groups_claim_name_) |
| 347 | + : 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_) |
| 349 | +{ |
| 350 | + if (!jwks_uri_.empty()) |
| 351 | + { |
| 352 | + jwt_validator.emplace(name_ + "jwks_val", jwks_uri_, cache_invalidation_interval_); |
| 353 | + } |
| 354 | +} |
| 355 | + |
338 | 356 | OpenIDAccessTokenProcessor::OpenIDAccessTokenProcessor(const String & name_, |
339 | 357 | const UInt64 cache_invalidation_interval_, |
340 | 358 | const String & email_regex_str, |
341 | | - const String & openid_config_endpoint_) |
| 359 | + const String & openid_config_endpoint_, |
| 360 | + const String & groups_claim_name_) |
342 | 361 | : IAccessTokenProcessor(name_, cache_invalidation_interval_, email_regex_str) |
343 | 362 | { |
344 | 363 | const picojson::object openid_config = getObjectFromURI(Poco::URI(openid_config_endpoint_)); |
345 | 364 |
|
346 | 365 | if (!openid_config.contains("userinfo_endpoint") || !openid_config.contains("introspection_endpoint")) |
347 | 366 | throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "{}: Cannot extract userinfo_endpoint or introspection_endpoint from OIDC configuration, consider manual configuration.", name); |
| 367 | + |
| 368 | + OpenIDAccessTokenProcessor(name_, |
| 369 | + cache_invalidation_interval_, |
| 370 | + email_regex_str, |
| 371 | + getValueByKey(openid_config, "userinfo_endpoint"), |
| 372 | + getValueByKey(openid_config, "introspection_endpoint"), |
| 373 | + openid_config.contains("jwks_uri") ? getValueByKey(openid_config, "jwks_uri") : "", |
| 374 | + groups_claim_name_); |
348 | 375 | } |
349 | 376 |
|
350 | 377 | bool OpenIDAccessTokenProcessor::resolveAndValidate(const TokenCredentials & credentials) |
351 | 378 | { |
352 | 379 | const String & token = credentials.getToken(); |
| 380 | + String username; |
| 381 | + picojson::object user_info_json; |
353 | 382 |
|
354 | | - try |
| 383 | + if (jwt_validator.has_value() && jwt_validator.value().validate("", token, username)) |
355 | 384 | { |
356 | | - String username = validateTokenAndGetUsername(token); |
357 | | - if (!username.empty()) |
| 385 | + |
| 386 | + try |
358 | 387 | { |
359 | | - /// Credentials are passed as const everywhere up the flow, so we have to comply, |
360 | | - /// in this case const_cast looks acceptable. |
361 | | - const_cast<TokenCredentials &>(credentials).setUserName(username); |
| 388 | + auto decoded_token = jwt::decode(token); |
| 389 | + user_info_json = decoded_token.get_payload_json(); |
| 390 | + |
| 391 | + /// TODO: Now we work only with Keycloak -- and it provides expires_at in token itself. Need to add actual token introspection logic for other OIDC providers. |
| 392 | + if (decoded_token.has_expires_at()) |
| 393 | + const_cast<TokenCredentials &>(credentials).setExpiresAt(decoded_token.get_expires_at()); |
362 | 394 | } |
363 | | - else |
364 | | - LOG_TRACE(getLogger("AccessTokenProcessor"), "{}: Failed to get username with token", name); |
| 395 | + catch (const std::exception & ex) |
| 396 | + { |
| 397 | + LOG_TRACE(getLogger("AccessTokenProcessor"), "{}: Failed to process token as JWT: {}", name, ex.what()); |
| 398 | + } |
| 399 | + } |
| 400 | + |
| 401 | + /// If username or user info is empty -- local validation failed, trying introspection via provider |
| 402 | + if (username.empty() || user_info_json.empty()) |
| 403 | + { |
| 404 | + try |
| 405 | + { |
| 406 | + user_info_json = getObjectFromURI(userinfo_endpoint, token); |
| 407 | + username = getValueByKey(user_info_json, "sub"); |
| 408 | + } |
| 409 | + catch (...) |
| 410 | + { |
| 411 | + return false; |
| 412 | + } |
| 413 | + } |
365 | 414 |
|
| 415 | + if (user_info_json.empty()) |
| 416 | + { |
| 417 | + LOG_TRACE(getLogger("AccessTokenProcessor"), "{}: Failed to obtain user info", name); |
| 418 | + return false; |
366 | 419 | } |
367 | | - catch (...) |
| 420 | + else if (username.empty()) |
368 | 421 | { |
| 422 | + LOG_TRACE(getLogger("AccessTokenProcessor"), "{}: Failed to get username", name); |
369 | 423 | return false; |
370 | 424 | } |
371 | 425 |
|
372 | | - return true; |
| 426 | + /// Credentials are passed as const everywhere up the flow, so we have to comply, |
| 427 | + /// in this case const_cast is acceptable. |
| 428 | + const_cast<TokenCredentials &>(credentials).setUserName(username); |
373 | 429 |
|
374 | | - /// TODO: add proper groups functionality |
375 | | -// try |
376 | | -// { |
377 | | -// const_cast<TokenCredentials &>(credentials).setExpiresAt(jwt::decode(token).get_expires_at()); |
378 | | -// } |
379 | | -// catch (...) { |
380 | | -// LOG_TRACE(getLogger("AccessTokenProcessor"), |
381 | | -// "{}: No expiration data found in a valid token, will use default cache lifetime", name); |
382 | | -// } |
383 | | -} |
| 430 | + /// 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) |
| 431 | + /// 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)) |
| 433 | + { |
| 434 | + if (!user_info_json[groups_claim_name].is<picojson::array>()) |
| 435 | + { |
| 436 | + LOG_TRACE(getLogger("AccessTokenProcessor"), |
| 437 | + "{}: Failed to extract groups: invalid content in user data", name); |
| 438 | + return true; |
| 439 | + } |
384 | 440 |
|
385 | | -String OpenIDAccessTokenProcessor::validateTokenAndGetUsername(const String & token) const |
386 | | -{ |
387 | | - picojson::object user_info_json = getObjectFromURI(userinfo_endpoint, token); |
388 | | - return getValueByKey(user_info_json, "sub"); |
| 441 | + std::set<String> external_groups_names; |
| 442 | + |
| 443 | + picojson::array groups_array = user_info_json[groups_claim_name].get<picojson::array>(); |
| 444 | + for (const auto & group: groups_array) |
| 445 | + { |
| 446 | + if (group.is<std::string>()) |
| 447 | + external_groups_names.insert(group.get<std::string>()); |
| 448 | + } |
| 449 | + const_cast<TokenCredentials &>(credentials).setGroups(external_groups_names); |
| 450 | + } |
| 451 | + |
| 452 | + return true; |
389 | 453 | } |
390 | 454 |
|
391 | 455 | } |
0 commit comments