Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02a2c4a16f | |||
| f37008fdc3 | |||
| 7cb616c7ce | |||
| 34df5f5b9a | |||
| e26c5128b7 | |||
| ed813af45c | |||
| 1e071c83a9 | |||
| 76430bec21 | |||
| e81c2ad33d | |||
| 23360485a8 | |||
| 2ca6725fc6 | |||
| 4c7d1cfc8d | |||
| b68c704c4d | |||
| 849c67c32a | |||
| b3725dd2f5 | |||
| 6117aaaed3 | |||
| 403f8be429 | |||
| 2a1274d8a8 | |||
| e331544cee | |||
| 37b0b4a281 | |||
| f34366a260 | |||
| 529dc4616b | |||
| f739330341 | |||
| 136df2422b | |||
| eb8ca92bca | |||
| 0f03541486 | |||
| ef07b1a6c9 | |||
| 4f82357f24 | |||
| 9ef2311c71 | |||
| c4293b6750 | |||
| 72e4eb3d19 | |||
| 47dd2df7aa | |||
| 9fd2022151 | |||
| b99dc52c95 | |||
| 78b27fb5e9 | |||
| 03e39a3f94 | |||
| 5259658458 | |||
| e03a3c2e83 | |||
| 94cbd3015d | |||
| 49a961cbcc | |||
| e1aca04aff | |||
| 3b12e585ca | |||
| e647c87dd8 | |||
| cb74157d51 | |||
| 202058bdc8 | |||
| c312911538 | |||
| e602684743 | |||
| 8221046d8a | |||
| 3e45b6ca25 | |||
| 9ec7637579 | |||
| 670188f9e4 | |||
| 3878beaf65 | |||
| a5a0571bde | |||
| 0e7e74867f | |||
| a29045cca4 | |||
| b11c3ddfb6 | |||
| 562c102711 | |||
| 3c3646bec2 | |||
| dd636e6a08 | |||
| d7a8719d0e | |||
| 97fa9ef8a7 | |||
| 77dd17b3e1 | |||
| d56ec33b77 | |||
| a1c5acc1c2 | |||
| e0de2e17e9 | |||
| 4fc0cb5a41 | |||
| ff9cca716b | |||
| ef4a82e589 | |||
| 301c502e57 | |||
| d4d291d6d2 | |||
| e4b0ea5093 | |||
| 6833f7f117 | |||
| 7db2a5c586 | |||
| b76c10f18c | |||
| ab7411d9fd | |||
| d02fe3c3b6 | |||
| 49f9cead69 | |||
| 415b1c901b | |||
| 90b96a8afe | |||
| 57a2157c58 | |||
| bfdc33c390 | |||
| 8844c07ecb | |||
| 0a0ef10989 | |||
| 9414d9c9c3 | |||
| 8a52df4a8e | |||
| a36038422b | |||
| 2147fc1696 | |||
| a19017c686 | |||
| f0e5333e43 | |||
| 553e84e5f2 | |||
| ff20031601 | |||
| 04e0ab127a | |||
| 1117a83a52 | |||
| 01b43c96ba | |||
| c9db6afb59 | |||
| 50b69a2531 | |||
| 8e0a4d8ce5 | |||
| 72fce189d2 | |||
| 1e877f17f7 | |||
| 50a824155c | |||
| 0df9e41332 | |||
| 13f76a7734 | |||
| 3baf10662f | |||
| 81ca799410 | |||
| 2f1bd1bbe9 | |||
| d452684535 | |||
| bfbaed9a66 | |||
| ff32149220 | |||
| d55e5708c7 | |||
| d4ee5a74c2 | |||
| db79afacb9 | |||
| 261749fcdc | |||
| 6730dd4a4b | |||
| 8734c4b292 | |||
| 29df645d53 | |||
| bdb0e17401 | |||
| 8942f3119c | |||
| 3863cca2ed | |||
| a93e7a1e3b | |||
| f2d2dd8068 | |||
| d915efd3f6 | |||
| 053cf7798b | |||
| 87c6f077f3 | |||
| 38e12db46a | |||
| eb7e15cac0 | |||
| e3436fecc0 | |||
| e3feb3eb2f | |||
| eedaa2e3f1 | |||
| d517fe09d8 | |||
| 98627593d5 | |||
| 64649c902d | |||
| 08ebab9f48 | |||
| 3ff6346c03 | |||
| c9a687171a | |||
| df5f85e0c6 | |||
| 76dce41ed9 | |||
| 642108ee91 | |||
| ce5724f05e |
@@ -0,0 +1,138 @@
|
||||
# Keycloak OAuth Configuration for Nextcloud MCP Server
|
||||
#
|
||||
# This configuration uses Keycloak as the OAuth/OIDC identity provider
|
||||
# while still accessing Nextcloud APIs. Nextcloud's user_oidc app validates
|
||||
# Keycloak bearer tokens and provisions users automatically.
|
||||
#
|
||||
# Architecture: Client → Keycloak (OAuth) → MCP Server → Nextcloud (user_oidc validates) → APIs
|
||||
#
|
||||
# This enables ADR-002 authentication patterns without admin credentials!
|
||||
|
||||
# ==============================================================================
|
||||
# OAUTH PROVIDER SELECTION
|
||||
# ==============================================================================
|
||||
|
||||
# OAuth provider: "keycloak" or "nextcloud" (default)
|
||||
OAUTH_PROVIDER=keycloak
|
||||
|
||||
# ==============================================================================
|
||||
# KEYCLOAK CONFIGURATION
|
||||
# ==============================================================================
|
||||
|
||||
# Keycloak base URL (accessible from MCP server container)
|
||||
KEYCLOAK_URL=http://keycloak:8080
|
||||
|
||||
# Keycloak realm name
|
||||
KEYCLOAK_REALM=nextcloud-mcp
|
||||
|
||||
# OAuth client credentials (from Keycloak realm export or manual configuration)
|
||||
KEYCLOAK_CLIENT_ID=nextcloud-mcp-server
|
||||
KEYCLOAK_CLIENT_SECRET=mcp-secret-change-in-production
|
||||
|
||||
# OIDC discovery URL (auto-constructed from URL + realm, or specify explicitly)
|
||||
KEYCLOAK_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||
|
||||
# ==============================================================================
|
||||
# NEXTCLOUD CONFIGURATION
|
||||
# ==============================================================================
|
||||
|
||||
# Nextcloud URL (accessible from MCP server container)
|
||||
# Used for API access - Keycloak tokens are validated by user_oidc app
|
||||
NEXTCLOUD_HOST=http://app:80
|
||||
|
||||
# MCP server URL (for OAuth redirect URIs)
|
||||
# This is the publicly accessible URL that OAuth clients connect to
|
||||
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
|
||||
|
||||
# Public Keycloak issuer URL (accessible from OAuth clients)
|
||||
# If clients access Keycloak via a different URL than the internal one,
|
||||
# set this to the public URL for OAuth flows
|
||||
NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888
|
||||
|
||||
# ==============================================================================
|
||||
# REFRESH TOKEN STORAGE (ADR-002 Tier 1: Offline Access)
|
||||
# ==============================================================================
|
||||
|
||||
# Enable offline_access scope to get refresh tokens
|
||||
ENABLE_OFFLINE_ACCESS=true
|
||||
|
||||
# Encryption key for storing refresh tokens (generate with instructions below)
|
||||
# IMPORTANT: Keep this secret! Tokens are encrypted at rest using this key.
|
||||
#
|
||||
# Generate a key:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
#
|
||||
# Example (DO NOT use this in production!):
|
||||
# TOKEN_ENCRYPTION_KEY=your-base64-encoded-fernet-key-here
|
||||
|
||||
# Path to SQLite database for token storage
|
||||
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
|
||||
# ==============================================================================
|
||||
# DOCKER COMPOSE NOTES
|
||||
# ==============================================================================
|
||||
|
||||
# When running via docker-compose, the mcp-keycloak service is pre-configured
|
||||
# with these environment variables. See docker-compose.yml for the full config.
|
||||
#
|
||||
# Start services:
|
||||
# docker-compose up -d keycloak app mcp-keycloak
|
||||
#
|
||||
# View logs:
|
||||
# docker-compose logs -f mcp-keycloak
|
||||
#
|
||||
# Check Keycloak realm:
|
||||
# curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||
#
|
||||
# Check user_oidc provider:
|
||||
# docker compose exec app php occ user_oidc:provider keycloak
|
||||
|
||||
# ==============================================================================
|
||||
# KEYCLOAK SETUP VERIFICATION
|
||||
# ==============================================================================
|
||||
|
||||
# 1. Verify Keycloak is running and realm is imported:
|
||||
# curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||
#
|
||||
# 2. Verify Nextcloud user_oidc provider is configured:
|
||||
# docker compose exec app php occ user_oidc:provider keycloak
|
||||
#
|
||||
# 3. Test OAuth flow manually:
|
||||
# - Get token from Keycloak:
|
||||
# curl -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
|
||||
# -d "grant_type=password" \
|
||||
# -d "client_id=nextcloud-mcp-server" \
|
||||
# -d "client_secret=mcp-secret-change-in-production" \
|
||||
# -d "username=admin" \
|
||||
# -d "password=admin" \
|
||||
# -d "scope=openid profile email offline_access"
|
||||
#
|
||||
# - Use token with Nextcloud API:
|
||||
# curl -H "Authorization: Bearer <access_token>" \
|
||||
# http://localhost:8080/ocs/v2.php/cloud/capabilities
|
||||
#
|
||||
# 4. Connect MCP client to server:
|
||||
# - Point your MCP client to http://localhost:8002
|
||||
# - Complete OAuth flow via Keycloak (credentials: admin/admin)
|
||||
# - Client should receive access token and be able to call MCP tools
|
||||
|
||||
# ==============================================================================
|
||||
# TROUBLESHOOTING
|
||||
# ==============================================================================
|
||||
|
||||
# If OAuth flow fails:
|
||||
# - Check that Keycloak is accessible: curl http://localhost:8888
|
||||
# - Check that user_oidc provider is configured: docker compose exec app php occ user_oidc:provider keycloak
|
||||
# - Check MCP server logs: docker-compose logs mcp-keycloak
|
||||
# - Verify redirect URIs match in Keycloak client configuration
|
||||
#
|
||||
# If token validation fails:
|
||||
# - Verify user_oidc has bearer validation enabled (--check-bearer=1)
|
||||
# - Check Nextcloud logs: docker compose exec app tail -f /var/www/html/data/nextcloud.log
|
||||
# - Verify Keycloak discovery URL is accessible from Nextcloud container:
|
||||
# docker compose exec app curl http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||
#
|
||||
# If offline_access/refresh tokens not working:
|
||||
# - Verify TOKEN_ENCRYPTION_KEY is set and valid
|
||||
# - Check token storage database: ls -lah /app/data/tokens.db (inside container)
|
||||
# - Check that offline_access scope is requested in realm configuration
|
||||
@@ -0,0 +1,122 @@
|
||||
name: Release Charts
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions
|
||||
# see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "$GITHUB_ACTOR"
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
|
||||
env:
|
||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Update gh-pages with Chart README and Index
|
||||
run: |
|
||||
# Get the repository name
|
||||
REPO_NAME="${GITHUB_REPOSITORY##*/}"
|
||||
REPO_OWNER="${GITHUB_REPOSITORY%/*}"
|
||||
|
||||
# Switch to gh-pages branch
|
||||
git fetch origin gh-pages
|
||||
git checkout gh-pages
|
||||
|
||||
# Copy Chart README to root
|
||||
git checkout ${GITHUB_REF#refs/tags/} -- charts/nextcloud-mcp-server/README.md
|
||||
mv charts/nextcloud-mcp-server/README.md README.md || true
|
||||
rm -rf charts 2>/dev/null || true
|
||||
|
||||
# Create index.html with installation instructions
|
||||
cat > index.html <<'EOF'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nextcloud MCP Server Helm Chart</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
code {
|
||||
background: #f4f4f4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: "Monaco", "Courier New", monospace;
|
||||
}
|
||||
pre {
|
||||
background: #f4f4f4;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
h1, h2 { color: #0082c9; }
|
||||
a { color: #0082c9; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Nextcloud MCP Server Helm Chart</h1>
|
||||
|
||||
<p>A Helm chart for deploying the Nextcloud MCP (Model Context Protocol) Server on Kubernetes, enabling AI assistants to interact with your Nextcloud instance.</p>
|
||||
|
||||
<h2>Installation</h2>
|
||||
|
||||
<p>Add the Helm repository:</p>
|
||||
<pre><code>helm repo add nextcloud-mcp https://REPO_OWNER.github.io/REPO_NAME/
|
||||
helm repo update</code></pre>
|
||||
|
||||
<p>Install the chart:</p>
|
||||
<pre><code>helm install nextcloud-mcp nextcloud-mcp/nextcloud-mcp-server \
|
||||
--set nextcloud.host=https://cloud.example.com \
|
||||
--set auth.basic.username=myuser \
|
||||
--set auth.basic.password=mypassword</code></pre>
|
||||
|
||||
<h2>Documentation</h2>
|
||||
|
||||
<ul>
|
||||
<li><a href="README.md">Chart README</a> - Full documentation for the Helm chart</li>
|
||||
<li><a href="https://github.com/REPO_OWNER/REPO_NAME">GitHub Repository</a> - Source code and issues</li>
|
||||
<li><a href="index.yaml">Helm Repository Index</a> - Chart metadata</li>
|
||||
</ul>
|
||||
|
||||
<h2>Quick Start</h2>
|
||||
|
||||
<p>See the <a href="README.md">full documentation</a> for detailed configuration options, examples, and troubleshooting guides.</p>
|
||||
|
||||
<hr>
|
||||
<p><small>Generated by <a href="https://github.com/helm/chart-releaser">chart-releaser</a></small></p>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
|
||||
# Replace placeholders
|
||||
sed -i "s/REPO_OWNER/$REPO_OWNER/g" index.html
|
||||
sed -i "s/REPO_NAME/$REPO_NAME/g" index.html
|
||||
|
||||
# Commit changes
|
||||
git add README.md index.html
|
||||
git commit -m "Update README and index from chart release" || echo "No changes to commit"
|
||||
git push origin gh-pages
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
- name: Install Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Build
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
- name: Set up php 8.4
|
||||
uses: shivammathur/setup-php@v2
|
||||
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
coverage: none
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
up-flags: "--build"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: |
|
||||
|
||||
+126
@@ -1,3 +1,129 @@
|
||||
## v0.23.0 (2025-11-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- Auto-configure impersonation role in Keycloak realm import
|
||||
- Implement dual-tier token exchange (Standard V2 + Legacy V1 impersonation)
|
||||
- Add Keycloak external IdP integration with custom scopes
|
||||
- Implement RFC 8693 token exchange for Keycloak (ADR-002 Tier 2)
|
||||
- Add Keycloak OAuth provider support with refresh token storage
|
||||
|
||||
### Fix
|
||||
|
||||
- Complete Keycloak external IdP integration with all tests passing
|
||||
- Complete Keycloak external IdP integration with all tests passing
|
||||
- Update DCR token_type tests for OIDC app changes
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove NEXTCLOUD_OIDC_CLIENT_STORAGE environment variable
|
||||
- Remove unnecessary user_oidc patch - CORSMiddleware patch is sufficient
|
||||
- Unify OAuth configuration to be provider-agnostic
|
||||
|
||||
## v0.22.7 (2025-10-29)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: Remove image tag overide
|
||||
|
||||
## v0.22.6 (2025-10-29)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: Update helm chart with extraArgs
|
||||
|
||||
## v0.22.5 (2025-10-29)
|
||||
|
||||
### Fix
|
||||
|
||||
- Update helm chart variables
|
||||
|
||||
## v0.22.4 (2025-10-29)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: Update helm version with release
|
||||
- **helm**: Update helm version with release
|
||||
|
||||
## v0.22.3 (2025-10-29)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: Update helm version with release
|
||||
|
||||
## v0.22.2 (2025-10-29)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: Update helm version with release
|
||||
|
||||
## v0.22.1 (2025-10-29)
|
||||
|
||||
### Fix
|
||||
|
||||
- Trigger release
|
||||
|
||||
## v0.22.0 (2025-10-29)
|
||||
|
||||
### Feat
|
||||
|
||||
- **server**: Add /live & /health endpoints
|
||||
- Initialize helm chart
|
||||
|
||||
## v0.21.0 (2025-10-25)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add text processing background worker for telling client about progress
|
||||
|
||||
### Refactor
|
||||
|
||||
- Transform document parsing into pluggable processor architecture
|
||||
|
||||
## v0.20.0 (2025-10-24)
|
||||
|
||||
### Feat
|
||||
|
||||
- **auth**: Add support for client registration deletion
|
||||
- Split read/write scopes into app:read/write scopes
|
||||
|
||||
### Fix
|
||||
|
||||
- Add support for RFC 7592 client registration and deletion
|
||||
- Update webdav models for proper serialization
|
||||
|
||||
## v0.19.1 (2025-10-24)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.19,<1.20
|
||||
|
||||
## v0.19.0 (2025-10-23)
|
||||
|
||||
### Feat
|
||||
|
||||
- Enable token introspection for opaque tokens
|
||||
|
||||
### Fix
|
||||
|
||||
- Add CORS middleware to allow browser-based clients like MCP Inspector
|
||||
|
||||
## v0.18.0 (2025-10-23)
|
||||
|
||||
### Feat
|
||||
|
||||
- **server**: Add support for custom OIDC scopes and permissions via JWTs
|
||||
- Initialize JWT-scoped tools
|
||||
|
||||
### Fix
|
||||
|
||||
- Use occ-created OAuth clients with allowed_scopes for all tests
|
||||
- Separate OAuth fixtures for opaque vs JWT tokens
|
||||
|
||||
### Refactor
|
||||
|
||||
- Update JWT client to use DCR, re-enable tool filtering
|
||||
|
||||
## v0.17.1 (2025-10-20)
|
||||
|
||||
### Fix
|
||||
|
||||
@@ -5,20 +5,41 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## Development Commands
|
||||
|
||||
### Testing
|
||||
|
||||
The test suite is organized in layers for fast feedback:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
# FAST FEEDBACK (recommended for development)
|
||||
# Unit tests only - ~5 seconds
|
||||
uv run pytest tests/unit/ -v
|
||||
|
||||
# Smoke tests - critical path validation - ~30-60 seconds
|
||||
uv run pytest -m smoke -v
|
||||
|
||||
# INTEGRATION TESTS
|
||||
# Integration tests without OAuth - ~2-3 minutes
|
||||
uv run pytest -m "integration and not oauth" -v
|
||||
|
||||
# Full test suite - ~4-5 minutes
|
||||
uv run pytest
|
||||
|
||||
# Run integration tests only
|
||||
uv run pytest -m integration
|
||||
# OAuth tests only (slowest, requires Playwright) - ~3 minutes
|
||||
uv run pytest -m oauth -v
|
||||
|
||||
# COVERAGE
|
||||
# Run tests with coverage
|
||||
uv run pytest --cov
|
||||
|
||||
# LEGACY COMMANDS (still work)
|
||||
# Run all integration tests
|
||||
uv run pytest -m integration -v
|
||||
|
||||
# Skip integration tests
|
||||
uv run pytest -m "not integration"
|
||||
uv run pytest -m "not integration" -v
|
||||
```
|
||||
|
||||
! Hint: If the tests are failing due to missing environment variables, then usually the correct .env has not been created or not correctly configured yet.
|
||||
|
||||
### Load Testing
|
||||
```bash
|
||||
# Run benchmark with default settings (10 workers, 30 seconds)
|
||||
@@ -89,16 +110,18 @@ docker-compose up
|
||||
# For basic auth changes (most common) - uses admin credentials
|
||||
docker-compose up --build -d mcp
|
||||
|
||||
# For OAuth changes - uses OAuth authentication flow
|
||||
# For OAuth changes - uses OAuth authentication with JWT tokens
|
||||
docker-compose up --build -d mcp-oauth
|
||||
|
||||
# Build Docker image
|
||||
docker build -t nextcloud-mcp-server .
|
||||
```
|
||||
|
||||
**Important: Two MCP Server Containers**
|
||||
**Important: MCP Server Containers**
|
||||
- **`mcp`** (port 8000): Uses basic auth with admin credentials. Use this for most development and testing.
|
||||
- **`mcp-oauth`** (port 8001): Uses OAuth authentication. Only use this when working on OAuth-specific features or tests.
|
||||
- **`mcp-oauth`** (port 8001): Uses OAuth authentication with JWT tokens. Use this when working on OAuth-specific features or tests.
|
||||
- JWT tokens are used for testing (faster validation, scopes embedded in token)
|
||||
- The server can handle both JWT and opaque tokens via the token verifier
|
||||
|
||||
### Environment Setup
|
||||
```bash
|
||||
@@ -109,6 +132,36 @@ uv sync
|
||||
uv sync --group dev
|
||||
```
|
||||
|
||||
### Database Inspection
|
||||
|
||||
**Docker Compose Database Credentials:**
|
||||
- Root user: `root` / password: `password`
|
||||
- App user: `nextcloud` / password: `password`
|
||||
- Database: `nextcloud`
|
||||
|
||||
**Common Database Commands:**
|
||||
```bash
|
||||
# Connect to database as root (most common for inspection)
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud
|
||||
|
||||
# Check OAuth clients
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT id, name, token_type FROM oc_oidc_clients ORDER BY id DESC LIMIT 10;"
|
||||
|
||||
# Check OAuth client scopes
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT c.id, c.name, s.scope FROM oc_oidc_clients c LEFT JOIN oc_oidc_client_scopes s ON c.id = s.client_id WHERE c.name LIKE '%MCP%';"
|
||||
|
||||
# Check OAuth access tokens
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT id, client_id, user_id, created_at FROM oc_oidc_access_tokens ORDER BY created_at DESC LIMIT 10;"
|
||||
```
|
||||
|
||||
**Important Tables:**
|
||||
- `oc_oidc_clients` - OAuth client registrations (DCR clients)
|
||||
- `oc_oidc_client_scopes` - Client allowed scopes
|
||||
- `oc_oidc_access_tokens` - Issued access tokens
|
||||
- `oc_oidc_authorization_codes` - Authorization codes
|
||||
- `oc_oidc_registration_tokens` - RFC 7592 registration tokens for client management
|
||||
- `oc_oidc_redirect_uris` - Redirect URIs for each client
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
This is a Python MCP (Model Context Protocol) server that provides LLM integration with Nextcloud. The architecture follows a layered pattern:
|
||||
@@ -179,9 +232,37 @@ FastMCP serialization issue: raw lists get mangled into dicts with numeric strin
|
||||
|
||||
### Testing Structure
|
||||
|
||||
- **Integration tests** in `tests/client/` and `tests/server/` - Test real Nextcloud API interactions
|
||||
- **Fixtures** in `tests/conftest.py` - Shared test setup and utilities
|
||||
- Tests are marked with `@pytest.mark.integration` for selective running
|
||||
The test suite follows a layered architecture for fast feedback:
|
||||
|
||||
```
|
||||
tests/
|
||||
├── unit/ # Fast unit tests (~5s total)
|
||||
│ ├── test_scope_decorator.py
|
||||
│ └── test_response_models.py
|
||||
├── smoke/ # Critical path tests (~30-60s)
|
||||
│ └── test_smoke.py
|
||||
├── integration/
|
||||
│ ├── client/ # Direct API layer tests
|
||||
│ │ ├── notes/
|
||||
│ │ ├── calendar/
|
||||
│ │ └── ...
|
||||
│ └── server/ # MCP tool layer tests
|
||||
│ ├── oauth/ # OAuth-specific tests (slow, ~3min)
|
||||
│ │ ├── test_oauth_core.py
|
||||
│ │ ├── test_scope_authorization.py
|
||||
│ │ └── ...
|
||||
│ ├── test_mcp.py
|
||||
│ └── ...
|
||||
└── load/ # Performance tests
|
||||
```
|
||||
|
||||
**Test Markers:**
|
||||
- `@pytest.mark.unit` - Fast unit tests with mocked dependencies
|
||||
- `@pytest.mark.integration` - Integration tests requiring Docker containers
|
||||
- `@pytest.mark.oauth` - OAuth tests requiring Playwright (slowest)
|
||||
- `@pytest.mark.smoke` - Critical path smoke tests
|
||||
|
||||
**Fixtures** in `tests/conftest.py` - Shared test setup and utilities
|
||||
- **Important**: Integration tests run against live Docker containers. After making code changes:
|
||||
- For basic auth tests: rebuild with `docker-compose up --build -d mcp`
|
||||
- For OAuth tests: rebuild with `docker-compose up --build -d mcp-oauth`
|
||||
@@ -206,16 +287,84 @@ FastMCP serialization issue: raw lists get mangled into dicts with numeric strin
|
||||
- For OAuth changes: `uv run pytest tests/server/test_oauth*.py -v` (remember to rebuild `mcp-oauth` container)
|
||||
- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead
|
||||
|
||||
#### Writing Mocked Unit Tests
|
||||
|
||||
For client-layer tests that verify response parsing logic, use mocked HTTP responses instead of real network calls:
|
||||
|
||||
**Pattern:**
|
||||
```python
|
||||
import httpx
|
||||
import pytest
|
||||
from nextcloud_mcp_server.client.notes import NotesClient
|
||||
from tests.conftest import create_mock_note_response
|
||||
|
||||
async def test_notes_api_get_note(mocker):
|
||||
"""Test that get_note correctly parses the API response."""
|
||||
# Create mock response using helper functions
|
||||
mock_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
title="Test Note",
|
||||
content="Test content",
|
||||
category="Test",
|
||||
etag="abc123",
|
||||
)
|
||||
|
||||
# Mock the _make_request method
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NotesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Create client and test
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
note = await client.get_note(note_id=123)
|
||||
|
||||
# Verify the response was parsed correctly
|
||||
assert note["id"] == 123
|
||||
assert note["title"] == "Test Note"
|
||||
# Verify the correct API endpoint was called
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/notes/api/v1/notes/123")
|
||||
```
|
||||
|
||||
**Mock Response Helpers in `tests/conftest.py`:**
|
||||
- `create_mock_response()` - Generic HTTP response builder
|
||||
- `create_mock_note_response()` - Pre-configured note response
|
||||
- `create_mock_error_response()` - Error responses (404, 412, etc.)
|
||||
|
||||
**Benefits:**
|
||||
- ⚡ Fast execution (~0.1s vs minutes for integration tests)
|
||||
- 🔒 No Docker dependency
|
||||
- 🎯 Tests focus on response parsing logic
|
||||
- ♻️ Repeatable and deterministic
|
||||
|
||||
**When to use:**
|
||||
- Testing client methods that parse JSON responses
|
||||
- Testing error handling (404, 412, etc.)
|
||||
- Testing request parameter building
|
||||
|
||||
**When NOT to use (keep as integration tests):**
|
||||
- Complex protocol interactions (CalDAV, CardDAV, WebDAV)
|
||||
- Multi-component workflows (Notes + WebDAV attachments)
|
||||
- OAuth flows
|
||||
- End-to-end MCP tool testing
|
||||
|
||||
**Reference Implementation:**
|
||||
- See `tests/client/notes/test_notes_api.py` for complete examples
|
||||
- Mark unit tests with `pytestmark = pytest.mark.unit`
|
||||
- Run with: `uv run pytest tests/unit/ tests/client/notes/test_notes_api.py -v`
|
||||
|
||||
#### OAuth/OIDC Testing
|
||||
OAuth integration tests use **automated Playwright browser automation** to complete the OAuth flow programmatically.
|
||||
|
||||
**OAuth Testing Setup:**
|
||||
- **Main fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` - Use Playwright automation
|
||||
- **Shared OAuth Client**: All test users authenticate using a single OAuth client
|
||||
- Stored in `.nextcloud_oauth_shared_test_client.json`
|
||||
- Matches production MCP server behavior
|
||||
- **Created fresh for each test session** via Dynamic Client Registration (DCR)
|
||||
- Matches production MCP server behavior (one client, multiple user tokens)
|
||||
- Each user gets their own unique access token
|
||||
- **Automatic cleanup**: Client is registered at session start, deleted at session end (RFC 7592)
|
||||
- Implementation: `shared_oauth_client_credentials` fixture in `tests/conftest.py`
|
||||
- **Note**: Client deletion may fail due to Nextcloud middleware (logged as warning). This doesn't affect tests.
|
||||
- **Available fixtures**: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`
|
||||
- **Multi-user fixtures**: `alice_oauth_token`, `bob_oauth_token`, `charlie_oauth_token`, `diana_oauth_token`
|
||||
- **Requirements**: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
|
||||
@@ -226,13 +375,13 @@ OAuth integration tests use **automated Playwright browser automation** to compl
|
||||
**Example Commands:**
|
||||
```bash
|
||||
# Run all OAuth tests with Playwright automation using Firefox
|
||||
uv run pytest tests/server/test_oauth*.py --browser firefox -v
|
||||
uv run pytest tests/server/oauth/ --browser firefox -v
|
||||
|
||||
# Run specific tests with visible browser for debugging
|
||||
uv run pytest tests/server/test_mcp_oauth.py --browser firefox --headed -v
|
||||
# Run specific OAuth test file with visible browser for debugging
|
||||
uv run pytest tests/server/oauth/test_oauth_core.py --browser firefox --headed -v
|
||||
|
||||
# Run with Chromium (default)
|
||||
uv run pytest tests/server/test_oauth*.py -v
|
||||
# Run with Chromium (default) - use -m oauth marker for all OAuth tests
|
||||
uv run pytest -m oauth -v
|
||||
```
|
||||
|
||||
**Test Environment:**
|
||||
@@ -241,12 +390,142 @@ uv run pytest tests/server/test_oauth*.py -v
|
||||
- `mcp-oauth` (port 8001): Uses OAuth authentication - for OAuth-specific testing
|
||||
- Start OAuth MCP server: `docker-compose up --build -d mcp-oauth`
|
||||
- **Important**: When working on OAuth functionality, always rebuild `mcp-oauth` container, not `mcp`
|
||||
- OAuth client credentials cached in `.nextcloud_oauth_shared_test_client.json`
|
||||
|
||||
**CI/CD Notes:**
|
||||
- Playwright tests run in CI/CD environments
|
||||
- Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects)
|
||||
|
||||
#### Keycloak OAuth/OIDC Testing (ADR-002 Integration)
|
||||
|
||||
The MCP server supports using **Keycloak as an external OAuth/OIDC identity provider** instead of Nextcloud's built-in OIDC app. This validates the ADR-002 architecture for background jobs and external identity providers.
|
||||
|
||||
**Architecture:**
|
||||
```
|
||||
MCP Client → Keycloak (OAuth) → MCP Server → Nextcloud user_oidc (validates token) → APIs
|
||||
```
|
||||
|
||||
**Key Benefits:**
|
||||
- ✅ **No admin credentials needed** - All API access uses user's Keycloak token
|
||||
- ✅ **External identity provider** - Demonstrates integration with enterprise IdPs
|
||||
- ✅ **ADR-002 validation** - Tests offline_access and refresh token patterns
|
||||
- ✅ **User provisioning** - Nextcloud automatically provisions users from Keycloak
|
||||
|
||||
**Setup and Testing:**
|
||||
```bash
|
||||
# 1. Start Keycloak and MCP server with Keycloak OAuth
|
||||
docker-compose up -d keycloak app mcp-keycloak
|
||||
|
||||
# 2. Verify Keycloak realm is available
|
||||
curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||
|
||||
# 3. Verify user_oidc provider is configured
|
||||
docker compose exec app php occ user_oidc:provider keycloak
|
||||
|
||||
# 4. Generate encryption key for refresh token storage (optional, for offline access)
|
||||
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
# Set in environment: export TOKEN_ENCRYPTION_KEY='<key>'
|
||||
|
||||
# 5. Test OAuth flow manually
|
||||
# Get token from Keycloak:
|
||||
TOKEN=$(curl -s -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
|
||||
-d "grant_type=password" \
|
||||
-d "client_id=mcp-client" \
|
||||
-d "client_secret=mcp-secret-change-in-production" \
|
||||
-d "username=admin" \
|
||||
-d "password=admin" \
|
||||
-d "scope=openid profile email offline_access" | jq -r .access_token)
|
||||
|
||||
# Use token with Nextcloud API (validated by user_oidc):
|
||||
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/ocs/v2.php/cloud/capabilities
|
||||
|
||||
# 6. Connect MCP client
|
||||
# Point client to: http://localhost:8002
|
||||
# Complete OAuth flow using Keycloak credentials: admin/admin
|
||||
```
|
||||
|
||||
**Three MCP Server Containers:**
|
||||
- **`mcp`** (port 8000): Basic auth with admin credentials
|
||||
- **`mcp-oauth`** (port 8001): Nextcloud OIDC provider (JWT tokens)
|
||||
- **`mcp-keycloak`** (port 8002): Keycloak OIDC provider (external IdP)
|
||||
|
||||
**Keycloak Configuration:**
|
||||
- **Realm**: `nextcloud-mcp` (auto-imported from `keycloak/realm-export.json`)
|
||||
- **Client**: `mcp-client` (pre-configured with PKCE, offline_access)
|
||||
- **Admin user**: `admin/admin` (created in realm export)
|
||||
- **Redirect URIs**: `http://localhost:*/callback`, `http://127.0.0.1:*/callback`
|
||||
|
||||
**Environment Variables** (Generic OIDC - works with any provider):
|
||||
```bash
|
||||
# Generic OIDC configuration (provider-agnostic)
|
||||
OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||
OIDC_CLIENT_ID=nextcloud-mcp-server # OAuth client ID
|
||||
OIDC_CLIENT_SECRET=mcp-secret-... # OAuth client secret
|
||||
|
||||
# Nextcloud API configuration
|
||||
NEXTCLOUD_HOST=http://app:80 # Nextcloud API (token validation in external IdP mode)
|
||||
|
||||
# Refresh tokens and token exchange (ADR-002)
|
||||
ENABLE_OFFLINE_ACCESS=true # Enable refresh tokens
|
||||
TOKEN_ENCRYPTION_KEY=<fernet-key> # Encrypt refresh tokens
|
||||
TOKEN_STORAGE_DB=/app/data/tokens.db # Token storage path
|
||||
|
||||
# OAuth scopes (optional - uses defaults if not specified)
|
||||
NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access notes:read notes:write ...
|
||||
```
|
||||
|
||||
**Provider Mode Detection:**
|
||||
- **External IdP mode**: If `OIDC_DISCOVERY_URL` issuer ≠ `NEXTCLOUD_HOST` → Uses external provider (Keycloak, Auth0, Okta, etc.)
|
||||
- **Integrated mode**: If `OIDC_DISCOVERY_URL` not set or issuer = `NEXTCLOUD_HOST` → Uses Nextcloud OIDC app
|
||||
|
||||
**Nextcloud user_oidc Configuration:**
|
||||
The `user_oidc` app is automatically configured by `app-hooks/post-installation/15-setup-keycloak-provider.sh`:
|
||||
```bash
|
||||
# Configured with:
|
||||
--check-bearer=1 # Validate bearer tokens
|
||||
--bearer-provisioning=1 # Auto-provision users
|
||||
--unique-uid=1 # Hash user IDs
|
||||
--scope="openid profile email offline_access"
|
||||
```
|
||||
|
||||
**Troubleshooting:**
|
||||
```bash
|
||||
# Check Keycloak is running
|
||||
docker-compose ps keycloak
|
||||
docker-compose logs keycloak
|
||||
|
||||
# Check user_oidc provider configuration
|
||||
docker compose exec app php occ user_oidc:provider keycloak
|
||||
|
||||
# Check MCP server logs
|
||||
docker-compose logs -f mcp-keycloak
|
||||
|
||||
# Check Nextcloud logs for token validation
|
||||
docker compose exec app tail -f /var/www/html/data/nextcloud.log
|
||||
|
||||
# Verify Keycloak is accessible from Nextcloud container
|
||||
docker compose exec app curl http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
**ADR-002 Offline Access Testing:**
|
||||
The Keycloak integration enables testing ADR-002's primary authentication pattern (offline access with refresh tokens):
|
||||
|
||||
1. **Refresh token storage**: Tokens stored encrypted in SQLite (`/app/data/tokens.db`)
|
||||
2. **Token refresh**: Access tokens refreshed automatically when expired
|
||||
3. **Background workers**: Can access APIs using stored refresh tokens
|
||||
4. **No admin credentials**: All operations use user's OAuth tokens
|
||||
|
||||
**Note**: Service account tokens (client_credentials grant) were considered but rejected as they create Nextcloud user accounts and violate OAuth "act on-behalf-of" principles. See ADR-002 "Will Not Implement" section.
|
||||
|
||||
See `docs/ADR-002-vector-sync-authentication.md` for architectural details.
|
||||
|
||||
**Audience Validation:**
|
||||
Tokens include `aud: ["mcp-server", "nextcloud"]` claims for proper security:
|
||||
- MCP server validates tokens are intended for it
|
||||
- Nextcloud validates tokens include it as audience
|
||||
- Prevents token misuse across services
|
||||
|
||||
See `docs/audience-validation-setup.md` for configuration details and `docs/keycloak-multi-client-validation.md` for realm-level validation behavior.
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- **`pyproject.toml`** - Python project configuration using uv for dependency management
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.9.4-python3.11-alpine@sha256:1a51c7710eaf839fa3365329ad993b48d17ddd9ab0f0672efaa9b09f407ebf44
|
||||
FROM ghcr.io/astral-sh/uv:0.9.7-python3.11-alpine@sha256:0006b77df7ebf46e68959fdc8d3af9d19f1adfae8c2e7e77907ad257e5d05be4
|
||||
|
||||
# Install git (required for caldav dependency from git)
|
||||
RUN apk add --no-cache git
|
||||
|
||||
@@ -23,6 +23,7 @@ The Nextcloud MCP (Model Context Protocol) server allows Large Language Models l
|
||||
| **Calendar** | ✅ Full CalDAV + tasks (20+ tools) | ✅ Events, free/busy, tasks (4 tools) |
|
||||
| **Contacts** | ✅ Full CardDAV (8 tools) | ✅ Find person, current user (2 tools) |
|
||||
| **Files (WebDAV)** | ✅ Full filesystem access (12 tools) | ✅ Read, folder tree, sharing (3 tools) |
|
||||
| **Document Processing** | ✅ OCR with progress (PDF, DOCX, images) | ❌ Not implemented |
|
||||
| **Deck** | ✅ Full project management (15 tools) | ✅ Basic board/card ops (2 tools) |
|
||||
| **Tables** | ✅ Row operations (5 tools) | ❌ Not implemented |
|
||||
| **Cookbook** | ✅ Full recipe management (13 tools) | ❌ Not implemented |
|
||||
@@ -71,9 +72,17 @@ uv sync
|
||||
|
||||
# Or using Docker
|
||||
docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
|
||||
# Or deploy to Kubernetes with Helm
|
||||
helm repo add nextcloud-mcp https://cbcoutinho.github.io/nextcloud-mcp-server
|
||||
helm repo update
|
||||
helm install nextcloud-mcp nextcloud-mcp/nextcloud-mcp-server \
|
||||
--set nextcloud.host=https://cloud.example.com \
|
||||
--set auth.basic.username=myuser \
|
||||
--set auth.basic.password=mypassword
|
||||
```
|
||||
|
||||
See [Installation Guide](docs/installation.md) for detailed instructions.
|
||||
See [Installation Guide](docs/installation.md) for detailed instructions, or [Helm Chart README](charts/nextcloud-mcp-server/README.md) for Kubernetes deployment.
|
||||
|
||||
### 2. Configure
|
||||
|
||||
@@ -182,13 +191,85 @@ Or connect from:
|
||||
The server exposes Nextcloud functionality through MCP tools (for actions) and resources (for data browsing).
|
||||
|
||||
### Tools
|
||||
Tools enable AI assistants to perform actions:
|
||||
|
||||
The server provides 90+ tools across 8 Nextcloud apps. When using OAuth, tools are dynamically filtered based on your granted scopes.
|
||||
|
||||
For a complete list of all supported OAuth scopes and their descriptions, see [OAuth Scopes Documentation](docs/oauth-architecture.md#oauth-scopes).
|
||||
|
||||
#### Available Tool Categories
|
||||
|
||||
| App | Tools | Read Scope | Write Scope | Operations |
|
||||
|-----|-------|-----------|-------------|------------|
|
||||
| **Notes** | 7 | `notes:read` | `notes:write` | Create, read, update, delete, search notes |
|
||||
| **Calendar** | 20+ | `calendar:read` `todo:read` | `calendar:write` `todo:write` | Events, todos (tasks), calendars, recurring events, attendees |
|
||||
| **Contacts** | 8 | `contacts:read` | `contacts:write` | Create, read, update, delete contacts and address books |
|
||||
| **Files (WebDAV)** | 12 | `files:read` | `files:write` | List, read, upload, delete, move files; **OCR/document processing** |
|
||||
| **Deck** | 15 | `deck:read` | `deck:write` | Boards, stacks, cards, labels, assignments |
|
||||
| **Cookbook** | 13 | `cookbook:read` | `cookbook:write` | Recipes, import from URLs, search, categories |
|
||||
| **Tables** | 5 | `tables:read` | `tables:write` | Row operations on Nextcloud Tables |
|
||||
| **Sharing** | 10+ | `sharing:read` | `sharing:write` | Create, manage, delete shares |
|
||||
|
||||
#### Document Processing (Optional)
|
||||
|
||||
The WebDAV file reading tool (`nc_webdav_read_file`) supports **automatic text extraction** from documents and images:
|
||||
|
||||
**Supported Formats:**
|
||||
- **Documents**: PDF, DOCX, PPTX, XLSX, RTF, ODT, EPUB
|
||||
- **Images**: PNG, JPEG, TIFF, BMP (with OCR)
|
||||
- **Email**: EML, MSG files
|
||||
|
||||
**Features:**
|
||||
- **Progress Notifications**: Long-running OCR operations (up to 120s) send progress updates every 10 seconds to prevent client timeouts
|
||||
- **Pluggable Architecture**: Multiple processor backends (Unstructured.io, Tesseract, custom HTTP APIs)
|
||||
- **Automatic Detection**: Files are processed based on MIME type
|
||||
- **Graceful Fallback**: Returns base64-encoded content if processing fails
|
||||
|
||||
**Configuration:**
|
||||
```dotenv
|
||||
# Enable document processing (optional)
|
||||
ENABLE_DOCUMENT_PROCESSING=true
|
||||
|
||||
# Unstructured.io processor (cloud/API-based, supports many formats)
|
||||
ENABLE_UNSTRUCTURED=true
|
||||
UNSTRUCTURED_API_URL=http://localhost:8002
|
||||
UNSTRUCTURED_STRATEGY=auto # auto, fast, or hi_res
|
||||
UNSTRUCTURED_LANGUAGES=eng,deu
|
||||
PROGRESS_INTERVAL=10 # Progress update interval in seconds
|
||||
|
||||
# Tesseract processor (local OCR, images only)
|
||||
ENABLE_TESSERACT=false
|
||||
TESSERACT_LANG=eng
|
||||
|
||||
# Custom HTTP processor
|
||||
ENABLE_CUSTOM_PROCESSOR=false
|
||||
CUSTOM_PROCESSOR_URL=http://localhost:9000/process
|
||||
CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg
|
||||
```
|
||||
|
||||
**Example Usage:**
|
||||
```
|
||||
AI: "Read the contents of Documents/report.pdf"
|
||||
→ Uses nc_webdav_read_file tool with automatic OCR processing
|
||||
→ Returns extracted text with parsing metadata
|
||||
→ Sends progress updates during long operations
|
||||
```
|
||||
|
||||
See [env.sample](env.sample) for complete configuration options.
|
||||
|
||||
**Example Tools:**
|
||||
- `nc_notes_create_note` - Create a new note
|
||||
- `nc_cookbook_import_recipe` - Import recipes from URLs with schema.org metadata
|
||||
- `deck_create_card` - Create a Deck card
|
||||
- `nc_calendar_create_event` - Create a calendar event
|
||||
- `nc_calendar_create_todo` - Create a CalDAV task/todo
|
||||
- `nc_contacts_create_contact` - Create a contact
|
||||
- And many more...
|
||||
- `nc_webdav_upload_file` - Upload a file to Nextcloud
|
||||
- And 80+ more...
|
||||
|
||||
> [!TIP]
|
||||
> **OAuth Scope Filtering**: When connecting via OAuth, MCP clients will only see tools for which you've granted access. For example, granting only `notes:read` and `notes:write` will show 7 Notes tools instead of all 90+ tools. See [OAuth Scopes Documentation](docs/oauth-architecture.md#oauth-scopes) for the complete scope reference, or [OAuth Troubleshooting - Limited Scopes](docs/oauth-troubleshooting.md#limited-scopes---only-seeing-notes-tools) if you're only seeing a subset of tools.
|
||||
>
|
||||
> **Known Issue**: Claude Code and some other MCP clients may only request/grant Notes scopes during initial connection. Track progress at [#234](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/234).
|
||||
|
||||
### Resources
|
||||
Resources provide read-only access to Nextcloud data:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
diff --git a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
|
||||
index 4453f5a7d4b..f1ca9b48d21 100644
|
||||
--- a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
|
||||
+++ b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
|
||||
@@ -73,6 +73,13 @@ class CORSMiddleware extends Middleware {
|
||||
$user = array_key_exists('PHP_AUTH_USER', $this->request->server) ? $this->request->server['PHP_AUTH_USER'] : null;
|
||||
$pass = array_key_exists('PHP_AUTH_PW', $this->request->server) ? $this->request->server['PHP_AUTH_PW'] : null;
|
||||
|
||||
+ // Allow Bearer token authentication for CORS requests
|
||||
+ // Bearer tokens are stateless and don't require CSRF protection
|
||||
+ $authorizationHeader = $this->request->getHeader('Authorization');
|
||||
+ if (!empty($authorizationHeader) && str_starts_with($authorizationHeader, 'Bearer ')) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
// Allow to use the current session if a CSRF token is provided
|
||||
if ($this->request->passesCSRFCheck()) {
|
||||
return;
|
||||
@@ -33,5 +33,6 @@ fi
|
||||
# Configure OIDC Identity Provider with dynamic client registration enabled
|
||||
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
|
||||
php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean
|
||||
php /var/www/html/occ config:app:set oidc default_token_type --value='jwt'
|
||||
|
||||
echo "OIDC app installed and configured successfully"
|
||||
|
||||
@@ -9,5 +9,13 @@ php /var/www/html/occ app:enable user_oidc
|
||||
|
||||
# Configure user_oidc to validate bearer tokens from the OIDC Identity Provider
|
||||
php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
|
||||
php /var/www/html/occ config:system:set user_oidc httpclient.allowselfsigned --value=true --type=boolean
|
||||
|
||||
patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch
|
||||
# Allow Nextcloud to connect to local/internal servers (required for external IdP mode)
|
||||
# This enables user_oidc to fetch JWKS from internal Keycloak container
|
||||
php /var/www/html/occ config:system:set allow_local_remote_servers --value=true --type=boolean
|
||||
|
||||
# Note: The user_oidc app_api session flag patch is NOT required when using the
|
||||
# CORSMiddleware Bearer token patch (20-apply-cors-bearer-token-patch.sh).
|
||||
# The CORSMiddleware patch fixes the root cause by allowing Bearer tokens to bypass
|
||||
# CORS/CSRF checks at the framework level.
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Configure user_oidc to accept bearer tokens from Keycloak
|
||||
#
|
||||
# This script sets up Keycloak as an external OIDC provider for Nextcloud.
|
||||
# It enables bearer token validation, allowing the MCP server to use Keycloak
|
||||
# tokens to access Nextcloud APIs without admin credentials.
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
echo "===================================================================="
|
||||
echo "Configuring user_oidc provider for Keycloak..."
|
||||
echo "===================================================================="
|
||||
|
||||
# Wait for Keycloak to be ready and realm to be available
|
||||
echo "Waiting for Keycloak realm to be available..."
|
||||
MAX_RETRIES=30
|
||||
RETRY_COUNT=0
|
||||
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||
if curl -sf http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration > /dev/null 2>&1; then
|
||||
echo "✓ Keycloak realm is ready"
|
||||
break
|
||||
fi
|
||||
echo " Waiting for Keycloak... (attempt $((RETRY_COUNT + 1))/$MAX_RETRIES)"
|
||||
sleep 5
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
done
|
||||
|
||||
if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then
|
||||
echo "⚠ Warning: Keycloak not available after $MAX_RETRIES attempts"
|
||||
echo " Keycloak provider will not be configured"
|
||||
echo " You can configure it manually using:"
|
||||
echo " docker compose exec app php occ user_oidc:provider keycloak \\"
|
||||
echo " --clientid='nextcloud' \\"
|
||||
echo " --clientsecret='nextcloud-secret-change-in-production' \\"
|
||||
echo " --discoveryuri='http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration' \\"
|
||||
echo " --check-bearer=1 \\"
|
||||
echo " --bearer-provisioning=1 \\"
|
||||
echo " --unique-uid=1"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if provider already exists
|
||||
if php /var/www/html/occ user_oidc:provider keycloak 2>/dev/null | grep -q "Identifier"; then
|
||||
echo " Keycloak provider already exists, updating configuration..."
|
||||
|
||||
# Update existing provider
|
||||
php /var/www/html/occ user_oidc:provider keycloak \
|
||||
--clientid="nextcloud" \
|
||||
--clientsecret="nextcloud-secret-change-in-production" \
|
||||
--discoveryuri="http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration" \
|
||||
--check-bearer=1 \
|
||||
--bearer-provisioning=1 \
|
||||
--unique-uid=1 \
|
||||
--mapping-uid="sub" \
|
||||
--mapping-display-name="name" \
|
||||
--mapping-email="email" \
|
||||
--scope="openid profile email offline_access"
|
||||
|
||||
echo "✓ Updated Keycloak provider configuration"
|
||||
else
|
||||
echo " Creating new Keycloak provider..."
|
||||
|
||||
# Create new provider
|
||||
php /var/www/html/occ user_oidc:provider keycloak \
|
||||
--clientid="nextcloud" \
|
||||
--clientsecret="nextcloud-secret-change-in-production" \
|
||||
--discoveryuri="http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration" \
|
||||
--check-bearer=1 \
|
||||
--bearer-provisioning=1 \
|
||||
--unique-uid=1 \
|
||||
--mapping-uid="sub" \
|
||||
--mapping-display-name="name" \
|
||||
--mapping-email="email" \
|
||||
--scope="openid profile email offline_access"
|
||||
|
||||
echo "✓ Created Keycloak provider"
|
||||
fi
|
||||
|
||||
# Display provider details
|
||||
echo ""
|
||||
echo "Keycloak provider configuration:"
|
||||
php /var/www/html/occ user_oidc:provider keycloak
|
||||
|
||||
echo ""
|
||||
echo "===================================================================="
|
||||
echo "✓ Keycloak provider configured successfully"
|
||||
echo "===================================================================="
|
||||
echo ""
|
||||
echo "Key features enabled:"
|
||||
echo " • Bearer token validation (--check-bearer=1)"
|
||||
echo " • Automatic user provisioning (--bearer-provisioning=1)"
|
||||
echo " • Unique user IDs (--unique-uid=1)"
|
||||
echo " • Offline access scope (for refresh tokens)"
|
||||
echo ""
|
||||
echo "MCP server can now use Keycloak tokens to access Nextcloud APIs"
|
||||
echo "without admin credentials (ADR-002 architecture)."
|
||||
echo ""
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Apply upstream CORSMiddleware Bearer token authentication patch
|
||||
#
|
||||
# This patch allows Bearer tokens to bypass CORS/CSRF checks, fixing
|
||||
# authentication issues with app-specific APIs (Notes, Calendar, etc.)
|
||||
# when using OAuth/OIDC Bearer tokens.
|
||||
#
|
||||
# Upstream PR: https://github.com/nextcloud/server/pull/55878
|
||||
# Commit: 8fb5e77db82 (fix(cors): Allow Bearer token authentication)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
PATCH_FILE="/docker-entrypoint-hooks.d/patches/cors-bearer-token.patch"
|
||||
TARGET_FILE="/var/www/html/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php"
|
||||
|
||||
echo "===================================================================="
|
||||
echo "Applying CORSMiddleware Bearer token authentication patch..."
|
||||
echo "===================================================================="
|
||||
|
||||
# Check if patch file exists
|
||||
if [ ! -f "$PATCH_FILE" ]; then
|
||||
echo "⚠ Warning: Patch file not found: $PATCH_FILE"
|
||||
echo " Skipping CORS Bearer token patch"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if target file exists
|
||||
if [ ! -f "$TARGET_FILE" ]; then
|
||||
echo "⚠ Warning: Target file not found: $TARGET_FILE"
|
||||
echo " Skipping CORS Bearer token patch"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if already patched
|
||||
if grep -q "Allow Bearer token authentication for CORS requests" "$TARGET_FILE"; then
|
||||
echo "✓ CORSMiddleware already patched for Bearer token support"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Applying patch to CORSMiddleware.php..."
|
||||
|
||||
# Apply the patch
|
||||
cd /var/www/html
|
||||
if patch -p1 --dry-run < "$PATCH_FILE" > /dev/null 2>&1; then
|
||||
patch -p1 < "$PATCH_FILE"
|
||||
echo "✓ Patch applied successfully"
|
||||
else
|
||||
echo "⚠ Warning: Patch failed to apply (may already be applied or file changed)"
|
||||
echo " This is expected if using a Nextcloud version that already includes the fix"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "===================================================================="
|
||||
echo "✓ CORSMiddleware Bearer token patch applied"
|
||||
echo "===================================================================="
|
||||
echo ""
|
||||
echo "Benefits:"
|
||||
echo " • Bearer tokens now work with app-specific APIs (Notes, Calendar, etc.)"
|
||||
echo " • OAuth/OIDC authentication works without CORS errors"
|
||||
echo " • Stateless API authentication is properly supported"
|
||||
echo ""
|
||||
@@ -0,0 +1,23 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@@ -0,0 +1,23 @@
|
||||
apiVersion: v2
|
||||
name: nextcloud-mcp-server
|
||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||
type: application
|
||||
version: 0.23.0
|
||||
appVersion: "0.23.0"
|
||||
keywords:
|
||||
- nextcloud
|
||||
- mcp
|
||||
- model-context-protocol
|
||||
- llm
|
||||
- ai
|
||||
- claude
|
||||
- webdav
|
||||
- caldav
|
||||
- carddav
|
||||
maintainers:
|
||||
- name: Chris Coutinho
|
||||
email: chris@coutinho.io
|
||||
home: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
sources:
|
||||
- https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
icon: https://raw.githubusercontent.com/nextcloud/server/master/core/img/logo/logo.svg
|
||||
@@ -0,0 +1,489 @@
|
||||
# Nextcloud MCP Server Helm Chart
|
||||
|
||||
This Helm chart deploys the Nextcloud MCP (Model Context Protocol) Server on a Kubernetes cluster, enabling AI assistants to interact with your Nextcloud instance.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Kubernetes 1.19+
|
||||
- Helm 3.0+
|
||||
- A running Nextcloud instance (accessible from the Kubernetes cluster)
|
||||
- Nextcloud credentials (username/password for basic auth OR OAuth client for OAuth mode)
|
||||
|
||||
## Installation
|
||||
|
||||
### Quick Start with Basic Authentication
|
||||
|
||||
```bash
|
||||
# Install with basic auth (recommended for most users)
|
||||
helm install nextcloud-mcp ./helm/nextcloud-mcp-server \
|
||||
--set nextcloud.host=https://cloud.example.com \
|
||||
--set auth.basic.username=myuser \
|
||||
--set auth.basic.password=mypassword
|
||||
```
|
||||
|
||||
### Using a values file
|
||||
|
||||
Create a `custom-values.yaml` file:
|
||||
|
||||
```yaml
|
||||
nextcloud:
|
||||
host: https://cloud.example.com
|
||||
|
||||
auth:
|
||||
mode: basic
|
||||
basic:
|
||||
username: myuser
|
||||
password: mypassword
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 512Mi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
```
|
||||
|
||||
Install with your custom values:
|
||||
|
||||
```bash
|
||||
helm install nextcloud-mcp ./helm/nextcloud-mcp-server -f custom-values.yaml
|
||||
```
|
||||
|
||||
### OAuth Authentication Mode (Experimental)
|
||||
|
||||
**Warning:** OAuth mode is experimental and requires patches to the Nextcloud `user_oidc` app. See the [Authentication Guide](https://github.com/cbcoutinho/nextcloud-mcp-server#authentication) for details.
|
||||
|
||||
```yaml
|
||||
nextcloud:
|
||||
host: https://cloud.example.com
|
||||
mcpServerUrl: https://mcp.example.com
|
||||
publicIssuerUrl: https://cloud.example.com
|
||||
|
||||
auth:
|
||||
mode: oauth
|
||||
oauth:
|
||||
# Optional: provide pre-registered client credentials
|
||||
# If not provided, will use Dynamic Client Registration
|
||||
clientId: "your-client-id"
|
||||
clientSecret: "your-client-secret"
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 100Mi
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: mcp.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: nextcloud-mcp-tls
|
||||
hosts:
|
||||
- mcp.example.com
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Key Configuration Parameters
|
||||
|
||||
#### Nextcloud Connection
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `nextcloud.host` | URL of your Nextcloud instance (required) | `""` |
|
||||
| `nextcloud.mcpServerUrl` | MCP server URL for OAuth callbacks (OAuth only, optional) | Smart default* |
|
||||
| `nextcloud.publicIssuerUrl` | Public issuer URL for OAuth (OAuth only, optional) | Smart default** |
|
||||
|
||||
**Smart Defaults:**
|
||||
- `*mcpServerUrl`: If not set, automatically uses ingress host (if enabled) or `http://localhost:8000` (for port-forward setups)
|
||||
- `**publicIssuerUrl`: If not set, automatically defaults to `nextcloud.host` (which works when both clients and MCP server access Nextcloud at the same URL)
|
||||
|
||||
#### Authentication
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `auth.mode` | Authentication mode: `basic` or `oauth` | `basic` |
|
||||
| `auth.basic.username` | Nextcloud username (basic auth) | `""` |
|
||||
| `auth.basic.password` | Nextcloud password (basic auth) | `""` |
|
||||
| `auth.basic.existingSecret` | Use existing secret for credentials | `""` |
|
||||
| `auth.oauth.clientId` | OAuth client ID (OAuth mode, optional) | `""` |
|
||||
| `auth.oauth.clientSecret` | OAuth client secret (OAuth mode, optional) | `""` |
|
||||
| `auth.oauth.persistence.enabled` | Enable persistent storage for OAuth | `true` |
|
||||
| `auth.oauth.persistence.size` | Size of OAuth storage PVC | `100Mi` |
|
||||
|
||||
#### MCP Server Configuration
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `mcp.transport` | Transport mode | `streamable-http` |
|
||||
| `mcp.port` | Server port (used by both auth modes) | `8000` |
|
||||
| `mcp.extraArgs` | Additional command-line arguments | `[]` |
|
||||
|
||||
The `extraArgs` parameter allows you to pass additional command-line arguments to the MCP server. This is useful for enabling debug logging, enabling specific apps, or other runtime configuration.
|
||||
|
||||
**Example:**
|
||||
```yaml
|
||||
mcp:
|
||||
extraArgs:
|
||||
- "--log-level"
|
||||
- "debug"
|
||||
- "--enable-app"
|
||||
- "notes"
|
||||
```
|
||||
|
||||
#### Image Configuration
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `image.repository` | Container image repository | `ghcr.io/cbcoutinho/nextcloud-mcp-server` |
|
||||
| `image.pullPolicy` | Image pull policy | `IfNotPresent` |
|
||||
|
||||
**Note:** Image tag is automatically set to the chart's `appVersion` and cannot be overridden.
|
||||
|
||||
#### Resources
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `resources.limits.cpu` | CPU limit | `1000m` |
|
||||
| `resources.limits.memory` | Memory limit | `512Mi` |
|
||||
| `resources.requests.cpu` | CPU request | `100m` |
|
||||
| `resources.requests.memory` | Memory request | `128Mi` |
|
||||
|
||||
#### Service
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `service.type` | Service type | `ClusterIP` |
|
||||
| `service.port` | Service port | `8000` |
|
||||
|
||||
#### Ingress
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `ingress.enabled` | Enable ingress | `false` |
|
||||
| `ingress.className` | Ingress class name | `""` |
|
||||
| `ingress.hosts` | Ingress host configuration | See values.yaml |
|
||||
| `ingress.tls` | Ingress TLS configuration | `[]` |
|
||||
|
||||
#### Autoscaling
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `autoscaling.enabled` | Enable HPA | `false` |
|
||||
| `autoscaling.minReplicas` | Minimum replicas | `1` |
|
||||
| `autoscaling.maxReplicas` | Maximum replicas | `10` |
|
||||
| `autoscaling.targetCPUUtilizationPercentage` | Target CPU % | `80` |
|
||||
|
||||
#### Health Probes
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `livenessProbe.httpGet.path` | Liveness probe endpoint | `/health/live` |
|
||||
| `livenessProbe.initialDelaySeconds` | Initial delay for liveness | `30` |
|
||||
| `livenessProbe.periodSeconds` | Check interval for liveness | `10` |
|
||||
| `readinessProbe.httpGet.path` | Readiness probe endpoint | `/health/ready` |
|
||||
| `readinessProbe.initialDelaySeconds` | Initial delay for readiness | `10` |
|
||||
| `readinessProbe.periodSeconds` | Check interval for readiness | `5` |
|
||||
|
||||
The application exposes HTTP health check endpoints:
|
||||
- `/health/live` - Liveness probe (checks if application is running)
|
||||
- `/health/ready` - Readiness probe (checks if application is ready to serve traffic)
|
||||
|
||||
#### Document Processing (Optional)
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `documentProcessing.enabled` | Enable document processing | `false` |
|
||||
| `documentProcessing.defaultProcessor` | Default processor | `unstructured` |
|
||||
| `documentProcessing.unstructured.enabled` | Enable Unstructured.io processor | `false` |
|
||||
| `documentProcessing.unstructured.apiUrl` | Unstructured API URL | `http://unstructured:8000` |
|
||||
| `documentProcessing.tesseract.enabled` | Enable Tesseract OCR | `false` |
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Basic Auth with Ingress
|
||||
|
||||
```yaml
|
||||
nextcloud:
|
||||
host: https://cloud.example.com
|
||||
|
||||
auth:
|
||||
mode: basic
|
||||
basic:
|
||||
username: admin
|
||||
password: secure-password
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
hosts:
|
||||
- host: mcp.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: mcp-tls
|
||||
hosts:
|
||||
- mcp.example.com
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 1Gi
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
```
|
||||
|
||||
### Example 2: Using Existing Secrets
|
||||
|
||||
#### Basic Auth with Existing Secret
|
||||
|
||||
Create a secret manually:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic nextcloud-credentials \
|
||||
--from-literal=username=myuser \
|
||||
--from-literal=password=mypassword
|
||||
```
|
||||
|
||||
Then reference it in your values:
|
||||
|
||||
```yaml
|
||||
nextcloud:
|
||||
host: https://cloud.example.com
|
||||
|
||||
auth:
|
||||
mode: basic
|
||||
basic:
|
||||
existingSecret: nextcloud-credentials
|
||||
usernameKey: username
|
||||
passwordKey: password
|
||||
```
|
||||
|
||||
#### OAuth with Existing Secret (Pre-registered Client)
|
||||
|
||||
If you have a pre-registered OAuth client:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic nextcloud-oauth-creds \
|
||||
--from-literal=clientId=my-oauth-client-id \
|
||||
--from-literal=clientSecret=my-oauth-client-secret
|
||||
```
|
||||
|
||||
Then reference it in your values:
|
||||
|
||||
```yaml
|
||||
nextcloud:
|
||||
host: https://cloud.example.com
|
||||
# mcpServerUrl and publicIssuerUrl are optional!
|
||||
# If not set, mcpServerUrl defaults to ingress host or localhost
|
||||
# publicIssuerUrl defaults to nextcloud.host
|
||||
|
||||
auth:
|
||||
mode: oauth
|
||||
oauth:
|
||||
existingSecret: nextcloud-oauth-creds
|
||||
clientIdKey: clientId
|
||||
clientSecretKey: clientSecret
|
||||
persistence:
|
||||
enabled: true
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
hosts:
|
||||
- host: mcp.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: mcp-tls
|
||||
hosts:
|
||||
- mcp.example.com
|
||||
```
|
||||
|
||||
### Example 3: OAuth with Document Processing and Dynamic Client Registration
|
||||
|
||||
This example shows OAuth without pre-registered credentials (using DCR) and optional URL values:
|
||||
|
||||
```yaml
|
||||
nextcloud:
|
||||
host: https://cloud.example.com
|
||||
# mcpServerUrl will automatically use ingress host (https://mcp.example.com)
|
||||
# publicIssuerUrl will automatically default to nextcloud.host
|
||||
|
||||
auth:
|
||||
mode: oauth
|
||||
oauth:
|
||||
# No clientId/clientSecret - will use Dynamic Client Registration!
|
||||
persistence:
|
||||
enabled: true
|
||||
storageClass: fast-ssd
|
||||
size: 200Mi
|
||||
|
||||
documentProcessing:
|
||||
enabled: true
|
||||
defaultProcessor: unstructured
|
||||
unstructured:
|
||||
enabled: true
|
||||
apiUrl: http://unstructured-api:8000
|
||||
strategy: hi_res
|
||||
languages: eng,deu,fra
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: mcp.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
```
|
||||
|
||||
### Example 4: High Availability with Autoscaling
|
||||
|
||||
```yaml
|
||||
replicaCount: 2
|
||||
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 2
|
||||
maxReplicas: 20
|
||||
targetCPUUtilizationPercentage: 70
|
||||
targetMemoryUtilizationPercentage: 80
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 1Gi
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchExpressions:
|
||||
- key: app.kubernetes.io/name
|
||||
operator: In
|
||||
values:
|
||||
- nextcloud-mcp-server
|
||||
topologyKey: kubernetes.io/hostname
|
||||
```
|
||||
|
||||
## Upgrading
|
||||
|
||||
### To upgrade an existing deployment:
|
||||
|
||||
```bash
|
||||
helm upgrade nextcloud-mcp ./helm/nextcloud-mcp-server -f custom-values.yaml
|
||||
```
|
||||
|
||||
### To upgrade with new values:
|
||||
|
||||
```bash
|
||||
helm upgrade nextcloud-mcp ./helm/nextcloud-mcp-server \
|
||||
--set resources.limits.memory=1Gi
|
||||
```
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
helm uninstall nextcloud-mcp
|
||||
```
|
||||
|
||||
**Note:** This will delete all resources including PVCs. If you want to preserve OAuth client data, backup the PVC before uninstalling.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Check pod status
|
||||
|
||||
```bash
|
||||
kubectl get pods -l app.kubernetes.io/name=nextcloud-mcp-server
|
||||
```
|
||||
|
||||
### View logs
|
||||
|
||||
```bash
|
||||
kubectl logs -l app.kubernetes.io/name=nextcloud-mcp-server --tail=100 -f
|
||||
```
|
||||
|
||||
### Check health endpoints
|
||||
|
||||
The application exposes health check endpoints for monitoring:
|
||||
|
||||
```bash
|
||||
# Port forward to the service
|
||||
kubectl port-forward svc/nextcloud-mcp 8000:8000
|
||||
|
||||
# Check liveness (if app is running)
|
||||
curl http://localhost:8000/health/live
|
||||
|
||||
# Check readiness (if app is ready to serve traffic)
|
||||
curl http://localhost:8000/health/ready
|
||||
```
|
||||
|
||||
**Example responses:**
|
||||
|
||||
Liveness (always returns 200 if running):
|
||||
```json
|
||||
{
|
||||
"status": "alive",
|
||||
"mode": "basic"
|
||||
}
|
||||
```
|
||||
|
||||
Readiness (returns 200 if ready, 503 if not ready):
|
||||
```json
|
||||
{
|
||||
"status": "ready",
|
||||
"checks": {
|
||||
"nextcloud_configured": "ok",
|
||||
"auth_mode": "basic",
|
||||
"auth_configured": "ok"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Connection refused to Nextcloud**
|
||||
- Verify `nextcloud.host` is accessible from the Kubernetes cluster
|
||||
- Check network policies and firewall rules
|
||||
|
||||
2. **Authentication failures**
|
||||
- For basic auth: verify username/password are correct
|
||||
- For OAuth: check that OIDC app is properly configured
|
||||
|
||||
3. **OAuth persistence issues**
|
||||
- Verify PVC is bound: `kubectl get pvc`
|
||||
- Check storage class exists: `kubectl get storageclass`
|
||||
|
||||
4. **Resource constraints**
|
||||
- Increase memory limits if seeing OOM errors
|
||||
- Adjust CPU requests based on load
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Secrets Management**: Consider using external secret management (e.g., Sealed Secrets, External Secrets Operator)
|
||||
2. **TLS**: Always use TLS/HTTPS for production deployments
|
||||
3. **Network Policies**: Restrict network access to necessary services only
|
||||
4. **RBAC**: Review and customize ServiceAccount permissions as needed
|
||||
5. **App Passwords**: For basic auth, use Nextcloud app passwords instead of main account passwords
|
||||
|
||||
## Support
|
||||
|
||||
- GitHub Issues: https://github.com/cbcoutinho/nextcloud-mcp-server/issues
|
||||
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
||||
|
||||
## License
|
||||
|
||||
This chart is licensed under AGPL-3.0, consistent with the Nextcloud MCP Server project.
|
||||
@@ -0,0 +1,80 @@
|
||||
Thank you for installing {{ .Chart.Name }}!
|
||||
|
||||
Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentication mode.
|
||||
|
||||
1. Get the application URL by running these commands:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- range $host := .Values.ingress.hosts }}
|
||||
{{- range .paths }}
|
||||
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- else if contains "NodePort" .Values.service.type }}
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "nextcloud-mcp-server.fullname" . }})
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
echo http://$NODE_IP:$NODE_PORT
|
||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "nextcloud-mcp-server.fullname" . }}'
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "nextcloud-mcp-server.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "nextcloud-mcp-server.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||
echo "Visit http://127.0.0.1:8080 to use your MCP server"
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
||||
{{- end }}
|
||||
|
||||
2. Check the deployment status:
|
||||
kubectl --namespace {{ .Release.Namespace }} get pods -l "app.kubernetes.io/name={{ include "nextcloud-mcp-server.name" . }},app.kubernetes.io/instance={{ .Release.Name }}"
|
||||
|
||||
{{- if eq .Values.auth.mode "basic" }}
|
||||
|
||||
3. Basic Authentication Mode:
|
||||
{{- if .Values.auth.basic.existingSecret }}
|
||||
- Credentials: (using existing secret {{ .Values.auth.basic.existingSecret }})
|
||||
{{- else }}
|
||||
- Username: {{ .Values.auth.basic.username }}
|
||||
- Password: (stored in secret {{ include "nextcloud-mcp-server.basicAuthSecretName" . }})
|
||||
{{- end }}
|
||||
- Connected to: {{ .Values.nextcloud.host }}
|
||||
{{- else if eq .Values.auth.mode "oauth" }}
|
||||
|
||||
3. OAuth Authentication Mode:
|
||||
- Server URL: {{ include "nextcloud-mcp-server.mcpServerUrl" . }}
|
||||
- Issuer URL: {{ include "nextcloud-mcp-server.publicIssuerUrl" . }}
|
||||
- Connected to: {{ .Values.nextcloud.host }}
|
||||
{{- if .Values.auth.oauth.existingSecret }}
|
||||
- Using existing OAuth client secret: {{ .Values.auth.oauth.existingSecret }}
|
||||
{{- else if and .Values.auth.oauth.clientId .Values.auth.oauth.clientSecret }}
|
||||
- Using pre-registered OAuth client
|
||||
{{- else }}
|
||||
- Using Dynamic Client Registration (DCR)
|
||||
{{- end }}
|
||||
{{- if .Values.auth.oauth.persistence.enabled }}
|
||||
- OAuth client credentials are persisted in PVC: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
||||
{{- end }}
|
||||
|
||||
IMPORTANT: OAuth mode is experimental and requires patches to the user_oidc app.
|
||||
See: https://github.com/cbcoutinho/nextcloud-mcp-server#authentication
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.documentProcessing.enabled }}
|
||||
|
||||
4. Document Processing:
|
||||
- Enabled: {{ .Values.documentProcessing.enabled }}
|
||||
- Default processor: {{ .Values.documentProcessing.defaultProcessor }}
|
||||
{{- if .Values.documentProcessing.unstructured.enabled }}
|
||||
- Unstructured API: {{ .Values.documentProcessing.unstructured.apiUrl }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
For more information and documentation:
|
||||
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
||||
|
||||
To upgrade this deployment:
|
||||
helm upgrade {{ .Release.Name }} nextcloud-mcp-server
|
||||
|
||||
To uninstall:
|
||||
helm uninstall {{ .Release.Name }}
|
||||
@@ -0,0 +1,142 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.labels" -}}
|
||||
helm.sh/chart: {{ include "nextcloud-mcp-server.chart" . }}
|
||||
{{ include "nextcloud-mcp-server.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "nextcloud-mcp-server.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "nextcloud-mcp-server.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the secret to use for basic auth
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.basicAuthSecretName" -}}
|
||||
{{- if .Values.auth.basic.existingSecret }}
|
||||
{{- .Values.auth.basic.existingSecret }}
|
||||
{{- else }}
|
||||
{{- include "nextcloud-mcp-server.fullname" . }}-basic-auth
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the secret to use for OAuth
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.oauthSecretName" -}}
|
||||
{{- if .Values.auth.oauth.existingSecret }}
|
||||
{{- .Values.auth.oauth.existingSecret }}
|
||||
{{- else }}
|
||||
{{- include "nextcloud-mcp-server.fullname" . }}-oauth
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the PVC to use for OAuth storage
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.oauthPvcName" -}}
|
||||
{{- if .Values.auth.oauth.persistence.existingClaim }}
|
||||
{{- .Values.auth.oauth.persistence.existingClaim }}
|
||||
{{- else }}
|
||||
{{- include "nextcloud-mcp-server.fullname" . }}-oauth-storage
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Return the MCP server port
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.port" -}}
|
||||
{{- .Values.mcp.port }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Return the image tag (always uses chart appVersion)
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.imageTag" -}}
|
||||
{{- .Chart.AppVersion }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Return the public issuer URL for OAuth
|
||||
Defaults to nextcloud.host if not specified
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.publicIssuerUrl" -}}
|
||||
{{- if .Values.nextcloud.publicIssuerUrl }}
|
||||
{{- .Values.nextcloud.publicIssuerUrl }}
|
||||
{{- else }}
|
||||
{{- .Values.nextcloud.host }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Return the MCP server URL for OAuth callbacks
|
||||
If not specified:
|
||||
- Uses ingress host if ingress is enabled
|
||||
- Otherwise defaults to http://localhost:8000 (for port-forward setups)
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.mcpServerUrl" -}}
|
||||
{{- if .Values.nextcloud.mcpServerUrl }}
|
||||
{{- .Values.nextcloud.mcpServerUrl }}
|
||||
{{- else if .Values.ingress.enabled }}
|
||||
{{- $host := index .Values.ingress.hosts 0 }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
{{- printf "https://%s" $host.host }}
|
||||
{{- else }}
|
||||
{{- printf "http://%s" $host.host }}
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
{{- printf "http://localhost:%d" (int .Values.mcp.port) }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,188 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "nextcloud-mcp-server.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 8 }}
|
||||
{{- with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "nextcloud-mcp-server.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
{{- with .Values.initContainers }}
|
||||
initContainers:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ include "nextcloud-mcp-server.imageTag" . }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
args:
|
||||
- "--transport"
|
||||
- "{{ .Values.mcp.transport }}"
|
||||
{{- if eq .Values.auth.mode "oauth" }}
|
||||
- "--oauth"
|
||||
- "--oauth-token-type"
|
||||
- "{{ .Values.auth.oauth.tokenType }}"
|
||||
{{- end }}
|
||||
{{- with .Values.mcp.extraArgs }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ include "nextcloud-mcp-server.port" . }}
|
||||
protocol: TCP
|
||||
env:
|
||||
# Nextcloud connection
|
||||
- name: NEXTCLOUD_HOST
|
||||
value: {{ .Values.nextcloud.host | quote }}
|
||||
{{- if eq .Values.auth.mode "basic" }}
|
||||
# Basic auth mode
|
||||
- name: NEXTCLOUD_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "nextcloud-mcp-server.basicAuthSecretName" . }}
|
||||
key: {{ .Values.auth.basic.usernameKey }}
|
||||
- name: NEXTCLOUD_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "nextcloud-mcp-server.basicAuthSecretName" . }}
|
||||
key: {{ .Values.auth.basic.passwordKey }}
|
||||
{{- else if eq .Values.auth.mode "oauth" }}
|
||||
# OAuth mode
|
||||
- name: NEXTCLOUD_MCP_SERVER_URL
|
||||
value: {{ include "nextcloud-mcp-server.mcpServerUrl" . | quote }}
|
||||
- name: NEXTCLOUD_PUBLIC_ISSUER_URL
|
||||
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
|
||||
- name: NEXTCLOUD_OIDC_SCOPES
|
||||
value: {{ .Values.auth.oauth.scopes | quote }}
|
||||
{{- if .Values.auth.oauth.clientId }}
|
||||
- name: NEXTCLOUD_OIDC_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "nextcloud-mcp-server.oauthSecretName" . }}
|
||||
key: {{ .Values.auth.oauth.clientIdKey }}
|
||||
- name: NEXTCLOUD_OIDC_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "nextcloud-mcp-server.oauthSecretName" . }}
|
||||
key: {{ .Values.auth.oauth.clientSecretKey }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.documentProcessing.enabled }}
|
||||
# Document processing
|
||||
- name: ENABLE_DOCUMENT_PROCESSING
|
||||
value: {{ .Values.documentProcessing.enabled | quote }}
|
||||
- name: DOCUMENT_PROCESSOR
|
||||
value: {{ .Values.documentProcessing.defaultProcessor | quote }}
|
||||
- name: PROGRESS_INTERVAL
|
||||
value: {{ .Values.documentProcessing.progressInterval | quote }}
|
||||
{{- if .Values.documentProcessing.unstructured.enabled }}
|
||||
- name: ENABLE_UNSTRUCTURED
|
||||
value: "true"
|
||||
- name: UNSTRUCTURED_API_URL
|
||||
value: {{ .Values.documentProcessing.unstructured.apiUrl | quote }}
|
||||
- name: UNSTRUCTURED_TIMEOUT
|
||||
value: {{ .Values.documentProcessing.unstructured.timeout | quote }}
|
||||
- name: UNSTRUCTURED_STRATEGY
|
||||
value: {{ .Values.documentProcessing.unstructured.strategy | quote }}
|
||||
- name: UNSTRUCTURED_LANGUAGES
|
||||
value: {{ .Values.documentProcessing.unstructured.languages | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.documentProcessing.tesseract.enabled }}
|
||||
- name: ENABLE_TESSERACT
|
||||
value: "true"
|
||||
{{- if .Values.documentProcessing.tesseract.cmd }}
|
||||
- name: TESSERACT_CMD
|
||||
value: {{ .Values.documentProcessing.tesseract.cmd | quote }}
|
||||
{{- end }}
|
||||
- name: TESSERACT_LANG
|
||||
value: {{ .Values.documentProcessing.tesseract.lang | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.documentProcessing.custom.enabled }}
|
||||
- name: ENABLE_CUSTOM_PROCESSOR
|
||||
value: "true"
|
||||
- name: CUSTOM_PROCESSOR_NAME
|
||||
value: {{ .Values.documentProcessing.custom.name | quote }}
|
||||
- name: CUSTOM_PROCESSOR_URL
|
||||
value: {{ .Values.documentProcessing.custom.url | quote }}
|
||||
{{- if .Values.documentProcessing.custom.apiKey }}
|
||||
- name: CUSTOM_PROCESSOR_API_KEY
|
||||
value: {{ .Values.documentProcessing.custom.apiKey | quote }}
|
||||
{{- end }}
|
||||
- name: CUSTOM_PROCESSOR_TIMEOUT
|
||||
value: {{ .Values.documentProcessing.custom.timeout | quote }}
|
||||
- name: CUSTOM_PROCESSOR_TYPES
|
||||
value: {{ .Values.documentProcessing.custom.types | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .Values.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.extraEnvFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- toYaml .Values.readinessProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled }}
|
||||
- name: oauth-storage
|
||||
mountPath: /app/.oauth
|
||||
{{- end }}
|
||||
{{- with .Values.volumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled }}
|
||||
- name: oauth-storage
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
||||
{{- end }}
|
||||
{{- with .Values.volumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{- if .Values.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}
|
||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,61 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
{{- $fullName := include "nextcloud-mcp-server.fullname" . -}}
|
||||
{{- $svcPort := .Values.service.port -}}
|
||||
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
|
||||
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
|
||||
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1beta1
|
||||
{{- else -}}
|
||||
apiVersion: extensions/v1beta1
|
||||
{{- end }}
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ $fullName }}
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
|
||||
pathType: {{ .pathType }}
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ $fullName }}
|
||||
port:
|
||||
number: {{ $svcPort }}
|
||||
{{- else }}
|
||||
serviceName: {{ $fullName }}
|
||||
servicePort: {{ $svcPort }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,17 @@
|
||||
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled (not .Values.auth.oauth.persistence.existingClaim) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-oauth-storage
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.auth.oauth.persistence.accessMode }}
|
||||
{{- if .Values.auth.oauth.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.auth.oauth.persistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.auth.oauth.persistence.size }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,29 @@
|
||||
{{- if eq .Values.auth.mode "basic" }}
|
||||
{{- if not .Values.auth.basic.existingSecret }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-basic-auth
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{ .Values.auth.basic.usernameKey }}: {{ .Values.auth.basic.username | b64enc | quote }}
|
||||
{{ .Values.auth.basic.passwordKey }}: {{ .Values.auth.basic.password | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if eq .Values.auth.mode "oauth" }}
|
||||
{{- if and .Values.auth.oauth.clientId (not .Values.auth.oauth.existingSecret) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-oauth
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{ .Values.auth.oauth.clientIdKey }}: {{ .Values.auth.oauth.clientId | b64enc | quote }}
|
||||
{{ .Values.auth.oauth.clientSecretKey }}: {{ .Values.auth.oauth.clientSecret | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,19 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
{{- with .Values.service.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "nextcloud-mcp-server.selectorLabels" . | nindent 4 }}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,266 @@
|
||||
# Default values for nextcloud-mcp-server
|
||||
# This is a YAML-formatted file.
|
||||
# Declare variables to be passed into your templates.
|
||||
|
||||
# Number of replicas
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/cbcoutinho/nextcloud-mcp-server
|
||||
pullPolicy: IfNotPresent
|
||||
# Image tag is automatically set to chart appVersion
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
# Nextcloud connection settings
|
||||
nextcloud:
|
||||
# URL of your Nextcloud instance (required)
|
||||
# Example: https://cloud.example.com
|
||||
host: ""
|
||||
|
||||
# MCP server URL for OAuth callbacks (OAuth mode only)
|
||||
# If not specified, will be constructed from ingress.hosts[0] if ingress is enabled,
|
||||
# or defaults to http://localhost:8000 (suitable for port-forward setups)
|
||||
# Example: https://mcp.example.com
|
||||
mcpServerUrl: ""
|
||||
|
||||
# Public issuer URL for OAuth (OAuth mode only)
|
||||
# If not specified, defaults to nextcloud.host
|
||||
# Only set this if your Nextcloud is accessible at a different URL for OAuth
|
||||
# Example: https://cloud.example.com
|
||||
publicIssuerUrl: ""
|
||||
|
||||
# Authentication configuration
|
||||
# Choose either basic auth OR oauth (not both)
|
||||
auth:
|
||||
# Authentication mode: "basic" or "oauth"
|
||||
# basic: Uses username/password (recommended for most users)
|
||||
# oauth: Uses OAuth2/OIDC (experimental, requires patches)
|
||||
mode: basic
|
||||
|
||||
# Basic authentication settings
|
||||
basic:
|
||||
# Nextcloud username (ignored if existingSecret is set)
|
||||
username: ""
|
||||
# Nextcloud password or app password (recommended) (ignored if existingSecret is set)
|
||||
password: ""
|
||||
# Use existing secret instead of creating one
|
||||
# If set, username and password above are ignored
|
||||
# Secret must contain keys specified in usernameKey and passwordKey
|
||||
# Example:
|
||||
# kubectl create secret generic my-nextcloud-creds \
|
||||
# --from-literal=username=myuser \
|
||||
# --from-literal=password=mypassword
|
||||
existingSecret: ""
|
||||
# Keys in the existing secret
|
||||
usernameKey: "username"
|
||||
passwordKey: "password"
|
||||
|
||||
# OAuth2/OIDC settings (experimental)
|
||||
oauth:
|
||||
# OAuth token type: "jwt" or "opaque"
|
||||
tokenType: "jwt"
|
||||
# Pre-registered OAuth client ID (optional, ignored if existingSecret is set)
|
||||
# If not provided and no existingSecret, will use Dynamic Client Registration (DCR)
|
||||
clientId: ""
|
||||
# Pre-registered OAuth client secret (optional, ignored if existingSecret is set)
|
||||
clientSecret: ""
|
||||
# OAuth scopes to request (space-separated)
|
||||
scopes: "openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write"
|
||||
# Use existing secret for OAuth client credentials
|
||||
# If set, clientId and clientSecret above are ignored
|
||||
# Secret must contain keys specified in clientIdKey and clientSecretKey
|
||||
# Example:
|
||||
# kubectl create secret generic my-oauth-creds \
|
||||
# --from-literal=clientId=my-client-id \
|
||||
# --from-literal=clientSecret=my-client-secret
|
||||
existingSecret: ""
|
||||
# Keys in the existing secret
|
||||
clientIdKey: "clientId"
|
||||
clientSecretKey: "clientSecret"
|
||||
# Persistent storage for OAuth client credentials
|
||||
persistence:
|
||||
enabled: true
|
||||
# Storage class (leave empty for default)
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
size: 100Mi
|
||||
# Use existing PVC
|
||||
existingClaim: ""
|
||||
|
||||
# MCP server configuration
|
||||
mcp:
|
||||
# Transport mode (default: streamable-http for SSE)
|
||||
transport: "streamable-http"
|
||||
# Port for MCP server (both basic auth and OAuth modes)
|
||||
port: 8000
|
||||
# Additional command-line arguments to pass to nextcloud-mcp-server
|
||||
# Example: ["--log-level", "debug", "--enable-app", "notes"]
|
||||
extraArgs: []
|
||||
|
||||
# Document processing configuration (optional)
|
||||
documentProcessing:
|
||||
# Enable document processing (PDF, DOCX, images, etc.)
|
||||
enabled: false
|
||||
# Default processor: unstructured, tesseract, or custom
|
||||
defaultProcessor: "unstructured"
|
||||
# Progress reporting interval in seconds
|
||||
progressInterval: 10
|
||||
|
||||
# Unstructured.io processor
|
||||
unstructured:
|
||||
enabled: false
|
||||
# Unstructured API endpoint
|
||||
apiUrl: "http://unstructured:8000"
|
||||
# Request timeout in seconds
|
||||
timeout: 120
|
||||
# Parsing strategy: auto, fast, or hi_res
|
||||
strategy: "auto"
|
||||
# OCR languages (comma-separated ISO 639-3 codes)
|
||||
languages: "eng,deu"
|
||||
|
||||
# Tesseract processor (local OCR)
|
||||
tesseract:
|
||||
enabled: false
|
||||
# Path to tesseract executable (optional, auto-detected if in PATH)
|
||||
cmd: ""
|
||||
# OCR language (e.g., eng, deu, eng+deu for multiple)
|
||||
lang: "eng"
|
||||
|
||||
# Custom processor
|
||||
custom:
|
||||
enabled: false
|
||||
# Unique name for your processor
|
||||
name: "my_ocr"
|
||||
# Custom processor API endpoint
|
||||
url: ""
|
||||
# Optional API key for authentication
|
||||
apiKey: ""
|
||||
# Request timeout in seconds
|
||||
timeout: 60
|
||||
# Comma-separated MIME types your processor supports
|
||||
types: "application/pdf,image/jpeg,image/png"
|
||||
|
||||
serviceAccount:
|
||||
# Specifies whether a service account should be created
|
||||
create: true
|
||||
# Automatically mount a ServiceAccount's API credentials?
|
||||
automount: true
|
||||
# Annotations to add to the service account
|
||||
annotations: {}
|
||||
# The name of the service account to use.
|
||||
# If not set and create is true, a name is generated using the fullname template
|
||||
name: ""
|
||||
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
|
||||
podSecurityContext:
|
||||
fsGroup: 2000
|
||||
|
||||
securityContext:
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8000
|
||||
annotations: {}
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# kubernetes.io/tls-acme: "true"
|
||||
# cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
hosts:
|
||||
- host: mcp.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls: []
|
||||
# - secretName: nextcloud-mcp-tls
|
||||
# hosts:
|
||||
# - mcp.example.com
|
||||
|
||||
resources:
|
||||
# We recommend setting resource requests and limits
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 512Mi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
|
||||
# Liveness probe configuration
|
||||
# Checks if the application process is running
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: http
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# Readiness probe configuration
|
||||
# Checks if the application is ready to serve traffic
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: http
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
# Autoscaling configuration
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 10
|
||||
targetCPUUtilizationPercentage: 80
|
||||
# targetMemoryUtilizationPercentage: 80
|
||||
|
||||
# Additional volumes on the output Deployment definition.
|
||||
volumes: []
|
||||
# - name: foo
|
||||
# secret:
|
||||
# secretName: mysecret
|
||||
# optional: false
|
||||
|
||||
# Additional volumeMounts on the output Deployment definition.
|
||||
volumeMounts: []
|
||||
# - name: foo
|
||||
# mountPath: "/etc/foo"
|
||||
# readOnly: true
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
# Init containers
|
||||
initContainers: []
|
||||
|
||||
# Additional environment variables
|
||||
extraEnv: []
|
||||
# - name: CUSTOM_VAR
|
||||
# value: "custom_value"
|
||||
|
||||
# Additional environment variables from ConfigMaps or Secrets
|
||||
extraEnvFrom: []
|
||||
# - configMapRef:
|
||||
# name: my-configmap
|
||||
# - secretRef:
|
||||
# name: my-secret
|
||||
+86
-19
@@ -21,7 +21,7 @@ services:
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.0@sha256:4fbd72f05b5e6b82e078542b6cb2ecf021d2f8b5045454ffa7f4e080e488b375
|
||||
image: docker.io/library/nextcloud:32.0.1@sha256:1e4eae55eebe094cae6f9e7b6e0b4bccf4a4fe7b7e6f6f8f57010994b3b2ee42
|
||||
restart: always
|
||||
ports:
|
||||
- 0.0.0.0:8080:80
|
||||
@@ -30,10 +30,10 @@ services:
|
||||
- db
|
||||
volumes:
|
||||
- nextcloud:/var/www/html
|
||||
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro
|
||||
- ./app-hooks:/docker-entrypoint-hooks.d:ro
|
||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
||||
# The post-installation hook will register /opt/apps as an additional app directory
|
||||
- ./third_party/oidc:/opt/apps/oidc:ro
|
||||
- ./third_party:/opt/apps:ro
|
||||
environment:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
- NEXTCLOUD_ADMIN_USER=admin
|
||||
@@ -43,14 +43,29 @@ services:
|
||||
- MYSQL_USER=nextcloud
|
||||
- MYSQL_HOST=db
|
||||
- REDIS_HOST=redis
|
||||
#healthcheck:
|
||||
#test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"]
|
||||
#interval: 10s
|
||||
#timeout: 30s
|
||||
#retries: 30
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine@sha256:61e01287e546aac28a3f56839c136b31f590273f3b41187a36f46f6a03bbfe22
|
||||
image: docker.io/library/nginx:alpine@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14
|
||||
restart: always
|
||||
volumes:
|
||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
unstructured:
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:a43ab55898599157fb0e0e097dabb8ecdd1d8e3df1ae5b67c6e15a136b171a6c
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8002:8000
|
||||
# Unstructured API runs on port 8000 internally
|
||||
# We expose it on 8002 externally to avoid conflict
|
||||
profiles:
|
||||
- unstructured
|
||||
|
||||
mcp:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http"]
|
||||
@@ -66,45 +81,97 @@ services:
|
||||
|
||||
mcp-oauth:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001"]
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||
restart: always
|
||||
depends_on:
|
||||
- app
|
||||
ports:
|
||||
- 127.0.0.1:8001:8001
|
||||
environment:
|
||||
# Generic OIDC configuration (integrated mode - Nextcloud OIDC app)
|
||||
# OIDC_DISCOVERY_URL not set - defaults to NEXTCLOUD_HOST/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID not set - uses Dynamic Client Registration (DCR)
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth/nextcloud_oauth_client.json
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
||||
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration
|
||||
# Client credentials will be registered and stored in volume on first startup
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
|
||||
|
||||
# Refresh token storage (ADR-002 Tier 1)
|
||||
- ENABLE_OFFLINE_ACCESS=true
|
||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
|
||||
# NO admin credentials - using OAuth with Dynamic Client Registration (DCR)
|
||||
# Client credentials registered via RFC 7591 and stored in volume
|
||||
# JWT token type is used for testing (faster validation, scopes embedded in token)
|
||||
volumes:
|
||||
- oauth-client-storage:/app/.oauth
|
||||
- oauth-tokens:/app/data
|
||||
|
||||
mcp-oauth-jwt:
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.4.2
|
||||
command:
|
||||
- "start-dev"
|
||||
- "--import-realm"
|
||||
- "--hostname=http://localhost:8888"
|
||||
- "--hostname-strict=false"
|
||||
- "--hostname-backchannel-dynamic=true"
|
||||
- "--features=preview" # Enable Legacy V1 token exchange (supports both Standard V2 and Legacy V1)
|
||||
ports:
|
||||
- 127.0.0.1:8888:8080
|
||||
environment:
|
||||
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
|
||||
volumes:
|
||||
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm.json:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/nextcloud-mcp HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q 'HTTP/1.1 200'"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
|
||||
mcp-keycloak:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
|
||||
restart: always
|
||||
depends_on:
|
||||
- app
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
app:
|
||||
condition: service_started
|
||||
ports:
|
||||
- 127.0.0.1:8002:8002
|
||||
environment:
|
||||
# Generic OIDC configuration (external IdP mode - Keycloak)
|
||||
# Provider auto-detected from OIDC_DISCOVERY_URL issuer
|
||||
# Using internal Docker hostname for discovery to get consistent issuer
|
||||
- OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||
- OIDC_CLIENT_ID=nextcloud-mcp-server
|
||||
- OIDC_CLIENT_SECRET=mcp-secret-change-in-production
|
||||
- OIDC_JWKS_URI=http://keycloak:8080/realms/nextcloud-mcp/protocol/openid-connect/certs
|
||||
|
||||
# Nextcloud API endpoint (for accessing APIs with validated token)
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth-jwt/nextcloud_oauth_client.json
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
||||
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
||||
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration (DCR)
|
||||
# Client will be registered with token_type=JWT on first startup
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
|
||||
|
||||
# Refresh token storage (ADR-002 Tier 1 & 2)
|
||||
- ENABLE_OFFLINE_ACCESS=true
|
||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
|
||||
# OAuth scopes (optional - uses defaults if not specified)
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
|
||||
|
||||
# NO admin credentials - using external IdP OAuth only!
|
||||
volumes:
|
||||
- oauth-jwt-client-storage:/app/.oauth-jwt
|
||||
- keycloak-tokens:/app/data
|
||||
- keycloak-oauth-storage:/app/.oauth
|
||||
|
||||
volumes:
|
||||
nextcloud:
|
||||
db:
|
||||
oauth-client-storage:
|
||||
oauth-jwt-client-storage:
|
||||
oauth-tokens:
|
||||
keycloak-tokens:
|
||||
keycloak-oauth-storage:
|
||||
|
||||
@@ -0,0 +1,959 @@
|
||||
# ADR-002: Vector Database Background Sync Authentication
|
||||
|
||||
## Status
|
||||
Accepted - Tier 2 (Token Exchange with Delegation) Implemented
|
||||
|
||||
**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](https://github.com/keycloak/keycloak/issues/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
|
||||
|
||||
```python
|
||||
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):
|
||||
```yaml
|
||||
command:
|
||||
- "start-dev"
|
||||
- "--features=preview" # Required for Legacy V1 token exchange
|
||||
```
|
||||
|
||||
2. **Grant impersonation role to service account** (using Keycloak CLI):
|
||||
```bash
|
||||
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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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](https://github.com/keycloak/keycloak/issues/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
|
||||
|
||||
```python
|
||||
# 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)
|
||||
|
||||
```python
|
||||
@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
|
||||
```python
|
||||
# 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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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
|
||||
```bash
|
||||
# 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`):
|
||||
```json
|
||||
{
|
||||
"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
|
||||
```yaml
|
||||
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**
|
||||
```bash
|
||||
# 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)**
|
||||
```bash
|
||||
# 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
|
||||
|
||||
- [RFC 8693: OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693)
|
||||
- [RFC 6749: OAuth 2.0 - Refresh Tokens](https://datatracker.ietf.org/doc/html/rfc6749#section-1.5)
|
||||
- [OpenID Connect Core - Offline Access](https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess)
|
||||
- [OWASP: OAuth Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/OAuth2_Cheat_Sheet.html)
|
||||
- [RFC 8707: Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,521 @@
|
||||
# Audience Validation Setup
|
||||
|
||||
## Overview
|
||||
|
||||
This document explains the **separate clients architecture** for Keycloak → MCP Server → Nextcloud integration, following OAuth 2.0 best practices and RFC 8707 (Resource Indicators).
|
||||
|
||||
## Architecture: Separate Clients Pattern
|
||||
|
||||
```
|
||||
Keycloak Realm: nextcloud-mcp
|
||||
├── Client: "nextcloud" (Resource Server)
|
||||
│ └── Represents Nextcloud as a protected resource
|
||||
│ └── Used by user_oidc for bearer token validation
|
||||
│ └── Validates tokens with aud="nextcloud"
|
||||
│
|
||||
└── Client: "nextcloud-mcp-server" (OAuth Client)
|
||||
└── MCP Server uses this to REQUEST tokens
|
||||
└── Issues tokens with aud="nextcloud" (targeting resource)
|
||||
└── Future: aud=["nextcloud", "other-service"]
|
||||
|
||||
Token Flow:
|
||||
MCP Server (client: nextcloud-mcp-server)
|
||||
↓ requests token from Keycloak
|
||||
Token issued:
|
||||
- aud: "nextcloud" (intended for Nextcloud resource)
|
||||
- azp: "nextcloud-mcp-server" (requested by MCP Server)
|
||||
- preferred_username: "admin" (on behalf of user)
|
||||
↓ sent to Nextcloud API
|
||||
Nextcloud user_oidc (client: nextcloud)
|
||||
✓ validates aud matches configured client_id
|
||||
```
|
||||
|
||||
**Key Benefits**:
|
||||
- ✅ **Proper OAuth separation**: OAuth client ≠ resource server
|
||||
- ✅ **Future extensibility**: MCP Server can request multi-resource tokens
|
||||
- ✅ **RFC 8707 compliance**: Audience indicates intended resource
|
||||
- ✅ **Clear requester identification**: azp claim identifies MCP Server
|
||||
|
||||
## Token Claims
|
||||
|
||||
Tokens issued by the `nextcloud-mcp-server` client contain:
|
||||
|
||||
- **`aud: "nextcloud"`** - Audience: Token intended for Nextcloud resource server (matches user_oidc client_id)
|
||||
- **`azp: "nextcloud-mcp-server"`** - Authorized Party: Identifies MCP Server as the OAuth client that requested the token
|
||||
- **`preferred_username: "admin"`** - User identifier (Keycloak uses this for password grant; `sub` for authorization_code grant)
|
||||
- **`scope: "openid profile email offline_access"`** - Requested scopes including offline access for background jobs
|
||||
|
||||
**How user_oidc Validates**:
|
||||
1. SelfEncodedValidator checks: `aud == user_oidc.client_id`?
|
||||
- ✓ "nextcloud" == "nextcloud" → PASS
|
||||
2. Fast JWT verification with JWKS (no HTTP call to userinfo endpoint)
|
||||
3. User provisioned based on `preferred_username` or `sub` claim
|
||||
|
||||
**For Background Jobs**:
|
||||
- MCP Server stores encrypted refresh tokens
|
||||
- Refreshes access tokens when needed
|
||||
- All tokens have `aud: "nextcloud"` → validated by user_oidc
|
||||
- No admin credentials required
|
||||
|
||||
## Configuration
|
||||
|
||||
The configuration requires **two separate clients** in Keycloak:
|
||||
|
||||
1. **`nextcloud`** - Resource server client (for user_oidc validation)
|
||||
2. **`nextcloud-mcp-server`** - OAuth client (for MCP Server to request tokens)
|
||||
|
||||
### 1. Keycloak - Create Resource Server Client
|
||||
|
||||
First, create the `nextcloud` client that represents Nextcloud as a resource server:
|
||||
|
||||
**Via Keycloak Admin API:**
|
||||
|
||||
```bash
|
||||
# Get admin token
|
||||
ADMIN_TOKEN=$(curl -X POST "http://localhost:8888/realms/master/protocol/openid-connect/token" \
|
||||
-d "grant_type=password" \
|
||||
-d "client_id=admin-cli" \
|
||||
-d "username=admin" \
|
||||
-d "password=admin" | jq -r '.access_token')
|
||||
|
||||
# Create 'nextcloud' resource server client
|
||||
curl -X POST "http://localhost:8888/admin/realms/nextcloud-mcp/clients" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"clientId": "nextcloud",
|
||||
"name": "Nextcloud Resource Server",
|
||||
"description": "Resource server for Nextcloud APIs - used by user_oidc for bearer token validation",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "nextcloud-secret-change-in-production",
|
||||
"bearerOnly": true,
|
||||
"standardFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"publicClient": false
|
||||
}'
|
||||
```
|
||||
|
||||
**Via Realm Export** (`keycloak/realm-export.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "nextcloud",
|
||||
"name": "Nextcloud Resource Server",
|
||||
"enabled": true,
|
||||
"bearerOnly": true,
|
||||
"secret": "nextcloud-secret-change-in-production"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Keycloak - Create OAuth Client with Audience Mapper
|
||||
|
||||
Next, create the `nextcloud-mcp-server` client that MCP Server uses to request tokens:
|
||||
|
||||
**Via Keycloak Admin API:**
|
||||
|
||||
```bash
|
||||
# Create 'nextcloud-mcp-server' OAuth client
|
||||
curl -X POST "http://localhost:8888/admin/realms/nextcloud-mcp/clients" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"clientId": "nextcloud-mcp-server",
|
||||
"name": "Nextcloud MCP Server",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "mcp-secret-change-in-production",
|
||||
"standardFlowEnabled": true,
|
||||
"directAccessGrantsEnabled": true,
|
||||
"redirectUris": ["http://localhost:*/callback"]
|
||||
}'
|
||||
|
||||
# Get client internal ID
|
||||
CLIENT_ID=$(curl "http://localhost:8888/admin/realms/nextcloud-mcp/clients" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[] | select(.clientId=="nextcloud-mcp-server") | .id')
|
||||
|
||||
# Add audience mapper targeting 'nextcloud' resource
|
||||
curl -X POST "http://localhost:8888/admin/realms/nextcloud-mcp/clients/$CLIENT_ID/protocol-mappers/models" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "audience-nextcloud",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.custom.audience": "nextcloud",
|
||||
"access.token.claim": "true",
|
||||
"id.token.claim": "false"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Option B: Via Realm Export** (for infrastructure-as-code)
|
||||
|
||||
Update `keycloak/realm-export.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "nextcloud-mcp-server",
|
||||
"name": "Nextcloud MCP Server",
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "audience-nextcloud-mcp-server",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.custom.audience": "nextcloud-mcp-server",
|
||||
"access.token.claim": "true",
|
||||
"id.token.claim": "false"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Then re-import realm or restart Keycloak.
|
||||
|
||||
**Option C: Via Keycloak Admin UI**
|
||||
|
||||
1. Go to Keycloak Admin Console → Realm → Clients → `nextcloud-mcp-server`
|
||||
2. Click "Client scopes" tab
|
||||
3. Click "Add client scope" → "Create dedicated scope"
|
||||
4. Add protocol mapper: "Audience"
|
||||
- Mapper Type: `Audience`
|
||||
- Included Custom Audience: `nextcloud`
|
||||
- Add to access token: ON
|
||||
- Add to ID token: OFF
|
||||
|
||||
### 3. Nextcloud user_oidc - Configure Resource Server Client
|
||||
|
||||
Configure user_oidc to use the `nextcloud` resource server client:
|
||||
|
||||
```bash
|
||||
docker compose exec app php occ user_oidc:provider keycloak \
|
||||
--clientid="nextcloud" \
|
||||
--clientsecret="nextcloud-secret-change-in-production" \
|
||||
--discoveryuri="http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration" \
|
||||
--check-bearer=1 \
|
||||
--bearer-provisioning=1 \
|
||||
--unique-uid=1 \
|
||||
--mapping-uid="sub" \
|
||||
--mapping-display-name="name" \
|
||||
--mapping-email="email"
|
||||
```
|
||||
|
||||
**Result**: user_oidc validates tokens with `aud="nextcloud"` using SelfEncodedValidator (fast JWT verification).
|
||||
|
||||
### 3. Nextcloud user_oidc - Realm-Level Validation
|
||||
|
||||
Nextcloud's `user_oidc` app validates at **realm level** via userinfo endpoint:
|
||||
|
||||
- ✅ **No configuration needed** - works automatically
|
||||
- ✅ Validates any token from Keycloak realm
|
||||
- ✅ Audience check is **optional** (disabled by default)
|
||||
|
||||
**Optional: Disable strict audience checking** (if enabled):
|
||||
|
||||
```bash
|
||||
docker compose exec app php occ config:app:set user_oidc \
|
||||
selfencoded_bearer_validation_audience_check --value=false --type=boolean
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
### 1. Check Token Claims
|
||||
|
||||
```bash
|
||||
# Get token from Keycloak
|
||||
TOKEN=$(curl -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
|
||||
-d "grant_type=password" \
|
||||
-d "client_id=nextcloud-mcp-server" \
|
||||
-d "client_secret=mcp-secret-change-in-production" \
|
||||
-d "username=admin" \
|
||||
-d "password=admin" | jq -r '.access_token')
|
||||
|
||||
# Decode JWT
|
||||
echo $TOKEN | cut -d'.' -f2 | base64 -d | jq '.'
|
||||
|
||||
# Should show:
|
||||
{
|
||||
"aud": "nextcloud", # ✓ Intended for Nextcloud
|
||||
"azp": "nextcloud-mcp-server", # ✓ Requested by MCP Server
|
||||
"iss": "http://localhost:8888/realms/nextcloud-mcp",
|
||||
"scope": "openid email profile offline_access",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Test with Nextcloud API
|
||||
|
||||
```bash
|
||||
# Token should be accepted
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"http://localhost:8080/ocs/v2.php/cloud/capabilities"
|
||||
|
||||
# Should return HTTP 200 OK
|
||||
```
|
||||
|
||||
### 3. Test Audience Rejection
|
||||
|
||||
```bash
|
||||
# Get token from different client (without audience mappers)
|
||||
TOKEN_WRONG=$(curl -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
|
||||
-d "grant_type=password" \
|
||||
-d "client_id=test-client-b" \
|
||||
-d "client_secret=test-secret-b" \
|
||||
-d "username=admin" \
|
||||
-d "password=admin" | jq -r '.access_token')
|
||||
|
||||
# This token has NO audience claim - should be rejected by MCP server
|
||||
# (But accepted by Nextcloud user_oidc which validates at realm level)
|
||||
```
|
||||
|
||||
## Token Flow Example
|
||||
|
||||
### Successful Request (Background Job)
|
||||
|
||||
```
|
||||
1. User authorizes MCP Client via OAuth
|
||||
└─ MCP Server gets refresh token (stored encrypted)
|
||||
|
||||
2. Background worker needs to sync data
|
||||
└─ MCP Server refreshes access token from Keycloak
|
||||
└─ Token issued with aud: "nextcloud", azp: "nextcloud-mcp-server"
|
||||
|
||||
3. MCP Server → Nextcloud API (with token)
|
||||
└─ user_oidc validates via userinfo endpoint ✓
|
||||
└─ Nextcloud identifies:
|
||||
- Token intended for Nextcloud (aud: "nextcloud")
|
||||
- Request from MCP Server (azp: "nextcloud-mcp-server")
|
||||
- On behalf of user (sub: "user-id")
|
||||
|
||||
4. Success! MCP Server can act on behalf of user in background.
|
||||
```
|
||||
|
||||
### Rejected Request
|
||||
|
||||
```
|
||||
1. Attacker gets token for different client
|
||||
└─ Token has aud: "other-service"
|
||||
|
||||
2. Attacker → Nextcloud API (with wrong token)
|
||||
└─ user_oidc validates via userinfo endpoint
|
||||
└─ Token validation fails (invalid/expired/wrong realm)
|
||||
└─ HTTP 401 Unauthorized
|
||||
|
||||
3. Request blocked - token not valid for this realm/service
|
||||
```
|
||||
|
||||
## OAuth Flows and User Consent
|
||||
|
||||
### When Does the User Grant Consent?
|
||||
|
||||
User consent happens during the **Authorization Code Flow** (production OAuth):
|
||||
|
||||
```
|
||||
1. User clicks "Connect" in MCP Client (e.g., Claude Desktop)
|
||||
2. MCP Client initiates OAuth flow by opening browser to Keycloak:
|
||||
https://keycloak/realms/nextcloud-mcp/protocol/openid-connect/auth?
|
||||
client_id=nextcloud-mcp-server&
|
||||
redirect_uri=<mcp-client-redirect-uri>&
|
||||
response_type=code&
|
||||
scope=openid profile email offline_access
|
||||
|
||||
3. Keycloak shows login screen (if not logged in)
|
||||
4. **Keycloak shows consent screen:**
|
||||
"Nextcloud MCP Server wants to access your Nextcloud data on your behalf"
|
||||
Requested permissions:
|
||||
- Access your profile (openid, profile, email)
|
||||
- Offline access (background operations with refresh tokens)
|
||||
|
||||
5. User clicks "Allow" → grants consent
|
||||
6. Keycloak redirects back to MCP Client with authorization code
|
||||
7. MCP Client exchanges code for tokens (receives access + refresh tokens)
|
||||
8. MCP Client shares tokens with MCP Server via MCP protocol
|
||||
9. MCP Server stores refresh token encrypted for background operations
|
||||
```
|
||||
|
||||
**Key Architecture Notes:**
|
||||
- **MCP Server is a protected resource** (requires OAuth to access)
|
||||
- **MCP Client** (Claude Desktop) is the OAuth client that initiates the flow
|
||||
- **MCP Client handles the redirect** and token exchange with Keycloak
|
||||
- **MCP Client shares refresh token** with MCP Server so it can act on behalf of user in background
|
||||
|
||||
**Key Points:**
|
||||
- ✅ **Explicit user consent** before any access
|
||||
- ✅ **Scopes displayed** so user knows what's being requested
|
||||
- ✅ **Offline access** must be explicitly granted (for background jobs)
|
||||
- ✅ **Revocable** - user can revoke consent in Keycloak at any time
|
||||
|
||||
### Grant Types
|
||||
|
||||
Our architecture supports multiple OAuth grant types:
|
||||
|
||||
**1. Authorization Code + PKCE (Production)**
|
||||
```
|
||||
Use case: Interactive login from MCP clients
|
||||
Consent: Yes - explicit user authorization
|
||||
Tokens: Access token + Refresh token (if offline_access granted)
|
||||
Security: PKCE prevents authorization code interception
|
||||
```
|
||||
|
||||
**2. Password Grant (Testing Only)**
|
||||
```
|
||||
Use case: Integration testing with docker-compose
|
||||
Consent: No - username/password provided directly
|
||||
Tokens: Access token + Refresh token
|
||||
Security: NOT for production - exposes user credentials
|
||||
```
|
||||
|
||||
**3. Refresh Token Grant (Background Jobs)**
|
||||
```
|
||||
Use case: MCP Server refreshing expired access tokens
|
||||
Consent: No new consent - uses previously granted refresh token
|
||||
Tokens: New access token (refresh token may rotate)
|
||||
Security: Refresh tokens stored encrypted, rotated on use
|
||||
```
|
||||
|
||||
## Authentication Strategies for Background Jobs
|
||||
|
||||
> **Note on Service Account Tokens**: Service account tokens (`client_credentials` grant) were evaluated but **rejected** as they create Nextcloud user accounts (e.g., `service-account-{client_id}`) which violates OAuth "act on-behalf-of" principles. See ADR-002 "Will Not Implement" section for details.
|
||||
|
||||
### Current Approach: Offline Access with Refresh Tokens
|
||||
|
||||
The MCP server uses **offline_access** scope to enable background operations:
|
||||
|
||||
**How it works:**
|
||||
1. User grants `offline_access` scope during OAuth consent
|
||||
2. MCP Client receives refresh token from Keycloak
|
||||
3. MCP Client shares refresh token with MCP Server via MCP protocol
|
||||
4. MCP Server stores refresh token encrypted (see ADR-002)
|
||||
5. Background jobs exchange refresh token for fresh access tokens as needed
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Works today with Keycloak and all OIDC providers
|
||||
- ✅ Standard OAuth pattern (RFC 6749)
|
||||
- ✅ Explicit user consent to `offline_access` scope
|
||||
- ✅ MCP Server can act on behalf of user in background
|
||||
|
||||
**Limitations:**
|
||||
- ⚠️ Requires secure token storage on MCP Server
|
||||
- ⚠️ MCP Client must trust MCP Server with refresh token
|
||||
- ⚠️ Weak audit trail - API requests appear to come from user directly
|
||||
- ⚠️ No visibility that MCP Server is the actual actor
|
||||
|
||||
### Token Exchange with Delegation (ADR-002 Tier 2 - Implemented)
|
||||
|
||||
**RFC 8693 Delegation** would provide better audit trail and security:
|
||||
|
||||
**How it would work:**
|
||||
1. User grants `may_act:nextcloud-mcp-server` scope during authentication
|
||||
2. Subject token includes: `{ "may_act": { "client": "nextcloud-mcp-server" } }`
|
||||
3. MCP Server has its own service account token (actor_token)
|
||||
4. Background job requests token exchange:
|
||||
- `subject_token` (user's token with may_act claim)
|
||||
- `actor_token` (mcp-server's service token)
|
||||
5. Keycloak validates actor matches may_act claim
|
||||
6. Returns delegated token: `{ "sub": "user", "act": "nextcloud-mcp-server" }`
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Better audit trail - Nextcloud APIs see both user and actor
|
||||
- ✅ No token storage needed (tokens generated on-demand)
|
||||
- ✅ Fine-grained permissions via `may_act` claim
|
||||
- ✅ User explicitly consents to MCP Server acting on their behalf
|
||||
- ✅ RFC 8693 compliant
|
||||
|
||||
**Current Status:**
|
||||
- ❌ **NOT implemented in Keycloak yet** ([Issue #38279](https://github.com/keycloak/keycloak/issues/38279))
|
||||
- ❌ Would require custom implementation or waiting for upstream
|
||||
- 📝 Proposal includes `act` claim and `may_act` consent mechanism
|
||||
|
||||
**Why Not Available:**
|
||||
- Keycloak supports **impersonation** (changes `sub` claim), but not **delegation** (`act` claim)
|
||||
- Impersonation has poor audit trail (actor invisible)
|
||||
- Delegation proposal is open but not implemented yet
|
||||
|
||||
**Reference:** See `docs/ADR-002-vector-sync-authentication.md` for detailed comparison of authentication tiers.
|
||||
|
||||
## Security Benefits
|
||||
|
||||
1. **Intent Validation**: Tokens explicitly declare Nextcloud as the intended recipient via `aud` claim
|
||||
2. **Requester Identification**: The `azp` claim identifies MCP Server as the requester
|
||||
3. **User Context**: The `sub` claim preserves user identity for audit and authorization
|
||||
4. **Background Jobs**: Refresh tokens enable MCP Server to act on behalf of users without admin credentials
|
||||
5. **OAuth Standards**: Follows RFC 8707 (Resource Indicators) and RFC 6749 (OAuth 2.0)
|
||||
|
||||
**Current Limitations:**
|
||||
- API requests from background jobs appear to come from user directly (no `act` claim yet)
|
||||
- See "Authentication Strategies for Background Jobs" section for future delegation support
|
||||
|
||||
## Token Claims
|
||||
|
||||
### Key Claims
|
||||
|
||||
- **`aud: "nextcloud"`** - Audience: Token intended for Nextcloud APIs
|
||||
- **`azp: "nextcloud-mcp-server"`** - Authorized Party: MCP Server requested the token
|
||||
- **`sub: "user-id"`** - Subject: User on whose behalf the request is made
|
||||
- **`scope: "openid profile email offline_access"`** - Requested scopes including offline access for background jobs
|
||||
|
||||
### Client Naming
|
||||
|
||||
The Keycloak client is named `nextcloud-mcp-server` to clarify:
|
||||
- **MCP Server** uses this client to get tokens for Nextcloud
|
||||
- **MCP Clients** (like Claude Desktop) connect to MCP Server via separate OAuth flows
|
||||
- **Not** named "mcp-client" to avoid confusion about which component is the client
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Token Has No Audience
|
||||
|
||||
**Symptom**: `"aud": null` in decoded JWT
|
||||
|
||||
**Cause**: Protocol mappers not configured
|
||||
|
||||
**Solution**: Add audience mappers via Keycloak Admin API (see Configuration section)
|
||||
|
||||
### MCP Server Rejects Token
|
||||
|
||||
**Symptom**: HTTP 401 with "JWT validation failed"
|
||||
|
||||
**Cause**: Token audience doesn't match expected value
|
||||
|
||||
**Solution**:
|
||||
1. Check token has correct `aud` claim
|
||||
2. Verify MCP server expects correct audience value in code
|
||||
3. Check logs for specific JWT validation error
|
||||
|
||||
### Nextcloud Rejects Token
|
||||
|
||||
**Symptom**: HTTP 401 from Nextcloud API
|
||||
|
||||
**Cause**: User not provisioned or token invalid
|
||||
|
||||
**Solution**:
|
||||
1. Check user_oidc provider is configured: `php occ user_oidc:provider keycloak`
|
||||
2. Check bearer validation enabled: `--check-bearer=1`
|
||||
3. Test token with userinfo endpoint: `curl -H "Authorization: Bearer $TOKEN" http://keycloak/realms/.../userinfo`
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **Multi-client validation**: `docs/keycloak-multi-client-validation.md`
|
||||
- **ADR-002**: `docs/ADR-002-vector-sync-authentication.md`
|
||||
- **OAuth setup**: `docs/oauth-setup.md`
|
||||
- **Keycloak integration**: `docs/keycloak-integration.md` (if created)
|
||||
|
||||
## References
|
||||
|
||||
- [RFC 8707 - Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707)
|
||||
- [OIDC Core - ID Token aud claim](https://openid.net/specs/openid-connect-core-1_0.html#IDToken)
|
||||
- [Keycloak Audience Protocol Mappers](https://www.keycloak.org/docs/latest/server_admin/#_audience)
|
||||
+2
-11
@@ -45,8 +45,7 @@ NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# OAuth Storage and Callback Settings (optional)
|
||||
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
|
||||
# OAuth Callback Settings (optional)
|
||||
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||
|
||||
# Leave these EMPTY for OAuth mode
|
||||
@@ -61,7 +60,6 @@ NEXTCLOUD_PASSWORD=
|
||||
| `NEXTCLOUD_HOST` | ✅ Yes | - | Full URL of your Nextcloud instance (e.g., `https://cloud.example.com`) |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Optional | - | OAuth client ID (auto-registers if empty) |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Optional | - | OAuth client secret (auto-registers if empty) |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Path to store auto-registered client credentials |
|
||||
| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for OAuth callbacks |
|
||||
| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty to enable OAuth mode |
|
||||
| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty to enable OAuth mode |
|
||||
@@ -160,10 +158,6 @@ Options:
|
||||
NEXTCLOUD_OIDC_CLIENT_ID env var)
|
||||
--oauth-client-secret TEXT OAuth client secret (can also use
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET env var)
|
||||
--oauth-storage-path TEXT Path to store OAuth client credentials
|
||||
(can also use
|
||||
NEXTCLOUD_OIDC_CLIENT_STORAGE env var)
|
||||
[default: .nextcloud_oauth_client.json]
|
||||
--mcp-server-url TEXT MCP server URL for OAuth callbacks (can
|
||||
also use NEXTCLOUD_MCP_SERVER_URL env
|
||||
var) [default: http://localhost:8000]
|
||||
@@ -225,10 +219,7 @@ uv run nextcloud-mcp-server --no-oauth \
|
||||
- Store OAuth client credentials securely
|
||||
- Use environment variables from your deployment platform (Docker secrets, Kubernetes ConfigMaps, etc.)
|
||||
- Never commit credentials to version control
|
||||
- Set appropriate file permissions on credential storage:
|
||||
```bash
|
||||
chmod 600 .nextcloud_oauth_client.json
|
||||
```
|
||||
- SQLite database permissions are handled automatically by the server
|
||||
|
||||
### For Docker
|
||||
|
||||
|
||||
+92
-93
@@ -28,18 +28,18 @@ The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 90
|
||||
### Key Features
|
||||
|
||||
- ✅ **JWT Token Support** - RFC 9068 compliant access tokens with RS256 signatures
|
||||
- ✅ **Custom Scopes** - `nc:read` and `nc:write` for read/write access control
|
||||
- ✅ **Custom Scopes** - `mcp:notes:read` and `mcp:notes:write` for read/write access control
|
||||
- ✅ **Dynamic Tool Filtering** - Tools filtered based on user's token scopes
|
||||
- ✅ **Scope Challenges** - RFC-compliant `WWW-Authenticate` headers for insufficient scopes
|
||||
- ✅ **Protected Resource Metadata** - RFC 8959 endpoint for scope discovery
|
||||
- ✅ **Protected Resource Metadata** - RFC 9728 endpoint for scope discovery
|
||||
- ✅ **Backward Compatible** - BasicAuth mode bypasses all scope checks
|
||||
|
||||
### Supported Scopes
|
||||
|
||||
| Scope | Description | Tool Count |
|
||||
|-------|-------------|------------|
|
||||
| `nc:read` | Read-only access to Nextcloud data | 36 tools |
|
||||
| `nc:write` | Write access to create/modify/delete data | 54 tools |
|
||||
| `mcp:notes:read` | Read-only access to Nextcloud data | 36 tools |
|
||||
| `mcp:notes:write` | Write access to create/modify/delete data | 54 tools |
|
||||
|
||||
All MCP tools (90 total) require at least one of these scopes. Standard OIDC scopes (`openid`, `profile`, `email`) are also supported.
|
||||
|
||||
@@ -75,7 +75,7 @@ The Nextcloud OIDC app supports two token formats, configured per-client:
|
||||
"aud": "client_id",
|
||||
"exp": 1234567890,
|
||||
"iat": 1234567890,
|
||||
"scope": "openid profile email nc:read nc:write",
|
||||
"scope": "openid profile email mcp:notes:read mcp:notes:write",
|
||||
"client_id": "...",
|
||||
"jti": "..."
|
||||
}
|
||||
@@ -116,8 +116,8 @@ The MCP server uses **coarse-grained scopes** for simplicity:
|
||||
|
||||
| Scope | Operations | Examples |
|
||||
|-------|------------|----------|
|
||||
| `nc:read` | Read-only access | Get notes, search files, list calendars, read contacts |
|
||||
| `nc:write` | Write operations | Create notes, update events, delete files, modify contacts |
|
||||
| `mcp:notes:read` | Read-only access | Get notes, search files, list calendars, read contacts |
|
||||
| `mcp:notes:write` | Write operations | Create notes, update events, delete files, modify contacts |
|
||||
|
||||
### Standard OIDC Scopes
|
||||
|
||||
@@ -131,12 +131,12 @@ The MCP server uses **coarse-grained scopes** for simplicity:
|
||||
|
||||
**Full Access:**
|
||||
```
|
||||
openid profile email nc:read nc:write
|
||||
openid profile email mcp:notes:read mcp:notes:write
|
||||
```
|
||||
|
||||
**Read-Only:**
|
||||
```
|
||||
openid profile email nc:read
|
||||
openid profile email mcp:notes:read
|
||||
```
|
||||
|
||||
**No Custom Scopes (OIDC only):**
|
||||
@@ -150,44 +150,46 @@ All 90 MCP tools are decorated with scope requirements:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("mcp:notes:read")
|
||||
async def nc_notes_get_note(note_id: int, ctx: Context):
|
||||
"""Get a note by ID (requires nc:read scope)"""
|
||||
"""Get a note by ID (requires mcp:notes:read scope)"""
|
||||
...
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("mcp:notes:write")
|
||||
async def nc_notes_create_note(title: str, content: str, ctx: Context):
|
||||
"""Create a note (requires nc:write scope)"""
|
||||
"""Create a note (requires mcp:notes:write scope)"""
|
||||
...
|
||||
```
|
||||
|
||||
**Coverage:**
|
||||
- ✅ 36 read tools decorated with `@require_scopes("nc:read")`
|
||||
- ✅ 54 write tools decorated with `@require_scopes("nc:write")`
|
||||
- ✅ 36 read tools decorated with `@require_scopes("mcp:notes:read")`
|
||||
- ✅ 54 write tools decorated with `@require_scopes("mcp:notes:write")`
|
||||
- ✅ 90/90 tools covered (100%)
|
||||
|
||||
### Dynamic Tool Filtering
|
||||
|
||||
The MCP server implements **dynamic tool filtering** - users only see tools they have permission to use:
|
||||
The MCP server implements **dynamic tool filtering** - users only see tools they have permission to use. This applies to **both JWT and Bearer (opaque) tokens** in OAuth mode:
|
||||
|
||||
**JWT with `nc:read` only:**
|
||||
**Token with `mcp:notes:read` only:**
|
||||
- `list_tools()` returns 36 read-only tools
|
||||
- Write tools are hidden from the tool list
|
||||
|
||||
**JWT with `nc:write` only:**
|
||||
**Token with `mcp:notes:write` only:**
|
||||
- `list_tools()` returns 54 write-only tools
|
||||
- Read tools are hidden from the tool list
|
||||
|
||||
**JWT with both scopes:**
|
||||
**Token with both scopes:**
|
||||
- `list_tools()` returns all 90 tools
|
||||
|
||||
**JWT with no custom scopes:**
|
||||
- `list_tools()` returns 0 tools (all require `nc:read` or `nc:write`)
|
||||
**Token with no custom scopes:**
|
||||
- `list_tools()` returns 0 tools (all require `mcp:notes:read` or `mcp:notes:write`)
|
||||
|
||||
**BasicAuth mode:**
|
||||
- `list_tools()` returns all 90 tools (no filtering)
|
||||
|
||||
**Note:** JWT tokens include scopes in the token payload, while Bearer tokens retrieve scopes via the introspection endpoint. Both methods provide reliable scope information for filtering.
|
||||
|
||||
### Scope Challenges
|
||||
|
||||
When a tool is called without required scopes, the server returns a `403 Forbidden` response with a `WWW-Authenticate` header:
|
||||
@@ -195,23 +197,23 @@ When a tool is called without required scopes, the server returns a `403 Forbidd
|
||||
```http
|
||||
HTTP/1.1 403 Forbidden
|
||||
WWW-Authenticate: Bearer error="insufficient_scope",
|
||||
scope="nc:write",
|
||||
resource_metadata="http://server/.well-known/oauth-protected-resource"
|
||||
scope="mcp:notes:write",
|
||||
resource_metadata="http://server/.well-known/oauth-protected-resource/mcp"
|
||||
```
|
||||
|
||||
This enables **step-up authorization** - clients can detect missing scopes and trigger re-authentication to obtain additional permissions.
|
||||
|
||||
### Protected Resource Metadata (PRM)
|
||||
|
||||
The server implements RFC 8959's Protected Resource Metadata endpoint:
|
||||
The server implements RFC 9728's Protected Resource Metadata endpoint:
|
||||
|
||||
**Endpoint:** `GET /.well-known/oauth-protected-resource`
|
||||
**Endpoint:** `GET /.well-known/oauth-protected-resource/mcp`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"resource": "http://localhost:8002",
|
||||
"scopes_supported": ["nc:read", "nc:write"],
|
||||
"resource": "http://localhost:8001/mcp",
|
||||
"scopes_supported": ["mcp:notes:read", "mcp:notes:write"],
|
||||
"authorization_servers": ["http://localhost:8080"],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_signing_alg_values_supported": ["RS256"]
|
||||
@@ -226,55 +228,53 @@ This allows OAuth clients to discover supported scopes before requesting authori
|
||||
|
||||
### Docker Services
|
||||
|
||||
The development environment includes three MCP server variants:
|
||||
The development environment includes two MCP server variants:
|
||||
|
||||
| Service | Port | Auth Type | Token Type | Use Case |
|
||||
|---------|------|-----------|------------|----------|
|
||||
| `mcp` | 8000 | BasicAuth | N/A | Development, testing |
|
||||
| `mcp-oauth` | 8001 | OAuth | Opaque | Standard OAuth flows |
|
||||
| `mcp-oauth-jwt` | 8002 | OAuth | JWT | Production, JWT testing |
|
||||
| `mcp-oauth` | 8001 | OAuth | JWT (configurable) | OAuth testing with JWT tokens |
|
||||
|
||||
### JWT Service Configuration
|
||||
### OAuth Service Configuration
|
||||
|
||||
The `mcp-oauth-jwt` service uses **Dynamic Client Registration (DCR)** by default:
|
||||
The `mcp-oauth` service uses **Dynamic Client Registration (DCR)** by default and is configured to request JWT tokens:
|
||||
|
||||
**Default Configuration (DCR):**
|
||||
**Default Configuration (DCR with JWT tokens):**
|
||||
```yaml
|
||||
mcp-oauth-jwt:
|
||||
mcp-oauth:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||
ports:
|
||||
- 127.0.0.1:8002:8002
|
||||
- 127.0.0.1:8001:8001
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
||||
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
|
||||
volumes:
|
||||
- ./oauth-storage:/app/.oauth # Optional: persist DCR credentials
|
||||
- oauth-client-storage:/app/.oauth # Persist DCR credentials
|
||||
```
|
||||
|
||||
**With Pre-Configured Credentials:**
|
||||
```yaml
|
||||
mcp-oauth-jwt:
|
||||
mcp-oauth:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||
ports:
|
||||
- 127.0.0.1:8002:8002
|
||||
- 127.0.0.1:8001:8001
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- NEXTCLOUD_OIDC_CLIENT_ID=<your_client_id> # Skips DCR
|
||||
- NEXTCLOUD_OIDC_CLIENT_SECRET=<your_client_secret> # Skips DCR
|
||||
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- **No credentials needed** - DCR automatically registers the client on first start
|
||||
- **Credentials persist** - Saved to `.nextcloud_oauth_client.json` and reused
|
||||
- **JWT tokens** - Set `TOKEN_TYPE=jwt` for better performance
|
||||
- **Credentials persist** - Saved to SQLite database and reused
|
||||
- **JWT tokens** - Use `--oauth-token-type jwt` for better performance
|
||||
- **Token verifier supports both** - Can handle JWT and opaque tokens
|
||||
- **Pre-configured credentials** - Providing `CLIENT_ID`/`CLIENT_SECRET` skips DCR
|
||||
|
||||
### Environment Variables
|
||||
@@ -286,8 +286,7 @@ mcp-oauth-jwt:
|
||||
| `NEXTCLOUD_PUBLIC_ISSUER_URL` | Public issuer URL for JWT validation | (uses `NEXTCLOUD_HOST`) |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured OAuth client ID | (optional - uses DCR if unset) |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured OAuth client secret | (optional - uses DCR if unset) |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | Path to persist DCR-registered credentials | `.nextcloud_oauth_client.json` |
|
||||
| `NEXTCLOUD_OIDC_SCOPES` | Space-separated scopes to request | `"openid profile email nc:read nc:write"` |
|
||||
| `NEXTCLOUD_OIDC_SCOPES` | Space-separated scopes to request | `"openid profile email mcp:notes:read mcp:notes:write"` |
|
||||
| `NEXTCLOUD_OIDC_TOKEN_TYPE` | Token format: `"jwt"` or `"Bearer"` | `"Bearer"` |
|
||||
|
||||
### Dynamic Client Registration (DCR)
|
||||
@@ -303,8 +302,8 @@ When the MCP server starts in OAuth mode, it follows this **three-tier credentia
|
||||
├─ NEXTCLOUD_OIDC_CLIENT_ID
|
||||
└─ NEXTCLOUD_OIDC_CLIENT_SECRET
|
||||
|
||||
2. Storage File (Second Priority)
|
||||
└─ NEXTCLOUD_OIDC_CLIENT_STORAGE (.nextcloud_oauth_client.json)
|
||||
2. SQLite Database (Second Priority)
|
||||
└─ OAuth client credentials table
|
||||
|
||||
3. Dynamic Client Registration (Automatic Fallback)
|
||||
├─ Discovers registration endpoint from /.well-known/openid-configuration
|
||||
@@ -321,16 +320,16 @@ DCR automatically configures the client based on environment variables:
|
||||
# Minimal DCR configuration (no credentials needed!)
|
||||
export NEXTCLOUD_HOST=http://localhost:8080
|
||||
export NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||
export NEXTCLOUD_OIDC_SCOPES="openid profile email nc:read nc:write"
|
||||
export NEXTCLOUD_OIDC_SCOPES="openid profile email mcp:notes:read mcp:notes:write"
|
||||
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt # or "Bearer" for opaque tokens
|
||||
```
|
||||
|
||||
**Credential Storage:**
|
||||
|
||||
- Registered credentials are saved to `NEXTCLOUD_OIDC_CLIENT_STORAGE` (default: `.nextcloud_oauth_client.json`)
|
||||
- File has restrictive permissions (0600 - owner read/write only)
|
||||
- Registered credentials are saved to SQLite database
|
||||
- Database is encrypted and protected by file system permissions
|
||||
- Credentials are reused on subsequent starts (no re-registration needed)
|
||||
- Storage file is checked for expiration (auto-regenerates if expired)
|
||||
- Stored credentials are checked for expiration (auto-regenerates if expired)
|
||||
|
||||
**Format:**
|
||||
```json
|
||||
@@ -363,7 +362,7 @@ Manual client creation is **optional** but may be preferred when:
|
||||
```bash
|
||||
docker compose exec app php occ oidc:create \
|
||||
--token_type=jwt \
|
||||
--allowed_scopes="openid profile email nc:read nc:write" \
|
||||
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
|
||||
"Nextcloud MCP Server" \
|
||||
"http://localhost:8000/oauth/callback"
|
||||
```
|
||||
@@ -374,7 +373,7 @@ docker compose exec app php occ oidc:create \
|
||||
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
|
||||
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
|
||||
"token_type": "jwt",
|
||||
"allowed_scopes": "openid profile email nc:read nc:write"
|
||||
"allowed_scopes": "openid profile email mcp:notes:read mcp:notes:write"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -386,9 +385,9 @@ export NEXTCLOUD_OIDC_CLIENT_ID="<client_id>"
|
||||
export NEXTCLOUD_OIDC_CLIENT_SECRET="<client_secret>"
|
||||
export NEXTCLOUD_OIDC_TOKEN_TYPE="jwt"
|
||||
|
||||
# Option 2: Storage file (second priority)
|
||||
# Save the JSON response to .nextcloud_oauth_client.json
|
||||
# Server will automatically load it on startup
|
||||
# Option 2: SQLite database (second priority)
|
||||
# Credentials are automatically saved to the database after DCR
|
||||
# Server will automatically load them on startup
|
||||
```
|
||||
|
||||
When credentials are provided via environment variables or storage file, **DCR is skipped**.
|
||||
@@ -407,7 +406,7 @@ When credentials are provided via environment variables or storage file, **DCR i
|
||||
│ │
|
||||
│ JWT Access Token │
|
||||
│ { │
|
||||
│ "scope": "openid nc:read nc:write" │
|
||||
│ "scope": "openid mcp:notes:read mcp:notes:write" │
|
||||
│ ... │
|
||||
│ } │
|
||||
│ │
|
||||
@@ -456,16 +455,16 @@ When credentials are provided via environment variables or storage file, **DCR i
|
||||
- `has_required_scopes()` - Check if user has necessary scopes
|
||||
- `InsufficientScopeError` exception for WWW-Authenticate challenges
|
||||
|
||||
**3. Dynamic Filtering** (`nextcloud_mcp_server/app.py:433-488`)
|
||||
**3. Dynamic Filtering** (`nextcloud_mcp_server/app.py:473-516`)
|
||||
- Overrides FastMCP's `list_tools()` method
|
||||
- Filters based on user's JWT token scopes
|
||||
- Filters based on user's OAuth token scopes (JWT and Bearer)
|
||||
- Only active in OAuth mode
|
||||
- Bypassed in BasicAuth mode
|
||||
|
||||
**4. PRM Endpoint** (`nextcloud_mcp_server/app.py:503-532`)
|
||||
- `GET /.well-known/oauth-protected-resource`
|
||||
- Advertises `["nc:read", "nc:write"]`
|
||||
- RFC 8959 compliant
|
||||
- `GET /.well-known/oauth-protected-resource/mcp`
|
||||
- Advertises `["mcp:notes:read", "mcp:notes:write"]`
|
||||
- RFC 9728 compliant
|
||||
|
||||
**5. Exception Handler** (`nextcloud_mcp_server/app.py:540-563`)
|
||||
- Catches `InsufficientScopeError`
|
||||
@@ -500,7 +499,7 @@ The `NextcloudTokenVerifier` implements a **cascading validation strategy** that
|
||||
│ ├─ Authenticate with client credentials
|
||||
│ ├─ Response contains:
|
||||
│ │ • active: true/false
|
||||
│ │ • scope: "openid nc:read nc:write"
|
||||
│ │ • scope: "openid mcp:notes:read mcp:notes:write"
|
||||
│ │ • sub, exp, iat, client_id
|
||||
│ ├─ Extract scopes from response
|
||||
│ └─ Success: Return AccessToken
|
||||
@@ -554,7 +553,7 @@ uv run pytest tests/server/test_scope_authorization.py::test_jwt_with_no_custom_
|
||||
```
|
||||
|
||||
**Scenario:** JWT token with only OIDC defaults (`openid profile email`)
|
||||
**Expected:** 0 tools returned (all require `nc:read` or `nc:write`)
|
||||
**Expected:** 0 tools returned (all require `mcp:notes:read` or `mcp:notes:write`)
|
||||
**Verifies:** Security - users who decline custom scopes cannot access any MCP tools
|
||||
|
||||
#### 2. Read-Only Access (36 tools)
|
||||
@@ -562,7 +561,7 @@ uv run pytest tests/server/test_scope_authorization.py::test_jwt_with_no_custom_
|
||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_read_only -v
|
||||
```
|
||||
|
||||
**Scenario:** JWT token with `nc:read` only
|
||||
**Scenario:** JWT token with `mcp:notes:read` only
|
||||
**Expected:** 36 read-only tools visible, write tools hidden
|
||||
**Verifies:** Read tools accessible, write tools filtered out
|
||||
|
||||
@@ -571,7 +570,7 @@ uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenari
|
||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_write_only -v
|
||||
```
|
||||
|
||||
**Scenario:** JWT token with `nc:write` only
|
||||
**Scenario:** JWT token with `mcp:notes:write` only
|
||||
**Expected:** 54 write tools visible, read tools hidden
|
||||
**Verifies:** Write tools accessible, read tools filtered out
|
||||
|
||||
@@ -580,21 +579,21 @@ uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenari
|
||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_full_access -v
|
||||
```
|
||||
|
||||
**Scenario:** JWT token with both `nc:read` and `nc:write`
|
||||
**Scenario:** JWT token with both `mcp:notes:read` and `mcp:notes:write`
|
||||
**Expected:** All 90 tools visible
|
||||
**Verifies:** Full access when user grants all custom scopes
|
||||
|
||||
### Test Fixtures
|
||||
|
||||
**OAuth Client Fixtures:**
|
||||
- `read_only_oauth_client_credentials` - Client with `nc:read` only
|
||||
- `write_only_oauth_client_credentials` - Client with `nc:write` only
|
||||
- `read_only_oauth_client_credentials` - Client with `mcp:notes:read` only
|
||||
- `write_only_oauth_client_credentials` - Client with `mcp:notes:write` only
|
||||
- `full_access_oauth_client_credentials` - Client with both scopes
|
||||
- `no_custom_scopes_oauth_client_credentials` - Client with OIDC defaults only
|
||||
|
||||
**Token Fixtures:**
|
||||
- `playwright_oauth_token_read_only` - Obtains token with `nc:read`
|
||||
- `playwright_oauth_token_write_only` - Obtains token with `nc:write`
|
||||
- `playwright_oauth_token_read_only` - Obtains token with `mcp:notes:read`
|
||||
- `playwright_oauth_token_write_only` - Obtains token with `mcp:notes:write`
|
||||
- `playwright_oauth_token_full_access` - Obtains token with both scopes
|
||||
- `playwright_oauth_token_no_custom_scopes` - Obtains token with no custom scopes
|
||||
|
||||
@@ -682,26 +681,26 @@ docker compose exec app php occ oidc:list
|
||||
# If empty, recreate client with --allowed_scopes
|
||||
docker compose exec app php occ oidc:create \
|
||||
--token_type=jwt \
|
||||
--allowed_scopes="openid profile email nc:read nc:write" \
|
||||
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
|
||||
"Client Name" \
|
||||
"http://callback/url"
|
||||
```
|
||||
|
||||
### Issue: All Tools Visible Despite Read-Only Token
|
||||
|
||||
**Symptom:** User with `nc:read` token can see all 90 tools including write tools
|
||||
**Symptom:** User with `mcp:notes:read` token can see all 90 tools including write tools
|
||||
|
||||
**Cause:** Server running in BasicAuth mode, not OAuth mode
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Verify OAuth mode is active
|
||||
docker compose logs mcp-oauth-jwt | grep "OAuth mode"
|
||||
docker compose logs mcp-oauth | grep "OAuth mode"
|
||||
|
||||
# Should see: "Running in OAuth mode"
|
||||
|
||||
# If not, check environment variables:
|
||||
docker compose exec mcp-oauth-jwt env | grep NEXTCLOUD_OIDC
|
||||
docker compose exec mcp-oauth env | grep NEXTCLOUD_OIDC
|
||||
|
||||
# Ensure no NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD set
|
||||
```
|
||||
@@ -717,14 +716,14 @@ DCR **now properly sets `allowed_scopes`** when the `scope` parameter is provide
|
||||
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
|
||||
-e "SELECT name, allowed_scopes FROM oc_oauth2_clients WHERE name LIKE 'DCR-%' ORDER BY id DESC LIMIT 1;"
|
||||
|
||||
# Should show your requested scopes (e.g., "openid profile email nc:read nc:write")
|
||||
# Should show your requested scopes (e.g., "openid profile email mcp:notes:read mcp:notes:write")
|
||||
```
|
||||
|
||||
**If scopes are missing:**
|
||||
1. Ensure `NEXTCLOUD_OIDC_SCOPES` environment variable is set correctly
|
||||
2. Check MCP server startup logs for the scopes being requested
|
||||
3. Verify DCR is enabled in Nextcloud OIDC app settings
|
||||
4. Delete `.nextcloud_oauth_client.json` and restart to force re-registration
|
||||
4. Clear the SQLite database OAuth client entry and restart to force re-registration
|
||||
|
||||
### Issue: Token Type Case Sensitivity
|
||||
|
||||
@@ -750,12 +749,12 @@ export NEXTCLOUD_OIDC_TOKEN_TYPE=JWT
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check server logs for OAuth mode
|
||||
docker compose logs mcp-oauth-jwt | grep "WWW-Authenticate scope challenges enabled"
|
||||
docker compose logs mcp-oauth | grep "WWW-Authenticate scope challenges enabled"
|
||||
|
||||
# Should see this during startup
|
||||
|
||||
# Check exception handling
|
||||
docker compose logs mcp-oauth-jwt | grep "InsufficientScopeError"
|
||||
docker compose logs mcp-oauth | grep "InsufficientScopeError"
|
||||
```
|
||||
|
||||
### Debugging Tools
|
||||
@@ -780,10 +779,10 @@ docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
|
||||
**Check server logs:**
|
||||
```bash
|
||||
# Follow JWT verification logs
|
||||
docker compose logs -f mcp-oauth-jwt | grep -E "JWT|scope|tool"
|
||||
docker compose logs -f mcp-oauth | grep -E "JWT|scope|tool"
|
||||
|
||||
# Check for issuer mismatches
|
||||
docker compose logs mcp-oauth-jwt | grep -i issuer
|
||||
docker compose logs mcp-oauth | grep -i issuer
|
||||
```
|
||||
|
||||
---
|
||||
@@ -804,18 +803,18 @@ docker compose logs mcp-oauth-jwt | grep -i issuer
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (production)
|
||||
mcp-oauth-jwt:
|
||||
mcp-oauth:
|
||||
image: ghcr.io/yourusername/nextcloud-mcp-server:latest
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||
- NEXTCLOUD_MCP_SERVER_URL=https://mcp.example.com
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=https://nextcloud.example.com
|
||||
- NEXTCLOUD_OIDC_CLIENT_ID=${JWT_CLIENT_ID}
|
||||
- NEXTCLOUD_OIDC_CLIENT_SECRET=${JWT_CLIENT_SECRET}
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
||||
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
|
||||
ports:
|
||||
- "8002:8002"
|
||||
- "8001:8001"
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
@@ -847,16 +846,16 @@ mcp-oauth-jwt:
|
||||
```bash
|
||||
# Success
|
||||
INFO JWT verified successfully for user: admin
|
||||
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'nc:read', 'nc:write'}
|
||||
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'mcp:notes:read', 'mcp:notes:write'}
|
||||
|
||||
# Failures
|
||||
WARNING JWT issuer validation failed: Invalid issuer
|
||||
WARNING Missing required scopes: nc:write
|
||||
WARNING Missing required scopes: mcp:notes:write
|
||||
```
|
||||
|
||||
### Known Limitations
|
||||
|
||||
1. **No Fine-Grained Scopes** - Only coarse `nc:read` and `nc:write` (not per-app scopes)
|
||||
1. **No Fine-Grained Scopes** - Only coarse `mcp:notes:read` and `mcp:notes:write` (not per-app scopes)
|
||||
2. **No Refresh Token Support** - Tokens must be reacquired when expired
|
||||
|
||||
### Future Enhancements
|
||||
@@ -876,7 +875,7 @@ WARNING Missing required scopes: nc:write
|
||||
- [RFC 9068: JWT Profile for OAuth 2.0 Access Tokens](https://www.rfc-editor.org/rfc/rfc9068.html)
|
||||
- [RFC 7519: JSON Web Token (JWT)](https://www.rfc-editor.org/rfc/rfc7519.html)
|
||||
- [RFC 7517: JSON Web Key (JWK)](https://www.rfc-editor.org/rfc/rfc7517.html)
|
||||
- [RFC 8959: Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc8959.html)
|
||||
- [RFC 9728: OAuth 2.0 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728.html)
|
||||
- [RFC 7662: OAuth 2.0 Token Introspection](https://www.rfc-editor.org/rfc/rfc7662.html)
|
||||
|
||||
### Related Documentation
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
# Keycloak Multi-Client Token Validation
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Question**: Can Nextcloud's `user_oidc` app (configured with client A) validate bearer tokens from client B in the same Keycloak realm?
|
||||
|
||||
**Answer**: ✅ **YES** - user_oidc validates tokens at the **realm level**, not per-client.
|
||||
|
||||
## Test Results
|
||||
|
||||
### Setup
|
||||
- **Keycloak Realm**: `nextcloud-mcp`
|
||||
- **Provider in user_oidc**: Configured with `mcp-client` credentials
|
||||
- **Test**: Get token from `test-client-b`, validate via Nextcloud API
|
||||
|
||||
### Result
|
||||
```bash
|
||||
# Token from test-client-b (client B)
|
||||
$ TOKEN=$(curl -X POST ".../token" -d "client_id=test-client-b" ...)
|
||||
|
||||
# Validated successfully by Nextcloud (configured with mcp-client = client A)
|
||||
$ curl -H "Authorization: Bearer $TOKEN" "http://nextcloud/ocs/.../capabilities"
|
||||
HTTP/1.1 200 OK
|
||||
{"ocs":{"meta":{"status":"ok"}}}
|
||||
```
|
||||
|
||||
✅ **Token from client B validated successfully!**
|
||||
|
||||
## How It Works
|
||||
|
||||
### Token Structure from Keycloak
|
||||
|
||||
**Access Token** (password grant):
|
||||
```json
|
||||
{
|
||||
"iss": "http://keycloak/realms/nextcloud-mcp",
|
||||
"azp": "test-client-b", // Authorized party = client B
|
||||
"typ": "Bearer",
|
||||
"exp": 1234567890,
|
||||
// NO "sub" claim
|
||||
// NO "aud" claim
|
||||
"scope": "openid profile email"
|
||||
}
|
||||
```
|
||||
|
||||
**ID Token** (for comparison):
|
||||
```json
|
||||
{
|
||||
"iss": "http://keycloak/realms/nextcloud-mcp",
|
||||
"aud": "test-client-b", // Audience = client B
|
||||
"sub": "923da741-7ebe-4cf9-baf2-37fcf2ecc95d",
|
||||
"azp": "test-client-b"
|
||||
}
|
||||
```
|
||||
|
||||
**Key Observation**: Access tokens from Keycloak's password grant **do not contain** `sub` or `aud` claims!
|
||||
|
||||
### Validation Flow in user_oidc
|
||||
|
||||
From source code analysis (`~/Software/user_oidc/lib/User/Backend.php`):
|
||||
|
||||
```
|
||||
1. Request with Bearer token arrives
|
||||
↓
|
||||
2. user_oidc loops through providers with checkBearer=true
|
||||
↓
|
||||
3. Try SelfEncodedValidator (JWT/JWKS validation):
|
||||
- Validates JWT signature using Keycloak's JWKS
|
||||
- Tries to extract 'sub' claim → FAILS (no sub in access token)
|
||||
↓
|
||||
4. Fallback to UserInfoValidator:
|
||||
- Calls Keycloak userinfo endpoint with bearer token
|
||||
- Keycloak validates token server-side
|
||||
- Returns userinfo with 'sub' claim
|
||||
→ SUCCESS!
|
||||
↓
|
||||
5. User identified, request authorized
|
||||
```
|
||||
|
||||
### Why This Works
|
||||
|
||||
**Realm-Level Trust**:
|
||||
- Keycloak's userinfo endpoint validates ANY valid token from the realm
|
||||
- It doesn't matter which client issued the token
|
||||
- The token is validated by Keycloak itself (via userinfo call)
|
||||
|
||||
**No Audience Check**:
|
||||
- Access tokens have no `aud` claim
|
||||
- SelfEncodedValidator's audience check is bypassed (no audience to validate)
|
||||
- UserInfoValidator doesn't check audience (delegates to Keycloak)
|
||||
|
||||
**Client Credentials Role**:
|
||||
- The configured `client_id`/`client_secret` in user_oidc are **NOT used** for bearer token validation
|
||||
- They're only used for OAuth login flows (authorization code exchange)
|
||||
- Userinfo endpoint doesn't require client authentication
|
||||
|
||||
## Source Code Evidence
|
||||
|
||||
### SelfEncodedValidator - Audience Check
|
||||
|
||||
```php
|
||||
// ~/Software/user_oidc/lib/User/Validator/SelfEncodedValidator.php:64-76
|
||||
|
||||
$checkAudience = !isset($oidcSystemConfig['selfencoded_bearer_validation_audience_check'])
|
||||
|| !in_array($oidcSystemConfig['selfencoded_bearer_validation_audience_check'],
|
||||
[false, 'false', 0, '0'], true);
|
||||
|
||||
if ($checkAudience) {
|
||||
$tokenAudience = $payload->aud ?? null;
|
||||
|
||||
if ((is_string($tokenAudience) && $tokenAudience !== $providerClientId)
|
||||
|| (is_array($tokenAudience) && !in_array($providerClientId, $tokenAudience))) {
|
||||
$this->logger->debug('Audience does not match client ID');
|
||||
return null; // REJECT
|
||||
}
|
||||
}
|
||||
|
||||
// If $tokenAudience is null (our case), both conditions are false → validation continues
|
||||
```
|
||||
|
||||
### UserInfoValidator - No Client Auth
|
||||
|
||||
```php
|
||||
// ~/Software/user_oidc/lib/Service/OIDCService.php:28-45
|
||||
|
||||
public function userinfo(Provider $provider, string $accessToken): array {
|
||||
$url = $this->discoveryService->obtainDiscovery($provider)['userinfo_endpoint'];
|
||||
|
||||
// Bearer token passed directly - NO client credentials used
|
||||
$options = ['headers' => ['Authorization' => 'Bearer ' . $accessToken]];
|
||||
|
||||
return json_decode($this->clientService->get($url, [], $options), true);
|
||||
}
|
||||
```
|
||||
|
||||
### Keycloak Userinfo Response
|
||||
|
||||
```bash
|
||||
$ curl -H "Authorization: Bearer $TOKEN_FROM_CLIENT_B" \
|
||||
"http://keycloak/realms/nextcloud-mcp/protocol/openid-connect/userinfo"
|
||||
|
||||
{
|
||||
"sub": "923da741-7ebe-4cf9-baf2-37fcf2ecc95d",
|
||||
"email_verified": true,
|
||||
"name": "Admin User",
|
||||
"email": "admin@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
Keycloak validates the token **regardless of which client issued it**, as long as it's from the same realm.
|
||||
|
||||
## Implications for Your Architecture
|
||||
|
||||
### Desired Architecture
|
||||
```
|
||||
MCP Server (client A) ← DCR with Keycloak
|
||||
MCP Clients (client B, C, D...) ← DCR with Keycloak
|
||||
Nextcloud user_oidc ← configured once with any client from realm
|
||||
```
|
||||
|
||||
### What This Means
|
||||
|
||||
✅ **You can do exactly what you want!**
|
||||
|
||||
1. **Configure user_oidc once** with any client from the Keycloak realm (e.g., a dedicated `nextcloud-validator` client)
|
||||
|
||||
2. **MCP Server registers via DCR** as a unique client (e.g., `mcp-server-abc123`)
|
||||
- Gets its own client credentials
|
||||
- Issues tokens with `azp: "mcp-server-abc123"`
|
||||
- These tokens will be validated by user_oidc!
|
||||
|
||||
3. **MCP Clients also use DCR** (each gets unique identity)
|
||||
- Client A: `client-123`
|
||||
- Client B: `client-456`
|
||||
- Tokens from all clients validated by user_oidc!
|
||||
|
||||
4. **Tokens from ANY client** in the realm can access Nextcloud APIs
|
||||
- user_oidc validates via Keycloak userinfo endpoint
|
||||
- Realm-level trust (not per-client)
|
||||
|
||||
### Configuration
|
||||
|
||||
**Step 1: Configure user_oidc Provider**
|
||||
```bash
|
||||
php occ user_oidc:provider keycloak-realm \
|
||||
--clientid="nextcloud-validator" \
|
||||
--clientsecret="***" \
|
||||
--discoveryuri="https://keycloak/realms/my-realm/.well-known/openid-configuration" \
|
||||
--check-bearer=1 \
|
||||
--bearer-provisioning=1
|
||||
```
|
||||
|
||||
**Step 2: MCP Server Registers with Keycloak (DCR)**
|
||||
```python
|
||||
# MCP server startup
|
||||
registration_response = await keycloak_client.register_client(
|
||||
client_name="MCP Server Instance",
|
||||
redirect_uris=["http://mcp-server/oauth/callback"]
|
||||
)
|
||||
# Store: client_id, client_secret
|
||||
```
|
||||
|
||||
**Step 3: Issue Tokens to Users**
|
||||
- Users authenticate via Keycloak
|
||||
- MCP server gets tokens issued to its `client_id`
|
||||
- These tokens validated by user_oidc!
|
||||
|
||||
**Step 4: Background Operations (ADR-002)**
|
||||
- Store user refresh tokens (encrypted)
|
||||
- Refresh access tokens as needed
|
||||
- All tokens validated by user_oidc regardless of issuing client
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Token Grant Types Matter
|
||||
|
||||
**Password Grant** (what we tested):
|
||||
- Access tokens have NO `sub` or `aud`
|
||||
- Forces validation via userinfo endpoint
|
||||
- Works with any client in realm
|
||||
|
||||
**Authorization Code Grant** (production):
|
||||
- Tokens MAY include `aud` claim
|
||||
- Need to verify behavior with real OAuth flows
|
||||
- May require disabling audience check
|
||||
|
||||
### Recommendation for Production
|
||||
|
||||
**Option 1: Disable Audience Check (Simplest)**
|
||||
```php
|
||||
// config.php
|
||||
'user_oidc' => [
|
||||
'selfencoded_bearer_validation_audience_check' => false,
|
||||
],
|
||||
```
|
||||
|
||||
**Option 2: Rely on UserInfo Validation**
|
||||
```php
|
||||
// config.php
|
||||
'user_oidc' => [
|
||||
'userinfo_bearer_validation' => true, // Enable userinfo validation
|
||||
],
|
||||
```
|
||||
|
||||
**Option 3: Configure Keycloak to Not Include aud in Access Tokens**
|
||||
- Keep default behavior (works as tested)
|
||||
- Tokens validated via userinfo endpoint
|
||||
|
||||
## Testing Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Test multi-client validation
|
||||
|
||||
# Create second client in Keycloak
|
||||
curl -X POST "http://keycloak/admin/realms/my-realm/clients" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-d '{
|
||||
"clientId": "test-client-b",
|
||||
"secret": "test-secret-b",
|
||||
"standardFlowEnabled": true,
|
||||
"directAccessGrantsEnabled": true
|
||||
}'
|
||||
|
||||
# Get token from client B
|
||||
TOKEN=$(curl -X POST "http://keycloak/realms/my-realm/protocol/openid-connect/token" \
|
||||
-d "grant_type=password" \
|
||||
-d "client_id=test-client-b" \
|
||||
-d "client_secret=test-secret-b" \
|
||||
-d "username=testuser" \
|
||||
-d "password=password" | jq -r '.access_token')
|
||||
|
||||
# Test with Nextcloud (configured with client A)
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"http://nextcloud/ocs/v2.php/cloud/capabilities"
|
||||
|
||||
# Should return 200 OK!
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
✅ **Your proposed architecture is fully supported!**
|
||||
|
||||
- user_oidc configured once with ANY client from Keycloak realm
|
||||
- MCP server registers dynamically via DCR
|
||||
- MCP clients also register dynamically
|
||||
- ALL tokens from realm validated successfully
|
||||
- No per-client configuration needed
|
||||
|
||||
The key insight: **user_oidc validates tokens at the realm level** (via Keycloak's userinfo endpoint), not at the client level.
|
||||
|
||||
## References
|
||||
|
||||
- Source code: `~/Software/user_oidc/lib/User/Backend.php:260-343`
|
||||
- SelfEncodedValidator: `~/Software/user_oidc/lib/User/Validator/SelfEncodedValidator.php`
|
||||
- UserInfoValidator: `~/Software/user_oidc/lib/User/Validator/UserInfoValidator.php`
|
||||
- Test setup: `docker-compose.yml` (mcp-keycloak service)
|
||||
- Configuration: `.env.keycloak.sample`
|
||||
+543
-116
@@ -8,166 +8,463 @@ The Nextcloud MCP Server acts as an **OAuth 2.0 Resource Server**, protecting ac
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
The complete OAuth flow includes server startup (with DCR), client discovery (with PRM), authorization (with PKCE), and API access phases:
|
||||
|
||||
```
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
Phase 0: MCP Server Startup & Client Registration (DCR - RFC 7591)
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
┌──────────────────┐ ┌─────────────────┐
|
||||
│ MCP Server │ │ Nextcloud │
|
||||
│ (Resource │ │ (OIDC Provider)│
|
||||
│ Server) │ │ │
|
||||
└────────┬─────────┘ └────────┬────────┘
|
||||
│ │
|
||||
│ 0a. OIDC Discovery │
|
||||
├────────────────────────────────────>│
|
||||
│ GET │
|
||||
| /.well-known/openid-configuration │
|
||||
│ │
|
||||
│ 0b. Discovery response │
|
||||
│<────────────────────────────────────┤
|
||||
│ {issuer, endpoints, PKCE methods} │
|
||||
│ │
|
||||
│ 0c. Register OAuth client (DCR) │
|
||||
├────────────────────────────────────>│
|
||||
│ POST /apps/oidc/register │
|
||||
│ {client_name, redirect_uris, │
|
||||
│ scopes, token_type} │
|
||||
│ │
|
||||
│ 0d. Client credentials │
|
||||
│<────────────────────────────────────┤
|
||||
│ {client_id, client_secret} │
|
||||
│ → Saved to SQLite database │
|
||||
│ │
|
||||
│ ✓ Server ready for connections │
|
||||
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
Phase 1: Client Connection & Discovery (PRM - RFC 9728)
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ │ │ │ │ │
|
||||
│ MCP Client │ │ MCP Server │ │ Nextcloud │
|
||||
│ (Claude, │ │ (Resource │ │ Instance │
|
||||
│ etc.) │ │ Server) │ │ │
|
||||
│ │ │ MCP Server │ │ Nextcloud │
|
||||
│ MCP Client │ │ (Resource │ │ Instance │
|
||||
│ (Claude) │ │ Server) │ │ │
|
||||
│ │ │ │ │ │
|
||||
└──────┬──────┘ └────────┬─────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ 1. Connect to MCP │ │
|
||||
│ 1a. Connect to MCP │ │
|
||||
├─────────────────────────────────>│ │
|
||||
│ │ │
|
||||
│ 2. Return auth settings │ │
|
||||
│ (issuer_url, scopes) │ │
|
||||
│ 1b. Return auth settings │ │
|
||||
│<─────────────────────────────────┤ │
|
||||
│ {issuer_url, resource_url} │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ 3. Start OAuth flow (with PKCE) │ │
|
||||
├──────────────────────────────────┼────────────────────────────────────>│
|
||||
│ │ /apps/oidc/authorize │
|
||||
│ │ │
|
||||
│ 4. User authenticates in browser│ │
|
||||
│<─────────────────────────────────┼─────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 5. Authorization code (redirect)│ │
|
||||
│<─────────────────────────────────┤ │
|
||||
│ │ │
|
||||
│ 6. Exchange code for token │ │
|
||||
├──────────────────────────────────┼────────────────────────────────────>│
|
||||
│ │ /apps/oidc/token │
|
||||
│ │ │
|
||||
│ 7. Access token │ │
|
||||
│<─────────────────────────────────┼─────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ 8. API request with Bearer token│ │
|
||||
│ 1c. PRM Discovery (RFC 9728) │ │
|
||||
├─────────────────────────────────>│ │
|
||||
│ Authorization: Bearer xxx │ │
|
||||
│ GET /.well-known/oauth- │ │
|
||||
│ protected-resource/mcp │ │
|
||||
│ │ │
|
||||
│ │ 9. Validate token via userinfo │
|
||||
│ ├────────────────────────────────────>│
|
||||
│ │ /apps/oidc/userinfo │
|
||||
│ │ │
|
||||
│ │ 10. User info (token valid) │
|
||||
│ │<────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ │ 11. Nextcloud API request │
|
||||
│ ├────────────────────────────────────>│
|
||||
│ │ Authorization: Bearer xxx │
|
||||
│ │ (Notes, Calendar, etc.) │
|
||||
│ │ │
|
||||
│ │ 12. API response │
|
||||
│ │<────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 13. MCP tool response │ │
|
||||
│ 1d. PRM response (scopes!) │ │
|
||||
│<─────────────────────────────────┤ │
|
||||
│ {resource, scopes_supported, │ ← Dynamically discovered from │
|
||||
│ authorization_servers} │ @require_scopes decorators │
|
||||
│ │ │
|
||||
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
Phase 2: OAuth Authorization Flow (PKCE - RFC 7636)
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
│ │ │
|
||||
│ 2a. Generate PKCE challenge │ │
|
||||
│ code_verifier = random(43-128) │ │
|
||||
│ code_challenge = SHA256(verif.) │ │
|
||||
│ │ │
|
||||
│ 2b. Authorization request │ │
|
||||
├──────────────────────────────────┼────────────────────────────────────>│
|
||||
│ /apps/oidc/authorize? │ │
|
||||
│ client_id=xxx │ │
|
||||
│ &code_challenge=abc... │ │
|
||||
│ &code_challenge_method=S256 │ │
|
||||
│ &scope=openid notes:read ... │ │
|
||||
│ │ │
|
||||
│ 2c. User consent page │ │
|
||||
│<─────────────────────────────────┼─────────────────────────────────────┤
|
||||
│ (Browser: Select scopes) │ │
|
||||
│ │ │
|
||||
│ 2d. User grants scopes │ │
|
||||
├──────────────────────────────────┼────────────────────────────────────>│
|
||||
│ │ │
|
||||
│ 2e. Authorization code redirect │ │
|
||||
│<─────────────────────────────────┼─────────────────────────────────────┤
|
||||
│ callback?code=xyz123 │ │
|
||||
│ │ │
|
||||
│ 2f. Exchange code for token │ │
|
||||
├──────────────────────────────────┼────────────────────────────────────>│
|
||||
│ POST /apps/oidc/token │ │
|
||||
│ {code, code_verifier, │ ← Validates PKCE challenge │
|
||||
│ client_id, client_secret} │ │
|
||||
│ │ │
|
||||
│ 2g. Access token (JWT/opaque) │ │
|
||||
│<─────────────────────────────────┼─────────────────────────────────────┤
|
||||
│ {access_token, token_type, │ │
|
||||
│ scope: "openid notes:read...") │ ← User's granted scopes │
|
||||
│ │ │
|
||||
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
Phase 3: MCP Tool Access (Scope-based Authorization)
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
│ │ │
|
||||
│ 3a. list_tools request │ │
|
||||
├─────────────────────────────────>│ │
|
||||
│ Authorization: Bearer <token> │ │
|
||||
│ │ │
|
||||
│ │ 3b. Validate token │
|
||||
│ ├────────────────────────────────────>│
|
||||
│ │ GET /apps/oidc/userinfo │
|
||||
│ │ Authorization: Bearer <token> │
|
||||
│ │ │
|
||||
│ │ 3c. Token valid + scopes │
|
||||
│ │<────────────────────────────────────┤
|
||||
│ │ {sub, scopes, ...} │
|
||||
│ │ ← Cached for 1 hour │
|
||||
│ │ │
|
||||
│ 3d. Filtered tool list │ │
|
||||
│<─────────────────────────────────┤ ← Only tools matching user's │
|
||||
│ [tools matching token scopes] │ token scopes (via @require_scopes)
|
||||
│ │ │
|
||||
│ 3e. Call tool │ │
|
||||
├─────────────────────────────────>│ │
|
||||
│ nc_notes_get_note(note_id=1) │ ← @require_scopes("notes:read") │
|
||||
│ Authorization: Bearer <token> │ │
|
||||
│ │ │
|
||||
│ │ 3f. Scope check PASSED │
|
||||
│ │ ✓ Token has notes:read │
|
||||
│ │ │
|
||||
│ │ 3g. Nextcloud API call │
|
||||
│ ├────────────────────────────────────>│
|
||||
│ │ GET /apps/notes/api/v1/notes/1 │
|
||||
│ │ Authorization: Bearer <token> │
|
||||
│ │ ← user_oidc validates Bearer token │
|
||||
│ │ │
|
||||
│ │ 3h. API response │
|
||||
│ │<────────────────────────────────────┤
|
||||
│ │ {id: 1, title: "Note", ...} │
|
||||
│ │ │
|
||||
│ 3i. MCP tool response │ │
|
||||
│<─────────────────────────────────┤ │
|
||||
│ {note data} │ │
|
||||
│ │ │
|
||||
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
Insufficient Scope Example (Step-Up Authorization)
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
│ 4a. Call write tool │ │
|
||||
├─────────────────────────────────>│ │
|
||||
│ nc_notes_create_note(...) │ ← @require_scopes("notes:write") │
|
||||
│ Authorization: Bearer <token> │ │
|
||||
│ │ │
|
||||
│ │ 4b. Scope check FAILED │
|
||||
│ │ ✗ Token only has notes:read │
|
||||
│ │ │
|
||||
│ 4c. 403 Insufficient Scope │ │
|
||||
│<─────────────────────────────────┤ │
|
||||
│ WWW-Authenticate: Bearer │ │
|
||||
│ error="insufficient_scope", │ │
|
||||
│ scope="notes:write", │ │
|
||||
│ resource_metadata="..." │ │
|
||||
│ │ │
|
||||
│ → Client can re-authorize with │ │
|
||||
│ additional scopes (Step-Up) │ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### 1. MCP Client
|
||||
- Any MCP-compatible client (Claude Desktop, Claude Code, custom clients)
|
||||
### 1. MCP Client (e.g., Claude Desktop, Claude Code)
|
||||
|
||||
**Capabilities**:
|
||||
- Discovers OAuth configuration via MCP server
|
||||
- Queries PRM endpoint for supported scopes
|
||||
- Initiates OAuth flow with PKCE (Proof Key for Code Exchange)
|
||||
- Stores and sends access token with each request
|
||||
- **Example**: Claude Desktop, Claude Code
|
||||
- Handles scope-based tool filtering
|
||||
- Supports step-up authorization (re-auth for additional scopes)
|
||||
|
||||
### 2. MCP Server (Resource Server)
|
||||
- **Role**: OAuth 2.0 Resource Server
|
||||
- **Location**: This Nextcloud MCP Server implementation
|
||||
- **Responsibilities**:
|
||||
- Validates Bearer tokens by calling Nextcloud's userinfo endpoint
|
||||
- Caches validated tokens (default: 1 hour TTL)
|
||||
- Creates authenticated Nextcloud client instances per-user
|
||||
- Enforces PKCE requirements (S256 code challenge method)
|
||||
- Exposes Nextcloud functionality via MCP tools
|
||||
**Examples**: Claude Desktop, Claude Code, MCP Inspector, custom MCP clients
|
||||
|
||||
### 2. MCP Server (Resource Server - This Implementation)
|
||||
|
||||
**Role**: OAuth 2.0 Resource Server (RFC 6749)
|
||||
|
||||
**Responsibilities**:
|
||||
|
||||
#### Startup Phase
|
||||
- **OIDC Discovery**: Queries `/.well-known/openid-configuration` for OAuth endpoints
|
||||
- **PKCE Validation**: Verifies server advertises S256 code challenge method
|
||||
- **Dynamic Client Registration (DCR)**: Automatically registers OAuth client via `/apps/oidc/register` (RFC 7591)
|
||||
- Or loads pre-configured client credentials
|
||||
- Saves credentials to SQLite database
|
||||
- **Tool Registration**: Loads all MCP tools with their `@require_scopes` decorators
|
||||
|
||||
#### Client Connection Phase
|
||||
- **Auth Settings**: Returns OAuth issuer URL and resource URL
|
||||
- **PRM Endpoint**: Exposes `/.well-known/oauth-protected-resource/mcp` (RFC 9728)
|
||||
- Dynamically discovers scopes from all registered tools
|
||||
- Returns `scopes_supported` list based on `@require_scopes` decorators
|
||||
|
||||
#### Request Processing Phase
|
||||
- **Token Validation**: Validates Bearer tokens via Nextcloud userinfo endpoint
|
||||
- Supports both JWT and opaque tokens
|
||||
- Caches validation results (1-hour TTL)
|
||||
- Extracts user identity and granted scopes
|
||||
- **Scope Enforcement**:
|
||||
- Filters `list_tools` based on user's token scopes
|
||||
- Validates scopes before executing each tool
|
||||
- Returns 403 with `WWW-Authenticate` header for insufficient scopes
|
||||
- **Per-User Clients**: Creates authenticated `NextcloudClient` instance per user
|
||||
- Uses Bearer token for all Nextcloud API requests
|
||||
- User-specific permissions and audit trails
|
||||
|
||||
**Key Files**:
|
||||
- [`app.py`](../nextcloud_mcp_server/app.py) - OAuth mode detection and configuration
|
||||
- [`auth/token_verifier.py`](../nextcloud_mcp_server/auth/token_verifier.py) - Token validation logic
|
||||
- [`app.py`](../nextcloud_mcp_server/app.py) - OAuth mode, DCR, PRM endpoint
|
||||
- [`auth/token_verifier.py`](../nextcloud_mcp_server/auth/token_verifier.py) - Token validation (userinfo + introspection + JWT)
|
||||
- [`auth/context_helper.py`](../nextcloud_mcp_server/auth/context_helper.py) - Per-user client creation
|
||||
- [`auth/scope_authorization.py`](../nextcloud_mcp_server/auth/scope_authorization.py) - `@require_scopes` decorator, scope discovery
|
||||
- [`auth/client_registration.py`](../nextcloud_mcp_server/auth/client_registration.py) - DCR implementation (RFC 7591)
|
||||
|
||||
### 3. Nextcloud OIDC Apps
|
||||
|
||||
#### a) `oidc` - OIDC Identity Provider
|
||||
- **Role**: OAuth 2.0 Authorization Server
|
||||
- **Location**: Nextcloud app (`apps/oidc`)
|
||||
- **Endpoints**:
|
||||
- `/.well-known/openid-configuration` - Discovery endpoint
|
||||
- `/apps/oidc/authorize` - Authorization endpoint
|
||||
- `/apps/oidc/token` - Token endpoint
|
||||
- `/apps/oidc/userinfo` - User info endpoint (token validation)
|
||||
- `/apps/oidc/jwks` - JSON Web Key Set
|
||||
- `/apps/oidc/register` - Dynamic client registration
|
||||
|
||||
**Role**: OAuth 2.0 Authorization Server + OIDC Provider
|
||||
|
||||
**Location**: Nextcloud app (`apps/oidc`)
|
||||
|
||||
**Endpoints**:
|
||||
- `/.well-known/openid-configuration` - OIDC Discovery (RFC 8414)
|
||||
- `/apps/oidc/authorize` - Authorization endpoint (OAuth 2.0 + PKCE)
|
||||
- `/apps/oidc/token` - Token endpoint (issues JWT or opaque tokens)
|
||||
- `/apps/oidc/userinfo` - UserInfo endpoint (OIDC Core, used for token validation)
|
||||
- `/apps/oidc/jwks` - JSON Web Key Set (for JWT signature verification)
|
||||
- `/apps/oidc/register` - Dynamic Client Registration endpoint (RFC 7591)
|
||||
- `/apps/oidc/introspect` - Token Introspection endpoint (RFC 7662, optional)
|
||||
|
||||
**Token Types**:
|
||||
- **JWT tokens**: Self-contained tokens with embedded scopes, validated via JWKS or userinfo
|
||||
- **Opaque tokens**: Random strings, validated via userinfo or introspection endpoint
|
||||
|
||||
**Configuration**:
|
||||
```bash
|
||||
# Enable dynamic client registration (optional)
|
||||
# Settings → OIDC → "Allow dynamic client registration"
|
||||
# Enable dynamic client registration (recommended for development)
|
||||
# Nextcloud Admin → Settings → OIDC → "Allow dynamic client registration"
|
||||
|
||||
# Enable token introspection (optional, for opaque token validation)
|
||||
# Nextcloud Admin → Settings → OIDC → "Enable token introspection"
|
||||
```
|
||||
|
||||
#### b) `user_oidc` - OpenID Connect User Backend
|
||||
- **Role**: Bearer token validation middleware
|
||||
- **Location**: Nextcloud app (`apps/user_oidc`)
|
||||
- **Responsibilities**:
|
||||
- Validates Bearer tokens for Nextcloud API requests
|
||||
- Creates user sessions from valid Bearer tokens
|
||||
- Integrates with Nextcloud's authentication system
|
||||
|
||||
**Role**: Bearer token validation middleware for Nextcloud APIs
|
||||
|
||||
**Location**: Nextcloud app (`apps/user_oidc`)
|
||||
|
||||
**Responsibilities**:
|
||||
- Intercepts Nextcloud API requests with `Authorization: Bearer` header
|
||||
- Validates tokens against OIDC provider (`oidc` app)
|
||||
- Creates authenticated user sessions
|
||||
- Enforces user-specific permissions on API requests
|
||||
|
||||
**Configuration**:
|
||||
```bash
|
||||
# Enable Bearer token validation (required)
|
||||
# Enable Bearer token validation (required for OAuth mode)
|
||||
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The `user_oidc` app requires a patch to properly support Bearer token authentication for non-OCS endpoints. See [Upstream Status](oauth-upstream-status.md) for details.
|
||||
> The `user_oidc` app requires a patch to properly support Bearer token authentication for non-OCS endpoints (like Notes API, Calendar API). See [Upstream Status](oauth-upstream-status.md) for patch details and PR status.
|
||||
|
||||
### 4. Nextcloud Instance
|
||||
- **Role**: Resource Owner / API Provider
|
||||
- **Provides**: Notes, Calendar, Contacts, Deck, Files, etc.
|
||||
|
||||
**Role**: Resource Owner + API Provider
|
||||
|
||||
**APIs Exposed**:
|
||||
- **Notes API**: `/apps/notes/api/v1/` - Note CRUD operations
|
||||
- **Calendar (CalDAV)**: `/remote.php/dav/calendars/` - Events and todos
|
||||
- **Contacts (CardDAV)**: `/remote.php/dav/addressbooks/` - Contact management
|
||||
- **Cookbook API**: `/apps/cookbook/api/v1/` - Recipe management
|
||||
- **Deck API**: `/apps/deck/api/v1.0/` - Kanban boards
|
||||
- **Tables API**: `/apps/tables/api/2/` - Table row operations
|
||||
- **WebDAV (Files)**: `/remote.php/dav/files/` - File operations
|
||||
- **Sharing API**: `/ocs/v2.php/apps/files_sharing/api/v1/` - Share management
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
### Phase 1: OAuth Authorization (Steps 1-7)
|
||||
The OAuth flow consists of four distinct phases (see diagram above for visual representation):
|
||||
|
||||
1. **Client Connects**: MCP client connects to MCP server
|
||||
2. **Auth Settings**: MCP server returns OAuth settings:
|
||||
```json
|
||||
{
|
||||
"issuer_url": "https://nextcloud.example.com",
|
||||
"resource_server_url": "http://localhost:8000",
|
||||
"required_scopes": ["openid", "profile"]
|
||||
}
|
||||
```
|
||||
3. **OAuth Flow**: Client initiates OAuth flow with PKCE
|
||||
- Generates `code_verifier` (random string)
|
||||
- Calculates `code_challenge` = SHA256(code_verifier)
|
||||
- Redirects user to `/apps/oidc/authorize` with `code_challenge`
|
||||
4. **User Authentication**: User logs in to Nextcloud via browser
|
||||
5. **Authorization Code**: Nextcloud redirects back with authorization code
|
||||
6. **Token Exchange**: Client exchanges code for access token
|
||||
- Sends `code` + `code_verifier` to `/apps/oidc/token`
|
||||
- OIDC app validates PKCE challenge
|
||||
7. **Access Token**: Client receives access token (JWT or opaque)
|
||||
### Phase 0: MCP Server Startup (One-time Setup)
|
||||
|
||||
### Phase 2: API Access (Steps 8-13)
|
||||
**Happens**: On MCP server first startup
|
||||
|
||||
8. **API Request**: Client sends MCP request with Bearer token
|
||||
9. **Token Validation**: MCP server validates token:
|
||||
- Checks cache (1-hour TTL by default)
|
||||
- If not cached, calls `/apps/oidc/userinfo` with Bearer token
|
||||
- Extracts username from `sub` or `preferred_username` claim
|
||||
10. **User Info**: Nextcloud returns user info if token is valid
|
||||
11. **Nextcloud API Call**: MCP server calls Nextcloud API on behalf of user
|
||||
- Creates `NextcloudClient` instance with Bearer token
|
||||
- User-specific permissions apply
|
||||
12. **API Response**: Nextcloud returns data
|
||||
13. **MCP Response**: MCP server returns formatted response to client
|
||||
**Steps**:
|
||||
1. **OIDC Discovery** (`GET /.well-known/openid-configuration`)
|
||||
- MCP server queries Nextcloud for OAuth endpoints
|
||||
- Validates PKCE support (requires `S256` code challenge method)
|
||||
- Extracts endpoints: authorize, token, userinfo, jwks, register
|
||||
|
||||
2. **Dynamic Client Registration** (`POST /apps/oidc/register`)
|
||||
- If no pre-configured client credentials exist
|
||||
- MCP server registers itself as OAuth client (RFC 7591)
|
||||
- Provides: client name, redirect URIs, requested scopes, token type
|
||||
- Receives: `client_id`, `client_secret`
|
||||
- Saves credentials to SQLite database
|
||||
|
||||
3. **Tool Registration**
|
||||
- All MCP tools loaded with their `@require_scopes` decorators
|
||||
- Scope metadata stored for later discovery
|
||||
|
||||
**Result**: MCP server ready to accept client connections
|
||||
|
||||
### Phase 1: Client Discovery (Per MCP Client Connection)
|
||||
|
||||
**Happens**: When MCP client first connects
|
||||
|
||||
**Steps**:
|
||||
1. **MCP Connection**
|
||||
- Client connects to MCP server
|
||||
- Server returns OAuth auth settings (issuer URL, resource URL)
|
||||
|
||||
2. **PRM Discovery** (`GET /.well-known/oauth-protected-resource/mcp`)
|
||||
- Client queries Protected Resource Metadata endpoint (RFC 9728)
|
||||
- Server **dynamically discovers** scopes from all registered tools
|
||||
- Returns: resource URL, `scopes_supported` list, authorization servers
|
||||
- Client now knows which scopes are available
|
||||
|
||||
**Result**: Client knows OAuth configuration and available scopes
|
||||
|
||||
### Phase 2: OAuth Authorization (PKCE Flow - RFC 7636)
|
||||
|
||||
**Happens**: User authorizes access
|
||||
|
||||
**Steps**:
|
||||
1. **PKCE Challenge Generation** (Client-side)
|
||||
- Generate `code_verifier`: random 43-128 character string
|
||||
- Calculate `code_challenge`: `BASE64URL(SHA256(code_verifier))`
|
||||
|
||||
2. **Authorization Request** (`GET /apps/oidc/authorize`)
|
||||
- Client redirects user to Nextcloud consent page
|
||||
- Parameters:
|
||||
- `client_id`: OAuth client ID
|
||||
- `code_challenge`: SHA256 hash of verifier
|
||||
- `code_challenge_method`: `S256`
|
||||
- `scope`: Requested scopes (e.g., `openid notes:read notes:write`)
|
||||
- `redirect_uri`: MCP server callback URL
|
||||
|
||||
3. **User Consent**
|
||||
- User authenticates to Nextcloud (if not already logged in)
|
||||
- User reviews and approves/denies requested scopes
|
||||
- Can select subset of requested scopes
|
||||
|
||||
4. **Authorization Code**
|
||||
- Nextcloud redirects to `callback?code=xyz123`
|
||||
- Code is bound to PKCE challenge
|
||||
|
||||
5. **Token Exchange** (`POST /apps/oidc/token`)
|
||||
- Client sends:
|
||||
- Authorization `code`
|
||||
- `code_verifier` (proves possession of original challenge)
|
||||
- `client_id` and `client_secret`
|
||||
- Nextcloud validates PKCE challenge: `SHA256(code_verifier) == code_challenge`
|
||||
- Nextcloud issues access token
|
||||
|
||||
6. **Access Token Response**
|
||||
- Token type: JWT or opaque (configurable)
|
||||
- Contains user's **granted scopes** (may be subset of requested)
|
||||
- Client stores token for subsequent requests
|
||||
|
||||
**Result**: Client has valid access token with granted scopes
|
||||
|
||||
### Phase 3: MCP Tool Access (Scope-Based Authorization)
|
||||
|
||||
**Happens**: Every MCP tool invocation
|
||||
|
||||
**Steps**:
|
||||
|
||||
#### Tool Listing (`list_tools`)
|
||||
1. **List Tools Request**
|
||||
- Client sends `list_tools` with `Authorization: Bearer <token>`
|
||||
|
||||
2. **Token Validation**
|
||||
- MCP server calls `/apps/oidc/userinfo` with Bearer token
|
||||
- Nextcloud returns user info including **granted scopes**
|
||||
- Result cached for 1 hour
|
||||
|
||||
3. **Dynamic Tool Filtering**
|
||||
- Server compares token scopes with each tool's `@require_scopes`
|
||||
- Only returns tools where user has all required scopes
|
||||
- Example: Token with `notes:read` sees 4 read tools, not 3 write tools
|
||||
|
||||
4. **Filtered Tool List**
|
||||
- Client receives only tools they can use
|
||||
|
||||
#### Tool Execution (e.g., `nc_notes_get_note`)
|
||||
1. **Tool Call**
|
||||
- Client invokes tool with `Authorization: Bearer <token>`
|
||||
|
||||
2. **Scope Validation**
|
||||
- `@require_scopes` decorator extracts token scopes
|
||||
- Verifies token contains required scope (e.g., `notes:read`)
|
||||
- If missing → 403 with `WWW-Authenticate` header (step-up auth)
|
||||
- If present → continues execution
|
||||
|
||||
3. **Nextcloud API Call**
|
||||
- MCP server creates `NextcloudClient` with Bearer token
|
||||
- Calls Nextcloud API (e.g., `GET /apps/notes/api/v1/notes/1`)
|
||||
- `user_oidc` app validates Bearer token again
|
||||
- Request executes as authenticated user
|
||||
|
||||
4. **Response**
|
||||
- Nextcloud returns data
|
||||
- MCP server formats response
|
||||
- Returns to client
|
||||
|
||||
**Result**: User can only access tools and data they have permissions for
|
||||
|
||||
### Phase 4: Insufficient Scope Handling (Step-Up Authorization)
|
||||
|
||||
**Happens**: When user lacks required scopes
|
||||
|
||||
**Steps**:
|
||||
1. **Tool Call with Insufficient Scopes**
|
||||
- User calls `nc_notes_create_note` (requires `notes:write`)
|
||||
- But token only has `notes:read`
|
||||
|
||||
2. **Scope Validation Fails**
|
||||
- `@require_scopes("notes:write")` decorator checks token
|
||||
- Finds `notes:write` missing
|
||||
|
||||
3. **403 Response with Challenge**
|
||||
- Returns `403 Forbidden`
|
||||
- Includes `WWW-Authenticate` header:
|
||||
```
|
||||
Bearer error="insufficient_scope",
|
||||
scope="notes:write",
|
||||
resource_metadata="http://localhost:8000/.well-known/oauth-protected-resource/mcp"
|
||||
```
|
||||
|
||||
4. **Client Re-Authorization** (Optional)
|
||||
- Client can initiate new OAuth flow requesting additional scopes
|
||||
- User re-consents with expanded permissions
|
||||
- New token includes both `notes:read` and `notes:write`
|
||||
|
||||
**Result**: User can dynamically upgrade permissions without full re-authentication
|
||||
|
||||
## Token Validation
|
||||
|
||||
@@ -218,7 +515,7 @@ NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||
**How it works**:
|
||||
1. Server checks `/.well-known/openid-configuration` for `registration_endpoint`
|
||||
2. Calls `/apps/oidc/register` to register a client on first startup
|
||||
3. Saves credentials to `.nextcloud_oauth_client.json`
|
||||
3. Saves credentials to SQLite database
|
||||
4. Reuses these credentials on subsequent startups
|
||||
5. Re-registers only if credentials are missing or expired
|
||||
|
||||
@@ -272,14 +569,145 @@ client = get_client_from_context(ctx)
|
||||
- Protects against authorization code interception
|
||||
|
||||
### Scopes
|
||||
- Required scopes: `openid`, `profile`
|
||||
- Additional scopes inferred from userinfo response
|
||||
- Base required scopes: `openid`, `profile`, `email`
|
||||
- App-specific scopes control access to individual Nextcloud apps
|
||||
- See [OAuth Scopes](#oauth-scopes) section for complete scope reference
|
||||
|
||||
### Token Validation
|
||||
- Every MCP request validates Bearer token
|
||||
- Cached for performance (1-hour default)
|
||||
- Calls userinfo endpoint for validation
|
||||
|
||||
## OAuth Scopes
|
||||
|
||||
The Nextcloud MCP Server implements fine-grained OAuth scopes for each Nextcloud app integration. Scopes control which tools are visible and accessible to users based on their granted permissions.
|
||||
|
||||
### Scope-Based Access Control
|
||||
|
||||
When using OAuth authentication:
|
||||
1. **Dynamic Discovery**: The server automatically discovers all required scopes from `@require_scopes` decorators on MCP tools
|
||||
2. **Tool Filtering**: Tools are dynamically filtered based on the user's token scopes - users only see tools they have permission to use
|
||||
3. **Per-Tool Enforcement**: Each tool validates required scopes before execution, returning a 403 error if insufficient scopes are present
|
||||
|
||||
### Supported Scopes
|
||||
|
||||
The server supports the following OAuth scopes, organized by Nextcloud app:
|
||||
|
||||
#### Base OIDC Scopes
|
||||
- `openid` - OpenID Connect authentication (required)
|
||||
- `profile` - Access to user profile information (required)
|
||||
- `email` - Access to user email address (required)
|
||||
|
||||
#### Notes App
|
||||
- `notes:read` - Read notes, search notes, get note attachments
|
||||
- `notes:write` - Create, update, append to, and delete notes
|
||||
|
||||
#### Calendar App
|
||||
- `calendar:read` - List calendars, read events, search events
|
||||
- `calendar:write` - Create, update, and delete calendars and events
|
||||
|
||||
#### Calendar Tasks (VTODO)
|
||||
- `todo:read` - List and read CalDAV tasks
|
||||
- `todo:write` - Create, update, and delete CalDAV tasks
|
||||
|
||||
#### Contacts App
|
||||
- `contacts:read` - List address books and read contacts (CardDAV)
|
||||
- `contacts:write` - Create, update, and delete address books and contacts
|
||||
|
||||
#### Cookbook App
|
||||
- `cookbook:read` - Read recipes, search recipes
|
||||
- `cookbook:write` - Create, update, and delete recipes
|
||||
|
||||
#### Deck App
|
||||
- `deck:read` - List boards, stacks, cards, and labels
|
||||
- `deck:write` - Create, update, and delete boards, stacks, cards, and labels
|
||||
|
||||
#### Tables App
|
||||
- `tables:read` - List tables and read rows
|
||||
- `tables:write` - Create, update, and delete rows in tables
|
||||
|
||||
#### Files (WebDAV)
|
||||
- `files:read` - List files, read file contents, search files
|
||||
- `files:write` - Upload, update, move, copy, and delete files
|
||||
|
||||
#### Sharing
|
||||
- `sharing:read` - List shares and read share information
|
||||
- `sharing:write` - Create, update, and delete shares
|
||||
|
||||
### Scope Discovery
|
||||
|
||||
The MCP server provides scope discovery through two mechanisms:
|
||||
|
||||
#### 1. Protected Resource Metadata (PRM) Endpoint
|
||||
```bash
|
||||
# Query the PRM endpoint
|
||||
curl http://localhost:8000/.well-known/oauth-protected-resource/mcp
|
||||
|
||||
# Response includes dynamically discovered scopes
|
||||
{
|
||||
"resource": "http://localhost:8000/mcp",
|
||||
"scopes_supported": ["openid", "profile", "email", "notes:read", ...],
|
||||
"authorization_servers": ["https://nextcloud.example.com"],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_signing_alg_values_supported": ["RS256"]
|
||||
}
|
||||
```
|
||||
|
||||
The `scopes_supported` field is **dynamically generated** from all registered MCP tools, ensuring it always reflects the actual available scopes.
|
||||
|
||||
#### 2. Scope Enforcement via Decorators
|
||||
|
||||
Tools are decorated with `@require_scopes()` to declare their required permissions:
|
||||
|
||||
```python
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_get_note(ctx: Context, note_id: int):
|
||||
"""Get a specific note by ID"""
|
||||
# Implementation
|
||||
```
|
||||
|
||||
### Client Registration Scopes
|
||||
|
||||
During OAuth client registration (dynamic or manual), clients request a set of scopes that define the **maximum allowed** scopes for that client. The actual per-tool enforcement is handled separately via decorators.
|
||||
|
||||
**Environment Variable**:
|
||||
```bash
|
||||
NEXTCLOUD_OIDC_SCOPES="openid profile email notes:read notes:write calendar:read calendar:write ..."
|
||||
```
|
||||
|
||||
**Default**: All supported scopes (recommended for development)
|
||||
|
||||
> **Note**: Client registration scopes define the maximum permissions. The MCP server's PRM endpoint dynamically advertises the actual supported scopes based on registered tools.
|
||||
|
||||
### Step-Up Authorization
|
||||
|
||||
The server supports OAuth step-up authorization (RFC 8693). If a user attempts to use a tool requiring scopes they don't have:
|
||||
|
||||
1. Tool returns `403 Forbidden` with `InsufficientScopeError`
|
||||
2. Response includes `WWW-Authenticate` header listing missing scopes:
|
||||
```
|
||||
WWW-Authenticate: Bearer error="insufficient_scope", scope="notes:write", resource_metadata="..."
|
||||
```
|
||||
3. Client can re-authorize with additional scopes
|
||||
|
||||
### Scope Validation
|
||||
|
||||
All scope enforcement happens at two levels:
|
||||
|
||||
1. **Tool Visibility**: During `list_tools` requests, only tools matching the user's token scopes are returned
|
||||
2. **Execution Time**: When calling a tool, the `@require_scopes` decorator validates the token has necessary scopes
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
# User token has: ["openid", "profile", "email", "notes:read"]
|
||||
# They will see: 4 read-only notes tools
|
||||
# They will NOT see: 3 write notes tools (notes:write required)
|
||||
# Attempting to call a write tool returns 403 Forbidden
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
See [Configuration Guide](configuration.md) for all OAuth environment variables:
|
||||
@@ -290,7 +718,6 @@ See [Configuration Guide](configuration.md) for all OAuth environment variables:
|
||||
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured client ID (optional) |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured client secret (optional) |
|
||||
| `NEXTCLOUD_MCP_SERVER_URL` | MCP server URL for OAuth callbacks |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | Path for auto-registered credentials |
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
# OAuth Impersonation Investigation Findings
|
||||
|
||||
**Date**: 2025-11-02
|
||||
**Last Updated**: 2025-11-02 (Token Exchange Resolution)
|
||||
**Status**: Implementation Complete - Token Exchange Working
|
||||
**Conclusion**: Keycloak Standard Token Exchange (RFC 8693) working for internal-to-internal token exchange. User impersonation requires Legacy V1.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ IMPORTANT UPDATE (2025-11-02)
|
||||
|
||||
**This document contains outdated information regarding service account tokens.**
|
||||
|
||||
After implementation and testing, we discovered that service account tokens (`client_credentials` grant) **violate OAuth "act on-behalf-of" principles** by creating Nextcloud user accounts (e.g., `service-account-nextcloud-mcp-server`). This approach has been **REJECTED** and moved to ADR-002's "Will Not Implement" section.
|
||||
|
||||
**Key Changes:**
|
||||
- ❌ **Service account tokens (client_credentials) are INVALID** - Creates user accounts, breaks audit trail
|
||||
- ✅ **Token exchange (RFC 8693) is the correct approach** - Implemented and working (ADR-002 Tier 2)
|
||||
- ✅ **Offline access with refresh tokens** - Still valid for background operations (ADR-002 primary approach)
|
||||
|
||||
**For current architecture, see**: `docs/ADR-002-vector-sync-authentication.md`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
We investigated options for implementing user impersonation to enable background operations without requiring admin credentials (ADR-002 Tier 2). Here are the findings:
|
||||
|
||||
## 1. Keycloak Token Exchange (RFC 8693)
|
||||
|
||||
### What We Implemented
|
||||
- ✅ Service account token acquisition (`client_credentials` grant)
|
||||
- ✅ `get_service_account_token()` method in `KeycloakOAuthClient`
|
||||
- ✅ `exchange_token_for_user()` method implementing RFC 8693
|
||||
- ✅ Token exchange configuration in Keycloak realm
|
||||
|
||||
### What Works ✅
|
||||
**Keycloak Standard V2 Token Exchange (RFC 8693) is WORKING**:
|
||||
- ✅ Service account token acquisition via `client_credentials` grant
|
||||
- ✅ Token exchange for internal-to-internal tokens
|
||||
- ✅ Audience and scope modifications
|
||||
- ✅ Integration with Nextcloud APIs using exchanged tokens
|
||||
|
||||
**Configuration Requirements**:
|
||||
To enable Standard Token Exchange in Keycloak 26.2+, add to client attributes in `realm-export.json`:
|
||||
```json
|
||||
"attributes": {
|
||||
"token.exchange.grant.enabled": "true",
|
||||
"client.token.exchange.standard.enabled": "true"
|
||||
}
|
||||
```
|
||||
|
||||
### Limitations
|
||||
Keycloak Standard V2 does NOT support:
|
||||
- ❌ User impersonation (`requested_subject` parameter)
|
||||
- ❌ Cross-client delegation (limited to same realm)
|
||||
|
||||
These features require Legacy V1 with `--features=preview`
|
||||
|
||||
### Alternative: Keycloak Legacy V1
|
||||
Keycloak Legacy Token Exchange (V1) WOULD support user impersonation, but:
|
||||
- ❌ Requires `--features=preview --features=token-exchange` flag
|
||||
- ❌ Not suitable for production
|
||||
- ❌ Deprecated and being phased out
|
||||
|
||||
**Decision**: Not viable for production use.
|
||||
|
||||
---
|
||||
|
||||
## 2. Nextcloud OIDC App Token Exchange
|
||||
|
||||
### Discovery Endpoint Analysis
|
||||
```json
|
||||
{
|
||||
"grant_types_supported": [
|
||||
"authorization_code",
|
||||
"implicit"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Findings
|
||||
❌ **Nextcloud OIDC app does NOT support**:
|
||||
- RFC 8693 token exchange
|
||||
- `client_credentials` grant
|
||||
- `refresh_token` grant (refresh tokens not issued)
|
||||
- User impersonation APIs
|
||||
|
||||
The Nextcloud OIDC app is a basic OAuth 2.0 provider focused on:
|
||||
- Authorization code flow for user login
|
||||
- JWT tokens for API access
|
||||
- Scope-based authorization
|
||||
|
||||
It is NOT designed for:
|
||||
- Service accounts
|
||||
- Token delegation
|
||||
- Background operations
|
||||
|
||||
**Decision**: Not viable - missing required grant types.
|
||||
|
||||
---
|
||||
|
||||
## 3. Nextcloud Impersonate App
|
||||
|
||||
### What It Provides
|
||||
✅ Admin users can impersonate other users via:
|
||||
- UI: Settings → Users → Impersonate button
|
||||
- API: `POST /apps/impersonate/user` with `userId` parameter
|
||||
|
||||
### How It Works
|
||||
```php
|
||||
// From SettingsController.php
|
||||
public function impersonate(string $userId): JSONResponse {
|
||||
// 1. Verify admin/delegated admin permissions
|
||||
// 2. Check target user has logged in before
|
||||
// 3. Set session: $this->userSession->setUser($impersonatee)
|
||||
// 4. Return success
|
||||
}
|
||||
```
|
||||
|
||||
### Requirements
|
||||
- ✅ Admin credentials
|
||||
- ✅ Session-based authentication (cookies)
|
||||
- ✅ CSRF token
|
||||
- ✅ Target user must have logged in at least once
|
||||
- ❌ Not compatible with encryption-enabled instances
|
||||
|
||||
### Limitations for Background Workers
|
||||
❌ **Session-based, not stateless**:
|
||||
- Requires maintaining HTTP session/cookies
|
||||
- Not suitable for distributed workers
|
||||
- Can't use with bearer tokens
|
||||
- Requires re-authentication periodically
|
||||
|
||||
❌ **Security concerns**:
|
||||
- Requires admin credentials stored on server
|
||||
- All impersonated actions logged as target user
|
||||
- Violates principle of least privilege
|
||||
|
||||
**Decision**: Not suitable for background operations - session-based architecture incompatible with stateless OAuth/bearer token model.
|
||||
|
||||
---
|
||||
|
||||
## 4. What Actually Works
|
||||
|
||||
### Option A: Admin Credentials (Current Implementation)
|
||||
✅ **BasicAuth mode with admin account**:
|
||||
```python
|
||||
client = NextcloudClient.from_env() # Uses NEXTCLOUD_USERNAME/PASSWORD
|
||||
# Can access all APIs with admin permissions
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- Simple, works immediately
|
||||
- Full access to all APIs
|
||||
|
||||
**Cons**:
|
||||
- Requires admin credentials stored on server
|
||||
- No per-user permission scoping
|
||||
- Security risk if credentials leaked
|
||||
- Violates ADR-002 goals
|
||||
|
||||
**Status**: Available but not recommended for production.
|
||||
|
||||
### Option B: Service Account with Scoped Permissions
|
||||
✅ **Create dedicated service account**:
|
||||
1. Create `mcp-sync` user in Nextcloud
|
||||
2. Grant specific permissions (group memberships, shares)
|
||||
3. Use those credentials for background operations
|
||||
|
||||
**Pros**:
|
||||
- Dedicated account, easier to audit
|
||||
- Can limit permissions via Nextcloud groups
|
||||
- Works with current BasicAuth implementation
|
||||
|
||||
**Cons**:
|
||||
- Still requires credentials storage
|
||||
- Can't truly act "as" individual users
|
||||
- Limited by Nextcloud's permission model
|
||||
|
||||
**Status**: Best available option without OAuth delegation.
|
||||
|
||||
---
|
||||
|
||||
## 5. Recommendations
|
||||
|
||||
### Short Term (Immediate)
|
||||
**Use Service Account Pattern**:
|
||||
```python
|
||||
# Background worker configuration
|
||||
SYNC_ACCOUNT_USERNAME=mcp-sync
|
||||
SYNC_ACCOUNT_PASSWORD=<secure-password>
|
||||
|
||||
# Create service account with limited permissions
|
||||
docker compose exec app php occ user:add mcp-sync
|
||||
docker compose exec app php occ group:adduser <appropriate-group> mcp-sync
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Works with existing implementation
|
||||
- Better than admin credentials
|
||||
- Auditable
|
||||
|
||||
### Medium Term (If OAuth Delegation Required)
|
||||
**Wait for proper standards support**:
|
||||
- Monitor Keycloak for Standard V2 improvements
|
||||
- Contribute to/request Nextcloud OIDC app enhancements
|
||||
- Consider alternative identity providers (e.g., Authelia, Authentik)
|
||||
|
||||
### Long Term (Ideal Solution)
|
||||
**Implement proper OAuth delegation**:
|
||||
1. Use identity provider that supports RFC 8693 properly (e.g., Auth0, Okta)
|
||||
2. Or implement custom delegation endpoint in Nextcloud
|
||||
3. Or propose MCP protocol extension for refresh token sharing
|
||||
|
||||
---
|
||||
|
||||
## 6. Updated ADR-002 Status
|
||||
|
||||
| Tier | Solution | Status | Viability |
|
||||
|------|----------|--------|-----------|
|
||||
| **Tier 0** | Admin BasicAuth | ✅ Implemented | ⚠️ Works but not recommended |
|
||||
| **Tier 1** | Offline Access (Refresh Tokens) | ⚠️ Infrastructure ready | ❌ MCP protocol limitation |
|
||||
| **Tier 2** | Token Exchange (RFC 8693) | ✅ **WORKING** | ✅ **Internal token exchange functional** |
|
||||
| **Tier 3** | Service Account (NEW) | ✅ Available | ✅ **RECOMMENDED for background ops** |
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation Status
|
||||
|
||||
### What Was Built
|
||||
1. ✅ `RefreshTokenStorage` - SQLite + encryption (ready for future use)
|
||||
2. ✅ `KeycloakOAuthClient.get_service_account_token()` - Works
|
||||
3. ✅ `KeycloakOAuthClient.exchange_token_for_user()` - Implemented but non-functional
|
||||
4. ✅ Token exchange configuration - Keycloak realm updated
|
||||
5. ✅ Test scripts - Comprehensive testing completed
|
||||
|
||||
### What to Use
|
||||
**For Background Operations**:
|
||||
```python
|
||||
# Use service account with BasicAuth
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
# In background worker
|
||||
sync_client = NextcloudClient(
|
||||
base_url=os.getenv("NEXTCLOUD_HOST"),
|
||||
username=os.getenv("SYNC_ACCOUNT_USERNAME"),
|
||||
password=os.getenv("SYNC_ACCOUNT_PASSWORD"),
|
||||
)
|
||||
|
||||
# Perform operations
|
||||
notes = await sync_client.notes.search_notes("important")
|
||||
# Index to vector database, etc.
|
||||
```
|
||||
|
||||
**For User Requests**:
|
||||
```python
|
||||
# Continue using OAuth bearer tokens
|
||||
# Per-request client creation as currently implemented
|
||||
client = get_client_from_context(ctx, nextcloud_host)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Files Modified/Created
|
||||
|
||||
### Implementation
|
||||
- `nextcloud_mcp_server/auth/keycloak_oauth.py` - Token exchange methods
|
||||
- `nextcloud_mcp_server/auth/refresh_token_storage.py` - Token storage (ready for future)
|
||||
- `nextcloud_mcp_server/app.py` - OAuth configuration updates
|
||||
- `keycloak/realm-export.json` - Token exchange enabled
|
||||
- `pyproject.toml` - Added aiosqlite dependency
|
||||
|
||||
### Documentation
|
||||
- `docs/oauth-impersonation-findings.md` - This document
|
||||
- `docs/ADR-002-vector-sync-authentication.md` - Original architecture decision
|
||||
|
||||
### Tests
|
||||
- `tests/manual/test_token_exchange.py` - Keycloak RFC 8693 testing
|
||||
- `tests/manual/test_nextcloud_impersonate.py` - Nextcloud impersonate API testing
|
||||
|
||||
---
|
||||
|
||||
## 9. Conclusion
|
||||
|
||||
**Neither Keycloak nor Nextcloud currently provide viable OAuth-based user impersonation for background operations.**
|
||||
|
||||
The infrastructure is ready (token storage, exchange methods), but provider limitations prevent use.
|
||||
|
||||
**Recommended approach**: Use dedicated service account with appropriate Nextcloud permissions for background operations until proper OAuth delegation becomes available.
|
||||
|
||||
The implemented code remains valuable:
|
||||
- Ready for future when providers add support
|
||||
- Demonstrates proper OAuth patterns
|
||||
- Test infrastructure for validation
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Technical Details
|
||||
|
||||
### Keycloak Configuration Applied
|
||||
```json
|
||||
{
|
||||
"clientId": "nextcloud-mcp-server",
|
||||
"serviceAccountsEnabled": true,
|
||||
"attributes": {
|
||||
"token.exchange.grant.enabled": "true"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test Results - UPDATED (2025-11-02)
|
||||
```
|
||||
✅ Service account token acquisition: WORKS
|
||||
✅ Token exchange discovery: SUPPORTED
|
||||
✅ Token exchange configuration: ENABLED
|
||||
✅ Actual token exchange: WORKS (after adding client.token.exchange.standard.enabled)
|
||||
✅ Nextcloud API access: WORKS with exchanged tokens
|
||||
```
|
||||
|
||||
**Resolution**: The realm-export.json was missing the `client.token.exchange.standard.enabled` attribute. After adding this attribute to keycloak/realm-export.json:128, token exchange works correctly on fresh Keycloak imports.
|
||||
|
||||
### Nextcloud Impersonate Results
|
||||
```
|
||||
✓ App installation: SUCCESS
|
||||
✓ Admin can impersonate: YES (session-based)
|
||||
✗ Bearer token impersonate: NO (requires session cookies)
|
||||
✗ Stateless impersonate: NOT AVAILABLE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Token Exchange Resolution (2025-11-02)
|
||||
|
||||
### Problem
|
||||
Initial token exchange implementation was failing with:
|
||||
```
|
||||
"Standard token exchange is not enabled for the requested client"
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
The `realm-export.json` was missing a critical attribute for Keycloak 26.2+ Standard Token Exchange:
|
||||
- Had: `"token.exchange.grant.enabled": "true"` ✓
|
||||
- Missing: `"client.token.exchange.standard.enabled": "true"` ❌
|
||||
|
||||
### Fix Applied
|
||||
Updated `keycloak/realm-export.json` at line 128 to include both attributes:
|
||||
```json
|
||||
"attributes": {
|
||||
"pkce.code.challenge.method": "S256",
|
||||
"use.refresh.tokens": "true",
|
||||
"backchannel.logout.session.required": "true",
|
||||
"backchannel.logout.url": "http://app:80/index.php/apps/user_oidc/backchannel-logout/keycloak",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"oidc.ciba.grant.enabled": "false",
|
||||
"client_credentials.use_refresh_token": "false",
|
||||
"display.on.consent.screen": "false",
|
||||
"token.exchange.grant.enabled": "true",
|
||||
"client.token.exchange.standard.enabled": "true" // ADDED
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
After recreating Keycloak with fresh realm import:
|
||||
```bash
|
||||
$ docker compose down -v keycloak && docker compose up -d keycloak
|
||||
$ uv run python tests/manual/test_token_exchange.py
|
||||
✅ Token Exchange Test PASSED
|
||||
```
|
||||
|
||||
### Current Status
|
||||
- ✅ RFC 8693 Token Exchange fully functional
|
||||
- ✅ Service account token acquisition works
|
||||
- ✅ Token exchange for internal tokens works
|
||||
- ✅ Exchanged tokens validate with Nextcloud APIs
|
||||
- ✅ Realm import automatically applies correct configuration
|
||||
- ⚠️ User impersonation still requires Keycloak Legacy V1
|
||||
|
||||
### Files Modified
|
||||
- `keycloak/realm-export.json` - Added `client.token.exchange.standard.enabled` attribute
|
||||
- `docs/oauth-impersonation-findings.md` - Updated with resolution
|
||||
|
||||
### Testing
|
||||
Run the complete token exchange flow:
|
||||
```bash
|
||||
uv run python tests/manual/test_token_exchange.py
|
||||
```
|
||||
+5
-9
@@ -170,7 +170,7 @@ You have two options for managing OAuth clients:
|
||||
**How it works**:
|
||||
- MCP server automatically registers an OAuth client on first startup
|
||||
- Uses Nextcloud's dynamic client registration endpoint
|
||||
- Saves credentials to `.nextcloud_oauth_client.json`
|
||||
- Saves credentials to SQLite database
|
||||
- Reuses stored credentials on subsequent restarts
|
||||
- Re-registers automatically if credentials expire
|
||||
|
||||
@@ -253,9 +253,6 @@ NEXTCLOUD_PASSWORD=
|
||||
|
||||
# Optional: MCP server URL (for OAuth callbacks)
|
||||
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||
|
||||
# Optional: Client storage path
|
||||
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
|
||||
EOF
|
||||
```
|
||||
|
||||
@@ -291,7 +288,6 @@ EOF
|
||||
| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Mode B only | - | OAuth client ID |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Mode B only | - | OAuth client secret |
|
||||
| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for callbacks |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Client credentials storage path |
|
||||
| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty for OAuth |
|
||||
| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty for OAuth |
|
||||
|
||||
@@ -334,7 +330,7 @@ INFO OIDC discovery successful
|
||||
INFO Attempting dynamic client registration...
|
||||
INFO Dynamic client registration successful
|
||||
INFO OAuth client ready: <client-id>...
|
||||
INFO Saved OAuth client credentials to .nextcloud_oauth_client.json
|
||||
INFO Saved OAuth client credentials to SQLite database
|
||||
INFO OAuth initialization complete
|
||||
INFO MCP server ready at http://127.0.0.1:8000
|
||||
```
|
||||
@@ -427,9 +423,9 @@ uv run nextcloud-mcp-server --oauth --log-level debug
|
||||
|
||||
2. **Secure Credential Storage**
|
||||
```bash
|
||||
# Set restrictive permissions
|
||||
chmod 600 .nextcloud_oauth_client.json
|
||||
# Set restrictive permissions on environment file
|
||||
chmod 600 .env
|
||||
# Database permissions are handled automatically
|
||||
```
|
||||
|
||||
3. **Use HTTPS for MCP Server**
|
||||
@@ -474,7 +470,7 @@ services:
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET: ${NEXTCLOUD_OIDC_CLIENT_SECRET}
|
||||
NEXTCLOUD_MCP_SERVER_URL: http://your-server:8000
|
||||
volumes:
|
||||
- ./oauth_client.json:/app/.nextcloud_oauth_client.json
|
||||
- ./data:/app/data # For SQLite database persistence
|
||||
command: ["--oauth", "--transport", "streamable-http"]
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
+108
-20
@@ -14,9 +14,10 @@ Start here to identify your issue:
|
||||
| "OAuth mode requires client credentials OR dynamic registration" | OIDC apps not configured | [Missing OIDC Apps](#missing-or-misconfigured-oidc-apps) |
|
||||
| "PKCE support validation failed" | OIDC app doesn't advertise PKCE | [PKCE Not Advertised](#pkce-not-advertised) |
|
||||
| "Stored client has expired" | Dynamic client expired | [Client Expired](#client-expired) |
|
||||
| Only seeing Notes tools (7 instead of 90+) | Limited OAuth scopes granted | [Limited Scopes](#limited-scopes---only-seeing-notes-tools) |
|
||||
| HTTP 401 for Notes API | Bearer token patch missing | [Bearer Token Auth Fails](#bearer-token-authentication-fails) |
|
||||
| "OIDC discovery failed" | Network or configuration issue | [Discovery Failed](#oidc-discovery-failed) |
|
||||
| "Permission denied" on .nextcloud_oauth_client.json | File permissions issue | [File Permission Error](#file-permission-error) |
|
||||
| "Database error" on OAuth client storage | Database permissions issue | [Database Permission Error](#database-permission-error) |
|
||||
|
||||
## Configuration Issues
|
||||
|
||||
@@ -160,39 +161,38 @@ php occ config:app:set oidc expire_time --value "86400" # 24 hours
|
||||
|
||||
---
|
||||
|
||||
### File Permission Error
|
||||
### Database Permission Error
|
||||
|
||||
**Error Message**:
|
||||
```
|
||||
Permission denied when reading/writing .nextcloud_oauth_client.json
|
||||
Permission denied when accessing SQLite database
|
||||
Database is locked
|
||||
```
|
||||
|
||||
**Cause**: The server cannot access the OAuth client storage file.
|
||||
**Cause**: The server cannot access the SQLite database file.
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# Check file permissions
|
||||
ls -la .nextcloud_oauth_client.json
|
||||
|
||||
# Fix file permissions (owner read/write only)
|
||||
chmod 600 .nextcloud_oauth_client.json
|
||||
# Check database directory permissions
|
||||
ls -la /app/data/
|
||||
|
||||
# Ensure directory is writable
|
||||
chmod 755 $(dirname .nextcloud_oauth_client.json)
|
||||
chmod 755 /app/data
|
||||
|
||||
# If file doesn't exist, ensure directory is writable
|
||||
mkdir -p $(dirname .nextcloud_oauth_client.json)
|
||||
# Check if database file exists and has correct permissions
|
||||
ls -la /app/data/tokens.db
|
||||
chmod 644 /app/data/tokens.db
|
||||
|
||||
# If running in Docker, ensure volume is mounted correctly
|
||||
docker compose logs mcp-oauth | grep -i "database\|sqlite"
|
||||
```
|
||||
|
||||
For custom storage paths:
|
||||
```bash
|
||||
# Set custom path in .env
|
||||
NEXTCLOUD_OIDC_CLIENT_STORAGE=/path/to/custom/oauth_client.json
|
||||
|
||||
# Ensure directory exists and is writable
|
||||
mkdir -p $(dirname /path/to/custom/oauth_client.json)
|
||||
chmod 755 $(dirname /path/to/custom/oauth_client.json)
|
||||
**For Docker deployments**:
|
||||
Ensure the data directory is properly mounted as a volume:
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data:/app/data # Persistent storage for SQLite database
|
||||
```
|
||||
|
||||
---
|
||||
@@ -407,6 +407,94 @@ http://localhost:8000/oauth/callback
|
||||
|
||||
---
|
||||
|
||||
### Limited Scopes - Only Seeing Notes Tools
|
||||
|
||||
**Symptoms**:
|
||||
- MCP client (e.g., Claude Code) successfully connects via OAuth
|
||||
- Only Notes tools are available (7 tools instead of 90+)
|
||||
- Token scopes show only `mcp:notes:read` and `mcp:notes:write`
|
||||
|
||||
**Cause**: During the OAuth consent flow, the user only granted access to Notes scopes, or the client only requested those scopes.
|
||||
|
||||
**Diagnosis**:
|
||||
|
||||
Check what scopes the client has been granted:
|
||||
|
||||
```bash
|
||||
# View registered clients and their allowed scopes
|
||||
php occ oidc:list | jq '.[] | select(.name | contains("Claude Code")) | {name, allowed_scopes}'
|
||||
```
|
||||
|
||||
Look for the client's `allowed_scopes` field. If it's empty or only contains notes scopes, that's the issue.
|
||||
|
||||
**Solution**:
|
||||
|
||||
**Option 1: Delete Client and Reconnect** (Recommended for MCP clients)
|
||||
|
||||
```bash
|
||||
# Find the client ID
|
||||
php occ oidc:list | jq '.[] | select(.name | contains("Claude Code")) | {name, client_id}'
|
||||
|
||||
# Delete the client
|
||||
php occ oidc:delete <client_id>
|
||||
|
||||
# Reconnect from Claude Code
|
||||
# This will trigger a new OAuth flow where you can grant all scopes
|
||||
```
|
||||
|
||||
When reconnecting, you'll see a consent screen listing all available scopes. Make sure to approve all the scopes you want the client to access.
|
||||
|
||||
**Option 2: Update Client Scopes via CLI**
|
||||
|
||||
```bash
|
||||
# Update allowed scopes for an existing client
|
||||
php occ oidc:update <client_id> \
|
||||
--allowed-scopes "openid profile email mcp:notes:read mcp:notes:write mcp:calendar:read mcp:calendar:write mcp:contacts:read mcp:contacts:write mcp:cookbook:read mcp:cookbook:write mcp:deck:read mcp:deck:write mcp:tables:read mcp:tables:write mcp:files:read mcp:files:write mcp:sharing:read mcp:sharing:write"
|
||||
|
||||
# User will need to reconnect to get new token with updated scopes
|
||||
```
|
||||
|
||||
**Verify Available Scopes**:
|
||||
|
||||
Check what scopes the MCP server advertises:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8001/.well-known/oauth-protected-resource | jq '.scopes_supported'
|
||||
|
||||
# Should show all 16 scope categories:
|
||||
# - openid
|
||||
# - mcp:notes:read, mcp:notes:write
|
||||
# - mcp:calendar:read, mcp:calendar:write
|
||||
# - mcp:contacts:read, mcp:contacts:write
|
||||
# - mcp:cookbook:read, mcp:cookbook:write
|
||||
# - mcp:deck:read, mcp:deck:write
|
||||
# - mcp:tables:read, mcp:tables:write
|
||||
# - mcp:files:read, mcp:files:write
|
||||
# - mcp:sharing:read, mcp:sharing:write
|
||||
```
|
||||
|
||||
**Understanding Scope Filtering**:
|
||||
|
||||
The MCP server dynamically filters tools based on the scopes in your access token:
|
||||
- Check server logs for: `✂️ JWT scope filtering: X/90 tools available for scopes: {...}`
|
||||
- This shows how many tools are visible vs total available
|
||||
- Each tool requires specific scopes (read and/or write)
|
||||
|
||||
**Available Scope Categories**:
|
||||
|
||||
| Scope Prefix | Nextcloud App | Read Operations | Write Operations |
|
||||
|--------------|---------------|-----------------|------------------|
|
||||
| `mcp:notes:*` | Notes | Get, search, list | Create, update, delete, append |
|
||||
| `mcp:calendar:*` | Calendar (CalDAV) | Get events, todos, calendars | Create/update/delete events, todos |
|
||||
| `mcp:contacts:*` | Contacts (CardDAV) | Get contacts, address books | Create/update/delete contacts |
|
||||
| `mcp:cookbook:*` | Cookbook | Get recipes, search | Create/update recipes |
|
||||
| `mcp:deck:*` | Deck | Get boards, cards | Create/update boards, cards |
|
||||
| `mcp:tables:*` | Tables | Get rows, tables | Create/update/delete rows |
|
||||
| `mcp:files:*` | Files (WebDAV) | List, read files | Upload, delete, move files |
|
||||
| `mcp:sharing:*` | Sharing | Get shares | Create/update shares |
|
||||
|
||||
---
|
||||
|
||||
## Switching Authentication Modes
|
||||
|
||||
### From BasicAuth to OAuth
|
||||
|
||||
@@ -16,35 +16,79 @@ While the core OAuth flow works, there are **pending upstream improvements** tha
|
||||
|
||||
**Status**: 🟡 **Patch Required** (Pending Upstream)
|
||||
|
||||
**Affected Component**: `user_oidc` app
|
||||
**Affected Component**: **Nextcloud core server** (`CORSMiddleware`)
|
||||
|
||||
**Issue**: Bearer token authentication fails for app-specific APIs (Notes, Calendar, etc.) with `401 Unauthorized` errors, even though OCS APIs work correctly.
|
||||
|
||||
**Root Cause**: The `CORSMiddleware` in Nextcloud logs out sessions created by Bearer token authentication when CSRF tokens are missing, which breaks API requests.
|
||||
**Root Cause**: The `CORSMiddleware` in Nextcloud core server logs out sessions when CSRF tokens are missing. Bearer token authentication creates a session (via `user_oidc` app), but doesn't include CSRF tokens (stateless authentication). The middleware detects the logged-in session without CSRF token and calls `session->logout()`, invalidating the request.
|
||||
|
||||
**Solution**: Set the `app_api` session flag during Bearer token authentication to bypass CSRF checks.
|
||||
**Solution**: Allow Bearer token requests to bypass CORS/CSRF checks in `CORSMiddleware`, since Bearer tokens are stateless and don't require CSRF protection.
|
||||
|
||||
**Upstream PR**: [nextcloud/user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221)
|
||||
**Upstream PR**: [nextcloud/server#55878](https://github.com/nextcloud/server/pull/55878)
|
||||
|
||||
**Workaround**: Manually apply the patch to `lib/User/Backend.php` in the `user_oidc` app
|
||||
**Workaround**: Manually apply the patch to `lib/private/AppFramework/Middleware/Security/CORSMiddleware.php` in Nextcloud core server
|
||||
|
||||
**Impact**:
|
||||
- ✅ **Works**: OCS APIs (`/ocs/v2.php/cloud/capabilities`)
|
||||
- ❌ **Requires Patch**: App APIs (`/apps/notes/api/`, `/apps/calendar/`, etc.)
|
||||
|
||||
**Files Modified**: `lib/User/Backend.php` in `user_oidc` app
|
||||
**Files Modified**: `lib/private/AppFramework/Middleware/Security/CORSMiddleware.php` in **Nextcloud core server**
|
||||
|
||||
**Patch Summary**:
|
||||
```php
|
||||
// Add before successful Bearer token authentication returns
|
||||
$this->session->set('app_api', true);
|
||||
// Allow Bearer token authentication for CORS requests
|
||||
// Bearer tokens are stateless and don't require CSRF protection
|
||||
$authorizationHeader = $this->request->getHeader('Authorization');
|
||||
if (!empty($authorizationHeader) && str_starts_with($authorizationHeader, 'Bearer ')) {
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
|
||||
This is added before the CSRF check at line ~73 in `CORSMiddleware.php`.
|
||||
|
||||
---
|
||||
|
||||
### 2. PKCE Support (RFC 7636)
|
||||
### 2. JWT Token Support, Introspection, and Scope Validation
|
||||
|
||||
**Status**: ✅ **Complete** (Merged Upstream)
|
||||
|
||||
**Affected Component**: `oidc` app
|
||||
|
||||
**Issue**: The OIDC app needed support for JWT tokens, token introspection, and enhanced scope validation for fine-grained authorization.
|
||||
|
||||
**Resolution**: Complete JWT and scope validation support has been implemented and merged:
|
||||
|
||||
**Upstream PR**: [H2CK/oidc#585](https://github.com/H2CK/oidc/pull/585) - ✅ **Merged**
|
||||
- **Changes**:
|
||||
- JWT token generation and validation
|
||||
- Token introspection endpoint (RFC 7662)
|
||||
- Enhanced scope validation and parsing
|
||||
- Custom scope support for Nextcloud apps
|
||||
- **Status**: Merged and available in v1.10.0+ of the `oidc` app
|
||||
|
||||
---
|
||||
|
||||
### 3. User Consent Management
|
||||
|
||||
**Status**: ✅ **Complete** (Merged Upstream)
|
||||
|
||||
**Affected Component**: `oidc` app
|
||||
|
||||
**Issue**: The OIDC app needed proper user consent management for OAuth authorization flows.
|
||||
|
||||
**Resolution**: Complete user consent management has been implemented and merged:
|
||||
|
||||
**Upstream PR**: [H2CK/oidc#586](https://github.com/H2CK/oidc/pull/586) - ✅ **Merged**
|
||||
- **Changes**:
|
||||
- User consent UI for OAuth authorization
|
||||
- Consent expiration and cleanup
|
||||
- Admin control for user consent settings
|
||||
- Consent tracking and management
|
||||
- **Status**: Merged and available in v1.11.0+ of the `oidc` app
|
||||
|
||||
---
|
||||
|
||||
### 4. PKCE Support (RFC 7636)
|
||||
|
||||
**Status**: ✅ **Complete** (Merged Upstream)
|
||||
|
||||
@@ -97,24 +141,34 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
|
||||
|
||||
| PR/Issue | Component | Status | Priority | Notes |
|
||||
|----------|-----------|--------|----------|-------|
|
||||
| [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) | `user_oidc` | 🟡 Open | High | Required for app-specific APIs |
|
||||
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | ✅ Merged | ~~Medium~~ | ✅ PKCE advertisement complete (v1.10.0+) |
|
||||
| [server#55878](https://github.com/nextcloud/server/pull/55878) | Nextcloud core server | 🟡 Open | High | CORSMiddleware patch for Bearer tokens |
|
||||
| [H2CK/oidc#586](https://github.com/H2CK/oidc/pull/586) | `oidc` | ✅ Merged | Medium | ✅ User consent complete (v1.11.0+) |
|
||||
| [H2CK/oidc#585](https://github.com/H2CK/oidc/pull/585) | `oidc` | ✅ Merged | Medium | ✅ JWT tokens, introspection, scope validation (v1.10.0+) |
|
||||
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | ✅ Merged | ~~High~~ | ✅ PKCE support (RFC 7636) (v1.10.0+) |
|
||||
|
||||
## What Works Without Patches
|
||||
|
||||
The following functionality works **out of the box** without any patches:
|
||||
|
||||
✅ **OAuth Flow**:
|
||||
- OIDC discovery with full PKCE support (requires `oidc` app v1.10.0+)
|
||||
✅ **OAuth Flow** (requires `oidc` app v1.10.0+):
|
||||
- OIDC discovery with full PKCE support (RFC 7636)
|
||||
- Dynamic client registration
|
||||
- Authorization code flow with PKCE (S256 and plain methods)
|
||||
- Token exchange with code_verifier verification
|
||||
- User consent management
|
||||
- Userinfo endpoint
|
||||
|
||||
✅ **Token Features** (requires `oidc` app v1.10.0+):
|
||||
- JWT token generation and validation
|
||||
- Token introspection endpoint (RFC 7662)
|
||||
- Enhanced scope validation and parsing
|
||||
- Custom scope support for Nextcloud apps
|
||||
|
||||
✅ **MCP Server as Resource Server**:
|
||||
- Token validation via userinfo
|
||||
- Per-user client instances
|
||||
- Token caching
|
||||
- Scope-based authorization
|
||||
|
||||
✅ **Nextcloud OCS APIs**:
|
||||
- Capabilities endpoint
|
||||
@@ -124,7 +178,7 @@ The following functionality works **out of the box** without any patches:
|
||||
|
||||
The following functionality requires upstream patches:
|
||||
|
||||
🟡 **App-Specific APIs** (Requires user_oidc#1221):
|
||||
🟡 **App-Specific APIs** (Requires Nextcloud core server CORSMiddleware patch):
|
||||
- Notes API (`/apps/notes/api/`)
|
||||
- Calendar API (CalDAV)
|
||||
- Contacts API (CardDAV)
|
||||
@@ -198,19 +252,23 @@ uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
|
||||
|
||||
## Monitoring Upstream Progress
|
||||
|
||||
To track progress on these issues:
|
||||
To track progress on remaining issues:
|
||||
|
||||
1. **Watch the upstream repositories**:
|
||||
- [nextcloud/user_oidc](https://github.com/nextcloud/user_oidc)
|
||||
- [nextcloud/oidc](https://github.com/nextcloud/oidc)
|
||||
1. **Watch the upstream repository**:
|
||||
- [nextcloud/server](https://github.com/nextcloud/server)
|
||||
|
||||
2. **Subscribe to specific issues**:
|
||||
- [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) - Bearer token support
|
||||
2. **Subscribe to the CORSMiddleware PR**:
|
||||
- [server#55878](https://github.com/nextcloud/server/pull/55878) - CORSMiddleware Bearer token support
|
||||
|
||||
3. **Check Nextcloud release notes** for mentions of:
|
||||
3. **Check Nextcloud server release notes** for mentions of:
|
||||
- Bearer token authentication improvements
|
||||
- OIDC/OAuth enhancements
|
||||
- AppAPI compatibility
|
||||
- CORS middleware enhancements
|
||||
- OAuth/OIDC API compatibility
|
||||
|
||||
4. **Completed upstream work** (no monitoring needed):
|
||||
- ✅ [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - PKCE support (v1.10.0+)
|
||||
- ✅ [H2CK/oidc#585](https://github.com/H2CK/oidc/pull/585) - JWT, introspection, scopes (v1.10.0+)
|
||||
- ✅ [H2CK/oidc#586](https://github.com/H2CK/oidc/pull/586) - User consent (v1.11.0+)
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -237,6 +295,6 @@ Want to help get these patches merged?
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-10-20
|
||||
**Last Updated**: 2025-11-02
|
||||
|
||||
**Next Review**: When issue #1221 (Bearer token support) has activity
|
||||
**Next Review**: When Nextcloud server CORSMiddleware PR has activity
|
||||
|
||||
@@ -182,15 +182,15 @@ You can test using the MCP OAuth container or manually:
|
||||
|
||||
**Option A: Using MCP OAuth container**
|
||||
```bash
|
||||
# The mcp-oauth-jwt container will trigger the OAuth flow
|
||||
docker compose logs -f mcp-oauth-jwt
|
||||
# The mcp-oauth container will trigger the OAuth flow
|
||||
docker compose logs -f mcp-oauth
|
||||
```
|
||||
|
||||
**Option B: Manual browser test**
|
||||
1. Get client_id from the JWT client JSON
|
||||
2. Visit in browser:
|
||||
```
|
||||
http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:8002/oauth/callback&scope=openid+profile+email+nc:read+nc:write&state=test123
|
||||
http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:8001/oauth/callback&scope=openid+profile+email+mcp:notes:read+mcp:notes:write&state=test123
|
||||
```
|
||||
|
||||
### 3. Expected Behavior
|
||||
@@ -203,8 +203,8 @@ http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type
|
||||
- ✓ Basic authentication (openid) - required, cannot deselect
|
||||
- ✓ Profile information (profile)
|
||||
- ✓ Email address (email)
|
||||
- ✓ nc:read (custom scope, shown as-is)
|
||||
- ✓ nc:write (custom scope, shown as-is)
|
||||
- ✓ mcp:notes:read (custom scope, shown as-is)
|
||||
- ✓ mcp:notes:write (custom scope, shown as-is)
|
||||
- "Allow" and "Deny" buttons
|
||||
3. User selects scopes and clicks "Allow"
|
||||
4. Authorization proceeds with selected scopes
|
||||
|
||||
+13
-10
@@ -136,24 +136,27 @@ A patch for the `user_oidc` app is required to fix Bearer token support. See [oa
|
||||
|
||||
---
|
||||
|
||||
### Issue: "Permission denied" when reading/writing OAuth client credentials file
|
||||
### Issue: "Permission denied" or "Database is locked" when accessing OAuth client storage
|
||||
|
||||
**Cause:** The server cannot access the OAuth client storage file (default: `.nextcloud_oauth_client.json`).
|
||||
**Cause:** The server cannot access the SQLite database for OAuth client credentials storage.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Check file permissions
|
||||
ls -la .nextcloud_oauth_client.json
|
||||
# Check database directory permissions
|
||||
ls -la data/
|
||||
|
||||
# Fix file permissions (should be 0600 - owner read/write only)
|
||||
chmod 600 .nextcloud_oauth_client.json
|
||||
# Ensure directory is writable
|
||||
chmod 755 data/
|
||||
|
||||
# Ensure the directory is writable
|
||||
chmod 755 $(dirname .nextcloud_oauth_client.json)
|
||||
# Check if database file exists and has correct permissions
|
||||
ls -la data/tokens.db
|
||||
chmod 644 data/tokens.db
|
||||
|
||||
# If the file doesn't exist, ensure the directory is writable so it can be created
|
||||
mkdir -p $(dirname .nextcloud_oauth_client.json)
|
||||
# For Docker deployments, ensure volume is mounted correctly:
|
||||
# docker-compose.yml should have:
|
||||
# volumes:
|
||||
# - ./data:/app/data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+82
-1
@@ -8,12 +8,19 @@ NEXTCLOUD_HOST=
|
||||
# - Requires Nextcloud OIDC app installed and configured
|
||||
# - Admin must enable "Dynamic Client Registration" in OIDC app settings
|
||||
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty to use OAuth mode
|
||||
# - OAuth client credentials are stored encrypted in SQLite (TOKEN_STORAGE_DB)
|
||||
# - Optional: Pre-register client and provide credentials (otherwise auto-registers)
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=
|
||||
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
|
||||
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||
|
||||
# OAuth Storage Configuration (SQLite storage for OAuth clients and refresh tokens)
|
||||
# TOKEN_ENCRYPTION_KEY: Required for encrypting OAuth client secrets and refresh tokens
|
||||
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
#TOKEN_ENCRYPTION_KEY=
|
||||
# TOKEN_STORAGE_DB: Path to SQLite database (default: /app/data/tokens.db)
|
||||
#TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
|
||||
# Option 2: Basic Authentication (LEGACY - Less Secure)
|
||||
# - Requires username and password
|
||||
# - Credentials stored in environment variables
|
||||
@@ -21,3 +28,77 @@ NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||
# - If these are set, OAuth mode is disabled
|
||||
NEXTCLOUD_USERNAME=
|
||||
NEXTCLOUD_PASSWORD=
|
||||
|
||||
# ============================================
|
||||
# Document Processing Configuration
|
||||
# ============================================
|
||||
# Enable document processing (PDF, DOCX, images, etc.)
|
||||
# Set to false to disable all document processing
|
||||
ENABLE_DOCUMENT_PROCESSING=false
|
||||
|
||||
# Default processor to use when multiple are available
|
||||
# Options: unstructured, tesseract, custom
|
||||
DOCUMENT_PROCESSOR=unstructured
|
||||
|
||||
# ============================================
|
||||
# Unstructured.io Processor
|
||||
# ============================================
|
||||
# Enable Unstructured processor (requires unstructured service in docker-compose)
|
||||
# This is a cloud-based/API processor supporting many document types
|
||||
ENABLE_UNSTRUCTURED=false
|
||||
|
||||
# Unstructured API endpoint
|
||||
UNSTRUCTURED_API_URL=http://unstructured:8000
|
||||
|
||||
# Request timeout in seconds (default: 120)
|
||||
# OCR operations can take 30-120 seconds for large documents
|
||||
UNSTRUCTURED_TIMEOUT=120
|
||||
|
||||
# Parsing strategy: auto, fast, hi_res
|
||||
# - auto: Automatically choose based on document type
|
||||
# - fast: Fast parsing without OCR
|
||||
# - hi_res: High-resolution with OCR (slowest, most accurate)
|
||||
UNSTRUCTURED_STRATEGY=auto
|
||||
|
||||
# OCR languages (comma-separated ISO 639-3 codes)
|
||||
# Common: eng=English, deu=German, fra=French, spa=Spanish
|
||||
UNSTRUCTURED_LANGUAGES=eng,deu
|
||||
|
||||
# Progress reporting interval in seconds (default: 10)
|
||||
# During long-running OCR operations, progress notifications are sent to the MCP client
|
||||
# at this interval to prevent timeouts and provide status updates
|
||||
PROGRESS_INTERVAL=10
|
||||
|
||||
# ============================================
|
||||
# Tesseract Processor (Local OCR)
|
||||
# ============================================
|
||||
# Enable Tesseract processor (requires tesseract binary installed)
|
||||
# This is a local, lightweight OCR solution for images only
|
||||
ENABLE_TESSERACT=false
|
||||
|
||||
# Path to tesseract executable (optional, auto-detected if in PATH)
|
||||
#TESSERACT_CMD=/usr/bin/tesseract
|
||||
|
||||
# OCR language (e.g., eng, deu, eng+deu for multiple)
|
||||
TESSERACT_LANG=eng
|
||||
|
||||
# ============================================
|
||||
# Custom Processor (Your own API)
|
||||
# ============================================
|
||||
# Enable custom document processor via HTTP API
|
||||
ENABLE_CUSTOM_PROCESSOR=false
|
||||
|
||||
# Unique name for your processor
|
||||
#CUSTOM_PROCESSOR_NAME=my_ocr
|
||||
|
||||
# Your custom processor API endpoint
|
||||
#CUSTOM_PROCESSOR_URL=http://localhost:9000/process
|
||||
|
||||
# Optional API key for authentication
|
||||
#CUSTOM_PROCESSOR_API_KEY=your-api-key-here
|
||||
|
||||
# Request timeout in seconds
|
||||
#CUSTOM_PROCESSOR_TIMEOUT=60
|
||||
|
||||
# Comma-separated MIME types your processor supports
|
||||
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
{
|
||||
"id": "nextcloud-mcp",
|
||||
"realm": "nextcloud-mcp",
|
||||
"notBefore": 0,
|
||||
"defaultSignatureAlgorithm": "RS256",
|
||||
"revokeRefreshToken": false,
|
||||
"refreshTokenMaxReuse": 0,
|
||||
"accessTokenLifespan": 300,
|
||||
"accessTokenLifespanForImplicitFlow": 900,
|
||||
"ssoSessionIdleTimeout": 1800,
|
||||
"ssoSessionMaxLifespan": 36000,
|
||||
"offlineSessionIdleTimeout": 2592000,
|
||||
"offlineSessionMaxLifespanEnabled": false,
|
||||
"offlineSessionMaxLifespan": 5184000,
|
||||
"accessCodeLifespan": 60,
|
||||
"accessCodeLifespanUserAction": 300,
|
||||
"accessCodeLifespanLogin": 1800,
|
||||
"enabled": true,
|
||||
"sslRequired": "external",
|
||||
"registrationAllowed": false,
|
||||
"loginWithEmailAllowed": true,
|
||||
"duplicateEmailsAllowed": false,
|
||||
"resetPasswordAllowed": false,
|
||||
"editUsernameAllowed": false,
|
||||
"bruteForceProtected": false,
|
||||
"attributes": {
|
||||
"frontendUrl": "http://localhost:8888"
|
||||
},
|
||||
"roles": {
|
||||
"realm": [
|
||||
{
|
||||
"name": "offline_access",
|
||||
"description": "${role_offline-access}",
|
||||
"composite": false,
|
||||
"clientRole": false
|
||||
},
|
||||
{
|
||||
"name": "uma_authorization",
|
||||
"description": "${role_uma_authorization}",
|
||||
"composite": false,
|
||||
"clientRole": false
|
||||
},
|
||||
{
|
||||
"name": "default-roles-nextcloud-mcp",
|
||||
"description": "${role_default-roles}",
|
||||
"composite": true,
|
||||
"composites": {
|
||||
"realm": [
|
||||
"offline_access",
|
||||
"uma_authorization"
|
||||
]
|
||||
},
|
||||
"clientRole": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"enabled": true,
|
||||
"email": "admin@example.com",
|
||||
"emailVerified": true,
|
||||
"firstName": "Admin",
|
||||
"lastName": "User",
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "admin",
|
||||
"temporary": false
|
||||
}
|
||||
],
|
||||
"realmRoles": [
|
||||
"default-roles-nextcloud-mcp",
|
||||
"offline_access"
|
||||
],
|
||||
"attributes": {
|
||||
"quota": [
|
||||
"1073741824"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"username": "test_read_only",
|
||||
"enabled": true,
|
||||
"email": "readonly@example.com",
|
||||
"emailVerified": true,
|
||||
"firstName": "Read",
|
||||
"lastName": "Only",
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "test123",
|
||||
"temporary": false
|
||||
}
|
||||
],
|
||||
"realmRoles": [
|
||||
"default-roles-nextcloud-mcp",
|
||||
"offline_access"
|
||||
],
|
||||
"attributes": {
|
||||
"quota": [
|
||||
"1073741824"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"username": "test_write_only",
|
||||
"enabled": true,
|
||||
"email": "writeonly@example.com",
|
||||
"emailVerified": true,
|
||||
"firstName": "Write",
|
||||
"lastName": "Only",
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "test123",
|
||||
"temporary": false
|
||||
}
|
||||
],
|
||||
"realmRoles": [
|
||||
"default-roles-nextcloud-mcp",
|
||||
"offline_access"
|
||||
],
|
||||
"attributes": {
|
||||
"quota": [
|
||||
"1073741824"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"username": "test_no_scopes",
|
||||
"enabled": true,
|
||||
"email": "noscopes@example.com",
|
||||
"emailVerified": true,
|
||||
"firstName": "No",
|
||||
"lastName": "Scopes",
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "test123",
|
||||
"temporary": false
|
||||
}
|
||||
],
|
||||
"realmRoles": [
|
||||
"default-roles-nextcloud-mcp",
|
||||
"offline_access"
|
||||
],
|
||||
"attributes": {
|
||||
"quota": [
|
||||
"1073741824"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"username": "service-account-nextcloud-mcp-server",
|
||||
"enabled": true,
|
||||
"serviceAccountClientId": "nextcloud-mcp-server",
|
||||
"clientRoles": {
|
||||
"realm-management": [
|
||||
"impersonation"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "nextcloud",
|
||||
"name": "Nextcloud Resource Server",
|
||||
"description": "Resource server for Nextcloud APIs - used by user_oidc app for bearer token validation",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "nextcloud-secret-change-in-production",
|
||||
"redirectUris": [],
|
||||
"webOrigins": [],
|
||||
"bearerOnly": true,
|
||||
"consentRequired": false,
|
||||
"standardFlowEnabled": false,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"publicClient": false,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"display.on.consent.screen": "false"
|
||||
},
|
||||
"fullScopeAllowed": true,
|
||||
"nodeReRegistrationTimeout": -1
|
||||
},
|
||||
{
|
||||
"clientId": "nextcloud-mcp-server",
|
||||
"name": "Nextcloud MCP Server",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "mcp-secret-change-in-production",
|
||||
"redirectUris": [
|
||||
"http://localhost:*",
|
||||
"http://127.0.0.1:*",
|
||||
"http://localhost:*/callback",
|
||||
"http://127.0.0.1:*/callback"
|
||||
],
|
||||
"webOrigins": [
|
||||
"+"
|
||||
],
|
||||
"bearerOnly": false,
|
||||
"consentRequired": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": true,
|
||||
"serviceAccountsEnabled": true,
|
||||
"publicClient": false,
|
||||
"frontchannelLogout": false,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"pkce.code.challenge.method": "S256",
|
||||
"use.refresh.tokens": "true",
|
||||
"backchannel.logout.session.required": "true",
|
||||
"backchannel.logout.url": "http://app:80/index.php/apps/user_oidc/backchannel-logout/keycloak",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"oidc.ciba.grant.enabled": "false",
|
||||
"client_credentials.use_refresh_token": "false",
|
||||
"display.on.consent.screen": "false",
|
||||
"token.exchange.grant.enabled": "true",
|
||||
"client.token.exchange.standard.enabled": "true"
|
||||
},
|
||||
"fullScopeAllowed": true,
|
||||
"nodeReRegistrationTimeout": -1,
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "audience-nextcloud",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.custom.audience": "nextcloud",
|
||||
"access.token.claim": "true",
|
||||
"id.token.claim": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sub",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "username",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "sub",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "full name",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-full-name-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "email",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "email",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "preferred_username",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "username",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "preferred_username",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "quota",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "quota",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "quota",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"profile",
|
||||
"roles",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt",
|
||||
"notes:read",
|
||||
"notes:write",
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
"contacts:read",
|
||||
"contacts:write",
|
||||
"cookbook:read",
|
||||
"cookbook:write",
|
||||
"deck:read",
|
||||
"deck:write",
|
||||
"tables:read",
|
||||
"tables:write",
|
||||
"files:read",
|
||||
"files:write",
|
||||
"sharing:read",
|
||||
"sharing:write",
|
||||
"todo:read",
|
||||
"todo:write"
|
||||
]
|
||||
}
|
||||
],
|
||||
"clientScopes": [
|
||||
{
|
||||
"name": "offline_access",
|
||||
"description": "OpenID Connect built-in scope: offline_access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"consent.screen.text": "${offlineAccessScopeConsentText}",
|
||||
"display.on.consent.screen": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "profile",
|
||||
"description": "OpenID Connect built-in scope: profile",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "full name",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-full-name-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "username",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "preferred_username",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "given name",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "firstName",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "given_name",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "family name",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "lastName",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "family_name",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"description": "OpenID Connect built-in scope: email",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "email",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "email",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "email",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email verified",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "emailVerified",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "email_verified",
|
||||
"jsonType.label": "boolean"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "roles",
|
||||
"description": "OpenID Connect scope for add user roles to the access token",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "false",
|
||||
"display.on.consent.screen": "true"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "realm roles",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-realm-role-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"user.attribute": "foo",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "realm_access.roles",
|
||||
"jsonType.label": "String",
|
||||
"multivalued": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "client roles",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-client-role-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"user.attribute": "foo",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "resource_access.${client_id}.roles",
|
||||
"jsonType.label": "String",
|
||||
"multivalued": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "web-origins",
|
||||
"description": "OpenID Connect scope for add allowed web origins to the access token",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "false",
|
||||
"display.on.consent.screen": "false"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "allowed web origins",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-allowed-origins-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "notes:read",
|
||||
"description": "Nextcloud Notes read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Read your notes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "notes:write",
|
||||
"description": "Nextcloud Notes write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Create, update, and delete your notes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "calendar:read",
|
||||
"description": "Nextcloud Calendar read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Read your calendars and events"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "calendar:write",
|
||||
"description": "Nextcloud Calendar write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Create, update, and delete calendars and events"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "contacts:read",
|
||||
"description": "Nextcloud Contacts read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Read your contacts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "contacts:write",
|
||||
"description": "Nextcloud Contacts write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Create, update, and delete contacts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cookbook:read",
|
||||
"description": "Nextcloud Cookbook read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Read your recipes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cookbook:write",
|
||||
"description": "Nextcloud Cookbook write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Create, update, and delete recipes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deck:read",
|
||||
"description": "Nextcloud Deck read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Read your boards and cards"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deck:write",
|
||||
"description": "Nextcloud Deck write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Create, update, and delete boards and cards"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tables:read",
|
||||
"description": "Nextcloud Tables read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Read your tables and rows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tables:write",
|
||||
"description": "Nextcloud Tables write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Create, update, and delete tables and rows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files:read",
|
||||
"description": "Nextcloud Files read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Read your files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files:write",
|
||||
"description": "Nextcloud Files write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Upload, update, and delete files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sharing:read",
|
||||
"description": "Nextcloud Sharing read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "View shared resources"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sharing:write",
|
||||
"description": "Nextcloud Sharing write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Create and manage shares"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "todo:read",
|
||||
"description": "Nextcloud Tasks/Todo read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Read your tasks"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "todo:write",
|
||||
"description": "Nextcloud Tasks/Todo write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Create, update, and delete tasks"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "audience",
|
||||
"description": "Audience scope for token validation",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "false"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "mcp-server-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "nextcloud-mcp-server",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nextcloud-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "nextcloud",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"components": {
|
||||
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [
|
||||
{
|
||||
"name": "Trusted Hosts",
|
||||
"providerId": "trusted-hosts",
|
||||
"subType": "anonymous",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"trusted-hosts": [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"172.19.0.1"
|
||||
],
|
||||
"host-sending-registration-request-must-match": [
|
||||
"false"
|
||||
],
|
||||
"client-uris-must-match": [
|
||||
"true"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Max Clients",
|
||||
"providerId": "max-clients",
|
||||
"subType": "anonymous",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"max-clients": [
|
||||
"200"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"defaultDefaultClientScopes": [
|
||||
"profile",
|
||||
"email",
|
||||
"roles",
|
||||
"web-origins",
|
||||
"audience"
|
||||
],
|
||||
"defaultOptionalClientScopes": [
|
||||
"offline_access",
|
||||
"notes:read",
|
||||
"notes:write",
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
"contacts:read",
|
||||
"contacts:write",
|
||||
"cookbook:read",
|
||||
"cookbook:write",
|
||||
"deck:read",
|
||||
"deck:write",
|
||||
"tables:read",
|
||||
"tables:write",
|
||||
"files:read",
|
||||
"files:write",
|
||||
"sharing:read",
|
||||
"sharing:write",
|
||||
"todo:read",
|
||||
"todo:write"
|
||||
]
|
||||
}
|
||||
+531
-162
@@ -3,6 +3,10 @@ import os
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
|
||||
import click
|
||||
import httpx
|
||||
@@ -11,19 +15,26 @@ from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
from nextcloud_mcp_server.auth import (
|
||||
InsufficientScopeError,
|
||||
NextcloudTokenVerifier,
|
||||
discover_all_scopes,
|
||||
get_access_token_scopes,
|
||||
has_required_scopes,
|
||||
is_jwt_token,
|
||||
)
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import LOGGING_CONFIG, setup_logging
|
||||
from nextcloud_mcp_server.config import (
|
||||
LOGGING_CONFIG,
|
||||
get_document_processor_config,
|
||||
setup_logging,
|
||||
)
|
||||
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
|
||||
from nextcloud_mcp_server.document_processors import get_registry
|
||||
from nextcloud_mcp_server.server import (
|
||||
configure_calendar_tools,
|
||||
configure_contacts_tools,
|
||||
@@ -38,6 +49,92 @@ from nextcloud_mcp_server.server import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def initialize_document_processors():
|
||||
"""Initialize and register document processors based on configuration.
|
||||
|
||||
This function reads the environment configuration and registers available
|
||||
processors (Unstructured, Tesseract, Custom HTTP) with the global registry.
|
||||
"""
|
||||
config = get_document_processor_config()
|
||||
|
||||
if not config["enabled"]:
|
||||
logger.info("Document processing disabled")
|
||||
return
|
||||
|
||||
registry = get_registry()
|
||||
registered_count = 0
|
||||
|
||||
# Register Unstructured processor
|
||||
if "unstructured" in config["processors"]:
|
||||
unst_config = config["processors"]["unstructured"]
|
||||
try:
|
||||
from nextcloud_mcp_server.document_processors.unstructured import (
|
||||
UnstructuredProcessor,
|
||||
)
|
||||
|
||||
processor = UnstructuredProcessor(
|
||||
api_url=unst_config["api_url"],
|
||||
timeout=unst_config["timeout"],
|
||||
default_strategy=unst_config["strategy"],
|
||||
default_languages=unst_config["languages"],
|
||||
progress_interval=unst_config.get("progress_interval", 10),
|
||||
)
|
||||
registry.register(processor, priority=10)
|
||||
logger.info(f"Registered Unstructured processor: {unst_config['api_url']}")
|
||||
registered_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register Unstructured processor: {e}")
|
||||
|
||||
# Register Tesseract processor
|
||||
if "tesseract" in config["processors"]:
|
||||
tess_config = config["processors"]["tesseract"]
|
||||
try:
|
||||
from nextcloud_mcp_server.document_processors.tesseract import (
|
||||
TesseractProcessor,
|
||||
)
|
||||
|
||||
processor = TesseractProcessor(
|
||||
tesseract_cmd=tess_config.get("tesseract_cmd"),
|
||||
default_lang=tess_config["lang"],
|
||||
)
|
||||
registry.register(processor, priority=5)
|
||||
logger.info(f"Registered Tesseract processor: lang={tess_config['lang']}")
|
||||
registered_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register Tesseract processor: {e}")
|
||||
|
||||
# Register custom processor
|
||||
if "custom" in config["processors"]:
|
||||
custom_config = config["processors"]["custom"]
|
||||
try:
|
||||
from nextcloud_mcp_server.document_processors.custom_http import (
|
||||
CustomHTTPProcessor,
|
||||
)
|
||||
|
||||
processor = CustomHTTPProcessor(
|
||||
name=custom_config["name"],
|
||||
api_url=custom_config["api_url"],
|
||||
api_key=custom_config.get("api_key"),
|
||||
timeout=custom_config["timeout"],
|
||||
supported_types=custom_config["supported_types"],
|
||||
)
|
||||
registry.register(processor, priority=1)
|
||||
logger.info(
|
||||
f"Registered Custom processor '{custom_config['name']}': {custom_config['api_url']}"
|
||||
)
|
||||
registered_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register Custom processor: {e}")
|
||||
|
||||
if registered_count > 0:
|
||||
logger.info(
|
||||
f"Document processing initialized with {registered_count} processor(s): "
|
||||
f"{', '.join(registry.list_processors())}"
|
||||
)
|
||||
else:
|
||||
logger.warning("Document processing enabled but no processors registered")
|
||||
|
||||
|
||||
def validate_pkce_support(discovery: dict, discovery_url: str) -> None:
|
||||
"""
|
||||
Validate that the OIDC provider properly advertises PKCE support.
|
||||
@@ -115,6 +212,9 @@ class OAuthAppContext:
|
||||
|
||||
nextcloud_host: str
|
||||
token_verifier: NextcloudTokenVerifier
|
||||
refresh_token_storage: Optional["RefreshTokenStorage"] = None
|
||||
oauth_client: Optional[object] = None # NextcloudOAuthClient or KeycloakOAuthClient
|
||||
oauth_provider: str = "nextcloud" # "nextcloud" or "keycloak"
|
||||
|
||||
|
||||
def is_oauth_mode() -> bool:
|
||||
@@ -168,21 +268,22 @@ async def load_oauth_client_credentials(
|
||||
logger.info("Using pre-configured OAuth client credentials from environment")
|
||||
return (client_id, client_secret)
|
||||
|
||||
# Try loading from storage file
|
||||
storage_path = os.getenv(
|
||||
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
|
||||
)
|
||||
from pathlib import Path
|
||||
# Try loading from SQLite storage
|
||||
try:
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registration import load_client_from_file
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
client_info = load_client_from_file(Path(storage_path))
|
||||
|
||||
if client_info:
|
||||
logger.info(
|
||||
f"Loaded OAuth client from storage: {client_info.client_id[:16]}..."
|
||||
)
|
||||
return (client_info.client_id, client_info.client_secret)
|
||||
client_data = await storage.get_oauth_client()
|
||||
if client_data:
|
||||
logger.info(
|
||||
f"Loaded OAuth client from SQLite: {client_data['client_id'][:16]}..."
|
||||
)
|
||||
return (client_data["client_id"], client_data["client_secret"])
|
||||
except ValueError:
|
||||
# TOKEN_ENCRYPTION_KEY not set, skip SQLite storage check
|
||||
logger.debug("SQLite storage not available (TOKEN_ENCRYPTION_KEY not set)")
|
||||
|
||||
# Try dynamic registration if available
|
||||
if registration_endpoint:
|
||||
@@ -191,9 +292,39 @@ async def load_oauth_client_credentials(
|
||||
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
|
||||
|
||||
# Get scopes from environment or use defaults
|
||||
scopes = os.getenv(
|
||||
"NEXTCLOUD_OIDC_SCOPES", "openid profile email nc:read nc:write"
|
||||
# Note: Client registration happens BEFORE tools are registered, so we can't
|
||||
# dynamically discover scopes here. These scopes define the "maximum allowed"
|
||||
# scopes for this OAuth client. The actual per-tool scope enforcement happens
|
||||
# via @require_scopes decorators, and the PRM endpoint advertises the actual
|
||||
# supported scopes dynamically.
|
||||
#
|
||||
# IMPORTANT: Keep this list in sync with all @require_scopes decorators
|
||||
# when adding new apps, or set NEXTCLOUD_OIDC_SCOPES environment variable
|
||||
# to override.
|
||||
default_scopes = (
|
||||
"openid profile email "
|
||||
"notes:read notes:write "
|
||||
"calendar:read calendar:write "
|
||||
"todo:read todo:write "
|
||||
"contacts:read contacts:write "
|
||||
"cookbook:read cookbook:write "
|
||||
"deck:read deck:write "
|
||||
"tables:read tables:write "
|
||||
"files:read files:write "
|
||||
"sharing:read sharing:write"
|
||||
)
|
||||
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", default_scopes)
|
||||
|
||||
# Add offline_access scope if refresh tokens are enabled
|
||||
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
if enable_offline_access and "offline_access" not in scopes:
|
||||
scopes = f"{scopes} offline_access"
|
||||
logger.info("✓ offline_access scope enabled for refresh tokens")
|
||||
|
||||
logger.info(f"Requesting OAuth scopes: {scopes}")
|
||||
|
||||
# Get token type from environment (Bearer or jwt)
|
||||
@@ -204,16 +335,18 @@ async def load_oauth_client_credentials(
|
||||
token_type = "Bearer"
|
||||
logger.info(f"Requesting token type: {token_type}")
|
||||
|
||||
# Load or register client
|
||||
from nextcloud_mcp_server.auth.client_registration import (
|
||||
load_or_register_client,
|
||||
)
|
||||
# Ensure OAuth client in SQLite storage
|
||||
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
|
||||
client_info = await load_or_register_client(
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
client_info = await ensure_oauth_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
storage_path=storage_path,
|
||||
client_name="Nextcloud MCP Server",
|
||||
storage=storage,
|
||||
client_name=f"Nextcloud MCP Server ({token_type})",
|
||||
redirect_uris=redirect_uris,
|
||||
scopes=scopes,
|
||||
token_type=token_type,
|
||||
@@ -226,8 +359,9 @@ async def load_oauth_client_credentials(
|
||||
raise ValueError(
|
||||
"OAuth mode requires either:\n"
|
||||
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET environment variables, OR\n"
|
||||
"2. Pre-existing client credentials file at NEXTCLOUD_OIDC_CLIENT_STORAGE, OR\n"
|
||||
"3. Dynamic client registration enabled on Nextcloud OIDC app"
|
||||
"2. Pre-existing client credentials in SQLite storage (TOKEN_STORAGE_DB), OR\n"
|
||||
"3. Dynamic client registration enabled on Nextcloud OIDC app\n\n"
|
||||
"Note: TOKEN_ENCRYPTION_KEY is required for SQLite storage"
|
||||
)
|
||||
|
||||
|
||||
@@ -245,6 +379,9 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
client = NextcloudClient.from_env()
|
||||
logger.info("Client initialization complete")
|
||||
|
||||
# Initialize document processors
|
||||
initialize_document_processors()
|
||||
|
||||
try:
|
||||
yield AppContext(client=client)
|
||||
finally:
|
||||
@@ -252,81 +389,26 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
await client.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
|
||||
"""
|
||||
Manage application lifecycle for OAuth mode.
|
||||
|
||||
Initializes OAuth client registration and token verifier.
|
||||
Does NOT create a Nextcloud client - clients are created per-request.
|
||||
"""
|
||||
logger.info("Starting MCP server in OAuth mode")
|
||||
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
raise ValueError("NEXTCLOUD_HOST environment variable is required")
|
||||
|
||||
nextcloud_host = nextcloud_host.rstrip("/")
|
||||
|
||||
# Get OAuth discovery endpoint
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
|
||||
try:
|
||||
# Fetch OIDC discovery
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
|
||||
logger.info(f"OIDC discovery successful: {discovery_url}")
|
||||
|
||||
# Extract endpoints
|
||||
userinfo_uri = discovery["userinfo_endpoint"]
|
||||
registration_endpoint = discovery.get("registration_endpoint")
|
||||
introspection_uri = discovery.get("introspection_endpoint")
|
||||
|
||||
logger.info(f"Userinfo endpoint: {userinfo_uri}")
|
||||
if introspection_uri:
|
||||
logger.info(f"Introspection endpoint: {introspection_uri}")
|
||||
|
||||
# Load OAuth client credentials
|
||||
client_id, client_secret = await load_oauth_client_credentials(
|
||||
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
|
||||
)
|
||||
|
||||
# Create token verifier with introspection support
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
nextcloud_host=nextcloud_host,
|
||||
userinfo_uri=userinfo_uri,
|
||||
introspection_uri=introspection_uri,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
|
||||
logger.info("OAuth initialization complete")
|
||||
|
||||
try:
|
||||
yield OAuthAppContext(
|
||||
nextcloud_host=nextcloud_host, token_verifier=token_verifier
|
||||
)
|
||||
finally:
|
||||
logger.info("Shutting down OAuth mode")
|
||||
await token_verifier.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize OAuth mode: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def setup_oauth_config():
|
||||
"""
|
||||
Setup OAuth configuration by performing OIDC discovery and client registration.
|
||||
|
||||
Auto-detects OAuth provider mode:
|
||||
- Integrated mode: OIDC_DISCOVERY_URL points to NEXTCLOUD_HOST (or not set)
|
||||
→ Nextcloud OIDC app provides both OAuth and API access
|
||||
- External IdP mode: OIDC_DISCOVERY_URL points to external provider
|
||||
→ External IdP for OAuth, Nextcloud user_oidc validates tokens and provides API access
|
||||
|
||||
Uses generic OIDC environment variables:
|
||||
- OIDC_DISCOVERY_URL: OIDC discovery endpoint (optional, defaults to NEXTCLOUD_HOST)
|
||||
- OIDC_CLIENT_ID / OIDC_CLIENT_SECRET: Static credentials (optional, uses DCR if not provided)
|
||||
- NEXTCLOUD_OIDC_SCOPES: Requested OAuth scopes
|
||||
|
||||
This is done synchronously before FastMCP initialization because FastMCP
|
||||
requires token_verifier at construction time.
|
||||
|
||||
Returns:
|
||||
Tuple of (nextcloud_host, token_verifier, auth_settings)
|
||||
Tuple of (nextcloud_host, token_verifier, auth_settings, refresh_token_storage, oauth_client, oauth_provider)
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
@@ -335,68 +417,222 @@ async def setup_oauth_config():
|
||||
)
|
||||
|
||||
nextcloud_host = nextcloud_host.rstrip("/")
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
|
||||
# Get OIDC discovery URL (defaults to Nextcloud integrated mode)
|
||||
discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL", f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
)
|
||||
logger.info(f"Performing OIDC discovery: {discovery_url}")
|
||||
|
||||
# Fetch OIDC discovery
|
||||
# Perform OIDC discovery
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
|
||||
logger.info("OIDC discovery successful")
|
||||
logger.info("✓ OIDC discovery successful")
|
||||
|
||||
# Validate PKCE support
|
||||
validate_pkce_support(discovery, discovery_url)
|
||||
|
||||
# Extract endpoints
|
||||
# Extract OIDC endpoints
|
||||
issuer = discovery["issuer"]
|
||||
userinfo_uri = discovery["userinfo_endpoint"]
|
||||
jwks_uri = discovery.get("jwks_uri")
|
||||
introspection_uri = discovery.get("introspection_endpoint")
|
||||
registration_endpoint = discovery.get("registration_endpoint")
|
||||
|
||||
# Allow overriding JWKS URI (useful when running in Docker with frontendUrl)
|
||||
# Example: frontendUrl=http://localhost:8888 but MCP server needs http://keycloak:8080
|
||||
jwks_uri_override = os.getenv("OIDC_JWKS_URI")
|
||||
if jwks_uri_override:
|
||||
logger.info(f"OIDC_JWKS_URI override: {jwks_uri} → {jwks_uri_override}")
|
||||
jwks_uri = jwks_uri_override
|
||||
|
||||
logger.info("OIDC endpoints discovered:")
|
||||
logger.info(f" Issuer: {issuer}")
|
||||
logger.info(f" Userinfo: {userinfo_uri}")
|
||||
logger.info(f" JWKS: {jwks_uri}")
|
||||
if jwks_uri:
|
||||
logger.info(f" JWKS: {jwks_uri}")
|
||||
if introspection_uri:
|
||||
logger.info(f" Introspection: {introspection_uri}")
|
||||
|
||||
# Allow override of public issuer URL for both client configuration and JWT validation
|
||||
# Auto-detect provider mode based on issuer
|
||||
# External IdP mode: issuer doesn't match Nextcloud host
|
||||
# Normalize URLs for comparison (handle port differences like :80 for HTTP)
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def normalize_url(url: str) -> str:
|
||||
"""Normalize URL by removing default ports (80 for HTTP, 443 for HTTPS)."""
|
||||
parsed = urlparse(url)
|
||||
# Remove default ports
|
||||
if (parsed.scheme == "http" and parsed.port == 80) or (
|
||||
parsed.scheme == "https" and parsed.port == 443
|
||||
):
|
||||
# Remove explicit default port
|
||||
hostname = parsed.hostname or parsed.netloc.split(":")[0]
|
||||
return f"{parsed.scheme}://{hostname}"
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
issuer_normalized = normalize_url(issuer)
|
||||
nextcloud_normalized = normalize_url(nextcloud_host)
|
||||
|
||||
is_external_idp = not issuer_normalized.startswith(nextcloud_normalized)
|
||||
|
||||
if is_external_idp:
|
||||
oauth_provider = "external" # Could be Keycloak, Auth0, Okta, etc.
|
||||
logger.info(
|
||||
f"✓ Detected external IdP mode (issuer: {issuer} != Nextcloud: {nextcloud_host})"
|
||||
)
|
||||
logger.info(" Tokens will be validated via Nextcloud user_oidc app")
|
||||
else:
|
||||
oauth_provider = "nextcloud"
|
||||
logger.info("✓ Detected integrated mode (Nextcloud OIDC app)")
|
||||
|
||||
# Check if offline access (refresh tokens) is enabled
|
||||
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
|
||||
# Initialize refresh token storage if enabled
|
||||
refresh_token_storage = None
|
||||
if enable_offline_access:
|
||||
try:
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import (
|
||||
RefreshTokenStorage,
|
||||
)
|
||||
|
||||
# Validate encryption key before initializing
|
||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
if not encryption_key:
|
||||
logger.warning(
|
||||
"ENABLE_OFFLINE_ACCESS=true but TOKEN_ENCRYPTION_KEY not set. "
|
||||
"Refresh tokens will NOT be stored. Generate a key with:\n"
|
||||
' python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
|
||||
)
|
||||
else:
|
||||
refresh_token_storage = RefreshTokenStorage.from_env()
|
||||
await refresh_token_storage.initialize()
|
||||
logger.info(
|
||||
"✓ Refresh token storage initialized (offline_access enabled)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize refresh token storage: {e}")
|
||||
logger.warning(
|
||||
"Continuing without refresh token storage - users will need to re-authenticate after token expiration"
|
||||
)
|
||||
|
||||
# Load client credentials (static or dynamic registration)
|
||||
client_id = os.getenv("OIDC_CLIENT_ID")
|
||||
client_secret = os.getenv("OIDC_CLIENT_SECRET")
|
||||
|
||||
if client_id and client_secret:
|
||||
logger.info(f"Using static OIDC client credentials: {client_id}")
|
||||
elif registration_endpoint:
|
||||
logger.info("OIDC_CLIENT_ID not set, attempting Dynamic Client Registration")
|
||||
client_id, client_secret = await load_oauth_client_credentials(
|
||||
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
"OIDC_CLIENT_ID and OIDC_CLIENT_SECRET environment variables are required "
|
||||
"when the OIDC provider does not support Dynamic Client Registration. "
|
||||
f"Discovery URL: {discovery_url}"
|
||||
)
|
||||
|
||||
# Handle public issuer override (for clients accessing via different URL)
|
||||
# When clients access Nextcloud via a public URL (e.g., http://127.0.0.1:8080),
|
||||
# the OIDC app issues JWT tokens with that public URL in the 'iss' claim,
|
||||
# even though the MCP server accesses Nextcloud via an internal URL (e.g., http://app).
|
||||
# Therefore, we must validate JWT tokens against the public issuer, not the internal one.
|
||||
# but the MCP server accesses via internal URL (e.g., http://app:80),
|
||||
# we need to use the public URL for JWT validation and client configuration
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
public_issuer = public_issuer.rstrip("/")
|
||||
logger.info(
|
||||
f"Using public issuer URL for clients and JWT validation: {public_issuer}"
|
||||
f"Using public issuer URL override for JWT validation: {public_issuer}"
|
||||
)
|
||||
# Use public issuer for both client configuration AND JWT validation
|
||||
issuer = public_issuer
|
||||
jwt_validation_issuer = public_issuer
|
||||
client_issuer = public_issuer
|
||||
else:
|
||||
# Use discovered issuer for both
|
||||
jwt_validation_issuer = issuer
|
||||
client_issuer = issuer
|
||||
|
||||
# Load OAuth client credentials
|
||||
client_id, client_secret = await load_oauth_client_credentials(
|
||||
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
|
||||
)
|
||||
# Create token verifier
|
||||
if is_external_idp:
|
||||
# External IdP mode: Validate via Nextcloud user_oidc app
|
||||
# The user_oidc app accepts tokens from the external IdP and provisions users
|
||||
nextcloud_userinfo_uri = f"{nextcloud_host}/apps/user_oidc/userinfo"
|
||||
|
||||
# Create token verifier with JWT support and introspection
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
nextcloud_host=nextcloud_host,
|
||||
userinfo_uri=userinfo_uri,
|
||||
jwks_uri=jwks_uri, # Enable JWT verification if available
|
||||
issuer=jwt_validation_issuer, # Use original issuer for JWT validation
|
||||
introspection_uri=introspection_uri, # Enable introspection for opaque tokens
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
nextcloud_host=nextcloud_host,
|
||||
userinfo_uri=nextcloud_userinfo_uri, # Nextcloud validates external tokens
|
||||
jwks_uri=jwks_uri, # External IdP's JWKS for JWT validation
|
||||
issuer=jwt_validation_issuer, # External IdP issuer
|
||||
introspection_uri=None, # External IdP introspection not used
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✓ External IdP mode configured - tokens validated via Nextcloud user_oidc app"
|
||||
)
|
||||
|
||||
else:
|
||||
# Integrated mode: Nextcloud provides both OAuth and validation
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
nextcloud_host=nextcloud_host,
|
||||
userinfo_uri=userinfo_uri, # Nextcloud userinfo endpoint
|
||||
jwks_uri=jwks_uri, # Nextcloud JWKS for JWT validation
|
||||
issuer=jwt_validation_issuer, # Nextcloud issuer (or public override)
|
||||
introspection_uri=introspection_uri, # Nextcloud introspection for opaque tokens
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✓ Integrated mode configured - Nextcloud provides OAuth and validation"
|
||||
)
|
||||
|
||||
# Create OAuth client for server-initiated flows (e.g., token exchange, background workers)
|
||||
oauth_client = None
|
||||
if enable_offline_access and refresh_token_storage and is_external_idp:
|
||||
# For external IdP mode, create generic OIDC client for token operations
|
||||
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
|
||||
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
redirect_uri = f"{mcp_server_url}/oauth/callback"
|
||||
|
||||
# Extract base URL and realm from discovery URL
|
||||
# Format: http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||
# → base_url: http://keycloak:8080, realm: nextcloud-mcp
|
||||
if "/realms/" in discovery_url:
|
||||
base_url = discovery_url.split("/realms/")[0]
|
||||
realm = discovery_url.split("/realms/")[1].split("/")[0]
|
||||
else:
|
||||
# Fallback: use issuer to extract base URL
|
||||
base_url = (
|
||||
issuer.rsplit("/realms/", 1)[0] if "/realms/" in issuer else issuer
|
||||
)
|
||||
realm = issuer.split("/realms/")[1] if "/realms/" in issuer else ""
|
||||
|
||||
oauth_client = KeycloakOAuthClient(
|
||||
keycloak_url=base_url,
|
||||
realm=realm,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
await oauth_client.discover()
|
||||
logger.info(
|
||||
"✓ OIDC client initialized for token operations (token exchange, refresh)"
|
||||
)
|
||||
elif enable_offline_access and refresh_token_storage:
|
||||
# For integrated mode, OAuth client could be added later
|
||||
# For now, token refresh can use httpx directly with discovered endpoints
|
||||
logger.info(
|
||||
"OAuth client for token refresh not yet implemented for integrated mode"
|
||||
)
|
||||
|
||||
# Create auth settings
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
@@ -405,13 +641,22 @@ async def setup_oauth_config():
|
||||
# Scopes are now advertised via PRM endpoint and enforced per-tool.
|
||||
# This allows dynamic tool filtering based on user's actual token scopes.
|
||||
auth_settings = AuthSettings(
|
||||
issuer_url=AnyHttpUrl(issuer),
|
||||
issuer_url=AnyHttpUrl(
|
||||
client_issuer
|
||||
), # Use client issuer (may be public override)
|
||||
resource_server_url=AnyHttpUrl(mcp_server_url),
|
||||
)
|
||||
|
||||
logger.info("OAuth configuration complete")
|
||||
|
||||
return nextcloud_host, token_verifier, auth_settings
|
||||
return (
|
||||
nextcloud_host,
|
||||
token_verifier,
|
||||
auth_settings,
|
||||
refresh_token_storage,
|
||||
oauth_client,
|
||||
oauth_provider,
|
||||
)
|
||||
|
||||
|
||||
def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
@@ -423,12 +668,55 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
if oauth_enabled:
|
||||
logger.info("Configuring MCP server for OAuth mode")
|
||||
# Asynchronously get the OAuth configuration
|
||||
import asyncio
|
||||
import anyio
|
||||
|
||||
(
|
||||
nextcloud_host,
|
||||
token_verifier,
|
||||
auth_settings,
|
||||
refresh_token_storage,
|
||||
oauth_client,
|
||||
oauth_provider,
|
||||
) = anyio.run(setup_oauth_config)
|
||||
|
||||
# Create lifespan function with captured OAuth context (closure)
|
||||
@asynccontextmanager
|
||||
async def oauth_lifespan(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
|
||||
"""
|
||||
Lifespan context for OAuth mode - captures OAuth configuration from outer scope.
|
||||
"""
|
||||
logger.info("Starting MCP server in OAuth mode")
|
||||
logger.info(f"Using OAuth provider: {oauth_provider}")
|
||||
if refresh_token_storage:
|
||||
logger.info("Refresh token storage is available")
|
||||
if oauth_client:
|
||||
logger.info("OAuth client is available for token refresh")
|
||||
|
||||
# Initialize document processors
|
||||
initialize_document_processors()
|
||||
|
||||
try:
|
||||
yield OAuthAppContext(
|
||||
nextcloud_host=nextcloud_host,
|
||||
token_verifier=token_verifier,
|
||||
refresh_token_storage=refresh_token_storage,
|
||||
oauth_client=oauth_client,
|
||||
oauth_provider=oauth_provider,
|
||||
)
|
||||
finally:
|
||||
logger.info("Shutting down MCP server")
|
||||
# RefreshTokenStorage uses context managers, no close() needed
|
||||
# OAuth client cleanup (if it has a close method)
|
||||
if oauth_client and hasattr(oauth_client, "close"):
|
||||
try:
|
||||
await oauth_client.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing OAuth client: {e}")
|
||||
logger.info("MCP server shutdown complete")
|
||||
|
||||
_, token_verifier, auth_settings = asyncio.run(setup_oauth_config())
|
||||
mcp = FastMCP(
|
||||
"Nextcloud MCP",
|
||||
lifespan=app_lifespan_oauth,
|
||||
lifespan=oauth_lifespan,
|
||||
token_verifier=token_verifier,
|
||||
auth=auth_settings,
|
||||
)
|
||||
@@ -474,7 +762,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
original_list_tools = mcp._tool_manager.list_tools
|
||||
|
||||
def list_tools_filtered():
|
||||
"""List tools filtered by user's token scopes (JWT tokens only)."""
|
||||
"""List tools filtered by user's token scopes (JWT and Bearer tokens)."""
|
||||
# Get user's scopes from token using MCP SDK's contextvar
|
||||
# This works for all request types including list_tools
|
||||
user_scopes = get_access_token_scopes()
|
||||
@@ -487,35 +775,36 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
# Get all tools
|
||||
all_tools = original_list_tools()
|
||||
|
||||
# Only filter for JWT tokens (opaque tokens show all tools)
|
||||
# JWT tokens have scopes embedded, so we can reliably filter
|
||||
# Opaque tokens may not have accurate scope information from introspection
|
||||
if is_jwt and user_scopes:
|
||||
# Filter tools based on user's token scopes (both JWT and opaque tokens)
|
||||
# JWT tokens have scopes embedded in payload
|
||||
# Opaque tokens get scopes via introspection endpoint
|
||||
# Claude Code now properly respects PRM endpoint for scope discovery
|
||||
if user_scopes:
|
||||
allowed_tools = [
|
||||
tool
|
||||
for tool in all_tools
|
||||
if has_required_scopes(tool.fn, user_scopes)
|
||||
]
|
||||
token_type = "JWT" if is_jwt else "Bearer"
|
||||
logger.info(
|
||||
f"✂️ JWT scope filtering: {len(allowed_tools)}/{len(all_tools)} tools "
|
||||
f"✂️ {token_type} scope filtering: {len(allowed_tools)}/{len(all_tools)} tools "
|
||||
f"available for scopes: {user_scopes}"
|
||||
)
|
||||
else:
|
||||
# Opaque token, BasicAuth mode, or no token - show all tools
|
||||
# BasicAuth mode or no token - show all tools
|
||||
allowed_tools = all_tools
|
||||
reason = (
|
||||
"opaque token (no filtering)"
|
||||
if not is_jwt and user_scopes
|
||||
else "no token/BasicAuth"
|
||||
logger.info(
|
||||
f"📋 Showing all {len(all_tools)} tools (no token/BasicAuth)"
|
||||
)
|
||||
logger.info(f"📋 Showing all {len(all_tools)} tools ({reason})")
|
||||
|
||||
# Return the Tool objects directly (they're already in the correct format)
|
||||
return allowed_tools
|
||||
|
||||
# Replace the tool manager's list_tools method
|
||||
mcp._tool_manager.list_tools = list_tools_filtered
|
||||
logger.info("Dynamic tool filtering enabled for OAuth mode (JWT tokens only)")
|
||||
logger.info(
|
||||
"Dynamic tool filtering enabled for OAuth mode (JWT and Bearer tokens)"
|
||||
)
|
||||
|
||||
if transport == "sse":
|
||||
mcp_app = mcp.sse_app()
|
||||
@@ -529,15 +818,85 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
yield
|
||||
|
||||
# Health check endpoints for Kubernetes probes
|
||||
def health_live(request):
|
||||
"""Liveness probe endpoint.
|
||||
|
||||
Returns 200 OK if the application process is running.
|
||||
This is a simple check that doesn't verify external dependencies.
|
||||
"""
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "alive",
|
||||
"mode": "oauth" if oauth_enabled else "basic",
|
||||
}
|
||||
)
|
||||
|
||||
async def health_ready(request):
|
||||
"""Readiness probe endpoint.
|
||||
|
||||
Returns 200 OK if the application is ready to serve traffic.
|
||||
Checks that required configuration is present.
|
||||
"""
|
||||
checks = {}
|
||||
is_ready = True
|
||||
|
||||
# Check Nextcloud host configuration
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if nextcloud_host:
|
||||
checks["nextcloud_configured"] = "ok"
|
||||
else:
|
||||
checks["nextcloud_configured"] = "error: NEXTCLOUD_HOST not set"
|
||||
is_ready = False
|
||||
|
||||
# Check authentication configuration
|
||||
if oauth_enabled:
|
||||
# OAuth mode - just verify we got this far (token_verifier initialized in lifespan)
|
||||
checks["auth_mode"] = "oauth"
|
||||
checks["auth_configured"] = "ok"
|
||||
else:
|
||||
# BasicAuth mode - verify credentials are set
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||
if username and password:
|
||||
checks["auth_mode"] = "basic"
|
||||
checks["auth_configured"] = "ok"
|
||||
else:
|
||||
checks["auth_mode"] = "basic"
|
||||
checks["auth_configured"] = "error: credentials not set"
|
||||
is_ready = False
|
||||
|
||||
status_code = 200 if is_ready else 503
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "ready" if is_ready else "not_ready",
|
||||
"checks": checks,
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
# Add Protected Resource Metadata (PRM) endpoint for OAuth mode
|
||||
routes = []
|
||||
|
||||
# Add health check routes (available in both OAuth and BasicAuth modes)
|
||||
routes.append(Route("/health/live", health_live, methods=["GET"]))
|
||||
routes.append(Route("/health/ready", health_ready, methods=["GET"]))
|
||||
logger.info("Health check endpoints enabled: /health/live, /health/ready")
|
||||
|
||||
if oauth_enabled:
|
||||
|
||||
def oauth_protected_resource_metadata(request):
|
||||
"""RFC 8959 Protected Resource Metadata endpoint."""
|
||||
"""RFC 9728 Protected Resource Metadata endpoint.
|
||||
|
||||
Dynamically discovers supported scopes from registered MCP tools.
|
||||
This ensures the advertised scopes always match the actual tool requirements.
|
||||
"""
|
||||
mcp_server_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
# Append /mcp to match the actual resource path (FastMCP streamable-http endpoint)
|
||||
resource_url = f"{mcp_server_url}/mcp"
|
||||
|
||||
# Use PUBLIC_ISSUER_URL for authorization server since external clients
|
||||
# (like Claude) need the publicly accessible URL, not internal Docker URLs
|
||||
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
@@ -545,16 +904,30 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
# Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set
|
||||
public_issuer_url = os.getenv("NEXTCLOUD_HOST", "")
|
||||
|
||||
# Dynamically discover all scopes from registered tools
|
||||
# This provides a single source of truth based on @require_scopes decorators
|
||||
supported_scopes = discover_all_scopes(mcp)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"resource": mcp_server_url,
|
||||
"scopes_supported": ["nc:read", "nc:write"],
|
||||
"resource": resource_url,
|
||||
"scopes_supported": supported_scopes,
|
||||
"authorization_servers": [public_issuer_url],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_signing_alg_values_supported": ["RS256"],
|
||||
}
|
||||
)
|
||||
|
||||
# Register PRM endpoint at both path-based and root locations per RFC 9728
|
||||
# Path-based discovery: /.well-known/oauth-protected-resource{path}
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-protected-resource/mcp",
|
||||
oauth_protected_resource_metadata,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
# Root discovery (fallback): /.well-known/oauth-protected-resource
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-protected-resource",
|
||||
@@ -562,11 +935,23 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
logger.info("Protected Resource Metadata (PRM) endpoint enabled")
|
||||
logger.info(
|
||||
"Protected Resource Metadata (PRM) endpoints enabled (path-based + root)"
|
||||
)
|
||||
|
||||
routes.append(Mount("/", app=mcp_app))
|
||||
app = Starlette(routes=routes, lifespan=lifespan)
|
||||
|
||||
# Add CORS middleware to allow browser-based clients like MCP Inspector
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Allow all origins for development
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["*"],
|
||||
)
|
||||
|
||||
# Add exception handler for scope challenges (OAuth mode only)
|
||||
if oauth_enabled:
|
||||
|
||||
@@ -584,7 +969,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
"WWW-Authenticate": (
|
||||
f'Bearer error="insufficient_scope", '
|
||||
f'scope="{scope_str}", '
|
||||
f'resource_metadata="{resource_url}/.well-known/oauth-protected-resource"'
|
||||
f'resource_metadata="{resource_url}/.well-known/oauth-protected-resource/mcp"'
|
||||
)
|
||||
},
|
||||
content={
|
||||
@@ -645,13 +1030,6 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
envvar="NEXTCLOUD_OIDC_CLIENT_SECRET",
|
||||
help="OAuth client secret (can also use NEXTCLOUD_OIDC_CLIENT_SECRET env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--oauth-storage-path",
|
||||
envvar="NEXTCLOUD_OIDC_CLIENT_STORAGE",
|
||||
default=".nextcloud_oauth_client.json",
|
||||
show_default=True,
|
||||
help="Path to store OAuth client credentials (can also use NEXTCLOUD_OIDC_CLIENT_STORAGE env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--mcp-server-url",
|
||||
envvar="NEXTCLOUD_MCP_SERVER_URL",
|
||||
@@ -677,9 +1055,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
@click.option(
|
||||
"--oauth-scopes",
|
||||
envvar="NEXTCLOUD_OIDC_SCOPES",
|
||||
default="openid profile email nc:read nc:write",
|
||||
default="openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write",
|
||||
show_default=True,
|
||||
help="OAuth scopes to request (can also use NEXTCLOUD_OIDC_SCOPES env var)",
|
||||
help="OAuth scopes to request during client registration. These define the maximum allowed scopes for the client. Note: Actual supported scopes are discovered dynamically from MCP tools at runtime. (can also use NEXTCLOUD_OIDC_SCOPES env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--oauth-token-type",
|
||||
@@ -703,7 +1081,6 @@ def run(
|
||||
oauth: bool | None,
|
||||
oauth_client_id: str | None,
|
||||
oauth_client_secret: str | None,
|
||||
oauth_storage_path: str,
|
||||
mcp_server_url: str,
|
||||
nextcloud_host: str | None,
|
||||
nextcloud_username: str | None,
|
||||
@@ -741,7 +1118,7 @@ def run(
|
||||
|
||||
# OAuth mode with custom scopes and JWT tokens
|
||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
|
||||
--oauth-scopes="openid nc:read" --oauth-token-type=jwt
|
||||
--oauth-scopes="openid notes:read notes:write" --oauth-token-type=jwt
|
||||
|
||||
# OAuth with public issuer URL (for Docker/proxy setups)
|
||||
$ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\
|
||||
@@ -758,8 +1135,6 @@ def run(
|
||||
os.environ["NEXTCLOUD_OIDC_CLIENT_ID"] = oauth_client_id
|
||||
if oauth_client_secret:
|
||||
os.environ["NEXTCLOUD_OIDC_CLIENT_SECRET"] = oauth_client_secret
|
||||
if oauth_storage_path:
|
||||
os.environ["NEXTCLOUD_OIDC_CLIENT_STORAGE"] = oauth_storage_path
|
||||
if oauth_scopes:
|
||||
os.environ["NEXTCLOUD_OIDC_SCOPES"] = oauth_scopes
|
||||
if oauth_token_type:
|
||||
@@ -802,13 +1177,7 @@ def run(
|
||||
click.echo("OAuth Configuration:", err=True)
|
||||
click.echo(" Mode: Dynamic Client Registration", err=True)
|
||||
click.echo(" Host: " + nextcloud_host, err=True)
|
||||
click.echo(
|
||||
" Storage: "
|
||||
+ os.getenv(
|
||||
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
click.echo(" Storage: SQLite (TOKEN_STORAGE_DB)", err=True)
|
||||
click.echo("", err=True)
|
||||
click.echo(
|
||||
"Note: Make sure 'Dynamic Client Registration' is enabled", err=True
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""OAuth authentication components for Nextcloud MCP server."""
|
||||
|
||||
from .bearer_auth import BearerAuth
|
||||
from .client_registration import load_or_register_client, register_client
|
||||
from .client_registration import ensure_oauth_client, register_client
|
||||
from .context_helper import get_client_from_context
|
||||
from .scope_authorization import (
|
||||
InsufficientScopeError,
|
||||
ScopeAuthorizationError,
|
||||
check_scopes,
|
||||
discover_all_scopes,
|
||||
get_access_token_scopes,
|
||||
get_required_scopes,
|
||||
has_required_scopes,
|
||||
@@ -19,12 +20,13 @@ __all__ = [
|
||||
"BearerAuth",
|
||||
"NextcloudTokenVerifier",
|
||||
"register_client",
|
||||
"load_or_register_client",
|
||||
"ensure_oauth_client",
|
||||
"get_client_from_context",
|
||||
"require_scopes",
|
||||
"ScopeAuthorizationError",
|
||||
"InsufficientScopeError",
|
||||
"check_scopes",
|
||||
"discover_all_scopes",
|
||||
"get_access_token_scopes",
|
||||
"get_required_scopes",
|
||||
"has_required_scopes",
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
"""Dynamic client registration for Nextcloud OIDC."""
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClientInfo:
|
||||
"""Client registration information."""
|
||||
"""Client registration information with RFC 7592 support."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -23,12 +23,16 @@ class ClientInfo:
|
||||
client_id_issued_at: int,
|
||||
client_secret_expires_at: int,
|
||||
redirect_uris: list[str],
|
||||
registration_access_token: str | None = None,
|
||||
registration_client_uri: str | None = None,
|
||||
):
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.client_id_issued_at = client_id_issued_at
|
||||
self.client_secret_expires_at = client_secret_expires_at
|
||||
self.redirect_uris = redirect_uris
|
||||
self.registration_access_token = registration_access_token
|
||||
self.registration_client_uri = registration_client_uri
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
@@ -42,13 +46,18 @@ class ClientInfo:
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for storage."""
|
||||
return {
|
||||
result = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"client_id_issued_at": self.client_id_issued_at,
|
||||
"client_secret_expires_at": self.client_secret_expires_at,
|
||||
"redirect_uris": self.redirect_uris,
|
||||
}
|
||||
if self.registration_access_token:
|
||||
result["registration_access_token"] = self.registration_access_token
|
||||
if self.registration_client_uri:
|
||||
result["registration_client_uri"] = self.registration_client_uri
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "ClientInfo":
|
||||
@@ -59,6 +68,8 @@ class ClientInfo:
|
||||
client_id_issued_at=data["client_id_issued_at"],
|
||||
client_secret_expires_at=data["client_secret_expires_at"],
|
||||
redirect_uris=data["redirect_uris"],
|
||||
registration_access_token=data.get("registration_access_token"),
|
||||
registration_client_uri=data.get("registration_client_uri"),
|
||||
)
|
||||
|
||||
|
||||
@@ -125,6 +136,16 @@ async def register_client(
|
||||
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
|
||||
)
|
||||
|
||||
# Log if RFC 7592 fields are present
|
||||
has_reg_token = "registration_access_token" in client_info
|
||||
has_reg_uri = "registration_client_uri" in client_info
|
||||
if has_reg_token and has_reg_uri:
|
||||
logger.info(
|
||||
"RFC 7592 management fields received - client deletion will be supported"
|
||||
)
|
||||
else:
|
||||
logger.warning("RFC 7592 fields missing - client deletion may not work")
|
||||
|
||||
return ClientInfo(
|
||||
client_id=client_info["client_id"],
|
||||
client_secret=client_info["client_secret"],
|
||||
@@ -135,6 +156,8 @@ async def register_client(
|
||||
"client_secret_expires_at", int(time.time()) + 3600
|
||||
),
|
||||
redirect_uris=client_info.get("redirect_uris", redirect_uris),
|
||||
registration_access_token=client_info.get("registration_access_token"),
|
||||
registration_client_uri=client_info.get("registration_client_uri"),
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
@@ -146,94 +169,154 @@ async def register_client(
|
||||
raise ValueError(f"Invalid registration response: missing {e}")
|
||||
|
||||
|
||||
def load_client_from_file(storage_path: Path) -> ClientInfo | None:
|
||||
async def delete_client(
|
||||
nextcloud_url: str,
|
||||
client_id: str,
|
||||
registration_access_token: str | None = None,
|
||||
client_secret: str | None = None,
|
||||
registration_client_uri: str | None = None,
|
||||
max_retries: int = 3,
|
||||
) -> bool:
|
||||
"""
|
||||
Load client credentials from storage file.
|
||||
Delete a dynamically registered OAuth client using RFC 7592.
|
||||
|
||||
This implements RFC 7592 Section 2.3 (Client Delete Request).
|
||||
Prefers Bearer token authentication (RFC 7592 standard) but falls back
|
||||
to HTTP Basic Auth if registration_access_token is not available.
|
||||
|
||||
Args:
|
||||
storage_path: Path to the JSON file containing client credentials
|
||||
nextcloud_url: Base URL of the Nextcloud instance
|
||||
client_id: Client identifier to delete
|
||||
registration_access_token: RFC 7592 registration access token (preferred)
|
||||
client_secret: Client secret for fallback HTTP Basic Auth
|
||||
registration_client_uri: RFC 7592 client configuration URI (optional)
|
||||
max_retries: Maximum number of retries for 429 responses (default: 3)
|
||||
|
||||
Returns:
|
||||
ClientInfo if file exists and is valid, None otherwise
|
||||
True if deletion successful, False otherwise
|
||||
|
||||
Note:
|
||||
RFC 7592 deletion endpoint: {registration_client_uri} or {nextcloud_url}/apps/oidc/register/{client_id}
|
||||
|
||||
Authentication methods (in order of preference):
|
||||
1. Bearer token: Authorization: Bearer {registration_access_token} (RFC 7592 standard)
|
||||
2. HTTP Basic Auth: client_id as username, client_secret as password (fallback)
|
||||
"""
|
||||
if not storage_path.exists():
|
||||
logger.debug(f"Client storage file not found: {storage_path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(storage_path, "r") as f:
|
||||
data = json.load(f)
|
||||
# Determine deletion endpoint
|
||||
if registration_client_uri:
|
||||
deletion_endpoint = registration_client_uri
|
||||
else:
|
||||
deletion_endpoint = f"{nextcloud_url}/apps/oidc/register/{client_id}"
|
||||
|
||||
client_info = ClientInfo.from_dict(data)
|
||||
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
|
||||
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
|
||||
|
||||
if client_info.is_expired:
|
||||
logger.warning(
|
||||
f"Stored client has expired (expired at {client_info.client_secret_expires_at})"
|
||||
)
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Prefer RFC 7592 Bearer token authentication
|
||||
if registration_access_token:
|
||||
logger.debug("Using RFC 7592 Bearer token authentication")
|
||||
response = await http_client.delete(
|
||||
deletion_endpoint,
|
||||
headers={
|
||||
"Authorization": f"Bearer {registration_access_token}"
|
||||
},
|
||||
)
|
||||
elif client_secret:
|
||||
logger.debug(
|
||||
"Falling back to HTTP Basic Auth (registration_access_token not available)"
|
||||
)
|
||||
response = await http_client.delete(
|
||||
deletion_endpoint,
|
||||
auth=(client_id, client_secret),
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Cannot delete client: no registration_access_token or client_secret provided"
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(f"Loaded client from storage: {client_info.client_id[:16]}...")
|
||||
if client_info.expires_soon:
|
||||
logger.warning("Client expires soon (within 5 minutes)")
|
||||
# RFC 7592: Successful deletion returns 204 No Content
|
||||
if response.status_code == 204:
|
||||
logger.info(
|
||||
f"Successfully deleted OAuth client: {client_id[:16]}..."
|
||||
)
|
||||
return True
|
||||
elif response.status_code == 429:
|
||||
# Rate limited - retry with exponential backoff
|
||||
if attempt < max_retries - 1:
|
||||
retry_after = int(response.headers.get("Retry-After", 2))
|
||||
wait_time = min(
|
||||
retry_after, 2**attempt
|
||||
) # Exponential backoff, max from header
|
||||
logger.warning(
|
||||
f"Rate limited (429) deleting client {client_id[:16]}..., "
|
||||
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
|
||||
)
|
||||
await anyio.sleep(wait_time)
|
||||
continue
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to delete client {client_id[:16]}... after {max_retries} attempts: Rate limited (429)"
|
||||
)
|
||||
return False
|
||||
elif response.status_code == 401:
|
||||
logger.error(
|
||||
f"Failed to delete client {client_id[:16]}...: Authentication failed (invalid credentials)"
|
||||
)
|
||||
return False
|
||||
elif response.status_code == 403:
|
||||
logger.error(
|
||||
f"Failed to delete client {client_id[:16]}...: Not authorized (not a DCR client or wrong client)"
|
||||
)
|
||||
return False
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to delete client {client_id[:16]}...: HTTP {response.status_code}"
|
||||
)
|
||||
logger.debug(f"Response: {response.text}")
|
||||
return False
|
||||
|
||||
return client_info
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"HTTP error deleting client {client_id[:16]}...: {e.response.status_code}"
|
||||
)
|
||||
logger.debug(f"Response: {e.response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error deleting client {client_id[:16]}...: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||
logger.error(f"Failed to load client from file: {e}")
|
||||
return None
|
||||
# Should not reach here, but return False if we do
|
||||
return False
|
||||
|
||||
|
||||
def save_client_to_file(client_info: ClientInfo, storage_path: Path):
|
||||
"""
|
||||
Save client credentials to storage file.
|
||||
|
||||
Args:
|
||||
client_info: Client information to save
|
||||
storage_path: Path to save the JSON file
|
||||
|
||||
Raises:
|
||||
OSError: If file cannot be written
|
||||
"""
|
||||
try:
|
||||
# Create directory if it doesn't exist
|
||||
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write client info
|
||||
with open(storage_path, "w") as f:
|
||||
json.dump(client_info.to_dict(), f, indent=2)
|
||||
|
||||
# Set restrictive permissions (owner read/write only)
|
||||
os.chmod(storage_path, 0o600)
|
||||
|
||||
logger.info(f"Saved client credentials to {storage_path}")
|
||||
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to save client credentials: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def load_or_register_client(
|
||||
async def ensure_oauth_client(
|
||||
nextcloud_url: str,
|
||||
registration_endpoint: str,
|
||||
storage_path: str | Path,
|
||||
storage: RefreshTokenStorage,
|
||||
client_name: str = "Nextcloud MCP Server",
|
||||
redirect_uris: list[str] | None = None,
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str = "Bearer",
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Load client from storage or register a new one if not found/expired.
|
||||
Ensure OAuth client exists in SQLite storage.
|
||||
|
||||
This function:
|
||||
1. Checks for existing client credentials in storage
|
||||
1. Checks for existing client credentials in SQLite storage
|
||||
2. Validates the credentials are not expired
|
||||
3. Registers a new client if needed (no stored credentials or expired)
|
||||
4. Saves the new client credentials
|
||||
4. Saves the new client credentials to SQLite
|
||||
|
||||
Args:
|
||||
nextcloud_url: Base URL of the Nextcloud instance
|
||||
registration_endpoint: Full URL to the registration endpoint
|
||||
storage_path: Path to store client credentials
|
||||
storage: RefreshTokenStorage instance for SQLite storage
|
||||
client_name: Name of the client application
|
||||
redirect_uris: List of redirect URIs
|
||||
scopes: Space-separated list of scopes to request (default: "openid profile email")
|
||||
@@ -246,12 +329,13 @@ async def load_or_register_client(
|
||||
httpx.HTTPStatusError: If registration fails
|
||||
ValueError: If response is invalid
|
||||
"""
|
||||
storage_path = Path(storage_path)
|
||||
|
||||
# Try to load existing client
|
||||
client_info = load_client_from_file(storage_path)
|
||||
if client_info:
|
||||
return client_info
|
||||
# Try to load existing client from SQLite
|
||||
client_data = await storage.get_oauth_client()
|
||||
if client_data:
|
||||
logger.info(
|
||||
f"Loaded OAuth client from SQLite: {client_data['client_id'][:16]}..."
|
||||
)
|
||||
return ClientInfo.from_dict(client_data)
|
||||
|
||||
# Register new client
|
||||
logger.info("Registering new OAuth client...")
|
||||
@@ -264,7 +348,15 @@ async def load_or_register_client(
|
||||
token_type=token_type,
|
||||
)
|
||||
|
||||
# Save to storage
|
||||
save_client_to_file(client_info, storage_path)
|
||||
# Save to SQLite storage
|
||||
await storage.store_oauth_client(
|
||||
client_id=client_info.client_id,
|
||||
client_secret=client_info.client_secret,
|
||||
client_id_issued_at=client_info.client_id_issued_at,
|
||||
client_secret_expires_at=client_info.client_secret_expires_at,
|
||||
redirect_uris=client_info.redirect_uris,
|
||||
registration_access_token=client_info.registration_access_token,
|
||||
registration_client_uri=client_info.registration_client_uri,
|
||||
)
|
||||
|
||||
return client_info
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
"""
|
||||
Keycloak OAuth 2.0 / OIDC Client
|
||||
|
||||
Handles OAuth flows with Keycloak as the identity provider, including:
|
||||
- OIDC Discovery
|
||||
- Authorization Code Flow with PKCE
|
||||
- Token refresh using refresh tokens (ADR-002 Tier 1)
|
||||
- Integration with RefreshTokenStorage
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KeycloakOAuthClient:
|
||||
"""OAuth 2.0 client for Keycloak integration"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
keycloak_url: str,
|
||||
realm: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
redirect_uri: str,
|
||||
scopes: Optional[list[str]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize Keycloak OAuth client.
|
||||
|
||||
Args:
|
||||
keycloak_url: Base URL of Keycloak (e.g., http://keycloak:8080)
|
||||
realm: Keycloak realm name
|
||||
client_id: OAuth client ID
|
||||
client_secret: OAuth client secret
|
||||
redirect_uri: OAuth redirect URI
|
||||
scopes: List of scopes to request (default: openid, profile, email, offline_access)
|
||||
"""
|
||||
self.keycloak_url = keycloak_url.rstrip("/")
|
||||
self.realm = realm
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
self.scopes = scopes or ["openid", "profile", "email", "offline_access"]
|
||||
|
||||
# Discovered endpoints (populated by discover())
|
||||
self.authorization_endpoint: Optional[str] = None
|
||||
self.token_endpoint: Optional[str] = None
|
||||
self.userinfo_endpoint: Optional[str] = None
|
||||
self.jwks_uri: Optional[str] = None
|
||||
self.end_session_endpoint: Optional[str] = None
|
||||
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "KeycloakOAuthClient":
|
||||
"""
|
||||
Create client from environment variables.
|
||||
|
||||
Environment variables:
|
||||
KEYCLOAK_URL: Keycloak base URL
|
||||
KEYCLOAK_REALM: Realm name
|
||||
KEYCLOAK_CLIENT_ID: Client ID
|
||||
KEYCLOAK_CLIENT_SECRET: Client secret
|
||||
NEXTCLOUD_MCP_SERVER_URL: MCP server URL (for redirect URI)
|
||||
|
||||
Returns:
|
||||
KeycloakOAuthClient instance
|
||||
|
||||
Raises:
|
||||
ValueError: If required environment variables are missing
|
||||
"""
|
||||
keycloak_url = os.getenv("KEYCLOAK_URL")
|
||||
realm = os.getenv("KEYCLOAK_REALM")
|
||||
client_id = os.getenv("KEYCLOAK_CLIENT_ID")
|
||||
client_secret = os.getenv("KEYCLOAK_CLIENT_SECRET")
|
||||
server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
|
||||
if not all([keycloak_url, realm, client_id, client_secret]):
|
||||
raise ValueError(
|
||||
"Missing required environment variables: "
|
||||
"KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET"
|
||||
)
|
||||
|
||||
# Parse server URL to construct redirect URI
|
||||
parsed_url = urlparse(server_url)
|
||||
redirect_uri = f"{parsed_url.scheme}://{parsed_url.netloc}/oauth/callback"
|
||||
|
||||
return cls(
|
||||
keycloak_url=keycloak_url,
|
||||
realm=realm,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
|
||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create HTTP client"""
|
||||
if self._http_client is None:
|
||||
self._http_client = httpx.AsyncClient(timeout=30.0)
|
||||
return self._http_client
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close HTTP client"""
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
async def discover(self) -> None:
|
||||
"""
|
||||
Perform OIDC discovery to get endpoint URLs.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: If discovery fails
|
||||
"""
|
||||
discovery_url = (
|
||||
f"{self.keycloak_url}/realms/{self.realm}/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
logger.info(f"Discovering Keycloak endpoints at {discovery_url}")
|
||||
|
||||
client = await self._get_http_client()
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
|
||||
discovery_data = response.json()
|
||||
|
||||
self.authorization_endpoint = discovery_data["authorization_endpoint"]
|
||||
self.token_endpoint = discovery_data["token_endpoint"]
|
||||
self.userinfo_endpoint = discovery_data["userinfo_endpoint"]
|
||||
self.jwks_uri = discovery_data.get("jwks_uri")
|
||||
self.end_session_endpoint = discovery_data.get("end_session_endpoint")
|
||||
|
||||
logger.info(
|
||||
f"✓ Discovered Keycloak endpoints:\n"
|
||||
f" Authorization: {self.authorization_endpoint}\n"
|
||||
f" Token: {self.token_endpoint}\n"
|
||||
f" Userinfo: {self.userinfo_endpoint}\n"
|
||||
f" JWKS: {self.jwks_uri}"
|
||||
)
|
||||
|
||||
def generate_pkce_challenge(self) -> tuple[str, str]:
|
||||
"""
|
||||
Generate PKCE code verifier and challenge.
|
||||
|
||||
Returns:
|
||||
Tuple of (code_verifier, code_challenge)
|
||||
"""
|
||||
import base64
|
||||
|
||||
# Generate code verifier (43-128 characters)
|
||||
code_verifier = secrets.token_urlsafe(32)
|
||||
|
||||
# Generate code challenge using S256 method (base64url-encoded SHA256)
|
||||
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||
code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
||||
|
||||
return code_verifier, code_challenge
|
||||
|
||||
async def get_authorization_url(
|
||||
self,
|
||||
state: str,
|
||||
code_challenge: str,
|
||||
extra_params: Optional[dict[str, str]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build authorization URL for OAuth flow.
|
||||
|
||||
Args:
|
||||
state: CSRF protection state parameter
|
||||
code_challenge: PKCE code challenge
|
||||
extra_params: Additional query parameters
|
||||
|
||||
Returns:
|
||||
Authorization URL
|
||||
|
||||
Raises:
|
||||
RuntimeError: If discover() hasn't been called
|
||||
"""
|
||||
if not self.authorization_endpoint:
|
||||
await self.discover()
|
||||
|
||||
if not self.authorization_endpoint:
|
||||
raise RuntimeError("Authorization endpoint not discovered")
|
||||
|
||||
params = {
|
||||
"client_id": self.client_id,
|
||||
"response_type": "code",
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"scope": " ".join(self.scopes),
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
|
||||
if extra_params:
|
||||
params.update(extra_params)
|
||||
|
||||
return f"{self.authorization_endpoint}?{urlencode(params)}"
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self,
|
||||
code: str,
|
||||
code_verifier: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Exchange authorization code for tokens.
|
||||
|
||||
Args:
|
||||
code: Authorization code from OAuth callback
|
||||
code_verifier: PKCE code verifier
|
||||
|
||||
Returns:
|
||||
Token response dictionary with keys:
|
||||
- access_token: Access token
|
||||
- refresh_token: Refresh token (if offline_access scope requested)
|
||||
- id_token: ID token (JWT)
|
||||
- expires_in: Access token lifetime in seconds
|
||||
- refresh_expires_in: Refresh token lifetime in seconds (optional)
|
||||
- token_type: Token type (Bearer)
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: If token exchange fails
|
||||
"""
|
||||
if not self.token_endpoint:
|
||||
await self.discover()
|
||||
|
||||
if not self.token_endpoint:
|
||||
raise RuntimeError("Token endpoint not discovered")
|
||||
|
||||
logger.debug(
|
||||
f"Exchanging authorization code for tokens at {self.token_endpoint}"
|
||||
)
|
||||
|
||||
client = await self._get_http_client()
|
||||
response = await client.post(
|
||||
self.token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"code_verifier": code_verifier,
|
||||
},
|
||||
auth=(self.client_id, self.client_secret),
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
logger.info("✓ Successfully exchanged authorization code for tokens")
|
||||
|
||||
if "refresh_token" in token_data:
|
||||
logger.info(" Received refresh token (offline_access granted)")
|
||||
|
||||
return token_data
|
||||
|
||||
async def refresh_access_token(self, refresh_token: str) -> dict:
|
||||
"""
|
||||
Refresh access token using refresh token.
|
||||
|
||||
Args:
|
||||
refresh_token: Refresh token
|
||||
|
||||
Returns:
|
||||
Token response dictionary (same format as exchange_authorization_code)
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: If token refresh fails
|
||||
"""
|
||||
if not self.token_endpoint:
|
||||
await self.discover()
|
||||
|
||||
if not self.token_endpoint:
|
||||
raise RuntimeError("Token endpoint not discovered")
|
||||
|
||||
logger.debug("Refreshing access token")
|
||||
|
||||
client = await self._get_http_client()
|
||||
response = await client.post(
|
||||
self.token_endpoint,
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
auth=(self.client_id, self.client_secret),
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
logger.debug("✓ Successfully refreshed access token")
|
||||
|
||||
return token_data
|
||||
|
||||
async def get_userinfo(self, access_token: str) -> dict:
|
||||
"""
|
||||
Get user information using access token.
|
||||
|
||||
Args:
|
||||
access_token: Access token
|
||||
|
||||
Returns:
|
||||
Userinfo response dictionary with claims like:
|
||||
- sub: Subject (user ID)
|
||||
- name: Full name
|
||||
- preferred_username: Username
|
||||
- email: Email address
|
||||
- email_verified: Email verification status
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: If userinfo request fails
|
||||
"""
|
||||
if not self.userinfo_endpoint:
|
||||
await self.discover()
|
||||
|
||||
if not self.userinfo_endpoint:
|
||||
raise RuntimeError("Userinfo endpoint not discovered")
|
||||
|
||||
logger.debug("Fetching user info")
|
||||
|
||||
client = await self._get_http_client()
|
||||
response = await client.get(
|
||||
self.userinfo_endpoint,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
userinfo = response.json()
|
||||
|
||||
logger.debug(f"✓ Retrieved user info for subject: {userinfo.get('sub')}")
|
||||
|
||||
return userinfo
|
||||
|
||||
async def get_service_account_token(self, scopes: list[str] | None = None) -> dict:
|
||||
"""
|
||||
Get a service account token using client_credentials grant.
|
||||
|
||||
⚠️ **WARNING: DO NOT USE FOR DIRECT API ACCESS IN OAUTH MODE** ⚠️
|
||||
|
||||
This method creates a service account user in Nextcloud which VIOLATES
|
||||
OAuth "act on-behalf-of" principles. Using this token directly for API
|
||||
access will:
|
||||
- Create a Nextcloud user: `service-account-{client_id}`
|
||||
- Attribute all actions to service account instead of real user
|
||||
- Break audit trail and user attribution
|
||||
- Create stateful server identity in Nextcloud
|
||||
- Violate OAuth security model
|
||||
|
||||
**Valid Use Case**: ONLY as subject_token for RFC 8693 token exchange
|
||||
(ADR-002 Tier 2) where it's immediately exchanged for a user token.
|
||||
|
||||
**Invalid Use Case**: Direct API access with this token (ADR-002 rejected
|
||||
this as "Tier 1" - see docs/ADR-002-vector-sync-authentication.md).
|
||||
|
||||
**Alternative**: Use token exchange (impersonation/delegation) for
|
||||
background operations, or use BasicAuth mode if truly need service account.
|
||||
|
||||
This requires the client to have serviceAccountsEnabled=true in provider.
|
||||
|
||||
Args:
|
||||
scopes: Optional list of scopes to request (default: openid profile email)
|
||||
|
||||
Returns:
|
||||
Token response dictionary with:
|
||||
- access_token: Service account access token
|
||||
- token_type: Bearer
|
||||
- expires_in: Token lifetime in seconds
|
||||
- scope: Granted scopes
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: If token request fails
|
||||
|
||||
See Also:
|
||||
- ADR-002 "Will Not Implement" section for detailed critique
|
||||
- exchange_token_for_user() for proper token exchange usage
|
||||
"""
|
||||
if not self.token_endpoint:
|
||||
await self.discover()
|
||||
|
||||
if not self.token_endpoint:
|
||||
raise RuntimeError("Token endpoint not discovered")
|
||||
|
||||
# Default scopes
|
||||
if scopes is None:
|
||||
scopes = ["openid", "profile", "email"]
|
||||
|
||||
scope_str = " ".join(scopes)
|
||||
|
||||
logger.info(f"Requesting service account token with scopes: {scope_str}")
|
||||
|
||||
client = await self._get_http_client()
|
||||
response = await client.post(
|
||||
self.token_endpoint,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"scope": scope_str,
|
||||
},
|
||||
auth=(self.client_id, self.client_secret),
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
logger.info("✓ Service account token acquired")
|
||||
|
||||
return token_data
|
||||
|
||||
async def exchange_token_for_user(
|
||||
self,
|
||||
subject_token: str,
|
||||
target_user_id: str | None = None,
|
||||
audience: str | None = None,
|
||||
scopes: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Exchange a token for a user-scoped token using RFC 8693 Token Exchange.
|
||||
|
||||
This allows the MCP server (with a service account token) to obtain
|
||||
user-scoped access tokens for background operations without needing
|
||||
refresh tokens.
|
||||
|
||||
Args:
|
||||
subject_token: The token being exchanged (service account or user token)
|
||||
target_user_id: Optional user ID to impersonate/exchange for
|
||||
audience: Optional target audience (client ID)
|
||||
scopes: Optional list of scopes for the new token
|
||||
|
||||
Returns:
|
||||
Token response dictionary with:
|
||||
- access_token: User-scoped access token
|
||||
- issued_token_type: urn:ietf:params:oauth:token-type:access_token
|
||||
- token_type: Bearer
|
||||
- expires_in: Token lifetime in seconds
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: If token exchange fails (403 if not authorized)
|
||||
|
||||
Example:
|
||||
# Get service account token
|
||||
service_token = await client.get_service_account_token()
|
||||
|
||||
# Exchange for user-scoped token
|
||||
user_token = await client.exchange_token_for_user(
|
||||
subject_token=service_token["access_token"],
|
||||
target_user_id="admin", # Username or sub claim
|
||||
audience="nextcloud",
|
||||
scopes=["notes:read", "files:read"]
|
||||
)
|
||||
|
||||
Note:
|
||||
This implements BOTH ADR-002 tiers:
|
||||
|
||||
**Tier 2 (Delegation - Recommended)**: When target_user_id is None
|
||||
- Uses Keycloak Standard V2 (production-ready)
|
||||
- Service account maintains its identity (sub claim unchanged)
|
||||
- No special permissions required
|
||||
|
||||
**Tier 1 (Impersonation - Advanced)**: When target_user_id is provided
|
||||
- Requires Keycloak Legacy V1 (--features=preview)
|
||||
- Subject claim changes to target user
|
||||
- Requires impersonation role granted via Keycloak CLI:
|
||||
```
|
||||
kcadm.sh add-roles -r <realm> \
|
||||
--uusername service-account-<client-id> \
|
||||
--cclientid realm-management \
|
||||
--rolename impersonation
|
||||
```
|
||||
|
||||
Both tiers require:
|
||||
- Client has token.exchange.grant.enabled=true
|
||||
- Client has serviceAccountsEnabled=true
|
||||
"""
|
||||
if not self.token_endpoint:
|
||||
await self.discover()
|
||||
|
||||
if not self.token_endpoint:
|
||||
raise RuntimeError("Token endpoint not discovered")
|
||||
|
||||
# Build token exchange request
|
||||
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",
|
||||
}
|
||||
|
||||
# Add optional parameters
|
||||
if audience:
|
||||
data["audience"] = audience
|
||||
|
||||
if scopes:
|
||||
data["scope"] = " ".join(scopes)
|
||||
|
||||
if target_user_id:
|
||||
# Tier 1: Impersonation (Legacy V1)
|
||||
# Use requested_subject for user impersonation
|
||||
data["requested_subject"] = target_user_id
|
||||
logger.info(
|
||||
f"Exchanging token with impersonation (Tier 1): target_user={target_user_id}"
|
||||
)
|
||||
else:
|
||||
# Tier 2: Delegation (Standard V2)
|
||||
logger.info(
|
||||
"Exchanging token with delegation (Tier 2): service account identity preserved"
|
||||
)
|
||||
|
||||
client = await self._get_http_client()
|
||||
response = await client.post(
|
||||
self.token_endpoint,
|
||||
data=data,
|
||||
auth=(self.client_id, self.client_secret),
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
error_data = (
|
||||
response.json()
|
||||
if response.headers.get("content-type", "").startswith(
|
||||
"application/json"
|
||||
)
|
||||
else {"error": "unknown"}
|
||||
)
|
||||
logger.error(f"Token exchange failed: {response.status_code}")
|
||||
logger.error(f"Error response: {error_data}")
|
||||
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
logger.info(
|
||||
f"✓ Token exchange successful, issued_token_type: {token_data.get('issued_token_type')}"
|
||||
)
|
||||
|
||||
return token_data
|
||||
|
||||
async def check_token_exchange_support(self) -> bool:
|
||||
"""
|
||||
Check if Keycloak supports RFC 8693 token exchange.
|
||||
|
||||
Returns:
|
||||
True if token exchange is supported
|
||||
|
||||
Note:
|
||||
This is ADR-002 Tier 2. Most Keycloak installations don't
|
||||
have token exchange enabled by default.
|
||||
"""
|
||||
if not self.token_endpoint:
|
||||
await self.discover()
|
||||
|
||||
# Try to get discovery document and check for token exchange grant
|
||||
discovery_url = (
|
||||
f"{self.keycloak_url}/realms/{self.realm}/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
try:
|
||||
client = await self._get_http_client()
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery_data = response.json()
|
||||
|
||||
grant_types = discovery_data.get("grant_types_supported", [])
|
||||
supported = "urn:ietf:params:oauth:grant-type:token-exchange" in grant_types
|
||||
|
||||
if supported:
|
||||
logger.info("✓ Token exchange (RFC 8693) is supported")
|
||||
else:
|
||||
logger.info("Token exchange (RFC 8693) is not supported")
|
||||
|
||||
return supported
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check token exchange support: {e}")
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["KeycloakOAuthClient"]
|
||||
@@ -0,0 +1,628 @@
|
||||
"""
|
||||
Refresh Token Storage for ADR-002 Tier 1: Offline Access
|
||||
|
||||
Securely stores and manages user refresh tokens for background operations.
|
||||
Tokens are encrypted at rest using Fernet symmetric encryption.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import aiosqlite
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RefreshTokenStorage:
|
||||
"""Securely store and manage user refresh tokens"""
|
||||
|
||||
def __init__(self, db_path: str, encryption_key: bytes):
|
||||
"""
|
||||
Initialize refresh token storage.
|
||||
|
||||
Args:
|
||||
db_path: Path to SQLite database file
|
||||
encryption_key: Fernet encryption key (32 bytes, base64-encoded)
|
||||
"""
|
||||
self.db_path = db_path
|
||||
self.cipher = Fernet(encryption_key)
|
||||
self._initialized = False
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "RefreshTokenStorage":
|
||||
"""
|
||||
Create storage instance from environment variables.
|
||||
|
||||
Environment variables:
|
||||
TOKEN_STORAGE_DB: Path to database file (default: /app/data/tokens.db)
|
||||
TOKEN_ENCRYPTION_KEY: Base64-encoded Fernet key
|
||||
|
||||
Returns:
|
||||
RefreshTokenStorage instance
|
||||
|
||||
Raises:
|
||||
ValueError: If TOKEN_ENCRYPTION_KEY is not set
|
||||
"""
|
||||
db_path = os.getenv("TOKEN_STORAGE_DB", "/app/data/tokens.db")
|
||||
encryption_key_b64 = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
|
||||
if not encryption_key_b64:
|
||||
raise ValueError(
|
||||
"TOKEN_ENCRYPTION_KEY environment variable is required. "
|
||||
"Generate one with: python -c 'from cryptography.fernet import Fernet; "
|
||||
"print(Fernet.generate_key().decode())'"
|
||||
)
|
||||
|
||||
# Fernet expects a base64url-encoded key as bytes, not decoded bytes
|
||||
# The key from Fernet.generate_key() is already base64url-encoded
|
||||
try:
|
||||
# Convert string to bytes if needed
|
||||
if isinstance(encryption_key_b64, str):
|
||||
encryption_key = encryption_key_b64.encode()
|
||||
else:
|
||||
encryption_key = encryption_key_b64
|
||||
|
||||
# Validate the key by trying to create a Fernet instance
|
||||
Fernet(encryption_key)
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"Invalid TOKEN_ENCRYPTION_KEY: {e}. "
|
||||
"Must be a valid Fernet key (base64url-encoded 32 bytes)."
|
||||
) from e
|
||||
|
||||
return cls(db_path=db_path, encryption_key=encryption_key)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize database schema"""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
# Ensure directory exists
|
||||
db_dir = Path(self.db_path).parent
|
||||
db_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Set restrictive permissions on database file
|
||||
if Path(self.db_path).exists():
|
||||
os.chmod(self.db_path, 0o600)
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
encrypted_token BLOB NOT NULL,
|
||||
expires_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
resource_type TEXT,
|
||||
resource_id TEXT,
|
||||
auth_method TEXT,
|
||||
hostname TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Create index on audit logs for efficient queries
|
||||
await db.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp "
|
||||
"ON audit_logs(user_id, timestamp)"
|
||||
)
|
||||
|
||||
# OAuth client credentials storage
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
id INTEGER PRIMARY KEY,
|
||||
client_id TEXT UNIQUE NOT NULL,
|
||||
encrypted_client_secret BLOB NOT NULL,
|
||||
client_id_issued_at INTEGER NOT NULL,
|
||||
client_secret_expires_at INTEGER NOT NULL,
|
||||
redirect_uris TEXT NOT NULL,
|
||||
encrypted_registration_access_token BLOB,
|
||||
registration_client_uri TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Set restrictive permissions after creation
|
||||
os.chmod(self.db_path, 0o600)
|
||||
|
||||
self._initialized = True
|
||||
logger.info(f"Initialized refresh token storage at {self.db_path}")
|
||||
|
||||
async def store_refresh_token(
|
||||
self,
|
||||
user_id: str,
|
||||
refresh_token: str,
|
||||
expires_at: Optional[int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Store encrypted refresh token for user.
|
||||
|
||||
Args:
|
||||
user_id: User identifier (from OIDC 'sub' claim)
|
||||
refresh_token: Refresh token to store
|
||||
expires_at: Token expiration timestamp (Unix epoch), if known
|
||||
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
encrypted_token = self.cipher.encrypt(refresh_token.encode())
|
||||
now = int(time.time())
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO refresh_tokens
|
||||
(user_id, encrypted_token, expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, COALESCE((SELECT created_at FROM refresh_tokens WHERE user_id = ?), ?), ?)
|
||||
""",
|
||||
(user_id, encrypted_token, expires_at, user_id, now, now),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Stored refresh token for user {user_id}"
|
||||
+ (f" (expires at {expires_at})" if expires_at else "")
|
||||
)
|
||||
|
||||
# Audit log
|
||||
await self._audit_log(
|
||||
event="store_refresh_token",
|
||||
user_id=user_id,
|
||||
auth_method="offline_access",
|
||||
)
|
||||
|
||||
async def get_refresh_token(self, user_id: str) -> Optional[str]:
|
||||
"""
|
||||
Retrieve and decrypt refresh token for user.
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
|
||||
Returns:
|
||||
Decrypted refresh token, or None if not found or expired
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT encrypted_token, expires_at FROM refresh_tokens WHERE user_id = ?",
|
||||
(user_id,),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
logger.debug(f"No refresh token found for user {user_id}")
|
||||
return None
|
||||
|
||||
encrypted_token, expires_at = row
|
||||
|
||||
# Check expiration
|
||||
if expires_at is not None and expires_at < time.time():
|
||||
logger.warning(
|
||||
f"Refresh token for user {user_id} has expired (expired at {expires_at})"
|
||||
)
|
||||
await self.delete_refresh_token(user_id)
|
||||
return None
|
||||
|
||||
try:
|
||||
decrypted_token = self.cipher.decrypt(encrypted_token).decode()
|
||||
logger.debug(f"Retrieved refresh token for user {user_id}")
|
||||
return decrypted_token
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt refresh token for user {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def delete_refresh_token(self, user_id: str) -> bool:
|
||||
"""
|
||||
Delete refresh token for user.
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
|
||||
Returns:
|
||||
True if token was deleted, False if not found
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM refresh_tokens WHERE user_id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount > 0
|
||||
|
||||
if deleted:
|
||||
logger.info(f"Deleted refresh token for user {user_id}")
|
||||
await self._audit_log(
|
||||
event="delete_refresh_token",
|
||||
user_id=user_id,
|
||||
auth_method="offline_access",
|
||||
)
|
||||
else:
|
||||
logger.debug(f"No refresh token to delete for user {user_id}")
|
||||
|
||||
return deleted
|
||||
|
||||
async def get_all_user_ids(self) -> list[str]:
|
||||
"""
|
||||
Get list of all user IDs with stored refresh tokens.
|
||||
|
||||
Returns:
|
||||
List of user IDs
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT user_id FROM refresh_tokens ORDER BY updated_at DESC"
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
user_ids = [row[0] for row in rows]
|
||||
logger.debug(f"Found {len(user_ids)} users with refresh tokens")
|
||||
return user_ids
|
||||
|
||||
async def cleanup_expired_tokens(self) -> int:
|
||||
"""
|
||||
Remove expired refresh tokens from storage.
|
||||
|
||||
Returns:
|
||||
Number of tokens deleted
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
now = int(time.time())
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < ?",
|
||||
(now,),
|
||||
)
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount
|
||||
|
||||
if deleted > 0:
|
||||
logger.info(f"Cleaned up {deleted} expired refresh token(s)")
|
||||
|
||||
return deleted
|
||||
|
||||
async def store_oauth_client(
|
||||
self,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
client_id_issued_at: int,
|
||||
client_secret_expires_at: int,
|
||||
redirect_uris: list[str],
|
||||
registration_access_token: Optional[str] = None,
|
||||
registration_client_uri: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Store encrypted OAuth client credentials.
|
||||
|
||||
Args:
|
||||
client_id: OAuth client identifier
|
||||
client_secret: OAuth client secret (will be encrypted)
|
||||
client_id_issued_at: Unix timestamp when client was issued
|
||||
client_secret_expires_at: Unix timestamp when secret expires
|
||||
redirect_uris: List of redirect URIs
|
||||
registration_access_token: RFC 7592 registration token (will be encrypted)
|
||||
registration_client_uri: RFC 7592 client management URI
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
# Encrypt sensitive data
|
||||
encrypted_secret = self.cipher.encrypt(client_secret.encode())
|
||||
encrypted_reg_token = (
|
||||
self.cipher.encrypt(registration_access_token.encode())
|
||||
if registration_access_token
|
||||
else None
|
||||
)
|
||||
|
||||
# Serialize redirect_uris as JSON
|
||||
redirect_uris_json = json.dumps(redirect_uris)
|
||||
now = int(time.time())
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO oauth_clients
|
||||
(id, client_id, encrypted_client_secret, client_id_issued_at,
|
||||
client_secret_expires_at, redirect_uris, encrypted_registration_access_token,
|
||||
registration_client_uri, created_at, updated_at)
|
||||
VALUES (
|
||||
1, ?, ?, ?, ?, ?, ?, ?,
|
||||
COALESCE((SELECT created_at FROM oauth_clients WHERE id = 1), ?),
|
||||
?
|
||||
)
|
||||
""",
|
||||
(
|
||||
client_id,
|
||||
encrypted_secret,
|
||||
client_id_issued_at,
|
||||
client_secret_expires_at,
|
||||
redirect_uris_json,
|
||||
encrypted_reg_token,
|
||||
registration_client_uri,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Stored OAuth client credentials (client_id: {client_id[:16]}..., "
|
||||
f"expires at {client_secret_expires_at})"
|
||||
)
|
||||
|
||||
# Audit log
|
||||
await self._audit_log(
|
||||
event="store_oauth_client",
|
||||
user_id="system",
|
||||
auth_method="oauth",
|
||||
)
|
||||
|
||||
async def get_oauth_client(self) -> Optional[dict]:
|
||||
"""
|
||||
Retrieve and decrypt OAuth client credentials.
|
||||
|
||||
Returns:
|
||||
Dictionary with client credentials, or None if not found or expired:
|
||||
{
|
||||
"client_id": str,
|
||||
"client_secret": str,
|
||||
"client_id_issued_at": int,
|
||||
"client_secret_expires_at": int,
|
||||
"redirect_uris": list[str],
|
||||
"registration_access_token": str | None,
|
||||
"registration_client_uri": str | None,
|
||||
}
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"""
|
||||
SELECT client_id, encrypted_client_secret, client_id_issued_at,
|
||||
client_secret_expires_at, redirect_uris,
|
||||
encrypted_registration_access_token, registration_client_uri
|
||||
FROM oauth_clients WHERE id = 1
|
||||
"""
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
logger.debug("No OAuth client credentials found in storage")
|
||||
return None
|
||||
|
||||
(
|
||||
client_id,
|
||||
encrypted_secret,
|
||||
issued_at,
|
||||
expires_at,
|
||||
redirect_uris_json,
|
||||
encrypted_reg_token,
|
||||
reg_client_uri,
|
||||
) = row
|
||||
|
||||
# Check expiration
|
||||
if expires_at < time.time():
|
||||
logger.warning(
|
||||
f"OAuth client has expired (expired at {expires_at}), deleting"
|
||||
)
|
||||
await self.delete_oauth_client()
|
||||
return None
|
||||
|
||||
try:
|
||||
# Decrypt sensitive data
|
||||
client_secret = self.cipher.decrypt(encrypted_secret).decode()
|
||||
reg_token = (
|
||||
self.cipher.decrypt(encrypted_reg_token).decode()
|
||||
if encrypted_reg_token
|
||||
else None
|
||||
)
|
||||
|
||||
# Deserialize redirect_uris
|
||||
redirect_uris = json.loads(redirect_uris_json)
|
||||
|
||||
logger.debug(
|
||||
f"Retrieved OAuth client credentials (client_id: {client_id[:16]}...)"
|
||||
)
|
||||
|
||||
return {
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"client_id_issued_at": issued_at,
|
||||
"client_secret_expires_at": expires_at,
|
||||
"redirect_uris": redirect_uris,
|
||||
"registration_access_token": reg_token,
|
||||
"registration_client_uri": reg_client_uri,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt OAuth client credentials: {e}")
|
||||
return None
|
||||
|
||||
async def delete_oauth_client(self) -> bool:
|
||||
"""
|
||||
Delete OAuth client credentials.
|
||||
|
||||
Returns:
|
||||
True if client was deleted, False if not found
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute("DELETE FROM oauth_clients WHERE id = 1")
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount > 0
|
||||
|
||||
if deleted:
|
||||
logger.info("Deleted OAuth client credentials from storage")
|
||||
await self._audit_log(
|
||||
event="delete_oauth_client",
|
||||
user_id="system",
|
||||
auth_method="oauth",
|
||||
)
|
||||
else:
|
||||
logger.debug("No OAuth client credentials to delete")
|
||||
|
||||
return deleted
|
||||
|
||||
async def has_oauth_client(self) -> bool:
|
||||
"""
|
||||
Check if OAuth client credentials exist (and are not expired).
|
||||
|
||||
Returns:
|
||||
True if valid client exists, False otherwise
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT client_secret_expires_at FROM oauth_clients WHERE id = 1"
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
return False
|
||||
|
||||
expires_at = row[0]
|
||||
return expires_at >= time.time()
|
||||
|
||||
async def _audit_log(
|
||||
self,
|
||||
event: str,
|
||||
user_id: str,
|
||||
resource_type: Optional[str] = None,
|
||||
resource_id: Optional[str] = None,
|
||||
auth_method: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Log operation to audit log.
|
||||
|
||||
Args:
|
||||
event: Event name (e.g., "store_refresh_token", "token_refresh")
|
||||
user_id: User identifier
|
||||
resource_type: Resource type (e.g., "note", "file")
|
||||
resource_id: Resource identifier
|
||||
auth_method: Authentication method used
|
||||
"""
|
||||
import socket
|
||||
|
||||
hostname = socket.gethostname()
|
||||
timestamp = int(time.time())
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs
|
||||
(timestamp, event, user_id, resource_type, resource_id, auth_method, hostname)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
timestamp,
|
||||
event,
|
||||
user_id,
|
||||
resource_type,
|
||||
resource_id,
|
||||
auth_method,
|
||||
hostname,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def get_audit_logs(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
since: Optional[int] = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Retrieve audit logs.
|
||||
|
||||
Args:
|
||||
user_id: Filter by user ID (optional)
|
||||
since: Filter by timestamp (Unix epoch, optional)
|
||||
limit: Maximum number of logs to return
|
||||
|
||||
Returns:
|
||||
List of audit log entries
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
query = "SELECT * FROM audit_logs WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if user_id:
|
||||
query += " AND user_id = ?"
|
||||
params.append(user_id)
|
||||
|
||||
if since:
|
||||
query += " AND timestamp >= ?"
|
||||
params.append(since)
|
||||
|
||||
query += " ORDER BY timestamp DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(query, params) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
async def generate_encryption_key() -> str:
|
||||
"""
|
||||
Generate a new Fernet encryption key.
|
||||
|
||||
Returns:
|
||||
Base64-encoded encryption key suitable for TOKEN_ENCRYPTION_KEY env var
|
||||
"""
|
||||
return Fernet.generate_key().decode()
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
async def main():
|
||||
# Generate a key for testing
|
||||
key = await generate_encryption_key()
|
||||
print(f"Generated encryption key: {key}")
|
||||
print(f"Set this in your environment: export TOKEN_ENCRYPTION_KEY='{key}'")
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -46,7 +46,7 @@ def require_scopes(*required_scopes: str):
|
||||
users who lack the necessary scopes.
|
||||
|
||||
Args:
|
||||
*required_scopes: Variable number of scope strings required (e.g., "nc:read", "nc:write")
|
||||
*required_scopes: Variable number of scope strings required (e.g., "notes:read", "notes:write")
|
||||
|
||||
Returns:
|
||||
Decorated function that checks scopes before execution
|
||||
@@ -54,15 +54,15 @@ def require_scopes(*required_scopes: str):
|
||||
Example:
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_get_note(ctx: Context, note_id: int):
|
||||
# This tool requires the nc:read scope
|
||||
# This tool requires the notes:read scope
|
||||
...
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("notes:write")
|
||||
async def nc_notes_create_note(ctx: Context, ...):
|
||||
# This tool requires the nc:write scope
|
||||
# This tool requires the notes:write scope
|
||||
...
|
||||
```
|
||||
|
||||
@@ -173,7 +173,7 @@ def check_scopes(ctx: Context, *required_scopes: str) -> tuple[bool, set[str]]:
|
||||
Example:
|
||||
```python
|
||||
async def my_tool(ctx: Context):
|
||||
has_scopes, missing = check_scopes(ctx, "nc:read", "nc:write")
|
||||
has_scopes, missing = check_scopes(ctx, "notes:read", "notes:write")
|
||||
if not has_scopes:
|
||||
# Handle missing scopes
|
||||
...
|
||||
@@ -203,11 +203,11 @@ def get_required_scopes(func: Callable) -> list[str]:
|
||||
|
||||
Example:
|
||||
```python
|
||||
@require_scopes("nc:read", "nc:write")
|
||||
@require_scopes("notes:read", "notes:write")
|
||||
async def my_tool():
|
||||
pass
|
||||
|
||||
scopes = get_required_scopes(my_tool) # ["nc:read", "nc:write"]
|
||||
scopes = get_required_scopes(my_tool) # ["notes:read", "notes:write"]
|
||||
```
|
||||
"""
|
||||
return getattr(func, "_required_scopes", [])
|
||||
@@ -253,14 +253,14 @@ def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
|
||||
|
||||
Example:
|
||||
```python
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("notes:write")
|
||||
async def create_note():
|
||||
pass
|
||||
|
||||
user_scopes = {"nc:read", "nc:write"}
|
||||
user_scopes = {"notes:read", "notes:write"}
|
||||
can_see = has_required_scopes(create_note, user_scopes) # True
|
||||
|
||||
limited_user_scopes = {"nc:read"}
|
||||
limited_user_scopes = {"notes:read"}
|
||||
can_see = has_required_scopes(create_note, limited_user_scopes) # False
|
||||
```
|
||||
"""
|
||||
@@ -276,3 +276,68 @@ def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
|
||||
|
||||
# Check if user has all required scopes
|
||||
return set(required).issubset(user_scopes)
|
||||
|
||||
|
||||
def discover_all_scopes(mcp) -> list[str]:
|
||||
"""
|
||||
Dynamically discover all OAuth scopes required by registered MCP tools.
|
||||
|
||||
This function inspects all registered tools and extracts their required scopes
|
||||
from the @require_scopes decorator metadata. It provides a single source of truth
|
||||
for available scopes based on the actual tool implementations.
|
||||
|
||||
Args:
|
||||
mcp: FastMCP instance with registered tools
|
||||
|
||||
Returns:
|
||||
Sorted list of unique scope strings, including base OIDC scopes
|
||||
|
||||
Example:
|
||||
```python
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("My Server")
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
async def get_notes():
|
||||
pass
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:write")
|
||||
async def create_note():
|
||||
pass
|
||||
|
||||
scopes = discover_all_scopes(mcp)
|
||||
# Returns: ["notes:read", "notes:write", "openid", "profile", "email"]
|
||||
```
|
||||
|
||||
Note:
|
||||
- Base OIDC scopes (openid, profile, email) are always included
|
||||
- Scopes are deduplicated and sorted alphabetically
|
||||
- Only scopes from decorated tools are included
|
||||
- Must be called after tools are registered
|
||||
"""
|
||||
# Start with base OIDC scopes that are always required
|
||||
all_scopes = {"openid", "profile", "email"}
|
||||
|
||||
# Get all registered tools
|
||||
try:
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
except AttributeError:
|
||||
logger.warning("FastMCP instance does not have _tool_manager attribute")
|
||||
return sorted(all_scopes)
|
||||
|
||||
# Extract scopes from each tool
|
||||
for tool in tools:
|
||||
# Get the original function (tools have a .fn attribute)
|
||||
func = getattr(tool, "fn", None)
|
||||
if func is None:
|
||||
continue
|
||||
|
||||
# Extract scopes using existing helper
|
||||
tool_scopes = get_required_scopes(func)
|
||||
all_scopes.update(tool_scopes)
|
||||
|
||||
# Return sorted list of unique scopes
|
||||
return sorted(all_scopes)
|
||||
|
||||
@@ -168,27 +168,36 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
||||
|
||||
# Verify and decode JWT
|
||||
# Accept tokens with audience: "mcp-server" or ["mcp-server", "nextcloud"]
|
||||
# This allows:
|
||||
# 1. Tokens from MCP clients (aud: "mcp-server")
|
||||
# 2. Tokens for Nextcloud APIs (aud: "nextcloud")
|
||||
# 3. Tokens for both (aud: ["mcp-server", "nextcloud"])
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
issuer=self.issuer,
|
||||
audience=["mcp-server", "nextcloud"], # Accept either audience
|
||||
options={
|
||||
"verify_signature": True,
|
||||
"verify_exp": True,
|
||||
"verify_iat": True,
|
||||
"verify_iss": True if self.issuer else False,
|
||||
"verify_aud": False, # Skip audience validation for Bearer tokens
|
||||
"verify_aud": True, # Enable audience validation
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(f"JWT verified successfully for user: {payload.get('sub')}")
|
||||
logger.debug(f"Full JWT payload: {payload}")
|
||||
|
||||
# Extract username (sub claim)
|
||||
username = payload.get("sub")
|
||||
# Extract username (sub claim, with fallback to preferred_username)
|
||||
# Some OIDC providers (like Keycloak) may not include sub in access tokens
|
||||
username = payload.get("sub") or payload.get("preferred_username")
|
||||
if not username:
|
||||
logger.error("No 'sub' claim found in JWT payload")
|
||||
logger.error(
|
||||
"No 'sub' or 'preferred_username' claim found in JWT payload"
|
||||
)
|
||||
return None
|
||||
|
||||
# Extract scopes from scope claim (space-separated string)
|
||||
@@ -309,11 +318,18 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
)
|
||||
|
||||
elif response.status_code in (400, 401, 403):
|
||||
logger.info(f"Token introspection failed: HTTP {response.status_code}")
|
||||
logger.warning(
|
||||
f"Token introspection failed: HTTP {response.status_code}. "
|
||||
f"This may indicate: (1) Client credentials mismatch - trying to introspect "
|
||||
f"token issued to different OAuth client, (2) Expired client credentials, "
|
||||
f"(3) Invalid token. Will fall back to userinfo endpoint. "
|
||||
f"Response: {response.text[:200] if response.text else 'empty'}"
|
||||
)
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unexpected response from introspection: {response.status_code}"
|
||||
f"Unexpected response from introspection: {response.status_code}. "
|
||||
f"Response: {response.text[:200] if response.text else 'empty'}"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -420,15 +436,31 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
"""
|
||||
Extract scopes from userinfo response.
|
||||
|
||||
Since the userinfo response doesn't include the original scopes,
|
||||
we infer them from the claims present in the response.
|
||||
First attempts to read actual scopes from the 'scope' field (RFC 8693).
|
||||
If not present, infers scopes from the claims present in the response.
|
||||
|
||||
Args:
|
||||
userinfo: The userinfo response dictionary
|
||||
|
||||
Returns:
|
||||
List of inferred scopes
|
||||
List of scopes (actual or inferred)
|
||||
"""
|
||||
# Try to get actual scopes from userinfo response (if OIDC provider includes it)
|
||||
scope_string = userinfo.get("scope")
|
||||
if scope_string:
|
||||
scopes = scope_string.split() if isinstance(scope_string, str) else []
|
||||
if scopes:
|
||||
logger.debug(
|
||||
f"Using actual scopes from userinfo: {scopes} (scope field present)"
|
||||
)
|
||||
return scopes
|
||||
|
||||
# Fallback: Infer scopes from claims present in response
|
||||
# This maintains backward compatibility with OIDC providers that don't
|
||||
# include the scope field in userinfo responses
|
||||
logger.debug(
|
||||
"No scope field in userinfo response, inferring scopes from claims"
|
||||
)
|
||||
scopes = ["openid"] # Always present
|
||||
|
||||
if "email" in userinfo:
|
||||
@@ -445,6 +477,7 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
if "groups" in userinfo:
|
||||
scopes.append("groups")
|
||||
|
||||
logger.debug(f"Inferred scopes from userinfo claims: {scopes}")
|
||||
return scopes
|
||||
|
||||
def clear_cache(self):
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import logging.config
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
@@ -51,3 +53,68 @@ LOGGING_CONFIG = {
|
||||
|
||||
def setup_logging():
|
||||
logging.config.dictConfig(LOGGING_CONFIG)
|
||||
|
||||
|
||||
# Document Processing Configuration
|
||||
|
||||
|
||||
def get_document_processor_config() -> dict[str, Any]:
|
||||
"""Get document processor configuration from environment.
|
||||
|
||||
Returns:
|
||||
Dict with processor configs:
|
||||
{
|
||||
"enabled": bool,
|
||||
"default_processor": str,
|
||||
"processors": {
|
||||
"unstructured": {...},
|
||||
"tesseract": {...},
|
||||
"custom": {...},
|
||||
}
|
||||
}
|
||||
"""
|
||||
config: dict[str, Any] = {
|
||||
"enabled": os.getenv("ENABLE_DOCUMENT_PROCESSING", "false").lower() == "true",
|
||||
"default_processor": os.getenv("DOCUMENT_PROCESSOR", "unstructured"),
|
||||
"processors": {},
|
||||
}
|
||||
|
||||
# Unstructured configuration
|
||||
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() == "true":
|
||||
config["processors"]["unstructured"] = {
|
||||
"api_url": os.getenv("UNSTRUCTURED_API_URL", "http://unstructured:8000"),
|
||||
"timeout": int(os.getenv("UNSTRUCTURED_TIMEOUT", "120")),
|
||||
"strategy": os.getenv("UNSTRUCTURED_STRATEGY", "auto"),
|
||||
"languages": [
|
||||
lang.strip()
|
||||
for lang in os.getenv("UNSTRUCTURED_LANGUAGES", "eng,deu").split(",")
|
||||
if lang.strip()
|
||||
],
|
||||
"progress_interval": int(os.getenv("PROGRESS_INTERVAL", "10")),
|
||||
}
|
||||
|
||||
# Tesseract configuration
|
||||
if os.getenv("ENABLE_TESSERACT", "false").lower() == "true":
|
||||
config["processors"]["tesseract"] = {
|
||||
"tesseract_cmd": os.getenv("TESSERACT_CMD"), # None = auto-detect
|
||||
"lang": os.getenv("TESSERACT_LANG", "eng"),
|
||||
}
|
||||
|
||||
# Custom processor (via HTTP API)
|
||||
if os.getenv("ENABLE_CUSTOM_PROCESSOR", "false").lower() == "true":
|
||||
custom_url = os.getenv("CUSTOM_PROCESSOR_URL")
|
||||
if custom_url:
|
||||
supported_types_str = os.getenv("CUSTOM_PROCESSOR_TYPES", "application/pdf")
|
||||
supported_types = {
|
||||
t.strip() for t in supported_types_str.split(",") if t.strip()
|
||||
}
|
||||
|
||||
config["processors"]["custom"] = {
|
||||
"name": os.getenv("CUSTOM_PROCESSOR_NAME", "custom"),
|
||||
"api_url": custom_url,
|
||||
"api_key": os.getenv("CUSTOM_PROCESSOR_API_KEY"),
|
||||
"timeout": int(os.getenv("CUSTOM_PROCESSOR_TIMEOUT", "60")),
|
||||
"supported_types": supported_types,
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Document processing plugins for extracting text from various file formats."""
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
from .registry import ProcessorRegistry, get_registry
|
||||
|
||||
__all__ = [
|
||||
"DocumentProcessor",
|
||||
"ProcessingResult",
|
||||
"ProcessorError",
|
||||
"ProcessorRegistry",
|
||||
"get_registry",
|
||||
]
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Abstract base class for document processing plugins."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProcessingResult(BaseModel):
|
||||
"""Standardized result from any document processor."""
|
||||
|
||||
text: str
|
||||
"""Extracted text content"""
|
||||
|
||||
metadata: dict[str, Any]
|
||||
"""Processor-specific metadata"""
|
||||
|
||||
processor: str
|
||||
"""Name of processor that handled this (e.g., 'unstructured', 'tesseract')"""
|
||||
|
||||
success: bool = True
|
||||
"""Whether processing succeeded"""
|
||||
|
||||
error: Optional[str] = None
|
||||
"""Error message if processing failed"""
|
||||
|
||||
|
||||
class DocumentProcessor(ABC):
|
||||
"""Abstract base class for document processing plugins.
|
||||
|
||||
Document processors extract text from various file formats (PDF, DOCX, images, etc.).
|
||||
Each processor implements this interface and can be registered with the ProcessorRegistry.
|
||||
|
||||
Example:
|
||||
class MyProcessor(DocumentProcessor):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "my_processor"
|
||||
|
||||
@property
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
return {"application/pdf", "image/jpeg"}
|
||||
|
||||
async def process(self, content: bytes, content_type: str, **kwargs) -> ProcessingResult:
|
||||
# Extract text from content
|
||||
return ProcessingResult(text="...", metadata={}, processor=self.name)
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return True
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Unique identifier for this processor (e.g., 'unstructured', 'tesseract')."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
"""Set of MIME types this processor can handle.
|
||||
|
||||
Examples: {"application/pdf", "image/jpeg", "image/png"}
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process a document and extract text.
|
||||
|
||||
Args:
|
||||
content: Document bytes
|
||||
content_type: MIME type of the document
|
||||
filename: Optional filename for format detection
|
||||
options: Processor-specific options (e.g., OCR language, strategy)
|
||||
progress_callback: Optional async callback for progress updates.
|
||||
Called as: await progress_callback(progress, total, message)
|
||||
- progress: Current progress value (monotonically increasing)
|
||||
- total: Optional total value (None if unknown)
|
||||
- message: Optional human-readable status message
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If processing fails
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if processor is available and healthy.
|
||||
|
||||
Returns:
|
||||
True if processor is ready to use, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
def supports(self, content_type: str) -> bool:
|
||||
"""Check if this processor supports the given MIME type.
|
||||
|
||||
Args:
|
||||
content_type: MIME type (may include parameters like "application/pdf; charset=utf-8")
|
||||
|
||||
Returns:
|
||||
True if this processor can handle the type
|
||||
"""
|
||||
# Strip parameters from content type
|
||||
base_type = content_type.split(";")[0].strip().lower()
|
||||
return base_type in self.supported_mime_types
|
||||
|
||||
|
||||
class ProcessorError(Exception):
|
||||
"""Raised when document processing fails."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Generic HTTP API processor wrapper for custom document processing services."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomHTTPProcessor(DocumentProcessor):
|
||||
"""Generic HTTP API processor wrapper.
|
||||
|
||||
Allows integration with any custom document processing API that follows
|
||||
a simple request/response pattern. This makes it easy to integrate your
|
||||
own text extraction services without writing a full processor.
|
||||
|
||||
Expected API Contract:
|
||||
- POST request with file as multipart/form-data
|
||||
- Response: {"text": "extracted text", "metadata": {...}}
|
||||
|
||||
Example:
|
||||
processor = CustomHTTPProcessor(
|
||||
name="my_ocr",
|
||||
api_url="https://my-ocr-service.com/process",
|
||||
api_key="secret",
|
||||
supported_types={"application/pdf", "image/jpeg"},
|
||||
)
|
||||
result = await processor.process(pdf_bytes, "application/pdf")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: str,
|
||||
api_key: Optional[str] = None,
|
||||
timeout: int = 60,
|
||||
supported_types: Optional[set[str]] = None,
|
||||
name: str = "custom",
|
||||
):
|
||||
"""Initialize custom HTTP processor.
|
||||
|
||||
Args:
|
||||
api_url: Your API endpoint (should accept POST with multipart/form-data)
|
||||
api_key: Optional API key for authentication (sent as Bearer token)
|
||||
timeout: Request timeout in seconds (default: 60)
|
||||
supported_types: MIME types your API supports
|
||||
name: Unique name for this processor (default: "custom")
|
||||
"""
|
||||
self.api_url = api_url
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self._name = name
|
||||
self._supported_types = supported_types or set()
|
||||
|
||||
logger.info(f"Initialized CustomHTTPProcessor: {name} -> {api_url}")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
return self._supported_types
|
||||
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process via custom HTTP API.
|
||||
|
||||
Args:
|
||||
content: Document bytes
|
||||
content_type: MIME type
|
||||
filename: Optional filename
|
||||
options: Custom options (passed as form data to API)
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If API call fails
|
||||
"""
|
||||
options = options or {}
|
||||
|
||||
# Prepare request
|
||||
files = {"file": (filename or "document", content, content_type)}
|
||||
headers = {}
|
||||
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
self.api_url,
|
||||
files=files,
|
||||
headers=headers,
|
||||
data=options, # Pass options as form data
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse response
|
||||
result = response.json()
|
||||
text = result.get("text", "")
|
||||
metadata = result.get("metadata", {})
|
||||
|
||||
logger.debug(
|
||||
f"Custom processor '{self.name}' extracted {len(text)} characters"
|
||||
)
|
||||
|
||||
return ProcessingResult(
|
||||
text=text,
|
||||
metadata=metadata,
|
||||
processor=self.name,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Custom processor '{self.name}' HTTP error: {e}")
|
||||
raise ProcessorError(f"API call failed: {str(e)}") from e
|
||||
except Exception as e:
|
||||
logger.error(f"Custom processor '{self.name}' failed: {e}")
|
||||
raise ProcessorError(f"Processing failed: {str(e)}") from e
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if custom API is available.
|
||||
|
||||
Returns:
|
||||
True if API responds with status < 500
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
# Try GET request to check availability
|
||||
response = await client.get(
|
||||
self.api_url,
|
||||
headers={"User-Agent": "nextcloud-mcp-server"},
|
||||
)
|
||||
return response.status_code < 500
|
||||
except Exception as e:
|
||||
logger.warning(f"Custom processor '{self.name}' health check failed: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Central registry for document processors."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProcessorRegistry:
|
||||
"""Central registry for document processors.
|
||||
|
||||
Manages registration and routing of document processing requests to
|
||||
appropriate processors based on MIME types and priorities.
|
||||
|
||||
Example:
|
||||
registry = ProcessorRegistry()
|
||||
registry.register(UnstructuredProcessor(...), priority=10)
|
||||
registry.register(TesseractProcessor(...), priority=5)
|
||||
|
||||
# Auto-select processor based on MIME type
|
||||
result = await registry.process(pdf_bytes, "application/pdf")
|
||||
|
||||
# Force specific processor
|
||||
result = await registry.process(img_bytes, "image/png", processor_name="tesseract")
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._processors: dict[str, tuple[DocumentProcessor, int]] = {}
|
||||
self._priority_order: list[str] = []
|
||||
|
||||
def register(self, processor: DocumentProcessor, priority: int = 0):
|
||||
"""Register a document processor.
|
||||
|
||||
Args:
|
||||
processor: Processor instance to register
|
||||
priority: Higher priority processors are tried first (default: 0)
|
||||
"""
|
||||
name = processor.name
|
||||
|
||||
if name in self._processors:
|
||||
logger.warning(f"Processor '{name}' already registered, replacing")
|
||||
|
||||
self._processors[name] = (processor, priority)
|
||||
|
||||
# Update priority order
|
||||
if name in self._priority_order:
|
||||
self._priority_order.remove(name)
|
||||
|
||||
# Insert in priority order (higher priority first)
|
||||
inserted = False
|
||||
for i, existing_name in enumerate(self._priority_order):
|
||||
existing_priority = self._processors[existing_name][1]
|
||||
if priority > existing_priority:
|
||||
self._priority_order.insert(i, name)
|
||||
inserted = True
|
||||
break
|
||||
|
||||
if not inserted:
|
||||
self._priority_order.append(name)
|
||||
|
||||
logger.info(
|
||||
f"Registered processor: {name} "
|
||||
f"(priority={priority}, supports={len(processor.supported_mime_types)} types)"
|
||||
)
|
||||
|
||||
def get_processor(self, name: str) -> Optional[DocumentProcessor]:
|
||||
"""Get a processor by name.
|
||||
|
||||
Args:
|
||||
name: Processor name
|
||||
|
||||
Returns:
|
||||
DocumentProcessor instance or None if not found
|
||||
"""
|
||||
if name in self._processors:
|
||||
return self._processors[name][0]
|
||||
return None
|
||||
|
||||
def find_processor(self, content_type: str) -> Optional[DocumentProcessor]:
|
||||
"""Find the first processor that supports the given MIME type.
|
||||
|
||||
Processors are checked in priority order (highest priority first).
|
||||
|
||||
Args:
|
||||
content_type: MIME type to match
|
||||
|
||||
Returns:
|
||||
First matching processor or None
|
||||
"""
|
||||
for name in self._priority_order:
|
||||
processor = self._processors[name][0]
|
||||
if processor.supports(content_type):
|
||||
logger.debug(f"Found processor '{name}' for type '{content_type}'")
|
||||
return processor
|
||||
|
||||
logger.debug(f"No processor found for type '{content_type}'")
|
||||
return None
|
||||
|
||||
def list_processors(self) -> list[str]:
|
||||
"""List all registered processor names in priority order.
|
||||
|
||||
Returns:
|
||||
List of processor names (highest priority first)
|
||||
"""
|
||||
return list(self._priority_order)
|
||||
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
processor_name: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process a document using available processors.
|
||||
|
||||
Args:
|
||||
content: Document bytes
|
||||
content_type: MIME type
|
||||
filename: Optional filename for format detection
|
||||
processor_name: Force specific processor (or None for auto-select)
|
||||
options: Processing options passed to processor
|
||||
progress_callback: Optional async callback for progress updates
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If no processor found or processing fails
|
||||
"""
|
||||
# Find processor
|
||||
if processor_name:
|
||||
processor = self.get_processor(processor_name)
|
||||
if not processor:
|
||||
raise ProcessorError(
|
||||
f"Processor '{processor_name}' not found. "
|
||||
f"Available: {', '.join(self.list_processors())}"
|
||||
)
|
||||
else:
|
||||
processor = self.find_processor(content_type)
|
||||
if not processor:
|
||||
raise ProcessorError(
|
||||
f"No processor found for type: {content_type}. "
|
||||
f"Registered processors: {', '.join(self.list_processors())}"
|
||||
)
|
||||
|
||||
logger.info(f"Processing with '{processor.name}' processor")
|
||||
|
||||
# Process
|
||||
return await processor.process(
|
||||
content, content_type, filename, options, progress_callback
|
||||
)
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_registry = ProcessorRegistry()
|
||||
|
||||
|
||||
def get_registry() -> ProcessorRegistry:
|
||||
"""Get the global processor registry.
|
||||
|
||||
Returns:
|
||||
Singleton ProcessorRegistry instance
|
||||
"""
|
||||
return _registry
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Document processor using Tesseract OCR (local)."""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import io
|
||||
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
|
||||
TESSERACT_AVAILABLE = True
|
||||
except ImportError:
|
||||
TESSERACT_AVAILABLE = False
|
||||
|
||||
|
||||
class TesseractProcessor(DocumentProcessor):
|
||||
"""Document processor using Tesseract OCR (local).
|
||||
|
||||
This processor runs OCR locally using the Tesseract engine, which is
|
||||
faster and more lightweight than cloud-based solutions but requires
|
||||
Tesseract to be installed on the system.
|
||||
|
||||
Requirements:
|
||||
- tesseract binary installed (e.g., apt install tesseract-ocr)
|
||||
- Python packages: pip install pytesseract pillow
|
||||
|
||||
Example:
|
||||
processor = TesseractProcessor(default_lang="eng+deu")
|
||||
result = await processor.process(image_bytes, "image/jpeg")
|
||||
"""
|
||||
|
||||
SUPPORTED_TYPES = {
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/tiff",
|
||||
"image/bmp",
|
||||
"image/gif",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tesseract_cmd: Optional[str] = None,
|
||||
default_lang: str = "eng",
|
||||
):
|
||||
"""Initialize Tesseract processor.
|
||||
|
||||
Args:
|
||||
tesseract_cmd: Path to tesseract executable (None = auto-detect)
|
||||
default_lang: Default OCR language (e.g., "eng", "deu", "eng+deu")
|
||||
|
||||
Raises:
|
||||
ProcessorError: If Tesseract or required packages not available
|
||||
"""
|
||||
if not TESSERACT_AVAILABLE:
|
||||
raise ProcessorError(
|
||||
"Tesseract processor requires: pip install pytesseract pillow"
|
||||
)
|
||||
|
||||
if tesseract_cmd:
|
||||
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
|
||||
elif not shutil.which("tesseract"):
|
||||
raise ProcessorError(
|
||||
"Tesseract not found in PATH. Install with: apt install tesseract-ocr"
|
||||
)
|
||||
|
||||
self.default_lang = default_lang
|
||||
logger.info(f"Initialized TesseractProcessor: lang={default_lang}")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "tesseract"
|
||||
|
||||
@property
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
return self.SUPPORTED_TYPES
|
||||
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process image via Tesseract OCR.
|
||||
|
||||
Args:
|
||||
content: Image bytes
|
||||
content_type: Image MIME type
|
||||
filename: Optional filename
|
||||
options: Processing options:
|
||||
- lang: OCR language(s) (default: from init)
|
||||
- config: Tesseract config string
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If OCR fails
|
||||
"""
|
||||
options = options or {}
|
||||
lang = options.get("lang", self.default_lang)
|
||||
config = options.get("config", "")
|
||||
|
||||
try:
|
||||
# Load image
|
||||
image = Image.open(io.BytesIO(content))
|
||||
|
||||
# Run OCR
|
||||
text = pytesseract.image_to_string(image, lang=lang, config=config)
|
||||
|
||||
# Get additional data for confidence scores
|
||||
data = pytesseract.image_to_data(
|
||||
image, lang=lang, output_type=pytesseract.Output.DICT
|
||||
)
|
||||
|
||||
# Calculate average confidence
|
||||
confidences = [c for c in data["conf"] if c != -1]
|
||||
avg_confidence = sum(confidences) / len(confidences) if confidences else 0
|
||||
|
||||
metadata = {
|
||||
"text_length": len(text),
|
||||
"language": lang,
|
||||
"image_size": image.size,
|
||||
"image_mode": image.mode,
|
||||
"confidence": round(avg_confidence, 2),
|
||||
"words_detected": len([c for c in data["conf"] if c != -1]),
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
f"Tesseract OCR completed: {len(text)} chars, "
|
||||
f"confidence={avg_confidence:.1f}%"
|
||||
)
|
||||
|
||||
return ProcessingResult(
|
||||
text=text.strip(),
|
||||
metadata=metadata,
|
||||
processor=self.name,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Tesseract processing failed: {e}")
|
||||
raise ProcessorError(f"OCR failed: {str(e)}") from e
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if Tesseract is available.
|
||||
|
||||
Returns:
|
||||
True if Tesseract is installed and working
|
||||
"""
|
||||
try:
|
||||
pytesseract.get_tesseract_version()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Document processor using Unstructured.io API."""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnstructuredProcessor(DocumentProcessor):
|
||||
"""Document processor using Unstructured.io API.
|
||||
|
||||
The Unstructured API provides document parsing capabilities for various formats
|
||||
including PDF, DOCX, images with OCR, and more.
|
||||
|
||||
API Documentation: https://docs.unstructured.io/api-reference/api-services/api-parameters
|
||||
"""
|
||||
|
||||
# Supported MIME types for Unstructured
|
||||
SUPPORTED_TYPES = {
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-excel",
|
||||
"application/rtf",
|
||||
"text/rtf",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"application/epub+zip",
|
||||
"message/rfc822",
|
||||
"application/vnd.ms-outlook",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/tiff",
|
||||
"image/bmp",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: str,
|
||||
timeout: int = 120,
|
||||
default_strategy: str = "auto",
|
||||
default_languages: Optional[list[str]] = None,
|
||||
progress_interval: int = 10,
|
||||
):
|
||||
"""Initialize Unstructured processor.
|
||||
|
||||
Args:
|
||||
api_url: Unstructured API endpoint
|
||||
timeout: Request timeout in seconds (default: 120)
|
||||
default_strategy: Default parsing strategy - "auto", "fast", or "hi_res"
|
||||
default_languages: Default OCR language codes (e.g., ["eng", "deu"])
|
||||
progress_interval: Seconds between progress updates (default: 10)
|
||||
"""
|
||||
self.api_url = api_url
|
||||
self.timeout = timeout
|
||||
self.default_strategy = default_strategy
|
||||
self.default_languages = default_languages or ["eng"]
|
||||
self.progress_interval = progress_interval
|
||||
|
||||
logger.info(
|
||||
f"Initialized UnstructuredProcessor: {api_url}, "
|
||||
f"strategy={default_strategy}, languages={self.default_languages}, "
|
||||
f"progress_interval={progress_interval}s"
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "unstructured"
|
||||
|
||||
@property
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
return self.SUPPORTED_TYPES
|
||||
|
||||
async def _run_progress_poller(
|
||||
self,
|
||||
stop_event: anyio.Event,
|
||||
progress_callback: Callable[
|
||||
[float, Optional[float], Optional[str]], Awaitable[None]
|
||||
],
|
||||
start_time: float,
|
||||
):
|
||||
"""Run progress poller that reports status every N seconds.
|
||||
|
||||
Args:
|
||||
stop_event: Event to signal when processing is complete
|
||||
progress_callback: Async callback to report progress
|
||||
start_time: Time when processing started (from time.time())
|
||||
"""
|
||||
logger.debug("Starting progress poller")
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
# Wait for the event to be set, with a timeout equal to progress_interval
|
||||
with anyio.fail_after(self.progress_interval):
|
||||
await stop_event.wait()
|
||||
# If wait() finished, the event was set (processing complete)
|
||||
break
|
||||
except TimeoutError:
|
||||
# Timeout occurred - time to send a progress update
|
||||
if not stop_event.is_set(): # Double-check in case of race condition
|
||||
elapsed = int(time.time() - start_time)
|
||||
message = (
|
||||
f"Processing document with unstructured... ({elapsed}s elapsed)"
|
||||
)
|
||||
try:
|
||||
await progress_callback(
|
||||
progress=float(elapsed),
|
||||
total=None, # Unknown total duration
|
||||
message=message,
|
||||
)
|
||||
logger.debug(f"Progress update sent: {elapsed}s elapsed")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send progress update: {e}")
|
||||
logger.debug("Progress poller stopped")
|
||||
|
||||
async def _make_api_request(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str],
|
||||
strategy: str,
|
||||
languages: list[str],
|
||||
extract_image_block_types: Optional[list[str]],
|
||||
) -> ProcessingResult:
|
||||
"""Make the actual API request to Unstructured.
|
||||
|
||||
Args:
|
||||
content: Document bytes
|
||||
content_type: MIME type
|
||||
filename: Optional filename
|
||||
strategy: Processing strategy
|
||||
languages: OCR languages
|
||||
extract_image_block_types: Image element types to extract
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If processing fails
|
||||
"""
|
||||
# Prepare multipart request
|
||||
files = {
|
||||
"files": (
|
||||
filename or "document",
|
||||
io.BytesIO(content),
|
||||
content_type or "application/octet-stream",
|
||||
)
|
||||
}
|
||||
|
||||
data = {
|
||||
"strategy": strategy,
|
||||
"languages": ",".join(languages),
|
||||
}
|
||||
|
||||
if extract_image_block_types:
|
||||
data["extract_image_block_types"] = ",".join(extract_image_block_types)
|
||||
|
||||
logger.debug(
|
||||
f"Processing with Unstructured API: strategy={strategy}, languages={languages}"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/general/v0/general",
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse response
|
||||
elements = response.json()
|
||||
|
||||
# Extract text and metadata
|
||||
texts = []
|
||||
element_types: dict[str, int] = {}
|
||||
|
||||
for element in elements:
|
||||
if "text" in element and element["text"]:
|
||||
texts.append(element["text"])
|
||||
|
||||
el_type = element.get("type", "unknown")
|
||||
element_types[el_type] = element_types.get(el_type, 0) + 1
|
||||
|
||||
parsed_text = "\n\n".join(texts)
|
||||
|
||||
metadata = {
|
||||
"element_count": len(elements),
|
||||
"text_length": len(parsed_text),
|
||||
"element_types": element_types,
|
||||
"strategy": strategy,
|
||||
"languages": languages,
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
f"Successfully processed: {len(elements)} elements, "
|
||||
f"{len(parsed_text)} characters"
|
||||
)
|
||||
|
||||
return ProcessingResult(
|
||||
text=parsed_text,
|
||||
metadata=metadata,
|
||||
processor=self.name,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Unstructured API HTTP error: {e}")
|
||||
raise ProcessorError(f"HTTP error: {str(e)}") from e
|
||||
except Exception as e:
|
||||
logger.error(f"Unstructured API processing failed: {e}")
|
||||
raise ProcessorError(f"Processing failed: {str(e)}") from e
|
||||
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process document via Unstructured API.
|
||||
|
||||
Args:
|
||||
content: Document bytes
|
||||
content_type: MIME type
|
||||
filename: Optional filename for format detection
|
||||
options: Processing options:
|
||||
- strategy: "auto", "fast", or "hi_res" (default: from init)
|
||||
- languages: List of language codes (default: from init)
|
||||
- extract_image_block_types: Types of image elements to extract
|
||||
progress_callback: Optional async callback for progress updates
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If processing fails
|
||||
"""
|
||||
options = options or {}
|
||||
|
||||
# Extract options with defaults
|
||||
strategy = options.get("strategy", self.default_strategy)
|
||||
languages = options.get("languages", self.default_languages)
|
||||
extract_image_block_types = options.get("extract_image_block_types")
|
||||
|
||||
# If no progress callback, just make the request directly
|
||||
if progress_callback is None:
|
||||
return await self._make_api_request(
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
filename=filename,
|
||||
strategy=strategy,
|
||||
languages=languages,
|
||||
extract_image_block_types=extract_image_block_types,
|
||||
)
|
||||
|
||||
# With progress callback: run API request + progress poller concurrently
|
||||
stop_event = anyio.Event()
|
||||
start_time = time.time()
|
||||
result = None
|
||||
|
||||
async def capture_result():
|
||||
nonlocal result
|
||||
try:
|
||||
result = await self._make_api_request(
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
filename=filename,
|
||||
strategy=strategy,
|
||||
languages=languages,
|
||||
extract_image_block_types=extract_image_block_types,
|
||||
)
|
||||
finally:
|
||||
# Signal poller to stop after API request completes
|
||||
stop_event.set()
|
||||
|
||||
# Run both tasks concurrently using anyio task groups
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(capture_result)
|
||||
tg.start_soon(
|
||||
self._run_progress_poller, stop_event, progress_callback, start_time
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if Unstructured API is available.
|
||||
|
||||
Returns:
|
||||
True if API is healthy, False otherwise
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get(f"{self.api_url}/healthcheck")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.warning(f"Unstructured health check failed: {e}")
|
||||
return False
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .base import BaseResponse, IdResponse, StatusResponse
|
||||
|
||||
@@ -38,8 +38,7 @@ class Nutrition(BaseModel):
|
||||
None, description="Unsaturated fat (e.g., '40 g')"
|
||||
)
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class RecipeStub(BaseModel):
|
||||
@@ -91,9 +90,7 @@ class Recipe(BaseModel):
|
||||
)
|
||||
nutrition: Optional[Nutrition] = Field(None, description="Nutrition information")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
extra = "allow" # Allow additional schema.org fields
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class Category(BaseModel):
|
||||
@@ -127,8 +124,7 @@ class VisibleInfoBlocks(BaseModel):
|
||||
)
|
||||
tools: Optional[bool] = Field(None, description="Show tools list")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class CookbookConfig(BaseModel):
|
||||
|
||||
@@ -40,7 +40,7 @@ class DirectoryListing(BaseResponse):
|
||||
"""Response model for directory listings."""
|
||||
|
||||
path: str = Field(description="Directory path")
|
||||
items: List[FileInfo] = Field(description="Files and directories in the path")
|
||||
files: List[FileInfo] = Field(description="Files and directories in the path")
|
||||
total_count: int = Field(description="Total number of items")
|
||||
directories_count: int = Field(description="Number of directories")
|
||||
files_count: int = Field(description="Number of files")
|
||||
|
||||
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
|
||||
def configure_calendar_tools(mcp: FastMCP):
|
||||
# Calendar tools
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("calendar:read")
|
||||
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
||||
"""List all available calendars for the user"""
|
||||
client = get_client(ctx)
|
||||
@@ -29,7 +29,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("calendar:write")
|
||||
async def nc_calendar_create_event(
|
||||
calendar_name: str,
|
||||
title: str,
|
||||
@@ -105,7 +105,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return await client.calendar.create_event(calendar_name, event_data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("calendar:read")
|
||||
async def nc_calendar_list_events(
|
||||
calendar_name: str,
|
||||
ctx: Context,
|
||||
@@ -207,7 +207,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return events
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("calendar:read")
|
||||
async def nc_calendar_get_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
@@ -219,7 +219,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return event_data
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("calendar:write")
|
||||
async def nc_calendar_update_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
@@ -292,7 +292,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("calendar:write")
|
||||
async def nc_calendar_delete_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
@@ -303,7 +303,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return await client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("calendar:write")
|
||||
async def nc_calendar_create_meeting(
|
||||
title: str,
|
||||
date: str,
|
||||
@@ -369,7 +369,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return await client.calendar.create_event(calendar_name, event_data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("calendar:read")
|
||||
async def nc_calendar_get_upcoming_events(
|
||||
ctx: Context,
|
||||
calendar_name: str = "", # Empty = all calendars
|
||||
@@ -419,7 +419,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return all_events[:limit]
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("calendar:read")
|
||||
async def nc_calendar_find_availability(
|
||||
duration_minutes: int,
|
||||
ctx: Context,
|
||||
@@ -499,7 +499,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("calendar:write")
|
||||
async def nc_calendar_bulk_operations(
|
||||
operation: str, # "update", "delete", "move"
|
||||
ctx: Context,
|
||||
@@ -748,7 +748,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("calendar:write")
|
||||
async def nc_calendar_manage_calendar(
|
||||
action: str, # "create", "delete", "update", "list"
|
||||
ctx: Context,
|
||||
@@ -817,7 +817,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
# ============= Todo/Task Tools =============
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("todo:read", "calendar:read")
|
||||
async def nc_calendar_list_todos(
|
||||
calendar_name: str,
|
||||
ctx: Context,
|
||||
@@ -862,7 +862,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("todo:write", "calendar:read")
|
||||
async def nc_calendar_create_todo(
|
||||
calendar_name: str,
|
||||
summary: str,
|
||||
@@ -905,7 +905,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return await client.calendar.create_todo(calendar_name, todo_data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("todo:write", "calendar:read")
|
||||
async def nc_calendar_update_todo(
|
||||
calendar_name: str,
|
||||
todo_uid: str,
|
||||
@@ -965,7 +965,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("todo:write", "calendar:read")
|
||||
async def nc_calendar_delete_todo(
|
||||
calendar_name: str,
|
||||
todo_uid: str,
|
||||
@@ -985,7 +985,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return await client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("todo:read", "calendar:read")
|
||||
async def nc_calendar_search_todos(
|
||||
ctx: Context,
|
||||
status: Optional[str] = None,
|
||||
|
||||
@@ -11,21 +11,21 @@ logger = logging.getLogger(__name__)
|
||||
def configure_contacts_tools(mcp: FastMCP):
|
||||
# Contacts tools
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("contacts:read")
|
||||
async def nc_contacts_list_addressbooks(ctx: Context):
|
||||
"""List all addressbooks for the user."""
|
||||
client = get_client(ctx)
|
||||
return await client.contacts.list_addressbooks()
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("contacts:read")
|
||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
||||
"""List all contacts in the specified addressbook."""
|
||||
client = get_client(ctx)
|
||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("contacts:write")
|
||||
async def nc_contacts_create_addressbook(
|
||||
ctx: Context, *, name: str, display_name: str
|
||||
):
|
||||
@@ -41,14 +41,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("contacts:write")
|
||||
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
||||
"""Delete an addressbook."""
|
||||
client = get_client(ctx)
|
||||
return await client.contacts.delete_addressbook(name=name)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("contacts:write")
|
||||
async def nc_contacts_create_contact(
|
||||
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
|
||||
):
|
||||
@@ -65,14 +65,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("contacts:write")
|
||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||
"""Delete a contact."""
|
||||
client = get_client(ctx)
|
||||
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("contacts:write")
|
||||
async def nc_contacts_update_contact(
|
||||
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
|
||||
):
|
||||
|
||||
@@ -71,7 +71,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("cookbook:write")
|
||||
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
|
||||
"""Import a recipe from a URL using schema.org metadata.
|
||||
|
||||
@@ -128,7 +128,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
||||
"""Get all recipes in the database"""
|
||||
client = get_client(ctx)
|
||||
@@ -153,7 +153,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
|
||||
"""Get a specific recipe by its ID"""
|
||||
client = get_client(ctx)
|
||||
@@ -178,7 +178,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("cookbook:write")
|
||||
async def nc_cookbook_create_recipe(
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
@@ -257,7 +257,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("cookbook:write")
|
||||
async def nc_cookbook_update_recipe(
|
||||
recipe_id: int,
|
||||
name: str | None = None,
|
||||
@@ -346,7 +346,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("cookbook:write")
|
||||
async def nc_cookbook_delete_recipe(
|
||||
recipe_id: int, ctx: Context
|
||||
) -> DeleteRecipeResponse:
|
||||
@@ -381,7 +381,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_search_recipes(
|
||||
query: str, ctx: Context
|
||||
) -> SearchRecipesResponse:
|
||||
@@ -417,7 +417,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
|
||||
"""Get all known categories.
|
||||
|
||||
@@ -444,7 +444,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_get_recipes_in_category(
|
||||
category: str, ctx: Context
|
||||
) -> ListRecipesResponse:
|
||||
@@ -480,7 +480,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
||||
"""Get all known keywords/tags"""
|
||||
client = get_client(ctx)
|
||||
@@ -505,7 +505,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_get_recipes_with_keywords(
|
||||
keywords: list[str], ctx: Context
|
||||
) -> ListRecipesResponse:
|
||||
@@ -539,7 +539,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("cookbook:write")
|
||||
async def nc_cookbook_set_config(
|
||||
folder: str | None = None,
|
||||
update_interval: int | None = None,
|
||||
@@ -582,7 +582,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("cookbook:write")
|
||||
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
|
||||
"""Trigger a rescan of all recipes into the caching database.
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
# Read Tools (converted from resources)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
||||
"""Get all Nextcloud Deck boards"""
|
||||
client = get_client(ctx)
|
||||
@@ -125,7 +125,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return boards
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
||||
"""Get details of a specific Nextcloud Deck board"""
|
||||
client = get_client(ctx)
|
||||
@@ -133,7 +133,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return board
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
||||
"""Get all stacks in a Nextcloud Deck board"""
|
||||
client = get_client(ctx)
|
||||
@@ -141,7 +141,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return stacks
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
||||
"""Get details of a specific Nextcloud Deck stack"""
|
||||
client = get_client(ctx)
|
||||
@@ -149,7 +149,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return stack
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_cards(
|
||||
ctx: Context, board_id: int, stack_id: int
|
||||
) -> list[DeckCard]:
|
||||
@@ -161,7 +161,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return []
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> DeckCard:
|
||||
@@ -171,7 +171,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return card
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
||||
"""Get all labels in a Nextcloud Deck board"""
|
||||
client = get_client(ctx)
|
||||
@@ -179,7 +179,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return board.labels
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
||||
"""Get details of a specific Nextcloud Deck label"""
|
||||
client = get_client(ctx)
|
||||
@@ -189,7 +189,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
# Create/Update/Delete Tools
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_create_board(
|
||||
ctx: Context, title: str, color: str
|
||||
) -> CreateBoardResponse:
|
||||
@@ -206,7 +206,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
# Stack Tools
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_create_stack(
|
||||
ctx: Context, board_id: int, title: str, order: int
|
||||
) -> CreateStackResponse:
|
||||
@@ -222,7 +222,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_update_stack(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -248,7 +248,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_delete_stack(
|
||||
ctx: Context, board_id: int, stack_id: int
|
||||
) -> StackOperationResponse:
|
||||
@@ -269,7 +269,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
# Card Tools
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_create_card(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -303,7 +303,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_update_card(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -356,7 +356,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_delete_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> CardOperationResponse:
|
||||
@@ -378,7 +378,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_archive_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> CardOperationResponse:
|
||||
@@ -400,7 +400,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_unarchive_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> CardOperationResponse:
|
||||
@@ -422,7 +422,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_reorder_card(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -454,7 +454,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
# Label Tools
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_create_label(
|
||||
ctx: Context, board_id: int, title: str, color: str
|
||||
) -> CreateLabelResponse:
|
||||
@@ -470,7 +470,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_update_label(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -496,7 +496,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_delete_label(
|
||||
ctx: Context, board_id: int, label_id: int
|
||||
) -> LabelOperationResponse:
|
||||
@@ -517,7 +517,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
# Card-Label Assignment Tools
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_assign_label_to_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||
) -> CardOperationResponse:
|
||||
@@ -540,7 +540,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_remove_label_from_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||
) -> CardOperationResponse:
|
||||
@@ -564,7 +564,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
# Card-User Assignment Tools
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_assign_user_to_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||
) -> CardOperationResponse:
|
||||
@@ -587,7 +587,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("deck:write")
|
||||
async def deck_unassign_user_from_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||
) -> CardOperationResponse:
|
||||
|
||||
@@ -85,11 +85,11 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("notes:write")
|
||||
async def nc_notes_create_note(
|
||||
title: str, content: str, category: str, ctx: Context
|
||||
) -> CreateNoteResponse:
|
||||
"""Create a new note (requires nc:write scope)"""
|
||||
"""Create a new note (requires notes:write scope)"""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.create_note(
|
||||
@@ -131,7 +131,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("notes:write")
|
||||
async def nc_notes_update_note(
|
||||
note_id: int,
|
||||
etag: str,
|
||||
@@ -140,7 +140,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
category: str | None,
|
||||
ctx: Context,
|
||||
) -> UpdateNoteResponse:
|
||||
"""Update an existing note's title, content, or category (requires nc:write scope).
|
||||
"""Update an existing note's title, content, or category (requires notes:write scope).
|
||||
|
||||
REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes.
|
||||
Get the current ETag by first retrieving the note using nc_notes_get_note tool.
|
||||
@@ -196,7 +196,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("notes:write")
|
||||
async def nc_notes_append_content(
|
||||
note_id: int, content: str, ctx: Context
|
||||
) -> AppendContentResponse:
|
||||
@@ -246,9 +246,9 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||
"""Search notes by title or content, returning only id, title, and category (requires nc:read scope)."""
|
||||
"""Search notes by title or content, returning only id, title, and category (requires notes:read scope)."""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
search_results_raw = await client.notes_search_notes(query=query)
|
||||
@@ -292,9 +292,9 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
||||
"""Get a specific note by its ID (requires nc:read scope)"""
|
||||
"""Get a specific note by its ID (requires notes:read scope)"""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.get_note(note_id)
|
||||
@@ -321,7 +321,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_get_attachment(
|
||||
note_id: int, attachment_filename: str, ctx: Context
|
||||
) -> dict[str, str]:
|
||||
@@ -367,7 +367,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("notes:write")
|
||||
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
||||
"""Delete a note permanently"""
|
||||
logger.info("Deleting note %s", note_id)
|
||||
|
||||
@@ -16,7 +16,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
"""
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("sharing:write")
|
||||
async def nc_share_create(
|
||||
path: str,
|
||||
share_with: str,
|
||||
@@ -55,7 +55,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
return json.dumps(share_data, indent=2)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("sharing:write")
|
||||
async def nc_share_delete(share_id: int, ctx: Context) -> str:
|
||||
"""Delete a share by its ID.
|
||||
|
||||
@@ -74,7 +74,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("sharing:write")
|
||||
async def nc_share_get(share_id: int, ctx: Context) -> str:
|
||||
"""Get information about a specific share.
|
||||
|
||||
@@ -92,7 +92,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
return json.dumps(share_data, indent=2)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("sharing:write")
|
||||
async def nc_share_list(
|
||||
ctx: Context, path: str | None = None, shared_with_me: bool = False
|
||||
) -> str:
|
||||
@@ -113,7 +113,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
return json.dumps(shares, indent=2)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("sharing:write")
|
||||
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
|
||||
"""Update the permissions of an existing share.
|
||||
|
||||
|
||||
@@ -11,21 +11,21 @@ logger = logging.getLogger(__name__)
|
||||
def configure_tables_tools(mcp: FastMCP):
|
||||
# Tables tools
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("tables:read")
|
||||
async def nc_tables_list_tables(ctx: Context):
|
||||
"""List all tables available to the user"""
|
||||
client = get_client(ctx)
|
||||
return await client.tables.list_tables()
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("tables:read")
|
||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||
"""Get the schema/structure of a specific table including columns and views"""
|
||||
client = get_client(ctx)
|
||||
return await client.tables.get_table_schema(table_id)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("tables:read")
|
||||
async def nc_tables_read_table(
|
||||
table_id: int,
|
||||
ctx: Context,
|
||||
@@ -37,7 +37,7 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("tables:write")
|
||||
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
||||
"""Insert a new row into a table.
|
||||
|
||||
@@ -47,7 +47,7 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
return await client.tables.create_row(table_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("tables:write")
|
||||
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
||||
"""Update an existing row in a table.
|
||||
|
||||
@@ -57,7 +57,7 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
return await client.tables.update_row(row_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("tables:write")
|
||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||
"""Delete a row from a table"""
|
||||
client = get_client(ctx)
|
||||
|
||||
@@ -4,7 +4,11 @@ from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models import FileInfo, SearchFilesResponse
|
||||
from nextcloud_mcp_server.models import DirectoryListing, FileInfo, SearchFilesResponse
|
||||
from nextcloud_mcp_server.utils.document_parser import (
|
||||
is_parseable_document,
|
||||
parse_document,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -12,21 +16,40 @@ logger = logging.getLogger(__name__)
|
||||
def configure_webdav_tools(mcp: FastMCP):
|
||||
# WebDAV file system tools
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_webdav_list_directory(ctx: Context, path: str = ""):
|
||||
@require_scopes("files:read")
|
||||
async def nc_webdav_list_directory(
|
||||
ctx: Context, path: str = ""
|
||||
) -> DirectoryListing:
|
||||
"""List files and directories in the specified NextCloud path.
|
||||
|
||||
Args:
|
||||
path: Directory path to list (empty string for root directory)
|
||||
|
||||
Returns:
|
||||
List of items with metadata including name, path, is_directory, size, content_type, last_modified
|
||||
DirectoryListing with files, total_count, directories_count, files_count, and total_size
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
return await client.webdav.list_directory(path)
|
||||
items = await client.webdav.list_directory(path)
|
||||
|
||||
# Convert to FileInfo models
|
||||
file_infos = [FileInfo(**item) for item in items]
|
||||
|
||||
# Calculate metadata
|
||||
directories_count = sum(1 for f in file_infos if f.is_directory)
|
||||
files_count = sum(1 for f in file_infos if not f.is_directory)
|
||||
total_size = sum(f.size or 0 for f in file_infos if not f.is_directory)
|
||||
|
||||
return DirectoryListing(
|
||||
path=path,
|
||||
files=file_infos,
|
||||
total_count=len(file_infos),
|
||||
directories_count=directories_count,
|
||||
files_count=files_count,
|
||||
total_size=total_size,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("files:read")
|
||||
async def nc_webdav_read_file(path: str, ctx: Context):
|
||||
"""Read the content of a file from NextCloud.
|
||||
|
||||
@@ -34,12 +57,53 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
path: Full path to the file to read
|
||||
|
||||
Returns:
|
||||
Dict with path, content, content_type, size, and encoding (if binary)
|
||||
Text files are decoded to UTF-8, binary files are base64 encoded
|
||||
Dict with path, content, content_type, size, and optional parsing metadata
|
||||
- Text files are decoded to UTF-8
|
||||
- Documents (PDF, DOCX, etc.) are parsed and text is extracted
|
||||
- Other binary files are base64 encoded
|
||||
|
||||
Examples:
|
||||
# Read a text file
|
||||
result = await nc_webdav_read_file("Documents/readme.txt")
|
||||
logger.info(result['content']) # Decoded text content
|
||||
|
||||
# Read a PDF document (automatically parsed)
|
||||
result = await nc_webdav_read_file("Documents/report.pdf")
|
||||
logger.info(result['content']) # Extracted text from PDF
|
||||
logger.info(result['parsing_metadata']) # Document parsing info
|
||||
|
||||
# Read a binary file
|
||||
result = await nc_webdav_read_file("Images/photo.jpg")
|
||||
logger.info(result['encoding']) # 'base64'
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
content, content_type = await client.webdav.read_file(path)
|
||||
|
||||
# Check if this is a parseable document (PDF, DOCX, etc.)
|
||||
# is_parseable_document() checks if document processing is enabled
|
||||
if is_parseable_document(content_type):
|
||||
try:
|
||||
logger.info(f"Parsing document '{path}' of type '{content_type}'")
|
||||
parsed_text, metadata = await parse_document(
|
||||
content,
|
||||
content_type,
|
||||
filename=path,
|
||||
progress_callback=ctx.report_progress,
|
||||
)
|
||||
return {
|
||||
"path": path,
|
||||
"content": parsed_text,
|
||||
"content_type": content_type,
|
||||
"size": len(content),
|
||||
"parsed": True,
|
||||
"parsing_metadata": metadata,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to parse document '{path}', falling back to base64: {e}"
|
||||
)
|
||||
# Fall through to base64 encoding on parse failure
|
||||
|
||||
# For text files, decode content for easier viewing
|
||||
if content_type and content_type.startswith("text/"):
|
||||
try:
|
||||
@@ -65,7 +129,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("files:write")
|
||||
async def nc_webdav_write_file(
|
||||
path: str, content: str, ctx: Context, content_type: str | None = None
|
||||
):
|
||||
@@ -93,7 +157,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
return await client.webdav.write_file(path, content_bytes, content_type)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("files:write")
|
||||
async def nc_webdav_create_directory(path: str, ctx: Context):
|
||||
"""Create a directory in NextCloud.
|
||||
|
||||
@@ -107,7 +171,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
return await client.webdav.create_directory(path)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("files:write")
|
||||
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
||||
"""Delete a file or directory in NextCloud.
|
||||
|
||||
@@ -121,7 +185,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
return await client.webdav.delete_resource(path)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("files:write")
|
||||
async def nc_webdav_move_resource(
|
||||
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||
):
|
||||
@@ -141,7 +205,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
@require_scopes("files:write")
|
||||
async def nc_webdav_copy_resource(
|
||||
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||
):
|
||||
@@ -161,7 +225,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("files:read")
|
||||
async def nc_webdav_search_files(
|
||||
ctx: Context,
|
||||
scope: str = "",
|
||||
@@ -277,7 +341,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("files:read")
|
||||
async def nc_webdav_find_by_name(
|
||||
pattern: str, ctx: Context, scope: str = "", limit: int | None = None
|
||||
) -> SearchFilesResponse:
|
||||
@@ -304,7 +368,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("files:read")
|
||||
async def nc_webdav_find_by_type(
|
||||
mime_type: str, ctx: Context, scope: str = "", limit: int | None = None
|
||||
) -> SearchFilesResponse:
|
||||
@@ -331,7 +395,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
@require_scopes("files:read")
|
||||
async def nc_webdav_list_favorites(
|
||||
ctx: Context, scope: str = "", limit: int | None = None
|
||||
) -> SearchFilesResponse:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility functions for the Nextcloud MCP server."""
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Document parsing utilities using pluggable processor registry."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from nextcloud_mcp_server.config import get_document_processor_config
|
||||
from nextcloud_mcp_server.document_processors import (
|
||||
ProcessorError,
|
||||
get_registry,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_parseable_document(content_type: Optional[str]) -> bool:
|
||||
"""Check if a document type can be parsed by any registered processor.
|
||||
|
||||
Args:
|
||||
content_type: The MIME type of the document
|
||||
|
||||
Returns:
|
||||
True if any processor can handle this type, False otherwise
|
||||
"""
|
||||
if not content_type:
|
||||
return False
|
||||
|
||||
config = get_document_processor_config()
|
||||
if not config["enabled"]:
|
||||
return False
|
||||
|
||||
registry = get_registry()
|
||||
processor = registry.find_processor(content_type)
|
||||
return processor is not None
|
||||
|
||||
|
||||
async def parse_document(
|
||||
content: bytes,
|
||||
content_type: Optional[str],
|
||||
filename: Optional[str] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> Tuple[str, dict]:
|
||||
"""Parse a document using registered processors.
|
||||
|
||||
This function uses the processor registry to find an appropriate
|
||||
processor for the given document type and extract text from it.
|
||||
|
||||
Args:
|
||||
content: The document content as bytes
|
||||
content_type: The MIME type of the document
|
||||
filename: Optional filename to help with format detection
|
||||
progress_callback: Optional async callback for progress updates during long operations
|
||||
|
||||
Returns:
|
||||
Tuple of (parsed_text, metadata) where:
|
||||
- parsed_text: The extracted text content
|
||||
- metadata: Additional metadata about the parsing
|
||||
|
||||
Raises:
|
||||
ValueError: If the document type is not supported
|
||||
Exception: If parsing fails
|
||||
"""
|
||||
if not content_type:
|
||||
raise ValueError("Content type is required for document parsing")
|
||||
|
||||
config = get_document_processor_config()
|
||||
if not config["enabled"]:
|
||||
raise ValueError("Document processing is disabled")
|
||||
|
||||
registry = get_registry()
|
||||
|
||||
logger.debug(f"Parsing document of type '{content_type}'")
|
||||
|
||||
try:
|
||||
# Process using registry (auto-selects processor based on MIME type)
|
||||
result = await registry.process(
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
filename=filename,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
logger.info(f"Successfully parsed document with '{result.processor}' processor")
|
||||
|
||||
return result.text, result.metadata
|
||||
|
||||
except ProcessorError as e:
|
||||
logger.error(f"Document processing failed: {e}")
|
||||
# Fallback to base64 with error metadata
|
||||
parsed_text = f"Document could not be parsed. Base64 content: {base64.b64encode(content).decode('ascii')[:200]}..."
|
||||
metadata = {
|
||||
"mime_type": content_type,
|
||||
"text_length": len(parsed_text),
|
||||
"parsing_method": "fallback_base64",
|
||||
"error": str(e),
|
||||
}
|
||||
return parsed_text, metadata
|
||||
+18
-5
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.17.1"
|
||||
version = "0.23.0"
|
||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||
authors = [
|
||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||
@@ -10,7 +10,7 @@ license = {text = "AGPL-3.0-only"}
|
||||
requires-python = ">=3.11"
|
||||
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
||||
dependencies = [
|
||||
"mcp[cli] (>=1.18,<1.19)",
|
||||
"mcp[cli] (>=1.19,<1.20)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=12.0.0,<12.1.0)",
|
||||
"icalendar (>=6.0.0,<7.0.0)",
|
||||
@@ -18,7 +18,8 @@ dependencies = [
|
||||
"pydantic>=2.11.4",
|
||||
"click>=8.1.8",
|
||||
"caldav",
|
||||
"pyjwt[crypto]>=2.8.0", # JWT validation with RSA support
|
||||
"pyjwt[crypto]>=2.8.0",
|
||||
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
@@ -46,8 +47,11 @@ log_cli = 1
|
||||
log_cli_level = "ERROR"
|
||||
log_level = "ERROR"
|
||||
markers = [
|
||||
"integration: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||
"oauth: marks tests as oauth (deselect with '-m \"not oauth\"')"
|
||||
"unit: Fast unit tests with mocked dependencies",
|
||||
"integration: Integration tests requiring Docker containers",
|
||||
"oauth: OAuth tests requiring Playwright (slowest)",
|
||||
"smoke: Critical path smoke tests for quick validation",
|
||||
"keycloak: OAuth tests that utilize keycloak external identity provider",
|
||||
]
|
||||
testpaths = [
|
||||
"tests",
|
||||
@@ -63,6 +67,13 @@ version_scheme = "pep440"
|
||||
version_provider = "uv"
|
||||
update_changelog_on_bump = true
|
||||
major_version_zero = true
|
||||
version_files = [
|
||||
"charts/nextcloud-mcp-server/Chart.yaml:appVersion",
|
||||
"charts/nextcloud-mcp-server/Chart.yaml:version"
|
||||
]
|
||||
ignored_tag_formats = [
|
||||
"nextcloud-mcp-server-*"
|
||||
]
|
||||
|
||||
[tool.ruff.lint]
|
||||
extend-select = ["I"]
|
||||
@@ -85,9 +96,11 @@ dev = [
|
||||
"playwright>=1.49.1",
|
||||
"pytest>=8.3.5",
|
||||
"pytest-cov>=6.1.1",
|
||||
"pytest-mock>=3.15.1",
|
||||
"pytest-playwright-asyncio>=0.7.1",
|
||||
"pytest-timeout>=2.3.1",
|
||||
"ruff>=0.11.13",
|
||||
"reportlab>=4.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== Testing Separate Clients Architecture ==="
|
||||
echo ""
|
||||
|
||||
# Check both clients exist in Keycloak
|
||||
echo "1. Verifying Keycloak clients..."
|
||||
docker compose exec -T app curl -s http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration > /dev/null && echo "✓ Keycloak realm available"
|
||||
|
||||
# Check user_oidc provider configuration
|
||||
echo ""
|
||||
echo "2. Checking user_oidc provider..."
|
||||
PROVIDER_INFO=$(docker compose exec -T app php occ user_oidc:provider keycloak)
|
||||
echo "$PROVIDER_INFO" | grep -q "nextcloud" && echo "✓ user_oidc configured with 'nextcloud' client"
|
||||
|
||||
# Get token from nextcloud-mcp-server client
|
||||
echo ""
|
||||
echo "3. Getting token from 'nextcloud-mcp-server' client..."
|
||||
TOKEN=$(curl -s -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
|
||||
-d "grant_type=password" \
|
||||
-d "client_id=nextcloud-mcp-server" \
|
||||
-d "client_secret=mcp-secret-change-in-production" \
|
||||
-d "username=admin" \
|
||||
-d "password=admin" \
|
||||
-d "scope=openid profile email offline_access" | jq -r '.access_token')
|
||||
|
||||
if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then
|
||||
echo "✗ Failed to get token"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Got token from nextcloud-mcp-server client"
|
||||
|
||||
# Check token claims
|
||||
echo ""
|
||||
echo "4. Inspecting token claims..."
|
||||
CLAIMS=$(echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq '{aud, azp, iss, preferred_username}')
|
||||
echo "$CLAIMS"
|
||||
|
||||
AUD=$(echo "$CLAIMS" | jq -r '.aud')
|
||||
AZP=$(echo "$CLAIMS" | jq -r '.azp')
|
||||
|
||||
echo ""
|
||||
echo "Architecture validation:"
|
||||
if [ "$AUD" = "nextcloud" ]; then
|
||||
echo " ✓ aud='nextcloud' - Token intended for Nextcloud resource server"
|
||||
else
|
||||
echo " ✗ FAILED: aud='$AUD', expected 'nextcloud'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$AZP" = "nextcloud-mcp-server" ]; then
|
||||
echo " ✓ azp='nextcloud-mcp-server' - Token requested by MCP Server client"
|
||||
else
|
||||
echo " ✗ FAILED: azp='$AZP', expected 'nextcloud-mcp-server'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test with Nextcloud API
|
||||
echo ""
|
||||
echo "5. Testing token with Nextcloud API..."
|
||||
HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/nc_response.json \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
"http://localhost:8080/ocs/v2.php/cloud/capabilities?format=json")
|
||||
|
||||
echo "HTTP Status: $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "✓ Token validated successfully!"
|
||||
echo ""
|
||||
echo "===================================================================="
|
||||
echo "SUCCESS: Separate Clients Architecture Working!"
|
||||
echo "===================================================================="
|
||||
echo ""
|
||||
echo "Summary:"
|
||||
echo " - MCP Server client: 'nextcloud-mcp-server' (requests tokens)"
|
||||
echo " - Resource server: 'nextcloud' (validates tokens via user_oidc)"
|
||||
echo " - Token audience: 'nextcloud' (proper resource targeting)"
|
||||
echo " - Token azp: 'nextcloud-mcp-server' (identifies requester)"
|
||||
echo ""
|
||||
echo "This architecture supports:"
|
||||
echo " - Future multi-resource tokens: aud=['nextcloud', 'other-service']"
|
||||
echo " - Clear separation of OAuth client vs resource server"
|
||||
echo " - RFC 8707 Resource Indicators compliance"
|
||||
else
|
||||
echo "✗ Token validation failed"
|
||||
cat /tmp/nc_response.json
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,482 @@
|
||||
import httpx
|
||||
|
||||
# ============================================================================
|
||||
# Mock Response Helpers for Unit Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def create_mock_response(
|
||||
status_code: int = 200,
|
||||
json_data: dict | list | None = None,
|
||||
headers: dict | None = None,
|
||||
content: bytes | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock httpx.Response for testing.
|
||||
|
||||
Args:
|
||||
status_code: HTTP status code
|
||||
json_data: JSON data to return from response.json()
|
||||
headers: Response headers
|
||||
content: Raw response content (if not using json_data)
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response object
|
||||
"""
|
||||
import json as json_module
|
||||
|
||||
if headers is None:
|
||||
headers = {}
|
||||
|
||||
# If json_data is provided, serialize it to content
|
||||
if json_data is not None:
|
||||
content = json_module.dumps(json_data).encode("utf-8")
|
||||
headers.setdefault("content-type", "application/json")
|
||||
|
||||
if content is None:
|
||||
content = b""
|
||||
|
||||
# Create a mock request
|
||||
request = httpx.Request("GET", "http://test.local/api")
|
||||
|
||||
# Create the response
|
||||
return httpx.Response(
|
||||
status_code=status_code,
|
||||
headers=headers,
|
||||
content=content,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
def create_mock_note_response(
|
||||
note_id: int = 1,
|
||||
title: str = "Test Note",
|
||||
content: str = "Test content",
|
||||
category: str = "Test",
|
||||
etag: str = "abc123",
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud note.
|
||||
|
||||
Args:
|
||||
note_id: Note ID
|
||||
title: Note title
|
||||
content: Note content
|
||||
category: Note category
|
||||
etag: ETag header value
|
||||
**kwargs: Additional note fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with note data
|
||||
"""
|
||||
note_data = {
|
||||
"id": note_id,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"category": category,
|
||||
"etag": etag,
|
||||
"modified": 1234567890,
|
||||
"favorite": False,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(
|
||||
status_code=200,
|
||||
json_data=note_data,
|
||||
headers={"etag": f'"{etag}"'},
|
||||
)
|
||||
|
||||
|
||||
def create_mock_error_response(
|
||||
status_code: int,
|
||||
message: str = "Error",
|
||||
) -> httpx.Response:
|
||||
"""Create a mock error response.
|
||||
|
||||
Args:
|
||||
status_code: HTTP error status code (e.g., 404, 412)
|
||||
message: Error message
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with error
|
||||
"""
|
||||
return create_mock_response(
|
||||
status_code=status_code,
|
||||
json_data={"message": message},
|
||||
)
|
||||
|
||||
|
||||
def create_mock_recipe_response(
|
||||
recipe_id: int = 1,
|
||||
name: str = "Test Recipe",
|
||||
description: str = "Test description",
|
||||
recipe_category: str = "Test",
|
||||
keywords: str = "test",
|
||||
recipe_yield: int = 4,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Cookbook recipe.
|
||||
|
||||
Args:
|
||||
recipe_id: Recipe ID
|
||||
name: Recipe name
|
||||
description: Recipe description
|
||||
recipe_category: Recipe category
|
||||
keywords: Recipe keywords (comma-separated)
|
||||
recipe_yield: Recipe yield (number of servings)
|
||||
**kwargs: Additional recipe fields (recipeIngredient, recipeInstructions, etc.)
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with recipe data
|
||||
"""
|
||||
recipe_data = {
|
||||
"id": recipe_id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"recipeCategory": recipe_category,
|
||||
"keywords": keywords,
|
||||
"recipeYield": recipe_yield,
|
||||
"recipeIngredient": kwargs.get("recipeIngredient", []),
|
||||
"recipeInstructions": kwargs.get("recipeInstructions", []),
|
||||
"prepTime": kwargs.get("prepTime", "PT15M"),
|
||||
"cookTime": kwargs.get("cookTime", "PT30M"),
|
||||
"totalTime": kwargs.get("totalTime", "PT45M"),
|
||||
"url": kwargs.get("url", ""),
|
||||
**{
|
||||
k: v
|
||||
for k, v in kwargs.items()
|
||||
if k
|
||||
not in [
|
||||
"recipeIngredient",
|
||||
"recipeInstructions",
|
||||
"prepTime",
|
||||
"cookTime",
|
||||
"totalTime",
|
||||
"url",
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
return create_mock_response(
|
||||
status_code=200,
|
||||
json_data=recipe_data,
|
||||
)
|
||||
|
||||
|
||||
def create_mock_recipe_list_response(
|
||||
recipes: list[dict] = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a list of recipe stubs.
|
||||
|
||||
Args:
|
||||
recipes: List of recipe stub dictionaries. If None, returns empty list.
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with recipe list data
|
||||
"""
|
||||
if recipes is None:
|
||||
recipes = []
|
||||
|
||||
return create_mock_response(
|
||||
status_code=200,
|
||||
json_data=recipes,
|
||||
)
|
||||
|
||||
|
||||
def create_mock_deck_board_response(
|
||||
board_id: int = 1,
|
||||
title: str = "Test Board",
|
||||
color: str = "0000FF",
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Deck board.
|
||||
|
||||
Args:
|
||||
board_id: Board ID
|
||||
title: Board title
|
||||
color: Board color (hex without #)
|
||||
**kwargs: Additional board fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with board data
|
||||
"""
|
||||
board_data = {
|
||||
"id": board_id,
|
||||
"title": title,
|
||||
"color": color,
|
||||
"owner": {
|
||||
"primaryKey": "testuser",
|
||||
"uid": "testuser",
|
||||
"displayname": "Test User",
|
||||
},
|
||||
"archived": False,
|
||||
"labels": [],
|
||||
"acl": [],
|
||||
"permissions": {
|
||||
"PERMISSION_READ": True,
|
||||
"PERMISSION_EDIT": True,
|
||||
"PERMISSION_MANAGE": True,
|
||||
"PERMISSION_SHARE": True,
|
||||
},
|
||||
"users": [],
|
||||
"deletedAt": 0,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=board_data)
|
||||
|
||||
|
||||
def create_mock_deck_stack_response(
|
||||
stack_id: int = 1,
|
||||
title: str = "Test Stack",
|
||||
board_id: int = 1,
|
||||
order: int = 1,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Deck stack.
|
||||
|
||||
Args:
|
||||
stack_id: Stack ID
|
||||
title: Stack title
|
||||
board_id: Parent board ID
|
||||
order: Stack order
|
||||
**kwargs: Additional stack fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with stack data
|
||||
"""
|
||||
stack_data = {
|
||||
"id": stack_id,
|
||||
"title": title,
|
||||
"boardId": board_id,
|
||||
"order": order,
|
||||
"deletedAt": 0,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=stack_data)
|
||||
|
||||
|
||||
def create_mock_deck_card_response(
|
||||
card_id: int = 1,
|
||||
title: str = "Test Card",
|
||||
stack_id: int = 1,
|
||||
description: str = "Test description",
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Deck card.
|
||||
|
||||
Args:
|
||||
card_id: Card ID
|
||||
title: Card title
|
||||
stack_id: Parent stack ID
|
||||
description: Card description
|
||||
**kwargs: Additional card fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with card data
|
||||
"""
|
||||
card_data = {
|
||||
"id": card_id,
|
||||
"title": title,
|
||||
"stackId": stack_id,
|
||||
"type": "plain",
|
||||
"order": 999,
|
||||
"archived": False,
|
||||
"owner": "testuser",
|
||||
"description": description,
|
||||
"labels": [],
|
||||
"assignedUsers": [],
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=card_data)
|
||||
|
||||
|
||||
def create_mock_deck_label_response(
|
||||
label_id: int = 1,
|
||||
title: str = "Test Label",
|
||||
color: str = "FF0000",
|
||||
board_id: int = 1,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Deck label.
|
||||
|
||||
Args:
|
||||
label_id: Label ID
|
||||
title: Label title
|
||||
color: Label color (hex without #)
|
||||
board_id: Parent board ID
|
||||
**kwargs: Additional label fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with label data
|
||||
"""
|
||||
label_data = {
|
||||
"id": label_id,
|
||||
"title": title,
|
||||
"color": color,
|
||||
"boardId": board_id,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=label_data)
|
||||
|
||||
|
||||
def create_mock_deck_comment_response(
|
||||
comment_id: int = 1,
|
||||
message: str = "Test comment",
|
||||
card_id: int = 1,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Deck comment (OCS format).
|
||||
|
||||
Args:
|
||||
comment_id: Comment ID
|
||||
message: Comment message
|
||||
card_id: Parent card ID
|
||||
**kwargs: Additional comment fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with comment data in OCS format
|
||||
"""
|
||||
comment_data = {
|
||||
"id": comment_id,
|
||||
"objectId": card_id,
|
||||
"message": message,
|
||||
"actorId": "testuser",
|
||||
"actorDisplayName": "Test User",
|
||||
"actorType": "users",
|
||||
"creationDateTime": "2024-01-01T00:00:00+00:00",
|
||||
"mentions": [], # Required field
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
# Wrap in OCS format
|
||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": comment_data}}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
||||
|
||||
|
||||
def create_mock_tables_list_response(
|
||||
tables: list[dict] = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for list of Nextcloud Tables (OCS format).
|
||||
|
||||
Args:
|
||||
tables: List of table dictionaries. If None, returns empty list.
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with tables list data in OCS format
|
||||
"""
|
||||
if tables is None:
|
||||
tables = []
|
||||
|
||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": tables}}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
||||
|
||||
|
||||
def create_mock_table_schema_response(
|
||||
table_id: int = 1,
|
||||
columns: list[dict] = None,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for Nextcloud Tables schema.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
columns: List of column definitions. If None, creates sample columns.
|
||||
**kwargs: Additional schema fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with table schema data
|
||||
"""
|
||||
if columns is None:
|
||||
columns = [
|
||||
{"id": 1, "title": "Column 1", "type": "text"},
|
||||
{"id": 2, "title": "Column 2", "type": "number"},
|
||||
]
|
||||
|
||||
schema_data = {
|
||||
"id": table_id,
|
||||
"columns": columns,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=schema_data)
|
||||
|
||||
|
||||
def create_mock_table_row_response(
|
||||
row_id: int = 1,
|
||||
table_id: int = 1,
|
||||
data: list[dict] = None,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for Nextcloud Tables row.
|
||||
|
||||
Args:
|
||||
row_id: Row ID
|
||||
table_id: Table ID
|
||||
data: List of column data dicts. If None, creates sample data.
|
||||
**kwargs: Additional row fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with row data
|
||||
"""
|
||||
if data is None:
|
||||
data = [
|
||||
{"columnId": 1, "value": "Test value"},
|
||||
{"columnId": 2, "value": 42},
|
||||
]
|
||||
|
||||
row_data = {
|
||||
"id": row_id,
|
||||
"tableId": table_id,
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
"data": data,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=row_data)
|
||||
|
||||
|
||||
def create_mock_table_row_ocs_response(
|
||||
row_id: int = 1,
|
||||
table_id: int = 1,
|
||||
data: list[dict] = None,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock OCS response for Nextcloud Tables row (used by create_row).
|
||||
|
||||
Args:
|
||||
row_id: Row ID
|
||||
table_id: Table ID
|
||||
data: List of column data dicts. If None, creates sample data.
|
||||
**kwargs: Additional row fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with row data in OCS format
|
||||
"""
|
||||
if data is None:
|
||||
data = [
|
||||
{"columnId": 1, "value": "Test value"},
|
||||
{"columnId": 2, "value": 42},
|
||||
]
|
||||
|
||||
row_data = {
|
||||
"id": row_id,
|
||||
"tableId": table_id,
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
"data": data,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": row_data}}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
||||
@@ -1,386 +1,371 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.client.cookbook import CookbookClient
|
||||
from tests.client.conftest import (
|
||||
create_mock_error_response,
|
||||
create_mock_recipe_list_response,
|
||||
create_mock_recipe_response,
|
||||
create_mock_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
# Mark all tests in this module as unit tests
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
async def test_cookbook_version(nc_client: NextcloudClient):
|
||||
"""Test getting Cookbook app version."""
|
||||
logger.info("Getting Cookbook app version")
|
||||
version_data = await nc_client.cookbook.get_version()
|
||||
async def test_cookbook_version(mocker):
|
||||
"""Test that get_version correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"cookbook_version": "1.0.0",
|
||||
"api_version": "1.0.0",
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
version_data = await client.get_version()
|
||||
|
||||
assert "cookbook_version" in version_data
|
||||
assert "api_version" in version_data
|
||||
logger.info(f"Cookbook version: {version_data}")
|
||||
assert version_data["cookbook_version"] == "1.0.0"
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/version")
|
||||
|
||||
|
||||
async def test_cookbook_config(nc_client: NextcloudClient):
|
||||
"""Test getting Cookbook app configuration."""
|
||||
logger.info("Getting Cookbook app configuration")
|
||||
config_data = await nc_client.cookbook.get_config()
|
||||
async def test_cookbook_config(mocker):
|
||||
"""Test that get_config correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"folder": "/recipes",
|
||||
"update_interval": 60,
|
||||
"print_image": True,
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
config_data = await client.get_config()
|
||||
|
||||
# Config may be empty initially, just verify we can get it
|
||||
assert isinstance(config_data, dict)
|
||||
logger.info(f"Cookbook config: {config_data}")
|
||||
assert config_data["folder"] == "/recipes"
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/config")
|
||||
|
||||
|
||||
async def test_cookbook_list_recipes(nc_client: NextcloudClient):
|
||||
"""Test listing all recipes."""
|
||||
logger.info("Listing all recipes")
|
||||
recipes = await nc_client.cookbook.list_recipes()
|
||||
async def test_cookbook_list_recipes(mocker):
|
||||
"""Test that list_recipes correctly parses the API response."""
|
||||
mock_response = create_mock_recipe_list_response(
|
||||
recipes=[
|
||||
{"id": 1, "name": "Recipe 1", "recipeCategory": "Test"},
|
||||
{"id": 2, "name": "Recipe 2", "recipeCategory": "Test"},
|
||||
]
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
recipes = await client.list_recipes()
|
||||
|
||||
assert isinstance(recipes, list)
|
||||
logger.info(f"Found {len(recipes)} recipes")
|
||||
assert len(recipes) == 2
|
||||
assert recipes[0]["name"] == "Recipe 1"
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/recipes")
|
||||
|
||||
|
||||
async def test_cookbook_create_and_read_recipe(nc_client: NextcloudClient):
|
||||
"""Test creating a recipe and reading it back."""
|
||||
# Create a test recipe
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
async def test_cookbook_create_recipe(mocker):
|
||||
"""Test that create_recipe correctly parses the API response."""
|
||||
# Create_recipe returns just the recipe ID
|
||||
mock_response = create_mock_response(status_code=200, json_data=123)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": "A test recipe for integration testing",
|
||||
"recipeIngredient": ["100g flour", "2 eggs", "200ml milk"],
|
||||
"recipeInstructions": [
|
||||
"Mix ingredients",
|
||||
"Cook for 20 minutes",
|
||||
"Serve hot",
|
||||
],
|
||||
"recipeCategory": "Test",
|
||||
"keywords": "test,integration",
|
||||
"recipeYield": 4,
|
||||
"prepTime": "PT15M",
|
||||
"cookTime": "PT20M",
|
||||
"totalTime": "PT35M",
|
||||
}
|
||||
|
||||
logger.info(f"Creating recipe: {recipe_name}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
logger.info(f"Created recipe with ID: {recipe_id}")
|
||||
|
||||
try:
|
||||
# Read the recipe back
|
||||
logger.info(f"Reading recipe ID: {recipe_id}")
|
||||
retrieved_recipe = await nc_client.cookbook.get_recipe(recipe_id)
|
||||
|
||||
assert retrieved_recipe["name"] == recipe_name
|
||||
assert (
|
||||
retrieved_recipe["description"] == "A test recipe for integration testing"
|
||||
)
|
||||
assert len(retrieved_recipe["recipeIngredient"]) == 3
|
||||
assert len(retrieved_recipe["recipeInstructions"]) == 3
|
||||
assert retrieved_recipe["recipeCategory"] == "Test"
|
||||
assert retrieved_recipe["recipeYield"] == 4
|
||||
logger.info(f"Successfully verified recipe: {recipe_name}")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
logger.info(f"Successfully deleted recipe ID: {recipe_id}")
|
||||
|
||||
|
||||
async def test_cookbook_update_recipe(nc_client: NextcloudClient):
|
||||
"""Test updating a recipe."""
|
||||
# Create a test recipe
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": "Original description",
|
||||
"name": "Test Recipe",
|
||||
"description": "Test description",
|
||||
"recipeIngredient": ["100g flour"],
|
||||
"recipeInstructions": ["Mix ingredients"],
|
||||
"recipeCategory": "Original",
|
||||
}
|
||||
recipe_id = await client.create_recipe(recipe_data)
|
||||
|
||||
logger.info(f"Creating recipe for update test: {recipe_name}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
assert recipe_id == 123
|
||||
|
||||
try:
|
||||
# Get the current recipe first
|
||||
current_recipe = await nc_client.cookbook.get_recipe(recipe_id)
|
||||
|
||||
# Update the recipe with all required fields
|
||||
updated_data = current_recipe.copy()
|
||||
updated_data["description"] = "Updated description"
|
||||
updated_data["recipeIngredient"] = ["100g flour", "2 eggs"]
|
||||
updated_data["recipeInstructions"] = ["Mix ingredients", "Cook"]
|
||||
updated_data["recipeCategory"] = "Updated"
|
||||
|
||||
logger.info(f"Updating recipe ID: {recipe_id}")
|
||||
updated_id = await nc_client.cookbook.update_recipe(recipe_id, updated_data)
|
||||
assert updated_id == recipe_id
|
||||
|
||||
# Verify the update
|
||||
await asyncio.sleep(1) # Allow propagation
|
||||
updated_recipe = await nc_client.cookbook.get_recipe(recipe_id)
|
||||
assert updated_recipe["description"] == "Updated description"
|
||||
assert len(updated_recipe["recipeIngredient"]) == 2
|
||||
assert len(updated_recipe["recipeInstructions"]) == 2
|
||||
assert updated_recipe["recipeCategory"] == "Updated"
|
||||
logger.info(f"Successfully updated recipe ID: {recipe_id}")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/cookbook/api/v1/recipes", json=recipe_data
|
||||
)
|
||||
|
||||
|
||||
async def test_cookbook_delete_nonexistent_recipe(nc_client: NextcloudClient):
|
||||
"""Test deleting a non-existent recipe.
|
||||
async def test_cookbook_get_recipe(mocker):
|
||||
"""Test that get_recipe correctly parses the API response."""
|
||||
mock_response = create_mock_recipe_response(
|
||||
recipe_id=123,
|
||||
name="Test Recipe",
|
||||
description="Test description",
|
||||
recipe_category="Test",
|
||||
keywords="test,integration",
|
||||
recipe_yield=4,
|
||||
recipeIngredient=["100g flour", "2 eggs"],
|
||||
recipeInstructions=["Mix ingredients", "Cook"],
|
||||
)
|
||||
|
||||
Note: The Cookbook API may return 502 or succeed silently for non-existent IDs
|
||||
rather than 404. This test verifies the behavior."""
|
||||
non_existent_id = 999999999
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
logger.info(f"Attempting to delete non-existent recipe ID: {non_existent_id}")
|
||||
try:
|
||||
result = await nc_client.cookbook.delete_recipe(non_existent_id)
|
||||
logger.info(f"Delete returned: {result}")
|
||||
# API may succeed silently or return an error message
|
||||
assert isinstance(result, str)
|
||||
except HTTPStatusError as e:
|
||||
# API may return 404 or 502 for non-existent recipes
|
||||
assert e.response.status_code in [404, 502]
|
||||
logger.info(f"Delete correctly failed with {e.response.status_code}")
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
recipe = await client.get_recipe(recipe_id=123)
|
||||
|
||||
assert recipe["id"] == 123
|
||||
assert recipe["name"] == "Test Recipe"
|
||||
assert recipe["description"] == "Test description"
|
||||
assert len(recipe["recipeIngredient"]) == 2
|
||||
assert len(recipe["recipeInstructions"]) == 2
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"GET", "/apps/cookbook/api/v1/recipes/123"
|
||||
)
|
||||
|
||||
|
||||
async def test_cookbook_import_recipe_from_url(nc_client: NextcloudClient):
|
||||
"""Test importing a recipe from a URL.
|
||||
async def test_cookbook_update_recipe(mocker):
|
||||
"""Test that update_recipe correctly parses the API response."""
|
||||
# Update_recipe returns the recipe ID
|
||||
mock_response = create_mock_response(status_code=200, json_data=123)
|
||||
|
||||
This is the key feature test - importing recipes from URLs using schema.org metadata.
|
||||
Uses an nginx container to serve reliable, controlled test data.
|
||||
"""
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Use the nginx container hostname within the Docker network
|
||||
test_url = "http://recipes/black-pepper-tofu"
|
||||
|
||||
logger.info(f"Importing recipe from nginx container: {test_url}")
|
||||
|
||||
try:
|
||||
imported_recipe = await nc_client.cookbook.import_recipe(test_url)
|
||||
logger.info(f"Successfully imported recipe: {imported_recipe.get('name')}")
|
||||
|
||||
# Verify basic recipe structure
|
||||
assert "name" in imported_recipe
|
||||
assert imported_recipe["name"] == "Black Pepper Tofu"
|
||||
assert "id" in imported_recipe
|
||||
|
||||
# Verify schema.org fields were imported correctly
|
||||
assert imported_recipe.get("description")
|
||||
assert len(imported_recipe.get("recipeIngredient", [])) > 0
|
||||
assert len(imported_recipe.get("recipeInstructions", [])) > 0
|
||||
assert imported_recipe.get("recipeCategory") == "Main Course"
|
||||
assert "tofu" in imported_recipe.get("keywords", "").lower()
|
||||
|
||||
recipe_id = int(imported_recipe["id"])
|
||||
|
||||
# Verify we can read it back
|
||||
retrieved = await nc_client.cookbook.get_recipe(recipe_id)
|
||||
assert retrieved["name"] == imported_recipe["name"]
|
||||
logger.info(f"Verified imported recipe ID: {recipe_id}")
|
||||
|
||||
# Clean up
|
||||
logger.info(f"Deleting imported recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
logger.info("Successfully deleted imported recipe")
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 409:
|
||||
# Recipe already exists - this is acceptable in tests
|
||||
logger.warning("Recipe already exists (409 conflict)")
|
||||
pytest.skip("Recipe already exists in test environment")
|
||||
elif e.response.status_code == 400:
|
||||
# URL couldn't be imported
|
||||
logger.error(
|
||||
f"Failed to import recipe from nginx container: {test_url}. "
|
||||
f"Status: {e.response.status_code}, Response: {e.response.text}"
|
||||
)
|
||||
raise
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def test_cookbook_search_recipes(nc_client: NextcloudClient):
|
||||
"""Test searching for recipes."""
|
||||
# Create a test recipe with unique keywords
|
||||
unique_keyword = f"testkeyword{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": f"Recipe for testing search with {unique_keyword}",
|
||||
"keywords": unique_keyword,
|
||||
"recipeIngredient": ["test ingredient"],
|
||||
"recipeInstructions": ["test instruction"],
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
updated_data = {
|
||||
"name": "Updated Recipe",
|
||||
"description": "Updated description",
|
||||
"recipeIngredient": ["100g flour", "2 eggs", "200ml milk"],
|
||||
"recipeInstructions": ["Mix ingredients", "Cook", "Serve"],
|
||||
}
|
||||
updated_id = await client.update_recipe(recipe_id=123, recipe_data=updated_data)
|
||||
|
||||
logger.info(f"Creating recipe for search test with keyword: {unique_keyword}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
assert updated_id == 123
|
||||
|
||||
try:
|
||||
# Allow time for indexing
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Search for the recipe
|
||||
logger.info(f"Searching for recipes with keyword: {unique_keyword}")
|
||||
search_results = await nc_client.cookbook.search_recipes(unique_keyword)
|
||||
|
||||
assert isinstance(search_results, list)
|
||||
# Should find at least our recipe
|
||||
assert len(search_results) > 0
|
||||
|
||||
# Verify our recipe is in the results
|
||||
found = any(str(r.get("id")) == str(recipe_id) for r in search_results)
|
||||
assert found, f"Recipe {recipe_id} not found in search results"
|
||||
logger.info(f"Successfully found recipe {recipe_id} in search results")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
mock_make_request.assert_called_once_with(
|
||||
"PUT", "/apps/cookbook/api/v1/recipes/123", json=updated_data
|
||||
)
|
||||
|
||||
|
||||
async def test_cookbook_list_categories(nc_client: NextcloudClient):
|
||||
"""Test listing recipe categories."""
|
||||
logger.info("Listing recipe categories")
|
||||
categories = await nc_client.cookbook.list_categories()
|
||||
async def test_cookbook_delete_recipe(mocker):
|
||||
"""Test that delete_recipe correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200, json_data="Recipe deleted successfully"
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
result = await client.delete_recipe(recipe_id=123)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "deleted" in result.lower()
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"DELETE", "/apps/cookbook/api/v1/recipes/123"
|
||||
)
|
||||
|
||||
|
||||
async def test_cookbook_delete_nonexistent_recipe(mocker):
|
||||
"""Test that deleting a non-existent recipe raises HTTPStatusError."""
|
||||
error_response = create_mock_error_response(404, "Recipe not found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(CookbookClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("DELETE", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.delete_recipe(recipe_id=999999999)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
|
||||
|
||||
async def test_cookbook_search_recipes(mocker):
|
||||
"""Test that search_recipes correctly parses the API response."""
|
||||
mock_response = create_mock_recipe_list_response(
|
||||
recipes=[
|
||||
{"id": 1, "name": "Test Recipe 1", "keywords": "test,search"},
|
||||
{"id": 2, "name": "Test Recipe 2", "keywords": "test,search"},
|
||||
]
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
search_results = await client.search_recipes("test")
|
||||
|
||||
assert isinstance(search_results, list)
|
||||
assert len(search_results) == 2
|
||||
|
||||
# Verify URL encoding happened
|
||||
mock_make_request.assert_called_once()
|
||||
call_args = mock_make_request.call_args[0]
|
||||
assert "/apps/cookbook/api/v1/search/" in call_args[1]
|
||||
|
||||
|
||||
async def test_cookbook_list_categories(mocker):
|
||||
"""Test that list_categories correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{"name": "Desserts", "recipe_count": 5},
|
||||
{"name": "Main Course", "recipe_count": 10},
|
||||
],
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
categories = await client.list_categories()
|
||||
|
||||
assert isinstance(categories, list)
|
||||
logger.info(f"Found {len(categories)} categories")
|
||||
assert len(categories) == 2
|
||||
assert categories[0]["name"] == "Desserts"
|
||||
assert categories[0]["recipe_count"] == 5
|
||||
|
||||
# Each category should have name and recipe_count
|
||||
if categories:
|
||||
assert "name" in categories[0]
|
||||
assert "recipe_count" in categories[0]
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/categories")
|
||||
|
||||
|
||||
async def test_cookbook_get_recipes_in_category(nc_client: NextcloudClient):
|
||||
"""Test getting recipes in a specific category."""
|
||||
# Create a recipe in a test category
|
||||
unique_category = f"TestCategory{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"recipeCategory": unique_category,
|
||||
"recipeIngredient": ["test"],
|
||||
"recipeInstructions": ["test"],
|
||||
}
|
||||
async def test_cookbook_get_recipes_in_category(mocker):
|
||||
"""Test that get_recipes_in_category correctly parses the API response."""
|
||||
mock_response = create_mock_recipe_list_response(
|
||||
recipes=[
|
||||
{"id": 1, "name": "Recipe 1", "recipeCategory": "Desserts"},
|
||||
{"id": 2, "name": "Recipe 2", "recipeCategory": "Desserts"},
|
||||
]
|
||||
)
|
||||
|
||||
logger.info(f"Creating recipe in category: {unique_category}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
try:
|
||||
# Allow time for indexing
|
||||
await asyncio.sleep(2)
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
recipes_in_category = await client.get_recipes_in_category("Desserts")
|
||||
|
||||
# Get recipes in this category
|
||||
logger.info(f"Getting recipes in category: {unique_category}")
|
||||
recipes_in_category = await nc_client.cookbook.get_recipes_in_category(
|
||||
unique_category
|
||||
)
|
||||
assert isinstance(recipes_in_category, list)
|
||||
assert len(recipes_in_category) == 2
|
||||
assert recipes_in_category[0]["recipeCategory"] == "Desserts"
|
||||
|
||||
assert isinstance(recipes_in_category, list)
|
||||
assert len(recipes_in_category) > 0
|
||||
|
||||
# Verify our recipe is in the results
|
||||
found = any(str(r.get("id")) == str(recipe_id) for r in recipes_in_category)
|
||||
assert found, f"Recipe {recipe_id} not found in category {unique_category}"
|
||||
logger.info(f"Successfully found recipe in category {unique_category}")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
# Verify URL encoding happened
|
||||
mock_make_request.assert_called_once()
|
||||
call_args = mock_make_request.call_args[0]
|
||||
assert "/apps/cookbook/api/v1/category/" in call_args[1]
|
||||
|
||||
|
||||
async def test_cookbook_list_keywords(nc_client: NextcloudClient):
|
||||
"""Test listing recipe keywords."""
|
||||
logger.info("Listing recipe keywords")
|
||||
keywords = await nc_client.cookbook.list_keywords()
|
||||
async def test_cookbook_list_keywords(mocker):
|
||||
"""Test that list_keywords correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{"name": "vegetarian", "recipe_count": 15},
|
||||
{"name": "quick", "recipe_count": 8},
|
||||
],
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
keywords = await client.list_keywords()
|
||||
|
||||
assert isinstance(keywords, list)
|
||||
logger.info(f"Found {len(keywords)} keywords")
|
||||
assert len(keywords) == 2
|
||||
assert keywords[0]["name"] == "vegetarian"
|
||||
assert keywords[0]["recipe_count"] == 15
|
||||
|
||||
# Each keyword should have name and recipe_count
|
||||
if keywords:
|
||||
assert "name" in keywords[0]
|
||||
assert "recipe_count" in keywords[0]
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/keywords")
|
||||
|
||||
|
||||
async def test_cookbook_get_recipes_with_keywords(nc_client: NextcloudClient):
|
||||
"""Test getting recipes with specific keywords.
|
||||
async def test_cookbook_get_recipes_with_keywords(mocker):
|
||||
"""Test that get_recipes_with_keywords correctly parses the API response."""
|
||||
mock_response = create_mock_recipe_list_response(
|
||||
recipes=[
|
||||
{"id": 1, "name": "Recipe 1", "keywords": "vegetarian,quick"},
|
||||
{"id": 2, "name": "Recipe 2", "keywords": "vegetarian,healthy"},
|
||||
]
|
||||
)
|
||||
|
||||
Note: The keywords filtering may require exact keyword matches and sufficient
|
||||
indexing time. This test uses a longer wait time."""
|
||||
# Create a recipe with unique keywords
|
||||
unique_keyword = f"testtag{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"keywords": f"{unique_keyword},integration",
|
||||
"recipeIngredient": ["test"],
|
||||
"recipeInstructions": ["test"],
|
||||
}
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
logger.info(f"Creating recipe with keyword: {unique_keyword}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
recipes_with_keywords = await client.get_recipes_with_keywords(
|
||||
["vegetarian", "quick"]
|
||||
)
|
||||
|
||||
try:
|
||||
# Allow extra time for indexing
|
||||
await asyncio.sleep(3)
|
||||
assert isinstance(recipes_with_keywords, list)
|
||||
assert len(recipes_with_keywords) == 2
|
||||
|
||||
# Trigger a reindex to ensure the recipe is indexed
|
||||
await nc_client.cookbook.reindex()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Get recipes with this keyword
|
||||
logger.info(f"Getting recipes with keyword: {unique_keyword}")
|
||||
recipes_with_keywords = await nc_client.cookbook.get_recipes_with_keywords(
|
||||
[unique_keyword]
|
||||
)
|
||||
|
||||
assert isinstance(recipes_with_keywords, list)
|
||||
# Keyword filtering might not find recipes immediately due to indexing
|
||||
# Log the results for debugging
|
||||
logger.info(
|
||||
f"Found {len(recipes_with_keywords)} recipes with keyword {unique_keyword}"
|
||||
)
|
||||
|
||||
if len(recipes_with_keywords) > 0:
|
||||
# Verify our recipe is in the results if any are found
|
||||
found = any(
|
||||
str(r.get("id")) == str(recipe_id) for r in recipes_with_keywords
|
||||
)
|
||||
if found:
|
||||
logger.info(f"Successfully found recipe with keyword {unique_keyword}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Recipe {recipe_id} not in keyword results, but other recipes found"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"No recipes found with keyword {unique_keyword} - may be indexing delay"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
# Verify URL encoding and keyword joining happened
|
||||
mock_make_request.assert_called_once()
|
||||
call_args = mock_make_request.call_args[0]
|
||||
assert "/apps/cookbook/api/v1/tags/" in call_args[1]
|
||||
|
||||
|
||||
async def test_cookbook_reindex(nc_client: NextcloudClient):
|
||||
"""Test triggering a reindex of recipes."""
|
||||
logger.info("Triggering recipe reindex")
|
||||
result = await nc_client.cookbook.reindex()
|
||||
async def test_cookbook_reindex(mocker):
|
||||
"""Test that reindex correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data="Reindex completed successfully",
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
result = await client.reindex()
|
||||
|
||||
# Should return a success message
|
||||
assert isinstance(result, str)
|
||||
logger.info(f"Reindex result: {result}")
|
||||
assert "reindex" in result.lower() or "completed" in result.lower()
|
||||
|
||||
mock_make_request.assert_called_once_with("POST", "/apps/cookbook/api/v1/reindex")
|
||||
|
||||
+455
-271
@@ -1,327 +1,511 @@
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.models.deck import DeckCard, DeckLabel, DeckStack
|
||||
from nextcloud_mcp_server.client.deck import DeckClient
|
||||
from nextcloud_mcp_server.models.deck import (
|
||||
DeckBoard,
|
||||
DeckCard,
|
||||
DeckComment,
|
||||
DeckLabel,
|
||||
DeckStack,
|
||||
)
|
||||
from tests.client.conftest import (
|
||||
create_mock_deck_board_response,
|
||||
create_mock_deck_card_response,
|
||||
create_mock_deck_comment_response,
|
||||
create_mock_deck_label_response,
|
||||
create_mock_deck_stack_response,
|
||||
create_mock_error_response,
|
||||
create_mock_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
# Mark all tests in this module as unit tests
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
# Board CRUD Tests
|
||||
# Board Tests
|
||||
|
||||
|
||||
async def test_deck_board_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""
|
||||
Test complete board CRUD workflow using the temporary_board fixture.
|
||||
"""
|
||||
board_data = temporary_board
|
||||
board_id = board_data["id"]
|
||||
original_title = board_data["title"]
|
||||
original_color = board_data["color"]
|
||||
|
||||
logger.info(f"Testing CRUD operations on board ID: {board_id}")
|
||||
|
||||
# Read the board
|
||||
read_board = await nc_client.deck.get_board(board_id)
|
||||
assert read_board.id == board_id
|
||||
assert read_board.title == original_title
|
||||
assert read_board.color == original_color
|
||||
logger.info(f"Successfully read board ID: {board_id}")
|
||||
|
||||
# Update the board
|
||||
updated_title = f"Updated {original_title}"
|
||||
updated_color = "00FF00" # Green color
|
||||
await nc_client.deck.update_board(
|
||||
board_id, title=updated_title, color=updated_color
|
||||
async def test_deck_get_boards(mocker):
|
||||
"""Test that get_boards correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Board 1",
|
||||
"color": "FF0000",
|
||||
"owner": {
|
||||
"primaryKey": "testuser",
|
||||
"uid": "testuser",
|
||||
"displayname": "Test User",
|
||||
},
|
||||
"archived": False,
|
||||
"labels": [],
|
||||
"acl": [],
|
||||
"permissions": {
|
||||
"PERMISSION_READ": True,
|
||||
"PERMISSION_EDIT": True,
|
||||
"PERMISSION_MANAGE": True,
|
||||
"PERMISSION_SHARE": True,
|
||||
},
|
||||
"users": [],
|
||||
"deletedAt": 0,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Board 2",
|
||||
"color": "00FF00",
|
||||
"owner": {
|
||||
"primaryKey": "testuser",
|
||||
"uid": "testuser",
|
||||
"displayname": "Test User",
|
||||
},
|
||||
"archived": False,
|
||||
"labels": [],
|
||||
"acl": [],
|
||||
"permissions": {
|
||||
"PERMISSION_READ": True,
|
||||
"PERMISSION_EDIT": True,
|
||||
"PERMISSION_MANAGE": True,
|
||||
"PERMISSION_SHARE": True,
|
||||
},
|
||||
"users": [],
|
||||
"deletedAt": 0,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# Verify the update
|
||||
updated_board = await nc_client.deck.get_board(board_id)
|
||||
assert updated_board.title == updated_title
|
||||
assert updated_board.color == updated_color
|
||||
logger.info(f"Successfully updated board ID: {board_id}")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
boards = await client.get_boards()
|
||||
|
||||
async def test_deck_list_boards(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test listing all boards with different options.
|
||||
"""
|
||||
# Test basic listing
|
||||
boards = await nc_client.deck.get_boards()
|
||||
assert isinstance(boards, list)
|
||||
logger.info(f"Found {len(boards)} boards")
|
||||
assert len(boards) == 2
|
||||
assert all(isinstance(b, DeckBoard) for b in boards)
|
||||
assert boards[0].id == 1
|
||||
assert boards[0].title == "Board 1"
|
||||
|
||||
# Test with details
|
||||
detailed_boards = await nc_client.deck.get_boards(details=True)
|
||||
assert isinstance(detailed_boards, list)
|
||||
logger.info(f"Found {len(detailed_boards)} boards with details")
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_board_operations_nonexistent(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test operations on non-existent board return appropriate errors.
|
||||
"""
|
||||
non_existent_id = 999999999
|
||||
|
||||
# Test get non-existent board
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.deck.get_board(non_existent_id)
|
||||
assert excinfo.value.response.status_code in [
|
||||
404,
|
||||
403,
|
||||
] # 403 might be returned for access denied
|
||||
logger.info(
|
||||
f"Get non-existent board correctly failed with {excinfo.value.response.status_code}"
|
||||
async def test_deck_create_board(mocker):
|
||||
"""Test that create_board correctly parses the API response."""
|
||||
mock_response = create_mock_deck_board_response(
|
||||
board_id=123, title="New Board", color="FF0000"
|
||||
)
|
||||
|
||||
# Test update non-existent board
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.deck.update_board(non_existent_id, title="Should Fail")
|
||||
assert excinfo.value.response.status_code in [
|
||||
404,
|
||||
403,
|
||||
400,
|
||||
] # 400 for bad request on invalid board ID
|
||||
logger.info(
|
||||
f"Update non-existent board correctly failed with {excinfo.value.response.status_code}"
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
board = await client.create_board(title="New Board", color="FF0000")
|
||||
|
||||
# Stack CRUD Tests
|
||||
assert isinstance(board, DeckBoard)
|
||||
assert board.id == 123
|
||||
assert board.title == "New Board"
|
||||
assert board.color == "FF0000"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "POST"
|
||||
assert call_args[1]["json"]["title"] == "New Board"
|
||||
|
||||
|
||||
async def test_deck_stack_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""
|
||||
Test complete stack CRUD workflow.
|
||||
"""
|
||||
board_id = temporary_board["id"]
|
||||
stack_title = f"Test Stack {uuid.uuid4().hex[:8]}"
|
||||
stack_order = 1
|
||||
stack = None
|
||||
async def test_deck_get_board(mocker):
|
||||
"""Test that get_board correctly parses the API response."""
|
||||
mock_response = create_mock_deck_board_response(
|
||||
board_id=123, title="Test Board", color="0000FF"
|
||||
)
|
||||
|
||||
try:
|
||||
# Create stack
|
||||
stack = await nc_client.deck.create_stack(board_id, stack_title, stack_order)
|
||||
assert isinstance(stack, DeckStack)
|
||||
assert stack.title == stack_title
|
||||
assert stack.order == stack_order
|
||||
stack_id = stack.id
|
||||
logger.info(f"Created stack ID: {stack_id}")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Read stack
|
||||
read_stack = await nc_client.deck.get_stack(board_id, stack_id)
|
||||
assert read_stack.id == stack_id
|
||||
assert read_stack.title == stack_title
|
||||
logger.info(f"Successfully read stack ID: {stack_id}")
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
board = await client.get_board(board_id=123)
|
||||
|
||||
# Update stack
|
||||
updated_title = f"Updated {stack_title}"
|
||||
updated_order = 2
|
||||
await nc_client.deck.update_stack(
|
||||
board_id, stack_id, title=updated_title, order=updated_order
|
||||
)
|
||||
assert isinstance(board, DeckBoard)
|
||||
assert board.id == 123
|
||||
assert board.title == "Test Board"
|
||||
|
||||
# Verify update
|
||||
updated_stack = await nc_client.deck.get_stack(board_id, stack_id)
|
||||
assert updated_stack.title == updated_title
|
||||
assert updated_stack.order == updated_order
|
||||
logger.info(f"Successfully updated stack ID: {stack_id}")
|
||||
|
||||
# List stacks
|
||||
stacks = await nc_client.deck.get_stacks(board_id)
|
||||
assert isinstance(stacks, list)
|
||||
assert any(s.id == stack_id for s in stacks)
|
||||
logger.info(f"Found stack ID: {stack_id} in board stacks list")
|
||||
|
||||
finally:
|
||||
# Clean up - delete stack
|
||||
if stack and hasattr(stack, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_stack(board_id, stack.id)
|
||||
logger.info(f"Cleaned up stack ID: {stack.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up stack ID: {stack.id}: {e}")
|
||||
mock_make_request.assert_called_once()
|
||||
assert "/boards/123" in mock_make_request.call_args[0][1]
|
||||
|
||||
|
||||
# Card CRUD Tests
|
||||
async def test_deck_update_board(mocker):
|
||||
"""Test that update_board makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
await client.update_board(board_id=123, title="Updated Board", color="00FF00")
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "PUT"
|
||||
assert "/boards/123" in call_args[0][1]
|
||||
assert call_args[1]["json"]["title"] == "Updated Board"
|
||||
|
||||
|
||||
async def test_deck_card_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board_with_stack: tuple
|
||||
):
|
||||
"""
|
||||
Test complete card CRUD workflow.
|
||||
"""
|
||||
board_data, stack_data = temporary_board_with_stack
|
||||
board_id = board_data["id"]
|
||||
stack_id = stack_data["id"]
|
||||
async def test_deck_get_board_nonexistent(mocker):
|
||||
"""Test that getting a non-existent board raises HTTPStatusError."""
|
||||
error_response = create_mock_error_response(404, "Board not found")
|
||||
|
||||
card_title = f"Test Card {uuid.uuid4().hex[:8]}"
|
||||
card_description = f"Test description for card {uuid.uuid4().hex[:8]}"
|
||||
card = None
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(DeckClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("GET", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
try:
|
||||
# Create card
|
||||
card = await nc_client.deck.create_card(
|
||||
board_id, stack_id, card_title, description=card_description
|
||||
)
|
||||
assert isinstance(card, DeckCard)
|
||||
assert card.title == card_title
|
||||
assert card.description == card_description
|
||||
card_id = card.id
|
||||
logger.info(f"Created card ID: {card_id}")
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
|
||||
# Read card
|
||||
read_card = await nc_client.deck.get_card(board_id, stack_id, card_id)
|
||||
assert read_card.id == card_id
|
||||
assert read_card.title == card_title
|
||||
logger.info(f"Successfully read card ID: {card_id}")
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.get_board(board_id=999999999)
|
||||
|
||||
# Update card
|
||||
updated_title = f"Updated {card_title}"
|
||||
updated_description = f"Updated description for {card_title}"
|
||||
await nc_client.deck.update_card(
|
||||
board_id,
|
||||
stack_id,
|
||||
card_id,
|
||||
title=updated_title,
|
||||
description=updated_description,
|
||||
)
|
||||
|
||||
# Verify update
|
||||
updated_card = await nc_client.deck.get_card(board_id, stack_id, card_id)
|
||||
assert updated_card.title == updated_title
|
||||
assert updated_card.description == updated_description
|
||||
logger.info(f"Successfully updated card ID: {card_id}")
|
||||
|
||||
# Archive and unarchive card
|
||||
await nc_client.deck.archive_card(board_id, stack_id, card_id)
|
||||
logger.info(f"Archived card ID: {card_id}")
|
||||
|
||||
await nc_client.deck.unarchive_card(board_id, stack_id, card_id)
|
||||
logger.info(f"Unarchived card ID: {card_id}")
|
||||
|
||||
finally:
|
||||
# Clean up - delete card
|
||||
if card and hasattr(card, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, stack_id, card.id)
|
||||
logger.info(f"Cleaned up card ID: {card.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up card ID: {card.id}: {e}")
|
||||
assert excinfo.value.response.status_code == 404
|
||||
|
||||
|
||||
# Label CRUD Tests
|
||||
# Stack Tests
|
||||
|
||||
|
||||
async def test_deck_label_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""
|
||||
Test complete label CRUD workflow.
|
||||
"""
|
||||
board_id = temporary_board["id"]
|
||||
label_title = f"Test Label {uuid.uuid4().hex[:8]}"
|
||||
label_color = "FF0000" # Red
|
||||
label = None
|
||||
async def test_deck_create_stack(mocker):
|
||||
"""Test that create_stack correctly parses the API response."""
|
||||
mock_response = create_mock_deck_stack_response(
|
||||
stack_id=456, title="Test Stack", board_id=123, order=1
|
||||
)
|
||||
|
||||
try:
|
||||
# Create label
|
||||
label = await nc_client.deck.create_label(board_id, label_title, label_color)
|
||||
assert isinstance(label, DeckLabel)
|
||||
assert label.title == label_title
|
||||
assert label.color == label_color
|
||||
label_id = label.id
|
||||
logger.info(f"Created label ID: {label_id}")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Read label
|
||||
read_label = await nc_client.deck.get_label(board_id, label_id)
|
||||
assert read_label.id == label_id
|
||||
assert read_label.title == label_title
|
||||
logger.info(f"Successfully read label ID: {label_id}")
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
stack = await client.create_stack(board_id=123, title="Test Stack", order=1)
|
||||
|
||||
# Update label
|
||||
updated_title = f"Updated {label_title}"
|
||||
updated_color = "00FF00" # Green
|
||||
await nc_client.deck.update_label(
|
||||
board_id, label_id, title=updated_title, color=updated_color
|
||||
)
|
||||
assert isinstance(stack, DeckStack)
|
||||
assert stack.id == 456
|
||||
assert stack.title == "Test Stack"
|
||||
assert stack.boardId == 123
|
||||
|
||||
# Verify update
|
||||
updated_label = await nc_client.deck.get_label(board_id, label_id)
|
||||
assert updated_label.title == updated_title
|
||||
assert updated_label.color == updated_color
|
||||
logger.info(f"Successfully updated label ID: {label_id}")
|
||||
|
||||
finally:
|
||||
# Clean up - delete label
|
||||
if label and hasattr(label, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_label(board_id, label.id)
|
||||
logger.info(f"Cleaned up label ID: {label.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up label ID: {label.id}: {e}")
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
# Configuration and Comments Tests
|
||||
async def test_deck_get_stack(mocker):
|
||||
"""Test that get_stack correctly parses the API response."""
|
||||
mock_response = create_mock_deck_stack_response(
|
||||
stack_id=456, title="Test Stack", board_id=123, order=1
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
stack = await client.get_stack(board_id=123, stack_id=456)
|
||||
|
||||
assert isinstance(stack, DeckStack)
|
||||
assert stack.id == 456
|
||||
assert stack.title == "Test Stack"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
assert "/boards/123/stacks/456" in mock_make_request.call_args[0][1]
|
||||
|
||||
|
||||
async def test_deck_config_operations(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test deck configuration operations.
|
||||
"""
|
||||
# Get config
|
||||
config = await nc_client.deck.get_config()
|
||||
assert config is not None
|
||||
logger.info(f"Retrieved deck config: {config}")
|
||||
async def test_deck_get_stacks(mocker):
|
||||
"""Test that get_stacks correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{"id": 1, "title": "Stack 1", "boardId": 123, "order": 1, "deletedAt": 0},
|
||||
{"id": 2, "title": "Stack 2", "boardId": 123, "order": 2, "deletedAt": 0},
|
||||
],
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
stacks = await client.get_stacks(board_id=123)
|
||||
|
||||
assert isinstance(stacks, list)
|
||||
assert len(stacks) == 2
|
||||
assert all(isinstance(s, DeckStack) for s in stacks)
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_comments_workflow(
|
||||
nc_client: NextcloudClient, temporary_board_with_card: tuple
|
||||
):
|
||||
"""
|
||||
Test comment operations on a card.
|
||||
"""
|
||||
board_data, stack_data, card_data = temporary_board_with_card
|
||||
card_id = card_data["id"]
|
||||
# Card Tests
|
||||
|
||||
comment_message = f"Test comment {uuid.uuid4().hex[:8]}"
|
||||
comment = None
|
||||
|
||||
try:
|
||||
# Create comment
|
||||
comment = await nc_client.deck.create_comment(card_id, comment_message)
|
||||
assert comment.message == comment_message
|
||||
comment_id = comment.id
|
||||
logger.info(f"Created comment ID: {comment_id}")
|
||||
async def test_deck_create_card(mocker):
|
||||
"""Test that create_card correctly parses the API response."""
|
||||
mock_response = create_mock_deck_card_response(
|
||||
card_id=789, title="Test Card", stack_id=456, description="Test description"
|
||||
)
|
||||
|
||||
# List comments
|
||||
comments = await nc_client.deck.get_comments(card_id)
|
||||
assert isinstance(comments, list)
|
||||
assert any(c.id == comment_id for c in comments)
|
||||
logger.info(f"Found comment ID: {comment_id} in card comments")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Update comment
|
||||
updated_message = f"Updated {comment_message}"
|
||||
updated_comment = await nc_client.deck.update_comment(
|
||||
card_id, comment_id, updated_message
|
||||
)
|
||||
assert updated_comment.message == updated_message
|
||||
logger.info(f"Successfully updated comment ID: {comment_id}")
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
card = await client.create_card(
|
||||
board_id=123, stack_id=456, title="Test Card", description="Test description"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Clean up - delete comment
|
||||
if comment and hasattr(comment, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_comment(card_id, comment.id)
|
||||
logger.info(f"Cleaned up comment ID: {comment.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up comment ID: {comment.id}: {e}")
|
||||
assert isinstance(card, DeckCard)
|
||||
assert card.id == 789
|
||||
assert card.title == "Test Card"
|
||||
assert card.description == "Test description"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_get_card(mocker):
|
||||
"""Test that get_card correctly parses the API response."""
|
||||
mock_response = create_mock_deck_card_response(
|
||||
card_id=789, title="Test Card", stack_id=456
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
card = await client.get_card(board_id=123, stack_id=456, card_id=789)
|
||||
|
||||
assert isinstance(card, DeckCard)
|
||||
assert card.id == 789
|
||||
assert card.title == "Test Card"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
assert "/boards/123/stacks/456/cards/789" in mock_make_request.call_args[0][1]
|
||||
|
||||
|
||||
async def test_deck_update_card(mocker):
|
||||
"""Test that update_card makes the correct API calls."""
|
||||
# Mock get_card response (update_card calls get_card first)
|
||||
get_response = create_mock_deck_card_response(
|
||||
card_id=789, title="Original Card", stack_id=456
|
||||
)
|
||||
|
||||
# Mock update response
|
||||
update_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(DeckClient, "_make_request")
|
||||
# First call returns the card, second call is the update
|
||||
mock_make_request.side_effect = [get_response, update_response]
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
await client.update_card(
|
||||
board_id=123, stack_id=456, card_id=789, title="Updated Card"
|
||||
)
|
||||
|
||||
# Should be called twice: GET then PUT
|
||||
assert mock_make_request.call_count == 2
|
||||
|
||||
# Check the PUT call
|
||||
put_call = mock_make_request.call_args_list[1]
|
||||
assert put_call[0][0] == "PUT"
|
||||
assert "/boards/123/stacks/456/cards/789" in put_call[0][1]
|
||||
assert put_call[1]["json"]["title"] == "Updated Card"
|
||||
|
||||
|
||||
# Label Tests
|
||||
|
||||
|
||||
async def test_deck_create_label(mocker):
|
||||
"""Test that create_label correctly parses the API response."""
|
||||
mock_response = create_mock_deck_label_response(
|
||||
label_id=111, title="Test Label", color="FF0000", board_id=123
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
label = await client.create_label(board_id=123, title="Test Label", color="FF0000")
|
||||
|
||||
assert isinstance(label, DeckLabel)
|
||||
assert label.id == 111
|
||||
assert label.title == "Test Label"
|
||||
assert label.color == "FF0000"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_get_label(mocker):
|
||||
"""Test that get_label correctly parses the API response."""
|
||||
mock_response = create_mock_deck_label_response(
|
||||
label_id=111, title="Test Label", color="FF0000", board_id=123
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
label = await client.get_label(board_id=123, label_id=111)
|
||||
|
||||
assert isinstance(label, DeckLabel)
|
||||
assert label.id == 111
|
||||
assert label.title == "Test Label"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
assert "/boards/123/labels/111" in mock_make_request.call_args[0][1]
|
||||
|
||||
|
||||
# Comment Tests
|
||||
|
||||
|
||||
async def test_deck_create_comment(mocker):
|
||||
"""Test that create_comment correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_deck_comment_response(
|
||||
comment_id=222, message="Test comment", card_id=789
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
comment = await client.create_comment(card_id=789, message="Test comment")
|
||||
|
||||
assert isinstance(comment, DeckComment)
|
||||
assert comment.id == 222
|
||||
assert comment.message == "Test comment"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_get_comments(mocker):
|
||||
"""Test that get_comments correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"ocs": {
|
||||
"meta": {"status": "ok"},
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"objectId": 789,
|
||||
"message": "Comment 1",
|
||||
"actorId": "testuser",
|
||||
"actorDisplayName": "Test User",
|
||||
"actorType": "users",
|
||||
"creationDateTime": "2024-01-01T00:00:00+00:00",
|
||||
"mentions": [],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"objectId": 789,
|
||||
"message": "Comment 2",
|
||||
"actorId": "testuser",
|
||||
"actorDisplayName": "Test User",
|
||||
"actorType": "users",
|
||||
"creationDateTime": "2024-01-01T00:00:00+00:00",
|
||||
"mentions": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
comments = await client.get_comments(card_id=789)
|
||||
|
||||
assert isinstance(comments, list)
|
||||
assert len(comments) == 2
|
||||
assert all(isinstance(c, DeckComment) for c in comments)
|
||||
assert comments[0].message == "Comment 1"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_update_comment(mocker):
|
||||
"""Test that update_comment correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_deck_comment_response(
|
||||
comment_id=222, message="Updated comment", card_id=789
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
comment = await client.update_comment(
|
||||
card_id=789, comment_id=222, message="Updated comment"
|
||||
)
|
||||
|
||||
assert isinstance(comment, DeckComment)
|
||||
assert comment.id == 222
|
||||
assert comment.message == "Updated comment"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
# Config Test
|
||||
|
||||
|
||||
async def test_deck_get_config(mocker):
|
||||
"""Test that get_config correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"ocs": {
|
||||
"meta": {"status": "ok"},
|
||||
"data": {
|
||||
"calendar": True,
|
||||
"cardDetailsInModal": True,
|
||||
"cardIdBadge": False,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
config = await client.get_config()
|
||||
|
||||
assert config.calendar is True
|
||||
assert config.cardDetailsInModal is True
|
||||
assert config.cardIdBadge is False
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
@@ -1,260 +1,255 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid # Keep uuid if needed for generating unique data within tests
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
# Note: nc_client fixture is now session-scoped in conftest.py
|
||||
from nextcloud_mcp_server.client.notes import NotesClient
|
||||
from tests.client.conftest import create_mock_error_response, create_mock_note_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
# Mark all tests in this module as unit tests
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
async def test_notes_api_create_and_read(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests creating a note via the API (using fixture) and then reading it back.
|
||||
"""
|
||||
created_note_data = temporary_note # Get data from fixture
|
||||
note_id = created_note_data["id"]
|
||||
|
||||
logger.info(f"Reading note created by fixture, ID: {note_id}")
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
|
||||
assert read_note["id"] == note_id
|
||||
assert read_note["title"] == created_note_data["title"]
|
||||
assert read_note["content"] == created_note_data["content"]
|
||||
assert read_note["category"] == created_note_data["category"]
|
||||
logger.info(f"Successfully read and verified note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
|
||||
"""
|
||||
Tests updating a note created by the fixture.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_etag = created_note_data["etag"]
|
||||
original_category = created_note_data["category"]
|
||||
|
||||
update_title = f"Updated Title {uuid.uuid4().hex[:8]}"
|
||||
update_content = f"Updated Content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Attempting to update note ID: {note_id} with etag: {original_etag}")
|
||||
updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag,
|
||||
title=update_title,
|
||||
content=update_content,
|
||||
# category=original_category # Explicitly pass category if required by update
|
||||
async def test_notes_api_get_note(mocker):
|
||||
"""Test that get_note correctly parses the API response."""
|
||||
# Create mock response
|
||||
mock_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
title="Test Note",
|
||||
content="Test content",
|
||||
category="Test",
|
||||
etag="abc123",
|
||||
)
|
||||
logger.info(f"Note updated: {updated_note}")
|
||||
|
||||
assert updated_note["id"] == note_id
|
||||
assert updated_note["title"] == update_title
|
||||
assert updated_note["content"] == update_content
|
||||
assert (
|
||||
updated_note["category"] == original_category
|
||||
) # Verify category didn't change
|
||||
assert "etag" in updated_note
|
||||
assert updated_note["etag"] != original_etag # Etag must change
|
||||
|
||||
# Optional: Verify update by reading again
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
read_updated_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_updated_note["title"] == update_title
|
||||
assert read_updated_note["content"] == update_content
|
||||
logger.info(f"Successfully updated and verified note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_update_conflict(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests that attempting to update with an old etag fails with 412.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_etag = created_note_data["etag"]
|
||||
|
||||
# Perform a first update to change the etag
|
||||
first_update_title = f"First Update {uuid.uuid4().hex[:8]}"
|
||||
logger.info(f"Performing first update on note ID: {note_id} to change etag.")
|
||||
first_updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag,
|
||||
title=first_update_title,
|
||||
content="First update content",
|
||||
# category=created_note_data["category"] # Pass category if required
|
||||
# Mock the _make_request method
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NotesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
new_etag = first_updated_note["etag"]
|
||||
assert new_etag != original_etag
|
||||
logger.info(f"Note ID: {note_id} updated, new etag: {new_etag}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Now attempt update with the *original* etag
|
||||
logger.info(
|
||||
f"Attempting second update on note ID: {note_id} with OLD etag: {original_etag}"
|
||||
# Create client and test
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
note = await client.get_note(note_id=123)
|
||||
|
||||
# Verify the response was parsed correctly
|
||||
assert note["id"] == 123
|
||||
assert note["title"] == "Test Note"
|
||||
assert note["content"] == "Test content"
|
||||
assert note["category"] == "Test"
|
||||
assert note["etag"] == "abc123"
|
||||
|
||||
# Verify the correct API endpoint was called
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/notes/api/v1/notes/123")
|
||||
|
||||
|
||||
async def test_notes_api_create_note(mocker):
|
||||
"""Test that create_note correctly parses the API response."""
|
||||
mock_response = create_mock_note_response(
|
||||
note_id=456,
|
||||
title="New Note",
|
||||
content="New content",
|
||||
category="Category",
|
||||
etag="def456",
|
||||
)
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag, # Use the stale etag
|
||||
title="This update should fail due to conflict",
|
||||
# category=created_note_data["category"] # Pass category if required
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NotesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
note = await client.create_note(
|
||||
title="New Note", content="New content", category="Category"
|
||||
)
|
||||
|
||||
assert note["id"] == 456
|
||||
assert note["title"] == "New Note"
|
||||
assert note["content"] == "New content"
|
||||
assert note["category"] == "Category"
|
||||
|
||||
# Verify the correct API call was made
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/apps/notes/api/v1/notes",
|
||||
json={"title": "New Note", "content": "New content", "category": "Category"},
|
||||
)
|
||||
|
||||
|
||||
async def test_notes_api_update(mocker):
|
||||
"""Test that update correctly parses the API response and handles etag."""
|
||||
# Mock the update response (no category passed, so no GET call happens)
|
||||
update_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
title="Updated Title",
|
||||
content="Updated content",
|
||||
category="Test",
|
||||
etag="new_etag",
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
# Mock _make_request to return the update response
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
mock_make_request.return_value = update_response
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
updated_note = await client.update(
|
||||
note_id=123,
|
||||
etag="abc123",
|
||||
title="Updated Title",
|
||||
content="Updated content",
|
||||
)
|
||||
|
||||
assert updated_note["id"] == 123
|
||||
assert updated_note["title"] == "Updated Title"
|
||||
assert updated_note["content"] == "Updated content"
|
||||
assert updated_note["etag"] == "new_etag"
|
||||
|
||||
# Verify the PUT request was made with the correct etag header (only 1 call since no category)
|
||||
assert mock_make_request.call_count == 1
|
||||
put_call = mock_make_request.call_args_list[0]
|
||||
assert put_call[0] == ("PUT", "/apps/notes/api/v1/notes/123")
|
||||
assert put_call[1]["headers"]["If-Match"] == '"abc123"'
|
||||
|
||||
|
||||
async def test_notes_api_update_conflict(mocker):
|
||||
"""Test that update raises HTTPStatusError on 412 conflict."""
|
||||
# Mock the 412 error response
|
||||
error_response = create_mock_error_response(412, "Precondition Failed")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"412 Precondition Failed",
|
||||
request=httpx.Request("PUT", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.update(
|
||||
note_id=123,
|
||||
etag="old_etag",
|
||||
title="This should fail",
|
||||
)
|
||||
assert excinfo.value.response.status_code == 412 # Precondition Failed
|
||||
logger.info("Update with old etag correctly failed with 412 Precondition Failed.")
|
||||
|
||||
assert excinfo.value.response.status_code == 412
|
||||
|
||||
|
||||
async def test_notes_api_delete_nonexistent(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests deleting a note that doesn't exist fails with 404.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
logger.info(f"\nAttempting to delete non-existent note ID: {non_existent_id}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.delete_note(note_id=non_existent_id)
|
||||
async def test_notes_api_delete_note(mocker):
|
||||
"""Test that delete_note makes the correct API call."""
|
||||
# Mock get_note response (to fetch category for cleanup)
|
||||
get_response = create_mock_note_response(note_id=123, category="Test")
|
||||
|
||||
# Mock delete response
|
||||
delete_response = create_mock_note_response(note_id=123)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
mock_make_request.side_effect = [get_response, delete_response]
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
await client.delete_note(note_id=123)
|
||||
|
||||
# Verify DELETE was called
|
||||
assert any(call[0][0] == "DELETE" for call in mock_make_request.call_args_list)
|
||||
|
||||
|
||||
async def test_notes_api_delete_nonexistent(mocker):
|
||||
"""Test that deleting a non-existent note raises 404."""
|
||||
# Mock 404 error when fetching note details
|
||||
error_response = create_mock_error_response(404, "Not Found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("GET", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.delete_note(note_id=999999999)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404."
|
||||
|
||||
|
||||
async def test_notes_api_append_content(mocker):
|
||||
"""Test that append_content correctly appends to existing content."""
|
||||
# Mock get_note response (to fetch current content)
|
||||
get_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
content="Original content",
|
||||
etag="old_etag",
|
||||
)
|
||||
|
||||
|
||||
async def test_notes_api_append_content_to_existing_note(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests appending content to an existing note using the new append functionality.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_content = created_note_data["content"]
|
||||
|
||||
append_text = f"Appended content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Appending content to note ID: {note_id}")
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=append_text
|
||||
# Mock update response with appended content
|
||||
update_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
content="Original content\n---\nAppended content",
|
||||
etag="new_etag",
|
||||
)
|
||||
logger.info(f"Note after append: {updated_note}")
|
||||
|
||||
# Verify the note was updated
|
||||
assert updated_note["id"] == note_id
|
||||
assert "etag" in updated_note
|
||||
assert updated_note["etag"] != created_note_data["etag"] # Etag must change
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
# First call: GET (from get_note), second call: PUT (from update)
|
||||
mock_make_request.side_effect = [get_response, update_response]
|
||||
|
||||
# Verify content has the separator and appended text
|
||||
expected_content = original_content + "\n---\n" + append_text
|
||||
assert updated_note["content"] == expected_content
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
updated_note = await client.append_content(note_id=123, content="Appended content")
|
||||
|
||||
# Verify by reading the note again
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == expected_content
|
||||
logger.info(f"Successfully appended content to note ID: {note_id}")
|
||||
assert updated_note["content"] == "Original content\n---\nAppended content"
|
||||
assert updated_note["etag"] == "new_etag"
|
||||
|
||||
|
||||
async def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests appending content to an empty note (no separator should be added).
|
||||
"""
|
||||
# Create an empty note
|
||||
test_title = f"Empty Note {uuid.uuid4().hex[:8]}"
|
||||
test_category = "Test"
|
||||
|
||||
logger.info("Creating empty note for append test")
|
||||
empty_note = await nc_client.notes.create_note(
|
||||
title=test_title,
|
||||
async def test_notes_api_append_content_to_empty_note(mocker):
|
||||
"""Test that appending to empty note doesn't add separator."""
|
||||
# Mock get_note response with empty content
|
||||
get_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
content="",
|
||||
category=test_category, # Empty content
|
||||
)
|
||||
note_id = empty_note["id"]
|
||||
|
||||
try:
|
||||
append_text = f"First content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Appending content to empty note ID: {note_id}")
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=append_text
|
||||
)
|
||||
|
||||
# For empty notes, content should just be the appended text (no separator)
|
||||
assert updated_note["content"] == append_text
|
||||
|
||||
# Verify by reading the note again
|
||||
await asyncio.sleep(1)
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == append_text
|
||||
logger.info(f"Successfully appended content to empty note ID: {note_id}")
|
||||
|
||||
finally:
|
||||
# Clean up the test note
|
||||
try:
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Cleaned up test note ID: {note_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up test note ID: {note_id}: {e}")
|
||||
|
||||
|
||||
async def test_notes_api_append_content_multiple_times(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests appending content multiple times to verify separator behavior.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_content = created_note_data["content"]
|
||||
|
||||
first_append = f"First append {uuid.uuid4().hex[:8]}"
|
||||
second_append = f"Second append {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Performing multiple appends to note ID: {note_id}")
|
||||
|
||||
# First append
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=first_append
|
||||
etag="old_etag",
|
||||
)
|
||||
|
||||
expected_content_after_first = original_content + "\n---\n" + first_append
|
||||
assert updated_note["content"] == expected_content_after_first
|
||||
|
||||
# Second append
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=second_append
|
||||
# Mock update response with just the appended text (no separator)
|
||||
update_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
content="First content",
|
||||
etag="new_etag",
|
||||
)
|
||||
|
||||
expected_content_after_second = (
|
||||
expected_content_after_first + "\n---\n" + second_append
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
# First call: GET (from get_note), second call: PUT (from update)
|
||||
mock_make_request.side_effect = [get_response, update_response]
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
updated_note = await client.append_content(note_id=123, content="First content")
|
||||
|
||||
# For empty notes, no separator should be added
|
||||
assert updated_note["content"] == "First content"
|
||||
|
||||
|
||||
async def test_notes_api_append_content_nonexistent_note(mocker):
|
||||
"""Test that appending to a non-existent note raises 404."""
|
||||
error_response = create_mock_error_response(404, "Not Found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("GET", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
assert updated_note["content"] == expected_content_after_second
|
||||
|
||||
# Verify by reading the note again
|
||||
await asyncio.sleep(1)
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == expected_content_after_second
|
||||
logger.info(f"Successfully performed multiple appends to note ID: {note_id}")
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.append_content(note_id=999999999, content="This should fail")
|
||||
|
||||
async def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests that appending to a non-existent note fails with 404.
|
||||
"""
|
||||
non_existent_id = 999999999
|
||||
|
||||
logger.info(f"Attempting to append to non-existent note ID: {non_existent_id}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.append_content(
|
||||
note_id=non_existent_id, content="This should fail"
|
||||
)
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Appending to non-existent note ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
|
||||
@@ -1,535 +1,326 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.client.tables import TablesClient
|
||||
from tests.client.conftest import (
|
||||
create_mock_error_response,
|
||||
create_mock_response,
|
||||
create_mock_table_row_ocs_response,
|
||||
create_mock_table_row_response,
|
||||
create_mock_table_schema_response,
|
||||
create_mock_tables_list_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
# Mark all tests in this module as unit tests
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def sample_table_info(nc_client: NextcloudClient) -> Dict[str, Any]:
|
||||
"""
|
||||
Fixture to get information about the sample table that comes with Nextcloud Tables.
|
||||
This assumes that the sample table exists in the Nextcloud instance.
|
||||
"""
|
||||
logger.info("Looking for sample table in Nextcloud Tables app")
|
||||
async def test_tables_list_tables(mocker):
|
||||
"""Test that list_tables correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_tables_list_response(
|
||||
tables=[
|
||||
{"id": 1, "title": "Table 1"},
|
||||
{"id": 2, "title": "Table 2"},
|
||||
]
|
||||
)
|
||||
|
||||
# Get all tables
|
||||
tables = await nc_client.tables.list_tables()
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Look for a sample table (usually created by default)
|
||||
sample_table = None
|
||||
for table in tables:
|
||||
# Common names for sample tables
|
||||
if any(
|
||||
keyword in table.get("title", "").lower()
|
||||
for keyword in ["sample", "demo", "example", "test"]
|
||||
):
|
||||
sample_table = table
|
||||
break
|
||||
|
||||
if not sample_table and tables:
|
||||
# If no sample table found, use the first available table
|
||||
sample_table = tables[0]
|
||||
logger.info(
|
||||
f"No sample table found, using first available table: {sample_table.get('title')}"
|
||||
)
|
||||
|
||||
if not sample_table:
|
||||
pytest.skip(
|
||||
"No tables found in Nextcloud Tables app. Please ensure Tables app is installed and has at least one table."
|
||||
)
|
||||
|
||||
# Get the schema for the sample table
|
||||
table_id = sample_table["id"]
|
||||
schema = await nc_client.tables.get_table_schema(table_id)
|
||||
|
||||
logger.info(f"Using sample table: {sample_table.get('title')} (ID: {table_id})")
|
||||
|
||||
return {
|
||||
"table": sample_table,
|
||||
"schema": schema,
|
||||
"table_id": table_id,
|
||||
"columns": schema.get("columns", []),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_table_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Fixture to create a temporary row in the sample table for testing.
|
||||
Yields the created row data and cleans up afterward.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# Create test data based on the table schema
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
# Generate test data based on column type
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 42
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-01-01T12:00:00Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Test Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
test_data[column_id] = f"Test {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Creating temporary row in table {table_id} with data: {test_data}")
|
||||
|
||||
created_row = None
|
||||
try:
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
row_id = created_row.get("id")
|
||||
|
||||
if not row_id:
|
||||
pytest.fail("Failed to get ID from created temporary row.")
|
||||
|
||||
logger.info(f"Temporary row created with ID: {row_id}")
|
||||
yield created_row
|
||||
|
||||
finally:
|
||||
if created_row and created_row.get("id"):
|
||||
row_id = created_row["id"]
|
||||
logger.info(f"Cleaning up temporary row ID: {row_id}")
|
||||
try:
|
||||
await nc_client.tables.delete_row(row_id)
|
||||
logger.info(f"Successfully deleted temporary row ID: {row_id}")
|
||||
except HTTPStatusError as e:
|
||||
# Ignore 404 if row was already deleted by the test itself
|
||||
if e.response.status_code != 404:
|
||||
logger.error(f"HTTP error deleting temporary row {row_id}: {e}")
|
||||
else:
|
||||
logger.warning(f"Temporary row {row_id} already deleted (404).")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting temporary row {row_id}: {e}")
|
||||
|
||||
|
||||
async def test_tables_list_tables(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test listing all tables available to the user.
|
||||
"""
|
||||
logger.info("Testing list_tables functionality")
|
||||
|
||||
tables = await nc_client.tables.list_tables()
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
tables = await client.list_tables()
|
||||
|
||||
assert isinstance(tables, list)
|
||||
assert len(tables) > 0, "Expected at least one table to be available"
|
||||
assert len(tables) == 2
|
||||
assert tables[0]["id"] == 1
|
||||
assert tables[0]["title"] == "Table 1"
|
||||
|
||||
# Check that each table has required fields
|
||||
for table in tables:
|
||||
assert "id" in table
|
||||
assert "title" in table
|
||||
assert isinstance(table["id"], int)
|
||||
assert isinstance(table["title"], str)
|
||||
|
||||
logger.info(f"Successfully listed {len(tables)} tables")
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_tables_get_schema(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test getting the schema/structure of a specific table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
async def test_tables_get_schema(mocker):
|
||||
"""Test that get_table_schema correctly parses the API response."""
|
||||
mock_response = create_mock_table_schema_response(
|
||||
table_id=123,
|
||||
columns=[
|
||||
{"id": 1, "title": "Name", "type": "text"},
|
||||
{"id": 2, "title": "Age", "type": "number"},
|
||||
{"id": 3, "title": "Email", "type": "text"},
|
||||
],
|
||||
)
|
||||
|
||||
logger.info(f"Testing get_table_schema for table ID: {table_id}")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
schema = await nc_client.tables.get_table_schema(table_id)
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
schema = await client.get_table_schema(table_id=123)
|
||||
|
||||
assert isinstance(schema, dict)
|
||||
assert "columns" in schema
|
||||
assert isinstance(schema["columns"], list)
|
||||
assert len(schema["columns"]) > 0, "Expected at least one column in the table"
|
||||
assert len(schema["columns"]) == 3
|
||||
assert schema["columns"][0]["title"] == "Name"
|
||||
|
||||
# Check that each column has required fields
|
||||
for column in schema["columns"]:
|
||||
assert "id" in column
|
||||
assert "title" in column
|
||||
assert "type" in column
|
||||
assert isinstance(column["id"], int)
|
||||
assert isinstance(column["title"], str)
|
||||
assert isinstance(column["type"], str)
|
||||
|
||||
logger.info(f"Successfully retrieved schema with {len(schema['columns'])} columns")
|
||||
mock_make_request.assert_called_once()
|
||||
assert "/tables/123/scheme" in mock_make_request.call_args[0][1]
|
||||
|
||||
|
||||
async def test_tables_read_table(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test reading rows from a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
async def test_tables_get_rows(mocker):
|
||||
"""Test that get_table_rows correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{
|
||||
"id": 1,
|
||||
"tableId": 123,
|
||||
"data": [
|
||||
{"columnId": 1, "value": "John"},
|
||||
{"columnId": 2, "value": 30},
|
||||
],
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"tableId": 123,
|
||||
"data": [
|
||||
{"columnId": 1, "value": "Jane"},
|
||||
{"columnId": 2, "value": 25},
|
||||
],
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
logger.info(f"Testing get_table_rows for table ID: {table_id}")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Test without pagination
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
rows = await client.get_table_rows(table_id=123)
|
||||
|
||||
assert isinstance(rows, list)
|
||||
# Note: The table might be empty, so we don't assert len > 0
|
||||
assert len(rows) == 2
|
||||
assert rows[0]["id"] == 1
|
||||
assert rows[0]["tableId"] == 123
|
||||
|
||||
# Test with pagination
|
||||
rows_limited = await nc_client.tables.get_table_rows(table_id, limit=5, offset=0)
|
||||
|
||||
assert isinstance(rows_limited, list)
|
||||
assert len(rows_limited) <= 5
|
||||
|
||||
# If there are rows, check their structure
|
||||
if rows:
|
||||
row = rows[0]
|
||||
assert "id" in row
|
||||
assert "tableId" in row
|
||||
assert "data" in row
|
||||
assert isinstance(row["id"], int)
|
||||
assert isinstance(row["tableId"], int)
|
||||
assert isinstance(row["data"], list)
|
||||
|
||||
logger.info(f"Successfully read {len(rows)} rows from table")
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_tables_create_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test creating a new row in a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
async def test_tables_get_rows_with_pagination(mocker):
|
||||
"""Test that get_table_rows correctly handles pagination parameters."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{
|
||||
"id": 1,
|
||||
"tableId": 123,
|
||||
"data": [],
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# Create test data based on the table schema
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
rows = await client.get_table_rows(table_id=123, limit=5, offset=10)
|
||||
|
||||
# Generate test data based on column type
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test Create {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 123
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-01-01T12:00:00Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Test Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
test_data[column_id] = f"Test Create {column_title} {unique_suffix}"
|
||||
assert isinstance(rows, list)
|
||||
|
||||
logger.info(f"Testing create_row for table ID: {table_id} with data: {test_data}")
|
||||
|
||||
created_row = None
|
||||
try:
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
|
||||
assert isinstance(created_row, dict)
|
||||
assert "id" in created_row
|
||||
assert "tableId" in created_row
|
||||
assert isinstance(created_row["id"], int)
|
||||
assert created_row["tableId"] == table_id
|
||||
|
||||
# Verify the row was created by reading it back
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
created_row_id = created_row["id"]
|
||||
|
||||
# Find the created row in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == created_row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is not None, (
|
||||
f"Created row with ID {created_row_id} not found in table"
|
||||
)
|
||||
|
||||
logger.info(f"Successfully created row with ID: {created_row_id}")
|
||||
|
||||
finally:
|
||||
# Clean up the created row
|
||||
if created_row and created_row.get("id"):
|
||||
try:
|
||||
await nc_client.tables.delete_row(created_row["id"])
|
||||
logger.info(f"Cleaned up created row ID: {created_row['id']}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up created row: {e}")
|
||||
# Verify pagination parameters were passed
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[1]["params"]["limit"] == 5
|
||||
assert call_args[1]["params"]["offset"] == 10
|
||||
|
||||
|
||||
async def test_tables_update_row(
|
||||
nc_client: NextcloudClient,
|
||||
temporary_table_row: Dict[str, Any],
|
||||
sample_table_info: Dict[str, Any],
|
||||
):
|
||||
"""
|
||||
Test updating an existing row in a table.
|
||||
"""
|
||||
row_id = temporary_table_row["id"]
|
||||
columns = sample_table_info["columns"]
|
||||
async def test_tables_create_row(mocker):
|
||||
"""Test that create_row correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_table_row_ocs_response(
|
||||
row_id=456,
|
||||
table_id=123,
|
||||
data=[
|
||||
{"columnId": 1, "value": "Test Name"},
|
||||
{"columnId": 2, "value": 99},
|
||||
],
|
||||
)
|
||||
|
||||
# Create updated data
|
||||
update_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
test_data = {1: "Test Name", 2: 99}
|
||||
created_row = await client.create_row(table_id=123, data=test_data)
|
||||
|
||||
# Generate updated test data based on column type
|
||||
if column_type == "text":
|
||||
update_data[column_id] = f"Updated {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
update_data[column_id] = 456
|
||||
elif column_type == "datetime":
|
||||
update_data[column_id] = "2024-12-31T23:59:59Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
update_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
update_data[column_id] = "Updated Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
update_data[column_id] = f"Updated {column_title} {unique_suffix}"
|
||||
assert isinstance(created_row, dict)
|
||||
assert created_row["id"] == 456
|
||||
assert created_row["tableId"] == 123
|
||||
|
||||
logger.info(f"Testing update_row for row ID: {row_id} with data: {update_data}")
|
||||
# Verify the data was transformed to string keys
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[1]["json"]["data"]["1"] == "Test Name"
|
||||
assert call_args[1]["json"]["data"]["2"] == 99
|
||||
|
||||
updated_row = await nc_client.tables.update_row(row_id, update_data)
|
||||
|
||||
async def test_tables_update_row(mocker):
|
||||
"""Test that update_row correctly parses the API response."""
|
||||
mock_response = create_mock_table_row_response(
|
||||
row_id=456,
|
||||
table_id=123,
|
||||
data=[
|
||||
{"columnId": 1, "value": "Updated Name"},
|
||||
{"columnId": 2, "value": 100},
|
||||
],
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
update_data = {1: "Updated Name", 2: 100}
|
||||
updated_row = await client.update_row(row_id=456, data=update_data)
|
||||
|
||||
assert isinstance(updated_row, dict)
|
||||
assert "id" in updated_row
|
||||
assert updated_row["id"] == row_id
|
||||
assert updated_row["id"] == 456
|
||||
|
||||
# Verify the row was updated by reading it back
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
table_id = sample_table_info["table_id"]
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
|
||||
# Find the updated row in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is not None, f"Updated row with ID {row_id} not found in table"
|
||||
|
||||
logger.info(f"Successfully updated row with ID: {row_id}")
|
||||
# Verify the PUT request was made
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "PUT"
|
||||
assert "/rows/456" in call_args[0][1]
|
||||
|
||||
|
||||
async def test_tables_delete_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test deleting a row from a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# First create a row to delete
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test Delete {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 789
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-06-15T10:30:00Z"
|
||||
elif column_type == "select":
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Delete Option"
|
||||
else:
|
||||
test_data[column_id] = f"Test Delete {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Creating row for delete test in table ID: {table_id}")
|
||||
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
row_id = created_row["id"]
|
||||
|
||||
logger.info(f"Testing delete_row for row ID: {row_id}")
|
||||
|
||||
# Delete the row
|
||||
delete_result = await nc_client.tables.delete_row(row_id)
|
||||
|
||||
assert isinstance(delete_result, dict)
|
||||
# The delete response might vary, but it should be successful
|
||||
|
||||
# Verify the row was deleted by trying to find it
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
|
||||
# Ensure the deleted row is not in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is None, f"Deleted row with ID {row_id} still found in table"
|
||||
|
||||
logger.info(f"Successfully deleted row with ID: {row_id}")
|
||||
|
||||
|
||||
async def test_tables_delete_nonexistent_row(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that deleting a non-existent row fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(f"Testing delete_row for non-existent row ID: {non_existent_id}")
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.delete_row(non_existent_id)
|
||||
|
||||
# Accept both 404 and 500 as valid error responses for non-existent rows
|
||||
# The API behavior may vary between Nextcloud versions
|
||||
assert excinfo.value.response.status_code in [404, 500]
|
||||
logger.info(
|
||||
f"Deleting non-existent row ID: {non_existent_id} correctly failed with {excinfo.value.response.status_code}."
|
||||
async def test_tables_delete_row(mocker):
|
||||
"""Test that delete_row correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200, json_data={"message": "Row deleted"}
|
||||
)
|
||||
|
||||
|
||||
async def test_tables_transform_row_data(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test the transform_row_data utility method.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
logger.info(f"Testing transform_row_data for table ID: {table_id}")
|
||||
|
||||
# Get some rows to transform
|
||||
rows = await nc_client.tables.get_table_rows(table_id, limit=5)
|
||||
|
||||
if not rows:
|
||||
logger.info("No rows to transform, skipping transform_row_data test")
|
||||
return
|
||||
|
||||
# Transform the rows
|
||||
transformed_rows = nc_client.tables.transform_row_data(rows, columns)
|
||||
|
||||
assert isinstance(transformed_rows, list)
|
||||
assert len(transformed_rows) == len(rows)
|
||||
|
||||
# Check the structure of transformed rows
|
||||
for i, transformed_row in enumerate(transformed_rows):
|
||||
original_row = rows[i]
|
||||
|
||||
assert "id" in transformed_row
|
||||
assert "tableId" in transformed_row
|
||||
assert "data" in transformed_row
|
||||
assert transformed_row["id"] == original_row["id"]
|
||||
assert transformed_row["tableId"] == original_row["tableId"]
|
||||
assert isinstance(transformed_row["data"], dict)
|
||||
|
||||
# Check that column IDs were transformed to column names
|
||||
for column in columns:
|
||||
column_title = column["title"]
|
||||
# The transformed data should have column names as keys
|
||||
# (though the column might not have data in this row)
|
||||
if any(item["columnId"] == column["id"] for item in original_row["data"]):
|
||||
assert column_title in transformed_row["data"]
|
||||
|
||||
logger.info(f"Successfully transformed {len(transformed_rows)} rows")
|
||||
|
||||
|
||||
async def test_tables_get_nonexistent_table_schema(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that getting schema for a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(
|
||||
f"Testing get_table_schema for non-existent table ID: {non_existent_id}"
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.get_table_schema(non_existent_id)
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
result = await client.delete_row(row_id=456)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
|
||||
# Verify the DELETE request was made
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "DELETE"
|
||||
assert "/rows/456" in call_args[0][1]
|
||||
|
||||
|
||||
async def test_tables_delete_nonexistent_row(mocker):
|
||||
"""Test that deleting a non-existent row raises HTTPStatusError."""
|
||||
error_response = create_mock_error_response(404, "Row not found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(TablesClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("DELETE", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.delete_row(row_id=999999999)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Getting schema for non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
|
||||
|
||||
async def test_tables_get_nonexistent_schema(mocker):
|
||||
"""Test that getting schema for non-existent table raises HTTPStatusError."""
|
||||
error_response = create_mock_error_response(404, "Table not found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(TablesClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("GET", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
|
||||
async def test_tables_read_nonexistent_table(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that reading from a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(f"Testing get_table_rows for non-existent table ID: {non_existent_id}")
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.get_table_rows(non_existent_id)
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.get_table_schema(table_id=999999999)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Reading from non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
|
||||
|
||||
async def test_tables_create_row_invalid_table(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that creating a row in a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
test_data = {1: "test value"}
|
||||
def test_tables_transform_row_data():
|
||||
"""Test the transform_row_data utility method (synchronous)."""
|
||||
# This is a pure function, no mocking needed
|
||||
client = TablesClient(None, "testuser") # Client not used for this method
|
||||
|
||||
logger.info(f"Testing create_row for non-existent table ID: {non_existent_id}")
|
||||
raw_rows = [
|
||||
{
|
||||
"id": 1,
|
||||
"tableId": 123,
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
"data": [
|
||||
{"columnId": 1, "value": "John Doe"},
|
||||
{"columnId": 2, "value": 30},
|
||||
{"columnId": 3, "value": "john@example.com"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"tableId": 123,
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
"data": [
|
||||
{"columnId": 1, "value": "Jane Smith"},
|
||||
{"columnId": 2, "value": 25},
|
||||
{"columnId": 3, "value": "jane@example.com"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.create_row(non_existent_id, test_data)
|
||||
columns = [
|
||||
{"id": 1, "title": "Name", "type": "text"},
|
||||
{"id": 2, "title": "Age", "type": "number"},
|
||||
{"id": 3, "title": "Email", "type": "text"},
|
||||
]
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Creating row in non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
transformed = client.transform_row_data(raw_rows, columns)
|
||||
|
||||
assert len(transformed) == 2
|
||||
assert transformed[0]["id"] == 1
|
||||
assert transformed[0]["data"]["Name"] == "John Doe"
|
||||
assert transformed[0]["data"]["Age"] == 30
|
||||
assert transformed[0]["data"]["Email"] == "john@example.com"
|
||||
|
||||
assert transformed[1]["data"]["Name"] == "Jane Smith"
|
||||
assert transformed[1]["data"]["Age"] == 25
|
||||
|
||||
@@ -9,7 +9,6 @@ logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_and_delete_share(nc_client):
|
||||
"""Test creating and deleting a file share."""
|
||||
# Create a test user to share with
|
||||
@@ -68,7 +67,6 @@ async def test_create_and_delete_share(nc_client):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_share_permissions(nc_client):
|
||||
"""Test updating share permissions."""
|
||||
# Create a test user to share with
|
||||
@@ -120,7 +118,6 @@ async def test_update_share_permissions(nc_client):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_shares(nc_client):
|
||||
"""Test listing all shares."""
|
||||
# Create a test user to share with
|
||||
|
||||
+769
-165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
Integration test for RFC 8693 Token Exchange - Legacy V1 (Impersonation/Tier 1).
|
||||
|
||||
Tests the advanced impersonation feature where the service account token is
|
||||
exchanged for a token with the target user's identity (sub claim changes).
|
||||
|
||||
This requires:
|
||||
1. Keycloak with --features=preview enabled
|
||||
2. Impersonation role granted to the service account
|
||||
|
||||
⚠️ This test will SKIP if impersonation permissions are not configured.
|
||||
|
||||
Configuration (one-time setup):
|
||||
# Grant impersonation role
|
||||
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
|
||||
|
||||
Usage:
|
||||
pytest tests/integration/auth/test_token_exchange_legacy_v1.py -v
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.anyio, pytest.mark.keycloak]
|
||||
|
||||
|
||||
def decode_jwt(token: str) -> dict:
|
||||
"""Decode JWT token payload without verification."""
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return {"error": "Invalid JWT format"}
|
||||
|
||||
payload = parts[1]
|
||||
padding = 4 - (len(payload) % 4)
|
||||
if padding != 4:
|
||||
payload += "=" * padding
|
||||
|
||||
decoded = base64.urlsafe_b64decode(payload)
|
||||
return json.loads(decoded)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def keycloak_config():
|
||||
"""Keycloak configuration for testing."""
|
||||
return {
|
||||
"url": os.getenv("KEYCLOAK_URL", "http://localhost:8888"),
|
||||
"realm": os.getenv("KEYCLOAK_REALM", "nextcloud-mcp"),
|
||||
"client_id": os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server"),
|
||||
"client_secret": os.getenv(
|
||||
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
|
||||
),
|
||||
"token_endpoint": f"{os.getenv('KEYCLOAK_URL', 'http://localhost:8888')}/realms/{os.getenv('KEYCLOAK_REALM', 'nextcloud-mcp')}/protocol/openid-connect/token",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def service_account_token(keycloak_config):
|
||||
"""Get a service account token using client_credentials grant."""
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
keycloak_config["token_endpoint"],
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": keycloak_config["client_id"],
|
||||
"client_secret": keycloak_config["client_secret"],
|
||||
"scope": "openid profile email",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
return token_data["access_token"]
|
||||
|
||||
|
||||
async def test_token_exchange_impersonation_requires_permissions(
|
||||
keycloak_config, service_account_token
|
||||
):
|
||||
"""Test that impersonation requires explicit permission grant.
|
||||
|
||||
This test documents that Legacy V1 impersonation is opt-in and requires
|
||||
administrative configuration via Keycloak CLI.
|
||||
"""
|
||||
|
||||
target_user = "admin" # User to impersonate
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
exchange_response = await client.post(
|
||||
keycloak_config["token_endpoint"],
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
"client_id": keycloak_config["client_id"],
|
||||
"client_secret": keycloak_config["client_secret"],
|
||||
"subject_token": service_account_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, # ← KEY: Request impersonation
|
||||
},
|
||||
)
|
||||
|
||||
# If permissions not granted, we expect 403 Forbidden
|
||||
if exchange_response.status_code == 403:
|
||||
pytest.skip(
|
||||
"Impersonation permissions not configured. "
|
||||
"Run tests/manual/configure_impersonation.py or grant manually via Keycloak CLI. "
|
||||
"See test docstring for configuration commands."
|
||||
)
|
||||
|
||||
# If permissions are granted, exchange should succeed
|
||||
assert exchange_response.status_code == 200, (
|
||||
f"Token exchange failed: {exchange_response.status_code} {exchange_response.text}"
|
||||
)
|
||||
|
||||
|
||||
async def test_token_exchange_impersonation_changes_subject(
|
||||
keycloak_config, service_account_token
|
||||
):
|
||||
"""Test Legacy V1 impersonation - subject claim should change."""
|
||||
|
||||
target_user = "admin"
|
||||
|
||||
# Decode service account token
|
||||
service_claims = decode_jwt(service_account_token)
|
||||
assert "error" not in service_claims
|
||||
service_sub = service_claims["sub"]
|
||||
assert "service-account" in service_sub.lower()
|
||||
|
||||
# Exchange token WITH requested_subject (Legacy V1 impersonation)
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
exchange_response = await client.post(
|
||||
keycloak_config["token_endpoint"],
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
"client_id": keycloak_config["client_id"],
|
||||
"client_secret": keycloak_config["client_secret"],
|
||||
"subject_token": service_account_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, # ← KEY: Impersonate admin
|
||||
},
|
||||
)
|
||||
|
||||
# Skip if permissions not configured
|
||||
if exchange_response.status_code == 403:
|
||||
pytest.skip(
|
||||
"Impersonation permissions not configured. "
|
||||
"See test docstring for setup instructions."
|
||||
)
|
||||
|
||||
# Token exchange should succeed with permissions
|
||||
assert exchange_response.status_code == 200, (
|
||||
f"Token exchange failed: {exchange_response.status_code} {exchange_response.text}"
|
||||
)
|
||||
|
||||
exchanged_data = exchange_response.json()
|
||||
assert "access_token" in exchanged_data
|
||||
exchanged_token = exchanged_data["access_token"]
|
||||
|
||||
# Decode exchanged token
|
||||
exchanged_claims = decode_jwt(exchanged_token)
|
||||
assert "error" not in exchanged_claims
|
||||
exchanged_sub = exchanged_claims["sub"]
|
||||
|
||||
# CRITICAL: Verify impersonation - sub claim MUST change
|
||||
assert service_sub != exchanged_sub, (
|
||||
f"Impersonation should change subject claim. "
|
||||
f"Original: {service_sub}, Exchanged: {exchanged_sub}"
|
||||
)
|
||||
|
||||
# Verify the new token represents the target user
|
||||
assert "preferred_username" in exchanged_claims
|
||||
assert exchanged_claims["preferred_username"] == target_user
|
||||
|
||||
|
||||
async def test_impersonated_token_with_nextcloud(
|
||||
keycloak_config, service_account_token
|
||||
):
|
||||
"""Test that impersonated token works with Nextcloud APIs."""
|
||||
|
||||
target_user = "admin"
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||
|
||||
# Exchange token with impersonation
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
exchange_response = await client.post(
|
||||
keycloak_config["token_endpoint"],
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
"client_id": keycloak_config["client_id"],
|
||||
"client_secret": keycloak_config["client_secret"],
|
||||
"subject_token": service_account_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,
|
||||
},
|
||||
)
|
||||
|
||||
# Skip if permissions not configured
|
||||
if exchange_response.status_code == 403:
|
||||
pytest.skip("Impersonation permissions not configured.")
|
||||
|
||||
exchange_response.raise_for_status()
|
||||
exchanged_token = exchange_response.json()["access_token"]
|
||||
|
||||
# Test with Nextcloud API
|
||||
nc_response = await client.get(
|
||||
f"{nextcloud_host}/ocs/v2.php/cloud/capabilities",
|
||||
headers={"Authorization": f"Bearer {exchanged_token}"},
|
||||
)
|
||||
|
||||
# Should get valid response from Nextcloud
|
||||
assert nc_response.status_code in [
|
||||
200,
|
||||
401,
|
||||
], f"Unexpected status: {nc_response.status_code}"
|
||||
|
||||
if nc_response.status_code == 200:
|
||||
# Token was accepted - verify we got a valid response
|
||||
# Nextcloud OCS API can return XML or JSON
|
||||
assert len(nc_response.content) > 0, "Response should not be empty"
|
||||
content_type = nc_response.headers.get("content-type", "")
|
||||
assert any(t in content_type for t in ["json", "xml"]), (
|
||||
f"Unexpected content type: {content_type}"
|
||||
)
|
||||
|
||||
|
||||
async def test_standard_v2_rejects_requested_subject():
|
||||
"""Verify that Standard V2 (without preview features) rejects requested_subject.
|
||||
|
||||
This test documents the key difference between Standard V2 and Legacy V1.
|
||||
|
||||
NOTE: This test will PASS if preview features are enabled, as Keycloak
|
||||
accepts the parameter in Legacy V1 mode. The test exists to document the
|
||||
expected behavior when preview features are DISABLED.
|
||||
"""
|
||||
|
||||
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
|
||||
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
|
||||
client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server")
|
||||
client_secret = os.getenv(
|
||||
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
|
||||
)
|
||||
token_endpoint = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# Get service account token
|
||||
token_response = await client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"scope": "openid profile email",
|
||||
},
|
||||
)
|
||||
token_response.raise_for_status()
|
||||
service_token = token_response.json()["access_token"]
|
||||
|
||||
# Try token exchange with requested_subject
|
||||
exchange_response = await client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"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",
|
||||
"requested_subject": "admin", # Try to impersonate
|
||||
},
|
||||
)
|
||||
|
||||
# Standard V2: expects 400 Bad Request with "not supported" message
|
||||
# Legacy V1: accepts parameter, returns 200 or 403 (depending on permissions)
|
||||
|
||||
if exchange_response.status_code == 400:
|
||||
# Standard V2 behavior
|
||||
error_data = exchange_response.json()
|
||||
assert (
|
||||
"requested_subject" in error_data.get("error_description", "").lower()
|
||||
)
|
||||
# Test passes - Standard V2 correctly rejects the parameter
|
||||
elif exchange_response.status_code in [200, 403]:
|
||||
# Legacy V1 behavior - parameter is accepted
|
||||
pytest.skip(
|
||||
"Preview features enabled - Keycloak is in Legacy V1 mode. "
|
||||
"This test documents Standard V2 behavior which rejects requested_subject."
|
||||
)
|
||||
else:
|
||||
pytest.fail(
|
||||
f"Unexpected status code: {exchange_response.status_code}. "
|
||||
f"Expected 400 (Standard V2) or 200/403 (Legacy V1)"
|
||||
)
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Integration test for RFC 8693 Token Exchange - Standard V2 (Delegation/Tier 2).
|
||||
|
||||
Tests the production-ready token exchange without impersonation.
|
||||
The service account exchanges its token for a user-scoped token while
|
||||
maintaining its own identity (sub claim unchanged).
|
||||
|
||||
This is the RECOMMENDED approach for most use cases.
|
||||
|
||||
Requirements:
|
||||
- Keycloak container running (can be Standard V2 or Legacy V1)
|
||||
- MCP Keycloak service running on port 8002
|
||||
|
||||
Usage:
|
||||
pytest tests/integration/auth/test_token_exchange_standard_v2.py -v
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.anyio, pytest.mark.keycloak]
|
||||
|
||||
|
||||
def decode_jwt(token: str) -> dict:
|
||||
"""Decode JWT token payload without verification."""
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return {"error": "Invalid JWT format"}
|
||||
|
||||
payload = parts[1]
|
||||
padding = 4 - (len(payload) % 4)
|
||||
if padding != 4:
|
||||
payload += "=" * padding
|
||||
|
||||
decoded = base64.urlsafe_b64decode(payload)
|
||||
return json.loads(decoded)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def keycloak_config():
|
||||
"""Keycloak configuration for testing."""
|
||||
return {
|
||||
"url": os.getenv("KEYCLOAK_URL", "http://localhost:8888"),
|
||||
"realm": os.getenv("KEYCLOAK_REALM", "nextcloud-mcp"),
|
||||
"client_id": os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server"),
|
||||
"client_secret": os.getenv(
|
||||
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
|
||||
),
|
||||
"token_endpoint": f"{os.getenv('KEYCLOAK_URL', 'http://localhost:8888')}/realms/{os.getenv('KEYCLOAK_REALM', 'nextcloud-mcp')}/protocol/openid-connect/token",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def service_account_token(keycloak_config):
|
||||
"""Get a service account token using client_credentials grant."""
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
keycloak_config["token_endpoint"],
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": keycloak_config["client_id"],
|
||||
"client_secret": keycloak_config["client_secret"],
|
||||
"scope": "openid profile email",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
return token_data["access_token"]
|
||||
|
||||
|
||||
async def test_token_exchange_delegation(keycloak_config, service_account_token):
|
||||
"""Test Standard V2 token exchange with delegation (no impersonation)."""
|
||||
|
||||
# Decode service account token to get original claims
|
||||
service_claims = decode_jwt(service_account_token)
|
||||
assert "error" not in service_claims, "Failed to decode service account token"
|
||||
assert "sub" in service_claims
|
||||
service_sub = service_claims["sub"]
|
||||
|
||||
# Exchange token WITHOUT requested_subject (Standard V2 delegation)
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
exchange_response = await client.post(
|
||||
keycloak_config["token_endpoint"],
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
"client_id": keycloak_config["client_id"],
|
||||
"client_secret": keycloak_config["client_secret"],
|
||||
"subject_token": service_account_token,
|
||||
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||
# NOTE: NO requested_subject parameter - this is delegation, not impersonation
|
||||
},
|
||||
)
|
||||
|
||||
# Token exchange should succeed
|
||||
assert exchange_response.status_code == 200, (
|
||||
f"Token exchange failed: {exchange_response.status_code} {exchange_response.text}"
|
||||
)
|
||||
|
||||
exchanged_data = exchange_response.json()
|
||||
assert "access_token" in exchanged_data
|
||||
assert "token_type" in exchanged_data
|
||||
assert exchanged_data["token_type"].lower() == "bearer"
|
||||
|
||||
exchanged_token = exchanged_data["access_token"]
|
||||
|
||||
# Decode exchanged token
|
||||
exchanged_claims = decode_jwt(exchanged_token)
|
||||
assert "error" not in exchanged_claims, "Failed to decode exchanged token"
|
||||
assert "sub" in exchanged_claims
|
||||
exchanged_sub = exchanged_claims["sub"]
|
||||
|
||||
# CRITICAL: Verify delegation behavior - sub claim should NOT change
|
||||
assert service_sub == exchanged_sub, (
|
||||
f"Subject should remain unchanged in delegation (service account identity preserved). Original: {service_sub}, Exchanged: {exchanged_sub}"
|
||||
)
|
||||
|
||||
# The exchanged token should still identify as the service account
|
||||
assert "service-account" in exchanged_sub.lower(), (
|
||||
"Exchanged token should maintain service account identity"
|
||||
)
|
||||
|
||||
|
||||
async def test_exchanged_token_with_nextcloud(keycloak_config, service_account_token):
|
||||
"""Test that exchanged token works with Nextcloud APIs."""
|
||||
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||
|
||||
# Exchange the service account token
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
exchange_response = await client.post(
|
||||
keycloak_config["token_endpoint"],
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
"client_id": keycloak_config["client_id"],
|
||||
"client_secret": keycloak_config["client_secret"],
|
||||
"subject_token": service_account_token,
|
||||
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||
},
|
||||
)
|
||||
exchange_response.raise_for_status()
|
||||
exchanged_token = exchange_response.json()["access_token"]
|
||||
|
||||
# Test the exchanged token with Nextcloud API
|
||||
nc_response = await client.get(
|
||||
f"{nextcloud_host}/ocs/v2.php/cloud/capabilities",
|
||||
headers={"Authorization": f"Bearer {exchanged_token}"},
|
||||
)
|
||||
|
||||
# Should get a valid response from Nextcloud
|
||||
# Note: This might fail with 401 if user_oidc doesn't accept the token
|
||||
# That's expected - this test verifies the token exchange itself works
|
||||
assert nc_response.status_code in [
|
||||
200,
|
||||
401,
|
||||
], f"Unexpected status: {nc_response.status_code}"
|
||||
|
||||
if nc_response.status_code == 200:
|
||||
# Token was accepted - verify we got a valid response
|
||||
# Nextcloud OCS API can return XML or JSON
|
||||
assert len(nc_response.content) > 0, "Response should not be empty"
|
||||
# Verify we got either JSON or XML capabilities response
|
||||
content_type = nc_response.headers.get("content-type", "")
|
||||
assert any(t in content_type for t in ["json", "xml"]), (
|
||||
f"Unexpected content type: {content_type}"
|
||||
)
|
||||
|
||||
|
||||
async def test_token_exchange_without_permissions_should_work():
|
||||
"""Verify Standard V2 doesn't require special permissions (unlike Legacy V1 impersonation)."""
|
||||
|
||||
# This test documents that Standard V2 token exchange works out-of-the-box
|
||||
# without needing to grant impersonation roles via Keycloak CLI
|
||||
|
||||
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
|
||||
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
|
||||
client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server")
|
||||
client_secret = os.getenv(
|
||||
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
|
||||
)
|
||||
token_endpoint = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# Get service account token
|
||||
token_response = await client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"scope": "openid profile email",
|
||||
},
|
||||
)
|
||||
token_response.raise_for_status()
|
||||
service_token = token_response.json()["access_token"]
|
||||
|
||||
# Exchange token - should work without any special role grants
|
||||
exchange_response = await client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"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",
|
||||
},
|
||||
)
|
||||
|
||||
# Should succeed without 403 Forbidden (no permission requirements)
|
||||
assert exchange_response.status_code == 200, (
|
||||
f"Standard V2 delegation should work without special permissions. "
|
||||
f"Got: {exchange_response.status_code} {exchange_response.text}"
|
||||
)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Integration tests for document processing with progress notifications."""
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
class TestDocumentProcessingProgress:
|
||||
"""Test document processing with progress notifications."""
|
||||
|
||||
async def test_unstructured_processor_with_progress_callback(self, nc_client):
|
||||
"""Test that UnstructuredProcessor calls progress callback during processing."""
|
||||
import os
|
||||
|
||||
# Skip if unstructured is not enabled
|
||||
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() != "true":
|
||||
pytest.skip("Unstructured processor not enabled")
|
||||
|
||||
from nextcloud_mcp_server.document_processors.unstructured import (
|
||||
UnstructuredProcessor,
|
||||
)
|
||||
|
||||
# Track progress callback invocations
|
||||
progress_updates = []
|
||||
|
||||
async def track_progress(progress: float, total: float | None, message: str):
|
||||
progress_updates.append(
|
||||
{"progress": progress, "total": total, "message": message}
|
||||
)
|
||||
|
||||
# Create processor configured to use local unstructured service
|
||||
processor = UnstructuredProcessor(
|
||||
api_url=os.getenv("UNSTRUCTURED_API_URL", "http://unstructured:8000"),
|
||||
timeout=120,
|
||||
progress_interval=2, # 2 second intervals for testing
|
||||
)
|
||||
|
||||
# Create a simple test image (which requires OCR processing)
|
||||
# This should take long enough to trigger at least one progress update
|
||||
img = Image.new("RGB", (400, 200), color=(73, 109, 137))
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
test_image = buffer.getvalue()
|
||||
|
||||
# Process with progress callback
|
||||
result = await processor.process(
|
||||
content=test_image,
|
||||
content_type="image/png",
|
||||
filename="test.png",
|
||||
progress_callback=track_progress,
|
||||
)
|
||||
|
||||
# Verify processing succeeded
|
||||
assert result.success is True
|
||||
assert result.processor == "unstructured"
|
||||
assert isinstance(result.text, str)
|
||||
|
||||
# Note: Progress updates may or may not occur depending on processing speed
|
||||
# If updates occurred, verify their structure
|
||||
if progress_updates:
|
||||
for update in progress_updates:
|
||||
assert isinstance(update["progress"], float)
|
||||
assert update["total"] is None # Unknown total
|
||||
assert "Processing document with unstructured" in update["message"]
|
||||
assert "elapsed" in update["message"]
|
||||
|
||||
async def test_webdav_read_file_sends_progress_notifications(
|
||||
self, nc_mcp_client, nc_client
|
||||
):
|
||||
"""Test that reading a document via WebDAV MCP tool sends progress notifications."""
|
||||
import os
|
||||
|
||||
# Skip if document processing is not enabled
|
||||
if os.getenv("ENABLE_DOCUMENT_PROCESSING", "false").lower() != "true":
|
||||
pytest.skip("Document processing not enabled")
|
||||
|
||||
# Create a test image file in Nextcloud via WebDAV
|
||||
from PIL import Image
|
||||
|
||||
img = Image.new("RGB", (400, 200), color=(100, 150, 200))
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
test_image = buffer.getvalue()
|
||||
|
||||
# Upload test file
|
||||
test_path = "test_progress.png"
|
||||
await nc_client.webdav.write_file(test_path, test_image, "image/png")
|
||||
|
||||
try:
|
||||
# Read file via MCP tool (which should trigger document processing)
|
||||
# The MCP client will automatically track progress notifications
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_read_file", arguments={"path": test_path}
|
||||
)
|
||||
|
||||
# Note: FastMCP progress notifications are sent automatically by ctx.report_progress
|
||||
# We can't easily capture them in this test without mocking the MCP transport layer
|
||||
# The important thing is that the code path is exercised without errors
|
||||
assert result.isError is False
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_path)
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
async def test_progress_callback_not_required(self, nc_client):
|
||||
"""Test that processing works without progress callback (backward compatibility)."""
|
||||
import os
|
||||
|
||||
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() != "true":
|
||||
pytest.skip("Unstructured processor not enabled")
|
||||
|
||||
from nextcloud_mcp_server.document_processors.unstructured import (
|
||||
UnstructuredProcessor,
|
||||
)
|
||||
|
||||
processor = UnstructuredProcessor(
|
||||
api_url=os.getenv("UNSTRUCTURED_API_URL", "http://unstructured:8000"),
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
# Create simple test image
|
||||
img = Image.new("RGB", (200, 100), color=(50, 100, 150))
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
test_image = buffer.getvalue()
|
||||
|
||||
# Process WITHOUT progress callback
|
||||
result = await processor.process(
|
||||
content=test_image,
|
||||
content_type="image/png",
|
||||
filename="test.png",
|
||||
progress_callback=None, # Explicitly None
|
||||
)
|
||||
|
||||
# Should still work
|
||||
assert result.success is True
|
||||
assert result.processor == "unstructured"
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Integration tests for Unstructured API functionality."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
from mcp.client.session import ClientSession
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_base_path(nc_client: NextcloudClient):
|
||||
"""Base path for test files/directories."""
|
||||
test_dir = f"mcp_test_unstructured_{uuid.uuid4().hex[:8]}"
|
||||
await nc_client.webdav.create_directory(test_dir)
|
||||
yield test_dir
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
|
||||
def create_test_pdf(text: str) -> bytes:
|
||||
"""Create a simple PDF document with the given text."""
|
||||
buffer = BytesIO()
|
||||
c = canvas.Canvas(buffer, pagesize=letter)
|
||||
c.drawString(100, 750, text)
|
||||
c.save()
|
||||
buffer.seek(0)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
condition=os.getenv("ENABLE_UNSTRUCTURED", "false") != "true",
|
||||
reason="Unstructured is not enabled",
|
||||
)
|
||||
async def test_unstructured_api_enabled_parsing(
|
||||
nc_client: NextcloudClient, test_base_path: str, nc_mcp_client: ClientSession
|
||||
):
|
||||
"""Test that documents are parsed using the Unstructured API when enabled."""
|
||||
test_file = f"{test_base_path}/test_unstructured_pdf.pdf"
|
||||
test_text = "This is a test PDF document for Unstructured API parsing"
|
||||
|
||||
try:
|
||||
# Create a simple PDF
|
||||
pdf_content = create_test_pdf(test_text)
|
||||
|
||||
# Upload the PDF
|
||||
await nc_client.webdav.write_file(
|
||||
test_file, pdf_content, content_type="application/pdf"
|
||||
)
|
||||
logger.info(f"Uploaded PDF file: {test_file}")
|
||||
|
||||
# Read the PDF using MCP tool (should parse via Unstructured API)
|
||||
mcp_result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_read_file", arguments={"path": test_file}
|
||||
)
|
||||
|
||||
# Extract content from the MCP result
|
||||
if hasattr(mcp_result.content[0], "text"):
|
||||
result_text = mcp_result.content[0].text
|
||||
else:
|
||||
# Fallback for other content types
|
||||
result_text = str(mcp_result.content[0])
|
||||
|
||||
# Parse the JSON response
|
||||
result = json.loads(result_text)
|
||||
|
||||
# Verify the result structure
|
||||
assert "path" in result
|
||||
assert "content" in result
|
||||
assert "content_type" in result
|
||||
assert "parsed" in result # Should be present when parsing succeeds
|
||||
|
||||
# The content should be readable text, not base64
|
||||
content = result["content"]
|
||||
assert isinstance(content, str)
|
||||
assert len(content) > 0
|
||||
assert "test" in content.lower() # Should contain our test text
|
||||
|
||||
# Should have parsing metadata
|
||||
assert "parsing_metadata" in result
|
||||
parsing_metadata = result["parsing_metadata"]
|
||||
assert parsing_metadata["parsing_method"] == "unstructured_api"
|
||||
|
||||
logger.info("Successfully parsed PDF using Unstructured API")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_file)
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
condition=os.getenv("ENABLE_UNSTRUCTURED", "false") != "true",
|
||||
reason="Unstructured is not enabled",
|
||||
)
|
||||
async def test_unstructured_api_with_docx(
|
||||
nc_client: NextcloudClient, test_base_path: str, nc_mcp_client: ClientSession
|
||||
):
|
||||
"""Test Unstructured API with DOCX files."""
|
||||
test_file = f"{test_base_path}/test_unstructured_docx.docx"
|
||||
try:
|
||||
# Create a simple DOCX-like file for testing purposes
|
||||
# Since we're removing python-docx dependency, we'll create a simple file
|
||||
docx_content = (
|
||||
b"This is a mock DOCX file content for testing Unstructured API parsing"
|
||||
)
|
||||
|
||||
# Upload the file
|
||||
await nc_client.webdav.write_file(
|
||||
test_file,
|
||||
docx_content,
|
||||
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
logger.info(f"Uploaded DOCX file: {test_file}")
|
||||
|
||||
# Read the file using MCP tool
|
||||
mcp_result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_read_file", arguments={"path": test_file}
|
||||
)
|
||||
|
||||
# Extract content from the MCP result
|
||||
if hasattr(mcp_result.content[0], "text"):
|
||||
result_text = mcp_result.content[0].text
|
||||
else:
|
||||
# Fallback for other content types
|
||||
result_text = str(mcp_result.content[0])
|
||||
|
||||
# Parse the JSON response
|
||||
result = json.loads(result_text)
|
||||
|
||||
# Verify the result structure
|
||||
assert "path" in result
|
||||
assert "content" in result
|
||||
assert "content_type" in result
|
||||
|
||||
logger.info("Successfully processed DOCX file with Unstructured API")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_file)
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
@@ -27,7 +27,8 @@ import click
|
||||
import httpx
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registration import load_or_register_client
|
||||
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from tests.load.oauth_metrics import OAuthBenchmarkMetrics
|
||||
from tests.load.oauth_pool import (
|
||||
@@ -142,7 +143,7 @@ async def setup_oauth_client(
|
||||
nextcloud_host: str, callback_url: str, registration_endpoint: str
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Setup OAuth client using load_or_register_client.
|
||||
Setup OAuth client using ensure_oauth_client with SQLite storage.
|
||||
|
||||
Args:
|
||||
nextcloud_host: Nextcloud host URL
|
||||
@@ -154,11 +155,15 @@ async def setup_oauth_client(
|
||||
"""
|
||||
logger.info("Setting up OAuth client...")
|
||||
|
||||
# Use the client registration utility
|
||||
client_info = await load_or_register_client(
|
||||
# Initialize SQLite storage
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
# Use the client registration utility with SQLite storage
|
||||
client_info = await ensure_oauth_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
storage_path=".nextcloud_oauth_benchmark_client.json",
|
||||
storage=storage,
|
||||
client_name="OAuth Benchmark Test Client",
|
||||
redirect_uris=[callback_url],
|
||||
)
|
||||
|
||||
@@ -4,11 +4,11 @@ OAuth User Pool Management for Load Testing.
|
||||
Manages multiple OAuth-authenticated users for realistic multi-user load testing scenarios.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
@@ -394,7 +394,7 @@ class OAuthUserPool:
|
||||
raise TimeoutError(
|
||||
f"Timeout waiting for OAuth callback for {username}"
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Received auth code for {username}")
|
||||
|
||||
@@ -5,7 +5,6 @@ Defines coordinated workflows that span multiple users, simulating realistic
|
||||
collaborative scenarios like note sharing, file collaboration, and permission management.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
@@ -15,6 +14,8 @@ from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
import anyio
|
||||
|
||||
from tests.load.oauth_pool import UserSessionWrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -299,7 +300,9 @@ class CollaborativeEditWorkflow(Workflow):
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*read_tasks)
|
||||
async with anyio.create_task_group() as tg:
|
||||
for task in read_tasks:
|
||||
tg.start_soon(task)
|
||||
|
||||
# Step 3: Append content concurrently by all collaborators
|
||||
append_tasks = []
|
||||
@@ -318,7 +321,9 @@ class CollaborativeEditWorkflow(Workflow):
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*append_tasks)
|
||||
async with anyio.create_task_group() as tg:
|
||||
for task in append_tasks:
|
||||
tg.start_soon(task)
|
||||
|
||||
# Step 4: Owner verifies final state
|
||||
await self._execute_step(
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Configure Keycloak client for token exchange with impersonation.
|
||||
|
||||
This script uses Keycloak Admin API to configure the necessary permissions
|
||||
for the nextcloud-mcp-server client to impersonate users via token exchange.
|
||||
|
||||
Usage:
|
||||
uv run python tests/manual/configure_impersonation.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s | %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Configure impersonation permissions in Keycloak"""
|
||||
|
||||
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
|
||||
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
|
||||
admin_username = "admin"
|
||||
admin_password = "admin"
|
||||
client_id = "nextcloud-mcp-server"
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("Configuring Keycloak Impersonation Permissions")
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"Keycloak URL: {keycloak_url}")
|
||||
logger.info(f"Realm: {realm}")
|
||||
logger.info(f"Client ID: {client_id}")
|
||||
logger.info("")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# Step 1: Get admin access token
|
||||
logger.info("Step 1: Getting admin access token...")
|
||||
token_response = await client.post(
|
||||
f"{keycloak_url}/realms/master/protocol/openid-connect/token",
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"client_id": "admin-cli",
|
||||
"username": admin_username,
|
||||
"password": admin_password,
|
||||
},
|
||||
)
|
||||
token_response.raise_for_status()
|
||||
admin_token = token_response.json()["access_token"]
|
||||
logger.info("✓ Admin token acquired")
|
||||
logger.info("")
|
||||
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
# Step 2: Get client internal ID
|
||||
logger.info("Step 2: Looking up client internal ID...")
|
||||
clients_response = await client.get(
|
||||
f"{keycloak_url}/admin/realms/{realm}/clients",
|
||||
headers=headers,
|
||||
params={"clientId": client_id},
|
||||
)
|
||||
clients_response.raise_for_status()
|
||||
clients = clients_response.json()
|
||||
|
||||
if not clients:
|
||||
logger.error(f"❌ Client '{client_id}' not found")
|
||||
return 1
|
||||
|
||||
client_uuid = clients[0]["id"]
|
||||
logger.info(f"✓ Found client UUID: {client_uuid}")
|
||||
logger.info("")
|
||||
|
||||
# Step 3: Enable token exchange permission
|
||||
logger.info("Step 3: Configuring token exchange permissions...")
|
||||
|
||||
# Get all clients (we need to allow exchange from/to any client)
|
||||
all_clients_response = await client.get(
|
||||
f"{keycloak_url}/admin/realms/{realm}/clients",
|
||||
headers=headers,
|
||||
)
|
||||
all_clients_response.raise_for_status()
|
||||
all_clients = all_clients_response.json()
|
||||
|
||||
# Get all users (we need to allow impersonation of any user)
|
||||
users_response = await client.get(
|
||||
f"{keycloak_url}/admin/realms/{realm}/users",
|
||||
headers=headers,
|
||||
)
|
||||
users_response.raise_for_status()
|
||||
users = users_response.json()
|
||||
|
||||
logger.info(f" Found {len(all_clients)} clients and {len(users)} users")
|
||||
logger.info("")
|
||||
|
||||
# Step 4: Enable permission for client to perform token exchange
|
||||
logger.info("Step 4: Enabling token exchange permission...")
|
||||
|
||||
# Update client to enable fine-grained permissions
|
||||
update_response = await client.put(
|
||||
f"{keycloak_url}/admin/realms/{realm}/clients/{client_uuid}",
|
||||
headers=headers,
|
||||
json={
|
||||
**clients[0],
|
||||
"authorizationServicesEnabled": False, # Don't need full authz
|
||||
"serviceAccountsEnabled": True, # Already enabled
|
||||
},
|
||||
)
|
||||
|
||||
if update_response.status_code in [200, 204]:
|
||||
logger.info("✓ Client configuration updated")
|
||||
else:
|
||||
logger.warning(f"⚠ Client update returned {update_response.status_code}")
|
||||
|
||||
logger.info("")
|
||||
|
||||
# Step 5: Set up token exchange permission policy
|
||||
logger.info("Step 5: Configuring impersonation policy...")
|
||||
|
||||
# In Keycloak Legacy V1, we need to use the token-exchange permissions endpoint
|
||||
# This is part of the preview features
|
||||
|
||||
# First, check if token exchange permissions endpoint exists
|
||||
try:
|
||||
perms_response = await client.get(
|
||||
f"{keycloak_url}/admin/realms/{realm}/clients/{client_uuid}/token-exchange/permissions",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if perms_response.status_code == 200:
|
||||
logger.info("✓ Token exchange permissions endpoint available")
|
||||
permissions = perms_response.json()
|
||||
logger.info(f" Current permissions: {permissions}")
|
||||
logger.info("")
|
||||
|
||||
# Enable impersonation for all users
|
||||
logger.info("Step 6: Enabling impersonation for admin user...")
|
||||
|
||||
# Find admin user
|
||||
admin_user = next((u for u in users if u["username"] == "admin"), None)
|
||||
|
||||
if admin_user:
|
||||
# Enable permission for this client to impersonate admin
|
||||
enable_response = await client.put(
|
||||
f"{keycloak_url}/admin/realms/{realm}/users/{admin_user['id']}/impersonation",
|
||||
headers=headers,
|
||||
json={
|
||||
"client": client_uuid,
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
if enable_response.status_code in [200, 204]:
|
||||
logger.info("✓ Impersonation enabled for admin user")
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠ Impersonation enable returned {enable_response.status_code}"
|
||||
)
|
||||
logger.info(f" Response: {enable_response.text}")
|
||||
else:
|
||||
logger.error("❌ Admin user not found")
|
||||
|
||||
elif perms_response.status_code == 404:
|
||||
logger.warning("⚠ Token exchange permissions endpoint not found")
|
||||
logger.info(" This might mean preview features aren't fully enabled")
|
||||
logger.info(" Or the Keycloak version doesn't support this API")
|
||||
else:
|
||||
logger.warning(f"⚠ Unexpected response: {perms_response.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error configuring permissions: {e}")
|
||||
logger.info("")
|
||||
logger.info("Alternative: Manual configuration required")
|
||||
logger.info(" 1. Open Keycloak Admin Console")
|
||||
logger.info(" 2. Go to Clients → nextcloud-mcp-server")
|
||||
logger.info(" 3. Go to Permissions tab")
|
||||
logger.info(" 4. Enable 'token-exchange' permission")
|
||||
logger.info(" 5. Configure permission policies for impersonation")
|
||||
|
||||
logger.info("")
|
||||
logger.info("=" * 80)
|
||||
logger.info("Configuration Complete")
|
||||
logger.info("=" * 80)
|
||||
logger.info("")
|
||||
logger.info("Next step: Run impersonation test")
|
||||
logger.info(" uv run python tests/manual/test_impersonation.py")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
Manual test for RFC 8693 Token Exchange with USER IMPERSONATION.
|
||||
|
||||
This script tests whether Keycloak actually supports the requested_subject
|
||||
parameter for user impersonation, as claimed in ADR-002 to be unsupported.
|
||||
|
||||
Test procedure:
|
||||
1. Get service account token (client_credentials grant)
|
||||
2. Attempt to exchange token WITH requested_subject parameter
|
||||
3. Observe actual behavior (success or error)
|
||||
4. Decode resulting token to verify sub claim
|
||||
|
||||
Usage:
|
||||
# Start Keycloak and app containers
|
||||
docker compose up -d keycloak app
|
||||
|
||||
# Run the test
|
||||
uv run python tests/manual/test_impersonation.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
|
||||
|
||||
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(levelname)-8s | %(name)-30s | %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def decode_jwt(token: str) -> dict:
|
||||
"""Decode JWT token payload without verification"""
|
||||
try:
|
||||
# Split token and get payload (second part)
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return {"error": "Invalid JWT format"}
|
||||
|
||||
# Decode payload (add padding if needed)
|
||||
payload = parts[1]
|
||||
padding = 4 - (len(payload) % 4)
|
||||
if padding != 4:
|
||||
payload += "=" * padding
|
||||
|
||||
decoded = base64.urlsafe_b64decode(payload)
|
||||
return json.loads(decoded)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def main():
|
||||
"""Test token exchange with impersonation"""
|
||||
|
||||
# Configuration (matches docker-compose mcp-keycloak service)
|
||||
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
|
||||
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
|
||||
client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server")
|
||||
client_secret = os.getenv(
|
||||
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
|
||||
)
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||
redirect_uri = "http://localhost:8002/oauth/callback"
|
||||
target_user = "admin" # User to impersonate
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("RFC 8693 Token Exchange IMPERSONATION Test")
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"Keycloak URL: {keycloak_url}")
|
||||
logger.info(f"Realm: {realm}")
|
||||
logger.info(f"Client ID: {client_id}")
|
||||
logger.info(f"Target User: {target_user}")
|
||||
logger.info(f"Nextcloud: {nextcloud_host}")
|
||||
logger.info("")
|
||||
logger.info("⚠️ This test attempts impersonation to verify ADR-002 claims")
|
||||
logger.info("")
|
||||
|
||||
# Step 1: Create Keycloak OAuth client
|
||||
logger.info("Step 1: Initializing Keycloak OAuth client...")
|
||||
oauth_client = KeycloakOAuthClient(
|
||||
keycloak_url=keycloak_url,
|
||||
realm=realm,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
|
||||
# Discover endpoints
|
||||
await oauth_client.discover()
|
||||
logger.info(f"✓ Discovered token endpoint: {oauth_client.token_endpoint}")
|
||||
logger.info("")
|
||||
|
||||
# Step 2: Check token exchange support
|
||||
logger.info("Step 2: Checking token exchange support...")
|
||||
supported = await oauth_client.check_token_exchange_support()
|
||||
|
||||
if not supported:
|
||||
logger.error("❌ Token exchange is NOT supported by this Keycloak instance")
|
||||
logger.error(
|
||||
" You may need to enable it with: --features=preview --features=token-exchange"
|
||||
)
|
||||
return 1
|
||||
|
||||
logger.info("✓ Token exchange is supported")
|
||||
logger.info("")
|
||||
|
||||
# Step 3: Get service account token
|
||||
logger.info("Step 3: Requesting service account token (client_credentials)...")
|
||||
try:
|
||||
service_token_response = await oauth_client.get_service_account_token(
|
||||
scopes=["openid", "profile", "email"]
|
||||
)
|
||||
service_token = service_token_response["access_token"]
|
||||
logger.info("✓ Service account token acquired")
|
||||
|
||||
# Decode and show claims
|
||||
service_claims = decode_jwt(service_token)
|
||||
logger.info(f" Subject (sub): {service_claims.get('sub')}")
|
||||
logger.info(f" Preferred username: {service_claims.get('preferred_username')}")
|
||||
logger.info(f" Client ID (azp): {service_claims.get('azp')}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to get service account token: {e}")
|
||||
return 1
|
||||
|
||||
logger.info("")
|
||||
|
||||
# Step 4: Attempt token exchange WITH impersonation
|
||||
logger.info(
|
||||
f"Step 4: Attempting token exchange WITH impersonation (requested_subject={target_user})..."
|
||||
)
|
||||
logger.info(
|
||||
" 🧪 This is the actual test - will Keycloak accept requested_subject?"
|
||||
)
|
||||
logger.info("")
|
||||
|
||||
try:
|
||||
user_token_response = await oauth_client.exchange_token_for_user(
|
||||
subject_token=service_token,
|
||||
target_user_id=target_user, # ← THE KEY TEST: Request impersonation
|
||||
audience=None,
|
||||
scopes=["openid", "profile", "email"],
|
||||
)
|
||||
|
||||
user_token = user_token_response["access_token"]
|
||||
logger.info("✅ Token exchange with impersonation SUCCEEDED!")
|
||||
logger.info("")
|
||||
logger.info("📊 Response details:")
|
||||
logger.info(
|
||||
f" Issued token type: {user_token_response.get('issued_token_type')}"
|
||||
)
|
||||
logger.info(f" Token type: {user_token_response.get('token_type')}")
|
||||
logger.info(f" Expires in: {user_token_response.get('expires_in')}s")
|
||||
logger.info("")
|
||||
|
||||
# Decode and analyze the exchanged token
|
||||
user_claims = decode_jwt(user_token)
|
||||
logger.info("📋 Token claims analysis:")
|
||||
logger.info(f" Subject (sub): {user_claims.get('sub')}")
|
||||
logger.info(f" Preferred username: {user_claims.get('preferred_username')}")
|
||||
logger.info(f" Client ID (azp): {user_claims.get('azp')}")
|
||||
logger.info(f" Audience (aud): {user_claims.get('aud')}")
|
||||
logger.info("")
|
||||
|
||||
# Verify if impersonation actually worked
|
||||
service_sub = service_claims.get("sub")
|
||||
user_sub = user_claims.get("sub")
|
||||
|
||||
if service_sub != user_sub:
|
||||
logger.info("✅ IMPERSONATION VERIFIED:")
|
||||
logger.info(f" Original sub: {service_sub}")
|
||||
logger.info(f" New sub: {user_sub}")
|
||||
logger.info("")
|
||||
logger.info(" ➡️ The subject claim CHANGED - impersonation worked!")
|
||||
impersonation_worked = True
|
||||
else:
|
||||
logger.warning("⚠️ IMPERSONATION DID NOT OCCUR:")
|
||||
logger.warning(f" Subject unchanged: {user_sub}")
|
||||
logger.warning("")
|
||||
logger.warning(" ➡️ Token exchange succeeded but sub claim is the same")
|
||||
logger.warning(
|
||||
" This is delegation/audience change, not impersonation"
|
||||
)
|
||||
impersonation_worked = False
|
||||
|
||||
except Exception as e:
|
||||
logger.error("❌ Token exchange with impersonation FAILED!")
|
||||
logger.error(f" Error: {e}")
|
||||
logger.error("")
|
||||
logger.error("📋 Error analysis:")
|
||||
|
||||
# Try to extract detailed error message
|
||||
error_str = str(e)
|
||||
if "requested_subject" in error_str.lower():
|
||||
logger.error(
|
||||
" ➡️ Error mentions 'requested_subject' - parameter not supported"
|
||||
)
|
||||
elif "impersonation" in error_str.lower():
|
||||
logger.error(" ➡️ Error mentions 'impersonation' - feature not enabled")
|
||||
elif "permission" in error_str.lower():
|
||||
logger.error(" ➡️ Error mentions 'permission' - client lacks permissions")
|
||||
else:
|
||||
logger.error(" ➡️ Generic error - check Keycloak logs for details")
|
||||
|
||||
logger.error("")
|
||||
logger.error("💡 Possible causes:")
|
||||
logger.error(" 1. Keycloak Standard V2 doesn't support requested_subject")
|
||||
logger.error(" 2. Requires Legacy V1 with --features=preview")
|
||||
logger.error(" 3. Client lacks impersonation permissions")
|
||||
logger.error(" 4. Target user doesn't exist")
|
||||
|
||||
return 1
|
||||
|
||||
logger.info("")
|
||||
|
||||
# Step 5: Test impersonated token with Nextcloud API
|
||||
if impersonation_worked:
|
||||
logger.info("Step 5: Testing impersonated token with Nextcloud API...")
|
||||
try:
|
||||
# Create Nextcloud client with exchanged token
|
||||
nc_client = NextcloudClient.from_token(
|
||||
base_url=nextcloud_host, token=user_token, username=target_user
|
||||
)
|
||||
|
||||
# Test API call
|
||||
capabilities = await nc_client.capabilities()
|
||||
logger.info("✓ Nextcloud API call successful with impersonated token")
|
||||
logger.info(f" Version: {capabilities.get('version', {}).get('string')}")
|
||||
|
||||
await nc_client.close()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Nextcloud API call failed: {e}")
|
||||
logger.error(" The impersonated token may not be valid for Nextcloud")
|
||||
return 1
|
||||
|
||||
logger.info("")
|
||||
logger.info("=" * 80)
|
||||
logger.info("TEST RESULTS SUMMARY")
|
||||
logger.info("=" * 80)
|
||||
|
||||
if impersonation_worked:
|
||||
logger.info("✅ IMPERSONATION IS SUPPORTED!")
|
||||
logger.info("")
|
||||
logger.info("Key findings:")
|
||||
logger.info(" • Token exchange with requested_subject WORKS")
|
||||
logger.info(" • Subject claim successfully changed")
|
||||
logger.info(" • Impersonated token works with Nextcloud APIs")
|
||||
logger.info("")
|
||||
logger.info("⚠️ ADR-002 DOCUMENTATION IS INCORRECT")
|
||||
logger.info(" Current docs claim impersonation doesn't work in Standard V2")
|
||||
logger.info(" This test proves it DOES work!")
|
||||
logger.info("")
|
||||
logger.info("Action items:")
|
||||
logger.info(" 1. Update ADR-002 to mark Tier 1 as IMPLEMENTED")
|
||||
logger.info(" 2. Remove 'NOT IMPLEMENTED' warnings from code")
|
||||
logger.info(" 3. Add automated tests for impersonation")
|
||||
logger.info(" 4. Update oauth-impersonation-findings.md")
|
||||
else:
|
||||
logger.info("❌ IMPERSONATION IS NOT SUPPORTED")
|
||||
logger.info("")
|
||||
logger.info("Key findings:")
|
||||
logger.info(" • Token exchange with requested_subject FAILED")
|
||||
logger.info(" • Keycloak rejected the parameter")
|
||||
logger.info(" • Confirms ADR-002 documentation")
|
||||
logger.info("")
|
||||
logger.info("✅ ADR-002 DOCUMENTATION IS CORRECT")
|
||||
logger.info(" Impersonation requires Keycloak Legacy V1")
|
||||
logger.info("")
|
||||
logger.info("Action items:")
|
||||
logger.info(" 1. Add this test as evidence to ADR-002")
|
||||
logger.info(" 2. Document exact error message")
|
||||
logger.info(" 3. Add 'Verified by testing' note to docs")
|
||||
|
||||
logger.info("")
|
||||
|
||||
return 0 if impersonation_worked else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Manual test for Nextcloud impersonate API.
|
||||
|
||||
This script tests using the Nextcloud impersonate app to allow
|
||||
admin users to act on behalf of other users.
|
||||
|
||||
This is NOT the same as OAuth token exchange, but could serve
|
||||
as a workaround for background operations.
|
||||
|
||||
Usage:
|
||||
# Start app container
|
||||
docker compose up -d app
|
||||
|
||||
# Run the test
|
||||
uv run python tests/manual/test_nextcloud_impersonate.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
|
||||
|
||||
import httpx
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(levelname)-8s | %(name)-30s | %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Test Nextcloud impersonate API"""
|
||||
|
||||
# Configuration
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||
admin_user = os.getenv("NEXTCLOUD_USERNAME", "admin")
|
||||
admin_password = os.getenv("NEXTCLOUD_PASSWORD", "admin")
|
||||
target_user = "testuser" # We'll create this user
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("Nextcloud Impersonate API Test")
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"Nextcloud: {nextcloud_host}")
|
||||
logger.info(f"Admin user: {admin_user}")
|
||||
logger.info(f"Target user: {target_user}")
|
||||
logger.info("")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Step 1: Login as admin and get session
|
||||
logger.info("Step 1: Logging in as admin...")
|
||||
login_response = await client.post(
|
||||
f"{nextcloud_host}/login",
|
||||
data={
|
||||
"user": admin_user,
|
||||
"password": admin_password,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
if login_response.status_code != 200:
|
||||
logger.error(f"❌ Admin login failed: {login_response.status_code}")
|
||||
return 1
|
||||
|
||||
# Get requesttoken from response
|
||||
requesttoken = None
|
||||
for cookie in client.cookies.jar:
|
||||
if cookie.name == "nc_session":
|
||||
logger.info(f"✓ Admin logged in, session: {cookie.value[:20]}...")
|
||||
break
|
||||
|
||||
logger.info("")
|
||||
|
||||
# Step 2: Create test user if doesn't exist
|
||||
logger.info(f"Step 2: Creating test user '{target_user}'...")
|
||||
create_user_response = await client.post(
|
||||
f"{nextcloud_host}/ocs/v1.php/cloud/users",
|
||||
auth=(admin_user, admin_password),
|
||||
data={
|
||||
"userid": target_user,
|
||||
"password": "testpassword123",
|
||||
},
|
||||
headers={"OCS-APIRequest": "true"},
|
||||
)
|
||||
|
||||
if create_user_response.status_code in (200, 400): # 400 if already exists
|
||||
logger.info("✓ Test user ready")
|
||||
else:
|
||||
logger.warning(
|
||||
f"User creation response: {create_user_response.status_code}"
|
||||
)
|
||||
|
||||
# Make sure user has logged in at least once (requirement for impersonation)
|
||||
logger.info(f" Performing initial login for {target_user}...")
|
||||
await client.post(
|
||||
f"{nextcloud_host}/login",
|
||||
data={
|
||||
"user": target_user,
|
||||
"password": "testpassword123",
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
logger.info("✓ Test user has logged in")
|
||||
|
||||
# Re-login as admin
|
||||
await client.post(
|
||||
f"{nextcloud_host}/login",
|
||||
data={
|
||||
"user": admin_user,
|
||||
"password": admin_password,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
logger.info("")
|
||||
|
||||
# Step 3: Get CSRF token for impersonate request
|
||||
logger.info("Step 3: Getting CSRF token...")
|
||||
|
||||
# Try to get token from settings page
|
||||
settings_response = await client.get(
|
||||
f"{nextcloud_host}/settings/users",
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
# Extract requesttoken from HTML
|
||||
import re
|
||||
|
||||
token_match = re.search(r'data-requesttoken="([^"]+)"', settings_response.text)
|
||||
if token_match:
|
||||
requesttoken = token_match.group(1)
|
||||
logger.info(f"✓ CSRF token acquired: {requesttoken[:20]}...")
|
||||
else:
|
||||
logger.error("❌ Could not extract CSRF token from page")
|
||||
return 1
|
||||
|
||||
logger.info("")
|
||||
|
||||
# Step 4: Call impersonate API
|
||||
logger.info(f"Step 4: Impersonating user '{target_user}'...")
|
||||
impersonate_response = await client.post(
|
||||
f"{nextcloud_host}/apps/impersonate/user",
|
||||
data={
|
||||
"userId": target_user,
|
||||
},
|
||||
headers={
|
||||
"requesttoken": requesttoken,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
)
|
||||
|
||||
if impersonate_response.status_code != 200:
|
||||
logger.error(f"❌ Impersonate failed: {impersonate_response.status_code}")
|
||||
logger.error(f"Response: {impersonate_response.text}")
|
||||
return 1
|
||||
|
||||
logger.info("✓ Impersonation successful")
|
||||
logger.info("")
|
||||
|
||||
# Step 5: Test API call as impersonated user
|
||||
logger.info("Step 5: Testing API call as impersonated user...")
|
||||
capabilities_response = await client.get(
|
||||
f"{nextcloud_host}/ocs/v2.php/cloud/capabilities",
|
||||
headers={"OCS-APIRequest": "true"},
|
||||
)
|
||||
|
||||
if capabilities_response.status_code == 200:
|
||||
caps = capabilities_response.json()
|
||||
logger.info(f"✓ API call successful as {target_user}")
|
||||
logger.info(
|
||||
f" Version: {caps.get('ocs', {}).get('data', {}).get('version', {}).get('string')}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"❌ API call failed: {capabilities_response.status_code}")
|
||||
return 1
|
||||
|
||||
logger.info("")
|
||||
|
||||
# Step 6: Get current user to verify impersonation
|
||||
logger.info("Step 6: Verifying current user...")
|
||||
user_response = await client.get(
|
||||
f"{nextcloud_host}/ocs/v2.php/cloud/user",
|
||||
headers={"OCS-APIRequest": "true"},
|
||||
)
|
||||
|
||||
if user_response.status_code == 200:
|
||||
user_data = user_response.json()
|
||||
current_user = user_data.get("ocs", {}).get("data", {}).get("id")
|
||||
logger.info(f"✓ Current user: {current_user}")
|
||||
|
||||
if current_user == target_user:
|
||||
logger.info(" ✓ Successfully impersonating target user!")
|
||||
else:
|
||||
logger.warning(f" ⚠ Expected {target_user}, got {current_user}")
|
||||
else:
|
||||
logger.error(f"❌ User check failed: {user_response.status_code}")
|
||||
|
||||
logger.info("")
|
||||
logger.info("=" * 80)
|
||||
logger.info("✅ Impersonate API Test PASSED")
|
||||
logger.info("=" * 80)
|
||||
logger.info("")
|
||||
logger.info("Summary:")
|
||||
logger.info(" 1. Admin can impersonate other users via session-based API")
|
||||
logger.info(" 2. Impersonated session can access APIs as that user")
|
||||
logger.info(" 3. Requires admin credentials and CSRF token")
|
||||
logger.info("")
|
||||
logger.info("Limitations:")
|
||||
logger.info(" - Session-based (not stateless like OAuth)")
|
||||
logger.info(" - Requires admin credentials")
|
||||
logger.info(" - Target user must have logged in at least once")
|
||||
logger.info(" - Not suitable for distributed/background workers")
|
||||
logger.info("")
|
||||
logger.info("For background operations, consider:")
|
||||
logger.info(" - Use service account with appropriate permissions")
|
||||
logger.info(" - Or implement proper OAuth delegation (RFC 8693)")
|
||||
logger.info("")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Manual test for RFC 8693 Token Exchange with Keycloak.
|
||||
|
||||
This script demonstrates ADR-002 Tier 2 implementation:
|
||||
1. Get service account token (client_credentials grant)
|
||||
2. Exchange token for user-scoped token (RFC 8693)
|
||||
3. Use exchanged token to access Nextcloud APIs
|
||||
|
||||
Usage:
|
||||
# Start Keycloak and app containers
|
||||
docker compose up -d keycloak app
|
||||
|
||||
# Run the test
|
||||
uv run python tests/manual/test_token_exchange.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
|
||||
|
||||
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(levelname)-8s | %(name)-30s | %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Test token exchange flow"""
|
||||
|
||||
# Configuration (matches docker-compose mcp-keycloak service)
|
||||
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
|
||||
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
|
||||
client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server")
|
||||
client_secret = os.getenv(
|
||||
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
|
||||
)
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||
redirect_uri = "http://localhost:8002/oauth/callback"
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("RFC 8693 Token Exchange Test")
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"Keycloak URL: {keycloak_url}")
|
||||
logger.info(f"Realm: {realm}")
|
||||
logger.info(f"Client ID: {client_id}")
|
||||
logger.info(f"Nextcloud: {nextcloud_host}")
|
||||
logger.info("")
|
||||
|
||||
# Step 1: Create Keycloak OAuth client
|
||||
logger.info("Step 1: Initializing Keycloak OAuth client...")
|
||||
oauth_client = KeycloakOAuthClient(
|
||||
keycloak_url=keycloak_url,
|
||||
realm=realm,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
|
||||
# Discover endpoints
|
||||
await oauth_client.discover()
|
||||
logger.info(f"✓ Discovered token endpoint: {oauth_client.token_endpoint}")
|
||||
logger.info("")
|
||||
|
||||
# Step 2: Check token exchange support
|
||||
logger.info("Step 2: Checking token exchange support...")
|
||||
supported = await oauth_client.check_token_exchange_support()
|
||||
|
||||
if not supported:
|
||||
logger.error("❌ Token exchange is NOT supported by this Keycloak instance")
|
||||
logger.error(
|
||||
" You may need to enable it with: --features=preview --features=token-exchange"
|
||||
)
|
||||
return 1
|
||||
|
||||
logger.info("")
|
||||
|
||||
# Step 3: Get service account token
|
||||
# ⚠️ WARNING: Service account tokens MUST NOT be used directly with Nextcloud APIs!
|
||||
# Using this token directly violates OAuth "act on-behalf-of" principles:
|
||||
# - Creates Nextcloud user: service-account-{client_id}
|
||||
# - Breaks audit trail (actions not attributable to real user)
|
||||
# - Creates stateful server identity in Nextcloud
|
||||
#
|
||||
# VALID USE: ONLY as subject_token for RFC 8693 token exchange (Step 4 below)
|
||||
# INVALID USE: Direct API access (see ADR-002 "Will Not Implement" section)
|
||||
#
|
||||
# If you need background operations without token exchange support, use BasicAuth mode.
|
||||
logger.info("Step 3: Requesting service account token (client_credentials)...")
|
||||
try:
|
||||
service_token_response = await oauth_client.get_service_account_token(
|
||||
scopes=["openid", "profile", "email"]
|
||||
)
|
||||
service_token = service_token_response["access_token"]
|
||||
logger.info("✓ Service account token acquired")
|
||||
logger.info(f" Token type: {service_token_response.get('token_type')}")
|
||||
logger.info(f" Expires in: {service_token_response.get('expires_in')}s")
|
||||
logger.info(f" Scope: {service_token_response.get('scope')}")
|
||||
logger.info(f" Token (first 50 chars): {service_token[:50]}...")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to get service account token: {e}")
|
||||
logger.error(
|
||||
" Make sure serviceAccountsEnabled=true for the client in Keycloak"
|
||||
)
|
||||
return 1
|
||||
|
||||
logger.info("")
|
||||
|
||||
# Step 4: Exchange token (without impersonation - Standard V2)
|
||||
logger.info(
|
||||
"Step 4: Exchanging service token with different audience (RFC 8693)..."
|
||||
)
|
||||
logger.info(" Note: Keycloak Standard V2 doesn't support user impersonation")
|
||||
logger.info(" That requires Legacy V1 with --features=preview")
|
||||
try:
|
||||
user_token_response = await oauth_client.exchange_token_for_user(
|
||||
subject_token=service_token,
|
||||
target_user_id=None, # Don't request impersonation
|
||||
audience=None, # No cross-client exchange in Standard V2
|
||||
scopes=["openid", "profile"], # Try downscoping
|
||||
)
|
||||
user_token = user_token_response["access_token"]
|
||||
logger.info("✓ Token exchange successful")
|
||||
logger.info(
|
||||
f" Issued token type: {user_token_response.get('issued_token_type')}"
|
||||
)
|
||||
logger.info(f" Token type: {user_token_response.get('token_type')}")
|
||||
logger.info(f" Expires in: {user_token_response.get('expires_in')}s")
|
||||
logger.info(f" User token (first 50 chars): {user_token[:50]}...")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Token exchange failed: {e}")
|
||||
logger.error(" Possible causes:")
|
||||
logger.error(" - token.exchange.grant.enabled not set to true")
|
||||
logger.error(" - Missing exchange permissions in Keycloak")
|
||||
logger.error(" - User 'admin' does not exist")
|
||||
return 1
|
||||
|
||||
logger.info("")
|
||||
|
||||
# Step 5: Test user token with Nextcloud API
|
||||
logger.info("Step 5: Testing exchanged token with Nextcloud capabilities API...")
|
||||
try:
|
||||
# Create Nextcloud client with exchanged token
|
||||
nc_client = NextcloudClient.from_token(
|
||||
base_url=nextcloud_host, token=user_token, username="admin"
|
||||
)
|
||||
|
||||
# Test API call
|
||||
capabilities = await nc_client.capabilities()
|
||||
logger.info("✓ Nextcloud API call successful")
|
||||
logger.info(f" Version: {capabilities.get('version', {}).get('string')}")
|
||||
logger.info(
|
||||
f" Edition: {capabilities.get('capabilities', {}).get('core', {}).get('webdav-root')}"
|
||||
)
|
||||
|
||||
await nc_client.close()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Nextcloud API call failed: {e}")
|
||||
logger.error(" The exchanged token may not be valid for Nextcloud")
|
||||
logger.error(" Check that user_oidc app is configured correctly")
|
||||
return 1
|
||||
|
||||
logger.info("")
|
||||
logger.info("=" * 80)
|
||||
logger.info("✅ Token Exchange Test PASSED")
|
||||
logger.info("=" * 80)
|
||||
logger.info("")
|
||||
logger.info("Summary:")
|
||||
logger.info(" 1. Service account token acquired")
|
||||
logger.info(" 2. Token exchanged with different audience")
|
||||
logger.info(" 3. Exchanged token works with Nextcloud APIs")
|
||||
logger.info("")
|
||||
logger.info("This demonstrates ADR-002 Tier 2: Token Exchange")
|
||||
logger.info(
|
||||
"The MCP server can perform token exchange for different audiences/scopes"
|
||||
)
|
||||
logger.info("without needing refresh tokens or admin credentials.")
|
||||
logger.info("")
|
||||
logger.info(
|
||||
"Note: User impersonation requires Keycloak Legacy V1 with --features=preview"
|
||||
)
|
||||
logger.info("")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
@@ -0,0 +1 @@
|
||||
"""OAuth-specific integration tests."""
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Test DCR deletion endpoint with different authentication methods.
|
||||
|
||||
This simplified test focuses only on testing the deletion endpoint
|
||||
with various authentication methods to answer the question:
|
||||
"Does the 401 issue occur for both basic auth and credentials in the body?"
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registration import register_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_deletion_authentication_methods(
|
||||
anyio_backend,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test DCR deletion with different authentication methods.
|
||||
|
||||
Tests:
|
||||
1. HTTP Basic Auth (client_id:client_secret)
|
||||
2. Credentials in JSON body
|
||||
3. Credentials in query parameters
|
||||
|
||||
This answers: Does the 401 issue occur with all authentication methods?
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover OIDC endpoints
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
|
||||
# Register a client for testing
|
||||
logger.info("Registering test client...")
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Auth Methods Test",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email",
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
deletion_endpoint = f"{nextcloud_host}/apps/oidc/register/{client_info.client_id}"
|
||||
logger.info(f"\nTesting deletion endpoint: {deletion_endpoint}")
|
||||
logger.info(f"Client ID: {client_info.client_id}")
|
||||
logger.info(f"Client Secret (first 16 chars): {client_info.client_secret[:16]}...")
|
||||
|
||||
results = {}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as test_client:
|
||||
# Method 1: HTTP Basic Auth
|
||||
logger.info("\n=== Method 1: HTTP Basic Auth ===")
|
||||
try:
|
||||
response = await test_client.delete(
|
||||
deletion_endpoint,
|
||||
auth=(client_info.client_id, client_info.client_secret),
|
||||
)
|
||||
results["basic_auth"] = {
|
||||
"status": response.status_code,
|
||||
"body": response.text[:200],
|
||||
}
|
||||
logger.info(f"Status: {response.status_code}")
|
||||
logger.info(f"Body: {response.text[:200]}")
|
||||
except Exception as e:
|
||||
results["basic_auth"] = {"status": "error", "error": str(e)}
|
||||
logger.error(f"Error: {e}")
|
||||
|
||||
# Method 2: Credentials in JSON body
|
||||
logger.info("\n=== Method 2: Credentials in JSON Body ===")
|
||||
try:
|
||||
response = await test_client.delete(
|
||||
deletion_endpoint,
|
||||
json={
|
||||
"client_id": client_info.client_id,
|
||||
"client_secret": client_info.client_secret,
|
||||
},
|
||||
)
|
||||
results["json_body"] = {
|
||||
"status": response.status_code,
|
||||
"body": response.text[:200],
|
||||
}
|
||||
logger.info(f"Status: {response.status_code}")
|
||||
logger.info(f"Body: {response.text[:200]}")
|
||||
except Exception as e:
|
||||
results["json_body"] = {"status": "error", "error": str(e)}
|
||||
logger.error(f"Error: {e}")
|
||||
|
||||
# Method 3: Credentials in query parameters
|
||||
logger.info("\n=== Method 3: Credentials in Query Parameters ===")
|
||||
try:
|
||||
response = await test_client.delete(
|
||||
deletion_endpoint,
|
||||
params={
|
||||
"client_id": client_info.client_id,
|
||||
"client_secret": client_info.client_secret,
|
||||
},
|
||||
)
|
||||
results["query_params"] = {
|
||||
"status": response.status_code,
|
||||
"body": response.text[:200],
|
||||
}
|
||||
logger.info(f"Status: {response.status_code}")
|
||||
logger.info(f"Body: {response.text[:200]}")
|
||||
except Exception as e:
|
||||
results["query_params"] = {"status": "error", "error": str(e)}
|
||||
logger.error(f"Error: {e}")
|
||||
|
||||
# Method 4: No authentication (baseline)
|
||||
logger.info("\n=== Method 4: No Authentication (Baseline) ===")
|
||||
try:
|
||||
response = await test_client.delete(deletion_endpoint)
|
||||
results["no_auth"] = {
|
||||
"status": response.status_code,
|
||||
"body": response.text[:200],
|
||||
}
|
||||
logger.info(f"Status: {response.status_code}")
|
||||
logger.info(f"Body: {response.text[:200]}")
|
||||
except Exception as e:
|
||||
results["no_auth"] = {"status": "error", "error": str(e)}
|
||||
logger.error(f"Error: {e}")
|
||||
|
||||
# Print summary
|
||||
logger.info("\n" + "=" * 70)
|
||||
logger.info("SUMMARY: DCR Deletion Authentication Methods")
|
||||
logger.info("=" * 70)
|
||||
|
||||
for method, result in results.items():
|
||||
status = result.get("status", "unknown")
|
||||
logger.info(f"{method:20s} → Status: {status}")
|
||||
|
||||
# Analysis
|
||||
logger.info("\n" + "=" * 70)
|
||||
logger.info("ANALYSIS")
|
||||
logger.info("=" * 70)
|
||||
|
||||
all_401 = all(
|
||||
r.get("status") == 401 for r in results.values() if r.get("status") != "error"
|
||||
)
|
||||
any_204 = any(r.get("status") == 204 for r in results.values())
|
||||
|
||||
if all_401:
|
||||
logger.info("✗ ALL authentication methods return 401 Unauthorized")
|
||||
logger.info(
|
||||
" This indicates the deletion endpoint does not accept any form of credentials."
|
||||
)
|
||||
logger.info(
|
||||
" Likely cause: RFC 7592 not fully implemented (missing registration_access_token)"
|
||||
)
|
||||
elif any_204:
|
||||
logger.info("✓ At least one authentication method succeeded (204 No Content)")
|
||||
for method, result in results.items():
|
||||
if result.get("status") == 204:
|
||||
logger.info(f" Working method: {method}")
|
||||
else:
|
||||
logger.info("? Mixed results - further investigation needed")
|
||||
for method, result in results.items():
|
||||
logger.info(f" {method}: {result.get('status')}")
|
||||
|
||||
# Document the finding
|
||||
assert all_401 or any_204, (
|
||||
f"Expected either all 401s (not implemented) or at least one 204 (working). "
|
||||
f"Got: {results}"
|
||||
)
|
||||
|
||||
if all_401:
|
||||
logger.info(
|
||||
"\n✓ Test confirms: DCR deletion returns 401 with ALL authentication methods"
|
||||
)
|
||||
else:
|
||||
logger.info("\n✓ Test confirms: DCR deletion works with at least one method")
|
||||
@@ -0,0 +1,471 @@
|
||||
"""
|
||||
Tests for Dynamic Client Registration (DCR) lifecycle - register and delete.
|
||||
|
||||
These tests verify the complete lifecycle of DCR clients:
|
||||
1. Registration via RFC 7591
|
||||
2. Token acquisition and use
|
||||
3. Deletion via RFC 7592
|
||||
4. Error handling for deletion edge cases
|
||||
|
||||
This is critical for ensuring the fixture cleanup code works reliably.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registration import delete_client, register_client
|
||||
|
||||
from ...conftest import _handle_oauth_consent_screen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
async def get_oauth_token_with_client(
|
||||
browser,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
token_endpoint: str,
|
||||
authorization_endpoint: str,
|
||||
callback_url: str,
|
||||
auth_states: dict,
|
||||
scopes: str = "openid profile email notes:read notes:write",
|
||||
) -> str:
|
||||
"""
|
||||
Helper to obtain OAuth access token using existing client credentials.
|
||||
|
||||
Args:
|
||||
browser: Playwright browser instance
|
||||
client_id: OAuth client ID
|
||||
client_secret: OAuth client secret
|
||||
token_endpoint: Token endpoint URL
|
||||
authorization_endpoint: Authorization endpoint URL
|
||||
callback_url: Callback URL for OAuth redirect
|
||||
auth_states: Dict for storing auth codes (from callback server)
|
||||
scopes: Space-separated list of scopes to request
|
||||
|
||||
Returns:
|
||||
Access token string
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||
|
||||
if not all([nextcloud_host, username, password]):
|
||||
pytest.skip(
|
||||
"OAuth requires NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD"
|
||||
)
|
||||
|
||||
# Generate unique state parameter
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# URL-encode scopes
|
||||
scopes_encoded = quote(scopes, safe="")
|
||||
|
||||
# Construct authorization URL
|
||||
auth_url = (
|
||||
f"{authorization_endpoint}?"
|
||||
f"response_type=code&"
|
||||
f"client_id={client_id}&"
|
||||
f"redirect_uri={quote(callback_url, safe='')}&"
|
||||
f"state={state}&"
|
||||
f"scope={scopes_encoded}"
|
||||
)
|
||||
|
||||
# Browser automation
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
|
||||
current_url = page.url
|
||||
|
||||
# Login if needed
|
||||
if "/login" in current_url or "/index.php/login" in current_url:
|
||||
logger.info("Logging in for DCR lifecycle test...")
|
||||
await page.wait_for_selector('input[name="user"]', timeout=10000)
|
||||
await page.fill('input[name="user"]', username)
|
||||
await page.fill('input[name="password"]', password)
|
||||
await page.click('button[type="submit"]')
|
||||
await page.wait_for_load_state("networkidle", timeout=60000)
|
||||
|
||||
# Handle consent screen if present
|
||||
try:
|
||||
await _handle_oauth_consent_screen(page, username)
|
||||
except Exception as e:
|
||||
logger.debug(f"No consent screen or already authorized: {e}")
|
||||
|
||||
# Wait for callback
|
||||
logger.info("Waiting for OAuth callback...")
|
||||
timeout_seconds = 30
|
||||
start_time = time.time()
|
||||
while state not in auth_states:
|
||||
if time.time() - start_time > timeout_seconds:
|
||||
raise TimeoutError(
|
||||
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
||||
)
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Got auth code: {auth_code[:20]}...")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
# Exchange code for token
|
||||
logger.info("Exchanging authorization code for access token...")
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
token_response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"redirect_uri": callback_url,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
token_response.raise_for_status()
|
||||
token_data = token_response.json()
|
||||
access_token = token_data.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
raise ValueError(f"No access_token in response: {token_data}")
|
||||
|
||||
logger.info("Successfully obtained access token")
|
||||
return access_token
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_register_and_delete_lifecycle(
|
||||
anyio_backend,
|
||||
browser,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test the complete DCR lifecycle: register → use → delete.
|
||||
|
||||
This verifies:
|
||||
1. Client registration succeeds
|
||||
2. Client can obtain tokens and make API calls
|
||||
3. Client deletion succeeds (returns 204)
|
||||
4. Deleted client cannot be used again (tokens are revoked)
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover OIDC endpoints
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
token_endpoint = oidc_config.get("token_endpoint")
|
||||
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||
|
||||
# Step 1: Register client (and capture full response including registration_access_token)
|
||||
logger.info("Step 1: Registering OAuth client...")
|
||||
|
||||
# Register manually to capture full response
|
||||
client_metadata = {
|
||||
"client_name": "DCR Lifecycle Test Client",
|
||||
"redirect_uris": [callback_url],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "openid profile email notes:read",
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as reg_client:
|
||||
reg_response = await reg_client.post(
|
||||
registration_endpoint,
|
||||
json=client_metadata,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
reg_response.raise_for_status()
|
||||
full_client_info = reg_response.json()
|
||||
|
||||
logger.info(f"Full registration response keys: {list(full_client_info.keys())}")
|
||||
logger.info(f"Registration response: {full_client_info}")
|
||||
|
||||
# Use the register_client function for the ClientInfo object
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Lifecycle Test Client 2",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email notes:read",
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
# Store RFC 7592 fields if present
|
||||
registration_access_token = full_client_info.get("registration_access_token")
|
||||
registration_client_uri = full_client_info.get("registration_client_uri")
|
||||
logger.info(
|
||||
f"Registration access token present: {registration_access_token is not None}"
|
||||
)
|
||||
logger.info(
|
||||
f"Registration client URI present: {registration_client_uri is not None}"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Client registered: {client_info.client_id[:16]}...")
|
||||
|
||||
# Step 2: Obtain token and verify client works
|
||||
logger.info("Step 2: Obtaining OAuth token with registered client...")
|
||||
access_token = await get_oauth_token_with_client(
|
||||
browser=browser,
|
||||
client_id=client_info.client_id,
|
||||
client_secret=client_info.client_secret,
|
||||
token_endpoint=token_endpoint,
|
||||
authorization_endpoint=authorization_endpoint,
|
||||
callback_url=callback_url,
|
||||
auth_states=auth_states,
|
||||
scopes="openid profile email notes:read",
|
||||
)
|
||||
|
||||
assert access_token, "Failed to obtain access token"
|
||||
logger.info(f"✅ Access token obtained: {access_token[:30]}...")
|
||||
|
||||
# Step 3: Delete the client using RFC 7592
|
||||
logger.info("Step 3: Deleting OAuth client...")
|
||||
logger.info(f"Client ID: {client_info.client_id}")
|
||||
logger.info(f"Client secret (first 16 chars): {client_info.client_secret[:16]}...")
|
||||
logger.info(
|
||||
f"Registration access token: {registration_access_token[:16] if registration_access_token else 'None'}..."
|
||||
)
|
||||
|
||||
# Use delete_client() which prefers RFC 7592 Bearer token, falls back to Basic Auth
|
||||
success = await delete_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
client_id=client_info.client_id,
|
||||
registration_access_token=registration_access_token,
|
||||
client_secret=client_info.client_secret,
|
||||
registration_client_uri=registration_client_uri,
|
||||
)
|
||||
|
||||
assert success, (
|
||||
"Client deletion should succeed with RFC 7592 Bearer token or Basic Auth"
|
||||
)
|
||||
logger.info(f"✅ Client deleted successfully: {client_info.client_id[:16]}...")
|
||||
|
||||
# Step 4: Verify deleted client cannot obtain new tokens
|
||||
logger.info("Step 4: Verifying deleted client cannot obtain new tokens...")
|
||||
|
||||
# Try to use the deleted client to get a token
|
||||
# This should fail because the client no longer exists
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
try:
|
||||
# Try to use client credentials grant (should fail)
|
||||
token_response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_info.client_id,
|
||||
"client_secret": client_info.client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
# If we get here, check the status code
|
||||
# Accept either 400 (Bad Request) or 401 (Unauthorized) as valid rejection
|
||||
if token_response.status_code in [400, 401]:
|
||||
logger.info(
|
||||
f"✅ Deleted client correctly rejected ({token_response.status_code})"
|
||||
)
|
||||
else:
|
||||
# Unexpected success - client should be deleted
|
||||
pytest.fail(
|
||||
f"Deleted client should not be able to obtain tokens, "
|
||||
f"but got status {token_response.status_code}"
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
# Expected - client should be rejected
|
||||
if e.response.status_code == 401:
|
||||
logger.info("✅ Deleted client correctly rejected (401 Unauthorized)")
|
||||
else:
|
||||
# Re-raise if it's a different error
|
||||
raise
|
||||
|
||||
logger.info("✅ Complete DCR lifecycle test passed!")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_delete_with_wrong_credentials(
|
||||
anyio_backend,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test that deletion fails with wrong registration_access_token (401 Unauthorized).
|
||||
|
||||
This verifies:
|
||||
1. Client registration succeeds and returns registration_access_token
|
||||
2. Deletion with wrong registration_access_token returns 401
|
||||
3. Deletion with correct registration_access_token succeeds (RFC 7592)
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover OIDC endpoints
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
|
||||
# Register client
|
||||
logger.info("Registering OAuth client for credential test...")
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Wrong Credentials Test",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email",
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
logger.info(f"Client registered: {client_info.client_id[:16]}...")
|
||||
|
||||
# Try to delete with wrong registration_access_token (RFC 7592 Bearer token)
|
||||
logger.info("Attempting deletion with wrong registration_access_token...")
|
||||
wrong_token = "wrong_token_" + secrets.token_urlsafe(32)
|
||||
|
||||
success = await delete_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
client_id=client_info.client_id,
|
||||
registration_access_token=wrong_token,
|
||||
client_secret=client_info.client_secret, # Should not be used if token is present
|
||||
)
|
||||
|
||||
assert not success, "Deletion with wrong credentials should fail"
|
||||
logger.info("✅ Deletion correctly failed with wrong credentials")
|
||||
|
||||
# Clean up: Delete with correct RFC 7592 Bearer token
|
||||
logger.info("Cleaning up: deleting with correct registration_access_token...")
|
||||
success = await delete_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
client_id=client_info.client_id,
|
||||
registration_access_token=client_info.registration_access_token,
|
||||
client_secret=client_info.client_secret,
|
||||
registration_client_uri=client_info.registration_client_uri,
|
||||
)
|
||||
|
||||
assert success, "Deletion with correct credentials should succeed"
|
||||
logger.info("✅ Cleanup successful with correct credentials")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_delete_nonexistent_client(
|
||||
anyio_backend,
|
||||
):
|
||||
"""
|
||||
Test that deleting a non-existent client fails gracefully.
|
||||
|
||||
This verifies:
|
||||
1. Deletion of fake client_id returns False (not 204)
|
||||
2. No exceptions are raised (graceful failure)
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
# Try to delete a client that doesn't exist
|
||||
fake_client_id = "nonexistent_" + secrets.token_urlsafe(16)
|
||||
fake_client_secret = secrets.token_urlsafe(32)
|
||||
|
||||
logger.info(f"Attempting to delete non-existent client: {fake_client_id[:16]}...")
|
||||
|
||||
success = await delete_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
client_id=fake_client_id,
|
||||
client_secret=fake_client_secret,
|
||||
)
|
||||
|
||||
assert not success, "Deletion of non-existent client should fail"
|
||||
logger.info("✅ Non-existent client deletion correctly failed")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_deletion_is_idempotent(
|
||||
anyio_backend,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test that deleting the same client twice fails gracefully on second attempt.
|
||||
|
||||
This verifies:
|
||||
1. First deletion succeeds (204)
|
||||
2. Second deletion fails gracefully (returns False, not an exception)
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover OIDC endpoints
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
|
||||
# Register client
|
||||
logger.info("Registering OAuth client for idempotency test...")
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Idempotency Test",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email",
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
logger.info(f"Client registered: {client_info.client_id[:16]}...")
|
||||
|
||||
# First deletion with RFC 7592 Bearer token
|
||||
logger.info("First deletion attempt...")
|
||||
success = await delete_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
client_id=client_info.client_id,
|
||||
registration_access_token=client_info.registration_access_token,
|
||||
client_secret=client_info.client_secret,
|
||||
registration_client_uri=client_info.registration_client_uri,
|
||||
)
|
||||
|
||||
assert success, "First deletion should succeed"
|
||||
logger.info("✅ First deletion succeeded")
|
||||
|
||||
# Second deletion (should fail gracefully - token no longer valid after first deletion)
|
||||
logger.info("Second deletion attempt (should fail)...")
|
||||
success = await delete_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
client_id=client_info.client_id,
|
||||
registration_access_token=client_info.registration_access_token,
|
||||
client_secret=client_info.client_secret,
|
||||
registration_client_uri=client_info.registration_client_uri,
|
||||
)
|
||||
|
||||
assert not success, "Second deletion should fail (client already deleted)"
|
||||
logger.info("✅ Second deletion correctly failed (client already deleted)")
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Test the new DCR deletion implementation.
|
||||
|
||||
This test verifies that the recently implemented DCR deletion branch works correctly.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_new_dcr_registration_includes_access_token(
|
||||
anyio_backend,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test that registration now includes registration_access_token.
|
||||
|
||||
The new DCR deletion implementation should provide a registration_access_token
|
||||
in the registration response per RFC 7592.
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover OIDC endpoints
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
|
||||
# Register a client and inspect the full response
|
||||
client_metadata = {
|
||||
"client_name": "DCR New Implementation Test",
|
||||
"redirect_uris": [callback_url],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "openid profile email",
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
logger.info("Registering client to check for registration_access_token...")
|
||||
response = await client.post(
|
||||
registration_endpoint,
|
||||
json=client_metadata,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
registration_data = response.json()
|
||||
|
||||
# Log the full response
|
||||
logger.info(f"\n{'=' * 70}")
|
||||
logger.info("REGISTRATION RESPONSE")
|
||||
logger.info(f"{'=' * 70}")
|
||||
logger.info(f"Response keys: {sorted(registration_data.keys())}")
|
||||
logger.info("\nFull response:")
|
||||
for key, value in sorted(registration_data.items()):
|
||||
if key in ["client_secret", "registration_access_token"]:
|
||||
# Truncate secrets for security
|
||||
logger.info(f" {key}: {value[:20]}... (truncated)")
|
||||
else:
|
||||
logger.info(f" {key}: {value}")
|
||||
|
||||
# Check for RFC 7592 required fields
|
||||
logger.info(f"\n{'=' * 70}")
|
||||
logger.info("RFC 7592 COMPLIANCE CHECK")
|
||||
logger.info(f"{'=' * 70}")
|
||||
|
||||
has_token = "registration_access_token" in registration_data
|
||||
has_uri = "registration_client_uri" in registration_data
|
||||
|
||||
logger.info(f"registration_access_token present: {has_token}")
|
||||
logger.info(f"registration_client_uri present: {has_uri}")
|
||||
|
||||
if has_token and has_uri:
|
||||
logger.info(
|
||||
"\n✓ PASS: Registration response includes RFC 7592 management fields!"
|
||||
)
|
||||
logger.info(
|
||||
" This means DCR deletion should now work with Bearer token authentication."
|
||||
)
|
||||
|
||||
# Store these for deletion test
|
||||
client_id = registration_data["client_id"]
|
||||
registration_access_token = registration_data["registration_access_token"]
|
||||
registration_client_uri = registration_data.get("registration_client_uri")
|
||||
|
||||
# Now test deletion with the registration_access_token
|
||||
logger.info(f"\n{'=' * 70}")
|
||||
logger.info("TESTING DCR DELETION WITH REGISTRATION_ACCESS_TOKEN")
|
||||
logger.info(f"{'=' * 70}")
|
||||
|
||||
deletion_endpoint = (
|
||||
registration_client_uri
|
||||
or f"{nextcloud_host}/apps/oidc/register/{client_id}"
|
||||
)
|
||||
logger.info(f"Deletion endpoint: {deletion_endpoint}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# Try deletion with Bearer token (RFC 7592 standard)
|
||||
logger.info("\nAttempting deletion with Bearer token...")
|
||||
delete_response = await client.delete(
|
||||
deletion_endpoint,
|
||||
headers={"Authorization": f"Bearer {registration_access_token}"},
|
||||
)
|
||||
|
||||
logger.info(f"Response status: {delete_response.status_code}")
|
||||
logger.info(f"Response body: {delete_response.text[:200]}")
|
||||
|
||||
if delete_response.status_code == 204:
|
||||
logger.info(
|
||||
"\n✓✓✓ SUCCESS! DCR deletion works with new implementation!"
|
||||
)
|
||||
logger.info(" RFC 7592 deletion is now fully functional.")
|
||||
assert True
|
||||
elif delete_response.status_code == 401:
|
||||
logger.error(
|
||||
"\n✗ FAIL: Still getting 401 even with registration_access_token"
|
||||
)
|
||||
logger.error(
|
||||
" The token may not be recognized or there's a middleware issue."
|
||||
)
|
||||
pytest.fail(
|
||||
"DCR deletion failed with 401 even with registration_access_token"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"\n? UNEXPECTED: Got status {delete_response.status_code}"
|
||||
)
|
||||
pytest.fail(
|
||||
f"Unexpected status code: {delete_response.status_code}, body: {delete_response.text[:500]}"
|
||||
)
|
||||
|
||||
else:
|
||||
logger.warning(
|
||||
"\n✗ FAIL: Registration response still missing RFC 7592 management fields"
|
||||
)
|
||||
logger.warning(
|
||||
" The new DCR deletion implementation may not be active or needs configuration."
|
||||
)
|
||||
pytest.fail(
|
||||
f"Registration response missing RFC 7592 fields. "
|
||||
f"Has token: {has_token}, Has URI: {has_uri}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_deletion_with_basic_auth_new_impl(
|
||||
anyio_backend,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Verify whether HTTP Basic Auth is now supported for deletion.
|
||||
|
||||
Some implementations support both Bearer token and Basic Auth.
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover and register
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
|
||||
# Register
|
||||
client_metadata = {
|
||||
"client_name": "DCR Basic Auth Test",
|
||||
"redirect_uris": [callback_url],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code"],
|
||||
"response_types": ["code"],
|
||||
"scope": "openid profile email",
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
registration_endpoint,
|
||||
json=client_metadata,
|
||||
)
|
||||
response.raise_for_status()
|
||||
reg_data = response.json()
|
||||
|
||||
client_id = reg_data["client_id"]
|
||||
client_secret = reg_data["client_secret"]
|
||||
deletion_endpoint = f"{nextcloud_host}/apps/oidc/register/{client_id}"
|
||||
|
||||
logger.info(f"\n{'=' * 70}")
|
||||
logger.info("TESTING DCR DELETION WITH HTTP BASIC AUTH")
|
||||
logger.info(f"{'=' * 70}")
|
||||
logger.info(f"Endpoint: {deletion_endpoint}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.delete(
|
||||
deletion_endpoint,
|
||||
auth=(client_id, client_secret),
|
||||
)
|
||||
|
||||
logger.info(f"Status: {response.status_code}")
|
||||
logger.info(f"Body: {response.text[:200]}")
|
||||
|
||||
if response.status_code == 204:
|
||||
logger.info("\n✓ SUCCESS: HTTP Basic Auth works for deletion!")
|
||||
elif response.status_code == 401:
|
||||
logger.info(
|
||||
"\n✗ HTTP Basic Auth not supported - use registration_access_token instead"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"\n? Unexpected status: {response.status_code}")
|
||||
|
||||
# This test is informational - we don't fail if Basic Auth doesn't work
|
||||
# as long as Bearer token works
|
||||
assert True
|
||||
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
Tests for Dynamic Client Registration (DCR) token_type parameter.
|
||||
|
||||
These tests verify that the Nextcloud OIDC server properly honors the token_type
|
||||
parameter during client registration, issuing the correct type of access tokens:
|
||||
- token_type="jwt" → JWT-formatted tokens (RFC 9068)
|
||||
- token_type="opaque" → Opaque tokens (standard OAuth2)
|
||||
|
||||
This is critical for ensuring:
|
||||
1. Client choice is respected by the OIDC server
|
||||
2. JWT tokens embed scope information in claims
|
||||
3. Opaque tokens require introspection for scope information
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registration import register_client
|
||||
|
||||
from ...conftest import _handle_oauth_consent_screen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
def is_jwt_format(token: str) -> bool:
|
||||
"""
|
||||
Check if a token is in JWT format (three base64-encoded parts separated by dots).
|
||||
|
||||
Args:
|
||||
token: The access token to check
|
||||
|
||||
Returns:
|
||||
True if token appears to be JWT format, False otherwise
|
||||
"""
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return False
|
||||
|
||||
# Try to decode the header and payload to verify it's valid base64
|
||||
try:
|
||||
# Add padding if needed
|
||||
header_part = parts[0] + "=" * (4 - len(parts[0]) % 4)
|
||||
payload_part = parts[1] + "=" * (4 - len(parts[1]) % 4)
|
||||
|
||||
# Decode
|
||||
base64.urlsafe_b64decode(header_part)
|
||||
base64.urlsafe_b64decode(payload_part)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def decode_jwt_payload(token: str) -> dict:
|
||||
"""
|
||||
Decode the payload of a JWT token without verification.
|
||||
|
||||
Args:
|
||||
token: The JWT token
|
||||
|
||||
Returns:
|
||||
Dict containing the decoded payload
|
||||
|
||||
Raises:
|
||||
ValueError: If token is not valid JWT format
|
||||
"""
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}")
|
||||
|
||||
# Decode payload (second part)
|
||||
payload_part = parts[1] + "=" * (4 - len(parts[1]) % 4)
|
||||
payload_bytes = base64.urlsafe_b64decode(payload_part)
|
||||
return json.loads(payload_bytes)
|
||||
|
||||
|
||||
async def get_oauth_token_with_client(
|
||||
browser,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
token_endpoint: str,
|
||||
authorization_endpoint: str,
|
||||
callback_url: str,
|
||||
auth_states: dict,
|
||||
scopes: str = "openid profile email notes:read notes:write",
|
||||
) -> str:
|
||||
"""
|
||||
Helper to obtain OAuth access token using existing client credentials.
|
||||
|
||||
Args:
|
||||
browser: Playwright browser instance
|
||||
client_id: OAuth client ID
|
||||
client_secret: OAuth client secret
|
||||
token_endpoint: Token endpoint URL
|
||||
authorization_endpoint: Authorization endpoint URL
|
||||
callback_url: Callback URL for OAuth redirect
|
||||
auth_states: Dict for storing auth codes (from callback server)
|
||||
scopes: Space-separated list of scopes to request
|
||||
|
||||
Returns:
|
||||
Access token string
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||
|
||||
if not all([nextcloud_host, username, password]):
|
||||
pytest.skip(
|
||||
"OAuth requires NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD"
|
||||
)
|
||||
|
||||
# Generate unique state parameter
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# URL-encode scopes
|
||||
scopes_encoded = quote(scopes, safe="")
|
||||
|
||||
# Construct authorization URL
|
||||
auth_url = (
|
||||
f"{authorization_endpoint}?"
|
||||
f"response_type=code&"
|
||||
f"client_id={client_id}&"
|
||||
f"redirect_uri={quote(callback_url, safe='')}&"
|
||||
f"state={state}&"
|
||||
f"scope={scopes_encoded}"
|
||||
)
|
||||
|
||||
# Browser automation
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
|
||||
current_url = page.url
|
||||
|
||||
# Login if needed
|
||||
if "/login" in current_url or "/index.php/login" in current_url:
|
||||
logger.info("Logging in for DCR test...")
|
||||
await page.wait_for_selector('input[name="user"]', timeout=10000)
|
||||
await page.fill('input[name="user"]', username)
|
||||
await page.fill('input[name="password"]', password)
|
||||
await page.click('button[type="submit"]')
|
||||
await page.wait_for_load_state("networkidle", timeout=60000)
|
||||
|
||||
# Handle consent screen if present
|
||||
try:
|
||||
await _handle_oauth_consent_screen(page, username)
|
||||
except Exception as e:
|
||||
logger.debug(f"No consent screen or already authorized: {e}")
|
||||
|
||||
# Wait for callback
|
||||
logger.info("Waiting for OAuth callback...")
|
||||
timeout_seconds = 30
|
||||
start_time = time.time()
|
||||
while state not in auth_states:
|
||||
if time.time() - start_time > timeout_seconds:
|
||||
raise TimeoutError(
|
||||
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
||||
)
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Got auth code: {auth_code[:20]}...")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
# Exchange code for token
|
||||
logger.info("Exchanging authorization code for access token...")
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
token_response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"redirect_uri": callback_url,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
token_response.raise_for_status()
|
||||
token_data = token_response.json()
|
||||
access_token = token_data.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
raise ValueError(f"No access_token in response: {token_data}")
|
||||
|
||||
logger.info("Successfully obtained access token")
|
||||
return access_token
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_respects_jwt_token_type(
|
||||
anyio_backend,
|
||||
browser,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test that DCR honors token_type=jwt and issues JWT-formatted tokens.
|
||||
|
||||
This verifies:
|
||||
1. Client registration with token_type="jwt" succeeds
|
||||
2. Tokens obtained via this client are JWT format (base64.base64.signature)
|
||||
3. JWT payload contains expected claims (sub, iss, scope, etc.)
|
||||
|
||||
Note: The OIDC app uses lowercase 'jwt' (not 'JWT').
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover OIDC endpoints
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
token_endpoint = oidc_config.get("token_endpoint")
|
||||
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||
|
||||
# Register client with token_type="jwt"
|
||||
logger.info("Registering OAuth client with token_type=jwt...")
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Test - JWT Token Type",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email notes:read notes:write",
|
||||
token_type="jwt",
|
||||
)
|
||||
|
||||
logger.info(f"Registered JWT client: {client_info.client_id[:16]}...")
|
||||
|
||||
# Obtain token via OAuth flow
|
||||
access_token = await get_oauth_token_with_client(
|
||||
browser=browser,
|
||||
client_id=client_info.client_id,
|
||||
client_secret=client_info.client_secret,
|
||||
token_endpoint=token_endpoint,
|
||||
authorization_endpoint=authorization_endpoint,
|
||||
callback_url=callback_url,
|
||||
auth_states=auth_states,
|
||||
)
|
||||
|
||||
# Verify token is JWT format
|
||||
assert is_jwt_format(access_token), (
|
||||
f"Expected JWT format token (3 parts separated by dots), "
|
||||
f"but got token with {len(access_token.split('.'))} parts"
|
||||
)
|
||||
|
||||
# Decode and verify JWT payload
|
||||
payload = decode_jwt_payload(access_token)
|
||||
|
||||
# Verify standard JWT claims
|
||||
assert "sub" in payload, "JWT payload missing 'sub' claim (subject/user ID)"
|
||||
assert "iss" in payload, "JWT payload missing 'iss' claim (issuer)"
|
||||
assert "exp" in payload, "JWT payload missing 'exp' claim (expiration)"
|
||||
assert "iat" in payload, "JWT payload missing 'iat' claim (issued at)"
|
||||
|
||||
# Verify scope claim exists (critical for MCP tool filtering)
|
||||
assert "scope" in payload, "JWT payload missing 'scope' claim"
|
||||
scopes = payload["scope"].split()
|
||||
assert "notes:read" in scopes, "JWT scope claim missing notes:read"
|
||||
assert "notes:write" in scopes, "JWT scope claim missing notes:write"
|
||||
|
||||
logger.info(
|
||||
f"✅ DCR with token_type=jwt works correctly! "
|
||||
f"Token is JWT format with scope claim: {payload['scope']}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_respects_bearer_token_type(
|
||||
anyio_backend,
|
||||
browser,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test that DCR honors token_type=opaque and issues opaque tokens.
|
||||
|
||||
This verifies:
|
||||
1. Client registration with token_type="opaque" succeeds
|
||||
2. Tokens obtained via this client are opaque (NOT JWT format)
|
||||
3. Opaque tokens are simple strings, not base64-encoded structures
|
||||
|
||||
Note: The OIDC app uses 'opaque' or 'jwt' as token_type values (not 'Bearer').
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover OIDC endpoints
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
token_endpoint = oidc_config.get("token_endpoint")
|
||||
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||
|
||||
# Register client with token_type="opaque" (opaque tokens)
|
||||
logger.info("Registering OAuth client with token_type=opaque...")
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Test - Opaque Token Type",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email notes:read notes:write",
|
||||
token_type="opaque",
|
||||
)
|
||||
|
||||
logger.info(f"Registered Opaque token client: {client_info.client_id[:16]}...")
|
||||
|
||||
# Obtain token via OAuth flow
|
||||
access_token = await get_oauth_token_with_client(
|
||||
browser=browser,
|
||||
client_id=client_info.client_id,
|
||||
client_secret=client_info.client_secret,
|
||||
token_endpoint=token_endpoint,
|
||||
authorization_endpoint=authorization_endpoint,
|
||||
callback_url=callback_url,
|
||||
auth_states=auth_states,
|
||||
)
|
||||
|
||||
# Verify token is NOT JWT format
|
||||
assert not is_jwt_format(access_token), (
|
||||
f"Expected opaque token (not JWT format), "
|
||||
f"but got token that looks like JWT: {access_token[:50]}..."
|
||||
)
|
||||
|
||||
# Opaque tokens should be simple strings (not parseable as JWT)
|
||||
try:
|
||||
decode_jwt_payload(access_token)
|
||||
pytest.fail("Opaque token should not be decodable as JWT")
|
||||
except ValueError:
|
||||
# Expected - opaque tokens are not JWT format
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
f"✅ DCR with token_type=opaque works correctly! "
|
||||
f"Token is opaque (not JWT format): {access_token[:30]}..."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_jwt_tokens_embed_scopes_in_payload():
|
||||
"""
|
||||
Test that JWT tokens contain scope information in the payload.
|
||||
|
||||
This is critical for MCP server's dynamic tool filtering, which extracts
|
||||
scopes from JWT token claims without making additional API calls.
|
||||
|
||||
Note: Uses existing shared JWT OAuth client fixture.
|
||||
"""
|
||||
from ...conftest import (
|
||||
DEFAULT_FULL_SCOPES,
|
||||
)
|
||||
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
# This test leverages the existing JWT client creation helper
|
||||
# to verify that JWT tokens contain scope claims
|
||||
|
||||
# The test verifies that when we create a JWT client with specific scopes,
|
||||
# and obtain a token, the token's payload contains those scopes
|
||||
|
||||
# This is already tested implicitly by the scope authorization tests,
|
||||
# but we document the behavior explicitly here for reference
|
||||
|
||||
logger.info(
|
||||
"✅ JWT token scope embedding verified. "
|
||||
f"Expected scopes in JWT payload: {DEFAULT_FULL_SCOPES}"
|
||||
)
|
||||
|
||||
# This test primarily serves as documentation
|
||||
# Actual verification happens in test_dcr_respects_jwt_token_type
|
||||
assert True
|
||||
@@ -0,0 +1,479 @@
|
||||
"""
|
||||
Integration tests for token introspection authorization.
|
||||
|
||||
These tests verify that the introspection endpoint properly enforces
|
||||
authorization rules:
|
||||
1. Client authentication is required (401 if missing)
|
||||
2. Only the token owner can introspect its own tokens
|
||||
3. Only the designated resource server can introspect tokens
|
||||
4. Other clients cannot introspect tokens they don't own or aren't the audience for
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
|
||||
# Import helpers from conftest
|
||||
import time
|
||||
from typing import AsyncGenerator
|
||||
from urllib.parse import quote
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
# Import from the root tests/ conftest.py using relative import
|
||||
from ...conftest import _handle_oauth_consent_screen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def nextcloud_host() -> str:
|
||||
"""Get Nextcloud host from environment."""
|
||||
host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||
return host
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def oidc_endpoints(nextcloud_host: str) -> dict[str, str]:
|
||||
"""Discover OIDC endpoints."""
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
config = response.json()
|
||||
|
||||
return {
|
||||
"token_endpoint": config["token_endpoint"],
|
||||
"authorization_endpoint": config.get("authorization_endpoint"),
|
||||
"introspection_endpoint": config.get("introspection_endpoint"),
|
||||
"registration_endpoint": config.get("registration_endpoint"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def test_oauth_clients(
|
||||
nextcloud_host: str, oidc_endpoints: dict[str, str], oauth_callback_server
|
||||
) -> AsyncGenerator[dict[str, tuple[str, str]], None]:
|
||||
"""
|
||||
Create multiple OAuth clients for introspection testing.
|
||||
|
||||
Returns a dict mapping client names to (client_id, client_secret) tuples.
|
||||
"""
|
||||
from nextcloud_mcp_server.auth.client_registration import register_client
|
||||
|
||||
clients = {}
|
||||
registration_endpoint = oidc_endpoints["registration_endpoint"]
|
||||
|
||||
# Get the correct callback URL from the oauth_callback_server fixture
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Create client A (will be the token owner)
|
||||
logger.info("Creating OAuth client A for introspection testing")
|
||||
client_a = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="Introspection Test Client A",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email",
|
||||
token_type="Bearer", # Use opaque tokens for this test
|
||||
)
|
||||
clients["clientA"] = (client_a.client_id, client_a.client_secret)
|
||||
logger.info(f"Created client A: {client_a.client_id[:16]}...")
|
||||
|
||||
# Create client B (will attempt to introspect client A's tokens)
|
||||
logger.info("Creating OAuth client B for introspection testing")
|
||||
client_b = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="Introspection Test Client B",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email",
|
||||
token_type="Bearer",
|
||||
)
|
||||
clients["clientB"] = (client_b.client_id, client_b.client_secret)
|
||||
logger.info(f"Created client B: {client_b.client_id[:16]}...")
|
||||
|
||||
# Create client C (third party, should not be able to introspect)
|
||||
logger.info("Creating OAuth client C for introspection testing")
|
||||
client_c = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="Introspection Test Client C",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email",
|
||||
token_type="Bearer",
|
||||
)
|
||||
clients["clientC"] = (client_c.client_id, client_c.client_secret)
|
||||
logger.info(f"Created client C: {client_c.client_id[:16]}...")
|
||||
|
||||
yield clients
|
||||
|
||||
# Cleanup is handled by Nextcloud - clients will be removed when tests are done
|
||||
logger.info("Test OAuth clients fixture complete")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_introspection_requires_client_authentication(
|
||||
oidc_endpoints: dict[str, str],
|
||||
):
|
||||
"""
|
||||
Test that the introspection endpoint requires client authentication.
|
||||
|
||||
Expected: 401 UNAUTHORIZED when credentials are missing or invalid.
|
||||
"""
|
||||
introspection_endpoint = oidc_endpoints["introspection_endpoint"]
|
||||
if not introspection_endpoint:
|
||||
pytest.skip("Introspection endpoint not available")
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Test 1: No credentials
|
||||
response = await client.post(
|
||||
introspection_endpoint,
|
||||
data={"token": "some_token"},
|
||||
)
|
||||
assert response.status_code == 401, "Should return 401 without credentials"
|
||||
data = response.json()
|
||||
assert data.get("error") == "invalid_client"
|
||||
|
||||
# Test 2: Invalid credentials
|
||||
response = await client.post(
|
||||
introspection_endpoint,
|
||||
data={"token": "some_token"},
|
||||
auth=("invalid_client", "invalid_secret"),
|
||||
)
|
||||
assert response.status_code == 401, "Should return 401 with invalid credentials"
|
||||
data = response.json()
|
||||
logger.info(f"Invalid client response: {data}")
|
||||
# Response may be either {"error": "invalid_client"} or {"message": "..."}
|
||||
# Both are acceptable as long as we get 401
|
||||
assert "error" in data or "message" in data, "Should return error information"
|
||||
|
||||
|
||||
async def _obtain_token_for_client(
|
||||
browser,
|
||||
oauth_callback_server,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
token_endpoint: str,
|
||||
authorization_endpoint: str,
|
||||
scope: str = "openid profile email",
|
||||
resource: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Helper to obtain an OAuth token using existing callback server and playwright automation.
|
||||
|
||||
Reuses the pattern from conftest.py's playwright_oauth_token fixture.
|
||||
"""
|
||||
username = os.getenv("NEXTCLOUD_USERNAME", "admin")
|
||||
password = os.getenv("NEXTCLOUD_PASSWORD", "admin")
|
||||
|
||||
# Get callback server from fixture
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Generate unique state parameter
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Construct authorization URL
|
||||
auth_url_parts = [
|
||||
f"{authorization_endpoint}?",
|
||||
"response_type=code&",
|
||||
f"client_id={client_id}&",
|
||||
f"redirect_uri={quote(callback_url, safe='')}&",
|
||||
f"state={state}&",
|
||||
f"scope={quote(scope, safe='')}",
|
||||
]
|
||||
|
||||
if resource:
|
||||
auth_url_parts.append(f"&resource={quote(resource, safe='')}")
|
||||
|
||||
auth_url = "".join(auth_url_parts)
|
||||
|
||||
logger.info(f"Obtaining token for client {client_id[:16]}... with scopes={scope}")
|
||||
if resource:
|
||||
logger.info(f" Resource parameter: {resource[:16]}...")
|
||||
|
||||
# Browser automation (same pattern as conftest.py)
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
logger.debug(f"Navigating to: {auth_url[:100]}...")
|
||||
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
|
||||
current_url = page.url
|
||||
logger.debug(f"Current URL after navigation: {current_url}")
|
||||
|
||||
# Handle login if needed
|
||||
if "/login" in current_url or "/index.php/login" in current_url:
|
||||
logger.info("Login page detected, filling credentials...")
|
||||
await page.wait_for_selector('input[name="user"]', timeout=10000)
|
||||
await page.fill('input[name="user"]', username)
|
||||
await page.fill('input[name="password"]', password)
|
||||
await page.click('button[type="submit"]')
|
||||
await page.wait_for_load_state("networkidle", timeout=60000)
|
||||
current_url = page.url
|
||||
logger.info(f"After login: {current_url}")
|
||||
|
||||
# Wait a bit for page to fully render after login
|
||||
await anyio.sleep(2)
|
||||
current_url = page.url
|
||||
logger.info(f"After waiting, current URL: {current_url}")
|
||||
|
||||
# Check page content for debugging
|
||||
page_content = await page.content()
|
||||
has_consent_div = "#oidc-consent" in page_content
|
||||
logger.info(f"Page has #oidc-consent div: {has_consent_div}")
|
||||
|
||||
# Handle consent screen using the helper from conftest
|
||||
try:
|
||||
consent_handled = await _handle_oauth_consent_screen(page, username)
|
||||
logger.info(f"Consent screen handled: {consent_handled}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error handling consent screen: {e}")
|
||||
# Take screenshot for debugging
|
||||
await page.screenshot(path=f"/tmp/consent_error_{state[:8]}.png")
|
||||
logger.error("Consent error screenshot saved")
|
||||
raise
|
||||
|
||||
# Wait for callback server to receive auth code
|
||||
logger.info("Waiting for callback server to receive auth code...")
|
||||
timeout_seconds = 30
|
||||
start_time = time.time()
|
||||
while state not in auth_states:
|
||||
if time.time() - start_time > timeout_seconds:
|
||||
screenshot_path = (
|
||||
f"/tmp/oauth_introspection_test_timeout_{state[:8]}.png"
|
||||
)
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(f"Timeout! Screenshot saved to {screenshot_path}")
|
||||
logger.error(f"Current URL: {page.url}")
|
||||
raise TimeoutError(
|
||||
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
||||
)
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Successfully received auth code: {auth_code[:20]}...")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
# Exchange code for token
|
||||
logger.debug("Exchanging authorization code for access token...")
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
token_response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"redirect_uri": callback_url,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
token_response.raise_for_status()
|
||||
token_data = token_response.json()
|
||||
access_token = token_data.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
raise ValueError(f"No access_token in response: {token_data}")
|
||||
|
||||
logger.info("Successfully obtained access token")
|
||||
return access_token
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_client_cannot_introspect_other_clients_tokens(
|
||||
playwright_oauth_token: str,
|
||||
shared_oauth_client_credentials: tuple,
|
||||
test_oauth_clients: dict[str, tuple[str, str]],
|
||||
oidc_endpoints: dict[str, str],
|
||||
):
|
||||
"""
|
||||
Test that one client cannot introspect tokens owned by another client.
|
||||
|
||||
This test uses a pre-authorized shared OAuth client (with existing token)
|
||||
and verifies that a different client cannot introspect that token.
|
||||
|
||||
Expected: introspection returns {active: false} to not reveal token existence.
|
||||
"""
|
||||
introspection_endpoint = oidc_endpoints["introspection_endpoint"]
|
||||
if not introspection_endpoint:
|
||||
pytest.skip("Introspection endpoint not available")
|
||||
|
||||
# Use the shared OAuth client's token (pre-authorized, working)
|
||||
access_token = playwright_oauth_token
|
||||
shared_client_id, shared_client_secret, _, _, _ = shared_oauth_client_credentials
|
||||
|
||||
# Get a different client to try to introspect
|
||||
different_client_id, different_client_secret = test_oauth_clients["clientB"]
|
||||
|
||||
logger.info(
|
||||
f"Testing introspection with shared client token: {access_token[:16]}..."
|
||||
)
|
||||
logger.info(f"Shared client ID: {shared_client_id[:16]}...")
|
||||
logger.info(f"Different client ID: {different_client_id[:16]}...")
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Test 1: The owning client (shared client) can introspect its own token
|
||||
response = await client.post(
|
||||
introspection_endpoint,
|
||||
data={"token": access_token},
|
||||
auth=(shared_client_id, shared_client_secret),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Owner client introspection response: {data}")
|
||||
assert data.get("active") is True, (
|
||||
"Owner client should be able to introspect its own token"
|
||||
)
|
||||
|
||||
# Test 2: A different client CANNOT introspect the shared client's token
|
||||
response = await client.post(
|
||||
introspection_endpoint,
|
||||
data={"token": access_token},
|
||||
auth=(different_client_id, different_client_secret),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Different client introspection response: {data}")
|
||||
assert data.get("active") is False, (
|
||||
"Different client should NOT be able to introspect another client's token"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_introspection_with_resource_parameter(
|
||||
browser,
|
||||
oauth_callback_server,
|
||||
test_oauth_clients: dict[str, tuple[str, str]],
|
||||
oidc_endpoints: dict[str, str],
|
||||
nextcloud_host: str,
|
||||
):
|
||||
"""
|
||||
Test that the resource server (specified via 'resource' parameter) can introspect tokens.
|
||||
|
||||
This test verifies that when a token is issued with resource=clientB,
|
||||
clientB can introspect it even though it's owned by clientA.
|
||||
|
||||
This requires obtaining a token with the 'resource' parameter set via authorization code grant.
|
||||
|
||||
Uses playwright automation to obtain real tokens.
|
||||
"""
|
||||
introspection_endpoint = oidc_endpoints["introspection_endpoint"]
|
||||
if not introspection_endpoint:
|
||||
pytest.skip("Introspection endpoint not available")
|
||||
|
||||
client_a_id, client_a_secret = test_oauth_clients["clientA"]
|
||||
client_b_id, client_b_secret = test_oauth_clients["clientB"]
|
||||
client_c_id, client_c_secret = test_oauth_clients["clientC"]
|
||||
|
||||
token_endpoint = oidc_endpoints["token_endpoint"]
|
||||
authorization_endpoint = oidc_endpoints.get("authorization_endpoint")
|
||||
if not authorization_endpoint:
|
||||
pytest.skip("Authorization endpoint not available")
|
||||
|
||||
# Obtain a token for client A with resource parameter set to client B
|
||||
try:
|
||||
access_token = await _obtain_token_for_client(
|
||||
browser=browser,
|
||||
oauth_callback_server=oauth_callback_server,
|
||||
client_id=client_a_id,
|
||||
client_secret=client_a_secret,
|
||||
token_endpoint=token_endpoint,
|
||||
authorization_endpoint=authorization_endpoint,
|
||||
scope="openid profile email",
|
||||
resource=client_b_id, # Set client B as the resource server
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to obtain token with resource parameter: {e}")
|
||||
pytest.skip(f"Cannot obtain test token with resource parameter: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Obtained access token from client A with resource={client_b_id}: {access_token[:16]}..."
|
||||
)
|
||||
|
||||
# Test introspection
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Test 1: Client A (owner) can introspect its own token
|
||||
response = await client.post(
|
||||
introspection_endpoint,
|
||||
data={"token": access_token},
|
||||
auth=(client_a_id, client_a_secret),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Client A (owner) introspection response: {data}")
|
||||
assert data.get("active") is True, (
|
||||
"Client A (owner) should be able to introspect its own token"
|
||||
)
|
||||
|
||||
# Test 2: Client B (resource server) can introspect the token
|
||||
response = await client.post(
|
||||
introspection_endpoint,
|
||||
data={"token": access_token},
|
||||
auth=(client_b_id, client_b_secret),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Client B (resource server) introspection response: {data}")
|
||||
assert data.get("active") is True, (
|
||||
"Client B (resource server) should be able to introspect token intended for it"
|
||||
)
|
||||
|
||||
# Verify the resource field in the response matches client B
|
||||
logger.info(f"Full introspection response from Client B: {data}")
|
||||
|
||||
# Test 3: Client C CANNOT introspect the token (not owner, not resource server)
|
||||
response = await client.post(
|
||||
introspection_endpoint,
|
||||
data={"token": access_token},
|
||||
auth=(client_c_id, client_c_secret),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Client C (third party) introspection response: {data}")
|
||||
assert data.get("active") is False, (
|
||||
"Client C should NOT be able to introspect token (not owner or resource server)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_introspection_returns_inactive_for_invalid_token(
|
||||
test_oauth_clients: dict[str, tuple[str, str]],
|
||||
oidc_endpoints: dict[str, str],
|
||||
):
|
||||
"""
|
||||
Test that introspection returns {active: false} for invalid/unknown tokens.
|
||||
|
||||
This is important for security - we shouldn't reveal whether a token exists or not.
|
||||
"""
|
||||
introspection_endpoint = oidc_endpoints["introspection_endpoint"]
|
||||
if not introspection_endpoint:
|
||||
pytest.skip("Introspection endpoint not available")
|
||||
|
||||
client_a_id, client_a_secret = test_oauth_clients["clientA"]
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Test with a fake token
|
||||
response = await client.post(
|
||||
introspection_endpoint,
|
||||
data={"token": "completely_fake_token_12345"},
|
||||
auth=(client_a_id, client_a_secret),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Introspection response for fake token: {data}")
|
||||
assert data.get("active") is False, (
|
||||
"Should return active=false for invalid token"
|
||||
)
|
||||
# Should NOT return any other information
|
||||
assert len(data) == 1, "Should only return 'active' field for invalid token"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run with: uv run pytest tests/server/test_introspection_authorization.py -v -s
|
||||
pytest.main([__file__, "-v", "-s", "-m", "integration"])
|
||||
@@ -0,0 +1,566 @@
|
||||
"""Keycloak External IdP Integration Tests.
|
||||
|
||||
Tests verify ADR-002 external identity provider integration where:
|
||||
1. Keycloak acts as external OAuth/OIDC provider
|
||||
2. MCP server validates tokens via Nextcloud user_oidc app
|
||||
3. Nextcloud auto-provisions users from Keycloak token claims
|
||||
4. MCP tools execute successfully with Keycloak tokens
|
||||
|
||||
Architecture:
|
||||
MCP Client → Keycloak (OAuth) → MCP Server → Nextcloud user_oidc (validates) → APIs
|
||||
|
||||
Tests:
|
||||
1. Keycloak OAuth token acquisition via Playwright
|
||||
2. MCP client connection to mcp-keycloak service (port 8002)
|
||||
3. Token validation through Nextcloud user_oidc app
|
||||
4. MCP tool execution with Keycloak tokens
|
||||
5. User auto-provisioning from Keycloak claims
|
||||
6. Scope-based tool filtering with Keycloak JWT tokens
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.keycloak]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# OAuth Token Acquisition Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_keycloak_oauth_token_acquisition(keycloak_oauth_token):
|
||||
"""Test that Playwright can obtain OAuth token from Keycloak.
|
||||
|
||||
Verifies:
|
||||
- Playwright automation handles Keycloak login page (input#username, input#password)
|
||||
- Keycloak consent screen is handled correctly
|
||||
- Authorization code is exchanged for access token
|
||||
- Token is returned successfully
|
||||
|
||||
This is a foundational test - if this fails, all other Keycloak tests will fail.
|
||||
"""
|
||||
assert keycloak_oauth_token is not None
|
||||
assert isinstance(keycloak_oauth_token, str)
|
||||
assert len(keycloak_oauth_token) > 100 # Tokens should be substantial length
|
||||
|
||||
logger.info(
|
||||
f"✓ Keycloak OAuth token acquired (length: {len(keycloak_oauth_token)})"
|
||||
)
|
||||
logger.info(f" Token prefix: {keycloak_oauth_token[:50]}...")
|
||||
|
||||
|
||||
async def test_keycloak_oauth_client_credentials_discovery(
|
||||
keycloak_oauth_client_credentials,
|
||||
):
|
||||
"""Test Keycloak OIDC discovery and credential loading.
|
||||
|
||||
Verifies:
|
||||
- OIDC discovery endpoint is accessible
|
||||
- Token and authorization endpoints are discovered
|
||||
- Static client credentials are loaded from environment
|
||||
- Callback server is initialized
|
||||
"""
|
||||
(
|
||||
client_id,
|
||||
client_secret,
|
||||
callback_url,
|
||||
token_endpoint,
|
||||
authorization_endpoint,
|
||||
) = keycloak_oauth_client_credentials
|
||||
|
||||
assert client_id == "nextcloud-mcp-server"
|
||||
assert client_secret == "mcp-secret-change-in-production"
|
||||
assert callback_url.startswith("http://")
|
||||
# With --hostname-backchannel-dynamic, external clients see localhost:8888
|
||||
assert "localhost:8888" in token_endpoint or "keycloak" in token_endpoint
|
||||
assert (
|
||||
"localhost:8888" in authorization_endpoint
|
||||
or "keycloak" in authorization_endpoint
|
||||
)
|
||||
assert "/realms/nextcloud-mcp/" in token_endpoint
|
||||
|
||||
logger.info("✓ Keycloak OIDC discovery successful")
|
||||
logger.info(f" Client ID: {client_id}")
|
||||
logger.info(f" Token endpoint: {token_endpoint}")
|
||||
logger.info(f" Authorization endpoint: {authorization_endpoint}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MCP Server Connectivity Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_mcp_client_connects_to_keycloak_server(nc_mcp_keycloak_client):
|
||||
"""Test MCP client can connect to mcp-keycloak service (port 8002).
|
||||
|
||||
Verifies:
|
||||
- MCP client session is established
|
||||
- Server responds to list_tools request
|
||||
- Tools are available for use
|
||||
"""
|
||||
result = await nc_mcp_keycloak_client.list_tools()
|
||||
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
logger.info(
|
||||
f"✓ MCP client connected to Keycloak server with {len(result.tools)} tools"
|
||||
)
|
||||
|
||||
|
||||
async def test_external_idp_server_initialization(nc_mcp_keycloak_client):
|
||||
"""Test that MCP server correctly initializes with external IdP configuration.
|
||||
|
||||
Verifies:
|
||||
- Server auto-detects external IdP mode (issuer != Nextcloud host)
|
||||
- Server reports correct provider type
|
||||
- All expected tools are registered
|
||||
|
||||
The server should log messages like:
|
||||
- "✓ Detected external IdP mode (issuer: http://keycloak:8080/realms/nextcloud-mcp != Nextcloud: http://app:80)"
|
||||
"""
|
||||
result = await nc_mcp_keycloak_client.list_tools()
|
||||
|
||||
# Verify we have a full set of tools (not filtered to specific apps)
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
|
||||
# Should have tools from multiple apps
|
||||
has_notes = any("notes" in name for name in tool_names)
|
||||
has_calendar = any("calendar" in name for name in tool_names)
|
||||
has_files = any("webdav" in name for name in tool_names)
|
||||
|
||||
assert has_notes, "Missing Notes tools"
|
||||
assert has_calendar, "Missing Calendar tools"
|
||||
assert has_files, "Missing WebDAV/Files tools"
|
||||
|
||||
logger.info("✓ MCP server initialized with external IdP mode")
|
||||
logger.info(f" Tools from multiple apps detected: {len(result.tools)} total")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Token Validation Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_external_idp_token_validation(nc_mcp_keycloak_client):
|
||||
"""Test that Keycloak tokens are validated via Nextcloud user_oidc app.
|
||||
|
||||
Token flow:
|
||||
1. Keycloak issues OAuth token
|
||||
2. MCP client sends token to MCP server
|
||||
3. MCP server passes token to Nextcloud user_oidc app
|
||||
4. user_oidc validates token with Keycloak (JWKS or introspection)
|
||||
5. Nextcloud returns user info to MCP server
|
||||
6. MCP server uses token to access Nextcloud APIs
|
||||
|
||||
This test verifies the entire flow works.
|
||||
"""
|
||||
# Execute a read operation (requires token validation)
|
||||
result = await nc_mcp_keycloak_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# Successful response means token was validated and user was authenticated
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info("✓ Keycloak token validated successfully via Nextcloud user_oidc app")
|
||||
logger.info(f" Tool execution returned {len(response_data['results'])} results")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tool Execution Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_tools_work_with_keycloak_token(nc_mcp_keycloak_client):
|
||||
"""Test that MCP tools execute successfully with Keycloak OAuth tokens.
|
||||
|
||||
Verifies end-to-end functionality:
|
||||
- Read operations work (nc_notes_search_notes)
|
||||
- Write operations work (nc_notes_create_note)
|
||||
- Different apps work (Notes, Calendar, Files)
|
||||
"""
|
||||
# Test 1: Read operation (Notes)
|
||||
search_result = await nc_mcp_keycloak_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
assert search_result.isError is False
|
||||
logger.info("✓ Read operation successful (nc_notes_search_notes)")
|
||||
|
||||
# Test 2: Write operation (Notes)
|
||||
create_result = await nc_mcp_keycloak_client.call_tool(
|
||||
"nc_notes_create_note",
|
||||
arguments={
|
||||
"title": "Keycloak Test Note",
|
||||
"content": "Created via external IdP token",
|
||||
"category": "Test",
|
||||
},
|
||||
)
|
||||
assert create_result.isError is False
|
||||
create_data = json.loads(create_result.content[0].text)
|
||||
note_id = create_data["id"]
|
||||
logger.info(f"✓ Write operation successful (created note {note_id})")
|
||||
|
||||
# Test 3: Different app (Calendar)
|
||||
calendar_result = await nc_mcp_keycloak_client.call_tool(
|
||||
"nc_calendar_list_calendars", arguments={}
|
||||
)
|
||||
assert calendar_result.isError is False
|
||||
logger.info("✓ Calendar tool execution successful")
|
||||
|
||||
# Test 4: File operations (WebDAV)
|
||||
files_result = await nc_mcp_keycloak_client.call_tool(
|
||||
"nc_webdav_list_directory", arguments={"path": "/"}
|
||||
)
|
||||
assert files_result.isError is False
|
||||
logger.info("✓ WebDAV tool execution successful")
|
||||
|
||||
# Cleanup: Delete test note
|
||||
await nc_mcp_keycloak_client.call_tool(
|
||||
"nc_notes_delete_note", arguments={"note_id": note_id}
|
||||
)
|
||||
logger.info(f"✓ Cleanup: Deleted test note {note_id}")
|
||||
|
||||
|
||||
async def test_keycloak_token_persistence(nc_mcp_keycloak_client):
|
||||
"""Test that Keycloak token works across multiple operations.
|
||||
|
||||
Verifies:
|
||||
- Token is properly cached by MCP server
|
||||
- Token can be reused for multiple API calls
|
||||
- No re-authentication is required between calls
|
||||
"""
|
||||
# Execute multiple operations with same session
|
||||
operations = [
|
||||
("nc_notes_search_notes", {"query": ""}),
|
||||
("nc_calendar_list_calendars", {}),
|
||||
("nc_webdav_list_directory", {"path": "/"}),
|
||||
]
|
||||
|
||||
for tool_name, arguments in operations:
|
||||
result = await nc_mcp_keycloak_client.call_tool(tool_name, arguments=arguments)
|
||||
assert result.isError is False, f"Failed to execute {tool_name}"
|
||||
logger.info(f"✓ {tool_name} executed successfully")
|
||||
|
||||
logger.info("✓ Keycloak token persistence verified (3 operations with same token)")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# User Provisioning Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_user_auto_provisioning(nc_client: NextcloudClient, keycloak_oauth_token):
|
||||
"""Test that Nextcloud validates users from Keycloak token claims.
|
||||
|
||||
When a user authenticates with Keycloak, Nextcloud's user_oidc app
|
||||
validates the token and authenticates the user. In this test setup,
|
||||
the Keycloak 'admin' user maps to the Nextcloud 'admin' user.
|
||||
|
||||
Verification:
|
||||
1. User exists in Nextcloud after OAuth authentication
|
||||
2. User can access Nextcloud APIs with Keycloak token
|
||||
3. Bearer token validation is working correctly
|
||||
|
||||
Note: With bearer-provisioning enabled, user_oidc would auto-provision
|
||||
new users from token claims, but since we use 'admin' in both Keycloak
|
||||
and Nextcloud, they map to the same user.
|
||||
"""
|
||||
# Get list of users (returns List[str] of user IDs)
|
||||
user_ids = await nc_client.users.search_users()
|
||||
|
||||
logger.info(f"Found {len(user_ids)} users in Nextcloud")
|
||||
logger.info(f"Users: {user_ids}")
|
||||
|
||||
# Verify the admin user exists (used for authentication)
|
||||
assert "admin" in user_ids, "Expected 'admin' user to exist in Nextcloud"
|
||||
|
||||
# Verify we can access APIs with the Keycloak token (already tested in previous tests)
|
||||
# The fact that we got this far means bearer token validation is working
|
||||
|
||||
logger.info("✓ User authentication and bearer token validation verified")
|
||||
logger.info(f" Total users: {len(user_ids)}")
|
||||
logger.info(" Bearer provisioning is enabled and working correctly")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Scope-Based Authorization Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_scope_filtering_with_keycloak(nc_mcp_keycloak_client):
|
||||
"""Test that tool filtering works correctly with Keycloak JWT scopes.
|
||||
|
||||
Keycloak tokens should include scopes in JWT payload (if JWT format).
|
||||
The MCP server should filter tools based on these scopes.
|
||||
|
||||
Expected scopes (from docker-compose.yml):
|
||||
- openid profile email offline_access
|
||||
- notes:read notes:write
|
||||
- calendar:read calendar:write
|
||||
- contacts:read contacts:write
|
||||
- etc.
|
||||
|
||||
Tools should be filtered accordingly.
|
||||
"""
|
||||
result = await nc_mcp_keycloak_client.list_tools()
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
|
||||
# With full scopes, all app tools should be available
|
||||
expected_tools = [
|
||||
"nc_notes_get_note", # notes:read
|
||||
"nc_notes_create_note", # notes:write
|
||||
"nc_calendar_list_calendars", # calendar:read
|
||||
"nc_calendar_create_event", # calendar:write
|
||||
"nc_webdav_list_directory", # files:read
|
||||
"nc_webdav_write_file", # files:write
|
||||
]
|
||||
|
||||
for tool_name in expected_tools:
|
||||
assert tool_name in tool_names, f"Expected tool {tool_name} not found"
|
||||
|
||||
logger.info("✓ Scope-based tool filtering working with Keycloak tokens")
|
||||
logger.info(f" Available tools: {len(tool_names)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Error Handling Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_keycloak_error_handling(nc_mcp_keycloak_client):
|
||||
"""Test error handling with Keycloak tokens.
|
||||
|
||||
Verifies:
|
||||
- Invalid operations return proper errors
|
||||
- Token validation errors are handled correctly
|
||||
- API errors propagate correctly through the chain
|
||||
"""
|
||||
# Try to get a non-existent note
|
||||
result = await nc_mcp_keycloak_client.call_tool(
|
||||
"nc_notes_get_note", arguments={"note_id": 999999}
|
||||
)
|
||||
|
||||
# Should get an error (note doesn't exist)
|
||||
assert result.isError is True
|
||||
logger.info(
|
||||
"✓ Keycloak OAuth server correctly handles errors for invalid operations"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Documentation Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_external_idp_architecture():
|
||||
"""Document the external IdP architecture (ADR-002).
|
||||
|
||||
This test captures the design and flow for reference.
|
||||
"""
|
||||
architecture = {
|
||||
"flow": [
|
||||
"1. User authenticates with Keycloak (external IdP)",
|
||||
"2. Keycloak issues OAuth access token with scopes",
|
||||
"3. MCP client uses token to authenticate with MCP server",
|
||||
"4. MCP server receives token and passes to Nextcloud",
|
||||
"5. Nextcloud user_oidc app validates token with Keycloak",
|
||||
"6. Nextcloud auto-provisions user from token claims (if first login)",
|
||||
"7. Nextcloud returns validated user info to MCP server",
|
||||
"8. MCP server executes tool using validated token",
|
||||
],
|
||||
"components": {
|
||||
"keycloak": "External OAuth/OIDC provider (port 8888)",
|
||||
"mcp_server": "MCP server with external IdP config (port 8002)",
|
||||
"nextcloud": "API server with user_oidc app (port 8080)",
|
||||
"user_oidc": "Nextcloud app that validates external IdP tokens",
|
||||
},
|
||||
"configuration": {
|
||||
"keycloak_realm": "nextcloud-mcp",
|
||||
"keycloak_client": "nextcloud-mcp-server",
|
||||
"nextcloud_provider": "keycloak (via user_oidc app)",
|
||||
"token_validation": "Keycloak JWKS or introspection endpoint",
|
||||
},
|
||||
"advantages": [
|
||||
"No admin credentials needed in MCP server",
|
||||
"Centralized identity management",
|
||||
"Standards-based (RFC 6749, RFC 7662, RFC 9068)",
|
||||
"Supports enterprise IdPs (Keycloak, Auth0, Okta, etc.)",
|
||||
"User auto-provisioning from IdP claims",
|
||||
],
|
||||
}
|
||||
|
||||
logger.info("External IdP Architecture (ADR-002):")
|
||||
logger.info(json.dumps(architecture, indent=2))
|
||||
|
||||
assert True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Scope-Based Authorization Tests (JWT Token Filtering)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_keycloak_read_only_token_filters_write_tools(
|
||||
nc_mcp_keycloak_client_read_only,
|
||||
):
|
||||
"""Test that a Keycloak token with only read scopes filters out write tools."""
|
||||
# Connect with token that has only read scopes
|
||||
result = await nc_mcp_keycloak_client_read_only.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"Keycloak read-only token sees {len(tool_names)} tools")
|
||||
|
||||
# Verify read tools are present
|
||||
expected_read_tools = [
|
||||
"nc_notes_get_note", # notes:read
|
||||
"nc_notes_search_notes", # notes:read
|
||||
"nc_calendar_list_calendars", # calendar:read
|
||||
"nc_calendar_get_event", # calendar:read
|
||||
]
|
||||
|
||||
for tool in expected_read_tools:
|
||||
assert tool in tool_names, f"Expected read tool {tool} not found in tool list"
|
||||
|
||||
# Verify write tools are NOT present (filtered out)
|
||||
write_tools_should_be_filtered = [
|
||||
"nc_notes_create_note", # notes:write
|
||||
"nc_notes_update_note", # notes:write
|
||||
"nc_notes_delete_note", # notes:write
|
||||
"nc_calendar_create_event", # calendar:write
|
||||
"nc_calendar_update_event", # calendar:write
|
||||
"nc_calendar_delete_event", # calendar:write
|
||||
]
|
||||
|
||||
for tool in write_tools_should_be_filtered:
|
||||
assert tool not in tool_names, (
|
||||
f"Write tool {tool} should be filtered out but was found in tool list"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✅ Keycloak read-only token properly filters tools: {len(tool_names)} read tools visible, "
|
||||
f"write tools hidden"
|
||||
)
|
||||
|
||||
|
||||
async def test_keycloak_write_only_token_filters_read_tools(
|
||||
nc_mcp_keycloak_client_write_only,
|
||||
):
|
||||
"""Test that a Keycloak token with only write scopes filters out read tools."""
|
||||
# Connect with token that has only write scopes
|
||||
result = await nc_mcp_keycloak_client_write_only.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"Keycloak write-only token sees {len(tool_names)} tools")
|
||||
|
||||
# Verify write tools are present
|
||||
expected_write_tools = [
|
||||
"nc_notes_create_note", # notes:write
|
||||
"nc_notes_update_note", # notes:write
|
||||
"nc_notes_delete_note", # notes:write
|
||||
"nc_calendar_create_event", # calendar:write
|
||||
"nc_calendar_update_event", # calendar:write
|
||||
"nc_calendar_delete_event", # calendar:write
|
||||
]
|
||||
|
||||
for tool in expected_write_tools:
|
||||
assert tool in tool_names, f"Expected write tool {tool} not found in tool list"
|
||||
|
||||
# Verify read-only tools are NOT present (write-only scope)
|
||||
read_tools_should_be_filtered = [
|
||||
"nc_notes_get_note", # notes:read
|
||||
"nc_notes_search_notes", # notes:read
|
||||
"nc_calendar_list_calendars", # calendar:read
|
||||
"nc_calendar_get_event", # calendar:read
|
||||
]
|
||||
|
||||
for tool in read_tools_should_be_filtered:
|
||||
assert tool not in tool_names, (
|
||||
f"Read tool {tool} should be filtered out but was found in tool list"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✅ Keycloak write-only token properly filters tools: {len(tool_names)} write tools visible, "
|
||||
f"read tools hidden"
|
||||
)
|
||||
|
||||
|
||||
async def test_keycloak_full_access_token_shows_all_tools(nc_mcp_keycloak_client):
|
||||
"""Test that a Keycloak token with both read and write scopes sees all tools."""
|
||||
# Connect with token that has both read and write scopes
|
||||
result = await nc_mcp_keycloak_client.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"Keycloak full access token sees {len(tool_names)} tools")
|
||||
|
||||
# Verify both read and write tools are present
|
||||
expected_read_tools = [
|
||||
"nc_notes_get_note", # notes:read
|
||||
"nc_notes_search_notes", # notes:read
|
||||
"nc_calendar_list_calendars", # calendar:read
|
||||
]
|
||||
|
||||
expected_write_tools = [
|
||||
"nc_notes_create_note", # notes:write
|
||||
"nc_calendar_create_event", # calendar:write
|
||||
]
|
||||
|
||||
for tool in expected_read_tools:
|
||||
assert tool in tool_names, f"Expected read tool {tool} not found"
|
||||
|
||||
for tool in expected_write_tools:
|
||||
assert tool in tool_names, f"Expected write tool {tool} not found"
|
||||
|
||||
# Should have all 90+ tools (both read and write)
|
||||
assert len(tool_names) >= 90
|
||||
|
||||
logger.info(
|
||||
f"✅ Keycloak full access token sees all tools: {len(tool_names)} total (read + write)"
|
||||
)
|
||||
|
||||
|
||||
async def test_keycloak_no_custom_scopes_returns_zero_tools(
|
||||
nc_mcp_keycloak_client_no_custom_scopes,
|
||||
):
|
||||
"""
|
||||
Test that a Keycloak JWT token with only OIDC default scopes returns 0 tools.
|
||||
|
||||
This tests the security behavior when a user declines to grant custom scopes during consent.
|
||||
Expected: JWT token has scopes=['openid', 'profile', 'email'] but no custom scopes.
|
||||
All tools require at least one custom scope, so they should all be filtered out.
|
||||
"""
|
||||
# Connect with JWT token that has NO custom scopes (only openid, profile, email)
|
||||
result = await nc_mcp_keycloak_client_no_custom_scopes.list_tools()
|
||||
assert result is not None
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(
|
||||
f"Keycloak JWT token with no custom scopes sees {len(tool_names)} tools (should be 0)"
|
||||
)
|
||||
|
||||
# All tools require custom scopes, so should be filtered out
|
||||
assert len(tool_names) == 0, (
|
||||
f"Expected 0 tools but got {len(tool_names)}: {tool_names[:10]}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Keycloak JWT token without custom scopes correctly returns 0 tools (all filtered out)"
|
||||
)
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Core OAuth integration tests.
|
||||
|
||||
Consolidated from:
|
||||
- test_mcp_oauth.py: Basic OAuth connectivity
|
||||
- test_mcp_oauth_jwt.py: JWT-specific operations
|
||||
- test_jwt_tokens.py: JWT token structure validation
|
||||
|
||||
Tests verify:
|
||||
1. OAuth server connectivity and tool listing
|
||||
2. Tool execution with OAuth tokens
|
||||
3. JWT token structure and claims
|
||||
4. Multiple operations with same token (persistence)
|
||||
5. Error handling with OAuth
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
def decode_jwt_without_verification(token: str) -> dict:
|
||||
"""Decode JWT token without signature verification (for inspection only).
|
||||
|
||||
Returns:
|
||||
Dict with header and payload
|
||||
"""
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}")
|
||||
|
||||
# Decode header
|
||||
header = json.loads(
|
||||
base64.urlsafe_b64decode(parts[0] + "=" * (4 - len(parts[0]) % 4))
|
||||
)
|
||||
|
||||
# Decode payload
|
||||
payload = json.loads(
|
||||
base64.urlsafe_b64decode(parts[1] + "=" * (4 - len(parts[1]) % 4))
|
||||
)
|
||||
|
||||
return {
|
||||
"header": header,
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Basic OAuth Connectivity Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_mcp_oauth_server_connection(nc_mcp_oauth_client):
|
||||
"""Test connection to OAuth-enabled MCP server."""
|
||||
result = await nc_mcp_oauth_client.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
logger.info(f"OAuth MCP server has {len(result.tools)} tools available")
|
||||
|
||||
|
||||
async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client):
|
||||
"""Test executing a tool on the OAuth-enabled MCP server."""
|
||||
# Example: Execute the 'nc_notes_search_notes' tool
|
||||
result = await nc_mcp_oauth_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# The search response should have a 'results' field containing the list
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully executed 'nc_notes_search_notes' tool on OAuth MCP server and got {len(response_data['results'])} notes."
|
||||
)
|
||||
|
||||
|
||||
async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client):
|
||||
"""Test that MCP OAuth client via Playwright can execute tools."""
|
||||
# Test: Execute the 'nc_notes_search_notes' tool
|
||||
result = await nc_mcp_oauth_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# The search response should have a 'results' field containing the list
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes."
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# JWT-Specific Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_jwt_tool_list_operations(nc_mcp_oauth_jwt_client):
|
||||
"""Test that list_tools works with JWT authentication and returns expected tools.
|
||||
|
||||
This test verifies that tools are properly filtered based on per-app scopes:
|
||||
- notes:read/write → Notes app tools
|
||||
- calendar:read/write → Calendar app tools
|
||||
- files:read/write → WebDAV/Files app tools
|
||||
- etc.
|
||||
"""
|
||||
result = await nc_mcp_oauth_jwt_client.list_tools()
|
||||
|
||||
# Verify we have tools
|
||||
assert len(result.tools) > 0
|
||||
|
||||
# Verify expected tools exist based on configured scopes
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
|
||||
# Notes tools (require notes:read and notes:write)
|
||||
assert "nc_notes_get_note" in tool_names, "Missing nc_notes_get_note (notes:read)"
|
||||
assert "nc_notes_create_note" in tool_names, (
|
||||
"Missing nc_notes_create_note (notes:write)"
|
||||
)
|
||||
|
||||
# Calendar tools (require calendar:read and calendar:write)
|
||||
assert "nc_calendar_list_calendars" in tool_names, (
|
||||
"Missing nc_calendar_list_calendars (calendar:read)"
|
||||
)
|
||||
assert "nc_calendar_create_event" in tool_names, (
|
||||
"Missing nc_calendar_create_event (calendar:write)"
|
||||
)
|
||||
|
||||
# Verify we have a reasonable number of tools for the configured scopes
|
||||
# With notes + calendar scopes, expect ~20-30 tools
|
||||
assert len(tool_names) >= 20, (
|
||||
f"Expected at least 20 tools with notes+calendar scopes, got {len(tool_names)}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"JWT OAuth server provides {len(result.tools)} tools with configured per-app scopes"
|
||||
)
|
||||
|
||||
|
||||
async def test_jwt_multiple_operations(nc_mcp_oauth_jwt_client):
|
||||
"""Test multiple operations with same JWT token to verify token persistence.
|
||||
|
||||
JWT tokens should work across multiple tool calls without re-authentication,
|
||||
demonstrating that the token is properly cached and reused.
|
||||
"""
|
||||
# First operation: Search notes
|
||||
result1 = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
assert result1.isError is False
|
||||
|
||||
# Second operation: List calendars
|
||||
result2 = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_calendar_list_calendars", arguments={}
|
||||
)
|
||||
assert result2.isError is False
|
||||
|
||||
# Third operation: List directory
|
||||
result3 = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_webdav_list_directory", arguments={"path": "/"}
|
||||
)
|
||||
assert result3.isError is False
|
||||
|
||||
logger.info(
|
||||
"Successfully executed 3 different operations with same JWT token (token persistence verified)"
|
||||
)
|
||||
|
||||
|
||||
async def test_jwt_error_handling(nc_mcp_oauth_jwt_client):
|
||||
"""Test error handling with JWT authentication.
|
||||
|
||||
Verifies that invalid operations return proper errors even with valid JWT tokens.
|
||||
"""
|
||||
# Try to get a non-existent note
|
||||
result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_get_note", arguments={"note_id": 999999}
|
||||
)
|
||||
|
||||
# Should get an error (note doesn't exist)
|
||||
assert result.isError is True
|
||||
logger.info("JWT OAuth server correctly handles errors for invalid operations")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# JWT Token Structure Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_jwt_tokens_embed_scopes_in_payload():
|
||||
"""Document that JWT tokens embed scopes in the payload (RFC 9068).
|
||||
|
||||
This test documents expected JWT structure based on manual testing.
|
||||
"""
|
||||
expected_structure = {
|
||||
"header": {
|
||||
"typ": "at+JWT", # RFC 9068 access token type
|
||||
"alg": "RS256", # Signature algorithm
|
||||
},
|
||||
"payload_claims": {
|
||||
"iss": "issuer URL",
|
||||
"sub": "user ID",
|
||||
"aud": "client ID",
|
||||
"exp": "expiration timestamp",
|
||||
"iat": "issued at timestamp",
|
||||
"scope": "space-separated scope string (e.g., 'notes:read notes:write')",
|
||||
"client_id": "client identifier",
|
||||
"jti": "JWT ID",
|
||||
},
|
||||
"scope_claim": {
|
||||
"format": "space-separated string",
|
||||
"example": "openid profile email notes:read notes:write",
|
||||
"extraction": "payload['scope'].split()",
|
||||
},
|
||||
}
|
||||
|
||||
logger.info("JWT token structure (RFC 9068):")
|
||||
logger.info(json.dumps(expected_structure, indent=2))
|
||||
|
||||
# This test documents expected behavior
|
||||
assert True
|
||||
|
||||
|
||||
async def test_opaque_token_vs_jwt_comparison():
|
||||
"""Document differences between opaque tokens and JWT tokens.
|
||||
|
||||
This test captures our findings about the two token types.
|
||||
"""
|
||||
findings = {
|
||||
"jwt_advantages": [
|
||||
"Scopes embedded in payload - no introspection needed",
|
||||
"Self-contained - can validate with JWKS",
|
||||
"Standard approach (RFC 9068)",
|
||||
],
|
||||
"jwt_disadvantages": [
|
||||
"10-15x larger than opaque tokens (~800-1200 chars vs 72)",
|
||||
"Cannot be easily revoked (until expiration)",
|
||||
],
|
||||
"token_sizes": {
|
||||
"opaque": "72 characters",
|
||||
"jwt": "~800-1200 characters",
|
||||
},
|
||||
"recommendation": "Use JWT for MCP server (scopes available without introspection)",
|
||||
}
|
||||
|
||||
logger.info("JWT vs Opaque token comparison:")
|
||||
logger.info(json.dumps(findings, indent=2))
|
||||
|
||||
assert True
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user