Merge pull request #589 from cbcoutinho/feat/docker-compose-profiles-login-flow
feat: Docker Compose profiles and Login Flow v2 integration tests
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"""Add scopes and login flow sessions for Login Flow v2
|
||||
|
||||
This migration adds support for:
|
||||
1. Scoped app passwords (scopes column + username column on app_passwords)
|
||||
2. Login Flow v2 session tracking (login_flow_sessions table)
|
||||
|
||||
Nullable scopes preserves backward compat: NULL = legacy app password = all scopes allowed.
|
||||
|
||||
Revision ID: 003
|
||||
Revises: 002
|
||||
Create Date: 2026-02-27 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "003"
|
||||
down_revision = "002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add scopes/username to app_passwords and create login_flow_sessions."""
|
||||
|
||||
# Add scopes column (nullable JSON array, NULL = all scopes allowed)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE app_passwords ADD COLUMN scopes TEXT
|
||||
"""
|
||||
)
|
||||
|
||||
# Add username column (Nextcloud loginName from Login Flow v2)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE app_passwords ADD COLUMN username TEXT
|
||||
"""
|
||||
)
|
||||
|
||||
# Login Flow v2 session tracking
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_flow_sessions (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
encrypted_poll_token BLOB NOT NULL,
|
||||
poll_endpoint TEXT NOT NULL,
|
||||
requested_scopes TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Index for efficient cleanup of expired sessions
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_login_flow_sessions_expires
|
||||
ON login_flow_sessions(expires_at)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop login_flow_sessions and remove added columns."""
|
||||
|
||||
op.execute("DROP INDEX IF EXISTS idx_login_flow_sessions_expires")
|
||||
op.execute("DROP TABLE IF EXISTS login_flow_sessions")
|
||||
|
||||
# SQLite doesn't support DROP COLUMN before 3.35.0
|
||||
# Recreate app_passwords without the new columns
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE app_passwords_backup (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
encrypted_password BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO app_passwords_backup (user_id, encrypted_password, created_at, updated_at)
|
||||
SELECT user_id, encrypted_password, created_at, updated_at FROM app_passwords
|
||||
"""
|
||||
)
|
||||
op.execute("DROP TABLE app_passwords")
|
||||
op.execute("ALTER TABLE app_passwords_backup RENAME TO app_passwords")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_app_passwords_updated
|
||||
ON app_passwords(updated_at)
|
||||
"""
|
||||
)
|
||||
@@ -11,6 +11,12 @@ This package is organized into modules by domain:
|
||||
- visualization.py: Search and PDF visualization endpoints
|
||||
"""
|
||||
|
||||
from nextcloud_mcp_server.api.access import (
|
||||
get_user_access,
|
||||
list_supported_scopes,
|
||||
update_user_scopes,
|
||||
)
|
||||
|
||||
# Re-export all public functions for backward compatibility
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
__version__,
|
||||
@@ -44,6 +50,10 @@ from nextcloud_mcp_server.api.webhooks import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Access endpoints (from access.py)
|
||||
"get_user_access",
|
||||
"update_user_scopes",
|
||||
"list_supported_scopes",
|
||||
# Version
|
||||
"__version__",
|
||||
# Shared helpers (from management.py)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Access and scope management API endpoints.
|
||||
|
||||
Provides REST API endpoints for querying and managing user access status
|
||||
and application-level scopes for Login Flow v2 mode.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
|
||||
from nextcloud_mcp_server.api.passwords import (
|
||||
_extract_basic_auth,
|
||||
_get_app_password_storage,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.scope_authorization import invalidate_scope_cache
|
||||
from nextcloud_mcp_server.models.auth import ALL_SUPPORTED_SCOPES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_user_access(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/users/{user_id}/access - Get user's provisioned access and scopes.
|
||||
|
||||
Returns the user's current provisioning status, granted scopes, and metadata.
|
||||
Requires BasicAuth with the user's credentials.
|
||||
"""
|
||||
path_user_id = request.path_params.get("user_id")
|
||||
if not path_user_id:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Missing user_id in path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
username, _, error_response = _extract_basic_auth(request, path_user_id)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
storage = await _get_app_password_storage(request)
|
||||
data = await storage.get_app_password_with_scopes(username)
|
||||
|
||||
if data is None:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"user_id": username,
|
||||
"provisioned": False,
|
||||
"scopes": None,
|
||||
"username": None,
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"user_id": username,
|
||||
"provisioned": True,
|
||||
"scopes": data["scopes"],
|
||||
"username": data.get("username"),
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "get_user_access")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def update_user_scopes(request: Request) -> JSONResponse:
|
||||
"""PATCH /api/v1/users/{user_id}/scopes - Update user's application-level scopes.
|
||||
|
||||
Accepts JSON body with:
|
||||
- scopes: list[str] - New scope set to apply
|
||||
|
||||
This only updates the stored scopes, not the app password itself.
|
||||
The app password remains valid; scope enforcement is application-level.
|
||||
|
||||
Security note: This endpoint allows direct scope modification without
|
||||
re-authenticating via Login Flow. The caller must authenticate with
|
||||
valid BasicAuth credentials (user_id + app_password), which serves
|
||||
as the authorization check.
|
||||
"""
|
||||
path_user_id = request.path_params.get("user_id")
|
||||
if not path_user_id:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Missing user_id in path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
username, _, error_response = _extract_basic_auth(request, path_user_id)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid JSON body"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
scopes = body.get("scopes")
|
||||
if scopes is None or not isinstance(scopes, list):
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "scopes must be a list of strings"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate scopes
|
||||
invalid = [s for s in scopes if s not in ALL_SUPPORTED_SCOPES]
|
||||
if invalid:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Invalid scopes: {', '.join(invalid)}",
|
||||
"valid_scopes": sorted(ALL_SUPPORTED_SCOPES),
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
storage = await _get_app_password_storage(request)
|
||||
existing = await storage.get_app_password_with_scopes(username)
|
||||
|
||||
if existing is None:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": "No app password provisioned for this user",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Update scopes only (no decrypt/re-encrypt of the password)
|
||||
await storage.update_app_password_scopes(
|
||||
user_id=username,
|
||||
scopes=scopes,
|
||||
)
|
||||
|
||||
# Invalidate scope cache so subsequent tool calls see updated scopes
|
||||
invalidate_scope_cache(username)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"user_id": username,
|
||||
"scopes": scopes,
|
||||
"message": "Scopes updated successfully",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "update_user_scopes")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def list_supported_scopes(_: Request) -> JSONResponse:
|
||||
"""GET /api/v1/scopes - List all supported application-level scopes."""
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"scopes": sorted(ALL_SUPPORTED_SCOPES),
|
||||
}
|
||||
)
|
||||
@@ -288,10 +288,23 @@ async def provision_app_password(request: Request) -> JSONResponse:
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Parse optional scopes and username from request body
|
||||
scopes = None
|
||||
nc_username = None
|
||||
try:
|
||||
body = await request.json()
|
||||
scopes = body.get("scopes") # list[str] | None
|
||||
nc_username = body.get("username") # Nextcloud loginName
|
||||
except Exception:
|
||||
pass # No JSON body = legacy call without scopes
|
||||
|
||||
# Store the validated app password
|
||||
try:
|
||||
storage = await _get_app_password_storage(request)
|
||||
await storage.store_app_password(username, app_password)
|
||||
|
||||
await storage.store_app_password_with_scopes(
|
||||
username, app_password, scopes=scopes, username=nc_username
|
||||
)
|
||||
|
||||
_record_rate_limit_attempt(path_user_id, success=True)
|
||||
logger.info(f"Provisioned app password for user: {username}")
|
||||
@@ -300,6 +313,7 @@ async def provision_app_password(request: Request) -> JSONResponse:
|
||||
{
|
||||
"success": True,
|
||||
"message": f"App password stored for {username}",
|
||||
"scopes": scopes,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+98
-19
@@ -40,12 +40,15 @@ from nextcloud_mcp_server.api import (
|
||||
get_installed_apps,
|
||||
get_pdf_preview,
|
||||
get_server_status,
|
||||
get_user_access,
|
||||
get_user_session,
|
||||
get_vector_sync_status,
|
||||
list_supported_scopes,
|
||||
list_webhooks,
|
||||
provision_app_password,
|
||||
revoke_user_access,
|
||||
unified_search,
|
||||
update_user_scopes,
|
||||
vector_search,
|
||||
)
|
||||
from nextcloud_mcp_server.auth import (
|
||||
@@ -63,13 +66,16 @@ from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
||||
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
|
||||
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
|
||||
from nextcloud_mcp_server.auth.oauth_routes import (
|
||||
oauth_as_metadata,
|
||||
oauth_authorize,
|
||||
oauth_authorize_nextcloud,
|
||||
oauth_callback,
|
||||
oauth_callback_nextcloud,
|
||||
oauth_register_proxy,
|
||||
oauth_token_endpoint,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage, get_shared_storage
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
@@ -123,6 +129,7 @@ from nextcloud_mcp_server.server import (
|
||||
configure_tables_tools,
|
||||
configure_webdav_tools,
|
||||
)
|
||||
from nextcloud_mcp_server.server.auth_tools import register_auth_tools
|
||||
from nextcloud_mcp_server.server.oauth_tools import register_oauth_tools
|
||||
from nextcloud_mcp_server.vector import processor_task, scanner_task
|
||||
from nextcloud_mcp_server.vector.oauth_sync import (
|
||||
@@ -1468,6 +1475,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
"Skipping provisioning tools registration (offline access not enabled)"
|
||||
)
|
||||
|
||||
# Register Login Flow v2 auth tools (ADR-022)
|
||||
if settings.enable_login_flow:
|
||||
logger.info("Registering Login Flow v2 auth tools")
|
||||
register_auth_tools(mcp)
|
||||
|
||||
# Override list_tools to filter based on user's token scopes (OAuth mode only)
|
||||
if oauth_enabled:
|
||||
original_list_tools = mcp._tool_manager.list_tools
|
||||
@@ -1519,6 +1531,43 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
|
||||
mcp_app = mcp.streamable_http_app()
|
||||
|
||||
async def _login_flow_cleanup_loop() -> None:
|
||||
"""Periodically clean up expired Login Flow v2 sessions and proxy codes."""
|
||||
from nextcloud_mcp_server.auth.oauth_routes import ( # noqa: PLC0415
|
||||
_cleanup_expired_proxy_codes,
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
storage = await get_shared_storage()
|
||||
count = await storage.delete_expired_login_flow_sessions()
|
||||
if count:
|
||||
logger.info(f"Cleaned up {count} expired login flow sessions")
|
||||
# Also clean up expired AS proxy codes/sessions
|
||||
_cleanup_expired_proxy_codes()
|
||||
except Exception as e:
|
||||
logger.warning(f"Login flow cleanup error: {e}")
|
||||
await anyio.sleep(3600) # Every hour
|
||||
|
||||
@asynccontextmanager
|
||||
async def _maybe_login_flow_cleanup():
|
||||
"""Start Login Flow cleanup task if enabled."""
|
||||
if settings.enable_login_flow:
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(_login_flow_cleanup_loop)
|
||||
yield
|
||||
tg.cancel_scope.cancel()
|
||||
else:
|
||||
yield
|
||||
|
||||
@asynccontextmanager
|
||||
async def _mcp_session_with_login_flow():
|
||||
"""Start MCP session manager with optional Login Flow cleanup."""
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
await stack.enter_async_context(_maybe_login_flow_cleanup())
|
||||
yield
|
||||
|
||||
@asynccontextmanager
|
||||
async def starlette_lifespan(app: Starlette):
|
||||
# Set OAuth context for OAuth login routes (ADR-004)
|
||||
@@ -1752,8 +1801,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
)
|
||||
|
||||
# Run MCP session manager and yield
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
async with _mcp_session_with_login_flow():
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -1935,8 +1983,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
)
|
||||
|
||||
# Run MCP session manager and yield
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
async with _mcp_session_with_login_flow():
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -1955,8 +2002,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
"To enable, set NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET."
|
||||
)
|
||||
# Just run MCP session manager without vector sync
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
async with _mcp_session_with_login_flow():
|
||||
yield
|
||||
|
||||
else:
|
||||
@@ -1976,8 +2022,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
logger.warning(
|
||||
"Vector sync enabled but TOKEN_ENCRYPTION_KEY not set"
|
||||
)
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
async with _mcp_session_with_login_flow():
|
||||
yield
|
||||
|
||||
# Health check endpoints for Kubernetes probes
|
||||
@@ -2208,10 +2253,27 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
routes.append(
|
||||
Route("/api/v1/webhooks/{webhook_id}", delete_webhook, methods=["DELETE"])
|
||||
)
|
||||
# Access and scope management endpoints (ADR-022)
|
||||
routes.append(
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/access",
|
||||
get_user_access,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
routes.append(
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/scopes",
|
||||
update_user_scopes,
|
||||
methods=["PATCH"],
|
||||
)
|
||||
)
|
||||
routes.append(Route("/api/v1/scopes", list_supported_scopes, methods=["GET"]))
|
||||
logger.info(
|
||||
"Management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status, "
|
||||
"/api/v1/users/{user_id}/session, /api/v1/users/{user_id}/revoke, "
|
||||
"/api/v1/users/{user_id}/app-password, "
|
||||
"/api/v1/users/{user_id}/app-password, /api/v1/users/{user_id}/access, "
|
||||
"/api/v1/users/{user_id}/scopes, /api/v1/scopes, "
|
||||
"/api/v1/vector-viz/search, /api/v1/search, /api/v1/apps, "
|
||||
"/api/v1/webhooks, /api/v1/pdf-preview"
|
||||
)
|
||||
@@ -2264,14 +2326,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
The 'resource' field is set to the MCP server's public URL (RFC 9728 requires a URL).
|
||||
This is used as the audience in access tokens via the resource parameter (RFC 8707).
|
||||
The introspection controller matches this URL to the MCP server's client via resource_url field.
|
||||
"""
|
||||
# Use PUBLIC_ISSUER_URL for authorization server since external clients
|
||||
# (like Claude) need the publicly accessible URL, not internal Docker URLs
|
||||
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if not public_issuer_url:
|
||||
# Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set
|
||||
public_issuer_url = os.getenv("NEXTCLOUD_HOST", "")
|
||||
|
||||
ADR-023: authorization_servers points to the MCP server itself (AS proxy)
|
||||
so that clients authenticate through the proxy and tokens have correct audience.
|
||||
"""
|
||||
# RFC 9728 requires resource to be a URL (not a client ID)
|
||||
# Use the MCP server's public URL
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL")
|
||||
@@ -2283,11 +2341,14 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
# This provides a single source of truth based on @require_scopes decorators
|
||||
supported_scopes = discover_all_scopes(mcp)
|
||||
|
||||
# ADR-023: Point authorization_servers to the MCP server itself.
|
||||
# The MCP server acts as an OAuth AS proxy, forwarding to Nextcloud
|
||||
# with its own client_id so tokens have the correct audience.
|
||||
return JSONResponse(
|
||||
{
|
||||
"resource": f"{mcp_server_url}/mcp", # RFC 9728: must be a URL
|
||||
"scopes_supported": supported_scopes,
|
||||
"authorization_servers": [public_issuer_url],
|
||||
"authorization_servers": [mcp_server_url],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_signing_alg_values_supported": ["RS256"],
|
||||
}
|
||||
@@ -2344,7 +2405,21 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
# Multi-user BasicAuth uses hybrid mode with only Flow 2 (resource provisioning)
|
||||
if oauth_enabled:
|
||||
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
|
||||
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
|
||||
|
||||
# ADR-023: AS proxy endpoints — MCP server acts as its own OAuth AS
|
||||
routes.append(Route("/oauth/token", oauth_token_endpoint, methods=["POST"]))
|
||||
routes.append(Route("/oauth/register", oauth_register_proxy, methods=["POST"]))
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
oauth_as_metadata,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"OAuth AS proxy routes enabled: /oauth/authorize, /oauth/token, "
|
||||
"/oauth/register, /.well-known/oauth-authorization-server (ADR-023)"
|
||||
)
|
||||
|
||||
# Add browser OAuth login routes for Management API access
|
||||
# Available in OAuth modes AND multi-user BasicAuth with offline access
|
||||
@@ -2453,6 +2528,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
"Routes: /user/* with SessionAuth, /mcp with FastMCP OAuth Bearer tokens"
|
||||
)
|
||||
|
||||
# Store supported scopes on app.state for AS metadata endpoint (ADR-023)
|
||||
if oauth_enabled:
|
||||
app.state.supported_scopes = discover_all_scopes(mcp)
|
||||
|
||||
# Add debugging middleware to log Authorization headers and client capabilities
|
||||
@app.middleware("http")
|
||||
async def log_auth_headers(request, call_next):
|
||||
|
||||
@@ -83,6 +83,7 @@ async def register_client(
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str | None = "Bearer",
|
||||
resource_url: str | None = None,
|
||||
max_retries: int = 3,
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Register a new OAuth client using RFC 7591 Dynamic Client Registration.
|
||||
@@ -98,6 +99,7 @@ async def register_client(
|
||||
token_type: Type of access tokens (default: "Bearer", supports "JWT" for Nextcloud).
|
||||
Set to None to omit this field (required for Keycloak and other standard providers).
|
||||
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
|
||||
max_retries: Maximum number of retries for 429 responses (default: 3)
|
||||
|
||||
Returns:
|
||||
ClientInfo with registration details
|
||||
@@ -135,57 +137,91 @@ async def register_client(
|
||||
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
||||
|
||||
async with nextcloud_httpx_client(timeout=30.0) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
registration_endpoint,
|
||||
json=client_metadata,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
client_info = response.json()
|
||||
logger.info(
|
||||
f"Successfully registered client: {client_info.get('client_id')}"
|
||||
)
|
||||
expires_at = dt.datetime.fromtimestamp(
|
||||
client_info.get("client_secret_expires_at")
|
||||
)
|
||||
logger.info(
|
||||
f"Client expires at: {expires_at} "
|
||||
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
|
||||
)
|
||||
|
||||
# Log if RFC 7592 fields are present
|
||||
has_reg_token = "registration_access_token" in client_info
|
||||
has_reg_uri = "registration_client_uri" in client_info
|
||||
if has_reg_token and has_reg_uri:
|
||||
logger.info(
|
||||
"RFC 7592 management fields received - client deletion will be supported"
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = await client.post(
|
||||
registration_endpoint,
|
||||
json=client_metadata,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
else:
|
||||
logger.warning("RFC 7592 fields missing - client deletion may not work")
|
||||
|
||||
return ClientInfo(
|
||||
client_id=client_info["client_id"],
|
||||
client_secret=client_info["client_secret"],
|
||||
client_id_issued_at=client_info.get(
|
||||
"client_id_issued_at", int(time.time())
|
||||
),
|
||||
client_secret_expires_at=client_info.get(
|
||||
"client_secret_expires_at", int(time.time()) + 3600
|
||||
),
|
||||
redirect_uris=client_info.get("redirect_uris", redirect_uris),
|
||||
registration_access_token=client_info.get("registration_access_token"),
|
||||
registration_client_uri=client_info.get("registration_client_uri"),
|
||||
)
|
||||
if response.status_code == 429:
|
||||
# Rate limited - retry with exponential backoff
|
||||
if attempt < max_retries - 1:
|
||||
retry_after = int(response.headers.get("Retry-After", 2))
|
||||
wait_time = min(retry_after, 2**attempt)
|
||||
logger.warning(
|
||||
f"Rate limited (429) registering client, "
|
||||
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
|
||||
)
|
||||
await anyio.sleep(wait_time)
|
||||
continue
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to register client after {max_retries} attempts: Rate limited (429)"
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Failed to register client: HTTP {e.response.status_code}")
|
||||
logger.error(f"Response: {e.response.text}")
|
||||
raise
|
||||
except KeyError as e:
|
||||
logger.error(f"Invalid response from registration endpoint: missing {e}")
|
||||
raise ValueError(f"Invalid registration response: missing {e}")
|
||||
response.raise_for_status()
|
||||
|
||||
client_info = response.json()
|
||||
logger.info(
|
||||
f"Successfully registered client: {client_info.get('client_id')}"
|
||||
)
|
||||
expires_at = dt.datetime.fromtimestamp(
|
||||
client_info.get("client_secret_expires_at")
|
||||
)
|
||||
logger.info(
|
||||
f"Client expires at: {expires_at} "
|
||||
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
|
||||
)
|
||||
|
||||
# Log if RFC 7592 fields are present
|
||||
has_reg_token = "registration_access_token" in client_info
|
||||
has_reg_uri = "registration_client_uri" in client_info
|
||||
if has_reg_token and has_reg_uri:
|
||||
logger.info(
|
||||
"RFC 7592 management fields received - client deletion will be supported"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"RFC 7592 fields missing - client deletion may not work"
|
||||
)
|
||||
|
||||
return ClientInfo(
|
||||
client_id=client_info["client_id"],
|
||||
client_secret=client_info["client_secret"],
|
||||
client_id_issued_at=client_info.get(
|
||||
"client_id_issued_at", int(time.time())
|
||||
),
|
||||
client_secret_expires_at=client_info.get(
|
||||
"client_secret_expires_at", int(time.time()) + 3600
|
||||
),
|
||||
redirect_uris=client_info.get("redirect_uris", redirect_uris),
|
||||
registration_access_token=client_info.get(
|
||||
"registration_access_token"
|
||||
),
|
||||
registration_client_uri=client_info.get("registration_client_uri"),
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"Failed to register client: HTTP {e.response.status_code}"
|
||||
)
|
||||
logger.error(f"Response: {e.response.text}")
|
||||
raise
|
||||
except KeyError as e:
|
||||
logger.error(
|
||||
f"Invalid response from registration endpoint: missing {e}"
|
||||
)
|
||||
raise ValueError(f"Invalid registration response: missing {e}")
|
||||
|
||||
# Should not reach here, but raise if we do
|
||||
raise httpx.HTTPStatusError(
|
||||
"Registration failed after retries",
|
||||
request=httpx.Request("POST", registration_endpoint),
|
||||
response=httpx.Response(429),
|
||||
)
|
||||
|
||||
|
||||
async def delete_client(
|
||||
|
||||
@@ -142,8 +142,8 @@ class ClientRegistry:
|
||||
if not self._validate_redirect_uri(client, redirect_uri):
|
||||
return False, f"Invalid redirect_uri for client {client_id}"
|
||||
|
||||
# Validate scopes if provided
|
||||
if scopes:
|
||||
# Validate scopes if provided (wildcard "*" allows all scopes)
|
||||
if scopes and "*" not in client.allowed_scopes:
|
||||
invalid_scopes = set(scopes) - set(client.allowed_scopes)
|
||||
if invalid_scopes:
|
||||
return False, f"Invalid scopes for client {client_id}: {invalid_scopes}"
|
||||
@@ -202,6 +202,29 @@ class ClientRegistry:
|
||||
# In production, would persist to database
|
||||
return True
|
||||
|
||||
def register_proxy_client(
|
||||
self, client_id: str, redirect_uris: list[str], name: str = ""
|
||||
) -> None:
|
||||
"""Register a client discovered via DCR proxy.
|
||||
|
||||
When the MCP server acts as an OAuth AS proxy, clients register via
|
||||
the proxy's /oauth/register endpoint. This method stores the client
|
||||
locally so /oauth/authorize can validate it.
|
||||
|
||||
Args:
|
||||
client_id: Client identifier from Nextcloud DCR response
|
||||
redirect_uris: Allowed redirect URIs
|
||||
name: Optional human-readable name
|
||||
"""
|
||||
self._clients[client_id] = MCPClientInfo(
|
||||
client_id=client_id,
|
||||
name=name or f"DCR-{client_id[:8]}",
|
||||
redirect_uris=redirect_uris or ["http://localhost:*", "http://127.0.0.1:*"],
|
||||
allowed_scopes=["*"], # Nextcloud enforces actual scopes
|
||||
is_public=True,
|
||||
)
|
||||
logger.info(f"Registered proxy client: {client_id}")
|
||||
|
||||
def get_client(self, client_id: str) -> Optional[MCPClientInfo]:
|
||||
"""
|
||||
Get client information.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""MCP elicitation helpers for Login Flow v2.
|
||||
|
||||
Provides a unified way to present login URLs to users, using MCP elicitation
|
||||
when the client supports it, or falling back to returning the URL in a message.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from mcp.server.fastmcp import Context
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginFlowConfirmation(BaseModel):
|
||||
"""Schema for Login Flow v2 confirmation elicitation."""
|
||||
|
||||
acknowledged: bool = Field(
|
||||
default=False,
|
||||
description="Check this box after completing login at the provided URL",
|
||||
)
|
||||
|
||||
|
||||
async def present_login_url(
|
||||
ctx: Context,
|
||||
login_url: str,
|
||||
message: str | None = None,
|
||||
) -> str:
|
||||
"""Present a login URL to the user via MCP elicitation or message.
|
||||
|
||||
Tries MCP elicitation first (ctx.elicit) for interactive clients.
|
||||
Falls back to returning the URL as a plain message.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
login_url: URL the user should open in their browser
|
||||
message: Optional custom message (defaults to standard Login Flow prompt)
|
||||
|
||||
Returns:
|
||||
"accepted" if user acknowledged via elicitation,
|
||||
"declined" if user declined,
|
||||
"message_only" if elicitation not supported (URL returned in message)
|
||||
"""
|
||||
if message is None:
|
||||
message = (
|
||||
f"Please log in to Nextcloud to grant access:\n\n"
|
||||
f"{login_url}\n\n"
|
||||
f"Open this URL in your browser, log in, and grant the requested permissions. "
|
||||
f"Then check the box below and click OK."
|
||||
)
|
||||
|
||||
if not hasattr(ctx, "elicit"):
|
||||
logger.debug(
|
||||
"Elicitation not available (no elicit method), returning URL in message"
|
||||
)
|
||||
return "message_only"
|
||||
|
||||
try:
|
||||
result = await ctx.elicit(
|
||||
message=message,
|
||||
schema=LoginFlowConfirmation,
|
||||
)
|
||||
|
||||
if result.action == "accept":
|
||||
if hasattr(result, "data") and not result.data.acknowledged: # type: ignore[union-attr]
|
||||
logger.warning(
|
||||
"User accepted login flow without checking the acknowledged box — "
|
||||
"login completion will be verified via polling"
|
||||
)
|
||||
logger.info("User acknowledged login flow completion")
|
||||
return "accepted"
|
||||
elif result.action == "decline":
|
||||
logger.info("User declined login flow")
|
||||
return "declined"
|
||||
else:
|
||||
logger.info("User cancelled login flow")
|
||||
return "cancelled"
|
||||
|
||||
except NotImplementedError:
|
||||
# Elicitation not supported by this client/SDK - fall back to message
|
||||
logger.debug("Elicitation not available, returning URL in message")
|
||||
return "message_only"
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Elicitation failed unexpectedly ({type(e).__name__}: {e}), "
|
||||
"falling back to message"
|
||||
)
|
||||
return "message_only"
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Nextcloud Login Flow v2 HTTP client.
|
||||
|
||||
Implements the Nextcloud Login Flow v2 protocol for obtaining app passwords.
|
||||
See: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
||||
|
||||
The flow has two steps:
|
||||
1. Initiate: POST /index.php/login/v2 → returns login URL + poll endpoint/token
|
||||
2. Poll: POST to poll endpoint with token → returns server URL, loginName, appPassword
|
||||
"""
|
||||
|
||||
import logging
|
||||
import ssl
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from nextcloud_mcp_server.http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginFlowInitResponse(BaseModel):
|
||||
"""Response from initiating Login Flow v2."""
|
||||
|
||||
login_url: str = Field(description="URL to present to the user for browser login")
|
||||
poll_endpoint: str = Field(description="URL to poll for flow completion")
|
||||
poll_token: str = Field(description="Token to use when polling")
|
||||
|
||||
|
||||
class LoginFlowPollResult(BaseModel):
|
||||
"""Result of polling Login Flow v2."""
|
||||
|
||||
status: str = Field(description="Flow status: 'pending', 'completed', or 'expired'")
|
||||
server: str | None = Field(None, description="Nextcloud server URL (on completion)")
|
||||
login_name: str | None = Field(
|
||||
None, description="Nextcloud login name (on completion)"
|
||||
)
|
||||
app_password: str | None = Field(
|
||||
None, description="Generated app password (on completion)"
|
||||
)
|
||||
|
||||
|
||||
class LoginFlowV2Client:
|
||||
"""HTTP client for Nextcloud Login Flow v2.
|
||||
|
||||
This client handles the two-step Login Flow v2 process:
|
||||
1. Initiate a flow to get a login URL for the user
|
||||
2. Poll for completion to receive the app password
|
||||
|
||||
Args:
|
||||
nextcloud_host: Base URL of the Nextcloud instance
|
||||
verify_ssl: SSL verification setting (True, False, or SSLContext)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nextcloud_host: str,
|
||||
verify_ssl: bool | ssl.SSLContext = True,
|
||||
):
|
||||
self.nextcloud_host = nextcloud_host.rstrip("/")
|
||||
self.verify_ssl = verify_ssl
|
||||
|
||||
async def initiate(
|
||||
self, user_agent: str = "nextcloud-mcp-server"
|
||||
) -> LoginFlowInitResponse:
|
||||
"""Initiate Login Flow v2 by sending an HTTP POST to the Nextcloud instance.
|
||||
|
||||
Makes an outbound HTTP request to POST /index.php/login/v2 on the
|
||||
configured Nextcloud server to start a new login flow.
|
||||
|
||||
Args:
|
||||
user_agent: User-Agent string for the app password name
|
||||
|
||||
Returns:
|
||||
LoginFlowInitResponse with login URL and poll credentials
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the Nextcloud server returns an error
|
||||
"""
|
||||
url = f"{self.nextcloud_host}/index.php/login/v2"
|
||||
|
||||
async with nextcloud_httpx_client(
|
||||
verify=self.verify_ssl, timeout=15.0
|
||||
) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
headers={"User-Agent": user_agent},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
poll_data = data.get("poll", {})
|
||||
|
||||
try:
|
||||
result = LoginFlowInitResponse(
|
||||
login_url=data["login"],
|
||||
poll_endpoint=poll_data["endpoint"],
|
||||
poll_token=poll_data["token"],
|
||||
)
|
||||
except KeyError as e:
|
||||
raise ValueError(
|
||||
f"Malformed Login Flow v2 initiate response from Nextcloud (missing key: {e})"
|
||||
) from e
|
||||
|
||||
logger.info(f"Login Flow v2 initiated: login_url={result.login_url[:60]}...")
|
||||
return result
|
||||
|
||||
async def poll(self, poll_endpoint: str, poll_token: str) -> LoginFlowPollResult:
|
||||
"""Poll for Login Flow v2 completion by sending an HTTP POST to the Nextcloud instance.
|
||||
|
||||
Makes an outbound HTTP request to the poll endpoint provided by the
|
||||
initiate response. Nextcloud returns:
|
||||
- 200 with credentials when the user completes login
|
||||
- 404 when still pending
|
||||
- Other errors for expired/invalid flows
|
||||
|
||||
Args:
|
||||
poll_endpoint: URL to poll (from initiate response)
|
||||
poll_token: Token for polling (from initiate response)
|
||||
|
||||
Returns:
|
||||
LoginFlowPollResult with status and optional credentials
|
||||
"""
|
||||
async with nextcloud_httpx_client(
|
||||
verify=self.verify_ssl, timeout=10.0
|
||||
) as client:
|
||||
response = await client.post(
|
||||
poll_endpoint,
|
||||
data={"token": poll_token},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
logger.info(
|
||||
f"Login Flow v2 completed: server={data.get('server')}, "
|
||||
f"loginName={data.get('loginName')}"
|
||||
)
|
||||
try:
|
||||
return LoginFlowPollResult(
|
||||
status="completed",
|
||||
server=data["server"],
|
||||
login_name=data["loginName"],
|
||||
app_password=data["appPassword"],
|
||||
)
|
||||
except KeyError as e:
|
||||
raise ValueError(
|
||||
f"Malformed Login Flow v2 poll response from Nextcloud (missing key: {e})"
|
||||
) from e
|
||||
|
||||
if response.status_code == 404:
|
||||
logger.debug("Login Flow v2 still pending")
|
||||
return LoginFlowPollResult(status="pending")
|
||||
|
||||
# Any other status indicates the flow has expired or is invalid
|
||||
logger.warning(
|
||||
f"Login Flow v2 poll returned unexpected status: {response.status_code}"
|
||||
)
|
||||
return LoginFlowPollResult(status="expired")
|
||||
@@ -1,13 +1,13 @@
|
||||
"""
|
||||
OAuth 2.0 Login Routes for ADR-004 (Offline Access Architecture)
|
||||
OAuth 2.0 Login Routes for ADR-004 (Offline Access Architecture) and ADR-023 (AS Proxy)
|
||||
|
||||
Implements dual OAuth flows with optional offline access provisioning:
|
||||
|
||||
Flow 1: Client Authentication - MCP client authenticates directly to IdP
|
||||
- Client requests: Nextcloud MCP resource scopes (notes:*, calendar:*, etc.)
|
||||
- Token audience (aud): "mcp-server"
|
||||
- No server interception - IdP redirects directly to client
|
||||
- Client receives resource-scoped token for MCP session
|
||||
Flow 1: Client Authentication (AS Proxy mode, ADR-023)
|
||||
- MCP server acts as its own OAuth Authorization Server
|
||||
- Proxies DCR, authorization, and token endpoints to Nextcloud
|
||||
- Uses MCP server's own client_id so tokens have correct audience
|
||||
- Client exchanges proxy authorization code for Nextcloud token
|
||||
|
||||
Flow 2: Resource Provisioning - MCP server gets delegated Nextcloud access
|
||||
- Triggered by user calling provision_nextcloud_access tool
|
||||
@@ -25,6 +25,8 @@ import os
|
||||
import secrets
|
||||
import time
|
||||
from base64 import urlsafe_b64encode
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
@@ -41,13 +43,113 @@ from ..http import nextcloud_httpx_client
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory proxy code store for AS proxy flow (ADR-023)
|
||||
# Proxy codes are ephemeral (60s TTL), single-instance, so in-memory is fine.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProxyCodeEntry:
|
||||
"""Stores state for a proxy authorization code issued by the AS proxy.
|
||||
|
||||
Proxy codes have a 60-second TTL as a security mitigation: they are
|
||||
single-use, ephemeral codes that bridge the AS proxy callback and the
|
||||
client's token exchange. The short window limits replay risk.
|
||||
"""
|
||||
|
||||
client_id: str
|
||||
client_redirect_uri: str
|
||||
client_state: str
|
||||
code_challenge: str
|
||||
code_challenge_method: str
|
||||
nc_token_response: dict[str, Any] # Full JSON token response from Nextcloud
|
||||
created_at: float = field(default_factory=time.time)
|
||||
expires_at: float = field(default_factory=lambda: time.time() + 60)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return time.time() > self.expires_at
|
||||
|
||||
|
||||
# Server-side state for AS proxy authorize → callback mapping
|
||||
@dataclass
|
||||
class ASProxySession:
|
||||
"""Stores state between /oauth/authorize and the Nextcloud callback.
|
||||
|
||||
Sessions have a 600-second (10 minute) TTL to allow time for the user
|
||||
to complete the browser-based authorization flow.
|
||||
"""
|
||||
|
||||
client_id: str
|
||||
client_redirect_uri: str
|
||||
client_state: str
|
||||
code_challenge: str
|
||||
code_challenge_method: str
|
||||
requested_scopes: str
|
||||
created_at: float = field(default_factory=time.time)
|
||||
expires_at: float = field(default_factory=lambda: time.time() + 600)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return time.time() > self.expires_at
|
||||
|
||||
|
||||
# In-memory stores (single-instance, ephemeral)
|
||||
_proxy_codes: dict[str, ProxyCodeEntry] = {}
|
||||
_as_proxy_sessions: dict[str, ASProxySession] = {}
|
||||
|
||||
# OIDC discovery document cache (URL → (expires_at, data))
|
||||
_discovery_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
_DISCOVERY_CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
# DCR rate limiting (IP → [timestamps])
|
||||
_dcr_rate_limit: dict[str, list[float]] = {}
|
||||
_DCR_RATE_LIMIT_MAX = 10 # max requests
|
||||
_DCR_RATE_LIMIT_WINDOW = 60 # per 60 seconds
|
||||
|
||||
|
||||
async def _get_cached_discovery(url: str) -> dict[str, Any]:
|
||||
"""Fetch OIDC discovery document with caching (5-minute TTL)."""
|
||||
now = time.time()
|
||||
if url in _discovery_cache:
|
||||
expires_at, data = _discovery_cache[url]
|
||||
if now < expires_at:
|
||||
return data
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
_discovery_cache[url] = (now + _DISCOVERY_CACHE_TTL, data)
|
||||
return data
|
||||
|
||||
|
||||
def _cleanup_expired_proxy_codes() -> None:
|
||||
"""Remove expired proxy codes and sessions."""
|
||||
now = time.time()
|
||||
expired_codes = [k for k, v in _proxy_codes.items() if now > v.expires_at]
|
||||
for k in expired_codes:
|
||||
del _proxy_codes[k]
|
||||
expired_sessions = [k for k, v in _as_proxy_sessions.items() if now > v.expires_at]
|
||||
for k in expired_sessions:
|
||||
del _as_proxy_sessions[k]
|
||||
|
||||
|
||||
async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
"""
|
||||
OAuth authorization endpoint for Flow 1: Client Authentication.
|
||||
OAuth authorization endpoint — AS Proxy intermediary (ADR-023).
|
||||
|
||||
The client authenticates directly to the IdP with its own client_id.
|
||||
The server validates the client is authorized but does NOT intercept the callback.
|
||||
IdP redirects directly back to the client's redirect_uri.
|
||||
The MCP server acts as its own OAuth Authorization Server, proxying
|
||||
the authorization to Nextcloud. This ensures tokens have the correct
|
||||
audience (MCP server's client_id) instead of the MCP client's client_id.
|
||||
|
||||
Flow:
|
||||
1. Client sends authorize request with its own client_id + PKCE
|
||||
2. Server stores client params, generates server-side state
|
||||
3. Server redirects to Nextcloud with MCP server's own client_id
|
||||
4. Nextcloud callback returns to /oauth/callback (flow_type=as_proxy)
|
||||
5. Server exchanges code, generates proxy_code for client
|
||||
6. Client exchanges proxy_code at /oauth/token
|
||||
|
||||
Query parameters:
|
||||
response_type: Must be "code"
|
||||
@@ -59,8 +161,11 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
code_challenge_method: PKCE method, must be "S256" (required)
|
||||
|
||||
Returns:
|
||||
302 redirect to IdP authorization endpoint
|
||||
302 redirect to Nextcloud authorization endpoint
|
||||
"""
|
||||
# Clean up expired entries periodically
|
||||
_cleanup_expired_proxy_codes()
|
||||
|
||||
# Extract parameters
|
||||
response_type = request.query_params.get("response_type")
|
||||
client_id = request.query_params.get("client_id")
|
||||
@@ -125,7 +230,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate client_id (required for Flow 1)
|
||||
# Validate client_id (required)
|
||||
if not client_id:
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -166,102 +271,89 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
oauth_client = oauth_ctx["oauth_client"]
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Flow 1: Client authenticates directly to IdP WITHOUT server interception
|
||||
# CRITICAL: This is a direct pass-through to IdP
|
||||
# The IdP will redirect directly back to the client's callback
|
||||
# The MCP server does NOT see the IdP authorization code!
|
||||
# AS Proxy: Store client's params and redirect to Nextcloud with MCP server's credentials
|
||||
# PKCE is validated locally when the client exchanges the proxy_code at /oauth/token.
|
||||
# We do NOT forward PKCE to Nextcloud — the MCP server is a confidential client.
|
||||
server_state = secrets.token_urlsafe(32)
|
||||
|
||||
logger.info(
|
||||
f"Starting Flow 1 - no server session needed, "
|
||||
f"client will handle IdP response directly at {redirect_uri}"
|
||||
)
|
||||
|
||||
# Use client's redirect_uri for DIRECT callback (bypasses server)
|
||||
callback_uri = redirect_uri
|
||||
|
||||
# Request resource scopes for MCP tools access
|
||||
# The token will have aud: "mcp-server" claim
|
||||
# Build scopes from NEXTCLOUD_OIDC_SCOPES config
|
||||
requested_scope = request.query_params.get("scope", "")
|
||||
default_scopes = "openid profile email"
|
||||
resource_scopes = oauth_config.get("scopes", "")
|
||||
scopes = f"{default_scopes} {resource_scopes}".strip()
|
||||
if requested_scope:
|
||||
# Merge client-requested scopes with server defaults
|
||||
all_scopes = set(scopes.split()) | set(requested_scope.split())
|
||||
scopes = " ".join(sorted(all_scopes))
|
||||
|
||||
# Pass through client's state directly
|
||||
idp_state = state
|
||||
# Store session for callback
|
||||
_as_proxy_sessions[server_state] = ASProxySession(
|
||||
client_id=client_id,
|
||||
client_redirect_uri=redirect_uri,
|
||||
client_state=state,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method=code_challenge_method,
|
||||
requested_scopes=scopes,
|
||||
)
|
||||
|
||||
# Use client's own client_id (client must be pre-registered at IdP)
|
||||
idp_client_id = client_id
|
||||
# Use MCP server's own client_id with Nextcloud
|
||||
mcp_server_client_id = os.getenv(
|
||||
"MCP_SERVER_CLIENT_ID", oauth_config.get("client_id")
|
||||
)
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||
|
||||
logger.info("Flow 1: Direct client auth to IdP")
|
||||
logger.info(f" Client ID: {client_id}")
|
||||
logger.info(f" Client will receive IdP code directly at: {callback_uri}")
|
||||
logger.info(f" Scopes: {scopes} (resource access for MCP tools)")
|
||||
logger.info("AS Proxy: Intermediary authorization flow")
|
||||
logger.info(f" Client: {client_id}")
|
||||
logger.info(f" MCP server client_id: {mcp_server_client_id}")
|
||||
logger.info(f" Server callback: {callback_uri}")
|
||||
logger.info(f" Scopes: {scopes}")
|
||||
|
||||
# Get authorization endpoint from OAuth client
|
||||
if oauth_client:
|
||||
# External IdP mode (Keycloak) - use oauth_client
|
||||
auth_url = await oauth_client.get_authorization_url(
|
||||
state=idp_state,
|
||||
code_challenge="", # Server doesn't use PKCE with IdP
|
||||
# Discover Nextcloud authorization endpoint
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if not discovery_url:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth discovery URL not configured",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
logger.info(f"Redirecting to external IdP: {auth_url.split('?')[0]}")
|
||||
else:
|
||||
# Integrated mode (Nextcloud OIDC) - build URL directly
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if not discovery_url:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth discovery URL not configured",
|
||||
},
|
||||
status_code=500,
|
||||
|
||||
discovery = await _get_cached_discovery(discovery_url)
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
|
||||
# Replace internal Docker hostname with public URL for browser access
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||
auth_parsed = parse_url(authorization_endpoint)
|
||||
|
||||
if auth_parsed.hostname == internal_parsed.hostname:
|
||||
public_parsed = parse_url(public_issuer)
|
||||
authorization_endpoint = (
|
||||
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
|
||||
)
|
||||
if auth_parsed.query:
|
||||
authorization_endpoint += f"?{auth_parsed.query}"
|
||||
logger.info(
|
||||
f"Rewrote authorization endpoint for browser access: {authorization_endpoint}"
|
||||
)
|
||||
|
||||
# Fetch authorization endpoint from discovery
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
# Redirect to Nextcloud with MCP server's own client_id (no PKCE — confidential client)
|
||||
idp_params = {
|
||||
"client_id": mcp_server_client_id,
|
||||
"redirect_uri": callback_uri,
|
||||
"response_type": "code",
|
||||
"scope": scopes,
|
||||
"state": server_state,
|
||||
"prompt": "consent",
|
||||
"resource": f"{mcp_server_url}/mcp", # MCP server audience
|
||||
}
|
||||
|
||||
# IMPORTANT: Replace internal Docker hostname with public URL for browser access
|
||||
# The discovery endpoint returns http://app/apps/oidc/authorize (internal)
|
||||
# But browsers need http://localhost:8080/apps/oidc/authorize (public)
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
# Parse internal and authorization endpoint to compare hostnames
|
||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||
auth_parsed = parse_url(authorization_endpoint)
|
||||
|
||||
# Check if authorization endpoint uses internal hostname
|
||||
if auth_parsed.hostname == internal_parsed.hostname:
|
||||
# Replace internal hostname+port with public URL
|
||||
# Keep the path from authorization_endpoint
|
||||
public_parsed = parse_url(public_issuer)
|
||||
authorization_endpoint = (
|
||||
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
|
||||
)
|
||||
if auth_parsed.query:
|
||||
authorization_endpoint += f"?{auth_parsed.query}"
|
||||
logger.info(
|
||||
f"Rewrote authorization endpoint for browser access: {authorization_endpoint}"
|
||||
)
|
||||
|
||||
idp_params = {
|
||||
"client_id": idp_client_id,
|
||||
"redirect_uri": callback_uri,
|
||||
"response_type": "code",
|
||||
"scope": scopes,
|
||||
"state": idp_state,
|
||||
"prompt": "consent", # Ensure refresh token
|
||||
"resource": f"{oauth_config['mcp_server_url']}/mcp", # MCP server audience
|
||||
}
|
||||
|
||||
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
|
||||
logger.info(f"Redirecting to Nextcloud OIDC: {auth_url.split('?')[0]}")
|
||||
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
|
||||
logger.info(f"Redirecting to Nextcloud OIDC: {auth_url.split('?')[0]}")
|
||||
|
||||
return RedirectResponse(auth_url, status_code=302)
|
||||
|
||||
@@ -355,11 +447,8 @@ async def oauth_authorize_nextcloud(
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
discovery = await _get_cached_discovery(discovery_url)
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
|
||||
# Fix internal hostname for browser access
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
@@ -461,11 +550,17 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
if not discovery_url:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OIDC discovery URL not configured",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
discovery = await _get_cached_discovery(discovery_url)
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
# Build token exchange params
|
||||
token_params = {
|
||||
@@ -599,6 +694,11 @@ async def oauth_callback(request: Request):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Check AS proxy sessions first (in-memory, ADR-023)
|
||||
if state in _as_proxy_sessions:
|
||||
logger.info("Routing to AS proxy callback (ADR-023)")
|
||||
return await _oauth_callback_as_proxy(request, state)
|
||||
|
||||
# Lookup OAuth session to determine flow type
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
if not oauth_ctx:
|
||||
@@ -641,3 +741,580 @@ async def oauth_callback(request: Request):
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AS Proxy endpoints (ADR-023)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _oauth_callback_as_proxy(
|
||||
request: Request, server_state: str
|
||||
) -> RedirectResponse | JSONResponse:
|
||||
"""
|
||||
Handle Nextcloud callback for the AS proxy flow.
|
||||
|
||||
Exchanges the Nextcloud auth code for tokens server-side, generates a
|
||||
proxy authorization code, and redirects back to the client.
|
||||
"""
|
||||
# Check for errors from Nextcloud
|
||||
error = request.query_params.get("error")
|
||||
if error:
|
||||
error_description = request.query_params.get(
|
||||
"error_description", "Authorization failed"
|
||||
)
|
||||
logger.error(f"AS proxy callback error: {error} - {error_description}")
|
||||
|
||||
# Retrieve session to redirect back to client with error
|
||||
session = _as_proxy_sessions.pop(server_state, None)
|
||||
if session:
|
||||
params = urlencode(
|
||||
{
|
||||
"error": error,
|
||||
"error_description": error_description,
|
||||
"state": session.client_state,
|
||||
}
|
||||
)
|
||||
return RedirectResponse(
|
||||
f"{session.client_redirect_uri}?{params}", status_code=302
|
||||
)
|
||||
return JSONResponse(
|
||||
{"error": error, "error_description": error_description},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
code = request.query_params.get("code")
|
||||
if not code:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "code parameter is required",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Retrieve and consume the session (one-time use)
|
||||
session = _as_proxy_sessions.pop(server_state, None)
|
||||
if not session:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Unknown or expired server state",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if session.is_expired:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Authorization session expired",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
mcp_server_client_id = os.getenv(
|
||||
"MCP_SERVER_CLIENT_ID", oauth_config.get("client_id")
|
||||
)
|
||||
mcp_server_client_secret = os.getenv(
|
||||
"MCP_SERVER_CLIENT_SECRET", oauth_config.get("client_secret")
|
||||
)
|
||||
|
||||
if not mcp_server_client_id or not mcp_server_client_secret:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "MCP server OAuth credentials not configured",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||
|
||||
# Discover token endpoint
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if not discovery_url:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OIDC discovery URL not configured",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
discovery = await _get_cached_discovery(discovery_url)
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
# Exchange auth code with Nextcloud (server-side, confidential client, no PKCE)
|
||||
token_params = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": callback_uri,
|
||||
"client_id": mcp_server_client_id,
|
||||
"client_secret": mcp_server_client_secret,
|
||||
}
|
||||
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.post(token_endpoint, data=token_params)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"AS proxy token exchange failed: {response.status_code} {response.text}"
|
||||
)
|
||||
params = urlencode(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "Failed to exchange authorization code",
|
||||
"state": session.client_state,
|
||||
}
|
||||
)
|
||||
return RedirectResponse(
|
||||
f"{session.client_redirect_uri}?{params}", status_code=302
|
||||
)
|
||||
|
||||
nc_token_response = response.json()
|
||||
|
||||
logger.info(
|
||||
"AS proxy: Successfully exchanged code for Nextcloud token "
|
||||
f"(token_type={nc_token_response.get('token_type')})"
|
||||
)
|
||||
|
||||
# Generate a proxy authorization code for the client
|
||||
proxy_code = secrets.token_urlsafe(32)
|
||||
_proxy_codes[proxy_code] = ProxyCodeEntry(
|
||||
client_id=session.client_id,
|
||||
client_redirect_uri=session.client_redirect_uri,
|
||||
client_state=session.client_state,
|
||||
code_challenge=session.code_challenge,
|
||||
code_challenge_method=session.code_challenge_method,
|
||||
nc_token_response=nc_token_response,
|
||||
)
|
||||
|
||||
# Redirect back to client with proxy_code and client's original state
|
||||
redirect_params = urlencode({"code": proxy_code, "state": session.client_state})
|
||||
redirect_url = f"{session.client_redirect_uri}?{redirect_params}"
|
||||
|
||||
logger.info(
|
||||
f"AS proxy: Redirecting to client with proxy_code (client_id={session.client_id})"
|
||||
)
|
||||
return RedirectResponse(redirect_url, status_code=302)
|
||||
|
||||
|
||||
def _verify_pkce_s256(code_verifier: str, code_challenge: str) -> bool:
|
||||
"""Verify PKCE S256 code_verifier against stored code_challenge.
|
||||
|
||||
Per RFC 7636 Section 4.6:
|
||||
code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
|
||||
"""
|
||||
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
||||
computed_challenge = urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
return secrets.compare_digest(computed_challenge, code_challenge)
|
||||
|
||||
|
||||
async def oauth_token_endpoint(request: Request) -> JSONResponse:
|
||||
"""
|
||||
OAuth token endpoint for AS proxy (ADR-023).
|
||||
|
||||
Handles:
|
||||
- grant_type=authorization_code: Exchange proxy_code for Nextcloud token
|
||||
- grant_type=refresh_token: Proxy refresh request to Nextcloud
|
||||
|
||||
Form parameters:
|
||||
grant_type: "authorization_code" or "refresh_token"
|
||||
code: Proxy authorization code (for authorization_code grant)
|
||||
redirect_uri: Must match the original redirect_uri
|
||||
code_verifier: PKCE verifier (for authorization_code grant)
|
||||
client_id: Client identifier
|
||||
client_secret: Client secret (optional for public clients)
|
||||
refresh_token: Refresh token (for refresh_token grant)
|
||||
"""
|
||||
# Parse form body
|
||||
form = await request.form()
|
||||
grant_type = form.get("grant_type")
|
||||
|
||||
if grant_type == "authorization_code":
|
||||
return await _token_authorization_code(request, form)
|
||||
elif grant_type == "refresh_token":
|
||||
return await _token_refresh(request, form)
|
||||
else:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "unsupported_grant_type",
|
||||
"error_description": f"Unsupported grant_type: {grant_type}",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
async def _token_authorization_code(request: Request, form) -> JSONResponse:
|
||||
"""Handle authorization_code grant type at the token endpoint."""
|
||||
code = form.get("code")
|
||||
redirect_uri = form.get("redirect_uri")
|
||||
code_verifier = form.get("code_verifier")
|
||||
client_id = form.get("client_id")
|
||||
|
||||
logger.debug(
|
||||
"AS proxy token: received code=%s client_id=%s redirect_uri=%s "
|
||||
"code_verifier=%s",
|
||||
code[:8] + "..." if code else None,
|
||||
client_id,
|
||||
redirect_uri,
|
||||
"present" if code_verifier else "missing",
|
||||
)
|
||||
|
||||
if not code:
|
||||
logger.warning("AS proxy token: Missing 'code' parameter")
|
||||
return JSONResponse(
|
||||
{"error": "invalid_request", "error_description": "code is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Look up and consume proxy code (one-time use)
|
||||
entry = _proxy_codes.pop(code, None)
|
||||
if not entry:
|
||||
logger.warning(
|
||||
"AS proxy token: Invalid or expired code (active_codes=%d)",
|
||||
len(_proxy_codes),
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Invalid or expired authorization code",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if entry.is_expired:
|
||||
age = time.time() - entry.created_at
|
||||
logger.warning("AS proxy token: Proxy code expired (age=%.1fs, TTL=60s)", age)
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Authorization code has expired",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate client_id (required per RFC 6749 Section 4.1.3)
|
||||
if not client_id:
|
||||
logger.warning("AS proxy token: Missing 'client_id' parameter")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "client_id is required",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if client_id != entry.client_id:
|
||||
logger.warning(
|
||||
"AS proxy token: client_id mismatch (got=%s, expected=%s)",
|
||||
client_id,
|
||||
entry.client_id,
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "client_id mismatch",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate redirect_uri (required per RFC 6749 Section 4.1.3)
|
||||
if not redirect_uri:
|
||||
logger.warning("AS proxy token: Missing 'redirect_uri' parameter")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "redirect_uri is required",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if redirect_uri != entry.client_redirect_uri:
|
||||
logger.warning(
|
||||
"AS proxy token: redirect_uri mismatch (got=%s, expected=%s)",
|
||||
redirect_uri,
|
||||
entry.client_redirect_uri,
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "redirect_uri mismatch",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Verify PKCE (always required — oauth_authorize mandates code_challenge)
|
||||
if not entry.code_challenge:
|
||||
logger.error("AS proxy token: code_challenge missing from stored entry")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "Internal state error: missing PKCE challenge",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
if not code_verifier:
|
||||
logger.warning("AS proxy token: Missing 'code_verifier' (PKCE required)")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "code_verifier is required (PKCE)",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not _verify_pkce_s256(code_verifier, entry.code_challenge):
|
||||
logger.warning(f"PKCE verification failed for client {entry.client_id}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "PKCE verification failed",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"AS proxy token: Returning Nextcloud token for client {entry.client_id}"
|
||||
)
|
||||
|
||||
# Return the stored Nextcloud token response directly
|
||||
return JSONResponse(entry.nc_token_response)
|
||||
|
||||
|
||||
async def _token_refresh(request: Request, form) -> JSONResponse:
|
||||
"""Handle refresh_token grant type by proxying to Nextcloud."""
|
||||
refresh_token = form.get("refresh_token")
|
||||
if not refresh_token:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "refresh_token is required",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
if not oauth_ctx:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth not configured on server",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
mcp_server_client_id = os.getenv(
|
||||
"MCP_SERVER_CLIENT_ID", oauth_config.get("client_id")
|
||||
)
|
||||
mcp_server_client_secret = os.getenv(
|
||||
"MCP_SERVER_CLIENT_SECRET", oauth_config.get("client_secret")
|
||||
)
|
||||
|
||||
if not mcp_server_client_id or not mcp_server_client_secret:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "MCP server OAuth credentials not configured",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
|
||||
# Discover token endpoint
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if not discovery_url:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OIDC discovery URL not configured",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
discovery = await _get_cached_discovery(discovery_url)
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
# Proxy refresh request to Nextcloud
|
||||
token_params = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": mcp_server_client_id,
|
||||
"client_secret": mcp_server_client_secret,
|
||||
"resource": f"{mcp_server_url}/mcp",
|
||||
}
|
||||
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.post(token_endpoint, data=token_params)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"AS proxy token refresh failed: {response.status_code} {response.text}"
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Token refresh failed",
|
||||
},
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
return JSONResponse(response.json())
|
||||
|
||||
|
||||
async def oauth_register_proxy(request: Request) -> JSONResponse:
|
||||
"""
|
||||
DCR proxy endpoint for AS proxy (ADR-023).
|
||||
|
||||
Proxies Dynamic Client Registration requests to Nextcloud's OIDC endpoint
|
||||
and registers the resulting client in the local ClientRegistry.
|
||||
|
||||
This allows MCP clients to register via the MCP server (their AS) rather
|
||||
than directly with Nextcloud (which would produce tokens with wrong audience).
|
||||
"""
|
||||
# Parse JSON body
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Request body must be valid JSON",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get OAuth context for Nextcloud endpoint
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
if not oauth_ctx:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth not configured on server",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
oauth_config = oauth_ctx["config"]
|
||||
nextcloud_host = oauth_config["nextcloud_host"]
|
||||
|
||||
# Rate limit DCR requests per client IP
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
now = time.time()
|
||||
timestamps = _dcr_rate_limit.get(client_ip, [])
|
||||
# Remove timestamps outside the window
|
||||
timestamps = [t for t in timestamps if now - t < _DCR_RATE_LIMIT_WINDOW]
|
||||
if len(timestamps) >= _DCR_RATE_LIMIT_MAX:
|
||||
logger.warning(f"DCR rate limit exceeded for {client_ip}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "too_many_requests",
|
||||
"error_description": "Rate limit exceeded for client registration",
|
||||
},
|
||||
status_code=429,
|
||||
headers={"Retry-After": str(_DCR_RATE_LIMIT_WINDOW)},
|
||||
)
|
||||
timestamps.append(now)
|
||||
_dcr_rate_limit[client_ip] = timestamps
|
||||
|
||||
# Discover registration endpoint from OIDC discovery (prefer over hardcoded path)
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if discovery_url:
|
||||
try:
|
||||
discovery = await _get_cached_discovery(discovery_url)
|
||||
registration_endpoint = discovery.get(
|
||||
"registration_endpoint", f"{nextcloud_host}/apps/oidc/register"
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to fetch OIDC discovery for DCR endpoint, using fallback"
|
||||
)
|
||||
registration_endpoint = f"{nextcloud_host}/apps/oidc/register"
|
||||
else:
|
||||
registration_endpoint = f"{nextcloud_host}/apps/oidc/register"
|
||||
|
||||
logger.info(f"DCR proxy: Forwarding registration to {registration_endpoint}")
|
||||
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.post(
|
||||
registration_endpoint,
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
logger.error(
|
||||
f"DCR proxy: Nextcloud registration failed: {response.status_code} {response.text}"
|
||||
)
|
||||
return JSONResponse(
|
||||
response.json()
|
||||
if response.headers.get("content-type", "").startswith("application/json")
|
||||
else {
|
||||
"error": "server_error",
|
||||
"error_description": f"Upstream registration failed: {response.status_code}",
|
||||
},
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
nc_response = response.json()
|
||||
new_client_id = nc_response.get("client_id")
|
||||
|
||||
if new_client_id:
|
||||
# Register in local ClientRegistry so /oauth/authorize accepts it
|
||||
redirect_uris = nc_response.get("redirect_uris", [])
|
||||
client_name = nc_response.get("client_name", "")
|
||||
registry = get_client_registry()
|
||||
registry.register_proxy_client(
|
||||
client_id=new_client_id,
|
||||
redirect_uris=redirect_uris,
|
||||
name=client_name,
|
||||
)
|
||||
logger.info(f"DCR proxy: Registered client {new_client_id} in local registry")
|
||||
|
||||
return JSONResponse(nc_response, status_code=response.status_code)
|
||||
|
||||
|
||||
async def oauth_as_metadata(request: Request) -> JSONResponse:
|
||||
"""
|
||||
RFC 8414 OAuth Authorization Server Metadata endpoint (ADR-023).
|
||||
|
||||
Advertises the MCP server as its own OAuth Authorization Server so that
|
||||
MCP clients (e.g., Claude Code) authenticate through the proxy rather
|
||||
than directly with Nextcloud.
|
||||
"""
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
|
||||
# Dynamically discover scopes from registered tools if available
|
||||
scopes_supported = ["openid", "profile", "email"]
|
||||
app_scopes = getattr(request.app.state, "supported_scopes", None)
|
||||
if app_scopes:
|
||||
scopes_supported = app_scopes
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"issuer": mcp_server_url,
|
||||
"authorization_endpoint": f"{mcp_server_url}/oauth/authorize",
|
||||
"token_endpoint": f"{mcp_server_url}/oauth/token",
|
||||
"registration_endpoint": f"{mcp_server_url}/oauth/register",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": [
|
||||
"client_secret_post",
|
||||
"client_secret_basic",
|
||||
"none",
|
||||
],
|
||||
"scopes_supported": scopes_supported,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Scope-based authorization for MCP tools."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
@@ -9,10 +10,18 @@ from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.fastmcp import Context
|
||||
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
|
||||
|
||||
from nextcloud_mcp_server.auth.storage import get_shared_storage
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Scopes that only assert identity (OIDC standard claims).
|
||||
# Tools requiring *only* these scopes (e.g. auth provisioning tools) must
|
||||
# bypass the Login Flow v2 "is the user provisioned?" check — otherwise the
|
||||
# very tools that *create* app passwords would be blocked for unprovisioned
|
||||
# users, creating a circular dependency.
|
||||
IDENTITY_ONLY_SCOPES: frozenset[str] = frozenset({"openid", "profile", "email"})
|
||||
|
||||
|
||||
class ScopeAuthorizationError(Exception):
|
||||
"""Raised when a request lacks required scopes."""
|
||||
@@ -120,13 +129,61 @@ def require_scopes(*required_scopes: str):
|
||||
)
|
||||
|
||||
if access_token is None:
|
||||
# Not in OAuth mode (BasicAuth or no auth)
|
||||
# In BasicAuth mode, all operations are allowed
|
||||
# No OAuth token — BasicAuth mode bypasses scope checks
|
||||
logger.debug(
|
||||
f"No access token present for {func_name} - allowing (BasicAuth mode)"
|
||||
f"No access token for {func_name} - allowing (BasicAuth mode)"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# ── Login Flow v2: Check stored app password scopes ──
|
||||
# In Login Flow v2 multi-user mode, OAuth tokens provide MCP session
|
||||
# identity only. Nextcloud API access uses stored app passwords.
|
||||
# Check if the user has a stored app password with appropriate scopes.
|
||||
if get_settings().enable_login_flow and not set(required_scopes).issubset(
|
||||
IDENTITY_ONLY_SCOPES
|
||||
):
|
||||
from nextcloud_mcp_server.auth.token_utils import ( # noqa: PLC0415
|
||||
extract_user_id_from_token,
|
||||
)
|
||||
|
||||
user_id = await extract_user_id_from_token(ctx)
|
||||
if user_id and user_id != "default_user":
|
||||
stored_scopes = await _get_stored_scopes(user_id)
|
||||
|
||||
if stored_scopes is None:
|
||||
# No stored app password → require provisioning
|
||||
error_msg = (
|
||||
f"Access denied to {func_name}: "
|
||||
f"Nextcloud access not provisioned. "
|
||||
f"Please call 'nc_auth_provision_access' first."
|
||||
)
|
||||
logger.warning(error_msg)
|
||||
raise ProvisioningRequiredError(error_msg)
|
||||
|
||||
if stored_scopes == "all":
|
||||
# NULL scopes in DB = legacy app password = all allowed
|
||||
logger.debug(
|
||||
f"Stored app password scope check passed for {func_name}: all scopes"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Check stored scopes against required
|
||||
stored_set = set(stored_scopes)
|
||||
missing = set(required_scopes) - stored_set
|
||||
if missing:
|
||||
error_msg = (
|
||||
f"Access denied to {func_name}: "
|
||||
f"Missing scopes: {', '.join(sorted(missing))}. "
|
||||
f"Call 'nc_auth_update_scopes' to add permissions."
|
||||
)
|
||||
logger.warning(error_msg)
|
||||
raise InsufficientScopeError(list(missing), error_msg)
|
||||
|
||||
logger.debug(
|
||||
f"Stored app password scope check passed for {func_name}"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Extract scopes from access token
|
||||
token_scopes = set(access_token.scopes or [])
|
||||
required_scopes_set = set(required_scopes)
|
||||
@@ -416,3 +473,47 @@ def discover_all_scopes(mcp) -> list[str]:
|
||||
|
||||
# Return sorted list of unique scopes
|
||||
return sorted(all_scopes)
|
||||
|
||||
|
||||
# ── Login Flow v2 helpers ────────────────────────────────────────────────
|
||||
|
||||
# Scope cache: user_id → (expires_at, scopes)
|
||||
_scope_cache: dict[str, tuple[float, list[str] | str | None]] = {}
|
||||
_SCOPE_CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
|
||||
def invalidate_scope_cache(user_id: str) -> None:
|
||||
"""Remove cached scopes for a user (call when scopes are updated)."""
|
||||
_scope_cache.pop(user_id, None)
|
||||
|
||||
|
||||
async def _get_stored_scopes(user_id: str) -> list[str] | str | None:
|
||||
"""Look up stored app password scopes for a user (with TTL cache).
|
||||
|
||||
Returns:
|
||||
- list[str]: Specific scopes granted
|
||||
- "all": NULL scopes in DB (legacy = all allowed)
|
||||
- None: No stored app password (provisioning required)
|
||||
|
||||
Raises:
|
||||
Storage/infrastructure exceptions propagate to the caller
|
||||
(require_scopes decorator) for proper MCP error responses.
|
||||
"""
|
||||
now = time.time()
|
||||
if user_id in _scope_cache:
|
||||
expires_at, cached = _scope_cache[user_id]
|
||||
if now < expires_at:
|
||||
return cached
|
||||
|
||||
storage = await get_shared_storage()
|
||||
|
||||
data = await storage.get_app_password_with_scopes(user_id)
|
||||
if data is None:
|
||||
result = None
|
||||
elif data["scopes"] is None:
|
||||
result = "all"
|
||||
else:
|
||||
result = data["scopes"]
|
||||
|
||||
_scope_cache[user_id] = (now + _SCOPE_CACHE_TTL, result)
|
||||
return result
|
||||
|
||||
@@ -1477,6 +1477,420 @@ class RefreshTokenStorage:
|
||||
|
||||
return removed
|
||||
|
||||
# ── Login Flow v2: Scoped App Passwords ──────────────────────────────
|
||||
|
||||
async def store_app_password_with_scopes(
|
||||
self,
|
||||
user_id: str,
|
||||
app_password: str,
|
||||
scopes: list[str] | None = None,
|
||||
username: str | None = None,
|
||||
) -> None:
|
||||
"""Store encrypted app password with optional scopes and Nextcloud username.
|
||||
|
||||
Args:
|
||||
user_id: MCP user ID (identity from OAuth token or session)
|
||||
app_password: Nextcloud app password to encrypt and store
|
||||
scopes: List of granted scopes (None = all scopes allowed)
|
||||
username: Nextcloud loginName from Login Flow v2 response
|
||||
|
||||
Raises:
|
||||
ValueError: If any scope is not in ALL_SUPPORTED_SCOPES
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
if not self.cipher:
|
||||
raise RuntimeError(
|
||||
"Encryption key not configured. "
|
||||
"Set TOKEN_ENCRYPTION_KEY for app password storage."
|
||||
)
|
||||
|
||||
# Defense-in-depth: validate scopes at storage layer
|
||||
if scopes is not None:
|
||||
from nextcloud_mcp_server.models.auth import ( # noqa: PLC0415
|
||||
ALL_SUPPORTED_SCOPES,
|
||||
)
|
||||
|
||||
invalid = [s for s in scopes if s not in ALL_SUPPORTED_SCOPES]
|
||||
if invalid:
|
||||
raise ValueError(f"Invalid scopes: {invalid}")
|
||||
|
||||
encrypted_password = self.cipher.encrypt(app_password.encode())
|
||||
scopes_json = json.dumps(scopes) if scopes is not None else None
|
||||
now = int(time.time())
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO app_passwords
|
||||
(user_id, encrypted_password, created_at, updated_at, scopes, username)
|
||||
VALUES (
|
||||
?,
|
||||
?,
|
||||
COALESCE((SELECT created_at FROM app_passwords WHERE user_id = ?), ?),
|
||||
?,
|
||||
?,
|
||||
?
|
||||
)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
encrypted_password,
|
||||
user_id,
|
||||
now,
|
||||
now,
|
||||
scopes_json,
|
||||
username,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "insert", duration, "success")
|
||||
logger.info(
|
||||
f"Stored scoped app password for user {user_id} "
|
||||
f"(scopes={'all' if scopes is None else len(scopes)}, "
|
||||
f"username={username or 'N/A'})"
|
||||
)
|
||||
|
||||
except Exception:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "insert", duration, "error")
|
||||
raise
|
||||
|
||||
await self._audit_log(
|
||||
event="store_app_password_with_scopes",
|
||||
user_id=user_id,
|
||||
auth_method="app_password",
|
||||
)
|
||||
|
||||
async def get_app_password_with_scopes(self, user_id: str) -> dict[str, Any] | None:
|
||||
"""Retrieve app password with scopes and metadata.
|
||||
|
||||
Args:
|
||||
user_id: MCP user ID
|
||||
|
||||
Returns:
|
||||
Dict with keys: app_password, scopes, username, created_at, updated_at
|
||||
or None if not found
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
if not self.cipher:
|
||||
raise RuntimeError(
|
||||
"Encryption key not configured. "
|
||||
"Set TOKEN_ENCRYPTION_KEY for app password retrieval."
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"""
|
||||
SELECT encrypted_password, scopes, username, created_at, updated_at
|
||||
FROM app_passwords WHERE user_id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
logger.debug(f"No app password found for user {user_id}")
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "success")
|
||||
return None
|
||||
|
||||
encrypted_password, scopes_json, username, created_at, updated_at = row
|
||||
decrypted_password = self.cipher.decrypt(encrypted_password).decode()
|
||||
scopes = json.loads(scopes_json) if scopes_json else None
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "success")
|
||||
|
||||
return {
|
||||
"app_password": decrypted_password,
|
||||
"scopes": scopes,
|
||||
"username": username,
|
||||
"created_at": created_at,
|
||||
"updated_at": updated_at,
|
||||
}
|
||||
|
||||
except Exception:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "error")
|
||||
raise
|
||||
|
||||
async def update_app_password_scopes(self, user_id: str, scopes: list[str]) -> bool:
|
||||
"""Update only the scopes for an existing app password (no decrypt/re-encrypt).
|
||||
|
||||
Args:
|
||||
user_id: MCP user ID
|
||||
scopes: New scope list
|
||||
|
||||
Returns:
|
||||
True if a row was updated, False if user not found
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
scopes_json = json.dumps(scopes)
|
||||
now = int(time.time())
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"UPDATE app_passwords SET scopes = ?, updated_at = ? WHERE user_id = ?",
|
||||
(scopes_json, now, user_id),
|
||||
)
|
||||
await db.commit()
|
||||
updated = cursor.rowcount > 0
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "update", duration, "success")
|
||||
|
||||
if updated:
|
||||
await self._audit_log(
|
||||
event="update_app_password_scopes",
|
||||
user_id=user_id,
|
||||
auth_method="app_password",
|
||||
)
|
||||
|
||||
return updated
|
||||
|
||||
except Exception:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "update", duration, "error")
|
||||
raise
|
||||
|
||||
# ── Login Flow v2: Session Tracking ──────────────────────────────────
|
||||
|
||||
async def store_login_flow_session(
|
||||
self,
|
||||
user_id: str,
|
||||
poll_token: str,
|
||||
poll_endpoint: str,
|
||||
requested_scopes: list[str] | None = None,
|
||||
expires_at: int | None = None,
|
||||
) -> None:
|
||||
"""Store a Login Flow v2 polling session.
|
||||
|
||||
Args:
|
||||
user_id: MCP user ID
|
||||
poll_token: Token for polling (will be encrypted)
|
||||
poll_endpoint: URL to poll for completion
|
||||
requested_scopes: Scopes requested in this flow
|
||||
expires_at: Expiration timestamp (defaults to 20 minutes from now)
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
if not self.cipher:
|
||||
raise RuntimeError(
|
||||
"Encryption key not configured. "
|
||||
"Set TOKEN_ENCRYPTION_KEY for login flow session storage."
|
||||
)
|
||||
|
||||
encrypted_token = self.cipher.encrypt(poll_token.encode())
|
||||
scopes_json = json.dumps(requested_scopes) if requested_scopes else None
|
||||
now = int(time.time())
|
||||
if expires_at is None:
|
||||
expires_at = now + 1200 # 20 minutes default
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO login_flow_sessions
|
||||
(user_id, encrypted_poll_token, poll_endpoint, requested_scopes,
|
||||
created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
encrypted_token,
|
||||
poll_endpoint,
|
||||
scopes_json,
|
||||
now,
|
||||
expires_at,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "insert", duration, "success")
|
||||
logger.info(f"Stored login flow session for user {user_id}")
|
||||
|
||||
except Exception:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "insert", duration, "error")
|
||||
raise
|
||||
|
||||
async def get_login_flow_session(self, user_id: str) -> dict[str, Any] | None:
|
||||
"""Retrieve a pending Login Flow v2 session.
|
||||
|
||||
Returns None if session doesn't exist or has expired.
|
||||
|
||||
Args:
|
||||
user_id: MCP user ID
|
||||
|
||||
Returns:
|
||||
Dict with keys: poll_token, poll_endpoint, requested_scopes, created_at, expires_at
|
||||
or None if not found/expired
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
if not self.cipher:
|
||||
raise RuntimeError(
|
||||
"Encryption key not configured. "
|
||||
"Set TOKEN_ENCRYPTION_KEY for login flow session retrieval."
|
||||
)
|
||||
|
||||
now = int(time.time())
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"""
|
||||
SELECT encrypted_poll_token, poll_endpoint, requested_scopes,
|
||||
created_at, expires_at
|
||||
FROM login_flow_sessions
|
||||
WHERE user_id = ? AND expires_at > ?
|
||||
""",
|
||||
(user_id, now),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "success")
|
||||
return None
|
||||
|
||||
encrypted_token, poll_endpoint, scopes_json, created_at, expires_at = row
|
||||
poll_token = self.cipher.decrypt(encrypted_token).decode()
|
||||
requested_scopes = json.loads(scopes_json) if scopes_json else None
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "success")
|
||||
|
||||
return {
|
||||
"poll_token": poll_token,
|
||||
"poll_endpoint": poll_endpoint,
|
||||
"requested_scopes": requested_scopes,
|
||||
"created_at": created_at,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "error")
|
||||
logger.error(
|
||||
f"Failed to retrieve login flow session for user {user_id}: {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
async def delete_login_flow_session(self, user_id: str) -> bool:
|
||||
"""Delete a Login Flow v2 session.
|
||||
|
||||
Args:
|
||||
user_id: MCP user ID
|
||||
|
||||
Returns:
|
||||
True if session was deleted, False if not found
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM login_flow_sessions WHERE user_id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount > 0
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "delete", duration, "success")
|
||||
|
||||
if deleted:
|
||||
logger.info(f"Deleted login flow session for user {user_id}")
|
||||
await self._audit_log(
|
||||
event="delete_login_flow_session",
|
||||
user_id=user_id,
|
||||
auth_method="login_flow",
|
||||
)
|
||||
|
||||
return deleted
|
||||
|
||||
except Exception:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "delete", duration, "error")
|
||||
raise
|
||||
|
||||
async def delete_expired_login_flow_sessions(self) -> int:
|
||||
"""Delete all expired Login Flow v2 sessions.
|
||||
|
||||
Returns:
|
||||
Number of sessions deleted
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
now = int(time.time())
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM login_flow_sessions WHERE expires_at <= ?",
|
||||
(now,),
|
||||
)
|
||||
await db.commit()
|
||||
count = cursor.rowcount
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "delete", duration, "success")
|
||||
|
||||
if count > 0:
|
||||
logger.info(f"Cleaned up {count} expired login flow sessions")
|
||||
await self._audit_log(
|
||||
event="delete_expired_login_flow_sessions",
|
||||
user_id="system",
|
||||
auth_method="login_flow",
|
||||
)
|
||||
|
||||
return count
|
||||
|
||||
except Exception:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "delete", duration, "error")
|
||||
raise
|
||||
|
||||
|
||||
_shared_instance: RefreshTokenStorage | None = None
|
||||
_shared_lock: anyio.Lock = anyio.Lock()
|
||||
|
||||
|
||||
async def get_shared_storage() -> RefreshTokenStorage:
|
||||
"""Get the process-wide RefreshTokenStorage singleton (lock-protected).
|
||||
|
||||
All modules that need storage should use this function instead of
|
||||
creating their own lazy singletons. The lock ensures thread-safe
|
||||
initialization on concurrent first-access.
|
||||
"""
|
||||
global _shared_instance
|
||||
async with _shared_lock:
|
||||
if _shared_instance is None:
|
||||
_shared_instance = RefreshTokenStorage.from_env()
|
||||
await _shared_instance.initialize()
|
||||
return _shared_instance
|
||||
|
||||
|
||||
async def generate_encryption_key() -> str:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Token utility functions for extracting user identity from MCP access tokens.
|
||||
|
||||
Extracted from server/oauth_tools.py to break circular import dependencies
|
||||
between server/ and auth/ layers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import jwt
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def extract_user_id_from_token(ctx: Context) -> str:
|
||||
"""Extract user_id from the MCP access token (Flow 1).
|
||||
|
||||
Handles both JWT and opaque tokens:
|
||||
- JWT: Decode and extract 'sub' claim
|
||||
- Opaque: Call userinfo endpoint to get 'sub'
|
||||
|
||||
Args:
|
||||
ctx: MCP context with access token
|
||||
|
||||
Returns:
|
||||
user_id extracted from token, or "default_user" as fallback
|
||||
"""
|
||||
# Use MCP SDK's get_access_token() which uses contextvars
|
||||
access_token: AccessToken | None = get_access_token()
|
||||
|
||||
if not access_token or not access_token.token:
|
||||
logger.warning(" ✗ No access token found via get_access_token()")
|
||||
return "default_user"
|
||||
|
||||
token = access_token.token
|
||||
is_jwt = "." in token and token.count(".") >= 2
|
||||
logger.info(f" Token type: {'JWT' if is_jwt else 'Opaque'}")
|
||||
|
||||
# Try JWT decode first
|
||||
if is_jwt:
|
||||
try:
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
user_id = payload.get("sub", "unknown")
|
||||
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
|
||||
return user_id
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ JWT decode failed: {type(e).__name__}: {e}")
|
||||
|
||||
# Opaque token - call userinfo endpoint
|
||||
logger.info(" Opaque token detected, calling userinfo endpoint...")
|
||||
try:
|
||||
# Get userinfo endpoint from OIDC discovery
|
||||
oidc_discovery_uri = os.getenv(
|
||||
"OIDC_DISCOVERY_URI",
|
||||
"http://localhost:8080/.well-known/openid-configuration",
|
||||
)
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
discovery_response = await http_client.get(oidc_discovery_uri)
|
||||
discovery_response.raise_for_status()
|
||||
discovery = discovery_response.json()
|
||||
userinfo_endpoint = discovery.get("userinfo_endpoint")
|
||||
|
||||
if userinfo_endpoint:
|
||||
userinfo = await _query_idp_userinfo(token, userinfo_endpoint)
|
||||
if userinfo:
|
||||
user_id = userinfo.get("sub", "unknown")
|
||||
logger.info(f" ✓ Userinfo query successful: user_id={user_id}")
|
||||
return user_id
|
||||
else:
|
||||
logger.error(" ✗ Userinfo query failed")
|
||||
else:
|
||||
logger.error(" ✗ No userinfo_endpoint available")
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Userinfo query failed: {type(e).__name__}: {e}")
|
||||
|
||||
# Fallback
|
||||
logger.warning(" Using fallback user_id: default_user")
|
||||
return "default_user"
|
||||
@@ -5,7 +5,7 @@ import socket
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
|
||||
class DeploymentMode(Enum):
|
||||
@@ -169,31 +169,32 @@ class Settings:
|
||||
# Optional: If not set, mode is auto-detected from other settings
|
||||
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
|
||||
# oauth_token_exchange, smithery
|
||||
deployment_mode: Optional[str] = None
|
||||
deployment_mode: str | None = None
|
||||
|
||||
# OAuth/OIDC settings
|
||||
oidc_discovery_url: Optional[str] = None
|
||||
oidc_client_id: Optional[str] = None
|
||||
oidc_client_secret: Optional[str] = None
|
||||
oidc_issuer: Optional[str] = None
|
||||
oidc_discovery_url: str | None = None
|
||||
oidc_client_id: str | None = None
|
||||
oidc_client_secret: str | None = None
|
||||
oidc_issuer: str | None = None
|
||||
|
||||
# Nextcloud settings
|
||||
nextcloud_host: Optional[str] = None
|
||||
nextcloud_username: Optional[str] = None
|
||||
nextcloud_password: Optional[str] = None
|
||||
nextcloud_host: str | None = None
|
||||
nextcloud_username: str | None = None
|
||||
nextcloud_password: str | None = None
|
||||
nextcloud_app_password: str | None = None # Preferred over nextcloud_password
|
||||
|
||||
# Nextcloud SSL/TLS settings
|
||||
nextcloud_verify_ssl: bool = True
|
||||
nextcloud_ca_bundle: Optional[str] = None
|
||||
nextcloud_ca_bundle: str | None = None
|
||||
|
||||
# ADR-005: Token Audience Validation (required for OAuth mode)
|
||||
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
|
||||
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
|
||||
nextcloud_mcp_server_url: str | None = None # MCP server URL (used as audience)
|
||||
nextcloud_resource_uri: str | None = None # Nextcloud resource identifier
|
||||
|
||||
# Token verification endpoints
|
||||
jwks_uri: Optional[str] = None
|
||||
introspection_uri: Optional[str] = None
|
||||
userinfo_uri: Optional[str] = None
|
||||
jwks_uri: str | None = None
|
||||
introspection_uri: str | None = None
|
||||
userinfo_uri: str | None = None
|
||||
|
||||
# Progressive Consent settings (always enabled - no flag needed)
|
||||
enable_token_exchange: bool = False
|
||||
@@ -204,6 +205,9 @@ class Settings:
|
||||
# and passes them through to Nextcloud APIs (no storage, stateless)
|
||||
enable_multi_user_basic_auth: bool = False
|
||||
|
||||
# Login Flow v2 settings (ADR-022)
|
||||
enable_login_flow: bool = False
|
||||
|
||||
# Token exchange cache settings
|
||||
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
|
||||
|
||||
@@ -214,8 +218,8 @@ class Settings:
|
||||
# TOKEN_STORAGE_DB: Path to SQLite database for persistent storage.
|
||||
# Used for webhook tracking (all modes) and OAuth token storage.
|
||||
# Defaults to /tmp/tokens.db
|
||||
token_encryption_key: Optional[str] = None
|
||||
token_storage_db: Optional[str] = None
|
||||
token_encryption_key: str | None = None
|
||||
token_storage_db: str | None = None
|
||||
|
||||
# Vector sync settings (ADR-007)
|
||||
vector_sync_enabled: bool = False
|
||||
@@ -225,19 +229,19 @@ class Settings:
|
||||
vector_sync_user_poll_interval: int = 60 # seconds - OAuth mode user discovery
|
||||
|
||||
# Qdrant settings (mutually exclusive modes)
|
||||
qdrant_url: Optional[str] = None # Network mode: http://qdrant:6333
|
||||
qdrant_location: Optional[str] = None # Local mode: :memory: or /path/to/data
|
||||
qdrant_api_key: Optional[str] = None
|
||||
qdrant_url: str | None = None # Network mode: http://qdrant:6333
|
||||
qdrant_location: str | None = None # Local mode: :memory: or /path/to/data
|
||||
qdrant_api_key: str | None = None
|
||||
qdrant_collection: str = "nextcloud_content"
|
||||
|
||||
# Ollama settings (for embeddings)
|
||||
ollama_base_url: Optional[str] = None
|
||||
ollama_base_url: str | None = None
|
||||
ollama_embedding_model: str = "nomic-embed-text"
|
||||
ollama_verify_ssl: bool = True
|
||||
|
||||
# OpenAI settings (for embeddings)
|
||||
openai_api_key: Optional[str] = None
|
||||
openai_base_url: Optional[str] = None
|
||||
openai_api_key: str | None = None
|
||||
openai_base_url: str | None = None
|
||||
openai_embedding_model: str = "text-embedding-3-small"
|
||||
|
||||
# Document chunking settings (for vector embeddings)
|
||||
@@ -247,7 +251,7 @@ class Settings:
|
||||
# Observability settings
|
||||
metrics_enabled: bool = True
|
||||
metrics_port: int = 9090
|
||||
otel_exporter_otlp_endpoint: Optional[str] = None
|
||||
otel_exporter_otlp_endpoint: str | None = None
|
||||
otel_exporter_verify_ssl: bool = False
|
||||
otel_service_name: str = "nextcloud-mcp-server"
|
||||
otel_traces_sampler: str = "always_on"
|
||||
@@ -523,6 +527,7 @@ def get_settings() -> Settings:
|
||||
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
||||
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
|
||||
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
|
||||
nextcloud_app_password=os.getenv("NEXTCLOUD_APP_PASSWORD"),
|
||||
# Nextcloud SSL/TLS settings
|
||||
nextcloud_verify_ssl=(
|
||||
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
|
||||
@@ -544,6 +549,8 @@ def get_settings() -> Settings:
|
||||
enable_multi_user_basic_auth=(
|
||||
os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true"
|
||||
),
|
||||
# Login Flow v2 settings (ADR-022)
|
||||
enable_login_flow=(os.getenv("ENABLE_LOGIN_FLOW", "false").lower() == "true"),
|
||||
# Token exchange cache settings
|
||||
token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
|
||||
# Token and webhook storage settings (encryption key optional for webhook-only usage)
|
||||
|
||||
@@ -9,6 +9,8 @@ from nextcloud_mcp_server.auth.context_helper import (
|
||||
get_client_from_context,
|
||||
get_session_client_from_context,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
|
||||
from nextcloud_mcp_server.auth.storage import get_shared_storage
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import (
|
||||
DeploymentMode,
|
||||
@@ -78,6 +80,11 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
|
||||
# Login Flow v2 multi-user mode: app password is REQUIRED for NC API access
|
||||
# OAuth token is only used for MCP session identity, not NC API calls
|
||||
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_login_flow:
|
||||
return await _get_client_from_login_flow(ctx, lifespan_ctx.nextcloud_host)
|
||||
|
||||
# BasicAuth mode - use shared client (no token exchange)
|
||||
if hasattr(lifespan_ctx, "client"):
|
||||
return lifespan_ctx.client
|
||||
@@ -245,3 +252,51 @@ def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient:
|
||||
username=username,
|
||||
auth=BasicAuth(username, password),
|
||||
)
|
||||
|
||||
|
||||
async def _get_client_from_login_flow(
|
||||
ctx: Context, nextcloud_host: str
|
||||
) -> NextcloudClient:
|
||||
"""Create NextcloudClient from stored Login Flow v2 app password.
|
||||
|
||||
In Login Flow v2 mode, the OAuth token only provides MCP session identity.
|
||||
Nextcloud API calls always use the stored app password obtained via Login Flow v2.
|
||||
|
||||
Args:
|
||||
ctx: MCP context (used to extract user identity)
|
||||
nextcloud_host: Nextcloud instance URL
|
||||
|
||||
Returns:
|
||||
NextcloudClient with stored app password credentials
|
||||
|
||||
Raises:
|
||||
ProvisioningRequiredError: If no stored app password exists
|
||||
"""
|
||||
from nextcloud_mcp_server.auth.token_utils import ( # noqa: PLC0415
|
||||
extract_user_id_from_token,
|
||||
)
|
||||
|
||||
user_id = await extract_user_id_from_token(ctx)
|
||||
if not user_id or user_id == "default_user":
|
||||
raise ProvisioningRequiredError(
|
||||
"Cannot determine user identity from MCP token."
|
||||
)
|
||||
|
||||
storage = await get_shared_storage()
|
||||
|
||||
app_data = await storage.get_app_password_with_scopes(user_id)
|
||||
if not app_data:
|
||||
raise ProvisioningRequiredError(
|
||||
"Nextcloud access not provisioned. "
|
||||
"Call nc_auth_provision_access to complete Login Flow."
|
||||
)
|
||||
|
||||
username = app_data.get("username") or user_id
|
||||
|
||||
logger.debug(f"Creating Login Flow v2 client for {nextcloud_host} as {username}")
|
||||
|
||||
return NextcloudClient(
|
||||
base_url=nextcloud_host,
|
||||
username=username,
|
||||
auth=BasicAuth(username, app_data["app_password"]),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Pydantic response models for Login Flow v2 auth tools."""
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from nextcloud_mcp_server.models.base import BaseResponse
|
||||
|
||||
|
||||
class ProvisionAccessResponse(BaseResponse):
|
||||
"""Response from nc_auth_provision_access tool."""
|
||||
|
||||
status: str = Field(
|
||||
description="Provisioning status: 'login_required', 'already_provisioned', 'declined', 'cancelled', 'error'"
|
||||
)
|
||||
login_url: str | None = Field(
|
||||
None, description="URL to open in browser for Nextcloud login"
|
||||
)
|
||||
message: str = Field(description="Human-readable status message")
|
||||
user_id: str | None = Field(None, description="MCP user ID")
|
||||
requested_scopes: list[str] | None = Field(
|
||||
None, description="Scopes requested in this provisioning flow"
|
||||
)
|
||||
|
||||
|
||||
class ProvisionStatusResponse(BaseResponse):
|
||||
"""Response from nc_auth_check_status tool."""
|
||||
|
||||
status: str = Field(
|
||||
description="Status: 'provisioned', 'pending', 'not_initiated', 'error'"
|
||||
)
|
||||
message: str = Field(description="Human-readable status message")
|
||||
user_id: str | None = Field(None, description="MCP user ID")
|
||||
scopes: list[str] | None = Field(
|
||||
None, description="Granted scopes (None = all scopes)"
|
||||
)
|
||||
username: str | None = Field(None, description="Nextcloud username (loginName)")
|
||||
|
||||
|
||||
class UpdateScopesResponse(BaseResponse):
|
||||
"""Response from nc_auth_update_scopes tool."""
|
||||
|
||||
status: str = Field(
|
||||
description="Status: 'login_required', 'unchanged', 'declined', 'cancelled', 'error'"
|
||||
)
|
||||
login_url: str | None = Field(
|
||||
None, description="URL for re-provisioning with new scopes"
|
||||
)
|
||||
message: str = Field(description="Human-readable status message")
|
||||
previous_scopes: list[str] | None = Field(
|
||||
None, description="Previously granted scopes"
|
||||
)
|
||||
new_scopes: list[str] | None = Field(None, description="Updated scope set")
|
||||
|
||||
|
||||
# All supported application-level scopes (frozenset for O(1) membership tests)
|
||||
ALL_SUPPORTED_SCOPES: frozenset[str] = frozenset(
|
||||
{
|
||||
"notes:read",
|
||||
"notes:write",
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
"todo:read",
|
||||
"todo:write",
|
||||
"contacts:read",
|
||||
"contacts:write",
|
||||
"files:read",
|
||||
"files:write",
|
||||
"tables:read",
|
||||
"tables:write",
|
||||
"deck:read",
|
||||
"deck:write",
|
||||
"cookbook:read",
|
||||
"cookbook:write",
|
||||
"sharing:read",
|
||||
"sharing:write",
|
||||
"news:read",
|
||||
"news:write",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,493 @@
|
||||
"""MCP tools for Login Flow v2 authentication (ADR-022).
|
||||
|
||||
Provides tools for users to provision Nextcloud access via Login Flow v2,
|
||||
check provisioning status, and update granted scopes.
|
||||
|
||||
These tools work alongside (not replacing) the existing OAuth provisioning
|
||||
tools during the migration period.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth.elicitation import present_login_url
|
||||
from nextcloud_mcp_server.auth.login_flow import LoginFlowV2Client
|
||||
from nextcloud_mcp_server.auth.scope_authorization import (
|
||||
invalidate_scope_cache,
|
||||
require_scopes,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.storage import get_shared_storage
|
||||
from nextcloud_mcp_server.auth.token_utils import extract_user_id_from_token
|
||||
from nextcloud_mcp_server.config import get_nextcloud_ssl_verify, get_settings
|
||||
from nextcloud_mcp_server.models.auth import (
|
||||
ALL_SUPPORTED_SCOPES,
|
||||
ProvisionAccessResponse,
|
||||
ProvisionStatusResponse,
|
||||
UpdateScopesResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_auth_tools(mcp: FastMCP) -> None:
|
||||
"""Register Login Flow v2 auth tools with the MCP server."""
|
||||
|
||||
@mcp.tool(
|
||||
name="nc_auth_provision_access",
|
||||
title="Provision Nextcloud Access",
|
||||
description=(
|
||||
"Start Nextcloud Login Flow v2 to obtain an app password. "
|
||||
"This is required before using any Nextcloud tools. "
|
||||
"You will be given a URL to open in your browser to log in."
|
||||
),
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False,
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def nc_auth_provision_access(
|
||||
ctx: Context,
|
||||
scopes: list[str] | None = None,
|
||||
) -> ProvisionAccessResponse:
|
||||
"""Provision Nextcloud access via Login Flow v2.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
scopes: Requested application scopes (e.g. ["notes:read", "calendar:write"]).
|
||||
If not specified, all available scopes are requested.
|
||||
|
||||
Returns:
|
||||
ProvisionAccessResponse with login URL or status
|
||||
"""
|
||||
user_id = await extract_user_id_from_token(ctx)
|
||||
if user_id == "default_user":
|
||||
return ProvisionAccessResponse(
|
||||
status="error",
|
||||
message="Could not determine user identity from MCP token.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
storage = await get_shared_storage()
|
||||
|
||||
# Check if already provisioned
|
||||
existing = await storage.get_app_password_with_scopes(user_id)
|
||||
if existing:
|
||||
return ProvisionAccessResponse(
|
||||
status="already_provisioned",
|
||||
message=(
|
||||
f"Nextcloud access already provisioned for {user_id}. "
|
||||
f"Scopes: {existing['scopes'] or 'all'}. "
|
||||
f"Use nc_auth_update_scopes to modify permissions."
|
||||
),
|
||||
user_id=user_id,
|
||||
requested_scopes=existing["scopes"],
|
||||
)
|
||||
|
||||
# Determine scopes
|
||||
requested_scopes = scopes if scopes else list(ALL_SUPPORTED_SCOPES)
|
||||
|
||||
# Validate requested scopes
|
||||
invalid_scopes = [s for s in requested_scopes if s not in ALL_SUPPORTED_SCOPES]
|
||||
if invalid_scopes:
|
||||
return ProvisionAccessResponse(
|
||||
status="error",
|
||||
message=f"Invalid scopes: {', '.join(invalid_scopes)}. "
|
||||
f"Valid scopes: {', '.join(sorted(ALL_SUPPORTED_SCOPES))}",
|
||||
success=False,
|
||||
)
|
||||
|
||||
# Initiate Login Flow v2
|
||||
settings = get_settings()
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
if not nextcloud_host:
|
||||
return ProvisionAccessResponse(
|
||||
status="error",
|
||||
message="NEXTCLOUD_HOST not configured on the server.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
try:
|
||||
flow_client = LoginFlowV2Client(
|
||||
nextcloud_host=nextcloud_host,
|
||||
verify_ssl=get_nextcloud_ssl_verify(),
|
||||
)
|
||||
init_response = await flow_client.initiate()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initiate Login Flow v2: {e}")
|
||||
return ProvisionAccessResponse(
|
||||
status="error",
|
||||
message=f"Failed to start login flow: {e}",
|
||||
success=False,
|
||||
)
|
||||
|
||||
# Store the polling session
|
||||
await storage.store_login_flow_session(
|
||||
user_id=user_id,
|
||||
poll_token=init_response.poll_token,
|
||||
poll_endpoint=init_response.poll_endpoint,
|
||||
requested_scopes=requested_scopes,
|
||||
)
|
||||
|
||||
# Present login URL to user via elicitation
|
||||
elicitation_result = await present_login_url(ctx, init_response.login_url)
|
||||
|
||||
if elicitation_result == "declined":
|
||||
await storage.delete_login_flow_session(user_id)
|
||||
return ProvisionAccessResponse(
|
||||
status="declined",
|
||||
message="Login flow declined. Call nc_auth_provision_access again to retry.",
|
||||
user_id=user_id,
|
||||
success=False,
|
||||
)
|
||||
|
||||
if elicitation_result == "cancelled":
|
||||
await storage.delete_login_flow_session(user_id)
|
||||
return ProvisionAccessResponse(
|
||||
status="cancelled",
|
||||
message="Login flow cancelled. Call nc_auth_provision_access again to retry.",
|
||||
user_id=user_id,
|
||||
success=False,
|
||||
)
|
||||
|
||||
message = (
|
||||
f"Please open this URL in your browser to log in to Nextcloud:\n\n"
|
||||
f"{init_response.login_url}\n\n"
|
||||
f"After logging in, call nc_auth_check_status to complete provisioning."
|
||||
)
|
||||
|
||||
if elicitation_result == "accepted":
|
||||
message = (
|
||||
"Login acknowledged. Call nc_auth_check_status to verify "
|
||||
"and complete provisioning."
|
||||
)
|
||||
return ProvisionAccessResponse(
|
||||
status="pending",
|
||||
login_url=init_response.login_url,
|
||||
message=message,
|
||||
user_id=user_id,
|
||||
requested_scopes=requested_scopes,
|
||||
)
|
||||
|
||||
return ProvisionAccessResponse(
|
||||
status="login_required",
|
||||
login_url=init_response.login_url,
|
||||
message=message,
|
||||
user_id=user_id,
|
||||
requested_scopes=requested_scopes,
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
name="nc_auth_check_status",
|
||||
title="Check Nextcloud Access Status",
|
||||
description=(
|
||||
"Check if Nextcloud access has been provisioned. "
|
||||
"If a Login Flow is pending, this will poll for completion. "
|
||||
"Recommended polling interval: 5 seconds."
|
||||
),
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True,
|
||||
idempotentHint=True,
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def nc_auth_check_status(
|
||||
ctx: Context,
|
||||
) -> ProvisionStatusResponse:
|
||||
"""Check provisioning status and poll pending Login Flows.
|
||||
|
||||
Returns:
|
||||
ProvisionStatusResponse with current status
|
||||
"""
|
||||
user_id = await extract_user_id_from_token(ctx)
|
||||
if user_id == "default_user":
|
||||
return ProvisionStatusResponse(
|
||||
status="error",
|
||||
message="Could not determine user identity from MCP token.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
storage = await get_shared_storage()
|
||||
|
||||
# Check for existing app password
|
||||
existing = await storage.get_app_password_with_scopes(user_id)
|
||||
if existing:
|
||||
return ProvisionStatusResponse(
|
||||
status="provisioned",
|
||||
message=f"Nextcloud access is provisioned for {existing.get('username') or user_id}.",
|
||||
user_id=user_id,
|
||||
scopes=existing["scopes"],
|
||||
username=existing.get("username"),
|
||||
)
|
||||
|
||||
# Check for pending login flow session
|
||||
try:
|
||||
session = await storage.get_login_flow_session(user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check login flow session for {user_id}: {e}")
|
||||
return ProvisionStatusResponse(
|
||||
status="error",
|
||||
message=f"Failed to check login flow session: {e}",
|
||||
user_id=user_id,
|
||||
success=False,
|
||||
)
|
||||
if not session:
|
||||
return ProvisionStatusResponse(
|
||||
status="not_initiated",
|
||||
message=(
|
||||
"No provisioning in progress. "
|
||||
"Call nc_auth_provision_access to start."
|
||||
),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Poll the Login Flow
|
||||
settings = get_settings()
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
if not nextcloud_host:
|
||||
return ProvisionStatusResponse(
|
||||
status="error",
|
||||
message="NEXTCLOUD_HOST not configured.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
try:
|
||||
flow_client = LoginFlowV2Client(
|
||||
nextcloud_host=nextcloud_host,
|
||||
verify_ssl=get_nextcloud_ssl_verify(),
|
||||
)
|
||||
poll_result = await flow_client.poll(
|
||||
poll_endpoint=session["poll_endpoint"],
|
||||
poll_token=session["poll_token"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to poll Login Flow v2: {e}")
|
||||
return ProvisionStatusResponse(
|
||||
status="error",
|
||||
message=f"Failed to check login status: {e}",
|
||||
success=False,
|
||||
)
|
||||
|
||||
if poll_result.status == "completed":
|
||||
# Store the app password with scopes
|
||||
if poll_result.app_password is None:
|
||||
return ProvisionStatusResponse(
|
||||
status="error",
|
||||
message="Login Flow completed but no app password was returned.",
|
||||
success=False,
|
||||
)
|
||||
await storage.store_app_password_with_scopes(
|
||||
user_id=user_id,
|
||||
app_password=poll_result.app_password,
|
||||
scopes=session.get("requested_scopes"),
|
||||
username=poll_result.login_name,
|
||||
)
|
||||
invalidate_scope_cache(user_id)
|
||||
|
||||
# Clean up the flow session
|
||||
await storage.delete_login_flow_session(user_id)
|
||||
|
||||
return ProvisionStatusResponse(
|
||||
status="provisioned",
|
||||
message=f"Nextcloud access provisioned successfully as {poll_result.login_name}.",
|
||||
user_id=user_id,
|
||||
scopes=session.get("requested_scopes"),
|
||||
username=poll_result.login_name,
|
||||
)
|
||||
|
||||
if poll_result.status == "expired":
|
||||
# Clean up expired session
|
||||
await storage.delete_login_flow_session(user_id)
|
||||
return ProvisionStatusResponse(
|
||||
status="not_initiated",
|
||||
message=(
|
||||
"Login flow expired. "
|
||||
"Call nc_auth_provision_access to start a new one."
|
||||
),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Still pending
|
||||
return ProvisionStatusResponse(
|
||||
status="pending",
|
||||
message=(
|
||||
"Login flow is still pending. "
|
||||
"Please complete the login in your browser, then call this tool again."
|
||||
),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
name="nc_auth_update_scopes",
|
||||
title="Update Nextcloud Access Scopes",
|
||||
description=(
|
||||
"Update the scopes for your Nextcloud access. "
|
||||
"This starts a new Login Flow with the combined scope set. "
|
||||
"The current app password remains valid until the new one is obtained."
|
||||
),
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False,
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def nc_auth_update_scopes(
|
||||
ctx: Context,
|
||||
add_scopes: list[str] | None = None,
|
||||
remove_scopes: list[str] | None = None,
|
||||
) -> UpdateScopesResponse:
|
||||
"""Update granted scopes by re-provisioning with merged scope set.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
add_scopes: Scopes to add to the current set
|
||||
remove_scopes: Scopes to remove from the current set
|
||||
|
||||
Returns:
|
||||
UpdateScopesResponse with new login URL or status
|
||||
"""
|
||||
user_id = await extract_user_id_from_token(ctx)
|
||||
if user_id == "default_user":
|
||||
return UpdateScopesResponse(
|
||||
status="error",
|
||||
message="Could not determine user identity from MCP token.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
if not add_scopes and not remove_scopes:
|
||||
return UpdateScopesResponse(
|
||||
status="error",
|
||||
message="Provide add_scopes and/or remove_scopes to update.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
storage = await get_shared_storage()
|
||||
|
||||
# Get current state - require existing provisioning
|
||||
existing = await storage.get_app_password_with_scopes(user_id)
|
||||
if existing is None:
|
||||
return UpdateScopesResponse(
|
||||
status="error",
|
||||
message="Not provisioned. Call nc_auth_provision_access first.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
previous_scopes = existing["scopes"]
|
||||
|
||||
# Compute new scope set
|
||||
current_set = (
|
||||
set(previous_scopes) if previous_scopes else set(ALL_SUPPORTED_SCOPES)
|
||||
)
|
||||
if add_scopes:
|
||||
invalid = [s for s in add_scopes if s not in ALL_SUPPORTED_SCOPES]
|
||||
if invalid:
|
||||
return UpdateScopesResponse(
|
||||
status="error",
|
||||
message=f"Invalid scopes: {', '.join(invalid)}",
|
||||
success=False,
|
||||
)
|
||||
current_set.update(add_scopes)
|
||||
if remove_scopes:
|
||||
current_set -= set(remove_scopes)
|
||||
|
||||
new_scopes = sorted(current_set)
|
||||
|
||||
if not new_scopes:
|
||||
return UpdateScopesResponse(
|
||||
status="error",
|
||||
message="Cannot remove all scopes. At least one scope must remain.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
# No-op detection: skip Login Flow if scopes are unchanged
|
||||
previous_scopes_set = (
|
||||
set(previous_scopes) if previous_scopes else set(ALL_SUPPORTED_SCOPES)
|
||||
)
|
||||
if set(new_scopes) == previous_scopes_set:
|
||||
return UpdateScopesResponse(
|
||||
status="unchanged",
|
||||
message="Requested scopes match current scopes. No changes needed.",
|
||||
previous_scopes=previous_scopes,
|
||||
new_scopes=new_scopes,
|
||||
)
|
||||
|
||||
# Initiate new Login Flow v2
|
||||
# Note: existing app password stays valid until the new flow completes.
|
||||
# store_app_password_with_scopes() does an upsert, so the old password
|
||||
# is replaced atomically when nc_auth_check_status stores the new one.
|
||||
settings = get_settings()
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
if not nextcloud_host:
|
||||
return UpdateScopesResponse(
|
||||
status="error",
|
||||
message="NEXTCLOUD_HOST not configured.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
try:
|
||||
flow_client = LoginFlowV2Client(
|
||||
nextcloud_host=nextcloud_host,
|
||||
verify_ssl=get_nextcloud_ssl_verify(),
|
||||
)
|
||||
init_response = await flow_client.initiate()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initiate Login Flow v2 for scope update: {e}")
|
||||
return UpdateScopesResponse(
|
||||
status="error",
|
||||
message=f"Failed to start re-provisioning flow: {e}",
|
||||
success=False,
|
||||
)
|
||||
|
||||
# Store new flow session
|
||||
await storage.store_login_flow_session(
|
||||
user_id=user_id,
|
||||
poll_token=init_response.poll_token,
|
||||
poll_endpoint=init_response.poll_endpoint,
|
||||
requested_scopes=new_scopes,
|
||||
)
|
||||
|
||||
# Present login URL
|
||||
elicitation_result = await present_login_url(ctx, init_response.login_url)
|
||||
|
||||
if elicitation_result == "declined":
|
||||
await storage.delete_login_flow_session(user_id)
|
||||
return UpdateScopesResponse(
|
||||
status="declined",
|
||||
message="Scope update declined. Call nc_auth_update_scopes again to retry.",
|
||||
previous_scopes=previous_scopes if previous_scopes else None,
|
||||
new_scopes=new_scopes,
|
||||
success=False,
|
||||
)
|
||||
|
||||
if elicitation_result == "cancelled":
|
||||
await storage.delete_login_flow_session(user_id)
|
||||
return UpdateScopesResponse(
|
||||
status="cancelled",
|
||||
message="Scope update cancelled. Call nc_auth_update_scopes again to retry.",
|
||||
previous_scopes=previous_scopes if previous_scopes else None,
|
||||
new_scopes=new_scopes,
|
||||
success=False,
|
||||
)
|
||||
|
||||
message = (
|
||||
f"Scope update requires re-authentication.\n\n"
|
||||
f"Please open this URL to log in:\n{init_response.login_url}\n\n"
|
||||
f"After logging in, call nc_auth_check_status to complete."
|
||||
)
|
||||
|
||||
if elicitation_result == "accepted":
|
||||
message = (
|
||||
"Login acknowledged for scope update. "
|
||||
"Call nc_auth_check_status to verify and complete."
|
||||
)
|
||||
|
||||
return UpdateScopesResponse(
|
||||
status="login_required",
|
||||
login_url=init_response.login_url,
|
||||
message=message,
|
||||
previous_scopes=previous_scopes if previous_scopes else None,
|
||||
new_scopes=new_scopes,
|
||||
)
|
||||
@@ -12,9 +12,6 @@ from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import jwt
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.fastmcp import Context
|
||||
from mcp.types import ToolAnnotations
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -23,80 +20,16 @@ from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
||||
|
||||
# Re-export for backward compatibility — canonical location is auth.token_utils
|
||||
from nextcloud_mcp_server.auth.token_utils import (
|
||||
extract_user_id_from_token as extract_user_id_from_token, # noqa: PLC0414
|
||||
)
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def extract_user_id_from_token(ctx: Context) -> str:
|
||||
"""Extract user_id from the MCP access token (Flow 1).
|
||||
|
||||
Handles both JWT and opaque tokens:
|
||||
- JWT: Decode and extract 'sub' claim
|
||||
- Opaque: Call userinfo endpoint to get 'sub'
|
||||
|
||||
Args:
|
||||
ctx: MCP context with access token
|
||||
|
||||
Returns:
|
||||
user_id extracted from token, or "default_user" as fallback
|
||||
"""
|
||||
# Use MCP SDK's get_access_token() which uses contextvars
|
||||
access_token: AccessToken | None = get_access_token()
|
||||
|
||||
if not access_token or not access_token.token:
|
||||
logger.warning(" ✗ No access token found via get_access_token()")
|
||||
return "default_user"
|
||||
|
||||
token = access_token.token
|
||||
is_jwt = "." in token and token.count(".") >= 2
|
||||
logger.info(f" Token type: {'JWT' if is_jwt else 'Opaque'}")
|
||||
|
||||
# Try JWT decode first
|
||||
if is_jwt:
|
||||
try:
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
user_id = payload.get("sub", "unknown")
|
||||
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
|
||||
return user_id
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ JWT decode failed: {type(e).__name__}: {e}")
|
||||
|
||||
# Opaque token - call userinfo endpoint
|
||||
logger.info(" Opaque token detected, calling userinfo endpoint...")
|
||||
try:
|
||||
# Get userinfo endpoint from OIDC discovery
|
||||
oidc_discovery_uri = os.getenv(
|
||||
"OIDC_DISCOVERY_URI",
|
||||
"http://localhost:8080/.well-known/openid-configuration",
|
||||
)
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
discovery_response = await http_client.get(oidc_discovery_uri)
|
||||
discovery_response.raise_for_status()
|
||||
discovery = discovery_response.json()
|
||||
userinfo_endpoint = discovery.get("userinfo_endpoint")
|
||||
|
||||
if userinfo_endpoint:
|
||||
userinfo = await _query_idp_userinfo(token, userinfo_endpoint)
|
||||
if userinfo:
|
||||
user_id = userinfo.get("sub", "unknown")
|
||||
logger.info(f" ✓ Userinfo query successful: user_id={user_id}")
|
||||
return user_id
|
||||
else:
|
||||
logger.error(" ✗ Userinfo query failed")
|
||||
else:
|
||||
logger.error(" ✗ No userinfo_endpoint available")
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Userinfo query failed: {type(e).__name__}: {e}")
|
||||
|
||||
# Fallback
|
||||
logger.warning(" Using fallback user_id: default_user")
|
||||
return "default_user"
|
||||
|
||||
|
||||
class ProvisioningStatus(BaseModel):
|
||||
"""Status of Nextcloud provisioning for a user."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user