Summary
Add a small native HTTP handler type that performs the OAuth 2.0 authorization-code → token exchange on the ClickHouse server, using IdP credentials already in server config. This lets a browser SPA served directly from ClickHouse (e.g. via an <http_handlers> static rule) complete an OIDC login without ever shipping the client_secret to the browser.
ClickHouse already does the two hard parts of this — it stores an OIDC client secret in config and it POSTs to an IdP token endpoint and parses the JSON — for token introspection and for GCP auth. This proposal wires those existing pieces to an HTTP endpoint so the secret-bearing step happens server-side.
Motivation
A growing pattern is to serve a static SPA from ClickHouse itself (an <http_handlers> rule returning an HTML file from user_files) that then queries the same ClickHouse with an OIDC Bearer token — no separate backend. For IdPs that support public PKCE clients (Auth0, Okta, Entra, Cognito) this needs no secret. But Google's "Web application" client requires a client_secret on the token exchange even with PKCE, so a pure-SPA deployment is forced to publish the secret in a browser-readable config file (it's fetched pre-auth). Locking the redirect URI and using an "Internal" consent screen mitigates this, but the secret is still public.
The clean fix is a Backend-For-Frontend that holds the secret and does the code↔token exchange server-side. Today that means standing up a separate server — which defeats the "no backend, served from ClickHouse" design. Since ClickHouse is already the server here (and Antalya is already OIDC-aware), a tiny handler closes the gap with no extra infrastructure.
What already exists (so this is small)
| Building block |
Where |
Note |
| OIDC client secret in config |
src/Access/TokenProcessorsParse.cpp (introspection_client_id / introspection_client_secret, ~L99–105); stored on OpenIdTokenProcessor, src/Access/TokenProcessors.h (~L238) |
Secret already lives server-side for RFC 7662 introspection |
Form-POST to an IdP endpoint with client_id/client_secret |
src/Access/TokenProcessorsOpaque.cpp postFormToURI(...) (~L153, used ~L574) |
Exactly the call shape a code-exchange needs |
POST to Google's token endpoint + JSON parse (incl. id_token) |
src/IO/GCPOAuth.cpp (oauth2.googleapis.com/token, grant_type=refresh_token, Poco::JSON) |
Near-identical precedent; swap to grant_type=authorization_code |
OIDC discovery (.well-known/openid-configuration) |
src/Access/TokenProcessorsOpaque.cpp getObjectFromURI(...) (~L411, L474–562) |
Already extracts userinfo/introspection/jwks — does not yet read token_endpoint |
| Named IdP registry |
src/Access/ExternalAuthenticators.{h,cpp} (token_processors map, lookup by name) |
A handler can reuse a processor's client_id/secret/discovered endpoints |
| HTTP handler framework |
src/Server/HTTPHandlerFactory.cpp createHandlersFactoryFromConfig() type dispatch; base src/Server/HTTP/HTTPRequestHandler.h; small template src/Server/ReplicasStatusHandler.{h,cpp} |
Add one else if + a factory + a handler class |
| Outbound TLS / timeouts / SSRF guard |
src/IO/HTTPCommon.h (makeHTTPSession), ConnectionTimeouts, RemoteHostFilter (already threaded into OpenIdTokenProcessor) |
Reuse for the token-endpoint call |
Proposed design
Config
A new handler type under <http_handlers>, ideally referencing an existing <token_processors> entry to reuse its credentials and OIDC discovery (DRY, one place for secrets):
<http_handlers>
<rule>
<url>/oauth/token</url>
<methods><method>POST</method></methods>
<handler>
<type>oauth_token_handler</type>
<!-- reuse client_id/secret + discovered token_endpoint from this processor -->
<token_processor>google</token_processor>
<!-- redirect_uri allowlist (anti-open-redirect / must match the SPA origin) -->
<allowed_redirect_uris>https://ch.example.com/sql</allowed_redirect_uris>
</handler>
</rule>
</http_handlers>
Fallback: allow inline <token_url>, <client_id>, <client_secret> on the handler for non-token_processors setups.
Request / response contract
- Request (
POST /oauth/token, same-origin from the SPA), form or JSON: code, redirect_uri, optional code_verifier (PKCE pass-through), optional state.
- Server looks up the referenced processor, resolves the token endpoint (from OIDC discovery or config), and POSTs
grant_type=authorization_code&code=…&redirect_uri=…&client_id=…&client_secret=…[&code_verifier=…] — i.e. postFormToURI(token_endpoint, {…}, client_id, client_secret) / the GCPOAuth.cpp POST shape.
- Response: relay the IdP token JSON (
id_token, expires_in, …) to the browser. The browser stores it and continues to authenticate queries with Bearer <id_token> exactly as today — no change to the query/auth path or to token_processors verification.
Implementation steps
- Extract
token_endpoint during OIDC discovery in TokenProcessorsOpaque.cpp (alongside userinfo_endpoint/introspection_endpoint), and/or accept a token_endpoint config key in TokenProcessorsParse.cpp. Store it on OpenIdTokenProcessor.
- New handler
OAuthTokenHandler : HTTPRequestHandler (modeled on ReplicasStatusHandler): parse code/redirect_uri/code_verifier (via HTMLForm / request.getStream()), validate redirect_uri against the allowlist, call the token endpoint with the configured secret (reusing postFormToURI / the GCPOAuth.cpp pattern, guarded by RemoteHostFilter + ConnectionTimeouts), return the token JSON.
- Register
oauth_token_handler in the createHandlersFactoryFromConfig() dispatch in HTTPHandlerFactory.cpp, with a createOAuthTokenHandlerFactory(...) reading the config above.
Security considerations
- Pin the token endpoint from config/discovery, never from the request → no SSRF/open-proxy. Reuse the existing
RemoteHostFilter (allow_http_discovery_urls style) already wired into OpenIdTokenProcessor.
redirect_uri allowlist so the handler can't be used to mint codes for arbitrary callbacks.
- POST-only, rate-limited, same-origin recommended; never log the
code, client_secret, or returned tokens.
- The handler is reachable pre-auth (it runs before the user has a CH token) — treat it as a hardened, narrowly-scoped endpoint, not a generic proxy. It does only the code-exchange relay.
- Note: this adds an outbound-calling, secret-holding endpoint to the server's network surface — a deliberate (if small) expansion of responsibility worth weighing.
Scope / non-goals
- Not a full OAuth server; just the authorization-code → token relay (optionally refresh, mirroring
GCPOAuth.cpp).
- Tokens still live client-side (the SPA keeps using
Bearer); only the secret moves server-side. (A cookie-session model would be a larger, separate change.)
- Verification (
token_processors) is unchanged.
Alternatives considered
- Public PKCE client (no secret): the right answer for IdPs that support it; this proposal targets the Google case that forces a secret.
- Separate BFF/proxy service: the textbook fix, but requires standing up and operating a server — exactly what the "served-from-ClickHouse, no backend" deployments avoid.
- Google Identity Services
id_token flow: no secret, no backend, but no refresh token and a Google-specific browser code path.
- Ship the secret + lock redirect URI + Internal consent: today's mitigation; makes the public secret nearly inert but doesn't remove it.
Open questions
- Reuse
introspection_client_secret (semantically "the IdP client secret") or introduce a clearer shared client_secret on the processor?
- Should the handler optionally set an
HttpOnly cookie instead of returning the token, for deployments that proxy queries too? (Out of scope for v1.)
Drafted with Claude Code against the Antalya tree; file/line references from a read-through of src/Access/TokenProcessors*, src/IO/GCPOAuth.cpp, and src/Server/HTTPHandlerFactory.cpp. Happy to follow up with a PoC PR.
Summary
Add a small native HTTP handler type that performs the OAuth 2.0 authorization-code → token exchange on the ClickHouse server, using IdP credentials already in server config. This lets a browser SPA served directly from ClickHouse (e.g. via an
<http_handlers>static rule) complete an OIDC login without ever shipping theclient_secretto the browser.ClickHouse already does the two hard parts of this — it stores an OIDC client secret in config and it POSTs to an IdP token endpoint and parses the JSON — for token introspection and for GCP auth. This proposal wires those existing pieces to an HTTP endpoint so the secret-bearing step happens server-side.
Motivation
A growing pattern is to serve a static SPA from ClickHouse itself (an
<http_handlers>rule returning an HTML file fromuser_files) that then queries the same ClickHouse with an OIDCBearertoken — no separate backend. For IdPs that support public PKCE clients (Auth0, Okta, Entra, Cognito) this needs no secret. But Google's "Web application" client requires aclient_secreton the token exchange even with PKCE, so a pure-SPA deployment is forced to publish the secret in a browser-readable config file (it's fetched pre-auth). Locking the redirect URI and using an "Internal" consent screen mitigates this, but the secret is still public.The clean fix is a Backend-For-Frontend that holds the secret and does the code↔token exchange server-side. Today that means standing up a separate server — which defeats the "no backend, served from ClickHouse" design. Since ClickHouse is already the server here (and Antalya is already OIDC-aware), a tiny handler closes the gap with no extra infrastructure.
What already exists (so this is small)
src/Access/TokenProcessorsParse.cpp(introspection_client_id/introspection_client_secret, ~L99–105); stored onOpenIdTokenProcessor,src/Access/TokenProcessors.h(~L238)client_id/client_secretsrc/Access/TokenProcessorsOpaque.cpppostFormToURI(...)(~L153, used ~L574)id_token)src/IO/GCPOAuth.cpp(oauth2.googleapis.com/token,grant_type=refresh_token, Poco::JSON)grant_type=authorization_code.well-known/openid-configuration)src/Access/TokenProcessorsOpaque.cppgetObjectFromURI(...)(~L411, L474–562)token_endpointsrc/Access/ExternalAuthenticators.{h,cpp}(token_processorsmap, lookup by name)src/Server/HTTPHandlerFactory.cppcreateHandlersFactoryFromConfig()type dispatch; basesrc/Server/HTTP/HTTPRequestHandler.h; small templatesrc/Server/ReplicasStatusHandler.{h,cpp}else if+ a factory + a handler classsrc/IO/HTTPCommon.h(makeHTTPSession),ConnectionTimeouts,RemoteHostFilter(already threaded intoOpenIdTokenProcessor)Proposed design
Config
A new handler type under
<http_handlers>, ideally referencing an existing<token_processors>entry to reuse its credentials and OIDC discovery (DRY, one place for secrets):Fallback: allow inline
<token_url>,<client_id>,<client_secret>on the handler for non-token_processorssetups.Request / response contract
POST /oauth/token, same-origin from the SPA), form or JSON:code,redirect_uri, optionalcode_verifier(PKCE pass-through), optionalstate.grant_type=authorization_code&code=…&redirect_uri=…&client_id=…&client_secret=…[&code_verifier=…]— i.e.postFormToURI(token_endpoint, {…}, client_id, client_secret)/ theGCPOAuth.cppPOST shape.id_token,expires_in, …) to the browser. The browser stores it and continues to authenticate queries withBearer <id_token>exactly as today — no change to the query/auth path or totoken_processorsverification.Implementation steps
token_endpointduring OIDC discovery inTokenProcessorsOpaque.cpp(alongsideuserinfo_endpoint/introspection_endpoint), and/or accept atoken_endpointconfig key inTokenProcessorsParse.cpp. Store it onOpenIdTokenProcessor.OAuthTokenHandler : HTTPRequestHandler(modeled onReplicasStatusHandler): parsecode/redirect_uri/code_verifier(viaHTMLForm/request.getStream()), validateredirect_uriagainst the allowlist, call the token endpoint with the configured secret (reusingpostFormToURI/ theGCPOAuth.cpppattern, guarded byRemoteHostFilter+ConnectionTimeouts), return the token JSON.oauth_token_handlerin thecreateHandlersFactoryFromConfig()dispatch inHTTPHandlerFactory.cpp, with acreateOAuthTokenHandlerFactory(...)reading the config above.Security considerations
RemoteHostFilter(allow_http_discovery_urlsstyle) already wired intoOpenIdTokenProcessor.redirect_uriallowlist so the handler can't be used to mint codes for arbitrary callbacks.code,client_secret, or returned tokens.Scope / non-goals
GCPOAuth.cpp).Bearer); only the secret moves server-side. (A cookie-session model would be a larger, separate change.)token_processors) is unchanged.Alternatives considered
id_tokenflow: no secret, no backend, but no refresh token and a Google-specific browser code path.Open questions
introspection_client_secret(semantically "the IdP client secret") or introduce a clearer sharedclient_secreton the processor?HttpOnlycookie instead of returning the token, for deployments that proxy queries too? (Out of scope for v1.)Drafted with Claude Code against the Antalya tree; file/line references from a read-through of
src/Access/TokenProcessors*,src/IO/GCPOAuth.cpp, andsrc/Server/HTTPHandlerFactory.cpp. Happy to follow up with a PoC PR.