Skip to content

Feature/oauth require email verified#111

Merged
Slach merged 7 commits into
mainfrom
feature/oauth-require-email-verified
May 12, 2026
Merged

Feature/oauth require email verified#111
Slach merged 7 commits into
mainfrom
feature/oauth-require-email-verified

Conversation

@Slach
Copy link
Copy Markdown
Collaborator

@Slach Slach commented May 11, 2026

@BorisTyshkevich please use main as base branch always

BorisTyshkevich and others added 7 commits May 7, 2026 19:38
…uire_email_verified

In gating + cluster_secret mode, GetClickHouseClientWithOAuth uses
`oauthClaims.Email` verbatim as the ClickHouse `initial_user` field
(server_client.go:303-311). ClickHouse trusts that impersonation
because the cluster_secret authenticates the peer; the username is
just metadata. So without `require_email_verified=true`, any IdP-
issued token with `email_verified=false` (e.g. an Auth0 Database
Connection that forgot to require verification, a self-hosted OIDC,
a federated partner IdP) lets the bearer impersonate any provisioned
ClickHouse user just by typing their email at registration.

Reproducer: Auth0 tenant with both Google Social + a Database
Connection that doesn't verify email. Attacker registers
`admin@altinity.com` on the Database Connection (no verification
sent / required), logs in via that connection, gets an id_token
with `email=admin@altinity.com, email_verified=false`. MCP
validates signature/iss/aud/exp — all pass. MCP routes the bearer
to gating-mode CH using cluster_secret with `initial_user=
admin@altinity.com`. CH looks up admin, applies admin's grants.
Attacker now runs queries as admin.

`allowed_email_domains` alone doesn't fix this — domain restriction
allows `admin@altinity.com` regardless of `email_verified`. The
actual protection is `require_email_verified=true`.

Fix: validateOAuthRuntimeConfig now refuses to start when:
- OAuth is enabled in gating mode AND
- ClickHouse cluster_secret is set AND
- require_email_verified is false

Forces operators to opt in explicitly. Auth0 + Google Social
deployments can flip the flag with one config line; deployments
that intentionally federate with non-verifying IdPs need to
redesign the impersonation path (don't use cluster_secret with
unverified-email-source IdPs).

Tests: 4 new sub-tests in TestValidateOAuthRuntimeConfig covering
the unsafe combo, the safe-with-flag combo, the no-cluster-secret
case (unaffected), and the forward-mode case (unaffected because
forward never uses Email as initial_user).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Under the new gating semantics MCP no longer acts as the OAuth
Authorization Server — Auth0 (or another DCR-capable AS) owns DCR,
/authorize, /token, refresh rotation, and reuse detection. MCP's
job under gating is to validate AS-issued JWTs (signature + RFC 8707
audience byte-equality + expiry) and authorize per-tool scopes.

Code changes:
- registerOAuthHTTPRoutes: under gating, only /.well-known/oauth-protected-resource is registered. Forward-mode routes unchanged.
- handleOAuthProtectedResource: under gating advertises cfg.OAuth.Issuer as the upstream AS; forward unchanged.
- handleOAuthCallback / handleOAuthTokenAuthCode: gating mint branches removed; forward path retained.
- handleOAuthTokenRefresh and the gating-only mint helpers (gatingIdentity, mintGatingTokenResponse, encodeSelfIssuedAccessToken) deleted.
- ValidateOAuthToken: both modes now route through parseAndVerifyExternalJWT (JWKS); parseAndVerifySelfIssuedOAuthToken removed.
- validateOAuthClaims: issuer enforcement deferred to parseAndVerifyExternalJWT (UpstreamIssuerAllowlist); kept audience, expiry, scopes, identity-policy checks.

Forward mode (Google direct, basic-tier Auth0, IdPs without DCR) is
preserved unchanged — this is the supported escape hatch when the
upstream AS doesn't support DCR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Now that gating mode no longer runs an AS leg, six OAuthConfig fields
become forward-mode-exclusive: ClientID, ClientSecret, TokenURL,
AuthURL, UserInfoURL, PublicAuthServerURL. Under gating these are
silent dead weight that confuses operators migrating from the v1
"MCP-as-AS" gating semantics.

validateOAuthRuntimeConfig now refuses startup with operator-oriented
errors pointing at helm values when any of these fields are set under
gating, and requires Issuer + Audience non-empty under gating
(RFC 8707 byte-equality target for AS-issued JWTs).

Forward-mode validation is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ures (#109)

Removes test surface that exercised the deleted gating-mode AS leg:
self-issued access-token mint/decode, gating refresh-token grant,
reuse-detection, gating-flow-e2e, and the standalone-claims issuer
checks (issuer is now enforced upstream in parseAndVerifyExternalJWT
via UpstreamIssuerAllowlist).

Adds OAuth.Audience to three TestValidateOAuthRuntimeConfig fixtures
that exercise gating mode — the new startup check requires Audience
to be non-empty under gating.

Forward-mode test coverage is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reflects the post-#109 semantics: gating mode is now a pure OAuth
resource server, forward mode is unchanged. Adds:

- Mode-taxonomy table + per-mode helm-values examples (live: otel
  for gating, antalya for forward)
- Auth0 setup checklist for gating mode (per-cluster API resource,
  scopes, token policy, RFC 8707 + DCR notes)
- Migration note: forbidden fields under gating that will refuse
  startup (client_id, client_secret, token_url, auth_url,
  userinfo_url, public_auth_server_url, refresh_revokes_tracking)
- Rewritten Refresh Tokens / Identity Policy / Discovery sections
  to differentiate gating-mode (Auth0-managed) vs forward-mode
  (MCP-mediated) flows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auth0 enhanced-security third-party (DCR) clients silently drop
non-namespaced custom claims from access tokens. Operators
work around this with a post-login Action that re-adds email under
a URL-prefixed claim key, e.g. `https://mcp.altinity.cloud/email`.

The cluster_secret + Auth.Username impersonation path now accepts
either the standard `email` claim, any `*/email` namespaced claim
from OAuthClaims.Extra, or finally `sub` as a last-resort fallback.

Required to make claude.ai / ChatGPT MCP connectors against an
Auth0-fronted gating-mode MCP impersonate the OAuth user (rather
than the Google user-id "google-oauth2|...") to ClickHouse.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
oauth: redefine gating mode as pure OAuth resource server (#109)
@Slach Slach requested a review from BorisTyshkevich May 12, 2026 06:10
@Slach Slach merged commit 37cf63b into main May 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants