feat(mcp): capture the IdP refresh token at OIDC login (opt-in)

[oidc] capture_user_credential (default off; env
TURNSTONE_OIDC_CAPTURE_USER_CREDENTIAL) persists the user's IdP refresh
token - encrypted with the MCP token envelope - as the single
credential oauth_obo servers will redeem on demand.

- enabling the knob appends offline_access to the login scopes
  (idempotent when the operator already lists it)
- capture runs after user provisioning and is best-effort: a capture
  failure logs loudly but never blocks login; the mint path surfaces a
  missing credential on the reconnect rail
- startup hard-fails (SystemExit) when capture is enabled without a
  [security] token encryption key, same as the oauth_user enforcement

Refs #551.
This commit is contained in:
Patrick Buckley
2026-07-11 21:29:06 -07:00
parent be22eb2fee
commit 0732f9a7d2
5 changed files with 366 additions and 1 deletions
+78
View File
@@ -126,6 +126,84 @@ class TestLoadOIDCConfig:
assert cfg.allow_private_network is False
def test_load_oidc_config_capture_user_credential_env(self, monkeypatch):
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
monkeypatch.setenv("TURNSTONE_OIDC_CAPTURE_USER_CREDENTIAL", "true")
with patch("turnstone.core.config.load_config", return_value={}):
cfg = load_oidc_config()
assert cfg.capture_user_credential is True
def test_load_oidc_config_capture_user_credential_toml(self, monkeypatch):
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
monkeypatch.delenv("TURNSTONE_OIDC_CAPTURE_USER_CREDENTIAL", raising=False)
with patch(
"turnstone.core.config.load_config",
return_value={"capture_user_credential": True},
):
cfg = load_oidc_config()
assert cfg.capture_user_credential is True
def test_load_oidc_config_capture_default_off(self, monkeypatch):
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
monkeypatch.delenv("TURNSTONE_OIDC_CAPTURE_USER_CREDENTIAL", raising=False)
with patch("turnstone.core.config.load_config", return_value={}):
cfg = load_oidc_config()
assert cfg.capture_user_credential is False
def test_capture_appends_offline_access_to_scopes(self, monkeypatch):
"""Enabling capture requests offline_access without operator scope edits."""
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
monkeypatch.delenv("TURNSTONE_OIDC_SCOPES", raising=False)
with patch(
"turnstone.core.config.load_config",
return_value={"capture_user_credential": True},
):
cfg = load_oidc_config()
assert cfg.scopes == "openid email profile offline_access"
def test_capture_scope_append_is_idempotent(self, monkeypatch):
"""An operator who already lists offline_access doesn't get it twice."""
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
monkeypatch.setenv("TURNSTONE_OIDC_SCOPES", "openid offline_access email")
with patch(
"turnstone.core.config.load_config",
return_value={"capture_user_credential": True},
):
cfg = load_oidc_config()
assert cfg.scopes == "openid offline_access email"
def test_no_capture_leaves_scopes_untouched(self, monkeypatch):
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
monkeypatch.delenv("TURNSTONE_OIDC_SCOPES", raising=False)
monkeypatch.delenv("TURNSTONE_OIDC_CAPTURE_USER_CREDENTIAL", raising=False)
with patch("turnstone.core.config.load_config", return_value={}):
cfg = load_oidc_config()
assert cfg.scopes == "openid email profile"
def test_load_oidc_config_disabled_when_missing(self, monkeypatch):
monkeypatch.delenv("TURNSTONE_OIDC_ISSUER", raising=False)
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_ID", raising=False)
+221
View File
@@ -727,6 +727,227 @@ class TestOIDCCallback:
mock_fetch_jwks.assert_not_called()
# ---------------------------------------------------------------------------
# Single-credential capture tests (issue #551)
# ---------------------------------------------------------------------------
class TestOIDCCallbackCapture:
"""Capture of the IdP refresh token at login (``capture_user_credential``)."""
def _capture_client(
self,
storage: SQLiteBackend,
oidc_config: OIDCConfig,
*,
capture: bool = True,
with_store: bool = True,
) -> tuple[TestClient, Any, OIDCConfig]:
"""Client wired like ``authorize_client`` plus a real MCPTokenStore."""
import dataclasses
from tests.conftest import make_mcp_token_cipher
from turnstone.core.mcp_crypto import MCPTokenStore
cfg = dataclasses.replace(oidc_config, capture_user_credential=capture)
app = Starlette(
routes=[
Mount("/v1", routes=[Route("/api/auth/oidc/callback", _oidc_callback)]),
],
)
app.state.oidc_config = cfg
app.state.auth_storage = storage
app.state.jwt_secret = "test-jwt-secret-key-padded-32b!!"
app.state.jwks_data = {"keys": []}
app.state.login_limiter = None
store = MCPTokenStore(storage, make_mcp_token_cipher()) if with_store else None
app.state.mcp_token_store = store
return TestClient(app, raise_server_exceptions=False), store, cfg
def _login(
self,
client: TestClient,
storage: SQLiteBackend,
mock_exchange: AsyncMock,
mock_validate: Any,
mock_provision: Any,
*,
tokens: dict[str, Any],
state: str = "valid-state",
) -> Any:
storage.create_oidc_pending_state(state, "test-nonce", "test-verifier", "test-audience")
mock_exchange.return_value = tokens
mock_validate.return_value = {
"sub": "user123",
"email": "u@example.com",
"nonce": "test-nonce",
}
mock_provision.return_value = {"user_id": "test-admin", "username": "testadmin"}
return client.get(
f"/v1/api/auth/oidc/callback?code=authcode&state={state}",
follow_redirects=False,
)
@patch("turnstone.core.auth.provision_oidc_user")
@patch("turnstone.core.auth.validate_id_token")
@patch("turnstone.core.auth.exchange_code", new_callable=AsyncMock)
def test_capture_persists_credential(
self,
mock_exchange: AsyncMock,
mock_validate: Any,
mock_provision: Any,
storage: SQLiteBackend,
oidc_config: OIDCConfig,
) -> None:
client, store, cfg = self._capture_client(storage, oidc_config)
resp = self._login(
client,
storage,
mock_exchange,
mock_validate,
mock_provision,
tokens={"id_token": "fake.jwt.token", "access_token": "at", "refresh_token": "rt-1"},
)
assert resp.status_code == 302
assert "oidc_success=1" in resp.headers["location"]
assert store is not None
plain = store.get_oidc_credential("test-admin", cfg.issuer)
assert plain is not None
assert plain["refresh_token"] == "rt-1"
@patch("turnstone.core.auth.provision_oidc_user")
@patch("turnstone.core.auth.validate_id_token")
@patch("turnstone.core.auth.exchange_code", new_callable=AsyncMock)
def test_second_login_replaces_credential(
self,
mock_exchange: AsyncMock,
mock_validate: Any,
mock_provision: Any,
storage: SQLiteBackend,
oidc_config: OIDCConfig,
) -> None:
client, store, cfg = self._capture_client(storage, oidc_config)
self._login(
client,
storage,
mock_exchange,
mock_validate,
mock_provision,
tokens={"id_token": "t", "refresh_token": "rt-old"},
state="s1",
)
self._login(
client,
storage,
mock_exchange,
mock_validate,
mock_provision,
tokens={"id_token": "t", "refresh_token": "rt-new"},
state="s2",
)
assert store is not None
plain = store.get_oidc_credential("test-admin", cfg.issuer)
assert plain is not None
assert plain["refresh_token"] == "rt-new"
@patch("turnstone.core.auth.provision_oidc_user")
@patch("turnstone.core.auth.validate_id_token")
@patch("turnstone.core.auth.exchange_code", new_callable=AsyncMock)
def test_no_refresh_token_logs_and_login_succeeds(
self,
mock_exchange: AsyncMock,
mock_validate: Any,
mock_provision: Any,
storage: SQLiteBackend,
oidc_config: OIDCConfig,
) -> None:
client, store, cfg = self._capture_client(storage, oidc_config)
resp = self._login(
client,
storage,
mock_exchange,
mock_validate,
mock_provision,
tokens={"id_token": "fake.jwt.token", "access_token": "at"},
)
assert resp.status_code == 302
assert "oidc_success=1" in resp.headers["location"]
assert store is not None
assert store.get_oidc_credential("test-admin", cfg.issuer) is None
@patch("turnstone.core.auth.provision_oidc_user")
@patch("turnstone.core.auth.validate_id_token")
@patch("turnstone.core.auth.exchange_code", new_callable=AsyncMock)
def test_capture_disabled_persists_nothing(
self,
mock_exchange: AsyncMock,
mock_validate: Any,
mock_provision: Any,
storage: SQLiteBackend,
oidc_config: OIDCConfig,
) -> None:
client, store, cfg = self._capture_client(storage, oidc_config, capture=False)
resp = self._login(
client,
storage,
mock_exchange,
mock_validate,
mock_provision,
tokens={"id_token": "t", "refresh_token": "rt-present"},
)
assert resp.status_code == 302
assert store is not None
assert store.get_oidc_credential("test-admin", cfg.issuer) is None
@patch("turnstone.core.auth.provision_oidc_user")
@patch("turnstone.core.auth.validate_id_token")
@patch("turnstone.core.auth.exchange_code", new_callable=AsyncMock)
def test_missing_store_login_still_succeeds(
self,
mock_exchange: AsyncMock,
mock_validate: Any,
mock_provision: Any,
storage: SQLiteBackend,
oidc_config: OIDCConfig,
) -> None:
client, _store, _cfg = self._capture_client(storage, oidc_config, with_store=False)
resp = self._login(
client,
storage,
mock_exchange,
mock_validate,
mock_provision,
tokens={"id_token": "t", "refresh_token": "rt-1"},
)
assert resp.status_code == 302
assert "oidc_success=1" in resp.headers["location"]
@patch("turnstone.core.auth.provision_oidc_user")
@patch("turnstone.core.auth.validate_id_token")
@patch("turnstone.core.auth.exchange_code", new_callable=AsyncMock)
def test_store_failure_does_not_block_login(
self,
mock_exchange: AsyncMock,
mock_validate: Any,
mock_provision: Any,
storage: SQLiteBackend,
oidc_config: OIDCConfig,
) -> None:
client, store, _cfg = self._capture_client(storage, oidc_config)
assert store is not None
with patch.object(store, "upsert_oidc_credential", side_effect=RuntimeError("boom")):
resp = self._login(
client,
storage,
mock_exchange,
mock_validate,
mock_provision,
tokens={"id_token": "t", "refresh_token": "rt-1"},
)
assert resp.status_code == 302
assert "oidc_success=1" in resp.headers["location"]
# ---------------------------------------------------------------------------
# Admin OIDC identity endpoint tests
# ---------------------------------------------------------------------------
+29
View File
@@ -2109,6 +2109,35 @@ async def handle_oidc_callback(request: Request, audience: str, cookie_name: str
_record_oidc_failure()
return RedirectResponse("/?oidc_error=Authentication+failed", status_code=302)
# Capture the IdP refresh token as the user's single OBO credential
# (issue #551). Best-effort: capture failure must not block login —
# the mint path surfaces a missing credential on the reconnect rail.
if oidc_config.capture_user_credential:
idp_refresh_token = tokens.get("refresh_token")
token_store = getattr(request.app.state, "mcp_token_store", None)
if not isinstance(idp_refresh_token, str) or not idp_refresh_token:
log.info(
"oidc.capture: no refresh_token in token response (user=%s) — "
"check the IdP allows offline_access for this client",
user["user_id"],
)
elif token_store is None:
log.warning(
"oidc.capture: enabled but no token encryption key configured — "
"credential NOT captured (user=%s)",
user["user_id"],
)
else:
try:
await asyncio.to_thread(
token_store.upsert_oidc_credential,
user["user_id"],
oidc_config.issuer,
refresh_token=idp_refresh_token,
)
except Exception:
log.exception("oidc.capture: failed to persist credential")
# Load permissions and issue Turnstone JWT
perms = await asyncio.to_thread(_load_user_permissions, storage, user["user_id"])
scopes = _permissions_to_scopes(perms)
+22
View File
@@ -553,6 +553,8 @@ def initialize_mcp_crypto_state(app_state: object, *, node_id: str = "") -> None
logging.
2. Counts ``mcp_servers`` rows with ``auth_type='oauth_user'``. If
any exist AND no key is configured, raises ``SystemExit(1)``.
Same enforcement when ``[oidc] capture_user_credential`` is
enabled (the captured IdP credential must be encrypted at rest).
3. On success, sets ``app_state.mcp_token_cipher`` and
``app_state.mcp_token_store`` (both possibly ``None`` when no
key + no oauth_user rows).
@@ -587,6 +589,26 @@ def initialize_mcp_crypto_state(app_state: object, *, node_id: str = "") -> None
)
raise SystemExit(1)
# Same enforcement for single-credential capture (issue #551): the
# captured IdP refresh token must never be persisted unencrypted.
# Runs after OIDC init (see docstring), so app_state.oidc_config is set.
oidc_config = getattr(app_state, "oidc_config", None)
if (
oidc_config is not None
and getattr(oidc_config, "enabled", False)
and getattr(oidc_config, "capture_user_credential", False)
and cipher_cfg is None
):
log.error(
"oidc.capture: [oidc] capture_user_credential is enabled but no "
"[security] mcp_token_encryption_keys (rotation list) or "
"mcp_token_encryption_key (single) in config.toml. Generate a key with: "
"python -c 'from cryptography.fernet import Fernet; "
"print(Fernet.generate_key().decode())' "
"and add it to your config.toml."
)
raise SystemExit(1)
if cipher_cfg is None:
# No oauth_user rows + no key configured: zero new code paths
# exercised; install None sentinels so callers can fast-path.
+16 -1
View File
@@ -106,7 +106,8 @@ class OIDCConfig:
Startup-config fields (set by :func:`load_oidc_config`):
``enabled``, ``issuer``, ``client_id``, ``client_secret``, ``scopes``,
``provider_name``, ``role_claim``, ``role_map``, ``password_enabled``,
``redirect_base``, ``trusted_endpoint_hosts``, ``allow_private_network``.
``redirect_base``, ``trusted_endpoint_hosts``, ``allow_private_network``,
``capture_user_credential``.
Discovery-derived fields (set by :func:`discover_oidc`; empty before
discovery completes):
@@ -129,6 +130,11 @@ class OIDCConfig:
# (and its same-origin discovered endpoints) to resolve to private
# addresses. Link-local/multicast/reserved stay refused regardless.
allow_private_network: bool = False
# Opt-in single-credential capture (issue #551): persist the user's IdP
# refresh token (encrypted) at login so `auth_type='oauth_obo'` MCP
# servers can mint per-server access tokens on demand. Requires the
# [security] MCP token encryption key — enforced at startup.
capture_user_credential: bool = False
# Discovered from .well-known/openid-configuration
authorization_endpoint: str = ""
token_endpoint: str = ""
@@ -197,6 +203,14 @@ def load_oidc_config() -> OIDCConfig:
allow_private_network = _env_or_cfg_bool(
"TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK", cfg, "allow_private_network", False
)
capture_user_credential = _env_or_cfg_bool(
"TURNSTONE_OIDC_CAPTURE_USER_CREDENTIAL", cfg, "capture_user_credential", False
)
if capture_user_credential and "offline_access" not in scopes.split():
# The captured credential IS the offline_access refresh token; ask
# for it at login so operators don't have to edit two keys in step.
scopes = f"{scopes} offline_access".strip()
log.info("oidc.capture: appended offline_access to login scopes")
# Role map: env var is "admin:builtin-admin,eng:builtin-operator"
role_map_raw = os.environ.get("TURNSTONE_OIDC_ROLE_MAP", "").strip()
@@ -289,6 +303,7 @@ def load_oidc_config() -> OIDCConfig:
redirect_base=redirect_base,
trusted_endpoint_hosts=trusted_endpoint_hosts,
allow_private_network=allow_private_network,
capture_user_credential=capture_user_credential,
)