From 0732f9a7d28ebf398b95332148e3898aed9d0ce9 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Sat, 11 Jul 2026 21:29:06 -0700 Subject: [PATCH] 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. --- tests/test_oidc.py | 78 +++++++++++++ tests/test_oidc_handlers.py | 221 +++++++++++++++++++++++++++++++++++ turnstone/core/auth.py | 29 +++++ turnstone/core/mcp_crypto.py | 22 ++++ turnstone/core/oidc.py | 17 ++- 5 files changed, 366 insertions(+), 1 deletion(-) diff --git a/tests/test_oidc.py b/tests/test_oidc.py index e50ba32c..3c2e00f4 100644 --- a/tests/test_oidc.py +++ b/tests/test_oidc.py @@ -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) diff --git a/tests/test_oidc_handlers.py b/tests/test_oidc_handlers.py index cd0ee1b4..eb6c527f 100644 --- a/tests/test_oidc_handlers.py +++ b/tests/test_oidc_handlers.py @@ -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 # --------------------------------------------------------------------------- diff --git a/turnstone/core/auth.py b/turnstone/core/auth.py index 1a6f2507..1c9df138 100644 --- a/turnstone/core/auth.py +++ b/turnstone/core/auth.py @@ -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) diff --git a/turnstone/core/mcp_crypto.py b/turnstone/core/mcp_crypto.py index a511ca06..4c00c636 100644 --- a/turnstone/core/mcp_crypto.py +++ b/turnstone/core/mcp_crypto.py @@ -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. diff --git a/turnstone/core/oidc.py b/turnstone/core/oidc.py index 9f45b1d6..73be6c0f 100644 --- a/turnstone/core/oidc.py +++ b/turnstone/core/oidc.py @@ -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, )