oauth: redefine gating mode as pure OAuth resource server (#109)#110
Merged
Slach merged 5 commits intofeature/oauth-require-email-verifiedfrom May 11, 2026
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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).
Why
Today
oauth.mode: gatingmeans 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: gatingis 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 viacluster_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)
access_token_lifetime: 600on the per-cluster Auth0 API.pkg/oauth_state, KeeperMap) is no longer needed — oauth: H-2 — refresh-token reuse detection (gating mode) #106 stays open as a v2 follow-up if/when the role-picker forces MCP back into the AS business.What changes
5cd4e87124335bgating+ AS-side fields at startup (client_id,client_secret,token_url,auth_url,userinfo_url,public_auth_server_url,refresh_revokes_tracking); requireIssuer+Audiencenon-empty under gating845149dAudienceto TestValidateOAuthRuntimeConfig fixtures93008c7docs/oauth_authorization.mdmode taxonomy + Auth0 setup checklist + migration note3ab6738*/emailcustom claim for CH impersonation (Auth0 enhanced-security third-party DCR strips the standardemailclaim — 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):
currentUser()returnsbtyshkevich@gmail.com. RFC 9728 advertises Auth0 as the AS;/.well-known/oauth-authorization-serverreturns 404 under gating. End-to-end smoke pass.<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 atmcp-oauth-debugging.md§ #109.Test plan
go build ./...clean on the branchgo 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)🤖 Generated with Claude Code