mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user