feat: add neutral auth store boundary

This commit is contained in:
lda
2026-06-06 10:18:16 +07:00 Verified
parent 71facde1a2
commit 34726433a8
15 changed files with 456 additions and 41 deletions
+4
View File
@@ -227,6 +227,10 @@ implementation state.
[2026-06-06 auth/source secrets boundary](./superpowers/specs/2026-06-06-auth-source-secrets-boundary.md): [2026-06-06 auth/source secrets boundary](./superpowers/specs/2026-06-06-auth-source-secrets-boundary.md):
sources carry `auth_ref`, runtime resolves through an auth store interface, sources carry `auth_ref`, runtime resolves through an auth store interface,
and the current filesystem auth files are only one adapter. and the current filesystem auth files are only one adapter.
First implementation slice complete: neutral auth records/store protocol
exist in `wf_api`, MCP runtime auth resolution prefers explicit `auth_ref`
with legacy connection-id fallback, and MCP payload interpretation is
isolated in provider-specific adapter helpers.
- Completed: `wf run watch` starts run progress UX with polling over existing - Completed: `wf run watch` starts run progress UX with polling over existing
`inspect_run` and optional bounded `read_run_trace`. SSE/WebSocket/MCP `inspect_run` and optional bounded `read_run_trace`. SSE/WebSocket/MCP
progress remains deferred until polling UX proves insufficient. progress remains deferred until polling UX proves insufficient.
@@ -11,6 +11,12 @@ This spec defines the next boundary before expanding auth behavior. The
important choice is interface first: file-backed auth is one implementation, not important choice is interface first: file-backed auth is one implementation, not
the architecture. the architecture.
## Status
Slice 1 implements the neutral auth record/store protocol and MCP compatibility
bridge. Diagnostics, auth admin surfaces, and provider-specific auth unions are
future slices.
## Current State ## Current State
Existing MCP runtime auth is connection-id keyed: Existing MCP runtime auth is connection-id keyed:
+5
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from .auth import AUTH_ID_PATTERN, AuthRecord, AuthStore, validate_auth_id
from .listing import matches_query, paged_list_payload from .listing import matches_query, paged_list_payload
from .admin import ( from .admin import (
WorkflowAdminApi, WorkflowAdminApi,
@@ -66,7 +67,10 @@ from .stores import WorkflowStores, file_workflow_stores
from .durable_context import durable_workflow_api, require_workflow_stores from .durable_context import durable_workflow_api, require_workflow_stores
__all__ = [ __all__ = [
"AUTH_ID_PATTERN",
"DEFAULT_CALL_STEP_ID", "DEFAULT_CALL_STEP_ID",
"AuthRecord",
"AuthStore",
"builtin_sources", "builtin_sources",
"get_qualified_spec", "get_qualified_spec",
"matches_query", "matches_query",
@@ -119,6 +123,7 @@ __all__ = [
"WrapperHintConfidence", "WrapperHintConfidence",
"WrapperOutcomePolicy", "WrapperOutcomePolicy",
"parse_workflow_surface_capability_id", "parse_workflow_surface_capability_id",
"validate_auth_id",
"workflow_output_schema_for_authoring", "workflow_output_schema_for_authoring",
"wrapper_hints_for_capability", "wrapper_hints_for_capability",
"resolve_runtime_dependencies", "resolve_runtime_dependencies",
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Protocol
AUTH_ID_PATTERN = r"^[A-Za-z0-9_][A-Za-z0-9_.-]*$"
def validate_auth_id(value: str) -> str:
"""Validate auth refs that are safe as store keys and path segments.
Auth refs deliberately carry no provider semantics. Source providers decide
how a resolved auth record is interpreted.
"""
if not re.fullmatch(AUTH_ID_PATTERN, value):
raise ValueError(
"auth id must start with alphanumeric or underscore and contain "
"only [A-Za-z0-9_.-]"
)
return value
@dataclass(frozen=True, slots=True)
class AuthRecord:
"""Neutral credential record resolved by auth ref.
`scheme + payload` is a compatibility bridge, not the long-term taxonomy.
Keep payload interpretation inside provider adapters so a future
discriminated union can replace this without touching workflow/config code.
"""
id: str
scheme: str
payload: Mapping[str, object]
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
validate_auth_id(self.id)
if not self.scheme:
raise ValueError("auth scheme must be non-empty")
class AuthStore(Protocol):
"""Read-only runtime credential lookup by auth ref."""
def load_auth(self, auth_ref: str) -> AuthRecord | None: ...
__all__ = [
"AUTH_ID_PATTERN",
"AuthRecord",
"AuthStore",
"validate_auth_id",
]
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
from wf_api.auth import AuthRecord as NeutralAuthRecord
from .models import AuthRecord as McpAuthRecord
def mcp_auth_from_neutral(record: NeutralAuthRecord) -> McpAuthRecord:
"""Adapt neutral auth to the current MCP compatibility record."""
return McpAuthRecord(
connection_id=record.id,
scheme=record.scheme,
payload=dict(record.payload),
)
def neutral_auth_from_mcp(record: McpAuthRecord) -> NeutralAuthRecord:
"""Adapt legacy MCP auth into the neutral record shape."""
return NeutralAuthRecord(
id=record.connection_id,
scheme=record.scheme,
payload=dict(record.payload),
)
def mcp_auth_headers(auth: McpAuthRecord | None) -> dict[str, str]:
"""Return HTTP headers understood by MCP HTTP transports.
This is intentionally MCP-specific. Neutral code must not inspect payload
keys such as `headers` or `token`.
"""
if auth is None:
return {}
raw_headers = auth.payload.get("headers", {})
headers = {
str(key): str(value)
for key, value in raw_headers.items()
if isinstance(key, str) and isinstance(value, str)
} if isinstance(raw_headers, dict) else {}
token = auth.payload.get("token")
if isinstance(token, str) and "Authorization" not in headers:
headers["Authorization"] = f"Bearer {token}"
return headers
def mcp_auth_env(auth: McpAuthRecord | None) -> dict[str, str]:
"""Return environment variables understood by MCP stdio transports."""
if auth is None:
return {}
raw_env = auth.payload.get("env", {})
if not isinstance(raw_env, dict):
return {}
return {
str(key): str(value)
for key, value in raw_env.items()
if isinstance(key, str) and isinstance(value, str)
}
__all__ = [
"mcp_auth_env",
"mcp_auth_from_neutral",
"mcp_auth_headers",
"neutral_auth_from_mcp",
]
+1 -1
View File
@@ -91,7 +91,7 @@ class WfMcpService:
connection_list_enabled=self.connection_service.list_enabled, connection_list_enabled=self.connection_service.list_enabled,
connection_list_all=self.connection_service.list_all, connection_list_all=self.connection_service.list_all,
tool_executor_for=self.upstream.tool_executor_for, tool_executor_for=self.upstream.tool_executor_for,
load_auth=self.upstream.load_auth, load_auth=self.upstream.load_connection_auth,
emit_event=self.events.record_event, emit_event=self.events.record_event,
default_catalog_max_age_seconds=self.default_catalog_max_age_seconds, default_catalog_max_age_seconds=self.default_catalog_max_age_seconds,
) )
+2 -2
View File
@@ -36,7 +36,7 @@ from .specs import get_qualified_spec, qualify_spec
ConnectionLookup = Callable[[str], ConnectionConfig] ConnectionLookup = Callable[[str], ConnectionConfig]
ConnectionList = Callable[[], list[ConnectionConfig]] ConnectionList = Callable[[], list[ConnectionConfig]]
ToolExecutorLookup = Callable[[ConnectionConfig], ToolExecutor] ToolExecutorLookup = Callable[[ConnectionConfig], ToolExecutor]
AuthLoader = Callable[[str], AuthRecord | None] AuthLoader = Callable[[ConnectionConfig], AuthRecord | None]
EventEmitter = Callable[[McpEvent], None] EventEmitter = Callable[[McpEvent], None]
@@ -270,7 +270,7 @@ class SourceCatalogService:
async def invoke_tool(payload: BaseModel) -> NodeReturn[BaseModel]: async def invoke_tool(payload: BaseModel) -> NodeReturn[BaseModel]:
connection = self.connection_lookup(entry.connection_id) connection = self.connection_lookup(entry.connection_id)
auth = self.load_auth(entry.connection_id) auth = self.load_auth(connection)
result = await self.tool_executor_for(connection).call_tool( result = await self.tool_executor_for(connection).call_tool(
connection, connection,
auth, auth,
@@ -63,6 +63,19 @@ class UpstreamTransportService:
def load_auth(self, connection_id: str) -> AuthRecord | None: def load_auth(self, connection_id: str) -> AuthRecord | None:
return self.store.load_auth(connection_id) return self.store.load_auth(connection_id)
def load_connection_auth(self, connection: ConnectionConfig) -> AuthRecord | None:
"""Resolve auth for a connection, preferring explicit source auth_ref.
Legacy MCP auth records are keyed by connection id. New source registry
and neutral config entries carry `auth_ref`; keep both paths until the
old compatibility surface has no callers.
"""
auth_ref = connection.metadata.get("auth_ref")
if isinstance(auth_ref, str):
return self.load_auth(auth_ref)
return self.load_auth(connection.id)
def tool_executor_for(self, connection: ConnectionConfig) -> ToolExecutor: def tool_executor_for(self, connection: ConnectionConfig) -> ToolExecutor:
"""Return the executor used by generated workflow NodeSpecs. """Return the executor used by generated workflow NodeSpecs.
@@ -81,7 +94,7 @@ class UpstreamTransportService:
uri: str, uri: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
adapter = require_adapter(connection, self.adapters) adapter = require_adapter(connection, self.adapters)
auth = self.load_auth(connection.id) auth = self.load_connection_auth(connection)
self.event_sink( self.event_sink(
make_event( make_event(
"resource_read_started", "resource_read_started",
@@ -109,7 +122,7 @@ class UpstreamTransportService:
arguments: dict[str, str] | None = None, arguments: dict[str, str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
adapter = require_adapter(connection, self.adapters) adapter = require_adapter(connection, self.adapters)
auth = self.load_auth(connection.id) auth = self.load_connection_auth(connection)
self.event_sink( self.event_sink(
make_event( make_event(
"prompt_get_started", "prompt_get_started",
@@ -137,7 +150,7 @@ class UpstreamTransportService:
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
adapter = require_adapter(connection, self.adapters) adapter = require_adapter(connection, self.adapters)
auth = self.load_auth(connection.id) auth = self.load_connection_auth(connection)
self.event_sink( self.event_sink(
make_event( make_event(
"raw_method_started", "raw_method_started",
@@ -165,7 +178,7 @@ class UpstreamTransportService:
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
) -> None: ) -> None:
adapter = require_adapter(connection, self.adapters) adapter = require_adapter(connection, self.adapters)
auth = self.load_auth(connection.id) auth = self.load_connection_auth(connection)
self.event_sink( self.event_sink(
make_event( make_event(
"raw_notification_started", "raw_notification_started",
@@ -193,7 +206,7 @@ class UpstreamTransportService:
default_catalog_max_age_seconds: int = 300, default_catalog_max_age_seconds: int = 300,
record_catalog_change_events: Callable[[str, CatalogSnapshot, str], None], record_catalog_change_events: Callable[[str, CatalogSnapshot, str], None],
) -> None: ) -> None:
auth = self.load_auth(connection.id) auth = self.load_connection_auth(connection)
self.event_sink( self.event_sink(
make_event( make_event(
"catalog_refresh_started", "catalog_refresh_started",
@@ -295,7 +308,7 @@ class UpstreamTransportService:
continue continue
try: try:
adapter = require_adapter(connection, self.adapters) adapter = require_adapter(connection, self.adapters)
auth = self.load_auth(source_id) auth = self.load_connection_auth(connection)
await asyncio.wait_for( await asyncio.wait_for(
adapter.list_tools(connection, auth), adapter.list_tools(connection, auth),
timeout=LIVE_SOURCE_CHECK_TIMEOUT_SECONDS, timeout=LIVE_SOURCE_CHECK_TIMEOUT_SECONDS,
+5 -15
View File
@@ -10,20 +10,11 @@ from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamable_http_client from mcp.client.streamable_http import streamable_http_client
from mcp.types import CallToolResult from mcp.types import CallToolResult
from ..auth import mcp_auth_env, mcp_auth_headers
from ..models import AuthRecord, ConnectionConfig from ..models import AuthRecord, ConnectionConfig
from .session import PersistentMcpSession from .session import PersistentMcpSession
def _auth_headers(auth: AuthRecord | None) -> dict[str, str]:
if auth is None:
return {}
headers = dict(auth.payload.get("headers", {}))
token = auth.payload.get("token")
if isinstance(token, str) and "Authorization" not in headers:
headers["Authorization"] = f"Bearer {token}"
return headers
@dataclass(slots=True) @dataclass(slots=True)
class PersistentSessionFactory: class PersistentSessionFactory:
"""Create initialized persistent MCP sessions for configured connections. """Create initialized persistent MCP sessions for configured connections.
@@ -57,10 +48,9 @@ class PersistentSessionFactory:
transport = connection.metadata.get("transport", "stdio") transport = connection.metadata.get("transport", "stdio")
if transport == "stdio": if transport == "stdio":
env = connection.metadata.get("env") env = connection.metadata.get("env")
if auth is not None: auth_env = mcp_auth_env(auth)
auth_env = auth.payload.get("env") if auth_env:
if isinstance(auth_env, dict): env = {**(env or {}), **auth_env}
env = {**(env or {}), **auth_env}
params = StdioServerParameters( params = StdioServerParameters(
command=connection.metadata["command"], command=connection.metadata["command"],
args=list(connection.metadata.get("args", [])), args=list(connection.metadata.get("args", [])),
@@ -78,7 +68,7 @@ class PersistentSessionFactory:
if transport == "streamable_http": if transport == "streamable_http":
http_client = await stack.enter_async_context( http_client = await stack.enter_async_context(
httpx.AsyncClient(headers=_auth_headers(auth) or None) httpx.AsyncClient(headers=mcp_auth_headers(auth) or None)
) )
( (
read_stream, read_stream,
+5 -15
View File
@@ -17,6 +17,7 @@ from mcp.types import (
) )
from pydantic import AnyUrl from pydantic import AnyUrl
from ..auth import mcp_auth_env, mcp_auth_headers
from ..capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool from ..capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
from ..models import AuthRecord, ConnectionConfig from ..models import AuthRecord, ConnectionConfig
from .base import BackendAdapter, ToolCallResult from .base import BackendAdapter, ToolCallResult
@@ -28,16 +29,6 @@ from .converters import (
) )
def _auth_headers(auth: AuthRecord | None) -> dict[str, str]:
if auth is None:
return {}
headers = dict(auth.payload.get("headers", {}))
token = auth.payload.get("token")
if isinstance(token, str) and "Authorization" not in headers:
headers["Authorization"] = f"Bearer {token}"
return headers
class McpSdkAdapter(BackendAdapter): class McpSdkAdapter(BackendAdapter):
@asynccontextmanager @asynccontextmanager
async def _session( async def _session(
@@ -51,10 +42,9 @@ class McpSdkAdapter(BackendAdapter):
args = list(connection.metadata.get("args", [])) args = list(connection.metadata.get("args", []))
env = connection.metadata.get("env") env = connection.metadata.get("env")
cwd = connection.metadata.get("cwd") cwd = connection.metadata.get("cwd")
if auth is not None: auth_env = mcp_auth_env(auth)
auth_env = auth.payload.get("env") if auth_env:
if isinstance(auth_env, dict): env = {**(env or {}), **auth_env}
env = {**(env or {}), **auth_env}
params = StdioServerParameters( params = StdioServerParameters(
command=command, command=command,
args=args, args=args,
@@ -69,7 +59,7 @@ class McpSdkAdapter(BackendAdapter):
if transport == "streamable_http": if transport == "streamable_http":
url = connection.metadata["url"] url = connection.metadata["url"]
headers = _auth_headers(auth) headers = mcp_auth_headers(auth)
http_client = httpx.AsyncClient(headers=headers or None) http_client = httpx.AsyncClient(headers=headers or None)
async with http_client: async with http_client:
async with streamable_http_client( async with streamable_http_client(
+22
View File
@@ -3,6 +3,9 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
from wf_api.auth import AuthRecord as NeutralAuthRecord
from ..auth import mcp_auth_from_neutral, neutral_auth_from_mcp
from ..connections import parse_connection_id from ..connections import parse_connection_id
from ..models import ( from ..models import (
AuthRecord, AuthRecord,
@@ -21,6 +24,12 @@ class Store:
def load_auth(self, connection_id: str) -> AuthRecord | None: def load_auth(self, connection_id: str) -> AuthRecord | None:
raise NotImplementedError raise NotImplementedError
def save_auth_record(self, record: NeutralAuthRecord) -> None:
raise NotImplementedError
def load_auth_record(self, auth_ref: str) -> NeutralAuthRecord | None:
raise NotImplementedError
def save_catalog(self, snapshot: CatalogSnapshot) -> None: def save_catalog(self, snapshot: CatalogSnapshot) -> None:
raise NotImplementedError raise NotImplementedError
@@ -81,6 +90,19 @@ class FileStore(Store):
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
return AuthRecord(**data) return AuthRecord(**data)
def save_auth_record(self, record: NeutralAuthRecord) -> None:
"""Save neutral auth through the legacy MCP file shape."""
self.save_auth(mcp_auth_from_neutral(record))
def load_auth_record(self, auth_ref: str) -> NeutralAuthRecord | None:
"""Load neutral auth from the legacy MCP file shape."""
record = self.load_auth(auth_ref)
if record is None:
return None
return neutral_auth_from_mcp(record)
def save_catalog(self, snapshot: CatalogSnapshot) -> None: def save_catalog(self, snapshot: CatalogSnapshot) -> None:
self._catalog_path(snapshot.connection_id).write_text( self._catalog_path(snapshot.connection_id).write_text(
json.dumps(dump_catalog_snapshot(snapshot), indent=2), json.dumps(dump_catalog_snapshot(snapshot), indent=2),
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import assert_type
import pytest
from wf_api.auth import AuthRecord, AuthStore, validate_auth_id
def test_validate_auth_id_accepts_safe_dotted_ids() -> None:
assert validate_auth_id("github.work") == "github.work"
assert validate_auth_id("api_ci-1") == "api_ci-1"
@pytest.mark.parametrize("auth_id", ["", ".hidden", "../secret", "bad/id"])
def test_validate_auth_id_rejects_unsafe_ids(auth_id: str) -> None:
with pytest.raises(ValueError, match="auth id must start"):
validate_auth_id(auth_id)
def test_auth_record_is_immutable_and_mapping_typed() -> None:
record = AuthRecord(
id="github.work",
scheme="bearer",
payload={"token": "secret"},
metadata={"owner": "test"},
)
assert record.id == "github.work"
assert record.scheme == "bearer"
assert record.payload["token"] == "secret"
assert_type(record.payload, Mapping[str, object])
with pytest.raises(AttributeError):
record.scheme = "headers" # type: ignore[misc]
class MemoryAuthStore:
def __init__(self, records: dict[str, AuthRecord]) -> None:
self.records = records
def load_auth(self, auth_ref: str) -> AuthRecord | None:
return self.records.get(auth_ref)
def test_auth_store_protocol_is_read_only_lookup() -> None:
record = AuthRecord(id="github.work", scheme="opaque", payload={"x": 1})
store: AuthStore = MemoryAuthStore({"github.work": record})
assert store.load_auth("github.work") is record
assert store.load_auth("missing") is None
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from wf_artifacts import WorkflowDeployment from wf_artifacts import WorkflowDeployment
from wf_platform import CapabilityBuckets, CapabilitySource, SourcePermissions from wf_platform import CapabilityBuckets, CapabilitySource, SourcePermissions
@@ -16,6 +18,14 @@ from ..test_support import FakeAdapter, local_temp_root
from ..workflow_surface.conftest import echo_artifact from ..workflow_surface.conftest import echo_artifact
def _transport(root: Path) -> UpstreamTransportService:
events: list[McpEvent] = []
return UpstreamTransportService(
store=FileStore(root),
event_sink=events.append,
)
def test_upstream_transport_registers_adapter() -> None: def test_upstream_transport_registers_adapter() -> None:
events: list[McpEvent] = [] events: list[McpEvent] = []
transport = UpstreamTransportService( transport = UpstreamTransportService(
@@ -98,7 +108,7 @@ async def test_upstream_transport_refreshes_catalog_directly() -> None:
connection_list_enabled=connections.list_enabled, connection_list_enabled=connections.list_enabled,
connection_list_all=connections.list_all, connection_list_all=connections.list_all,
tool_executor_for=transport.tool_executor_for, tool_executor_for=transport.tool_executor_for,
load_auth=transport.load_auth, load_auth=transport.load_connection_auth,
emit_event=events.append, emit_event=events.append,
) )
source_catalog.hydrate_connection_source_from_snapshot(connection) source_catalog.hydrate_connection_source_from_snapshot(connection)
@@ -131,7 +141,7 @@ async def test_upstream_transport_live_diagnostics_report_missing_connection() -
connection_list_enabled=lambda: [], connection_list_enabled=lambda: [],
connection_list_all=lambda: [], connection_list_all=lambda: [],
tool_executor_for=transport.tool_executor_for, tool_executor_for=transport.tool_executor_for,
load_auth=transport.load_auth, load_auth=transport.load_connection_auth,
emit_event=lambda event: None, emit_event=lambda event: None,
) )
source_catalog.register_capability_source( source_catalog.register_capability_source(
@@ -158,3 +168,82 @@ async def test_upstream_transport_live_diagnostics_report_missing_connection() -
assert diagnostics[0].code == "source_unreachable" assert diagnostics[0].code == "source_unreachable"
assert diagnostics[0].bound_source == "demo.personal" assert diagnostics[0].bound_source == "demo.personal"
def test_upstream_load_connection_auth_prefers_auth_ref(tmp_path: Path) -> None:
service = _transport(tmp_path)
service.save_auth(
AuthRecord(
connection_id="github.creds",
scheme="bearer",
payload={"token": "secret"},
)
)
service.save_auth(
AuthRecord(
connection_id="github.work",
scheme="bearer",
payload={"token": "wrong"},
)
)
connection = ConnectionConfig(
id="github.work",
server="github",
account="work",
metadata={"auth_ref": "github.creds"},
)
assert service.load_connection_auth(connection) == AuthRecord(
connection_id="github.creds",
scheme="bearer",
payload={"token": "secret"},
)
def test_upstream_load_connection_auth_falls_back_to_connection_id(
tmp_path: Path,
) -> None:
service = _transport(tmp_path)
service.save_auth(
AuthRecord(
connection_id="github.work",
scheme="bearer",
payload={"token": "legacy"},
)
)
connection = ConnectionConfig(
id="github.work",
server="github",
account="work",
)
assert service.load_connection_auth(connection) == AuthRecord(
connection_id="github.work",
scheme="bearer",
payload={"token": "legacy"},
)
def test_upstream_load_connection_auth_ignores_non_string_auth_ref(
tmp_path: Path,
) -> None:
service = _transport(tmp_path)
service.save_auth(
AuthRecord(
connection_id="github.work",
scheme="bearer",
payload={"token": "legacy"},
)
)
connection = ConnectionConfig(
id="github.work",
server="github",
account="work",
metadata={"auth_ref": 123},
)
assert service.load_connection_auth(connection) == AuthRecord(
connection_id="github.work",
scheme="bearer",
payload={"token": "legacy"},
)
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
from pathlib import Path
from wf_api.auth import AuthRecord as NeutralAuthRecord
from wf_mcp.auth import (
mcp_auth_env,
mcp_auth_headers,
mcp_auth_from_neutral,
neutral_auth_from_mcp,
)
from wf_mcp.models import AuthRecord as McpAuthRecord
from wf_mcp.storage import FileStore
def test_mcp_auth_from_neutral_preserves_scheme_and_payload() -> None:
neutral = NeutralAuthRecord(
id="github.work",
scheme="bearer",
payload={"token": "secret"},
metadata={"owner": "test"},
)
mcp = mcp_auth_from_neutral(neutral)
assert mcp == McpAuthRecord(
connection_id="github.work",
scheme="bearer",
payload={"token": "secret"},
)
def test_neutral_auth_from_mcp_preserves_payload() -> None:
mcp = McpAuthRecord(
connection_id="github.work",
scheme="headers",
payload={"headers": {"X-Test": "yes"}},
)
neutral = neutral_auth_from_mcp(mcp)
assert neutral.id == "github.work"
assert neutral.scheme == "headers"
assert neutral.payload == {"headers": {"X-Test": "yes"}}
def test_mcp_auth_headers_extracts_explicit_headers_and_bearer_token() -> None:
auth = McpAuthRecord(
connection_id="api.work",
scheme="bearer",
payload={"headers": {"X-Test": "yes"}, "token": "secret"},
)
assert mcp_auth_headers(auth) == {
"X-Test": "yes",
"Authorization": "Bearer secret",
}
def test_mcp_auth_headers_does_not_override_authorization_header() -> None:
auth = McpAuthRecord(
connection_id="api.work",
scheme="bearer",
payload={
"headers": {"Authorization": "Basic already"},
"token": "secret",
},
)
assert mcp_auth_headers(auth) == {"Authorization": "Basic already"}
def test_mcp_auth_env_returns_string_map_only() -> None:
auth = McpAuthRecord(
connection_id="mcp.local",
scheme="env",
payload={"env": {"TOKEN": "secret", "BAD": 123}},
)
assert mcp_auth_env(auth) == {"TOKEN": "secret"}
def test_file_store_saves_and_loads_neutral_auth_record(tmp_path: Path) -> None:
store = FileStore(tmp_path)
record = NeutralAuthRecord(
id="github.work",
scheme="bearer",
payload={"token": "secret"},
metadata={"owner": "test"},
)
store.save_auth_record(record)
loaded = store.load_auth_record("github.work")
assert loaded is not None
assert loaded.id == record.id
assert loaded.scheme == record.scheme
assert loaded.payload == record.payload
# Legacy file format does not persist metadata
assert loaded.metadata == {}
def test_file_store_legacy_auth_methods_still_work(tmp_path: Path) -> None:
store = FileStore(tmp_path)
legacy = McpAuthRecord(
connection_id="github.work",
scheme="bearer",
payload={"token": "secret"},
)
store.save_auth(legacy)
assert store.load_auth("github.work") == legacy
assert store.load_auth_record("github.work") == NeutralAuthRecord(
id="github.work",
scheme="bearer",
payload={"token": "secret"},
)