Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3485b55e2d | |||
| 4adb9de5f0 | |||
| bfa944d0e8 | |||
| 01569497d7 | |||
| 6cccd92b3b | |||
| 9dcda0cd6a | |||
| 7c2f39930a | |||
| 205c3b013c | |||
| f587a4e31f | |||
| 6e95447272 | |||
| 8983f25eaf | |||
| 1675fc521b | |||
| dec02f17d1 | |||
| 881b0ba03c | |||
| 942fe35719 | |||
| 723eb57060 | |||
| 619d0e4be6 | |||
| dc7abcbd48 | |||
| 3d4dfcbb35 | |||
| de99296779 | |||
| 10dffd0c10 | |||
| 737d62fe91 | |||
| 192c4bf009 | |||
| 01d1cf9190 | |||
| 0ff85dbe4f | |||
| 96789db29d | |||
| b20c9c6203 | |||
| 15113dbb03 | |||
| 615f345928 | |||
| d14f2f666d | |||
| d92945a388 | |||
| 42426b4597 | |||
| c2dcb06fe1 | |||
| 95b73019ab | |||
| 6a0f537d66 | |||
| 71e77e95bc | |||
| 636bfd416f | |||
| 64864db736 | |||
| 027fc0b2d6 | |||
| d768909fd4 | |||
| 3b4606b798 | |||
| 63b457380a | |||
| b41bbd6c65 | |||
| 9adfc72612 | |||
| c896a2de63 | |||
| d16bcdcfbb | |||
| 6c3997b24c | |||
| 9d514f52b0 | |||
| 4e1d143e54 | |||
| 0d45120470 | |||
| babd60e08b | |||
| f48e039e9e | |||
| 14a8f70503 | |||
| bf8120682e | |||
| f2af5a39a8 |
@@ -18,6 +18,9 @@ jobs:
|
||||
- name: Linting
|
||||
run: |
|
||||
uv run --frozen ruff check
|
||||
- name: Linting
|
||||
run: |
|
||||
uv run --frozen ty check -- nextcloud_mcp_server
|
||||
|
||||
|
||||
integration-test:
|
||||
@@ -81,4 +84,4 @@ jobs:
|
||||
NEXTCLOUD_USERNAME: "admin"
|
||||
NEXTCLOUD_PASSWORD: "admin"
|
||||
run: |
|
||||
uv run pytest -v --log-cli-level=INFO
|
||||
uv run pytest -v --log-cli-level=WARN --ignore=tests/manual
|
||||
|
||||
@@ -18,3 +18,9 @@ repos:
|
||||
entry: uv run ruff format
|
||||
language: system
|
||||
types: [python]
|
||||
- id: ty-check
|
||||
name: ty-check
|
||||
language: system
|
||||
types: [python]
|
||||
exclude: tests/.*
|
||||
entry: uv run ty check
|
||||
|
||||
@@ -1,3 +1,41 @@
|
||||
## v0.24.0 (2025-11-04)
|
||||
|
||||
### Feat
|
||||
|
||||
- add scope protection to OAuth provisioning tools
|
||||
- enable authorization services for token exchange in Keycloak
|
||||
- implement scope-based audience mapping and RFC 9728 support
|
||||
- integrate token exchange into MCP server application
|
||||
- implement RFC 8693 Standard Token Exchange for Keycloak
|
||||
- Add userinfo route/page
|
||||
- add browser-based user info page with separate OAuth flow
|
||||
- Implement ADR-004 Progressive Consent foundation (partial)
|
||||
- Complete ADR-004 Progressive Consent OAuth flows implementation
|
||||
- Implement ADR-004 Progressive Consent foundation components
|
||||
- Implement ADR-004 Hybrid Flow with comprehensive integration tests
|
||||
|
||||
### Fix
|
||||
|
||||
- add missing await for get_nextcloud_client in capabilities resource
|
||||
- use valid Fernet encryption keys in token exchange tests
|
||||
- accept resource URL in token audience for Nextcloud JWT tokens
|
||||
- remove token-exchange-nextcloud scope and accept tokens without audience
|
||||
- move audience mapper from scope to nextcloud-mcp-server client
|
||||
- move token-exchange-nextcloud from default to optional scopes
|
||||
- restructure routes to prevent SessionAuthBackend from interfering with FastMCP OAuth
|
||||
- allow OAuth Bearer tokens on /mcp endpoint by excluding from session auth
|
||||
- correct OAuth token audience validation using RFC 8707 resource parameter
|
||||
- remove remaining references to deleted oauth_callback and oauth_token
|
||||
- remove Hybrid Flow, make Progressive Consent default (ADR-004)
|
||||
- browser OAuth userinfo endpoint and refresh token rotation
|
||||
- make ENABLE_PROGRESSIVE_CONSENT consistently opt-in (default false)
|
||||
- make provisioning checks opt-in (default false)
|
||||
- Disable Progressive Consent for mcp-oauth to enable Hybrid Flow tests
|
||||
|
||||
### Refactor
|
||||
|
||||
- integrate token exchange into unified get_client() pattern
|
||||
|
||||
## v0.23.0 (2025-11-03)
|
||||
|
||||
### Feat
|
||||
|
||||
@@ -2,544 +2,304 @@
|
||||
|
||||
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`
|
||||
### Progressive Consent Architecture (ADR-004)
|
||||
|
||||
### Server Integration
|
||||
**Status**: Always enabled in OAuth mode (default)
|
||||
|
||||
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
|
||||
**What is Progressive Consent?**
|
||||
- Dual OAuth flow architecture that separates client authentication (Flow 1) from resource provisioning (Flow 2)
|
||||
- Flow 1: MCP client authenticates directly to IdP with resource scopes (notes:*, calendar:*, etc.)
|
||||
- Token audience: "mcp-server"
|
||||
- Client receives resource-scoped token for MCP session
|
||||
- Flow 2: Server explicitly provisions Nextcloud access via separate login
|
||||
- Server requests: openid, profile, email, offline_access
|
||||
- Token audience: "nextcloud"
|
||||
- Server receives refresh token for offline access
|
||||
- Client never sees this token
|
||||
- Provides clear separation between session tokens and offline access tokens
|
||||
|
||||
### Supported Nextcloud Apps
|
||||
**When to use OAuth mode:**
|
||||
- Multi-user deployments
|
||||
- Background jobs requiring offline access
|
||||
- Enhanced security with separate authorization contexts
|
||||
- Explicit user control over resource access
|
||||
|
||||
- **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
|
||||
**When to use BasicAuth instead:**
|
||||
- Simple single-user deployments
|
||||
- Local development and testing
|
||||
|
||||
### Key Patterns
|
||||
**Key features:**
|
||||
- No scope escalation - client gets exactly what it requests
|
||||
- User explicitly authorizes via `provision_nextcloud_access` tool
|
||||
- Clear security boundaries between MCP session and Nextcloud access
|
||||
|
||||
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)
|
||||
|
||||
### MCP Response Patterns
|
||||
**Never return raw `List[Dict]` from MCP tools** - FastMCP mangles them into dicts with numeric string keys.
|
||||
|
||||
**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
|
||||
|
||||
+4
-2
@@ -1,7 +1,9 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.9.7-python3.11-alpine@sha256:0006b77df7ebf46e68959fdc8d3af9d19f1adfae8c2e7e77907ad257e5d05be4
|
||||
|
||||
# Install git (required for caldav dependency from git)
|
||||
RUN apk add --no-cache git
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
# 2. sqlite for development with token db
|
||||
RUN apk add --no-cache git sqlite
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -31,8 +31,9 @@ else
|
||||
fi
|
||||
|
||||
# Configure OIDC Identity Provider with dynamic client registration enabled
|
||||
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
|
||||
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true' # NOTE: String
|
||||
php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean
|
||||
php /var/www/html/occ config:app:set oidc allow_user_settings --value='enabled'
|
||||
php /var/www/html/occ config:app:set oidc default_token_type --value='jwt'
|
||||
|
||||
echo "OIDC app installed and configured successfully"
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: nextcloud-mcp-server
|
||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||
type: application
|
||||
version: 0.23.0
|
||||
appVersion: "0.23.0"
|
||||
version: 0.24.0
|
||||
appVersion: "0.24.0"
|
||||
keywords:
|
||||
- nextcloud
|
||||
- mcp
|
||||
|
||||
+18
-9
@@ -17,7 +17,7 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: docker.io/library/redis:alpine@sha256:59b6e694653476de2c992937ebe1c64182af4728e54bb49e9b7a6c26614d8933
|
||||
image: docker.io/library/redis:alpine@sha256:28c9c4d7596949a24b183eaaab6455f8e5d55ecbf72d02ff5e2c17fe72671d31
|
||||
restart: always
|
||||
|
||||
app:
|
||||
@@ -28,6 +28,7 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
- keycloak
|
||||
volumes:
|
||||
- nextcloud:/var/www/html
|
||||
- ./app-hooks:/docker-entrypoint-hooks.d:ro
|
||||
@@ -43,11 +44,11 @@ services:
|
||||
- MYSQL_USER=nextcloud
|
||||
- MYSQL_HOST=db
|
||||
- REDIS_HOST=redis
|
||||
#healthcheck:
|
||||
#test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"]
|
||||
#interval: 10s
|
||||
#timeout: 30s
|
||||
#retries: 30
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 30s
|
||||
retries: 30
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14
|
||||
@@ -71,7 +72,8 @@ services:
|
||||
command: ["--transport", "streamable-http"]
|
||||
restart: always
|
||||
depends_on:
|
||||
- app
|
||||
app:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- 127.0.0.1:8000:8000
|
||||
environment:
|
||||
@@ -84,7 +86,8 @@ services:
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||
restart: always
|
||||
depends_on:
|
||||
- app
|
||||
app:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- 127.0.0.1:8001:8001
|
||||
environment:
|
||||
@@ -101,6 +104,9 @@ services:
|
||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
|
||||
# ADR-004: Use Hybrid Flow (server intercepts OAuth callback)
|
||||
# Set to false to enable Hybrid Flow tests - server stores refresh token and issues MCP codes
|
||||
|
||||
# NO admin credentials - using OAuth with Dynamic Client Registration (DCR)
|
||||
# Client credentials registered via RFC 7591 and stored in volume
|
||||
# JWT token type is used for testing (faster validation, scopes embedded in token)
|
||||
@@ -109,7 +115,7 @@ services:
|
||||
- oauth-tokens:/app/data
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.4.2
|
||||
image: quay.io/keycloak/keycloak:26.4.2@sha256:3617b09bb4b7510a8d8d9b9fc5707399e2d70688dbcc2f8fb013a144829be1b9
|
||||
command:
|
||||
- "start-dev"
|
||||
- "--import-realm"
|
||||
@@ -160,6 +166,9 @@ services:
|
||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
|
||||
# Token exchange (RFC 8693) - convert aud:nextcloud-mcp-server → aud:nextcloud
|
||||
- ENABLE_TOKEN_EXCHANGE=true
|
||||
|
||||
# OAuth scopes (optional - uses defaults if not specified)
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
# ADR-002: Vector Database Background Sync Authentication
|
||||
|
||||
> **⚠️ DEPRECATED**: This ADR has been superseded by [ADR-004: MCP Server as OAuth Client for Offline Access](./ADR-004-mcp-application-oauth.md).
|
||||
>
|
||||
> **Reason for Deprecation**: This ADR fundamentally misunderstood the MCP protocol's authentication architecture. The MCP server receives tokens from clients but cannot initiate OAuth flows or store refresh tokens, making the proposed solutions ineffective for true offline access. ADR-004 provides the correct architectural pattern where the MCP server acts as its own OAuth client.
|
||||
|
||||
## Status
|
||||
Accepted - Tier 2 (Token Exchange with Delegation) Implemented
|
||||
~~Accepted - Tier 2 (Token Exchange with Delegation) Implemented~~
|
||||
**Superseded by ADR-004** - The token exchange implementation exists but doesn't solve the offline access problem.
|
||||
|
||||
**Important**: Service account tokens (old Tier 1) have been rejected as they violate OAuth "act on-behalf-of" principles by creating Nextcloud user accounts for the MCP server.
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
Excellent and incredibly thorough work on ADR-004. It outlines a robust, secure, and modern approach to federated authentication that aligns with industry best practices. The Progressive Consent architecture with dual OAuth flows is the right direction for a system with these requirements.
|
||||
|
||||
Here is a review of the current implementation in light of the architecture proposed in the ADR.
|
||||
|
||||
### High-Level Assessment
|
||||
|
||||
The project is in a good state, with a clear vision for its authentication architecture. The current implementation provides a backward-compatible "Hybrid Flow" while also containing the scaffolding for the target "Progressive Consent" flow. The hybrid flow is well-tested, which is a great foundation.
|
||||
|
||||
The following points are intended to help bridge the gap between the current implementation and the final vision outlined in ADR-004.
|
||||
|
||||
### Critical Security Review
|
||||
|
||||
#### 1. Missing Token Audience (`aud`) Validation
|
||||
|
||||
This is the most critical issue. The `require_scopes` decorator currently checks for scopes but does not validate the `audience` (`aud` claim) of the incoming JWT.
|
||||
|
||||
* **Risk:** This creates a "confused deputy" vulnerability. An access token issued for a different application could be used to access the MCP server, as long as the scope names happen to match.
|
||||
* **ADR Reference:** The ADR correctly identifies this and proposes an `MCPTokenVerifier` that validates `aud: "mcp-server"`.
|
||||
* **Recommendation:** Implement the audience validation as a central part of your token verification middleware. An incoming token should be rejected immediately if its audience is not `mcp-server`. This check should happen before any tool-specific scope checks.
|
||||
|
||||
### Architecture and Implementation Review
|
||||
|
||||
#### 2. Progressive Consent Flow is Untested
|
||||
|
||||
The code for the Progressive Consent flow (behind the `ENABLE_PROGRESSIVE_CONSENT` flag) exists in `oauth_routes.py` and `oauth_tools.py`. However, there are no integration tests to validate it.
|
||||
|
||||
* **Risk:** Given the complexity of OAuth flows, it's likely there are bugs in the untested implementation.
|
||||
* **Recommendation:** Create a new test file, `test_adr004_progressive_flow.py`, that uses Playwright to test the dual-flow architecture end-to-end:
|
||||
1. **Flow 1:** A test MCP client authenticates directly with the IdP to get an `mcp-server` token.
|
||||
2. **Provisioning Check:** The test verifies that calling a Nextcloud tool fails with a `ProvisioningRequiredError`.
|
||||
3. **Flow 2:** The test calls the `provision_nextcloud_access` tool and automates the second OAuth flow to grant the server offline access.
|
||||
4. **Tool Execution:** The test verifies that Nextcloud tools can now be successfully called.
|
||||
|
||||
#### 3. Inconsistent Authorization URL Generation
|
||||
|
||||
There is duplicated and inconsistent logic for generating the IdP authorization URL.
|
||||
|
||||
* **Location 1:** `oauth_tools.py` in `generate_oauth_url_for_flow2` hardcodes the authorization endpoint path.
|
||||
* **Location 2:** `oauth_routes.py` in `oauth_authorize_nextcloud` correctly uses the OIDC discovery document to find the `authorization_endpoint`.
|
||||
* **Risk:** The hardcoded path is brittle and will break with IdPs that use different endpoint paths (like Keycloak).
|
||||
* **Recommendation:** Consolidate this logic. The `provision_nextcloud_access` tool should not build the URL itself. Instead, it should return a URL pointing to the MCP server's own `/oauth/authorize-nextcloud` endpoint. This endpoint (which you've already created as `oauth_authorize_nextcloud` in `oauth_routes.py`) can then be the single source of truth for generating the IdP redirect.
|
||||
|
||||
#### 4. Poor User Experience due to Missing Token Refresh
|
||||
|
||||
The `/oauth/token` endpoint does not implement the `refresh_token` grant type. This means that when the client's `mcp-server` access token expires (e.g., after one hour), the user must go through the entire browser-based login flow again.
|
||||
|
||||
* **Risk:** This creates a frustrating user experience, especially for long-lived desktop clients.
|
||||
* **ADR Reference:** A proper Flow 1 should result in the MCP client receiving both an access token and a refresh token from the IdP.
|
||||
* **Recommendation:**
|
||||
1. Ensure the IdP is configured to issue refresh tokens to the MCP client for Flow 1.
|
||||
2. The MCP client should securely store this refresh token.
|
||||
3. The client should use the refresh token to get new `mcp-server` access tokens directly from the IdP, without involving the MCP server or the user. The MCP server should not be involved in the client's session management with the IdP.
|
||||
|
||||
### Summary
|
||||
|
||||
The project is on the right track. The ADR is a solid plan, and the initial implementation is a good starting point.
|
||||
|
||||
My recommendations in order of priority are:
|
||||
|
||||
1. **Implement Audience Validation** to close the security gap.
|
||||
2. **Add Integration Tests** for the Progressive Consent flow.
|
||||
3. **Refactor the client-side token refresh** to improve user experience.
|
||||
4. **Consolidate the URL generation** logic to fix the inconsistency.
|
||||
|
||||
Addressing these points will align the implementation with the excellent vision in ADR-004 and result in a secure, robust, and user-friendly system.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,348 @@
|
||||
# Token Acquisition Patterns for ADR-004 Progressive Consent
|
||||
|
||||
## Overview
|
||||
|
||||
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.
|
||||
|
||||
**Key Principle**: Refresh tokens from Flow 2 (Progressive Consent) should **NEVER** be used for MCP tool calls - they are exclusively for background jobs.
|
||||
|
||||
## 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. Server passes Flow 1 token to Nextcloud
|
||||
5. Nextcloud validates token with IdP
|
||||
6. Refresh tokens (from Flow 2) used **only** for background jobs
|
||||
|
||||
### 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
|
||||
|
||||
## Optional Token Exchange Mode
|
||||
|
||||
### Token Exchange Pattern (ENABLE_TOKEN_EXCHANGE=true)
|
||||
|
||||
**MCP Session (Foreground Operations)**:
|
||||
|
||||
```
|
||||
┌─────────────┐ Flow 1 Token ┌──────────────┐
|
||||
│ MCP Client │ ───(aud: mcp-server)──> │ MCP Server │
|
||||
└─────────────┘ └──────────────┘
|
||||
│
|
||||
Tool Call │
|
||||
"search_notes()" │
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Token Exchange │
|
||||
│ 1. Validate Flow 1 │
|
||||
│ 2. Check permission │
|
||||
│ 3. Request delegated│
|
||||
│ Nextcloud token │
|
||||
└─────────────────────┘
|
||||
│
|
||||
│ Exchange Request
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ IdP Token Endpoint │
|
||||
│ (Token Exchange) │
|
||||
└─────────────────────┘
|
||||
│
|
||||
│ Delegated Token
|
||||
│ (aud: nextcloud)
|
||||
│ (limited scopes)
|
||||
│ (short-lived)
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Nextcloud API Call │
|
||||
│ GET /notes │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
**Key Properties of Session Tokens:**
|
||||
- ✅ Generated **on-demand** during tool execution
|
||||
- ✅ **Ephemeral** - used only for current operation
|
||||
- ✅ **NOT stored** - discarded after use
|
||||
- ✅ **Limited scopes** - only what tool needs (e.g., `notes:read` for search)
|
||||
- ✅ **Short-lived** - expires quickly (e.g., 5 minutes)
|
||||
|
||||
**Background Jobs (Offline Operations)**:
|
||||
|
||||
```
|
||||
┌─────────────────┐ Scheduled Job ┌──────────────┐
|
||||
│ Background │ ──────────────────────> │ Worker │
|
||||
│ Scheduler │ │ Process │
|
||||
└─────────────────┘ └──────────────┘
|
||||
│
|
||||
│ Use stored
|
||||
│ refresh token
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Refresh Token Store │
|
||||
│ (Flow 2 provisioned)│
|
||||
└─────────────────────┘
|
||||
│
|
||||
│ Refresh Token
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ IdP Token Endpoint │
|
||||
│ (Refresh Grant) │
|
||||
└─────────────────────┘
|
||||
│
|
||||
│ Background Token
|
||||
│ (aud: nextcloud)
|
||||
│ (different scopes)
|
||||
│ (longer-lived)
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Nextcloud API │
|
||||
│ (Background Sync) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
**Key Properties of Background Tokens:**
|
||||
- ✅ Obtained from **stored refresh token** (Flow 2)
|
||||
- ✅ **Different scopes** than session tokens (e.g., `notes:sync`, `files:sync`)
|
||||
- ✅ **Longer-lived** for background operations
|
||||
- ✅ **Never used for MCP sessions**
|
||||
- ✅ **Only for offline/background jobs**
|
||||
|
||||
## Implementation Requirements
|
||||
|
||||
### 1. Token Exchange Endpoint
|
||||
|
||||
Implement RFC 8693 Token Exchange:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/auth/token_exchange.py
|
||||
|
||||
async def exchange_token_for_delegation(
|
||||
flow1_token: str,
|
||||
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_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 (audience check)
|
||||
# 2. Check user has provisioned Nextcloud access (Flow 2)
|
||||
# 3. Request token exchange from IdP (without scopes - Nextcloud doesn't support them)
|
||||
# 4. Return ephemeral delegated token
|
||||
```
|
||||
|
||||
### 2. Unified get_client() Pattern
|
||||
|
||||
The token acquisition mode is handled transparently by `get_client()`:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/context.py
|
||||
|
||||
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. MCP Tool Pattern (No Changes Required!)
|
||||
|
||||
Tools use the same pattern regardless of token acquisition mode:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
@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."""
|
||||
|
||||
# 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)
|
||||
|
||||
# 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."""
|
||||
|
||||
# Get refresh token stored during Flow 2 (Progressive Consent)
|
||||
token_storage = get_token_storage()
|
||||
refresh_token = await token_storage.get_refresh_token(user_id)
|
||||
|
||||
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 (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:
|
||||
1. ✅ **Least Privilege**: Each operation gets only needed scopes
|
||||
2. ✅ **Time-Limited**: Session tokens expire quickly
|
||||
3. ✅ **Audit Trail**: Each exchange can be logged
|
||||
4. ✅ **Token Isolation**: Session ≠ Background tokens
|
||||
5. ✅ **Revocation**: Can revoke background access without affecting active sessions
|
||||
|
||||
### Current Incorrect Pattern:
|
||||
1. ❌ **Over-Privileged**: Refresh token has all scopes
|
||||
2. ❌ **Long-Lived**: Same token reused indefinitely
|
||||
3. ❌ **No Separation**: Sessions and background jobs use same credential
|
||||
4. ❌ **Revocation Issues**: Revoking affects everything
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Phase 1: Token Exchange (High Priority)
|
||||
1. Implement RFC 8693 token exchange endpoint
|
||||
2. Update Token Broker with `get_session_token()` vs `get_background_token()`
|
||||
3. Modify tool pattern to use token exchange
|
||||
|
||||
### Phase 2: Scope Separation (High Priority)
|
||||
1. Define session scopes vs background scopes
|
||||
2. Update provisioning flow to request appropriate scopes
|
||||
3. Validate scopes in token exchange
|
||||
|
||||
### Phase 3: Background Jobs (Medium Priority)
|
||||
1. Implement background worker pattern
|
||||
2. Create scheduled jobs (note sync, etc.)
|
||||
3. Use background token pattern
|
||||
|
||||
### Phase 4: Testing (High Priority)
|
||||
1. Test token exchange flow end-to-end
|
||||
2. Verify session tokens are ephemeral
|
||||
3. Verify background tokens are separate
|
||||
4. Load test token exchange performance
|
||||
|
||||
## References
|
||||
|
||||
- **RFC 8693**: OAuth 2.0 Token Exchange
|
||||
- **RFC 9068**: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens
|
||||
- **ADR-004**: Progressive Consent OAuth Flows
|
||||
- **OAuth 2.0 Delegation**: On-Behalf-Of vs Impersonation patterns
|
||||
|
||||
## Status
|
||||
|
||||
**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
|
||||
|
||||
**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)
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,323 @@
|
||||
# OAuth Architecture Comparison: MCP Server Authentication Patterns
|
||||
|
||||
This document compares three authentication architectures for the MCP server, explaining the evolution from pass-through authentication to true offline access capabilities.
|
||||
|
||||
## Pattern 1: Pass-Through Authentication (Current Implementation)
|
||||
|
||||
### Architecture
|
||||
```
|
||||
┌─────────────┐ OAuth Flow ┌─────────────┐
|
||||
│ MCP Client │◄──────────────────│ OAuth │
|
||||
│ (Claude) │ │ Provider │
|
||||
└──────┬──────┘ └─────────────┘
|
||||
│
|
||||
│ Access Token
|
||||
│ (per request)
|
||||
▼
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ MCP Server │───────────────────►│ Nextcloud │
|
||||
│(Pass-through) │ APIs │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
| Aspect | Description |
|
||||
|--------|-------------|
|
||||
| **Token Flow** | MCP Client → MCP Server → Nextcloud |
|
||||
| **Token Storage** | None (tokens exist only during request) |
|
||||
| **Offline Access** | ❌ Impossible |
|
||||
| **Background Workers** | ❌ Not supported |
|
||||
| **User Consent** | Single OAuth flow (client-managed) |
|
||||
| **Complexity** | Low |
|
||||
| **Security** | High (no token persistence) |
|
||||
|
||||
### How It Works
|
||||
1. MCP Client performs OAuth with provider
|
||||
2. Client includes access token in each MCP request
|
||||
3. MCP Server validates token and forwards to Nextcloud
|
||||
4. Token discarded after request completes
|
||||
|
||||
### Limitations
|
||||
- No operations possible without active MCP session
|
||||
- Background sync/indexing impossible
|
||||
- Cannot refresh tokens independently
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2: Token Exchange Delegation (ADR-002 - Flawed)
|
||||
|
||||
### Architecture
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ MCP Client │────────────────────│ OAuth │
|
||||
│ (Claude) │ │ Provider │
|
||||
└──────┬──────┘ └──────┬──────┘
|
||||
│ │
|
||||
│ Access Token │ Service Account Token
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ MCP Server │
|
||||
│ ┌────────────────────────────────────┐ │
|
||||
│ │ Token Exchange (RFC 8693) │ │
|
||||
│ │ Subject: Service Account │ │
|
||||
│ │ Target: User │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
└───────────────┬─────────────────────────────┘
|
||||
│ Exchanged Token
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Nextcloud │
|
||||
│ APIs │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
| Aspect | Description |
|
||||
|--------|-------------|
|
||||
| **Token Flow** | Service Account → Exchange → User Token |
|
||||
| **Token Storage** | None (MCP server still stateless) |
|
||||
| **Offline Access** | ❌ Still impossible (circular dependency) |
|
||||
| **Background Workers** | ❌ Requires service account (rejected) |
|
||||
| **User Consent** | Implicit through service account |
|
||||
| **Complexity** | High |
|
||||
| **Security** | ⚠️ Service accounts violate OAuth principles |
|
||||
|
||||
### Why It Fails
|
||||
1. **Circular Dependency**: To exchange tokens, you need a token to exchange
|
||||
2. **Service Account Problem**: Creates Nextcloud user identity for service
|
||||
3. **OAuth Violation**: Service acts as itself, not on behalf of users
|
||||
4. **No Bootstrap**: Still can't obtain initial tokens offline
|
||||
|
||||
### The Fatal Flaw
|
||||
```
|
||||
Q: How does background worker get tokens?
|
||||
A: Use token exchange with service account
|
||||
|
||||
Q: How does service account get authorized?
|
||||
A: Client credentials grant creates user account (violates OAuth)
|
||||
|
||||
Q: Can we use user's refresh token?
|
||||
A: MCP server never sees refresh tokens (by design)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3: Sign-in with Nextcloud (Previous ADR-004 Draft)
|
||||
|
||||
### Architecture
|
||||
```
|
||||
┌─────────────┐ ┌─────────────────┐ ┌────────────┐
|
||||
│ MCP Client ├───────────────────> │ MCP Server ├────────────────────>│ Nextcloud │
|
||||
│ (Claude) │ (MCP Protocol) │ (OAuth Client) │ (OIDC + APIs) │ (IdP) │
|
||||
└─────────────┘ └─────────────────┘ └────────────┘
|
||||
│
|
||||
┌──────▼────────┐
|
||||
│ Token Storage │
|
||||
│ (NC Tokens) │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
| Aspect | Description |
|
||||
|--------|-------------|
|
||||
| **Token Flow** | MCP Server uses Nextcloud as identity provider |
|
||||
| **Token Storage** | ✅ Encrypted Nextcloud refresh tokens |
|
||||
| **Offline Access** | ✅ Full support |
|
||||
| **Background Workers** | ✅ Use stored refresh tokens |
|
||||
| **User Consent** | Single OAuth flow (Nextcloud only) |
|
||||
| **Complexity** | Medium |
|
||||
| **Security** | High (with token rotation) |
|
||||
|
||||
### How It Works
|
||||
1. **Initial Setup**:
|
||||
- User tries to use MCP tool
|
||||
- MCP server returns auth required
|
||||
- User authenticates with Nextcloud's OIDC endpoint
|
||||
- Nextcloud may use user_oidc to delegate to external IdP (Keycloak, etc.)
|
||||
- MCP server stores Nextcloud-issued refresh token (encrypted)
|
||||
|
||||
2. **Subsequent Requests**:
|
||||
- MCP server uses stored Nextcloud tokens
|
||||
- Refreshes automatically when expired
|
||||
- No client involvement needed
|
||||
|
||||
3. **Background Operations**:
|
||||
- Worker retrieves stored refresh token
|
||||
- Refreshes with Nextcloud directly
|
||||
- Performs operations independently
|
||||
|
||||
### Advantages
|
||||
- ✅ Single sign-on with Nextcloud
|
||||
- ✅ True offline access capability
|
||||
- ✅ OAuth-compliant with proper consent
|
||||
- ✅ Supports external IdPs via user_oidc
|
||||
- ✅ Simpler integration - only one OAuth endpoint
|
||||
|
||||
### Trade-offs
|
||||
- Authentication flows through Nextcloud
|
||||
- Nextcloud manages IdP relationships (via user_oidc)
|
||||
- MCP server only knows about Nextcloud, not the underlying IdP
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4: Federated Authentication Architecture (ADR-004 - Solution)
|
||||
|
||||
### Architecture
|
||||
```
|
||||
┌─────────────┐ ┌─────────────────┐ ┌──────────────┐ ┌────────────┐
|
||||
│ MCP Client │◄──────401──────│ MCP Server │◄────OAuth──────│ Shared IdP │──Validates──►│ Nextcloud │
|
||||
│ (Claude) │ │ (OAuth Client) │ (On-Behalf) │ (Keycloak) │ Tokens │(Resource) │
|
||||
└─────────────┘ └─────────────────┘ └──────────────┘ └────────────┘
|
||||
│
|
||||
┌───────▼────────┐
|
||||
│ Token Storage │
|
||||
│ (IdP Tokens) │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
| Aspect | Description |
|
||||
|--------|-------------|
|
||||
| **Token Flow** | Shared IdP issues tokens for Nextcloud access |
|
||||
| **Token Storage** | ✅ Encrypted IdP refresh tokens |
|
||||
| **Offline Access** | ✅ Full support |
|
||||
| **Background Workers** | ✅ Use stored IdP refresh tokens |
|
||||
| **User Consent** | Single OAuth flow (IdP manages consent) |
|
||||
| **Complexity** | Medium-High |
|
||||
| **Security** | Highest (enterprise-grade IdP) |
|
||||
|
||||
### How It Works
|
||||
1. **Initial Setup**:
|
||||
- MCP client connects, receives 401
|
||||
- Browser opens MCP server OAuth URL
|
||||
- MCP server redirects to shared IdP
|
||||
- User authenticates once to IdP
|
||||
- IdP shows consent for both identity and Nextcloud access
|
||||
- MCP server stores IdP refresh token (encrypted)
|
||||
- MCP server issues session token to client
|
||||
|
||||
2. **Subsequent Requests**:
|
||||
- MCP server validates session token
|
||||
- Uses stored IdP token for Nextcloud
|
||||
- Refreshes with IdP when expired
|
||||
- No client involvement needed
|
||||
|
||||
3. **Background Operations**:
|
||||
- Worker retrieves stored IdP refresh token
|
||||
- Gets new access token from IdP
|
||||
- Uses token to access Nextcloud
|
||||
- Performs operations independently
|
||||
|
||||
### Advantages
|
||||
- ✅ True single sign-on (SSO)
|
||||
- ✅ Enterprise-ready with SAML/LDAP support
|
||||
- ✅ OAuth-compliant with proper delegation
|
||||
- ✅ Direct IdP relationship - no intermediary
|
||||
- ✅ Flexible - can swap resource servers
|
||||
- ✅ Industry-standard federated pattern
|
||||
|
||||
### Trade-offs
|
||||
- Requires shared IdP infrastructure
|
||||
- More complex initial setup
|
||||
- Token validation overhead
|
||||
|
||||
---
|
||||
|
||||
## Comparison Matrix
|
||||
|
||||
| Feature | Pass-Through | Token Exchange | Sign-in with NC | Federated Auth |
|
||||
|---------|--------------|----------------|-----------------|----------------|
|
||||
| **Offline Access** | ❌ No | ❌ No | ✅ Yes | ✅ Yes |
|
||||
| **Background Workers** | ❌ No | ❌ No* | ✅ Yes | ✅ Yes |
|
||||
| **Token Storage** | None | None | NC refresh tokens | IdP refresh tokens |
|
||||
| **OAuth Compliance** | ✅ Full | ⚠️ Violates | ✅ Full | ✅ Full |
|
||||
| **User Consent** | Once | Implicit | Once (NC) | Once (IdP) |
|
||||
| **Implementation Complexity** | Low | High | Medium | Medium-High |
|
||||
| **Security** | High | Medium | High | Highest |
|
||||
| **Enterprise Ready** | ❌ No | ❌ No | ⚠️ Indirect | ✅ Yes |
|
||||
| **Identity Provider** | Client-managed | N/A | Nextcloud (+user_oidc) | Shared IdP |
|
||||
| **Suitable For** | Interactive only | N/A (flawed) | Small teams | Enterprise |
|
||||
|
||||
\* *Requires service accounts that violate OAuth principles*
|
||||
|
||||
---
|
||||
|
||||
## Evolution Summary
|
||||
|
||||
### Stage 1: Simple Pass-Through ✅
|
||||
- **Goal**: Basic MCP functionality
|
||||
- **Result**: Works well for interactive use
|
||||
- **Limitation**: No offline capabilities
|
||||
|
||||
### Stage 2: Attempted Delegation ❌
|
||||
- **Goal**: Enable offline access without changing architecture
|
||||
- **Result**: Circular dependencies, OAuth violations
|
||||
- **Learning**: MCP protocol constraints are fundamental
|
||||
|
||||
### Stage 3: Sign-in with Nextcloud ⚠️
|
||||
- **Goal**: True offline access with OAuth compliance
|
||||
- **Result**: MCP server uses Nextcloud as identity provider
|
||||
- **Limitation**: Tight coupling to Nextcloud, no enterprise IdP
|
||||
|
||||
### Stage 4: Federated Pattern ✅
|
||||
- **Goal**: Enterprise-ready offline access
|
||||
- **Result**: Shared IdP for both MCP server and Nextcloud
|
||||
- **Trade-off**: Additional infrastructure justified by enterprise needs
|
||||
|
||||
---
|
||||
|
||||
## Key Insights
|
||||
|
||||
1. **Pattern 3 vs Pattern 4**: Both support external IdPs, but differ in integration approach:
|
||||
- Pattern 3: MCP → Nextcloud OIDC → (user_oidc) → External IdP
|
||||
- Pattern 4: MCP → External IdP directly (Nextcloud also uses same IdP)
|
||||
- Choose Pattern 3 for Nextcloud-centric deployments, Pattern 4 for IdP-centric enterprises
|
||||
|
||||
2. **The MCP Protocol Boundary**: The MCP protocol creates a fundamental boundary between client and server token management. Attempting to breach this boundary (ADR-002) leads to architectural contradictions.
|
||||
|
||||
3. **Service Accounts Don't Solve User Problems**: Using service accounts for user operations violates OAuth's core principle of acting on behalf of users, not as a service identity.
|
||||
|
||||
4. **Double OAuth is Industry Standard**: Major platforms (Zapier, IFTTT, Microsoft Power Automate) use this pattern - the integration platform is an OAuth client that maintains its own relationships with upstream services.
|
||||
|
||||
5. **Refresh Tokens Are The Solution**: The OAuth spec designed refresh tokens specifically for offline access. Rejecting them (as ADR-002 did) means rejecting the standard solution.
|
||||
|
||||
6. **Complexity is Justified**: The additional complexity of managing OAuth flows is acceptable when offline access is a requirement. The alternative is no offline access at all.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### For Simple Deployments
|
||||
Use **Pattern 1 (Pass-Through)** if:
|
||||
- Offline access not needed
|
||||
- Only interactive operations required
|
||||
- Simplicity is priority
|
||||
|
||||
### For Teams Using Nextcloud
|
||||
Use **Pattern 3 (Sign-in with Nextcloud)** if:
|
||||
- Background sync/indexing required
|
||||
- Nextcloud manages your authentication
|
||||
- Can use external IdPs via user_oidc
|
||||
- Prefer single integration point through Nextcloud
|
||||
|
||||
### For Enterprise Deployments
|
||||
Use **Pattern 4 (Federated Authentication)** if:
|
||||
- Enterprise IdP already exists (Keycloak, Okta, Azure AD)
|
||||
- Multiple resource servers beyond Nextcloud
|
||||
- Compliance requirements for centralized auth
|
||||
- Building platform for multiple organizations
|
||||
|
||||
### Never Use Pattern 2
|
||||
Token Exchange with service accounts should not be used as it:
|
||||
- Doesn't enable true offline access
|
||||
- Violates OAuth principles
|
||||
- Adds complexity without solving the problem
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-002: Vector Database Background Sync Authentication (Deprecated)](./ADR-002-vector-sync-authentication.md)
|
||||
- [ADR-004: MCP Server as OAuth Client for Offline Access](./ADR-004-mcp-application-oauth.md)
|
||||
- [RFC 6749: OAuth 2.0 Framework](https://datatracker.ietf.org/doc/html/rfc6749)
|
||||
- [RFC 8693: OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693)
|
||||
+22
@@ -21,6 +21,28 @@ NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||
# TOKEN_STORAGE_DB: Path to SQLite database (default: /app/data/tokens.db)
|
||||
#TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
|
||||
# ===== ADR-004 PROGRESSIVE CONSENT CONFIGURATION =====
|
||||
# Enable Progressive Consent mode (dual OAuth flows)
|
||||
# When enabled: Flow 1 for client auth, Flow 2 for Nextcloud resource access
|
||||
# When disabled: Uses existing hybrid flow (backward compatible)
|
||||
|
||||
# MCP Server OAuth Client Configuration
|
||||
# The MCP server's own OAuth client credentials for Flow 2
|
||||
# If not set, will use dynamic client registration
|
||||
#MCP_SERVER_CLIENT_ID=
|
||||
#MCP_SERVER_CLIENT_SECRET=
|
||||
|
||||
# Allowed MCP Client IDs (comma-separated list)
|
||||
# Client IDs that are allowed to authenticate in Flow 1
|
||||
# Examples: claude-desktop,continue-dev,zed-editor
|
||||
#ALLOWED_MCP_CLIENTS=claude-desktop,continue-dev,zed-editor
|
||||
|
||||
# Token cache configuration for Token Broker Service
|
||||
# Cache TTL in seconds (default: 300 = 5 minutes)
|
||||
#TOKEN_CACHE_TTL=300
|
||||
# Early refresh threshold in seconds (default: 30)
|
||||
#TOKEN_CACHE_EARLY_REFRESH=30
|
||||
|
||||
# Option 2: Basic Authentication (LEGACY - Less Secure)
|
||||
# - Requires username and password
|
||||
# - Credentials stored in environment variables
|
||||
|
||||
+77
-43
@@ -166,22 +166,76 @@
|
||||
{
|
||||
"clientId": "nextcloud",
|
||||
"name": "Nextcloud Resource Server",
|
||||
"description": "Resource server for Nextcloud APIs - used by user_oidc app for bearer token validation",
|
||||
"description": "Resource server for Nextcloud APIs - used by user_oidc app for bearer token validation and as token exchange target",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "nextcloud-secret-change-in-production",
|
||||
"redirectUris": [],
|
||||
"webOrigins": [],
|
||||
"bearerOnly": true,
|
||||
"bearerOnly": false,
|
||||
"consentRequired": false,
|
||||
"standardFlowEnabled": false,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"serviceAccountsEnabled": true,
|
||||
"authorizationServicesEnabled": true,
|
||||
"publicClient": false,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"display.on.consent.screen": "false"
|
||||
"display.on.consent.screen": "false",
|
||||
"token.exchange.grant.enabled": "true",
|
||||
"client.token.exchange.standard.enabled": "true",
|
||||
"standard.token.exchange.enabled": "true"
|
||||
},
|
||||
"authorizationSettings": {
|
||||
"allowRemoteResourceManagement": true,
|
||||
"policyEnforcementMode": "ENFORCING",
|
||||
"resources": [
|
||||
{
|
||||
"name": "token-exchange",
|
||||
"type": "urn:keycloak:token-exchange",
|
||||
"ownerManagedAccess": false,
|
||||
"displayName": "Token Exchange",
|
||||
"attributes": {},
|
||||
"uris": [],
|
||||
"scopes": [
|
||||
{
|
||||
"name": "token-exchange"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"policies": [
|
||||
{
|
||||
"name": "allow-nextcloud-mcp-server-to-exchange",
|
||||
"description": "",
|
||||
"type": "client",
|
||||
"logic": "POSITIVE",
|
||||
"decisionStrategy": "UNANIMOUS",
|
||||
"config": {
|
||||
"clients": "[\"nextcloud-mcp-server\",\"nextcloud\"]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "token-exchange-permission",
|
||||
"description": "",
|
||||
"type": "scope",
|
||||
"logic": "POSITIVE",
|
||||
"decisionStrategy": "AFFIRMATIVE",
|
||||
"config": {
|
||||
"resources": "[\"token-exchange\"]",
|
||||
"scopes": "[\"token-exchange\"]",
|
||||
"applyPolicies": "[\"allow-nextcloud-mcp-server-to-exchange\"]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"scopes": [
|
||||
{
|
||||
"name": "token-exchange",
|
||||
"displayName": "Token Exchange"
|
||||
}
|
||||
],
|
||||
"decisionStrategy": "UNANIMOUS"
|
||||
},
|
||||
"fullScopeAllowed": true,
|
||||
"nodeReRegistrationTimeout": -1
|
||||
@@ -220,20 +274,34 @@
|
||||
"client_credentials.use_refresh_token": "false",
|
||||
"display.on.consent.screen": "false",
|
||||
"token.exchange.grant.enabled": "true",
|
||||
"client.token.exchange.standard.enabled": "true"
|
||||
"client.token.exchange.standard.enabled": "true",
|
||||
"standard.token.exchange.enabled": "true"
|
||||
},
|
||||
"fullScopeAllowed": true,
|
||||
"nodeReRegistrationTimeout": -1,
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "audience-nextcloud",
|
||||
"name": "mcp-server-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.custom.audience": "nextcloud",
|
||||
"included.client.audience": "nextcloud-mcp-server",
|
||||
"access.token.claim": "true",
|
||||
"id.token.claim": "false"
|
||||
"id.token.claim": "false",
|
||||
"introspection.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nextcloud-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "nextcloud",
|
||||
"access.token.claim": "true",
|
||||
"id.token.claim": "false",
|
||||
"introspection.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -683,39 +751,6 @@
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Create, update, and delete tasks"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "audience",
|
||||
"description": "Audience scope for token validation",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "false"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "mcp-server-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "nextcloud-mcp-server",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nextcloud-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "nextcloud",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"components": {
|
||||
@@ -756,8 +791,7 @@
|
||||
"profile",
|
||||
"email",
|
||||
"roles",
|
||||
"web-origins",
|
||||
"audience"
|
||||
"web-origins"
|
||||
],
|
||||
"defaultOptionalClientScopes": [
|
||||
"offline_access",
|
||||
|
||||
+206
-74
@@ -15,18 +15,21 @@ from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
from nextcloud_mcp_server.auth import (
|
||||
InsufficientScopeError,
|
||||
NextcloudTokenVerifier,
|
||||
discover_all_scopes,
|
||||
get_access_token_scopes,
|
||||
has_required_scopes,
|
||||
is_jwt_token,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.progressive_token_verifier import (
|
||||
ProgressiveConsentTokenVerifier,
|
||||
)
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import (
|
||||
LOGGING_CONFIG,
|
||||
@@ -45,6 +48,7 @@ from nextcloud_mcp_server.server import (
|
||||
configure_tables_tools,
|
||||
configure_webdav_tools,
|
||||
)
|
||||
from nextcloud_mcp_server.server.oauth_tools import register_oauth_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -211,7 +215,9 @@ class OAuthAppContext:
|
||||
"""Application context for OAuth mode."""
|
||||
|
||||
nextcloud_host: str
|
||||
token_verifier: NextcloudTokenVerifier
|
||||
token_verifier: (
|
||||
object # Can be NextcloudTokenVerifier or ProgressiveConsentTokenVerifier
|
||||
)
|
||||
refresh_token_storage: Optional["RefreshTokenStorage"] = None
|
||||
oauth_client: Optional[object] = None # NextcloudOAuthClient or KeycloakOAuthClient
|
||||
oauth_provider: str = "nextcloud" # "nextcloud" or "keycloak"
|
||||
@@ -289,31 +295,19 @@ async def load_oauth_client_credentials(
|
||||
if registration_endpoint:
|
||||
logger.info("Dynamic client registration available")
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
|
||||
redirect_uris = [
|
||||
f"{mcp_server_url}/oauth/callback", # MCP OAuth flow
|
||||
f"{mcp_server_url}/oauth/login-callback", # Browser OAuth flow for /user/page
|
||||
]
|
||||
|
||||
# Get scopes from environment or use defaults
|
||||
# Note: Client registration happens BEFORE tools are registered, so we can't
|
||||
# dynamically discover scopes here. These scopes define the "maximum allowed"
|
||||
# scopes for this OAuth client. The actual per-tool scope enforcement happens
|
||||
# via @require_scopes decorators, and the PRM endpoint advertises the actual
|
||||
# supported scopes dynamically.
|
||||
# MCP server DCR: Register with ALL supported scopes
|
||||
# When we register as a resource server (with resource_url), the allowed_scopes
|
||||
# represent what scopes are AVAILABLE for this resource, not what the server needs.
|
||||
# External clients will request tokens with resource=http://localhost:8001/mcp
|
||||
# and the authorization server will limit them to these allowed scopes.
|
||||
#
|
||||
# IMPORTANT: Keep this list in sync with all @require_scopes decorators
|
||||
# when adding new apps, or set NEXTCLOUD_OIDC_SCOPES environment variable
|
||||
# to override.
|
||||
default_scopes = (
|
||||
"openid profile email "
|
||||
"notes:read notes:write "
|
||||
"calendar:read calendar:write "
|
||||
"todo:read todo:write "
|
||||
"contacts:read contacts:write "
|
||||
"cookbook:read cookbook:write "
|
||||
"deck:read deck:write "
|
||||
"tables:read tables:write "
|
||||
"files:read files:write "
|
||||
"sharing:read sharing:write"
|
||||
)
|
||||
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", default_scopes)
|
||||
# The PRM endpoint advertises the same scopes dynamically via @require_scopes decorators.
|
||||
dcr_scopes = "openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write"
|
||||
|
||||
# Add offline_access scope if refresh tokens are enabled
|
||||
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
||||
@@ -321,11 +315,11 @@ async def load_oauth_client_credentials(
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
if enable_offline_access and "offline_access" not in scopes:
|
||||
scopes = f"{scopes} offline_access"
|
||||
if enable_offline_access:
|
||||
dcr_scopes = f"{dcr_scopes} offline_access"
|
||||
logger.info("✓ offline_access scope enabled for refresh tokens")
|
||||
|
||||
logger.info(f"Requesting OAuth scopes: {scopes}")
|
||||
logger.info(f"MCP server DCR scopes (resource server): {dcr_scopes}")
|
||||
|
||||
# Get token type from environment (Bearer or jwt)
|
||||
# Note: Must be lowercase "jwt" to match OIDC app's check
|
||||
@@ -342,14 +336,19 @@ async def load_oauth_client_credentials(
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
# RFC 9728: resource_url must be a URL for the protected resource
|
||||
# This URL is used by token introspection to match tokens to this client
|
||||
resource_url = f"{mcp_server_url}/mcp"
|
||||
|
||||
client_info = await ensure_oauth_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
storage=storage,
|
||||
client_name=f"Nextcloud MCP Server ({token_type})",
|
||||
redirect_uris=redirect_uris,
|
||||
scopes=scopes,
|
||||
scopes=dcr_scopes, # Use DCR-specific scopes (basic OIDC only)
|
||||
token_type=token_type,
|
||||
resource_url=resource_url, # RFC 9728 Protected Resource URL
|
||||
)
|
||||
|
||||
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
|
||||
@@ -408,7 +407,7 @@ async def setup_oauth_config():
|
||||
requires token_verifier at construction time.
|
||||
|
||||
Returns:
|
||||
Tuple of (nextcloud_host, token_verifier, auth_settings, refresh_token_storage, oauth_client, oauth_provider)
|
||||
Tuple of (nextcloud_host, token_verifier, auth_settings, refresh_token_storage, oauth_client, oauth_provider, client_id, client_secret)
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
@@ -552,47 +551,50 @@ async def setup_oauth_config():
|
||||
logger.info(
|
||||
f"Using public issuer URL override for JWT validation: {public_issuer}"
|
||||
)
|
||||
jwt_validation_issuer = public_issuer
|
||||
client_issuer = public_issuer
|
||||
else:
|
||||
jwt_validation_issuer = issuer
|
||||
client_issuer = issuer
|
||||
|
||||
# Create token verifier
|
||||
if is_external_idp:
|
||||
# External IdP mode: Validate via Nextcloud user_oidc app
|
||||
# The user_oidc app accepts tokens from the external IdP and provisions users
|
||||
nextcloud_userinfo_uri = f"{nextcloud_host}/apps/user_oidc/userinfo"
|
||||
# Progressive Consent mode (always enabled) - dual OAuth flows with audience separation
|
||||
logger.info("✓ Progressive Consent mode enabled - dual OAuth flows active")
|
||||
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
# Get encryption key for token broker
|
||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
if not encryption_key:
|
||||
logger.warning(
|
||||
"TOKEN_ENCRYPTION_KEY not set - token broker will not be available"
|
||||
)
|
||||
|
||||
# Create token broker service
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
|
||||
token_broker = None
|
||||
if encryption_key and refresh_token_storage:
|
||||
token_broker = TokenBrokerService(
|
||||
storage=refresh_token_storage,
|
||||
oidc_discovery_url=discovery_url,
|
||||
nextcloud_host=nextcloud_host,
|
||||
userinfo_uri=nextcloud_userinfo_uri, # Nextcloud validates external tokens
|
||||
jwks_uri=jwks_uri, # External IdP's JWKS for JWT validation
|
||||
issuer=jwt_validation_issuer, # External IdP issuer
|
||||
introspection_uri=None, # External IdP introspection not used
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
encryption_key=encryption_key,
|
||||
)
|
||||
logger.info("✓ Token Broker service initialized for audience-specific tokens")
|
||||
|
||||
logger.info(
|
||||
"✓ External IdP mode configured - tokens validated via Nextcloud user_oidc app"
|
||||
)
|
||||
# Create Progressive Consent token verifier
|
||||
token_verifier = ProgressiveConsentTokenVerifier(
|
||||
token_storage=refresh_token_storage,
|
||||
token_broker=token_broker,
|
||||
oidc_discovery_url=discovery_url,
|
||||
nextcloud_host=nextcloud_host,
|
||||
encryption_key=encryption_key,
|
||||
mcp_client_id=client_id,
|
||||
introspection_uri=introspection_uri,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
|
||||
else:
|
||||
# Integrated mode: Nextcloud provides both OAuth and validation
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
nextcloud_host=nextcloud_host,
|
||||
userinfo_uri=userinfo_uri, # Nextcloud userinfo endpoint
|
||||
jwks_uri=jwks_uri, # Nextcloud JWKS for JWT validation
|
||||
issuer=jwt_validation_issuer, # Nextcloud issuer (or public override)
|
||||
introspection_uri=introspection_uri, # Nextcloud introspection for opaque tokens
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✓ Integrated mode configured - Nextcloud provides OAuth and validation"
|
||||
)
|
||||
logger.info(
|
||||
"✓ Progressive Consent verifier configured - enforcing audience separation"
|
||||
)
|
||||
if introspection_uri:
|
||||
logger.info("✓ Opaque token introspection enabled (RFC 7662)")
|
||||
|
||||
# Create OAuth client for server-initiated flows (e.g., token exchange, background workers)
|
||||
oauth_client = None
|
||||
@@ -656,6 +658,8 @@ async def setup_oauth_config():
|
||||
refresh_token_storage,
|
||||
oauth_client,
|
||||
oauth_provider,
|
||||
client_id,
|
||||
client_secret,
|
||||
)
|
||||
|
||||
|
||||
@@ -677,6 +681,8 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
refresh_token_storage,
|
||||
oauth_client,
|
||||
oauth_provider,
|
||||
client_id,
|
||||
client_secret,
|
||||
) = anyio.run(setup_oauth_config)
|
||||
|
||||
# Create lifespan function with captured OAuth context (closure)
|
||||
@@ -728,7 +734,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
async def nc_get_capabilities():
|
||||
"""Get the Nextcloud Host capabilities"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_nextcloud_client(ctx)
|
||||
client = await get_nextcloud_client(ctx)
|
||||
return await client.capabilities()
|
||||
|
||||
# Define available apps and their configuration functions
|
||||
@@ -757,6 +763,17 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
f"Unknown app: {app_name}. Available apps: {list(available_apps.keys())}"
|
||||
)
|
||||
|
||||
# Register OAuth provisioning tools (only when offline access/Progressive Consent is used)
|
||||
# With token exchange enabled (external IdP), provisioning is not needed for MCP operations
|
||||
enable_token_exchange = (
|
||||
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
|
||||
)
|
||||
if oauth_enabled and not enable_token_exchange:
|
||||
logger.info("Registering OAuth provisioning tools for Progressive Consent")
|
||||
register_oauth_tools(mcp)
|
||||
elif oauth_enabled and enable_token_exchange:
|
||||
logger.info("Skipping provisioning tools registration (token exchange enabled)")
|
||||
|
||||
# Override list_tools to filter based on user's token scopes (OAuth mode only)
|
||||
if oauth_enabled:
|
||||
original_list_tools = mcp._tool_manager.list_tools
|
||||
@@ -801,19 +818,49 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
return allowed_tools
|
||||
|
||||
# Replace the tool manager's list_tools method
|
||||
mcp._tool_manager.list_tools = list_tools_filtered
|
||||
mcp._tool_manager.list_tools = list_tools_filtered # type: ignore[method-assign]
|
||||
logger.info(
|
||||
"Dynamic tool filtering enabled for OAuth mode (JWT and Bearer tokens)"
|
||||
)
|
||||
|
||||
if transport == "sse":
|
||||
mcp_app = mcp.sse_app()
|
||||
lifespan = None
|
||||
starlette_lifespan = None
|
||||
elif transport in ("http", "streamable-http"):
|
||||
mcp_app = mcp.streamable_http_app()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: Starlette):
|
||||
async def starlette_lifespan(app: Starlette):
|
||||
# Set OAuth context for OAuth login routes (ADR-004)
|
||||
if oauth_enabled:
|
||||
# Prepare OAuth config from setup_oauth_config closure variables
|
||||
mcp_server_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
f"{nextcloud_host}/.well-known/openid-configuration",
|
||||
)
|
||||
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", "")
|
||||
|
||||
app.state.oauth_context = {
|
||||
"storage": refresh_token_storage,
|
||||
"oauth_client": oauth_client,
|
||||
"token_verifier": token_verifier, # For querying IdP userinfo endpoint
|
||||
"config": {
|
||||
"mcp_server_url": mcp_server_url,
|
||||
"discovery_url": discovery_url,
|
||||
"client_id": client_id, # From setup_oauth_config (DCR or static)
|
||||
"client_secret": client_secret, # From setup_oauth_config (DCR or static)
|
||||
"scopes": scopes,
|
||||
"nextcloud_host": nextcloud_host,
|
||||
"oauth_provider": oauth_provider,
|
||||
},
|
||||
}
|
||||
logger.info(
|
||||
f"OAuth context initialized for login routes (client_id={client_id[:16]}...)"
|
||||
)
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
yield
|
||||
@@ -884,19 +931,19 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
logger.info("Health check endpoints enabled: /health/live, /health/ready")
|
||||
|
||||
if oauth_enabled:
|
||||
# Import OAuth routes (ADR-004 Progressive Consent)
|
||||
from nextcloud_mcp_server.auth.oauth_routes import oauth_authorize
|
||||
|
||||
def oauth_protected_resource_metadata(request):
|
||||
"""RFC 9728 Protected Resource Metadata endpoint.
|
||||
|
||||
Dynamically discovers supported scopes from registered MCP tools.
|
||||
This ensures the advertised scopes always match the actual tool requirements.
|
||||
"""
|
||||
mcp_server_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
# Append /mcp to match the actual resource path (FastMCP streamable-http endpoint)
|
||||
resource_url = f"{mcp_server_url}/mcp"
|
||||
|
||||
The 'resource' field is set to the MCP server's public URL (RFC 9728 requires a URL).
|
||||
This is used as the audience in access tokens via the resource parameter (RFC 8707).
|
||||
The introspection controller matches this URL to the MCP server's client via resource_url field.
|
||||
"""
|
||||
# Use PUBLIC_ISSUER_URL for authorization server since external clients
|
||||
# (like Claude) need the publicly accessible URL, not internal Docker URLs
|
||||
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
@@ -904,13 +951,20 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
# Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set
|
||||
public_issuer_url = os.getenv("NEXTCLOUD_HOST", "")
|
||||
|
||||
# RFC 9728 requires resource to be a URL (not a client ID)
|
||||
# Use the MCP server's public URL
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL")
|
||||
if not mcp_server_url:
|
||||
# Fallback to constructing from host and port
|
||||
mcp_server_url = f"http://localhost:{os.getenv('PORT', '8000')}"
|
||||
|
||||
# Dynamically discover all scopes from registered tools
|
||||
# This provides a single source of truth based on @require_scopes decorators
|
||||
supported_scopes = discover_all_scopes(mcp)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"resource": resource_url,
|
||||
"resource": f"{mcp_server_url}/mcp", # RFC 9728: must be a URL
|
||||
"scopes_supported": supported_scopes,
|
||||
"authorization_servers": [public_issuer_url],
|
||||
"bearer_methods_supported": ["header"],
|
||||
@@ -939,8 +993,86 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
"Protected Resource Metadata (PRM) endpoints enabled (path-based + root)"
|
||||
)
|
||||
|
||||
# Add OAuth login routes (ADR-004 Progressive Consent Flow 1)
|
||||
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
|
||||
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
|
||||
|
||||
# Add browser OAuth login routes (OAuth mode only)
|
||||
if oauth_enabled:
|
||||
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
||||
oauth_login,
|
||||
oauth_login_callback,
|
||||
oauth_logout,
|
||||
)
|
||||
|
||||
routes.append(
|
||||
Route("/oauth/login", oauth_login, methods=["GET"], name="oauth_login")
|
||||
)
|
||||
routes.append(
|
||||
Route(
|
||||
"/oauth/login-callback",
|
||||
oauth_login_callback,
|
||||
methods=["GET"],
|
||||
name="oauth_login_callback",
|
||||
)
|
||||
)
|
||||
routes.append(
|
||||
Route("/oauth/logout", oauth_logout, methods=["GET"], name="oauth_logout")
|
||||
)
|
||||
logger.info(
|
||||
"Browser OAuth routes enabled: /oauth/login, /oauth/login-callback, /oauth/logout"
|
||||
)
|
||||
|
||||
# Add user info routes (available in both BasicAuth and OAuth modes)
|
||||
# These require session authentication, so we wrap them in a separate app
|
||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
user_info_html,
|
||||
user_info_json,
|
||||
)
|
||||
|
||||
# Create a separate Starlette app for browser routes that need session auth
|
||||
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
||||
browser_routes = [
|
||||
Route("/", user_info_json, methods=["GET"]), # /user/ → user_info_json
|
||||
Route("/page", user_info_html, methods=["GET"]), # /user/page → user_info_html
|
||||
]
|
||||
|
||||
browser_app = Starlette(routes=browser_routes)
|
||||
browser_app.add_middleware(
|
||||
AuthenticationMiddleware,
|
||||
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
|
||||
)
|
||||
|
||||
# Mount browser app at /user (so /user and /user/page work)
|
||||
routes.append(Mount("/user", app=browser_app))
|
||||
logger.info("User info routes with session auth: /user, /user/page")
|
||||
|
||||
# Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
|
||||
routes.append(Mount("/", app=mcp_app))
|
||||
app = Starlette(routes=routes, lifespan=lifespan)
|
||||
|
||||
app = Starlette(routes=routes, lifespan=starlette_lifespan)
|
||||
logger.info(
|
||||
"Routes: /user/* with SessionAuth, /mcp with FastMCP OAuth Bearer tokens"
|
||||
)
|
||||
|
||||
# Add debugging middleware to log Authorization headers
|
||||
@app.middleware("http")
|
||||
async def log_auth_headers(request, call_next):
|
||||
auth_header = request.headers.get("authorization")
|
||||
if request.url.path.startswith("/mcp"):
|
||||
if auth_header:
|
||||
# Log first 50 chars of token for debugging
|
||||
token_preview = (
|
||||
auth_header[:50] + "..." if len(auth_header) > 50 else auth_header
|
||||
)
|
||||
logger.info(f"🔑 /mcp request with Authorization: {token_preview}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠️ /mcp request WITHOUT Authorization header from {request.client}"
|
||||
)
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
# Add CORS middleware to allow browser-based clients like MCP Inspector
|
||||
app.add_middleware(
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
"""Browser-based OAuth login routes for admin UI.
|
||||
|
||||
Separate from MCP OAuth flow - these routes establish browser sessions
|
||||
for accessing admin UI endpoints like /user/page.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
_get_userinfo_endpoint,
|
||||
_query_idp_userinfo,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
"""Browser OAuth login endpoint - redirects to IdP for authentication.
|
||||
|
||||
This is separate from the MCP OAuth flow (/oauth/authorize).
|
||||
Creates a browser session with refresh token for admin UI access.
|
||||
|
||||
Query parameters:
|
||||
next: Optional URL to redirect to after login (default: /user/page)
|
||||
|
||||
Returns:
|
||||
302 redirect to IdP authorization endpoint
|
||||
"""
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
if not oauth_ctx:
|
||||
# BasicAuth mode - no login needed, redirect to user page
|
||||
return RedirectResponse("/user/page", status_code=302)
|
||||
|
||||
storage = oauth_ctx["storage"]
|
||||
oauth_client = oauth_ctx["oauth_client"]
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Debug: Log oauth_config contents
|
||||
logger.info(f"oauth_login called - oauth_config keys: {oauth_config.keys()}")
|
||||
logger.info(f"oauth_login called - client_id: {oauth_config.get('client_id')}")
|
||||
logger.info(f"oauth_login called - oauth_client: {oauth_client is not None}")
|
||||
|
||||
# Generate state for CSRF protection
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Build OAuth authorization URL
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
callback_uri = f"{mcp_server_url}/oauth/login-callback"
|
||||
|
||||
# Request only basic OIDC scopes for browser session
|
||||
# Note: Nextcloud app scopes (notes:read, etc.) are for MCP client access tokens,
|
||||
# not for the MCP server's own browser authentication
|
||||
scopes = "openid profile email offline_access"
|
||||
|
||||
code_challenge = ""
|
||||
code_verifier = ""
|
||||
|
||||
if oauth_client:
|
||||
# External IdP mode (Keycloak)
|
||||
# Keycloak requires PKCE, so generate code_verifier and code_challenge
|
||||
if not oauth_client.authorization_endpoint:
|
||||
await oauth_client.discover()
|
||||
|
||||
# Generate PKCE values
|
||||
code_verifier, code_challenge = oauth_client.generate_pkce_challenge()
|
||||
|
||||
# Store code_verifier temporarily (using state as key)
|
||||
# We'll retrieve it in the callback using the state parameter
|
||||
await storage.store_oauth_session(
|
||||
session_id=state, # Use state as session ID
|
||||
client_id="browser-ui",
|
||||
client_redirect_uri="/user/page",
|
||||
state=state,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method="S256",
|
||||
mcp_authorization_code=code_verifier, # Store code_verifier here temporarily
|
||||
flow_type="browser",
|
||||
ttl_seconds=600, # 10 minutes
|
||||
)
|
||||
|
||||
idp_params = {
|
||||
"client_id": oauth_client.client_id,
|
||||
"redirect_uri": callback_uri,
|
||||
"response_type": "code",
|
||||
"scope": scopes,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"prompt": "consent", # Ensure refresh token
|
||||
}
|
||||
|
||||
auth_url = f"{oauth_client.authorization_endpoint}?{urlencode(idp_params)}"
|
||||
logger.info(f"Redirecting to external IdP login: {auth_url.split('?')[0]}")
|
||||
else:
|
||||
# Integrated mode (Nextcloud OIDC)
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if not discovery_url:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth discovery URL not configured",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Fetch authorization endpoint
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
|
||||
# Replace internal Docker hostname with public URL
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||
auth_parsed = parse_url(authorization_endpoint)
|
||||
|
||||
if auth_parsed.hostname == internal_parsed.hostname:
|
||||
public_parsed = parse_url(public_issuer)
|
||||
authorization_endpoint = (
|
||||
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
|
||||
)
|
||||
|
||||
idp_params = {
|
||||
"client_id": oauth_config["client_id"],
|
||||
"redirect_uri": callback_uri,
|
||||
"response_type": "code",
|
||||
"scope": scopes,
|
||||
"state": state,
|
||||
"prompt": "consent", # Ensure refresh token
|
||||
}
|
||||
|
||||
# Debug: Log full parameters
|
||||
logger.info(f"Building Nextcloud OIDC auth URL with params: {idp_params}")
|
||||
|
||||
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
|
||||
logger.info(f"Redirecting to Nextcloud OIDC login: {auth_url}")
|
||||
|
||||
return RedirectResponse(auth_url, status_code=302)
|
||||
|
||||
|
||||
async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLResponse:
|
||||
"""Browser OAuth callback - IdP redirects here after authentication.
|
||||
|
||||
Exchanges authorization code for tokens, stores refresh token,
|
||||
sets session cookie, and redirects to original destination.
|
||||
|
||||
Query parameters:
|
||||
code: Authorization code from IdP
|
||||
state: State parameter
|
||||
error: Error code (if authorization failed)
|
||||
|
||||
Returns:
|
||||
302 redirect to next URL with session cookie
|
||||
"""
|
||||
# Check for errors
|
||||
error = request.query_params.get("error")
|
||||
if error:
|
||||
error_description = request.query_params.get(
|
||||
"error_description", "Authorization failed"
|
||||
)
|
||||
logger.error(f"OAuth login error: {error} - {error_description}")
|
||||
login_url = str(request.url_for("oauth_login"))
|
||||
return HTMLResponse(
|
||||
f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login Failed</title></head>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Error: {error}</p>
|
||||
<p>{error_description}</p>
|
||||
<p><a href="{login_url}">Try again</a></p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Extract code and state
|
||||
code = request.query_params.get("code")
|
||||
state = request.query_params.get("state")
|
||||
|
||||
if not code or not state:
|
||||
return HTMLResponse(
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Invalid Request</title></head>
|
||||
<body>
|
||||
<h1>Invalid Request</h1>
|
||||
<p>Missing code or state parameter</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
storage = oauth_ctx["storage"]
|
||||
oauth_client = oauth_ctx["oauth_client"]
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Retrieve code_verifier from session storage (if using PKCE)
|
||||
code_verifier = ""
|
||||
if oauth_client:
|
||||
# For Keycloak (external IdP), we stored the code_verifier in the session
|
||||
oauth_session = await storage.get_oauth_session(state)
|
||||
if oauth_session:
|
||||
# code_verifier was stored in mcp_authorization_code field
|
||||
code_verifier = oauth_session.get("mcp_authorization_code", "")
|
||||
# Clean up the temporary session
|
||||
# Note: We don't have delete_oauth_session method, but it will expire after TTL
|
||||
|
||||
# Exchange authorization code for tokens
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
callback_uri = f"{mcp_server_url}/oauth/login-callback"
|
||||
|
||||
try:
|
||||
if oauth_client:
|
||||
# External IdP mode (Keycloak)
|
||||
# Use PKCE if we have a code_verifier
|
||||
if not oauth_client.token_endpoint:
|
||||
await oauth_client.discover()
|
||||
|
||||
token_params = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": callback_uri,
|
||||
"client_id": oauth_client.client_id,
|
||||
"client_secret": oauth_client.client_secret,
|
||||
}
|
||||
|
||||
# Add code_verifier if we have one (PKCE)
|
||||
if code_verifier:
|
||||
token_params["code_verifier"] = code_verifier
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.post(
|
||||
oauth_client.token_endpoint,
|
||||
data=token_params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
else:
|
||||
# Integrated mode (Nextcloud OIDC)
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": callback_uri,
|
||||
"client_id": oauth_config["client_id"],
|
||||
"client_secret": oauth_config["client_secret"],
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_body = (
|
||||
e.response.text if hasattr(e.response, "text") else str(e.response.content)
|
||||
)
|
||||
logger.error(
|
||||
f"Token exchange failed: HTTP {e.response.status_code} - {error_body}"
|
||||
)
|
||||
return HTMLResponse(
|
||||
f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login Failed</title></head>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Failed to exchange authorization code for tokens</p>
|
||||
<p>HTTP {e.response.status_code}: {error_body}</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=500,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Token exchange failed: {e}")
|
||||
return HTMLResponse(
|
||||
f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login Failed</title></head>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Failed to exchange authorization code for tokens</p>
|
||||
<p>Error: {e}</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
refresh_token = token_data.get("refresh_token")
|
||||
id_token = token_data.get("id_token")
|
||||
|
||||
logger.info(f"Token exchange response keys: {token_data.keys()}")
|
||||
logger.info(f"Refresh token present: {refresh_token is not None}")
|
||||
logger.info(f"ID token present: {id_token is not None}")
|
||||
|
||||
# Decode ID token to get user info
|
||||
try:
|
||||
userinfo = jwt.decode(id_token, options={"verify_signature": False})
|
||||
user_id = userinfo.get("sub")
|
||||
username = userinfo.get("preferred_username") or userinfo.get("email")
|
||||
logger.info(f"Browser login successful: {username} (sub={user_id})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode ID token: {e}")
|
||||
user_id = f"user-{secrets.token_hex(8)}"
|
||||
username = "unknown"
|
||||
|
||||
# Store refresh token (for background jobs ONLY)
|
||||
if refresh_token:
|
||||
logger.info(f"Storing refresh token for user_id: {user_id}")
|
||||
await storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
refresh_token=refresh_token,
|
||||
expires_at=None,
|
||||
flow_type="browser", # Browser-based login flow
|
||||
)
|
||||
logger.info(f"✓ Refresh token stored successfully for user_id: {user_id}")
|
||||
else:
|
||||
logger.warning("No refresh token in token response - cannot store session")
|
||||
|
||||
# Query and cache user profile (for browser UI display)
|
||||
access_token = token_data.get("access_token")
|
||||
if access_token:
|
||||
try:
|
||||
# Get the OAuth context to determine correct userinfo endpoint
|
||||
oauth_ctx = getattr(request.app.state, "oauth_context", {})
|
||||
userinfo_endpoint = await _get_userinfo_endpoint(oauth_ctx)
|
||||
|
||||
if userinfo_endpoint:
|
||||
# Query userinfo endpoint with fresh access token
|
||||
profile_data = await _query_idp_userinfo(
|
||||
access_token, userinfo_endpoint
|
||||
)
|
||||
|
||||
if profile_data:
|
||||
# Cache profile for browser UI (no token needed to display)
|
||||
await storage.store_user_profile(user_id, profile_data)
|
||||
logger.info(f"✓ User profile cached for {user_id}")
|
||||
else:
|
||||
logger.warning(f"Failed to query userinfo endpoint for {user_id}")
|
||||
else:
|
||||
logger.warning("Could not determine userinfo endpoint")
|
||||
except Exception as e:
|
||||
logger.error(f"Error caching user profile: {e}")
|
||||
# Continue anyway - profile cache is optional for browser UI
|
||||
|
||||
# Create response and set session cookie
|
||||
response = RedirectResponse("/user/page", status_code=302)
|
||||
response.set_cookie(
|
||||
key="mcp_session",
|
||||
value=user_id,
|
||||
max_age=86400 * 30, # 30 days
|
||||
httponly=True,
|
||||
secure=False, # Set to True in production with HTTPS
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
logger.info(f"Session cookie set for user: {username}")
|
||||
return response
|
||||
|
||||
|
||||
async def oauth_logout(request: Request) -> RedirectResponse:
|
||||
"""Browser OAuth logout - clears session cookie.
|
||||
|
||||
Query parameters:
|
||||
next: Optional URL to redirect to after logout (default: /oauth/login)
|
||||
|
||||
Returns:
|
||||
302 redirect with cleared session cookie
|
||||
"""
|
||||
next_url = request.query_params.get("next", "/oauth/login")
|
||||
|
||||
# TODO: Optionally revoke refresh token from storage
|
||||
# session_id = request.cookies.get("mcp_session")
|
||||
# if session_id:
|
||||
# await storage.delete_refresh_token(session_id)
|
||||
|
||||
response = RedirectResponse(next_url, status_code=302)
|
||||
response.delete_cookie("mcp_session")
|
||||
|
||||
logger.info("User logged out, session cookie cleared")
|
||||
return response
|
||||
@@ -80,6 +80,7 @@ async def register_client(
|
||||
redirect_uris: list[str] | None = None,
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str = "Bearer",
|
||||
resource_url: str | None = None,
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
|
||||
@@ -91,6 +92,7 @@ async def register_client(
|
||||
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
|
||||
scopes: Space-separated list of scopes to request
|
||||
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
|
||||
|
||||
Returns:
|
||||
ClientInfo with registration details
|
||||
@@ -112,6 +114,10 @@ async def register_client(
|
||||
"token_type": token_type,
|
||||
}
|
||||
|
||||
# Add resource_url if provided (RFC 9728)
|
||||
if resource_url:
|
||||
client_metadata["resource_url"] = resource_url
|
||||
|
||||
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
||||
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
||||
|
||||
@@ -303,6 +309,7 @@ async def ensure_oauth_client(
|
||||
redirect_uris: list[str] | None = None,
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str = "Bearer",
|
||||
resource_url: str | None = None,
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Ensure OAuth client exists in SQLite storage.
|
||||
@@ -321,6 +328,7 @@ async def ensure_oauth_client(
|
||||
redirect_uris: List of redirect URIs
|
||||
scopes: Space-separated list of scopes to request (default: "openid profile email")
|
||||
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
|
||||
|
||||
Returns:
|
||||
ClientInfo with valid credentials
|
||||
@@ -339,6 +347,8 @@ async def ensure_oauth_client(
|
||||
|
||||
# Register new client
|
||||
logger.info("Registering new OAuth client...")
|
||||
if resource_url:
|
||||
logger.info(f" with resource_url: {resource_url}")
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_url,
|
||||
registration_endpoint=registration_endpoint,
|
||||
@@ -346,6 +356,7 @@ async def ensure_oauth_client(
|
||||
redirect_uris=redirect_uris,
|
||||
scopes=scopes,
|
||||
token_type=token_type,
|
||||
resource_url=resource_url,
|
||||
)
|
||||
|
||||
# Save to SQLite storage
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
MCP Client Registry for ADR-004 Progressive Consent Architecture.
|
||||
|
||||
This module manages the registry of allowed MCP clients that can authenticate
|
||||
via Flow 1. In production, this would integrate with Dynamic Client Registration
|
||||
(DCR) or a database of pre-registered clients.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPClientInfo:
|
||||
"""Information about a registered MCP client."""
|
||||
|
||||
client_id: str
|
||||
name: str
|
||||
redirect_uris: List[str]
|
||||
allowed_scopes: List[str]
|
||||
is_public: bool = True # Native clients are public (no client_secret)
|
||||
metadata: Optional[Dict] = None
|
||||
|
||||
|
||||
class ClientRegistry:
|
||||
"""
|
||||
Registry for MCP clients allowed to authenticate via Flow 1.
|
||||
|
||||
In production, this would:
|
||||
1. Support Dynamic Client Registration (DCR) per RFC 7591
|
||||
2. Integrate with IdP client registry
|
||||
3. Store client metadata in database
|
||||
4. Support client updates and revocation
|
||||
"""
|
||||
|
||||
def __init__(self, allow_dynamic_registration: bool = False):
|
||||
"""
|
||||
Initialize the client registry.
|
||||
|
||||
Args:
|
||||
allow_dynamic_registration: Whether to allow DCR for new clients
|
||||
"""
|
||||
self.allow_dynamic_registration = allow_dynamic_registration
|
||||
self._clients: Dict[str, MCPClientInfo] = {}
|
||||
self._load_static_clients()
|
||||
|
||||
def _load_static_clients(self):
|
||||
"""Load statically configured clients from environment."""
|
||||
# Load from ALLOWED_MCP_CLIENTS environment variable
|
||||
allowed_clients = os.getenv("ALLOWED_MCP_CLIENTS", "").strip()
|
||||
|
||||
if allowed_clients:
|
||||
# Parse comma-separated list
|
||||
for client_id in allowed_clients.split(","):
|
||||
client_id = client_id.strip()
|
||||
if client_id:
|
||||
# Create basic client info
|
||||
# In production, would load full metadata from database
|
||||
self._clients[client_id] = MCPClientInfo(
|
||||
client_id=client_id,
|
||||
name=self._get_client_name(client_id),
|
||||
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
||||
allowed_scopes=["openid", "profile", "email", "mcp-server:api"],
|
||||
is_public=True,
|
||||
)
|
||||
logger.info(f"Registered static client: {client_id}")
|
||||
|
||||
# Add well-known clients if not explicitly configured
|
||||
if not self._clients:
|
||||
self._add_well_known_clients()
|
||||
|
||||
def _get_client_name(self, client_id: str) -> str:
|
||||
"""Get human-readable name for client_id."""
|
||||
known_names = {
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"continue-dev": "Continue IDE Extension",
|
||||
"zed-editor": "Zed Editor",
|
||||
"vscode-mcp": "VS Code MCP Extension",
|
||||
"test-mcp-client": "Test MCP Client",
|
||||
}
|
||||
return known_names.get(client_id, client_id.replace("-", " ").title())
|
||||
|
||||
def _add_well_known_clients(self):
|
||||
"""Add well-known MCP clients for testing and development."""
|
||||
well_known = [
|
||||
MCPClientInfo(
|
||||
client_id="claude-desktop",
|
||||
name="Claude Desktop",
|
||||
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
||||
allowed_scopes=["openid", "profile", "email", "mcp-server:api"],
|
||||
is_public=True,
|
||||
metadata={"vendor": "Anthropic"},
|
||||
),
|
||||
MCPClientInfo(
|
||||
client_id="test-mcp-client",
|
||||
name="Test MCP Client",
|
||||
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
||||
allowed_scopes=["openid", "profile", "email", "mcp-server:api"],
|
||||
is_public=True,
|
||||
metadata={"purpose": "testing"},
|
||||
),
|
||||
]
|
||||
|
||||
for client in well_known:
|
||||
self._clients[client.client_id] = client
|
||||
logger.info(f"Registered well-known client: {client.client_id}")
|
||||
|
||||
def validate_client(
|
||||
self,
|
||||
client_id: str,
|
||||
redirect_uri: Optional[str] = None,
|
||||
scopes: Optional[List[str]] = None,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate a client_id and optionally its redirect_uri and scopes.
|
||||
|
||||
Args:
|
||||
client_id: The client identifier to validate
|
||||
redirect_uri: Optional redirect URI to validate
|
||||
scopes: Optional list of scopes to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
# Check if client exists
|
||||
client = self._clients.get(client_id)
|
||||
if not client:
|
||||
if self.allow_dynamic_registration:
|
||||
# In production, would attempt DCR here
|
||||
logger.info(f"Unknown client {client_id}, would attempt DCR")
|
||||
return True, None
|
||||
else:
|
||||
return False, f"Unknown client_id: {client_id}"
|
||||
|
||||
# Validate redirect_uri if provided
|
||||
if redirect_uri:
|
||||
if not self._validate_redirect_uri(client, redirect_uri):
|
||||
return False, f"Invalid redirect_uri for client {client_id}"
|
||||
|
||||
# Validate scopes if provided
|
||||
if scopes:
|
||||
invalid_scopes = set(scopes) - set(client.allowed_scopes)
|
||||
if invalid_scopes:
|
||||
return False, f"Invalid scopes for client {client_id}: {invalid_scopes}"
|
||||
|
||||
return True, None
|
||||
|
||||
def _validate_redirect_uri(self, client: MCPClientInfo, redirect_uri: str) -> bool:
|
||||
"""
|
||||
Validate redirect_uri against client's registered URIs.
|
||||
|
||||
Args:
|
||||
client: The client info
|
||||
redirect_uri: The URI to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
# Parse the redirect URI
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(redirect_uri)
|
||||
|
||||
# Check against registered patterns
|
||||
for pattern in client.redirect_uris:
|
||||
if "*" in pattern:
|
||||
# Handle wildcard port (localhost:*)
|
||||
pattern_base = pattern.replace(":*", "")
|
||||
if redirect_uri.startswith(pattern_base + ":"):
|
||||
# Validate it's localhost with a port
|
||||
if parsed.hostname in ["localhost", "127.0.0.1"]:
|
||||
return True
|
||||
elif redirect_uri == pattern:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def register_client(self, client_info: MCPClientInfo) -> bool:
|
||||
"""
|
||||
Register a new MCP client (DCR support).
|
||||
|
||||
Args:
|
||||
client_info: Client information to register
|
||||
|
||||
Returns:
|
||||
True if registered successfully
|
||||
"""
|
||||
if not self.allow_dynamic_registration:
|
||||
logger.warning(f"DCR disabled, cannot register {client_info.client_id}")
|
||||
return False
|
||||
|
||||
if client_info.client_id in self._clients:
|
||||
logger.warning(f"Client {client_info.client_id} already registered")
|
||||
return False
|
||||
|
||||
self._clients[client_info.client_id] = client_info
|
||||
logger.info(f"Dynamically registered client: {client_info.client_id}")
|
||||
|
||||
# In production, would persist to database
|
||||
return True
|
||||
|
||||
def get_client(self, client_id: str) -> Optional[MCPClientInfo]:
|
||||
"""
|
||||
Get client information.
|
||||
|
||||
Args:
|
||||
client_id: The client identifier
|
||||
|
||||
Returns:
|
||||
Client info if found, None otherwise
|
||||
"""
|
||||
return self._clients.get(client_id)
|
||||
|
||||
def list_clients(self) -> List[MCPClientInfo]:
|
||||
"""
|
||||
List all registered clients.
|
||||
|
||||
Returns:
|
||||
List of client information
|
||||
"""
|
||||
return list(self._clients.values())
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_registry: Optional[ClientRegistry] = None
|
||||
|
||||
|
||||
def get_client_registry() -> ClientRegistry:
|
||||
"""Get the global client registry instance."""
|
||||
global _registry
|
||||
if _registry is None:
|
||||
# Check if DCR is enabled
|
||||
allow_dcr = os.getenv("ENABLE_DCR", "false").lower() == "true"
|
||||
_registry = ClientRegistry(allow_dynamic_registration=allow_dcr)
|
||||
return _registry
|
||||
@@ -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_audience
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,3 +65,81 @@ 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 client token for Nextcloud API token (pure RFC 8693)")
|
||||
|
||||
# Perform pure RFC 8693 token exchange (no refresh tokens)
|
||||
# Note: We don't pass scopes since Nextcloud doesn't enforce them.
|
||||
# The MCP server's @require_scopes decorator handles authorization.
|
||||
exchanged_token, expires_in = await exchange_token_for_audience(
|
||||
subject_token=flow1_token,
|
||||
requested_audience="nextcloud",
|
||||
requested_scopes=None, # Nextcloud doesn't support scopes
|
||||
)
|
||||
|
||||
logger.info(f"Pure token exchange successful. Token expires in {expires_in}s")
|
||||
|
||||
# Create client with exchanged token
|
||||
# This token is ephemeral (per-request) and NOT stored
|
||||
return NextcloudClient.from_token(
|
||||
base_url=base_url, token=exchanged_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}")
|
||||
raise RuntimeError(f"Token exchange required but failed: {e}") from e
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
"""
|
||||
OAuth 2.0 Login Routes for ADR-004 Progressive Consent Architecture
|
||||
|
||||
Implements dual OAuth flows with explicit provisioning:
|
||||
|
||||
Flow 1: Client Authentication - MCP client authenticates directly to IdP
|
||||
- Client requests: Nextcloud MCP resource scopes (notes:*, calendar:*, etc.)
|
||||
- Token audience (aud): "mcp-server"
|
||||
- No server interception - IdP redirects directly to client
|
||||
- Client receives resource-scoped token for MCP session
|
||||
|
||||
Flow 2: Resource Provisioning - MCP server gets delegated Nextcloud access
|
||||
- Triggered by user calling provision_nextcloud_access tool
|
||||
- Server requests: openid, profile, email scopes, offline_access
|
||||
- Separate login flow outside MCP session, results in browser login for user
|
||||
- Token audience (aud): "nextcloud", redirect/callback to mcp server
|
||||
- Server receives refresh token for offline access
|
||||
- Client never sees this token
|
||||
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registry import get_client_registry
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
"""
|
||||
OAuth authorization endpoint for Flow 1: Client Authentication.
|
||||
|
||||
The client authenticates directly to the IdP with its own client_id.
|
||||
The server validates the client is authorized but does NOT intercept the callback.
|
||||
IdP redirects directly back to the client's redirect_uri.
|
||||
|
||||
Query parameters:
|
||||
response_type: Must be "code"
|
||||
client_id: MCP client identifier (required)
|
||||
redirect_uri: Client's localhost redirect URI (required)
|
||||
scope: Requested scopes (optional, defaults to "openid profile email")
|
||||
state: CSRF protection state (required)
|
||||
code_challenge: PKCE code challenge from client (required)
|
||||
code_challenge_method: PKCE method, must be "S256" (required)
|
||||
|
||||
Returns:
|
||||
302 redirect to IdP authorization endpoint
|
||||
"""
|
||||
# Extract parameters
|
||||
response_type = request.query_params.get("response_type")
|
||||
client_id = request.query_params.get("client_id")
|
||||
redirect_uri = request.query_params.get("redirect_uri")
|
||||
state = request.query_params.get("state")
|
||||
code_challenge = request.query_params.get("code_challenge")
|
||||
code_challenge_method = request.query_params.get("code_challenge_method", "S256")
|
||||
|
||||
# Validate required parameters
|
||||
if response_type != "code":
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "unsupported_response_type",
|
||||
"error_description": "Only 'code' response_type is supported",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not redirect_uri:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "redirect_uri is required",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate redirect_uri is localhost (RFC 8252 for native clients)
|
||||
if not redirect_uri.startswith(("http://localhost:", "http://127.0.0.1:")):
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "redirect_uri must be localhost for native clients",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not state:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "state parameter is required for CSRF protection",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not code_challenge:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "code_challenge is required (PKCE)",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if code_challenge_method != "S256":
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "code_challenge_method must be S256",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate client_id (required for Progressive Consent Flow 1)
|
||||
if not client_id:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "client_id is required",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate client using registry
|
||||
registry = get_client_registry()
|
||||
is_valid, error_msg = registry.validate_client(
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scopes=request.query_params.get("scope", "").split()
|
||||
if request.query_params.get("scope")
|
||||
else None,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(f"Client validation failed: {error_msg}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "unauthorized_client",
|
||||
"error_description": error_msg,
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Get OAuth context from app state
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
if not oauth_ctx:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth not configured on server",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
oauth_client = oauth_ctx["oauth_client"]
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Flow 1: Client authenticates directly to IdP WITHOUT server interception
|
||||
# CRITICAL: This is a direct pass-through to IdP
|
||||
# The IdP will redirect directly back to the client's callback
|
||||
# The MCP server does NOT see the IdP authorization code!
|
||||
|
||||
logger.info(
|
||||
f"Starting Progressive Consent Flow 1 - no server session needed, "
|
||||
f"client will handle IdP response directly at {redirect_uri}"
|
||||
)
|
||||
|
||||
# Use client's redirect_uri for DIRECT callback (bypasses server)
|
||||
callback_uri = redirect_uri
|
||||
|
||||
# Request resource scopes for MCP tools access
|
||||
# The token will have aud: "mcp-server" claim
|
||||
# Build scopes from NEXTCLOUD_OIDC_SCOPES config
|
||||
default_scopes = "openid profile email"
|
||||
resource_scopes = oauth_config.get("scopes", "")
|
||||
scopes = f"{default_scopes} {resource_scopes}".strip()
|
||||
|
||||
# Pass through client's state directly
|
||||
idp_state = state
|
||||
|
||||
# Use client's own client_id (client must be pre-registered at IdP)
|
||||
idp_client_id = client_id
|
||||
|
||||
logger.info("Flow 1 (Progressive Consent): Direct client auth to IdP")
|
||||
logger.info(f" Client ID: {client_id}")
|
||||
logger.info(f" Client will receive IdP code directly at: {callback_uri}")
|
||||
logger.info(f" Scopes: {scopes} (resource access for MCP tools)")
|
||||
|
||||
# Get authorization endpoint from OAuth client
|
||||
if oauth_client:
|
||||
# External IdP mode (Keycloak) - use oauth_client
|
||||
auth_url = await oauth_client.get_authorization_url(
|
||||
state=idp_state,
|
||||
code_challenge="", # Server doesn't use PKCE with IdP
|
||||
)
|
||||
logger.info(f"Redirecting to external IdP: {auth_url.split('?')[0]}")
|
||||
else:
|
||||
# Integrated mode (Nextcloud OIDC) - build URL directly
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if not discovery_url:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth discovery URL not configured",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Fetch authorization endpoint from discovery
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
|
||||
# IMPORTANT: Replace internal Docker hostname with public URL for browser access
|
||||
# The discovery endpoint returns http://app/apps/oidc/authorize (internal)
|
||||
# But browsers need http://localhost:8080/apps/oidc/authorize (public)
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
# Parse internal and authorization endpoint to compare hostnames
|
||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||
auth_parsed = parse_url(authorization_endpoint)
|
||||
|
||||
# Check if authorization endpoint uses internal hostname
|
||||
if auth_parsed.hostname == internal_parsed.hostname:
|
||||
# Replace internal hostname+port with public URL
|
||||
# Keep the path from authorization_endpoint
|
||||
public_parsed = parse_url(public_issuer)
|
||||
authorization_endpoint = (
|
||||
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
|
||||
)
|
||||
if auth_parsed.query:
|
||||
authorization_endpoint += f"?{auth_parsed.query}"
|
||||
logger.info(
|
||||
f"Rewrote authorization endpoint for browser access: {authorization_endpoint}"
|
||||
)
|
||||
|
||||
idp_params = {
|
||||
"client_id": idp_client_id,
|
||||
"redirect_uri": callback_uri,
|
||||
"response_type": "code",
|
||||
"scope": scopes,
|
||||
"state": idp_state,
|
||||
"prompt": "consent", # Ensure refresh token
|
||||
}
|
||||
|
||||
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
|
||||
logger.info(f"Redirecting to Nextcloud OIDC: {auth_url.split('?')[0]}")
|
||||
|
||||
return RedirectResponse(auth_url, status_code=302)
|
||||
|
||||
|
||||
async def oauth_authorize_nextcloud(
|
||||
request: Request,
|
||||
) -> RedirectResponse | JSONResponse:
|
||||
"""
|
||||
OAuth authorization endpoint for Flow 2: Resource Provisioning.
|
||||
|
||||
This endpoint is used by the provision_nextcloud_access MCP tool
|
||||
to initiate delegated resource access to Nextcloud. Requires a separate
|
||||
login flow outside of the MCP session.
|
||||
|
||||
Query parameters:
|
||||
state: Session state for tracking
|
||||
|
||||
Returns:
|
||||
302 redirect to IdP authorization endpoint
|
||||
"""
|
||||
state = request.query_params.get("state")
|
||||
if not state:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "state parameter is required",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
if not oauth_ctx:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth not configured on server",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Get MCP server's OAuth client credentials
|
||||
mcp_server_client_id = os.getenv(
|
||||
"MCP_SERVER_CLIENT_ID", oauth_config.get("client_id")
|
||||
)
|
||||
if not mcp_server_client_id:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "MCP server OAuth client not configured",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback-nextcloud"
|
||||
|
||||
# Flow 2: Server only needs identity + offline access (no resource scopes)
|
||||
# Resource scopes are requested by client in Flow 1
|
||||
scopes = "openid profile email offline_access"
|
||||
|
||||
# Get authorization endpoint
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if not discovery_url:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth discovery URL not configured",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
|
||||
# Fix internal hostname for browser access
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||
auth_parsed = parse_url(authorization_endpoint)
|
||||
|
||||
if auth_parsed.hostname == internal_parsed.hostname:
|
||||
public_parsed = parse_url(public_issuer)
|
||||
authorization_endpoint = (
|
||||
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
|
||||
)
|
||||
|
||||
# Build authorization URL
|
||||
idp_params = {
|
||||
"client_id": mcp_server_client_id,
|
||||
"redirect_uri": callback_uri,
|
||||
"response_type": "code",
|
||||
"scope": scopes,
|
||||
"state": state,
|
||||
"prompt": "consent", # Force consent to show resource access
|
||||
"access_type": "offline", # Request refresh token
|
||||
}
|
||||
|
||||
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
|
||||
logger.info("Flow 2: Redirecting to IdP for resource provisioning")
|
||||
|
||||
return RedirectResponse(auth_url, status_code=302)
|
||||
|
||||
|
||||
async def oauth_callback_nextcloud(request: Request):
|
||||
"""
|
||||
OAuth callback endpoint for Flow 2: Resource Provisioning.
|
||||
|
||||
The IdP redirects here after user grants delegated resource access.
|
||||
Server stores the master refresh token for offline access.
|
||||
|
||||
Query parameters:
|
||||
code: Authorization code from IdP
|
||||
state: State parameter (session identifier)
|
||||
error: Error code (if authorization failed)
|
||||
|
||||
Returns:
|
||||
JSON response or HTML success page
|
||||
"""
|
||||
# Check for errors from IdP
|
||||
error = request.query_params.get("error")
|
||||
if error:
|
||||
error_description = request.query_params.get(
|
||||
"error_description", "Authorization failed"
|
||||
)
|
||||
logger.error(f"Flow 2 authorization error: {error} - {error_description}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": error,
|
||||
"error_description": error_description,
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
code = request.query_params.get("code")
|
||||
state = request.query_params.get("state")
|
||||
|
||||
if not code or not state:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "code and state parameters are required",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
storage: RefreshTokenStorage = oauth_ctx["storage"]
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Exchange code for tokens
|
||||
mcp_server_client_id = os.getenv(
|
||||
"MCP_SERVER_CLIENT_ID", oauth_config.get("client_id")
|
||||
)
|
||||
mcp_server_client_secret = os.getenv(
|
||||
"MCP_SERVER_CLIENT_SECRET", oauth_config.get("client_secret")
|
||||
)
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback-nextcloud"
|
||||
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
# Exchange code for tokens
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": callback_uri,
|
||||
"client_id": mcp_server_client_id,
|
||||
"client_secret": mcp_server_client_secret,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
refresh_token = token_data.get("refresh_token")
|
||||
id_token = token_data.get("id_token")
|
||||
|
||||
# Decode ID token to get user info
|
||||
try:
|
||||
userinfo = jwt.decode(id_token, options={"verify_signature": False})
|
||||
user_id = userinfo.get("sub")
|
||||
username = userinfo.get("preferred_username") or userinfo.get("email")
|
||||
logger.info(f"Flow 2: User {username} provisioned resource access")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode ID token: {e}")
|
||||
user_id = "unknown"
|
||||
|
||||
# Store master refresh token for Flow 2
|
||||
if refresh_token:
|
||||
# Parse granted scopes from token response
|
||||
granted_scopes = (
|
||||
token_data.get("scope", "").split() if token_data.get("scope") else None
|
||||
)
|
||||
|
||||
await storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
refresh_token=refresh_token,
|
||||
flow_type="flow2",
|
||||
token_audience="nextcloud",
|
||||
provisioning_client_id=state, # Store which client initiated provisioning
|
||||
scopes=granted_scopes,
|
||||
expires_at=None, # Refresh tokens typically don't expire
|
||||
)
|
||||
logger.info(f"Stored Flow 2 master refresh token for user {user_id}")
|
||||
|
||||
# Return success HTML page
|
||||
success_html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Nextcloud Access Provisioned</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; }
|
||||
.success { color: green; }
|
||||
.info { margin-top: 20px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="success">✓ Nextcloud Access Provisioned</h1>
|
||||
<p>The MCP server now has offline access to your Nextcloud resources.</p>
|
||||
<p class="info">You can close this window and return to your MCP client.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
return HTMLResponse(content=success_html, status_code=200)
|
||||
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
Token Verifier for ADR-004 Progressive Consent Architecture.
|
||||
|
||||
This module implements token verification with strict audience separation:
|
||||
- Flow 1 tokens have aud: <mcp-client-id> for MCP authentication
|
||||
- Flow 2 tokens have aud: "nextcloud" for resource access
|
||||
- Token Broker manages the exchange between audiences
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProgressiveConsentTokenVerifier:
|
||||
"""
|
||||
Token verifier for Progressive Consent dual OAuth flows.
|
||||
|
||||
This verifier:
|
||||
1. Validates Flow 1 tokens (aud: <mcp-client-id>) for MCP authentication
|
||||
2. Checks if user has provisioned Nextcloud access (Flow 2)
|
||||
3. Uses Token Broker to obtain aud: "nextcloud" tokens when needed
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token_storage: RefreshTokenStorage | None,
|
||||
token_broker: Optional[TokenBrokerService] = None,
|
||||
oidc_discovery_url: Optional[str] = None,
|
||||
nextcloud_host: Optional[str] = None,
|
||||
encryption_key: Optional[str] = None,
|
||||
mcp_client_id: Optional[str] = None,
|
||||
introspection_uri: Optional[str] = None,
|
||||
client_secret: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the Progressive Consent token verifier.
|
||||
|
||||
Args:
|
||||
token_storage: Storage for refresh tokens
|
||||
token_broker: Token broker service (created if not provided)
|
||||
oidc_discovery_url: OIDC provider discovery URL
|
||||
nextcloud_host: Nextcloud server URL
|
||||
encryption_key: Fernet key for token encryption
|
||||
mcp_client_id: MCP server OAuth client ID for audience validation
|
||||
introspection_uri: OAuth introspection endpoint URL (for opaque tokens)
|
||||
client_secret: OAuth client secret (required for introspection)
|
||||
"""
|
||||
self.storage = token_storage
|
||||
self.oidc_discovery_url = oidc_discovery_url or os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
f"{os.getenv('NEXTCLOUD_HOST')}/.well-known/openid-configuration",
|
||||
)
|
||||
self.nextcloud_host = nextcloud_host or os.getenv("NEXTCLOUD_HOST")
|
||||
self.encryption_key = encryption_key or os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
self.mcp_client_id = mcp_client_id or os.getenv("OIDC_CLIENT_ID")
|
||||
self.introspection_uri = introspection_uri
|
||||
self.client_secret = client_secret or os.getenv("OIDC_CLIENT_SECRET")
|
||||
|
||||
# HTTP client for introspection requests
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
if self.introspection_uri and self.mcp_client_id and self.client_secret:
|
||||
self._http_client = httpx.AsyncClient(timeout=10.0)
|
||||
logger.info(f"Introspection support enabled: {introspection_uri}")
|
||||
elif self.introspection_uri:
|
||||
logger.warning(
|
||||
"Introspection URI provided but missing client credentials - introspection disabled"
|
||||
)
|
||||
|
||||
# Create token broker if not provided
|
||||
if token_broker:
|
||||
self.token_broker = token_broker
|
||||
elif self.encryption_key and token_storage and self.nextcloud_host:
|
||||
self.token_broker = TokenBrokerService(
|
||||
storage=token_storage,
|
||||
oidc_discovery_url=self.oidc_discovery_url,
|
||||
nextcloud_host=self.nextcloud_host,
|
||||
encryption_key=self.encryption_key,
|
||||
)
|
||||
else:
|
||||
self.token_broker = None
|
||||
if not self.encryption_key:
|
||||
logger.warning("Token broker not available - encryption key missing")
|
||||
elif not token_storage:
|
||||
logger.warning("Token broker not available - token storage missing")
|
||||
elif not self.nextcloud_host:
|
||||
logger.warning("Token broker not available - nextcloud host missing")
|
||||
|
||||
async def verify_token(self, token: str) -> Optional[AccessToken]:
|
||||
"""
|
||||
Verify a Flow 1 token (aud: <mcp-client-id>).
|
||||
|
||||
This validates that:
|
||||
1. Token has correct audience for MCP server (matches client ID)
|
||||
2. Token is not expired
|
||||
3. Token has valid signature (if verification enabled)
|
||||
|
||||
Supports both JWT and opaque tokens:
|
||||
- JWT tokens: Decoded directly from payload
|
||||
- Opaque tokens: Validated via introspection endpoint (RFC 7662)
|
||||
|
||||
Args:
|
||||
token: Access token from Flow 1 (JWT or opaque)
|
||||
|
||||
Returns:
|
||||
AccessToken if valid, None otherwise
|
||||
"""
|
||||
logger.info("🔐 verify_token called - attempting to validate token")
|
||||
logger.info(f"Token (first 50 chars): {token[:50]}...")
|
||||
logger.info(f"Expected MCP client ID: {self.mcp_client_id}")
|
||||
|
||||
# Check if token is JWT format (has 3 parts separated by dots)
|
||||
is_jwt = "." in token and token.count(".") == 2
|
||||
logger.info(f"Token format: {'JWT' if is_jwt else 'opaque'}")
|
||||
|
||||
if is_jwt:
|
||||
# Try JWT verification
|
||||
return await self._verify_jwt_token(token)
|
||||
else:
|
||||
# Fall back to introspection for opaque tokens
|
||||
return await self._verify_opaque_token(token)
|
||||
|
||||
async def _verify_jwt_token(self, token: str) -> Optional[AccessToken]:
|
||||
"""Verify JWT token by decoding payload."""
|
||||
try:
|
||||
# Decode without signature verification (IdP handles that)
|
||||
# In production, would verify signature with IdP public key
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
logger.info(f"Token payload decoded: {payload}")
|
||||
|
||||
# CRITICAL: Verify audience is for MCP server (Flow 1)
|
||||
audiences = payload.get("aud", [])
|
||||
if isinstance(audiences, str):
|
||||
audiences = [audiences]
|
||||
|
||||
# Audience validation:
|
||||
# - Accept tokens with no audience (will validate via introspection if needed)
|
||||
# - Accept tokens with MCP client ID in audience (Keycloak multi-audience)
|
||||
# - Accept tokens with resource URL in audience (Nextcloud JWT redirect URI)
|
||||
# - Reject tokens with "nextcloud" audience only (wrong flow)
|
||||
if audiences:
|
||||
# Check if MCP client ID is in the audience (Keycloak multi-audience)
|
||||
if self.mcp_client_id in audiences:
|
||||
logger.debug(
|
||||
f"Token has audience {audiences} - MCP client ID present"
|
||||
)
|
||||
# Check if this is a Nextcloud-only token (wrong flow)
|
||||
elif audiences == ["nextcloud"]:
|
||||
logger.warning(
|
||||
f"Token rejected: Nextcloud-only audience {audiences}"
|
||||
)
|
||||
logger.error(
|
||||
"Received Nextcloud token in MCP context - "
|
||||
"client may be using wrong token"
|
||||
)
|
||||
return None
|
||||
# Otherwise accept (likely resource URL audience from Nextcloud JWT)
|
||||
else:
|
||||
logger.info(
|
||||
f"Token has audience {audiences} (resource URL or non-standard) - accepting"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Token has no audience claim - accepting for MCP server validation"
|
||||
)
|
||||
|
||||
# Check expiry
|
||||
exp = payload.get("exp", 0)
|
||||
if exp < datetime.now(timezone.utc).timestamp():
|
||||
logger.warning(
|
||||
f"❌ Token expired: exp={exp}, now={datetime.now(timezone.utc).timestamp()}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Extract user info
|
||||
user_id = payload.get("sub", "unknown")
|
||||
client_id = payload.get("client_id", "unknown")
|
||||
scopes = payload.get("scope", "").split()
|
||||
exp = payload.get("exp", None)
|
||||
|
||||
logger.info(
|
||||
f"✅ Token validation successful! user={user_id}, scopes={scopes}"
|
||||
)
|
||||
|
||||
# Create AccessToken for MCP framework
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=client_id,
|
||||
scopes=scopes,
|
||||
expires_at=exp,
|
||||
resource=user_id, # Store user_id in resource field (RFC 8707)
|
||||
)
|
||||
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"❌ Invalid token (JWT decode failed): {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Token verification failed with exception: {e}")
|
||||
return None
|
||||
|
||||
async def _verify_opaque_token(self, token: str) -> Optional[AccessToken]:
|
||||
"""
|
||||
Verify opaque token via introspection endpoint (RFC 7662).
|
||||
|
||||
Args:
|
||||
token: Opaque access token
|
||||
|
||||
Returns:
|
||||
AccessToken if active and valid, None otherwise
|
||||
"""
|
||||
if not self._http_client or not self.introspection_uri:
|
||||
logger.error(
|
||||
"❌ Cannot verify opaque token - introspection not configured. "
|
||||
"Set introspection_uri and client credentials."
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
logger.info(f"Introspecting token at {self.introspection_uri}")
|
||||
|
||||
# Call introspection endpoint (requires client authentication)
|
||||
response = await self._http_client.post(
|
||||
self.introspection_uri,
|
||||
data={"token": token},
|
||||
auth=(self.mcp_client_id, self.client_secret),
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
f"❌ Introspection failed: HTTP {response.status_code} - {response.text[:200]}"
|
||||
)
|
||||
return None
|
||||
|
||||
introspection_data = response.json()
|
||||
logger.info(f"Introspection response: {introspection_data}")
|
||||
|
||||
# Check if token is active
|
||||
if not introspection_data.get("active", False):
|
||||
logger.warning("❌ Token introspection returned active=false")
|
||||
return None
|
||||
|
||||
# Extract user info
|
||||
user_id = introspection_data.get("sub") or introspection_data.get(
|
||||
"username"
|
||||
)
|
||||
if not user_id:
|
||||
logger.error("❌ No username found in introspection response")
|
||||
return None
|
||||
|
||||
# Extract scopes (space-separated string)
|
||||
scope_string = introspection_data.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
|
||||
# Extract client ID and expiration
|
||||
client_id = introspection_data.get("client_id", "unknown")
|
||||
exp = introspection_data.get("exp")
|
||||
|
||||
logger.info(f"✅ Opaque token validated! user={user_id}, scopes={scopes}")
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=client_id,
|
||||
scopes=scopes,
|
||||
expires_at=int(exp) if exp else None,
|
||||
resource=user_id,
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("❌ Timeout while introspecting token")
|
||||
return None
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"❌ Network error during introspection: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Introspection failed with exception: {e}")
|
||||
return None
|
||||
|
||||
async def check_provisioning(self, user_id: str) -> bool:
|
||||
"""
|
||||
Check if user has provisioned Nextcloud access (Flow 2).
|
||||
|
||||
Args:
|
||||
user_id: User identifier from Flow 1 token
|
||||
|
||||
Returns:
|
||||
True if user has completed Flow 2, False otherwise
|
||||
"""
|
||||
if not self.storage:
|
||||
return False
|
||||
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
return refresh_data is not None
|
||||
|
||||
async def get_nextcloud_token(self, user_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get a Nextcloud access token (aud: "nextcloud") for the user.
|
||||
|
||||
This uses the Token Broker to:
|
||||
1. Check for cached Nextcloud token
|
||||
2. If expired, refresh using stored master refresh token
|
||||
3. Return token with aud: "nextcloud" for API access
|
||||
|
||||
Args:
|
||||
user_id: User identifier from Flow 1 token
|
||||
|
||||
Returns:
|
||||
Nextcloud access token if provisioned, None otherwise
|
||||
"""
|
||||
if not self.token_broker:
|
||||
logger.error("Token broker not available")
|
||||
return None
|
||||
|
||||
# Check if user has provisioned access
|
||||
if not await self.check_provisioning(user_id):
|
||||
logger.info(f"User {user_id} has not provisioned Nextcloud access")
|
||||
return None
|
||||
|
||||
# Get or refresh Nextcloud token
|
||||
try:
|
||||
nextcloud_token = await self.token_broker.get_nextcloud_token(user_id)
|
||||
if nextcloud_token:
|
||||
logger.debug(f"Obtained Nextcloud token for user {user_id}")
|
||||
return nextcloud_token
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Nextcloud token: {e}")
|
||||
return None
|
||||
|
||||
async def validate_scopes(
|
||||
self, token: AccessToken, required_scopes: list[str]
|
||||
) -> bool:
|
||||
"""
|
||||
Validate that token has required scopes.
|
||||
|
||||
Args:
|
||||
token: The access token
|
||||
required_scopes: List of required scopes
|
||||
|
||||
Returns:
|
||||
True if all required scopes present, False otherwise
|
||||
"""
|
||||
token_scopes = set(token.scopes) if token.scopes else set()
|
||||
required = set(required_scopes)
|
||||
|
||||
missing = required - token_scopes
|
||||
if missing:
|
||||
logger.debug(f"Token missing required scopes: {missing}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def close(self):
|
||||
"""Clean up resources."""
|
||||
if self.token_broker:
|
||||
await self.token_broker.close()
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Provisioning decorator for ADR-004 Progressive Consent Architecture.
|
||||
|
||||
This decorator ensures users have completed Flow 2 (Resource Provisioning)
|
||||
before accessing Nextcloud resources.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from mcp.server.fastmcp import Context
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def require_provisioning(func: Callable) -> Callable:
|
||||
"""
|
||||
Decorator that checks if user has provisioned Nextcloud access (Flow 2).
|
||||
|
||||
This decorator:
|
||||
1. Extracts user_id from the MCP token (Flow 1)
|
||||
2. Checks if user has completed Flow 2 provisioning
|
||||
3. Returns helpful error message if not provisioned
|
||||
4. Allows access if provisioned
|
||||
|
||||
Usage:
|
||||
@mcp.tool()
|
||||
@require_provisioning
|
||||
async def list_notes(ctx: Context):
|
||||
# Tool implementation
|
||||
pass
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Extract context from arguments
|
||||
ctx = None
|
||||
for arg in args:
|
||||
if isinstance(arg, Context):
|
||||
ctx = arg
|
||||
break
|
||||
if not ctx:
|
||||
ctx = kwargs.get("ctx")
|
||||
|
||||
if not ctx:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Context not found - cannot verify provisioning",
|
||||
)
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
# Check if we're in token exchange mode - if so, skip provisioning check
|
||||
# In token exchange mode, tokens are exchanged per-request (no stored refresh tokens)
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_token_exchange:
|
||||
# Token exchange mode - per-request exchange, no provisioning needed
|
||||
logger.debug("Token exchange mode detected - skipping provisioning check")
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Progressive Consent mode (offline access) - check if user has completed Flow 2 provisioning
|
||||
# Get user_id from authorization token
|
||||
user_id = None
|
||||
if hasattr(ctx, "authorization") and ctx.authorization:
|
||||
try:
|
||||
import jwt
|
||||
|
||||
token = ctx.authorization.token
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
user_id = payload.get("sub")
|
||||
logger.debug(f"Checking provisioning for user: {user_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract user_id from token: {e}")
|
||||
|
||||
if not user_id:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Cannot determine user identity for provisioning check",
|
||||
)
|
||||
)
|
||||
|
||||
# Check provisioning status
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
refresh_data = await storage.get_refresh_token(user_id)
|
||||
|
||||
if not refresh_data:
|
||||
# User has not completed Flow 2 - provide helpful error
|
||||
logger.info(
|
||||
f"User {user_id} attempted to use Nextcloud tool without provisioning"
|
||||
)
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=(
|
||||
"Nextcloud access not provisioned. "
|
||||
"Please run the 'provision_nextcloud_access' tool first to authorize "
|
||||
"the MCP server to access Nextcloud on your behalf. "
|
||||
"This is a one-time setup required for security."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"User {user_id} has provisioned access - proceeding with tool execution"
|
||||
)
|
||||
|
||||
# User has provisioned - allow access
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def require_provisioning_or_suggest(func: Callable) -> Callable:
|
||||
"""
|
||||
Softer version that suggests provisioning but doesn't block.
|
||||
|
||||
This decorator:
|
||||
1. Checks provisioning status
|
||||
2. Logs a warning if not provisioned
|
||||
3. Still allows the function to proceed
|
||||
4. Can be used for read-only operations that might work without explicit provisioning
|
||||
|
||||
Usage:
|
||||
@mcp.tool()
|
||||
@require_provisioning_or_suggest
|
||||
async def list_tools(ctx: Context):
|
||||
# Tool implementation
|
||||
pass
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Extract context from arguments
|
||||
ctx = None
|
||||
for arg in args:
|
||||
if isinstance(arg, Context):
|
||||
ctx = arg
|
||||
break
|
||||
if not ctx:
|
||||
ctx = kwargs.get("ctx")
|
||||
|
||||
if ctx:
|
||||
# Try to check provisioning status
|
||||
try:
|
||||
# Get user_id from authorization token
|
||||
user_id = None
|
||||
if hasattr(ctx, "authorization") and ctx.authorization:
|
||||
import jwt
|
||||
|
||||
token = ctx.authorization.token
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
user_id = payload.get("sub")
|
||||
|
||||
if user_id:
|
||||
# Check provisioning status
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
refresh_data = await storage.get_refresh_token(user_id)
|
||||
|
||||
if not refresh_data:
|
||||
logger.info(
|
||||
f"User {user_id} has not provisioned Nextcloud access. "
|
||||
"Some features may not work. Consider running "
|
||||
"'provision_nextcloud_access' tool."
|
||||
)
|
||||
else:
|
||||
logger.debug(f"User {user_id} has provisioned access")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not check provisioning status: {e}")
|
||||
|
||||
# Always proceed with the function
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -1,7 +1,22 @@
|
||||
"""
|
||||
Refresh Token Storage for ADR-002 Tier 1: Offline Access
|
||||
|
||||
Securely stores and manages user refresh tokens for background operations.
|
||||
Manages two separate concerns for OAuth authentication:
|
||||
|
||||
1. **Refresh Tokens** (for background jobs ONLY)
|
||||
- Securely stores encrypted refresh tokens for offline access
|
||||
- Used ONLY by background jobs to obtain access tokens
|
||||
- NEVER used within MCP client sessions or browser sessions
|
||||
|
||||
2. **User Profile Cache** (for browser UI display ONLY)
|
||||
- Caches IdP user profile data for browser-based admin UI
|
||||
- Queried ONCE at login, displayed from cache thereafter
|
||||
- NOT used for authorization decisions or background jobs
|
||||
|
||||
IMPORTANT: These are separate concerns. Browser sessions read profile cache for
|
||||
display purposes. Background jobs use refresh tokens for API access. Never mix
|
||||
the two.
|
||||
|
||||
Tokens are encrypted at rest using Fernet symmetric encryption.
|
||||
"""
|
||||
|
||||
@@ -10,7 +25,7 @@ import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import aiosqlite
|
||||
from cryptography.fernet import Fernet
|
||||
@@ -19,7 +34,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RefreshTokenStorage:
|
||||
"""Securely store and manage user refresh tokens"""
|
||||
"""Securely store and manage user refresh tokens and profile cache.
|
||||
|
||||
This class manages two separate concerns:
|
||||
- Refresh tokens: Encrypted storage for background job access (write-only by OAuth, read-only by background jobs)
|
||||
- User profiles: Plain JSON cache for browser UI display (written at login, read by UI)
|
||||
|
||||
These concerns are architecturally separate and should never be mixed.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str, encryption_key: bytes):
|
||||
"""
|
||||
@@ -98,7 +120,16 @@ class RefreshTokenStorage:
|
||||
encrypted_token BLOB NOT NULL,
|
||||
expires_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
updated_at INTEGER NOT NULL,
|
||||
-- ADR-004 Progressive Consent fields
|
||||
flow_type TEXT DEFAULT 'hybrid', -- 'hybrid', 'flow1', 'flow2'
|
||||
token_audience TEXT DEFAULT 'nextcloud', -- 'mcp-server' or 'nextcloud'
|
||||
provisioned_at INTEGER, -- When Flow 2 was completed
|
||||
provisioning_client_id TEXT, -- Which MCP client initiated Flow 1
|
||||
scopes TEXT, -- JSON array of granted scopes
|
||||
-- Browser session profile cache
|
||||
user_profile TEXT, -- JSON cache of IdP user profile (for browser UI only)
|
||||
profile_cached_at INTEGER -- When profile was last cached
|
||||
)
|
||||
"""
|
||||
)
|
||||
@@ -142,6 +173,37 @@ class RefreshTokenStorage:
|
||||
"""
|
||||
)
|
||||
|
||||
# OAuth flow sessions (ADR-004 Progressive Consent)
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS oauth_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
client_id TEXT,
|
||||
client_redirect_uri TEXT NOT NULL,
|
||||
state TEXT,
|
||||
code_challenge TEXT,
|
||||
code_challenge_method TEXT,
|
||||
mcp_authorization_code TEXT UNIQUE,
|
||||
idp_access_token TEXT,
|
||||
idp_refresh_token TEXT,
|
||||
user_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
-- ADR-004 Progressive Consent fields
|
||||
flow_type TEXT DEFAULT 'hybrid', -- 'hybrid', 'flow1', 'flow2'
|
||||
requested_scopes TEXT, -- JSON array of requested scopes
|
||||
granted_scopes TEXT, -- JSON array of granted scopes
|
||||
is_provisioning BOOLEAN DEFAULT FALSE -- True if this is a Flow 2 provisioning session
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Create index for MCP authorization code lookups
|
||||
await db.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_oauth_sessions_mcp_code "
|
||||
"ON oauth_sessions(mcp_authorization_code)"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Set restrictive permissions after creation
|
||||
@@ -155,6 +217,10 @@ class RefreshTokenStorage:
|
||||
user_id: str,
|
||||
refresh_token: str,
|
||||
expires_at: Optional[int] = None,
|
||||
flow_type: str = "hybrid",
|
||||
token_audience: str = "nextcloud",
|
||||
provisioning_client_id: Optional[str] = None,
|
||||
scopes: Optional[list[str]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Store encrypted refresh token for user.
|
||||
@@ -163,6 +229,10 @@ class RefreshTokenStorage:
|
||||
user_id: User identifier (from OIDC 'sub' claim)
|
||||
refresh_token: Refresh token to store
|
||||
expires_at: Token expiration timestamp (Unix epoch), if known
|
||||
flow_type: Type of flow ('hybrid', 'flow1', 'flow2')
|
||||
token_audience: Token audience ('mcp-server' or 'nextcloud')
|
||||
provisioning_client_id: Client ID that initiated Flow 1
|
||||
scopes: List of granted scopes
|
||||
|
||||
"""
|
||||
if not self._initialized:
|
||||
@@ -170,15 +240,33 @@ class RefreshTokenStorage:
|
||||
|
||||
encrypted_token = self.cipher.encrypt(refresh_token.encode())
|
||||
now = int(time.time())
|
||||
scopes_json = json.dumps(scopes) if scopes else None
|
||||
|
||||
# For Flow 2, set provisioned_at timestamp
|
||||
provisioned_at = now if flow_type == "flow2" else None
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO refresh_tokens
|
||||
(user_id, encrypted_token, expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, COALESCE((SELECT created_at FROM refresh_tokens WHERE user_id = ?), ?), ?)
|
||||
(user_id, encrypted_token, expires_at, created_at, updated_at,
|
||||
flow_type, token_audience, provisioned_at, provisioning_client_id, scopes)
|
||||
VALUES (?, ?, ?, COALESCE((SELECT created_at FROM refresh_tokens WHERE user_id = ?), ?), ?,
|
||||
?, ?, ?, ?, ?)
|
||||
""",
|
||||
(user_id, encrypted_token, expires_at, user_id, now, now),
|
||||
(
|
||||
user_id,
|
||||
encrypted_token,
|
||||
expires_at,
|
||||
user_id,
|
||||
now,
|
||||
now,
|
||||
flow_type,
|
||||
token_audience,
|
||||
provisioned_at,
|
||||
provisioning_client_id,
|
||||
scopes_json,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -194,7 +282,77 @@ class RefreshTokenStorage:
|
||||
auth_method="offline_access",
|
||||
)
|
||||
|
||||
async def get_refresh_token(self, user_id: str) -> Optional[str]:
|
||||
async def store_user_profile(
|
||||
self, user_id: str, profile_data: dict[str, Any]
|
||||
) -> None:
|
||||
"""
|
||||
Store user profile data (cached from IdP userinfo endpoint).
|
||||
|
||||
This profile is cached ONLY for browser UI display purposes, not for
|
||||
authorization decisions. Background jobs should NOT rely on this data.
|
||||
|
||||
Args:
|
||||
user_id: User identifier (must match refresh_tokens.user_id)
|
||||
profile_data: User profile dict from IdP userinfo endpoint
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
profile_json = json.dumps(profile_data)
|
||||
now = int(time.time())
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE refresh_tokens
|
||||
SET user_profile = ?, profile_cached_at = ?
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(profile_json, now, user_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.debug(f"Cached user profile for {user_id}")
|
||||
|
||||
async def get_user_profile(self, user_id: str) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
Retrieve cached user profile data.
|
||||
|
||||
This returns cached profile data from the initial OAuth login,
|
||||
NOT fresh data from the IdP. Use this for browser UI display only.
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
|
||||
Returns:
|
||||
User profile dict or None if not cached
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"""
|
||||
SELECT user_profile, profile_cached_at
|
||||
FROM refresh_tokens
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row or not row[0]:
|
||||
return None
|
||||
|
||||
profile_json, cached_at = row
|
||||
profile_data = json.loads(profile_json)
|
||||
|
||||
# Optionally add cache metadata
|
||||
profile_data["_cached_at"] = cached_at
|
||||
|
||||
return profile_data
|
||||
|
||||
async def get_refresh_token(self, user_id: str) -> Optional[dict]:
|
||||
"""
|
||||
Retrieve and decrypt refresh token for user.
|
||||
|
||||
@@ -202,14 +360,28 @@ class RefreshTokenStorage:
|
||||
user_id: User identifier
|
||||
|
||||
Returns:
|
||||
Decrypted refresh token, or None if not found or expired
|
||||
Dictionary with token data including ADR-004 fields:
|
||||
{
|
||||
"refresh_token": str,
|
||||
"expires_at": int | None,
|
||||
"flow_type": str,
|
||||
"token_audience": str,
|
||||
"provisioned_at": int | None,
|
||||
"provisioning_client_id": str | None,
|
||||
"scopes": list[str] | None
|
||||
}
|
||||
or None if not found or expired
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT encrypted_token, expires_at FROM refresh_tokens WHERE user_id = ?",
|
||||
"""
|
||||
SELECT encrypted_token, expires_at, flow_type, token_audience,
|
||||
provisioned_at, provisioning_client_id, scopes
|
||||
FROM refresh_tokens WHERE user_id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
@@ -218,7 +390,15 @@ class RefreshTokenStorage:
|
||||
logger.debug(f"No refresh token found for user {user_id}")
|
||||
return None
|
||||
|
||||
encrypted_token, expires_at = row
|
||||
(
|
||||
encrypted_token,
|
||||
expires_at,
|
||||
flow_type,
|
||||
token_audience,
|
||||
provisioned_at,
|
||||
provisioning_client_id,
|
||||
scopes_json,
|
||||
) = row
|
||||
|
||||
# Check expiration
|
||||
if expires_at is not None and expires_at < time.time():
|
||||
@@ -230,8 +410,22 @@ class RefreshTokenStorage:
|
||||
|
||||
try:
|
||||
decrypted_token = self.cipher.decrypt(encrypted_token).decode()
|
||||
logger.debug(f"Retrieved refresh token for user {user_id}")
|
||||
return decrypted_token
|
||||
scopes = json.loads(scopes_json) if scopes_json else None
|
||||
|
||||
logger.debug(
|
||||
f"Retrieved refresh token for user {user_id} (flow_type: {flow_type})"
|
||||
)
|
||||
|
||||
return {
|
||||
"refresh_token": decrypted_token,
|
||||
"expires_at": expires_at,
|
||||
"flow_type": flow_type or "hybrid", # Default for existing tokens
|
||||
"token_audience": token_audience
|
||||
or "nextcloud", # Default for existing tokens
|
||||
"provisioned_at": provisioned_at,
|
||||
"provisioning_client_id": provisioning_client_id,
|
||||
"scopes": scopes,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt refresh token for user {user_id}: {e}")
|
||||
return None
|
||||
@@ -604,6 +798,234 @@ class RefreshTokenStorage:
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
async def store_oauth_session(
|
||||
self,
|
||||
session_id: str,
|
||||
client_redirect_uri: str,
|
||||
state: Optional[str] = None,
|
||||
code_challenge: Optional[str] = None,
|
||||
code_challenge_method: Optional[str] = None,
|
||||
mcp_authorization_code: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
flow_type: str = "hybrid",
|
||||
is_provisioning: bool = False,
|
||||
requested_scopes: Optional[str] = None,
|
||||
ttl_seconds: int = 600, # 10 minutes
|
||||
) -> None:
|
||||
"""
|
||||
Store OAuth session for ADR-004 Progressive Consent.
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier
|
||||
client_redirect_uri: Client's localhost redirect URI
|
||||
state: CSRF protection state parameter
|
||||
code_challenge: PKCE code challenge
|
||||
code_challenge_method: PKCE method (S256)
|
||||
mcp_authorization_code: Pre-generated MCP authorization code
|
||||
client_id: Client identifier (for Flow 1)
|
||||
flow_type: Type of flow ('hybrid', 'flow1', 'flow2')
|
||||
is_provisioning: Whether this is a Flow 2 provisioning session
|
||||
requested_scopes: Requested OAuth scopes
|
||||
ttl_seconds: Session TTL in seconds
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
now = int(time.time())
|
||||
expires_at = now + ttl_seconds
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO oauth_sessions
|
||||
(session_id, client_id, client_redirect_uri, state, code_challenge,
|
||||
code_challenge_method, mcp_authorization_code, flow_type,
|
||||
is_provisioning, requested_scopes, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
session_id,
|
||||
client_id,
|
||||
client_redirect_uri,
|
||||
state,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
mcp_authorization_code,
|
||||
flow_type,
|
||||
is_provisioning,
|
||||
requested_scopes,
|
||||
now,
|
||||
expires_at,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.debug(f"Stored OAuth session {session_id} (expires in {ttl_seconds}s)")
|
||||
|
||||
async def get_oauth_session(self, session_id: str) -> Optional[dict]:
|
||||
"""
|
||||
Retrieve OAuth session by session ID.
|
||||
|
||||
Returns:
|
||||
Session dictionary or None if not found/expired
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"SELECT * FROM oauth_sessions WHERE session_id = ?", (session_id,)
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
session = dict(row)
|
||||
|
||||
# Check expiration
|
||||
if session["expires_at"] < time.time():
|
||||
logger.debug(f"OAuth session {session_id} has expired")
|
||||
await self.delete_oauth_session(session_id)
|
||||
return None
|
||||
|
||||
return session
|
||||
|
||||
async def get_oauth_session_by_mcp_code(
|
||||
self, mcp_authorization_code: str
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Retrieve OAuth session by MCP authorization code.
|
||||
|
||||
Returns:
|
||||
Session dictionary or None if not found/expired
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"SELECT * FROM oauth_sessions WHERE mcp_authorization_code = ?",
|
||||
(mcp_authorization_code,),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
session = dict(row)
|
||||
|
||||
# Check expiration
|
||||
if session["expires_at"] < time.time():
|
||||
logger.debug(
|
||||
f"OAuth session with MCP code {mcp_authorization_code[:16]}... has expired"
|
||||
)
|
||||
await self.delete_oauth_session(session["session_id"])
|
||||
return None
|
||||
|
||||
return session
|
||||
|
||||
async def update_oauth_session(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
idp_access_token: Optional[str] = None,
|
||||
idp_refresh_token: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Update OAuth session with IdP token data.
|
||||
|
||||
Returns:
|
||||
True if session was updated, False if not found
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
update_fields = []
|
||||
params = []
|
||||
|
||||
if user_id is not None:
|
||||
update_fields.append("user_id = ?")
|
||||
params.append(user_id)
|
||||
|
||||
if idp_access_token is not None:
|
||||
update_fields.append("idp_access_token = ?")
|
||||
params.append(idp_access_token)
|
||||
|
||||
if idp_refresh_token is not None:
|
||||
update_fields.append("idp_refresh_token = ?")
|
||||
params.append(idp_refresh_token)
|
||||
|
||||
if not update_fields:
|
||||
return False
|
||||
|
||||
params.append(session_id)
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
f"""
|
||||
UPDATE oauth_sessions
|
||||
SET {", ".join(update_fields)}
|
||||
WHERE session_id = ?
|
||||
""",
|
||||
params,
|
||||
)
|
||||
await db.commit()
|
||||
updated = cursor.rowcount > 0
|
||||
|
||||
if updated:
|
||||
logger.debug(f"Updated OAuth session {session_id}")
|
||||
|
||||
return updated
|
||||
|
||||
async def delete_oauth_session(self, session_id: str) -> bool:
|
||||
"""
|
||||
Delete OAuth session.
|
||||
|
||||
Returns:
|
||||
True if session was deleted, False if not found
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM oauth_sessions WHERE session_id = ?", (session_id,)
|
||||
)
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount > 0
|
||||
|
||||
if deleted:
|
||||
logger.debug(f"Deleted OAuth session {session_id}")
|
||||
|
||||
return deleted
|
||||
|
||||
async def cleanup_expired_sessions(self) -> int:
|
||||
"""
|
||||
Remove expired OAuth sessions from storage.
|
||||
|
||||
Returns:
|
||||
Number of sessions deleted
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
now = int(time.time())
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM oauth_sessions WHERE expires_at < ?", (now,)
|
||||
)
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount
|
||||
|
||||
if deleted > 0:
|
||||
logger.info(f"Cleaned up {deleted} expired OAuth session(s)")
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
async def generate_encryption_key() -> str:
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Scope-based authorization for MCP tools."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
from typing import Any, Callable
|
||||
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
@@ -33,6 +34,23 @@ class InsufficientScopeError(ScopeAuthorizationError):
|
||||
)
|
||||
|
||||
|
||||
class ProvisioningRequiredError(ScopeAuthorizationError):
|
||||
"""Raised when Nextcloud resource access requires provisioning (Flow 2).
|
||||
|
||||
In Progressive Consent mode, users must explicitly provision Nextcloud
|
||||
access using the provision_nextcloud_access MCP tool.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str | None = None):
|
||||
super().__init__(
|
||||
message
|
||||
or (
|
||||
"Nextcloud resource access not provisioned. "
|
||||
"Please run the 'provision_nextcloud_access' tool to grant access."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def require_scopes(*required_scopes: str):
|
||||
"""
|
||||
Decorator to require specific OAuth scopes for MCP tool execution.
|
||||
@@ -70,15 +88,18 @@ def require_scopes(*required_scopes: str):
|
||||
ScopeAuthorizationError: If required scopes are not present in the access token
|
||||
"""
|
||||
|
||||
def decorator(func: Callable):
|
||||
def decorator(func: Callable) -> Callable:
|
||||
# Store scope requirements as function metadata for dynamic filtering
|
||||
func._required_scopes = list(required_scopes) # type: ignore
|
||||
func._required_scopes = list(required_scopes) # type: ignore[attr-defined]
|
||||
|
||||
# Get function name for logging (works for any callable)
|
||||
func_name = getattr(func, "__name__", repr(func))
|
||||
|
||||
# Find which parameter receives the Context (FastMCP injects it by name)
|
||||
context_param_name = find_context_parameter(func)
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
# Extract context from kwargs (where FastMCP injected it)
|
||||
ctx: Context | None = (
|
||||
kwargs.get(context_param_name) if context_param_name else None
|
||||
@@ -88,7 +109,7 @@ def require_scopes(*required_scopes: str):
|
||||
# No context parameter found - likely BasicAuth mode
|
||||
# In BasicAuth mode, all operations are allowed
|
||||
logger.debug(
|
||||
f"No context parameter for {func.__name__} - allowing (BasicAuth mode)"
|
||||
f"No context parameter for {func_name} - allowing (BasicAuth mode)"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
@@ -101,7 +122,7 @@ def require_scopes(*required_scopes: str):
|
||||
# Not in OAuth mode (BasicAuth or no auth)
|
||||
# In BasicAuth mode, all operations are allowed
|
||||
logger.debug(
|
||||
f"No access token present for {func.__name__} - allowing (BasicAuth mode)"
|
||||
f"No access token present for {func_name} - allowing (BasicAuth mode)"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
@@ -109,11 +130,63 @@ def require_scopes(*required_scopes: str):
|
||||
token_scopes = set(access_token.scopes or [])
|
||||
required_scopes_set = set(required_scopes)
|
||||
|
||||
# Check if Progressive Consent is enabled
|
||||
enable_progressive = (
|
||||
os.getenv("ENABLE_PROGRESSIVE_CONSENT", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# In Progressive Consent mode, check if Nextcloud scopes require provisioning
|
||||
if enable_progressive:
|
||||
# Check if any required scopes are Nextcloud-specific
|
||||
nextcloud_scopes = [
|
||||
s
|
||||
for s in required_scopes
|
||||
if any(
|
||||
s.startswith(prefix)
|
||||
for prefix in [
|
||||
"notes:",
|
||||
"calendar:",
|
||||
"contacts:",
|
||||
"files:",
|
||||
"tables:",
|
||||
"deck:",
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
if nextcloud_scopes:
|
||||
# Check if user has completed Flow 2 provisioning
|
||||
# This would be indicated by having a stored refresh token
|
||||
# In production, we'd check the token broker or storage
|
||||
# For now, we check if the token has the required scopes
|
||||
# (Flow 1 tokens won't have Nextcloud scopes)
|
||||
has_nextcloud_scopes = any(
|
||||
s.startswith(prefix)
|
||||
for s in token_scopes
|
||||
for prefix in [
|
||||
"notes:",
|
||||
"calendar:",
|
||||
"contacts:",
|
||||
"files:",
|
||||
"tables:",
|
||||
"deck:",
|
||||
]
|
||||
)
|
||||
|
||||
if not has_nextcloud_scopes:
|
||||
error_msg = (
|
||||
f"Access denied to {func_name}: "
|
||||
f"Nextcloud resource access not provisioned. "
|
||||
f"Please run the 'provision_nextcloud_access' tool first."
|
||||
)
|
||||
logger.warning(error_msg)
|
||||
raise ProvisioningRequiredError(error_msg)
|
||||
|
||||
# Check if all required scopes are present
|
||||
missing_scopes = required_scopes_set - token_scopes
|
||||
if missing_scopes:
|
||||
error_msg = (
|
||||
f"Access denied to {func.__name__}: "
|
||||
f"Access denied to {func_name}: "
|
||||
f"Missing required scopes: {', '.join(sorted(missing_scopes))}. "
|
||||
f"Token has scopes: {', '.join(sorted(token_scopes)) if token_scopes else 'none'}"
|
||||
)
|
||||
@@ -122,7 +195,7 @@ def require_scopes(*required_scopes: str):
|
||||
|
||||
# All required scopes present - allow execution
|
||||
logger.debug(
|
||||
f"Scope authorization passed for {func.__name__}: {required_scopes}"
|
||||
f"Scope authorization passed for {func_name}: {required_scopes}"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Session-based authentication backend for Starlette routes.
|
||||
|
||||
Provides browser-based authentication for admin UI routes, separate from
|
||||
MCP's OAuth authentication flow.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from starlette.authentication import (
|
||||
AuthCredentials,
|
||||
AuthenticationBackend,
|
||||
SimpleUser,
|
||||
)
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SessionAuthBackend(AuthenticationBackend):
|
||||
"""Authentication backend using signed session cookies.
|
||||
|
||||
For BasicAuth mode: Always authenticates as the configured user.
|
||||
For OAuth mode: Checks for valid session cookie with stored refresh token.
|
||||
"""
|
||||
|
||||
def __init__(self, oauth_enabled: bool = False):
|
||||
"""Initialize session authentication backend.
|
||||
|
||||
Args:
|
||||
oauth_enabled: Whether OAuth mode is enabled
|
||||
"""
|
||||
self.oauth_enabled = oauth_enabled
|
||||
|
||||
async def authenticate(
|
||||
self, conn: HTTPConnection
|
||||
) -> tuple[AuthCredentials, SimpleUser] | None:
|
||||
"""Authenticate the request based on session cookie or BasicAuth mode.
|
||||
|
||||
This backend is only applied to browser routes (/user/*) via a separate
|
||||
Starlette app mount. FastMCP routes use their own OAuth Bearer token
|
||||
authentication.
|
||||
|
||||
Args:
|
||||
conn: HTTP connection
|
||||
|
||||
Returns:
|
||||
Tuple of (credentials, user) if authenticated, None otherwise
|
||||
"""
|
||||
# BasicAuth mode: Always authenticated as the configured user
|
||||
if not self.oauth_enabled:
|
||||
username = os.getenv("NEXTCLOUD_USERNAME", "admin")
|
||||
return AuthCredentials(["authenticated", "admin"]), SimpleUser(username)
|
||||
|
||||
# OAuth mode: Check for session cookie
|
||||
session_id = conn.cookies.get("mcp_session")
|
||||
logger.info(
|
||||
f"Session authentication check - cookie present: {session_id is not None}, path: {conn.url.path}"
|
||||
)
|
||||
if not session_id:
|
||||
logger.info("No session cookie found - redirecting to login")
|
||||
return None
|
||||
|
||||
logger.info(f"Found session cookie: {session_id[:16]}...")
|
||||
|
||||
# Get OAuth context from app state
|
||||
oauth_context = getattr(conn.app.state, "oauth_context", None)
|
||||
if not oauth_context:
|
||||
logger.warning("OAuth context not available in app state")
|
||||
return None
|
||||
|
||||
# Validate session
|
||||
storage = oauth_context.get("storage")
|
||||
if not storage:
|
||||
logger.warning("OAuth storage not available")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Check if user has refresh token (indicates logged-in session)
|
||||
logger.info(f"Looking up refresh token for session: {session_id[:16]}...")
|
||||
token_data = await storage.get_refresh_token(session_id)
|
||||
if not token_data:
|
||||
logger.warning(
|
||||
f"No refresh token found for session {session_id[:16]}..."
|
||||
)
|
||||
return None
|
||||
|
||||
# Session is valid - use session_id (which is user_id from ID token) as username
|
||||
username = session_id
|
||||
logger.info(f"✓ Session authenticated successfully: {username[:16]}...")
|
||||
|
||||
return AuthCredentials(["authenticated"]), SimpleUser(username)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Session validation error: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,588 @@
|
||||
"""
|
||||
Token Broker Service for ADR-004 Progressive Consent Architecture.
|
||||
|
||||
This service manages the lifecycle of Nextcloud access tokens, implementing
|
||||
the dual OAuth flow pattern where:
|
||||
1. MCP clients authenticate to MCP server with aud:"mcp-server" tokens
|
||||
2. MCP server uses stored refresh tokens to obtain aud:"nextcloud" tokens
|
||||
|
||||
The Token Broker provides:
|
||||
- Automatic token refresh when expired
|
||||
- Short-lived token caching (5-minute TTL)
|
||||
- Master refresh token rotation
|
||||
- Audience-specific token validation
|
||||
- Session vs background token separation (RFC 8693)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
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__)
|
||||
|
||||
|
||||
class TokenCache:
|
||||
"""In-memory cache for short-lived Nextcloud access tokens."""
|
||||
|
||||
def __init__(self, ttl_seconds: int = 300, early_refresh_seconds: int = 30):
|
||||
"""
|
||||
Initialize the token cache.
|
||||
|
||||
Args:
|
||||
ttl_seconds: Default TTL for cached tokens (5 minutes default)
|
||||
early_refresh_seconds: How many seconds before expiry to trigger early refresh (30s default)
|
||||
"""
|
||||
self._cache: Dict[str, Tuple[str, datetime]] = {}
|
||||
self._ttl = timedelta(seconds=ttl_seconds)
|
||||
self._early_refresh = timedelta(seconds=early_refresh_seconds)
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get(self, user_id: str) -> Optional[str]:
|
||||
"""Get cached token if valid."""
|
||||
async with self._lock:
|
||||
if user_id not in self._cache:
|
||||
return None
|
||||
|
||||
token, expiry = self._cache[user_id]
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Check if token has expired
|
||||
if now >= expiry:
|
||||
del self._cache[user_id]
|
||||
logger.debug(f"Cached token expired for user {user_id}")
|
||||
return None
|
||||
|
||||
# Check if token will expire soon (refresh early)
|
||||
if now >= expiry - self._early_refresh:
|
||||
logger.debug(f"Cached token expiring soon for user {user_id}")
|
||||
return None
|
||||
|
||||
logger.debug(f"Using cached token for user {user_id}")
|
||||
return token
|
||||
|
||||
async def set(self, user_id: str, token: str, expires_in: int | None = None):
|
||||
"""Store token in cache."""
|
||||
async with self._lock:
|
||||
# Use provided expiry or default TTL
|
||||
if expires_in:
|
||||
expiry = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
||||
else:
|
||||
expiry = datetime.now(timezone.utc) + self._ttl
|
||||
|
||||
self._cache[user_id] = (token, expiry)
|
||||
logger.debug(f"Cached token for user {user_id} until {expiry}")
|
||||
|
||||
async def invalidate(self, user_id: str):
|
||||
"""Remove token from cache."""
|
||||
async with self._lock:
|
||||
if user_id in self._cache:
|
||||
del self._cache[user_id]
|
||||
logger.debug(f"Invalidated cached token for user {user_id}")
|
||||
|
||||
|
||||
class TokenBrokerService:
|
||||
"""
|
||||
Manages token lifecycle for the Progressive Consent architecture.
|
||||
|
||||
This service handles:
|
||||
- Getting or refreshing Nextcloud access tokens
|
||||
- Managing a short-lived token cache
|
||||
- Refreshing master refresh tokens periodically
|
||||
- Validating token audiences
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage: RefreshTokenStorage,
|
||||
oidc_discovery_url: str,
|
||||
nextcloud_host: str,
|
||||
encryption_key: str,
|
||||
cache_ttl: int = 300,
|
||||
cache_early_refresh: int = 30,
|
||||
):
|
||||
"""
|
||||
Initialize the Token Broker Service.
|
||||
|
||||
Args:
|
||||
storage: Database storage for refresh tokens
|
||||
oidc_discovery_url: OIDC provider discovery URL
|
||||
nextcloud_host: Nextcloud server URL
|
||||
encryption_key: Fernet key for token encryption
|
||||
cache_ttl: Cache TTL in seconds (default: 5 minutes)
|
||||
cache_early_refresh: Early refresh threshold in seconds (default: 30 seconds)
|
||||
"""
|
||||
self.storage = storage
|
||||
self.oidc_discovery_url = oidc_discovery_url
|
||||
self.nextcloud_host = nextcloud_host
|
||||
self.fernet = Fernet(
|
||||
encryption_key.encode()
|
||||
if isinstance(encryption_key, str)
|
||||
else encryption_key
|
||||
)
|
||||
self.cache = TokenCache(cache_ttl, cache_early_refresh)
|
||||
self._oidc_config = None
|
||||
self._http_client = None
|
||||
|
||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create HTTP client."""
|
||||
if self._http_client is None:
|
||||
self._http_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(30.0), follow_redirects=True
|
||||
)
|
||||
return self._http_client
|
||||
|
||||
async def _get_oidc_config(self) -> dict:
|
||||
"""Get OIDC configuration from discovery endpoint."""
|
||||
if self._oidc_config is None:
|
||||
client = await self._get_http_client()
|
||||
response = await client.get(self.oidc_discovery_url)
|
||||
response.raise_for_status()
|
||||
self._oidc_config = response.json()
|
||||
return self._oidc_config
|
||||
|
||||
async def get_nextcloud_token(self, user_id: str) -> Optional[str]:
|
||||
"""
|
||||
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
|
||||
3. If refresh token exists, obtains new access token
|
||||
4. Caches the new token for future requests
|
||||
|
||||
Args:
|
||||
user_id: The user identifier
|
||||
|
||||
Returns:
|
||||
Valid Nextcloud access token or None if not provisioned
|
||||
"""
|
||||
# Check cache first
|
||||
cached_token = await self.cache.get(user_id)
|
||||
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()
|
||||
|
||||
# Exchange refresh token for new access token
|
||||
access_token, expires_in = await self._refresh_access_token(refresh_token)
|
||||
|
||||
# Cache the new token
|
||||
await self.cache.set(user_id, access_token, expires_in)
|
||||
|
||||
return access_token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Nextcloud token for user {user_id}: {e}")
|
||||
# Invalidate cache on error
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
# Request new access token using refresh token
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"scope": "openid profile email notes:read notes:write calendar:read calendar:write",
|
||||
}
|
||||
|
||||
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 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 (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.
|
||||
|
||||
Args:
|
||||
token: JWT token to validate
|
||||
expected_audience: Expected audience value
|
||||
|
||||
Raises:
|
||||
ValueError: If audience doesn't match
|
||||
"""
|
||||
try:
|
||||
# Decode without verification to check claims
|
||||
# In production, should verify signature
|
||||
claims = jwt.decode(token, options={"verify_signature": False})
|
||||
|
||||
audience = claims.get("aud", [])
|
||||
if isinstance(audience, str):
|
||||
audience = [audience]
|
||||
|
||||
if expected_audience not in audience:
|
||||
raise ValueError(
|
||||
f"Token audience {audience} doesn't include {expected_audience}"
|
||||
)
|
||||
|
||||
except jwt.DecodeError as e:
|
||||
# Token might be opaque, skip validation
|
||||
logger.debug(f"Cannot decode token for audience validation: {e}")
|
||||
|
||||
async def refresh_master_token(self, user_id: str) -> bool:
|
||||
"""
|
||||
Refresh the master refresh token (periodic rotation).
|
||||
|
||||
This should be called periodically (e.g., daily) to rotate
|
||||
refresh tokens for security.
|
||||
|
||||
Args:
|
||||
user_id: The user identifier
|
||||
|
||||
Returns:
|
||||
True if refresh successful, False otherwise
|
||||
"""
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
if not refresh_data:
|
||||
logger.warning(f"No refresh token to rotate for user {user_id}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Decrypt current refresh token
|
||||
encrypted_token = refresh_data["refresh_token"]
|
||||
current_refresh_token = self.fernet.decrypt(
|
||||
encrypted_token.encode()
|
||||
).decode()
|
||||
|
||||
# Get OIDC configuration
|
||||
config = await self._get_oidc_config()
|
||||
token_endpoint = config["token_endpoint"]
|
||||
|
||||
client = await self._get_http_client()
|
||||
|
||||
# Request new refresh token
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": current_refresh_token,
|
||||
"scope": "openid profile email offline_access notes:read notes:write calendar:read calendar:write",
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
token_endpoint,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Master token refresh failed: {response.status_code}")
|
||||
return False
|
||||
|
||||
token_data = response.json()
|
||||
new_refresh_token = token_data.get("refresh_token")
|
||||
|
||||
if new_refresh_token and new_refresh_token != current_refresh_token:
|
||||
# Encrypt and store new refresh token
|
||||
encrypted_new = self.fernet.encrypt(new_refresh_token.encode()).decode()
|
||||
await self.storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
refresh_token=encrypted_new,
|
||||
expires_at=datetime.now(timezone.utc)
|
||||
+ timedelta(days=90), # 90-day expiry
|
||||
)
|
||||
logger.info(f"Rotated master refresh token for user {user_id}")
|
||||
|
||||
# Invalidate cached access token
|
||||
await self.cache.invalidate(user_id)
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh master token for user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
async def has_nextcloud_provisioning(self, user_id: str) -> bool:
|
||||
"""
|
||||
Check if user has provisioned Nextcloud access (Flow 2).
|
||||
|
||||
Args:
|
||||
user_id: The user identifier
|
||||
|
||||
Returns:
|
||||
True if user has stored refresh token, False otherwise
|
||||
"""
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
return refresh_data is not None
|
||||
|
||||
async def revoke_nextcloud_access(self, user_id: str) -> bool:
|
||||
"""
|
||||
Revoke stored Nextcloud access for a user.
|
||||
|
||||
This removes stored refresh tokens and clears cache.
|
||||
|
||||
Args:
|
||||
user_id: The user identifier
|
||||
|
||||
Returns:
|
||||
True if revocation successful
|
||||
"""
|
||||
try:
|
||||
# Get refresh token for revocation at IdP
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
if refresh_data:
|
||||
try:
|
||||
# Attempt to revoke at IdP
|
||||
encrypted_token = refresh_data["refresh_token"]
|
||||
refresh_token = self.fernet.decrypt(
|
||||
encrypted_token.encode()
|
||||
).decode()
|
||||
await self._revoke_token_at_idp(refresh_token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to revoke at IdP: {e}")
|
||||
|
||||
# Remove from storage
|
||||
await self.storage.delete_refresh_token(user_id)
|
||||
|
||||
# Clear cache
|
||||
await self.cache.invalidate(user_id)
|
||||
|
||||
logger.info(f"Revoked Nextcloud access for user {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to revoke access for user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _revoke_token_at_idp(self, token: str):
|
||||
"""Revoke token at the IdP if revocation endpoint exists."""
|
||||
config = await self._get_oidc_config()
|
||||
revocation_endpoint = config.get("revocation_endpoint")
|
||||
|
||||
if not revocation_endpoint:
|
||||
logger.debug("No revocation endpoint available")
|
||||
return
|
||||
|
||||
client = await self._get_http_client()
|
||||
|
||||
data = {"token": token, "token_type_hint": "refresh_token"}
|
||||
|
||||
response = await client.post(
|
||||
revocation_endpoint,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info("Token revoked at IdP")
|
||||
else:
|
||||
logger.warning(f"Token revocation returned {response.status_code}")
|
||||
|
||||
async def close(self):
|
||||
"""Clean up resources."""
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
@@ -0,0 +1,595 @@
|
||||
"""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 Grant Type
|
||||
TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type: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
|
||||
|
||||
# Storage for Progressive Consent (refresh tokens) - only needed for delegation
|
||||
# NOT needed for pure RFC 8693 exchange (MCP tools)
|
||||
self.storage: Optional[RefreshTokenStorage] = None
|
||||
|
||||
# Create HTTP client
|
||||
self.http_client = httpx.AsyncClient(
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
if self.storage:
|
||||
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 _ensure_storage(self):
|
||||
"""Lazily initialize storage for Progressive Consent operations.
|
||||
|
||||
Only needed for delegation operations that use refresh tokens.
|
||||
NOT needed for pure RFC 8693 exchange (MCP tools).
|
||||
"""
|
||||
if self.storage is None:
|
||||
self.storage = RefreshTokenStorage.from_env()
|
||||
await self.storage.initialize()
|
||||
|
||||
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, # type: ignore[arg-type]
|
||||
"/.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 exchange_token_for_audience(
|
||||
self,
|
||||
subject_token: str,
|
||||
requested_audience: str = "nextcloud",
|
||||
requested_scopes: list[str] | None = None,
|
||||
) -> Tuple[str, int]:
|
||||
"""
|
||||
Pure RFC 8693 token exchange (no refresh tokens required).
|
||||
|
||||
This implements stateless per-request token exchange where:
|
||||
1. Client token has aud: <client-id> (e.g., "nextcloud-mcp-server")
|
||||
2. Exchange for token with aud: "nextcloud" (for API access)
|
||||
3. NO refresh tokens or provisioning required
|
||||
|
||||
Use case: All MCP tool calls (request-time operations).
|
||||
NOT for background jobs (which use refresh tokens separately).
|
||||
|
||||
Args:
|
||||
subject_token: Token being exchanged (from MCP client)
|
||||
requested_audience: Target audience (usually "nextcloud")
|
||||
requested_scopes: Optional scopes (may not be supported by all IdPs)
|
||||
|
||||
Returns:
|
||||
Tuple of (access_token, expires_in)
|
||||
|
||||
Raises:
|
||||
ValueError: If token validation fails
|
||||
RuntimeError: If exchange fails
|
||||
"""
|
||||
# 1. Validate subject token (accepts both "mcp-server" and client_id)
|
||||
await self._validate_flow1_token(subject_token)
|
||||
|
||||
# 2. Extract user ID for logging
|
||||
user_id = self._extract_user_id(subject_token)
|
||||
|
||||
# 3. 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")
|
||||
|
||||
# 4. Build pure RFC 8693 exchange request (subject_token ONLY)
|
||||
data = {
|
||||
"grant_type": self.TOKEN_EXCHANGE_GRANT,
|
||||
"subject_token": subject_token,
|
||||
"subject_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
|
||||
"requested_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
|
||||
"audience": requested_audience,
|
||||
}
|
||||
|
||||
# Add scopes if provided (may not be supported by all providers)
|
||||
if requested_scopes:
|
||||
data["scope"] = " ".join(requested_scopes)
|
||||
|
||||
# Add client credentials
|
||||
if self.client_id and self.client_secret:
|
||||
data["client_id"] = self.client_id
|
||||
data["client_secret"] = self.client_secret
|
||||
|
||||
try:
|
||||
# Perform exchange
|
||||
logger.debug(f"Exchanging token for audience={requested_audience}")
|
||||
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)
|
||||
|
||||
if not access_token:
|
||||
raise RuntimeError("No access token in exchange response")
|
||||
|
||||
logger.info(
|
||||
f"Pure RFC 8693 token exchange successful for user {user_id}: "
|
||||
f"audience={requested_audience}, expires_in={expires_in}s"
|
||||
)
|
||||
|
||||
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 _validate_flow1_token(self, token: str):
|
||||
"""Validate that token has correct audience for MCP server.
|
||||
|
||||
Accepts either:
|
||||
- "mcp-server" (Progressive Consent legacy)
|
||||
- self.client_id (external IdP, e.g., "nextcloud-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]
|
||||
|
||||
# Accept either "mcp-server" (Progressive Consent) or client_id (external IdP)
|
||||
valid_audiences = ["mcp-server"]
|
||||
if self.client_id:
|
||||
valid_audiences.append(self.client_id)
|
||||
|
||||
if not any(aud in audience for aud in valid_audiences):
|
||||
raise ValueError(
|
||||
f"Invalid token audience. Expected one of {valid_audiences}, 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
|
||||
"""
|
||||
await self._ensure_storage()
|
||||
assert self.storage is not None # _ensure_storage() ensures this
|
||||
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
|
||||
"""
|
||||
await self._ensure_storage()
|
||||
assert self.storage is not None # _ensure_storage() ensures this
|
||||
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.
|
||||
|
||||
Note: Storage is initialized lazily only when needed for delegation operations.
|
||||
Pure RFC 8693 exchange (MCP tools) doesn't require storage.
|
||||
|
||||
Returns:
|
||||
TokenExchangeService instance
|
||||
"""
|
||||
global _token_exchange_service
|
||||
|
||||
if _token_exchange_service is None:
|
||||
_token_exchange_service = TokenExchangeService()
|
||||
# Storage is initialized lazily via _ensure_storage() when needed
|
||||
|
||||
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 (Progressive Consent with refresh tokens).
|
||||
|
||||
NOTE: This is for background jobs only. For MCP tool calls, use exchange_token_for_audience().
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
async def exchange_token_for_audience(
|
||||
subject_token: str,
|
||||
requested_audience: str = "nextcloud",
|
||||
requested_scopes: list[str] | None = None,
|
||||
) -> Tuple[str, int]:
|
||||
"""Convenience function for pure RFC 8693 token exchange (no refresh tokens).
|
||||
|
||||
Use this for ALL MCP tool calls (request-time operations).
|
||||
|
||||
Args:
|
||||
subject_token: Token being exchanged (from MCP client)
|
||||
requested_audience: Target audience (usually "nextcloud")
|
||||
requested_scopes: Optional scopes (may not be supported by all IdPs)
|
||||
|
||||
Returns:
|
||||
Tuple of (access_token, expires_in)
|
||||
"""
|
||||
service = await get_token_exchange_service()
|
||||
return await service.exchange_token_for_audience(
|
||||
subject_token=subject_token,
|
||||
requested_audience=requested_audience,
|
||||
requested_scopes=requested_scopes,
|
||||
)
|
||||
@@ -165,6 +165,7 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
"""
|
||||
try:
|
||||
# Get signing key from JWKS
|
||||
assert self._jwks_client is not None # Caller should check before calling
|
||||
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
||||
|
||||
# Verify and decode JWT
|
||||
@@ -257,7 +258,7 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
try:
|
||||
# Introspection requires client authentication
|
||||
response = await self._client.post(
|
||||
self.introspection_uri,
|
||||
self.introspection_uri, # type: ignore
|
||||
data={"token": token},
|
||||
auth=(self.client_id, self.client_secret),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
"""User info routes for the MCP server admin UI.
|
||||
|
||||
Provides browser-based endpoints to view information about the currently
|
||||
authenticated user. Uses session-based authentication with OAuth flow.
|
||||
|
||||
For BasicAuth mode: Shows configured user info (no login needed).
|
||||
For OAuth mode: Requires browser-based OAuth login to establish session.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from starlette.authentication import requires
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
|
||||
"""Get the correct userinfo endpoint based on OAuth mode.
|
||||
|
||||
Args:
|
||||
oauth_ctx: OAuth context from app.state
|
||||
|
||||
Returns:
|
||||
Userinfo endpoint URL, or None if unavailable
|
||||
"""
|
||||
oauth_client = oauth_ctx.get("oauth_client")
|
||||
|
||||
# External IdP mode (Keycloak): use oauth_client's userinfo endpoint
|
||||
if oauth_client:
|
||||
# Ensure discovery has been performed
|
||||
if not oauth_client.userinfo_endpoint:
|
||||
try:
|
||||
await oauth_client.discover()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to discover IdP endpoints: {e}")
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"Using external IdP userinfo endpoint: {oauth_client.userinfo_endpoint}"
|
||||
)
|
||||
return oauth_client.userinfo_endpoint
|
||||
|
||||
# Integrated mode (Nextcloud): query discovery document
|
||||
oauth_config = oauth_ctx.get("config")
|
||||
if not oauth_config:
|
||||
return None
|
||||
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if not discovery_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
userinfo_endpoint = discovery.get("userinfo_endpoint")
|
||||
|
||||
if userinfo_endpoint:
|
||||
logger.debug(
|
||||
f"Using Nextcloud userinfo endpoint from discovery: {userinfo_endpoint}"
|
||||
)
|
||||
return userinfo_endpoint
|
||||
|
||||
logger.warning("No userinfo_endpoint in discovery document")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to query discovery document for userinfo endpoint: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _query_idp_userinfo(
|
||||
access_token_str: str, userinfo_uri: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""Query the IdP's userinfo endpoint.
|
||||
|
||||
Args:
|
||||
access_token_str: The access token string
|
||||
userinfo_uri: The userinfo endpoint URI
|
||||
|
||||
Returns:
|
||||
User info dictionary from IdP, or None if query fails
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
userinfo_uri,
|
||||
headers={"Authorization": f"Bearer {access_token_str}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to query IdP userinfo endpoint: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _get_user_info(request: Request) -> dict[str, Any]:
|
||||
"""Get user information for the currently authenticated user.
|
||||
|
||||
IMPORTANT: This function reads from cached profile data stored at login time.
|
||||
It does NOT perform token refresh or query the IdP on every request. The
|
||||
profile was cached once during oauth_login_callback and is displayed from
|
||||
storage thereafter.
|
||||
|
||||
This is for BROWSER UI DISPLAY ONLY. Do not use this for authorization
|
||||
decisions or background job authentication.
|
||||
|
||||
Args:
|
||||
request: Starlette request object (must be authenticated)
|
||||
|
||||
Returns:
|
||||
Dictionary containing user information from cache
|
||||
"""
|
||||
username = request.user.display_name
|
||||
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
||||
|
||||
# BasicAuth mode
|
||||
if not oauth_ctx:
|
||||
return {
|
||||
"username": username,
|
||||
"auth_mode": "basic",
|
||||
"nextcloud_host": os.getenv("NEXTCLOUD_HOST", "unknown"),
|
||||
}
|
||||
|
||||
# OAuth mode - read cached profile from browser session
|
||||
storage = oauth_ctx.get("storage")
|
||||
session_id = request.cookies.get("mcp_session")
|
||||
|
||||
if not storage or not session_id:
|
||||
return {
|
||||
"error": "Session not found",
|
||||
"username": username,
|
||||
"auth_mode": "oauth",
|
||||
}
|
||||
|
||||
try:
|
||||
# Check if background access was granted (refresh token exists)
|
||||
token_data = await storage.get_refresh_token(session_id)
|
||||
background_access_granted = token_data is not None
|
||||
|
||||
# Retrieve cached user profile (no token operations!)
|
||||
profile_data = await storage.get_user_profile(session_id)
|
||||
|
||||
# Build user context
|
||||
user_context = {
|
||||
"username": username, # From request.user.display_name (session_id)
|
||||
"auth_mode": "oauth",
|
||||
"session_id": session_id[:16] + "...", # Truncated for security
|
||||
"background_access_granted": background_access_granted,
|
||||
}
|
||||
|
||||
# Include cached profile if available
|
||||
if profile_data:
|
||||
user_context["idp_profile"] = profile_data
|
||||
logger.debug(f"Loaded cached profile for {session_id[:16]}...")
|
||||
else:
|
||||
logger.warning(f"No cached profile found for {session_id[:16]}...")
|
||||
user_context["idp_profile_error"] = (
|
||||
"Profile not cached. Try logging out and back in."
|
||||
)
|
||||
|
||||
return user_context
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
logger.error(f"Error retrieving user info: {e}")
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
return {
|
||||
"error": f"Failed to retrieve user info: {e}",
|
||||
"username": username,
|
||||
"auth_mode": "oauth",
|
||||
}
|
||||
|
||||
|
||||
@requires("authenticated", redirect="oauth_login")
|
||||
async def user_info_json(request: Request) -> JSONResponse:
|
||||
"""User info endpoint - returns JSON with current user information.
|
||||
|
||||
Requires authentication via session cookie (redirects to oauth_login route if not authenticated).
|
||||
|
||||
Args:
|
||||
request: Starlette request object
|
||||
|
||||
Returns:
|
||||
JSON response with user information
|
||||
"""
|
||||
user_info = await _get_user_info(request)
|
||||
return JSONResponse(user_info)
|
||||
|
||||
|
||||
@requires("authenticated", redirect="oauth_login")
|
||||
async def user_info_html(request: Request) -> HTMLResponse:
|
||||
"""User info page - returns HTML with current user information.
|
||||
|
||||
Requires authentication via session cookie (redirects to oauth_login route if not authenticated).
|
||||
|
||||
Args:
|
||||
request: Starlette request object
|
||||
|
||||
Returns:
|
||||
HTML response with formatted user information
|
||||
"""
|
||||
user_context = await _get_user_info(request)
|
||||
|
||||
# Check for error
|
||||
if "error" in user_context and user_context["error"] != "":
|
||||
# Get login URL dynamically
|
||||
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
||||
login_url = str(request.url_for("oauth_login")) if oauth_ctx else "/oauth/login"
|
||||
|
||||
error_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error - Nextcloud MCP Server</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
.container {{
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}}
|
||||
h1 {{
|
||||
color: #d32f2f;
|
||||
margin-top: 0;
|
||||
}}
|
||||
.error {{
|
||||
background-color: #ffebee;
|
||||
border-left: 4px solid #d32f2f;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Error Retrieving User Info</h1>
|
||||
<div class="error">
|
||||
<strong>Error:</strong> {user_context["error"]}
|
||||
</div>
|
||||
<p><a href="{login_url}">Login again</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(content=error_html)
|
||||
|
||||
# Build HTML response
|
||||
auth_mode = user_context.get("auth_mode", "unknown")
|
||||
username = user_context.get("username", "unknown")
|
||||
|
||||
# Get logout URL dynamically for OAuth mode
|
||||
logout_url = ""
|
||||
if auth_mode == "oauth":
|
||||
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
||||
logout_url = (
|
||||
str(request.url_for("oauth_logout")) if oauth_ctx else "/oauth/logout"
|
||||
)
|
||||
|
||||
# Build host info HTML (BasicAuth only)
|
||||
host_info_html = ""
|
||||
if auth_mode == "basic":
|
||||
nextcloud_host = user_context.get("nextcloud_host", "unknown")
|
||||
host_info_html = f"""
|
||||
<h2>Connection</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<td><strong>Nextcloud Host</strong></td>
|
||||
<td>{nextcloud_host}</td>
|
||||
</tr>
|
||||
</table>
|
||||
"""
|
||||
|
||||
# Build session info HTML (OAuth only)
|
||||
session_info_html = ""
|
||||
if auth_mode == "oauth" and "session_id" in user_context:
|
||||
session_id = user_context.get("session_id", "unknown")
|
||||
session_info_html = f"""
|
||||
<h2>Session Information</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<td><strong>Session ID</strong></td>
|
||||
<td><code>{session_id}</code></td>
|
||||
</tr>
|
||||
</table>
|
||||
"""
|
||||
|
||||
# Build IdP profile HTML
|
||||
idp_profile_html = ""
|
||||
if "idp_profile" in user_context:
|
||||
idp_profile = user_context["idp_profile"]
|
||||
idp_profile_html = "<h2>Identity Provider Profile</h2><table>"
|
||||
for key, value in idp_profile.items():
|
||||
# Handle list values
|
||||
if isinstance(value, list):
|
||||
value_str = ", ".join(str(v) for v in value)
|
||||
else:
|
||||
value_str = str(value)
|
||||
idp_profile_html += f"""
|
||||
<tr>
|
||||
<td><strong>{key}</strong></td>
|
||||
<td>{value_str}</td>
|
||||
</tr>
|
||||
"""
|
||||
idp_profile_html += "</table>"
|
||||
elif "idp_profile_error" in user_context:
|
||||
idp_profile_html = f"""
|
||||
<h2>Identity Provider Profile</h2>
|
||||
<div class="warning">{user_context["idp_profile_error"]}</div>
|
||||
"""
|
||||
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Info - Nextcloud MCP Server</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
.container {{
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}}
|
||||
h1 {{
|
||||
color: #0082c9;
|
||||
margin-top: 0;
|
||||
border-bottom: 2px solid #0082c9;
|
||||
padding-bottom: 10px;
|
||||
}}
|
||||
h2 {{
|
||||
color: #333;
|
||||
margin-top: 30px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-bottom: 5px;
|
||||
}}
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
}}
|
||||
td {{
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}}
|
||||
td:first-child {{
|
||||
width: 200px;
|
||||
color: #666;
|
||||
}}
|
||||
code {{
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}}
|
||||
.badge {{
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
.badge-oauth {{
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}}
|
||||
.badge-basic {{
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
}}
|
||||
.warning {{
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
color: #856404;
|
||||
}}
|
||||
.logout {{
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}}
|
||||
.button {{
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background-color: #d32f2f;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
}}
|
||||
.button:hover {{
|
||||
background-color: #b71c1c;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Nextcloud MCP Server - User Info</h1>
|
||||
|
||||
<h2>Authentication</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<td><strong>Username</strong></td>
|
||||
<td>{username}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Authentication Mode</strong></td>
|
||||
<td><span class="badge badge-{auth_mode}">{auth_mode}</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{host_info_html}
|
||||
{session_info_html}
|
||||
{idp_profile_html}
|
||||
|
||||
{f'<div class="logout"><a href="{logout_url}" class="button">Logout</a></div>' if auth_mode == "oauth" else ""}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return HTMLResponse(content=html_content)
|
||||
@@ -100,7 +100,7 @@ class CalendarClient:
|
||||
# Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color.
|
||||
# caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
|
||||
# Apple iCal namespace which Nextcloud doesn't recognize.
|
||||
from lxml import etree
|
||||
from lxml import etree # type: ignore[import-untyped]
|
||||
|
||||
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
@@ -261,11 +261,12 @@ class CalendarClient:
|
||||
result = []
|
||||
for event in events:
|
||||
await event.load(only_if_unloaded=True)
|
||||
event_dict = self._parse_ical_event(event.data)
|
||||
if event_dict:
|
||||
event_dict["href"] = str(event.url)
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
if event.data:
|
||||
event_dict = self._parse_ical_event(event.data)
|
||||
if event_dict:
|
||||
event_dict["href"] = str(event.url)
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
|
||||
if len(result) >= limit:
|
||||
break
|
||||
@@ -314,8 +315,8 @@ class CalendarClient:
|
||||
await event.load(only_if_unloaded=True)
|
||||
|
||||
# Merge updates into existing iCal data
|
||||
updated_ical = self._merge_ical_properties(event.data, event_data, event_uid)
|
||||
event.data = updated_ical
|
||||
updated_ical = self._merge_ical_properties(event.data, event_data, event_uid) # type: ignore[arg-type]
|
||||
event.data = updated_ical # type: ignore[misc]
|
||||
|
||||
await event.save()
|
||||
|
||||
@@ -349,7 +350,7 @@ class CalendarClient:
|
||||
event = await calendar.event_by_uid(event_uid)
|
||||
await event.load(only_if_unloaded=True)
|
||||
|
||||
event_data = self._parse_ical_event(event.data)
|
||||
event_data = self._parse_ical_event(event.data) if event.data else None # type: ignore[arg-type]
|
||||
if not event_data:
|
||||
raise ValueError(f"Failed to parse event data for {event_uid}")
|
||||
|
||||
@@ -416,7 +417,10 @@ class CalendarClient:
|
||||
# Only load if data not already present from REPORT response
|
||||
# This avoids 404 errors for virtual calendars (e.g., Deck boards)
|
||||
await todo.load(only_if_unloaded=True)
|
||||
todo_dict = self._parse_ical_todo(todo.data)
|
||||
if todo.data:
|
||||
todo_dict = self._parse_ical_todo(todo.data) # type: ignore[arg-type]
|
||||
else:
|
||||
continue
|
||||
if todo_dict:
|
||||
todo_dict["href"] = str(todo.url)
|
||||
todo_dict["etag"] = ""
|
||||
@@ -470,12 +474,14 @@ class CalendarClient:
|
||||
await todo.load(only_if_unloaded=True)
|
||||
|
||||
logger.debug(
|
||||
f"Loaded todo {todo_uid}, current data length: {len(todo.data)}"
|
||||
f"Loaded todo {todo_uid}, current data length: {len(todo.data)}" # type: ignore
|
||||
)
|
||||
|
||||
# Merge updates into existing iCal data
|
||||
updated_ical = self._merge_ical_todo_properties(
|
||||
todo.data, todo_data, todo_uid
|
||||
todo.data, # type: ignore[arg-type]
|
||||
todo_data,
|
||||
todo_uid,
|
||||
)
|
||||
logger.debug(f"Merged iCal data length: {len(updated_ical)}")
|
||||
logger.debug(f"Updated iCal content:\n{updated_ical}")
|
||||
|
||||
@@ -124,7 +124,7 @@ class ContactsClient(BaseNextcloudClient):
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
|
||||
|
||||
contact = Contact(fn=contact_data.get("fn"), uid=uid)
|
||||
contact = Contact(fn=contact_data.get("fn"), uid=uid) # type: ignore
|
||||
if "email" in contact_data:
|
||||
contact.email = [{"value": contact_data["email"], "type": ["HOME"]}]
|
||||
if "tel" in contact_data:
|
||||
@@ -174,7 +174,7 @@ class ContactsClient(BaseNextcloudClient):
|
||||
)
|
||||
else:
|
||||
# Fallback to creating new vCard if we couldn't get existing
|
||||
contact = Contact(fn=contact_data.get("fn"), uid=uid)
|
||||
contact = Contact(fn=contact_data.get("fn"), uid=uid) # type: ignore
|
||||
if "email" in contact_data:
|
||||
contact.email = [{"value": contact_data["email"], "type": ["HOME"]}]
|
||||
if "tel" in contact_data:
|
||||
|
||||
@@ -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,54 @@ 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 (always enabled - no flag needed)
|
||||
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 (always enabled)
|
||||
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"),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
|
||||
try:
|
||||
import io
|
||||
|
||||
import pytesseract
|
||||
import pytesseract # type: ignore
|
||||
from PIL import Image
|
||||
|
||||
TESSERACT_AVAILABLE = True
|
||||
|
||||
@@ -112,10 +112,10 @@ class UnstructuredProcessor(DocumentProcessor):
|
||||
f"Processing document with unstructured... ({elapsed}s elapsed)"
|
||||
)
|
||||
try:
|
||||
await progress_callback(
|
||||
progress=float(elapsed),
|
||||
total=None, # Unknown total duration
|
||||
message=message,
|
||||
await progress_callback( # type: ignore
|
||||
progress=float(elapsed), # type: ignore
|
||||
total=None, # Unknown total duration # type: ignore
|
||||
message=message, # type: ignore
|
||||
)
|
||||
logger.debug(f"Progress update sent: {elapsed}s elapsed")
|
||||
except Exception as e:
|
||||
@@ -293,7 +293,7 @@ class UnstructuredProcessor(DocumentProcessor):
|
||||
self._run_progress_poller, stop_event, progress_callback, start_time
|
||||
)
|
||||
|
||||
return result
|
||||
return result # type: ignore
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if Unstructured API is available.
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -191,7 +191,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
recipe_yield: int | None = None,
|
||||
category: str | None = None,
|
||||
keywords: str | None = None,
|
||||
ctx: Context = None,
|
||||
ctx: Context = None, # type: ignore
|
||||
) -> CreateRecipeResponse:
|
||||
"""Create a new recipe.
|
||||
|
||||
@@ -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:
|
||||
@@ -271,12 +271,12 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
recipe_yield: int | None = None,
|
||||
category: str | None = None,
|
||||
keywords: str | None = None,
|
||||
ctx: Context = None,
|
||||
ctx: Context = None, # type: ignore
|
||||
) -> UpdateRecipeResponse:
|
||||
"""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]
|
||||
@@ -544,7 +544,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
folder: str | None = None,
|
||||
update_interval: int | None = None,
|
||||
print_image: bool | None = None,
|
||||
ctx: Context = None,
|
||||
ctx: Context = None, # type: ignore
|
||||
) -> ReindexResponse:
|
||||
"""Set Cookbook app configuration.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,7 +28,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)
|
||||
|
||||
@@ -36,7 +36,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(
|
||||
@@ -58,7 +58,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)
|
||||
@@ -90,7 +90,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,
|
||||
@@ -147,7 +147,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,
|
||||
@@ -204,7 +204,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
|
||||
@@ -249,7 +249,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
@require_scopes("notes:read")
|
||||
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)
|
||||
|
||||
@@ -295,7 +295,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)
|
||||
@@ -326,12 +326,12 @@ 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
|
||||
)
|
||||
return {
|
||||
return { # type: ignore
|
||||
"uri": f"nc://Notes/{note_id}/attachments/{attachment_filename}",
|
||||
"mimeType": mime_type,
|
||||
"data": content,
|
||||
@@ -371,7 +371,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(
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
MCP Tools for OAuth and Provisioning Management (ADR-004 Progressive Consent).
|
||||
|
||||
This module provides MCP tools that enable users to explicitly provision
|
||||
Nextcloud access using the Flow 2 (Resource Provisioning) OAuth flow.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from mcp.server.fastmcp import Context
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProvisioningStatus(BaseModel):
|
||||
"""Status of Nextcloud provisioning for a user."""
|
||||
|
||||
is_provisioned: bool = Field(description="Whether Nextcloud access is provisioned")
|
||||
provisioned_at: Optional[str] = Field(
|
||||
None, description="ISO timestamp when provisioned"
|
||||
)
|
||||
client_id: Optional[str] = Field(
|
||||
None, description="Client ID that initiated the original Flow 1"
|
||||
)
|
||||
scopes: Optional[list[str]] = Field(None, description="Granted scopes")
|
||||
flow_type: Optional[str] = Field(
|
||||
None, description="Type of flow used ('hybrid', 'flow1', 'flow2')"
|
||||
)
|
||||
|
||||
|
||||
class ProvisioningResult(BaseModel):
|
||||
"""Result of provisioning attempt."""
|
||||
|
||||
success: bool = Field(description="Whether provisioning was initiated")
|
||||
authorization_url: Optional[str] = Field(
|
||||
None, description="URL for user to complete OAuth authorization"
|
||||
)
|
||||
message: str = Field(description="Status message for the user")
|
||||
already_provisioned: bool = Field(
|
||||
False, description="Whether access was already provisioned"
|
||||
)
|
||||
|
||||
|
||||
class RevocationResult(BaseModel):
|
||||
"""Result of access revocation."""
|
||||
|
||||
success: bool = Field(description="Whether revocation succeeded")
|
||||
message: str = Field(description="Status message for the user")
|
||||
|
||||
|
||||
async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningStatus:
|
||||
"""
|
||||
Check the provisioning status for Nextcloud access.
|
||||
|
||||
This checks whether the user has completed Flow 2 to provision
|
||||
offline access to Nextcloud resources.
|
||||
|
||||
Args:
|
||||
mcp: MCP context
|
||||
user_id: User identifier
|
||||
|
||||
Returns:
|
||||
ProvisioningStatus with current provisioning state
|
||||
"""
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
token_data = await storage.get_refresh_token(user_id)
|
||||
|
||||
if not token_data:
|
||||
return ProvisioningStatus(is_provisioned=False)
|
||||
|
||||
# Convert timestamp to ISO format if present
|
||||
provisioned_at_str = None
|
||||
if token_data.get("provisioned_at"):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
dt = datetime.fromtimestamp(token_data["provisioned_at"], tz=timezone.utc)
|
||||
provisioned_at_str = dt.isoformat()
|
||||
|
||||
return ProvisioningStatus(
|
||||
is_provisioned=True,
|
||||
provisioned_at=provisioned_at_str,
|
||||
client_id=token_data.get("provisioning_client_id"),
|
||||
scopes=token_data.get("scopes"),
|
||||
flow_type=token_data.get("flow_type", "hybrid"),
|
||||
)
|
||||
|
||||
|
||||
def generate_oauth_url_for_flow2(
|
||||
oidc_discovery_url: str,
|
||||
server_client_id: str,
|
||||
redirect_uri: str,
|
||||
state: str,
|
||||
scopes: list[str],
|
||||
) -> str:
|
||||
"""
|
||||
Generate OAuth authorization URL for Flow 2 (Resource Provisioning).
|
||||
|
||||
This creates the URL that the MCP server uses to get delegated
|
||||
access to Nextcloud on behalf of the user.
|
||||
|
||||
Args:
|
||||
oidc_discovery_url: OIDC provider discovery URL
|
||||
server_client_id: MCP server's OAuth client ID
|
||||
redirect_uri: Callback URL for the MCP server
|
||||
state: CSRF protection state
|
||||
scopes: List of scopes to request
|
||||
|
||||
Returns:
|
||||
Complete authorization URL for Flow 2
|
||||
"""
|
||||
# Extract base URL from discovery URL
|
||||
# Format: https://example.com/.well-known/openid-configuration
|
||||
# We need: https://example.com/apps/oidc/authorize
|
||||
base_url = oidc_discovery_url.replace("/.well-known/openid-configuration", "")
|
||||
auth_endpoint = f"{base_url}/apps/oidc/authorize"
|
||||
|
||||
# Build OAuth parameters
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": server_client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": " ".join(scopes),
|
||||
"state": state,
|
||||
# Request offline access for background operations
|
||||
"access_type": "offline",
|
||||
"prompt": "consent", # Force consent screen to show scopes
|
||||
}
|
||||
|
||||
return f"{auth_endpoint}?{urlencode(params)}"
|
||||
|
||||
|
||||
async def provision_nextcloud_access(
|
||||
ctx: Context, user_id: Optional[str] = None
|
||||
) -> ProvisioningResult:
|
||||
"""
|
||||
MCP Tool: Provision offline access to Nextcloud resources.
|
||||
|
||||
This tool initiates Flow 2 of the Progressive Consent architecture,
|
||||
allowing the MCP server to obtain delegated access to Nextcloud APIs.
|
||||
|
||||
The user must complete the OAuth flow in their browser to grant access.
|
||||
|
||||
Args:
|
||||
ctx: MCP context with user's Flow 1 token
|
||||
user_id: Optional user identifier (extracted from token if not provided)
|
||||
|
||||
Returns:
|
||||
ProvisioningResult with authorization URL or status
|
||||
"""
|
||||
try:
|
||||
# Extract user ID from the MCP access token (Flow 1 token)
|
||||
if not user_id:
|
||||
# Get the authorization token from context
|
||||
if hasattr(ctx, "authorization") and ctx.authorization:
|
||||
token = ctx.authorization.token # type: ignore
|
||||
# Decode token to get user info
|
||||
try:
|
||||
import jwt
|
||||
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
user_id = payload.get("sub", "unknown")
|
||||
logger.info(f"Extracted user_id from Flow 1 token: {user_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode token: {e}")
|
||||
user_id = "default_user"
|
||||
else:
|
||||
user_id = "default_user"
|
||||
|
||||
# Check if already provisioned
|
||||
status = await get_provisioning_status(ctx, user_id)
|
||||
if status.is_provisioned:
|
||||
return ProvisioningResult(
|
||||
success=True,
|
||||
already_provisioned=True,
|
||||
message=(
|
||||
f"Nextcloud access is already provisioned (since {status.provisioned_at}). "
|
||||
"Use 'revoke_nextcloud_access' if you want to re-provision."
|
||||
),
|
||||
)
|
||||
|
||||
# Get configuration
|
||||
enable_progressive = (
|
||||
os.getenv("ENABLE_PROGRESSIVE_CONSENT", "false").lower() == "true"
|
||||
)
|
||||
if not enable_progressive:
|
||||
return ProvisioningResult(
|
||||
success=False,
|
||||
message=(
|
||||
"Progressive Consent is not enabled. "
|
||||
"Set ENABLE_PROGRESSIVE_CONSENT=true to use this feature."
|
||||
),
|
||||
)
|
||||
|
||||
# Get MCP server's OAuth client credentials
|
||||
server_client_id = os.getenv("MCP_SERVER_CLIENT_ID")
|
||||
if not server_client_id:
|
||||
# In production, would use Dynamic Client Registration here
|
||||
return ProvisioningResult(
|
||||
success=False,
|
||||
message=(
|
||||
"MCP server OAuth client not configured. "
|
||||
"Administrator must set MCP_SERVER_CLIENT_ID."
|
||||
),
|
||||
)
|
||||
|
||||
# Generate OAuth URL for Flow 2
|
||||
oidc_discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
f"{os.getenv('NEXTCLOUD_HOST')}/.well-known/openid-configuration",
|
||||
)
|
||||
|
||||
# Generate secure state for CSRF protection
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Store state in session for validation on callback
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
# Create OAuth session for Flow 2
|
||||
session_id = f"flow2_{user_id}_{secrets.token_hex(8)}"
|
||||
redirect_uri = f"{os.getenv('NEXTCLOUD_MCP_SERVER_URL', 'http://localhost:8000')}/oauth/callback-nextcloud"
|
||||
|
||||
await storage.store_oauth_session(
|
||||
session_id=session_id,
|
||||
client_redirect_uri="", # No client redirect for Flow 2
|
||||
state=state,
|
||||
flow_type="flow2",
|
||||
is_provisioning=True,
|
||||
ttl_seconds=600, # 10 minute TTL
|
||||
)
|
||||
|
||||
# Define scopes for Nextcloud access
|
||||
scopes = [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"offline_access", # Critical for background operations
|
||||
"notes:read",
|
||||
"notes:write",
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
"contacts:read",
|
||||
"contacts:write",
|
||||
"files:read",
|
||||
"files:write",
|
||||
]
|
||||
|
||||
# Generate authorization URL
|
||||
auth_url = generate_oauth_url_for_flow2(
|
||||
oidc_discovery_url=oidc_discovery_url,
|
||||
server_client_id=server_client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
state=state,
|
||||
scopes=scopes,
|
||||
)
|
||||
|
||||
return ProvisioningResult(
|
||||
success=True,
|
||||
authorization_url=auth_url,
|
||||
message=(
|
||||
"Please visit the authorization URL to grant the MCP server "
|
||||
"offline access to your Nextcloud resources. This is a one-time "
|
||||
"setup that allows the server to access Nextcloud on your behalf "
|
||||
"even when you're not actively connected."
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initiate provisioning: {e}")
|
||||
return ProvisioningResult(
|
||||
success=False,
|
||||
message=f"Failed to initiate provisioning: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
async def revoke_nextcloud_access(
|
||||
ctx: Context, user_id: Optional[str] = None
|
||||
) -> RevocationResult:
|
||||
"""
|
||||
MCP Tool: Revoke offline access to Nextcloud resources.
|
||||
|
||||
This tool removes the stored refresh token and revokes access
|
||||
that was granted via Flow 2.
|
||||
|
||||
Args:
|
||||
mcp: MCP context
|
||||
user_id: Optional user identifier
|
||||
|
||||
Returns:
|
||||
RevocationResult with status
|
||||
"""
|
||||
try:
|
||||
# Get user ID from context if not provided
|
||||
if not user_id:
|
||||
user_id = (
|
||||
ctx.context.get("user_id", "default_user") # type: ignore
|
||||
if hasattr(ctx, "context")
|
||||
else "default_user"
|
||||
)
|
||||
|
||||
# Check current status
|
||||
status = await get_provisioning_status(ctx, user_id)
|
||||
if not status.is_provisioned:
|
||||
return RevocationResult(
|
||||
success=True,
|
||||
message="No Nextcloud access to revoke.",
|
||||
)
|
||||
|
||||
# Initialize Token Broker to handle revocation
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
if not encryption_key:
|
||||
return RevocationResult(
|
||||
success=False,
|
||||
message="Token encryption key not configured.",
|
||||
)
|
||||
|
||||
broker = TokenBrokerService(
|
||||
storage=storage,
|
||||
oidc_discovery_url=os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
f"{os.getenv('NEXTCLOUD_HOST')}/.well-known/openid-configuration",
|
||||
),
|
||||
nextcloud_host=os.getenv("NEXTCLOUD_HOST"), # type: ignore
|
||||
encryption_key=encryption_key,
|
||||
)
|
||||
|
||||
# Revoke access
|
||||
success = await broker.revoke_nextcloud_access(user_id)
|
||||
|
||||
if success:
|
||||
return RevocationResult(
|
||||
success=True,
|
||||
message=(
|
||||
"Successfully revoked Nextcloud access. "
|
||||
"You can run 'provision_nextcloud_access' again if needed."
|
||||
),
|
||||
)
|
||||
else:
|
||||
return RevocationResult(
|
||||
success=False,
|
||||
message="Failed to revoke access. Please try again.",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to revoke access: {e}")
|
||||
return RevocationResult(
|
||||
success=False,
|
||||
message=f"Failed to revoke access: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
async def check_provisioning_status(
|
||||
ctx: Context, user_id: Optional[str] = None
|
||||
) -> ProvisioningStatus:
|
||||
"""
|
||||
MCP Tool: Check the current provisioning status.
|
||||
|
||||
This tool allows users to check whether they have provisioned
|
||||
Nextcloud access and see details about their current authorization.
|
||||
|
||||
Args:
|
||||
mcp: MCP context
|
||||
user_id: Optional user identifier
|
||||
|
||||
Returns:
|
||||
ProvisioningStatus with current state
|
||||
"""
|
||||
# Get user ID from context if not provided
|
||||
if not user_id:
|
||||
user_id = (
|
||||
ctx.context.get("user_id", "default_user") # type: ignore
|
||||
if hasattr(ctx, "context")
|
||||
else "default_user"
|
||||
)
|
||||
|
||||
return await get_provisioning_status(ctx, user_id)
|
||||
|
||||
|
||||
# Register MCP tools
|
||||
def register_oauth_tools(mcp):
|
||||
"""Register OAuth and provisioning tools with the MCP server."""
|
||||
|
||||
@mcp.tool(
|
||||
name="provision_nextcloud_access",
|
||||
description=(
|
||||
"Provision offline access to Nextcloud resources. "
|
||||
"This is required before using Nextcloud tools. "
|
||||
"You'll need to complete an OAuth authorization in your browser."
|
||||
),
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def tool_provision_access(
|
||||
ctx: Context,
|
||||
user_id: Optional[str] = None,
|
||||
) -> ProvisioningResult:
|
||||
return await provision_nextcloud_access(ctx, user_id)
|
||||
|
||||
@mcp.tool(
|
||||
name="revoke_nextcloud_access",
|
||||
description="Revoke offline access to Nextcloud resources.",
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def tool_revoke_access(
|
||||
ctx: Context, user_id: Optional[str] = None
|
||||
) -> RevocationResult:
|
||||
return await revoke_nextcloud_access(ctx, user_id)
|
||||
|
||||
@mcp.tool(
|
||||
name="check_provisioning_status",
|
||||
description="Check whether Nextcloud access is provisioned.",
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def tool_check_status(
|
||||
ctx: Context, user_id: Optional[str] = None
|
||||
) -> ProvisioningStatus:
|
||||
return await check_provisioning_status(ctx, user_id)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||
authors = [
|
||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||
@@ -19,7 +19,8 @@ dependencies = [
|
||||
"click>=8.1.8",
|
||||
"caldav",
|
||||
"pyjwt[crypto]>=2.8.0",
|
||||
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
|
||||
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
|
||||
"authlib>=1.6.5",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
@@ -101,6 +102,7 @@ dev = [
|
||||
"pytest-timeout>=2.3.1",
|
||||
"ruff>=0.11.13",
|
||||
"reportlab>=4.0.0",
|
||||
"ty>=0.0.1a25",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
+76
-1
@@ -1120,6 +1120,37 @@ async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_serv
|
||||
)
|
||||
|
||||
|
||||
async def get_mcp_server_resource_metadata(mcp_base_url: str) -> dict:
|
||||
"""
|
||||
Fetch MCP server's Protected Resource Metadata (RFC 9470).
|
||||
|
||||
This retrieves the MCP server's resource information including:
|
||||
- resource: The MCP server's client ID (used as audience for tokens)
|
||||
- authorization_servers: List of trusted OAuth servers
|
||||
- scopes_supported: Available scopes
|
||||
|
||||
Args:
|
||||
mcp_base_url: Base URL of the MCP server (e.g., "http://localhost:8001")
|
||||
WITHOUT the /mcp path component
|
||||
|
||||
Returns:
|
||||
Dict with resource metadata
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If metadata endpoint is not available
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
prm_url = f"{mcp_base_url}/.well-known/oauth-protected-resource"
|
||||
logger.debug(f"Fetching resource metadata from: {prm_url}")
|
||||
|
||||
response = await http_client.get(prm_url)
|
||||
response.raise_for_status()
|
||||
metadata = response.json()
|
||||
|
||||
logger.debug(f"Resource metadata: {metadata}")
|
||||
return metadata
|
||||
|
||||
|
||||
async def _create_oauth_client_with_scopes(
|
||||
callback_url: str,
|
||||
client_name: str,
|
||||
@@ -1514,11 +1545,24 @@ async def playwright_oauth_token(
|
||||
logger.info(f"Using shared OAuth client: {client_id[:16]}...")
|
||||
logger.info(f"Using real callback server at: {callback_url}")
|
||||
|
||||
# Fetch MCP server's resource metadata to get correct audience
|
||||
mcp_server_base_url = "http://localhost:8001"
|
||||
try:
|
||||
resource_metadata = await get_mcp_server_resource_metadata(mcp_server_base_url)
|
||||
resource_id = resource_metadata.get("resource")
|
||||
if resource_id:
|
||||
logger.info(f"MCP server resource ID (for audience): {resource_id[:16]}...")
|
||||
else:
|
||||
logger.warning("No resource ID in metadata - token may have wrong audience")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch resource metadata: {e}")
|
||||
resource_id = None
|
||||
|
||||
# Generate unique state parameter for this OAuth flow
|
||||
state = secrets.token_urlsafe(32)
|
||||
logger.debug(f"Generated state: {state[:16]}...")
|
||||
|
||||
# Construct authorization URL with state parameter
|
||||
# Construct authorization URL with state and resource parameters
|
||||
auth_url = (
|
||||
f"{authorization_endpoint}?"
|
||||
f"response_type=code&"
|
||||
@@ -1528,6 +1572,11 @@ async def playwright_oauth_token(
|
||||
f"scope=openid%20profile%20email%20notes:read%20notes:write%20calendar:read%20calendar:write%20contacts:read%20contacts:write%20cookbook:read%20cookbook:write%20deck:read%20deck:write%20tables:read%20tables:write%20files:read%20files:write%20sharing:read%20sharing:write"
|
||||
)
|
||||
|
||||
# Add resource parameter (RFC 8707) if available
|
||||
if resource_id:
|
||||
auth_url += f"&resource={quote(resource_id, safe='')}"
|
||||
logger.debug(f"Added resource parameter to auth URL: {resource_id[:16]}...")
|
||||
|
||||
# Async browser automation using pytest-playwright's browser fixture
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
@@ -1745,6 +1794,7 @@ async def _get_oauth_token_with_scopes(
|
||||
shared_oauth_client_credentials,
|
||||
oauth_callback_server,
|
||||
scopes: str,
|
||||
resource: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Helper function to obtain OAuth token with specific scopes.
|
||||
@@ -1754,6 +1804,7 @@ async def _get_oauth_token_with_scopes(
|
||||
shared_oauth_client_credentials: Tuple of OAuth client credentials
|
||||
oauth_callback_server: OAuth callback server fixture
|
||||
scopes: Space-separated list of scopes (e.g., "openid profile email notes:read")
|
||||
resource: Optional resource parameter (RFC 8707) for token audience
|
||||
|
||||
Returns:
|
||||
OAuth access token string with requested scopes
|
||||
@@ -1783,6 +1834,25 @@ async def _get_oauth_token_with_scopes(
|
||||
logger.info(f"Using shared OAuth client: {client_id[:16]}...")
|
||||
logger.info(f"Using real callback server at: {callback_url}")
|
||||
|
||||
# If no resource provided, fetch from MCP server metadata
|
||||
if resource is None:
|
||||
mcp_server_base_url = "http://localhost:8001"
|
||||
try:
|
||||
resource_metadata = await get_mcp_server_resource_metadata(
|
||||
mcp_server_base_url
|
||||
)
|
||||
resource = resource_metadata.get("resource")
|
||||
if resource:
|
||||
logger.info(
|
||||
f"MCP server resource ID (for audience): {resource[:16]}..."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"No resource ID in metadata - token may have wrong audience"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch resource metadata: {e}")
|
||||
|
||||
# Generate unique state parameter for this OAuth flow
|
||||
state = secrets.token_urlsafe(32)
|
||||
logger.debug(f"Generated state: {state[:16]}...")
|
||||
@@ -1800,6 +1870,11 @@ async def _get_oauth_token_with_scopes(
|
||||
f"scope={scopes_encoded}"
|
||||
)
|
||||
|
||||
# Add resource parameter (RFC 8707) if available
|
||||
if resource:
|
||||
auth_url += f"&resource={quote(resource, safe='')}"
|
||||
logger.debug(f"Added resource parameter to auth URL: {resource[:16]}...")
|
||||
|
||||
# Async browser automation using pytest-playwright's browser fixture
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Integration tests for RFC 8693 Token Exchange with Keycloak.
|
||||
|
||||
These tests validate the complete token exchange flow:
|
||||
1. Obtain client token from Keycloak
|
||||
2. Exchange for Nextcloud-audience token via RFC 8693
|
||||
3. Use exchanged token to access Nextcloud APIs
|
||||
4. Verify CRUD operations work with exchanged tokens
|
||||
|
||||
Requirements:
|
||||
- Keycloak running with nextcloud-mcp realm configured
|
||||
- Nextcloud running with user_oidc app configured
|
||||
- Standard Token Exchange enabled on both clients
|
||||
- token-exchange-nextcloud scope configured
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def keycloak_base_url() -> str:
|
||||
"""Keycloak base URL (external)."""
|
||||
return "http://localhost:8888"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def keycloak_token_url(keycloak_base_url: str) -> str:
|
||||
"""Keycloak token endpoint URL."""
|
||||
return f"{keycloak_base_url}/realms/nextcloud-mcp/protocol/openid-connect/token"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def nextcloud_base_url() -> str:
|
||||
"""Nextcloud base URL."""
|
||||
return "http://localhost:8080"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def http_client() -> httpx.AsyncClient:
|
||||
"""Async HTTP client for API requests."""
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def keycloak_client_token(
|
||||
http_client: httpx.AsyncClient, keycloak_token_url: str
|
||||
) -> str:
|
||||
"""Get client token from Keycloak using password grant.
|
||||
|
||||
Returns token with aud: ["nextcloud-mcp-server", "nextcloud"]
|
||||
"""
|
||||
response = await http_client.post(
|
||||
keycloak_token_url,
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"client_id": "nextcloud-mcp-server",
|
||||
"client_secret": "mcp-secret-change-in-production",
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"scope": "openid profile email offline_access notes:read notes:write",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
return token_data["access_token"]
|
||||
|
||||
|
||||
async def exchange_token(
|
||||
http_client: httpx.AsyncClient,
|
||||
token_url: str,
|
||||
subject_token: str,
|
||||
audience: str = "nextcloud",
|
||||
) -> dict[str, Any]:
|
||||
"""Exchange token using RFC 8693.
|
||||
|
||||
Args:
|
||||
http_client: HTTP client
|
||||
token_url: Token endpoint URL
|
||||
subject_token: Token to exchange
|
||||
audience: Target audience
|
||||
|
||||
Returns:
|
||||
Token response with access_token and expires_in
|
||||
"""
|
||||
response = await http_client.post(
|
||||
token_url,
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
"client_id": "nextcloud-mcp-server",
|
||||
"client_secret": "mcp-secret-change-in-production",
|
||||
"subject_token": subject_token,
|
||||
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||
"audience": audience,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def decode_token_claims(token: str) -> dict[str, Any]:
|
||||
"""Decode JWT token claims without verification.
|
||||
|
||||
Args:
|
||||
token: JWT token
|
||||
|
||||
Returns:
|
||||
Token claims
|
||||
"""
|
||||
return jwt.decode(token, options={"verify_signature": False})
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.keycloak
|
||||
class TestKeycloakTokenExchange:
|
||||
"""Test RFC 8693 Token Exchange with Keycloak."""
|
||||
|
||||
async def test_token_exchange_basic(
|
||||
self,
|
||||
http_client: httpx.AsyncClient,
|
||||
keycloak_token_url: str,
|
||||
keycloak_client_token: str,
|
||||
):
|
||||
"""Test basic token exchange flow."""
|
||||
# Verify initial token has both audiences
|
||||
initial_claims = decode_token_claims(keycloak_client_token)
|
||||
assert "nextcloud-mcp-server" in initial_claims["aud"]
|
||||
assert "nextcloud" in initial_claims["aud"]
|
||||
assert initial_claims["azp"] == "nextcloud-mcp-server"
|
||||
|
||||
# Exchange for Nextcloud-audience token
|
||||
exchange_response = await exchange_token(
|
||||
http_client, keycloak_token_url, keycloak_client_token
|
||||
)
|
||||
|
||||
assert "access_token" in exchange_response
|
||||
assert "expires_in" in exchange_response
|
||||
assert exchange_response["expires_in"] > 0
|
||||
|
||||
# Verify exchanged token has correct audience
|
||||
exchanged_token = exchange_response["access_token"]
|
||||
exchanged_claims = decode_token_claims(exchanged_token)
|
||||
|
||||
assert exchanged_claims["aud"] == "nextcloud"
|
||||
assert exchanged_claims["azp"] == "nextcloud-mcp-server"
|
||||
assert exchanged_claims["sub"] == initial_claims["sub"]
|
||||
|
||||
async def test_token_exchange_with_nextcloud_api(
|
||||
self,
|
||||
http_client: httpx.AsyncClient,
|
||||
keycloak_token_url: str,
|
||||
keycloak_client_token: str,
|
||||
nextcloud_base_url: str,
|
||||
):
|
||||
"""Test exchanged token works with Nextcloud APIs."""
|
||||
# Exchange token
|
||||
exchange_response = await exchange_token(
|
||||
http_client, keycloak_token_url, keycloak_client_token
|
||||
)
|
||||
nextcloud_token = exchange_response["access_token"]
|
||||
|
||||
# Call Nextcloud Capabilities API
|
||||
response = await http_client.get(
|
||||
f"{nextcloud_base_url}/ocs/v1.php/cloud/capabilities",
|
||||
headers={
|
||||
"Authorization": f"Bearer {nextcloud_token}",
|
||||
"OCS-APIRequest": "true",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Verify response contains OCS data
|
||||
assert "ocs" in response.text.lower()
|
||||
|
||||
async def test_token_exchange_multiple_times(
|
||||
self,
|
||||
http_client: httpx.AsyncClient,
|
||||
keycloak_token_url: str,
|
||||
keycloak_client_token: str,
|
||||
):
|
||||
"""Test multiple exchanges from same client token (stateless)."""
|
||||
# Exchange token three times
|
||||
tokens = []
|
||||
for _ in range(3):
|
||||
exchange_response = await exchange_token(
|
||||
http_client, keycloak_token_url, keycloak_client_token
|
||||
)
|
||||
tokens.append(exchange_response["access_token"])
|
||||
|
||||
# All exchanges should succeed
|
||||
assert len(tokens) == 3
|
||||
|
||||
# Tokens should be different (fresh ephemeral tokens)
|
||||
# Note: Keycloak may cache, so tokens might be identical
|
||||
# The important thing is that all exchanges succeeded
|
||||
|
||||
async def test_token_exchange_crud_operations(
|
||||
self,
|
||||
http_client: httpx.AsyncClient,
|
||||
keycloak_token_url: str,
|
||||
keycloak_client_token: str,
|
||||
nextcloud_base_url: str,
|
||||
):
|
||||
"""Test CRUD operations with exchanged tokens."""
|
||||
notes_api = f"{nextcloud_base_url}/index.php/apps/notes/api/v1/notes"
|
||||
|
||||
# Step 1: Exchange token for CREATE
|
||||
exchange_response = await exchange_token(
|
||||
http_client, keycloak_token_url, keycloak_client_token
|
||||
)
|
||||
create_token = exchange_response["access_token"]
|
||||
|
||||
# Step 2: Create a test note
|
||||
create_response = await http_client.post(
|
||||
notes_api,
|
||||
headers={"Authorization": f"Bearer {create_token}"},
|
||||
json={
|
||||
"title": "Token Exchange Test",
|
||||
"content": "This note was created using an RFC 8693 exchanged token!",
|
||||
"category": "Test",
|
||||
},
|
||||
)
|
||||
create_response.raise_for_status()
|
||||
note_data = create_response.json()
|
||||
note_id = note_data["id"]
|
||||
|
||||
assert note_data["title"] == "Token Exchange Test"
|
||||
assert note_data["category"] == "Test"
|
||||
|
||||
# Step 3: Exchange token again for READ (simulate new request)
|
||||
exchange_response = await exchange_token(
|
||||
http_client, keycloak_token_url, keycloak_client_token
|
||||
)
|
||||
read_token = exchange_response["access_token"]
|
||||
|
||||
# Step 4: Read the note back
|
||||
read_response = await http_client.get(
|
||||
f"{notes_api}/{note_id}",
|
||||
headers={"Authorization": f"Bearer {read_token}"},
|
||||
)
|
||||
read_response.raise_for_status()
|
||||
read_data = read_response.json()
|
||||
|
||||
assert read_data["id"] == note_id
|
||||
assert read_data["title"] == "Token Exchange Test"
|
||||
assert "RFC 8693 exchanged token" in read_data["content"]
|
||||
|
||||
# Step 5: Exchange token again for DELETE
|
||||
exchange_response = await exchange_token(
|
||||
http_client, keycloak_token_url, keycloak_client_token
|
||||
)
|
||||
delete_token = exchange_response["access_token"]
|
||||
|
||||
# Step 6: Delete the note
|
||||
delete_response = await http_client.delete(
|
||||
f"{notes_api}/{note_id}",
|
||||
headers={"Authorization": f"Bearer {delete_token}"},
|
||||
)
|
||||
# Notes API returns the deleted note or empty array
|
||||
assert delete_response.status_code in (200, 204)
|
||||
|
||||
async def test_token_claims_preservation(
|
||||
self,
|
||||
http_client: httpx.AsyncClient,
|
||||
keycloak_token_url: str,
|
||||
keycloak_client_token: str,
|
||||
):
|
||||
"""Test that important claims are preserved during exchange."""
|
||||
initial_claims = decode_token_claims(keycloak_client_token)
|
||||
|
||||
# Exchange token
|
||||
exchange_response = await exchange_token(
|
||||
http_client, keycloak_token_url, keycloak_client_token
|
||||
)
|
||||
exchanged_token = exchange_response["access_token"]
|
||||
exchanged_claims = decode_token_claims(exchanged_token)
|
||||
|
||||
# Subject (user ID) should be preserved
|
||||
assert exchanged_claims["sub"] == initial_claims["sub"]
|
||||
|
||||
# Authorized party should show delegation
|
||||
assert exchanged_claims["azp"] == "nextcloud-mcp-server"
|
||||
|
||||
# Audience should be filtered to target
|
||||
assert exchanged_claims["aud"] == "nextcloud"
|
||||
|
||||
# Token should have expiration
|
||||
assert "exp" in exchanged_claims
|
||||
assert exchanged_claims["exp"] > 0
|
||||
|
||||
async def test_token_exchange_scope_configuration(
|
||||
self, http_client: httpx.AsyncClient, keycloak_token_url: str
|
||||
):
|
||||
"""Test that token-exchange-nextcloud scope is configured as default.
|
||||
|
||||
Since token-exchange-nextcloud is a default scope for nextcloud-mcp-server,
|
||||
all tokens should have the nextcloud audience available for exchange.
|
||||
"""
|
||||
# Get a token - should automatically include default scopes
|
||||
response = await http_client.post(
|
||||
keycloak_token_url,
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"client_id": "nextcloud-mcp-server",
|
||||
"client_secret": "mcp-secret-change-in-production",
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"scope": "openid profile email",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
token = response.json()["access_token"]
|
||||
|
||||
# Verify token has nextcloud in aud (from default token-exchange-nextcloud scope)
|
||||
claims = decode_token_claims(token)
|
||||
assert "nextcloud" in claims.get("aud", [])
|
||||
|
||||
# Exchange should succeed
|
||||
exchange_response = await http_client.post(
|
||||
keycloak_token_url,
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
"client_id": "nextcloud-mcp-server",
|
||||
"client_secret": "mcp-secret-change-in-production",
|
||||
"subject_token": token,
|
||||
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||
"audience": "nextcloud",
|
||||
},
|
||||
)
|
||||
|
||||
# Should succeed because token-exchange-nextcloud is a default scope
|
||||
assert exchange_response.status_code == 200
|
||||
exchanged_data = exchange_response.json()
|
||||
assert "access_token" in exchanged_data
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.keycloak
|
||||
class TestTokenExchangeService:
|
||||
"""Test the TokenExchangeService implementation."""
|
||||
|
||||
async def test_exchange_token_for_audience(
|
||||
self, keycloak_client_token: str, keycloak_token_url: str
|
||||
):
|
||||
"""Test the exchange_token_for_audience function."""
|
||||
from nextcloud_mcp_server.auth.token_exchange import (
|
||||
TokenExchangeService,
|
||||
)
|
||||
|
||||
# Create service
|
||||
service = TokenExchangeService(
|
||||
oidc_discovery_url="http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
|
||||
client_id="nextcloud-mcp-server",
|
||||
client_secret="mcp-secret-change-in-production",
|
||||
)
|
||||
|
||||
try:
|
||||
# Exchange token
|
||||
exchanged_token, expires_in = await service.exchange_token_for_audience(
|
||||
subject_token=keycloak_client_token,
|
||||
requested_audience="nextcloud",
|
||||
)
|
||||
|
||||
# Verify exchange succeeded
|
||||
assert exchanged_token is not None
|
||||
assert isinstance(exchanged_token, str)
|
||||
assert expires_in > 0
|
||||
|
||||
# Verify token has correct claims
|
||||
claims = decode_token_claims(exchanged_token)
|
||||
assert claims["aud"] == "nextcloud"
|
||||
assert claims["azp"] == "nextcloud-mcp-server"
|
||||
|
||||
finally:
|
||||
await service.close()
|
||||
@@ -0,0 +1,47 @@
|
||||
# Manual OAuth Flow Testing
|
||||
|
||||
This directory contains manual test scripts for OAuth flows that require browser interaction.
|
||||
|
||||
## ADR-004 OAuth Hybrid Flow Test
|
||||
|
||||
The `test_adr004_oauth_flow.py` script tests the complete OAuth flow described in ADR-004.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Install Playwright browsers:**
|
||||
```bash
|
||||
uv run playwright install firefox
|
||||
```
|
||||
|
||||
2. **Start MCP server with OAuth enabled:**
|
||||
|
||||
For Nextcloud OIDC:
|
||||
```bash
|
||||
export ENABLE_OFFLINE_ACCESS=true
|
||||
export TOKEN_ENCRYPTION_KEY=$(uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
|
||||
docker-compose up --build -d mcp-oauth
|
||||
```
|
||||
|
||||
For Keycloak:
|
||||
```bash
|
||||
export ENABLE_OFFLINE_ACCESS=true
|
||||
export TOKEN_ENCRYPTION_KEY=$(uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
|
||||
docker-compose up --build -d mcp-keycloak
|
||||
```
|
||||
|
||||
### Running the Test
|
||||
|
||||
**Test with Nextcloud OIDC:**
|
||||
```bash
|
||||
uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud
|
||||
```
|
||||
|
||||
**Test with Keycloak:**
|
||||
```bash
|
||||
uv run python tests/manual/test_adr004_oauth_flow.py --provider keycloak
|
||||
```
|
||||
|
||||
**Headless mode:**
|
||||
```bash
|
||||
uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud --headless
|
||||
```
|
||||
@@ -0,0 +1,203 @@
|
||||
# ADR-004 OAuth Flow Testing Instructions
|
||||
|
||||
## Automated Integration Test (Recommended)
|
||||
|
||||
The ADR-004 Hybrid Flow is now fully tested via automated integration tests using Playwright:
|
||||
|
||||
```bash
|
||||
# Run all ADR-004 tests
|
||||
uv run pytest tests/server/oauth/test_adr004_hybrid_flow.py --browser firefox -v
|
||||
|
||||
# Run specific test
|
||||
uv run pytest tests/server/oauth/test_adr004_hybrid_flow.py::test_adr004_hybrid_flow_tool_execution --browser firefox -v
|
||||
```
|
||||
|
||||
These tests verify:
|
||||
- ✅ PKCE code challenge/verifier flow
|
||||
- ✅ MCP server intercepts OAuth callback
|
||||
- ✅ Master refresh token storage
|
||||
- ✅ Client receives MCP access token
|
||||
- ✅ MCP session establishment with hybrid flow token
|
||||
- ✅ Tool execution using stored refresh tokens
|
||||
- ✅ Multiple operations without re-authentication
|
||||
|
||||
## Manual Test (Legacy)
|
||||
|
||||
For manual testing or debugging, you can use the standalone test script:
|
||||
|
||||
```bash
|
||||
# Make sure port 8765 is available
|
||||
lsof -ti:8765 | xargs kill -9 2>/dev/null
|
||||
|
||||
# Run the test
|
||||
uv run python tests/manual/test_adr004_manual.py --provider nextcloud
|
||||
```
|
||||
|
||||
## Expected Flow
|
||||
|
||||
### 1. Test Script Starts
|
||||
```
|
||||
======================================================================
|
||||
ADR-004 MANUAL OAUTH FLOW TEST
|
||||
======================================================================
|
||||
Provider: nextcloud
|
||||
MCP Server: http://localhost:8001
|
||||
Nextcloud: http://localhost:8080
|
||||
======================================================================
|
||||
|
||||
✓ Generated PKCE challenge: gxQLsYDJ...
|
||||
✓ Started callback server at http://localhost:8765/callback
|
||||
```
|
||||
|
||||
### 2. Open OAuth URL in Browser
|
||||
The script will print:
|
||||
```
|
||||
======================================================================
|
||||
STEP 1: AUTHORIZE THE MCP SERVER
|
||||
======================================================================
|
||||
|
||||
📋 Open this URL in your browser:
|
||||
|
||||
http://localhost:8001/oauth/authorize?response_type=code&...
|
||||
|
||||
📌 What will happen:
|
||||
1. You'll be redirected to Nextcloud/Keycloak login
|
||||
2. Login with username: admin, password: admin
|
||||
3. You'll see a consent screen asking to authorize the MCP server
|
||||
4. Click 'Authorize' or 'Allow'
|
||||
5. You'll be redirected to localhost:8765/callback
|
||||
6. The authorization code will appear in the terminal
|
||||
```
|
||||
|
||||
### 3. Browser Flow
|
||||
1. **Nextcloud Login** - You see the Nextcloud login page
|
||||
2. **Enter Credentials** - admin/admin
|
||||
3. **Consent Screen** - "Authorize Nextcloud MCP Server (jwt) to access your account?"
|
||||
4. **Click Authorize**
|
||||
5. **Redirect Chain**:
|
||||
- Nextcloud redirects to: `http://localhost:8001/oauth/callback?code=...`
|
||||
- MCP server processes the code
|
||||
- MCP server redirects to: `http://localhost:8765/callback?code=mcp-code-...&state=...`
|
||||
- Browser reaches the test script's callback server
|
||||
- You see: "✓ Authorization Successful - You can close this window"
|
||||
|
||||
### 4. Test Script Continues
|
||||
```
|
||||
✓ Received authorization code!
|
||||
Code: mcp-code-xyz...
|
||||
✓ State parameter verified (CSRF protection)
|
||||
|
||||
======================================================================
|
||||
STEP 2: EXCHANGE CODE FOR ACCESS TOKEN
|
||||
======================================================================
|
||||
|
||||
✓ Successfully received access token
|
||||
Token: eyJhbGciOiJSUzI1Ni...
|
||||
Type: Bearer
|
||||
Expires: 3600s
|
||||
|
||||
======================================================================
|
||||
STEP 3: CALL MCP TOOL WITH ACCESS TOKEN
|
||||
======================================================================
|
||||
|
||||
✓ MCP tool call succeeded!
|
||||
Result: {...}
|
||||
|
||||
======================================================================
|
||||
🎉 ADR-004 OAUTH FLOW TEST - SUCCESS
|
||||
======================================================================
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Browser Gets Stuck at "localhost:8765 refused to connect"
|
||||
|
||||
**Problem**: The callback server on port 8765 isn't accessible.
|
||||
|
||||
**Solutions**:
|
||||
1. Check firewall isn't blocking port 8765
|
||||
2. Verify the test script is still running
|
||||
3. Check another process isn't using port 8765:
|
||||
```bash
|
||||
lsof -ti:8765
|
||||
```
|
||||
|
||||
### Browser Shows "localhost:8765 - ERR_CONNECTION_REFUSED"
|
||||
|
||||
**Problem**: The callback server stopped or never started.
|
||||
|
||||
**Solution**:
|
||||
1. Check the test script output - it should say "✓ Started callback server"
|
||||
2. Restart the test script
|
||||
3. Manually test the callback server:
|
||||
```bash
|
||||
curl http://localhost:8765/callback?code=test&state=test
|
||||
```
|
||||
Should return HTML page with "Authorization Successful"
|
||||
|
||||
### "Session not found or expired" Error
|
||||
|
||||
**Problem**: Took too long between steps (>10 minutes).
|
||||
|
||||
**Solution**: Restart the test - sessions expire after 10 minutes.
|
||||
|
||||
### Client ID is None
|
||||
|
||||
**Problem**: OAuth client credentials not loaded.
|
||||
|
||||
**Solution**: Rebuild the MCP server:
|
||||
```bash
|
||||
docker-compose up --build -d mcp-oauth
|
||||
```
|
||||
|
||||
### Nextcloud Shows "Invalid redirect_uri"
|
||||
|
||||
**Problem**: The redirect URI isn't registered for the OAuth client.
|
||||
|
||||
**Solution**: Check registered URIs:
|
||||
```bash
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud -e \
|
||||
"SELECT c.client_identifier, r.redirect_uri FROM oc_oidc_clients c \
|
||||
LEFT JOIN oc_oidc_redirect_uris r ON c.id = r.client_id \
|
||||
WHERE c.name LIKE '%MCP%';"
|
||||
```
|
||||
|
||||
Should show: `http://localhost:8001/oauth/callback`
|
||||
|
||||
## Manual Test Without Script
|
||||
|
||||
If the automated test doesn't work, you can test manually:
|
||||
|
||||
1. **Start callback server manually**:
|
||||
```bash
|
||||
python3 -m http.server 8765
|
||||
```
|
||||
|
||||
2. **Open OAuth URL in browser** (get from test script output or build manually):
|
||||
```
|
||||
http://localhost:8001/oauth/authorize?response_type=code&client_id=test-mcp-client&redirect_uri=http://localhost:8765/callback&scope=openid+profile+email+offline_access&state=TEST&code_challenge=CHALLENGE&code_challenge_method=S256
|
||||
```
|
||||
|
||||
3. **Complete login** at Nextcloud
|
||||
|
||||
4. **Browser should redirect** to `http://localhost:8765/callback?code=mcp-code-...&state=TEST`
|
||||
|
||||
5. **Copy the code** from the URL and exchange it:
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/oauth/token \
|
||||
-d "grant_type=authorization_code" \
|
||||
-d "code=<MCP_CODE_HERE>" \
|
||||
-d "code_verifier=<VERIFIER_HERE>" \
|
||||
-d "redirect_uri=http://localhost:8765/callback" \
|
||||
-d "client_id=test-mcp-client"
|
||||
```
|
||||
|
||||
## Expected Database State After Success
|
||||
|
||||
```bash
|
||||
# Check refresh token was stored
|
||||
docker compose exec mcp-oauth sh -c \
|
||||
"sqlite3 /app/data/tokens.db 'SELECT user_id, created_at FROM refresh_tokens;'"
|
||||
```
|
||||
|
||||
Should show an entry for the authenticated user.
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ADR-004 Manual OAuth Flow Test
|
||||
|
||||
This is a simplified version that doesn't use Playwright automation.
|
||||
Instead, it prints URLs and waits for manual browser interaction.
|
||||
|
||||
Usage:
|
||||
uv run python tests/manual/test_adr004_manual.py --provider nextcloud
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
from base64 import urlsafe_b64encode
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from threading import Thread
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CallbackHandler(BaseHTTPRequestHandler):
|
||||
"""Handles OAuth callback redirect to localhost"""
|
||||
|
||||
authorization_code = None
|
||||
state = None
|
||||
|
||||
def do_GET(self):
|
||||
"""Handle GET request with authorization code"""
|
||||
parsed = urlparse(self.path)
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
# Ignore favicon requests
|
||||
if parsed.path == "/favicon.ico":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "image/x-icon")
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
CallbackHandler.authorization_code = params.get("code", [None])[0]
|
||||
CallbackHandler.state = params.get("state", [None])[0]
|
||||
|
||||
# Send success page
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
|
||||
code_display = (
|
||||
CallbackHandler.authorization_code[:50] + "..."
|
||||
if CallbackHandler.authorization_code
|
||||
else "No code received"
|
||||
)
|
||||
|
||||
html = """
|
||||
<html>
|
||||
<head><title>Authorization Success</title></head>
|
||||
<body>
|
||||
<h1 style="color: green;">✓ Authorization Successful</h1>
|
||||
<p>Authorization code received. You can close this window and return to the terminal.</p>
|
||||
<code style="background: #f0f0f0; padding: 10px; display: block; margin: 10px 0;">
|
||||
{}
|
||||
</code>
|
||||
</body>
|
||||
</html>
|
||||
""".format(code_display)
|
||||
self.wfile.write(html.encode())
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Log HTTP requests"""
|
||||
logger.info(f"Callback server: {format % args}")
|
||||
|
||||
|
||||
def generate_pkce_challenge():
|
||||
"""Generate PKCE code verifier and challenge"""
|
||||
code_verifier = secrets.token_urlsafe(32)
|
||||
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||
code_challenge = urlsafe_b64encode(digest).decode().rstrip("=")
|
||||
return code_verifier, code_challenge
|
||||
|
||||
|
||||
async def test_oauth_manual(
|
||||
provider: str,
|
||||
mcp_server_url: str,
|
||||
nextcloud_host: str,
|
||||
):
|
||||
"""
|
||||
Manual OAuth flow test - prints URLs for manual browser interaction.
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("ADR-004 MANUAL OAUTH FLOW TEST")
|
||||
print("=" * 70)
|
||||
print(f"Provider: {provider}")
|
||||
print(f"MCP Server: {mcp_server_url}")
|
||||
print(f"Nextcloud: {nextcloud_host}")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
# Generate PKCE challenge
|
||||
code_verifier, code_challenge = generate_pkce_challenge()
|
||||
logger.info(f"✓ Generated PKCE challenge: {code_challenge[:16]}...")
|
||||
|
||||
# Generate state for CSRF protection
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Start local HTTP server for OAuth callback
|
||||
callback_port = 8765
|
||||
redirect_uri = f"http://localhost:{callback_port}/callback"
|
||||
|
||||
server = HTTPServer(("localhost", callback_port), CallbackHandler)
|
||||
server_thread = Thread(target=server.serve_forever, daemon=True)
|
||||
server_thread.start()
|
||||
logger.info(f"✓ Started callback server at {redirect_uri}")
|
||||
|
||||
try:
|
||||
# Build authorization URL
|
||||
auth_params = {
|
||||
"response_type": "code",
|
||||
"client_id": "test-mcp-client",
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": "openid profile email offline_access notes:read notes:write",
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
|
||||
auth_url = f"{mcp_server_url}/oauth/authorize?{urlencode(auth_params)}"
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("STEP 1: AUTHORIZE THE MCP SERVER")
|
||||
print("=" * 70)
|
||||
print("\n📋 Open this URL in your browser:\n")
|
||||
print(f" {auth_url}")
|
||||
print("\n📌 What will happen:")
|
||||
print(" 1. You'll be redirected to Nextcloud/Keycloak login")
|
||||
print(" 2. Login with username: admin, password: admin")
|
||||
print(" 3. You'll see a consent screen asking to authorize the MCP server")
|
||||
print(" 4. Click 'Authorize' or 'Allow'")
|
||||
print(" 5. You'll be redirected to localhost:8765/callback")
|
||||
print(" 6. The authorization code will appear in the terminal\n")
|
||||
print("=" * 70)
|
||||
print("\n⏳ Waiting for authorization... (timeout: 5 minutes)\n")
|
||||
|
||||
# Wait for authorization code (with timeout)
|
||||
timeout = 300 # 5 minutes
|
||||
elapsed = 0
|
||||
while not CallbackHandler.authorization_code and elapsed < timeout:
|
||||
await asyncio.sleep(1)
|
||||
elapsed += 1
|
||||
|
||||
if not CallbackHandler.authorization_code:
|
||||
raise RuntimeError("Timeout waiting for authorization code")
|
||||
|
||||
authorization_code = CallbackHandler.authorization_code
|
||||
returned_state = CallbackHandler.state
|
||||
|
||||
print("\n✓ Received authorization code!")
|
||||
logger.info(f"Code: {authorization_code[:16]}...")
|
||||
|
||||
# Verify state
|
||||
if returned_state != state:
|
||||
raise RuntimeError(
|
||||
f"State mismatch! Expected {state}, got {returned_state}"
|
||||
)
|
||||
logger.info("✓ State parameter verified (CSRF protection)")
|
||||
|
||||
# Exchange authorization code for access token
|
||||
print("\n" + "=" * 70)
|
||||
print("STEP 2: EXCHANGE CODE FOR ACCESS TOKEN")
|
||||
print("=" * 70)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
f"{mcp_server_url}/oauth/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": authorization_code,
|
||||
"code_verifier": code_verifier,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": "test-mcp-client",
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
print(f"\n❌ Token exchange failed: {token_response.status_code}")
|
||||
print(f"Response: {token_response.text}")
|
||||
raise RuntimeError("Token exchange failed")
|
||||
|
||||
token_data = token_response.json()
|
||||
access_token = token_data["access_token"]
|
||||
|
||||
print("\n✓ Successfully received access token")
|
||||
print(f" Token: {access_token[:30]}...")
|
||||
print(f" Type: {token_data.get('token_type', 'Bearer')}")
|
||||
print(f" Expires: {token_data.get('expires_in', 'unknown')}s")
|
||||
|
||||
# Test MCP tool call
|
||||
print("\n" + "=" * 70)
|
||||
print("STEP 3: CALL MCP TOOL WITH ACCESS TOKEN")
|
||||
print("=" * 70)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
mcp_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "nc_notes_search_notes",
|
||||
"arguments": {"query": "test"},
|
||||
},
|
||||
}
|
||||
|
||||
mcp_response = await client.post(
|
||||
f"{mcp_server_url}/mcp",
|
||||
json=mcp_request,
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if mcp_response.status_code != 200:
|
||||
print(f"\n❌ MCP tool call failed: {mcp_response.status_code}")
|
||||
print(f"Response: {mcp_response.text}")
|
||||
raise RuntimeError("MCP tool call failed")
|
||||
|
||||
mcp_result = mcp_response.json()
|
||||
|
||||
if "error" in mcp_result:
|
||||
print(f"\n❌ MCP tool returned error: {mcp_result['error']}")
|
||||
raise RuntimeError(f"MCP tool error: {mcp_result['error']}")
|
||||
|
||||
print("\n✓ MCP tool call succeeded!")
|
||||
print(f" Result: {mcp_result.get('result', {})}")
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 70)
|
||||
print("🎉 ADR-004 OAUTH FLOW TEST - SUCCESS")
|
||||
print("=" * 70)
|
||||
print(f"Provider: {provider}")
|
||||
print(f"MCP Server: {mcp_server_url}")
|
||||
print(f"Nextcloud: {nextcloud_host}")
|
||||
print("")
|
||||
print("✓ User consented to MCP server access")
|
||||
print("✓ User consented to offline_access (refresh tokens)")
|
||||
print("✓ MCP server stored master refresh token")
|
||||
print("✓ Client received MCP access token via PKCE")
|
||||
print("✓ MCP tool call succeeded")
|
||||
print("✓ MCP server exchanged tokens in background")
|
||||
print("✓ Nextcloud data fetched successfully")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
return {"success": True}
|
||||
|
||||
finally:
|
||||
server.shutdown()
|
||||
logger.info("Stopped callback server")
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Manual test for ADR-004 OAuth Hybrid Flow"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
choices=["nextcloud", "keycloak"],
|
||||
required=True,
|
||||
help="OAuth provider to test",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--mcp-server-url",
|
||||
default="http://localhost:8001",
|
||||
help="MCP server URL (default: http://localhost:8001)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--nextcloud-host",
|
||||
default="http://localhost:8080",
|
||||
help="Nextcloud host URL (default: http://localhost:8080)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
result = await test_oauth_manual(
|
||||
provider=args.provider,
|
||||
mcp_server_url=args.mcp_server_url,
|
||||
nextcloud_host=args.nextcloud_host,
|
||||
)
|
||||
|
||||
return 0 if result["success"] else 1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n⚠️ Test interrupted by user")
|
||||
return 1
|
||||
except Exception as e:
|
||||
logger.error(f"OAuth flow test failed: {e}", exc_info=True)
|
||||
print("\n" + "=" * 70)
|
||||
print("❌ ADR-004 OAUTH FLOW TEST - FAILED")
|
||||
print("=" * 70)
|
||||
print(f"Error: {e}")
|
||||
print("=" * 70)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
exit(exit_code)
|
||||
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ADR-004 OAuth Flow Test Script
|
||||
|
||||
Tests the complete Hybrid Flow implementation:
|
||||
1. User initiates OAuth at MCP server /oauth/authorize
|
||||
2. User consents to MCP server access (IdP)
|
||||
3. User consents to MCP server accessing Nextcloud (IdP/Nextcloud)
|
||||
4. MCP server receives master refresh token
|
||||
5. Client receives MCP access token
|
||||
6. Client calls MCP tool
|
||||
7. MCP server exchanges master refresh token for Nextcloud access token
|
||||
8. MCP server fetches data from Nextcloud on behalf of user
|
||||
|
||||
Usage:
|
||||
# Test with Nextcloud OIDC app
|
||||
uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud
|
||||
|
||||
# Test with Keycloak
|
||||
uv run python tests/manual/test_adr004_oauth_flow.py --provider keycloak
|
||||
|
||||
Requirements:
|
||||
- MCP server running with OAuth enabled
|
||||
- System web browser
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import webbrowser
|
||||
from base64 import urlsafe_b64encode
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from threading import Thread
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CallbackHandler(BaseHTTPRequestHandler):
|
||||
"""Handles OAuth callback redirect to localhost"""
|
||||
|
||||
authorization_code = None
|
||||
state = None
|
||||
|
||||
def do_GET(self):
|
||||
"""Handle GET request with authorization code"""
|
||||
parsed = urlparse(self.path)
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
# Ignore favicon requests
|
||||
if parsed.path == "/favicon.ico":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "image/x-icon")
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
CallbackHandler.authorization_code = params.get("code", [None])[0]
|
||||
CallbackHandler.state = params.get("state", [None])[0]
|
||||
|
||||
# Send success page
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
|
||||
code_display = (
|
||||
CallbackHandler.authorization_code[:50] + "..."
|
||||
if CallbackHandler.authorization_code
|
||||
else "No code received"
|
||||
)
|
||||
|
||||
html = """
|
||||
<html>
|
||||
<head><title>Authorization Success</title></head>
|
||||
<body>
|
||||
<h1 style="color: green;">✓ Authorization Successful</h1>
|
||||
<p>Authorization code received. You can close this window and return to the terminal.</p>
|
||||
<code style="background: #f0f0f0; padding: 10px; display: block; margin: 10px 0;">
|
||||
{}
|
||||
</code>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>
|
||||
""".format(code_display)
|
||||
self.wfile.write(html.encode())
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Log HTTP requests"""
|
||||
logger.info(f"Callback: {format % args}")
|
||||
|
||||
|
||||
def generate_pkce_challenge():
|
||||
"""Generate PKCE code verifier and challenge"""
|
||||
code_verifier = secrets.token_urlsafe(32)
|
||||
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||
code_challenge = urlsafe_b64encode(digest).decode().rstrip("=")
|
||||
return code_verifier, code_challenge
|
||||
|
||||
|
||||
# Note: Playwright automation functions removed - using system browser instead
|
||||
|
||||
|
||||
async def test_oauth_flow(
|
||||
provider: str,
|
||||
mcp_server_url: str,
|
||||
nextcloud_host: str,
|
||||
username: str,
|
||||
password: str,
|
||||
):
|
||||
"""
|
||||
Test complete ADR-004 OAuth flow using system browser.
|
||||
|
||||
Args:
|
||||
provider: "nextcloud" or "keycloak"
|
||||
mcp_server_url: MCP server URL (e.g., http://localhost:8001)
|
||||
nextcloud_host: Nextcloud instance URL
|
||||
username: Test user username (for documentation)
|
||||
password: Test user password (for documentation)
|
||||
"""
|
||||
logger.info(f"Starting ADR-004 OAuth flow test with provider: {provider}")
|
||||
logger.info(f"MCP Server: {mcp_server_url}")
|
||||
logger.info(f"Nextcloud Host: {nextcloud_host}")
|
||||
|
||||
# Generate PKCE challenge
|
||||
code_verifier, code_challenge = generate_pkce_challenge()
|
||||
logger.info(f"✓ Generated PKCE challenge: {code_challenge[:16]}...")
|
||||
|
||||
# Generate state for CSRF protection
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Start local HTTP server for OAuth callback
|
||||
callback_port = 8765
|
||||
redirect_uri = f"http://localhost:{callback_port}/callback"
|
||||
|
||||
server = HTTPServer(("localhost", callback_port), CallbackHandler)
|
||||
server_thread = Thread(target=server.serve_forever, daemon=True)
|
||||
server_thread.start()
|
||||
logger.info(f"✓ Started callback server at {redirect_uri}")
|
||||
|
||||
try:
|
||||
# Step 1: Build authorization URL
|
||||
auth_params = {
|
||||
"response_type": "code",
|
||||
"client_id": "test-mcp-client",
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": "openid profile email offline_access notes:read notes:write",
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
|
||||
auth_url = f"{mcp_server_url}/oauth/authorize?{urlencode(auth_params)}"
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("STEP 1: AUTHORIZE IN BROWSER")
|
||||
print("=" * 70)
|
||||
print(f"\n📋 Opening browser to: {auth_url[:80]}...")
|
||||
print(f"\n📌 Login with: {username} / {password}")
|
||||
print("📌 Then authorize the MCP server")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
# Step 2: Open system browser
|
||||
logger.info("Opening system browser for OAuth flow...")
|
||||
webbrowser.open(auth_url)
|
||||
|
||||
logger.info("⏳ Waiting for authorization callback (timeout: 5 minutes)...")
|
||||
|
||||
# Wait for callback
|
||||
timeout = 300 # 5 minutes
|
||||
elapsed = 0
|
||||
while not CallbackHandler.authorization_code and elapsed < timeout:
|
||||
await asyncio.sleep(1)
|
||||
elapsed += 1
|
||||
|
||||
if not CallbackHandler.authorization_code:
|
||||
raise RuntimeError("Timeout waiting for authorization code")
|
||||
|
||||
# Step 3: Verify we received authorization code
|
||||
authorization_code = CallbackHandler.authorization_code
|
||||
returned_state = CallbackHandler.state
|
||||
|
||||
if not authorization_code:
|
||||
raise RuntimeError("Failed to receive authorization code from callback")
|
||||
|
||||
logger.info(f"✓ Received MCP authorization code: {authorization_code[:16]}...")
|
||||
|
||||
# Verify state matches (CSRF protection)
|
||||
if returned_state != state:
|
||||
raise RuntimeError(
|
||||
f"State mismatch! Expected {state}, got {returned_state}"
|
||||
)
|
||||
logger.info("✓ State parameter verified (CSRF protection)")
|
||||
|
||||
# Step 4: Exchange authorization code for access token
|
||||
logger.info("Exchanging authorization code for access token...")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
f"{mcp_server_url}/oauth/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": authorization_code,
|
||||
"code_verifier": code_verifier,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": "test-mcp-client",
|
||||
},
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
logger.error(f"Token exchange failed: {token_response.status_code}")
|
||||
logger.error(f"Response: {token_response.text}")
|
||||
raise RuntimeError(
|
||||
f"Token exchange failed: {token_response.status_code}"
|
||||
)
|
||||
|
||||
token_data = token_response.json()
|
||||
access_token = token_data["access_token"]
|
||||
|
||||
logger.info("✓ Successfully received access token")
|
||||
logger.info(f" Token: {access_token[:20]}...")
|
||||
logger.info(f" Type: {token_data.get('token_type', 'Bearer')}")
|
||||
logger.info(f" Expires in: {token_data.get('expires_in', 'unknown')}s")
|
||||
|
||||
# Step 5: Use access token to call MCP tool
|
||||
logger.info("Testing MCP tool call with access token...")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Call MCP server to list notes (this will trigger token exchange in background)
|
||||
mcp_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "nc_notes_search_notes",
|
||||
"arguments": {"query": "test"},
|
||||
},
|
||||
}
|
||||
|
||||
mcp_response = await client.post(
|
||||
f"{mcp_server_url}/mcp",
|
||||
json=mcp_request,
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if mcp_response.status_code != 200:
|
||||
logger.error(f"MCP tool call failed: {mcp_response.status_code}")
|
||||
logger.error(f"Response: {mcp_response.text}")
|
||||
raise RuntimeError(f"MCP tool call failed: {mcp_response.status_code}")
|
||||
|
||||
mcp_result = mcp_response.json()
|
||||
|
||||
if "error" in mcp_result:
|
||||
logger.error(f"MCP tool returned error: {mcp_result['error']}")
|
||||
raise RuntimeError(f"MCP tool error: {mcp_result['error']}")
|
||||
|
||||
logger.info("✓ MCP tool call succeeded!")
|
||||
logger.info(f" Result: {mcp_result.get('result', {})}")
|
||||
|
||||
# Step 6: Verify refresh token storage
|
||||
logger.info("Verifying refresh token storage...")
|
||||
|
||||
# Check if refresh token was stored (requires database access)
|
||||
# This would require accessing the SQLite database directly
|
||||
logger.info("✓ OAuth flow completed successfully!")
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 70)
|
||||
print("ADR-004 OAUTH FLOW TEST - SUCCESS")
|
||||
print("=" * 70)
|
||||
print(f"Provider: {provider}")
|
||||
print(f"MCP Server: {mcp_server_url}")
|
||||
print(f"Nextcloud: {nextcloud_host}")
|
||||
print(f"User: {username}")
|
||||
print("")
|
||||
print("✓ User consented to MCP server access")
|
||||
print("✓ User consented to offline_access (refresh tokens)")
|
||||
print("✓ MCP server stored master refresh token")
|
||||
print("✓ Client received MCP access token")
|
||||
print("✓ MCP tool call succeeded")
|
||||
print("✓ MCP server exchanged tokens in background")
|
||||
print("✓ Nextcloud data fetched successfully")
|
||||
print("=" * 70)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"access_token": access_token,
|
||||
"provider": provider,
|
||||
}
|
||||
|
||||
finally:
|
||||
server.shutdown()
|
||||
logger.info("Stopped callback server")
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Test ADR-004 OAuth Hybrid Flow",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Test with Nextcloud OIDC
|
||||
uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud
|
||||
|
||||
# Test with Keycloak
|
||||
uv run python tests/manual/test_adr004_oauth_flow.py --provider keycloak
|
||||
|
||||
# Headless mode
|
||||
uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud --headless
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
choices=["nextcloud", "keycloak"],
|
||||
required=True,
|
||||
help="OAuth provider to test (nextcloud or keycloak)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--mcp-server-url",
|
||||
default="http://localhost:8001",
|
||||
help="MCP server URL (default: http://localhost:8001 for OAuth)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--nextcloud-host",
|
||||
default="http://localhost:8080",
|
||||
help="Nextcloud host URL (default: http://localhost:8080)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--username", default="admin", help="Test user username (default: admin)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--password", default="admin", help="Test user password (default: admin)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
result = await test_oauth_flow(
|
||||
provider=args.provider,
|
||||
mcp_server_url=args.mcp_server_url,
|
||||
nextcloud_host=args.nextcloud_host,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
)
|
||||
|
||||
return 0 if result["success"] else 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OAuth flow test failed: {e}", exc_info=True)
|
||||
print("\n" + "=" * 70)
|
||||
print("ADR-004 OAUTH FLOW TEST - FAILED")
|
||||
print("=" * 70)
|
||||
print(f"Error: {e}")
|
||||
print("=" * 70)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
exit(exit_code)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Unit tests for user info routes.
|
||||
|
||||
Note: Most unit tests were removed as they relied on the old _get_user_info API.
|
||||
The new browser OAuth session-based implementation is covered by integration tests
|
||||
in tests/server/oauth/test_userinfo_integration.py which test the full OAuth flow
|
||||
with real browser sessions, token storage, and IdP interactions.
|
||||
|
||||
These unit tests cover only the simple _query_idp_userinfo helper function.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
async def test_query_idp_userinfo_success(mocker):
|
||||
"""Test successful IdP userinfo query."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"sub": "alice",
|
||||
"email": "alice@example.com",
|
||||
"name": "Alice Smith",
|
||||
}
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
# Mock the async context manager properly
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.auth.userinfo_routes.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
result = await _query_idp_userinfo("test_token", "https://example.com/userinfo")
|
||||
|
||||
assert result == {
|
||||
"sub": "alice",
|
||||
"email": "alice@example.com",
|
||||
"name": "Alice Smith",
|
||||
}
|
||||
mock_client.get.assert_called_once_with(
|
||||
"https://example.com/userinfo",
|
||||
headers={"Authorization": "Bearer test_token"},
|
||||
)
|
||||
|
||||
|
||||
async def test_query_idp_userinfo_failure(mocker):
|
||||
"""Test IdP userinfo query failure handling."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = Exception("Network error")
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.auth.userinfo_routes.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
result = await _query_idp_userinfo("test_token", "https://example.com/userinfo")
|
||||
|
||||
assert result is None
|
||||
@@ -394,11 +394,13 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
||||
nc_mcp_oauth_client_no_custom_scopes,
|
||||
):
|
||||
"""
|
||||
Test that a JWT token with only OIDC default scopes (no nc:read or nc:write) returns 0 tools.
|
||||
Test that a JWT token with only OIDC default scopes shows only OAuth provisioning tools.
|
||||
|
||||
This tests the security behavior when a user declines to grant custom scopes during consent.
|
||||
Expected: JWT token has scopes=['openid', 'profile', 'email'] but no nc:read or nc:write.
|
||||
All tools require at least one custom scope, so they should all be filtered out.
|
||||
Expected: JWT token has scopes=['openid', 'profile', 'email'] but no resource scopes.
|
||||
- Resource tools (notes:*, calendar:*, etc.) are filtered out
|
||||
- OAuth provisioning tools (requiring only 'openid') remain visible
|
||||
so users can provision Nextcloud access after authentication
|
||||
"""
|
||||
import logging
|
||||
|
||||
@@ -410,16 +412,24 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(
|
||||
f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 0)"
|
||||
f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 3 OAuth tools)"
|
||||
)
|
||||
|
||||
# All tools require nc:read or nc:write, so should be filtered out
|
||||
assert len(tool_names) == 0, (
|
||||
f"Expected 0 tools but got {len(tool_names)}: {tool_names[:10]}"
|
||||
# Only OAuth provisioning tools should be visible (they require 'openid' scope)
|
||||
expected_oauth_tools = [
|
||||
"provision_nextcloud_access",
|
||||
"revoke_nextcloud_access",
|
||||
"check_provisioning_status",
|
||||
]
|
||||
|
||||
assert set(tool_names) == set(expected_oauth_tools), (
|
||||
f"Expected only OAuth provisioning tools {expected_oauth_tools} "
|
||||
f"but got {tool_names}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ JWT token without custom scopes correctly returns 0 tools (all filtered out)"
|
||||
f"✅ JWT token with only openid scope correctly shows {len(tool_names)} OAuth provisioning tools, "
|
||||
"resource tools filtered out"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
"""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()
|
||||
|
||||
# Expose encryption key for tests that need to manually encrypt/decrypt
|
||||
storage._test_encryption_key = encryption_key
|
||||
|
||||
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
|
||||
encryption_key = token_storage._test_encryption_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."""
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
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."""
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# Use the same encryption key as token_storage/token_broker
|
||||
fernet = Fernet(token_storage._test_encryption_key)
|
||||
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"
|
||||
|
||||
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
|
||||
|
||||
# Use the same encryption key as token_storage/token_broker
|
||||
fernet = Fernet(token_storage._test_encryption_key)
|
||||
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."""
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# Use the same encryption key as token_storage/token_broker
|
||||
fernet = Fernet(token_storage._test_encryption_key)
|
||||
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
|
||||
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
Unit tests for Token Broker Service (ADR-004 Progressive Consent).
|
||||
|
||||
Tests the token management, caching, and refresh logic without
|
||||
requiring real network calls or database connections.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService, TokenCache
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def encryption_key():
|
||||
"""Generate test encryption key."""
|
||||
return Fernet.generate_key().decode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_storage():
|
||||
"""Mock RefreshTokenStorage."""
|
||||
storage = AsyncMock()
|
||||
storage.get_refresh_token = AsyncMock(return_value=None)
|
||||
storage.store_refresh_token = AsyncMock()
|
||||
storage.delete_refresh_token = AsyncMock()
|
||||
return storage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_oidc_config():
|
||||
"""Mock OIDC configuration."""
|
||||
return {
|
||||
"issuer": "https://idp.example.com",
|
||||
"token_endpoint": "https://idp.example.com/token",
|
||||
"revocation_endpoint": "https://idp.example.com/revoke",
|
||||
"jwks_uri": "https://idp.example.com/jwks",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def token_broker(mock_storage, encryption_key):
|
||||
"""Create TokenBrokerService instance."""
|
||||
broker = TokenBrokerService(
|
||||
storage=mock_storage,
|
||||
oidc_discovery_url="https://idp.example.com/.well-known/openid-configuration",
|
||||
nextcloud_host="https://nextcloud.example.com",
|
||||
encryption_key=encryption_key,
|
||||
cache_ttl=300,
|
||||
)
|
||||
yield broker
|
||||
await broker.close()
|
||||
|
||||
|
||||
class TestTokenCache:
|
||||
"""Test the TokenCache component."""
|
||||
|
||||
async def test_cache_stores_and_retrieves_token(self):
|
||||
"""Test basic cache storage and retrieval."""
|
||||
cache = TokenCache(ttl_seconds=60)
|
||||
|
||||
# Store token with sufficient expiry time (more than 30s threshold)
|
||||
await cache.set("user1", "test_token", expires_in=120)
|
||||
|
||||
# Retrieve token
|
||||
token = await cache.get("user1")
|
||||
assert token == "test_token"
|
||||
|
||||
async def test_cache_respects_ttl(self):
|
||||
"""Test that cache respects TTL."""
|
||||
# Create cache with 1 second TTL and 0 second early refresh
|
||||
cache = TokenCache(ttl_seconds=1, early_refresh_seconds=0)
|
||||
|
||||
# Store token
|
||||
await cache.set("user1", "test_token")
|
||||
|
||||
# Token should be available immediately
|
||||
assert await cache.get("user1") == "test_token"
|
||||
|
||||
# Wait for TTL to expire
|
||||
await asyncio.sleep(1.1)
|
||||
|
||||
# Token should be expired
|
||||
assert await cache.get("user1") is None
|
||||
|
||||
async def test_cache_early_refresh(self):
|
||||
"""Test that cache returns None for tokens expiring soon."""
|
||||
cache = TokenCache(ttl_seconds=60)
|
||||
|
||||
# Store token that expires in 25 seconds (less than 30s threshold)
|
||||
await cache.set("user1", "test_token", expires_in=25)
|
||||
|
||||
# Should return None as it's expiring soon (within 30s)
|
||||
assert await cache.get("user1") is None
|
||||
|
||||
async def test_cache_invalidation(self):
|
||||
"""Test cache invalidation."""
|
||||
cache = TokenCache(ttl_seconds=60)
|
||||
|
||||
# Store and verify token
|
||||
await cache.set("user1", "test_token")
|
||||
assert await cache.get("user1") == "test_token"
|
||||
|
||||
# Invalidate
|
||||
await cache.invalidate("user1")
|
||||
|
||||
# Should be removed
|
||||
assert await cache.get("user1") is None
|
||||
|
||||
|
||||
class TestTokenBrokerService:
|
||||
"""Test the TokenBrokerService."""
|
||||
|
||||
async def test_has_nextcloud_provisioning(self, token_broker, mock_storage):
|
||||
"""Test checking if user has provisioned Nextcloud access."""
|
||||
# No provisioning
|
||||
mock_storage.get_refresh_token.return_value = None
|
||||
assert await token_broker.has_nextcloud_provisioning("user1") is False
|
||||
|
||||
# Has provisioning
|
||||
mock_storage.get_refresh_token.return_value = {
|
||||
"refresh_token": "encrypted_token",
|
||||
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
|
||||
}
|
||||
assert await token_broker.has_nextcloud_provisioning("user1") is True
|
||||
|
||||
async def test_get_nextcloud_token_from_cache(self, token_broker):
|
||||
"""Test getting token from cache."""
|
||||
# Pre-populate cache
|
||||
await token_broker.cache.set("user1", "cached_token", expires_in=300)
|
||||
|
||||
# Should return cached token without calling storage
|
||||
token = await token_broker.get_nextcloud_token("user1")
|
||||
assert token == "cached_token"
|
||||
token_broker.storage.get_refresh_token.assert_not_called()
|
||||
|
||||
async def test_get_nextcloud_token_refresh(
|
||||
self, token_broker, mock_storage, encryption_key, mock_oidc_config
|
||||
):
|
||||
"""Test getting token via refresh when not cached."""
|
||||
# Setup encrypted refresh token in storage
|
||||
fernet = Fernet(encryption_key.encode())
|
||||
encrypted_token = fernet.encrypt(b"test_refresh_token").decode()
|
||||
mock_storage.get_refresh_token.return_value = {
|
||||
"refresh_token": encrypted_token,
|
||||
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
|
||||
}
|
||||
|
||||
# Mock HTTP client for token refresh
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"access_token": "new_access_token",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
token_broker, "_get_oidc_config", return_value=mock_oidc_config
|
||||
):
|
||||
with patch.object(token_broker, "_get_http_client") as mock_client:
|
||||
mock_client.return_value.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Get token (should refresh)
|
||||
token = await token_broker.get_nextcloud_token("user1")
|
||||
|
||||
assert token == "new_access_token"
|
||||
# Verify token was cached
|
||||
cached = await token_broker.cache.get("user1")
|
||||
assert cached == "new_access_token"
|
||||
|
||||
async def test_get_nextcloud_token_no_provisioning(
|
||||
self, token_broker, mock_storage
|
||||
):
|
||||
"""Test getting token when user hasn't provisioned."""
|
||||
mock_storage.get_refresh_token.return_value = None
|
||||
|
||||
token = await token_broker.get_nextcloud_token("user1")
|
||||
assert token is None
|
||||
|
||||
async def test_refresh_master_token(
|
||||
self, token_broker, mock_storage, encryption_key, mock_oidc_config
|
||||
):
|
||||
"""Test master refresh token rotation."""
|
||||
# Setup current refresh token
|
||||
fernet = Fernet(encryption_key.encode())
|
||||
encrypted_token = fernet.encrypt(b"current_refresh_token").decode()
|
||||
mock_storage.get_refresh_token.return_value = {
|
||||
"refresh_token": encrypted_token,
|
||||
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
|
||||
}
|
||||
|
||||
# Mock successful refresh response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"access_token": "new_access",
|
||||
"refresh_token": "new_refresh_token",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
token_broker, "_get_oidc_config", return_value=mock_oidc_config
|
||||
):
|
||||
with patch.object(token_broker, "_get_http_client") as mock_client:
|
||||
mock_client.return_value.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Rotate token
|
||||
success = await token_broker.refresh_master_token("user1")
|
||||
|
||||
assert success is True
|
||||
# Verify new token was stored
|
||||
mock_storage.store_refresh_token.assert_called_once()
|
||||
call_args = mock_storage.store_refresh_token.call_args[1]
|
||||
assert call_args["user_id"] == "user1"
|
||||
# Decrypt to verify it's the new token
|
||||
stored_token = fernet.decrypt(
|
||||
call_args["refresh_token"].encode()
|
||||
).decode()
|
||||
assert stored_token == "new_refresh_token"
|
||||
|
||||
async def test_refresh_master_token_no_rotation(
|
||||
self, token_broker, mock_storage, encryption_key, mock_oidc_config
|
||||
):
|
||||
"""Test when IdP returns same refresh token (no rotation)."""
|
||||
# Setup current refresh token
|
||||
fernet = Fernet(encryption_key.encode())
|
||||
encrypted_token = fernet.encrypt(b"same_refresh_token").decode()
|
||||
mock_storage.get_refresh_token.return_value = {
|
||||
"refresh_token": encrypted_token,
|
||||
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
|
||||
}
|
||||
|
||||
# Mock response with same refresh token
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"access_token": "new_access",
|
||||
"refresh_token": "same_refresh_token",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
token_broker, "_get_oidc_config", return_value=mock_oidc_config
|
||||
):
|
||||
with patch.object(token_broker, "_get_http_client") as mock_client:
|
||||
mock_client.return_value.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
success = await token_broker.refresh_master_token("user1")
|
||||
|
||||
assert success is True
|
||||
# Should not store if token didn't change
|
||||
mock_storage.store_refresh_token.assert_not_called()
|
||||
|
||||
async def test_revoke_nextcloud_access(
|
||||
self, token_broker, mock_storage, encryption_key, mock_oidc_config
|
||||
):
|
||||
"""Test revoking Nextcloud access."""
|
||||
# Setup refresh token for revocation
|
||||
fernet = Fernet(encryption_key.encode())
|
||||
encrypted_token = fernet.encrypt(b"token_to_revoke").decode()
|
||||
mock_storage.get_refresh_token.return_value = {
|
||||
"refresh_token": encrypted_token,
|
||||
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
|
||||
}
|
||||
|
||||
# Mock revocation response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(
|
||||
token_broker, "_get_oidc_config", return_value=mock_oidc_config
|
||||
):
|
||||
with patch.object(token_broker, "_get_http_client") as mock_client:
|
||||
mock_client.return_value.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Pre-populate cache
|
||||
await token_broker.cache.set("user1", "cached_token")
|
||||
|
||||
# Revoke access
|
||||
success = await token_broker.revoke_nextcloud_access("user1")
|
||||
|
||||
assert success is True
|
||||
# Verify token was deleted from storage
|
||||
mock_storage.delete_refresh_token.assert_called_once_with("user1")
|
||||
# Verify cache was cleared
|
||||
assert await token_broker.cache.get("user1") is None
|
||||
|
||||
async def test_validate_token_audience(self, token_broker):
|
||||
"""Test token audience validation."""
|
||||
# Create test token with audience
|
||||
test_payload = {
|
||||
"sub": "user1",
|
||||
"aud": ["nextcloud", "other-service"],
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
}
|
||||
test_token = jwt.encode(test_payload, "secret", algorithm="HS256")
|
||||
|
||||
# Should not raise for correct audience
|
||||
await token_broker._validate_token_audience(test_token, "nextcloud")
|
||||
|
||||
# Should raise for wrong audience
|
||||
with pytest.raises(ValueError, match="doesn't include wrong-audience"):
|
||||
await token_broker._validate_token_audience(test_token, "wrong-audience")
|
||||
|
||||
async def test_token_refresh_with_network_error(
|
||||
self, token_broker, mock_storage, encryption_key
|
||||
):
|
||||
"""Test handling network errors during token refresh."""
|
||||
# Setup encrypted refresh token
|
||||
fernet = Fernet(encryption_key.encode())
|
||||
encrypted_token = fernet.encrypt(b"test_refresh_token").decode()
|
||||
mock_storage.get_refresh_token.return_value = {
|
||||
"refresh_token": encrypted_token,
|
||||
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
|
||||
}
|
||||
|
||||
# Mock network error
|
||||
with patch.object(token_broker, "_get_http_client") as mock_client:
|
||||
mock_client.return_value.post = AsyncMock(
|
||||
side_effect=httpx.NetworkError("Connection failed")
|
||||
)
|
||||
|
||||
# Should return None on error
|
||||
token = await token_broker.get_nextcloud_token("user1")
|
||||
assert token is None
|
||||
|
||||
# Cache should be invalidated
|
||||
assert await token_broker.cache.get("user1") is None
|
||||
|
||||
async def test_concurrent_cache_access(self, token_broker):
|
||||
"""Test concurrent access to token cache."""
|
||||
# Pre-populate cache
|
||||
await token_broker.cache.set("user1", "token1", expires_in=300)
|
||||
await token_broker.cache.set("user2", "token2", expires_in=300)
|
||||
|
||||
# Concurrent reads
|
||||
results = await asyncio.gather(
|
||||
token_broker.cache.get("user1"),
|
||||
token_broker.cache.get("user2"),
|
||||
token_broker.cache.get("user1"),
|
||||
token_broker.cache.get("user2"),
|
||||
)
|
||||
|
||||
assert results == ["token1", "token2", "token1", "token2"]
|
||||
Vendored
+1
-1
Submodule third_party/oidc updated: 84f31d302f...19cad6e5b6
@@ -75,6 +75,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "authlib"
|
||||
version = "1.6.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/1d3bbd0bf23bdd99276d4def22f29c27a914067b4cf66f753ff9b8bbd0f3/authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b", size = 164553, upload-time = "2025-10-02T13:36:09.489Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "caldav"
|
||||
version = "2.0.2.dev38+g1aa2be35e"
|
||||
@@ -489,6 +501,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" },
|
||||
@@ -498,6 +512,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
|
||||
@@ -507,6 +523,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
|
||||
@@ -514,6 +532,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
|
||||
]
|
||||
|
||||
@@ -954,10 +974,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "authlib" },
|
||||
{ name = "caldav" },
|
||||
{ name = "click" },
|
||||
{ name = "httpx" },
|
||||
@@ -981,11 +1002,13 @@ dev = [
|
||||
{ name = "pytest-timeout" },
|
||||
{ name = "reportlab" },
|
||||
{ name = "ruff" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiosqlite", specifier = ">=0.20.0" },
|
||||
{ name = "authlib", specifier = ">=1.6.5" },
|
||||
{ name = "caldav", git = "https://github.com/cbcoutinho/caldav?branch=feature%2Fhttpx" },
|
||||
{ name = "click", specifier = ">=8.1.8" },
|
||||
{ name = "httpx", specifier = ">=0.28.1,<0.29.0" },
|
||||
@@ -1009,6 +1032,7 @@ dev = [
|
||||
{ name = "pytest-timeout", specifier = ">=2.3.1" },
|
||||
{ name = "reportlab", specifier = ">=4.0.0" },
|
||||
{ name = "ruff", specifier = ">=0.11.13" },
|
||||
{ name = "ty", specifier = ">=0.0.1a25" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1940,6 +1964,31 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.1a25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/6b/e73bc3c1039ea72936158a08313155a49e5aa5e7db5205a149fe516a4660/ty-0.0.1a25.tar.gz", hash = "sha256:5550b24b9dd0e0f8b4b2c1f0fcc608a55d0421dd67b6c364bc7bf25762334511", size = 4403670, upload-time = "2025-10-29T19:40:23.647Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3b/4457231238a2eeb04cba4ba7cc33d735be68ee46ca40a98ae30e187de864/ty-0.0.1a25-py3-none-linux_armv6l.whl", hash = "sha256:d35b2c1f94a014a22875d2745aa0432761d2a9a8eb7212630d5caf547daeef6d", size = 8878803, upload-time = "2025-10-29T19:39:42.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/fa/a328713dd310018fc7a381693d8588185baa2fdae913e01a6839187215df/ty-0.0.1a25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:192edac94675a468bac7f6e04687a77a64698e4e1fe01f6a048bf9b6dde5b703", size = 8695667, upload-time = "2025-10-29T19:39:45.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e8/5707939118992ced2bf5385adc3ede7723c1b717b07ad14c495eea1e47b4/ty-0.0.1a25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:949523621f336e01bc7d687b7bd08fe838edadbdb6563c2c057ed1d264e820cf", size = 8159012, upload-time = "2025-10-29T19:39:47.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/fb/ff313aa71602225cd78f1bce3017713d6d1b1c1e0fa8101ead4594a60d95/ty-0.0.1a25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f78f621458c05e59e890061021198197f29a7b51a33eda82bbb036e7ed73d7", size = 8433675, upload-time = "2025-10-29T19:39:48.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/8d/cc7e7fb57215a15b575a43ed042bdd92971871e0decec1b26d2e7d969465/ty-0.0.1a25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d9656fca8062a2c6709c30d76d662c96d2e7dbfee8f70e55ec6b6afd67b5d447", size = 8668456, upload-time = "2025-10-29T19:39:50.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/6d/d7bf5909ed2dcdcbc1e2ca7eea80929893e2d188d9c36b3fcb2b36532ff6/ty-0.0.1a25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9f3bbf523b49935bbd76e230408d858dce0d614f44f5807bbbd0954f64e0f01", size = 9023543, upload-time = "2025-10-29T19:39:52.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/b8/72bcefb4be32e5a84f0b21de2552f16cdb4cae3eb271ac891c8199c26b1a/ty-0.0.1a25-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f13ea9815f4a54a0a303ca7bf411b0650e3c2a24fc6c7889ffba2c94f5e97a6a", size = 9700013, upload-time = "2025-10-29T19:39:57.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/0d/cf7e794b840cf6b0bbecb022e593c543f85abad27a582241cf2095048cb1/ty-0.0.1a25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eab6e33ebe202a71a50c3d5a5580e3bc1a85cda3ffcdc48cec3f1c693b7a873b", size = 9372574, upload-time = "2025-10-29T19:40:04.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/71/2d35e7d51b48eabd330e2f7b7e0bce541cbd95950c4d2f780e85f3366af1/ty-0.0.1a25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6b9a31da43424cdab483703a54a561b93aabba84630788505329fc5294a9c62", size = 9535726, upload-time = "2025-10-29T19:40:06.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d3/01ecc23bbd8f3e0dfbcf9172d06d84e88155c5f416f1491137e8066fd859/ty-0.0.1a25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a90d897a7c1a5ae9b41a4c7b0a42262a06361476ad88d783dbedd7913edadbc", size = 9003380, upload-time = "2025-10-29T19:40:08.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/f9/cde9380d8a1a6ca61baeb9aecb12cbec90d489aa929be55cd78ad5c2ccd9/ty-0.0.1a25-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:93c7e7ab2859af0f866d34d27f4ae70dd4fb95b847387f082de1197f9f34e068", size = 8401833, upload-time = "2025-10-29T19:40:10.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/39/0acf3625b0c495011795a391016b572f97a812aca1d67f7a76621fdb9ebf/ty-0.0.1a25-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a247061bd32bae3865a236d7f8b6c9916c80995db30ae1600999010f90623a9", size = 8706761, upload-time = "2025-10-29T19:40:12.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/73/7de1648f3563dd9d416d36ab5f1649bfd7b47a179135027f31d44b89a246/ty-0.0.1a25-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1711dd587eccf04fd50c494dc39babe38f4cb345bc3901bf1d8149cac570e979", size = 8792426, upload-time = "2025-10-29T19:40:14.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/8a/b6e761a65eac7acd10b2e452f49b2d8ae0ea163ca36bb6b18b2dadae251b/ty-0.0.1a25-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f4c9b0cf7995e2e3de9bab4d066063dea92019f2f62673b7574e3612643dd35", size = 9103991, upload-time = "2025-10-29T19:40:16.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/25/9324ae947fcc4322470326cf8276a3fc2f08dc82adec1de79d963fdf7af5/ty-0.0.1a25-py3-none-win32.whl", hash = "sha256:168fc8aee396d617451acc44cd28baffa47359777342836060c27aa6f37e2445", size = 8387095, upload-time = "2025-10-29T19:40:18.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2b/cb12cbc7db1ba310aa7b1de9b4e018576f653105993736c086ee67d2ec02/ty-0.0.1a25-py3-none-win_amd64.whl", hash = "sha256:a2fad3d8e92bb4d57a8872a6f56b1aef54539d36f23ebb01abe88ac4338efafb", size = 9059225, upload-time = "2025-10-29T19:40:20.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/c1/f6be8cdd0bf387c1d8ee9d14bb299b7b5d2c0532f550a6693216a32ec0c5/ty-0.0.1a25-py3-none-win_arm64.whl", hash = "sha256:dde2962d448ed87c48736e9a4bb13715a4cced705525e732b1c0dac1d4c66e3d", size = 8536832, upload-time = "2025-10-29T19:40:22.014Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.19.2"
|
||||
|
||||
Reference in New Issue
Block a user