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