Merge pull request #908 from cbcoutinho/docs/login-flow-static-oidc-client-907

docs(login-flow): require static OIDC client; remove dead token-exchange mode
This commit is contained in:
Chris Coutinho
2026-06-14 17:43:54 +02:00
committed by GitHub
27 changed files with 182 additions and 1017 deletions
+12 -8
View File
@@ -177,8 +177,10 @@ services:
- VECTOR_SYNC_SCAN_INTERVAL=30
- VECTOR_SYNC_PROCESSOR_WORKERS=1
# OAuth credentials for background sync (optional - uses DCR if not provided)
# Uncomment to avoid DCR:
# OAuth client for the MCP server's IdP registration. Falls back to DCR
# if unset — but with Nextcloud's built-in `oidc` app, DCR clients expire
# after ~1h (client_expire_time) and break auth permanently (see #907).
# For any non-CI deployment, register a static client and set these:
# - NEXTCLOUD_OIDC_CLIENT_ID=your_client_id
# - NEXTCLOUD_OIDC_CLIENT_SECRET=your_client_secret
@@ -249,12 +251,6 @@ services:
- TOKEN_ENCRYPTION_KEY=${TOKEN_ENCRYPTION_KEY:?TOKEN_ENCRYPTION_KEY must be set in .env (see env.sample)}
- TOKEN_STORAGE_DB=/app/data/tokens.db
# ADR-005: Token exchange mode (RFC 8693)
# Exchange MCP tokens (aud: nextcloud-mcp-server) for Nextcloud tokens (aud: http://localhost:8080)
# Provides strict audience separation between MCP session and Nextcloud API access
- ENABLE_TOKEN_EXCHANGE=true
- TOKEN_EXCHANGE_CACHE_TTL=300 # Cache exchanged tokens for 5 minutes (default)
# Login Flow v2 (ADR-022) with external IdP — derived from the
# auto-detected LOGIN_FLOW deployment mode; no separate flag needed.
- ENABLE_DCR=true
@@ -293,6 +289,14 @@ services:
# the browser-based app-password layer is derived automatically.
- MCP_DEPLOYMENT_MODE=login_flow
# NOTE: this dev/test service relies on Dynamic Client Registration for
# the MCP server's own OIDC client, which is fine for short-lived CI runs.
# For a real self-hosted login_flow deployment, register a STATIC client
# in Nextcloud (Administration → OpenID Connect provider) and set
# NEXTCLOUD_OIDC_CLIENT_ID / NEXTCLOUD_OIDC_CLIENT_SECRET — the built-in
# `oidc` app deletes DCR clients after ~1h, which breaks auth (see #907
# and docs/login-flow-v2.md → Troubleshooting).
# Token storage (required for app password + session persistence).
# Source the key from .env — see env.sample. To generate a fresh key:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
+1 -1
View File
@@ -6,7 +6,7 @@
## Status
~~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.
**Superseded by ADR-004**, and ultimately by [ADR-022](ADR-022-deployment-mode-consolidation.md) (Login Flow v2) + [ADR-023](ADR-023-oauth-as-proxy.md) (OAuth AS proxy). The token-exchange approach was removed; background vector sync now uses Login Flow v2 app passwords. The only supported deployment modes are `single_user_basic`, `multi_user_basic`, and `login_flow`.
**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.
-65
View File
@@ -1,65 +0,0 @@
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.
+19 -4
View File
@@ -1,6 +1,16 @@
# ADR-004: Federated Authentication Architecture for Offline Access
**Status**: Draft
> **⚠️ DEPRECATED / superseded.** The token-exchange and dual-flow "federated
> authentication" design described here was **not adopted**. The MCP server's
> auth was consolidated in [ADR-022](ADR-022-deployment-mode-consolidation.md)
> (Login Flow v2) and [ADR-023](ADR-023-oauth-as-proxy.md) (OAuth AS proxy).
> The only supported deployment modes are **`single_user_basic`**,
> **`multi_user_basic`**, and **`login_flow`** — there is no token-exchange mode.
> The durable ideas from this ADR (MCP server as its own OAuth client; token
> **audience** validation) live on via [ADR-005](ADR-005-token-audience-validation.md)
> and ADR-023.
**Status**: Superseded by ADR-022 and ADR-023 (token-exchange design not adopted)
**Date**: 2025-11-02
**Supersedes**: ADR-002
@@ -1529,11 +1539,16 @@ The **Progressive Consent architecture** solves the critical challenges of token
This architecture follows industry best practices for federated systems and positions the MCP server as a secure token broker in an enterprise identity ecosystem.
## Implementation Status
## Historical Implementation Notes
**Current Status**: Partially Implemented (Refactoring Required)
> These were the planned refactoring steps at the time this ADR was written.
> They were **never completed** — the design was superseded by ADR-022 (Login
> Flow v2) and ADR-023 (OAuth AS proxy) before this progressive-consent /
> token-exchange architecture was adopted. The `ENABLE_TOKEN_EXCHANGE` /
> `settings.enable_token_exchange` symbols referenced below no longer exist in
> the codebase. Retained for historical context only.
The current implementation (`nextcloud_mcp_server/auth/oauth_routes.py`) implements a **simplified hybrid flow** but needs refactoring to match the progressive consent architecture documented above:
The implementation at the time (`nextcloud_mcp_server/auth/oauth_routes.py`) was a **simplified hybrid flow** that would have needed refactoring to match the progressive consent architecture documented above:
### What's Currently Implemented ✅
@@ -14,6 +14,13 @@ This ADR has been fully implemented with key simplifications based on RFC 7519 S
- Clients discover resource via PRM endpoint (RFC 9728)
- Nextcloud OIDC app uses client-specific resource URLs
> **Note:** The **token-exchange mode** (Option 2 / `ENABLE_TOKEN_EXCHANGE`)
> described in the sections below was **removed** in the ADR-022 (Login Flow v2)
> / ADR-023 (OAuth AS proxy) consolidation. Only **multi-audience mode** ships;
> `ENABLE_TOKEN_EXCHANGE` / `settings.enable_token_exchange` no longer exist.
> The token-exchange references in this document are retained for historical
> context only.
## Executive Summary
This ADR addresses a critical security vulnerability where the MCP server was passing tokens intended for itself directly to Nextcloud APIs (token passthrough). We will:
@@ -1,6 +1,6 @@
# ADR-007: Background Vector Database Synchronization
**Status**: Proposed
**Status**: Accepted — implemented (background vector sync ships; see `nc_get_vector_sync_status` and `VECTOR_SYNC_*` settings)
**Date**: 2025-01-08
**Supersedes**: ADR-003
**Depends On**: ADR-004 (Federated Authentication), ADR-006 (Progressive Consent)
@@ -1,6 +1,6 @@
# ADR-008: MCP Sampling for Multi-App Semantic Search with RAG
**Status**: Proposed
**Status**: Accepted — implemented (`nc_notes_semantic_search_answer` uses MCP sampling via `ctx.session.create_message`)
**Date**: 2025-01-11
**Depends On**: ADR-007 (Background Vector Sync)
+1 -1
View File
@@ -1,6 +1,6 @@
# ADR-009: Generic `semantic:read` OAuth Scope for Multi-App Vector Search
**Status**: Proposed
**Status**: Accepted — implemented (`semantic.read` scope gates the semantic-search tools)
**Date**: 2025-01-11
**Depends On**: ADR-007 (Background Vector Sync), ADR-008 (MCP Sampling for Semantic Search)
+1 -1
View File
@@ -1,6 +1,6 @@
# ADR-010: Webhook-Based Vector Database Synchronization
**Status**: Proposed
**Status**: Accepted — implemented (webhook listener registration; see `auth/webhook_routes.py` and the `registered_webhooks` store)
**Date**: 2025-01-10
**Depends On**: ADR-007 (Background Vector Sync)
@@ -1,7 +1,7 @@
# ADR-012: Unified Multi-Algorithm Search with Client-Configurable Weighting
## Status
Proposed
Accepted — implemented (hybrid search with RRF/DBSF fusion; see `search/hybrid.py`)
## Context
+1 -1
View File
@@ -1,6 +1,6 @@
## ADR-013: RAG Evaluation Testing Framework
**Status:** Proposed
**Status:** Partially implemented (RAG evaluation harness lives under `tests/rag_evaluation`)
**Date:** 2025-11-15
@@ -1,6 +1,6 @@
# ADR-018: Nextcloud PHP App for Settings and Management UI
**Status**: Proposed
**Status**: Accepted — implemented (the Astrolabe Nextcloud app provides the settings/management UI)
**Date**: 2025-12-14
**Updated**: 2025-12-15 (Added deployment modes and authentication architecture)
**Related**: ADR-011 (AppAPI Architecture - Rejected), ADR-008 (MCP Sampling), ADR-004 (OAuth Progressive Consent)
@@ -1,6 +1,6 @@
# ADR-024: Dynaconf Configuration Management
# ADR-025: Dynaconf Configuration Management
**Status:** Proposed
**Status:** Accepted — implemented (`config.py` is built on dynaconf)
**Date:** 2026-04-04
**Deciders:** Development Team
**Related:** ADR-020 (Deployment Modes), ADR-021 (Configuration Consolidation), ADR-022 (Login Flow v2)
-348
View File
@@ -1,348 +0,0 @@
# 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
+4 -50
View File
@@ -27,8 +27,11 @@ This guide helps you migrate from the old configuration variables to the new con
|----------|----------|--------|
| `VECTOR_SYNC_ENABLED` | `ENABLE_SEMANTIC_SEARCH` | Deprecated |
| `ENABLE_OFFLINE_ACCESS` | `ENABLE_BACKGROUND_OPERATIONS` | Deprecated |
| `ENABLE_TOKEN_EXCHANGE` | `MCP_DEPLOYMENT_MODE=login_flow` | Removed (now ignored) |
| N/A (auto-detected) | `MCP_DEPLOYMENT_MODE` | New (optional) |
> **`ENABLE_TOKEN_EXCHANGE` was removed.** The OAuth token-exchange mode was never fully implemented and was retired in the ADR-022/ADR-023 consolidation. The variable is now silently ignored — use `MCP_DEPLOYMENT_MODE=login_flow` for multi-user OAuth.
**Tuning parameters unchanged:**
- `VECTOR_SYNC_SCAN_INTERVAL` - Keep as-is
- `VECTOR_SYNC_PROCESSOR_WORKERS` - Keep as-is
@@ -233,54 +236,6 @@ NEXTCLOUD_OIDC_CLIENT_SECRET=secret
---
### Scenario 5: Token Exchange Mode with Semantic Search
**Before (v0.57.x):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
# Both required
ENABLE_OFFLINE_ACCESS=true
VECTOR_SYNC_ENABLED=true
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
TOKEN_EXCHANGE_CACHE_TTL=300
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
```
**After (v0.58.0+ - Simplified):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=oauth_token_exchange
# One variable!
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background operations
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
TOKEN_EXCHANGE_CACHE_TTL=300
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
```
**What Changed:**
- ✅ Semantic search auto-enables background operations
- ✅ Explicit mode declaration available
**Migration Steps:**
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
2. Remove `ENABLE_OFFLINE_ACCESS=true` (auto-enabled)
3. Optionally add `MCP_DEPLOYMENT_MODE=oauth_token_exchange`
4. Restart server
---
## Understanding Automatic Dependency Resolution
### How It Works
@@ -500,8 +455,7 @@ We provide mode-specific templates for new deployments:
| Template | Use Case |
|----------|----------|
| `env.sample.single-user` | Simplest setup |
| `env.sample.oauth-multi-user` | Recommended multi-user |
| `env.sample.oauth-advanced` | Token exchange mode |
| `env.sample.oauth-multi-user` | Recommended multi-user (Login Flow v2) |
**Usage:**
```bash
+22 -3
View File
@@ -19,7 +19,7 @@ cp env.sample .env # Full reference with all options
# Edit .env with your Nextcloud details
```
> **Note:** The legacy templates `env.sample.oauth-multi-user` and `env.sample.oauth-advanced` configure the deprecated direct-OAuth-to-Nextcloud modes. New deployments should use [Login Flow v2](login-flow-v2.md) for multi-user setups.
> **Note:** `env.sample.oauth-multi-user` is a Login Flow v2 quick-start template for multi-user setups. See [Login Flow v2](login-flow-v2.md).
Then choose your deployment mode:
@@ -97,6 +97,13 @@ MCP_DEPLOYMENT_MODE=login_flow
TOKEN_ENCRYPTION_KEY=<fernet-key>
TOKEN_STORAGE_DB=/app/data/tokens.db
# Static OIDC client for the MCP server's own IdP registration.
# Strongly recommended — with Nextcloud's built-in oidc app the DCR
# fallback expires after ~1h (see the warning below). Create the client
# under Administration settings → OpenID Connect provider.
NEXTCLOUD_OIDC_CLIENT_ID=<client-id-from-nextcloud>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret-from-nextcloud>
# Public URLs for browser redirects
NEXTCLOUD_MCP_SERVER_URL=https://mcp.example.com
NEXTCLOUD_PUBLIC_ISSUER_URL=https://your.nextcloud.instance.com
@@ -110,10 +117,22 @@ NEXTCLOUD_PUBLIC_ISSUER_URL=https://your.nextcloud.instance.com
| `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 (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`. |
| `NEXTCLOUD_OIDC_CLIENT_ID` | ✅ Strongly recommended | OIDC client ID for the MCP server's relying-party registration with the IdP (Nextcloud's built-in OIDC by default; Keycloak / Cognito / etc. via `OIDC_DISCOVERY_URL`). If unset and the IdP advertises a `registration_endpoint`, the server falls back to RFC 7591 Dynamic Client Registration (DCR) — **but with Nextcloud's built-in `oidc` app this fallback breaks after ~1 hour** (see warning below). Create a static client and set this instead. |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ✅ Strongly recommended | 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. |
> **⚠️ Use a static OIDC client with Nextcloud's built-in `oidc` app.** If you
> don't set `NEXTCLOUD_OIDC_CLIENT_ID` / `NEXTCLOUD_OIDC_CLIENT_SECRET`, the MCP
> server registers its own relying-party client via DCR. Nextcloud's `oidc` app
> treats DCR clients as **ephemeral** and deletes them after `client_expire_time`
> (default **3600s = 1 hour**), pruning on every `/authorize`. Once it's gone,
> authorization and token refresh fail and users hit an **"Access forbidden"**
> page — permanently, because the server keeps reusing the deleted client.
> Register a permanent client in **Administration settings → OpenID Connect
> provider** and set the two env vars. See
> [Login Flow v2 → Troubleshooting](login-flow-v2.md#troubleshooting) and
> [issue #907](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/907).
See [Login Flow v2](login-flow-v2.md) for full setup, scope reference, and troubleshooting.
---
+57 -5
View File
@@ -52,9 +52,12 @@ NEXTCLOUD_HOST=https://your.nextcloud.example.com
# OIDC client credentials for the MCP server's relying-party relationship with the IdP.
# These are generic OIDC client credentials — they work with any OIDC provider, despite
# the Nextcloud-flavored env-var names. Preferred path: register a client in your IdP
# (Nextcloud admin → OIDC, Keycloak realm → Clients, etc.) and set these. If both are
# unset and the IdP advertises a `registration_endpoint`, the server falls back to RFC 7591 DCR.
# the Nextcloud-flavored env-var names. Register a static client in your IdP
# (Nextcloud admin → OpenID Connect provider, Keycloak realm → Clients, etc.) and set these.
#
# Strongly recommended — do NOT rely on the DCR fallback with Nextcloud's built-in
# `oidc` app: it deletes dynamically-registered clients after ~1h, which breaks the
# connection permanently (see Troubleshooting → "Access forbidden" below).
NEXTCLOUD_OIDC_CLIENT_ID=<your-client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<your-client-secret>
@@ -63,7 +66,7 @@ MCP_DEPLOYMENT_MODE=login_flow
# App-password storage (required for persistence across restarts)
TOKEN_STORAGE_DB=/app/data/tokens.db
TOKEN_ENCRYPTION_KEY=<fernet-key> # see "Generating an encryption key" below
TOKEN_ENCRYPTION_KEY=<your-encryption-key> # see "Generating an encryption key" below
# Public URLs (for browser redirects)
NEXTCLOUD_MCP_SERVER_URL=https://mcp.example.com
@@ -72,6 +75,23 @@ NEXTCLOUD_PUBLIC_ISSUER_URL=https://your.nextcloud.example.com # Public URL of
When using an external IdP (Keycloak, Cognito, etc.), see [Keycloak Multi-Client Token Validation](keycloak-multi-client-validation.md) for how Nextcloud's `user_oidc` app handles realm-level token validation if you also federate Nextcloud's own login through the same IdP.
### Default IdP setup (Nextcloud's built-in `oidc` app)
When `OIDC_DISCOVERY_URL` is unset, Nextcloud's own **OpenID Connect provider**
(`oidc`) app is the IdP. Register a **static** client for the MCP server there —
don't rely on Dynamic Client Registration, because the `oidc` app auto-deletes
DCR clients after ~1 hour (see [Troubleshooting](#access-forbidden-after-the-connection-worked-for-a-while)).
1. Install/enable the **OpenID Connect provider** (`oidc`) app.
2. Go to **Administration settings → OpenID Connect provider → Add client** and set:
- **Redirect URI:** `https://<your-mcp-server>/oauth/callback`
- **Flow / response type:** authorization **code**
- **Type:** **confidential** (so it issues a client secret)
- **Resource identifier:** `https://<your-mcp-server>/mcp` (so issued tokens carry the MCP server's audience; the verifier's `_has_mcp_audience` accepts both this `/mcp` form and the bare server URL)
- **Scopes:** leave empty to allow all, or list the per-app scopes you want plus `openid profile email offline_access`
3. Copy the generated client ID and secret into `NEXTCLOUD_OIDC_CLIENT_ID` /
`NEXTCLOUD_OIDC_CLIENT_SECRET`.
### External IdP setup (Authentik / Keycloak / Cognito)
When `OIDC_DISCOVERY_URL` points at a third-party IdP rather than Nextcloud's own
@@ -173,9 +193,14 @@ mcp-login-flow:
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8004
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- MCP_DEPLOYMENT_MODE=login_flow
# Production: register a static OIDC client and set these — the DCR
# fallback used by this dev/test service expires after ~1h against the
# built-in `oidc` app (see "Default IdP setup" above and #907).
# - NEXTCLOUD_OIDC_CLIENT_ID=<your-client-id>
# - NEXTCLOUD_OIDC_CLIENT_SECRET=<your-client-secret>
# Dev-only inline value. In production, mount via Docker secret and read
# from a *_FILE env var or a secrets-management init step.
- TOKEN_ENCRYPTION_KEY=<your-fernet-key>
- TOKEN_ENCRYPTION_KEY=<your-encryption-key>
- TOKEN_STORAGE_DB=/app/data/tokens.db
volumes:
- login-flow-data:/app/data
@@ -329,6 +354,33 @@ JWTs are preferred for production because validation is local and stateless. Opa
## Troubleshooting
### "Access forbidden" after the connection worked for a while
**Symptom:** authentication succeeds and tools work for a while (often up to an
hour), then the connection silently drops. Re-connecting redirects to Nextcloud
and shows an **"Access forbidden"** page. Restarting the MCP server and
re-creating the MCP client/connector don't help. ([#907](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/907))
**Cause:** you didn't set `NEXTCLOUD_OIDC_CLIENT_ID` / `NEXTCLOUD_OIDC_CLIENT_SECRET`,
so the MCP server registered *its own* relying-party client with Nextcloud's
built-in `oidc` app via Dynamic Client Registration (DCR). The `oidc` app treats
DCR clients as ephemeral and **deletes them after `client_expire_time` (default
3600s = 1 hour)** — it prunes expired DCR clients on every `/authorize` request.
Once the server's client is gone, `/authorize` can't find it (→ the "Access
forbidden" page) and token refresh fails too. It's permanent because the server
cached that now-deleted client in `tokens.db` and keeps reusing it.
**Fix:** register a **static** (admin-created, non-DCR) client and configure it —
see [Default IdP setup](#default-idp-setup-nextclouds-built-in-oidc-app). Static
clients are never auto-deleted. Set `NEXTCLOUD_OIDC_CLIENT_ID` /
`NEXTCLOUD_OIDC_CLIENT_SECRET` (they take precedence over the cached DCR client)
and recreate the container. Existing users will need to re-authorize once after
this switch — their stored sessions were issued to the now-deleted DCR client,
so old refresh tokens no longer validate against the new static client.
As a non-recommended stopgap you can extend the DCR client lifetime globally:
`occ config:app:set oidc client_expire_time --value 31536000`.
### "Provisioning loop" — user keeps being asked to authorize
Check that `TOKEN_STORAGE_DB` is on a persistent volume. The default (`/tmp` or per-process tempfile) is wiped on container restart, so each restart loses every stored app password.
-323
View File
@@ -1,323 +0,0 @@
# 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)
-1
View File
@@ -72,7 +72,6 @@ The Helm chart has moved to a [separate repository](https://github.com/cbcoutinh
### OAuth Flow Metrics
- `mcp_oauth_token_validations_total` - Token validation count
- `mcp_oauth_token_exchange_total` - Token exchange operations
- `mcp_oauth_token_cache_hits_total` - Cache hit/miss rate
- `mcp_oauth_refresh_token_operations_total` - Refresh token storage ops
+1 -18
View File
@@ -207,24 +207,7 @@ php occ webhook_listeners:remove <webhook-id>
---
### 4. OAuth Token Exchange (RFC 8693)
**Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
ENABLE_BACKGROUND_OPERATIONS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/app/data/tokens.db
VECTOR_SYNC_ENABLED=true
```
**Enable/Disable Webhooks:**
Same process as OAuth Single-Audience. The token exchange happens transparently when the MCP server accesses Nextcloud APIs.
---
### 5. Smithery Stateless
### 4. Smithery Stateless
**Configuration:**
- Configuration from session URL params
-34
View File
@@ -101,40 +101,6 @@ NEXTCLOUD_PASSWORD=
# Optional features (semantic search, document processing):
# See "Optional Features" section below
# ============================================
# OAUTH TOKEN EXCHANGE MODE (Advanced)
# ============================================
# Multi-user OAuth with RFC 8693 token exchange
# Use for: Advanced deployments requiring separate MCP and Nextcloud tokens
# MCP tokens are separate from Nextcloud tokens
#
# Required:
#ENABLE_TOKEN_EXCHANGE=true
#
# Optional - Pre-registered OAuth Client:
# If you pre-register the client instead of using DCR:
#NEXTCLOUD_OIDC_CLIENT_ID=
#NEXTCLOUD_OIDC_CLIENT_SECRET=
#
# Optional - Token Exchange Configuration:
# Cache TTL in seconds (default: 300 = 5 minutes)
#TOKEN_EXCHANGE_CACHE_TTL=300
#
# Optional - Background Operations:
# Note: ENABLE_SEMANTIC_SEARCH automatically enables this in multi-user modes
#ENABLE_BACKGROUND_OPERATIONS=true
#TOKEN_ENCRYPTION_KEY=
#TOKEN_STORAGE_DB=/app/data/tokens.db
#
# Optional - Custom OIDC Discovery:
#NEXTCLOUD_OIDC_DISCOVERY_URL=
#
# MCP Server URL (for OAuth redirects):
#NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
#
# Optional features (semantic search, document processing):
# See "Optional Features" section below
# ============================================
# OAUTH CLIENT ALLOWLISTS (OAuth modes)
# ============================================
-80
View File
@@ -1,80 +0,0 @@
# ============================================
# OAUTH TOKEN EXCHANGE QUICK START (Advanced)
# ============================================
# Advanced OAuth deployment with RFC 8693 token exchange
# Use for: Deployments requiring separate MCP and Nextcloud tokens
# Features: Dual-audience tokens, enhanced security boundaries
#
# Copy this file to .env and configure
# ===== REQUIRED SETTINGS =====
# Your Nextcloud instance URL (without trailing slash)
NEXTCLOUD_HOST=https://nextcloud.example.com
# Enable token exchange mode
ENABLE_TOKEN_EXCHANGE=true
# ===== REQUIRED: LEAVE USERNAME/PASSWORD EMPTY =====
# OAuth mode activates when these are NOT set
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# ===== OPTIONAL: EXPLICIT MODE DECLARATION =====
# Recommended for clarity
MCP_DEPLOYMENT_MODE=oauth_token_exchange
# ===== OPTIONAL: PRE-REGISTERED OAUTH CLIENT =====
# If you pre-register the OAuth client instead of using DCR:
#NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
#NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
# MCP Server URL (for OAuth redirects)
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# ===== OPTIONAL: TOKEN EXCHANGE TUNING =====
# Cache TTL for exchanged tokens (default: 300 seconds = 5 minutes)
TOKEN_EXCHANGE_CACHE_TTL=300
# ===== OPTIONAL: SEMANTIC SEARCH =====
# AI-powered semantic search with automatic background operation setup
#
# Note: ENABLE_SEMANTIC_SEARCH automatically enables background operations
# in token exchange mode, just like in OAuth single-audience mode
#
ENABLE_SEMANTIC_SEARCH=true
# Vector Database (required for semantic search)
QDRANT_URL=http://qdrant:6333
# Embedding Provider (required for semantic search)
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# Token Storage (required for background operations - auto-enabled by semantic search)
# Generate encryption key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
TOKEN_ENCRYPTION_KEY=your-encryption-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
# ===== OPTIONAL: DOCUMENT PROCESSING =====
# Extract text from PDFs, images, DOCX for semantic search
#ENABLE_DOCUMENT_PROCESSING=true
#ENABLE_UNSTRUCTURED=true
#UNSTRUCTURED_API_URL=http://unstructured:8000
# ===== TOKEN EXCHANGE MODE EXPLANATION =====
# In this mode:
# 1. MCP clients authenticate with tokens scoped to "mcp-server" audience
# 2. Server exchanges MCP tokens for Nextcloud tokens on each request
# 3. Provides clear separation between MCP session and Nextcloud access
# 4. Enables fine-grained token lifecycle management
#
# When to use:
# - Strict security requirements (separate token contexts)
# - Complex multi-service architectures
# - Need independent token expiration policies
#
# When NOT to use:
# - Simple deployments (use oauth_single_audience instead)
# - High-performance requirements (token exchange adds latency)
# For more configuration options, see env.sample
+32 -19
View File
@@ -1,10 +1,12 @@
# ============================================
# OAUTH MULTI-USER QUICK START (Recommended)
# LOGIN FLOW v2 MULTI-USER QUICK START (Recommended)
# ============================================
# Multi-user deployment with OAuth authentication
# Use for: Multi-user production deployments, enhanced security
# Features: Single-audience tokens, automatic client registration (DCR)
# Multi-user deployment with OAuth/OIDC authentication (ADR-022).
# Use for: Multi-user production deployments, enhanced security.
# The MCP server authenticates clients via OIDC and holds per-user
# Nextcloud app passwords (encrypted) obtained via Login Flow v2.
#
# See docs/login-flow-v2.md for the full guide.
# Copy this file to .env and configure
# ===== REQUIRED SETTINGS =====
@@ -16,17 +18,32 @@ NEXTCLOUD_HOST=https://nextcloud.example.com
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# ===== OPTIONAL: EXPLICIT MODE DECLARATION =====
# Recommended for clarity
MCP_DEPLOYMENT_MODE=oauth_single_audience
# ===== REQUIRED: DEPLOYMENT MODE =====
MCP_DEPLOYMENT_MODE=login_flow
# ===== OPTIONAL: PRE-REGISTERED OAUTH CLIENT =====
# If you pre-register the OAuth client instead of using DCR:
#NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
#NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
# ===== STRONGLY RECOMMENDED: STATIC OIDC CLIENT =====
# Register a static client for the MCP server in your IdP and set these.
# With Nextcloud's built-in `oidc` app you MUST do this: the DCR fallback
# registers an ephemeral client that the app deletes after ~1h, which breaks
# auth permanently ("Access forbidden" on reconnect — see issue #907).
# Create one under Administration settings -> OpenID Connect provider.
NEXTCLOUD_OIDC_CLIENT_ID=<your-client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<your-client-secret>
# MCP Server URL (for OAuth redirects)
# ===== REQUIRED: PUBLIC URLs =====
# Public URL of the MCP server (used as the token audience and for OAuth redirects).
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# Public URL of Nextcloud as the user's browser sees it (for Login Flow v2
# browser redirects). Omitting it causes the "Login URL points to localhost"
# failure — see docs/login-flow-v2.md#troubleshooting.
NEXTCLOUD_PUBLIC_ISSUER_URL=https://nextcloud.example.com
# ===== REQUIRED: APP-PASSWORD STORAGE =====
# Required for login_flow — per-user Nextcloud app passwords are stored here
# (encrypted) so they survive restarts. See docs/login-flow-v2.md#setup.
# Generate encryption key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
TOKEN_ENCRYPTION_KEY=<your-encryption-key>
TOKEN_STORAGE_DB=/app/data/tokens.db
# ===== OPTIONAL: SEMANTIC SEARCH (Recommended) =====
# AI-powered semantic search with automatic background operation setup
@@ -53,11 +70,6 @@ OLLAMA_EMBEDDING_MODEL=nomic-embed-text
#AWS_REGION=us-east-1
#BEDROCK_EMBEDDING_MODEL=amazon.titan-embed-text-v2:0
# Token Storage (required for background operations - auto-enabled by semantic search)
# Generate encryption key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
TOKEN_ENCRYPTION_KEY=your-encryption-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
# ===== OPTIONAL: DOCUMENT PROCESSING =====
# Extract text from PDFs, images, DOCX for semantic search
#ENABLE_DOCUMENT_PROCESSING=true
@@ -68,10 +80,11 @@ TOKEN_STORAGE_DB=/app/data/tokens.db
# With ENABLE_SEMANTIC_SEARCH=true in OAuth mode:
# ✅ Background operations enabled automatically
# ✅ Refresh token storage enabled automatically
# ✅ OAuth credentials required (DCR or pre-registered)
# ✅ Static OIDC client credentials required (see above)
# ✅ Encryption key required for token storage
#
# You only need to set ENABLE_SEMANTIC_SEARCH and provide the required
# infrastructure (Qdrant, Ollama, encryption key). The rest is automatic!
# infrastructure (static OIDC client, Qdrant, Ollama, encryption key).
# The rest is automatic!
# For more advanced configuration, see env.sample
+14 -31
View File
@@ -2,15 +2,12 @@
Unified Token Verifier for ADR-005 Token Audience Validation.
This module replaces both NextcloudTokenVerifier and ProgressiveConsentTokenVerifier
with a single implementation that supports two compliant OAuth modes:
1. Multi-audience mode (default): Validates MCP audience per RFC 7519 (resource servers
validate only their own audience). Nextcloud independently validates its own audience.
2. Token exchange mode (opt-in): Tokens have MCP audience only, exchanged for Nextcloud tokens
with a single implementation using multi-audience validation: it validates the MCP
audience per RFC 7519 (resource servers validate only their own audience), and
Nextcloud independently validates its own audience when it receives the token.
Key Design Principles:
- Token verification happens HERE (validates MCP audience per OAuth spec)
- Token exchange happens in context_helper.py (when creating NextcloudClient)
- No token passthrough allowed (complies with MCP Security Specification)
- Token reuse IS allowed for multi-audience tokens (RFC 8707)
"""
@@ -39,18 +36,14 @@ logger = logging.getLogger(__name__)
class UnifiedTokenVerifier(TokenVerifier):
"""
Unified token verifier supporting both multi-audience and token exchange modes.
Unified token verifier for multi-audience tokens (ADR-005).
Compliant with MCP security specification - no token pass-through.
This verifier:
1. Validates tokens using JWT verification with JWKS or introspection fallback
2. Enforces proper audience validation based on configured mode
2. Enforces MCP audience validation (per RFC 7519); Nextcloud independently
validates its own audience when receiving API calls
3. Caches successful validations to avoid repeated API calls
Mode Selection (via ENABLE_TOKEN_EXCHANGE setting):
- False/omit (default): Multi-audience mode - validates MCP audience only (per RFC 7519).
Nextcloud independently validates its own audience when receiving API calls.
- True: Exchange mode - requires MCP audience only, then exchanges for Nextcloud token
"""
def __init__(self, settings: Settings):
@@ -61,7 +54,6 @@ class UnifiedTokenVerifier(TokenVerifier):
settings: Application settings containing OAuth configuration
"""
self.settings = settings
self.mode = "multi-audience"
# Common components for all modes
self.http_client = nextcloud_httpx_client(timeout=10.0)
@@ -118,8 +110,7 @@ class UnifiedTokenVerifier(TokenVerifier):
)
logger.info(
"UnifiedTokenVerifier initialized in %s mode. MCP audience: %s or %s, Nextcloud resource URI: %s, Valid issuers: %s",
self.mode,
"UnifiedTokenVerifier initialized (multi-audience). MCP audience: %s or %s, Nextcloud resource URI: %s, Valid issuers: %s",
settings.oidc_client_id,
settings.nextcloud_mcp_server_url,
settings.nextcloud_resource_uri,
@@ -130,10 +121,9 @@ class UnifiedTokenVerifier(TokenVerifier):
"""
Verify token according to MCP TokenVerifier protocol.
Per RFC 7519, we validate only MCP audience. The mode determines what
happens AFTER verification in context_helper.py:
- Multi-audience mode: Use token directly (Nextcloud validates its own audience)
- Exchange mode: Exchange for Nextcloud-audience token via RFC 8693
Per RFC 7519, we validate only MCP audience. The token is then used
directly against Nextcloud (which validates its own audience) — see
context_helper.py.
Args:
token: Bearer token to verify
@@ -150,7 +140,6 @@ class UnifiedTokenVerifier(TokenVerifier):
oauth_token_cache_hits_total.labels(hit="false").inc()
# Both modes do the same validation (MCP audience only)
return await self._verify_mcp_audience(token)
async def verify_token_for_management_api(self, token: str) -> AccessToken | None:
@@ -292,16 +281,10 @@ class UnifiedTokenVerifier(TokenVerifier):
record_oauth_token_validation(validation_method, "invalid")
return None
# Log based on mode for clarity
if self.mode == "multi-audience":
logger.info(
"MCP audience validated - token can be used directly "
"(Nextcloud will validate its own audience)"
)
else:
logger.info(
"MCP audience validated - token will be exchanged for Nextcloud access"
)
logger.info(
"MCP audience validated - token can be used directly "
"(Nextcloud will validate its own audience)"
)
return self._create_access_token(token, payload)
-6
View File
@@ -57,7 +57,6 @@ _DEFAULTS: dict[str, Any] = {
"enable_background_operations": False,
"vector_sync_enabled": False,
"enable_offline_access": False,
"enable_token_exchange": False,
# Token storage
"token_encryption_key": None,
# None = ephemeral per-process tempfile (see get_token_db_path()).
@@ -1268,7 +1267,6 @@ def _is_multi_user_mode() -> bool:
- Multi-user BasicAuth (MCP_DEPLOYMENT_MODE=multi_user_basic)
- Login Flow v2 / default OAuth (MCP_DEPLOYMENT_MODE=login_flow, or no
username/password and no explicit mode)
- OAuth Token Exchange (ENABLE_TOKEN_EXCHANGE=true)
Single-user mode is:
- Single-user BasicAuth (username and password both set)
@@ -1285,10 +1283,6 @@ def _is_multi_user_mode() -> bool:
if explicit_mode == "single_user_basic":
return False
# Token exchange implies OAuth multi-user
if _dynaconf.get("ENABLE_TOKEN_EXCHANGE", False):
return True
# If both username and password are set, it's single-user BasicAuth
has_username = bool(_dynaconf.get("NEXTCLOUD_USERNAME"))
has_password = bool(_dynaconf.get("NEXTCLOUD_PASSWORD"))
-1
View File
@@ -48,7 +48,6 @@ enable_background_operations = false
# Deprecated aliases (declared so env var overrides work)
vector_sync_enabled = false
enable_offline_access = false
enable_token_exchange = false
# --- Token storage ---
token_encryption_key = "@none"
+4 -11
View File
@@ -1,8 +1,8 @@
"""
Unit tests for UnifiedTokenVerifier (ADR-005).
Tests token audience validation for both multi-audience and token exchange modes
without requiring real network calls or IdP connections.
Tests multi-audience token validation without requiring real network calls or
IdP connections.
"""
import time
@@ -35,16 +35,9 @@ def base_settings():
class TestUnifiedTokenVerifierInit:
"""Test UnifiedTokenVerifier initialization."""
def test_init_multi_audience_mode(self, base_settings):
"""Test verifier initialization in multi-audience mode."""
def test_init(self, base_settings):
"""Test verifier initialization (multi-audience only; no token exchange)."""
verifier = UnifiedTokenVerifier(base_settings)
assert verifier.mode == "multi-audience"
assert verifier.settings == base_settings
def test_init_always_multi_audience(self, base_settings):
"""Test verifier always initializes in multi-audience mode."""
verifier = UnifiedTokenVerifier(base_settings)
assert verifier.mode == "multi-audience"
assert verifier.settings == base_settings