Summary
A <token> user-directory can only authenticate JWTs from a single token_processor, so a deployment that wants multiple OIDC IdPs side-by-side (e.g. Google and an Auth0/Keycloak tenant) cannot do it — even though the per-request multi-processor verification logic already exists in ExternalAuthenticators::checkTokenCredentials.
Observed on v26.3.10.20001.altinityantalya.
Why it can't be done today
- Only one
<token> user-directory is allowed — AccessControl.cpp:514 throws "Only one token section can be defined."
- The
<token> directory requires a single, non-empty <processor> — TokenAccessStorage.cpp:215-217:
provider_name = config.getString(prefix_str + "processor");
if (provider_name.empty())
throw Exception(ErrorCodes::BAD_ARGUMENTS, "'processor' must be specified for Token user directory");
- It then authenticates against only that processor —
TokenAccessStorage.cpp:595:
if (!external_authenticators.checkTokenCredentials(token_credentials, provider_name))
The capability already exists
ExternalAuthenticators::checkTokenCredentials already iterates all configured processors when processor_name is empty (ExternalAuthenticators.cpp:855-868):
if (processor_name.empty())
{
for (const auto & [name, proc] : processors_snapshot)
if (auto result = try_processor(proc); result.has_value())
return *result; // first processor that validates wins
}
else
{
const auto it = processors_snapshot.find(processor_name); // single, pinned
...
}
Each JwksJwtProcessor independently verifies the JWT against its own JWKS (keyed by the token's kid) plus optional expected_issuer/expected_audience. With distinct IdPs that means a given token validates under exactly one processor (the others fail at signature/issuer), so first-match is deterministic. The processor-aware token cache already handles two processors resolving different username_claims from the same token (ExternalAuthenticators.cpp:~709).
So the only thing missing is letting the <token> directory opt into that auto-discovery path.
Proposed change
Let the <token> user-directory authenticate against all configured token_processors (or an explicit subset). Either:
- Make
<processor> optional — when omitted/empty, pass an empty processor_name to checkTokenCredentials (the existing auto-discovery branch), or
- Accept a
<processors> list (or repeated <processor>).
MultipleAccessStorage/<token> plumbing is otherwise unchanged; this is a config-surface + one parameter change.
Repro
<token_processors>
<google_oauth><type>jwt_dynamic_jwks</type><jwks_uri>https://www.googleapis.com/oauth2/v3/certs</jwks_uri><username_claim>email</username_claim></google_oauth>
<auth0><type>jwt_dynamic_jwks</type><jwks_uri>https://TENANT.auth0.com/.well-known/jwks.json</jwks_uri>
<expected_issuer>https://TENANT.auth0.com/</expected_issuer><expected_audience>CLIENT_ID</expected_audience><username_claim>email</username_claim></auth0>
</token_processors>
<user_directories>
<token><processor>google_oauth</processor><common_roles>...</common_roles></token>
</user_directories>
A valid Google id_token authenticates. A valid Auth0 id_token (correct sig/iss/aud) is rejected — There is no user '<name>' in 'token' (UNKNOWN_USER) — because the directory only consults google_oauth. Pinning to auth0 instead just inverts which IdP works.
Safety note
With multiple processors, each should set expected_issuer + expected_audience so a token binds to exactly one IdP (an un-pinned processor accepts any token its JWKS can verify — the server already warns about this). First-match-wins is safe given distinct JWKS/issuers.
Filed via Claude Code while wiring multi-IdP login into a ClickHouse-served SQL browser.
Summary
A
<token>user-directory can only authenticate JWTs from a singletoken_processor, so a deployment that wants multiple OIDC IdPs side-by-side (e.g. Google and an Auth0/Keycloak tenant) cannot do it — even though the per-request multi-processor verification logic already exists inExternalAuthenticators::checkTokenCredentials.Observed on
v26.3.10.20001.altinityantalya.Why it can't be done today
<token>user-directory is allowed —AccessControl.cpp:514throws "Only onetokensection can be defined."<token>directory requires a single, non-empty<processor>—TokenAccessStorage.cpp:215-217:TokenAccessStorage.cpp:595:if (!external_authenticators.checkTokenCredentials(token_credentials, provider_name))The capability already exists
ExternalAuthenticators::checkTokenCredentialsalready iterates all configured processors whenprocessor_nameis empty (ExternalAuthenticators.cpp:855-868):Each
JwksJwtProcessorindependently verifies the JWT against its own JWKS (keyed by the token'skid) plus optionalexpected_issuer/expected_audience. With distinct IdPs that means a given token validates under exactly one processor (the others fail at signature/issuer), so first-match is deterministic. The processor-aware token cache already handles two processors resolving differentusername_claims from the same token (ExternalAuthenticators.cpp:~709).So the only thing missing is letting the
<token>directory opt into that auto-discovery path.Proposed change
Let the
<token>user-directory authenticate against all configuredtoken_processors(or an explicit subset). Either:<processor>optional — when omitted/empty, pass an emptyprocessor_nametocheckTokenCredentials(the existing auto-discovery branch), or<processors>list (or repeated<processor>).MultipleAccessStorage/<token>plumbing is otherwise unchanged; this is a config-surface + one parameter change.Repro
A valid Google id_token authenticates. A valid Auth0 id_token (correct sig/iss/aud) is rejected —
There is no user '<name>' in 'token'(UNKNOWN_USER) — because the directory only consultsgoogle_oauth. Pinning toauth0instead just inverts which IdP works.Safety note
With multiple processors, each should set
expected_issuer+expected_audienceso a token binds to exactly one IdP (an un-pinned processor accepts any token its JWKS can verify — the server already warns about this). First-match-wins is safe given distinct JWKS/issuers.Filed via Claude Code while wiring multi-IdP login into a ClickHouse-served SQL browser.