diff --git a/CLAUDE.md b/CLAUDE.md index 1d4ddde..3716d15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,544 +2,273 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Development Commands +## Coding Conventions -### Testing +### async/await Patterns +- **Use anyio + asyncio hybrid** - Both libraries are available + - pytest runs in `anyio` mode (`anyio_mode = "auto"` in pyproject.toml) + - asyncio used in auth modules (refresh_token_storage.py, token_exchange.py, token_broker.py) + - anyio used in calendar.py, client_registration.py, app.py + - Prefer standard async/await syntax without explicit library imports when possible -The test suite is organized in layers for fast feedback: - -```bash -# FAST FEEDBACK (recommended for development) -# Unit tests only - ~5 seconds -uv run pytest tests/unit/ -v - -# Smoke tests - critical path validation - ~30-60 seconds -uv run pytest -m smoke -v - -# INTEGRATION TESTS -# Integration tests without OAuth - ~2-3 minutes -uv run pytest -m "integration and not oauth" -v - -# Full test suite - ~4-5 minutes -uv run pytest - -# OAuth tests only (slowest, requires Playwright) - ~3 minutes -uv run pytest -m oauth -v - -# COVERAGE -# Run tests with coverage -uv run pytest --cov - -# LEGACY COMMANDS (still work) -# Run all integration tests -uv run pytest -m integration -v - -# Skip integration tests -uv run pytest -m "not integration" -v -``` - -! Hint: If the tests are failing due to missing environment variables, then usually the correct .env has not been created or not correctly configured yet. - -### Load Testing -```bash -# Run benchmark with default settings (10 workers, 30 seconds) -uv run python -m tests.load.benchmark - -# Quick test with custom concurrency and duration -uv run python -m tests.load.benchmark --concurrency 20 --duration 60 - -# Extended load test (50 workers for 5 minutes) -uv run python -m tests.load.benchmark -c 50 -d 300 - -# Export results to JSON for analysis -uv run python -m tests.load.benchmark -c 20 -d 60 --output results.json - -# Test OAuth server on port 8001 -uv run python -m tests.load.benchmark --url http://127.0.0.1:8001/mcp - -# Verbose mode with detailed logging -uv run python -m tests.load.benchmark -c 10 -d 30 --verbose -``` - -**Load Testing Features:** -- **Mixed workload** simulating realistic MCP usage (40% reads, 20% writes, 15% search, 25% other operations) -- **Real-time progress** bar with live RPS and error counts -- **Detailed metrics**: - - Throughput (requests/second) - - Latency percentiles (p50, p90, p95, p99) - - Per-operation breakdown - - Error rates and types -- **Automatic cleanup** of test data -- **JSON export** for CI/CD integration -- **Server health checks** before starting - -**Understanding Results:** -- **Requests/Second (RPS)**: Higher is better. Expected baseline: 50-200 RPS for mixed workload -- **Latency**: - - p50 (median): Should be <100ms for most operations - - p95: Should be <500ms - - p99: Should be <1000ms -- **Error Rate**: Should be <1% under normal load - -**Common Bottlenecks:** -1. Nextcloud backend API response times (most common) -2. Database connection limits -3. HTTP client connection pooling -4. Network I/O between containers +### Type Hints +- **Use Python 3.10+ union syntax**: `str | None` instead of `Optional[str]` +- **Use lowercase generics**: `dict[str, Any]` instead of `Dict[str, Any]` +- **Type all function signatures** - Parameters and return types +- **No explicit type checker configured** - Ruff handles linting only ### Code Quality -```bash -# Format and lint code -uv run ruff check -uv run ruff format +- **Run ruff before committing**: + ```bash + uv run ruff check + uv run ruff format + ``` +- **Ruff configuration** in pyproject.toml (extends select: ["I"] for import sorting) -# Type checking -# No explicit type checker configured - this is a Python project using ruff for linting +### Error Handling +- **Use custom decorators**: `@retry_on_429` for rate limiting (see base_client.py) +- **Standard exceptions**: `HTTPStatusError` from httpx, `McpError` for MCP-specific errors +- **Logging patterns**: + - `logger.debug()` for expected 404s and normal operations + - `logger.warning()` for retries and non-critical issues + - `logger.error()` for actual errors + +### Testing Patterns +- **Use existing fixtures** from `tests/conftest.py` (2888 lines of test infrastructure) +- **Session-scoped fixtures** handle anyio/pytest-asyncio incompatibility +- **Mocked unit tests** use `mocker.AsyncMock(spec=httpx.AsyncClient)` +- **pytest-timeout**: 180s default per test +- **Mark tests appropriately**: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.oauth`, `@pytest.mark.smoke` + +### Architectural Patterns +- **Base classes**: `BaseNextcloudClient` for all API clients +- **Pydantic responses**: All MCP tools return Pydantic models inheriting from `BaseResponse` +- **Decorators**: `@require_scopes`, `@require_provisioning` for access control +- **Context pattern**: `await get_client(ctx)` to access authenticated NextcloudClient (async!) +- **FastMCP decorators**: `@mcp.tool()`, `@mcp.resource()` +- **Token acquisition**: `get_client()` handles both pass-through and token exchange modes + - Pass-through (default): Simple, stateless (ENABLE_TOKEN_EXCHANGE=false) + - Token exchange (opt-in): RFC 8693 delegation (ENABLE_TOKEN_EXCHANGE=true) + +### Project Structure +- `nextcloud_mcp_server/client/` - HTTP clients for Nextcloud APIs +- `nextcloud_mcp_server/server/` - MCP tool/resource definitions +- `nextcloud_mcp_server/auth/` - OAuth/OIDC authentication +- `nextcloud_mcp_server/models/` - Pydantic response models +- `tests/` - Layered test suite (unit, smoke, integration, load) + +## Development Commands (Quick Reference) + +### Testing +```bash +# Fast feedback (recommended) +uv run pytest tests/unit/ -v # Unit tests (~5s) +uv run pytest -m smoke -v # Smoke tests (~30-60s) + +# Integration tests +uv run pytest -m "integration and not oauth" -v # Without OAuth (~2-3min) +uv run pytest -m oauth -v # OAuth only (~3min) +uv run pytest # Full suite (~4-5min) + +# Coverage +uv run pytest --cov + +# Specific tests after changes +uv run pytest tests/server/test_mcp.py -k "notes" -v +uv run pytest tests/client/notes/test_notes_api.py -v ``` +**Important**: After code changes, rebuild the correct container: +- Single-user tests: `docker-compose up --build -d mcp` +- OAuth tests: `docker-compose up --build -d mcp-oauth` +- Keycloak tests: `docker-compose up --build -d mcp-keycloak` + ### Running the Server ```bash -# Local development - load environment variables and run +# Local development export $(grep -v '^#' .env | xargs) mcp run --transport sse nextcloud_mcp_server.app:mcp -# Docker development environment with Nextcloud instance -docker-compose up - -# After code changes, rebuild and restart the appropriate MCP server container: -# For basic auth changes (most common) - uses admin credentials -docker-compose up --build -d mcp - -# For OAuth changes - uses OAuth authentication with JWT tokens -docker-compose up --build -d mcp-oauth - -# Build Docker image -docker build -t nextcloud-mcp-server . +# Docker development (rebuilds after code changes) +docker-compose up --build -d mcp # Single-user (port 8000) +docker-compose up --build -d mcp-oauth # Nextcloud OAuth (port 8001) +docker-compose up --build -d mcp-keycloak # Keycloak OAuth (port 8002) ``` -**Important: MCP Server Containers** -- **`mcp`** (port 8000): Uses basic auth with admin credentials. Use this for most development and testing. -- **`mcp-oauth`** (port 8001): Uses OAuth authentication with JWT tokens. Use this when working on OAuth-specific features or tests. - - JWT tokens are used for testing (faster validation, scopes embedded in token) - - The server can handle both JWT and opaque tokens via the token verifier - ### Environment Setup ```bash -# Install dependencies -uv sync - -# Install development dependencies -uv sync --group dev +uv sync # Install dependencies +uv sync --group dev # Install with dev dependencies ``` -### Database Inspection - -**Docker Compose Database Credentials:** -- Root user: `root` / password: `password` -- App user: `nextcloud` / password: `password` -- Database: `nextcloud` - -**Common Database Commands:** +### Load Testing ```bash -# Connect to database as root (most common for inspection) +# Quick test (default: 10 workers, 30 seconds) +uv run python -m tests.load.benchmark + +# Custom concurrency and duration +uv run python -m tests.load.benchmark -c 20 -d 60 + +# Export results for analysis +uv run python -m tests.load.benchmark --output results.json --verbose +``` + +**Expected Performance**: 50-200 RPS for mixed workload, p50 <100ms, p95 <500ms, p99 <1000ms. + +## Database Inspection + +**Credentials**: root/password, nextcloud/password, database: `nextcloud` + +```bash +# Connect to database docker compose exec db mariadb -u root -ppassword nextcloud # Check OAuth clients -docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT id, name, token_type FROM oc_oidc_clients ORDER BY id DESC LIMIT 10;" +docker compose exec db mariadb -u root -ppassword nextcloud -e \ + "SELECT id, name, token_type FROM oc_oidc_clients ORDER BY id DESC LIMIT 10;" # Check OAuth client scopes -docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT c.id, c.name, s.scope FROM oc_oidc_clients c LEFT JOIN oc_oidc_client_scopes s ON c.id = s.client_id WHERE c.name LIKE '%MCP%';" +docker compose exec db mariadb -u root -ppassword nextcloud -e \ + "SELECT c.id, c.name, s.scope FROM oc_oidc_clients c LEFT JOIN oc_oidc_client_scopes s ON c.id = s.client_id WHERE c.name LIKE '%MCP%';" # Check OAuth access tokens -docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT id, client_id, user_id, created_at FROM oc_oidc_access_tokens ORDER BY created_at DESC LIMIT 10;" +docker compose exec db mariadb -u root -ppassword nextcloud -e \ + "SELECT id, client_id, user_id, created_at FROM oc_oidc_access_tokens ORDER BY created_at DESC LIMIT 10;" ``` -**Important Tables:** -- `oc_oidc_clients` - OAuth client registrations (DCR clients) +**Important Tables**: +- `oc_oidc_clients` - OAuth client registrations (DCR) - `oc_oidc_client_scopes` - Client allowed scopes - `oc_oidc_access_tokens` - Issued access tokens - `oc_oidc_authorization_codes` - Authorization codes -- `oc_oidc_registration_tokens` - RFC 7592 registration tokens for client management -- `oc_oidc_redirect_uris` - Redirect URIs for each client +- `oc_oidc_registration_tokens` - RFC 7592 registration tokens +- `oc_oidc_redirect_uris` - Redirect URIs -## Architecture Overview +## Architecture Quick Reference -This is a Python MCP (Model Context Protocol) server that provides LLM integration with Nextcloud. The architecture follows a layered pattern: +**For detailed architecture, see:** +- `docs/comparison-context-agent.md` - Overall architecture +- `docs/oauth-architecture.md` - OAuth integration patterns +- `docs/ADR-004-progressive-consent.md` - Progressive consent implementation -### Core Components +**Core Components**: +- `nextcloud_mcp_server/app.py` - FastMCP server entry point +- `nextcloud_mcp_server/client/` - HTTP clients (Notes, Calendar, Contacts, Tables, WebDAV) +- `nextcloud_mcp_server/server/` - MCP tool/resource definitions +- `nextcloud_mcp_server/auth/` - OAuth/OIDC authentication -- **`nextcloud_mcp_server/app.py`** - Main MCP server entry point using FastMCP framework -- **`nextcloud_mcp_server/client/`** - HTTP client implementations for different Nextcloud APIs -- **`nextcloud_mcp_server/server/`** - MCP tool/resource definitions that expose client functionality -- **`nextcloud_mcp_server/controllers/`** - Business logic controllers (e.g., notes search) +**Supported Apps**: Notes, Calendar (CalDAV + VTODO tasks), Contacts (CardDAV), Tables, WebDAV, Deck, Cookbook -### Client Architecture +**Key Patterns**: +1. `NextcloudClient` orchestrates all app-specific clients +2. `BaseNextcloudClient` provides common HTTP functionality + retry logic +3. MCP tools use context pattern: `get_client(ctx)` → `NextcloudClient` +4. All operations are async using httpx -- **`NextcloudClient`** - Main orchestrating client that manages all app-specific clients -- **`BaseNextcloudClient`** - Abstract base class providing common HTTP functionality and retry logic -- **App-specific clients**: `NotesClient`, `CalendarClient`, `ContactsClient`, `TablesClient`, `WebDAVClient` +## MCP Response Patterns (CRITICAL) -### Server Integration +**Never return raw `List[Dict]` from MCP tools** - FastMCP mangles them into dicts with numeric string keys. -Each Nextcloud app has a corresponding server module that: -1. Defines MCP tools using `@mcp.tool()` decorators -2. Defines MCP resources using `@mcp.resource()` decorators -3. Uses the context pattern to access the `NextcloudClient` instance - -### Supported Nextcloud Apps - -- **Notes** - Full CRUD operations and search -- **Calendar** - CalDAV integration with events, recurring events, attendees, and **tasks (VTODO)** - - **Calendar Operations**: List, create, delete calendars - - **Event Operations**: Full CRUD, recurring events, attendees, reminders, bulk operations - - **Task Operations (VTODO)**: Full CRUD for CalDAV tasks with: - - Status tracking (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED) - - Priority levels (0-9, 1=highest, 9=lowest) - - Due dates, start dates, completion tracking - - Percent complete (0-100%) - - Categories and filtering - - Search across all calendars - - **Note**: Calendar implementation uses caldav library's AsyncDavClient -- **Contacts** - CardDAV integration with address book operations -- **Tables** - Row-level operations on Nextcloud Tables -- **WebDAV** - Complete file system access - -### Key Patterns - -1. **Environment-based configuration** - Uses `NextcloudClient.from_env()` to load credentials from environment variables -2. **Async/await throughout** - All operations are async using httpx -3. **Retry logic** - `@retry_on_429` decorator handles rate limiting -4. **Context injection** - MCP context provides access to the authenticated client instance -5. **Modular design** - Each Nextcloud app is isolated in its own client/server pair - -### MCP Response Patterns - -**CRITICAL: Never return raw `List[Dict]` from MCP tools - always wrap in Pydantic response models** - -FastMCP serialization issue: raw lists get mangled into dicts with numeric string keys. - -**Pattern:** +**Correct Pattern**: 1. Client methods return `List[Dict]` (raw data) 2. MCP tools convert to Pydantic models and wrap in response object 3. Response models inherit from `BaseResponse`, include `results` field + metadata -**Reference implementations:** -- `SearchNotesResponse` in `nextcloud_mcp_server/models/notes.py:80` -- `SearchFilesResponse` in `nextcloud_mcp_server/models/webdav.py:113` -- Tool examples: `nextcloud_mcp_server/server/{notes,webdav}.py` +**Reference implementations**: +- `nextcloud_mcp_server/models/notes.py:80` - `SearchNotesResponse` +- `nextcloud_mcp_server/models/webdav.py:113` - `SearchFilesResponse` +- `nextcloud_mcp_server/server/{notes,webdav}.py` - Tool examples -**Testing:** Extract `data["results"]` from MCP responses, not `data` directly. +**Testing**: Extract `data["results"]` from MCP responses, not `data` directly. -### Testing Structure +## Testing Best Practices (MANDATORY) -The test suite follows a layered architecture for fast feedback: +### Always Run Tests +- **Run tests to completion** before considering any task complete +- **Rebuild the correct container** after code changes (see Development Commands above) +- **If tests require modifications**, ask for permission before proceeding -``` -tests/ -├── unit/ # Fast unit tests (~5s total) -│ ├── test_scope_decorator.py -│ └── test_response_models.py -├── smoke/ # Critical path tests (~30-60s) -│ └── test_smoke.py -├── integration/ -│ ├── client/ # Direct API layer tests -│ │ ├── notes/ -│ │ ├── calendar/ -│ │ └── ... -│ └── server/ # MCP tool layer tests -│ ├── oauth/ # OAuth-specific tests (slow, ~3min) -│ │ ├── test_oauth_core.py -│ │ ├── test_scope_authorization.py -│ │ └── ... -│ ├── test_mcp.py -│ └── ... -└── load/ # Performance tests -``` +### Use Existing Fixtures +See `tests/conftest.py` for 2888 lines of test infrastructure: +- `nc_mcp_client` - MCP client for tool/resource testing (uses `mcp` container) +- `nc_mcp_oauth_client` - MCP client for OAuth testing (uses `mcp-oauth` container) +- `nc_client` - Direct NextcloudClient for setup/cleanup +- `temporary_note`, `temporary_addressbook`, `temporary_contact` - Auto-cleanup -**Test Markers:** -- `@pytest.mark.unit` - Fast unit tests with mocked dependencies -- `@pytest.mark.integration` - Integration tests requiring Docker containers -- `@pytest.mark.oauth` - OAuth tests requiring Playwright (slowest) -- `@pytest.mark.smoke` - Critical path smoke tests +### Writing Mocked Unit Tests +For client-layer response parsing tests, use mocked HTTP responses: -**Fixtures** in `tests/conftest.py` - Shared test setup and utilities -- **Important**: Integration tests run against live Docker containers. After making code changes: - - For basic auth tests: rebuild with `docker-compose up --build -d mcp` - - For OAuth tests: rebuild with `docker-compose up --build -d mcp-oauth` - -#### Testing Best Practices -- **MANDATORY: Always run tests after implementing features or fixing bugs** - - Run tests to completion before considering any task complete - - If tests require modifications to pass, ask for permission before proceeding - - **Rebuild the correct container** after code changes: - - For basic auth tests (most common): `docker-compose up --build -d mcp` - - For OAuth tests: `docker-compose up --build -d mcp-oauth` -- **Use existing fixtures** from `tests/conftest.py` to avoid duplicate setup work: - - `nc_mcp_client` - MCP client session for tool/resource testing (uses `mcp` container) - - `nc_mcp_oauth_client` - MCP client session for OAuth testing (uses `mcp-oauth` container) - - `nc_client` - Direct NextcloudClient for setup/cleanup operations - - `temporary_note` - Creates and cleans up test notes automatically - - `temporary_addressbook` - Creates and cleans up test address books - - `temporary_contact` - Creates and cleans up test contacts -- **Test specific functionality** after changes: - - For Notes changes: `uv run pytest tests/server/test_mcp.py -k "notes" -v` - - For specific API changes: `uv run pytest tests/client/notes/test_notes_api.py -v` - - For OAuth changes: `uv run pytest tests/server/test_oauth*.py -v` (remember to rebuild `mcp-oauth` container) -- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead - -#### Writing Mocked Unit Tests - -For client-layer tests that verify response parsing logic, use mocked HTTP responses instead of real network calls: - -**Pattern:** ```python -import httpx -import pytest -from nextcloud_mcp_server.client.notes import NotesClient -from tests.conftest import create_mock_note_response - async def test_notes_api_get_note(mocker): """Test that get_note correctly parses the API response.""" - # Create mock response using helper functions mock_response = create_mock_note_response( - note_id=123, - title="Test Note", - content="Test content", - category="Test", - etag="abc123", + note_id=123, title="Test Note", content="Test content", + category="Test", etag="abc123" ) - # Mock the _make_request method - mock_client = mocker.AsyncMock(spec=httpx.AsyncClient) mock_make_request = mocker.patch.object( NotesClient, "_make_request", return_value=mock_response ) - # Create client and test - client = NotesClient(mock_client, "testuser") + client = NotesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") note = await client.get_note(note_id=123) - # Verify the response was parsed correctly assert note["id"] == 123 - assert note["title"] == "Test Note" - # Verify the correct API endpoint was called mock_make_request.assert_called_once_with("GET", "/apps/notes/api/v1/notes/123") ``` -**Mock Response Helpers in `tests/conftest.py`:** -- `create_mock_response()` - Generic HTTP response builder -- `create_mock_note_response()` - Pre-configured note response -- `create_mock_error_response()` - Error responses (404, 412, etc.) +**Mock helpers in `tests/conftest.py`**: `create_mock_response()`, `create_mock_note_response()`, `create_mock_error_response()` -**Benefits:** -- ⚡ Fast execution (~0.1s vs minutes for integration tests) -- 🔒 No Docker dependency -- 🎯 Tests focus on response parsing logic -- ♻️ Repeatable and deterministic +**When to use**: Response parsing, error handling, request parameter building +**When NOT to use**: CalDAV/CardDAV/WebDAV protocols, OAuth flows, end-to-end MCP testing -**When to use:** -- Testing client methods that parse JSON responses -- Testing error handling (404, 412, etc.) -- Testing request parameter building +### OAuth Testing +OAuth tests use **Playwright browser automation** to complete flows programmatically. -**When NOT to use (keep as integration tests):** -- Complex protocol interactions (CalDAV, CardDAV, WebDAV) -- Multi-component workflows (Notes + WebDAV attachments) -- OAuth flows -- End-to-end MCP tool testing +**Test Environment**: +- Three MCP containers: `mcp` (single-user), `mcp-oauth` (Nextcloud OIDC), `mcp-keycloak` (external IdP) +- OAuth tests require `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables +- Playwright configuration: `--browser firefox --headed` for debugging +- Install browsers: `uv run playwright install firefox` -**Reference Implementation:** -- See `tests/client/notes/test_notes_api.py` for complete examples -- Mark unit tests with `pytestmark = pytest.mark.unit` -- Run with: `uv run pytest tests/unit/ tests/client/notes/test_notes_api.py -v` +**OAuth fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client`, `alice_oauth_token`, `bob_oauth_token`, etc. -#### OAuth/OIDC Testing -OAuth integration tests use **automated Playwright browser automation** to complete the OAuth flow programmatically. +**Shared OAuth Client**: All test users authenticate using a single OAuth client (created via DCR, deleted at session end via RFC 7592). Matches production behavior. -**OAuth Testing Setup:** -- **Main fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` - Use Playwright automation -- **Shared OAuth Client**: All test users authenticate using a single OAuth client - - **Created fresh for each test session** via Dynamic Client Registration (DCR) - - Matches production MCP server behavior (one client, multiple user tokens) - - Each user gets their own unique access token - - **Automatic cleanup**: Client is registered at session start, deleted at session end (RFC 7592) - - Implementation: `shared_oauth_client_credentials` fixture in `tests/conftest.py` - - **Note**: Client deletion may fail due to Nextcloud middleware (logged as warning). This doesn't affect tests. -- **Available fixtures**: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client` -- **Multi-user fixtures**: `alice_oauth_token`, `bob_oauth_token`, `charlie_oauth_token`, `diana_oauth_token` -- **Requirements**: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables -- Uses `pytest-playwright-asyncio` for async Playwright fixtures -- **Playwright configuration**: Use pytest CLI args like `--browser firefox --headed` to customize -- **Install browsers**: `uv run playwright install firefox` (or `chromium`, `webkit`) - -**Example Commands:** +**Run OAuth tests**: ```bash -# Run all OAuth tests with Playwright automation using Firefox +uv run pytest -m oauth -v # All OAuth tests uv run pytest tests/server/oauth/ --browser firefox -v - -# Run specific OAuth test file with visible browser for debugging uv run pytest tests/server/oauth/test_oauth_core.py --browser firefox --headed -v - -# Run with Chromium (default) - use -m oauth marker for all OAuth tests -uv run pytest -m oauth -v ``` -**Test Environment:** -- **Two MCP server containers are available:** - - `mcp` (port 8000): Uses basic auth with admin credentials - for most testing - - `mcp-oauth` (port 8001): Uses OAuth authentication - for OAuth-specific testing -- Start OAuth MCP server: `docker-compose up --build -d mcp-oauth` -- **Important**: When working on OAuth functionality, always rebuild `mcp-oauth` container, not `mcp` +### Keycloak OAuth Testing +**Validates ADR-002 architecture** for external identity providers and offline access patterns. -**CI/CD Notes:** -- Playwright tests run in CI/CD environments -- Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects) +**Architecture**: `MCP Client → Keycloak (OAuth) → MCP Server → Nextcloud user_oidc (validates token) → APIs` -#### Keycloak OAuth/OIDC Testing (ADR-002 Integration) - -The MCP server supports using **Keycloak as an external OAuth/OIDC identity provider** instead of Nextcloud's built-in OIDC app. This validates the ADR-002 architecture for background jobs and external identity providers. - -**Architecture:** -``` -MCP Client → Keycloak (OAuth) → MCP Server → Nextcloud user_oidc (validates token) → APIs -``` - -**Key Benefits:** -- ✅ **No admin credentials needed** - All API access uses user's Keycloak token -- ✅ **External identity provider** - Demonstrates integration with enterprise IdPs -- ✅ **ADR-002 validation** - Tests offline_access and refresh token patterns -- ✅ **User provisioning** - Nextcloud automatically provisions users from Keycloak - -**Setup and Testing:** +**Setup**: ```bash -# 1. Start Keycloak and MCP server with Keycloak OAuth docker-compose up -d keycloak app mcp-keycloak - -# 2. Verify Keycloak realm is available curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration - -# 3. Verify user_oidc provider is configured docker compose exec app php occ user_oidc:provider keycloak - -# 4. Generate encryption key for refresh token storage (optional, for offline access) -python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" -# Set in environment: export TOKEN_ENCRYPTION_KEY='' - -# 5. Test OAuth flow manually -# Get token from Keycloak: -TOKEN=$(curl -s -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \ - -d "grant_type=password" \ - -d "client_id=mcp-client" \ - -d "client_secret=mcp-secret-change-in-production" \ - -d "username=admin" \ - -d "password=admin" \ - -d "scope=openid profile email offline_access" | jq -r .access_token) - -# Use token with Nextcloud API (validated by user_oidc): -curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/ocs/v2.php/cloud/capabilities - -# 6. Connect MCP client -# Point client to: http://localhost:8002 -# Complete OAuth flow using Keycloak credentials: admin/admin ``` -**Three MCP Server Containers:** -- **`mcp`** (port 8000): Basic auth with admin credentials -- **`mcp-oauth`** (port 8001): Nextcloud OIDC provider (JWT tokens) -- **`mcp-keycloak`** (port 8002): Keycloak OIDC provider (external IdP) +**Credentials**: admin/admin (Keycloak realm: `nextcloud-mcp`) -**Keycloak Configuration:** -- **Realm**: `nextcloud-mcp` (auto-imported from `keycloak/realm-export.json`) -- **Client**: `mcp-client` (pre-configured with PKCE, offline_access) -- **Admin user**: `admin/admin` (created in realm export) -- **Redirect URIs**: `http://localhost:*/callback`, `http://127.0.0.1:*/callback` +**For detailed Keycloak setup, see**: +- `docs/oauth-setup.md` - OAuth configuration +- `docs/ADR-002-vector-sync-authentication.md` - Offline access architecture +- `docs/audience-validation-setup.md` - Token audience validation +- `docs/keycloak-multi-client-validation.md` - Realm-level validation -**Environment Variables** (Generic OIDC - works with any provider): -```bash -# Generic OIDC configuration (provider-agnostic) -OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration -OIDC_CLIENT_ID=nextcloud-mcp-server # OAuth client ID -OIDC_CLIENT_SECRET=mcp-secret-... # OAuth client secret +## Integration Testing with Docker -# Nextcloud API configuration -NEXTCLOUD_HOST=http://app:80 # Nextcloud API (token validation in external IdP mode) +**Nextcloud**: `docker compose exec app php occ ...` for occ commands +**MariaDB**: `docker compose exec db mariadb -u [user] -p [password] [database]` for queries -# Refresh tokens and token exchange (ADR-002) -ENABLE_OFFLINE_ACCESS=true # Enable refresh tokens -TOKEN_ENCRYPTION_KEY= # Encrypt refresh tokens -TOKEN_STORAGE_DB=/app/data/tokens.db # Token storage path - -# OAuth scopes (optional - uses defaults if not specified) -NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access notes:read notes:write ... -``` - -**Provider Mode Detection:** -- **External IdP mode**: If `OIDC_DISCOVERY_URL` issuer ≠ `NEXTCLOUD_HOST` → Uses external provider (Keycloak, Auth0, Okta, etc.) -- **Integrated mode**: If `OIDC_DISCOVERY_URL` not set or issuer = `NEXTCLOUD_HOST` → Uses Nextcloud OIDC app - -**Nextcloud user_oidc Configuration:** -The `user_oidc` app is automatically configured by `app-hooks/post-installation/15-setup-keycloak-provider.sh`: -```bash -# Configured with: ---check-bearer=1 # Validate bearer tokens ---bearer-provisioning=1 # Auto-provision users ---unique-uid=1 # Hash user IDs ---scope="openid profile email offline_access" -``` - -**Troubleshooting:** -```bash -# Check Keycloak is running -docker-compose ps keycloak -docker-compose logs keycloak - -# Check user_oidc provider configuration -docker compose exec app php occ user_oidc:provider keycloak - -# Check MCP server logs -docker-compose logs -f mcp-keycloak - -# Check Nextcloud logs for token validation -docker compose exec app tail -f /var/www/html/data/nextcloud.log - -# Verify Keycloak is accessible from Nextcloud container -docker compose exec app curl http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration -``` - -**ADR-002 Offline Access Testing:** -The Keycloak integration enables testing ADR-002's primary authentication pattern (offline access with refresh tokens): - -1. **Refresh token storage**: Tokens stored encrypted in SQLite (`/app/data/tokens.db`) -2. **Token refresh**: Access tokens refreshed automatically when expired -3. **Background workers**: Can access APIs using stored refresh tokens -4. **No admin credentials**: All operations use user's OAuth tokens - -**Note**: Service account tokens (client_credentials grant) were considered but rejected as they create Nextcloud user accounts and violate OAuth "act on-behalf-of" principles. See ADR-002 "Will Not Implement" section. - -See `docs/ADR-002-vector-sync-authentication.md` for architectural details. - -**Audience Validation:** -Tokens include `aud: ["mcp-server", "nextcloud"]` claims for proper security: -- MCP server validates tokens are intended for it -- Nextcloud validates tokens include it as audience -- Prevents token misuse across services - -See `docs/audience-validation-setup.md` for configuration details and `docs/keycloak-multi-client-validation.md` for realm-level validation behavior. - -### Configuration Files - -- **`pyproject.toml`** - Python project configuration using uv for dependency management -- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection -- **`docker-compose.yml`** - Complete development environment with Nextcloud + database - -## Integration testing with docker - -### Nextcloud - -- The `app` container is running nextcloud. -- Use `docker compose exec app php occ ...` to get a list of available commands - -### Mariadb - -- The `db` container is running mariadb -- Use `docker compose exec db mariadb -u [user] -p [password] [database]` to execute queries. Check the docker-compose file for credentials +**For detailed setup, see**: +- `docs/installation.md` - Installation guide +- `docs/configuration.md` - Configuration options +- `docs/authentication.md` - Authentication modes +- `docs/running.md` - Running the server diff --git a/docs/ADR-004-mcp-application-oauth.md b/docs/ADR-004-mcp-application-oauth.md index a1f793d..92e372a 100644 --- a/docs/ADR-004-mcp-application-oauth.md +++ b/docs/ADR-004-mcp-application-oauth.md @@ -1324,6 +1324,160 @@ grant_type=urn:ietf:params:oauth:grant-type:token-exchange - Configure audience claim per API - Use inline hooks for dynamic audiences +## Token Acquisition Patterns for MCP Tool Calls + +### Progressive Consent vs Token Exchange + +**IMPORTANT**: Progressive Consent and Token Exchange are complementary patterns that serve different purposes: + +- **Progressive Consent** = Authorization architecture (when and why users grant access) + - Flow 1: MCP client authenticates to MCP server + - Flow 2: MCP server provisions Nextcloud access + - Results in stored refresh tokens for background jobs + +- **Token Exchange** = Token acquisition pattern (how tokens are obtained during tool execution) + - Pass-through mode: Verify and pass Flow 1 token to Nextcloud + - Token exchange mode: Exchange Flow 1 token for ephemeral Nextcloud token + - Results in short-lived, operation-specific tokens + +**Key Principle**: Refresh tokens from Progressive Consent (Flow 2) should **NEVER** be used for MCP tool calls - they are exclusively for background jobs. This maintains clear separation between user-initiated operations and offline background work. + +### Two Token Acquisition Modes + +The MCP server supports two modes for obtaining Nextcloud tokens during tool execution: + +#### Mode 1: Pass-Through (Default - ENABLE_TOKEN_EXCHANGE=false) + +**How it works:** +1. MCP client sends Flow 1 token (aud: "mcp-server") with tool call +2. MCP server validates token audience and scopes +3. MCP server passes the same token to Nextcloud +4. Nextcloud validates token with IdP + +**Characteristics:** +- Simple, stateless operation +- Single token flows through the system +- Lower latency (no token exchange round-trip) +- Token lifetime determined by IdP's Flow 1 token settings + +**Use case**: Simple deployments where Flow 1 tokens are trusted to access Nextcloud directly. + +#### Mode 2: Token Exchange (Opt-In - ENABLE_TOKEN_EXCHANGE=true) + +**How it works:** +1. MCP client sends Flow 1 token (aud: "mcp-server") with tool call +2. MCP server validates token audience and scopes +3. MCP server exchanges Flow 1 token for ephemeral Nextcloud token via RFC 8693 +4. MCP server uses ephemeral token for Nextcloud API call +5. Ephemeral token is discarded (not cached) + +**Characteristics:** +- Enhanced security through token delegation +- Ephemeral tokens with minimal lifetime (5 minutes default) +- Token exchange provides audit trail +- Fallback to refresh grant if RFC 8693 not supported +- Tokens never cached or stored + +**Use case**: High-security environments requiring token delegation, audit trails, and minimal token lifetimes. + +### Implementation in get_client() + +The token acquisition mode is handled transparently by `get_client()`: + +```python +async def get_client(ctx: Context) -> NextcloudClient: + """ + Get the appropriate Nextcloud client based on authentication mode. + + This function handles three modes: + 1. BasicAuth mode: Returns shared client from lifespan context + 2. OAuth pass-through mode (ENABLE_TOKEN_EXCHANGE=false, default): + Verifies Flow 1 token and passes it to Nextcloud + 3. OAuth token exchange mode (ENABLE_TOKEN_EXCHANGE=true): + Exchanges Flow 1 token for ephemeral Nextcloud token via RFC 8693 + """ + settings = get_settings() + lifespan_ctx = ctx.request_context.lifespan_context + + # BasicAuth mode + if hasattr(lifespan_ctx, "client"): + return lifespan_ctx.client + + # OAuth mode + if hasattr(lifespan_ctx, "nextcloud_host"): + if settings.enable_token_exchange: + # Token exchange mode + return await get_session_client_from_context(ctx, lifespan_ctx.nextcloud_host) + else: + # Pass-through mode (default) + return get_client_from_context(ctx, lifespan_ctx.nextcloud_host) +``` + +### Nextcloud Scope Limitation + +**CRITICAL**: Nextcloud does not support OAuth scopes natively. The scopes used in this architecture (e.g., "notes:read", "calendar:write") are **soft-scopes** enforced by the MCP server via the `@require_scopes` decorator, **not by the IdP or Nextcloud**. + +**Implications:** +1. Token exchange requests don't pass scopes to the IdP (Nextcloud doesn't validate them) +2. The MCP server's `@require_scopes` decorator handles authorization checks +3. All Nextcloud tokens have equivalent permissions at the Nextcloud level +4. Fine-grained access control is enforced by the MCP server, not Nextcloud + +**Why this matters:** +- You cannot request a "notes-only" token from the IdP +- Token exchange provides audit and delegation benefits, not scope restriction +- Scopes are a convenience for MCP server authorization logic, not a security boundary + +### Background Job Pattern + +Background jobs use a **completely different** token acquisition pattern: + +```python +class BackgroundSyncWorker: + async def sync_user_data(self, user_id: str): + """Background workers use refresh tokens from Flow 2, never from tool calls.""" + + # Get refresh token stored during Flow 2 (Progressive Consent) + refresh_token = await self.storage.get_refresh_token(user_id) + + # Use refresh token to get Nextcloud access token + response = await self.idp_client.refresh_token( + refresh_token=refresh_token, + audience='nextcloud' + ) + + # Use access token for background operations + client = NextcloudClient.from_token( + base_url=self.nextcloud_url, + token=response.access_token, + username=user_id + ) + + await self.sync_notes(user_id, client) +``` + +**Key differences:** +- Uses refresh tokens from Flow 2 (Progressive Consent provisioning) +- Tokens can be cached for efficiency (longer-lived operations) +- No user interaction possible (offline) +- Different scopes than tool calls (e.g., "notes:sync" vs "notes:read") + +### When to Enable Token Exchange + +**Enable token exchange when:** +- You need audit trails showing token delegation +- You want minimal token lifetimes for security +- Your IdP supports RFC 8693 +- You operate in a high-security environment + +**Use pass-through mode when:** +- Simplicity is more important than token delegation +- Your IdP doesn't support RFC 8693 +- You trust Flow 1 tokens to access Nextcloud directly +- Lower latency is a priority + +**Both modes maintain the same security boundary**: Refresh tokens from Flow 2 are never used for tool calls, only for background jobs. + ## Decision Outcome The **Progressive Consent Architecture with Dual OAuth Flows** provides a secure, enterprise-ready solution for offline access while maintaining strict security boundaries and user transparency. By using separate OAuth flows for client authentication and resource provisioning, we achieve: diff --git a/docs/CRITICAL-TOKEN-EXCHANGE-PATTERN.md b/docs/CRITICAL-TOKEN-EXCHANGE-PATTERN.md index d74a43c..de2582a 100644 --- a/docs/CRITICAL-TOKEN-EXCHANGE-PATTERN.md +++ b/docs/CRITICAL-TOKEN-EXCHANGE-PATTERN.md @@ -1,28 +1,40 @@ -# CRITICAL: Token Exchange Pattern for ADR-004 +# Token Acquisition Patterns for ADR-004 Progressive Consent -## Problem Statement +## Overview -The current implementation of ADR-004 Progressive Consent does **NOT** correctly implement the token exchange pattern. This is a **critical architectural flaw** that must be corrected. +ADR-004 Progressive Consent establishes the authorization architecture (Flow 1 for client auth, Flow 2 for resource provisioning). This document describes **how tokens are acquired for different operational contexts** within that architecture. -## Current (Incorrect) Implementation +**Key Principle**: Refresh tokens from Flow 2 (Progressive Consent) should **NEVER** be used for MCP tool calls - they are exclusively for background jobs. -### What Happens Now: +## Implementation Status + +**Current Status**: ✅ Token exchange infrastructure implemented, available as opt-in feature + +The MCP server supports two token acquisition modes: +1. **Pass-through mode** (default, `ENABLE_TOKEN_EXCHANGE=false`): Simple, stateless +2. **Token exchange mode** (opt-in, `ENABLE_TOKEN_EXCHANGE=true`): Enhanced security with token delegation + +Both modes maintain the critical separation: **refresh tokens are never used for tool calls**. + +## Current Default (Pass-Through Mode) + +### What Happens (ENABLE_TOKEN_EXCHANGE=false): 1. Client gets Flow 1 token (`aud: "mcp-server"`) 2. Client calls MCP tool 3. Server validates Flow 1 token -4. **WRONG**: Server uses stored refresh token to get Nextcloud token -5. **WRONG**: Same refresh token used for all sessions and background jobs +4. Server passes Flow 1 token to Nextcloud +5. Nextcloud validates token with IdP +6. Refresh tokens (from Flow 2) used **only** for background jobs -### Problems: -- ❌ No separation between session tokens and background tokens -- ❌ Refresh tokens are reused across different contexts -- ❌ Session tokens could have different scope requirements than background tokens -- ❌ No on-demand delegation during tool calls -- ❌ Violates principle of least privilege +### Characteristics: +- ✅ Simple, stateless operation +- ✅ Clear separation: Flow 1 tokens for sessions, refresh tokens for background +- ✅ Lower latency (no token exchange round-trip) +- ✅ Works with any OAuth IdP -## Correct Implementation Required +## Optional Token Exchange Mode -### Token Exchange Pattern +### Token Exchange Pattern (ENABLE_TOKEN_EXCHANGE=true) **MCP Session (Foreground Operations)**: @@ -119,116 +131,136 @@ Implement RFC 8693 Token Exchange: async def exchange_token_for_delegation( flow1_token: str, - requested_scopes: list[str], - requested_audience: str = "nextcloud" + requested_audience: str = "nextcloud", + requested_scopes: list[str] | None = None ) -> tuple[str, int]: """ Exchange Flow 1 MCP token for delegated Nextcloud token. This implements RFC 8693 Token Exchange for on-behalf-of delegation. + IMPORTANT: Nextcloud doesn't support OAuth scopes natively. Scopes are + soft-scopes enforced by the MCP server via @require_scopes decorator, + not by the IdP or Nextcloud. Therefore, requested_scopes are not passed + to the IdP during token exchange. + Args: flow1_token: The MCP session token (aud: "mcp-server") - requested_scopes: Scopes needed for this operation requested_audience: Target audience (usually "nextcloud") + requested_scopes: Ignored (Nextcloud doesn't support scopes) Returns: Tuple of (delegated_token, expires_in) """ - # 1. Validate Flow 1 token + # 1. Validate Flow 1 token (audience check) # 2. Check user has provisioned Nextcloud access (Flow 2) - # 3. Request token exchange from IdP + # 3. Request token exchange from IdP (without scopes - Nextcloud doesn't support them) # 4. Return ephemeral delegated token ``` -### 2. Context-Aware Token Broker +### 2. Unified get_client() Pattern -Update Token Broker to distinguish contexts: +The token acquisition mode is handled transparently by `get_client()`: ```python -class TokenBrokerService: - async def get_session_token( - self, - flow1_token: str, - required_scopes: list[str] - ) -> str: - """Get ephemeral token for MCP session (on-demand).""" - # Exchange Flow 1 token for delegated token - # DO NOT use stored refresh token - # Return short-lived token +# nextcloud_mcp_server/context.py - async def get_background_token( - self, - user_id: str, - required_scopes: list[str] - ) -> str: - """Get token for background job (uses refresh token).""" - # Use stored refresh token from Flow 2 - # Different scope requirements - # Longer-lived token +async def get_client(ctx: Context) -> NextcloudClient: + """ + Get the appropriate Nextcloud client based on authentication mode. + + This function handles three modes: + 1. BasicAuth mode: Returns shared client from lifespan context + 2. OAuth pass-through mode (ENABLE_TOKEN_EXCHANGE=false, default): + Verifies Flow 1 token and passes it to Nextcloud + 3. OAuth token exchange mode (ENABLE_TOKEN_EXCHANGE=true): + Exchanges Flow 1 token for ephemeral Nextcloud token via RFC 8693 + """ + settings = get_settings() + lifespan_ctx = ctx.request_context.lifespan_context + + # BasicAuth mode - use shared client (no token exchange) + if hasattr(lifespan_ctx, "client"): + return lifespan_ctx.client + + # OAuth mode (has 'nextcloud_host' attribute) + if hasattr(lifespan_ctx, "nextcloud_host"): + # Check if token exchange is enabled + if settings.enable_token_exchange: + # Token exchange mode: Exchange Flow 1 token for ephemeral Nextcloud token + return await get_session_client_from_context( + ctx, lifespan_ctx.nextcloud_host + ) + else: + # Pass-through mode (default): Verify and pass Flow 1 token to Nextcloud + return get_client_from_context(ctx, lifespan_ctx.nextcloud_host) ``` -### 3. Update MCP Tool Pattern +### 3. MCP Tool Pattern (No Changes Required!) -Tools should request token exchange: +Tools use the same pattern regardless of token acquisition mode: ```python @mcp.tool() -@require_scopes("notes:read") +@require_scopes("notes:read") # Soft-scope enforced by MCP server, not Nextcloud @require_provisioning async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse: """Search notes by title or content.""" - # Extract Flow 1 token from context - flow1_token = ctx.authorization.token - - # Get Token Broker - broker = get_token_broker() - - # CRITICAL: Exchange for delegated token - nextcloud_token = await broker.get_session_token( - flow1_token=flow1_token, - required_scopes=["notes:read"] # Minimal scopes for this operation - ) - - # Create Nextcloud client with delegated token - client = await create_nextcloud_client( - host=NEXTCLOUD_HOST, - token=nextcloud_token # Ephemeral delegated token - ) + # get_client() handles both pass-through and token exchange modes + client = await get_client(ctx) # Execute operation - results = await client.notes_search_notes(query=query) + results = await client.notes.search_notes(query=query) - # Token automatically expires - NOT stored + # In token exchange mode, ephemeral token is automatically discarded + # In pass-through mode, Flow 1 token was validated and passed through return SearchNotesResponse(results=results) ``` +**Key Benefit**: Tools don't need to know which mode is active. The token acquisition pattern is configured at the server level via `ENABLE_TOKEN_EXCHANGE`. + ### 4. Background Job Pattern +Background jobs use a **different token acquisition pattern** - they use refresh tokens from Flow 2: + ```python # Background worker async def sync_notes_job(user_id: str): """Background job to sync notes.""" - broker = get_token_broker() + # Get refresh token stored during Flow 2 (Progressive Consent) + token_storage = get_token_storage() + refresh_token = await token_storage.get_refresh_token(user_id) - # CRITICAL: Use background token pattern - background_token = await broker.get_background_token( - user_id=user_id, - required_scopes=["notes:sync", "files:sync"] # Background-specific scopes + if not refresh_token: + logger.warning(f"No refresh token for user {user_id}") + return + + # Use refresh token to get Nextcloud access token + idp_client = get_idp_client() + response = await idp_client.refresh_token( + refresh_token=refresh_token, + audience='nextcloud' ) - # Create client with background token - client = await create_nextcloud_client( - host=NEXTCLOUD_HOST, - token=background_token + # Create client with background token (can be cached) + client = NextcloudClient.from_token( + base_url=NEXTCLOUD_HOST, + token=response.access_token, + username=user_id ) # Perform background sync await client.notes.sync_all() ``` +**Key differences from tool calls:** +- Uses refresh tokens from Flow 2 (Progressive Consent provisioning) +- Tokens can be cached for efficiency (longer-lived operations) +- No user interaction possible (offline) +- Never triggered during MCP tool execution + ## Security Benefits ### Proper Token Exchange: @@ -276,15 +308,41 @@ async def sync_notes_job(user_id: str): ## Status -**Current Status**: ❌ CRITICAL ISSUE - Token exchange not implemented -**Target Status**: ✅ Proper token exchange with session/background separation -**Priority**: **P0 - Blocker for production use** +**Current Status**: ✅ Token exchange infrastructure implemented, available as opt-in feature +**Modes Available**: +- ✅ Pass-through mode (default, `ENABLE_TOKEN_EXCHANGE=false`): Simple, stateless +- ✅ Token exchange mode (opt-in, `ENABLE_TOKEN_EXCHANGE=true`): Enhanced security -## Next Actions +**Implementation Complete**: +- ✅ `token_exchange.py` module with RFC 8693 support +- ✅ Fallback to refresh grant when RFC 8693 not supported +- ✅ `get_client()` unified pattern (handles both modes transparently) +- ✅ Tokens never cached in token exchange mode (ephemeral) +- ✅ Background jobs use separate pattern (refresh tokens from Flow 2) -1. [ ] Implement `token_exchange.py` module with RFC 8693 support -2. [ ] Update `TokenBrokerService` with session vs background methods -3. [ ] Refactor MCP tools to use token exchange pattern -4. [ ] Add integration tests for token exchange -5. [ ] Document background job patterns -6. [ ] Update ADR-004 with implementation details +## Configuration + +To enable token exchange mode: + +```bash +# docker-compose.yml or .env +ENABLE_TOKEN_EXCHANGE=true +``` + +When enabled, all MCP tool calls will use token exchange (RFC 8693) to obtain ephemeral Nextcloud tokens. When disabled (default), Flow 1 tokens are passed through to Nextcloud. + +## Nextcloud Scope Limitation + +**IMPORTANT**: Nextcloud does not support OAuth scopes natively. Scopes like "notes:read" are **soft-scopes** enforced by the MCP server via `@require_scopes` decorator, not by the IdP or Nextcloud. + +This means: +- Token exchange provides audit and delegation benefits, not scope restriction +- All Nextcloud tokens have equivalent permissions at the Nextcloud level +- Fine-grained access control is enforced by MCP server, not Nextcloud + +## Next Actions (Optional Enhancements) + +1. [ ] Add integration tests for token exchange mode with actual MCP tools +2. [ ] Document background job patterns for scheduled sync operations +3. [ ] Add metrics for token exchange performance +4. [ ] Consider making token exchange the default in future major version diff --git a/nextcloud_mcp_server/auth/context_helper.py b/nextcloud_mcp_server/auth/context_helper.py index 986e1be..867abc1 100644 --- a/nextcloud_mcp_server/auth/context_helper.py +++ b/nextcloud_mcp_server/auth/context_helper.py @@ -6,6 +6,8 @@ from mcp.server.auth.provider import AccessToken from mcp.server.fastmcp import Context from ..client import NextcloudClient +from ..config import get_settings +from .token_exchange import exchange_token_for_delegation logger = logging.getLogger(__name__) @@ -63,3 +65,85 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient: logger.error(f"Failed to extract OAuth context: {e}") logger.error("This may indicate the server is not running in OAuth mode") raise + + +async def get_session_client_from_context( + ctx: Context, base_url: str +) -> NextcloudClient: + """ + Create NextcloudClient using RFC 8693 token exchange for session operations. + + This implements the token exchange pattern where: + 1. Extract Flow 1 token from context (aud: "mcp-server") + 2. Exchange it for ephemeral Nextcloud token via RFC 8693 + 3. Create client with delegated token (NOT stored) + + Note: Nextcloud doesn't support OAuth scopes natively. Scopes are enforced + by the MCP server via @require_scopes decorator, not by the IdP. Therefore, + we don't pass scopes to the token exchange - the MCP server already validated + permissions before calling this function. + + Args: + ctx: MCP request context containing session info + base_url: Nextcloud base URL + + Returns: + NextcloudClient configured with ephemeral delegated token + + Raises: + AttributeError: If context doesn't contain expected OAuth session data + RuntimeError: If token exchange fails + """ + settings = get_settings() + + # Check if token exchange is enabled + if not settings.enable_token_exchange: + logger.info("Token exchange disabled, falling back to standard OAuth flow") + return get_client_from_context(ctx, base_url) + + try: + # Extract Flow 1 token from context + if hasattr(ctx.request_context.request, "user") and hasattr( + ctx.request_context.request.user, "access_token" + ): + access_token: AccessToken = ctx.request_context.request.user.access_token + flow1_token = access_token.token + username = access_token.resource # Username stored during verification + logger.debug(f"Retrieved Flow 1 token for user: {username}") + else: + logger.error("No Flow 1 token found in request context") + raise AttributeError("No access token found in OAuth request context") + + if not username: + logger.error("No username found in access token resource field") + raise ValueError("Username not available in OAuth token context") + + logger.info("Exchanging Flow 1 token for ephemeral Nextcloud token") + + # Perform RFC 8693 token exchange + # Note: We don't pass scopes since Nextcloud doesn't enforce them. + # The MCP server's @require_scopes decorator handles authorization. + delegated_token, expires_in = await exchange_token_for_delegation( + flow1_token=flow1_token, + requested_scopes=None, # Nextcloud doesn't support scopes + requested_audience="nextcloud", + ) + + logger.info( + f"Token exchange successful. Ephemeral token expires in {expires_in}s" + ) + + # Create client with ephemeral delegated token + # This token is NOT stored and will be discarded after use + return NextcloudClient.from_token( + base_url=base_url, token=delegated_token, username=username + ) + + except AttributeError as e: + logger.error(f"Failed to extract OAuth context: {e}") + raise + except Exception as e: + logger.error(f"Token exchange failed: {e}") + # Fall back to standard OAuth flow if token exchange fails + logger.info("Falling back to standard OAuth flow") + return get_client_from_context(ctx, base_url) diff --git a/nextcloud_mcp_server/auth/provisioning_decorator.py b/nextcloud_mcp_server/auth/provisioning_decorator.py index 1613257..9095933 100644 --- a/nextcloud_mcp_server/auth/provisioning_decorator.py +++ b/nextcloud_mcp_server/auth/provisioning_decorator.py @@ -55,6 +55,15 @@ def require_provisioning(func: Callable) -> Callable: ) ) + # Check if we're in BasicAuth mode - if so, skip provisioning check + # In BasicAuth mode, there's no OAuth and no provisioning needed + lifespan_ctx = ctx.request_context.lifespan_context + if hasattr(lifespan_ctx, "client"): + # BasicAuth mode - no provisioning needed, just proceed + logger.debug("BasicAuth mode detected - skipping provisioning check") + return await func(*args, **kwargs) + + # OAuth mode - check provisioning # Get user_id from authorization token user_id = None if hasattr(ctx, "authorization") and ctx.authorization: diff --git a/nextcloud_mcp_server/auth/token_broker.py b/nextcloud_mcp_server/auth/token_broker.py index 44f2a09..e69c354 100644 --- a/nextcloud_mcp_server/auth/token_broker.py +++ b/nextcloud_mcp_server/auth/token_broker.py @@ -11,6 +11,7 @@ The Token Broker provides: - Short-lived token caching (5-minute TTL) - Master refresh token rotation - Audience-specific token validation +- Session vs background token separation (RFC 8693) """ import asyncio @@ -23,6 +24,7 @@ import jwt from cryptography.fernet import Fernet from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage +from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation logger = logging.getLogger(__name__) @@ -150,6 +152,10 @@ class TokenBrokerService: """ Get a valid Nextcloud access token for the user. + DEPRECATED: This method uses the old pattern of stored refresh tokens + for all operations. Use get_session_token() or get_background_token() + instead for proper session/background separation. + This method: 1. Checks the cache for a valid token 2. If not cached, checks for stored refresh token @@ -192,10 +198,119 @@ class TokenBrokerService: await self.cache.invalidate(user_id) return None + async def get_session_token( + self, + flow1_token: str, + required_scopes: list[str], + requested_audience: str = "nextcloud", + ) -> Optional[str]: + """ + Get ephemeral token for MCP session operations (on-demand). + + This implements the correct Progressive Consent pattern where: + 1. Client provides Flow 1 token (aud: "mcp-server") + 2. Server exchanges it for ephemeral Nextcloud token + 3. Token is NOT stored, only used for current operation + + Key properties: + - On-demand generation during tool execution + - Ephemeral (not stored, discarded after use) + - Limited scopes (only what tool needs) + - Short-lived (5 minutes) + + Args: + flow1_token: The MCP session token (aud: "mcp-server") + required_scopes: Minimal scopes needed for this operation + requested_audience: Target audience (usually "nextcloud") + + Returns: + Ephemeral Nextcloud access token or None if exchange fails + """ + try: + # Perform RFC 8693 token exchange + delegated_token, expires_in = await exchange_token_for_delegation( + flow1_token=flow1_token, + requested_scopes=required_scopes, + requested_audience=requested_audience, + ) + + # NOTE: We intentionally do NOT cache session tokens + # They are ephemeral and should be discarded after use + logger.info( + f"Generated ephemeral session token with scopes: {required_scopes}, " + f"expires in {expires_in}s" + ) + + return delegated_token + + except Exception as e: + logger.error(f"Failed to get session token: {e}") + return None + + async def get_background_token( + self, user_id: str, required_scopes: list[str] + ) -> Optional[str]: + """ + Get token for background job operations (uses stored refresh token). + + This is for background/offline operations that run without user interaction. + Uses the stored refresh token from Flow 2 provisioning. + + Key properties: + - Uses stored refresh token from Flow 2 + - Different scopes than session tokens + - Longer-lived for background operations + - Can be cached for efficiency + + Args: + user_id: The user identifier + required_scopes: Scopes needed for background operation + + Returns: + Nextcloud access token for background operations or None if not provisioned + """ + # Check cache first (background tokens can be cached) + cache_key = f"{user_id}:background:{','.join(sorted(required_scopes))}" + cached_token = await self.cache.get(cache_key) + if cached_token: + return cached_token + + # Get stored refresh token + refresh_data = await self.storage.get_refresh_token(user_id) + if not refresh_data: + logger.info(f"No refresh token found for user {user_id}") + return None + + try: + # Decrypt refresh token + encrypted_token = refresh_data["refresh_token"] + refresh_token = self.fernet.decrypt(encrypted_token.encode()).decode() + + # Get token with specific scopes for background operation + access_token, expires_in = await self._refresh_access_token_with_scopes( + refresh_token, required_scopes + ) + + # Cache the background token + await self.cache.set(cache_key, access_token, expires_in) + + logger.info( + f"Generated background token for user {user_id} with scopes: {required_scopes}" + ) + + return access_token + + except Exception as e: + logger.error(f"Failed to get background token for user {user_id}: {e}") + await self.cache.invalidate(cache_key) + return None + async def _refresh_access_token(self, refresh_token: str) -> Tuple[str, int]: """ Exchange refresh token for new access token. + DEPRECATED: Use _refresh_access_token_with_scopes() for scope-specific requests. + Args: refresh_token: The refresh token @@ -236,6 +351,60 @@ class TokenBrokerService: logger.info(f"Refreshed access token (expires in {expires_in}s)") return access_token, expires_in + async def _refresh_access_token_with_scopes( + self, refresh_token: str, required_scopes: list[str] + ) -> Tuple[str, int]: + """ + Exchange refresh token for new access token with specific scopes. + + This method implements scope downscoping for least privilege. + + Args: + refresh_token: The refresh token + required_scopes: Minimal scopes needed for this operation + + Returns: + Tuple of (access_token, expires_in_seconds) + """ + config = await self._get_oidc_config() + token_endpoint = config["token_endpoint"] + + client = await self._get_http_client() + + # Always include basic OpenID scopes + scopes = list(set(["openid", "profile", "email"] + required_scopes)) + + # Request new access token with specific scopes + data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "scope": " ".join(scopes), + } + + response = await client.post( + token_endpoint, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + if response.status_code != 200: + logger.error( + f"Token refresh with scopes failed: {response.status_code} - {response.text}" + ) + raise Exception(f"Token refresh failed: {response.status_code}") + + token_data = response.json() + access_token = token_data["access_token"] + expires_in = token_data.get("expires_in", 3600) # Default 1 hour + + # Validate audience + await self._validate_token_audience(access_token, "nextcloud") + + logger.info( + f"Refreshed access token with scopes {scopes} (expires in {expires_in}s)" + ) + return access_token, expires_in + async def _validate_token_audience(self, token: str, expected_audience: str): """ Validate that token has correct audience claim. diff --git a/nextcloud_mcp_server/auth/token_exchange.py b/nextcloud_mcp_server/auth/token_exchange.py new file mode 100644 index 0000000..3afd2b5 --- /dev/null +++ b/nextcloud_mcp_server/auth/token_exchange.py @@ -0,0 +1,445 @@ +"""RFC 8693 Token Exchange implementation for ADR-004 Progressive Consent. + +This module implements the token exchange pattern to convert Flow 1 MCP tokens +(aud: "mcp-server") into ephemeral delegated Nextcloud tokens (aud: "nextcloud") +for session operations. + +Key Properties: +- On-demand generation during tool execution +- Ephemeral tokens (NOT stored, discarded after use) +- Limited scopes (only what tool needs) +- Short-lived (5 minutes default) +""" + +import logging +import time +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urljoin + +import httpx +import jwt + +from ..config import get_settings +from .refresh_token_storage import RefreshTokenStorage + +logger = logging.getLogger(__name__) + + +class TokenExchangeService: + """Implements RFC 8693 OAuth 2.0 Token Exchange.""" + + # RFC 8693 Token Type Identifiers + TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token" + TOKEN_TYPE_JWT = "urn:ietf:params:oauth:token-type:jwt" + TOKEN_TYPE_ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token" + + def __init__( + self, + oidc_discovery_url: Optional[str] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + nextcloud_host: Optional[str] = None, + ): + """Initialize token exchange service. + + Args: + oidc_discovery_url: OIDC discovery endpoint URL + client_id: OAuth client ID for token exchange + client_secret: OAuth client secret + nextcloud_host: Nextcloud instance URL + """ + settings = get_settings() + self.oidc_discovery_url = oidc_discovery_url or settings.oidc_discovery_url + self.client_id = client_id or settings.oidc_client_id + self.client_secret = client_secret or settings.oidc_client_secret + self.nextcloud_host = nextcloud_host or settings.nextcloud_host + + self._token_endpoint: Optional[str] = None + self._jwks_uri: Optional[str] = None + self._discovery_cache: Optional[Dict[str, Any]] = None + self._discovery_cache_time: float = 0 + self._discovery_cache_ttl: float = 3600 # 1 hour + + # Initialize storage for checking provisioning + self.storage = RefreshTokenStorage() + + # Create HTTP client + self.http_client = httpx.AsyncClient( + timeout=30.0, + follow_redirects=True, + ) + + async def __aenter__(self): + """Async context manager entry.""" + await self.storage.initialize() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + await self.close() + + async def close(self): + """Close HTTP client and storage.""" + await self.http_client.aclose() + # RefreshTokenStorage doesn't have a close method + + async def _discover_endpoints(self) -> Dict[str, Any]: + """Discover OIDC endpoints from discovery URL. + + Returns: + Discovery document containing endpoint URLs + """ + # Check cache + if ( + self._discovery_cache + and (time.time() - self._discovery_cache_time) < self._discovery_cache_ttl + ): + return self._discovery_cache + + if not self.oidc_discovery_url: + # Fallback to Nextcloud OIDC if no discovery URL + self.oidc_discovery_url = urljoin( + self.nextcloud_host, "/.well-known/openid-configuration" + ) + + try: + response = await self.http_client.get(self.oidc_discovery_url) + response.raise_for_status() + + self._discovery_cache = response.json() + self._discovery_cache_time = time.time() + + # Cache frequently used endpoints + self._token_endpoint = self._discovery_cache.get("token_endpoint") + self._jwks_uri = self._discovery_cache.get("jwks_uri") + + return self._discovery_cache + + except Exception as e: + logger.error(f"Failed to discover OIDC endpoints: {e}") + raise + + async def exchange_token_for_delegation( + self, + flow1_token: str, + requested_scopes: list[str], + requested_audience: str = "nextcloud", + ) -> Tuple[str, int]: + """Exchange Flow 1 MCP token for delegated Nextcloud token. + + This implements RFC 8693 Token Exchange for on-behalf-of delegation. + + Args: + flow1_token: The MCP session token (aud: "mcp-server") + requested_scopes: Scopes needed for this operation + requested_audience: Target audience (usually "nextcloud") + + Returns: + Tuple of (delegated_token, expires_in) + + Raises: + ValueError: If token validation fails + RuntimeError: If provisioning not completed or exchange fails + """ + # 1. Validate Flow 1 token audience + await self._validate_flow1_token(flow1_token) + + # 2. Extract user ID from token + user_id = self._extract_user_id(flow1_token) + + # 3. Check user has provisioned Nextcloud access (Flow 2) + if not await self._check_provisioning(user_id): + raise RuntimeError( + "Nextcloud access not provisioned. " + "User must complete Flow 2 provisioning first." + ) + + # 4. Get stored refresh token for user (from Flow 2) + refresh_token = await self._get_user_refresh_token(user_id) + if not refresh_token: + raise RuntimeError( + "No refresh token found. User must complete provisioning." + ) + + # 5. Perform token exchange with IdP + delegated_token, expires_in = await self._perform_token_exchange( + subject_token=flow1_token, + refresh_token=refresh_token, + requested_scopes=requested_scopes, + requested_audience=requested_audience, + ) + + # 6. Log the exchange for audit trail + logger.info( + f"Token exchange completed for user {user_id}: " + f"scopes={requested_scopes}, audience={requested_audience}, " + f"expires_in={expires_in}s" + ) + + return delegated_token, expires_in + + async def _validate_flow1_token(self, token: str): + """Validate that token has correct audience for MCP server. + + Args: + token: JWT token to validate + + Raises: + ValueError: If token is invalid or has wrong audience + """ + try: + # Decode without verification first to check audience + # In production, should verify signature against JWKS + payload = jwt.decode(token, options={"verify_signature": False}) + + # Check audience + audience = payload.get("aud", []) + if isinstance(audience, str): + audience = [audience] + + if "mcp-server" not in audience: + raise ValueError( + f"Invalid token audience. Expected 'mcp-server', got {audience}" + ) + + # Check expiration + exp = payload.get("exp", 0) + if exp < time.time(): + raise ValueError("Token has expired") + + except jwt.DecodeError as e: + raise ValueError(f"Invalid JWT token: {e}") + + def _extract_user_id(self, token: str) -> str: + """Extract user ID from JWT token. + + Args: + token: JWT token + + Returns: + User ID from token + """ + try: + payload = jwt.decode(token, options={"verify_signature": False}) + + # Try standard claims in order of preference + user_id = ( + payload.get("sub") + or payload.get("preferred_username") + or payload.get("email") + or payload.get("name") + ) + + if not user_id: + raise ValueError("No user identifier in token") + + return user_id + + except jwt.DecodeError as e: + raise ValueError(f"Failed to extract user ID: {e}") + + async def _check_provisioning(self, user_id: str) -> bool: + """Check if user has completed Flow 2 provisioning. + + Args: + user_id: User identifier + + Returns: + True if provisioned, False otherwise + """ + token_data = await self.storage.get_refresh_token(user_id) + return token_data is not None + + async def _get_user_refresh_token(self, user_id: str) -> Optional[str]: + """Get stored refresh token for user from Flow 2 provisioning. + + Args: + user_id: User identifier + + Returns: + Refresh token if found, None otherwise + """ + token_data = await self.storage.get_refresh_token(user_id) + if token_data: + return token_data.get("refresh_token") + return None + + async def _perform_token_exchange( + self, + subject_token: str, + refresh_token: str, + requested_scopes: list[str], + requested_audience: str, + ) -> Tuple[str, int]: + """Perform RFC 8693 token exchange with IdP. + + Args: + subject_token: The token being exchanged (Flow 1 token) + refresh_token: User's stored refresh token for delegation + requested_scopes: Minimal scopes for this operation + requested_audience: Target audience + + Returns: + Tuple of (access_token, expires_in) + """ + # Discover token endpoint + discovery = await self._discover_endpoints() + token_endpoint = discovery.get("token_endpoint") + + if not token_endpoint: + raise RuntimeError("No token endpoint found in discovery") + + # Build token exchange request per RFC 8693 + data = { + # Token exchange grant type + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + # The token we're exchanging (Flow 1 MCP token) + "subject_token": subject_token, + "subject_token_type": self.TOKEN_TYPE_ACCESS_TOKEN, + # Use refresh token as actor token (proves we have delegation rights) + "actor_token": refresh_token, + "actor_token_type": self.TOKEN_TYPE_ACCESS_TOKEN, + # Requested token properties + "requested_token_type": self.TOKEN_TYPE_ACCESS_TOKEN, + "audience": requested_audience, + "scope": " ".join(requested_scopes), + } + + # Add client credentials if configured + if self.client_id and self.client_secret: + data["client_id"] = self.client_id + data["client_secret"] = self.client_secret + + try: + # Attempt RFC 8693 token exchange + response = await self.http_client.post( + token_endpoint, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + if response.status_code == 400: + # Token exchange might not be supported, fall back to refresh grant + logger.info( + "Token exchange not supported, falling back to refresh grant" + ) + return await self._fallback_refresh_grant( + refresh_token=refresh_token, + requested_scopes=requested_scopes, + token_endpoint=token_endpoint, + ) + + response.raise_for_status() + result = response.json() + + access_token = result.get("access_token") + expires_in = result.get("expires_in", 300) # Default 5 minutes + + if not access_token: + raise RuntimeError("No access token in exchange response") + + return access_token, expires_in + + except httpx.HTTPStatusError as e: + logger.error(f"Token exchange failed: {e.response.text}") + raise RuntimeError(f"Token exchange failed: {e}") + except Exception as e: + logger.error(f"Token exchange error: {e}") + raise + + async def _fallback_refresh_grant( + self, refresh_token: str, requested_scopes: list[str], token_endpoint: str + ) -> Tuple[str, int]: + """Fallback to standard refresh token grant if token exchange not supported. + + This is less secure than token exchange but provides compatibility. + + Args: + refresh_token: User's stored refresh token + requested_scopes: Minimal scopes for this operation + token_endpoint: Token endpoint URL + + Returns: + Tuple of (access_token, expires_in) + """ + data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "scope": " ".join(requested_scopes), # Request minimal scopes + } + + # Add client credentials if configured + if self.client_id and self.client_secret: + data["client_id"] = self.client_id + data["client_secret"] = self.client_secret + + try: + response = await self.http_client.post( + token_endpoint, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + response.raise_for_status() + + result = response.json() + + access_token = result.get("access_token") + expires_in = result.get("expires_in", 300) # Default 5 minutes + + if not access_token: + raise RuntimeError("No access token in refresh response") + + # Log that we're using fallback + logger.warning( + f"Using refresh grant fallback for token exchange. " + f"Scopes: {requested_scopes}" + ) + + return access_token, expires_in + + except httpx.HTTPStatusError as e: + logger.error(f"Refresh grant failed: {e.response.text}") + raise RuntimeError(f"Refresh grant failed: {e}") + except Exception as e: + logger.error(f"Refresh grant error: {e}") + raise + + +# Singleton instance +_token_exchange_service: Optional[TokenExchangeService] = None + + +async def get_token_exchange_service() -> TokenExchangeService: + """Get or create the singleton token exchange service. + + Returns: + TokenExchangeService instance + """ + global _token_exchange_service + + if _token_exchange_service is None: + _token_exchange_service = TokenExchangeService() + await _token_exchange_service.storage.initialize() + + return _token_exchange_service + + +async def exchange_token_for_delegation( + flow1_token: str, requested_scopes: list[str], requested_audience: str = "nextcloud" +) -> Tuple[str, int]: + """Convenience function to exchange tokens. + + Args: + flow1_token: The MCP session token (aud: "mcp-server") + requested_scopes: Scopes needed for this operation + requested_audience: Target audience (usually "nextcloud") + + Returns: + Tuple of (delegated_token, expires_in) + """ + service = await get_token_exchange_service() + return await service.exchange_token_for_delegation( + flow1_token=flow1_token, + requested_scopes=requested_scopes, + requested_audience=requested_audience, + ) diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index 2617e58..9ca8900 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -1,6 +1,7 @@ import logging.config import os -from typing import Any +from dataclasses import dataclass +from typing import Any, Optional LOGGING_CONFIG = { "version": 1, @@ -118,3 +119,58 @@ def get_document_processor_config() -> dict[str, Any]: } return config + + +@dataclass +class Settings: + """Application settings from environment variables.""" + + # OAuth/OIDC settings + oidc_discovery_url: Optional[str] = None + oidc_client_id: Optional[str] = None + oidc_client_secret: Optional[str] = None + + # Nextcloud settings + nextcloud_host: Optional[str] = None + nextcloud_username: Optional[str] = None + nextcloud_password: Optional[str] = None + + # Progressive Consent settings + enable_progressive_consent: bool = False + enable_token_exchange: bool = False + enable_offline_access: bool = False + + # Token settings + token_encryption_key: Optional[str] = None + token_storage_db: Optional[str] = None + + +def get_settings() -> Settings: + """Get application settings from environment variables. + + Returns: + Settings object with configuration values + """ + return Settings( + # OAuth/OIDC settings + oidc_discovery_url=os.getenv("OIDC_DISCOVERY_URL"), + oidc_client_id=os.getenv("OIDC_CLIENT_ID"), + oidc_client_secret=os.getenv("OIDC_CLIENT_SECRET"), + # Nextcloud settings + nextcloud_host=os.getenv("NEXTCLOUD_HOST"), + nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"), + nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"), + # Progressive Consent settings + enable_progressive_consent=( + os.getenv("ENABLE_PROGRESSIVE_CONSENT", "false").lower() == "true" + ), + enable_token_exchange=( + os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true" + ), + enable_offline_access=( + os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true" + ), + # Token settings + token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"), + token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"), + ) diff --git a/nextcloud_mcp_server/context.py b/nextcloud_mcp_server/context.py index fad2bcc..c568e29 100644 --- a/nextcloud_mcp_server/context.py +++ b/nextcloud_mcp_server/context.py @@ -3,14 +3,22 @@ from mcp.server.fastmcp import Context from nextcloud_mcp_server.client import NextcloudClient +from nextcloud_mcp_server.config import get_settings -def get_client(ctx: Context) -> NextcloudClient: +async def get_client(ctx: Context) -> NextcloudClient: """ Get the appropriate Nextcloud client based on authentication mode. - In BasicAuth mode, returns the shared client from lifespan context. - In OAuth mode, creates a new client per-request using the OAuth context. + This function handles three modes: + 1. BasicAuth mode: Returns shared client from lifespan context + 2. OAuth pass-through mode (ENABLE_TOKEN_EXCHANGE=false, default): + Verifies Flow 1 token and passes it to Nextcloud + 3. OAuth token exchange mode (ENABLE_TOKEN_EXCHANGE=true): + Exchanges Flow 1 token for ephemeral Nextcloud token via RFC 8693 + + Note: Nextcloud doesn't support OAuth scopes natively. Scopes are enforced + by the MCP server via @require_scopes decorator, not by the IdP. This function automatically detects the authentication mode by checking the type of the lifespan context. @@ -28,21 +36,34 @@ def get_client(ctx: Context) -> NextcloudClient: ```python @mcp.tool() async def my_tool(ctx: Context): - client = get_client(ctx) + client = await get_client(ctx) return await client.capabilities() ``` """ + settings = get_settings() lifespan_ctx = ctx.request_context.lifespan_context - # Try BasicAuth mode first (has 'client' attribute) + # BasicAuth mode - use shared client (no token exchange) if hasattr(lifespan_ctx, "client"): return lifespan_ctx.client # OAuth mode (has 'nextcloud_host' attribute) if hasattr(lifespan_ctx, "nextcloud_host"): - from nextcloud_mcp_server.auth import get_client_from_context + # Check if token exchange is enabled + if settings.enable_token_exchange: + from nextcloud_mcp_server.auth.context_helper import ( + get_session_client_from_context, + ) - return get_client_from_context(ctx, lifespan_ctx.nextcloud_host) + # Token exchange mode: Exchange Flow 1 token for ephemeral Nextcloud token + return await get_session_client_from_context( + ctx, lifespan_ctx.nextcloud_host + ) + else: + # Pass-through mode (default): Verify and pass Flow 1 token to Nextcloud + from nextcloud_mcp_server.auth import get_client_from_context + + return get_client_from_context(ctx, lifespan_ctx.nextcloud_host) # Unknown context type raise AttributeError( diff --git a/nextcloud_mcp_server/server/calendar.py b/nextcloud_mcp_server/server/calendar.py index 265cba6..10598d5 100644 --- a/nextcloud_mcp_server/server/calendar.py +++ b/nextcloud_mcp_server/server/calendar.py @@ -22,7 +22,7 @@ def configure_calendar_tools(mcp: FastMCP): @require_scopes("calendar:read") async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse: """List all available calendars for the user""" - client = get_client(ctx) + client = await get_client(ctx) calendars_data = await client.calendar.list_calendars() calendars = [Calendar(**cal_data) for cal_data in calendars_data] @@ -79,7 +79,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: Dict with event creation result """ - client = get_client(ctx) + client = await get_client(ctx) event_data = { "title": title, @@ -139,7 +139,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: List of events matching the filters """ - client = get_client(ctx) + client = await get_client(ctx) # Convert YYYY-MM-DD format dates to datetime objects start_datetime = None @@ -214,7 +214,7 @@ def configure_calendar_tools(mcp: FastMCP): ctx: Context, ): """Get detailed information about a specific event""" - client = get_client(ctx) + client = await get_client(ctx) event_data, etag = await client.calendar.get_event(calendar_name, event_uid) return event_data @@ -248,7 +248,7 @@ def configure_calendar_tools(mcp: FastMCP): etag: str = "", ): """Update any aspect of an existing event""" - client = get_client(ctx) + client = await get_client(ctx) # Build update data with only non-None values event_data = {} @@ -299,7 +299,7 @@ def configure_calendar_tools(mcp: FastMCP): ctx: Context, ): """Delete a calendar event""" - client = get_client(ctx) + client = await get_client(ctx) return await client.calendar.delete_event(calendar_name, event_uid) @mcp.tool() @@ -342,7 +342,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: Dict with meeting creation result """ - client = get_client(ctx) + client = await get_client(ctx) # Combine date and time for start_datetime start_datetime = f"{date}T{time}:00" @@ -377,7 +377,7 @@ def configure_calendar_tools(mcp: FastMCP): limit: int = 10, ): """Get upcoming events in next N days""" - client = get_client(ctx) + client = await get_client(ctx) now = dt.datetime.now() end_datetime = now + dt.timedelta(days=days_ahead) @@ -447,7 +447,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: List of available time slots with start/end times and duration """ - client = get_client(ctx) + client = await get_client(ctx) # Parse attendees attendee_list = [] @@ -549,7 +549,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: Summary of operation results including counts and details """ - client = get_client(ctx) + client = await get_client(ctx) if operation not in ["update", "delete", "move"]: raise ValueError("Operation must be 'update', 'delete', or 'move'") @@ -772,7 +772,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: Result of the calendar management operation """ - client = get_client(ctx) + client = await get_client(ctx) if action == "list": return await client.calendar.list_calendars() @@ -839,7 +839,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: List of todos matching the filters """ - client = get_client(ctx) + client = await get_client(ctx) # Build filters dictionary filters = {} @@ -890,7 +890,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: Dict with todo creation result """ - client = get_client(ctx) + client = await get_client(ctx) todo_data = { "summary": summary, @@ -939,7 +939,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: Dict with todo update result """ - client = get_client(ctx) + client = await get_client(ctx) # Build update data with only non-None values todo_data = {} @@ -981,7 +981,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: Dict with deletion status """ - client = get_client(ctx) + client = await get_client(ctx) return await client.calendar.delete_todo(calendar_name, todo_uid) @mcp.tool() @@ -1005,7 +1005,7 @@ def configure_calendar_tools(mcp: FastMCP): Returns: List of todos matching the filters from all calendars """ - client = get_client(ctx) + client = await get_client(ctx) # Build filters dictionary filters = {} diff --git a/nextcloud_mcp_server/server/contacts.py b/nextcloud_mcp_server/server/contacts.py index 860d3db..a1f14d5 100644 --- a/nextcloud_mcp_server/server/contacts.py +++ b/nextcloud_mcp_server/server/contacts.py @@ -14,14 +14,14 @@ def configure_contacts_tools(mcp: FastMCP): @require_scopes("contacts:read") async def nc_contacts_list_addressbooks(ctx: Context): """List all addressbooks for the user.""" - client = get_client(ctx) + client = await get_client(ctx) return await client.contacts.list_addressbooks() @mcp.tool() @require_scopes("contacts:read") async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str): """List all contacts in the specified addressbook.""" - client = get_client(ctx) + client = await get_client(ctx) return await client.contacts.list_contacts(addressbook=addressbook) @mcp.tool() @@ -35,7 +35,7 @@ def configure_contacts_tools(mcp: FastMCP): name: The name of the addressbook. display_name: The display name of the addressbook. """ - client = get_client(ctx) + client = await get_client(ctx) return await client.contacts.create_addressbook( name=name, display_name=display_name ) @@ -44,7 +44,7 @@ def configure_contacts_tools(mcp: FastMCP): @require_scopes("contacts:write") async def nc_contacts_delete_addressbook(ctx: Context, *, name: str): """Delete an addressbook.""" - client = get_client(ctx) + client = await get_client(ctx) return await client.contacts.delete_addressbook(name=name) @mcp.tool() @@ -59,7 +59,7 @@ def configure_contacts_tools(mcp: FastMCP): uid: The unique ID for the contact. contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}. """ - client = get_client(ctx) + client = await get_client(ctx) return await client.contacts.create_contact( addressbook=addressbook, uid=uid, contact_data=contact_data ) @@ -68,7 +68,7 @@ def configure_contacts_tools(mcp: FastMCP): @require_scopes("contacts:write") async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str): """Delete a contact.""" - client = get_client(ctx) + client = await get_client(ctx) return await client.contacts.delete_contact(addressbook=addressbook, uid=uid) @mcp.tool() @@ -84,7 +84,7 @@ def configure_contacts_tools(mcp: FastMCP): contact_data: A dictionary with the contact's updated details, e.g. {"fn": "Jane Doe", "email": "jane.doe@example.com"}. etag: Optional ETag for optimistic concurrency control. """ - client = get_client(ctx) + client = await get_client(ctx) return await client.contacts.update_contact( addressbook=addressbook, uid=uid, contact_data=contact_data, etag=etag ) diff --git a/nextcloud_mcp_server/server/cookbook.py b/nextcloud_mcp_server/server/cookbook.py index 89432a2..5b7c8d8 100644 --- a/nextcloud_mcp_server/server/cookbook.py +++ b/nextcloud_mcp_server/server/cookbook.py @@ -33,7 +33,7 @@ def configure_cookbook_tools(mcp: FastMCP): async def cookbook_get_version(): """Get the Cookbook app and API version""" ctx: Context = mcp.get_context() - client = get_client(ctx) + client = await get_client(ctx) version_data = await client.cookbook.get_version() return Version(**version_data) @@ -41,7 +41,7 @@ def configure_cookbook_tools(mcp: FastMCP): async def cookbook_get_config(): """Get the Cookbook app configuration""" ctx: Context = mcp.get_context() - client = get_client(ctx) + client = await get_client(ctx) config_data = await client.cookbook.get_config() return CookbookConfig(**config_data) @@ -49,7 +49,7 @@ def configure_cookbook_tools(mcp: FastMCP): async def nc_cookbook_get_recipe_resource(recipe_id: int): """Get a recipe by ID using resource URI""" ctx: Context = mcp.get_context() - client = get_client(ctx) + client = await get_client(ctx) try: recipe_data = await client.cookbook.get_recipe(recipe_id) return Recipe(**recipe_data) @@ -77,7 +77,7 @@ def configure_cookbook_tools(mcp: FastMCP): This extracts recipe data from websites that use schema.org Recipe markup. Many popular recipe sites support this standard.""" - client = get_client(ctx) + client = await get_client(ctx) try: recipe_data = await client.cookbook.import_recipe(url) recipe = Recipe(**recipe_data) @@ -131,7 +131,7 @@ def configure_cookbook_tools(mcp: FastMCP): @require_scopes("cookbook:read") async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse: """Get all recipes in the database""" - client = get_client(ctx) + client = await get_client(ctx) try: recipes_data = await client.cookbook.list_recipes() recipes = [RecipeStub(**r) for r in recipes_data] @@ -156,7 +156,7 @@ def configure_cookbook_tools(mcp: FastMCP): @require_scopes("cookbook:read") async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe: """Get a specific recipe by its ID""" - client = get_client(ctx) + client = await get_client(ctx) try: recipe_data = await client.cookbook.get_recipe(recipe_id) return Recipe(**recipe_data) @@ -199,7 +199,7 @@ def configure_cookbook_tools(mcp: FastMCP): Optional: All other recipe fields following schema.org/Recipe format. Times should be in ISO8601 duration format (e.g., 'PT30M' for 30 minutes).""" - client = get_client(ctx) + client = await get_client(ctx) recipe_data = {"name": name} if description: @@ -276,7 +276,7 @@ def configure_cookbook_tools(mcp: FastMCP): """Update an existing recipe. Provide only the fields you want to update. Unspecified fields remain unchanged.""" - client = get_client(ctx) + client = await get_client(ctx) # First get the current recipe try: @@ -352,7 +352,7 @@ def configure_cookbook_tools(mcp: FastMCP): ) -> DeleteRecipeResponse: """Delete a recipe permanently""" logger.info("Deleting recipe %s", recipe_id) - client = get_client(ctx) + client = await get_client(ctx) try: message = await client.cookbook.delete_recipe(recipe_id) return DeleteRecipeResponse( @@ -386,7 +386,7 @@ def configure_cookbook_tools(mcp: FastMCP): query: str, ctx: Context ) -> SearchRecipesResponse: """Search for recipes by keywords, tags, and categories""" - client = get_client(ctx) + client = await get_client(ctx) try: recipes_data = await client.cookbook.search_recipes(query) recipes = [RecipeStub(**r) for r in recipes_data] @@ -422,7 +422,7 @@ def configure_cookbook_tools(mcp: FastMCP): """Get all known categories. Note: A category name of '*' indicates recipes with no category.""" - client = get_client(ctx) + client = await get_client(ctx) try: categories_data = await client.cookbook.list_categories() categories = [Category(**c) for c in categories_data] @@ -451,7 +451,7 @@ def configure_cookbook_tools(mcp: FastMCP): """Get all recipes in a specific category. Use '_' as the category name to get recipes with no category.""" - client = get_client(ctx) + client = await get_client(ctx) try: recipes_data = await client.cookbook.get_recipes_in_category(category) recipes = [RecipeStub(**r) for r in recipes_data] @@ -483,7 +483,7 @@ def configure_cookbook_tools(mcp: FastMCP): @require_scopes("cookbook:read") async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse: """Get all known keywords/tags""" - client = get_client(ctx) + client = await get_client(ctx) try: keywords_data = await client.cookbook.list_keywords() keywords = [Keyword(**k) for k in keywords_data] @@ -510,7 +510,7 @@ def configure_cookbook_tools(mcp: FastMCP): keywords: list[str], ctx: Context ) -> ListRecipesResponse: """Get all recipes that have specific keywords/tags""" - client = get_client(ctx) + client = await get_client(ctx) try: recipes_data = await client.cookbook.get_recipes_with_keywords(keywords) recipes = [RecipeStub(**r) for r in recipes_data] @@ -552,7 +552,7 @@ def configure_cookbook_tools(mcp: FastMCP): folder: Recipe folder path in user's files update_interval: Automatic rescan interval in minutes print_image: Whether to print images with recipes""" - client = get_client(ctx) + client = await get_client(ctx) config_data = {} if folder is not None: @@ -587,7 +587,7 @@ def configure_cookbook_tools(mcp: FastMCP): """Trigger a rescan of all recipes into the caching database. This rebuilds the search index and should be used after manual file changes.""" - client = get_client(ctx) + client = await get_client(ctx) try: message = await client.cookbook.reindex() return ReindexResponse(status_code=200, message=message) diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py index f3513c1..386b8a4 100644 --- a/nextcloud_mcp_server/server/deck.py +++ b/nextcloud_mcp_server/server/deck.py @@ -31,7 +31,7 @@ def configure_deck_tools(mcp: FastMCP): """List all Nextcloud Deck boards""" ctx: Context = mcp.get_context() await ctx.warning("This message is deprecated, use the deck_get_board instead") - client = get_client(ctx) + client = await get_client(ctx) boards = await client.deck.get_boards() return [board.model_dump() for board in boards] @@ -42,7 +42,7 @@ def configure_deck_tools(mcp: FastMCP): await ctx.warning( "This resource is deprecated, use the deck_get_board tool instead" ) - client = get_client(ctx) + client = await get_client(ctx) board = await client.deck.get_board(board_id) return board.model_dump() @@ -53,7 +53,7 @@ def configure_deck_tools(mcp: FastMCP): await ctx.warning( "This resource is deprecated, use the deck_get_stacks tool instead" ) - client = get_client(ctx) + client = await get_client(ctx) stacks = await client.deck.get_stacks(board_id) return [stack.model_dump() for stack in stacks] @@ -64,7 +64,7 @@ def configure_deck_tools(mcp: FastMCP): await ctx.warning( "This resource is deprecated, use the deck_get_stack tool instead" ) - client = get_client(ctx) + client = await get_client(ctx) stack = await client.deck.get_stack(board_id, stack_id) return stack.model_dump() @@ -75,7 +75,7 @@ def configure_deck_tools(mcp: FastMCP): await ctx.warning( "This resource is deprecated, use the deck_get_cards tool instead" ) - client = get_client(ctx) + client = await get_client(ctx) stack = await client.deck.get_stack(board_id, stack_id) if stack.cards: return [card.model_dump() for card in stack.cards] @@ -88,7 +88,7 @@ def configure_deck_tools(mcp: FastMCP): await ctx.warning( "This resource is deprecated, use the deck_get_card tool instead" ) - client = get_client(ctx) + client = await get_client(ctx) card = await client.deck.get_card(board_id, stack_id, card_id) return card.model_dump() @@ -99,7 +99,7 @@ def configure_deck_tools(mcp: FastMCP): await ctx.warning( "This resource is deprecated, use the deck_get_labels tool instead" ) - client = get_client(ctx) + client = await get_client(ctx) board = await client.deck.get_board(board_id) return [label.model_dump() for label in board.labels] @@ -110,7 +110,7 @@ def configure_deck_tools(mcp: FastMCP): await ctx.warning( "This resource is deprecated, use the deck_get_label tool instead" ) - client = get_client(ctx) + client = await get_client(ctx) label = await client.deck.get_label(board_id, label_id) return label.model_dump() @@ -120,7 +120,7 @@ def configure_deck_tools(mcp: FastMCP): @require_scopes("deck:read") async def deck_get_boards(ctx: Context) -> list[DeckBoard]: """Get all Nextcloud Deck boards""" - client = get_client(ctx) + client = await get_client(ctx) boards = await client.deck.get_boards() return boards @@ -128,7 +128,7 @@ def configure_deck_tools(mcp: FastMCP): @require_scopes("deck:read") async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard: """Get details of a specific Nextcloud Deck board""" - client = get_client(ctx) + client = await get_client(ctx) board = await client.deck.get_board(board_id) return board @@ -136,7 +136,7 @@ def configure_deck_tools(mcp: FastMCP): @require_scopes("deck:read") async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]: """Get all stacks in a Nextcloud Deck board""" - client = get_client(ctx) + client = await get_client(ctx) stacks = await client.deck.get_stacks(board_id) return stacks @@ -144,7 +144,7 @@ def configure_deck_tools(mcp: FastMCP): @require_scopes("deck:read") async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack: """Get details of a specific Nextcloud Deck stack""" - client = get_client(ctx) + client = await get_client(ctx) stack = await client.deck.get_stack(board_id, stack_id) return stack @@ -154,7 +154,7 @@ def configure_deck_tools(mcp: FastMCP): ctx: Context, board_id: int, stack_id: int ) -> list[DeckCard]: """Get all cards in a Nextcloud Deck stack""" - client = get_client(ctx) + client = await get_client(ctx) stack = await client.deck.get_stack(board_id, stack_id) if stack.cards: return stack.cards @@ -166,7 +166,7 @@ def configure_deck_tools(mcp: FastMCP): ctx: Context, board_id: int, stack_id: int, card_id: int ) -> DeckCard: """Get details of a specific Nextcloud Deck card""" - client = get_client(ctx) + client = await get_client(ctx) card = await client.deck.get_card(board_id, stack_id, card_id) return card @@ -174,7 +174,7 @@ def configure_deck_tools(mcp: FastMCP): @require_scopes("deck:read") async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]: """Get all labels in a Nextcloud Deck board""" - client = get_client(ctx) + client = await get_client(ctx) board = await client.deck.get_board(board_id) return board.labels @@ -182,7 +182,7 @@ def configure_deck_tools(mcp: FastMCP): @require_scopes("deck:read") async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel: """Get details of a specific Nextcloud Deck label""" - client = get_client(ctx) + client = await get_client(ctx) label = await client.deck.get_label(board_id, label_id) return label @@ -199,7 +199,7 @@ def configure_deck_tools(mcp: FastMCP): title: The title of the new board color: The hexadecimal color of the new board (e.g. FF0000) """ - client = get_client(ctx) + client = await get_client(ctx) board = await client.deck.create_board(title, color) return CreateBoardResponse(id=board.id, title=board.title, color=board.color) @@ -217,7 +217,7 @@ def configure_deck_tools(mcp: FastMCP): title: The title of the new stack order: Order for sorting the stacks """ - client = get_client(ctx) + client = await get_client(ctx) stack = await client.deck.create_stack(board_id, title, order) return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order) @@ -238,7 +238,7 @@ def configure_deck_tools(mcp: FastMCP): title: New title for the stack order: New order for the stack """ - client = get_client(ctx) + client = await get_client(ctx) await client.deck.update_stack(board_id, stack_id, title, order) return StackOperationResponse( success=True, @@ -258,7 +258,7 @@ def configure_deck_tools(mcp: FastMCP): board_id: The ID of the board stack_id: The ID of the stack """ - client = get_client(ctx) + client = await get_client(ctx) await client.deck.delete_stack(board_id, stack_id) return StackOperationResponse( success=True, @@ -291,7 +291,7 @@ def configure_deck_tools(mcp: FastMCP): description: Description of the card duedate: Due date of the card (ISO-8601 format) """ - client = get_client(ctx) + client = await get_client(ctx) card = await client.deck.create_card( board_id, stack_id, title, type, order, description, duedate ) @@ -333,7 +333,7 @@ def configure_deck_tools(mcp: FastMCP): archived: Whether the card should be archived done: Completion date for the card (ISO-8601 format) """ - client = get_client(ctx) + client = await get_client(ctx) await client.deck.update_card( board_id, stack_id, @@ -367,7 +367,7 @@ def configure_deck_tools(mcp: FastMCP): stack_id: The ID of the stack card_id: The ID of the card """ - client = get_client(ctx) + client = await get_client(ctx) await client.deck.delete_card(board_id, stack_id, card_id) return CardOperationResponse( success=True, @@ -389,7 +389,7 @@ def configure_deck_tools(mcp: FastMCP): stack_id: The ID of the stack card_id: The ID of the card """ - client = get_client(ctx) + client = await get_client(ctx) await client.deck.archive_card(board_id, stack_id, card_id) return CardOperationResponse( success=True, @@ -411,7 +411,7 @@ def configure_deck_tools(mcp: FastMCP): stack_id: The ID of the stack card_id: The ID of the card """ - client = get_client(ctx) + client = await get_client(ctx) await client.deck.unarchive_card(board_id, stack_id, card_id) return CardOperationResponse( success=True, @@ -440,7 +440,7 @@ def configure_deck_tools(mcp: FastMCP): order: New position in the target stack target_stack_id: The ID of the target stack """ - client = get_client(ctx) + client = await get_client(ctx) await client.deck.reorder_card( board_id, stack_id, card_id, order, target_stack_id ) @@ -465,7 +465,7 @@ def configure_deck_tools(mcp: FastMCP): title: The title of the new label color: The color of the new label (hex format without #) """ - client = get_client(ctx) + client = await get_client(ctx) label = await client.deck.create_label(board_id, title, color) return CreateLabelResponse(id=label.id, title=label.title, color=label.color) @@ -486,7 +486,7 @@ def configure_deck_tools(mcp: FastMCP): title: New title for the label color: New color for the label (hex format without #) """ - client = get_client(ctx) + client = await get_client(ctx) await client.deck.update_label(board_id, label_id, title, color) return LabelOperationResponse( success=True, @@ -506,7 +506,7 @@ def configure_deck_tools(mcp: FastMCP): board_id: The ID of the board label_id: The ID of the label """ - client = get_client(ctx) + client = await get_client(ctx) await client.deck.delete_label(board_id, label_id) return LabelOperationResponse( success=True, @@ -529,7 +529,7 @@ def configure_deck_tools(mcp: FastMCP): card_id: The ID of the card label_id: The ID of the label to assign """ - client = get_client(ctx) + client = await get_client(ctx) await client.deck.assign_label_to_card(board_id, stack_id, card_id, label_id) return CardOperationResponse( success=True, @@ -552,7 +552,7 @@ def configure_deck_tools(mcp: FastMCP): card_id: The ID of the card label_id: The ID of the label to remove """ - client = get_client(ctx) + client = await get_client(ctx) await client.deck.remove_label_from_card(board_id, stack_id, card_id, label_id) return CardOperationResponse( success=True, @@ -576,7 +576,7 @@ def configure_deck_tools(mcp: FastMCP): card_id: The ID of the card user_id: The user ID to assign """ - client = get_client(ctx) + client = await get_client(ctx) await client.deck.assign_user_to_card(board_id, stack_id, card_id, user_id) return CardOperationResponse( success=True, @@ -599,7 +599,7 @@ def configure_deck_tools(mcp: FastMCP): card_id: The ID of the card user_id: The user ID to unassign """ - client = get_client(ctx) + client = await get_client(ctx) await client.deck.unassign_user_from_card(board_id, stack_id, card_id, user_id) return CardOperationResponse( success=True, diff --git a/nextcloud_mcp_server/server/notes.py b/nextcloud_mcp_server/server/notes.py index c36241c..acfe10b 100644 --- a/nextcloud_mcp_server/server/notes.py +++ b/nextcloud_mcp_server/server/notes.py @@ -29,7 +29,7 @@ def configure_notes_tools(mcp: FastMCP): ctx: Context = ( mcp.get_context() ) # https://github.com/modelcontextprotocol/python-sdk/issues/244 - client = get_client(ctx) + client = await get_client(ctx) settings_data = await client.notes.get_settings() return NotesSettings(**settings_data) @@ -37,7 +37,7 @@ def configure_notes_tools(mcp: FastMCP): async def nc_notes_get_attachment_resource(note_id: int, attachment_filename: str): """Get a specific attachment from a note""" ctx: Context = mcp.get_context() - client = get_client(ctx) + client = await get_client(ctx) # Assuming a method get_note_attachment exists in the client # This method should return the raw content and determine the mime type content, mime_type = await client.webdav.get_note_attachment( @@ -59,7 +59,7 @@ def configure_notes_tools(mcp: FastMCP): """Get user note using note id""" ctx: Context = mcp.get_context() - client = get_client(ctx) + client = await get_client(ctx) try: note_data = await client.notes.get_note(note_id) return Note(**note_data) @@ -92,7 +92,7 @@ def configure_notes_tools(mcp: FastMCP): title: str, content: str, category: str, ctx: Context ) -> CreateNoteResponse: """Create a new note (requires notes:write scope)""" - client = get_client(ctx) + client = await get_client(ctx) try: note_data = await client.notes.create_note( title=title, @@ -149,7 +149,7 @@ def configure_notes_tools(mcp: FastMCP): If the note has been modified by someone else since you retrieved it, the update will fail with a 412 error.""" logger.info("Updating note %s", note_id) - client = get_client(ctx) + client = await get_client(ctx) try: note_data = await client.notes.update( note_id=note_id, @@ -206,7 +206,7 @@ def configure_notes_tools(mcp: FastMCP): between the note and what will be appended.""" logger.info("Appending content to note %s", note_id) - client = get_client(ctx) + client = await get_client(ctx) try: note_data = await client.notes.append_content( note_id=note_id, content=content @@ -252,7 +252,7 @@ def configure_notes_tools(mcp: FastMCP): @require_provisioning async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse: """Search notes by title or content, returning only id, title, and category (requires notes:read scope).""" - client = get_client(ctx) + client = await get_client(ctx) try: search_results_raw = await client.notes_search_notes(query=query) @@ -298,7 +298,7 @@ def configure_notes_tools(mcp: FastMCP): @require_scopes("notes:read") async def nc_notes_get_note(note_id: int, ctx: Context) -> Note: """Get a specific note by its ID (requires notes:read scope)""" - client = get_client(ctx) + client = await get_client(ctx) try: note_data = await client.notes.get_note(note_id) return Note(**note_data) @@ -329,7 +329,7 @@ def configure_notes_tools(mcp: FastMCP): note_id: int, attachment_filename: str, ctx: Context ) -> dict[str, str]: """Get a specific attachment from a note""" - client = get_client(ctx) + client = await get_client(ctx) try: content, mime_type = await client.webdav.get_note_attachment( note_id=note_id, filename=attachment_filename @@ -374,7 +374,7 @@ def configure_notes_tools(mcp: FastMCP): async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse: """Delete a note permanently""" logger.info("Deleting note %s", note_id) - client = get_client(ctx) + client = await get_client(ctx) try: await client.notes.delete_note(note_id) return DeleteNoteResponse( diff --git a/nextcloud_mcp_server/server/sharing.py b/nextcloud_mcp_server/server/sharing.py index 0f7d777..5a2c1b6 100644 --- a/nextcloud_mcp_server/server/sharing.py +++ b/nextcloud_mcp_server/server/sharing.py @@ -45,7 +45,7 @@ def configure_sharing_tools(mcp: FastMCP): Returns: JSON string with share information including share ID """ - client = get_client(ctx) + client = await get_client(ctx) share_data = await client.sharing.create_share( path=path, share_with=share_with, @@ -67,7 +67,7 @@ def configure_sharing_tools(mcp: FastMCP): Returns: JSON string confirming deletion """ - client = get_client(ctx) + client = await get_client(ctx) await client.sharing.delete_share(share_id) return json.dumps( {"success": True, "message": f"Share {share_id} deleted"}, indent=2 @@ -87,7 +87,7 @@ def configure_sharing_tools(mcp: FastMCP): Returns: JSON string with share information """ - client = get_client(ctx) + client = await get_client(ctx) share_data = await client.sharing.get_share(share_id) return json.dumps(share_data, indent=2) @@ -106,7 +106,7 @@ def configure_sharing_tools(mcp: FastMCP): Returns: JSON string with list of shares """ - client = get_client(ctx) + client = await get_client(ctx) shares = await client.sharing.list_shares( path=path, shared_with_me=shared_with_me ) @@ -133,7 +133,7 @@ def configure_sharing_tools(mcp: FastMCP): Returns: JSON string with updated share information """ - client = get_client(ctx) + client = await get_client(ctx) share_data = await client.sharing.update_share( share_id=share_id, permissions=permissions ) diff --git a/nextcloud_mcp_server/server/tables.py b/nextcloud_mcp_server/server/tables.py index 774430d..f94e048 100644 --- a/nextcloud_mcp_server/server/tables.py +++ b/nextcloud_mcp_server/server/tables.py @@ -14,14 +14,14 @@ def configure_tables_tools(mcp: FastMCP): @require_scopes("tables:read") async def nc_tables_list_tables(ctx: Context): """List all tables available to the user""" - client = get_client(ctx) + client = await get_client(ctx) return await client.tables.list_tables() @mcp.tool() @require_scopes("tables:read") async def nc_tables_get_schema(table_id: int, ctx: Context): """Get the schema/structure of a specific table including columns and views""" - client = get_client(ctx) + client = await get_client(ctx) return await client.tables.get_table_schema(table_id) @mcp.tool() @@ -33,7 +33,7 @@ def configure_tables_tools(mcp: FastMCP): offset: int | None = None, ): """Read rows from a table with optional pagination""" - client = get_client(ctx) + client = await get_client(ctx) return await client.tables.get_table_rows(table_id, limit, offset) @mcp.tool() @@ -43,7 +43,7 @@ def configure_tables_tools(mcp: FastMCP): Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42} """ - client = get_client(ctx) + client = await get_client(ctx) return await client.tables.create_row(table_id, data) @mcp.tool() @@ -53,12 +53,12 @@ def configure_tables_tools(mcp: FastMCP): Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99} """ - client = get_client(ctx) + client = await get_client(ctx) return await client.tables.update_row(row_id, data) @mcp.tool() @require_scopes("tables:write") async def nc_tables_delete_row(row_id: int, ctx: Context): """Delete a row from a table""" - client = get_client(ctx) + client = await get_client(ctx) return await client.tables.delete_row(row_id) diff --git a/nextcloud_mcp_server/server/webdav.py b/nextcloud_mcp_server/server/webdav.py index eae3292..b92bf40 100644 --- a/nextcloud_mcp_server/server/webdav.py +++ b/nextcloud_mcp_server/server/webdav.py @@ -28,7 +28,7 @@ def configure_webdav_tools(mcp: FastMCP): Returns: DirectoryListing with files, total_count, directories_count, files_count, and total_size """ - client = get_client(ctx) + client = await get_client(ctx) items = await client.webdav.list_directory(path) # Convert to FileInfo models @@ -76,7 +76,7 @@ def configure_webdav_tools(mcp: FastMCP): result = await nc_webdav_read_file("Images/photo.jpg") logger.info(result['encoding']) # 'base64' """ - client = get_client(ctx) + client = await get_client(ctx) content, content_type = await client.webdav.read_file(path) # Check if this is a parseable document (PDF, DOCX, etc.) @@ -143,7 +143,7 @@ def configure_webdav_tools(mcp: FastMCP): Returns: Dict with status_code indicating success """ - client = get_client(ctx) + client = await get_client(ctx) # Handle base64 encoded content if content_type and "base64" in content_type.lower(): @@ -167,7 +167,7 @@ def configure_webdav_tools(mcp: FastMCP): Returns: Dict with status_code (201 for created, 405 if already exists) """ - client = get_client(ctx) + client = await get_client(ctx) return await client.webdav.create_directory(path) @mcp.tool() @@ -181,7 +181,7 @@ def configure_webdav_tools(mcp: FastMCP): Returns: Dict with status_code indicating result (404 if not found) """ - client = get_client(ctx) + client = await get_client(ctx) return await client.webdav.delete_resource(path) @mcp.tool() @@ -199,7 +199,7 @@ def configure_webdav_tools(mcp: FastMCP): Returns: Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False) """ - client = get_client(ctx) + client = await get_client(ctx) return await client.webdav.move_resource( source_path, destination_path, overwrite ) @@ -219,7 +219,7 @@ def configure_webdav_tools(mcp: FastMCP): Returns: Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False) """ - client = get_client(ctx) + client = await get_client(ctx) return await client.webdav.copy_resource( source_path, destination_path, overwrite ) @@ -249,7 +249,7 @@ def configure_webdav_tools(mcp: FastMCP): Returns: SearchFilesResponse with list of matching files """ - client = get_client(ctx) + client = await get_client(ctx) # Build where conditions based on filters conditions = [] @@ -355,7 +355,7 @@ def configure_webdav_tools(mcp: FastMCP): Returns: SearchFilesResponse with list of matching files """ - client = get_client(ctx) + client = await get_client(ctx) results = await client.webdav.find_by_name( pattern=pattern, scope=scope, limit=limit ) @@ -382,7 +382,7 @@ def configure_webdav_tools(mcp: FastMCP): Returns: SearchFilesResponse with list of matching files """ - client = get_client(ctx) + client = await get_client(ctx) results = await client.webdav.find_by_type( mime_type=mime_type, scope=scope, limit=limit ) @@ -408,7 +408,7 @@ def configure_webdav_tools(mcp: FastMCP): Returns: SearchFilesResponse with list of favorite files """ - client = get_client(ctx) + client = await get_client(ctx) results = await client.webdav.list_favorites(scope=scope, limit=limit) file_infos = [FileInfo(**result) for result in results] return SearchFilesResponse( diff --git a/tests/server/oauth/test_token_exchange.py b/tests/server/oauth/test_token_exchange.py new file mode 100644 index 0000000..32f761d --- /dev/null +++ b/tests/server/oauth/test_token_exchange.py @@ -0,0 +1,447 @@ +"""Unit tests for RFC 8693 Token Exchange (ADR-004). + +Tests the critical token exchange pattern that separates: +- Session tokens (ephemeral, on-demand) +- Background tokens (stored refresh tokens) +""" + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import jwt +import pytest + +from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage +from nextcloud_mcp_server.auth.token_broker import TokenBrokerService +from nextcloud_mcp_server.auth.token_exchange import TokenExchangeService + +pytestmark = pytest.mark.unit + + +@pytest.fixture +async def token_storage(): + """Create test token storage.""" + import tempfile + + from cryptography.fernet import Fernet + + # Generate valid Fernet key + encryption_key = Fernet.generate_key() + + # Create temporary database file + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: + db_path = tmp.name + + storage = RefreshTokenStorage(db_path=db_path, encryption_key=encryption_key) + await storage.initialize() + yield storage + + # Cleanup + if os.path.exists(db_path): + os.unlink(db_path) + + +@pytest.fixture +async def token_exchange_service(token_storage): + """Create test token exchange service.""" + service = TokenExchangeService( + oidc_discovery_url="http://test-idp/.well-known/openid-configuration", + client_id="test-client", + client_secret="test-secret", + nextcloud_host="http://test-nextcloud", + ) + service.storage = token_storage + yield service + await service.http_client.aclose() + + +@pytest.fixture +async def token_broker(token_storage): + """Create test token broker service.""" + # Use the same encryption key as storage + from cryptography.fernet import Fernet + + encryption_key = Fernet.generate_key() + + broker = TokenBrokerService( + storage=token_storage, + oidc_discovery_url="http://test-idp/.well-known/openid-configuration", + nextcloud_host="http://test-nextcloud", + encryption_key=encryption_key, + cache_ttl=300, + cache_early_refresh=30, + ) + yield broker + await broker.close() + + +def create_test_jwt( + user_id: str = "testuser", audience: str = "mcp-server", expires_in: int = 3600 +) -> str: + """Create a test JWT token.""" + import time + + payload = { + "sub": user_id, + "aud": audience, + "exp": int(time.time()) + expires_in, + "iat": int(time.time()), + "iss": "http://test-idp", + } + + # For testing, we don't sign the token (uses 'none' algorithm) + # In production, tokens would be properly signed + return jwt.encode(payload, "", algorithm="none") + + +class TestTokenExchange: + """Test RFC 8693 token exchange implementation.""" + + @pytest.mark.asyncio + async def test_validate_flow1_token_success(self, token_exchange_service): + """Test validation of Flow 1 token with correct audience.""" + # Create token with correct audience + flow1_token = create_test_jwt(audience="mcp-server") + + # Should not raise an exception + await token_exchange_service._validate_flow1_token(flow1_token) + + @pytest.mark.asyncio + async def test_validate_flow1_token_wrong_audience(self, token_exchange_service): + """Test validation fails with wrong audience.""" + # Create token with wrong audience + flow1_token = create_test_jwt(audience="nextcloud") + + with pytest.raises(ValueError, match="Invalid token audience"): + await token_exchange_service._validate_flow1_token(flow1_token) + + @pytest.mark.asyncio + async def test_validate_flow1_token_expired(self, token_exchange_service): + """Test validation fails with expired token.""" + # Create expired token + flow1_token = create_test_jwt(audience="mcp-server", expires_in=-3600) + + with pytest.raises(ValueError, match="Token has expired"): + await token_exchange_service._validate_flow1_token(flow1_token) + + @pytest.mark.asyncio + async def test_extract_user_id(self, token_exchange_service): + """Test extraction of user ID from token.""" + flow1_token = create_test_jwt(user_id="alice") + + user_id = token_exchange_service._extract_user_id(flow1_token) + assert user_id == "alice" + + @pytest.mark.asyncio + async def test_check_provisioning_not_provisioned(self, token_exchange_service): + """Test provisioning check when user not provisioned.""" + result = await token_exchange_service._check_provisioning("unknown_user") + assert result is False + + @pytest.mark.asyncio + async def test_check_provisioning_is_provisioned( + self, token_exchange_service, token_storage + ): + """Test provisioning check when user is provisioned.""" + # Store a refresh token for user + await token_storage.store_refresh_token( + user_id="alice", refresh_token="encrypted_refresh_token", flow_type="flow2" + ) + + result = await token_exchange_service._check_provisioning("alice") + assert result is True + + @pytest.mark.asyncio + async def test_exchange_token_not_provisioned(self, token_exchange_service): + """Test token exchange fails when user not provisioned.""" + flow1_token = create_test_jwt(user_id="unprovisioneduser") + + with pytest.raises(RuntimeError, match="Nextcloud access not provisioned"): + await token_exchange_service.exchange_token_for_delegation( + flow1_token=flow1_token, + requested_scopes=["notes:read"], + requested_audience="nextcloud", + ) + + @pytest.mark.asyncio + async def test_exchange_token_with_fallback( + self, token_exchange_service, token_storage + ): + """Test token exchange with refresh grant fallback.""" + # Store a refresh token for user + await token_storage.store_refresh_token( + user_id="alice", refresh_token="test_refresh_token", flow_type="flow2" + ) + + # Create Flow 1 token + flow1_token = create_test_jwt(user_id="alice", audience="mcp-server") + + # Mock HTTP client for token endpoint + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "delegated_token_12345", + "token_type": "Bearer", + "expires_in": 300, # 5 minutes + } + + with patch.object( + token_exchange_service.http_client, "post", return_value=mock_response + ): + # Mock discovery endpoint + with patch.object( + token_exchange_service, + "_discover_endpoints", + return_value={"token_endpoint": "http://test-idp/token"}, + ): + # Perform exchange + ( + token, + expires_in, + ) = await token_exchange_service.exchange_token_for_delegation( + flow1_token=flow1_token, + requested_scopes=["notes:read"], + requested_audience="nextcloud", + ) + + assert token == "delegated_token_12345" + assert expires_in == 300 + + +class TestTokenBroker: + """Test Token Broker session/background separation.""" + + @pytest.mark.asyncio + async def test_get_session_token(self, token_broker, token_storage): + """Test getting ephemeral session token via exchange.""" + # Store refresh token for user + await token_storage.store_refresh_token( + user_id="alice", refresh_token="test_refresh_token", flow_type="flow2" + ) + + # Create Flow 1 token + flow1_token = create_test_jwt(user_id="alice", audience="mcp-server") + + # Mock token exchange + with patch( + "nextcloud_mcp_server.auth.token_broker.exchange_token_for_delegation", + return_value=("ephemeral_token_xyz", 300), + ): + token = await token_broker.get_session_token( + flow1_token=flow1_token, + required_scopes=["notes:read"], + requested_audience="nextcloud", + ) + + assert token == "ephemeral_token_xyz" + + # Verify token is NOT cached (ephemeral) + cached = await token_broker.cache.get("alice") + assert cached is None # Should not be in cache + + @pytest.mark.asyncio + async def test_get_background_token(self, token_broker, token_storage): + """Test getting background token with stored refresh.""" + # Store encrypted refresh token for user + from cryptography.fernet import Fernet + + fernet = Fernet(b"test-key-" + b"0" * 32) + encrypted_token = fernet.encrypt(b"background_refresh_token").decode() + + await token_storage.store_refresh_token( + user_id="alice", refresh_token=encrypted_token, flow_type="flow2" + ) + + # Mock OIDC config and token response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "background_token_abc", + "token_type": "Bearer", + "expires_in": 3600, # 1 hour + } + + with patch.object( + token_broker, + "_get_oidc_config", + return_value={"token_endpoint": "http://test/token"}, + ): + with patch.object(token_broker, "_get_http_client") as mock_client: + mock_client.return_value.post = AsyncMock(return_value=mock_response) + + # Mock audience validation + with patch.object( + token_broker, "_validate_token_audience", return_value=None + ): + token = await token_broker.get_background_token( + user_id="alice", required_scopes=["notes:sync", "files:sync"] + ) + + assert token == "background_token_abc" + + # Verify token IS cached (background tokens can be cached) + cache_key = "alice:background:files:sync,notes:sync" + cached = await token_broker.cache.get(cache_key) + assert cached == "background_token_abc" + + @pytest.mark.asyncio + async def test_session_background_separation(self, token_broker, token_storage): + """Test that session and background tokens are kept separate.""" + # Store refresh token + from cryptography.fernet import Fernet + + fernet = Fernet(b"test-key-" + b"0" * 32) + encrypted_token = fernet.encrypt(b"master_refresh_token").decode() + + await token_storage.store_refresh_token( + user_id="alice", refresh_token=encrypted_token, flow_type="flow2" + ) + + flow1_token = create_test_jwt(user_id="alice", audience="mcp-server") + + # Mock different tokens for session vs background + session_token = "ephemeral_session_123" + background_token = "cached_background_456" + + # Get session token + with patch( + "nextcloud_mcp_server.auth.token_broker.exchange_token_for_delegation", + return_value=(session_token, 300), + ): + session_result = await token_broker.get_session_token( + flow1_token=flow1_token, required_scopes=["notes:read"] + ) + assert session_result == session_token + + # Get background token + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": background_token, + "expires_in": 3600, + } + + with patch.object( + token_broker, + "_get_oidc_config", + return_value={"token_endpoint": "http://test/token"}, + ): + with patch.object(token_broker, "_get_http_client") as mock_client: + mock_client.return_value.post = AsyncMock(return_value=mock_response) + with patch.object( + token_broker, "_validate_token_audience", return_value=None + ): + background_result = await token_broker.get_background_token( + user_id="alice", required_scopes=["notes:sync"] + ) + assert background_result == background_token + + # Verify they are different tokens + assert session_result != background_result + + # Verify session token not cached + assert await token_broker.cache.get("alice") is None + + # Verify background token IS cached + cache_key = "alice:background:notes:sync" + assert await token_broker.cache.get(cache_key) == background_token + + +class TestScopeDownscoping: + """Test that tokens request only necessary scopes.""" + + @pytest.mark.asyncio + async def test_session_token_minimal_scopes( + self, token_exchange_service, token_storage + ): + """Test session tokens request minimal scopes.""" + # Store refresh token + await token_storage.store_refresh_token( + user_id="alice", refresh_token="test_refresh_token", flow_type="flow2" + ) + + flow1_token = create_test_jwt(user_id="alice", audience="mcp-server") + + # Track what scopes are requested + requested_scopes = None + + async def mock_post(url, data, headers=None): + nonlocal requested_scopes + requested_scopes = data.get("scope", "").split() + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "scoped_token", + "expires_in": 300, + } + return mock_response + + with patch.object( + token_exchange_service.http_client, "post", side_effect=mock_post + ): + with patch.object( + token_exchange_service, + "_discover_endpoints", + return_value={"token_endpoint": "http://test/token"}, + ): + await token_exchange_service.exchange_token_for_delegation( + flow1_token=flow1_token, + requested_scopes=["notes:read"], # Only read scope + requested_audience="nextcloud", + ) + + # Verify only requested scope was included + assert "notes:read" in requested_scopes + assert "notes:write" not in requested_scopes + assert "calendar:write" not in requested_scopes + + @pytest.mark.asyncio + async def test_background_token_different_scopes(self, token_broker, token_storage): + """Test background tokens can request different scopes than session.""" + from cryptography.fernet import Fernet + + fernet = Fernet(b"test-key-" + b"0" * 32) + encrypted_token = fernet.encrypt(b"refresh_token").decode() + + await token_storage.store_refresh_token( + user_id="alice", refresh_token=encrypted_token, flow_type="flow2" + ) + + # Track requested scopes + requested_scopes = None + + async def mock_post(url, data, headers=None): + nonlocal requested_scopes + requested_scopes = data.get("scope", "").split() + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "background_sync_token", + "expires_in": 3600, + } + return mock_response + + with patch.object( + token_broker, + "_get_oidc_config", + return_value={"token_endpoint": "http://test/token"}, + ): + with patch.object(token_broker, "_get_http_client") as mock_client: + mock_client.return_value.post = mock_post + with patch.object( + token_broker, "_validate_token_audience", return_value=None + ): + await token_broker.get_background_token( + user_id="alice", + required_scopes=["notes:sync", "files:sync", "calendar:sync"], + ) + + # Verify sync scopes were requested + assert "notes:sync" in requested_scopes + assert "files:sync" in requested_scopes + assert "calendar:sync" in requested_scopes + # Basic OIDC scopes should also be included + assert "openid" in requested_scopes + assert "profile" in requested_scopes