From be22eb2feee35fca07a35891382d294051e2e26b Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Sat, 11 Jul 2026 21:19:07 -0700 Subject: [PATCH] feat(mcp): add oidc_user_credentials storage for single-credential minting One captured IdP refresh token per (user, issuer), Fernet-encrypted with the same envelope as mcp_user_tokens - the credential that auth_type='oauth_obo' servers will redeem on demand for per-server access tokens instead of holding per-(user, server) refresh tokens. - migration 067 + mirrored create_all schema (parity-tested) - storage protocol + both backends: upsert (replace-on-conflict), get, rotation write-back, delete, delete_user cascade - MCPTokenStore encrypt/decrypt wrappers Refs #551. --- tests/test_oidc_credential_storage.py | 110 ++++++++++++++++++ turnstone/core/mcp_crypto.py | 63 ++++++++++ turnstone/core/storage/_postgresql.py | 79 +++++++++++++ turnstone/core/storage/_protocol.py | 50 ++++++++ turnstone/core/storage/_schema.py | 17 +++ turnstone/core/storage/_sqlite.py | 79 +++++++++++++ .../versions/067_oidc_user_credentials.py | 40 +++++++ 7 files changed, 438 insertions(+) create mode 100644 tests/test_oidc_credential_storage.py create mode 100644 turnstone/core/storage/migrations/versions/067_oidc_user_credentials.py diff --git a/tests/test_oidc_credential_storage.py b/tests/test_oidc_credential_storage.py new file mode 100644 index 00000000..12548105 --- /dev/null +++ b/tests/test_oidc_credential_storage.py @@ -0,0 +1,110 @@ +"""Storage CRUD tests for ``oidc_user_credentials`` (single-credential MCP minting, #551). + +Validates the storage-protocol additions for the captured per-(user, issuer) +IdP refresh token: + +- ``upsert_oidc_user_credential`` (create-or-replace semantics) +- ``get_oidc_user_credential`` +- ``update_oidc_user_credential_refresh`` (rotation write-back) +- ``delete_oidc_user_credential`` +- ``delete_user`` cascade + +plus the ``MCPTokenStore`` encrypt/decrypt wrappers over the same rows. +""" + +from __future__ import annotations + +from tests.conftest import make_mcp_token_cipher +from turnstone.core.mcp_crypto import MCPTokenStore + +ISS = "https://login.example.test/tenant-1/v2.0" + + +class TestUpsertAndGet: + def test_round_trip(self, backend) -> None: + backend.upsert_oidc_user_credential("u1", ISS, refresh_token_ct=b"ct-1") + row = backend.get_oidc_user_credential("u1", ISS) + assert row is not None + assert row["user_id"] == "u1" + assert row["issuer"] == ISS + assert row["refresh_token_ct"] == b"ct-1" + assert row["created"] == row["last_refreshed"] + + def test_get_missing_returns_none(self, backend) -> None: + assert backend.get_oidc_user_credential("nobody", ISS) is None + + def test_keyed_by_user_and_issuer(self, backend) -> None: + backend.upsert_oidc_user_credential("u1", ISS, refresh_token_ct=b"ct-1") + assert backend.get_oidc_user_credential("u1", "https://other.test") is None + assert backend.get_oidc_user_credential("u2", ISS) is None + + def test_upsert_replaces_on_conflict(self, backend) -> None: + """A fresh login must overwrite a stale credential; ``created`` survives.""" + backend.upsert_oidc_user_credential("u1", ISS, refresh_token_ct=b"ct-old") + first = backend.get_oidc_user_credential("u1", ISS) + assert first is not None + backend.upsert_oidc_user_credential("u1", ISS, refresh_token_ct=b"ct-new") + second = backend.get_oidc_user_credential("u1", ISS) + assert second is not None + assert second["refresh_token_ct"] == b"ct-new" + assert second["created"] == first["created"] + + +class TestRotationWriteBack: + def test_update_rewrites_token(self, backend) -> None: + backend.upsert_oidc_user_credential("u1", ISS, refresh_token_ct=b"ct-1") + assert backend.update_oidc_user_credential_refresh("u1", ISS, refresh_token_ct=b"ct-2") + row = backend.get_oidc_user_credential("u1", ISS) + assert row is not None + assert row["refresh_token_ct"] == b"ct-2" + + def test_update_missing_returns_false(self, backend) -> None: + assert not backend.update_oidc_user_credential_refresh( + "nobody", ISS, refresh_token_ct=b"ct" + ) + + +class TestDelete: + def test_delete_existing(self, backend) -> None: + backend.upsert_oidc_user_credential("u1", ISS, refresh_token_ct=b"ct-1") + assert backend.delete_oidc_user_credential("u1", ISS) + assert backend.get_oidc_user_credential("u1", ISS) is None + + def test_delete_missing_returns_false(self, backend) -> None: + assert not backend.delete_oidc_user_credential("nobody", ISS) + + def test_delete_user_cascades_credential(self, backend) -> None: + backend.upsert_oidc_user_credential("u-doomed", ISS, refresh_token_ct=b"ct-1") + backend.delete_user("u-doomed") + assert backend.get_oidc_user_credential("u-doomed", ISS) is None + + +class TestTokenStoreWrappers: + def test_encrypt_decrypt_round_trip(self, backend) -> None: + store = MCPTokenStore(backend, make_mcp_token_cipher()) + store.upsert_oidc_credential("u1", ISS, refresh_token="rt-plaintext") + plain = store.get_oidc_credential("u1", ISS) + assert plain is not None + assert plain["refresh_token"] == "rt-plaintext" + # Ciphertext at rest — the raw row must not contain the plaintext. + raw = backend.get_oidc_user_credential("u1", ISS) + assert raw is not None + assert b"rt-plaintext" not in raw["refresh_token_ct"] + + def test_redeem_write_back_round_trip(self, backend) -> None: + store = MCPTokenStore(backend, make_mcp_token_cipher()) + store.upsert_oidc_credential("u1", ISS, refresh_token="rt-first") + assert store.update_oidc_credential_after_redeem("u1", ISS, refresh_token="rt-rotated") + plain = store.get_oidc_credential("u1", ISS) + assert plain is not None + assert plain["refresh_token"] == "rt-rotated" + + def test_get_missing_returns_none(self, backend) -> None: + store = MCPTokenStore(backend, make_mcp_token_cipher()) + assert store.get_oidc_credential("nobody", ISS) is None + + def test_delete_via_store(self, backend) -> None: + store = MCPTokenStore(backend, make_mcp_token_cipher()) + store.upsert_oidc_credential("u1", ISS, refresh_token="rt") + assert store.delete_oidc_credential("u1", ISS) + assert store.get_oidc_credential("u1", ISS) is None diff --git a/turnstone/core/mcp_crypto.py b/turnstone/core/mcp_crypto.py index f7b7742e..a511ca06 100644 --- a/turnstone/core/mcp_crypto.py +++ b/turnstone/core/mcp_crypto.py @@ -90,6 +90,21 @@ class MCPUserTokenPlain(TypedDict): last_refreshed: str | None +class OIDCCredentialPlain(TypedDict): + """Plaintext shape returned by ``MCPTokenStore.get_oidc_credential``. + + Mirrors ``OIDCUserCredential`` (storage row shape) with the refresh + token decrypted — the per-(user, issuer) credential that + ``auth_type='oauth_obo'`` servers redeem on demand (issue #551). + """ + + user_id: str + issuer: str + refresh_token: str + created: str + last_refreshed: str + + class MCPUserTokenMetadata(TypedDict): """Non-secret subset of ``MCPUserToken`` for the settings UI. @@ -378,6 +393,54 @@ class MCPTokenStore: """Delete the user-token row. Returns True if existed.""" return self._storage.delete_mcp_user_token(user_id, server_name) + # -- OIDC user credential (single-credential MCP minting, #551) ------------- + # + # Same cipher envelope as the per-(user, server) tokens above; lives on + # this store so there is exactly one owner of the encryption keys. + + def upsert_oidc_credential(self, user_id: str, issuer: str, *, refresh_token: str) -> None: + """Encrypt and create-or-replace the user's captured IdP refresh token.""" + refresh_ct = self._cipher.encrypt(refresh_token.encode("utf-8")) + self._storage.upsert_oidc_user_credential(user_id, issuer, refresh_token_ct=refresh_ct) + + def get_oidc_credential(self, user_id: str, issuer: str) -> OIDCCredentialPlain | None: + """Returns plaintext dict or None. + + Raises ``MCPTokenDecryptError`` on key mismatch — caller MUST NOT + auto-delete the row (same contract as ``get_user_token``). + """ + row = self._storage.get_oidc_user_credential(user_id, issuer) + if row is None: + return None + try: + refresh_pt = self._cipher.decrypt(row["refresh_token_ct"]).decode("utf-8") + except MCPTokenDecryptError as exc: + self._audit_decrypt_failure(f"oidc:{issuer}", exc.key_fingerprints_attempted) + raise + return OIDCCredentialPlain( + user_id=row["user_id"], + issuer=row["issuer"], + refresh_token=refresh_pt, + created=row["created"], + last_refreshed=row["last_refreshed"], + ) + + def update_oidc_credential_after_redeem( + self, user_id: str, issuer: str, *, refresh_token: str + ) -> bool: + """Rotation write-back: persist the newest refresh token after a + redemption returned one (both verified grant legs rotate). + Returns True when a row was updated. + """ + refresh_ct = self._cipher.encrypt(refresh_token.encode("utf-8")) + return self._storage.update_oidc_user_credential_refresh( + user_id, issuer, refresh_token_ct=refresh_ct + ) + + def delete_oidc_credential(self, user_id: str, issuer: str) -> bool: + """Remove the captured credential (logout-all / admin revoke).""" + return self._storage.delete_oidc_user_credential(user_id, issuer) + def list_user_token_metadata(self, user_id: str) -> list[MCPUserTokenMetadata]: """Return non-secret metadata for every token row owned by ``user_id``. diff --git a/turnstone/core/storage/_postgresql.py b/turnstone/core/storage/_postgresql.py index 37f081dd..e261c85e 100644 --- a/turnstone/core/storage/_postgresql.py +++ b/turnstone/core/storage/_postgresql.py @@ -26,6 +26,7 @@ from turnstone.core.storage._protocol import ( MCPUserTokenMetadataRow, OIDCIdentity, OIDCPendingState, + OIDCUserCredential, ) from turnstone.core.storage._schema import ( api_tokens, @@ -43,6 +44,7 @@ from turnstone.core.storage._schema import ( model_definitions, oidc_identities, oidc_pending_states, + oidc_user_credentials, orgs, output_assessments, output_guard_patterns, @@ -1502,6 +1504,9 @@ class PostgreSQLBackend: conn.execute(sa.delete(channel_users).where(channel_users.c.user_id == user_id)) conn.execute(sa.delete(api_tokens).where(api_tokens.c.user_id == user_id)) conn.execute(sa.delete(oidc_identities).where(oidc_identities.c.user_id == user_id)) + conn.execute( + sa.delete(oidc_user_credentials).where(oidc_user_credentials.c.user_id == user_id) + ) conn.execute(sa.delete(mcp_user_tokens).where(mcp_user_tokens.c.user_id == user_id)) conn.execute(sa.delete(mcp_oauth_pending).where(mcp_oauth_pending.c.user_id == user_id)) result = conn.execute(sa.delete(users).where(users.c.user_id == user_id)) @@ -5562,6 +5567,80 @@ class PostgreSQLBackend: conn.commit() return result.rowcount > 0 + # -- OIDC user credential (single-credential MCP minting, #551) ------------- + + def upsert_oidc_user_credential( + self, user_id: str, issuer: str, *, refresh_token_ct: bytes + ) -> None: + """Create or replace the user's captured IdP refresh token.""" + from sqlalchemy.dialects import postgresql + + now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + with self._conn() as conn: + stmt = postgresql.insert(oidc_user_credentials).values( + user_id=user_id, + issuer=issuer, + refresh_token_ct=refresh_token_ct, + created=now, + last_refreshed=now, + ) + conn.execute( + stmt.on_conflict_do_update( + index_elements=["user_id", "issuer"], + set_={"refresh_token_ct": refresh_token_ct, "last_refreshed": now}, + ) + ) + conn.commit() + + def get_oidc_user_credential(self, user_id: str, issuer: str) -> OIDCUserCredential | None: + """Return the captured credential row or None.""" + with self._conn() as conn: + row = conn.execute( + sa.select(oidc_user_credentials).where( + (oidc_user_credentials.c.user_id == user_id) + & (oidc_user_credentials.c.issuer == issuer) + ) + ).fetchone() + if row is None: + return None + m = row._mapping + return OIDCUserCredential( + user_id=m["user_id"], + issuer=m["issuer"], + refresh_token_ct=bytes(m["refresh_token_ct"]), + created=m["created"], + last_refreshed=m["last_refreshed"], + ) + + def update_oidc_user_credential_refresh( + self, user_id: str, issuer: str, *, refresh_token_ct: bytes + ) -> bool: + """Persist the newest refresh token after a rotating redemption.""" + now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + with self._conn() as conn: + result = conn.execute( + sa.update(oidc_user_credentials) + .where( + (oidc_user_credentials.c.user_id == user_id) + & (oidc_user_credentials.c.issuer == issuer) + ) + .values(refresh_token_ct=refresh_token_ct, last_refreshed=now) + ) + conn.commit() + return result.rowcount > 0 + + def delete_oidc_user_credential(self, user_id: str, issuer: str) -> bool: + """Remove the captured credential. Returns True if existed.""" + with self._conn() as conn: + result = conn.execute( + sa.delete(oidc_user_credentials).where( + (oidc_user_credentials.c.user_id == user_id) + & (oidc_user_credentials.c.issuer == issuer) + ) + ) + conn.commit() + return result.rowcount > 0 + # -- OIDC pending state ---------------------------------------------------- def create_oidc_pending_state( diff --git a/turnstone/core/storage/_protocol.py b/turnstone/core/storage/_protocol.py index 86c2aa94..857dfa84 100644 --- a/turnstone/core/storage/_protocol.py +++ b/turnstone/core/storage/_protocol.py @@ -38,6 +38,23 @@ class OIDCIdentity(TypedDict): tid: str +class OIDCUserCredential(TypedDict): + """Row shape for the per-(user, issuer) captured IdP refresh token. + + ``refresh_token_ct`` is a Fernet ciphertext blob (same envelope as + ``mcp_user_tokens``); the storage layer returns it verbatim and + ``MCPTokenStore`` handles encrypt/decrypt. One row per user per + issuer — the single credential that ``auth_type='oauth_obo'`` MCP + servers redeem on demand (issue #551). + """ + + user_id: str + issuer: str + refresh_token_ct: bytes + created: str + last_refreshed: str + + class OIDCPendingState(TypedDict): """Row shape returned when popping a pending OIDC authorization-flow state.""" @@ -997,6 +1014,39 @@ class StorageBackend(Protocol): """Remove an OIDC identity link. Returns True if existed.""" ... + # -- OIDC user credential (single-credential MCP minting, #551) ------------- + + def upsert_oidc_user_credential( + self, user_id: str, issuer: str, *, refresh_token_ct: bytes + ) -> None: + """Create or replace the user's captured IdP refresh token. + + Replace-on-conflict: a fresh login must overwrite a stale or + revoked credential. ``created`` is preserved on replace; + ``last_refreshed`` is reset to now either way. + """ + ... + + def get_oidc_user_credential(self, user_id: str, issuer: str) -> OIDCUserCredential | None: + """Return the captured credential row or None.""" + ... + + def update_oidc_user_credential_refresh( + self, user_id: str, issuer: str, *, refresh_token_ct: bytes + ) -> bool: + """Rotation write-back after a redemption returned a new refresh token. + + Both verified grant legs rotate (Entra returns a new RT per + redemption; Keycloak rotates on the refresh grant), so the mint + path MUST persist the newest token every time. Returns True when + a row was updated. + """ + ... + + def delete_oidc_user_credential(self, user_id: str, issuer: str) -> bool: + """Remove the captured credential (logout-all / admin revoke). Returns True if existed.""" + ... + # -- OIDC pending state ---------------------------------------------------- def create_oidc_pending_state( diff --git a/turnstone/core/storage/_schema.py b/turnstone/core/storage/_schema.py index 80096031..9818bd86 100644 --- a/turnstone/core/storage/_schema.py +++ b/turnstone/core/storage/_schema.py @@ -953,6 +953,23 @@ oidc_pending_states = sa.Table( sa.Column("created_at", sa.Text, nullable=False), ) +# One captured IdP refresh token per (user, issuer) — the single credential +# that `auth_type='oauth_obo'` MCP servers redeem on demand (issue #551). +# Deliberately separate from `oidc_identities`: this row is a Fernet-encrypted +# secret with hot rotation writes on the mint path, while identity rows are +# freely-read metadata. Keyed (user_id, issuer) — the mint path enters with +# user_id; issuer future-proofs multi-IdP (OIDCConfig is single-issuer today). +oidc_user_credentials = sa.Table( + "oidc_user_credentials", + metadata, + sa.Column("user_id", sa.Text, nullable=False), + sa.Column("issuer", sa.Text, nullable=False), + sa.Column("refresh_token_ct", sa.LargeBinary, nullable=False), + sa.Column("created", sa.Text, nullable=False), + sa.Column("last_refreshed", sa.Text, nullable=False), + sa.PrimaryKeyConstraint("user_id", "issuer"), +) + # --------------------------------------------------------------------------- # MCP per-(user, server) OAuth tokens and pending authorization-flow state. # No FKs at the schema level (matches `oidc_*` tables; tests avoid orphan diff --git a/turnstone/core/storage/_sqlite.py b/turnstone/core/storage/_sqlite.py index 976989f6..b972f877 100644 --- a/turnstone/core/storage/_sqlite.py +++ b/turnstone/core/storage/_sqlite.py @@ -26,6 +26,7 @@ from turnstone.core.storage._protocol import ( MCPUserTokenMetadataRow, OIDCIdentity, OIDCPendingState, + OIDCUserCredential, ) from turnstone.core.storage._schema import ( api_tokens, @@ -43,6 +44,7 @@ from turnstone.core.storage._schema import ( model_definitions, oidc_identities, oidc_pending_states, + oidc_user_credentials, orgs, output_assessments, output_guard_patterns, @@ -1651,6 +1653,9 @@ class SQLiteBackend: conn.execute(sa.delete(channel_users).where(channel_users.c.user_id == user_id)) conn.execute(sa.delete(api_tokens).where(api_tokens.c.user_id == user_id)) conn.execute(sa.delete(oidc_identities).where(oidc_identities.c.user_id == user_id)) + conn.execute( + sa.delete(oidc_user_credentials).where(oidc_user_credentials.c.user_id == user_id) + ) conn.execute(sa.delete(mcp_user_tokens).where(mcp_user_tokens.c.user_id == user_id)) conn.execute(sa.delete(mcp_oauth_pending).where(mcp_oauth_pending.c.user_id == user_id)) result = conn.execute(sa.delete(users).where(users.c.user_id == user_id)) @@ -5706,6 +5711,80 @@ class SQLiteBackend: conn.commit() return result.rowcount > 0 + # -- OIDC user credential (single-credential MCP minting, #551) ------------- + + def upsert_oidc_user_credential( + self, user_id: str, issuer: str, *, refresh_token_ct: bytes + ) -> None: + """Create or replace the user's captured IdP refresh token.""" + from sqlalchemy.dialects.sqlite import insert as sqlite_insert + + now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + with self._conn() as conn: + stmt = sqlite_insert(oidc_user_credentials).values( + user_id=user_id, + issuer=issuer, + refresh_token_ct=refresh_token_ct, + created=now, + last_refreshed=now, + ) + conn.execute( + stmt.on_conflict_do_update( + index_elements=["user_id", "issuer"], + set_={"refresh_token_ct": refresh_token_ct, "last_refreshed": now}, + ) + ) + conn.commit() + + def get_oidc_user_credential(self, user_id: str, issuer: str) -> OIDCUserCredential | None: + """Return the captured credential row or None.""" + with self._conn() as conn: + row = conn.execute( + sa.select(oidc_user_credentials).where( + (oidc_user_credentials.c.user_id == user_id) + & (oidc_user_credentials.c.issuer == issuer) + ) + ).fetchone() + if row is None: + return None + m = row._mapping + return OIDCUserCredential( + user_id=m["user_id"], + issuer=m["issuer"], + refresh_token_ct=bytes(m["refresh_token_ct"]), + created=m["created"], + last_refreshed=m["last_refreshed"], + ) + + def update_oidc_user_credential_refresh( + self, user_id: str, issuer: str, *, refresh_token_ct: bytes + ) -> bool: + """Persist the newest refresh token after a rotating redemption.""" + now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + with self._conn() as conn: + result = conn.execute( + sa.update(oidc_user_credentials) + .where( + (oidc_user_credentials.c.user_id == user_id) + & (oidc_user_credentials.c.issuer == issuer) + ) + .values(refresh_token_ct=refresh_token_ct, last_refreshed=now) + ) + conn.commit() + return result.rowcount > 0 + + def delete_oidc_user_credential(self, user_id: str, issuer: str) -> bool: + """Remove the captured credential. Returns True if existed.""" + with self._conn() as conn: + result = conn.execute( + sa.delete(oidc_user_credentials).where( + (oidc_user_credentials.c.user_id == user_id) + & (oidc_user_credentials.c.issuer == issuer) + ) + ) + conn.commit() + return result.rowcount > 0 + # -- OIDC pending state ---------------------------------------------------- def create_oidc_pending_state( diff --git a/turnstone/core/storage/migrations/versions/067_oidc_user_credentials.py b/turnstone/core/storage/migrations/versions/067_oidc_user_credentials.py new file mode 100644 index 00000000..7c889ce9 --- /dev/null +++ b/turnstone/core/storage/migrations/versions/067_oidc_user_credentials.py @@ -0,0 +1,40 @@ +"""Add oidc_user_credentials for single-credential MCP token minting. + +One encrypted IdP refresh token per (user, issuer), captured at OIDC login +when ``[oidc] capture_user_credential`` is enabled. Servers with +``auth_type='oauth_obo'`` redeem this single credential on demand for +short-lived per-server access tokens instead of holding a per-(user, server) +refresh token — see issue #551. + +Keyed ``(user_id, issuer)`` rather than ``user_id`` alone so a future +multi-IdP login surface needs no re-keying; today's OIDCConfig is +single-issuer, so the table holds at most one row per user in practice. + +Revision ID: 067 +Revises: 066 +Create Date: 2026-07-11 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "067" +down_revision = "066" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "oidc_user_credentials", + sa.Column("user_id", sa.Text, nullable=False), + sa.Column("issuer", sa.Text, nullable=False), + sa.Column("refresh_token_ct", sa.LargeBinary, nullable=False), + sa.Column("created", sa.Text, nullable=False), + sa.Column("last_refreshed", sa.Text, nullable=False), + sa.PrimaryKeyConstraint("user_id", "issuer"), + ) + + +def downgrade() -> None: + op.drop_table("oidc_user_credentials")