Skip to content

Commit f97ea15

Browse files
committed
test(auth): add trust-mode acceptance suite (no-DB, multi-worker, config-flip)
Suites: no-DB proof; multi-worker revocation; config-flip; UAID propagation; overage-degraded; cross-gateway UAID. Signed-off-by: Jonathan Springer <jps@s390x.com>
1 parent 0e0021e commit f97ea15

8 files changed

Lines changed: 1298 additions & 6 deletions

.secrets.baseline

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/security/uaid-cross-gateway-auth.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,27 @@ UAID_FORWARD_AUTH=false
6363
6. Response returns through Gateway A → forwarded to user
6464
```
6565

66+
### Layer 2 and JWT Trust Mode
67+
68+
When both gateways run JWT trust mode (`JWT_TRUST_MODE=jwt-trust`), the same
69+
forwarding rules apply without change:
70+
71+
- The forwarded bearer token is the caller's inbound JWT. A trust-mode token
72+
(`token_use="trusted"`) forwards verbatim; the remote gateway receives the
73+
original claims (`sub`, `groups`, `teams`, the revocation claim).
74+
- The remote gateway re-evaluates the token against its own
75+
`external_group_mappings` table. Team membership that the calling gateway
76+
derived from its own mappings does NOT transfer. An agent on a team the
77+
token does not map to on the remote gateway returns 404.
78+
- Local opaque tokens (`cf_sess_*`, `cf_pat_*`) are never forwarded. The
79+
remote gateway treats the call as unauthenticated.
80+
- `UAID_ALLOWED_DOMAINS` semantics are unchanged: the allowlist stays
81+
fail-closed. An empty allowlist blocks all cross-gateway routing unless
82+
`UAID_ALLOW_ALL_DOMAINS=true` (unsafe for production).
83+
84+
See `docs/docs/architecture/auth-token-dispatch.md` for the token dispatch
85+
rule the remote gateway applies to the forwarded token.
86+
6687
**Headers:**
6788

6889
```http
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# -*- coding: utf-8 -*-
2+
"""Location: ./tests/live_gateway/test_trust_mode_multi_worker.py
3+
Copyright contributors to the MCP-CONTEXT-FORGE project
4+
SPDX-License-Identifier: Apache-2.0
5+
6+
Multi-worker revocation propagation for JWT trust mode (issue #5905, suite b).
7+
8+
Two gateway workers share one database and one Redis blocklist. A trust-mode
9+
token revoked on worker A must be rejected by worker B within the documented
10+
negative-cache window (``auth_cache_revocation_ttl``, default 30 s): the
11+
trust branch re-checks the configured revocation claim on every request.
12+
13+
Requirements (same pattern as ``run_primary_worker_multiinstance.sh``):
14+
- docker compose stack with the gateway scaled to 2 replicas, sharing one
15+
Postgres database and one Redis instance, started with
16+
``JWT_TRUST_MODE=jwt-trust``
17+
- ``JWT_SECRET_KEY`` identical on both workers (shared signing key)
18+
- ``TRUST_MULTI_WORKER_URLS``: comma-separated base URL per worker, e.g.
19+
``http://127.0.0.1:8080,http://127.0.0.1:8081``
20+
21+
Run:
22+
JWT_TRUST_MODE=jwt-trust \
23+
TRUST_MULTI_WORKER_URLS=http://127.0.0.1:8080,http://127.0.0.1:8081 \
24+
uv run pytest tests/live_gateway/test_trust_mode_multi_worker.py
25+
26+
The suite self-skips when no gateway is reachable, when fewer than two
27+
worker URLs are configured, or when the stack is not in trust mode.
28+
"""
29+
30+
# Future
31+
from __future__ import annotations
32+
33+
# Standard
34+
import os
35+
36+
# Third-Party
37+
import httpx
38+
import pytest
39+
40+
# Local
41+
from tests.helpers.auth import make_auth_headers, make_trusted_test_jwt
42+
from .helpers.mcp_test_helpers import (
43+
BASE_URL,
44+
JWT_SECRET,
45+
skip_no_gateway,
46+
)
47+
48+
pytestmark = [pytest.mark.e2e, skip_no_gateway]
49+
50+
# Expected gateway mode for this run; must match the stack configuration.
51+
EXPECTED_TRUST_MODE = os.getenv("JWT_TRUST_MODE", "db")
52+
53+
# One base URL per worker. With the single-node default stack this list has
54+
# one entry and the multi-worker test skips; the standard live stack still
55+
# runs the single-worker revocation sanity check.
56+
WORKER_URLS = [url.strip() for url in os.getenv("TRUST_MULTI_WORKER_URLS", "").split(",") if url.strip()] or [BASE_URL]
57+
58+
skip_unless_trust_mode = pytest.mark.skipif(EXPECTED_TRUST_MODE != "jwt-trust", reason="requires the stack started with JWT_TRUST_MODE=jwt-trust")
59+
skip_unless_multi_worker = pytest.mark.skipif(len(WORKER_URLS) < 2, reason="multi-worker stack not configured (set TRUST_MULTI_WORKER_URLS to one base URL per worker)")
60+
61+
TRUST_USER_ID = "live-mw-trust-subject-0001"
62+
TRUST_EMAIL = "live.mw.trust.user@example.com"
63+
REVOKE_JTI = "live-mw-revoke-jti-0001"
64+
CONTROL_JTI = "live-mw-control-jti-0001"
65+
66+
67+
def _trust_token(jti: str) -> str:
68+
"""Mint a trust-marker token with a known revocation identifier."""
69+
return make_trusted_test_jwt(
70+
TRUST_USER_ID,
71+
email=TRUST_EMAIL,
72+
teams=[],
73+
roles=[],
74+
revocation_id=jti,
75+
secret=JWT_SECRET,
76+
)
77+
78+
79+
def _get_tools(worker_url: str, token: str) -> httpx.Response:
80+
"""Issue an authenticated read against one worker."""
81+
return httpx.get(f"{worker_url}/tools", headers=make_auth_headers(token), timeout=10)
82+
83+
84+
def _logout(worker_url: str, token: str) -> httpx.Response:
85+
"""Revoke the token through the logout endpoint on one worker."""
86+
return httpx.post(f"{worker_url}/auth/logout", headers=make_auth_headers(token), timeout=10)
87+
88+
89+
@skip_unless_trust_mode
90+
def test_revocation_visible_on_single_worker() -> None:
91+
"""Sanity: logout revokes a trust token on the standard single-worker stack."""
92+
token = _trust_token(REVOKE_JTI)
93+
94+
response = _get_tools(WORKER_URLS[0], token)
95+
assert response.status_code == 200, f"trust token rejected before revocation: {response.status_code} {response.text[:200]}"
96+
97+
logout_response = _logout(WORKER_URLS[0], token)
98+
assert logout_response.status_code == 200, f"logout failed: {logout_response.status_code} {logout_response.text[:200]}"
99+
assert logout_response.json().get("revoked_token") == REVOKE_JTI
100+
101+
response = _get_tools(WORKER_URLS[0], token)
102+
assert response.status_code == 401, f"revoked trust token still accepted: {response.status_code}"
103+
104+
105+
@skip_unless_trust_mode
106+
@skip_unless_multi_worker
107+
def test_revocation_propagates_across_workers() -> None:
108+
"""Revoke on worker A; the same jti is rejected on worker B.
109+
110+
Revocation lands in the shared database (and the shared Redis blocklist),
111+
so worker B rejects the token immediately — well within the documented
112+
30 s negative-cache window.
113+
"""
114+
worker_a, worker_b = WORKER_URLS[0], WORKER_URLS[1]
115+
token = _trust_token(REVOKE_JTI)
116+
117+
# The token authenticates against both workers before revocation.
118+
for url in (worker_a, worker_b):
119+
response = _get_tools(url, token)
120+
assert response.status_code == 200, f"trust token rejected by {url} before revocation: {response.status_code} {response.text[:200]}"
121+
122+
# Revoke on worker A via the logout endpoint (writes the blocklist row).
123+
logout_response = _logout(worker_a, token)
124+
assert logout_response.status_code == 200, f"logout on worker A failed: {logout_response.status_code} {logout_response.text[:200]}"
125+
assert logout_response.json().get("revoked_token") == REVOKE_JTI
126+
127+
# Worker B must reject the same jti. The trust branch checks the
128+
# revocation store on every request, so no cache wait is needed; the
129+
# assertion runs well inside the 30 s negative-cache window.
130+
response = _get_tools(worker_b, token)
131+
assert response.status_code == 401, f"worker B accepted a token revoked on worker A: {response.status_code}"
132+
133+
# Control: a different, unrevoked jti still authenticates on both workers.
134+
control = _trust_token(CONTROL_JTI)
135+
for url in (worker_a, worker_b):
136+
response = _get_tools(url, control)
137+
assert response.status_code == 200, f"unrevoked control token rejected by {url}: {response.status_code} {response.text[:200]}"

0 commit comments

Comments
 (0)