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.
This commit is contained in:
Patrick Buckley
2026-07-11 21:19:07 -07:00
parent f4e54ce814
commit be22eb2fee
7 changed files with 438 additions and 0 deletions
+110
View File
@@ -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
+63
View File
@@ -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``.
+79
View File
@@ -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(
+50
View File
@@ -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(
+17
View File
@@ -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
+79
View File
@@ -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(
@@ -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")