mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
d675b237a3
Adds the data model and admin UI surface required by the OAuth-MCP flow.
Phase 2 of the per-user delegation initiative.
Schema:
- migration 049 creates mcp_user_tokens (PK user_id, server_name) and
mcp_oauth_pending (PK state, indexed by created_at)
- eight new columns on mcp_servers: auth_type ('none' / 'static' /
'oauth_user', NOT NULL DEFAULT 'static') plus six oauth_* config
fields and oauth_as_issuer_cached
- post-upgrade UPDATE normalises auth_type to 'none' for streamable-http
rows whose headers are NULL/empty/'{}'; stdio rows are left at the
'static' default (auth_type is HTTP-auth-only)
- _schema.py kept in lockstep with the migration so metadata.create_all
and alembic upgrade produce identical shapes
- mcp_user_tokens / mcp_oauth_pending TypedDicts in _protocol.py for
Phase 3/4 use (no CRUD methods yet)
Storage / API:
- create_mcp_server gains the eight kwargs across protocol + sqlite +
postgresql
- MCP_SERVER_MUTABLE picks up auth_type and the six text oauth_* fields;
oauth_client_secret_ct is intentionally NOT in the whitelist — Phase 3
will own ciphertext writes via a dedicated method
- McpServerInfo + Create/Update Pydantic schemas extended; oauth_client_secret
accepted as plaintext input but discarded (Phase 3 wires encryption)
Admin handlers:
- _parse_auth_type validates against {'none', 'static', 'oauth_user'} and
rejects empty / unknown values; shared between create and update
- when auth_type changes away from 'oauth_user', the oauth_* config
columns are explicitly nulled in the same UPDATE so the row stays
consistent
- _clean_oauth_text caps text fields at 512 chars (URLs at 2048) to bound
admin write surface
- _mask_mcp_secrets now masks oauth_client_secret_ct to '***' regardless
of reveal=true (write-only field)
- audit detail dict redacts oauth_client_secret if present
Frontend:
- new "Multitenant Authorization" fieldset on the MCP-server modal with
three radio buttons (None / Shared / Per-user OAuth 2.1)
- conditional OAuth subform: AS URL, registration mode (preregistered /
dcr; cimd is future), client ID, client secret, scopes, audience
- secret input is autocomplete=off and never round-trips on edit
- audience auto-populates from the MCP server URL on blur
- headers textarea hidden and submitted as {} when auth_type is 'none' or
'oauth_user' so flipping the radio cleans up server-side state
Tests: storage round-trip for the new columns, oauth_pending table smoke,
migration 049 upgrade/downgrade with stdio-vs-http normalisation, four
admin-API tests for auth_type validation and oauth_*-clear-on-flip-away.
Suite passes 5284 (matched pre-Phase-2 baseline 5267 + 17 new).
Stacks on Phase 0; no behavioural change for existing rows.
134 lines
5.2 KiB
Python
134 lines
5.2 KiB
Python
"""Smoke tests for the new OAuth-MCP storage tables.
|
|
|
|
Phase 2 only adds the schema — token CRUD lands in Phase 3 and pending-
|
|
state CRUD in Phase 4. These tests verify the tables exist after
|
|
``init_storage`` and accept the documented row shape via raw SQL.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlalchemy as sa
|
|
|
|
from turnstone.core.storage._schema import mcp_oauth_pending, mcp_user_tokens
|
|
|
|
|
|
class TestMcpUserTokensTable:
|
|
def test_table_exists_and_accepts_row(self, backend) -> None:
|
|
with backend._engine.connect() as conn:
|
|
conn.execute(
|
|
sa.insert(mcp_user_tokens),
|
|
{
|
|
"user_id": "u1",
|
|
"server_name": "srv-a",
|
|
"access_token_ct": b"\x00ciphertext-a",
|
|
"refresh_token_ct": b"\x00ciphertext-r",
|
|
"expires_at": "2026-05-04T12:00:00",
|
|
"scopes": "openid profile",
|
|
"as_issuer": "https://auth.example.com",
|
|
"audience": "https://mcp.example.com",
|
|
"created": "2026-05-04T11:00:00",
|
|
"last_refreshed": None,
|
|
},
|
|
)
|
|
conn.commit()
|
|
row = conn.execute(
|
|
sa.select(mcp_user_tokens).where(
|
|
(mcp_user_tokens.c.user_id == "u1") & (mcp_user_tokens.c.server_name == "srv-a")
|
|
)
|
|
).one()
|
|
assert row.access_token_ct == b"\x00ciphertext-a"
|
|
assert row.refresh_token_ct == b"\x00ciphertext-r"
|
|
assert row.scopes == "openid profile"
|
|
assert row.audience == "https://mcp.example.com"
|
|
|
|
def test_composite_pk_distinguishes_user_server(self, backend) -> None:
|
|
"""Same user, different server => two rows; same (user, server) => conflict."""
|
|
with backend._engine.connect() as conn:
|
|
conn.execute(
|
|
sa.insert(mcp_user_tokens),
|
|
[
|
|
{
|
|
"user_id": "u1",
|
|
"server_name": "srv-a",
|
|
"access_token_ct": b"a",
|
|
"refresh_token_ct": None,
|
|
"expires_at": None,
|
|
"scopes": None,
|
|
"as_issuer": "https://auth.example.com",
|
|
"audience": "https://a.example.com",
|
|
"created": "2026-05-04T11:00:00",
|
|
"last_refreshed": None,
|
|
},
|
|
{
|
|
"user_id": "u1",
|
|
"server_name": "srv-b",
|
|
"access_token_ct": b"b",
|
|
"refresh_token_ct": None,
|
|
"expires_at": None,
|
|
"scopes": None,
|
|
"as_issuer": "https://auth.example.com",
|
|
"audience": "https://b.example.com",
|
|
"created": "2026-05-04T11:00:00",
|
|
"last_refreshed": None,
|
|
},
|
|
],
|
|
)
|
|
conn.commit()
|
|
count = conn.execute(sa.select(sa.func.count()).select_from(mcp_user_tokens)).scalar()
|
|
assert count == 2
|
|
|
|
|
|
class TestMcpOauthPendingTable:
|
|
def test_table_exists_and_accepts_row(self, backend) -> None:
|
|
with backend._engine.connect() as conn:
|
|
conn.execute(
|
|
sa.insert(mcp_oauth_pending),
|
|
{
|
|
"state": "rand-state-xyz",
|
|
"user_id": "u1",
|
|
"server_name": "srv-a",
|
|
"code_verifier": "verifier-blob",
|
|
"return_url": "/admin/mcp-servers",
|
|
"created_at": "2026-05-04T11:00:00",
|
|
},
|
|
)
|
|
conn.commit()
|
|
row = conn.execute(
|
|
sa.select(mcp_oauth_pending).where(mcp_oauth_pending.c.state == "rand-state-xyz")
|
|
).one()
|
|
assert row.user_id == "u1"
|
|
assert row.server_name == "srv-a"
|
|
assert row.return_url == "/admin/mcp-servers"
|
|
|
|
def test_state_pk_unique(self, backend) -> None:
|
|
"""A second insert with the same state value raises IntegrityError."""
|
|
with backend._engine.connect() as conn:
|
|
conn.execute(
|
|
sa.insert(mcp_oauth_pending),
|
|
{
|
|
"state": "dup-state",
|
|
"user_id": "u1",
|
|
"server_name": "srv-a",
|
|
"code_verifier": "v",
|
|
"return_url": "/x",
|
|
"created_at": "2026-05-04T11:00:00",
|
|
},
|
|
)
|
|
conn.commit()
|
|
import pytest
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
with pytest.raises(IntegrityError), backend._engine.connect() as conn:
|
|
conn.execute(
|
|
sa.insert(mcp_oauth_pending),
|
|
{
|
|
"state": "dup-state",
|
|
"user_id": "u2",
|
|
"server_name": "srv-b",
|
|
"code_verifier": "v",
|
|
"return_url": "/y",
|
|
"created_at": "2026-05-04T11:01:00",
|
|
},
|
|
)
|
|
conn.commit()
|