Skip to content

feat(auth): trusted-gateway identity mode (verify proxy-signed assertion as principal) - #981

Merged
taylorwilsdon merged 10 commits into
mainfrom
per-user-identity
Jul 30, 2026
Merged

feat(auth): trusted-gateway identity mode (verify proxy-signed assertion as principal)#981
taylorwilsdon merged 10 commits into
mainfrom
per-user-identity

Conversation

@taylorwilsdon

@taylorwilsdon taylorwilsdon commented Jul 29, 2026

Copy link
Copy Markdown
Owner

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=false to 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 with MCP_ENABLE_OAUTH21=true.

How:

  1. Verify (auth/gateway_identity.py) — verify the assertion JWT against the JWKS (signature + exp, optional iss/aud), with the algorithm pinned (blocks alg:none/confusion). Fail-closed.
  2. Principal — the verified email becomes the authenticated principal (authenticated_via=gateway_assertion), and is authoritative in this mode.
  3. No prompt / no spoofing — as in OAuth 2.1 mode, the user_google_email tool 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.
  4. Consent binding — the per-user Google consent is initiated for the principal, and at /oauth2callback the 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

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update

Testing

  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have tested this change manually

Local: ruff format --check + ruff check clean on all changed files; pytest tests/auth93 passed in a clean env, including the 12 new tests/auth/test_gateway_identity.py cases (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

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • I have enabled "Allow edits from maintainers" for this pull request

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

Env var Required Default Notes
TRUST_GATEWAY_IDENTITY yes false enable the mode
GATEWAY_IDENTITY_JWKS_URL yes the proxy's JWKS endpoint
GATEWAY_IDENTITY_HEADER no x-pomerium-jwt-assertion header carrying the JWT (e.g. cf-access-jwt-assertion)
GATEWAY_IDENTITY_ALGORITHMS no ES256 comma-separated allowed alg(s); pinned (e.g. RS256)
GATEWAY_IDENTITY_ISSUER / GATEWAY_IDENTITY_AUDIENCE no optional iss/aud pinning

An 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):

MCP_ENABLE_OAUTH21=false
TRUST_GATEWAY_IDENTITY=true
GATEWAY_IDENTITY_JWKS_URL=https://authenticate.example.com/.well-known/pomerium/jwks.json

Pomerium route — Pomerium owns the MCP handshake and must forward the assertion:

routes:
  - from: https://workspace.example.com
    to: http://workspace-mcp.internal:8000
    pass_identity_headers: true     # forwards X-Pomerium-Jwt-Assertion to the backend
    mcp:
      server: {}
    policy:
      allow:
        and:
          - domain: { is: example.com }
          - claim/groups: workspace-users

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-domain certs URL 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

    • Added trusted-gateway identity verification using signed JWT assertions with configurable header, JWKS, allowed algorithms, optional issuer, and required audience.
    • Google OAuth now derives and enforces the authenticated principal from the verified gateway identity, and binds OAuth consent to it.
    • Tool-call behavior was updated for gateway mode to rely on verified identity.
  • Bug Fixes

    • Prevents legacy fallback when gateway verification fails.
    • Rejects OAuth callbacks when consented email doesn’t match the enforced gateway principal.
    • Clears stale request identity context for gateway-authenticated requests.
  • Documentation

    • Updated trusted-gateway identity setup and “no prompt/no spoofing” behavior details.
  • Tests

    • Expanded unit and integration coverage for gateway identity, OAuth enforcement, and request-scoped behavior.

123andy and others added 8 commits June 28, 2026 17:39
…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>
…r-supplied email in gateway mode, consent state bound to principal
@taylorwilsdon taylorwilsdon self-assigned this Jul 29, 2026
@taylorwilsdon taylorwilsdon added the enhancement New feature or request label Jul 29, 2026
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 05750f38-33a5-400e-baa4-9fb6fafe39bd

📥 Commits

Reviewing files that changed from the base of the PR and between 6f76830 and b28adc2.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • auth/auth_info_middleware.py
  • auth/google_auth.py
  • auth/service_decorator.py
  • core/server.py
  • docs/trusted-gateway-identity.md
  • pyproject.toml
  • tests/auth/test_auth_info_middleware.py
  • tests/auth/test_google_auth_callback_refresh_token.py
  • tests/core/test_user_google_email_defaults.py

📝 Walkthrough

Walkthrough

Adds trusted-gateway JWT verification, request-scoped principal state, gateway-bound Google OAuth, managed-user enforcement, configuration validation, documentation, and comprehensive tests.

Changes

Trusted gateway identity

Layer / File(s) Summary
Gateway configuration and assertion verification
auth/oauth_config.py, auth/gateway_identity.py, tests/auth/test_gateway_identity.py
Adds environment-driven gateway configuration, JWKS-backed JWT verification, email normalization, principal helpers, fail-closed validation, and related tests.
Request-scoped gateway authentication
auth/auth_info_middleware.py, tests/auth/test_auth_info_middleware.py
Clears stale identity state, verifies gateway assertions asynchronously, records non-serializable identity fields, and prevents fallback authentication on failure.
OAuth principal binding
auth/google_auth.py, auth/oauth21_session_store.py, tests/auth/test_google_auth_callback_refresh_token.py, tests/auth/test_oauth21_session_store.py
Stores gateway principal metadata in OAuth state and requires the Google callback email to match the verified principal.
Managed principal service integration
auth/service_decorator.py, core/server.py, docs/trusted-gateway-identity.md, pyproject.toml, tests/core/*
Uses gateway principals for managed service wrappers and Google auth, hides client email inputs, adds email validation support, and documents gateway behavior.

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
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.08% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately describes the main change: adding a trusted-gateway identity mode for auth.
Description check ✅ Passed The description follows the template and covers the change summary, type, testing, checklist, and additional notes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch per-user-identity

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread auth/google_auth.py Fixed
Comment thread auth/service_decorator.py Fixed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between d9d09fa and ea1aea9.

📒 Files selected for processing (14)
  • auth/auth_info_middleware.py
  • auth/gateway_identity.py
  • auth/google_auth.py
  • auth/oauth21_session_store.py
  • auth/oauth_config.py
  • auth/service_decorator.py
  • core/server.py
  • docs/trusted-gateway-identity.md
  • tests/auth/test_auth_info_middleware.py
  • tests/auth/test_gateway_identity.py
  • tests/auth/test_google_auth_callback_refresh_token.py
  • tests/auth/test_oauth21_session_store.py
  • tests/core/test_start_google_auth.py
  • tests/core/test_user_google_email_defaults.py

Comment thread auth/gateway_identity.py Outdated
Comment thread auth/google_auth.py Outdated
Comment thread auth/oauth_config.py
Comment thread auth/oauth_config.py
@taylorwilsdon

Copy link
Copy Markdown
Owner Author

@coderabbitai re-review now and post findings as comment

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

@taylorwilsdon I’ll re-review PR #981 and post any findings as comments.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@taylorwilsdon

Copy link
Copy Markdown
Owner Author

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.

@taylorwilsdon
taylorwilsdon merged commit 1ec58b5 into main Jul 30, 2026
10 of 12 checks passed
@123andy

123andy commented Jul 30, 2026 via email

Copy link
Copy Markdown
Contributor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants