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>
76 lines
2.5 KiB
Python
76 lines
2.5 KiB
Python
"""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"
|