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)
Environment
v1.5.5(Variegata,d8cdaa33fd)icebergextensiond987bfc2(installed viacore_nightly)duckdb-icebergcommit4a07bc48— the closest public commit I could confirm the logic against; the exact nightly build installed may differ slightly.)Summary
An OAuth2
ICEBERGsecret configured withCLIENT_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 subsequentATTACHusing that secret fails hard withUnauthorized_401, even though fully validclient_secret+oauth2_server_uri+oauth2_scopeare present and a freshclient_credentialsexchange would succeed.Root cause
OAuth2Authorization::CanRefreshUnlocked()gates all refresh attempts (both the proactive pre-request check and the reactive post-401 retry) onclient_idbeing non-empty:duckdb-iceberg/src/catalog/rest/storage/authorization/oauth2.cpp
Lines 619 to 624 in 4a07bc4
This is called from both the proactive path and the reactive 401-retry path in
Request():duckdb-iceberg/src/catalog/rest/storage/authorization/oauth2.cpp
Lines 494 to 543 in 4a07bc4
...which is the exact method invoked by the initial catalog handshake:
duckdb-iceberg/src/catalog/rest/api/catalog_api.cpp
Lines 563 to 581 in 4a07bc4
When
client_idis empty butclient_secret/uriare present and valid,CanRefreshUnlocked()returnsfalse, soRequest()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 asnow() + expires_inwhenever a secret is loaded (not just when freshly issued):duckdb-iceberg/src/catalog/rest/storage/authorization/oauth2.cpp
Lines 581 to 588 in 4a07bc4
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 theCLIENT_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_IDis a legitimate configuration, not misuseSnowflake's Horizon/Polaris Iceberg REST Catalog requires this exact shape when authenticating with a Programmatic Access Token (PAT):
grant_type,scope, andclient_secretonly — noclient_idfield at all. Snowflake's own documentation shows this explicitly:— Access Apache Iceberg tables with an external engine through Snowflake Horizon Catalog (Snowflake Documentation)
We independently confirmed (via direct
curltesting) that supplying any non-emptyclient_idto this endpoint gets rejected (unauthorized_client/invalid_scope), while DuckDB'sCREATE SECRET ... TYPE ICEBERGrequires theCLIENT_IDfield 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
Expected behavior
CanRefreshUnlocked()should treatclient_idas "configured" whenever it's been explicitly set (including to an empty string) alongside a non-emptyclient_secretanduri— i.e., gate on whether the client_credentials fields were provided at all, not onclient_idbeing non-empty. Separately, persisting an absolute token-issued-at (or expires-at) timestamp in the secret, rather than only a relativeexpires_in, would let the proactive check correctly detect staleness across process restarts.Suggested fix
(dropping the
!client_id.empty()condition — an emptyclient_idis a valid value for theclient_credentialsgrant 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)