|
| 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