fix(auth): harden OAuth/session for hosted multi-tenant deployment (#626)
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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
83f2e88d2c
commit
15dbb26349
@@ -0,0 +1,75 @@
|
||||
"""Unit tests for browser_sessions storage (issue #626 finding 2).
|
||||
|
||||
The browser admin UI no longer uses the raw user_id as the cookie value
|
||||
— it uses a cryptographically random session_id mapped server-side to
|
||||
user_id. These tests pin the storage contract.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def storage():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_browser_sessions.db"
|
||||
s = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=Fernet.generate_key().decode()
|
||||
)
|
||||
await s.initialize()
|
||||
yield s
|
||||
|
||||
|
||||
async def test_create_and_get_browser_session(storage):
|
||||
sid = secrets.token_urlsafe(32)
|
||||
await storage.create_browser_session(session_id=sid, user_id="alice")
|
||||
|
||||
user_id = await storage.get_browser_session_user(sid)
|
||||
assert user_id == "alice"
|
||||
|
||||
|
||||
async def test_get_browser_session_unknown_returns_none(storage):
|
||||
assert await storage.get_browser_session_user("does-not-exist") is None
|
||||
|
||||
|
||||
async def test_delete_browser_session(storage):
|
||||
sid = secrets.token_urlsafe(32)
|
||||
await storage.create_browser_session(session_id=sid, user_id="alice")
|
||||
|
||||
deleted = await storage.delete_browser_session(sid)
|
||||
assert deleted is True
|
||||
assert await storage.get_browser_session_user(sid) is None
|
||||
|
||||
|
||||
async def test_expired_browser_session_rejected_and_deleted(storage):
|
||||
sid = secrets.token_urlsafe(32)
|
||||
# ttl_seconds=0 so the row is immediately expired (now == expires_at)
|
||||
await storage.create_browser_session(session_id=sid, user_id="alice", ttl_seconds=0)
|
||||
# Make sure clock advances past expires_at
|
||||
time.sleep(0.01)
|
||||
|
||||
assert await storage.get_browser_session_user(sid) is None
|
||||
# Expired row should be deleted on encounter
|
||||
assert await storage.delete_browser_session(sid) is False
|
||||
|
||||
|
||||
async def test_replace_existing_session_id(storage):
|
||||
"""INSERT OR REPLACE so re-using a session_id rebinds the user.
|
||||
|
||||
Not a recommended call pattern (session_ids are random), but the
|
||||
storage layer must not raise UNIQUE constraint errors if it happens.
|
||||
"""
|
||||
sid = secrets.token_urlsafe(32)
|
||||
await storage.create_browser_session(session_id=sid, user_id="alice")
|
||||
await storage.create_browser_session(session_id=sid, user_id="bob")
|
||||
|
||||
assert await storage.get_browser_session_user(sid) == "bob"
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Unit tests for OIDC ID token verification (issue #626 finding 1).
|
||||
|
||||
The OAuth callback handlers used to call
|
||||
`jwt.decode(id_token, options={"verify_signature": False})` and trust the
|
||||
result. They now go through `verify_id_token`, which checks signature
|
||||
against JWKS and validates issuer / audience / exp / nonce per OIDC core
|
||||
spec §3.1.3.7.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from base64 import urlsafe_b64encode
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from nextcloud_mcp_server.auth.token_utils import (
|
||||
IdTokenVerificationError,
|
||||
verify_id_token,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
# Generated once per process — RSA keypair generation is slow.
|
||||
_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
_PRIVATE_PEM = _KEY.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
_OTHER_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
_OTHER_PRIVATE_PEM = _OTHER_KEY.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
|
||||
ISSUER = "https://idp.example.com"
|
||||
DISCOVERY_URL = f"{ISSUER}/.well-known/openid-configuration"
|
||||
JWKS_URI = f"{ISSUER}/jwks"
|
||||
|
||||
|
||||
def _b64u_uint(n: int) -> str:
|
||||
raw = n.to_bytes((n.bit_length() + 7) // 8, "big")
|
||||
return urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _build_jwks() -> dict:
|
||||
pub = _KEY.public_key().public_numbers()
|
||||
return {
|
||||
"keys": [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"kid": "test-key-1",
|
||||
"alg": "RS256",
|
||||
"n": _b64u_uint(pub.n),
|
||||
"e": _b64u_uint(pub.e),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _sign(
|
||||
claims: dict, *, kid: str = "test-key-1", key_pem: bytes = _PRIVATE_PEM
|
||||
) -> str:
|
||||
return jwt.encode(claims, key_pem, algorithm="RS256", headers={"kid": kid})
|
||||
|
||||
|
||||
def _idp_handler(request: httpx.Request) -> httpx.Response:
|
||||
if str(request.url) == DISCOVERY_URL:
|
||||
return httpx.Response(200, json={"issuer": ISSUER, "jwks_uri": JWKS_URI})
|
||||
if str(request.url) == JWKS_URI:
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=json.dumps(_build_jwks()).encode(),
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
return httpx.Response(404)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_idp():
|
||||
"""Patch nextcloud_httpx_client used inside token_utils.verify_id_token."""
|
||||
transport = httpx.MockTransport(_idp_handler)
|
||||
|
||||
def fake_client(**kwargs):
|
||||
kwargs["transport"] = transport
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
|
||||
side_effect=fake_client,
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
async def test_verify_id_token_accepts_valid_token(mock_idp):
|
||||
now = int(time.time())
|
||||
token = _sign(
|
||||
{
|
||||
"iss": ISSUER,
|
||||
"aud": "test-client",
|
||||
"sub": "alice",
|
||||
"iat": now,
|
||||
"exp": now + 60,
|
||||
}
|
||||
)
|
||||
payload = await verify_id_token(
|
||||
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
|
||||
)
|
||||
assert payload["sub"] == "alice"
|
||||
|
||||
|
||||
async def test_verify_id_token_rejects_wrong_audience(mock_idp):
|
||||
now = int(time.time())
|
||||
token = _sign(
|
||||
{
|
||||
"iss": ISSUER,
|
||||
"aud": "other-client",
|
||||
"sub": "alice",
|
||||
"iat": now,
|
||||
"exp": now + 60,
|
||||
}
|
||||
)
|
||||
with pytest.raises(IdTokenVerificationError):
|
||||
await verify_id_token(
|
||||
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
|
||||
)
|
||||
|
||||
|
||||
async def test_verify_id_token_rejects_expired_token(mock_idp):
|
||||
now = int(time.time())
|
||||
token = _sign(
|
||||
{
|
||||
"iss": ISSUER,
|
||||
"aud": "test-client",
|
||||
"sub": "alice",
|
||||
"iat": now - 120,
|
||||
"exp": now - 60,
|
||||
}
|
||||
)
|
||||
with pytest.raises(IdTokenVerificationError):
|
||||
await verify_id_token(
|
||||
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
|
||||
)
|
||||
|
||||
|
||||
async def test_verify_id_token_rejects_wrong_issuer(mock_idp):
|
||||
now = int(time.time())
|
||||
token = _sign(
|
||||
{
|
||||
"iss": "https://evil.example.com",
|
||||
"aud": "test-client",
|
||||
"sub": "alice",
|
||||
"iat": now,
|
||||
"exp": now + 60,
|
||||
}
|
||||
)
|
||||
with pytest.raises(IdTokenVerificationError):
|
||||
await verify_id_token(
|
||||
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
|
||||
)
|
||||
|
||||
|
||||
async def test_verify_id_token_rejects_wrong_signature(mock_idp):
|
||||
"""Token signed with a different key but matching kid header must fail."""
|
||||
now = int(time.time())
|
||||
forged = _sign(
|
||||
{
|
||||
"iss": ISSUER,
|
||||
"aud": "test-client",
|
||||
"sub": "alice",
|
||||
"iat": now,
|
||||
"exp": now + 60,
|
||||
},
|
||||
key_pem=_OTHER_PRIVATE_PEM,
|
||||
)
|
||||
with pytest.raises(IdTokenVerificationError):
|
||||
await verify_id_token(
|
||||
forged, discovery_url=DISCOVERY_URL, expected_audience="test-client"
|
||||
)
|
||||
|
||||
|
||||
async def test_verify_id_token_rejects_unknown_kid(mock_idp):
|
||||
now = int(time.time())
|
||||
token = _sign(
|
||||
{
|
||||
"iss": ISSUER,
|
||||
"aud": "test-client",
|
||||
"sub": "alice",
|
||||
"iat": now,
|
||||
"exp": now + 60,
|
||||
},
|
||||
kid="not-in-jwks",
|
||||
)
|
||||
with pytest.raises(IdTokenVerificationError, match="No JWKS key matches"):
|
||||
await verify_id_token(
|
||||
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
|
||||
)
|
||||
|
||||
|
||||
async def test_verify_id_token_nonce_mismatch_rejected(mock_idp):
|
||||
now = int(time.time())
|
||||
token = _sign(
|
||||
{
|
||||
"iss": ISSUER,
|
||||
"aud": "test-client",
|
||||
"sub": "alice",
|
||||
"iat": now,
|
||||
"exp": now + 60,
|
||||
"nonce": "actual",
|
||||
}
|
||||
)
|
||||
with pytest.raises(IdTokenVerificationError, match="nonce"):
|
||||
await verify_id_token(
|
||||
token,
|
||||
discovery_url=DISCOVERY_URL,
|
||||
expected_audience="test-client",
|
||||
expected_nonce="expected",
|
||||
)
|
||||
|
||||
|
||||
async def test_verify_id_token_missing_token_rejected():
|
||||
with pytest.raises(IdTokenVerificationError, match="missing"):
|
||||
await verify_id_token(
|
||||
"", discovery_url=DISCOVERY_URL, expected_audience="test-client"
|
||||
)
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Unit tests for OAuth logout (issue #626 finding 4) and the
|
||||
SessionAuthBackend (finding 2).
|
||||
|
||||
These cover the new server-side session lifecycle:
|
||||
- logout deletes refresh token + browser session
|
||||
- logout calls IdP revocation_endpoint when available
|
||||
- logout still succeeds when IdP/storage errors
|
||||
- SessionAuthBackend resolves random session_id -> user_id, fails
|
||||
closed when the session is unknown / expired / has no refresh token
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
||||
_revoke_refresh_token_at_idp,
|
||||
oauth_logout,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# storage fixture (real SQLite backend; lighter than mocking every call)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def storage():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_logout.db"
|
||||
s = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=Fernet.generate_key().decode()
|
||||
)
|
||||
await s.initialize()
|
||||
yield s
|
||||
|
||||
|
||||
def _build_request(*, cookie: str | None, oauth_context: dict | None):
|
||||
"""Build a minimal Starlette-style request stub for oauth_logout."""
|
||||
request = MagicMock()
|
||||
request.query_params = {}
|
||||
request.cookies = {"mcp_session": cookie} if cookie else {}
|
||||
request.app.state.oauth_context = oauth_context
|
||||
return request
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# oauth_logout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_logout_deletes_refresh_token_and_session(storage):
|
||||
"""Happy path: logout removes the refresh token and the browser session."""
|
||||
await storage.create_browser_session(session_id="sid-1", user_id="alice")
|
||||
await storage.store_refresh_token(
|
||||
user_id="alice", refresh_token="rt-abc", flow_type="browser"
|
||||
)
|
||||
|
||||
request = _build_request(
|
||||
cookie="sid-1",
|
||||
oauth_context={"storage": storage, "discovery_url": None},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.browser_oauth_routes._revoke_refresh_token_at_idp",
|
||||
new=AsyncMock(),
|
||||
):
|
||||
response = await oauth_logout(request)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert await storage.get_refresh_token("alice") is None
|
||||
assert await storage.get_browser_session_user("sid-1") is None
|
||||
|
||||
|
||||
async def test_logout_calls_revocation_when_refresh_token_present(storage):
|
||||
"""The IdP revocation helper is called with the stored refresh token."""
|
||||
await storage.create_browser_session(session_id="sid-2", user_id="bob")
|
||||
await storage.store_refresh_token(
|
||||
user_id="bob", refresh_token="rt-xyz", flow_type="browser"
|
||||
)
|
||||
|
||||
revoke = AsyncMock()
|
||||
request = _build_request(
|
||||
cookie="sid-2",
|
||||
oauth_context={"storage": storage, "discovery_url": "http://idp/.well-known"},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.browser_oauth_routes._revoke_refresh_token_at_idp",
|
||||
new=revoke,
|
||||
):
|
||||
await oauth_logout(request)
|
||||
|
||||
revoke.assert_awaited_once()
|
||||
args = revoke.await_args.args
|
||||
# Second arg is the refresh token string
|
||||
assert args[1] == "rt-xyz"
|
||||
|
||||
|
||||
async def test_logout_no_session_cookie_returns_302(storage):
|
||||
"""Without a cookie, logout still 302s and doesn't touch storage."""
|
||||
request = _build_request(
|
||||
cookie=None, oauth_context={"storage": storage, "discovery_url": None}
|
||||
)
|
||||
response = await oauth_logout(request)
|
||||
assert response.status_code == 302
|
||||
|
||||
|
||||
async def test_logout_swallows_storage_errors(storage):
|
||||
"""Logout is best-effort — a storage failure must not 500 the response."""
|
||||
await storage.create_browser_session(session_id="sid-3", user_id="carol")
|
||||
broken_storage = MagicMock()
|
||||
broken_storage.get_browser_session_user = AsyncMock(
|
||||
side_effect=RuntimeError("db down")
|
||||
)
|
||||
broken_storage.delete_browser_session = AsyncMock()
|
||||
|
||||
request = _build_request(
|
||||
cookie="sid-3",
|
||||
oauth_context={"storage": broken_storage, "discovery_url": None},
|
||||
)
|
||||
response = await oauth_logout(request)
|
||||
assert response.status_code == 302 # logout still succeeds
|
||||
|
||||
|
||||
async def test_logout_handles_session_with_no_refresh_token(storage):
|
||||
"""Cookie + session row exist but refresh token already gone — logout is idempotent."""
|
||||
await storage.create_browser_session(session_id="sid-4", user_id="dave")
|
||||
|
||||
revoke = AsyncMock()
|
||||
request = _build_request(
|
||||
cookie="sid-4",
|
||||
oauth_context={"storage": storage, "discovery_url": None},
|
||||
)
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.browser_oauth_routes._revoke_refresh_token_at_idp",
|
||||
new=revoke,
|
||||
):
|
||||
await oauth_logout(request)
|
||||
|
||||
# Revoke not called — no token to revoke
|
||||
revoke.assert_not_called()
|
||||
# Browser session still cleared
|
||||
assert await storage.get_browser_session_user("sid-4") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _revoke_refresh_token_at_idp
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _httpx_handler(routes: dict[str, httpx.Response]):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return routes.get(str(request.url), httpx.Response(404))
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
async def test_revoke_helper_posts_to_revocation_endpoint():
|
||||
discovery_url = "http://idp.example/.well-known"
|
||||
revocation_url = "http://idp.example/revoke"
|
||||
|
||||
received: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if str(request.url) == discovery_url:
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=json.dumps({"revocation_endpoint": revocation_url}).encode(),
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
if str(request.url) == revocation_url:
|
||||
received.append(request)
|
||||
return httpx.Response(200)
|
||||
return httpx.Response(404)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
|
||||
def fake_client(**kwargs):
|
||||
kwargs["transport"] = transport
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
|
||||
side_effect=fake_client,
|
||||
):
|
||||
await _revoke_refresh_token_at_idp(
|
||||
{
|
||||
"discovery_url": discovery_url,
|
||||
"client_id": "test-client",
|
||||
"client_secret": "test-secret",
|
||||
},
|
||||
"rt-secret",
|
||||
)
|
||||
|
||||
assert len(received) == 1
|
||||
body = received[0].content.decode()
|
||||
assert "token=rt-secret" in body
|
||||
assert "token_type_hint=refresh_token" in body
|
||||
|
||||
|
||||
async def test_revoke_helper_skips_when_no_revocation_endpoint():
|
||||
"""IdPs without a revocation_endpoint advertised: helper must no-op silently."""
|
||||
discovery_url = "http://idp.example/.well-known"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if str(request.url) == discovery_url:
|
||||
return httpx.Response(200, json={}) # no revocation_endpoint
|
||||
return httpx.Response(404)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
|
||||
def fake_client(**kwargs):
|
||||
kwargs["transport"] = transport
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
|
||||
side_effect=fake_client,
|
||||
):
|
||||
# Returns None and does not raise
|
||||
result = await _revoke_refresh_token_at_idp(
|
||||
{
|
||||
"discovery_url": discovery_url,
|
||||
"client_id": "x",
|
||||
"client_secret": "y",
|
||||
},
|
||||
"rt",
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_revoke_helper_silent_on_idp_error():
|
||||
"""If the IdP 500s, the helper must not raise — caller treats it as best-effort."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(500, content=b"boom")
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
|
||||
def fake_client(**kwargs):
|
||||
kwargs["transport"] = transport
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
|
||||
side_effect=fake_client,
|
||||
):
|
||||
result = await _revoke_refresh_token_at_idp(
|
||||
{
|
||||
"discovery_url": "http://x/.well-known",
|
||||
"client_id": "x",
|
||||
"client_secret": "y",
|
||||
},
|
||||
"rt",
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionAuthBackend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_conn(*, cookie: str | None, oauth_context: dict | None):
|
||||
conn = MagicMock(spec=HTTPConnection)
|
||||
conn.cookies = {"mcp_session": cookie} if cookie else {}
|
||||
conn.url = SimpleNamespace(path="/app")
|
||||
conn.app = MagicMock()
|
||||
conn.app.state.oauth_context = oauth_context
|
||||
return conn
|
||||
|
||||
|
||||
async def test_session_backend_authenticates_known_session_with_token(storage):
|
||||
await storage.create_browser_session(session_id="sid-A", user_id="alice")
|
||||
await storage.store_refresh_token(
|
||||
user_id="alice", refresh_token="rt", flow_type="browser"
|
||||
)
|
||||
|
||||
backend = SessionAuthBackend(oauth_enabled=True)
|
||||
conn = _build_conn(cookie="sid-A", oauth_context={"storage": storage})
|
||||
|
||||
result = await backend.authenticate(conn)
|
||||
assert result is not None
|
||||
creds, user = result
|
||||
assert "authenticated" in creds.scopes
|
||||
assert user.username == "alice"
|
||||
|
||||
|
||||
async def test_session_backend_rejects_unknown_session(storage):
|
||||
backend = SessionAuthBackend(oauth_enabled=True)
|
||||
conn = _build_conn(cookie="not-a-real-sid", oauth_context={"storage": storage})
|
||||
assert await backend.authenticate(conn) is None
|
||||
|
||||
|
||||
async def test_session_backend_rejects_session_without_refresh_token(storage):
|
||||
"""Defense-in-depth: session row exists but user has no refresh token."""
|
||||
await storage.create_browser_session(session_id="sid-B", user_id="bob")
|
||||
# Note: NO refresh token stored for bob
|
||||
|
||||
backend = SessionAuthBackend(oauth_enabled=True)
|
||||
conn = _build_conn(cookie="sid-B", oauth_context={"storage": storage})
|
||||
assert await backend.authenticate(conn) is None
|
||||
|
||||
|
||||
async def test_session_backend_rejects_when_no_cookie(storage):
|
||||
backend = SessionAuthBackend(oauth_enabled=True)
|
||||
conn = _build_conn(cookie=None, oauth_context={"storage": storage})
|
||||
assert await backend.authenticate(conn) is None
|
||||
|
||||
|
||||
async def test_session_backend_basicauth_mode_short_circuits(monkeypatch, storage):
|
||||
"""In BasicAuth mode (oauth_enabled=False) the backend never touches storage."""
|
||||
monkeypatch.setenv("NEXTCLOUD_USERNAME", "admin-user")
|
||||
backend = SessionAuthBackend(oauth_enabled=False)
|
||||
conn = _build_conn(cookie=None, oauth_context=None)
|
||||
result = await backend.authenticate(conn)
|
||||
assert result is not None
|
||||
_, user = result
|
||||
assert user.username == "admin-user"
|
||||
@@ -0,0 +1,56 @@
|
||||
"""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
|
||||
@@ -10,7 +10,6 @@ from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
@@ -283,23 +282,6 @@ class TestTokenBrokerService:
|
||||
# Verify cache was cleared
|
||||
assert await token_broker.cache.get("user1") is None
|
||||
|
||||
async def test_validate_token_audience(self, token_broker):
|
||||
"""Test token audience validation."""
|
||||
# Create test token with audience
|
||||
test_payload = {
|
||||
"sub": "user1",
|
||||
"aud": ["nextcloud", "other-service"],
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
}
|
||||
test_token = jwt.encode(test_payload, "secret", algorithm="HS256")
|
||||
|
||||
# Should not raise for correct audience
|
||||
await token_broker._validate_token_audience(test_token, "nextcloud")
|
||||
|
||||
# Should raise for wrong audience
|
||||
with pytest.raises(ValueError, match="doesn't include wrong-audience"):
|
||||
await token_broker._validate_token_audience(test_token, "wrong-audience")
|
||||
|
||||
async def test_token_refresh_with_network_error(self, token_broker, mock_storage):
|
||||
"""Test handling network errors during token refresh."""
|
||||
# Storage returns already-decrypted refresh token
|
||||
|
||||
Reference in New Issue
Block a user