Skip to content

Latest commit

 

History

History
964 lines (753 loc) · 35.8 KB

File metadata and controls

964 lines (753 loc) · 35.8 KB

ADR-002: Vector Database Background Sync Authentication

⚠️ DEPRECATED: This ADR has been superseded by ADR-004: MCP Server as OAuth Client for Offline Access.

Reason for Deprecation: This ADR fundamentally misunderstood the MCP protocol's authentication architecture. The MCP server receives tokens from clients but cannot initiate OAuth flows or store refresh tokens, making the proposed solutions ineffective for true offline access. ADR-004 provides the correct architectural pattern where the MCP server acts as its own OAuth client.

Status

Accepted - Tier 2 (Token Exchange with Delegation) Implemented Superseded by ADR-004 - The token exchange implementation exists but doesn't solve the offline access problem.

Important: Service account tokens (old Tier 1) have been rejected as they violate OAuth "act on-behalf-of" principles by creating Nextcloud user accounts for the MCP server.

Context

To enable semantic search capabilities, the MCP server needs to index user content (notes, files, calendar events) into a vector database. This requires a background sync worker that:

  1. Runs independently of user requests (periodic or continuous operation)
  2. Accesses multiple users' content to build a comprehensive search index
  3. Respects user permissions - only index content users have access to
  4. Operates in OAuth mode - where the MCP server doesn't have traditional admin credentials

Current OAuth Architecture

The MCP server currently operates in two authentication modes:

  1. BasicAuth Mode: Uses username/password credentials (typically admin account)
  2. OAuth Mode: Single OAuth client, multiple user tokens
    • Users authenticate via OAuth flow
    • Each request includes user's access token
    • Server creates per-request NextcloudClient with user's bearer token
    • No tokens are stored server-side

The Challenge

Background workers need long-lived authentication to:

  • Index content continuously/periodically
  • Process multiple users' data in batch operations
  • Operate when users are not actively making requests

However, in OAuth mode:

  • User access tokens are ephemeral (exist only during request)
  • MCP server doesn't store user credentials
  • Admin credentials defeat the purpose of OAuth

We need an OAuth-native solution that maintains security while enabling background operations.

Decision

We will implement a tiered OAuth authentication strategy for background operations in OAuth mode. When OAuth authentication is not configured or available, the background sync feature is not available.

Note: This ADR applies only to OAuth mode. In BasicAuth mode (single-user deployments), credentials are already available via environment variables, and background operations work without additional configuration.

OAuth "Act On-Behalf-Of" Principle

Core Requirement: The MCP server must NEVER create its own user identity in Nextcloud when operating in OAuth mode.

Valid Patterns:

  • Foreground operations: Use user's access token from MCP request (currently implemented)
  • Background operations: Token exchange to impersonate/delegate as user (requires provider support)
  • Service account: Creates independent identity in Nextcloud (violates OAuth principles)

Why This Matters:

  1. Audit Trail: All operations must be attributable to the actual user, not a service account
  2. Stateless Server: MCP server should not have persistent identity/state in Nextcloud
  3. Security Model: Avoid creating "admin by another name" with broad cross-user permissions
  4. OAuth Design: OAuth tokens represent user authorization, not server authorization

If Token Exchange Not Available:

  • Background operations simply cannot happen in OAuth mode
  • This is correct behavior - not a limitation to work around
  • Don't create service accounts as "workaround" - this defeats OAuth's purpose
  • Use BasicAuth mode if background operations are critical to your deployment

Tier 1: Token Exchange with Impersonation (RFC 8693) ⚠️ NOT IMPLEMENTED

Better Security - Requires provider support for user impersonation

  • Service account exchanges token to impersonate specific users
  • Each background operation runs as the target user
  • Uses requested_subject parameter in token exchange
  • Per-user permission enforcement at API level

Requirements:

  • OIDC provider supports RFC 8693 token exchange
  • Provider supports user impersonation (rare - requires Legacy Keycloak V1 with preview features)
  • Service account has impersonation permissions

Status: ⚠️ Not implemented - Keycloak Standard V2 doesn't support impersonation Reference: See docs/oauth-impersonation-findings.md for investigation details

Tier 2: Token Exchange with Delegation (RFC 8693) ✅ IMPLEMENTED

Best Security - Requires provider support for delegation with act claim

  • Service account exchanges token on behalf of users (delegation, not impersonation)
  • Token includes act claim showing service account as actor
  • API sees both the user (sub) and actor (act) in token
  • Full audit trail of delegated operations
  • Implementation: KeycloakOAuthClient.exchange_token_for_user() (keycloak_oauth.py:397-495)
  • Testing: Manual test in tests/manual/test_token_exchange.py
  • Limitation: Keycloak doesn't support act claim yet - Issue #38279

Requirements:

  • OIDC provider supports RFC 8693 token exchange
  • Provider supports delegation with act claim (very rare)
  • Proper token exchange permissions configured

Current Implementation: Internal-to-internal token exchange with audience modification (without act claim)

❌ Will Not Implement

1. Service Account with Independent Identity (client_credentials)

  • Status: Previously proposed as Tier 1, now rejected
  • Why Invalid: Creates Nextcloud user account for MCP server (e.g., service-account-nextcloud-mcp-server)
  • Problems:
    • Violates OAuth "act on-behalf-of" principle: Actions attributed to service account instead of real user
    • Breaks audit trail: Can't determine which user initiated the action
    • Creates stateful server identity: MCP server has persistent identity/data in Nextcloud
    • Security risk: Service account becomes "admin by another name" with broad cross-user permissions
    • User provisioning side effect: Nextcloud's user_oidc app auto-provisions service account as real user
  • Code Status: Implementation exists (KeycloakOAuthClient.get_service_account_token()) but marked with warnings
  • Alternative: If service account pattern truly needed, use BasicAuth mode instead of OAuth mode
  • Reference: See commit c12df98 for detailed analysis of why this approach was rejected

2. Offline Access with Refresh Tokens

  • MCP Protocol Architecture: FastMCP SDK manages OAuth where MCP Client handles refresh tokens
  • Security Model: Refresh tokens must never be shared between client and server (OAuth best practice)
  • Technical Impossibility: MCP Server has no access to refresh tokens from the OAuth callback
  • Alternative: Token exchange provides similar benefits without violating OAuth security model

3. Admin Credentials Fallback

  • Out of Scope: This ADR focuses on OAuth mode only
  • Not Appropriate: Admin credentials bypass OAuth security model
  • BasicAuth Mode: For single-user deployments needing background operations, use BasicAuth mode instead

Key Architectural Principles

  1. Capability Detection: Automatically detect which OAuth methods are supported
  2. Dual-Phase Authorization:
    • Sync worker indexes with service credentials
    • User requests verify access with user's OAuth token
  3. Defense in Depth: Vector database is search accelerator, not security boundary
  4. Separation of Concerns: Sync credentials ≠ Request credentials

Implementation Details

1. Token Exchange with Impersonation (Tier 1) ✅ IMPLEMENTED (Legacy V1 only)

Status: Implemented and working with Keycloak Legacy V1 (--features=preview). Requires additional permission configuration. Recommended for advanced use cases only.

When to Use: When you need the exchanged token to have the exact same identity as the target user (sub claim changes). This provides the cleanest separation but requires preview features.

1.1 Impersonation Flow

async def exchange_token_for_user(
    subject_token: str,
    target_user_id: str,
    audience: str | None = None,
    scopes: list[str] | None = None,
) -> dict:
    """Exchange service token to impersonate specific user.

    Requires Keycloak Legacy V1 (--features=preview) and impersonation permissions.
    The returned token will have the target_user_id as the 'sub' claim.
    """
    data = {
        "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
        "subject_token": subject_token,
        "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
        "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
        "requested_subject": target_user_id,  # ← KEY: Impersonate this user
    }

    if audience:
        data["audience"] = audience
    if scopes:
        data["scope"] = " ".join(scopes)

    response = await self._http_client.post(
        self.token_endpoint,
        data=data,
        auth=(self.client_id, self.client_secret),
    )
    response.raise_for_status()
    return response.json()

Implementation Requirements:

  • ✅ Keycloak Legacy V1 with --features=preview flag
  • ✅ Impersonation role granted to service account (see configuration below)
  • ❌ NOT supported in Keycloak Standard V2 (rejects requested_subject parameter)
  • ⚠️ Very few OIDC providers support user impersonation via token exchange

Empirical Testing (2025-11-02):

Tested impersonation with requested_subject parameter against Keycloak 26.4.2:

Test Command: uv run python tests/manual/test_impersonation.py

Keycloak Standard V2 Result:

HTTP/1.1 400 Bad Request
{
  "error": "invalid_request",
  "error_description": "Parameter 'requested_subject' is not supported for standard token exchange"
}

Confirmation: Keycloak explicitly rejects requested_subject in Standard V2, confirming this feature is unsupported. The error message is unambiguous - this parameter is not available in the current production token exchange implementation.

Keycloak Legacy V1 Result - Initial Test (with --features=preview):

HTTP/1.1 403 Forbidden
{
  "error": "access_denied",
  "error_description": "Client not allowed to exchange"
}

Keycloak logs:
reason="subject not allowed to impersonate"
impersonator="service-account-nextcloud-mcp-server"
requested_subject="admin"

Analysis: Legacy V1 accepts the requested_subject parameter (error changed from "not supported" to "not allowed"), indicating the feature is present but requires permission configuration.

Configuration Steps to Enable Impersonation:

  1. Enable Keycloak preview features (in docker-compose.yml):

    command:
      - "start-dev"
      - "--features=preview"  # Required for Legacy V1 token exchange
  2. Grant impersonation role to service account (using Keycloak CLI):

    docker compose exec keycloak /opt/keycloak/bin/kcadm.sh config credentials \
      --server http://localhost:8080 \
      --realm master \
      --user admin \
      --password admin
    
    docker compose exec keycloak /opt/keycloak/bin/kcadm.sh add-roles \
      -r nextcloud-mcp \
      --uusername service-account-nextcloud-mcp-server \
      --cclientid realm-management \
      --rolename impersonation

Keycloak Legacy V1 Result - After Permission Grant:

✅ Token exchange with impersonation SUCCEEDED!

📊 Response details:
  Issued token type: urn:ietf:params:oauth:token-type:access_token
  Token type: Bearer
  Expires in: 300s

📋 Token claims analysis:
  Subject (sub): 47c3ba5a-9104-45e0-b84e-0e39ab942c9c  (admin user)
  Preferred username: admin
  Client ID (azp): nextcloud-mcp-server

✅ IMPERSONATION VERIFIED:
   Original sub: service-account-nextcloud-mcp-server
   New sub:      47c3ba5a-9104-45e0-b84e-0e39ab942c9c
   ➡️  The subject claim CHANGED - impersonation worked!

Nextcloud API Validation: The impersonated token successfully authenticated with Nextcloud APIs, confirming the token is valid and properly represents the target user.

Implementation Status: Impersonation IS IMPLEMENTED and working with Keycloak Legacy V1. The implementation has been tested and verified to work correctly when properly configured.

Production Considerations:

  • ⚠️ Requires preview features (--features=preview) - not production-ready
  • ⚠️ Requires Legacy V1 token exchange (may be deprecated in future Keycloak versions)
  • ⚠️ Requires manual CLI configuration for each service account
  • ⚠️ More complex permission model compared to delegation

When to Use Tier 1 (Impersonation):

  • ✅ You need the exchanged token to have the exact same identity as the target user
  • ✅ You want the cleanest separation (sub claim changes completely)
  • ✅ Your environment can support preview features
  • ✅ You have operational processes to manage impersonation permissions

Recommendation: For most use cases, use Tier 2 (Delegation) instead. It provides equivalent "act on-behalf-of" capability using production-ready Standard V2 token exchange. Use Tier 1 only when you specifically need identity impersonation.

Test Scripts:

  • tests/manual/test_impersonation.py - Complete impersonation test with validation
  • tests/manual/configure_impersonation.py - Automated permission configuration helper
  • See: docs/oauth-impersonation-findings.md for detailed investigation

2. Token Exchange with Delegation (Tier 2) ✅ IMPLEMENTED (Standard V2)

Status: Implemented and working with Keycloak Standard V2 (production-ready). This is the recommended approach for most use cases.

When to Use: When you need "act on-behalf-of" functionality with production-ready features. The service account maintains its identity (sub claim unchanged) but acts on behalf of the user. Fully supported in Keycloak Standard V2 without preview features.

2.1 Capability Detection

async def check_token_exchange_support(discovery_url: str) -> bool:
    """Check if OIDC provider supports RFC 8693 token exchange"""

    async with httpx.AsyncClient() as client:
        response = await client.get(discovery_url)
        discovery = response.json()

        # Check for token exchange grant type
        grant_types = discovery.get("grant_types_supported", [])
        return "urn:ietf:params:oauth:grant-type:token-exchange" in grant_types

2.2 Delegation Token Exchange

async def exchange_for_user_token(
    service_token: str,
    target_user_id: str,
    audience: str,
    scopes: list[str]
) -> str:
    """Exchange service token for user-scoped token via RFC 8693"""

    async with httpx.AsyncClient() as client:
        response = await client.post(
            token_endpoint,
            data={
                "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
                "subject_token": service_token,
                "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
                "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
                "audience": audience,  # Target resource server (e.g., "nextcloud")
                "scope": " ".join(scopes)
            },
            auth=(client_id, client_secret)
        )

        if response.status_code != 200:
            logger.warning(f"Token exchange failed: {response.status_code}")
            raise TokenExchangeNotSupportedError()

        return response.json()["access_token"]

Implementation: KeycloakOAuthClient.exchange_token_for_user() (keycloak_oauth.py:397-495)

Note: Full delegation with act claim requires provider support that is currently very rare. Keycloak tracking: Issue #38279

3. Comparison: When to Use Each Tier

Feature Tier 1: Impersonation Tier 2: Delegation (Recommended)
Status ✅ Implemented (Legacy V1) ✅ Implemented (Standard V2)
Token Identity Target user (sub changes) Service account (sub unchanged)
Keycloak Version Legacy V1 (--features=preview) Standard V2 (production-ready)
Setup Complexity High (manual permissions) Low (automatic)
Production Ready ⚠️ Preview features required ✅ Fully production-ready
Permission Grant Manual CLI per service account Automatic via token exchange
Audit Trail Shows as target user Shows as service account acting for user
Token Claims sub: user-id sub: service-account-id
Provider Support Rare (Keycloak Legacy V1 only) Common (Keycloak, Auth0, Okta)
Use Case Need exact user identity Standard OAuth workflows
Recommendation Advanced use only Default choice

Decision Guide:

  • Use Tier 2 (Delegation) for:

    • Production deployments
    • Standard OAuth workflows
    • Clear audit trails (service account visible)
    • Maximum provider compatibility
  • ⚠️ Use Tier 1 (Impersonation) only if:

    • You specifically need exact user identity (sub claim must match)
    • You can accept preview/experimental features
    • You have operational processes for permission management
    • Your IdP supports requested_subject parameter

4. Sync Worker with Tiered Authentication

# nextcloud_mcp_server/sync_worker.py
class VectorSyncWorker:
    """Background worker for indexing content into vector database"""

    def __init__(self):
        self.auth_method = None
        self.oauth_client = None  # KeycloakOAuthClient or similar
        self.vector_service = None

    async def initialize(self):
        """Detect and configure authentication method"""

        from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient

        try:
            self.oauth_client = KeycloakOAuthClient.from_env()
            await self.oauth_client.discover()

            # Verify service account access (Tier 1)
            service_token = await self.oauth_client.get_service_account_token()
            logger.info("✓ Service account token acquired")

            # Check if token exchange is supported (Tier 2/3)
            if await check_token_exchange_support(self.oauth_client.discovery_url):
                self.auth_method = "token_exchange_delegation"
                logger.info(
                    "✓ Token exchange supported (RFC 8693) - will use delegation for user-scoped operations"
                )
            else:
                self.auth_method = "service_account"
                logger.info(
                    "ℹ Token exchange not supported - using service account token for all operations"
                )

        except Exception as e:
            logger.error(f"Failed to initialize OAuth authentication: {e}")
            raise RuntimeError(
                "OAuth authentication is required for background sync. "
                "Either configure OIDC_CLIENT_ID/OIDC_CLIENT_SECRET with service account enabled, "
                "or use BasicAuth mode for single-user deployments."
            ) from e

    async def get_user_client(self, user_id: str) -> NextcloudClient:
        """Get authenticated client for user based on auth method"""

        if self.auth_method == "token_exchange_delegation":
            # Tier 2/3: Get service token and exchange for user-scoped token
            service_token_data = await self.oauth_client.get_service_account_token()

            user_token_data = await self.oauth_client.exchange_token_for_user(
                subject_token=service_token_data["access_token"],
                target_user_id=user_id,
                audience="nextcloud",
                scopes=["notes:read", "files:read", "calendar:read"]
            )

            return NextcloudClient.from_token(
                base_url=nextcloud_host,
                token=user_token_data["access_token"],
                username=user_id
            )

        elif self.auth_method == "service_account":
            # Tier 1: Use service account token directly (no user scoping)
            service_token_data = await self.oauth_client.get_service_account_token()

            return NextcloudClient.from_token(
                base_url=nextcloud_host,
                token=service_token_data["access_token"],
                username="service-account"
            )

        raise RuntimeError(f"Unknown auth method: {self.auth_method}")

    async def sync_user_content(self, user_id: str):
        """Index a user's content into vector database"""

        try:
            # Get authenticated client for this user
            client = await self.get_user_client(user_id)

            # Sync notes
            notes = await client.notes.list_notes()
            for note in notes:
                embedding = await self.vector_service.embed(note.content)
                await self.vector_service.upsert(
                    collection="nextcloud_content",
                    id=f"note_{note.id}",
                    vector=embedding,
                    metadata={
                        "user_id": user_id,
                        "content_type": "note",
                        "note_id": note.id,
                        "title": note.title,
                        "category": note.category
                    }
                )

            logger.info(f"Synced {len(notes)} notes for user: {user_id}")

        except Exception as e:
            logger.error(f"Failed to sync user {user_id}: {e}")

    async def run(self):
        """Main sync loop"""

        await self.initialize()

        while True:
            try:
                # Get list of users to sync
                # Implementation depends on how you track authenticated users
                # Options:
                # - Audit logs of MCP authentication events
                # - MCP session history
                # - Configured user list
                # - If using service account with broad permissions: list all users
                user_ids = await self.get_active_users()

                logger.info(f"Syncing content for {len(user_ids)} users")

                for user_id in user_ids:
                    await self.sync_user_content(user_id)

                logger.info("Sync complete, sleeping...")
                await asyncio.sleep(300)  # 5 minutes

            except Exception as e:
                logger.error(f"Sync failed: {e}")
                await asyncio.sleep(60)  # Retry after 1 minute

4. User Request Verification (Dual-Phase Authorization)

@mcp.tool()
@require_scopes("notes:read")
async def nc_notes_semantic_search(
    query: str,
    ctx: Context,
    limit: int = 10
) -> SemanticSearchResponse:
    """Semantic search with permission verification"""

    # Get user's OAuth client (uses their access token from request)
    user_client = get_client(ctx)
    username = user_client.username

    # Phase 1: Vector search (fast, may include false positives)
    embedding = await vector_service.embed(query)
    candidate_results = await qdrant.search(
        collection_name="nextcloud_content",
        query_vector=embedding,
        query_filter={
            "must": [
                {
                    "should": [
                        {"key": "user_id", "match": {"value": username}},
                        {"key": "shared_with", "match": {"any": [username]}}
                    ]
                },
                {"key": "content_type", "match": {"value": "note"}}
            ]
        },
        limit=limit * 2  # Get extra candidates
    )

    # Phase 2: Verify access via Nextcloud API (authoritative)
    verified_results = []
    for candidate in candidate_results:
        note_id = candidate.payload["note_id"]
        try:
            # This uses user's OAuth token - will fail if no access
            note = await user_client.notes.get_note(note_id)
            verified_results.append({
                "note": note,
                "score": candidate.score
            })
            if len(verified_results) >= limit:
                break
        except HTTPStatusError as e:
            if e.response.status_code == 403:
                # User doesn't have access - skip silently
                logger.debug(f"Filtered out note {note_id} for {username}")
                continue
            raise

    return SemanticSearchResponse(results=verified_results)

5. Security Implementation

5.1 Service Account Credentials Protection

# Store OAuth client credentials securely
# NEVER commit to source control

# Option 1: Environment variables (for development)
export OIDC_CLIENT_ID="nextcloud-mcp-server"
export OIDC_CLIENT_SECRET="<secure-secret>"

# Option 2: Secrets manager (for production)
import boto3
secrets = boto3.client('secretsmanager')
secret = secrets.get_secret_value(SecretId='nextcloud-mcp-oauth')
client_secret = json.loads(secret['SecretString'])['client_secret']

# Option 3: Encrypted storage (for self-hosted)
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage

storage = RefreshTokenStorage.from_env()
await storage.initialize()

# Client credentials are encrypted at rest using Fernet
client_data = await storage.get_oauth_client()

5.2 Token Lifecycle Management

async def manage_service_token_lifecycle():
    """Cache and refresh service account tokens"""

    # Cache service token (avoid repeated requests)
    cached_token = None
    token_expires_at = 0

    async def get_fresh_service_token() -> str:
        nonlocal cached_token, token_expires_at

        now = time.time()

        # Return cached token if still valid (with 5-minute buffer)
        if cached_token and now < (token_expires_at - 300):
            return cached_token

        # Request new token
        token_data = await oauth_client.get_service_account_token()

        cached_token = token_data["access_token"]
        token_expires_at = now + token_data.get("expires_in", 3600)

        logger.info("Service account token refreshed")
        return cached_token

    return get_fresh_service_token

5.3 Audit Logging

async def audit_log(
    event: str,
    user_id: str,
    resource_type: str,
    resource_id: str,
    auth_method: str
):
    """Log sync operations for audit trail"""

    await audit_db.execute(
        "INSERT INTO audit_logs VALUES (?, ?, ?, ?, ?, ?, ?)",
        (
            int(time.time()),
            event,  # "index_note", "index_file"
            user_id,
            resource_type,
            resource_id,
            auth_method,
            socket.gethostname()
        )
    )

6. Configuration

6.1 Environment Variables

# OAuth Configuration (Required for Background Sync in OAuth Mode)
# Requires external OIDC provider with client_credentials support
OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
OIDC_CLIENT_ID=nextcloud-mcp-server
OIDC_CLIENT_SECRET=<secure-secret>
NEXTCLOUD_HOST=http://app:80

# Tier selection is automatic:
# - Tier 1 (service_account): Always available if client has service account enabled
# - Tier 2/3 (token_exchange): Used if provider supports RFC 8693 token exchange

# Vector Database
QDRANT_URL=http://qdrant:6333
QDRANT_API_KEY=<api-key>

# Sync Configuration
SYNC_INTERVAL_SECONDS=300
SYNC_BATCH_SIZE=100

# Note: For BasicAuth mode (single-user), background sync uses NEXTCLOUD_USERNAME/NEXTCLOUD_PASSWORD
# This ADR focuses on OAuth mode only

6.2 Keycloak Configuration (for Token Exchange)

Client Settings (nextcloud-mcp-server):

{
  "clientId": "nextcloud-mcp-server",
  "serviceAccountsEnabled": true,
  "authorizationServicesEnabled": false,
  "attributes": {
    "token.exchange.grant.enabled": "true",
    "client.token.exchange.standard.enabled": "true"
  }
}

Service Account Roles:

  • Assign appropriate Nextcloud roles/scopes to the service account
  • Configure token exchange permissions

6.3 Docker Compose

services:
  mcp-sync:
    build: .
    command: ["python", "-m", "nextcloud_mcp_server.sync_worker"]
    environment:
      - NEXTCLOUD_HOST=http://app:80

      # External OIDC provider (Keycloak)
      - OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
      - OIDC_CLIENT_ID=nextcloud-mcp-server
      - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}

      # Vector database
      - QDRANT_URL=http://qdrant:6333
      - QDRANT_API_KEY=${QDRANT_API_KEY}
    volumes:
      - sync-data:/app/data  # For OAuth client credential storage
    depends_on:
      - app
      - keycloak
      - qdrant

volumes:
  sync-data:  # Persistent storage for encrypted OAuth client credentials

Consequences

Benefits

  1. OAuth-Native Authentication

    • Leverages standard OAuth flows (offline_access, token exchange)
    • No reliance on admin passwords in production
    • Compatible with enterprise OIDC providers
  2. User-Level Permissions

    • Each user's content indexed with their own credentials
    • Respects sharing, permissions, and access controls
    • Full audit trail of which user's token was used
  3. Security

    • Tokens encrypted at rest
    • Short-lived access tokens (refreshed as needed)
    • Token rotation support
    • Defense in depth with dual-phase authorization
  4. Flexibility

    • Automatic capability detection
    • Graceful degradation through authentication tiers
    • Works with varying OIDC provider capabilities
  5. Operational

    • Background sync independent of user activity
    • Efficient batch processing
    • Clear separation of sync vs request credentials

Limitations

  1. Complexity

    • Multiple authentication paths to maintain
    • Token storage and encryption infrastructure
    • More moving parts than simple admin auth
  2. User Experience

    • offline_access scope may require additional consent
    • Users must authenticate at least once for indexing
    • New users not automatically indexed
  3. OIDC Provider Dependency

    • Token exchange requires RFC 8693 support (rare)
    • Refresh token rotation varies by provider
    • Some providers may not support offline_access
  4. Operational Overhead

    • Token database maintenance
    • Monitoring token expiration
    • Handling revoked tokens gracefully

Security Considerations

Threat Model

Threat 1: Token Storage Breach

  • Mitigation: Encryption at rest using Fernet
  • Mitigation: Secure key management (secrets manager)
  • Mitigation: Minimal token lifetime
  • Detection: Audit logs for unusual access patterns

Threat 2: Token Replay

  • Mitigation: Short-lived access tokens (refreshed frequently)
  • Mitigation: Token rotation on each refresh
  • Mitigation: Revocation support

Threat 3: Privilege Escalation

  • Mitigation: Dual-phase authorization (vector DB + Nextcloud API)
  • Mitigation: Sync worker uses same scopes as user requests
  • Mitigation: Per-user token isolation

Threat 4: Vector Database Poisoning

  • Mitigation: User requests always verify via Nextcloud API
  • Mitigation: Vector DB is cache/accelerator, not source of truth
  • Mitigation: Sync operations audited per user

Security Best Practices

  1. OAuth Client Secret Management

    # Store in secrets manager (Vault, AWS Secrets Manager, etc.)
    # Or use environment variable with restricted permissions
    
    # For self-hosted: Use encrypted storage
    # OAuth client credentials stored in SQLite with Fernet encryption
    # Encryption key: TOKEN_ENCRYPTION_KEY environment variable
    
    # Generate encryption key:
    python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
  2. Service Account Token Lifecycle

    • Cache service tokens to minimize requests (with expiry buffer)
    • Automatically refresh expired tokens
    • Use short-lived tokens (provider default, typically 1 hour)
    • Monitor token request rates and failures
  3. Database Permissions (for Client Credential Storage)

    # Restrict database file permissions
    chmod 600 /app/data/tokens.db
    chown mcp-server:mcp-server /app/data/tokens.db
  4. Monitoring and Alerting

    • Alert on token exchange failures
    • Monitor for unusual access patterns
    • Track service account token usage
    • Audit sync operations per user (if delegation supported)

Future Enhancements

  1. Token Revocation Handling

    • Webhook endpoint for token revocation events
    • Periodic validation of stored tokens
    • Graceful handling of revoked tokens
  2. Selective Sync

    • Allow users to opt-in/opt-out of indexing
    • Per-content-type sync preferences
    • Privacy controls for sensitive content
  3. Multi-Tenant Token Storage

    • Separate token databases per tenant
    • Key rotation per tenant
    • Tenant isolation
  4. Token Lifecycle Management

    • Automatic cleanup of expired tokens
    • Token usage analytics
    • Token health dashboard
  5. Alternative OAuth Flows

    • Device flow for headless sync
    • Resource owner password credentials (ROPC) as fallback
    • SAML assertion grants

Alternatives Considered

Alternative 1: Admin BasicAuth Only

Approach: Background worker always uses admin credentials

Pros:

  • Simple implementation
  • No token storage complexity
  • Works with any authentication backend

Cons:

  • Violates principle of least privilege
  • Single powerful credential
  • No per-user audit trail
  • Bypasses OAuth entirely

Decision: Rejected for production use; kept as fallback only

Alternative 2: Client Credentials Grant Only

Approach: Service account with broad read permissions

Pros:

  • OAuth-native pattern
  • No user token storage
  • Standard OAuth flow

Cons:

  • Requires client_credentials support (may not be available)
  • Still needs broad cross-user permissions
  • Not well-suited for multi-user indexing

Decision: Rejected; token exchange is better fit for multi-user scenario

Alternative 3: Per-User Access Token Storage

Approach: Store user access tokens (not refresh tokens)

Pros:

  • Simpler than refresh token flow
  • No token refresh logic needed

Cons:

  • Access tokens are short-lived (1-24 hours)
  • Requires frequent re-authentication
  • Poor user experience
  • Sync gaps when tokens expire

Decision: Rejected; refresh tokens provide better UX

Alternative 4: On-Demand Indexing Only

Approach: Index content when user searches (no background worker)

Pros:

  • Uses user's request token
  • No background auth needed
  • Simpler architecture

Cons:

  • Very slow first search
  • Poor user experience
  • Incomplete index
  • Can't pre-compute embeddings

Decision: Rejected; background indexing is essential for semantic search

Alternative 5: Nextcloud App Tokens

Approach: Generate app-specific passwords for each user

Pros:

  • Nextcloud-native feature
  • User-controlled revocation
  • Scoped per-application

Cons:

  • Requires user interaction to create
  • May not support programmatic creation
  • Still requires secure storage
  • Not standard OAuth

Decision: Rejected; not automatable for background worker

Related Decisions

  • ADR-001: Enhanced Note Search (establishes need for vector search)
  • [Future] ADR-003: Vector Database Selection
  • [Future] ADR-004: Embedding Model Strategy

References