feat(auth): trusted-gateway identity mode (verify proxy-signed assertion as principal) - #981
Conversation
…r-user isolation Adds a provider-agnostic TRUST_GATEWAY_IDENTITY mode so an MCP-aware proxy can supply the per-request principal via a signed identity assertion (JWT), WITHOUT this server terminating MCP OAuth itself (MCP_ENABLE_OAUTH21 stays off — the proxy owns the handshake, so no contention/502). Closes the per-user isolation gap in proxy-fronted deployments: previously, with OAuth21 off, the Google credential was bound to the transport session, not the principal. Works with any proxy that injects a JWKS-verifiable JWT identity header — Pomerium (default header/alg), oauth2-proxy, Cloudflare Access, Istio/Envoy, Traefik ForwardAuth. - oauth_config: TRUST_GATEWAY_IDENTITY + GATEWAY_IDENTITY_JWKS_URL / _HEADER (default x-pomerium-jwt-assertion) / _ALGORITHMS (default ES256) / _ISSUER / _AUDIENCE; validated mutually exclusive with MCP_ENABLE_OAUTH21 and requires a JWKS URL. - auth/gateway_identity.py (new): verify the assertion against the proxy JWKS (configurable algs pinned, exp required, optional iss/aud), via PyJWT PyJWKClient (cached). Fail-closed. - auth_info_middleware: highest-priority path — verify the assertion header and set the verified email as authenticated_user_email (authenticated_via=gateway_assertion). - service_decorator: the existing per-user override now also engages under trusted-gateway identity, locking user_google_email to the verified principal while credentials still resolve via the legacy per-user store (keyed by email). True per-user isolation. Verified: imports + config load; verifier rejects malformed/garbage tokens. TODO: integration test through the proxy; unit tests; docs/.env.example. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent-principal enforcement
Follow-on UX + hardening for TRUST_GATEWAY_IDENTITY mode:
B (no prompt): hide & auto-fill user_google_email from the verified principal — mirrors the
existing OAuth 2.1 behavior via a _user_email_is_managed() helper, so the client never asks
"what's your email?" and the email can't be spoofed by the caller.
A (clear messages): identity-aware auth-required text ("sign in to Google as <principal>")
instead of the generic "must match the authenticated account".
C (consent enforcement): record the principal in the OAuth state and, at /oauth2callback,
reject a consent whose Google account doesn't match it — storing nothing — with a clear
"you signed in as X, but your identity is Y" error. Also persist user_email through the
shared-store serialization so the check actually receives it.
Verified end-to-end through Pomerium: andy→andy works; base-user→andy is rejected (no creds
stored); no "what's your email?" prompt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r unit tests docs/trusted-gateway-identity.md: config table, how-it-works, security notes. tests/auth/test_gateway_identity.py: 10 cases for verify_gateway_assertion / extract_email (valid, expired, wrong key, disallowed alg, aud match/mismatch, emailless, empty, no JWKS). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- auth_info_middleware: make the verified gateway assertion authoritative (return before the token/session paths that could overwrite it); offload the synchronous JWKS verify off the event loop via asyncio.to_thread. - gateway_identity: reject non-string / blank email claims explicitly (keep fail-closed). - oauth_config: fail fast when GATEWAY_IDENTITY_ALGORITHMS resolves to an empty list. - tests: add blank-email and non-string-email cases (12 pass); ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…into per-user-identity
…r-supplied email in gateway mode, consent state bound to principal
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (9)
📝 WalkthroughWalkthroughAdds trusted-gateway JWT verification, request-scoped principal state, gateway-bound Google OAuth, managed-user enforcement, configuration validation, documentation, and comprehensive tests. ChangesTrusted gateway identity
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Gateway
participant AuthInfoMiddleware
participant FastMCPContext
participant start_google_auth
participant GoogleOAuth
Gateway->>AuthInfoMiddleware: Provide signed identity assertion
AuthInfoMiddleware->>FastMCPContext: Store verified gateway principal
start_google_auth->>FastMCPContext: Read verified principal
start_google_auth->>GoogleOAuth: Start principal-bound authorization
GoogleOAuth-->>start_google_auth: Return consented Google account
GoogleOAuth->>FastMCPContext: Validate account against gateway principal
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@auth/gateway_identity.py`:
- Around line 36-40: Update normalize_principal_email to apply the repository’s
canonical email validator after trimming the input and before returning it as
the normalized principal or credential-store key; return None for invalid
addresses, while preserving lowercase canonical output for valid emails.
In `@auth/google_auth.py`:
- Around line 831-841: Update the enforcement_marker handling in the OAuth state
validation flow so that when is_trust_gateway_identity() is active, every value
other than True—including an explicit False—raises GoogleAuthenticationError and
prevents authentication from completing. Preserve the existing False fallback
only when gateway mode is inactive, and add coverage for a pre-deployment state
with enforce_user_email_match=False during gateway mode.
In `@auth/oauth_config.py`:
- Around line 112-116: Update the validation around gateway_identity_jwks_url to
require an HTTPS URL, while permitting an explicit development-only exception
for loopback addresses. Reject non-empty HTTP URLs and any non-loopback insecure
endpoint before the existing ValueError handling completes.
- Around line 90-96: Update the gateway_identity_algorithms configuration
parsing to reject symmetric or unsigned algorithms, including any HS* variant
and none, and reject configurations mixing algorithm families. Allow only a
single asymmetric JWT family while preserving the existing comma-separated
parsing and ES256 default.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: dc13efaf-f6e2-49bb-87b1-30be0db5265e
📒 Files selected for processing (14)
auth/auth_info_middleware.pyauth/gateway_identity.pyauth/google_auth.pyauth/oauth21_session_store.pyauth/oauth_config.pyauth/service_decorator.pycore/server.pydocs/trusted-gateway-identity.mdtests/auth/test_auth_info_middleware.pytests/auth/test_gateway_identity.pytests/auth/test_google_auth_callback_refresh_token.pytests/auth/test_oauth21_session_store.pytests/core/test_start_google_auth.pytests/core/test_user_google_email_defaults.py
…ormcement markers
|
@coderabbitai re-review now and post findings as comment |
|
✅ Action performedReview finished.
|
|
Hey @123andy, I put in some time last night getting this tightened up and I'd love to hear what you think! I don't currently implement a pattern I can test against so your feedback will be especially valuable. Merging now. |
|
I've got a stack features on top of the main, so it will take a little bit
to cleanly parse it out, but will give in the next day or two. Thanks!
…On Thu, Jul 30, 2026 at 9:42 AM Taylor Wilsdon ***@***.***> wrote:
Merged #981
<#981> into
main.
—
Reply to this email directly, view it on GitHub
<#981?email_source=notifications&email_token=ABY5VMJLQVWSPEOU2YYGYLD5HN3IDA5CNFSNUABQM5UWIORPF5TWS5BNNB2WEL2JONZXKZKFOZSW45CON52GSZTJMNQXI2LPNYXTEOBXGMZTGOJXG43DHJTSMVQXG33OU5WWK3TUNFXW5JLFOZSW45FMMZXW65DFOJPWG3DJMNVQ#event-28733397763>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ABY5VMMDY4WEDNFINR4DWST5HN3IDAVCNFSNUABFKJSXA33TNF2G64TZHM4TOMZXHA4DCMZWHNEXG43VMU5TKMBRGM2DCMJXGY42C5QC>
.
You are receiving this because you were mentioned.Message ID:
<taylorwilsdon/google_workspace_mcp/pull/981/issue_event/28733397763@
github.com>
|
Didn't have upstream edit access to @123andy branch so had to spin refactor off as new PR. He is the author to credit here!
Description
Adds an optional trusted-gateway identity mode (
TRUST_GATEWAY_IDENTITY) for deployments that run this server behind an MCP-aware reverse proxy.What: the proxy authenticates the user and forwards a signed identity assertion (a JWT) on every upstream request; this server cryptographically verifies that assertion against the proxy's JWKS and uses the asserted email as the per-request principal — without terminating MCP OAuth itself. It's provider-agnostic (works with any proxy that injects a JWKS-verifiable JWT identity header — oauth2-proxy, Cloudflare Access, Istio/Envoy, Traefik ForwardAuth, Pomerium, …); the header, algorithm(s), JWKS URL, and optional issuer/audience are configurable.
Why: in a multi-user HTTP deployment fronted by a proxy that already terminates the MCP OAuth handshake, you must run with
MCP_ENABLE_OAUTH21=falseto avoid contending for that handshake — but then the server has no verified per-request identity, so the per-user Google credential ends up bound to the transport session rather than the authenticated principal (no real per-user isolation). This recovers a verified principal from the proxy's signed assertion, reusing the existing per-user credential machinery. Mutually exclusive withMCP_ENABLE_OAUTH21=true.How:
auth/gateway_identity.py) — verify the assertion JWT against the JWKS (signature +exp, optionaliss/aud), with the algorithm pinned (blocksalg:none/confusion). Fail-closed.emailbecomes the authenticated principal (authenticated_via=gateway_assertion), and is authoritative in this mode.user_google_emailtool parameter is hidden and auto-filled from the verified principal; clients never ask for an email and a caller can't act on another account by passing one./oauth2callbackthe Google account actually consented must match it; a mismatch is rejected and nothing is stored.Off by default; no behavior change unless
TRUST_GATEWAY_IDENTITY=true.Type of Change
Testing
Local:
ruff format --check+ruff checkclean on all changed files;pytest tests/auth→ 93 passed in a clean env, including the 12 newtests/auth/test_gateway_identity.pycases (valid / expired / wrong key / disallowed alg / aud match+mismatch / blank+non-string+missing email / empty token / missing JWKS). Validated end-to-end behind a proxy + IdP: the verified identity drives the principal; clients are not asked for an email; and a consent whose Google account doesn't match the verified identity is rejected with a clear message and no credentials stored.Checklist
Additional Notes
Files touched (+477 / −14):
auth/gateway_identity.py(new),auth/oauth_config.py,auth/auth_info_middleware.py,auth/service_decorator.py,auth/google_auth.py,auth/oauth21_session_store.py,docs/trusted-gateway-identity.md(new),tests/auth/test_gateway_identity.py(new).Configuration
TRUST_GATEWAY_IDENTITYfalseGATEWAY_IDENTITY_JWKS_URLGATEWAY_IDENTITY_HEADERx-pomerium-jwt-assertioncf-access-jwt-assertion)GATEWAY_IDENTITY_ALGORITHMSES256RS256)GATEWAY_IDENTITY_ISSUER/GATEWAY_IDENTITY_AUDIENCEiss/audpinningAn example with Pomerium (the setup this was developed against)
Server env (the defaults already target Pomerium's header + algorithm, so the JWKS URL is the only required value):
Pomerium route — Pomerium owns the MCP handshake and must forward the assertion:
Other proxies work the same way by overriding the header/algorithm — e.g. Cloudflare Access:
GATEWAY_IDENTITY_HEADER=cf-access-jwt-assertion,GATEWAY_IDENTITY_ALGORITHMS=RS256, team-domaincertsURL for the JWKS.Security notes: the assertion is verified cryptographically (signature +
exp, algorithm pinned); set issuer/audience in production; the backend should be reachable only via the proxy so the identity header can't be supplied by an untrusted client.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Tests