Skip to content

oauth: redefine gating mode as pure OAuth resource server (#109)#110

Merged
Slach merged 5 commits intofeature/oauth-require-email-verifiedfrom
feature/dcr-via-auth0
May 11, 2026
Merged

oauth: redefine gating mode as pure OAuth resource server (#109)#110
Slach merged 5 commits intofeature/oauth-require-email-verifiedfrom
feature/dcr-via-auth0

Conversation

@BorisTyshkevich
Copy link
Copy Markdown
Collaborator

Closes #109. Alternative path to #106 (#106 is the v2 stateful approach; #109 is the v1 stateless approach using Auth0's native DCR + reuse-detection).

Note on PR base: stacked on top of #105 (which is stacked on #104). Until both predecessors merge, the diff shown is against feature/oauth-require-email-verified. Once #104 + #105 land, GitHub auto-rebases this onto main and the diff narrows to the #109 commits alone.

Why

Today oauth.mode: gating means MCP is the OAuth Authorization Server — DCR endpoint, /authorize, /token, JWE-wrapped refresh tokens, custom HKDF rotation, and (under #106) KeeperMap-backed refresh-token reuse detection. Auth0 Enterprise / Authentik / Keycloak / Okta all do these natively.

Under #109 oauth.mode: gating is redefined to mean MCP is a pure OAuth resource server: it validates AS-issued JWTs (signature + RFC 8707 audience byte-equality + expiry), enforces per-tool scopes, and impersonates the user to ClickHouse via cluster_secret + Auth.Username. The upstream IdP owns DCR, /authorize, /token, refresh rotation, and reuse detection.

Forward mode (oauth.mode: forward) is unchanged — kept for IdPs without DCR (Google direct, basic-tier Auth0).

Trade-offs (accepted up front)

What changes

Commit Effect
5cd4e87 Delete gating-mode AS handlers + state machinery (route registration gated by mode; forward path preserved)
124335b Refuse gating + AS-side fields at startup (client_id, client_secret, token_url, auth_url, userinfo_url, public_auth_server_url, refresh_revokes_tracking); require Issuer + Audience non-empty under gating
845149d Drop gating-AS test groups; add Audience to TestValidateOAuthRuntimeConfig fixtures
93008c7 Rewrite docs/oauth_authorization.md mode taxonomy + Auth0 setup checklist + migration note
3ab6738 Accept namespaced */email custom claim for CH impersonation (Auth0 enhanced-security third-party DCR strips the standard email claim — operators add it back via a post-login Action under a URL-prefixed key)

Net diff: ~580 production lines removed, ~580 test lines removed (gating-AS surface), ~210 docs lines added.

Live validation

Rolled out to two clusters (helm values live in the operator-side acm/mcp repo, not this PR):

  • otel-mcp (gating, post-oauth: gating mode simplification  #109 redefined): claude.ai DCR'd connector → Auth0 → MCP → CH currentUser() returns btyshkevich@gmail.com. RFC 9728 advertises Auth0 as the AS; /.well-known/oauth-authorization-server returns 404 under gating. End-to-end smoke pass.
  • antalya-mcp (forward, unchanged path): regression-free; CH returns rows via the existing <token_processor> flow. Confirms forward mode is preserved.

Operator runbook for replicating this on the next gating cluster (github, billing, customer deployments) is in the ops wiki at mcp-oauth-debugging.md § #109.

Test plan

  • go build ./... clean on the branch
  • go test ./pkg/{config,jwe_auth,clickhouse,server}/... ./cmd/altinity-mcp/... — pass (skipping embedded-CH harness tests, which are blocked by an environmental CH-26.3 bug unrelated to this PR — confirmed pre-existing on the base branch)
  • Live e2e on otel + antalya per the description above
  • CI green on the full stacked diff

🤖 Generated with Claude Code

BorisTyshkevich and others added 5 commits May 9, 2026 23:25
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>
@Slach Slach merged commit 37cce75 into feature/oauth-require-email-verified May 11, 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