c2dcb06fe1
Implements /user and /user/page endpoints for displaying authenticated user information in both BasicAuth and OAuth modes. Key Features: - Separate browser OAuth flow (/oauth/login, /oauth/login-callback, /oauth/logout) - Session-based authentication using signed cookies - Token refresh for persistent sessions - HTML and JSON user info endpoints - IdP profile information retrieval Architecture: - BasicAuth mode: Always authenticated as configured user - OAuth mode: Browser-based authorization code flow with refresh tokens - Session stored in SQLite with encrypted refresh tokens - Server-side token refresh using internal Docker hostnames OAuth Flow: - /oauth/login: Initiates browser OAuth flow - /oauth/login-callback: Handles IdP callback and stores refresh token - /oauth/logout: Clears session cookie - /user: JSON API endpoint (requires authentication) - /user/page: HTML page endpoint (requires authentication) DCR Scopes Fix: - MCP server DCR now only requests basic OIDC scopes (openid profile email offline_access) - Nextcloud app scopes (notes:read, etc.) are for MCP clients, not the server itself - PRM endpoint dynamically advertises supported scopes from tool decorators Files: - nextcloud_mcp_server/auth/browser_oauth_routes.py: Browser OAuth flow handlers - nextcloud_mcp_server/auth/session_backend.py: Starlette session authentication - nextcloud_mcp_server/auth/userinfo_routes.py: User info endpoints with token refresh - tests/server/auth/test_userinfo_routes.py: Unit tests - tests/server/oauth/test_userinfo_integration.py: OAuth integration tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
93 lines
3.1 KiB
Python
93 lines
3.1 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.
|
|
|
|
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: Check for session cookie
|
|
session_id = conn.cookies.get("mcp_session")
|
|
logger.info(
|
|
f"Session authentication check - cookie present: {session_id is not None}, path: {conn.url.path}"
|
|
)
|
|
if not session_id:
|
|
logger.info("No session cookie found - redirecting to login")
|
|
return None
|
|
|
|
logger.info(f"Found session cookie: {session_id[:16]}...")
|
|
|
|
# Get OAuth context from app state
|
|
oauth_context = getattr(conn.app.state, "oauth_context", None)
|
|
if not oauth_context:
|
|
logger.warning("OAuth context not available in app state")
|
|
return None
|
|
|
|
# Validate session
|
|
storage = oauth_context.get("storage")
|
|
if not storage:
|
|
logger.warning("OAuth storage not available")
|
|
return None
|
|
|
|
try:
|
|
# Check if user has refresh token (indicates logged-in session)
|
|
logger.info(f"Looking up refresh token for session: {session_id[:16]}...")
|
|
token_data = await storage.get_refresh_token(session_id)
|
|
if not token_data:
|
|
logger.warning(
|
|
f"No refresh token found for session {session_id[:16]}..."
|
|
)
|
|
return None
|
|
|
|
# Session is valid - use session_id (which is user_id from ID token) as username
|
|
username = session_id
|
|
logger.info(f"✓ Session authenticated successfully: {username[:16]}...")
|
|
|
|
return AuthCredentials(["authenticated"]), SimpleUser(username)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Session validation error: {e}")
|
|
return None
|