Proposes consolidating five deployment modes into two: - Single-User: App password in env vars (trusted environment) - Multi-User: Login Flow v2 for per-user app password acquisition Key changes: - Use Nextcloud Login Flow v2 (NC 16+) for delegated authentication - Application-level scope enforcement (app passwords have no native scopes) - MCP elicitation for seamless authorization prompting - Astrolabe front-end integration for scope management UI - Clear security posture documentation for administrators This removes the need for upstream Nextcloud OAuth patches and simplifies deployment while maintaining security through defense-in-depth. Related: #521 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
49 KiB
ADR-022: Deployment Mode Consolidation via Login Flow v2
Status: Proposed Date: 2026-02-01 Deciders: Development Team Related: ADR-020 (Deployment Modes), ADR-021 (Configuration Consolidation), ADR-004 (Progressive Consent), Issue #521
Context
The Nextcloud MCP Server currently supports five distinct deployment modes (ADR-020):
- Single-User BasicAuth - App password in environment variables
- Multi-User BasicAuth - HTTP header credential pass-through
- OAuth Single-Audience - Multi-audience token validation
- OAuth Token Exchange - RFC 8693 delegation
- Smithery Stateless - Session URL parameters
This complexity creates several problems:
Maintenance Burden
- Configuration validation requires ~460 lines of code with mode-specific logic
- Each mode has different conditional requirements and forbidden variables
- Documentation must cover 5 different deployment paths
- Testing requires separate containers for each mode (
mcp,mcp-oauth,mcp-keycloak)
Security Anti-Patterns
- Multi-User BasicAuth passes user credentials through the MCP server (credential exposure risk)
- OAuth modes require upstream patches to Nextcloud for Bearer token validation on non-OCS endpoints
- Token passthrough creates audit trail issues (actions attributed to MCP server, not user)
Adoption Barriers
- OAuth modes require patched
user_oidcapp or complex IdP configuration - Configuration matrix has ~500+ possible combinations
- Users struggle to select the appropriate mode for their use case
Critical Insight: Nextcloud App Passwords
Nextcloud's app password system provides a simple, native mechanism for delegated API access:
- Universal compatibility: Works on ANY Nextcloud instance (NC 16+)
- No upstream patches required: Uses standard Nextcloud APIs
- User-visible: Appears in Settings > Security > Devices & Sessions
- User-revocable: Users can revoke access at any time
- Proven pattern: Used by all official Nextcloud clients (Desktop, Mobile)
However, app passwords have no native scope support - they grant full API access equivalent to the user's permissions. This is a critical security consideration that requires application-level mitigation.
Decision
Consolidate deployment modes into two simplified modes:
Mode 1: Single-User Mode
Use Case: Personal Nextcloud, local development, single-tenant deployments
Configuration:
NEXTCLOUD_HOST=http://nextcloud.example.com
NEXTCLOUD_APP_PASSWORD=xxxxx-xxxxx-xxxxx-xxxxx-xxxxx
NEXTCLOUD_USERNAME=admin # Optional, can be inferred from app password
Characteristics:
- App password configured in environment variables
- No persistent state required (stateless)
- No Login Flow v2 (credentials pre-configured)
- All MCP tools available (no scope enforcement - trusted environment)
- Suitable for trusted environments only
Mode 2: Multi-User Mode
Use Case: Multi-user deployments, enterprise, shared instances
Architecture:
┌─────────────────┐ OAuth/OIDC ┌──────────────────┐ Login Flow v2 ┌─────────────────┐
│ MCP Client │ ───────────────> │ MCP Server │ ────────────────> │ Nextcloud │
│ (Claude) │ (mcp:* scopes) │ (OAuth Client) │ (app password) │ (NC 16+) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
Configuration:
NEXTCLOUD_HOST=http://nextcloud.example.com
MCP_DEPLOYMENT_MODE=multi_user # Or auto-detected when NEXTCLOUD_APP_PASSWORD not set
# Required for app password storage
TOKEN_ENCRYPTION_KEY=<fernet-key>
TOKEN_STORAGE_DB=/app/data/tokens.db
# Optional: Semantic search
ENABLE_SEMANTIC_SEARCH=true
QDRANT_URL=http://qdrant:6333
Characteristics:
- MCP clients authenticate to MCP server via OAuth (Nextcloud as IdP)
- Per-user app password acquisition via Nextcloud Login Flow v2
- Application-level scope enforcement (critical - see Security Considerations)
- Encrypted app password storage in SQLite
- Background sync uses stored app passwords
Authentication Flow (Multi-User Mode)
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ MCP Client │ │ MCP Server │ │ Nextcloud │
│ (Claude) │ │ (OAuth Client) │ │ (NC 16+) │
└────────┬────────┘ └────────┬─────────┘ └────────┬────────┘
│ │ │
│ 1. OAuth PKCE (mcp:* scopes) │ │
├───────────────────────────────────>│ │
│ │ │
│ 2. MCP Request (no app password) │ │
├───────────────────────────────────>│ │
│ │ │
│ 3. Elicitation Response │ │
│<───────────────────────────────────┤ │
│ "Visit: <login-flow-url>" │ │
│ │ │
│ 4. User clicks URL │ │
│ │ │
│ │ 5. POST /login/v2 │
│ ├────────────────────────────────────>│
│ │ │
│ │ 6. {poll_endpoint, login_url} │
│ │<────────────────────────────────────│
│ │ │
│ 7. User authenticates in browser │ │
│────────────────────────────────────┼────────────────────────────────────>│
│ │ │
│ │ 8. Poll for completion │
│ ├────────────────────────────────────>│
│ │ │
│ │ 9. {loginName, appPassword} │
│ │<────────────────────────────────────│
│ │ │
│ │ 10. Store encrypted + scopes │
│ │ │
│ 11. Retry MCP request │ │
├───────────────────────────────────>│ │
│ │ │
│ │ 12. Validate scopes, use app pass │
│ ├────────────────────────────────────>│
│ │ Authorization: Basic <app-password> │
│ │ │
│ 13. Return result │ │
│<───────────────────────────────────┤ │
What is Nextcloud Login Flow v2?
Login Flow v2 is Nextcloud's native authentication mechanism for desktop and mobile clients. It provides browser-based authentication without requiring the client to handle credentials directly.
API Flow:
- Client
POST /index.php/login/v2withUser-Agentheader - Server returns
{poll: {endpoint, token}, login: <url>} - User visits
loginURL in their browser, authenticates normally - Client polls
endpointwithtoken - On success:
{server, loginName, appPassword} - App password is generated with name from User-Agent (visible in Nextcloud Settings)
Key benefits:
- Browser-based auth: User authenticates using familiar Nextcloud login
- No credential handling: Client never sees username/password
- Works everywhere: Available on all Nextcloud 16+ instances
- User visibility: App passwords appear in Settings > Security > Devices & Sessions
- User control: Users can revoke access anytime without admin intervention
Architecture Details
Login Flow v2 MCP Tools
Two new MCP tools enable the provisioning flow:
@mcp.tool(
title="Provision Nextcloud Access",
annotations=ToolAnnotations(readOnlyHint=False, openWorldHint=True),
)
async def nc_auth_provision_access(
ctx: Context,
requested_scopes: list[str] | None = None,
) -> ProvisionAccessResponse:
"""
Initiate Nextcloud access provisioning via Login Flow v2.
The user will be prompted to authorize access in their browser.
Once complete, call nc_auth_check_status to confirm provisioning.
Args:
requested_scopes: Scopes to request (e.g., ["notes:read", "notes:write"]).
Defaults to all scopes the MCP client requested.
Returns:
Authorization URL to visit and polling status endpoint.
"""
user_id = extract_user_from_mcp_token(ctx)
# Determine scopes to request
if requested_scopes is None:
# Use scopes from MCP token
requested_scopes = get_access_token_scopes(ctx)
# Validate requested scopes against supported scopes
supported = set(discover_all_scopes(mcp))
invalid = set(requested_scopes) - supported
if invalid:
raise ValueError(f"Invalid scopes: {invalid}")
# Initiate Login Flow v2
response = await httpx.post(
f"{settings.nextcloud_host}/index.php/login/v2",
headers={"User-Agent": f"Nextcloud MCP Server (user:{user_id})"},
)
data = response.json()
# Store poll session with requested scopes
await storage.store_login_flow_session(
user_id=user_id,
poll_token=data["poll"]["token"],
poll_endpoint=data["poll"]["endpoint"],
requested_scopes=requested_scopes,
expires_at=int(time.time()) + 600, # 10 min TTL
)
return ProvisionAccessResponse(
status="authorization_required",
authorization_url=data["login"],
message="Please visit the URL to authorize Nextcloud access.",
requested_scopes=requested_scopes,
)
@mcp.tool(
title="Check Provisioning Status",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
async def nc_auth_check_status(ctx: Context) -> ProvisionStatusResponse:
"""
Check if Nextcloud access provisioning is complete.
Polls the Login Flow v2 endpoint to check authorization status.
"""
user_id = extract_user_from_mcp_token(ctx)
# Check for existing app password
existing = await storage.get_app_password_with_scopes(user_id)
if existing:
return ProvisionStatusResponse(
status="provisioned",
message="Access already provisioned.",
scopes=existing["scopes"],
)
# Get pending login flow session
session = await storage.get_login_flow_session(user_id)
if not session:
return ProvisionStatusResponse(
status="not_initiated",
message="No provisioning in progress. Call nc_auth_provision_access first.",
)
# Poll the endpoint
response = await httpx.post(
session["poll_endpoint"],
data={"token": session["poll_token"]},
)
if response.status_code == 404:
return ProvisionStatusResponse(
status="pending",
message="Waiting for user authorization.",
)
if response.status_code == 200:
data = response.json()
# Store app password WITH SCOPES
await storage.store_app_password(
user_id=user_id,
username=data["loginName"],
app_password=data["appPassword"],
scopes=session["requested_scopes"], # Critical: store authorized scopes
)
# Clean up session
await storage.delete_login_flow_session(user_id)
return ProvisionStatusResponse(
status="provisioned",
message="Access successfully provisioned.",
scopes=session["requested_scopes"],
)
return ProvisionStatusResponse(
status="error",
message=f"Authorization failed: {response.status_code}",
)
MCP Elicitation for Login Flow v2
The MCP protocol supports elicitation - a mechanism for servers to request that clients prompt users for input or actions. We use URL elicitation to initiate Login Flow v2 without requiring explicit tool calls.
How it works:
When a user attempts to access a Nextcloud resource without a provisioned app password, the server returns an elicitation response requesting URL navigation:
from mcp.types import ElicitResult, ElicitRequest
async def handle_nextcloud_access(ctx: Context, user_id: str) -> ElicitResult | None:
"""Check if user needs to provision access, return elicitation if needed."""
app_password = await storage.get_app_password_with_scopes(user_id)
if app_password is not None:
return None # Already provisioned
# Initiate Login Flow v2
response = await httpx.post(
f"{settings.nextcloud_host}/index.php/login/v2",
headers={"User-Agent": f"Nextcloud MCP Server (user:{user_id})"},
)
data = response.json()
# Store session for polling
await storage.store_login_flow_session(
user_id=user_id,
poll_token=data["poll"]["token"],
poll_endpoint=data["poll"]["endpoint"],
requested_scopes=get_access_token_scopes(ctx),
expires_at=int(time.time()) + 600,
)
# Return URL elicitation
return ElicitResult(
action="open_url",
url=data["login"],
message=(
"To access Nextcloud resources, please authorize this application. "
"Click the link to open Nextcloud in your browser and complete authentication."
),
# Server will poll automatically; client retries after user completes
)
MCP Client Behavior:
- Client receives elicitation response with
action: "open_url" - Client presents URL to user (clickable link, button, or automatic browser open)
- User completes authentication in browser
- Client retries the original request
- Server detects completed Login Flow (via polling) and proceeds
Benefits of Elicitation over Explicit Tools:
| Approach | User Experience |
|---|---|
Explicit tools (nc_auth_provision_access) |
User must know to call provisioning tool first |
| Elicitation (recommended) | Seamless - user just tries to use Nextcloud, prompted automatically |
Re-Authentication for Scope Updates
Users may need to update their authorized scopes after initial provisioning (e.g., initially authorized notes:read but now needs notes:write). The system supports re-authentication to add scopes.
Re-auth Tool:
@mcp.tool(
title="Update Nextcloud Access Scopes",
annotations=ToolAnnotations(readOnlyHint=False, openWorldHint=True),
)
async def nc_auth_update_scopes(
ctx: Context,
additional_scopes: list[str],
) -> ProvisionAccessResponse:
"""
Request additional Nextcloud access scopes.
If the user already has provisioned access, this initiates a new Login Flow v2
to authorize additional scopes. The new scopes will be MERGED with existing scopes.
Args:
additional_scopes: New scopes to add (e.g., ["calendar:read", "calendar:write"]).
Returns:
Authorization URL to visit for scope upgrade.
"""
user_id = extract_user_from_mcp_token(ctx)
# Get existing scopes
existing = await storage.get_app_password_with_scopes(user_id)
existing_scopes = set(existing["scopes"]) if existing else set()
# Validate new scopes
supported = set(discover_all_scopes(mcp))
invalid = set(additional_scopes) - supported
if invalid:
raise ValueError(f"Invalid scopes: {invalid}")
# Merge scopes
merged_scopes = list(existing_scopes | set(additional_scopes))
# Check if any new scopes actually needed
if set(additional_scopes) <= existing_scopes:
return ProvisionAccessResponse(
status="already_authorized",
message="All requested scopes are already authorized.",
scopes=list(existing_scopes),
)
# Revoke old app password (will be replaced)
if existing:
await _revoke_nextcloud_app_password(existing["username"], existing["app_password"])
await storage.delete_app_password(user_id)
# Initiate new Login Flow v2 with merged scopes
response = await httpx.post(
f"{settings.nextcloud_host}/index.php/login/v2",
headers={"User-Agent": f"Nextcloud MCP Server (user:{user_id}, scope-update)"},
)
data = response.json()
await storage.store_login_flow_session(
user_id=user_id,
poll_token=data["poll"]["token"],
poll_endpoint=data["poll"]["endpoint"],
requested_scopes=merged_scopes, # Merged scopes
expires_at=int(time.time()) + 600,
)
return ProvisionAccessResponse(
status="authorization_required",
authorization_url=data["login"],
message=f"Please re-authorize to add scopes: {additional_scopes}",
requested_scopes=merged_scopes,
previous_scopes=list(existing_scopes),
)
Automatic Re-auth via Elicitation:
When a tool requires a scope the user hasn't authorized, the server can automatically trigger re-auth:
# In @require_scopes decorator, when scopes are missing:
if missing:
# Instead of just raising error, offer re-auth via elicitation
return ElicitResult(
action="confirm",
title="Additional Permissions Required",
message=(
f"This action requires additional permissions: {', '.join(missing)}. "
f"Would you like to authorize these scopes?"
),
confirm_action="reauth",
confirm_data={"scopes": list(missing)},
)
Astrolabe Front-End Integration
Astrolabe is the Nextcloud PHP app that provides a management UI for the MCP server. It needs to support Login Flow v2 for users who access MCP via the Nextcloud web interface.
Integration Points:
┌─────────────────────────────────────────────────────────────────────────────┐
│ Nextcloud Instance │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Astrolabe App (/apps/astrolabe) │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ MCP Status │ │ Scope Manager │ │ Connection │ │ │
│ │ │ Dashboard │ │ UI │ │ Settings │ │ │
│ │ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ │
│ │ │ │ │ │ │
│ │ └──────────────────────┼──────────────────────┘ │ │
│ │ │ │ │
│ │ ┌───────▼───────┐ │ │
│ │ │ Login Flow v2 │ │ │
│ │ │ Controller │ │ │
│ │ └───────┬───────┘ │ │
│ └──────────────────────────────────┼──────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────▼──────────────────────────────────┐ │
│ │ Nextcloud Core (/index.php/login/v2) │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
│ App Password
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ MCP Server │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ POST /api/v1/users/{user_id}/app-password │ │
│ │ - Receives app password from Astrolabe │ │
│ │ - Stores encrypted with scopes │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Astrolabe UI Components:
-
Scope Selection UI (
/apps/astrolabe/src/components/ScopeSelector.vue):<template> <div class="scope-selector"> <h3>Select MCP Access Permissions</h3> <p class="description"> Choose which Nextcloud features the MCP server can access on your behalf. </p> <div v-for="category in scopeCategories" :key="category.name" class="scope-category"> <h4>{{ category.label }}</h4> <NcCheckboxRadioSwitch v-for="scope in category.scopes" :key="scope.id" v-model="selectedScopes" :value="scope.id" type="checkbox" > {{ scope.label }} <template #description>{{ scope.description }}</template> </NcCheckboxRadioSwitch> </div> <NcButton @click="initiateLoginFlow" :disabled="selectedScopes.length === 0"> Authorize Access </NcButton> </div> </template> -
Login Flow Controller (
/apps/astrolabe/lib/Controller/LoginFlowController.php):/** * Initiate Login Flow v2 and redirect user to authorization. * After completion, store app password in MCP server. */ public function initiateFlow(array $requestedScopes): RedirectResponse { // Start Login Flow v2 $response = $this->httpClient->post( $this->urlGenerator->getAbsoluteURL('/index.php/login/v2'), ['headers' => ['User-Agent' => 'Astrolabe MCP Provisioning']] ); $data = json_decode($response->getBody(), true); // Store session state $this->session->set('mcp_login_flow', [ 'poll_endpoint' => $data['poll']['endpoint'], 'poll_token' => $data['poll']['token'], 'requested_scopes' => $requestedScopes, 'expires' => time() + 600, ]); // Redirect to Nextcloud login return new RedirectResponse($data['login']); } /** * Callback after user completes Login Flow. * Poll for credentials and send to MCP server. */ public function completeFlow(): JSONResponse { $session = $this->session->get('mcp_login_flow'); // Poll for completion $response = $this->httpClient->post($session['poll_endpoint'], [ 'form_params' => ['token' => $session['poll_token']] ]); if ($response->getStatusCode() === 200) { $credentials = json_decode($response->getBody(), true); // Send to MCP server $this->mcpClient->storeAppPassword( userId: $this->userSession->getUser()->getUID(), appPassword: $credentials['appPassword'], scopes: $session['requested_scopes'] ); $this->session->remove('mcp_login_flow'); return new JSONResponse(['status' => 'success']); } return new JSONResponse(['status' => 'pending']); } -
Current Scopes Display (
/apps/astrolabe/src/components/CurrentAccess.vue):<template> <div class="current-access"> <h3>Current MCP Access</h3> <div v-if="accessStatus.provisioned"> <p>Access provisioned on {{ formatDate(accessStatus.created_at) }}</p> <h4>Authorized Scopes:</h4> <ul class="scope-list"> <li v-for="scope in accessStatus.scopes" :key="scope"> <NcIconSvgWrapper :path="getScopeIcon(scope)" /> {{ formatScope(scope) }} </li> </ul> <div class="actions"> <NcButton @click="showScopeUpdate = true"> Update Permissions </NcButton> <NcButton type="error" @click="revokeAccess"> Revoke Access </NcButton> </div> </div> <div v-else> <NcEmptyContent> <template #icon><AccountIcon /></template> <template #description> MCP access not configured. Set up access to use AI assistants with your Nextcloud. </template> <template #action> <NcButton @click="showScopeSelector = true"> Set Up Access </NcButton> </template> </NcEmptyContent> </div> </div> </template>
API Endpoints for Astrolabe:
| Endpoint | Method | Purpose |
|---|---|---|
/api/v1/users/{user_id}/access |
GET | Check provisioning status and scopes |
/api/v1/users/{user_id}/app-password |
POST | Store app password with scopes |
/api/v1/users/{user_id}/app-password |
DELETE | Revoke access |
/api/v1/users/{user_id}/scopes |
PATCH | Update scopes (triggers re-auth) |
/api/v1/scopes |
GET | List all supported scopes with descriptions |
Database Schema Changes
Add scopes column to app_passwords table and new login_flow_sessions table:
-- Migration: 003_add_scopes_and_login_flow_sessions.py
-- Add scopes column to existing app_passwords table (JSON array)
ALTER TABLE app_passwords ADD COLUMN scopes TEXT;
-- Add login flow sessions table for pending authorizations
CREATE TABLE IF NOT EXISTS login_flow_sessions (
user_id TEXT PRIMARY KEY,
poll_token TEXT NOT NULL,
poll_endpoint TEXT NOT NULL,
requested_scopes TEXT NOT NULL, -- JSON array
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
-- Create index for cleanup of expired sessions
CREATE INDEX IF NOT EXISTS idx_login_flow_expires
ON login_flow_sessions(expires_at);
Updated app_passwords schema:
CREATE TABLE app_passwords (
user_id TEXT PRIMARY KEY,
encrypted_password BLOB NOT NULL,
username TEXT NOT NULL, -- Nextcloud login name
scopes TEXT, -- JSON array of authorized scopes (NEW)
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
Scope Enforcement in @require_scopes Decorator
Modify the decorator to check scopes from stored app password when OAuth token is not available:
def require_scopes(*required_scopes: str):
"""
Decorator to require specific scopes for MCP tool execution.
Scope enforcement modes:
1. OAuth mode (access_token present): Check token scopes
2. App password mode (no token, stored app password): Check stored scopes
3. Single-user mode (env var app password): Bypass checks (trusted environment)
"""
def decorator(func: Callable) -> Callable:
func._required_scopes = list(required_scopes)
func_name = getattr(func, "__name__", repr(func))
context_param_name = find_context_parameter(func)
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
ctx: Context | None = (
kwargs.get(context_param_name) if context_param_name else None
)
if ctx is None:
# No context - allow (BasicAuth mode, backwards compat)
logger.debug(f"No context for {func_name} - allowing")
return await func(*args, **kwargs)
# Try OAuth token first
access_token: AccessToken | None = getattr(
ctx.request_context, "access_token", None
)
if access_token is not None:
# OAuth mode: check token scopes (existing logic)
return await _check_oauth_scopes(
func, access_token, required_scopes, *args, **kwargs
)
# No OAuth token - check deployment mode
settings = get_settings()
if settings.nextcloud_app_password:
# Single-user mode with env var: bypass scope checks
logger.debug(f"Single-user mode for {func_name} - allowing")
return await func(*args, **kwargs)
# Multi-user mode: check stored app password scopes
user_id = extract_user_from_context(ctx)
if user_id is None:
raise ScopeAuthorizationError("Cannot determine user identity")
storage = get_storage()
app_password_data = await storage.get_app_password_with_scopes(user_id)
if app_password_data is None:
raise ProvisioningRequiredError(
"Nextcloud access not provisioned. "
"Call nc_auth_provision_access to authorize."
)
stored_scopes = set(app_password_data.get("scopes") or [])
required_set = set(required_scopes)
missing = required_set - stored_scopes
if missing:
# Log scope mismatch for audit
await _audit_scope_mismatch(user_id, func_name, missing, stored_scopes)
raise InsufficientScopeError(
list(missing),
f"Access denied to {func_name}: Missing scopes {missing}. "
f"Re-provision with nc_auth_provision_access to request additional scopes."
)
logger.debug(f"App password scope check passed for {func_name}")
return await func(*args, **kwargs)
return wrapper
return decorator
Configuration Validation Simplification
Replace 5 modes with 2 modes:
class AuthMode(Enum):
SINGLE_USER = "single_user"
MULTI_USER = "multi_user"
MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
AuthMode.SINGLE_USER: ModeRequirements(
required=["nextcloud_host", "nextcloud_app_password"],
optional=[
"nextcloud_username", # Inferred from app password if not set
"enable_semantic_search",
"qdrant_url",
"qdrant_location",
],
forbidden=[],
conditional={
"enable_semantic_search": ["qdrant_url OR qdrant_location"],
},
description="Single-user deployment with app password in environment. "
"Suitable for personal instances and development.",
),
AuthMode.MULTI_USER: ModeRequirements(
required=["nextcloud_host", "token_encryption_key", "token_storage_db"],
optional=[
"enable_semantic_search",
"qdrant_url",
"qdrant_location",
],
forbidden=["nextcloud_app_password"],
conditional={
"enable_semantic_search": ["qdrant_url OR qdrant_location"],
},
description="Multi-user deployment with per-user app passwords via Login Flow v2. "
"App passwords acquired through browser-based authorization.",
),
}
Security Considerations
Critical: Application-Level Scope Enforcement
Nextcloud app passwords have NO native scope support. They grant full API access equivalent to the user's permissions in Nextcloud.
Implications:
- The MCP server enforces scopes at the application level only
- A compromised MCP server could bypass scope restrictions
- A malicious actor with direct access to stored app passwords has full Nextcloud API access
Mitigations:
- Clear Documentation: Administrators must understand this trust model
- Audit Logging: Log all scope enforcement decisions for security review
- Encryption at Rest: App passwords encrypted with Fernet (AES-256)
- User Visibility: App passwords visible in Nextcloud Settings > Security > Devices & Sessions
- User Revocation: Users can revoke app passwords directly in Nextcloud
- Named App Passwords: User-Agent includes user ID for identification
Security Posture Documentation
Include this warning in deployment documentation and server startup logs:
Security Notice: Scope Enforcement Limitations
App passwords generated via Login Flow v2 grant full API access to Nextcloud at the Nextcloud level. The MCP server enforces scope restrictions at the application level only.
What this means:
- When a user authorizes scopes like
notes:read, the MCP server records these scopes and enforces them before executing tools- The underlying app password can access ANY Nextcloud API the user can access
- Scope enforcement is defense-in-depth, not a security boundary
Trust Model:
- Trust the MCP server to enforce scopes correctly
- Trust the MCP server's storage to be secure (encrypted, access-controlled)
- Users can revoke access via Nextcloud Settings > Security > Devices & Sessions
Audit Trail:
- All scope enforcement decisions are logged
- Scope denials include user ID, tool name, and missing scopes
- Logs can be forwarded to SIEM for security monitoring
Rate Limiting
Implement rate limiting on Login Flow v2 operations to prevent abuse:
# Rate limits
LOGIN_FLOW_INITIATE_LIMIT = 5 # initiations per user per hour
LOGIN_FLOW_POLL_LIMIT = 60 # poll attempts per session (10 min at 10s intervals)
async def nc_auth_provision_access(ctx: Context, ...) -> ProvisionAccessResponse:
user_id = extract_user_from_mcp_token(ctx)
# Rate limit check for initiation
if await is_rate_limited(user_id, "login_flow_initiate", limit=5, window=3600):
raise RateLimitError("Too many provisioning attempts. Try again later.")
await record_rate_limit_hit(user_id, "login_flow_initiate")
# ... rest of implementation
Audit Logging
All authentication and scope-related events are logged:
AUDIT_EVENTS = [
"login_flow_initiated", # User started provisioning
"login_flow_completed", # User completed provisioning
"login_flow_failed", # Provisioning failed (timeout, rejection)
"login_flow_expired", # Session expired before completion
"scope_enforcement_allowed", # Tool execution allowed
"scope_enforcement_denied", # Tool execution denied (missing scopes)
"app_password_stored", # App password saved
"app_password_deleted", # App password revoked
"app_password_used", # App password used for API call
]
Migration Path
Modes Being Removed
| Current Mode | Replacement | Deprecation Reason |
|---|---|---|
| Single-User BasicAuth | Mode 1 (Single-User) | Renamed only (NEXTCLOUD_PASSWORD → NEXTCLOUD_APP_PASSWORD) |
| Multi-User BasicAuth | Mode 2 (Multi-User) | Credential pass-through is a security anti-pattern |
| OAuth Single-Audience | Mode 2 (Multi-User) | Requires upstream Nextcloud patches not planned for adoption |
| OAuth Token Exchange | Mode 2 (Multi-User) | Not required until Nextcloud supports OAuth bearer tokens (not planned) |
| Smithery Stateless | DROPPED | Smithery platform removed free tier; stateless model incompatible with Login Flow v2 |
Phase 1: Add Login Flow v2 Support (v0.65)
- Implement
nc_auth_provision_accessandnc_auth_check_statustools - Add
scopescolumn toapp_passwordstable - Add
login_flow_sessionstable - Update
@require_scopesdecorator for app password mode - Mark OAuth modes as deprecated in documentation
- Log deprecation warnings when deprecated modes detected
Phase 2: Deprecation Period (v0.66)
- Add prominent deprecation warnings at startup
- Provide migration guide with step-by-step instructions
- Add tooling to help users transition (config checker, etc.)
- Continue supporting all modes with warnings
Phase 3: Remove Deprecated Modes (v1.0)
- Remove
ENABLE_TOKEN_EXCHANGE,ENABLE_MULTI_USER_BASIC_AUTH,SMITHERY_*variables - Remove OAuth token pass-through code paths
- Remove Smithery stateless mode
- Simplify configuration validation to 2 modes
- Update all documentation
Backward Compatibility During Transition
Existing configurations continue working during the transition period:
# Config detection during transition
def detect_deployment_mode() -> AuthMode:
settings = get_settings()
# Explicit mode takes precedence
if settings.mcp_deployment_mode:
return settings.mcp_deployment_mode
# Legacy mode detection with deprecation warnings
if settings.enable_token_exchange:
logger.warning(
"ENABLE_TOKEN_EXCHANGE is deprecated. "
"Migrate to Multi-User mode with Login Flow v2. "
"See: https://docs.example.com/migration"
)
return AuthMode.MULTI_USER # Treat as multi-user
if settings.enable_multi_user_basic_auth:
logger.warning(
"ENABLE_MULTI_USER_BASIC_AUTH is deprecated. "
"Migrate to Multi-User mode with Login Flow v2."
)
return AuthMode.MULTI_USER
if settings.nextcloud_app_password:
return AuthMode.SINGLE_USER
# No app password configured = multi-user mode
return AuthMode.MULTI_USER
Consequences
Positive
- Simpler Deployment: 2 modes instead of 5
- No Upstream Dependencies: Works on any Nextcloud 16+ without patches
- Better UX: Browser-based authorization (familiar pattern for users)
- User Control: App passwords visible and revocable in Nextcloud settings
- Reduced Maintenance: Less configuration validation code
- Standard Pattern: Login Flow v2 is the same mechanism used by all official Nextcloud clients
- Clearer Security Model: Application-level scope enforcement is explicit, not hidden
- Audit Trail: All scope decisions logged for security review
- Seamless Elicitation: MCP clients automatically prompt for authorization when needed
- Progressive Scope Grants: Users can start with minimal scopes and add more as needed
- Dual Entry Points: Both MCP clients (via elicitation) and Astrolabe UI can initiate provisioning
Negative
- Scope Enforcement at Application Level: Not enforced by Nextcloud itself
- Trust in MCP Server: Administrators must trust server to enforce scopes correctly
- Migration Effort: Existing OAuth deployments need users to re-provision
- No Fine-Grained Nextcloud Permissions: App passwords grant full user-level access
- Smithery Users Affected: Stateless mode no longer supported
Neutral
- Same Security Model as Desktop/Mobile Apps: App passwords are already the standard for Nextcloud clients
- Background Sync Unchanged: App passwords work for offline operations
- Testing Simplified: Fewer containers and configurations to maintain
Alternatives Considered
Alternative 1: Keep All OAuth Modes
Rejected: Maintains complexity, requires upstream patches, limited adoption due to IdP configuration requirements. The current OAuth modes require either:
- Patched
user_oidcapp for Bearer token validation on non-OCS endpoints - Complex multi-IdP configuration for token exchange
Alternative 2: Remove Scope Support Entirely
Rejected: Security regression. Even application-level enforcement provides:
- Defense-in-depth against accidental misuse
- Audit logging for security review
- User-visible scope grants for transparency
- Foundation for future Nextcloud-native scope support
Alternative 3: Use Nextcloud's Native OAuth
Rejected: Nextcloud's OAuth implementation doesn't support fine-grained scopes. The Notes/Calendar/WebDAV APIs don't check OAuth scopes - they only verify the token is valid. This means Nextcloud OAuth provides no additional security over app passwords.
Alternative 4: Implement Scope Support in Nextcloud Upstream
Considered for Future: Contributing scope enforcement upstream would be the ideal long-term solution. However:
- Significant upstream contribution effort
- Requires changes to multiple Nextcloud apps
- Doesn't solve immediate consolidation needs
- Can be pursued in parallel without blocking this ADR
Alternative 5: Keep Smithery Mode
Rejected: Two factors make Smithery mode untenable:
-
Platform Change: Smithery has removed their free tier, significantly reducing the user base for this deployment mode. The maintenance burden no longer justifies the limited adoption.
-
Architectural Incompatibility: Smithery's stateless model (credentials in session URL parameters) is fundamentally incompatible with Login Flow v2 which requires persistent storage for:
- Poll sessions during authorization
- App passwords after authorization
- Scope enforcement data
Users previously deploying to Smithery must either:
- Self-host with persistent storage
- Use an alternative MCP hosting platform
- Use external storage (Redis, external PostgreSQL) with custom configuration
Alternative 6: Wait for Nextcloud OAuth Bearer Token Support
Rejected: Nextcloud does not currently support OAuth bearer token validation on non-OCS API endpoints (Notes, Calendar/CalDAV, WebDAV, etc.). There are no upstream plans to add this support. The token exchange mode (RFC 8693) was designed as a workaround, but:
- Requires complex IdP configuration
- Still needs upstream patches to
user_oidc - Adds significant complexity without widespread adoption
Login Flow v2 with app passwords provides equivalent functionality using native Nextcloud mechanisms.
References
- Nextcloud Login Flow Documentation
- ADR-020: Deployment Modes and Configuration Validation
- ADR-021: Configuration Consolidation
- ADR-004: Progressive Consent OAuth Architecture
- GitHub Issue #521: Login Flow v2 Support
- RFC 9728: OAuth 2.0 Protected Resource Metadata
Implementation Checklist
Phase 1: MCP Server Core (Login Flow v2)
| File | Changes |
|---|---|
nextcloud_mcp_server/auth/scope_authorization.py |
Add app password scope checking, elicitation support |
nextcloud_mcp_server/auth/storage.py |
Add scopes field, login_flow_sessions methods |
nextcloud_mcp_server/server/auth_tools.py |
Add nc_auth_provision_access, nc_auth_check_status, nc_auth_update_scopes |
nextcloud_mcp_server/auth/login_flow.py |
New: Login Flow v2 client implementation |
nextcloud_mcp_server/auth/elicitation.py |
New: MCP elicitation helpers for URL opening |
nextcloud_mcp_server/config.py |
Simplify mode detection to 2 modes |
nextcloud_mcp_server/config_validators.py |
Reduce validation to 2 modes |
alembic/versions/ |
Migration for scopes column and login_flow_sessions table |
Phase 2: Astrolabe Front-End
| File | Changes |
|---|---|
astrolabe/lib/Controller/LoginFlowController.php |
New: PHP controller for Login Flow v2 |
astrolabe/lib/Service/McpClientService.php |
Add scope storage API calls |
astrolabe/src/components/ScopeSelector.vue |
New: Scope selection UI |
astrolabe/src/components/CurrentAccess.vue |
New: Current access status and management |
astrolabe/src/views/Settings.vue |
Integrate Login Flow v2 UI |
Phase 3: API Endpoints
| Endpoint | File | Purpose |
|---|---|---|
GET /api/v1/users/{user_id}/access |
nextcloud_mcp_server/api/access.py |
Check provisioning status |
POST /api/v1/users/{user_id}/app-password |
nextcloud_mcp_server/api/passwords.py |
Store app password (existing, add scopes) |
PATCH /api/v1/users/{user_id}/scopes |
nextcloud_mcp_server/api/access.py |
Update scopes (trigger re-auth) |
GET /api/v1/scopes |
nextcloud_mcp_server/api/access.py |
List supported scopes with descriptions |
Phase 4: Documentation
| File | Changes |
|---|---|
docs/authentication.md |
Rewrite for 2-mode architecture |
docs/configuration.md |
Simplify configuration docs |
docs/astrolabe-integration.md |
New: Astrolabe setup guide |
docs/security-posture.md |
New: Security model documentation for admins |
Verification Steps
- Unit tests:
@require_scopesdecorator with app password scopes (no OAuth token) - Unit tests: MCP elicitation response generation
- Integration tests: Login Flow v2 initiation, polling, and completion
- Integration tests: Re-auth flow for scope updates
- Scope tests: Verify scope enforcement denies unauthorized access
- Scope tests: Verify scope merging on re-auth
- End-to-end (MCP): MCP client → elicitation → Login Flow v2 → Nextcloud API
- End-to-end (Astrolabe): Astrolabe UI → Login Flow v2 → MCP server storage
- Migration test: Existing OAuth deployment transitions to new mode
- Security audit: Verify scope enforcement cannot be bypassed
- Security audit: Verify app password encryption and storage