mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
fix(mcp): coherent obo auth-type carry-over + honest error affordances
Round-2 review follow-up. The headline is a redesign of the OAuth column carry-over so scopes/audience can no longer leak or vanish across an auth-type flip: - oauth_audience and oauth_scopes keep their meaning only WITHIN an auth type (a resource indicator vs. an IdP app id; AS-consent scopes vs. an rfc8693 exchange scope). On any oauth_user<->oauth_obo flip they are now recomputed from the request (present -> value, absent -> NULL) and never carried from the old row. A shared _oauth_columns_to_clear policy drives both the create and update handlers. No-op normalization of a re-sent equal value applies only to same-type edits. - The console form clears both semantic fields when the auth type changes and always submits the visible values; the previous "omit unchanged scopes" logic collided with the backend's flip handling and could silently drop or carry scopes. Write-time validation now rejects oauth_obo rows that can never mint — OIDC disabled/unconfigured, or an invalid obo_grant_profile — instead of letting them surface per-dispatch as a retryable transient that never heals. Honest failure affordances for sign-in passthrough (no per-server consent flow exists): - the token_revoked audit fires only when a row was actually deleted, so a permanent mint rejection against a surviving credential no longer appends a bogus revocation on every post-cooldown dispatch/prime; - the 403 insufficient-scope detail and the chat error card's action button are now auth-model-aware — obo errors point at the administrator rather than a dead-end re-consent, and the Connect button renders only when a real consent URL is present; - the read-side freshness gate now enforces scopes as well as audience, so an rfc8693 scope narrowing takes effect on the next dispatch even if the best-effort admin cache purge failed. Cleanups: the five decrypt-failure result constructions collapse into _decrypt_failure_result; the cleared-pairs TTL bookkeeping into _mark_pending_consent_cleared; drop the dead USER_SCOPED_AUTH_TYPES re-export from mcp_oauth; correct the now-bidirectional oidc<->mcp_oauth lazy-import note. Docs updated for the flip semantics and the OIDC prerequisite.
This commit is contained in:
+3
-1
@@ -92,6 +92,8 @@ obo_grant_profile = "entra" # "entra" | "rfc8693" — how tokens are
|
||||
|
||||
In the admin MCP form, choose **Sign-in passthrough** and set **Audience** (required — the downstream resource the token is minted for, e.g. `api://<app-id>` on Entra or the client id on Keycloak). The client-id / secret / registration fields do not apply and are hidden.
|
||||
|
||||
`oauth_obo` servers are accepted only when **OIDC sign-in is configured and enabled** and `[oidc] obo_grant_profile` is a valid profile — the write is rejected otherwise, since a row that can never mint would surface to users as a permanent "please retry" that never heals.
|
||||
|
||||
### Identity-provider setup
|
||||
|
||||
**Entra (`obo_grant_profile = "entra"`):**
|
||||
@@ -150,7 +152,7 @@ Additional indicators (circuit-breaker state, encryption-key mismatch) are expos
|
||||
| `none` / `static` → `oauth_user` | — | New code path activates for this server. Existing static headers (if any) are no longer sent. Users must authorize on first use. |
|
||||
| `oauth_user` → `none` / `static` | — | Existing `mcp_user_tokens` rows are **deleted**: the tokens are bound to the auth model + URL active at consent time, and rows left behind could silently rebind if a row with the old name/URL reappears. Switching back to `oauth_user` later starts clean — users re-consent on next use. This is **not reversible**; the AS-side grants are untouched (revoke upstream via the AS if needed). |
|
||||
| OAuth `client_id` or `client_secret` rotated | — | Existing tokens may stop refreshing if the AS treats them as bound to the previous client. Bulk-revoke after rotation. |
|
||||
| `oauth_user` ↔ `oauth_obo` | — | The per-user rows are **deleted** on the flip (they mean different things: per-server AS refresh tokens vs. minted cache). A flip into `oauth_obo` clears carried-over `oauth_scopes` under the `entra` profile (they cannot apply there); under `rfc8693` a scopes value submitted with the flip is kept as the token-exchange scope and the column is cleared only when the request omits it. A flip into `oauth_user` clears the obo-era `oauth_audience` (an IdP-side app identifier, not the resource indicator `oauth_user` needs) unless the request sets a new one. |
|
||||
| `oauth_user` ↔ `oauth_obo` | — | The per-user rows are **deleted** on the flip (they mean different things: per-server AS refresh tokens vs. minted cache). `oauth_audience` and `oauth_scopes` mean different things in each model (a resource indicator vs. an IdP app identifier; AS-consent scopes vs. an rfc8693 exchange scope), so on a flip they **never carry** — each is taken from the request for the target model or set NULL. The admin console clears these fields when you change the auth type, so re-enter the correct values for the new mode; via the API, supply them explicitly (a flip into `oauth_obo` with no `oauth_audience` is rejected, and a non-empty `oauth_scopes` under the `entra` profile is rejected since that leg pins `<audience>/.default`). |
|
||||
| `oauth_obo` → `none` / `static` | — | Minted cache rows are deleted. |
|
||||
| `oauth_obo` **audience**, **URL**, or **`oauth_scopes`** changed | — | Minted cache rows are **deleted** (tokens are bound to the audience/URL/scopes at mint time), forcing a fresh mint — so an audience or scope narrowing takes effect immediately, not at token expiry. |
|
||||
|
||||
|
||||
@@ -876,6 +876,30 @@ def test_phase8_xss_safe_render_in_build_mcp_error_embed() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_mcp_error_button_gated_on_consent_url_not_code_alone() -> None:
|
||||
"""Review finding: the chat error card rendered a Connect / Re-consent
|
||||
button from the error CODE alone, so an oauth_obo error (consent_url=None,
|
||||
since sign-in passthrough has no per-server consent flow and /start rejects
|
||||
obo rows) produced a button that dead-ended in a 'no consent URL' toast.
|
||||
The button must render only when a valid per-server consent URL is present —
|
||||
obo errors show the card's honest detail text without a broken affordance."""
|
||||
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
|
||||
start = body.index("function buildMcpErrorEmbed(")
|
||||
rest = body[start:]
|
||||
end_match = re.search(r"\n}\n", rest)
|
||||
assert end_match is not None
|
||||
fn = rest[: end_match.end()]
|
||||
# The render gate combines the category with a consent-URL presence check.
|
||||
assert "hasConsentAffordance" in fn, (
|
||||
"buildMcpErrorEmbed must gate the action button on the presence of a "
|
||||
"consent URL, not on the error category alone."
|
||||
)
|
||||
assert 'category === "actionable" && hasConsentAffordance' in fn, (
|
||||
"the button-render condition must require BOTH an actionable category "
|
||||
"and a real consent URL"
|
||||
)
|
||||
|
||||
|
||||
def test_phase8_css_classes_present_in_stylesheet() -> None:
|
||||
"""The MCP error-embed + connections classes app.js/interactive.js reference
|
||||
must keep their CSS rules (else the consent / connections UX silently loses
|
||||
|
||||
+108
-36
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -191,6 +192,19 @@ def _install_token_store(app, storage) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _enabled_oidc(profile: str = "entra") -> SimpleNamespace:
|
||||
"""An OIDC config that satisfies the oauth_obo write-time gate.
|
||||
|
||||
oauth_obo mints from the user's captured sign-in, so the write choke point
|
||||
requires OIDC enabled + a valid ``obo_grant_profile``. Tests exercising obo
|
||||
writes install one of these; the finding-C tests install a disabled /
|
||||
bad-profile config instead to assert the rejection.
|
||||
"""
|
||||
return SimpleNamespace(
|
||||
enabled=True, issuer="https://idp.example.com", obo_grant_profile=profile
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(storage):
|
||||
"""TestClient wired to console admin MCP endpoints with full permissions."""
|
||||
@@ -200,6 +214,10 @@ def client(storage):
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
_install_token_store(app, storage)
|
||||
# Default: OIDC enabled under the entra profile so oauth_obo writes pass the
|
||||
# requirement gate. Per-test overrides install rfc8693 / disabled / bad
|
||||
# profile as needed.
|
||||
app.state.oidc_config = _enabled_oidc("entra")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -746,6 +764,68 @@ class TestUpdateMcpServer:
|
||||
assert data["auth_type"] == "oauth_obo"
|
||||
assert data["oauth_audience"] == "api://mcp-a"
|
||||
|
||||
def test_create_obo_rejected_when_oidc_disabled(self, client):
|
||||
"""Review finding: oauth_obo mints from the user's OIDC sign-in, so an
|
||||
install with OIDC disabled can NEVER mint. Reject at write time (a
|
||||
permanent misconfig otherwise surfaces per-dispatch as a retryable
|
||||
transient that never heals)."""
|
||||
client.app.state.oidc_config = SimpleNamespace(enabled=False, obo_grant_profile="entra")
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "obo-no-oidc",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_obo",
|
||||
"oauth_audience": "api://mcp-a",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert "OIDC" in r.json()["error"]
|
||||
|
||||
def test_create_obo_rejected_on_invalid_grant_profile(self, client):
|
||||
"""Review finding: a typo'd deployment obo_grant_profile leaves the mint
|
||||
leg unresolved (obo_misconfigured per dispatch), so reject it at the
|
||||
write choke point rather than as a runtime transient."""
|
||||
client.app.state.oidc_config = _enabled_oidc("bogus-profile")
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "obo-bad-profile",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_obo",
|
||||
"oauth_audience": "api://mcp-a",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert "obo_grant_profile" in r.json()["error"]
|
||||
|
||||
def test_flip_user_to_obo_via_api_without_audience_is_rejected(self, client, storage):
|
||||
"""Review finding: a flip into obo must NOT carry the oauth_user-era
|
||||
oauth_audience (a resource indicator, conventionally the MCP URL) — it
|
||||
would pass the audience-required check and then fail every mint. An API
|
||||
PUT of just {auth_type: oauth_obo} recomputes audience from the body
|
||||
(absent → NULL) and is rejected loudly, not saved with the stale value."""
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "flip-api-noaud",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_user",
|
||||
"oauth_client_id": "cli_x",
|
||||
"oauth_audience": "https://mcp.example.com/sse", # resource indicator
|
||||
},
|
||||
)
|
||||
sid = r.json()["server_id"]
|
||||
r2 = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
json={"auth_type": "oauth_obo"}, # no audience in body
|
||||
)
|
||||
assert r2.status_code == 400, r2.text
|
||||
assert "oauth_audience" in r2.json()["error"]
|
||||
|
||||
def test_update_flip_oauth_user_to_obo_keeps_audience_and_purges_tokens(self, client, storage):
|
||||
"""#551 (findings 10344 + 10326): flipping oauth_user→oauth_obo must NOT
|
||||
null oauth_audience (the mint engine needs it), and MUST purge the old
|
||||
@@ -844,17 +924,17 @@ class TestUpdateMcpServer:
|
||||
sid: str = r.json()["server_id"]
|
||||
return sid
|
||||
|
||||
def test_flip_to_obo_resent_scopes_cleared_under_entra(self, client):
|
||||
"""Review finding: on a flip into obo the carried-over oauth_user
|
||||
scopes are profile-dependent. Under entra they can never apply (the
|
||||
leg pins <audience>/.default), so an equal-value re-send is treated
|
||||
as the stale pre-fill and cleared — NOT rejected (the flip must not
|
||||
400 on a value the operator never typed)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
client.app.state.oidc_config = SimpleNamespace(obo_grant_profile="entra")
|
||||
def test_flip_to_obo_under_entra_rejects_explicit_scopes_but_omit_clears(self, client):
|
||||
"""Redesign: a flip into obo recomputes scopes from the body (never
|
||||
carries the old row's value across the semantic boundary). Under entra,
|
||||
an EXPLICIT non-empty scopes value is rejected 400 — an honest visible
|
||||
snap rather than a silent drop — while the console-realistic flip (the
|
||||
form clears the semantic field on the auth-type switch, so scopes is
|
||||
omitted/empty) succeeds with scopes NULL."""
|
||||
client.app.state.oidc_config = _enabled_oidc("entra")
|
||||
# Explicit non-empty scopes on the flip → 400 (they can't apply on entra).
|
||||
sid = self._create_oauth_user_row_with_scopes(client, "flip-resend-entra")
|
||||
r2 = client.put(
|
||||
rejected = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
json={
|
||||
"auth_type": "oauth_obo",
|
||||
@@ -862,8 +942,16 @@ class TestUpdateMcpServer:
|
||||
"oauth_scopes": "openid profile offline_access",
|
||||
},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["oauth_scopes"] in (None, "") # carried-over value cleared
|
||||
assert rejected.status_code == 400
|
||||
assert "entra" in rejected.json()["error"]
|
||||
# The realistic flip (scopes field cleared → omitted) succeeds, NULL scopes.
|
||||
sid2 = self._create_oauth_user_row_with_scopes(client, "flip-omit-entra")
|
||||
ok = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid2}",
|
||||
json={"auth_type": "oauth_obo", "oauth_audience": "api://mcp-a"},
|
||||
)
|
||||
assert ok.status_code == 200, ok.text
|
||||
assert ok.json()["oauth_scopes"] in (None, "") # not carried across the flip
|
||||
|
||||
def test_flip_to_obo_resent_scopes_kept_under_rfc8693(self, client):
|
||||
"""Review finding: under rfc8693 oauth_scopes IS the token-exchange
|
||||
@@ -871,9 +959,7 @@ class TestUpdateMcpServer:
|
||||
Keycloak optional-audience scope can legitimately equal the old
|
||||
consent scope string) must NOT have it silently nulled; only an
|
||||
omitted field clears (previous test)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
client.app.state.oidc_config = SimpleNamespace(obo_grant_profile="rfc8693")
|
||||
client.app.state.oidc_config = _enabled_oidc("rfc8693")
|
||||
sid = self._create_oauth_user_row_with_scopes(client, "flip-resend-rfc")
|
||||
r2 = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
@@ -891,8 +977,6 @@ class TestUpdateMcpServer:
|
||||
entra profile must stay editable — an unrelated PUT that doesn't touch
|
||||
scopes must NOT be rejected (the entra-scope reject fires only on a real
|
||||
scopes write)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
# Seed an obo row that already has scopes (e.g. created under rfc8693).
|
||||
storage.create_mcp_server(
|
||||
server_id="entra-edit-id",
|
||||
@@ -904,7 +988,7 @@ class TestUpdateMcpServer:
|
||||
oauth_scopes="custom.scope",
|
||||
)
|
||||
# Now the deployment is on the entra profile.
|
||||
client.app.state.oidc_config = SimpleNamespace(obo_grant_profile="entra")
|
||||
client.app.state.oidc_config = _enabled_oidc("entra")
|
||||
|
||||
# An unrelated maintenance edit (disable) — does NOT touch scopes.
|
||||
r = client.put(
|
||||
@@ -946,8 +1030,6 @@ class TestUpdateMcpServer:
|
||||
)
|
||||
|
||||
# The list handler fans out node status; no cluster nodes in this test.
|
||||
from types import SimpleNamespace
|
||||
|
||||
client.app.state.collector = SimpleNamespace(get_all_nodes=lambda: [])
|
||||
client.app.state.proxy_client = MagicMock()
|
||||
r = client.get("/v1/api/admin/mcp-servers")
|
||||
@@ -1058,9 +1140,7 @@ class TestUpdateMcpServer:
|
||||
bearer's privileges exactly like the audience does — narrowing
|
||||
oauth_scopes must purge cached rows or the reduction silently waits
|
||||
out the token TTL (inconsistent with the audience purge)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
client.app.state.oidc_config = SimpleNamespace(obo_grant_profile="rfc8693")
|
||||
client.app.state.oidc_config = _enabled_oidc("rfc8693")
|
||||
sid = self._seed_obo_row_with_cache(
|
||||
client, storage, name="scope-change", scopes="api.read api.write"
|
||||
)
|
||||
@@ -1077,9 +1157,7 @@ class TestUpdateMcpServer:
|
||||
"""Review finding companion: the admin form re-submits the pre-filled
|
||||
scopes on every save — an EQUAL value is normalized out of the update
|
||||
and must not flush every user's minted tokens."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
client.app.state.oidc_config = SimpleNamespace(obo_grant_profile="rfc8693")
|
||||
client.app.state.oidc_config = _enabled_oidc("rfc8693")
|
||||
sid = self._seed_obo_row_with_cache(client, storage, name="scope-noop", scopes="api.read")
|
||||
r2 = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
@@ -1096,9 +1174,7 @@ class TestUpdateMcpServer:
|
||||
carried over, every consent yields a wrong-resource token that 401s
|
||||
with no visible cause. The flip must clear it (and the rfc8693
|
||||
exchange scopes) unless the request explicitly sets new values."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
client.app.state.oidc_config = SimpleNamespace(obo_grant_profile="rfc8693")
|
||||
client.app.state.oidc_config = _enabled_oidc("rfc8693")
|
||||
sid = self._seed_obo_row_with_cache(client, storage, name="flip-back", scopes="api.read")
|
||||
r2 = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
@@ -1117,16 +1193,14 @@ class TestUpdateMcpServer:
|
||||
oauth_scopes, so a same-type edit of an entra-profile obo row carrying
|
||||
legacy scopes must accept an EQUAL value (normalized to a no-op)
|
||||
instead of 400ing — only a genuine scope CHANGE is rejected."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
# The legacy-scoped entra row arises from a deployment profile switch:
|
||||
# the row is created while the profile is rfc8693 (scopes accepted),
|
||||
# then the deployment flips to entra.
|
||||
client.app.state.oidc_config = SimpleNamespace(obo_grant_profile="rfc8693")
|
||||
client.app.state.oidc_config = _enabled_oidc("rfc8693")
|
||||
sid = self._seed_obo_row_with_cache(
|
||||
client, storage, name="entra-resend", scopes="legacy.scope"
|
||||
)
|
||||
client.app.state.oidc_config = SimpleNamespace(obo_grant_profile="entra")
|
||||
client.app.state.oidc_config = _enabled_oidc("entra")
|
||||
# Equal re-send + unrelated change → accepted, scopes untouched.
|
||||
r2 = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
@@ -1146,9 +1220,7 @@ class TestUpdateMcpServer:
|
||||
"""#551 follow-up: oauth_scopes is meaningless for the entra grant leg
|
||||
(it mints <audience>/.default), so the write path rejects it rather than
|
||||
silently ignoring it at mint time."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
client.app.state.oidc_config = SimpleNamespace(obo_grant_profile="entra")
|
||||
client.app.state.oidc_config = _enabled_oidc("entra")
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
|
||||
@@ -545,6 +545,47 @@ class TestCacheAndCredentialLookup:
|
||||
row = storage.get_mcp_user_token(USER, SERVER)
|
||||
assert row is not None and row["audience"] == AUDIENCE
|
||||
|
||||
def test_stale_scopes_cache_row_is_not_served_and_remints(self, storage: SQLiteBackend) -> None:
|
||||
"""Review finding: the read-side freshness gate is the AUTHORITATIVE
|
||||
enforcement of a scope narrowing (the admin cache purge is best-effort).
|
||||
Under rfc8693 a row minted with the OLD, wider scopes must NOT be served
|
||||
after the server's scopes are narrowed — even if the purge failed — so
|
||||
the privilege reduction takes effect on the next dispatch, not at TTL."""
|
||||
_seed_obo_server(storage, oauth_scopes="api.read") # server's CURRENT scopes
|
||||
client = MagicMock(spec=httpx.AsyncClient)
|
||||
client.post = AsyncMock(
|
||||
side_effect=[
|
||||
_mk_response(200, {"access_token": "subject-at", "expires_in": 300}),
|
||||
_mk_response(200, {"access_token": "reminted-narrow", "expires_in": 3600}),
|
||||
]
|
||||
)
|
||||
state = _make_app_state(
|
||||
storage,
|
||||
http_client=client,
|
||||
oidc_config=_make_oidc_config(obo_grant_profile="rfc8693"),
|
||||
)
|
||||
_seed_credential(state)
|
||||
# Fresh, right-audience, refresh-less — but minted with the OLD wider scopes.
|
||||
state.mcp_token_store.create_user_token(
|
||||
USER,
|
||||
SERVER,
|
||||
access_token="wide-scope-at",
|
||||
refresh_token=None,
|
||||
expires_at=(datetime.now(UTC) + timedelta(seconds=3600)).strftime(_ISO),
|
||||
scopes="api.read api.write", # wider than the server's current api.read
|
||||
as_issuer=ISSUER,
|
||||
audience=AUDIENCE,
|
||||
)
|
||||
|
||||
result = _mint(state)
|
||||
|
||||
# The wider-scope row is NOT served; a fresh mint for the current scopes runs.
|
||||
assert result.kind == "token"
|
||||
assert result.token == "reminted-narrow"
|
||||
assert client.post.call_count == 2
|
||||
row = storage.get_mcp_user_token(USER, SERVER)
|
||||
assert row is not None and (row["scopes"] or "") == "api.read"
|
||||
|
||||
def test_missing_credential_returns_missing_with_zero_http_calls(
|
||||
self, storage: SQLiteBackend
|
||||
) -> None:
|
||||
@@ -683,12 +724,14 @@ class TestFailureHandling:
|
||||
assert "obo_mint_rejected" in blob
|
||||
assert "AADSTS65001" in blob # the actual IdP error text survives
|
||||
|
||||
def test_permanent_rejection_arms_cooldown_terminal_state(self, storage: SQLiteBackend) -> None:
|
||||
"""Review finding (2156): the obo permanent-rejection arm must arm the
|
||||
cooldown, else — because the credential survives and a missing cache row
|
||||
is mint-eligible — every subsequent dispatch re-runs the doomed
|
||||
redemption and emits another token_revoked audit row forever. A second
|
||||
immediate dispatch must NOT re-hit the IdP or re-audit."""
|
||||
def test_permanent_rejection_no_cache_row_emits_no_revoke_audit(
|
||||
self, storage: SQLiteBackend
|
||||
) -> None:
|
||||
"""Review finding: a permanent mint rejection with NO cache row (the
|
||||
common missing-tenant-grant case — the user never had a token for this
|
||||
server) must NOT emit a token_revoked audit for a row that never
|
||||
existed. The cooldown is still armed as the terminal backstop (the
|
||||
credential survives), so the second dispatch short-circuits."""
|
||||
_seed_obo_server(storage)
|
||||
client = MagicMock(spec=httpx.AsyncClient)
|
||||
client.post = AsyncMock(
|
||||
@@ -712,10 +755,43 @@ class TestFailureHandling:
|
||||
# Terminal: the cooldown short-circuits the second dispatch entirely.
|
||||
assert second.kind == "refresh_failed_transient"
|
||||
assert client.post.call_count == 1 # NOT re-minted
|
||||
# Exactly one revoke audit — not one per dispatch.
|
||||
# No row was ever deleted → no bogus revoke audit.
|
||||
events = storage.list_audit_events(action="mcp_server.oauth.token_revoked")
|
||||
assert len(events) == 0
|
||||
assert state.mcp_token_store.get_oidc_credential(USER, ISSUER) is not None
|
||||
|
||||
def test_permanent_rejection_with_cache_row_audits_exactly_once(
|
||||
self, storage: SQLiteBackend
|
||||
) -> None:
|
||||
"""Companion: when a cache row DID exist, the permanent rejection deletes
|
||||
it and audits token_revoked exactly ONCE. A later doomed re-mint (past
|
||||
the cooldown) finds no row to delete and must NOT append a second audit
|
||||
row — the crux of the audit-spam finding."""
|
||||
_seed_obo_server(storage)
|
||||
client = MagicMock(spec=httpx.AsyncClient)
|
||||
client.post = AsyncMock(
|
||||
return_value=_mk_response(400, {"error": "invalid_grant", "error_description": "dead"})
|
||||
)
|
||||
state = _make_app_state(storage, http_client=client, oidc_config=_make_oidc_config())
|
||||
_seed_credential(state)
|
||||
_seed_cache_row(state, expires_in_seconds=-1000, access_token="stale-at") # forces a mint
|
||||
|
||||
from turnstone.core.mcp_oauth import _clear_refresh_backoff
|
||||
|
||||
async def _run() -> None:
|
||||
# First dispatch: deletes the (stale) cache row + audits once.
|
||||
await get_obo_access_token_classified(app_state=state, user_id=USER, server_name=SERVER)
|
||||
# Clear the cooldown so the second dispatch actually re-mints (the
|
||||
# weekend-of-scheduled-runs scenario), then dispatch again.
|
||||
_clear_refresh_backoff(state, USER, SERVER)
|
||||
await get_obo_access_token_classified(app_state=state, user_id=USER, server_name=SERVER)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
assert client.post.call_count == 2 # re-minted after the cooldown cleared
|
||||
# But only ONE revoke audit — the second doomed mint found no row.
|
||||
events = storage.list_audit_events(action="mcp_server.oauth.token_revoked")
|
||||
assert len(events) == 1
|
||||
assert state.mcp_token_store.get_oidc_credential(USER, ISSUER) is not None
|
||||
|
||||
def test_force_refresh_during_cooldown_falls_through_on_fresh_cache(
|
||||
self, storage: SQLiteBackend
|
||||
|
||||
@@ -1187,6 +1187,22 @@ class TestTokenRejectedDetail:
|
||||
assert "consent" not in obo_detail.lower()
|
||||
assert "administrator" in obo_detail.lower()
|
||||
|
||||
def test_obo_insufficient_scope_detail_points_at_admin_not_reconsent(self) -> None:
|
||||
"""Review finding: the 403 insufficient-scope branch was the one
|
||||
user-actionable site not made obo-aware — it told obo users to
|
||||
'Re-consent with new scopes' though no per-server consent flow exists
|
||||
(consent_url is None, /start rejects obo). The obo detail names the real
|
||||
remedy (an administrator widening access), the oauth_user one keeps the
|
||||
step-up re-consent language."""
|
||||
from turnstone.core.mcp_client import _insufficient_scope_detail
|
||||
|
||||
user_detail = _insufficient_scope_detail({"auth_type": "oauth_user"}, "tool")
|
||||
assert "Re-consent" in user_detail
|
||||
|
||||
obo_detail = _insufficient_scope_detail({"auth_type": "oauth_obo"}, "tool")
|
||||
assert "consent" not in obo_detail.lower()
|
||||
assert "administrator" in obo_detail.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background token-freshness sweep (oauth_user keep-hot, no connection warming)
|
||||
|
||||
+141
-122
@@ -9802,6 +9802,47 @@ def _enforce_oauth_user_https(auth_type: str, url: str | None) -> JSONResponse |
|
||||
return None
|
||||
|
||||
|
||||
def _obo_profile(request: Request) -> str:
|
||||
"""Deployment-level ``[oidc] obo_grant_profile`` (``""`` when OIDC absent).
|
||||
|
||||
One reader for the profile so the create handler, the update handler, and
|
||||
the write-time enforcement never diverge on how they resolve it.
|
||||
"""
|
||||
oidc_config = getattr(request.app.state, "oidc_config", None)
|
||||
return str(getattr(oidc_config, "obo_grant_profile", "") or "")
|
||||
|
||||
|
||||
def _oauth_columns_to_clear(auth_type: str | None) -> dict[str, None]:
|
||||
"""OAuth columns to force-NULL for a row of *auth_type* (shared write policy).
|
||||
|
||||
Single source of the "which OAuth columns does this auth model use" policy,
|
||||
consumed by both the create and update handlers so they cannot drift.
|
||||
``oauth_user`` uses every column (nothing cleared); ``oauth_obo`` uses
|
||||
``oauth_audience`` (+ ``oauth_scopes`` under rfc8693) but none of the
|
||||
oauth_user-only columns; ``static``/``none`` use no OAuth columns at all.
|
||||
``oauth_client_secret_ct`` is owned by a dedicated write path and is not
|
||||
included here. Callers that cannot persist ``oauth_as_issuer_cached`` (the
|
||||
create path) drop that key.
|
||||
"""
|
||||
if auth_type == "oauth_user":
|
||||
return {}
|
||||
if auth_type == "oauth_obo":
|
||||
return {
|
||||
"oauth_client_id": None,
|
||||
"oauth_registration_mode": None,
|
||||
"oauth_authorization_server_url": None,
|
||||
"oauth_as_issuer_cached": None,
|
||||
}
|
||||
return { # static / none
|
||||
"oauth_client_id": None,
|
||||
"oauth_scopes": None,
|
||||
"oauth_audience": None,
|
||||
"oauth_registration_mode": None,
|
||||
"oauth_authorization_server_url": None,
|
||||
"oauth_as_issuer_cached": None,
|
||||
}
|
||||
|
||||
|
||||
def _enforce_oauth_obo_requirements(
|
||||
request: Request, auth_type: str, *, audience: str | None, scopes: str | None
|
||||
) -> JSONResponse | None:
|
||||
@@ -9814,6 +9855,14 @@ def _enforce_oauth_obo_requirements(
|
||||
per-user mint-cache rows AND makes ``initialize_mcp_crypto_state``
|
||||
``SystemExit(1)`` at the next restart, so accepting one keyless plants a
|
||||
deferred whole-cluster boot failure.
|
||||
- **OIDC not enabled / no captured-credential source** → 400. The mint
|
||||
redeems the user's captured IdP credential, so an install with no
|
||||
``[oidc]`` issuer (or OIDC operator-disabled) can NEVER mint — every tool
|
||||
call would return ``mcp_refresh_unavailable`` ("please retry") forever, a
|
||||
permanent misconfig dressed as a transient. Reject at write time.
|
||||
- **Invalid ``obo_grant_profile``** → 400. A typo'd deployment profile
|
||||
leaves the mint leg unresolved (``obo_misconfigured`` per dispatch), so
|
||||
surface it here rather than as a runtime transient that never heals.
|
||||
- **No ``oauth_audience``** → 400. The mint engine hard-requires the
|
||||
downstream audience; without it every tool call fails transient and logs
|
||||
``obo_misconfigured``. Reject here, exactly as bad URL schemes are.
|
||||
@@ -9827,6 +9876,33 @@ def _enforce_oauth_obo_requirements(
|
||||
return None
|
||||
if getattr(request.app.state, "mcp_token_store", None) is None:
|
||||
return JSONResponse({"error": _OAUTH_TOKEN_STORE_503_MSG}, status_code=503)
|
||||
oidc_config = getattr(request.app.state, "oidc_config", None)
|
||||
if oidc_config is None or not getattr(oidc_config, "enabled", False):
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": (
|
||||
"auth_type=oauth_obo requires OIDC sign-in to be configured and "
|
||||
"enabled (it mints per-server tokens from each user's Turnstone "
|
||||
"sign-in). Configure [oidc] and capture_user_credential, or use "
|
||||
"auth_type=oauth_user for per-server consent."
|
||||
)
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
profile = _obo_profile(request)
|
||||
from turnstone.core.mcp_oauth import OBO_GRANT_PROFILES
|
||||
|
||||
if profile not in OBO_GRANT_PROFILES:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": (
|
||||
f"[oidc] obo_grant_profile={profile!r} is not a valid grant profile "
|
||||
f"({', '.join(sorted(OBO_GRANT_PROFILES))}); fix the deployment "
|
||||
"config before configuring oauth_obo servers"
|
||||
)
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
if not (audience or "").strip():
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -9837,20 +9913,17 @@ def _enforce_oauth_obo_requirements(
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
if (scopes or "").strip():
|
||||
oidc_config = getattr(request.app.state, "oidc_config", None)
|
||||
profile = str(getattr(oidc_config, "obo_grant_profile", "") or "")
|
||||
if profile == "entra":
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": (
|
||||
"oauth_scopes is not used by the entra grant profile "
|
||||
"(it mints <audience>/.default); leave it empty or switch "
|
||||
"the deployment to the rfc8693 profile"
|
||||
)
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
if (scopes or "").strip() and profile == "entra":
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": (
|
||||
"oauth_scopes is not used by the entra grant profile "
|
||||
"(it mints <audience>/.default); leave it empty or switch "
|
||||
"the deployment to the rfc8693 profile"
|
||||
)
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -10272,30 +10345,23 @@ async def admin_create_mcp_server(request: Request) -> JSONResponse:
|
||||
headers_dict = body.get("headers", {})
|
||||
env_dict = body.get("env", {})
|
||||
|
||||
# Column policy — mirror of the update handler's clearing rules: persist
|
||||
# only the OAuth columns the target auth_type actually uses, so a later
|
||||
# auth_type flip can't resurrect values the operator never intended for
|
||||
# the new mode (the update path clears on TRANSITION; without the same
|
||||
# policy here, a row CREATED as oauth_obo could carry a client_id /
|
||||
# registration_mode / AS-URL straight into a later oauth_user flip).
|
||||
oauth_client_id = _clean_oauth_text(body.get("oauth_client_id"))
|
||||
oauth_scopes = _clean_oauth_text(body.get("oauth_scopes"))
|
||||
oauth_audience = _clean_oauth_text(body.get("oauth_audience"), max_length=2048)
|
||||
oauth_registration_mode = _clean_oauth_text(body.get("oauth_registration_mode"))
|
||||
oauth_as_url = _clean_oauth_text(body.get("oauth_authorization_server_url"), max_length=2048)
|
||||
if auth_type == "oauth_obo":
|
||||
# obo uses audience (+ scopes under rfc8693) but none of the
|
||||
# oauth_user-only columns.
|
||||
oauth_client_id = None
|
||||
oauth_registration_mode = None
|
||||
oauth_as_url = None
|
||||
elif auth_type != "oauth_user":
|
||||
# static/none use no OAuth columns at all.
|
||||
oauth_client_id = None
|
||||
oauth_scopes = None
|
||||
oauth_audience = None
|
||||
oauth_registration_mode = None
|
||||
oauth_as_url = None
|
||||
# Column policy — the SAME ``_oauth_columns_to_clear`` rule the update
|
||||
# handler applies on a flip: persist only the OAuth columns the target
|
||||
# auth_type actually uses, so a row created as oauth_obo can't carry a
|
||||
# client_id / registration_mode / AS-URL straight into a later oauth_user
|
||||
# flip. (``oauth_as_issuer_cached`` is discovery-owned and not a create
|
||||
# parameter, so it is dropped from the cleared set here.)
|
||||
oauth_cols: dict[str, Any] = {
|
||||
"oauth_client_id": _clean_oauth_text(body.get("oauth_client_id")),
|
||||
"oauth_scopes": _clean_oauth_text(body.get("oauth_scopes")),
|
||||
"oauth_audience": _clean_oauth_text(body.get("oauth_audience"), max_length=2048),
|
||||
"oauth_registration_mode": _clean_oauth_text(body.get("oauth_registration_mode")),
|
||||
"oauth_authorization_server_url": _clean_oauth_text(
|
||||
body.get("oauth_authorization_server_url"), max_length=2048
|
||||
),
|
||||
}
|
||||
oauth_cols.update(_oauth_columns_to_clear(auth_type))
|
||||
oauth_cols.pop("oauth_as_issuer_cached", None)
|
||||
|
||||
storage.create_mcp_server(
|
||||
server_id=server_id,
|
||||
@@ -10310,11 +10376,7 @@ async def admin_create_mcp_server(request: Request) -> JSONResponse:
|
||||
enabled=bool(body.get("enabled", True)),
|
||||
created_by=audit_uid,
|
||||
auth_type=auth_type,
|
||||
oauth_client_id=oauth_client_id,
|
||||
oauth_scopes=oauth_scopes,
|
||||
oauth_audience=oauth_audience,
|
||||
oauth_registration_mode=oauth_registration_mode,
|
||||
oauth_authorization_server_url=oauth_as_url,
|
||||
**oauth_cols,
|
||||
)
|
||||
|
||||
# Encrypt + persist the operator-supplied client secret via the dedicated
|
||||
@@ -10467,87 +10529,44 @@ async def admin_update_mcp_server(request: Request) -> JSONResponse:
|
||||
|
||||
old_auth = existing.get("auth_type")
|
||||
new_auth = updates.get("auth_type")
|
||||
# A flip is any auth_type transition. ``oauth_audience`` and ``oauth_scopes``
|
||||
# keep their meaning only WITHIN an auth type — across oauth_user↔oauth_obo
|
||||
# the audience is a resource indicator vs. an IdP-side app identifier, and
|
||||
# the scopes are AS-consent scopes vs. an rfc8693 exchange scope — so on a
|
||||
# flip they must NEVER carry from the old row; they are recomputed from the
|
||||
# request body (or NULLed) for the target type below.
|
||||
is_flip = new_auth is not None and new_auth != old_auth
|
||||
|
||||
# A request that re-sends oauth_scopes / oauth_audience equal to the row's
|
||||
# current value is a no-op — drop it from ``updates``. The admin form
|
||||
# pre-fills both fields and submits them on every save, and three rules
|
||||
# below key on "this request SETS the column": the entra-profile scope
|
||||
# rejection (without this, a pre-existing scoped row under the entra
|
||||
# profile is un-editable — every unrelated save 400s), the mint-cache
|
||||
# purge triggers (a no-op re-send must not flush every user's cache), and
|
||||
# the flip-into-oauth_user clearing of obo-era values (a re-sent stale
|
||||
# pre-fill must not masquerade as an explicit set).
|
||||
for _noop_key in ("oauth_scopes", "oauth_audience"):
|
||||
if _noop_key in updates and (updates[_noop_key] or None) == (
|
||||
existing.get(_noop_key) or None
|
||||
):
|
||||
del updates[_noop_key]
|
||||
if not is_flip:
|
||||
# SAME-TYPE edit: a re-send of oauth_scopes / oauth_audience equal to the
|
||||
# row's current value is a genuine no-op — drop it from ``updates`` so the
|
||||
# admin form's unconditional re-send of pre-filled fields doesn't (a)
|
||||
# trip the mint-cache purge triggers or (b) trip the entra scope
|
||||
# rejection that would otherwise make a legacy-scoped row un-editable.
|
||||
# (On a flip this comparison is meaningless — the old value is in the
|
||||
# other auth model's semantics — so it is skipped.)
|
||||
for _noop_key in ("oauth_scopes", "oauth_audience"):
|
||||
if _noop_key in updates and (updates[_noop_key] or None) == (
|
||||
existing.get(_noop_key) or None
|
||||
):
|
||||
del updates[_noop_key]
|
||||
|
||||
# Clear the OAuth columns the target auth_type does NOT use, so a stale
|
||||
# client_id / audience can't leak back on a later flip. static/none use
|
||||
# none; oauth_obo uses oauth_audience (+ oauth_scopes) but none of the
|
||||
# oauth_user-only columns; oauth_user uses client_id / registration /
|
||||
# AS-URL / scopes plus an audience with DIFFERENT semantics (RFC 8707
|
||||
# resource indicator, not the obo IdP-side app identifier).
|
||||
# ``oauth_client_secret_ct`` is owned by a dedicated write path (see below).
|
||||
if new_auth and not is_user_scoped_auth(new_auth):
|
||||
# → static/none: clear every OAuth column.
|
||||
updates.update(
|
||||
{
|
||||
"oauth_client_id": None,
|
||||
"oauth_scopes": None,
|
||||
"oauth_audience": None,
|
||||
"oauth_registration_mode": None,
|
||||
"oauth_authorization_server_url": None,
|
||||
"oauth_as_issuer_cached": None,
|
||||
}
|
||||
)
|
||||
elif new_auth == "oauth_obo":
|
||||
# obo keeps audience (it needs it); clear the oauth_user-only columns.
|
||||
updates.update(
|
||||
{
|
||||
"oauth_client_id": None,
|
||||
"oauth_registration_mode": None,
|
||||
"oauth_authorization_server_url": None,
|
||||
"oauth_as_issuer_cached": None,
|
||||
}
|
||||
)
|
||||
# A FLIP into obo carries oauth_user's AS-consent scopes, which mean
|
||||
# something different in each grant profile:
|
||||
# entra — scopes are never used (the leg pins <audience>/.default),
|
||||
# so clear the carry-over unless this request typed a
|
||||
# genuinely NEW value (which the enforce step below then
|
||||
# rejects loudly rather than silently discarding);
|
||||
# rfc8693 — scopes ARE the token-exchange scope, so honor the
|
||||
# request verbatim: a value equal to the old consent
|
||||
# scopes is a deliberate keep (the operator sees the
|
||||
# field on the flip form), and only an OMITTED field
|
||||
# clears the oauth_user consent scopes (different
|
||||
# semantics under the new auth model).
|
||||
if old_auth != "oauth_obo":
|
||||
profile = str(
|
||||
getattr(getattr(request.app.state, "oidc_config", None), "obo_grant_profile", "")
|
||||
or ""
|
||||
)
|
||||
if profile == "entra":
|
||||
if "oauth_scopes" not in updates:
|
||||
updates["oauth_scopes"] = None
|
||||
elif "oauth_scopes" not in body:
|
||||
updates["oauth_scopes"] = None
|
||||
elif new_auth == "oauth_user" and old_auth != "oauth_user":
|
||||
# Flipping INTO oauth_user: the obo-era oauth_audience is an IdP-side
|
||||
# app identifier (api://<guid> on Entra, a client id on Keycloak), NOT
|
||||
# the RFC 8707 resource indicator oauth_user passes to its per-server
|
||||
# AS — carried over, build_authorize_url requests (and the token
|
||||
# validator accepts) a wrong-resource token that 401s on every
|
||||
# dispatch with no visible cause. Same for rfc8693 exchange scopes
|
||||
# leaking into consent scopes. Clear both unless this request
|
||||
# explicitly sets them. (On main this was structurally impossible —
|
||||
# any non-oauth_user row had every OAuth column nulled.)
|
||||
if "oauth_audience" not in updates:
|
||||
updates["oauth_audience"] = None
|
||||
if "oauth_scopes" not in updates and "oauth_scopes" not in body:
|
||||
updates["oauth_scopes"] = None
|
||||
# Clear the OAuth columns the target auth_type does NOT use (shared
|
||||
# ``_oauth_columns_to_clear`` policy). ``oauth_client_secret_ct`` is owned by
|
||||
# a dedicated write path (see below).
|
||||
if is_flip:
|
||||
target_auth = new_auth # non-None: is_flip requires new_auth is not None
|
||||
if is_user_scoped_auth(target_auth):
|
||||
# FLIP into a user-scoped type: recompute the semantic columns from
|
||||
# the body — present → that value, absent → NULL — so neither the
|
||||
# old audience nor the old scopes can leak across the semantic
|
||||
# boundary. The top-of-handler loop already copied any body value
|
||||
# into ``updates``; forcing the key present here NULLs it when the
|
||||
# body omitted it (e.g. an API PUT of just {"auth_type": ...}, or the
|
||||
# console form clearing the field on the auth-type switch).
|
||||
updates["oauth_audience"] = updates.get("oauth_audience")
|
||||
updates["oauth_scopes"] = updates.get("oauth_scopes")
|
||||
updates.update(_oauth_columns_to_clear(target_auth))
|
||||
# Renaming a pool-backed row needs the same per-user-token purge as
|
||||
# delete: the tokens are keyed on the OLD ``server_name`` and a row
|
||||
# later created with that old name (with attacker-controlled URL)
|
||||
|
||||
@@ -5120,16 +5120,17 @@ function _wireMcpAudienceAutofill() {
|
||||
}
|
||||
|
||||
function _onMcpAuthTypeChange() {
|
||||
// Switching INTO sign-in passthrough: if the audience field still holds
|
||||
// the URL-autofill artifact (operator typed the URL first, then picked
|
||||
// this mode), blank it so the required-field check asks for a real
|
||||
// identity-provider application identifier instead of silently
|
||||
// persisting the MCP URL as the audience.
|
||||
if (_selectedMcpAuthType() === "oauth_obo") {
|
||||
const aud = document.getElementById("mcp-oauth-audience");
|
||||
const urlVal = document.getElementById("mcp-url").value.trim();
|
||||
if (aud && urlVal && aud.value.trim() === urlVal) aud.value = "";
|
||||
}
|
||||
// Audience and Scopes are auth-type-specific: for oauth_user the audience is
|
||||
// an RFC 8707 resource indicator (~ the MCP URL) and scopes are AS-consent
|
||||
// scopes; for sign-in passthrough the audience is the identity-provider-side
|
||||
// application identifier and scopes (rfc8693 only) are the token-exchange
|
||||
// scope. Carrying one type's value into the other passes validation and then
|
||||
// fails every mint/consent, so clear both when the auth type changes — the
|
||||
// operator re-enters the correct values for the new mode (the backend
|
||||
// likewise refuses to carry these columns across a flip). A same-type edit
|
||||
// never fires this (the radio didn't change), so pre-filled values are kept.
|
||||
document.getElementById("mcp-oauth-audience").value = "";
|
||||
document.getElementById("mcp-oauth-scopes").value = "";
|
||||
toggleMcpAuthFields();
|
||||
}
|
||||
|
||||
@@ -5179,11 +5180,7 @@ function _mcpResetForm() {
|
||||
document.getElementById("mcp-oauth-registration").value = "preregistered";
|
||||
document.getElementById("mcp-oauth-client-id").value = "";
|
||||
document.getElementById("mcp-oauth-client-secret").value = "";
|
||||
const scopesReset = document.getElementById("mcp-oauth-scopes");
|
||||
scopesReset.value = "";
|
||||
// Create flow has no loaded row — the marker's absence tells
|
||||
// _parseMcpForm to always include the field.
|
||||
delete scopesReset.dataset.loaded;
|
||||
document.getElementById("mcp-oauth-scopes").value = "";
|
||||
document.getElementById("mcp-oauth-audience").value = "";
|
||||
document.getElementById("mcp-create-error").classList.remove("is-visible");
|
||||
toggleMcpTransport();
|
||||
@@ -5256,12 +5253,7 @@ function showEditMcpModal(serverId) {
|
||||
s.oauth_client_id || "";
|
||||
// Secret field always blank — write-only, never read back.
|
||||
document.getElementById("mcp-oauth-client-secret").value = "";
|
||||
const scopesInput = document.getElementById("mcp-oauth-scopes");
|
||||
scopesInput.value = s.oauth_scopes || "";
|
||||
// Remember the loaded value so _parseMcpForm can omit an untouched
|
||||
// pre-fill from the payload (the server treats a present field as an
|
||||
// operator-set value).
|
||||
scopesInput.dataset.loaded = s.oauth_scopes || "";
|
||||
document.getElementById("mcp-oauth-scopes").value = s.oauth_scopes || "";
|
||||
document.getElementById("mcp-oauth-audience").value =
|
||||
s.oauth_audience || "";
|
||||
toggleMcpTransport();
|
||||
@@ -5364,18 +5356,13 @@ function _parseMcpForm() {
|
||||
if (!audience)
|
||||
return { error: "Audience is required for sign-in passthrough servers" };
|
||||
payload.oauth_audience = audience;
|
||||
// Include scopes only when the operator changed them from the loaded row
|
||||
// value (or on create, where no loaded marker exists): the server treats
|
||||
// a present field as an operator-set value, and re-submitting the
|
||||
// untouched pre-fill on every save must not read as one.
|
||||
const scopesInput = document.getElementById("mcp-oauth-scopes");
|
||||
const scopesVal = scopesInput.value.trim();
|
||||
if (
|
||||
scopesInput.dataset.loaded === undefined ||
|
||||
scopesVal !== scopesInput.dataset.loaded.trim()
|
||||
) {
|
||||
payload.oauth_scopes = scopesVal;
|
||||
}
|
||||
// Always send the visible Scopes value — the backend distinguishes a
|
||||
// same-type no-op re-send (dropped) from a genuine change / flip on its
|
||||
// side, so the form doesn't need omit-when-unchanged logic (which used to
|
||||
// collide with the backend's flip handling and silently drop scopes).
|
||||
payload.oauth_scopes = document
|
||||
.getElementById("mcp-oauth-scopes")
|
||||
.value.trim();
|
||||
}
|
||||
|
||||
return payload;
|
||||
|
||||
@@ -5713,8 +5713,7 @@ class MCPClientManager:
|
||||
return
|
||||
try:
|
||||
self._storage.delete_mcp_pending_consent(user_id, server_name)
|
||||
self._prune_pending_consent_cleared(now)
|
||||
self._pending_consent_cleared[key] = now
|
||||
self._mark_pending_consent_cleared(key, now)
|
||||
except Exception:
|
||||
log.debug(
|
||||
"mcp_pool.pending_consent_clear_failed user=%s server=%s",
|
||||
@@ -5723,6 +5722,17 @@ class MCPClientManager:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _mark_pending_consent_cleared(self, key: tuple[str, str], now: float) -> None:
|
||||
"""Record a DB-confirmed pending-consent DELETE in the TTL map.
|
||||
|
||||
The single "prune-then-stamp" step shared by the two DB-confirmed clear
|
||||
sites (:meth:`_clear_pending_consent_sync` on the hot dispatch path and
|
||||
:meth:`_clear_pending_consent_best_effort` from the sweep) so the
|
||||
bookkeeping order can't drift between them.
|
||||
"""
|
||||
self._prune_pending_consent_cleared(now)
|
||||
self._pending_consent_cleared[key] = now
|
||||
|
||||
def _prune_pending_consent_cleared(self, now: float) -> None:
|
||||
"""Drop aged entries when the TTL map grows large (memory hygiene).
|
||||
|
||||
@@ -6558,10 +6568,7 @@ class MCPClientManager:
|
||||
return _structured_error(
|
||||
code="mcp_insufficient_scope",
|
||||
server=server_name,
|
||||
detail=(
|
||||
f"{kind.capitalize()} requires elevated scopes. "
|
||||
"Re-consent flow with new scopes required."
|
||||
),
|
||||
detail=_insufficient_scope_detail(server_row, kind),
|
||||
scopes_required=list(scopes),
|
||||
consent_url=_build_consent_url(server_row, scopes_required=list(scopes)),
|
||||
)
|
||||
@@ -7243,6 +7250,30 @@ def _refresh_failed_detail(server_row: dict[str, Any]) -> str:
|
||||
return "Refresh token rejected. Re-consent required."
|
||||
|
||||
|
||||
def _insufficient_scope_detail(server_row: dict[str, Any], kind: str) -> str:
|
||||
"""User-facing detail for a 403 insufficient_scope, per auth model.
|
||||
|
||||
``oauth_user``: a per-server step-up re-consent with the widened scope set
|
||||
is the remedy (``_build_consent_url`` attaches the affordance). ``oauth_obo``:
|
||||
there is no per-server consent flow — the minted token's scopes come from
|
||||
the user's captured sign-in (entra: the app's delegated permissions;
|
||||
rfc8693: the server's configured ``oauth_scopes``), so a user-driven
|
||||
step-up is impossible (``_build_consent_url`` returns None). Point at the
|
||||
real fix — an administrator widening access — instead of a dead-end
|
||||
re-consent card.
|
||||
"""
|
||||
if server_row.get("auth_type") == "oauth_obo":
|
||||
return (
|
||||
f"This {kind} needs additional permissions your sign-in token does not "
|
||||
"carry. Ask your administrator to grant the required access (add the "
|
||||
"scope to the server, or widen your delegated permissions at the "
|
||||
"identity provider)."
|
||||
)
|
||||
return (
|
||||
f"{kind.capitalize()} requires elevated scopes. Re-consent flow with new scopes required."
|
||||
)
|
||||
|
||||
|
||||
def _token_rejected_detail(server_row: dict[str, Any]) -> str:
|
||||
"""User-facing detail for a 401 that survived one forced refresh (retry ceiling).
|
||||
|
||||
|
||||
+86
-73
@@ -39,7 +39,6 @@ import httpx
|
||||
from turnstone.core.audit import record_audit
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.mcp_crypto import (
|
||||
USER_SCOPED_AUTH_TYPES,
|
||||
MCPTokenDecryptError,
|
||||
OIDCCredentialPlain,
|
||||
is_user_scoped_auth,
|
||||
@@ -1500,21 +1499,64 @@ async def _revoke_after_refresh_failure(
|
||||
Drops the per-key refresh lock and backoff state — both safe to call when no
|
||||
entry exists — and returns ``refresh_failed`` so the dispatcher surfaces
|
||||
re-consent.
|
||||
|
||||
The ``token_revoked`` audit is emitted ONLY when a row was actually deleted.
|
||||
This matters for oauth_obo: its shared credential survives a per-server
|
||||
revoke, so after the first permanent rejection deletes the cache row, every
|
||||
later dispatch/prime past the cooldown re-runs the doomed redemption and
|
||||
lands here again with nothing to delete — auditing unconditionally would
|
||||
append a fresh "token_revoked" row for a token that no longer exists on
|
||||
every cycle, forever (misleading forensics + alert fatigue for operators
|
||||
who alert on token_revoked). oauth_user cannot hit the empty-delete case (a
|
||||
revoke there also removes the only refresh source, so no recurrence), so
|
||||
this gate is a no-op for it.
|
||||
"""
|
||||
await asyncio.to_thread(token_store.delete_user_token, user_id, server_name)
|
||||
await _audit_event(
|
||||
app_state,
|
||||
server_id=server_id_for_audit,
|
||||
user_id=user_id,
|
||||
action="mcp_server.oauth.token_revoked",
|
||||
server_name=server_name,
|
||||
detail={"reason": reason},
|
||||
)
|
||||
deleted = await asyncio.to_thread(token_store.delete_user_token, user_id, server_name)
|
||||
if deleted:
|
||||
await _audit_event(
|
||||
app_state,
|
||||
server_id=server_id_for_audit,
|
||||
user_id=user_id,
|
||||
action="mcp_server.oauth.token_revoked",
|
||||
server_name=server_name,
|
||||
detail={"reason": reason},
|
||||
)
|
||||
_drop_refresh_lock(app_state, user_id, server_name)
|
||||
_clear_refresh_backoff(app_state, user_id, server_name)
|
||||
return TokenLookupResult(kind="refresh_failed")
|
||||
|
||||
|
||||
def _decrypt_failure_result(
|
||||
app_state: Any,
|
||||
user_id: str,
|
||||
server_name: str,
|
||||
exc: MCPTokenDecryptError,
|
||||
*,
|
||||
event: str | None,
|
||||
) -> TokenLookupResult:
|
||||
"""Map an ``MCPTokenDecryptError`` to a classified ``decrypt_failure`` result.
|
||||
|
||||
One construction of the (optional warning log + ``_no_token_result`` +
|
||||
fingerprint-carrying ``TokenLookupResult``) shape, shared by the five
|
||||
token/credential read sites in the oauth_user and oauth_obo state machines
|
||||
(the raw ``get_user_token`` / ``get_oidc_credential`` calls each raise this
|
||||
on a key rotated away, and each must keep the classified-result contract).
|
||||
Pass the site's structured *event* name to log, or ``None`` for a re-read
|
||||
whose first read already logged.
|
||||
"""
|
||||
if event is not None:
|
||||
log.warning(event, user_id=user_id, server_name=server_name, exc_info=True)
|
||||
return _no_token_result(
|
||||
app_state,
|
||||
user_id,
|
||||
server_name,
|
||||
TokenLookupResult(
|
||||
kind="decrypt_failure",
|
||||
decrypt_fingerprints=tuple(exc.key_fingerprints_attempted),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def get_user_access_token(*, app_state: Any, user_id: str, server_name: str) -> str | None:
|
||||
"""Return a valid plaintext access token, refreshing if needed.
|
||||
|
||||
@@ -1786,20 +1828,12 @@ async def get_user_access_token_classified(
|
||||
try:
|
||||
plain = await asyncio.to_thread(token_store.get_user_token, user_id, server_name)
|
||||
except MCPTokenDecryptError as exc:
|
||||
log.warning(
|
||||
"mcp_server.oauth.token_decrypt_failed_classified",
|
||||
user_id=user_id,
|
||||
server_name=server_name,
|
||||
exc_info=True,
|
||||
)
|
||||
return _no_token_result(
|
||||
return _decrypt_failure_result(
|
||||
app_state,
|
||||
user_id,
|
||||
server_name,
|
||||
TokenLookupResult(
|
||||
kind="decrypt_failure",
|
||||
decrypt_fingerprints=tuple(exc.key_fingerprints_attempted),
|
||||
),
|
||||
exc,
|
||||
event="mcp_server.oauth.token_decrypt_failed_classified",
|
||||
)
|
||||
if plain is None:
|
||||
return _no_token_result(app_state, user_id, server_name, TokenLookupResult(kind="missing"))
|
||||
@@ -1856,20 +1890,12 @@ async def get_user_access_token_classified(
|
||||
try:
|
||||
plain2 = await asyncio.to_thread(token_store.get_user_token, user_id, server_name)
|
||||
except MCPTokenDecryptError as exc:
|
||||
log.warning(
|
||||
"mcp_server.oauth.token_decrypt_failed_classified",
|
||||
user_id=user_id,
|
||||
server_name=server_name,
|
||||
exc_info=True,
|
||||
)
|
||||
return _no_token_result(
|
||||
return _decrypt_failure_result(
|
||||
app_state,
|
||||
user_id,
|
||||
server_name,
|
||||
TokenLookupResult(
|
||||
kind="decrypt_failure",
|
||||
decrypt_fingerprints=tuple(exc.key_fingerprints_attempted),
|
||||
),
|
||||
exc,
|
||||
event="mcp_server.oauth.token_decrypt_failed_classified",
|
||||
)
|
||||
if plain2 is None:
|
||||
return _no_token_result(
|
||||
@@ -2185,20 +2211,12 @@ async def _read_obo_credential(
|
||||
try:
|
||||
credential = await asyncio.to_thread(token_store.get_oidc_credential, user_id, issuer)
|
||||
except MCPTokenDecryptError as exc:
|
||||
log.warning(
|
||||
"mcp_server.oauth.obo_credential_decrypt_failed",
|
||||
user_id=user_id,
|
||||
server_name=server_name,
|
||||
exc_info=True,
|
||||
)
|
||||
return _no_token_result(
|
||||
return _decrypt_failure_result(
|
||||
app_state,
|
||||
user_id,
|
||||
server_name,
|
||||
TokenLookupResult(
|
||||
kind="decrypt_failure",
|
||||
decrypt_fingerprints=tuple(exc.key_fingerprints_attempted),
|
||||
),
|
||||
exc,
|
||||
event="mcp_server.oauth.obo_credential_decrypt_failed",
|
||||
)
|
||||
if credential is None:
|
||||
# No captured credential → the consent affordance is a re-login.
|
||||
@@ -2206,10 +2224,12 @@ async def _read_obo_credential(
|
||||
return credential
|
||||
|
||||
|
||||
def _is_fresh_obo_cache_row(plain: MCPUserTokenPlain | None, current_audience: str) -> bool:
|
||||
def _is_fresh_obo_cache_row(
|
||||
plain: MCPUserTokenPlain | None, current_audience: str, current_scopes: str
|
||||
) -> bool:
|
||||
"""True when a cache row may be served as a minted obo access token.
|
||||
|
||||
Three conditions, all required (single source of truth for the pre-lock read
|
||||
Four conditions, all required (single source of truth for the pre-lock read
|
||||
AND the post-lock re-read so they can't drift):
|
||||
|
||||
- refresh_token is NULL — minted rows carry no refresh token; a
|
||||
@@ -2219,12 +2239,20 @@ def _is_fresh_obo_cache_row(plain: MCPUserTokenPlain | None, current_audience: s
|
||||
for a since-narrowed audience must NOT be served, so an operator's
|
||||
privilege reduction takes effect immediately rather than at token TTL
|
||||
(the audience-change purge is best-effort; this is the authoritative gate);
|
||||
- the row's scopes equal the server's CURRENT scopes — the same authoritative
|
||||
gate for the rfc8693 exchange scope (which shapes the minted bearer's
|
||||
privileges just like the audience): a scope NARROWING must take effect on
|
||||
the next dispatch even if the admin's best-effort cache purge failed,
|
||||
rather than serving the wider-privilege bearer until its TTL. Under the
|
||||
entra leg scopes are inert, so the stored and current values track the
|
||||
same server column and this term is a no-op there;
|
||||
- not at/near expiry.
|
||||
"""
|
||||
return (
|
||||
plain is not None
|
||||
and plain["refresh_token"] is None
|
||||
and (plain.get("audience") or "") == current_audience
|
||||
and (plain.get("scopes") or "") == current_scopes
|
||||
and not _token_needs_refresh(plain["expires_at"])
|
||||
)
|
||||
|
||||
@@ -2295,25 +2323,18 @@ async def get_obo_access_token_classified(
|
||||
try:
|
||||
plain = await asyncio.to_thread(token_store.get_user_token, user_id, server_name)
|
||||
except MCPTokenDecryptError as exc:
|
||||
log.warning(
|
||||
"mcp_server.oauth.obo_cache_decrypt_failed",
|
||||
user_id=user_id,
|
||||
server_name=server_name,
|
||||
exc_info=True,
|
||||
)
|
||||
return _no_token_result(
|
||||
return _decrypt_failure_result(
|
||||
app_state,
|
||||
user_id,
|
||||
server_name,
|
||||
TokenLookupResult(
|
||||
kind="decrypt_failure",
|
||||
decrypt_fingerprints=tuple(exc.key_fingerprints_attempted),
|
||||
),
|
||||
exc,
|
||||
event="mcp_server.oauth.obo_cache_decrypt_failed",
|
||||
)
|
||||
# Serve the cache only when it is a fresh, right-audience, refresh-less row
|
||||
# (see _is_fresh_obo_cache_row). A stale-audience or refresh-bearing row
|
||||
# falls through to a fresh mint (which overwrites it via _persist_obo_cache_row).
|
||||
fresh = _is_fresh_obo_cache_row(plain, audience)
|
||||
# Serve the cache only when it is a fresh, right-audience, right-scopes,
|
||||
# refresh-less row (see _is_fresh_obo_cache_row). A stale-audience/-scopes or
|
||||
# refresh-bearing row falls through to a fresh mint (which overwrites it via
|
||||
# _persist_obo_cache_row).
|
||||
fresh = _is_fresh_obo_cache_row(plain, audience, scopes)
|
||||
if fresh and not force_refresh and plain is not None:
|
||||
return _token_result(app_state, user_id, server_name, plain["access_token"])
|
||||
|
||||
@@ -2384,19 +2405,12 @@ async def get_obo_access_token_classified(
|
||||
try:
|
||||
plain2 = await asyncio.to_thread(token_store.get_user_token, user_id, server_name)
|
||||
except MCPTokenDecryptError as exc:
|
||||
return _no_token_result(
|
||||
app_state,
|
||||
user_id,
|
||||
server_name,
|
||||
TokenLookupResult(
|
||||
kind="decrypt_failure",
|
||||
decrypt_fingerprints=tuple(exc.key_fingerprints_attempted),
|
||||
),
|
||||
)
|
||||
# First read already logged obo_cache_decrypt_failed; this re-read
|
||||
# under the lock stays silent (event=None) to avoid a double line.
|
||||
return _decrypt_failure_result(app_state, user_id, server_name, exc, event=None)
|
||||
# Same servability gate as the pre-lock read (refresh-less, right-audience,
|
||||
# not-expired) — a stale-audience or refresh-bearing row falls through and
|
||||
# re-mints.
|
||||
if _is_fresh_obo_cache_row(plain2, audience) and plain2 is not None:
|
||||
# right-scopes, not-expired) — a stale row falls through and re-mints.
|
||||
if _is_fresh_obo_cache_row(plain2, audience, scopes) and plain2 is not None:
|
||||
if not force_refresh:
|
||||
return _token_result(app_state, user_id, server_name, plain2["access_token"])
|
||||
# Obo cache rows are delete+create'd per mint and never touched by
|
||||
@@ -4008,7 +4022,6 @@ __all__ = [
|
||||
"MCP_OAUTH_STATE_TTL_SECONDS",
|
||||
"OBO_GRANT_PROFILES",
|
||||
"TokenLookupResult",
|
||||
"USER_SCOPED_AUTH_TYPES",
|
||||
"build_authorize_url",
|
||||
"close_mcp_oauth_state",
|
||||
"create_pending_state",
|
||||
|
||||
@@ -224,8 +224,11 @@ def load_oidc_config() -> OIDCConfig:
|
||||
"TURNSTONE_OIDC_OBO_GRANT_PROFILE", cfg, "obo_grant_profile", "entra"
|
||||
).strip()
|
||||
# Validate against the operative mint-leg registry (single source of truth,
|
||||
# so a new leg needs no second edit here). Lazy import: mcp_oauth pulls in
|
||||
# the MCP stack, and it does not import this module, so there is no cycle.
|
||||
# so a new leg needs no second edit here). Function-level import: oidc and
|
||||
# mcp_oauth reference each other's runtime helpers (mcp_oauth's mint path
|
||||
# imports this module's ``maybe_rediscover_oidc`` likewise lazily), so BOTH
|
||||
# directions stay off the module-import graph to keep the cycle unrealised —
|
||||
# neither module may import the other at module scope.
|
||||
from turnstone.core.mcp_oauth import OBO_GRANT_PROFILES
|
||||
|
||||
if obo_grant_profile not in OBO_GRANT_PROFILES:
|
||||
|
||||
@@ -4453,7 +4453,20 @@ function buildMcpErrorEmbed(err, rawJson, onConsent) {
|
||||
body.appendChild(scopesLine);
|
||||
}
|
||||
|
||||
if (category === "actionable") {
|
||||
// Render the Connect / Re-consent button only when the dispatcher actually
|
||||
// supplied a per-server consent URL. An "actionable"-category code with no
|
||||
// consent_url means there is no per-server consent flow for this server —
|
||||
// sign-in passthrough (oauth_obo) mints from the user's Turnstone sign-in and
|
||||
// is deliberately absent from the Settings connections list and rejected by
|
||||
// /start — so a button here would dead-end ("no consent URL; open Settings"
|
||||
// pointing at a panel with nothing to connect). In that case the honest
|
||||
// remedy is the detail text (sign in again / ask your administrator), so we
|
||||
// show the card without a broken affordance.
|
||||
const consentUrl = err.consent_url;
|
||||
const hasConsentAffordance =
|
||||
typeof consentUrl === "string" &&
|
||||
consentUrl.startsWith("/v1/api/mcp/oauth/start");
|
||||
if (category === "actionable" && hasConsentAffordance) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "mcp-error-action-btn";
|
||||
@@ -4469,20 +4482,10 @@ function buildMcpErrorEmbed(err, rawJson, onConsent) {
|
||||
: "Connect to " + serverLabel,
|
||||
);
|
||||
btn.addEventListener("click", function () {
|
||||
const consentUrl = err.consent_url;
|
||||
if (!consentUrl || typeof consentUrl !== "string") {
|
||||
// Defensive: should always be present per the dispatcher. If a
|
||||
// path forgets to include it the user can still connect via the
|
||||
// Settings panel (gear icon).
|
||||
showToast("No consent URL available; open Settings to connect.");
|
||||
return;
|
||||
}
|
||||
// Defence-in-depth: reject anything that isn't path-relative to
|
||||
// the dispatcher's known prefix. ``_build_consent_url`` always
|
||||
// emits ``/v1/api/mcp/oauth/start?...`` — a non-prefix value
|
||||
// would indicate a future producer drift or a compromised
|
||||
// dispatcher, and ``window.open("javascript:...")`` would be
|
||||
// catastrophic. Never rely on the producer-side guarantee alone.
|
||||
// Defence-in-depth: the render gate already proved the prefix, but
|
||||
// re-check at click time — a non-prefix value would indicate producer
|
||||
// drift or a compromised dispatcher, and window.open("javascript:...")
|
||||
// would be catastrophic. Never rely on the producer-side guarantee alone.
|
||||
if (!consentUrl.startsWith("/v1/api/mcp/oauth/start")) {
|
||||
showToast("Invalid consent URL");
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user