Skip to content

OAuth2 auto-refresh (proactive and reactive-on-401) is silently disabled whenever CLIENT_ID is empty, even with a fully valid client_secret/oauth2_server_uri #1378

Description

@b-per

Environment

  • DuckDB v1.5.5 (Variegata, d8cdaa33fd)
  • iceberg extension d987bfc2 (installed via core_nightly)
  • macOS arm64
  • (Source references below point at duckdb-iceberg commit 4a07bc48 — the closest public commit I could confirm the logic against; the exact nightly build installed may differ slightly.)

Summary

An OAuth2 ICEBERG secret configured with CLIENT_ID '' (empty string) — a valid, spec-compliant configuration required by at least one real-world provider (Snowflake Horizon/Polaris Catalog, see below) — never auto-refreshes its access token, either proactively before expiry or reactively after a 401. Once the cached token in the secret expires, every subsequent ATTACH using that secret fails hard with Unauthorized_401, even though fully valid client_secret + oauth2_server_uri + oauth2_scope are present and a fresh client_credentials exchange would succeed.

Root cause

OAuth2Authorization::CanRefreshUnlocked() gates all refresh attempts (both the proactive pre-request check and the reactive post-401 retry) on client_id being non-empty:

bool OAuth2Authorization::CanRefreshUnlocked(std::lock_guard<std::mutex> &lock) const {
// Internal method - caller must hold token_mutex
(void)lock;
// Can refresh if we have a refresh_token or if we have client credentials
return !refresh_token.empty() || (!client_id.empty() && !client_secret.empty() && !uri.empty());
}

bool OAuth2Authorization::CanRefreshUnlocked(std::lock_guard<std::mutex> &lock) const {
	(void)lock;
	return !refresh_token.empty() || (!client_id.empty() && !client_secret.empty() && !uri.empty());
}

This is called from both the proactive path and the reactive 401-retry path in Request():

unique_ptr<HTTPResponse> OAuth2Authorization::Request(RequestType request_type, ClientContext &context,
const IRCEndpointBuilder &endpoint_builder, HTTPHeaders &headers,
const string &data) {
// --- Step 1: Proactive refresh under lock, then copy token ---
// Serialized refresh: at most one thread refreshes at a time.
// Refresh I/O under lock is acceptable (rare, bounded by token lifetime).
// Threads that queue behind the mutex will re-check expiry, see the
// fresh token, and skip refresh.
string bearer_token;
{
std::lock_guard<std::mutex> lock(token_mutex);
if (IsTokenExpiredUnlocked(context, lock) && CanRefreshUnlocked(lock)) {
RefreshAccessTokenUnlocked(context, lock);
}
bearer_token = token;
}
// Lock released -- catalog HTTP request runs concurrently with other threads.
// --- Step 2: Build headers and make the catalog request ---
for (auto &entry : extra_http_headers) {
headers.Insert(entry.first, entry.second);
}
if (!bearer_token.empty()) {
headers["Authorization"] = StringUtil::Format("Bearer %s", bearer_token);
}
auto response = APIUtils::Request(request_type, db, context, endpoint_builder, headers, data);
// --- Step 3: Reactive 401 refresh (exactly once) ---
// If the server rejected our token (e.g., revoked before expiry, clock skew,
// audience change), refresh once and retry. Guard against infinite loops.
if (response->status == HTTPStatusCode::Unauthorized_401) {
bool should_retry = false;
{
std::lock_guard<std::mutex> lock(token_mutex);
if (CanRefreshUnlocked(lock)) {
RefreshAccessTokenUnlocked(context, lock);
bearer_token = token;
should_retry = true;
}
}
// Lock released before retry -- avoid serializing catalog requests
if (should_retry) {
headers["Authorization"] = StringUtil::Format("Bearer %s", bearer_token);
response = APIUtils::Request(request_type, db, context, endpoint_builder, headers, data);
}
}
return response;
}

...which is the exact method invoked by the initial catalog handshake:

rest_api_objects::CatalogConfig IRCAPI::GetCatalogConfig(ClientContext &context, IcebergCatalog &catalog,
const string &warehouse) {
auto url_builder = catalog.GetBaseUrl();
url_builder.AddPathComponent(IRCPathComponent::RegularComponent("config"));
if (!warehouse.empty()) {
url_builder.SetParam("warehouse", IRCPathComponent::RegularComponent(warehouse));
}
string body = "";
HTTPHeaders headers(*context.db);
auto response = catalog.auth_handler->Request(RequestType::GET_REQUEST, context, url_builder, headers, body);
if (response->status != HTTPStatusCode::OK_200) {
throw InvalidConfigurationException("Request to '%s' returned a non-200 status code (%s), with reason: %s",
url_builder.GetURLEncoded(), EnumUtil::ToString(response->status),
response->reason);
}
auto doc = ICUtils::APIResultToDoc(response->body);
auto *root = yyjson_doc_get_root(doc.get());
return rest_api_objects::CatalogConfig::FromJSON(root);
}

When client_id is empty but client_secret/uri are present and valid, CanRefreshUnlocked() returns false, so Request() never attempts a refresh — the stale/invalid token is used (or, on 401, no retry happens) and the error surfaces straight to the caller.

A secondary, related issue: UpdateTokenState() computes the cached token's expiry as now() + expires_in whenever a secret is loaded (not just when freshly issued):

if (effective_expires_in > 0) {
// Calculate expiry time with safety buffer (clamped to avoid negative durations)
auto now = std::chrono::system_clock::now();
auto buffer_seconds =
std::min(30, effective_expires_in / 2); // Use 30s or half the lifetime, whichever is smaller
auto expiry_duration = std::chrono::seconds(effective_expires_in - buffer_seconds);
auto expiry_time = now + expiry_duration;
token_expires_at = std::chrono::duration_cast<std::chrono::seconds>(expiry_time.time_since_epoch()).count();

Because the persisted secret only stores the relative expires_in, never an absolute issued-at timestamp, a new process loading an already-expired cached token believes it's fresh — so the proactive check never catches it either. This makes the CLIENT_ID-empty bug worse in practice (it's not just "no refresh on 401," it's "the token gets reused across process restarts long past its real expiry, then fails with no recovery path").

Why an empty CLIENT_ID is a legitimate configuration, not misuse

Snowflake's Horizon/Polaris Iceberg REST Catalog requires this exact shape when authenticating with a Programmatic Access Token (PAT): grant_type, scope, and client_secret only — no client_id field at all. Snowflake's own documentation shows this explicitly:

curl -i --fail -X POST "https://<account_identifier>.snowflakecomputing.com/polaris/api/catalog/v1/oauth/tokens" \
 --header 'Content-Type: application/x-www-form-urlencoded' \
 --data-urlencode 'grant_type=client_credentials' \
 --data-urlencode 'scope=session:role:<role>' \
 --data-urlencode 'client_secret=<PAT_token>'

Access Apache Iceberg tables with an external engine through Snowflake Horizon Catalog (Snowflake Documentation)

We independently confirmed (via direct curl testing) that supplying any non-empty client_id to this endpoint gets rejected (unauthorized_client/invalid_scope), while DuckDB's CREATE SECRET ... TYPE ICEBERG requires the CLIENT_ID field to be present syntactically — CLIENT_ID '' is the only way to satisfy both constraints simultaneously. CanRefreshUnlocked() then punishes that exact, necessary workaround by treating "empty" as "not configured."

Minimal repro

-- Using any provider with a client_credentials endpoint that accepts an
-- empty client_id (e.g. Snowflake Horizon Catalog with a PAT):
CREATE OR REPLACE PERSISTENT SECRET repro_secret (
    TYPE ICEBERG,
    CLIENT_ID '',
    CLIENT_SECRET '<valid PAT/client_secret>',
    OAUTH2_SERVER_URI 'https://<account>.snowflakecomputing.com/polaris/api/catalog/v1/oauth/tokens',
    OAUTH2_SCOPE 'session:role:<role>',
    -- Force a broken cached token to simulate "expired token reused after
    -- a process restart" without waiting out the real ~1hr expiry:
    TOKEN 'deliberately_garbage_token_to_force_401',
    EXPIRES_IN 3600
);

ATTACH '<warehouse>' AS repro (
    TYPE ICEBERG,
    SECRET repro_secret,
    ENDPOINT 'https://<account>.snowflakecomputing.com/polaris/api/catalog'
);
-- Expected: transparent refresh + retry, ATTACH succeeds.
-- Actual: hard failure --
-- Invalid Configuration Error: Request to '.../v1/config?warehouse=...'
-- returned a non-200 status code (Unauthorized_401), with reason: Unauthorized

Expected behavior

CanRefreshUnlocked() should treat client_id as "configured" whenever it's been explicitly set (including to an empty string) alongside a non-empty client_secret and uri — i.e., gate on whether the client_credentials fields were provided at all, not on client_id being non-empty. Separately, persisting an absolute token-issued-at (or expires-at) timestamp in the secret, rather than only a relative expires_in, would let the proactive check correctly detect staleness across process restarts.

Suggested fix

bool OAuth2Authorization::CanRefreshUnlocked(std::lock_guard<std::mutex> &lock) const {
	(void)lock;
	return !refresh_token.empty() || (!client_secret.empty() && !uri.empty());
}

(dropping the !client_id.empty() condition — an empty client_id is a valid value for the client_credentials grant per RFC 6749 §2.3.1, which only requires client authentication when the client "was issued client credentials," not that a non-empty id string be present)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions