diff --git a/CLAUDE.md b/CLAUDE.md index 05a5b617..8b93202e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -300,7 +300,7 @@ Use `scripts/sqlitequery.py` for all SQLite queries: **For detailed architecture, see:** - `docs/comparison-context-agent.md` - Overall architecture -- `docs/oauth-architecture.md` - OAuth integration patterns +- `docs/login-flow-v2.md` - OAuth/OIDC integration patterns and architecture - `docs/ADR-004-progressive-consent.md` - Progressive consent implementation **Core Components**: @@ -509,9 +509,8 @@ docker compose exec app php occ user_oidc:provider keycloak **Credentials**: admin/admin (Keycloak realm: `nextcloud-mcp`) **For detailed Keycloak setup, see**: -- `docs/oauth-setup.md` - OAuth configuration +- `docs/login-flow-v2.md` - OAuth/OIDC configuration (set `OIDC_DISCOVERY_URL` to a Keycloak realm) - `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 ## Integration Testing with Docker diff --git a/docs/auth-flows.md b/docs/auth-flows.md index cf5df912..46d357f6 100644 --- a/docs/auth-flows.md +++ b/docs/auth-flows.md @@ -4,8 +4,8 @@ This document provides a unified reference for the auth flows in each supported ## Quick Reference Matrix -| Mode | Client → MCP → NC | Background Sync | Astrolabe → MCP | -|------|-------------------|-----------------|-----------------| +| Mode | Client → MCP → NC | Background Sync | Astrolabe (hosted UI) → MCP | +|------|-------------------|-----------------|------------------------------| | [Single-User BasicAuth](#1-single-user-basicauth) | Embedded credentials | Same credentials | N/A | | [Multi-User BasicAuth](#2-multi-user-basicauth) | Header pass-through | Stored app password (optional) | OAuth Bearer token | | [Login Flow v2](#3-login-flow-v2) | OAuth → MCP, app pwd → NC | Stored app password | OAuth Bearer token | @@ -141,8 +141,8 @@ This mode replaces the previously-supported "OAuth Single-Audience" and "OAuth T MCP Client MCP Server Nextcloud │ │ │ │── Bearer Token ────────────▶│ │ - │ (issued by MCP server, │ │ - │ mcp:* scopes) │ │ + │ (issued by configured IdP,│ │ + │ per-app scopes) │ │ │ │── Validate scopes ─────────│ │ │ (@require_scopes) │ │ │ │ @@ -159,7 +159,7 @@ MCP Client MCP Server Nextcloud **Key characteristics:** - MCP client authenticates to MCP server via OAuth 2.1 + PKCE - MCP server is an **OIDC relying party of a configurable IdP** (Nextcloud OIDC by default; Keycloak, AWS Cognito, etc. via `OIDC_DISCOVERY_URL`) + an OAuth facade for MCP clients. RFC 7591 DCR is used to register the MCP-client side; the server's own RP credentials come from `NEXTCLOUD_OIDC_CLIENT_ID/SECRET` (generic OIDC creds), with DCR fallback. Tokens are signed by the chosen IdP and validated against that IdP's JWKS. -- `mcp:*` scopes (e.g. `mcp:notes.read`, `mcp:notes.write`) gate tool access +- Per-app scopes (e.g. `notes.read`, `talk.read`, `files.write`) gate tool access — see [Login Flow v2 → Scope Reference](login-flow-v2.md#scope-reference) for the full list - Per-user app password obtained via Login Flow v2 (Nextcloud-specific protocol, used regardless of which IdP authenticated the client) and stored encrypted in SQLite - App passwords appear in Nextcloud's **Settings → Security → Devices & Sessions** and are user-revocable diff --git a/docs/authentication.md b/docs/authentication.md index 82ee2fee..bdc826b9 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -60,10 +60,10 @@ ENABLE_MULTI_USER_BASIC_AUTH=true The recommended mode for hosted and OAuth-based deployments. MCP clients authenticate to the MCP server via OAuth; the MCP server obtains a per-user app password from Nextcloud (via Login Flow v2) and uses HTTP Basic Auth to talk to Nextcloud APIs. ``` -MCP Client ──(OAuth, mcp:* scopes)──> MCP Server ──(Basic Auth, app password)──> Nextcloud +MCP Client ──(OAuth, per-app scopes)──> MCP Server ──(Basic Auth, app password)──> Nextcloud ``` -The MCP server enforces `mcp:*` scopes at the application layer (defense-in-depth, since Nextcloud app passwords have no native scope support). +The MCP server enforces per-app scopes (`notes.read`, `talk.write`, `files.read`, etc. — see [Login Flow v2 → Scope Reference](login-flow-v2.md#scope-reference)) at the application layer (defense-in-depth, since Nextcloud app passwords have no native scope support). **See [Login Flow v2](login-flow-v2.md) for full setup, architecture, scope reference, and troubleshooting.** diff --git a/docs/configuration.md b/docs/configuration.md index 2e4fb01b..513ef05c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -108,8 +108,11 @@ NEXTCLOUD_PUBLIC_ISSUER_URL=https://your.nextcloud.instance.com | `ENABLE_LOGIN_FLOW` | ✅ Yes | Set to `true` to enable Login Flow v2 | | `TOKEN_ENCRYPTION_KEY` | ✅ Yes | Fernet key for app-password encryption — generate with `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` | | `TOKEN_STORAGE_DB` | ✅ Yes | Path to SQLite DB for stored app passwords (use a persistent volume) | -| `NEXTCLOUD_MCP_SERVER_URL` | ✅ Yes | Public URL of the MCP server (OAuth issuer) | +| `NEXTCLOUD_MCP_SERVER_URL` | ✅ Yes | Public URL of the MCP server (used as the audience claim and for browser redirects) | | `NEXTCLOUD_PUBLIC_ISSUER_URL` | ✅ Yes | Public URL of Nextcloud (for browser redirects during Login Flow v2) | +| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Optional (preferred) | OIDC client ID for the MCP server's relying-party registration with the IdP (Nextcloud OIDC by default; Keycloak / Cognito / etc. via `OIDC_DISCOVERY_URL`). If unset and the IdP advertises a `registration_endpoint`, RFC 7591 DCR is used as fallback. | +| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Optional (preferred) | OIDC client secret paired with `NEXTCLOUD_OIDC_CLIENT_ID`. | +| `OIDC_DISCOVERY_URL` | Optional | Override the IdP discovery URL. Defaults to `${NEXTCLOUD_HOST}/.well-known/openid-configuration` (Nextcloud's built-in OIDC). Set to a Keycloak realm or AWS Cognito user-pool discovery URL to use an external IdP. | See [Login Flow v2](login-flow-v2.md) for full setup, scope reference, and troubleshooting. diff --git a/docs/login-flow-v2.md b/docs/login-flow-v2.md index 850fcd0c..468d5321 100644 --- a/docs/login-flow-v2.md +++ b/docs/login-flow-v2.md @@ -11,7 +11,7 @@ Two authentication legs, each with a different mechanism: ``` ┌─────────────────┐ OAuth/OIDC ┌──────────────────┐ App password ┌─────────────────┐ │ MCP Client │ ───────────────> │ MCP Server │ ────────────────> │ Nextcloud │ -│ (Claude, etc.) │ (mcp:* scopes) │ (OIDC RP of IdP,│ (Basic Auth) │ (NC 16+) │ +│ (Claude, etc.) │ (per-app scopes)│ (OIDC RP of IdP,│ (Basic Auth) │ (NC 16+) │ │ │ │ OAuth facade, │ │ │ │ │ │ app-pwd holder) │ │ │ └─────────────────┘ └──────────────────┘ └─────────────────┘ @@ -26,7 +26,7 @@ Two authentication legs, each with a different mechanism: └─────────────────┘ ``` -- **MCP client → MCP server**: OAuth 2.1 with PKCE. The MCP server is **not** a standalone OAuth issuer — it acts as an OIDC relying party of a configurable identity provider and exposes an OAuth facade in front of it. The IdP is selected by `OIDC_DISCOVERY_URL` (defaults to Nextcloud's built-in OIDC); Keycloak, AWS Cognito, and other OIDC-compliant IdPs are also supported. Tokens are signed by that IdP, validated by the MCP server against the IdP's JWKS, and carry `mcp:*` scopes that gate which tools the user can call. +- **MCP client → MCP server**: OAuth 2.1 with PKCE. The MCP server is **not** a standalone OAuth issuer — it acts as an OIDC relying party of a configurable identity provider and exposes an OAuth facade in front of it. The IdP is selected by `OIDC_DISCOVERY_URL` (defaults to Nextcloud's built-in OIDC); Keycloak, AWS Cognito, and other OIDC-compliant IdPs are also supported. Tokens are signed by that IdP, validated by the MCP server against the IdP's JWKS, and carry per-app scopes (`notes.read`, `talk.read`, `files.write`, …) that gate which tools the user can call. - **MCP server → IdP (auth leg)**: The MCP server registers itself with the IdP via static `NEXTCLOUD_OIDC_CLIENT_ID`/`SECRET` (preferred — these are generic OIDC client credentials despite the Nextcloud-flavored naming, and work with any OIDC provider) or RFC 7591 DCR (fallback). This relationship is used for OIDC discovery, JWKS retrieval, and token validation. - **MCP server → Nextcloud (data leg)**: Per-user **app password** obtained via Nextcloud's native [Login Flow v2](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2). Sent as HTTP Basic Auth. Login Flow v2 is always Nextcloud's protocol regardless of which IdP authenticated the MCP client. @@ -123,7 +123,7 @@ Each user goes through provisioning **once**, the first time they connect. Subse └──────┬──────┘ └────────┬─────────┘ └────────┬────────┘ │ 1. OAuth PKCE │ │ ├─────────────────────────────────>│ │ - │ ← access token (mcp:* scopes) │ │ + │ ← access token (per-app scopes) │ │ │ │ │ │ 2. MCP request │ │ ├─────────────────────────────────>│ │ @@ -178,14 +178,27 @@ Nextcloud's app passwords have **no native scope support** — they grant the us ### Scope Reference -| Scope | Operations | Tool count | -|-------|------------|------------| -| `mcp:notes.read` | Read access (get, list, search) across all Nextcloud apps | ~36 tools | -| `mcp:notes.write` | Write access (create, update, delete) across all Nextcloud apps | ~54 tools | +Scopes are **per-app** and follow an `.` pattern. There is no `mcp:` prefix. -> The historical `notes.read` / `notes.write` naming covers all Nextcloud apps, not just the Notes app — it's a project-wide read/write distinction. +| Scope | Covers | +|-------|--------| +| `notes.read` / `notes.write` | Notes app | +| `talk.read` / `talk.write` | Talk (spreed) | +| `files.read` / `files.write` | Files / WebDAV | +| `calendar.read` / `calendar.write` | Calendar (events + tasks/VTODO) | +| `contacts.read` / `contacts.write` | Contacts (CardDAV) | +| `deck.read` / `deck.write` | Deck | +| `tables.read` / `tables.write` | Tables | +| `cookbook.read` / `cookbook.write` | Cookbook | +| `todo.read` / `todo.write` | Tasks (VTODO outside Calendar) | +| `collectives.read` / `collectives.write` | Collectives | +| `news.read` | News (read-only) | +| `sharing.write` | Share-link / share-permission management | +| `semantic.read` | Semantic search + RAG (when enabled) | -Standard OIDC scopes (`openid`, `profile`, `email`) are also supported and have no effect on tool access. +The authoritative list is enumerated at runtime by [`scope_authorization.discover_all_scopes()`](../nextcloud_mcp_server/auth/scope_authorization.py) from each tool's `@require_scopes(...)` decorator and exposed via the PRM endpoint (`/.well-known/oauth-protected-resource/mcp`). + +Standard OIDC scopes (`openid`, `profile`, `email`) are also accepted and have no effect on tool access. ### How Scopes Are Enforced @@ -193,7 +206,7 @@ Each MCP tool is decorated with `@require_scopes(...)`: ```python @mcp.tool() -@require_scopes("mcp:notes.read") +@require_scopes("notes.read") async def nc_notes_get_note(note_id: int, ctx: Context): ... ``` @@ -203,7 +216,7 @@ When a client calls `list_tools`, the server returns only tools the user has gra ```http HTTP/1.1 403 Forbidden WWW-Authenticate: Bearer error="insufficient_scope", - scope="mcp:notes.write", + scope="notes.write", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp" ``` @@ -267,7 +280,7 @@ The user revoked it from **Settings → Security → Devices & Sessions**. Delet The provisioning session store is in-memory; `ENABLE_LOGIN_FLOW=true` assumes a single worker. Running with `uvicorn --workers N` will cause provisioning sessions to randomly fail. For higher concurrency, scale horizontally (multiple containers behind a sticky-session load balancer) rather than within a single process. -> **Sticky-session keying:** route on the user's OAuth bearer token (or a session cookie tied to the user) — **not** on source IP. MCP clients may not maintain stable IPs between the request that initiates provisioning and the polling request that completes it, so IP-based affinity will silently break the flow. A stable per-user identifier from the `Authorization` header is the right key. +> **Sticky-session keying:** route on the **user identity** (e.g. the `sub` claim from the OAuth Bearer token) — **not** the raw token value, and **not** source IP. Bearer tokens rotate on refresh, which would silently break token-value affinity if a refresh lands between the request that initiates provisioning and the polling request that completes it. MCP clients may also not maintain stable IPs across those requests. A stable per-user identifier extracted from the `Authorization` header (e.g. `sub`) is the right key. ## See Also diff --git a/docs/running.md b/docs/running.md index 48e60e4c..1ba8d963 100644 --- a/docs/running.md +++ b/docs/running.md @@ -93,8 +93,6 @@ docker run -p 127.0.0.1:8000:8000 --env-file .env \ Create `docker-compose.yml`: ```yaml -version: '3.8' - services: mcp: image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest @@ -369,8 +367,6 @@ docker-compose logs -f mcp For production deployments, use Docker Compose with the recommended settings: ```yaml -version: '3.8' - services: mcp: image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest diff --git a/docs/testing-oidc-consent.md b/docs/testing-oidc-consent.md index 8c641b2e..13077283 100644 --- a/docs/testing-oidc-consent.md +++ b/docs/testing-oidc-consent.md @@ -190,7 +190,7 @@ docker compose logs -f mcp-oauth 1. Get client_id from the JWT client JSON 2. Visit in browser: ``` -http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:8001/oauth/callback&scope=openid+profile+email+mcp:notes.read+mcp:notes.write&state=test123 +http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:8001/oauth/callback&scope=openid+profile+email+notes.read+notes.write&state=test123 ``` ### 3. Expected Behavior @@ -203,8 +203,8 @@ http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type - ✓ Basic authentication (openid) - required, cannot deselect - ✓ Profile information (profile) - ✓ Email address (email) - - ✓ mcp:notes.read (custom scope, shown as-is) - - ✓ mcp:notes.write (custom scope, shown as-is) + - ✓ notes.read (custom scope, shown as-is) + - ✓ notes.write (custom scope, shown as-is) - "Allow" and "Deny" buttons 3. User selects scopes and clicks "Allow" 4. Authorization proceeds with selected scopes