Skip to content

Feature: native oauth_token_handler — server-side OAuth2 code exchange (BFF for browser SPAs) #1930

Description

@BorisTyshkevich

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

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions