refactor: integrate token exchange into unified get_client() pattern

Resolves the token exchange implementation gap where get_session_client()
was implemented but never used by tools. Unifies token acquisition into a
single async get_client() method that handles both pass-through and token
exchange modes transparently.

Core Changes:
- Make get_client() async and merge token exchange logic into it
- Remove scopes parameter from token exchange (Nextcloud doesn't support OAuth scopes)
- Update all 8 tool modules to use await get_client(ctx)
- Fix provisioning decorator to skip checks in BasicAuth mode

Token Acquisition Modes:
1. BasicAuth: Returns shared client (no token operations)
2. OAuth pass-through (default): Verifies and passes Flow 1 token to Nextcloud
3. OAuth token exchange (opt-in): Exchanges Flow 1 token for ephemeral token via RFC 8693

Key Architectural Clarifications:
- Progressive Consent (Flow 1/2) = Authorization architecture
- Token Exchange = Token acquisition pattern during tool execution
- Refresh tokens from Flow 2 are NEVER used for tool calls (only background jobs)
- Nextcloud scopes are "soft-scopes" enforced by MCP server, not IdP

Documentation Updates:
- ADR-004: Added comprehensive token acquisition patterns section
- CRITICAL-TOKEN-EXCHANGE-PATTERN.md: Updated to reflect implementation status
- CLAUDE.md: Updated architectural patterns with async get_client()

Testing:
- All 36 unit tests passing
- All 4 smoke tests passing (BasicAuth mode)
- Linting issues fixed (ruff)

Configuration:
ENABLE_TOKEN_EXCHANGE=false (default) - pass-through mode
ENABLE_TOKEN_EXCHANGE=true (opt-in) - token exchange mode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-11-03 19:45:47 +01:00
parent 636bfd416f
commit 71e77e95bc
18 changed files with 1819 additions and 647 deletions
+182 -453
View File
@@ -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='<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=<fernet-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
+154
View File
@@ -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:
+140 -82
View File
@@ -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
@@ -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)
@@ -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:
+169
View File
@@ -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.
+445
View File
@@ -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,
)
+57 -1
View File
@@ -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"),
)
+28 -7
View File
@@ -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(
+16 -16
View File
@@ -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 = {}
+7 -7
View File
@@ -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
)
+16 -16
View File
@@ -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)
+33 -33
View File
@@ -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,
+10 -10
View File
@@ -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(
+5 -5
View File
@@ -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
)
+6 -6
View File
@@ -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)
+11 -11
View File
@@ -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(
+447
View File
@@ -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