Pre-launch hardening for the hosted Astrolabe Cloud offering. Addresses all five findings raised in #626 (Tim Kaufmann, code review of v0.65.0). Re-verified against master before fixing. Finding 3 (LLM-controllable user_id) — drop user_id from the public signatures of provision_nextcloud_access, revoke_nextcloud_access, check_provisioning_status, check_logged_in. Tool wrappers now always derive identity from the verified AccessToken; user_id is no longer accepted as MCP input. Adds parameterized CI-guard test that locks the schema. Finding 2 (predictable session cookie) — replace mcp_session=<user_id> cookie with a cryptographically random session_id mapped server-side (new browser_sessions table, alembic 005). Cookie value is opaque, expires, revocable. SessionAuthBackend looks up user_id via the new mapping and additionally requires a refresh token to fail closed. Finding 4 (logout doesn't revoke refresh token) — oauth_logout now calls the IdP revocation_endpoint (RFC 7009) when advertised, deletes the stored refresh token regardless, and clears the browser_sessions row. Cleanup is best-effort: logout always 302s. Finding 1 (unverified ID token decodes) — verify_id_token helper does JWKS signature + issuer + audience + exp + nonce checks per OIDC core 3.1.3.7. Used by both OAuth callback handlers (browser + MCP). Removes the four "verify_signature: False" decodes that previously trusted IdP claims unconditionally. Drops dead-code _validate_token_audience in token_broker. Refactors token_utils + provisioning_decorator to read user_id from the verified AccessToken instead of re-decoding the JWT. Finding 5 (hardcoded Fernet keys in docker-compose.yml) — replace the three inline TOKEN_ENCRYPTION_KEY values with required env var interpolation; document in env.sample. Test coverage: 4 new unit test modules (signature pinning, browser sessions, ID-token verification, logout + revoke + session backend). 693 unit tests pass; ruff/format/ty clean. Migration note: existing browser admin-UI sessions become invalid on rollout (cookies are looked up against the new browser_sessions table, which starts empty). Users re-login. MCP API access is unaffected. Tracked on Astrolabe Cloud POC board card #37. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
57 lines
1.9 KiB
Python
57 lines
1.9 KiB
Python
"""Unit tests for OAuth tool input-schema hardening (issue #626 finding 3).
|
|
|
|
These tools must derive `user_id` from the verified MCP access token and
|
|
must never accept it as an MCP-level input. Otherwise an LLM (or any MCP
|
|
client) could supply an arbitrary user_id and reach cross-user revoke or
|
|
status-disclosure operations.
|
|
"""
|
|
|
|
import pytest
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
from nextcloud_mcp_server.server.oauth_tools import register_oauth_tools
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
HARDENED_TOOLS = (
|
|
"provision_nextcloud_access",
|
|
"revoke_nextcloud_access",
|
|
"check_provisioning_status",
|
|
"check_logged_in",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def registered_tools():
|
|
"""Register the OAuth tools against a fresh FastMCP and return them by name.
|
|
|
|
Uses FastMCP's `_tool_manager.list_tools()`; flagged as internal and may
|
|
break on SDK upgrades, but this is the supported way to inspect a tool's
|
|
JSON input schema in unit tests (see tests/unit/test_stdio.py).
|
|
"""
|
|
mcp = FastMCP("test-oauth-tools")
|
|
register_oauth_tools(mcp)
|
|
tools = mcp._tool_manager.list_tools()
|
|
return {t.name: t for t in tools}
|
|
|
|
|
|
def test_oauth_tools_registered(registered_tools):
|
|
for name in HARDENED_TOOLS:
|
|
assert name in registered_tools, f"{name} should be registered"
|
|
|
|
|
|
@pytest.mark.parametrize("tool_name", HARDENED_TOOLS)
|
|
def test_oauth_tool_schema_does_not_accept_user_id(tool_name, registered_tools):
|
|
"""user_id must not appear in the tool's JSON input schema."""
|
|
tool = registered_tools[tool_name]
|
|
properties = tool.parameters.get("properties", {})
|
|
required = tool.parameters.get("required", [])
|
|
|
|
assert "user_id" not in properties, (
|
|
f"{tool_name} accepts user_id as an MCP input — must be derived from "
|
|
f"the verified access token (issue #626 finding 3). "
|
|
f"properties={list(properties.keys())}"
|
|
)
|
|
assert "user_id" not in required
|