Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
317f9e7
H-03
zvonand Apr 21, 2026
fbf0db6
H-04
zvonand Apr 21, 2026
b776e89
H-05
zvonand Apr 22, 2026
573ab08
H-06
zvonand Apr 22, 2026
714e57f
H-07
zvonand Apr 22, 2026
c7f0a65
H-08
zvonand Apr 23, 2026
b86e5ef
H-09
zvonand May 5, 2026
4c53201
H-10
zvonand May 5, 2026
4f084c1
H-11
zvonand May 5, 2026
be81da1
Revert "H-11"
zvonand May 5, 2026
45532e6
H-12
zvonand May 5, 2026
c5674a0
Revert "H-12"
zvonand May 5, 2026
394d349
H-13
zvonand May 5, 2026
3f281d1
H-14
zvonand May 5, 2026
826b39d
H-15
zvonand May 5, 2026
944341a
H-16
zvonand May 5, 2026
52d59b8
H-17
zvonand May 5, 2026
3252c49
H-18
zvonand May 5, 2026
1f98567
H-19
zvonand May 5, 2026
05fc443
H-20
zvonand May 5, 2026
2b976a9
H-21
zvonand May 5, 2026
77906e8
H-22
zvonand May 5, 2026
de521f2
H-25
zvonand May 5, 2026
d439628
H-26
zvonand May 5, 2026
624009c
H-27
zvonand May 5, 2026
8fc8ae4
H-28
zvonand May 5, 2026
c7aebfd
M-01
zvonand May 5, 2026
fd5811b
M-02
zvonand May 5, 2026
1d39816
M-06
zvonand May 5, 2026
97944b3
M-09
zvonand May 5, 2026
dc5a1e3
M-10
zvonand May 5, 2026
879ca13
M-11
zvonand May 5, 2026
b5a3375
M-13
zvonand May 5, 2026
d60bee7
M-14
zvonand May 5, 2026
ee2d06f
M-16
zvonand May 5, 2026
62b16d7
M-17
zvonand May 5, 2026
180b6ed
M-19
zvonand May 6, 2026
97998a4
M-20
zvonand May 6, 2026
1a7ed22
M-27
zvonand May 6, 2026
0417c5f
M-28
zvonand May 6, 2026
175dc6c
M-29
zvonand May 6, 2026
19ea3bc
M-31
zvonand May 6, 2026
a0fe223
L-02
zvonand May 6, 2026
cdc09d5
L-05
zvonand May 6, 2026
84e379a
L-10
zvonand May 6, 2026
768e0af
L-12
zvonand May 6, 2026
7b5200c
L-21
zvonand May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/Access/AccessControl.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,12 @@ void AccessControl::restoreFromBackup(RestorerFromBackup & restorer, const Strin

void AccessControl::setExternalAuthenticatorsConfig(const Poco::Util::AbstractConfiguration & config)
{
/// Re-read `enable_token_auth` on every config reload. `setupFromMainConfig`
/// runs only once at startup, so without this re-sync flipping the flag in
/// the config and triggering a reload would silently leave the previous
/// value in place -- operators who toggle token auth off in response to an
/// IdP outage or a credential leak would see no effect until restart.
setTokenAuthEnabled(config.getBool("enable_token_auth", true));
external_authenticators->setConfiguration(config, getLogger(), isTokenAuthEnabled());
}

Expand Down
39 changes: 37 additions & 2 deletions src/Access/AuthenticationData.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@ bool AuthenticationData::Util::checkPasswordBcrypt(std::string_view password [[m

bool operator ==(const AuthenticationData & lhs, const AuthenticationData & rhs)
{
/// `MemoryAccessStorage::updateNoLock` short-circuits when the existing
/// entity equals the new one, so any field omitted from this comparator
/// becomes invisible to ALTER USER -- same-type ALTER would silently
/// no-op. JWT users carry two extra fields (`token_processor_name` and
/// `jwt_claims`) and they MUST take part in equality, otherwise re-pinning
/// a JWT user via ALTER USER is a no-op (CREATE USER OR REPLACE works
/// only by accident, via storage->insertOrReplace).
return (lhs.type == rhs.type) && (lhs.password_hash == rhs.password_hash)
&& (lhs.ldap_server_name == rhs.ldap_server_name) && (lhs.kerberos_realm == rhs.kerberos_realm)
#if USE_SSL
Expand All @@ -160,6 +167,8 @@ bool operator ==(const AuthenticationData & lhs, const AuthenticationData & rhs)
#endif
&& (lhs.http_auth_scheme == rhs.http_auth_scheme)
&& (lhs.http_auth_server_name == rhs.http_auth_server_name)
&& (lhs.token_processor_name == rhs.token_processor_name)
&& (lhs.jwt_claims == rhs.jwt_claims)
&& (lhs.valid_until == rhs.valid_until);
}

Expand Down Expand Up @@ -405,9 +414,22 @@ boost::intrusive_ptr<ASTAuthenticationData> AuthenticationData::toAST() const
}
case AuthenticationType::JWT:
{
/// Round-trip into the same shape the parser produces: PROCESSOR
/// child first (when set), CLAIMS child after (when set), with the
/// AST flags telling the formatter which slot is which.
const auto & processor_name = getTokenProcessorName();
if (!processor_name.empty())
{
node->has_jwt_processor = true;
node->children.push_back(make_intrusive<ASTLiteral>(processor_name));
}

const auto & claims = getJWTClaims();
if (!claims.empty())
{
node->has_jwt_claims = true;
node->children.push_back(make_intrusive<ASTLiteral>(claims));
}
break;
}
case AuthenticationType::KERBEROS:
Expand Down Expand Up @@ -689,9 +711,22 @@ AuthenticationData AuthenticationData::fromAST(const ASTAuthenticationData & que
#if USE_JWT_CPP
else if (query.type == AuthenticationType::JWT)
{
if (!args.empty())
/// `query.has_jwt_processor` and `query.has_jwt_claims` describe which
/// of the two optional clauses the parser saw. Children are pushed in
/// PROCESSOR-then-CLAIMS order, so we walk them in that order.
size_t arg_idx = 0;

if (query.has_jwt_processor)
{
String processor_name = checkAndGetLiteralArgument<String>(args[arg_idx++], "processor");
if (processor_name.empty())
throw Exception(ErrorCodes::BAD_ARGUMENTS, "JWT 'PROCESSOR' name must not be empty");
auth_data.setTokenProcessorName(processor_name);
}

if (query.has_jwt_claims)
{
String value = checkAndGetLiteralArgument<String>(args[0], "claims");
String value = checkAndGetLiteralArgument<String>(args[arg_idx++], "claims");
picojson::value json_obj;
auto error = picojson::parse(json_obj, value);
if (!error.empty())
Expand Down
131 changes: 122 additions & 9 deletions src/Access/Common/JWKSProvider.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

#if USE_JWT_CPP
#include <Common/Exception.h>
#include <Common/logger_useful.h>
#include <filesystem>
#include <mutex>
#include <shared_mutex>
#include <system_error>
#include <Poco/Net/HTTPRequest.h>
#include <Poco/Net/HTTPResponse.h>
#include <Poco/Net/HTTPSClientSession.h>
Expand All @@ -21,28 +25,66 @@ namespace ErrorCodes

JWKSType JWKSClient::getJWKS()
{
/// `last_request_send` semantics: timestamp of the most recent fetch
/// *attempt*, success or failure. Updated unconditionally before the
/// HTTP call so a failed fetch doesn't leave the timestamp stale and
/// invite every concurrent thread to re-hammer a failing endpoint
/// (L-02). Within `refresh_timeout` of an attempt:
/// - if a previously-successful JWKS is cached, serve it.
/// - otherwise, throw a "fetch in cooldown" exception so callers
/// don't queue up new attempts during the back-off window.

{
std::shared_lock lock(mutex);
auto now = std::chrono::high_resolution_clock::now();
auto now = std::chrono::steady_clock::now();
auto diff = std::chrono::duration<double>(now - last_request_send).count();
if (diff < static_cast<double>(refresh_timeout) && cached_jwks.has_value())
return cached_jwks.value();
if (diff < static_cast<double>(refresh_timeout))
{
if (cached_jwks.has_value())
return cached_jwks.value();
throw Exception(ErrorCodes::AUTHENTICATION_FAILED,
"JWKS endpoint at '{}' is in cooldown after a recent failed fetch; will retry after the cache lifetime elapses",
jwks_uri.toString());
}
}

std::unique_lock lock(mutex);
auto now = std::chrono::high_resolution_clock::now();
auto diff = std::chrono::duration<double>(now - last_request_send).count();
if (diff < static_cast<double>(refresh_timeout) && cached_jwks.has_value())
return cached_jwks.value();
if (diff < static_cast<double>(refresh_timeout))
{
if (cached_jwks.has_value())
return cached_jwks.value();
throw Exception(ErrorCodes::AUTHENTICATION_FAILED,
"JWKS endpoint at '{}' is in cooldown after a recent failed fetch; will retry after the cache lifetime elapses",
jwks_uri.toString());
}

/// Mark the attempt before issuing the network call so that even if the
/// fetch throws, subsequent waiters on this mutex see an updated
/// `last_request_send` and short-circuit via the cooldown branches above
/// instead of repeating the failing fetch back-to-back.
last_request_send = now;

Poco::Net::HTTPResponse response;
std::string response_string;

Poco::Net::HTTPRequest request{Poco::Net::HTTPRequest::HTTP_GET, jwks_uri.getPathAndQuery()};

/// Bound every JWKS fetch to a known limit. Without this, Poco's default
/// `HTTPSession` timeout of 60 seconds applies, and because the JWKS fetch
/// runs while `ExternalAuthenticators::mutex` is held by the outer
/// `checkTokenCredentials` call, a single slow or hung JWKS endpoint would
/// stall the whole auth subsystem (LDAP, Kerberos, HTTP basic, all other
/// token auth paths) for up to a full minute per request. 10 seconds is a
/// conservative cap: well above any healthy provider latency, well below
/// the default.
const Poco::Timespan jwks_http_timeout(/*seconds=*/10, 0);

if (jwks_uri.getScheme() == "https")
{
Poco::Net::HTTPSClientSession session = Poco::Net::HTTPSClientSession(jwks_uri.getHost(), jwks_uri.getPort());
session.setTimeout(jwks_http_timeout, jwks_http_timeout, jwks_http_timeout);
session.sendRequest(request);
std::istream & response_stream = session.receiveResponse(response);
if (response.getStatus() != Poco::Net::HTTPResponse::HTTP_OK || !response_stream)
Expand All @@ -53,15 +95,14 @@ JWKSType JWKSClient::getJWKS()
else
{
Poco::Net::HTTPClientSession session = Poco::Net::HTTPClientSession(jwks_uri.getHost(), jwks_uri.getPort());
session.setTimeout(jwks_http_timeout, jwks_http_timeout, jwks_http_timeout);
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());
Poco::StreamCopier::copyToString(response_stream, response_string);
}

last_request_send = std::chrono::high_resolution_clock::now();

JWKSType parsed_jwks;

try
Expand Down Expand Up @@ -92,11 +133,18 @@ StaticJWKSParams::StaticJWKSParams(const std::string & static_jwks_, const std::

StaticJWKS::StaticJWKS(const StaticJWKSParams & params)
{
static_jwks_file = params.static_jwks_file;

String content = String(params.static_jwks);
if (!params.static_jwks_file.empty())
if (!static_jwks_file.empty())
{
std::ifstream ifs(params.static_jwks_file);
std::ifstream ifs(static_jwks_file);
Poco::StreamCopier::copyToString(ifs, content);
/// Record the mtime so subsequent `getJWKS()` calls can notice rotation.
std::error_code ec;
const auto write_time = std::filesystem::last_write_time(static_jwks_file, ec);
if (!ec)
last_loaded_mtime = write_time;
}
try
{
Expand All @@ -109,5 +157,70 @@ StaticJWKS::StaticJWKS(const StaticJWKSParams & params)
}
}

void StaticJWKS::reloadFromFileIfChangedNoLock()
{
/// Inline `static_jwks` source: nothing to refresh from disk.
if (static_jwks_file.empty())
return;

std::error_code ec;
const auto mtime = std::filesystem::last_write_time(static_jwks_file, ec);
if (ec)
{
/// File disappeared or became unreadable. Keep the previously-loaded
/// keys -- failing closed here would lock everyone out on a transient
/// filesystem hiccup. The operator gets a log signal.
LOG_WARNING(getLogger("TokenAuthentication"),
"StaticJWKS: failed to stat '{}' for refresh ({}); keeping previously-loaded keys.",
static_jwks_file, ec.message());
return;
}
if (mtime <= last_loaded_mtime)
return;

/// File has been rotated. Read + parse + swap.
String content;
try
{
std::ifstream ifs(static_jwks_file);
Poco::StreamCopier::copyToString(ifs, content);
auto new_keys = jwt::parse_jwks(content);
jwks = std::move(new_keys);
last_loaded_mtime = mtime;
LOG_INFO(getLogger("TokenAuthentication"),
"StaticJWKS: reloaded keys from '{}' after detecting mtime change.", static_jwks_file);
}
catch (const std::exception & e)
{
/// Malformed new JWKS: keep the old one. Loud signal so the operator
/// knows the rotation didn't take.
LOG_ERROR(getLogger("TokenAuthentication"),
"StaticJWKS: failed to parse '{}' on refresh: {}; keeping previously-loaded keys.",
static_jwks_file, e.what());
}
}

JWKSType StaticJWKS::getJWKS()
{
/// Fast path: shared lock + mtime check. Refresh under exclusive lock only
/// when the file actually changed.
{
std::shared_lock lock(mutex);
if (static_jwks_file.empty())
return jwks;

std::error_code ec;
const auto mtime = std::filesystem::last_write_time(static_jwks_file, ec);
if (ec)
return jwks;
if (mtime <= last_loaded_mtime)
return jwks;
}

std::unique_lock lock(mutex);
reloadFromFileIfChangedNoLock();
return jwks;
}

}
#endif
26 changes: 21 additions & 5 deletions src/Access/Common/JWKSProvider.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <base/types.h>
#include <jwt-cpp/jwt.h>
#include <jwt-cpp/traits/kazuho-picojson/traits.h>
#include <filesystem>
#include <shared_mutex>

#include <Poco/URI.h>
Expand Down Expand Up @@ -44,7 +45,9 @@ class JWKSClient : public IJWKSProvider

std::shared_mutex mutex;
std::optional<JWKSType> cached_jwks;
std::chrono::time_point<std::chrono::high_resolution_clock> last_request_send;
/// `steady_clock` (not `system_clock`): refresh-cooldown is an elapsed-time
/// measurement; a wall-clock jump must not skip or freeze it.
std::chrono::time_point<std::chrono::steady_clock> last_request_send;
};

struct StaticJWKSParams
Expand All @@ -60,12 +63,25 @@ class StaticJWKS : public IJWKSProvider
public:
explicit StaticJWKS(const StaticJWKSParams &params);

/// Reload the JWKS from disk if `static_jwks_file` was specified and the
/// file's mtime has advanced since the last load. Inline `static_jwks`
/// (no file path) is returned from the in-memory copy without I/O.
/// Without this, rotating the underlying file did NOT refresh the
/// in-memory keys -- admins had to trigger a full
/// `setExternalAuthenticatorsConfig` reload to pick up the new file.
JWKSType getJWKS() override;

private:
JWKSType getJWKS() override
{
return jwks;
}
void reloadFromFileIfChangedNoLock();

/// Source path -- empty when JWKS came from inline `<static_jwks>` config.
String static_jwks_file;
/// `mtime` of the file at the most recent successful load. Used to detect
/// rotation. `file_time_type::min()` means "not loaded from a file" or
/// "never seen the file yet".
std::filesystem::file_time_type last_loaded_mtime = std::filesystem::file_time_type::min();

mutable std::shared_mutex mutex;
JWKSType jwks;
};

Expand Down
Loading
Loading