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>
99 lines
3.4 KiB
Python
99 lines
3.4 KiB
Python
"""Session-based authentication backend for Starlette routes.
|
|
|
|
Provides browser-based authentication for admin UI routes, separate from
|
|
MCP's OAuth authentication flow.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
|
|
from starlette.authentication import (
|
|
AuthCredentials,
|
|
AuthenticationBackend,
|
|
SimpleUser,
|
|
)
|
|
from starlette.requests import HTTPConnection
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SessionAuthBackend(AuthenticationBackend):
|
|
"""Authentication backend using signed session cookies.
|
|
|
|
For BasicAuth mode: Always authenticates as the configured user.
|
|
For OAuth mode: Checks for valid session cookie with stored refresh token.
|
|
"""
|
|
|
|
def __init__(self, oauth_enabled: bool = False):
|
|
"""Initialize session authentication backend.
|
|
|
|
Args:
|
|
oauth_enabled: Whether OAuth mode is enabled
|
|
"""
|
|
self.oauth_enabled = oauth_enabled
|
|
|
|
async def authenticate(
|
|
self, conn: HTTPConnection
|
|
) -> tuple[AuthCredentials, SimpleUser] | None:
|
|
"""Authenticate the request based on session cookie or BasicAuth mode.
|
|
|
|
This backend is only applied to browser routes (/user/*) via a separate
|
|
Starlette app mount. FastMCP routes use their own OAuth Bearer token
|
|
authentication.
|
|
|
|
Args:
|
|
conn: HTTP connection
|
|
|
|
Returns:
|
|
Tuple of (credentials, user) if authenticated, None otherwise
|
|
"""
|
|
# BasicAuth mode: Always authenticated as the configured user
|
|
if not self.oauth_enabled:
|
|
username = os.getenv("NEXTCLOUD_USERNAME", "admin")
|
|
return AuthCredentials(["authenticated", "admin"]), SimpleUser(username)
|
|
|
|
# OAuth mode: opaque random session_id cookie -> user_id mapping.
|
|
# Replaces the prior `mcp_session=<user_id>` cookie pattern (issue
|
|
# #626 finding 2). The cookie value is no longer the user identity;
|
|
# we look it up server-side and reject unknown / expired sessions.
|
|
session_id = conn.cookies.get("mcp_session")
|
|
if not session_id:
|
|
logger.info("No session cookie found - redirecting to login")
|
|
return None
|
|
|
|
oauth_context = getattr(conn.app.state, "oauth_context", None)
|
|
if not oauth_context:
|
|
logger.warning("OAuth context not available in app state")
|
|
return None
|
|
|
|
storage = oauth_context.get("storage")
|
|
if not storage:
|
|
logger.warning("OAuth storage not available")
|
|
return None
|
|
|
|
try:
|
|
user_id = await storage.get_browser_session_user(session_id)
|
|
if not user_id:
|
|
logger.info(
|
|
"Browser session not found or expired (sid=%s…)", session_id[:8]
|
|
)
|
|
return None
|
|
|
|
# Defense-in-depth: only authenticate sessions for users that
|
|
# actually have a refresh token persisted. Logout deletes both,
|
|
# so an expired/revoked user state will fail closed here.
|
|
token_data = await storage.get_refresh_token(user_id)
|
|
if not token_data:
|
|
logger.warning(
|
|
"Session %s… has no refresh token for user %s; rejecting",
|
|
session_id[:8],
|
|
user_id,
|
|
)
|
|
return None
|
|
|
|
return AuthCredentials(["authenticated"]), SimpleUser(user_id)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Session validation error: {e}")
|
|
return None
|