mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
33ace975d2
Follow-up to the per-alias Entra OBO/app-identity backend auth: the console write path now applies default-deny field classification, the admin shelf gains full backend-auth support, and the session/registry rebind machinery is hardened for config changes landing under live sessions. Console write gate: - Default-deny classification: any non-neutral change to a row that is or becomes dynamic requires admin.mcp plus validation; the provably auth-neutral columns are enumerated (MODEL_AUTH_NEUTRAL_FIELDS) and a live-schema classification test forces every future column to be classified. The derivation is a pure function (_derive_auth_gate) with unit-pinned exclusivity invariants. - Two-tier validation mirroring the MCP oauth_obo validator: the row tier (audience allow-list) runs on every gated write; the posture tier (OIDC configured, token store present) runs on pair changes and on enable-arming. - Pure-disable carve-out: disabling a dynamic row is de-escalation and is never blocked — admin.models suffices and validation is skipped, including for rows with corrupt or skewed stored values. - Capabilities are compared canonically (key order, integral floats), the audience compare normalizes both sides, and staging an audience on a static row is refused on both write twins. - Calibrate writes the capabilities column under an enforced confinement invariant with a compare-and-swap persist. Admin shelf: - Backend-auth section with a per-open constraints fetch (GET /model-definitions/auth-constraints: audience allow-list, grant profile, dynamic modes), datalist audience suggestions, server-defined modes preserved on round-trip, and permission-aware visibility built on cache-skew-safe helpers shared through auth.js. - Refused live-registry swaps surface as an amber registry_warning on the write, delete, reload, and calibrate responses; audit rows carry auth_gated / auth_disarmed markers visible in the audit view. Registry and sessions: - The encryption-key requirement for dynamic auth is enforced inside ModelRegistry.reload() itself — nodes refuse with 503 and the console records coord_registry_error — and reload bumps the generation before the map swap so a racing reader can never pair a stale generation with new maps. - resolve()/resolve_binding() return the generation from inside the registry lock; sessions rebind per send on generation change with atomic client/provider/config commits, fallback-first handling of removed or unconstructable aliases, and judge/limiter resets only when the binding actually changed. - Mint refusals record per-user causes surfaced in the per-turn heartbeat logs; misconfiguration warnings are deduplicated with bounded state. Verification: 10417 tests (99 added on this branch), a 71-scenario browser harness over the real admin shelf, and a live rfc8693 token-exchange e2e run (MCP legs verified end to end; the model-leg scope gap is tracked as #955 under a narrow known-gap signature). Closes #950.
1231 lines
52 KiB
Python
1231 lines
52 KiB
Python
"""Unit tests for the single-credential OBO mint engine (issue #551).
|
|
|
|
Covers ``get_obo_access_token_classified`` and its grant legs
|
|
(``_obo_mint_entra`` / ``_obo_mint_rfc8693``) in ``turnstone/core/mcp_oauth.py``.
|
|
|
|
Request-body assertions follow the spike-verified wire shapes in
|
|
BRIEFING.md ("Verified wire shapes") — every mock asserts the EXACT form
|
|
payload posted to the IdP token endpoint (body-inspecting, not
|
|
call-counting):
|
|
|
|
- entra: ONE refresh-token grant always carrying
|
|
``scope=<audience>/.default`` (per-server ``oauth_scopes`` is ignored
|
|
on this leg — a bare scope list would drop the audience);
|
|
- rfc8693: a refresh grant (NO scope key) for a subject token, then a
|
|
token-exchange grant with ``audience=<server oauth_audience>`` and the
|
|
per-server scope only when configured.
|
|
|
|
Semantics pinned here:
|
|
|
|
- minted tokens cache in ``mcp_user_tokens`` with ``refresh_token_ct``
|
|
NULL (cache, not custody);
|
|
- rotation write-back persists the newest IdP refresh token on the
|
|
shared credential;
|
|
- a PERMANENT rejection (AADSTS65001-style ``invalid_grant``) drops ONLY
|
|
the per-server cache row — the shared credential is NEVER auto-deleted,
|
|
so one mis-granted server cannot lock the user out of the rest;
|
|
- transient failures keep everything and arm the per-(user, server)
|
|
cooldown that short-circuits the next attempt without an IdP call;
|
|
- misconfiguration (unusable grant profile / missing audience) is
|
|
loud-but-retryable: ``refresh_failed_transient`` with zero IdP
|
|
round-trips and no exception.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from datetime import UTC, datetime, timedelta
|
|
from types import SimpleNamespace
|
|
from typing import TYPE_CHECKING, Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from tests._oidc_test_helpers import (
|
|
ISSUER,
|
|
TOKEN_ENDPOINT,
|
|
make_oidc_config,
|
|
mint_warn_state_reset,
|
|
)
|
|
from tests.conftest import make_mcp_token_cipher
|
|
from turnstone.core.mcp_crypto import MCPTokenStore
|
|
from turnstone.core.mcp_oauth import get_obo_access_token_classified
|
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
|
|
from turnstone.core.oidc import OIDCConfig
|
|
|
|
USER = "user-1"
|
|
SERVER = "srv-obo"
|
|
SERVER_ID = "srv-obo-id"
|
|
AUDIENCE = "api://aud-a"
|
|
|
|
_ISO = "%Y-%m-%dT%H:%M:%S"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_warn_dedup_state() -> Iterator[None]:
|
|
"""Per-test mint warn/cause reset — see ``mint_warn_state_reset``."""
|
|
yield from mint_warn_state_reset()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures / builders
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def storage(tmp_path: Any) -> SQLiteBackend:
|
|
return SQLiteBackend(str(tmp_path / "test.db"))
|
|
|
|
|
|
def _make_app_state(
|
|
storage: SQLiteBackend,
|
|
*,
|
|
http_client: httpx.AsyncClient,
|
|
oidc_config: OIDCConfig,
|
|
) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
auth_storage=storage,
|
|
mcp_token_store=MCPTokenStore(storage, make_mcp_token_cipher(), node_id="test"),
|
|
oidc_config=oidc_config,
|
|
obo_http_client=http_client,
|
|
mcp_oauth_refresh_locks={},
|
|
)
|
|
|
|
|
|
def _seed_obo_server(
|
|
storage: SQLiteBackend,
|
|
*,
|
|
oauth_scopes: str | None = None,
|
|
oauth_audience: str | None = AUDIENCE,
|
|
) -> None:
|
|
storage.create_mcp_server(
|
|
server_id=SERVER_ID,
|
|
name=SERVER,
|
|
transport="streamable-http",
|
|
url="https://mcp.example.com/mcp",
|
|
auth_type="oauth_obo",
|
|
oauth_scopes=oauth_scopes,
|
|
oauth_audience=oauth_audience,
|
|
)
|
|
|
|
|
|
def _seed_credential(state: SimpleNamespace, *, refresh_token: str = "rt-1") -> None:
|
|
state.mcp_token_store.upsert_oidc_credential(USER, ISSUER, refresh_token=refresh_token)
|
|
|
|
|
|
def _seed_cache_row(
|
|
state: SimpleNamespace,
|
|
*,
|
|
expires_in_seconds: int,
|
|
access_token: str = "cached-at",
|
|
audience: str = AUDIENCE,
|
|
created_seconds_ago: int = 0,
|
|
) -> None:
|
|
expires_at = (datetime.now(UTC) + timedelta(seconds=expires_in_seconds)).strftime(_ISO)
|
|
state.mcp_token_store.create_user_token(
|
|
USER,
|
|
SERVER,
|
|
access_token=access_token,
|
|
refresh_token=None,
|
|
expires_at=expires_at,
|
|
scopes=None,
|
|
as_issuer=ISSUER,
|
|
audience=audience,
|
|
)
|
|
if created_seconds_ago:
|
|
# Backdate the row via direct SQL: create_user_token stamps
|
|
# created=now, but the under-lock force_refresh gate treats a row
|
|
# whose ``created`` >= the caller's lock-request time as "another
|
|
# caller just minted" and reuses it — a row seeded in the same second
|
|
# as the call reads as exactly that. Tests exercising the RE-MINT
|
|
# path need a row that is unambiguously from the past.
|
|
import sqlalchemy as sa
|
|
|
|
from turnstone.core.storage._schema import mcp_user_tokens
|
|
|
|
backdated = (datetime.now(UTC) - timedelta(seconds=created_seconds_ago)).strftime(_ISO)
|
|
storage = state.auth_storage
|
|
with storage._engine.connect() as conn:
|
|
conn.execute(
|
|
sa.update(mcp_user_tokens)
|
|
.where(
|
|
(mcp_user_tokens.c.user_id == USER) & (mcp_user_tokens.c.server_name == SERVER)
|
|
)
|
|
.values(created=backdated)
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def _mk_response(status_code: int = 200, json_body: Any = None) -> MagicMock:
|
|
resp = MagicMock(spec=httpx.Response)
|
|
resp.status_code = status_code
|
|
resp.headers = {}
|
|
body = "" if json_body is None else str(json_body)
|
|
resp.content = body.encode("utf-8")
|
|
if json_body is not None:
|
|
resp.json.return_value = json_body
|
|
else:
|
|
resp.json.side_effect = ValueError("no body")
|
|
resp.text = body
|
|
return resp
|
|
|
|
|
|
def _mint(state: SimpleNamespace) -> Any:
|
|
async def _run() -> Any:
|
|
return await get_obo_access_token_classified(
|
|
app_state=state, user_id=USER, server_name=SERVER
|
|
)
|
|
|
|
return asyncio.run(_run())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# entra leg — one RT redemption with scope=<audience>/.default
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEntraLeg:
|
|
def test_happy_path_mints_with_default_scope_and_caches(self, storage: SQLiteBackend) -> None:
|
|
"""Case 1: one POST with the exact spike-verified Entra body; the mint
|
|
caches as a refresh-less ``mcp_user_tokens`` row with ``as_issuer`` /
|
|
``audience`` populated and ``expires_at`` derived from ``expires_in``."""
|
|
_seed_obo_server(storage) # empty oauth_scopes → scope falls back to /.default
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(
|
|
return_value=_mk_response(200, {"access_token": "at-minted", "expires_in": 3600})
|
|
)
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state)
|
|
before = datetime.now(UTC)
|
|
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "token"
|
|
assert result.token == "at-minted"
|
|
# Exact wire shape (BRIEFING.md, Entra redemption) — full-dict equality
|
|
# also proves no stray keys (no resource=, no audience=) rode along.
|
|
assert client.post.call_count == 1
|
|
call = client.post.call_args
|
|
assert call.args == (TOKEN_ENDPOINT,)
|
|
assert call.kwargs["data"] == {
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": "rt-1",
|
|
"client_id": "cid",
|
|
"client_secret": "csecret",
|
|
"scope": f"{AUDIENCE}/.default",
|
|
}
|
|
# Cache row: refresh-less (cache, not custody), issuer/audience stamped.
|
|
raw = storage.get_mcp_user_token(USER, SERVER)
|
|
assert raw is not None
|
|
assert raw["refresh_token_ct"] is None
|
|
assert raw["as_issuer"] == ISSUER
|
|
assert raw["audience"] == AUDIENCE
|
|
assert raw["expires_at"] is not None
|
|
expires = datetime.strptime(raw["expires_at"], _ISO).replace(tzinfo=UTC)
|
|
remaining = expires - before
|
|
assert timedelta(seconds=3500) <= remaining <= timedelta(seconds=3601)
|
|
plain = state.mcp_token_store.get_user_token(USER, SERVER)
|
|
assert plain is not None
|
|
assert plain["access_token"] == "at-minted"
|
|
assert plain["refresh_token"] is None
|
|
|
|
def test_entra_ignores_oauth_scopes_and_always_pins_audience_default(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""Entra's ``scope`` is its only audience carrier, so it ALWAYS sends
|
|
``<audience>/.default`` and ignores a per-server ``oauth_scopes`` (a bare
|
|
scope list would drop the audience → wrong-audience bearer). Regression
|
|
guard for the review's D finding."""
|
|
_seed_obo_server(storage, oauth_scopes="custom.scope")
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(
|
|
return_value=_mk_response(200, {"access_token": "at-minted", "expires_in": 3600})
|
|
)
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state)
|
|
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "token"
|
|
assert client.post.call_count == 1
|
|
call = client.post.call_args
|
|
assert call.args == (TOKEN_ENDPOINT,)
|
|
# audience-qualified .default — NOT the raw oauth_scopes value.
|
|
assert call.kwargs["data"]["scope"] == "api://aud-a/.default"
|
|
|
|
def test_entra_cache_row_records_effective_scope_not_configured_scope(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""Review finding: a server scoped under rfc8693 that survives a switch
|
|
to obo_grant_profile=entra mints <audience>/.default (ignoring
|
|
oauth_scopes). The cache row must record the EFFECTIVE scope actually
|
|
minted ('' — .default), NOT the configured 'custom.scope' — otherwise
|
|
_is_fresh_obo_cache_row would keep serving the broad .default bearer
|
|
believing it is the narrow configured one, and a scope narrowing that
|
|
can't apply under entra would look like it did."""
|
|
_seed_obo_server(storage, oauth_scopes="custom.scope")
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock()
|
|
client.post.return_value = _mk_response(
|
|
200, {"access_token": "at-broad-default", "expires_in": 3600}
|
|
)
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state)
|
|
|
|
result = _mint(state)
|
|
assert result.kind == "token"
|
|
# The row records the effective (empty) scope, not "custom.scope".
|
|
row = storage.get_mcp_user_token(USER, SERVER)
|
|
assert row is not None
|
|
assert (row["scopes"] or "") == ""
|
|
|
|
# A second dispatch serves the cache honestly (fresh, right effective
|
|
# scope) with ZERO additional mints — no spurious re-mint from a
|
|
# scope-mismatch the entra leg could never resolve.
|
|
client.post.reset_mock()
|
|
result2 = _mint(state)
|
|
assert result2.kind == "token"
|
|
assert result2.token == "at-broad-default"
|
|
assert client.post.call_count == 0
|
|
|
|
def test_rotated_refresh_token_written_back_to_credential(self, storage: SQLiteBackend) -> None:
|
|
"""Case 5: Entra usually rotates the RT on redemption — the newest
|
|
value MUST be persisted to the shared credential (write-back rule)."""
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(
|
|
return_value=_mk_response(
|
|
200,
|
|
{"access_token": "at-minted", "expires_in": 3600, "refresh_token": "rt-2"},
|
|
)
|
|
)
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state, refresh_token="rt-1")
|
|
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "token"
|
|
cred = state.mcp_token_store.get_oidc_credential(USER, ISSUER)
|
|
assert cred is not None
|
|
assert cred["refresh_token"] == "rt-2"
|
|
# The rotated RT stays on the credential — the cache row is refresh-less.
|
|
raw = storage.get_mcp_user_token(USER, SERVER)
|
|
assert raw is not None
|
|
assert raw["refresh_token_ct"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# rfc8693 leg — refresh grant for a subject token, then token exchange
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRfc8693Leg:
|
|
def test_happy_path_two_posts_exchange_and_rotation_write_back(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""Case 3: exactly TWO POSTs with the spike-verified Keycloak shapes —
|
|
a scope-less refresh grant, then a token exchange whose
|
|
``subject_token`` is the FIRST call's access token; the RT rotated by
|
|
call 1 is persisted to the credential."""
|
|
_seed_obo_server(storage) # empty oauth_scopes → exchange carries NO scope key
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(
|
|
side_effect=[
|
|
_mk_response(
|
|
200,
|
|
{
|
|
"access_token": "subject-at",
|
|
"refresh_token": "rt-rotated",
|
|
"expires_in": 300,
|
|
},
|
|
),
|
|
_mk_response(200, {"access_token": "exchanged-at", "expires_in": 600}),
|
|
]
|
|
)
|
|
state = _make_app_state(
|
|
storage,
|
|
http_client=client,
|
|
oidc_config=make_oidc_config(obo_grant_profile="rfc8693"),
|
|
)
|
|
_seed_credential(state, refresh_token="rt-1")
|
|
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "token"
|
|
assert result.token == "exchanged-at"
|
|
assert client.post.call_count == 2
|
|
first, second = client.post.call_args_list
|
|
assert first.args == (TOKEN_ENDPOINT,)
|
|
# Refresh leg: full-dict equality proves NO scope key is sent.
|
|
assert first.kwargs["data"] == {
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": "rt-1",
|
|
"client_id": "cid",
|
|
"client_secret": "csecret",
|
|
}
|
|
assert second.args == (TOKEN_ENDPOINT,)
|
|
assert second.kwargs["data"] == {
|
|
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
"client_id": "cid",
|
|
"client_secret": "csecret",
|
|
"subject_token": "subject-at",
|
|
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
|
"audience": AUDIENCE,
|
|
}
|
|
# Rotation write-back from the FIRST leg persists on the credential.
|
|
cred = state.mcp_token_store.get_oidc_credential(USER, ISSUER)
|
|
assert cred is not None
|
|
assert cred["refresh_token"] == "rt-rotated"
|
|
# Cached mint is the EXCHANGED token, refresh-less.
|
|
plain = state.mcp_token_store.get_user_token(USER, SERVER)
|
|
assert plain is not None
|
|
assert plain["access_token"] == "exchanged-at"
|
|
assert plain["refresh_token"] is None
|
|
|
|
def test_per_server_scopes_carried_on_exchange_call(self, storage: SQLiteBackend) -> None:
|
|
"""Case 3 (scoped): with ``oauth_scopes`` set, the SECOND call carries
|
|
``scope`` (Keycloak optional audience scopes must be explicit) while
|
|
the refresh leg still sends none."""
|
|
_seed_obo_server(storage, oauth_scopes="custom.scope")
|
|
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": "exchanged-at", "expires_in": 600}),
|
|
]
|
|
)
|
|
state = _make_app_state(
|
|
storage,
|
|
http_client=client,
|
|
oidc_config=make_oidc_config(obo_grant_profile="rfc8693"),
|
|
)
|
|
_seed_credential(state)
|
|
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "token"
|
|
assert client.post.call_count == 2
|
|
first, second = client.post.call_args_list
|
|
assert first.kwargs["data"] == {
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": "rt-1",
|
|
"client_id": "cid",
|
|
"client_secret": "csecret",
|
|
}
|
|
assert second.kwargs["data"] == {
|
|
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
"client_id": "cid",
|
|
"client_secret": "csecret",
|
|
"subject_token": "subject-at",
|
|
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
|
"audience": AUDIENCE,
|
|
"scope": "custom.scope",
|
|
}
|
|
|
|
def test_subject_leg_missing_access_token_is_transient(self, storage: SQLiteBackend) -> None:
|
|
"""A 200 refresh-leg body without ``access_token`` aborts BEFORE the
|
|
exchange call and classifies transient — a malformed IdP response must
|
|
not delete anything."""
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(return_value=_mk_response(200, {"refresh_token": "rt-x"}))
|
|
state = _make_app_state(
|
|
storage,
|
|
http_client=client,
|
|
oidc_config=make_oidc_config(obo_grant_profile="rfc8693"),
|
|
)
|
|
_seed_credential(state)
|
|
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "refresh_failed_transient"
|
|
assert client.post.call_count == 1 # never reached the exchange leg
|
|
assert state.mcp_token_store.get_oidc_credential(USER, ISSUER) is not None
|
|
|
|
def test_rotation_from_refresh_leg_survives_exchange_leg_failure(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""Review finding B (the lockout bug): on a rotating IdP the refresh leg
|
|
consumes rt-1 and rotates to rt-rotated; if the exchange leg then fails,
|
|
the rotated RT MUST already be persisted (not lost) — else the next mint
|
|
for every obo server would redeem the consumed rt-1 and cascade-lock the
|
|
user out. The exchange-response RT must NOT overwrite the credential."""
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(
|
|
side_effect=[
|
|
_mk_response(
|
|
200,
|
|
{
|
|
"access_token": "subject-at",
|
|
"refresh_token": "rt-rotated",
|
|
"expires_in": 300,
|
|
},
|
|
),
|
|
# Exchange leg fails (e.g. one server's audience not yet granted).
|
|
_mk_response(400, {"error": "invalid_grant", "error_description": "AADSTS500..."}),
|
|
]
|
|
)
|
|
state = _make_app_state(
|
|
storage,
|
|
http_client=client,
|
|
oidc_config=make_oidc_config(obo_grant_profile="rfc8693"),
|
|
)
|
|
_seed_credential(state, refresh_token="rt-1")
|
|
|
|
result = _mint(state)
|
|
|
|
assert client.post.call_count == 2 # refresh leg + failed exchange
|
|
# The rotated RT from the refresh leg is persisted despite the failure.
|
|
cred = state.mcp_token_store.get_oidc_credential(USER, ISSUER)
|
|
assert cred is not None
|
|
assert cred["refresh_token"] == "rt-rotated"
|
|
# A 400 exchange with invalid_grant is a PERMANENT rejection for THIS
|
|
# server; the credential survives so the user's other servers are fine.
|
|
assert result.kind == "refresh_failed"
|
|
|
|
def test_mint_without_expires_in_gets_bounded_expiry_not_cached_forever(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""Review finding: a mint response omitting the (RFC 8693-optional)
|
|
expires_in must NOT cache expires_at=NULL — the freshness gate reads NULL
|
|
as never-expiring (correct for opaque oauth_user tokens, wrong for a
|
|
short-lived minted obo token), so it would be served indefinitely and
|
|
defeat audience/scope-narrowing that relies on TTL turnover. A missing
|
|
expiry falls back to a bounded default so the row re-mints soon."""
|
|
from datetime import datetime as _dt
|
|
|
|
from turnstone.core.mcp_oauth import _OBO_DEFAULT_TTL_SECONDS
|
|
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
# Entra-shaped single-POST mint, but the IdP omits expires_in.
|
|
client.post = AsyncMock(return_value=_mk_response(200, {"access_token": "at-no-exp"}))
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state)
|
|
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "token"
|
|
row = storage.get_mcp_user_token(USER, SERVER)
|
|
assert row is not None
|
|
# Not NULL — a bounded expiry within the default TTL window was stamped.
|
|
assert row["expires_at"] is not None
|
|
parsed = _dt.strptime(row["expires_at"], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=UTC)
|
|
remaining = (parsed - datetime.now(UTC)).total_seconds()
|
|
assert 0 < remaining <= _OBO_DEFAULT_TTL_SECONDS + 5
|
|
|
|
def test_exchange_response_refresh_token_never_overwrites_credential(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""Review finding (1960): an RFC 8693 exchange response MAY carry its own
|
|
(audience-scoped) refresh_token; it must never be written to the shared
|
|
issuer-wide credential."""
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(
|
|
side_effect=[
|
|
# Refresh leg does NOT rotate (no refresh_token).
|
|
_mk_response(200, {"access_token": "subject-at", "expires_in": 300}),
|
|
# Exchange leg returns an audience-scoped RT — must be ignored.
|
|
_mk_response(
|
|
200,
|
|
{
|
|
"access_token": "exchanged-at",
|
|
"refresh_token": "audience-rt",
|
|
"expires_in": 600,
|
|
},
|
|
),
|
|
]
|
|
)
|
|
state = _make_app_state(
|
|
storage,
|
|
http_client=client,
|
|
oidc_config=make_oidc_config(obo_grant_profile="rfc8693"),
|
|
)
|
|
_seed_credential(state, refresh_token="rt-1")
|
|
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "token"
|
|
cred = state.mcp_token_store.get_oidc_credential(USER, ISSUER)
|
|
assert cred is not None
|
|
assert cred["refresh_token"] == "rt-1" # unchanged — NOT "audience-rt"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cache-row and credential lookup short-circuits
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCacheAndCredentialLookup:
|
|
def test_fresh_cache_row_returns_token_with_zero_http_calls(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""Case 4: a fresh ``mcp_user_tokens`` row is served straight from the
|
|
cache — no IdP round-trip."""
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock()
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state)
|
|
_seed_cache_row(state, expires_in_seconds=3600, access_token="cached-at")
|
|
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "token"
|
|
assert result.token == "cached-at"
|
|
assert client.post.call_count == 0
|
|
|
|
def test_credential_present_hint_skips_the_pre_lock_existence_read(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""Review finding: when a caller already established the captured
|
|
credential exists (priming does one read for ALL of a user's obo
|
|
servers), get_obo_access_token_classified must skip its per-server
|
|
pre-lock existence re-read — otherwise session start re-reads the
|
|
credential N+1 times. With credential_present=True only the authoritative
|
|
under-lock read remains (one raw read); without the hint there are two
|
|
(pre-lock existence + under-lock)."""
|
|
from unittest.mock import patch
|
|
|
|
_seed_obo_server(storage)
|
|
|
|
def _run_with_hint(hint: bool | None) -> tuple[Any, int]:
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(
|
|
return_value=_mk_response(200, {"access_token": "minted-at", "expires_in": 3600})
|
|
)
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state)
|
|
reads = {"n": 0}
|
|
real = storage.get_oidc_user_credential
|
|
|
|
def _counting(user_id, issuer):
|
|
reads["n"] += 1
|
|
return real(user_id, issuer)
|
|
|
|
async def _go() -> Any:
|
|
with patch.object(storage, "get_oidc_user_credential", side_effect=_counting):
|
|
return await get_obo_access_token_classified(
|
|
app_state=state,
|
|
user_id=USER,
|
|
server_name=SERVER,
|
|
credential_present=hint,
|
|
)
|
|
|
|
res = asyncio.run(_go())
|
|
# Clear the cache row so the next run mints again (independent count).
|
|
storage.delete_mcp_user_token(USER, SERVER)
|
|
return res, reads["n"]
|
|
|
|
result_hint, reads_hint = _run_with_hint(True)
|
|
assert result_hint.kind == "token"
|
|
assert reads_hint == 1 # pre-lock skipped; only the under-lock read
|
|
|
|
result_none, reads_none = _run_with_hint(None)
|
|
assert result_none.kind == "token"
|
|
assert reads_none == 2 # pre-lock existence + under-lock
|
|
|
|
def test_stale_audience_cache_row_is_not_served_and_remints(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""Review finding (audience guard): a cached token minted for a DIFFERENT
|
|
audience than the server's current one must NOT be served — an operator's
|
|
audience narrowing has to take effect immediately, not at token TTL. The
|
|
stale row is ignored and a fresh mint (for the current audience) runs."""
|
|
_seed_obo_server(storage) # server oauth_audience = AUDIENCE (api://aud-a)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(
|
|
return_value=_mk_response(200, {"access_token": "reminted-at", "expires_in": 3600})
|
|
)
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state)
|
|
# A fresh, refresh-less cache row — but for the OLD/broader audience.
|
|
_seed_cache_row(
|
|
state, expires_in_seconds=3600, access_token="old-aud-at", audience="api://old-broad"
|
|
)
|
|
|
|
result = _mint(state)
|
|
|
|
# Not served from the stale-audience cache; a real mint happened.
|
|
assert result.kind == "token"
|
|
assert result.token == "reminted-at"
|
|
assert client.post.call_count == 1
|
|
# The cache row is now for the current audience.
|
|
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:
|
|
"""Case 6: no captured credential → ``missing`` (the dispatcher's
|
|
consent affordance is a re-login, not per-server consent); the IdP is
|
|
never contacted."""
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock()
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
# Deliberately NO upsert_oidc_credential.
|
|
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "missing"
|
|
assert client.post.call_count == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Failure handling — the load-bearing custody semantics
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFailureHandling:
|
|
def test_permanent_rejection_drops_cache_row_but_never_the_credential(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""Case 7 (load-bearing): a verified AADSTS65001-style
|
|
``invalid_grant`` classifies PERMANENT — the per-server cache row is
|
|
deleted (re-consent UX for THAT server) but the shared credential
|
|
survives, so one missing tenant grant can't lock the user out of
|
|
every other OBO server."""
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(
|
|
return_value=_mk_response(
|
|
400,
|
|
{
|
|
"error": "invalid_grant",
|
|
"error_description": (
|
|
"AADSTS65001: The user or administrator has not consented to use "
|
|
"the application."
|
|
),
|
|
},
|
|
)
|
|
)
|
|
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")
|
|
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "refresh_failed"
|
|
# Cache row GONE...
|
|
assert storage.get_mcp_user_token(USER, SERVER) is None
|
|
# ...but the credential STILL EXISTS — never auto-deleted here.
|
|
assert state.mcp_token_store.get_oidc_credential(USER, ISSUER) is not None
|
|
# The revoke is audited through the shared choke point.
|
|
events = storage.list_audit_events(action="mcp_server.oauth.token_revoked")
|
|
assert len(events) == 1
|
|
assert events[0]["user_id"] == USER
|
|
assert events[0]["resource_id"] == SERVER_ID
|
|
detail = events[0]["detail"]
|
|
detail = json.loads(detail) if isinstance(detail, str) else detail
|
|
assert detail["reason"] == "obo_mint_rejected"
|
|
|
|
def test_transient_503_keeps_credential_and_cooldown_short_circuits_second_call(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""Case 8: a 503 is transient — nothing is deleted — and the armed
|
|
cooldown makes an immediate second attempt return transient with NO
|
|
additional IdP call."""
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(
|
|
return_value=_mk_response(503, {"error": "temporarily_unavailable"})
|
|
)
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state)
|
|
|
|
async def _run() -> tuple[Any, Any]:
|
|
first = await get_obo_access_token_classified(
|
|
app_state=state, user_id=USER, server_name=SERVER
|
|
)
|
|
second = await get_obo_access_token_classified(
|
|
app_state=state, user_id=USER, server_name=SERVER
|
|
)
|
|
return first, second
|
|
|
|
first, second = asyncio.run(_run())
|
|
|
|
assert first.kind == "refresh_failed_transient"
|
|
assert second.kind == "refresh_failed_transient"
|
|
# Cooldown short-circuit: the second call never reached the IdP.
|
|
assert client.post.call_count == 1
|
|
assert state.mcp_token_store.get_oidc_credential(USER, ISSUER) is not None
|
|
|
|
def test_missing_access_token_in_200_body_is_transient(self, storage: SQLiteBackend) -> None:
|
|
"""Case 9: a 200 body without ``access_token`` is a malformed-IdP
|
|
blip — transient, credential kept, nothing cached."""
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(return_value=_mk_response(200, {"expires_in": 3600}))
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state)
|
|
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "refresh_failed_transient"
|
|
assert client.post.call_count == 1
|
|
assert state.mcp_token_store.get_oidc_credential(USER, ISSUER) is not None
|
|
assert storage.get_mcp_user_token(USER, SERVER) is None # nothing was cached
|
|
|
|
def test_oversized_error_body_on_client_error_is_ambiguous_not_transient(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""Review finding: the shared body-size guard raised with the DEFAULT
|
|
(TRANSIENT) class before the non-200 was classified, so a permanent
|
|
dead-grant whose error body exceeded the cap would loop 'please retry'
|
|
forever and NEVER escalate (TRANSIENT doesn't advance the streak). An
|
|
over-sized CLIENT-error body is now classified AMBIGUOUS by status, so it
|
|
still advances the ambiguous streak and escalates to the honest re-login
|
|
/ admin remedy after the threshold."""
|
|
from turnstone.core.mcp_oauth import _refresh_backoff_state
|
|
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
# 400 with an error body over the 64KB cap.
|
|
client.post = AsyncMock(
|
|
return_value=_mk_response(400, {"error": "invalid_grant", "pad": "x" * (70 * 1024)})
|
|
)
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state)
|
|
|
|
result = _mint(state)
|
|
|
|
# Immediate result is still a kept-token transient (streak below the
|
|
# escalation threshold), but the AMBIGUOUS class advanced the streak —
|
|
# the TRANSIENT default would have left it at 0 and never escalated.
|
|
assert result.kind == "refresh_failed_transient"
|
|
assert _refresh_backoff_state(state, USER, SERVER).ambiguous_streak == 1
|
|
|
|
def test_permanent_rejection_logs_idp_error_text(self, storage: SQLiteBackend, caplog) -> None:
|
|
"""Review finding (2316): the permanent-rejection path must log the IdP
|
|
error body (the token_revoked audit row carries only a reason code), so
|
|
an operator can tell a missing tenant grant from a dead credential."""
|
|
import logging
|
|
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(
|
|
return_value=_mk_response(
|
|
400,
|
|
{"error": "invalid_grant", "error_description": "AADSTS65001: no consent"},
|
|
)
|
|
)
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state)
|
|
|
|
with caplog.at_level(logging.WARNING, logger="turnstone.mcp"):
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "refresh_failed"
|
|
blob = " ".join(r.getMessage() + str(getattr(r, "__dict__", "")) for r in caplog.records)
|
|
assert "obo_mint_rejected" in blob
|
|
assert "AADSTS65001" in blob # the actual IdP error text survives
|
|
|
|
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), and — because the failure was PERMANENT — the
|
|
second dispatch surfaces the honest permanent classification during the
|
|
cooldown window (not a misleading retryable transient)."""
|
|
_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) # no cache row — the common missing-tenant-grant case
|
|
|
|
async def _run() -> tuple[Any, Any]:
|
|
first = await get_obo_access_token_classified(
|
|
app_state=state, user_id=USER, server_name=SERVER
|
|
)
|
|
second = await get_obo_access_token_classified(
|
|
app_state=state, user_id=USER, server_name=SERVER
|
|
)
|
|
return first, second
|
|
|
|
first, second = asyncio.run(_run())
|
|
|
|
assert first.kind == "refresh_failed"
|
|
# Terminal: the cooldown short-circuits the second dispatch — and reports
|
|
# the PERMANENT classification, not a retryable transient.
|
|
assert second.kind == "refresh_failed"
|
|
assert client.post.call_count == 1 # NOT re-minted
|
|
# 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
|
|
|
|
def test_force_refresh_during_cooldown_falls_through_on_fresh_cache(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""Review finding (2063): the pre-lock cooldown short-circuit is gated on
|
|
actually needing a mint. A force_refresh 401-retry with a still-fresh
|
|
cache row must fall THROUGH the armed cooldown to the locked path (so it
|
|
can re-mint / pick up a cluster-mate's token) rather than fail transient."""
|
|
import time
|
|
|
|
from turnstone.core.mcp_oauth import _refresh_backoff_state
|
|
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(
|
|
return_value=_mk_response(200, {"access_token": "at-reminted", "expires_in": 3600})
|
|
)
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state)
|
|
# Backdated so the under-lock reuse gate reads it as an OLD mint —
|
|
# this test is about the cooldown fall-through re-minting, not the
|
|
# same-second single-flight reuse (covered separately below).
|
|
_seed_cache_row(
|
|
state,
|
|
expires_in_seconds=3600,
|
|
access_token="stale-but-fresh-exp",
|
|
created_seconds_ago=30,
|
|
)
|
|
# Arm the cooldown as if a prior mint just failed transiently.
|
|
_refresh_backoff_state(state, USER, SERVER).last_failure_monotonic = time.monotonic()
|
|
|
|
async def _run() -> Any:
|
|
return await get_obo_access_token_classified(
|
|
app_state=state, user_id=USER, server_name=SERVER, force_refresh=True
|
|
)
|
|
|
|
result = asyncio.run(_run())
|
|
|
|
# Fell through the cooldown and re-minted (unconditional gate would have
|
|
# returned refresh_failed_transient with zero IdP calls).
|
|
assert result.kind == "token"
|
|
assert result.token == "at-reminted"
|
|
assert client.post.call_count == 1
|
|
|
|
def test_force_refresh_reuses_concurrently_minted_token_without_reminting(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""Serialized force_refresh waiters must single-flight the re-mint: a
|
|
waiter that acquires the lock AFTER a peer already re-minted reuses the
|
|
peer's fresh token instead of running its own redundant IdP redemption.
|
|
The reuse is decided by token IDENTITY (the under-lock row holds a
|
|
DIFFERENT token than the rejected one this caller came in with), not by
|
|
mint time — so a same-second concurrent mint is still reused."""
|
|
from unittest.mock import patch
|
|
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock() # any IdP call would be a gate failure
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state)
|
|
|
|
def _fresh_row(access_token: str) -> Any:
|
|
return {
|
|
"user_id": USER,
|
|
"server_name": SERVER,
|
|
"access_token": access_token,
|
|
"refresh_token": None,
|
|
"expires_at": (datetime.now(UTC) + timedelta(seconds=3600)).strftime(_ISO),
|
|
"scopes": None,
|
|
"as_issuer": ISSUER,
|
|
"audience": AUDIENCE,
|
|
"created": datetime.now(UTC).strftime(_ISO),
|
|
"last_refreshed": None,
|
|
}
|
|
|
|
# Pre-lock read returns the rejected token; the under-lock re-read returns
|
|
# a DIFFERENT token (a concurrent waiter re-minted while we held-waited).
|
|
reads = [_fresh_row("rejected-at"), _fresh_row("peer-reminted-at")]
|
|
with patch.object(state.mcp_token_store, "get_user_token", side_effect=reads):
|
|
|
|
async def _run() -> Any:
|
|
return await get_obo_access_token_classified(
|
|
app_state=state, user_id=USER, server_name=SERVER, force_refresh=True
|
|
)
|
|
|
|
result = asyncio.run(_run())
|
|
|
|
assert result.kind == "token"
|
|
assert result.token == "peer-reminted-at" # reused the peer's fresh token
|
|
assert client.post.call_count == 0 # no redundant redemption
|
|
|
|
def test_force_refresh_remints_when_cache_still_holds_rejected_token(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""The other half of the identity gate: when the under-lock row still
|
|
holds the SAME token the caller came in with (no peer re-minted), a
|
|
force_refresh must RE-MINT — never re-serve the just-rejected bearer.
|
|
A mint-time gate at 1-second ``created`` granularity would wrongly
|
|
re-serve a token minted in the same second as the retry."""
|
|
from unittest.mock import patch
|
|
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(
|
|
return_value=_mk_response(
|
|
200, {"access_token": "genuinely-reminted", "expires_in": 3600}
|
|
)
|
|
)
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state)
|
|
|
|
rejected = {
|
|
"user_id": USER,
|
|
"server_name": SERVER,
|
|
"access_token": "rejected-at",
|
|
"refresh_token": None,
|
|
"expires_at": (datetime.now(UTC) + timedelta(seconds=3600)).strftime(_ISO),
|
|
"scopes": None,
|
|
"as_issuer": ISSUER,
|
|
"audience": AUDIENCE,
|
|
"created": datetime.now(UTC).strftime(_ISO),
|
|
"last_refreshed": None,
|
|
}
|
|
# Both the pre-lock and under-lock reads return the SAME (rejected) token.
|
|
with patch.object(
|
|
state.mcp_token_store, "get_user_token", side_effect=[rejected, rejected]
|
|
):
|
|
|
|
async def _run() -> Any:
|
|
return await get_obo_access_token_classified(
|
|
app_state=state, user_id=USER, server_name=SERVER, force_refresh=True
|
|
)
|
|
|
|
result = asyncio.run(_run())
|
|
|
|
assert result.kind == "token"
|
|
assert result.token == "genuinely-reminted" # re-minted, NOT re-served
|
|
assert client.post.call_count == 1
|
|
|
|
def test_rotation_persist_failure_does_not_break_the_mint(self, storage: SQLiteBackend) -> None:
|
|
"""Review finding: a storage error inside the rotation-persist callback
|
|
escaped the classified-result contract (only MCPOAuthRefreshFailed is
|
|
caught around mint()) and broke the in-flight dispatch — and on a
|
|
strict-rotation IdP the consumed RT stayed stored either way. The
|
|
persist is best-effort: the mint still returns its token (this
|
|
dispatch works); the stale credential surfaces on a LATER mint at
|
|
worst, instead of a raw exception now."""
|
|
from unittest.mock import patch
|
|
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(
|
|
side_effect=[
|
|
_mk_response(
|
|
200,
|
|
{
|
|
"access_token": "subject-at",
|
|
"refresh_token": "rt-rotated",
|
|
"expires_in": 300,
|
|
},
|
|
),
|
|
_mk_response(200, {"access_token": "exchanged-at", "expires_in": 600}),
|
|
]
|
|
)
|
|
state = _make_app_state(
|
|
storage,
|
|
http_client=client,
|
|
oidc_config=make_oidc_config(obo_grant_profile="rfc8693"),
|
|
)
|
|
_seed_credential(state, refresh_token="rt-1")
|
|
|
|
with patch.object(
|
|
state.mcp_token_store,
|
|
"update_oidc_credential_after_redeem",
|
|
side_effect=RuntimeError("transient db blip"),
|
|
):
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "token"
|
|
assert result.token == "exchanged-at"
|
|
# The stored credential still holds the OLD RT — the failed persist is
|
|
# logged, never raised.
|
|
cred = state.mcp_token_store.get_oidc_credential(USER, ISSUER)
|
|
assert cred is not None
|
|
assert cred["refresh_token"] == "rt-1"
|
|
|
|
def test_disabled_retryable_config_rediscovers_and_mints(self, storage: SQLiteBackend) -> None:
|
|
"""Review finding: OIDC discovery ran boot-once — a node that booted
|
|
during a transient IdP outage kept enabled=False forever and every obo
|
|
mint on it failed "transient" until an operator restart. The mint path
|
|
now probes runtime re-discovery (cooldown-gated) before classifying
|
|
obo_misconfigured, so the node self-heals."""
|
|
import dataclasses as _dc
|
|
from unittest.mock import patch
|
|
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock(
|
|
return_value=_mk_response(200, {"access_token": "minted-at", "expires_in": 3600})
|
|
)
|
|
boot_failed = _dc.replace(
|
|
make_oidc_config(), enabled=False, token_endpoint="", discovery_retryable=True
|
|
)
|
|
state = _make_app_state(storage, http_client=client, oidc_config=boot_failed)
|
|
_seed_credential(state)
|
|
healed = make_oidc_config() # enabled, token_endpoint populated
|
|
|
|
async def _fake_discover(cfg: Any, *, client: Any = None) -> Any:
|
|
return healed
|
|
|
|
with patch("turnstone.core.oidc.discover_oidc", new=_fake_discover):
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "token"
|
|
assert result.token == "minted-at"
|
|
assert state.oidc_config.enabled is True
|
|
|
|
def test_credential_decrypt_failure_is_classified_not_raised(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""Review finding (2099): an undecryptable captured credential (key
|
|
rotated away) must return kind='decrypt_failure', not let
|
|
MCPTokenDecryptError escape the classified-result contract."""
|
|
from unittest.mock import patch
|
|
|
|
from turnstone.core.mcp_crypto import MCPTokenDecryptError
|
|
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock()
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state)
|
|
|
|
with patch.object(
|
|
state.mcp_token_store,
|
|
"get_oidc_credential",
|
|
side_effect=MCPTokenDecryptError("key unknown", key_fingerprints_attempted=("ab12",)),
|
|
):
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "decrypt_failure"
|
|
assert client.post.call_count == 0 # never reached the IdP
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Misconfiguration — loud, retryable, and never an exception
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMisconfiguration:
|
|
@pytest.mark.parametrize("profile", ["", "id_jag"])
|
|
def test_unusable_grant_profile_is_transient_with_zero_http_calls(
|
|
self, storage: SQLiteBackend, profile: str
|
|
) -> None:
|
|
"""Case 10a: an empty or unknown ``obo_grant_profile`` is an
|
|
operator-fixable misconfig — retryable classification, zero IdP
|
|
calls, no exception (fixing config heals without re-consent)."""
|
|
_seed_obo_server(storage)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock()
|
|
state = _make_app_state(
|
|
storage,
|
|
http_client=client,
|
|
oidc_config=make_oidc_config(obo_grant_profile=profile),
|
|
)
|
|
_seed_credential(state) # credential present — config alone blocks the mint
|
|
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "refresh_failed_transient"
|
|
assert client.post.call_count == 0
|
|
|
|
def test_missing_server_audience_is_transient_with_zero_http_calls(
|
|
self, storage: SQLiteBackend
|
|
) -> None:
|
|
"""Case 10b: a server row without ``oauth_audience`` cannot be minted
|
|
for — same retryable misconfig outcome, zero IdP calls."""
|
|
_seed_obo_server(storage, oauth_audience=None)
|
|
client = MagicMock(spec=httpx.AsyncClient)
|
|
client.post = AsyncMock()
|
|
state = _make_app_state(storage, http_client=client, oidc_config=make_oidc_config())
|
|
_seed_credential(state)
|
|
|
|
result = _mint(state)
|
|
|
|
assert result.kind == "refresh_failed_transient"
|
|
assert client.post.call_count == 0
|