Skip to content

Latest commit

 

History

History
1386 lines (1110 loc) · 60.6 KB

File metadata and controls

1386 lines (1110 loc) · 60.6 KB

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):

  1. Single-User BasicAuth - App password in environment variables
  2. Multi-User BasicAuth - HTTP header credential pass-through
  3. OAuth Single-Audience - Multi-audience token validation
  4. OAuth Token Exchange - RFC 8693 delegation
  5. Smithery Stateless - Session URL parameters (free tier sunsetting March 2026)

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_oidc app 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.

Nextcloud Platform Limitation

Important: Nextcloud does not support scoped app passwords, and OAuth bearer token support varies by endpoint type. This is a platform limitation, not an MCP server design choice.

OAuth Bearer Token Support by Endpoint:

Endpoint Type OAuth Bearer Supported Scoped Access
OCS API ✅ Yes ❌ No
WebDAV ✅ Yes ❌ No
CalDAV/CardDAV ❌ No ❌ No
Notes API ❌ No ❌ No
Other App APIs ❌ No ❌ No

Implications:

  • App passwords grant full API access to any Nextcloud API the user can access
  • Even where OAuth tokens are accepted, scopes are not enforced by Nextcloud
  • There are no upstream plans to add scoped OAuth support to App APIs

Our approach: The MCP server implements application-level scope enforcement as a defense-in-depth measure. This provides audit logging, user transparency, and protection against accidental misuse, but administrators must understand that scope enforcement occurs at the MCP server layer, not the Nextcloud layer.

If Nextcloud adds scoped OAuth support for App APIs in the future, this architecture will be revisited to leverage native scope enforcement.

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:

  1. Client POST /index.php/login/v2 with User-Agent header
  2. Server returns {poll: {endpoint, token}, login: <url>}
  3. User visits login URL in their browser, authenticates normally
  4. Client polls endpoint with token
  5. On success: {server, loginName, appPassword}
  6. 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. The MCP specification (2025-11-25) defines two elicitation modes:

  • Form mode: Structured data collection through the MCP client
  • URL mode: Out-of-band interactions via external URLs (e.g., OAuth flows)

Capability Negotiation

Clients declare elicitation support during session initialization:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "elicitation": {
        "form": {},
        "url": {}
      }
    }
  }
}

Important: URL mode elicitation (elicitation.url) is not widely supported by MCP clients as of early 2026. The MCP server MUST gracefully handle clients that:

  • Declare no elicitation capability
  • Declare only form mode support
  • Declare url mode but fail to open URLs

Implementation with Graceful Fallback

The server checks client capabilities and falls back to a message-based approach when URL elicitation is unavailable:

from mcp.types import ElicitResult, ElicitRequest

async def handle_nextcloud_access(ctx: Context, user_id: str) -> ElicitResult | ProvisioningRequiredError:
    """Check if user needs to provision access, return elicitation or error with URL."""

    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()
    login_url = data["login"]

    # 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,
    )

    # Check client capabilities for URL elicitation
    client_capabilities = get_client_capabilities(ctx)
    supports_url_elicitation = (
        client_capabilities.get("elicitation", {}).get("url") is not None
    )

    if supports_url_elicitation:
        # Preferred: Use URL elicitation for seamless UX
        return ElicitResult(
            mode="url",
            elicitationId=str(uuid.uuid4()),
            url=login_url,
            message=(
                "To access Nextcloud resources, please authorize this application. "
                "Click the link to open Nextcloud in your browser and complete authentication."
            ),
        )
    else:
        # Fallback: Return error with URL in message for manual copy/paste
        raise ProvisioningRequiredError(
            f"Nextcloud access not provisioned. Please visit the following URL to authorize:\n\n"
            f"    {login_url}\n\n"
            f"After completing authentication in your browser, retry your request."
        )

Client Behavior Expectations

Client Capability Server Behavior User Experience
elicitation.url supported Returns URL elicitation Client opens URL automatically or presents clickable link
elicitation.form only Returns error with URL in message User copies URL and pastes in browser
No elicitation support Returns error with URL in message User copies URL and pastes in browser

Fallback UX: Even without URL elicitation support, users can complete the Login Flow by copying the URL from the error message. This ensures the feature works with any MCP client, though with slightly degraded UX.

Retry Behavior

After the user completes authentication in their browser:

  1. User retries the original MCP request
  2. Server polls Login Flow v2 endpoint and detects completion
  3. Server stores app password with requested scopes
  4. Original request proceeds normally

Re-Authentication for Scope Updates

Users may need to update their authorized scopes after initial provisioning. The system supports re-authentication with scope merging.

Re-auth Scenarios

Scenario Trigger User Action Required
Initial provisioning User has no app password Complete Login Flow v2
Scope expansion Tool requires scope user hasn't authorized Re-authenticate to add scopes
Scope reduction User wants to revoke specific scopes Revoke and re-provision with fewer scopes
Token rotation Admin policy or user preference Re-authenticate (new app password issued)

Scope Merging Behavior

When a user re-authenticates to add scopes:

  1. Existing scopes are preserved: New scopes are merged with existing scopes
  2. Old app password is revoked: Nextcloud revokes the previous app password
  3. New app password issued: User authenticates via Login Flow v2
  4. Merged scopes stored: Both old and new scopes associated with new password
Initial:    [notes:read]
Request:    [calendar:read, calendar:write]
Result:     [notes:read, calendar:read, calendar:write]

Note: Scope reduction requires explicit revocation. Users cannot "downgrade" scopes without fully revoking and re-provisioning.

Re-auth Tool Implementation

@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 Prompting

When a tool requires a scope the user hasn't authorized, the @require_scopes decorator returns an error with re-auth instructions:

# In @require_scopes decorator, when scopes are missing:
if missing:
    # Return error with clear instructions for scope upgrade
    raise InsufficientScopeError(
        missing_scopes=list(missing),
        message=(
            f"This action requires additional permissions: {', '.join(missing)}.\n\n"
            f"To authorize these scopes, call nc_auth_update_scopes with:\n"
            f"  additional_scopes={list(missing)}"
        ),
    )

Design Choice: We use explicit tool calls for re-auth rather than automatic elicitation because:

  1. Users should consciously decide to expand access
  2. Scope changes are auditable events
  3. Avoids unexpected browser redirects during normal operation

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:

  1. 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>
  2. 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']);
    }
  3. 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:

  1. The MCP server enforces scopes at the application level only
  2. A compromised MCP server could bypass scope restrictions
  3. A malicious actor with direct access to stored app passwords has full Nextcloud API access

Mitigations:

  1. Clear Documentation: Administrators must understand this trust model
  2. Audit Logging: Log all scope enforcement decisions for security review
  3. Encryption at Rest: App passwords encrypted with Fernet (AES-256)
  4. User Visibility: App passwords visible in Nextcloud Settings > Security > Devices & Sessions
  5. User Revocation: Users can revoke app passwords directly in Nextcloud
  6. 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

Rate limiting is configurable and should be tuned based on deployment size and usage patterns. The MCP server is an external client to Nextcloud and should not require special Nextcloud configuration to function.

Configuration Variables:

# Login Flow v2 rate limits (environment variables)
LOGIN_FLOW_INITIATE_LIMIT=5      # Max initiations per user per window (default: 5)
LOGIN_FLOW_INITIATE_WINDOW=3600  # Window in seconds (default: 1 hour)
LOGIN_FLOW_POLL_INTERVAL=10      # Seconds between poll attempts (default: 10)
LOGIN_FLOW_POLL_TIMEOUT=600      # Max seconds to poll before timeout (default: 10 min)

Implementation:

async def nc_auth_provision_access(ctx: Context, ...) -> ProvisionAccessResponse:
    user_id = extract_user_from_mcp_token(ctx)
    settings = get_settings()

    # Rate limit check for initiation (configurable)
    if await is_rate_limited(
        user_id,
        "login_flow_initiate",
        limit=settings.login_flow_initiate_limit,
        window=settings.login_flow_initiate_window,
    ):
        raise RateLimitError("Too many provisioning attempts. Try again later.")

    await record_rate_limit_hit(user_id, "login_flow_initiate")
    # ... rest of implementation

Administrator Guidance:

Deployment Size Recommended Initiate Limit Notes
Personal (1-5 users) 10/hour Higher limit acceptable
Small team (5-50 users) 5/hour Default is appropriate
Enterprise (50+ users) 3/hour Consider integration with external rate limiting

Rate limiting at the MCP server level is defense-in-depth. Administrators should also consider:

  • Nextcloud's built-in brute force protection
  • Reverse proxy rate limiting (nginx, Traefik)
  • Network-level controls for multi-user deployments

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
]

App Password Lifecycle Management

App passwords acquired via Login Flow v2 require lifecycle management to handle revocation, expiry, and session cleanup.

Stale/Revoked Password Detection

When Nextcloud API calls return HTTP 401 using a stored app password, the server must distinguish credential failure from transient errors and trigger re-provisioning:

async def handle_api_response(response: httpx.Response, user_id: str) -> None:
    """Detect revoked/invalid app passwords and trigger re-provisioning."""

    if response.status_code == 401:
        # App password was revoked or invalidated by Nextcloud
        logger.warning(f"App password invalid for user {user_id}, marking for re-provisioning")
        await storage.mark_app_password_invalid(user_id)
        await audit_log("app_password_invalidated", user_id=user_id)

        raise ProvisioningRequiredError(
            "Your Nextcloud access has been revoked or expired. "
            "Call nc_auth_provision_access to re-authorize."
        )

    # Transient errors (5xx, timeouts) do NOT invalidate the password
    if response.status_code >= 500:
        raise NextcloudServerError(f"Nextcloud returned {response.status_code}")

Key distinction: Only HTTP 401 marks the password as invalid. Server errors (5xx) and network timeouts are transient and should be retried without invalidating credentials.

Login Flow Session Cleanup

Abandoned Login Flow v2 sessions (where the user never completes browser authorization) accumulate in the login_flow_sessions table. A background cleanup task removes expired rows:

async def cleanup_expired_login_flow_sessions() -> int:
    """Remove expired login flow sessions. Returns count of rows deleted."""
    result = await storage.delete_expired_login_flow_sessions(
        cutoff=int(time.time())
    )
    if result > 0:
        logger.info(f"Cleaned up {result} expired login flow sessions")
    return result

Configuration:

# Login flow session cleanup (environment variables)
LOGIN_FLOW_CLEANUP_INTERVAL=3600  # Seconds between cleanup runs (default: 1 hour)

Sessions expire naturally via the expires_at column (set to 10 minutes after initiation). The cleanup task is defense-in-depth to prevent unbounded table growth.

App Password Rotation (Optional)

Administrators can configure an optional rotation policy that prompts users to re-provision after a configurable age:

# App password rotation (environment variable)
APP_PASSWORD_MAX_AGE_DAYS=0  # 0 = disabled (default). Set to e.g. 90 for 90-day rotation.

When enabled, the server checks password age on each request:

async def check_app_password_age(user_id: str) -> None:
    """Check if app password exceeds max age and trigger rotation if needed."""
    settings = get_settings()
    if settings.app_password_max_age_days == 0:
        return  # Rotation disabled

    app_password_data = await storage.get_app_password_with_scopes(user_id)
    if app_password_data is None:
        return

    age_days = (time.time() - app_password_data["created_at"]) / 86400
    if age_days > settings.app_password_max_age_days:
        logger.info(f"App password for user {user_id} exceeded max age ({age_days:.0f} days)")
        await audit_log("app_password_rotation_triggered", user_id=user_id, age_days=age_days)

        # Invalidate old password, same path as revocation
        await storage.mark_app_password_invalid(user_id)
        raise ProvisioningRequiredError(
            f"Your Nextcloud access credentials have expired (>{settings.app_password_max_age_days} days). "
            "Call nc_auth_provision_access to re-authorize."
        )

Design notes:

  • Rotation reuses the same re-provisioning path as revoked password detection
  • The old app password is invalidated when the user completes re-provisioning (not before), avoiding a gap in access
  • Audit log records rotation events for compliance tracking

Migration Path

Modes Being Removed

Current Mode Replacement Deprecation Reason
Single-User BasicAuth Mode 1 (Single-User) Renamed only (NEXTCLOUD_PASSWORDNEXTCLOUD_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) Complex IdP configuration, limited adoption
Smithery Stateless DROPPED Free tier sunsetting March 2026; not cost-justified for a self-hostable server. Third-party hosting also conflicts with privacy goals

Phase 1: Add Login Flow v2 Support (v0.65)

  • Implement nc_auth_provision_access and nc_auth_check_status tools
  • Add scopes column to app_passwords table
  • Add login_flow_sessions table
  • Update @require_scopes decorator 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

  1. Simpler Deployment: 2 modes instead of 5
  2. No Upstream Dependencies: Works on any Nextcloud 16+ without patches
  3. Better UX: Browser-based authorization (familiar pattern for users)
  4. User Control: App passwords visible and revocable in Nextcloud settings
  5. Reduced Maintenance: Less configuration validation code
  6. Standard Pattern: Login Flow v2 is the same mechanism used by all official Nextcloud clients
  7. Clearer Security Model: Application-level scope enforcement is explicit, not hidden
  8. Audit Trail: All scope decisions logged for security review
  9. Seamless Elicitation: MCP clients automatically prompt for authorization when needed
  10. Progressive Scope Grants: Users can start with minimal scopes and add more as needed
  11. Dual Entry Points: Both MCP clients (via elicitation) and Astrolabe UI can initiate provisioning

Negative

  1. Scope Enforcement at Application Level: Not enforced by Nextcloud itself (platform limitation)
  2. Trust in MCP Server: Administrators must trust server to enforce scopes correctly
  3. Migration Effort: Existing OAuth deployments need users to re-provision
  4. No Fine-Grained Nextcloud Permissions: App passwords grant full user-level access
  5. No Third-Party Hosted Option: Users requiring managed hosting must self-host

Neutral

  1. Same Security Model as Desktop/Mobile Apps: App passwords are already the standard for Nextcloud clients
  2. Background Sync Unchanged: App passwords work for offline operations
  3. 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_oidc app 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/Third-Party Hosted Mode

Rejected: Smithery is sunsetting its free tier in March 2026, making continued support a paid hosting cost for a server explicitly designed to be self-hosted. Beyond the cost issue, third-party hosted deployments route user data through infrastructure outside the user's control, conflicting with the project's privacy-first design.

Recommendation for users:

  • Individual users: Use Single-User mode with self-hosted deployment (Docker, VM, bare metal)
  • Organizations: Use Multi-User mode with organizational infrastructure (Kubernetes, Docker Compose)

Alternative 6: Wait for Nextcloud OAuth Bearer Token Support

Rejected for now, with future revisit planned: Nextcloud does not currently support scoped OAuth bearer token validation on most App APIs. The current OAuth implementation validates tokens but does not enforce scopes at the API level.

Current state:

  • WebDAV and OCS endpoints accept OAuth bearer tokens (but without scope enforcement)
  • CalDAV, CardDAV, Notes API, and other App APIs do not accept OAuth bearer tokens
  • No upstream plans announced to add scoped OAuth support

Our approach:

  • Keep the implementation simple using Login Flow v2 and app passwords
  • Application-level scope enforcement provides defense-in-depth
  • If Nextcloud adds scoped OAuth support for App APIs in the future, we will revisit this architecture to leverage native scope enforcement

This approach prioritizes simplicity and compatibility over waiting for uncertain upstream changes.

References

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 (Required)

File Changes
README.md Add security notice about Nextcloud scope limitation (see below)
docs/authentication.md Rewrite for 2-mode architecture
docs/configuration.md Simplify configuration docs, add rate limiting guidance
docs/astrolabe-integration.md New: Astrolabe setup guide
docs/security-posture.md New: Security model documentation for admins

Required README Addition:

## Security Notice: Scope Enforcement

> **Important**: Nextcloud does not support scoped app passwords or OAuth scopes for
> most App APIs. This is a Nextcloud platform limitation, not an MCP server limitation.
>
> The MCP server implements **application-level scope enforcement** as a defense-in-depth
> measure. When users authorize scopes like `notes:read`, the MCP server records and
> enforces these scopes before executing tools. However, the underlying app password
> can access any Nextcloud API the user has permission to access.
>
> **Administrators should understand:**
> - Scope enforcement occurs at the MCP server layer, not the Nextcloud layer
> - A compromised MCP server could bypass scope restrictions
> - Users can revoke access via Nextcloud Settings > Security > Devices & Sessions
>
> If Nextcloud adds scoped OAuth support for App APIs in the future, this architecture
> will be revisited to leverage native scope enforcement.

Phase 5: Recommended Nextcloud Configuration

Document recommended Nextcloud settings for optimal MCP server operation:

Setting Recommendation Purpose
'auth.bruteforce.protection.enabled' true (default) Protects Login Flow v2 from abuse
'ratelimit.protection.enabled' true (default) General API rate limiting
'trusted_proxies' Configure if behind reverse proxy Accurate IP detection for rate limiting

Note: The MCP server is designed to work with a standard Nextcloud deployment without special configuration. These are recommendations for production deployments.

Verification Steps (Required for Implementation)

All tests must pass before the feature is considered complete.

Unit Tests:

  1. @require_scopes decorator with app password scopes (no OAuth token)
  2. MCP elicitation response generation with capability detection
  3. Elicitation fallback when URL mode not supported
  4. Scope merging logic for re-authentication
  5. Rate limiting configuration validation

Integration Tests: 6. Login Flow v2 initiation, polling, and completion 7. Re-auth flow for scope updates 8. Scope enforcement denies unauthorized access 9. Scope merging preserves existing scopes on re-auth

End-to-End Tests: 10. MCP client → provisioning → Login Flow v2 → Nextcloud API call 11. Astrolabe UI → Login Flow v2 → MCP server storage

Migration Tests: 12. Existing OAuth deployment transitions to new mode with deprecation warnings

Security Tests: 13. Verify scope enforcement cannot be bypassed via direct API calls 14. Verify app password encryption and secure storage 15. Verify rate limiting prevents abuse