Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f587a4e31f | |||
| 6e95447272 | |||
| 8983f25eaf | |||
| 1675fc521b | |||
| dec02f17d1 | |||
| 881b0ba03c | |||
| 942fe35719 | |||
| 723eb57060 | |||
| 619d0e4be6 | |||
| dc7abcbd48 | |||
| 3d4dfcbb35 | |||
| de99296779 | |||
| 10dffd0c10 | |||
| 737d62fe91 | |||
| 192c4bf009 | |||
| 01d1cf9190 | |||
| 0ff85dbe4f | |||
| 96789db29d | |||
| b20c9c6203 | |||
| 15113dbb03 | |||
| 615f345928 | |||
| d14f2f666d | |||
| d92945a388 | |||
| 42426b4597 | |||
| c2dcb06fe1 | |||
| 95b73019ab | |||
| 6a0f537d66 | |||
| 71e77e95bc | |||
| 636bfd416f | |||
| 64864db736 | |||
| 027fc0b2d6 | |||
| d768909fd4 | |||
| 3b4606b798 | |||
| 63b457380a | |||
| b41bbd6c65 | |||
| 9adfc72612 | |||
| c896a2de63 | |||
| d16bcdcfbb | |||
| 6c3997b24c | |||
| 9d514f52b0 | |||
| 4e1d143e54 | |||
| 02a2c4a16f | |||
| f37008fdc3 | |||
| 0d45120470 | |||
| babd60e08b | |||
| f48e039e9e | |||
| 14a8f70503 | |||
| bf8120682e | |||
| f2af5a39a8 | |||
| 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 |
@@ -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
|
||||
@@ -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: |
|
||||
@@ -81,4 +81,4 @@ jobs:
|
||||
NEXTCLOUD_USERNAME: "admin"
|
||||
NEXTCLOUD_PASSWORD: "admin"
|
||||
run: |
|
||||
uv run pytest -v --log-cli-level=INFO
|
||||
uv run pytest -v --log-cli-level=WARN --ignore=tests/manual
|
||||
|
||||
+110
@@ -1,3 +1,113 @@
|
||||
## v0.24.0 (2025-11-04)
|
||||
|
||||
### Feat
|
||||
|
||||
- add scope protection to OAuth provisioning tools
|
||||
- enable authorization services for token exchange in Keycloak
|
||||
- implement scope-based audience mapping and RFC 9728 support
|
||||
- integrate token exchange into MCP server application
|
||||
- implement RFC 8693 Standard Token Exchange for Keycloak
|
||||
- Add userinfo route/page
|
||||
- add browser-based user info page with separate OAuth flow
|
||||
- Implement ADR-004 Progressive Consent foundation (partial)
|
||||
- Complete ADR-004 Progressive Consent OAuth flows implementation
|
||||
- Implement ADR-004 Progressive Consent foundation components
|
||||
- Implement ADR-004 Hybrid Flow with comprehensive integration tests
|
||||
|
||||
### Fix
|
||||
|
||||
- add missing await for get_nextcloud_client in capabilities resource
|
||||
- use valid Fernet encryption keys in token exchange tests
|
||||
- accept resource URL in token audience for Nextcloud JWT tokens
|
||||
- remove token-exchange-nextcloud scope and accept tokens without audience
|
||||
- move audience mapper from scope to nextcloud-mcp-server client
|
||||
- move token-exchange-nextcloud from default to optional scopes
|
||||
- restructure routes to prevent SessionAuthBackend from interfering with FastMCP OAuth
|
||||
- allow OAuth Bearer tokens on /mcp endpoint by excluding from session auth
|
||||
- correct OAuth token audience validation using RFC 8707 resource parameter
|
||||
- remove remaining references to deleted oauth_callback and oauth_token
|
||||
- remove Hybrid Flow, make Progressive Consent default (ADR-004)
|
||||
- browser OAuth userinfo endpoint and refresh token rotation
|
||||
- make ENABLE_PROGRESSIVE_CONSENT consistently opt-in (default false)
|
||||
- make provisioning checks opt-in (default false)
|
||||
- Disable Progressive Consent for mcp-oauth to enable Hybrid Flow tests
|
||||
|
||||
### Refactor
|
||||
|
||||
- integrate token exchange into unified get_client() pattern
|
||||
|
||||
## 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
|
||||
|
||||
@@ -2,413 +2,304 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
## Coding Conventions
|
||||
|
||||
### Testing
|
||||
### async/await Patterns
|
||||
- **Use anyio + asyncio hybrid** - Both libraries are available
|
||||
- pytest runs in `anyio` mode (`anyio_mode = "auto"` in pyproject.toml)
|
||||
- asyncio used in auth modules (refresh_token_storage.py, token_exchange.py, token_broker.py)
|
||||
- anyio used in calendar.py, client_registration.py, app.py
|
||||
- Prefer standard async/await syntax without explicit library imports when possible
|
||||
|
||||
The test suite is organized in layers for fast feedback:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
# 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" -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)
|
||||
uv run python -m tests.load.benchmark
|
||||
|
||||
# Quick test with custom concurrency and duration
|
||||
uv run python -m tests.load.benchmark --concurrency 20 --duration 60
|
||||
|
||||
# Extended load test (50 workers for 5 minutes)
|
||||
uv run python -m tests.load.benchmark -c 50 -d 300
|
||||
|
||||
# Export results to JSON for analysis
|
||||
uv run python -m tests.load.benchmark -c 20 -d 60 --output results.json
|
||||
|
||||
# Test OAuth server on port 8001
|
||||
uv run python -m tests.load.benchmark --url http://127.0.0.1:8001/mcp
|
||||
|
||||
# Verbose mode with detailed logging
|
||||
uv run python -m tests.load.benchmark -c 10 -d 30 --verbose
|
||||
```
|
||||
|
||||
**Load Testing Features:**
|
||||
- **Mixed workload** simulating realistic MCP usage (40% reads, 20% writes, 15% search, 25% other operations)
|
||||
- **Real-time progress** bar with live RPS and error counts
|
||||
- **Detailed metrics**:
|
||||
- Throughput (requests/second)
|
||||
- Latency percentiles (p50, p90, p95, p99)
|
||||
- Per-operation breakdown
|
||||
- Error rates and types
|
||||
- **Automatic cleanup** of test data
|
||||
- **JSON export** for CI/CD integration
|
||||
- **Server health checks** before starting
|
||||
|
||||
**Understanding Results:**
|
||||
- **Requests/Second (RPS)**: Higher is better. Expected baseline: 50-200 RPS for mixed workload
|
||||
- **Latency**:
|
||||
- p50 (median): Should be <100ms for most operations
|
||||
- p95: Should be <500ms
|
||||
- p99: Should be <1000ms
|
||||
- **Error Rate**: Should be <1% under normal load
|
||||
|
||||
**Common Bottlenecks:**
|
||||
1. Nextcloud backend API response times (most common)
|
||||
2. Database connection limits
|
||||
3. HTTP client connection pooling
|
||||
4. Network I/O between containers
|
||||
### Type Hints
|
||||
- **Use Python 3.10+ union syntax**: `str | None` instead of `Optional[str]`
|
||||
- **Use lowercase generics**: `dict[str, Any]` instead of `Dict[str, Any]`
|
||||
- **Type all function signatures** - Parameters and return types
|
||||
- **No explicit type checker configured** - Ruff handles linting only
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Format and lint code
|
||||
uv run ruff check
|
||||
uv run ruff format
|
||||
- **Run ruff before committing**:
|
||||
```bash
|
||||
uv run ruff check
|
||||
uv run ruff format
|
||||
```
|
||||
- **Ruff configuration** in pyproject.toml (extends select: ["I"] for import sorting)
|
||||
|
||||
# Type checking
|
||||
# No explicit type checker configured - this is a Python project using ruff for linting
|
||||
### Error Handling
|
||||
- **Use custom decorators**: `@retry_on_429` for rate limiting (see base_client.py)
|
||||
- **Standard exceptions**: `HTTPStatusError` from httpx, `McpError` for MCP-specific errors
|
||||
- **Logging patterns**:
|
||||
- `logger.debug()` for expected 404s and normal operations
|
||||
- `logger.warning()` for retries and non-critical issues
|
||||
- `logger.error()` for actual errors
|
||||
|
||||
### Testing Patterns
|
||||
- **Use existing fixtures** from `tests/conftest.py` (2888 lines of test infrastructure)
|
||||
- **Session-scoped fixtures** handle anyio/pytest-asyncio incompatibility
|
||||
- **Mocked unit tests** use `mocker.AsyncMock(spec=httpx.AsyncClient)`
|
||||
- **pytest-timeout**: 180s default per test
|
||||
- **Mark tests appropriately**: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.oauth`, `@pytest.mark.smoke`
|
||||
|
||||
### Architectural Patterns
|
||||
- **Base classes**: `BaseNextcloudClient` for all API clients
|
||||
- **Pydantic responses**: All MCP tools return Pydantic models inheriting from `BaseResponse`
|
||||
- **Decorators**: `@require_scopes`, `@require_provisioning` for access control
|
||||
- **Context pattern**: `await get_client(ctx)` to access authenticated NextcloudClient (async!)
|
||||
- **FastMCP decorators**: `@mcp.tool()`, `@mcp.resource()`
|
||||
- **Token acquisition**: `get_client()` handles both pass-through and token exchange modes
|
||||
- Pass-through (default): Simple, stateless (ENABLE_TOKEN_EXCHANGE=false)
|
||||
- Token exchange (opt-in): RFC 8693 delegation (ENABLE_TOKEN_EXCHANGE=true)
|
||||
|
||||
### Project Structure
|
||||
- `nextcloud_mcp_server/client/` - HTTP clients for Nextcloud APIs
|
||||
- `nextcloud_mcp_server/server/` - MCP tool/resource definitions
|
||||
- `nextcloud_mcp_server/auth/` - OAuth/OIDC authentication
|
||||
- `nextcloud_mcp_server/models/` - Pydantic response models
|
||||
- `tests/` - Layered test suite (unit, smoke, integration, load)
|
||||
|
||||
## Development Commands (Quick Reference)
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Fast feedback (recommended)
|
||||
uv run pytest tests/unit/ -v # Unit tests (~5s)
|
||||
uv run pytest -m smoke -v # Smoke tests (~30-60s)
|
||||
|
||||
# Integration tests
|
||||
uv run pytest -m "integration and not oauth" -v # Without OAuth (~2-3min)
|
||||
uv run pytest -m oauth -v # OAuth only (~3min)
|
||||
uv run pytest # Full suite (~4-5min)
|
||||
|
||||
# Coverage
|
||||
uv run pytest --cov
|
||||
|
||||
# Specific tests after changes
|
||||
uv run pytest tests/server/test_mcp.py -k "notes" -v
|
||||
uv run pytest tests/client/notes/test_notes_api.py -v
|
||||
```
|
||||
|
||||
**Important**: After code changes, rebuild the correct container:
|
||||
- Single-user tests: `docker-compose up --build -d mcp`
|
||||
- OAuth tests: `docker-compose up --build -d mcp-oauth`
|
||||
- Keycloak tests: `docker-compose up --build -d mcp-keycloak`
|
||||
|
||||
### Running the Server
|
||||
```bash
|
||||
# Local development - load environment variables and run
|
||||
# Local development
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
mcp run --transport sse nextcloud_mcp_server.app:mcp
|
||||
|
||||
# Docker development environment with Nextcloud instance
|
||||
docker-compose up
|
||||
|
||||
# After code changes, rebuild and restart the appropriate MCP server container:
|
||||
# For basic auth changes (most common) - uses admin credentials
|
||||
docker-compose up --build -d mcp
|
||||
|
||||
# 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 .
|
||||
# Docker development (rebuilds after code changes)
|
||||
docker-compose up --build -d mcp # Single-user (port 8000)
|
||||
docker-compose up --build -d mcp-oauth # Nextcloud OAuth (port 8001)
|
||||
docker-compose up --build -d mcp-keycloak # Keycloak OAuth (port 8002)
|
||||
```
|
||||
|
||||
**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 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
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Install development dependencies
|
||||
uv sync --group dev
|
||||
uv sync # Install dependencies
|
||||
uv sync --group dev # Install with dev dependencies
|
||||
```
|
||||
|
||||
### Database Inspection
|
||||
|
||||
**Docker Compose Database Credentials:**
|
||||
- Root user: `root` / password: `password`
|
||||
- App user: `nextcloud` / password: `password`
|
||||
- Database: `nextcloud`
|
||||
|
||||
**Common Database Commands:**
|
||||
### Load Testing
|
||||
```bash
|
||||
# Connect to database as root (most common for inspection)
|
||||
# Quick test (default: 10 workers, 30 seconds)
|
||||
uv run python -m tests.load.benchmark
|
||||
|
||||
# Custom concurrency and duration
|
||||
uv run python -m tests.load.benchmark -c 20 -d 60
|
||||
|
||||
# Export results for analysis
|
||||
uv run python -m tests.load.benchmark --output results.json --verbose
|
||||
```
|
||||
|
||||
**Expected Performance**: 50-200 RPS for mixed workload, p50 <100ms, p95 <500ms, p99 <1000ms.
|
||||
|
||||
## Database Inspection
|
||||
|
||||
**Credentials**: root/password, nextcloud/password, database: `nextcloud`
|
||||
|
||||
```bash
|
||||
# Connect to database
|
||||
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;"
|
||||
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%';"
|
||||
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;"
|
||||
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)
|
||||
**Important Tables**:
|
||||
- `oc_oidc_clients` - OAuth client registrations (DCR)
|
||||
- `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
|
||||
- `oc_oidc_registration_tokens` - RFC 7592 registration tokens
|
||||
- `oc_oidc_redirect_uris` - Redirect URIs
|
||||
|
||||
## Architecture Overview
|
||||
## Architecture Quick Reference
|
||||
|
||||
This is a Python MCP (Model Context Protocol) server that provides LLM integration with Nextcloud. The architecture follows a layered pattern:
|
||||
**For detailed architecture, see:**
|
||||
- `docs/comparison-context-agent.md` - Overall architecture
|
||||
- `docs/oauth-architecture.md` - OAuth integration patterns
|
||||
- `docs/ADR-004-progressive-consent.md` - Progressive consent implementation
|
||||
|
||||
### Core Components
|
||||
**Core Components**:
|
||||
- `nextcloud_mcp_server/app.py` - FastMCP server entry point
|
||||
- `nextcloud_mcp_server/client/` - HTTP clients (Notes, Calendar, Contacts, Tables, WebDAV)
|
||||
- `nextcloud_mcp_server/server/` - MCP tool/resource definitions
|
||||
- `nextcloud_mcp_server/auth/` - OAuth/OIDC authentication
|
||||
|
||||
- **`nextcloud_mcp_server/app.py`** - Main MCP server entry point using FastMCP framework
|
||||
- **`nextcloud_mcp_server/client/`** - HTTP client implementations for different Nextcloud APIs
|
||||
- **`nextcloud_mcp_server/server/`** - MCP tool/resource definitions that expose client functionality
|
||||
- **`nextcloud_mcp_server/controllers/`** - Business logic controllers (e.g., notes search)
|
||||
**Supported Apps**: Notes, Calendar (CalDAV + VTODO tasks), Contacts (CardDAV), Tables, WebDAV, Deck, Cookbook
|
||||
|
||||
### Client Architecture
|
||||
**Key Patterns**:
|
||||
1. `NextcloudClient` orchestrates all app-specific clients
|
||||
2. `BaseNextcloudClient` provides common HTTP functionality + retry logic
|
||||
3. MCP tools use context pattern: `get_client(ctx)` → `NextcloudClient`
|
||||
4. All operations are async using httpx
|
||||
|
||||
- **`NextcloudClient`** - Main orchestrating client that manages all app-specific clients
|
||||
- **`BaseNextcloudClient`** - Abstract base class providing common HTTP functionality and retry logic
|
||||
- **App-specific clients**: `NotesClient`, `CalendarClient`, `ContactsClient`, `TablesClient`, `WebDAVClient`
|
||||
### Progressive Consent Architecture (ADR-004)
|
||||
|
||||
### Server Integration
|
||||
**Status**: Always enabled in OAuth mode (default)
|
||||
|
||||
Each Nextcloud app has a corresponding server module that:
|
||||
1. Defines MCP tools using `@mcp.tool()` decorators
|
||||
2. Defines MCP resources using `@mcp.resource()` decorators
|
||||
3. Uses the context pattern to access the `NextcloudClient` instance
|
||||
**What is Progressive Consent?**
|
||||
- Dual OAuth flow architecture that separates client authentication (Flow 1) from resource provisioning (Flow 2)
|
||||
- Flow 1: MCP client authenticates directly to IdP with resource scopes (notes:*, calendar:*, etc.)
|
||||
- Token audience: "mcp-server"
|
||||
- Client receives resource-scoped token for MCP session
|
||||
- Flow 2: Server explicitly provisions Nextcloud access via separate login
|
||||
- Server requests: openid, profile, email, offline_access
|
||||
- Token audience: "nextcloud"
|
||||
- Server receives refresh token for offline access
|
||||
- Client never sees this token
|
||||
- Provides clear separation between session tokens and offline access tokens
|
||||
|
||||
### Supported Nextcloud Apps
|
||||
**When to use OAuth mode:**
|
||||
- Multi-user deployments
|
||||
- Background jobs requiring offline access
|
||||
- Enhanced security with separate authorization contexts
|
||||
- Explicit user control over resource access
|
||||
|
||||
- **Notes** - Full CRUD operations and search
|
||||
- **Calendar** - CalDAV integration with events, recurring events, attendees, and **tasks (VTODO)**
|
||||
- **Calendar Operations**: List, create, delete calendars
|
||||
- **Event Operations**: Full CRUD, recurring events, attendees, reminders, bulk operations
|
||||
- **Task Operations (VTODO)**: Full CRUD for CalDAV tasks with:
|
||||
- Status tracking (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||||
- Priority levels (0-9, 1=highest, 9=lowest)
|
||||
- Due dates, start dates, completion tracking
|
||||
- Percent complete (0-100%)
|
||||
- Categories and filtering
|
||||
- Search across all calendars
|
||||
- **Note**: Calendar implementation uses caldav library's AsyncDavClient
|
||||
- **Contacts** - CardDAV integration with address book operations
|
||||
- **Tables** - Row-level operations on Nextcloud Tables
|
||||
- **WebDAV** - Complete file system access
|
||||
**When to use BasicAuth instead:**
|
||||
- Simple single-user deployments
|
||||
- Local development and testing
|
||||
|
||||
### Key Patterns
|
||||
**Key features:**
|
||||
- No scope escalation - client gets exactly what it requests
|
||||
- User explicitly authorizes via `provision_nextcloud_access` tool
|
||||
- Clear security boundaries between MCP session and Nextcloud access
|
||||
|
||||
1. **Environment-based configuration** - Uses `NextcloudClient.from_env()` to load credentials from environment variables
|
||||
2. **Async/await throughout** - All operations are async using httpx
|
||||
3. **Retry logic** - `@retry_on_429` decorator handles rate limiting
|
||||
4. **Context injection** - MCP context provides access to the authenticated client instance
|
||||
5. **Modular design** - Each Nextcloud app is isolated in its own client/server pair
|
||||
## MCP Response Patterns (CRITICAL)
|
||||
|
||||
### MCP Response Patterns
|
||||
**Never return raw `List[Dict]` from MCP tools** - FastMCP mangles them into dicts with numeric string keys.
|
||||
|
||||
**CRITICAL: Never return raw `List[Dict]` from MCP tools - always wrap in Pydantic response models**
|
||||
|
||||
FastMCP serialization issue: raw lists get mangled into dicts with numeric string keys.
|
||||
|
||||
**Pattern:**
|
||||
**Correct Pattern**:
|
||||
1. Client methods return `List[Dict]` (raw data)
|
||||
2. MCP tools convert to Pydantic models and wrap in response object
|
||||
3. Response models inherit from `BaseResponse`, include `results` field + metadata
|
||||
|
||||
**Reference implementations:**
|
||||
- `SearchNotesResponse` in `nextcloud_mcp_server/models/notes.py:80`
|
||||
- `SearchFilesResponse` in `nextcloud_mcp_server/models/webdav.py:113`
|
||||
- Tool examples: `nextcloud_mcp_server/server/{notes,webdav}.py`
|
||||
**Reference implementations**:
|
||||
- `nextcloud_mcp_server/models/notes.py:80` - `SearchNotesResponse`
|
||||
- `nextcloud_mcp_server/models/webdav.py:113` - `SearchFilesResponse`
|
||||
- `nextcloud_mcp_server/server/{notes,webdav}.py` - Tool examples
|
||||
|
||||
**Testing:** Extract `data["results"]` from MCP responses, not `data` directly.
|
||||
**Testing**: Extract `data["results"]` from MCP responses, not `data` directly.
|
||||
|
||||
### Testing Structure
|
||||
## Testing Best Practices (MANDATORY)
|
||||
|
||||
The test suite follows a layered architecture for fast feedback:
|
||||
### Always Run Tests
|
||||
- **Run tests to completion** before considering any task complete
|
||||
- **Rebuild the correct container** after code changes (see Development Commands above)
|
||||
- **If tests require modifications**, ask for permission before proceeding
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
### Use Existing Fixtures
|
||||
See `tests/conftest.py` for 2888 lines of test infrastructure:
|
||||
- `nc_mcp_client` - MCP client for tool/resource testing (uses `mcp` container)
|
||||
- `nc_mcp_oauth_client` - MCP client for OAuth testing (uses `mcp-oauth` container)
|
||||
- `nc_client` - Direct NextcloudClient for setup/cleanup
|
||||
- `temporary_note`, `temporary_addressbook`, `temporary_contact` - Auto-cleanup
|
||||
|
||||
**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
|
||||
### Writing Mocked Unit Tests
|
||||
For client-layer response parsing tests, use mocked HTTP responses:
|
||||
|
||||
**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`
|
||||
|
||||
#### Testing Best Practices
|
||||
- **MANDATORY: Always run tests after implementing features or fixing bugs**
|
||||
- Run tests to completion before considering any task complete
|
||||
- If tests require modifications to pass, ask for permission before proceeding
|
||||
- **Rebuild the correct container** after code changes:
|
||||
- For basic auth tests (most common): `docker-compose up --build -d mcp`
|
||||
- For OAuth tests: `docker-compose up --build -d mcp-oauth`
|
||||
- **Use existing fixtures** from `tests/conftest.py` to avoid duplicate setup work:
|
||||
- `nc_mcp_client` - MCP client session for tool/resource testing (uses `mcp` container)
|
||||
- `nc_mcp_oauth_client` - MCP client session for OAuth testing (uses `mcp-oauth` container)
|
||||
- `nc_client` - Direct NextcloudClient for setup/cleanup operations
|
||||
- `temporary_note` - Creates and cleans up test notes automatically
|
||||
- `temporary_addressbook` - Creates and cleans up test address books
|
||||
- `temporary_contact` - Creates and cleans up test contacts
|
||||
- **Test specific functionality** after changes:
|
||||
- For Notes changes: `uv run pytest tests/server/test_mcp.py -k "notes" -v`
|
||||
- For specific API changes: `uv run pytest tests/client/notes/test_notes_api.py -v`
|
||||
- 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",
|
||||
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")
|
||||
client = NotesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "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.)
|
||||
**Mock helpers in `tests/conftest.py`**: `create_mock_response()`, `create_mock_note_response()`, `create_mock_error_response()`
|
||||
|
||||
**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**: Response parsing, error handling, request parameter building
|
||||
**When NOT to use**: CalDAV/CardDAV/WebDAV protocols, OAuth flows, end-to-end MCP testing
|
||||
|
||||
**When to use:**
|
||||
- Testing client methods that parse JSON responses
|
||||
- Testing error handling (404, 412, etc.)
|
||||
- Testing request parameter building
|
||||
### OAuth Testing
|
||||
OAuth tests use **Playwright browser automation** to complete flows programmatically.
|
||||
|
||||
**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
|
||||
**Test Environment**:
|
||||
- Three MCP containers: `mcp` (single-user), `mcp-oauth` (Nextcloud OIDC), `mcp-keycloak` (external IdP)
|
||||
- OAuth tests require `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
|
||||
- Playwright configuration: `--browser firefox --headed` for debugging
|
||||
- Install browsers: `uv run playwright install firefox`
|
||||
|
||||
**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 fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client`, `alice_oauth_token`, `bob_oauth_token`, etc.
|
||||
|
||||
#### OAuth/OIDC Testing
|
||||
OAuth integration tests use **automated Playwright browser automation** to complete the OAuth flow programmatically.
|
||||
**Shared OAuth Client**: All test users authenticate using a single OAuth client (created via DCR, deleted at session end via RFC 7592). Matches production behavior.
|
||||
|
||||
**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
|
||||
- **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
|
||||
- Uses `pytest-playwright-asyncio` for async Playwright fixtures
|
||||
- **Playwright configuration**: Use pytest CLI args like `--browser firefox --headed` to customize
|
||||
- **Install browsers**: `uv run playwright install firefox` (or `chromium`, `webkit`)
|
||||
|
||||
**Example Commands:**
|
||||
**Run OAuth tests**:
|
||||
```bash
|
||||
# Run all OAuth tests with Playwright automation using Firefox
|
||||
uv run pytest -m oauth -v # All OAuth tests
|
||||
uv run pytest tests/server/oauth/ --browser firefox -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) - use -m oauth marker for all OAuth tests
|
||||
uv run pytest -m oauth -v
|
||||
```
|
||||
|
||||
**Test Environment:**
|
||||
- **Two MCP server containers are available:**
|
||||
- `mcp` (port 8000): Uses basic auth with admin credentials - for most testing
|
||||
- `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`
|
||||
### Keycloak OAuth Testing
|
||||
**Validates ADR-002 architecture** for external identity providers and offline access patterns.
|
||||
|
||||
**CI/CD Notes:**
|
||||
- Playwright tests run in CI/CD environments
|
||||
- Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects)
|
||||
**Architecture**: `MCP Client → Keycloak (OAuth) → MCP Server → Nextcloud user_oidc (validates token) → APIs`
|
||||
|
||||
### Configuration Files
|
||||
**Setup**:
|
||||
```bash
|
||||
docker-compose up -d keycloak app mcp-keycloak
|
||||
curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||
docker compose exec app php occ user_oidc:provider keycloak
|
||||
```
|
||||
|
||||
- **`pyproject.toml`** - Python project configuration using uv for dependency management
|
||||
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
|
||||
- **`docker-compose.yml`** - Complete development environment with Nextcloud + database
|
||||
**Credentials**: admin/admin (Keycloak realm: `nextcloud-mcp`)
|
||||
|
||||
## Integration testing with docker
|
||||
**For detailed Keycloak setup, see**:
|
||||
- `docs/oauth-setup.md` - OAuth configuration
|
||||
- `docs/ADR-002-vector-sync-authentication.md` - Offline access architecture
|
||||
- `docs/audience-validation-setup.md` - Token audience validation
|
||||
- `docs/keycloak-multi-client-validation.md` - Realm-level validation
|
||||
|
||||
### Nextcloud
|
||||
## Integration Testing with Docker
|
||||
|
||||
- The `app` container is running nextcloud.
|
||||
- Use `docker compose exec app php occ ...` to get a list of available commands
|
||||
**Nextcloud**: `docker compose exec app php occ ...` for occ commands
|
||||
**MariaDB**: `docker compose exec db mariadb -u [user] -p [password] [database]` for queries
|
||||
|
||||
### Mariadb
|
||||
|
||||
- The `db` container is running mariadb
|
||||
- Use `docker compose exec db mariadb -u [user] -p [password] [database]` to execute queries. Check the docker-compose file for credentials
|
||||
**For detailed setup, see**:
|
||||
- `docs/installation.md` - Installation guide
|
||||
- `docs/configuration.md` - Configuration options
|
||||
- `docs/authentication.md` - Authentication modes
|
||||
- `docs/running.md` - Running the server
|
||||
|
||||
+5
-3
@@ -1,7 +1,9 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.9.5-python3.11-alpine@sha256:64ecec379ff82bea84b8a80c0b374f6392bcd54aa52f8c63c12f510f9c0b214d
|
||||
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
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
# 2. sqlite for development with token db
|
||||
RUN apk add --no-cache git sqlite
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -72,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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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.24.0
|
||||
appVersion: "0.24.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
|
||||
+96
-11
@@ -17,11 +17,11 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: docker.io/library/redis:alpine@sha256:59b6e694653476de2c992937ebe1c64182af4728e54bb49e9b7a6c26614d8933
|
||||
image: docker.io/library/redis:alpine@sha256:28c9c4d7596949a24b183eaaab6455f8e5d55ecbf72d02ff5e2c17fe72671d31
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.1@sha256:42a36b4711191273a9cf8cebfd35602909eb1bee461b7076d4d5a57f7ec2b81e
|
||||
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,16 +43,21 @@ 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
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:a43ab55898599157fb0e0e097dabb8ecdd1d8e3df1ae5b67c6e15a136b171a6c
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8002:8000
|
||||
@@ -66,7 +71,8 @@ services:
|
||||
command: ["--transport", "streamable-http"]
|
||||
restart: always
|
||||
depends_on:
|
||||
- app
|
||||
app:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- 127.0.0.1:8000:8000
|
||||
environment:
|
||||
@@ -79,22 +85,101 @@ services:
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||
restart: always
|
||||
depends_on:
|
||||
- app
|
||||
app:
|
||||
condition: service_healthy
|
||||
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 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 USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration
|
||||
# Client credentials will be registered and stored in volume on first startup
|
||||
|
||||
# Refresh token storage (ADR-002 Tier 1)
|
||||
- ENABLE_OFFLINE_ACCESS=true
|
||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
|
||||
# ADR-004: Use Hybrid Flow (server intercepts OAuth callback)
|
||||
# Set to false to enable Hybrid Flow tests - server stores refresh token and issues MCP codes
|
||||
|
||||
# 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
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.4.2@sha256:3617b09bb4b7510a8d8d9b9fc5707399e2d70688dbcc2f8fb013a144829be1b9
|
||||
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:
|
||||
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: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
|
||||
|
||||
# Token exchange (RFC 8693) - convert aud:nextcloud-mcp-server → aud:nextcloud
|
||||
- ENABLE_TOKEN_EXCHANGE=true
|
||||
|
||||
# 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:
|
||||
- keycloak-tokens:/app/data
|
||||
- keycloak-oauth-storage:/app/.oauth
|
||||
|
||||
volumes:
|
||||
nextcloud:
|
||||
db:
|
||||
oauth-client-storage:
|
||||
oauth-tokens:
|
||||
keycloak-tokens:
|
||||
keycloak-oauth-storage:
|
||||
|
||||
@@ -0,0 +1,964 @@
|
||||
# ADR-002: Vector Database Background Sync Authentication
|
||||
|
||||
> **⚠️ DEPRECATED**: This ADR has been superseded by [ADR-004: MCP Server as OAuth Client for Offline Access](./ADR-004-mcp-application-oauth.md).
|
||||
>
|
||||
> **Reason for Deprecation**: This ADR fundamentally misunderstood the MCP protocol's authentication architecture. The MCP server receives tokens from clients but cannot initiate OAuth flows or store refresh tokens, making the proposed solutions ineffective for true offline access. ADR-004 provides the correct architectural pattern where the MCP server acts as its own OAuth client.
|
||||
|
||||
## Status
|
||||
~~Accepted - Tier 2 (Token Exchange with Delegation) Implemented~~
|
||||
**Superseded by ADR-004** - The token exchange implementation exists but doesn't solve the offline access problem.
|
||||
|
||||
**Important**: Service account tokens (old Tier 1) have been rejected as they violate OAuth "act on-behalf-of" principles by creating Nextcloud user accounts for the MCP server.
|
||||
|
||||
## Context
|
||||
|
||||
To enable semantic search capabilities, the MCP server needs to index user content (notes, files, calendar events) into a vector database. This requires a background sync worker that:
|
||||
|
||||
1. **Runs independently** of user requests (periodic or continuous operation)
|
||||
2. **Accesses multiple users' content** to build a comprehensive search index
|
||||
3. **Respects user permissions** - only index content users have access to
|
||||
4. **Operates in OAuth mode** - where the MCP server doesn't have traditional admin credentials
|
||||
|
||||
### Current OAuth Architecture
|
||||
|
||||
The MCP server currently operates in two authentication modes:
|
||||
|
||||
1. **BasicAuth Mode**: Uses username/password credentials (typically admin account)
|
||||
2. **OAuth Mode**: Single OAuth client, multiple user tokens
|
||||
- Users authenticate via OAuth flow
|
||||
- Each request includes user's access token
|
||||
- Server creates per-request `NextcloudClient` with user's bearer token
|
||||
- No tokens are stored server-side
|
||||
|
||||
### The Challenge
|
||||
|
||||
Background workers need long-lived authentication to:
|
||||
- Index content continuously/periodically
|
||||
- Process multiple users' data in batch operations
|
||||
- Operate when users are not actively making requests
|
||||
|
||||
However, in OAuth mode:
|
||||
- User access tokens are ephemeral (exist only during request)
|
||||
- MCP server doesn't store user credentials
|
||||
- Admin credentials defeat the purpose of OAuth
|
||||
|
||||
We need an OAuth-native solution that maintains security while enabling background operations.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a **tiered OAuth authentication strategy** for background operations in OAuth mode. When OAuth authentication is not configured or available, the background sync feature is not available.
|
||||
|
||||
**Note**: This ADR applies only to **OAuth mode**. In BasicAuth mode (single-user deployments), credentials are already available via environment variables, and background operations work without additional configuration.
|
||||
|
||||
### OAuth "Act On-Behalf-Of" Principle
|
||||
|
||||
**Core Requirement**: The MCP server must NEVER create its own user identity in Nextcloud when operating in OAuth mode.
|
||||
|
||||
**Valid Patterns**:
|
||||
- ✅ **Foreground operations**: Use user's access token from MCP request (currently implemented)
|
||||
- ✅ **Background operations**: Token exchange to impersonate/delegate as user (requires provider support)
|
||||
- ❌ **Service account**: Creates independent identity in Nextcloud (violates OAuth principles)
|
||||
|
||||
**Why This Matters**:
|
||||
1. **Audit Trail**: All operations must be attributable to the actual user, not a service account
|
||||
2. **Stateless Server**: MCP server should not have persistent identity/state in Nextcloud
|
||||
3. **Security Model**: Avoid creating "admin by another name" with broad cross-user permissions
|
||||
4. **OAuth Design**: OAuth tokens represent user authorization, not server authorization
|
||||
|
||||
**If Token Exchange Not Available**:
|
||||
- Background operations simply cannot happen in OAuth mode
|
||||
- This is correct behavior - not a limitation to work around
|
||||
- Don't create service accounts as "workaround" - this defeats OAuth's purpose
|
||||
- Use BasicAuth mode if background operations are critical to your deployment
|
||||
|
||||
### Tier 1: Token Exchange with Impersonation (RFC 8693) ⚠️ **NOT IMPLEMENTED**
|
||||
|
||||
**Better Security** - Requires provider support for user impersonation
|
||||
|
||||
- Service account exchanges token to impersonate specific users
|
||||
- Each background operation runs as the target user
|
||||
- Uses `requested_subject` parameter in token exchange
|
||||
- Per-user permission enforcement at API level
|
||||
|
||||
**Requirements**:
|
||||
- OIDC provider supports RFC 8693 token exchange
|
||||
- Provider supports user impersonation (rare - requires Legacy Keycloak V1 with preview features)
|
||||
- Service account has impersonation permissions
|
||||
|
||||
**Status**: ⚠️ Not implemented - Keycloak Standard V2 doesn't support impersonation
|
||||
**Reference**: See `docs/oauth-impersonation-findings.md` for investigation details
|
||||
|
||||
### Tier 2: Token Exchange with Delegation (RFC 8693) ✅ **IMPLEMENTED**
|
||||
|
||||
**Best Security** - Requires provider support for delegation with `act` claim
|
||||
|
||||
- Service account exchanges token on behalf of users (delegation, not impersonation)
|
||||
- Token includes `act` claim showing service account as actor
|
||||
- API sees both the user (`sub`) and actor (`act`) in token
|
||||
- Full audit trail of delegated operations
|
||||
- **Implementation**: `KeycloakOAuthClient.exchange_token_for_user()` (keycloak_oauth.py:397-495)
|
||||
- **Testing**: Manual test in `tests/manual/test_token_exchange.py`
|
||||
- **Limitation**: Keycloak doesn't support `act` claim yet - [Issue #38279](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,65 @@
|
||||
Excellent and incredibly thorough work on ADR-004. It outlines a robust, secure, and modern approach to federated authentication that aligns with industry best practices. The Progressive Consent architecture with dual OAuth flows is the right direction for a system with these requirements.
|
||||
|
||||
Here is a review of the current implementation in light of the architecture proposed in the ADR.
|
||||
|
||||
### High-Level Assessment
|
||||
|
||||
The project is in a good state, with a clear vision for its authentication architecture. The current implementation provides a backward-compatible "Hybrid Flow" while also containing the scaffolding for the target "Progressive Consent" flow. The hybrid flow is well-tested, which is a great foundation.
|
||||
|
||||
The following points are intended to help bridge the gap between the current implementation and the final vision outlined in ADR-004.
|
||||
|
||||
### Critical Security Review
|
||||
|
||||
#### 1. Missing Token Audience (`aud`) Validation
|
||||
|
||||
This is the most critical issue. The `require_scopes` decorator currently checks for scopes but does not validate the `audience` (`aud` claim) of the incoming JWT.
|
||||
|
||||
* **Risk:** This creates a "confused deputy" vulnerability. An access token issued for a different application could be used to access the MCP server, as long as the scope names happen to match.
|
||||
* **ADR Reference:** The ADR correctly identifies this and proposes an `MCPTokenVerifier` that validates `aud: "mcp-server"`.
|
||||
* **Recommendation:** Implement the audience validation as a central part of your token verification middleware. An incoming token should be rejected immediately if its audience is not `mcp-server`. This check should happen before any tool-specific scope checks.
|
||||
|
||||
### Architecture and Implementation Review
|
||||
|
||||
#### 2. Progressive Consent Flow is Untested
|
||||
|
||||
The code for the Progressive Consent flow (behind the `ENABLE_PROGRESSIVE_CONSENT` flag) exists in `oauth_routes.py` and `oauth_tools.py`. However, there are no integration tests to validate it.
|
||||
|
||||
* **Risk:** Given the complexity of OAuth flows, it's likely there are bugs in the untested implementation.
|
||||
* **Recommendation:** Create a new test file, `test_adr004_progressive_flow.py`, that uses Playwright to test the dual-flow architecture end-to-end:
|
||||
1. **Flow 1:** A test MCP client authenticates directly with the IdP to get an `mcp-server` token.
|
||||
2. **Provisioning Check:** The test verifies that calling a Nextcloud tool fails with a `ProvisioningRequiredError`.
|
||||
3. **Flow 2:** The test calls the `provision_nextcloud_access` tool and automates the second OAuth flow to grant the server offline access.
|
||||
4. **Tool Execution:** The test verifies that Nextcloud tools can now be successfully called.
|
||||
|
||||
#### 3. Inconsistent Authorization URL Generation
|
||||
|
||||
There is duplicated and inconsistent logic for generating the IdP authorization URL.
|
||||
|
||||
* **Location 1:** `oauth_tools.py` in `generate_oauth_url_for_flow2` hardcodes the authorization endpoint path.
|
||||
* **Location 2:** `oauth_routes.py` in `oauth_authorize_nextcloud` correctly uses the OIDC discovery document to find the `authorization_endpoint`.
|
||||
* **Risk:** The hardcoded path is brittle and will break with IdPs that use different endpoint paths (like Keycloak).
|
||||
* **Recommendation:** Consolidate this logic. The `provision_nextcloud_access` tool should not build the URL itself. Instead, it should return a URL pointing to the MCP server's own `/oauth/authorize-nextcloud` endpoint. This endpoint (which you've already created as `oauth_authorize_nextcloud` in `oauth_routes.py`) can then be the single source of truth for generating the IdP redirect.
|
||||
|
||||
#### 4. Poor User Experience due to Missing Token Refresh
|
||||
|
||||
The `/oauth/token` endpoint does not implement the `refresh_token` grant type. This means that when the client's `mcp-server` access token expires (e.g., after one hour), the user must go through the entire browser-based login flow again.
|
||||
|
||||
* **Risk:** This creates a frustrating user experience, especially for long-lived desktop clients.
|
||||
* **ADR Reference:** A proper Flow 1 should result in the MCP client receiving both an access token and a refresh token from the IdP.
|
||||
* **Recommendation:**
|
||||
1. Ensure the IdP is configured to issue refresh tokens to the MCP client for Flow 1.
|
||||
2. The MCP client should securely store this refresh token.
|
||||
3. The client should use the refresh token to get new `mcp-server` access tokens directly from the IdP, without involving the MCP server or the user. The MCP server should not be involved in the client's session management with the IdP.
|
||||
|
||||
### Summary
|
||||
|
||||
The project is on the right track. The ADR is a solid plan, and the initial implementation is a good starting point.
|
||||
|
||||
My recommendations in order of priority are:
|
||||
|
||||
1. **Implement Audience Validation** to close the security gap.
|
||||
2. **Add Integration Tests** for the Progressive Consent flow.
|
||||
3. **Refactor the client-side token refresh** to improve user experience.
|
||||
4. **Consolidate the URL generation** logic to fix the inconsistency.
|
||||
|
||||
Addressing these points will align the implementation with the excellent vision in ADR-004 and result in a secure, robust, and user-friendly system.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,348 @@
|
||||
# Token Acquisition Patterns for ADR-004 Progressive Consent
|
||||
|
||||
## Overview
|
||||
|
||||
ADR-004 Progressive Consent establishes the authorization architecture (Flow 1 for client auth, Flow 2 for resource provisioning). This document describes **how tokens are acquired for different operational contexts** within that architecture.
|
||||
|
||||
**Key Principle**: Refresh tokens from Flow 2 (Progressive Consent) should **NEVER** be used for MCP tool calls - they are exclusively for background jobs.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
**Current Status**: ✅ Token exchange infrastructure implemented, available as opt-in feature
|
||||
|
||||
The MCP server supports two token acquisition modes:
|
||||
1. **Pass-through mode** (default, `ENABLE_TOKEN_EXCHANGE=false`): Simple, stateless
|
||||
2. **Token exchange mode** (opt-in, `ENABLE_TOKEN_EXCHANGE=true`): Enhanced security with token delegation
|
||||
|
||||
Both modes maintain the critical separation: **refresh tokens are never used for tool calls**.
|
||||
|
||||
## Current Default (Pass-Through Mode)
|
||||
|
||||
### What Happens (ENABLE_TOKEN_EXCHANGE=false):
|
||||
1. Client gets Flow 1 token (`aud: "mcp-server"`)
|
||||
2. Client calls MCP tool
|
||||
3. Server validates Flow 1 token
|
||||
4. Server passes Flow 1 token to Nextcloud
|
||||
5. Nextcloud validates token with IdP
|
||||
6. Refresh tokens (from Flow 2) used **only** for background jobs
|
||||
|
||||
### Characteristics:
|
||||
- ✅ Simple, stateless operation
|
||||
- ✅ Clear separation: Flow 1 tokens for sessions, refresh tokens for background
|
||||
- ✅ Lower latency (no token exchange round-trip)
|
||||
- ✅ Works with any OAuth IdP
|
||||
|
||||
## Optional Token Exchange Mode
|
||||
|
||||
### Token Exchange Pattern (ENABLE_TOKEN_EXCHANGE=true)
|
||||
|
||||
**MCP Session (Foreground Operations)**:
|
||||
|
||||
```
|
||||
┌─────────────┐ Flow 1 Token ┌──────────────┐
|
||||
│ MCP Client │ ───(aud: mcp-server)──> │ MCP Server │
|
||||
└─────────────┘ └──────────────┘
|
||||
│
|
||||
Tool Call │
|
||||
"search_notes()" │
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Token Exchange │
|
||||
│ 1. Validate Flow 1 │
|
||||
│ 2. Check permission │
|
||||
│ 3. Request delegated│
|
||||
│ Nextcloud token │
|
||||
└─────────────────────┘
|
||||
│
|
||||
│ Exchange Request
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ IdP Token Endpoint │
|
||||
│ (Token Exchange) │
|
||||
└─────────────────────┘
|
||||
│
|
||||
│ Delegated Token
|
||||
│ (aud: nextcloud)
|
||||
│ (limited scopes)
|
||||
│ (short-lived)
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Nextcloud API Call │
|
||||
│ GET /notes │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
**Key Properties of Session Tokens:**
|
||||
- ✅ Generated **on-demand** during tool execution
|
||||
- ✅ **Ephemeral** - used only for current operation
|
||||
- ✅ **NOT stored** - discarded after use
|
||||
- ✅ **Limited scopes** - only what tool needs (e.g., `notes:read` for search)
|
||||
- ✅ **Short-lived** - expires quickly (e.g., 5 minutes)
|
||||
|
||||
**Background Jobs (Offline Operations)**:
|
||||
|
||||
```
|
||||
┌─────────────────┐ Scheduled Job ┌──────────────┐
|
||||
│ Background │ ──────────────────────> │ Worker │
|
||||
│ Scheduler │ │ Process │
|
||||
└─────────────────┘ └──────────────┘
|
||||
│
|
||||
│ Use stored
|
||||
│ refresh token
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Refresh Token Store │
|
||||
│ (Flow 2 provisioned)│
|
||||
└─────────────────────┘
|
||||
│
|
||||
│ Refresh Token
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ IdP Token Endpoint │
|
||||
│ (Refresh Grant) │
|
||||
└─────────────────────┘
|
||||
│
|
||||
│ Background Token
|
||||
│ (aud: nextcloud)
|
||||
│ (different scopes)
|
||||
│ (longer-lived)
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Nextcloud API │
|
||||
│ (Background Sync) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
**Key Properties of Background Tokens:**
|
||||
- ✅ Obtained from **stored refresh token** (Flow 2)
|
||||
- ✅ **Different scopes** than session tokens (e.g., `notes:sync`, `files:sync`)
|
||||
- ✅ **Longer-lived** for background operations
|
||||
- ✅ **Never used for MCP sessions**
|
||||
- ✅ **Only for offline/background jobs**
|
||||
|
||||
## Implementation Requirements
|
||||
|
||||
### 1. Token Exchange Endpoint
|
||||
|
||||
Implement RFC 8693 Token Exchange:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/auth/token_exchange.py
|
||||
|
||||
async def exchange_token_for_delegation(
|
||||
flow1_token: str,
|
||||
requested_audience: str = "nextcloud",
|
||||
requested_scopes: list[str] | None = None
|
||||
) -> tuple[str, int]:
|
||||
"""
|
||||
Exchange Flow 1 MCP token for delegated Nextcloud token.
|
||||
|
||||
This implements RFC 8693 Token Exchange for on-behalf-of delegation.
|
||||
|
||||
IMPORTANT: Nextcloud doesn't support OAuth scopes natively. Scopes are
|
||||
soft-scopes enforced by the MCP server via @require_scopes decorator,
|
||||
not by the IdP or Nextcloud. Therefore, requested_scopes are not passed
|
||||
to the IdP during token exchange.
|
||||
|
||||
Args:
|
||||
flow1_token: The MCP session token (aud: "mcp-server")
|
||||
requested_audience: Target audience (usually "nextcloud")
|
||||
requested_scopes: Ignored (Nextcloud doesn't support scopes)
|
||||
|
||||
Returns:
|
||||
Tuple of (delegated_token, expires_in)
|
||||
"""
|
||||
# 1. Validate Flow 1 token (audience check)
|
||||
# 2. Check user has provisioned Nextcloud access (Flow 2)
|
||||
# 3. Request token exchange from IdP (without scopes - Nextcloud doesn't support them)
|
||||
# 4. Return ephemeral delegated token
|
||||
```
|
||||
|
||||
### 2. Unified get_client() Pattern
|
||||
|
||||
The token acquisition mode is handled transparently by `get_client()`:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/context.py
|
||||
|
||||
async def get_client(ctx: Context) -> NextcloudClient:
|
||||
"""
|
||||
Get the appropriate Nextcloud client based on authentication mode.
|
||||
|
||||
This function handles three modes:
|
||||
1. BasicAuth mode: Returns shared client from lifespan context
|
||||
2. OAuth pass-through mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
||||
Verifies Flow 1 token and passes it to Nextcloud
|
||||
3. OAuth token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
||||
Exchanges Flow 1 token for ephemeral Nextcloud token via RFC 8693
|
||||
"""
|
||||
settings = get_settings()
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
|
||||
# BasicAuth mode - use shared client (no token exchange)
|
||||
if hasattr(lifespan_ctx, "client"):
|
||||
return lifespan_ctx.client
|
||||
|
||||
# OAuth mode (has 'nextcloud_host' attribute)
|
||||
if hasattr(lifespan_ctx, "nextcloud_host"):
|
||||
# Check if token exchange is enabled
|
||||
if settings.enable_token_exchange:
|
||||
# Token exchange mode: Exchange Flow 1 token for ephemeral Nextcloud token
|
||||
return await get_session_client_from_context(
|
||||
ctx, lifespan_ctx.nextcloud_host
|
||||
)
|
||||
else:
|
||||
# Pass-through mode (default): Verify and pass Flow 1 token to Nextcloud
|
||||
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
|
||||
```
|
||||
|
||||
### 3. MCP Tool Pattern (No Changes Required!)
|
||||
|
||||
Tools use the same pattern regardless of token acquisition mode:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read") # Soft-scope enforced by MCP server, not Nextcloud
|
||||
@require_provisioning
|
||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||
"""Search notes by title or content."""
|
||||
|
||||
# get_client() handles both pass-through and token exchange modes
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Execute operation
|
||||
results = await client.notes.search_notes(query=query)
|
||||
|
||||
# In token exchange mode, ephemeral token is automatically discarded
|
||||
# In pass-through mode, Flow 1 token was validated and passed through
|
||||
return SearchNotesResponse(results=results)
|
||||
```
|
||||
|
||||
**Key Benefit**: Tools don't need to know which mode is active. The token acquisition pattern is configured at the server level via `ENABLE_TOKEN_EXCHANGE`.
|
||||
|
||||
### 4. Background Job Pattern
|
||||
|
||||
Background jobs use a **different token acquisition pattern** - they use refresh tokens from Flow 2:
|
||||
|
||||
```python
|
||||
# Background worker
|
||||
async def sync_notes_job(user_id: str):
|
||||
"""Background job to sync notes."""
|
||||
|
||||
# Get refresh token stored during Flow 2 (Progressive Consent)
|
||||
token_storage = get_token_storage()
|
||||
refresh_token = await token_storage.get_refresh_token(user_id)
|
||||
|
||||
if not refresh_token:
|
||||
logger.warning(f"No refresh token for user {user_id}")
|
||||
return
|
||||
|
||||
# Use refresh token to get Nextcloud access token
|
||||
idp_client = get_idp_client()
|
||||
response = await idp_client.refresh_token(
|
||||
refresh_token=refresh_token,
|
||||
audience='nextcloud'
|
||||
)
|
||||
|
||||
# Create client with background token (can be cached)
|
||||
client = NextcloudClient.from_token(
|
||||
base_url=NEXTCLOUD_HOST,
|
||||
token=response.access_token,
|
||||
username=user_id
|
||||
)
|
||||
|
||||
# Perform background sync
|
||||
await client.notes.sync_all()
|
||||
```
|
||||
|
||||
**Key differences from tool calls:**
|
||||
- Uses refresh tokens from Flow 2 (Progressive Consent provisioning)
|
||||
- Tokens can be cached for efficiency (longer-lived operations)
|
||||
- No user interaction possible (offline)
|
||||
- Never triggered during MCP tool execution
|
||||
|
||||
## Security Benefits
|
||||
|
||||
### Proper Token Exchange:
|
||||
1. ✅ **Least Privilege**: Each operation gets only needed scopes
|
||||
2. ✅ **Time-Limited**: Session tokens expire quickly
|
||||
3. ✅ **Audit Trail**: Each exchange can be logged
|
||||
4. ✅ **Token Isolation**: Session ≠ Background tokens
|
||||
5. ✅ **Revocation**: Can revoke background access without affecting active sessions
|
||||
|
||||
### Current Incorrect Pattern:
|
||||
1. ❌ **Over-Privileged**: Refresh token has all scopes
|
||||
2. ❌ **Long-Lived**: Same token reused indefinitely
|
||||
3. ❌ **No Separation**: Sessions and background jobs use same credential
|
||||
4. ❌ **Revocation Issues**: Revoking affects everything
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Phase 1: Token Exchange (High Priority)
|
||||
1. Implement RFC 8693 token exchange endpoint
|
||||
2. Update Token Broker with `get_session_token()` vs `get_background_token()`
|
||||
3. Modify tool pattern to use token exchange
|
||||
|
||||
### Phase 2: Scope Separation (High Priority)
|
||||
1. Define session scopes vs background scopes
|
||||
2. Update provisioning flow to request appropriate scopes
|
||||
3. Validate scopes in token exchange
|
||||
|
||||
### Phase 3: Background Jobs (Medium Priority)
|
||||
1. Implement background worker pattern
|
||||
2. Create scheduled jobs (note sync, etc.)
|
||||
3. Use background token pattern
|
||||
|
||||
### Phase 4: Testing (High Priority)
|
||||
1. Test token exchange flow end-to-end
|
||||
2. Verify session tokens are ephemeral
|
||||
3. Verify background tokens are separate
|
||||
4. Load test token exchange performance
|
||||
|
||||
## References
|
||||
|
||||
- **RFC 8693**: OAuth 2.0 Token Exchange
|
||||
- **RFC 9068**: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens
|
||||
- **ADR-004**: Progressive Consent OAuth Flows
|
||||
- **OAuth 2.0 Delegation**: On-Behalf-Of vs Impersonation patterns
|
||||
|
||||
## Status
|
||||
|
||||
**Current Status**: ✅ Token exchange infrastructure implemented, available as opt-in feature
|
||||
**Modes Available**:
|
||||
- ✅ Pass-through mode (default, `ENABLE_TOKEN_EXCHANGE=false`): Simple, stateless
|
||||
- ✅ Token exchange mode (opt-in, `ENABLE_TOKEN_EXCHANGE=true`): Enhanced security
|
||||
|
||||
**Implementation Complete**:
|
||||
- ✅ `token_exchange.py` module with RFC 8693 support
|
||||
- ✅ Fallback to refresh grant when RFC 8693 not supported
|
||||
- ✅ `get_client()` unified pattern (handles both modes transparently)
|
||||
- ✅ Tokens never cached in token exchange mode (ephemeral)
|
||||
- ✅ Background jobs use separate pattern (refresh tokens from Flow 2)
|
||||
|
||||
## Configuration
|
||||
|
||||
To enable token exchange mode:
|
||||
|
||||
```bash
|
||||
# docker-compose.yml or .env
|
||||
ENABLE_TOKEN_EXCHANGE=true
|
||||
```
|
||||
|
||||
When enabled, all MCP tool calls will use token exchange (RFC 8693) to obtain ephemeral Nextcloud tokens. When disabled (default), Flow 1 tokens are passed through to Nextcloud.
|
||||
|
||||
## Nextcloud Scope Limitation
|
||||
|
||||
**IMPORTANT**: Nextcloud does not support OAuth scopes natively. Scopes like "notes:read" are **soft-scopes** enforced by the MCP server via `@require_scopes` decorator, not by the IdP or Nextcloud.
|
||||
|
||||
This means:
|
||||
- Token exchange provides audit and delegation benefits, not scope restriction
|
||||
- All Nextcloud tokens have equivalent permissions at the Nextcloud level
|
||||
- Fine-grained access control is enforced by MCP server, not Nextcloud
|
||||
|
||||
## Next Actions (Optional Enhancements)
|
||||
|
||||
1. [ ] Add integration tests for token exchange mode with actual MCP tools
|
||||
2. [ ] Document background job patterns for scheduled sync operations
|
||||
3. [ ] Add metrics for token exchange performance
|
||||
4. [ ] Consider making token exchange the default in future major version
|
||||
@@ -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
|
||||
|
||||
|
||||
+10
-11
@@ -272,7 +272,7 @@ mcp-oauth:
|
||||
|
||||
**Key Points:**
|
||||
- **No credentials needed** - DCR automatically registers the client on first start
|
||||
- **Credentials persist** - Saved to `.nextcloud_oauth_client.json` and reused
|
||||
- **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
|
||||
@@ -286,7 +286,6 @@ mcp-oauth:
|
||||
| `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 mcp:notes:read mcp:notes:write"` |
|
||||
| `NEXTCLOUD_OIDC_TOKEN_TYPE` | Token format: `"jwt"` or `"Bearer"` | `"Bearer"` |
|
||||
|
||||
@@ -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
|
||||
@@ -327,10 +326,10 @@ 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
|
||||
@@ -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**.
|
||||
@@ -724,7 +723,7 @@ docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
|
||||
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
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -0,0 +1,323 @@
|
||||
# OAuth Architecture Comparison: MCP Server Authentication Patterns
|
||||
|
||||
This document compares three authentication architectures for the MCP server, explaining the evolution from pass-through authentication to true offline access capabilities.
|
||||
|
||||
## Pattern 1: Pass-Through Authentication (Current Implementation)
|
||||
|
||||
### Architecture
|
||||
```
|
||||
┌─────────────┐ OAuth Flow ┌─────────────┐
|
||||
│ MCP Client │◄──────────────────│ OAuth │
|
||||
│ (Claude) │ │ Provider │
|
||||
└──────┬──────┘ └─────────────┘
|
||||
│
|
||||
│ Access Token
|
||||
│ (per request)
|
||||
▼
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ MCP Server │───────────────────►│ Nextcloud │
|
||||
│(Pass-through) │ APIs │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
| Aspect | Description |
|
||||
|--------|-------------|
|
||||
| **Token Flow** | MCP Client → MCP Server → Nextcloud |
|
||||
| **Token Storage** | None (tokens exist only during request) |
|
||||
| **Offline Access** | ❌ Impossible |
|
||||
| **Background Workers** | ❌ Not supported |
|
||||
| **User Consent** | Single OAuth flow (client-managed) |
|
||||
| **Complexity** | Low |
|
||||
| **Security** | High (no token persistence) |
|
||||
|
||||
### How It Works
|
||||
1. MCP Client performs OAuth with provider
|
||||
2. Client includes access token in each MCP request
|
||||
3. MCP Server validates token and forwards to Nextcloud
|
||||
4. Token discarded after request completes
|
||||
|
||||
### Limitations
|
||||
- No operations possible without active MCP session
|
||||
- Background sync/indexing impossible
|
||||
- Cannot refresh tokens independently
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2: Token Exchange Delegation (ADR-002 - Flawed)
|
||||
|
||||
### Architecture
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ MCP Client │────────────────────│ OAuth │
|
||||
│ (Claude) │ │ Provider │
|
||||
└──────┬──────┘ └──────┬──────┘
|
||||
│ │
|
||||
│ Access Token │ Service Account Token
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ MCP Server │
|
||||
│ ┌────────────────────────────────────┐ │
|
||||
│ │ Token Exchange (RFC 8693) │ │
|
||||
│ │ Subject: Service Account │ │
|
||||
│ │ Target: User │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
└───────────────┬─────────────────────────────┘
|
||||
│ Exchanged Token
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Nextcloud │
|
||||
│ APIs │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
| Aspect | Description |
|
||||
|--------|-------------|
|
||||
| **Token Flow** | Service Account → Exchange → User Token |
|
||||
| **Token Storage** | None (MCP server still stateless) |
|
||||
| **Offline Access** | ❌ Still impossible (circular dependency) |
|
||||
| **Background Workers** | ❌ Requires service account (rejected) |
|
||||
| **User Consent** | Implicit through service account |
|
||||
| **Complexity** | High |
|
||||
| **Security** | ⚠️ Service accounts violate OAuth principles |
|
||||
|
||||
### Why It Fails
|
||||
1. **Circular Dependency**: To exchange tokens, you need a token to exchange
|
||||
2. **Service Account Problem**: Creates Nextcloud user identity for service
|
||||
3. **OAuth Violation**: Service acts as itself, not on behalf of users
|
||||
4. **No Bootstrap**: Still can't obtain initial tokens offline
|
||||
|
||||
### The Fatal Flaw
|
||||
```
|
||||
Q: How does background worker get tokens?
|
||||
A: Use token exchange with service account
|
||||
|
||||
Q: How does service account get authorized?
|
||||
A: Client credentials grant creates user account (violates OAuth)
|
||||
|
||||
Q: Can we use user's refresh token?
|
||||
A: MCP server never sees refresh tokens (by design)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3: Sign-in with Nextcloud (Previous ADR-004 Draft)
|
||||
|
||||
### Architecture
|
||||
```
|
||||
┌─────────────┐ ┌─────────────────┐ ┌────────────┐
|
||||
│ MCP Client ├───────────────────> │ MCP Server ├────────────────────>│ Nextcloud │
|
||||
│ (Claude) │ (MCP Protocol) │ (OAuth Client) │ (OIDC + APIs) │ (IdP) │
|
||||
└─────────────┘ └─────────────────┘ └────────────┘
|
||||
│
|
||||
┌──────▼────────┐
|
||||
│ Token Storage │
|
||||
│ (NC Tokens) │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
| Aspect | Description |
|
||||
|--------|-------------|
|
||||
| **Token Flow** | MCP Server uses Nextcloud as identity provider |
|
||||
| **Token Storage** | ✅ Encrypted Nextcloud refresh tokens |
|
||||
| **Offline Access** | ✅ Full support |
|
||||
| **Background Workers** | ✅ Use stored refresh tokens |
|
||||
| **User Consent** | Single OAuth flow (Nextcloud only) |
|
||||
| **Complexity** | Medium |
|
||||
| **Security** | High (with token rotation) |
|
||||
|
||||
### How It Works
|
||||
1. **Initial Setup**:
|
||||
- User tries to use MCP tool
|
||||
- MCP server returns auth required
|
||||
- User authenticates with Nextcloud's OIDC endpoint
|
||||
- Nextcloud may use user_oidc to delegate to external IdP (Keycloak, etc.)
|
||||
- MCP server stores Nextcloud-issued refresh token (encrypted)
|
||||
|
||||
2. **Subsequent Requests**:
|
||||
- MCP server uses stored Nextcloud tokens
|
||||
- Refreshes automatically when expired
|
||||
- No client involvement needed
|
||||
|
||||
3. **Background Operations**:
|
||||
- Worker retrieves stored refresh token
|
||||
- Refreshes with Nextcloud directly
|
||||
- Performs operations independently
|
||||
|
||||
### Advantages
|
||||
- ✅ Single sign-on with Nextcloud
|
||||
- ✅ True offline access capability
|
||||
- ✅ OAuth-compliant with proper consent
|
||||
- ✅ Supports external IdPs via user_oidc
|
||||
- ✅ Simpler integration - only one OAuth endpoint
|
||||
|
||||
### Trade-offs
|
||||
- Authentication flows through Nextcloud
|
||||
- Nextcloud manages IdP relationships (via user_oidc)
|
||||
- MCP server only knows about Nextcloud, not the underlying IdP
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4: Federated Authentication Architecture (ADR-004 - Solution)
|
||||
|
||||
### Architecture
|
||||
```
|
||||
┌─────────────┐ ┌─────────────────┐ ┌──────────────┐ ┌────────────┐
|
||||
│ MCP Client │◄──────401──────│ MCP Server │◄────OAuth──────│ Shared IdP │──Validates──►│ Nextcloud │
|
||||
│ (Claude) │ │ (OAuth Client) │ (On-Behalf) │ (Keycloak) │ Tokens │(Resource) │
|
||||
└─────────────┘ └─────────────────┘ └──────────────┘ └────────────┘
|
||||
│
|
||||
┌───────▼────────┐
|
||||
│ Token Storage │
|
||||
│ (IdP Tokens) │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
| Aspect | Description |
|
||||
|--------|-------------|
|
||||
| **Token Flow** | Shared IdP issues tokens for Nextcloud access |
|
||||
| **Token Storage** | ✅ Encrypted IdP refresh tokens |
|
||||
| **Offline Access** | ✅ Full support |
|
||||
| **Background Workers** | ✅ Use stored IdP refresh tokens |
|
||||
| **User Consent** | Single OAuth flow (IdP manages consent) |
|
||||
| **Complexity** | Medium-High |
|
||||
| **Security** | Highest (enterprise-grade IdP) |
|
||||
|
||||
### How It Works
|
||||
1. **Initial Setup**:
|
||||
- MCP client connects, receives 401
|
||||
- Browser opens MCP server OAuth URL
|
||||
- MCP server redirects to shared IdP
|
||||
- User authenticates once to IdP
|
||||
- IdP shows consent for both identity and Nextcloud access
|
||||
- MCP server stores IdP refresh token (encrypted)
|
||||
- MCP server issues session token to client
|
||||
|
||||
2. **Subsequent Requests**:
|
||||
- MCP server validates session token
|
||||
- Uses stored IdP token for Nextcloud
|
||||
- Refreshes with IdP when expired
|
||||
- No client involvement needed
|
||||
|
||||
3. **Background Operations**:
|
||||
- Worker retrieves stored IdP refresh token
|
||||
- Gets new access token from IdP
|
||||
- Uses token to access Nextcloud
|
||||
- Performs operations independently
|
||||
|
||||
### Advantages
|
||||
- ✅ True single sign-on (SSO)
|
||||
- ✅ Enterprise-ready with SAML/LDAP support
|
||||
- ✅ OAuth-compliant with proper delegation
|
||||
- ✅ Direct IdP relationship - no intermediary
|
||||
- ✅ Flexible - can swap resource servers
|
||||
- ✅ Industry-standard federated pattern
|
||||
|
||||
### Trade-offs
|
||||
- Requires shared IdP infrastructure
|
||||
- More complex initial setup
|
||||
- Token validation overhead
|
||||
|
||||
---
|
||||
|
||||
## Comparison Matrix
|
||||
|
||||
| Feature | Pass-Through | Token Exchange | Sign-in with NC | Federated Auth |
|
||||
|---------|--------------|----------------|-----------------|----------------|
|
||||
| **Offline Access** | ❌ No | ❌ No | ✅ Yes | ✅ Yes |
|
||||
| **Background Workers** | ❌ No | ❌ No* | ✅ Yes | ✅ Yes |
|
||||
| **Token Storage** | None | None | NC refresh tokens | IdP refresh tokens |
|
||||
| **OAuth Compliance** | ✅ Full | ⚠️ Violates | ✅ Full | ✅ Full |
|
||||
| **User Consent** | Once | Implicit | Once (NC) | Once (IdP) |
|
||||
| **Implementation Complexity** | Low | High | Medium | Medium-High |
|
||||
| **Security** | High | Medium | High | Highest |
|
||||
| **Enterprise Ready** | ❌ No | ❌ No | ⚠️ Indirect | ✅ Yes |
|
||||
| **Identity Provider** | Client-managed | N/A | Nextcloud (+user_oidc) | Shared IdP |
|
||||
| **Suitable For** | Interactive only | N/A (flawed) | Small teams | Enterprise |
|
||||
|
||||
\* *Requires service accounts that violate OAuth principles*
|
||||
|
||||
---
|
||||
|
||||
## Evolution Summary
|
||||
|
||||
### Stage 1: Simple Pass-Through ✅
|
||||
- **Goal**: Basic MCP functionality
|
||||
- **Result**: Works well for interactive use
|
||||
- **Limitation**: No offline capabilities
|
||||
|
||||
### Stage 2: Attempted Delegation ❌
|
||||
- **Goal**: Enable offline access without changing architecture
|
||||
- **Result**: Circular dependencies, OAuth violations
|
||||
- **Learning**: MCP protocol constraints are fundamental
|
||||
|
||||
### Stage 3: Sign-in with Nextcloud ⚠️
|
||||
- **Goal**: True offline access with OAuth compliance
|
||||
- **Result**: MCP server uses Nextcloud as identity provider
|
||||
- **Limitation**: Tight coupling to Nextcloud, no enterprise IdP
|
||||
|
||||
### Stage 4: Federated Pattern ✅
|
||||
- **Goal**: Enterprise-ready offline access
|
||||
- **Result**: Shared IdP for both MCP server and Nextcloud
|
||||
- **Trade-off**: Additional infrastructure justified by enterprise needs
|
||||
|
||||
---
|
||||
|
||||
## Key Insights
|
||||
|
||||
1. **Pattern 3 vs Pattern 4**: Both support external IdPs, but differ in integration approach:
|
||||
- Pattern 3: MCP → Nextcloud OIDC → (user_oidc) → External IdP
|
||||
- Pattern 4: MCP → External IdP directly (Nextcloud also uses same IdP)
|
||||
- Choose Pattern 3 for Nextcloud-centric deployments, Pattern 4 for IdP-centric enterprises
|
||||
|
||||
2. **The MCP Protocol Boundary**: The MCP protocol creates a fundamental boundary between client and server token management. Attempting to breach this boundary (ADR-002) leads to architectural contradictions.
|
||||
|
||||
3. **Service Accounts Don't Solve User Problems**: Using service accounts for user operations violates OAuth's core principle of acting on behalf of users, not as a service identity.
|
||||
|
||||
4. **Double OAuth is Industry Standard**: Major platforms (Zapier, IFTTT, Microsoft Power Automate) use this pattern - the integration platform is an OAuth client that maintains its own relationships with upstream services.
|
||||
|
||||
5. **Refresh Tokens Are The Solution**: The OAuth spec designed refresh tokens specifically for offline access. Rejecting them (as ADR-002 did) means rejecting the standard solution.
|
||||
|
||||
6. **Complexity is Justified**: The additional complexity of managing OAuth flows is acceptable when offline access is a requirement. The alternative is no offline access at all.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### For Simple Deployments
|
||||
Use **Pattern 1 (Pass-Through)** if:
|
||||
- Offline access not needed
|
||||
- Only interactive operations required
|
||||
- Simplicity is priority
|
||||
|
||||
### For Teams Using Nextcloud
|
||||
Use **Pattern 3 (Sign-in with Nextcloud)** if:
|
||||
- Background sync/indexing required
|
||||
- Nextcloud manages your authentication
|
||||
- Can use external IdPs via user_oidc
|
||||
- Prefer single integration point through Nextcloud
|
||||
|
||||
### For Enterprise Deployments
|
||||
Use **Pattern 4 (Federated Authentication)** if:
|
||||
- Enterprise IdP already exists (Keycloak, Okta, Azure AD)
|
||||
- Multiple resource servers beyond Nextcloud
|
||||
- Compliance requirements for centralized auth
|
||||
- Building platform for multiple organizations
|
||||
|
||||
### Never Use Pattern 2
|
||||
Token Exchange with service accounts should not be used as it:
|
||||
- Doesn't enable true offline access
|
||||
- Violates OAuth principles
|
||||
- Adds complexity without solving the problem
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-002: Vector Database Background Sync Authentication (Deprecated)](./ADR-002-vector-sync-authentication.md)
|
||||
- [ADR-004: MCP Server as OAuth Client for Offline Access](./ADR-004-mcp-application-oauth.md)
|
||||
- [RFC 6749: OAuth 2.0 Framework](https://datatracker.ietf.org/doc/html/rfc6749)
|
||||
- [RFC 8693: OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693)
|
||||
@@ -39,7 +39,7 @@ Phase 0: MCP Server Startup & Client Registration (DCR - RFC 7591)
|
||||
│ 0d. Client credentials │
|
||||
│<────────────────────────────────────┤
|
||||
│ {client_id, client_secret} │
|
||||
│ → Saved to .nextcloud_oauth_*.json │
|
||||
│ → Saved to SQLite database │
|
||||
│ │
|
||||
│ ✓ Server ready for connections │
|
||||
|
||||
@@ -211,7 +211,7 @@ Insufficient Scope Example (Step-Up Authorization)
|
||||
- **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 `.nextcloud_oauth_client.json`
|
||||
- Saves credentials to SQLite database
|
||||
- **Tool Registration**: Loads all MCP tools with their `@require_scopes` decorators
|
||||
|
||||
#### Client Connection Phase
|
||||
@@ -324,7 +324,7 @@ The OAuth flow consists of four distinct phases (see diagram above for visual re
|
||||
- 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 `.nextcloud_oauth_client.json`
|
||||
- Saves credentials to SQLite database
|
||||
|
||||
3. **Tool Registration**
|
||||
- All MCP tools loaded with their `@require_scopes` decorators
|
||||
@@ -515,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
|
||||
|
||||
@@ -718,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
|
||||
```
|
||||
|
||||
@@ -17,7 +17,7 @@ Start here to identify your issue:
|
||||
| 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
|
||||
|
||||
@@ -161,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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
+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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+30
-1
@@ -8,12 +8,41 @@ 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
|
||||
|
||||
# ===== ADR-004 PROGRESSIVE CONSENT CONFIGURATION =====
|
||||
# Enable Progressive Consent mode (dual OAuth flows)
|
||||
# When enabled: Flow 1 for client auth, Flow 2 for Nextcloud resource access
|
||||
# When disabled: Uses existing hybrid flow (backward compatible)
|
||||
|
||||
# MCP Server OAuth Client Configuration
|
||||
# The MCP server's own OAuth client credentials for Flow 2
|
||||
# If not set, will use dynamic client registration
|
||||
#MCP_SERVER_CLIENT_ID=
|
||||
#MCP_SERVER_CLIENT_SECRET=
|
||||
|
||||
# Allowed MCP Client IDs (comma-separated list)
|
||||
# Client IDs that are allowed to authenticate in Flow 1
|
||||
# Examples: claude-desktop,continue-dev,zed-editor
|
||||
#ALLOWED_MCP_CLIENTS=claude-desktop,continue-dev,zed-editor
|
||||
|
||||
# Token cache configuration for Token Broker Service
|
||||
# Cache TTL in seconds (default: 300 = 5 minutes)
|
||||
#TOKEN_CACHE_TTL=300
|
||||
# Early refresh threshold in seconds (default: 30)
|
||||
#TOKEN_CACHE_EARLY_REFRESH=30
|
||||
|
||||
# Option 2: Basic Authentication (LEGACY - Less Secure)
|
||||
# - Requires username and password
|
||||
# - Credentials stored in environment variables
|
||||
|
||||
@@ -0,0 +1,817 @@
|
||||
{
|
||||
"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 and as token exchange target",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "nextcloud-secret-change-in-production",
|
||||
"redirectUris": [],
|
||||
"webOrigins": [],
|
||||
"bearerOnly": false,
|
||||
"consentRequired": false,
|
||||
"standardFlowEnabled": false,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": true,
|
||||
"authorizationServicesEnabled": true,
|
||||
"publicClient": false,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"display.on.consent.screen": "false",
|
||||
"token.exchange.grant.enabled": "true",
|
||||
"client.token.exchange.standard.enabled": "true",
|
||||
"standard.token.exchange.enabled": "true"
|
||||
},
|
||||
"authorizationSettings": {
|
||||
"allowRemoteResourceManagement": true,
|
||||
"policyEnforcementMode": "ENFORCING",
|
||||
"resources": [
|
||||
{
|
||||
"name": "token-exchange",
|
||||
"type": "urn:keycloak:token-exchange",
|
||||
"ownerManagedAccess": false,
|
||||
"displayName": "Token Exchange",
|
||||
"attributes": {},
|
||||
"uris": [],
|
||||
"scopes": [
|
||||
{
|
||||
"name": "token-exchange"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"policies": [
|
||||
{
|
||||
"name": "allow-nextcloud-mcp-server-to-exchange",
|
||||
"description": "",
|
||||
"type": "client",
|
||||
"logic": "POSITIVE",
|
||||
"decisionStrategy": "UNANIMOUS",
|
||||
"config": {
|
||||
"clients": "[\"nextcloud-mcp-server\",\"nextcloud\"]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "token-exchange-permission",
|
||||
"description": "",
|
||||
"type": "scope",
|
||||
"logic": "POSITIVE",
|
||||
"decisionStrategy": "AFFIRMATIVE",
|
||||
"config": {
|
||||
"resources": "[\"token-exchange\"]",
|
||||
"scopes": "[\"token-exchange\"]",
|
||||
"applyPolicies": "[\"allow-nextcloud-mcp-server-to-exchange\"]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"scopes": [
|
||||
{
|
||||
"name": "token-exchange",
|
||||
"displayName": "Token Exchange"
|
||||
}
|
||||
],
|
||||
"decisionStrategy": "UNANIMOUS"
|
||||
},
|
||||
"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",
|
||||
"standard.token.exchange.enabled": "true"
|
||||
},
|
||||
"fullScopeAllowed": true,
|
||||
"nodeReRegistrationTimeout": -1,
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "mcp-server-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "nextcloud-mcp-server",
|
||||
"access.token.claim": "true",
|
||||
"id.token.claim": "false",
|
||||
"introspection.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nextcloud-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "nextcloud",
|
||||
"access.token.claim": "true",
|
||||
"id.token.claim": "false",
|
||||
"introspection.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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"
|
||||
],
|
||||
"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"
|
||||
]
|
||||
}
|
||||
+524
-175
@@ -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,18 +15,21 @@ 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.authentication import AuthenticationMiddleware
|
||||
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.auth.progressive_token_verifier import (
|
||||
ProgressiveConsentTokenVerifier,
|
||||
)
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import (
|
||||
LOGGING_CONFIG,
|
||||
@@ -41,6 +48,7 @@ from nextcloud_mcp_server.server import (
|
||||
configure_tables_tools,
|
||||
configure_webdav_tools,
|
||||
)
|
||||
from nextcloud_mcp_server.server.oauth_tools import register_oauth_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -207,7 +215,12 @@ class OAuthAppContext:
|
||||
"""Application context for OAuth mode."""
|
||||
|
||||
nextcloud_host: str
|
||||
token_verifier: NextcloudTokenVerifier
|
||||
token_verifier: (
|
||||
object # Can be NextcloudTokenVerifier or ProgressiveConsentTokenVerifier
|
||||
)
|
||||
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:
|
||||
@@ -261,52 +274,52 @@ 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:
|
||||
logger.info("Dynamic client registration available")
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
|
||||
redirect_uris = [
|
||||
f"{mcp_server_url}/oauth/callback", # MCP OAuth flow
|
||||
f"{mcp_server_url}/oauth/login-callback", # Browser OAuth flow for /user/page
|
||||
]
|
||||
|
||||
# Get scopes from environment or use defaults
|
||||
# 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.
|
||||
# MCP server DCR: Register with ALL supported scopes
|
||||
# When we register as a resource server (with resource_url), the allowed_scopes
|
||||
# represent what scopes are AVAILABLE for this resource, not what the server needs.
|
||||
# External clients will request tokens with resource=http://localhost:8001/mcp
|
||||
# and the authorization server will limit them to these allowed scopes.
|
||||
#
|
||||
# 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"
|
||||
# The PRM endpoint advertises the same scopes dynamically via @require_scopes decorators.
|
||||
dcr_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"
|
||||
|
||||
# Add offline_access scope if refresh tokens are enabled
|
||||
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", default_scopes)
|
||||
logger.info(f"Requesting OAuth scopes: {scopes}")
|
||||
if enable_offline_access:
|
||||
dcr_scopes = f"{dcr_scopes} offline_access"
|
||||
logger.info("✓ offline_access scope enabled for refresh tokens")
|
||||
|
||||
logger.info(f"MCP server DCR scopes (resource server): {dcr_scopes}")
|
||||
|
||||
# Get token type from environment (Bearer or jwt)
|
||||
# Note: Must be lowercase "jwt" to match OIDC app's check
|
||||
@@ -316,19 +329,26 @@ 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()
|
||||
|
||||
# RFC 9728: resource_url must be a URL for the protected resource
|
||||
# This URL is used by token introspection to match tokens to this client
|
||||
resource_url = f"{mcp_server_url}/mcp"
|
||||
|
||||
client_info = await ensure_oauth_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
storage_path=storage_path,
|
||||
storage=storage,
|
||||
client_name=f"Nextcloud MCP Server ({token_type})",
|
||||
redirect_uris=redirect_uris,
|
||||
scopes=scopes,
|
||||
scopes=dcr_scopes, # Use DCR-specific scopes (basic OIDC only)
|
||||
token_type=token_type,
|
||||
resource_url=resource_url, # RFC 9728 Protected Resource URL
|
||||
)
|
||||
|
||||
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
|
||||
@@ -338,8 +358,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"
|
||||
)
|
||||
|
||||
|
||||
@@ -367,84 +388,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")
|
||||
|
||||
# Initialize document processors
|
||||
initialize_document_processors()
|
||||
|
||||
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, client_id, client_secret)
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
@@ -453,69 +416,226 @@ 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
|
||||
)
|
||||
# Progressive Consent mode (always enabled) - dual OAuth flows with audience separation
|
||||
logger.info("✓ Progressive Consent mode enabled - dual OAuth flows active")
|
||||
|
||||
# Create token verifier with JWT support and introspection
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
# Get encryption key for token broker
|
||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
if not encryption_key:
|
||||
logger.warning(
|
||||
"TOKEN_ENCRYPTION_KEY not set - token broker will not be available"
|
||||
)
|
||||
|
||||
# Create token broker service
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
|
||||
token_broker = None
|
||||
if encryption_key and refresh_token_storage:
|
||||
token_broker = TokenBrokerService(
|
||||
storage=refresh_token_storage,
|
||||
oidc_discovery_url=discovery_url,
|
||||
nextcloud_host=nextcloud_host,
|
||||
encryption_key=encryption_key,
|
||||
)
|
||||
logger.info("✓ Token Broker service initialized for audience-specific tokens")
|
||||
|
||||
# Create Progressive Consent token verifier
|
||||
token_verifier = ProgressiveConsentTokenVerifier(
|
||||
token_storage=refresh_token_storage,
|
||||
token_broker=token_broker,
|
||||
oidc_discovery_url=discovery_url,
|
||||
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,
|
||||
encryption_key=encryption_key,
|
||||
mcp_client_id=client_id,
|
||||
introspection_uri=introspection_uri,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✓ Progressive Consent verifier configured - enforcing audience separation"
|
||||
)
|
||||
if introspection_uri:
|
||||
logger.info("✓ Opaque token introspection enabled (RFC 7662)")
|
||||
|
||||
# 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")
|
||||
|
||||
@@ -523,13 +643,24 @@ 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,
|
||||
client_id,
|
||||
client_secret,
|
||||
)
|
||||
|
||||
|
||||
def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
@@ -543,10 +674,55 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
# Asynchronously get the OAuth configuration
|
||||
import anyio
|
||||
|
||||
_, token_verifier, auth_settings = anyio.run(setup_oauth_config)
|
||||
(
|
||||
nextcloud_host,
|
||||
token_verifier,
|
||||
auth_settings,
|
||||
refresh_token_storage,
|
||||
oauth_client,
|
||||
oauth_provider,
|
||||
client_id,
|
||||
client_secret,
|
||||
) = 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")
|
||||
|
||||
mcp = FastMCP(
|
||||
"Nextcloud MCP",
|
||||
lifespan=app_lifespan_oauth,
|
||||
lifespan=oauth_lifespan,
|
||||
token_verifier=token_verifier,
|
||||
auth=auth_settings,
|
||||
)
|
||||
@@ -558,7 +734,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
async def nc_get_capabilities():
|
||||
"""Get the Nextcloud Host capabilities"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_nextcloud_client(ctx)
|
||||
client = await get_nextcloud_client(ctx)
|
||||
return await client.capabilities()
|
||||
|
||||
# Define available apps and their configuration functions
|
||||
@@ -587,6 +763,17 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
f"Unknown app: {app_name}. Available apps: {list(available_apps.keys())}"
|
||||
)
|
||||
|
||||
# Register OAuth provisioning tools (only when offline access/Progressive Consent is used)
|
||||
# With token exchange enabled (external IdP), provisioning is not needed for MCP operations
|
||||
enable_token_exchange = (
|
||||
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
|
||||
)
|
||||
if oauth_enabled and not enable_token_exchange:
|
||||
logger.info("Registering OAuth provisioning tools for Progressive Consent")
|
||||
register_oauth_tools(mcp)
|
||||
elif oauth_enabled and enable_token_exchange:
|
||||
logger.info("Skipping provisioning tools registration (token exchange enabled)")
|
||||
|
||||
# Override list_tools to filter based on user's token scopes (OAuth mode only)
|
||||
if oauth_enabled:
|
||||
original_list_tools = mcp._tool_manager.list_tools
|
||||
@@ -638,32 +825,125 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
|
||||
if transport == "sse":
|
||||
mcp_app = mcp.sse_app()
|
||||
lifespan = None
|
||||
starlette_lifespan = None
|
||||
elif transport in ("http", "streamable-http"):
|
||||
mcp_app = mcp.streamable_http_app()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: Starlette):
|
||||
async def starlette_lifespan(app: Starlette):
|
||||
# Set OAuth context for OAuth login routes (ADR-004)
|
||||
if oauth_enabled:
|
||||
# Prepare OAuth config from setup_oauth_config closure variables
|
||||
mcp_server_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
f"{nextcloud_host}/.well-known/openid-configuration",
|
||||
)
|
||||
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", "")
|
||||
|
||||
app.state.oauth_context = {
|
||||
"storage": refresh_token_storage,
|
||||
"oauth_client": oauth_client,
|
||||
"token_verifier": token_verifier, # For querying IdP userinfo endpoint
|
||||
"config": {
|
||||
"mcp_server_url": mcp_server_url,
|
||||
"discovery_url": discovery_url,
|
||||
"client_id": client_id, # From setup_oauth_config (DCR or static)
|
||||
"client_secret": client_secret, # From setup_oauth_config (DCR or static)
|
||||
"scopes": scopes,
|
||||
"nextcloud_host": nextcloud_host,
|
||||
"oauth_provider": oauth_provider,
|
||||
},
|
||||
}
|
||||
logger.info(
|
||||
f"OAuth context initialized for login routes (client_id={client_id[:16]}...)"
|
||||
)
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
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:
|
||||
# Import OAuth routes (ADR-004 Progressive Consent)
|
||||
from nextcloud_mcp_server.auth.oauth_routes import oauth_authorize
|
||||
|
||||
def oauth_protected_resource_metadata(request):
|
||||
"""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"
|
||||
|
||||
The 'resource' field is set to the MCP server's public URL (RFC 9728 requires a URL).
|
||||
This is used as the audience in access tokens via the resource parameter (RFC 8707).
|
||||
The introspection controller matches this URL to the MCP server's client via resource_url field.
|
||||
"""
|
||||
# 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")
|
||||
@@ -671,13 +951,20 @@ 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", "")
|
||||
|
||||
# RFC 9728 requires resource to be a URL (not a client ID)
|
||||
# Use the MCP server's public URL
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL")
|
||||
if not mcp_server_url:
|
||||
# Fallback to constructing from host and port
|
||||
mcp_server_url = f"http://localhost:{os.getenv('PORT', '8000')}"
|
||||
|
||||
# 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": resource_url,
|
||||
"resource": f"{mcp_server_url}/mcp", # RFC 9728: must be a URL
|
||||
"scopes_supported": supported_scopes,
|
||||
"authorization_servers": [public_issuer_url],
|
||||
"bearer_methods_supported": ["header"],
|
||||
@@ -706,8 +993,86 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
"Protected Resource Metadata (PRM) endpoints enabled (path-based + root)"
|
||||
)
|
||||
|
||||
# Add OAuth login routes (ADR-004 Progressive Consent Flow 1)
|
||||
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
|
||||
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
|
||||
|
||||
# Add browser OAuth login routes (OAuth mode only)
|
||||
if oauth_enabled:
|
||||
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
||||
oauth_login,
|
||||
oauth_login_callback,
|
||||
oauth_logout,
|
||||
)
|
||||
|
||||
routes.append(
|
||||
Route("/oauth/login", oauth_login, methods=["GET"], name="oauth_login")
|
||||
)
|
||||
routes.append(
|
||||
Route(
|
||||
"/oauth/login-callback",
|
||||
oauth_login_callback,
|
||||
methods=["GET"],
|
||||
name="oauth_login_callback",
|
||||
)
|
||||
)
|
||||
routes.append(
|
||||
Route("/oauth/logout", oauth_logout, methods=["GET"], name="oauth_logout")
|
||||
)
|
||||
logger.info(
|
||||
"Browser OAuth routes enabled: /oauth/login, /oauth/login-callback, /oauth/logout"
|
||||
)
|
||||
|
||||
# Add user info routes (available in both BasicAuth and OAuth modes)
|
||||
# These require session authentication, so we wrap them in a separate app
|
||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
user_info_html,
|
||||
user_info_json,
|
||||
)
|
||||
|
||||
# Create a separate Starlette app for browser routes that need session auth
|
||||
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
||||
browser_routes = [
|
||||
Route("/", user_info_json, methods=["GET"]), # /user/ → user_info_json
|
||||
Route("/page", user_info_html, methods=["GET"]), # /user/page → user_info_html
|
||||
]
|
||||
|
||||
browser_app = Starlette(routes=browser_routes)
|
||||
browser_app.add_middleware(
|
||||
AuthenticationMiddleware,
|
||||
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
|
||||
)
|
||||
|
||||
# Mount browser app at /user (so /user and /user/page work)
|
||||
routes.append(Mount("/user", app=browser_app))
|
||||
logger.info("User info routes with session auth: /user, /user/page")
|
||||
|
||||
# Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
|
||||
routes.append(Mount("/", app=mcp_app))
|
||||
app = Starlette(routes=routes, lifespan=lifespan)
|
||||
|
||||
app = Starlette(routes=routes, lifespan=starlette_lifespan)
|
||||
logger.info(
|
||||
"Routes: /user/* with SessionAuth, /mcp with FastMCP OAuth Bearer tokens"
|
||||
)
|
||||
|
||||
# Add debugging middleware to log Authorization headers
|
||||
@app.middleware("http")
|
||||
async def log_auth_headers(request, call_next):
|
||||
auth_header = request.headers.get("authorization")
|
||||
if request.url.path.startswith("/mcp"):
|
||||
if auth_header:
|
||||
# Log first 50 chars of token for debugging
|
||||
token_preview = (
|
||||
auth_header[:50] + "..." if len(auth_header) > 50 else auth_header
|
||||
)
|
||||
logger.info(f"🔑 /mcp request with Authorization: {token_preview}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠️ /mcp request WITHOUT Authorization header from {request.client}"
|
||||
)
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
# Add CORS middleware to allow browser-based clients like MCP Inspector
|
||||
app.add_middleware(
|
||||
@@ -797,13 +1162,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",
|
||||
@@ -855,7 +1213,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,
|
||||
@@ -910,8 +1267,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:
|
||||
@@ -954,13 +1309,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,7 +1,7 @@
|
||||
"""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,
|
||||
@@ -20,7 +20,7 @@ __all__ = [
|
||||
"BearerAuth",
|
||||
"NextcloudTokenVerifier",
|
||||
"register_client",
|
||||
"load_or_register_client",
|
||||
"ensure_oauth_client",
|
||||
"get_client_from_context",
|
||||
"require_scopes",
|
||||
"ScopeAuthorizationError",
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
"""Browser-based OAuth login routes for admin UI.
|
||||
|
||||
Separate from MCP OAuth flow - these routes establish browser sessions
|
||||
for accessing admin UI endpoints like /user/page.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
_get_userinfo_endpoint,
|
||||
_query_idp_userinfo,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
"""Browser OAuth login endpoint - redirects to IdP for authentication.
|
||||
|
||||
This is separate from the MCP OAuth flow (/oauth/authorize).
|
||||
Creates a browser session with refresh token for admin UI access.
|
||||
|
||||
Query parameters:
|
||||
next: Optional URL to redirect to after login (default: /user/page)
|
||||
|
||||
Returns:
|
||||
302 redirect to IdP authorization endpoint
|
||||
"""
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
if not oauth_ctx:
|
||||
# BasicAuth mode - no login needed, redirect to user page
|
||||
return RedirectResponse("/user/page", status_code=302)
|
||||
|
||||
storage = oauth_ctx["storage"]
|
||||
oauth_client = oauth_ctx["oauth_client"]
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Debug: Log oauth_config contents
|
||||
logger.info(f"oauth_login called - oauth_config keys: {oauth_config.keys()}")
|
||||
logger.info(f"oauth_login called - client_id: {oauth_config.get('client_id')}")
|
||||
logger.info(f"oauth_login called - oauth_client: {oauth_client is not None}")
|
||||
|
||||
# Generate state for CSRF protection
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Build OAuth authorization URL
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
callback_uri = f"{mcp_server_url}/oauth/login-callback"
|
||||
|
||||
# Request only basic OIDC scopes for browser session
|
||||
# Note: Nextcloud app scopes (notes:read, etc.) are for MCP client access tokens,
|
||||
# not for the MCP server's own browser authentication
|
||||
scopes = "openid profile email offline_access"
|
||||
|
||||
code_challenge = ""
|
||||
code_verifier = ""
|
||||
|
||||
if oauth_client:
|
||||
# External IdP mode (Keycloak)
|
||||
# Keycloak requires PKCE, so generate code_verifier and code_challenge
|
||||
if not oauth_client.authorization_endpoint:
|
||||
await oauth_client.discover()
|
||||
|
||||
# Generate PKCE values
|
||||
code_verifier, code_challenge = oauth_client.generate_pkce_challenge()
|
||||
|
||||
# Store code_verifier temporarily (using state as key)
|
||||
# We'll retrieve it in the callback using the state parameter
|
||||
await storage.store_oauth_session(
|
||||
session_id=state, # Use state as session ID
|
||||
client_id="browser-ui",
|
||||
client_redirect_uri="/user/page",
|
||||
state=state,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method="S256",
|
||||
mcp_authorization_code=code_verifier, # Store code_verifier here temporarily
|
||||
flow_type="browser",
|
||||
ttl_seconds=600, # 10 minutes
|
||||
)
|
||||
|
||||
idp_params = {
|
||||
"client_id": oauth_client.client_id,
|
||||
"redirect_uri": callback_uri,
|
||||
"response_type": "code",
|
||||
"scope": scopes,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"prompt": "consent", # Ensure refresh token
|
||||
}
|
||||
|
||||
auth_url = f"{oauth_client.authorization_endpoint}?{urlencode(idp_params)}"
|
||||
logger.info(f"Redirecting to external IdP login: {auth_url.split('?')[0]}")
|
||||
else:
|
||||
# Integrated mode (Nextcloud OIDC)
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if not discovery_url:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth discovery URL not configured",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Fetch authorization endpoint
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
|
||||
# Replace internal Docker hostname with public URL
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||
auth_parsed = parse_url(authorization_endpoint)
|
||||
|
||||
if auth_parsed.hostname == internal_parsed.hostname:
|
||||
public_parsed = parse_url(public_issuer)
|
||||
authorization_endpoint = (
|
||||
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
|
||||
)
|
||||
|
||||
idp_params = {
|
||||
"client_id": oauth_config["client_id"],
|
||||
"redirect_uri": callback_uri,
|
||||
"response_type": "code",
|
||||
"scope": scopes,
|
||||
"state": state,
|
||||
"prompt": "consent", # Ensure refresh token
|
||||
}
|
||||
|
||||
# Debug: Log full parameters
|
||||
logger.info(f"Building Nextcloud OIDC auth URL with params: {idp_params}")
|
||||
|
||||
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
|
||||
logger.info(f"Redirecting to Nextcloud OIDC login: {auth_url}")
|
||||
|
||||
return RedirectResponse(auth_url, status_code=302)
|
||||
|
||||
|
||||
async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLResponse:
|
||||
"""Browser OAuth callback - IdP redirects here after authentication.
|
||||
|
||||
Exchanges authorization code for tokens, stores refresh token,
|
||||
sets session cookie, and redirects to original destination.
|
||||
|
||||
Query parameters:
|
||||
code: Authorization code from IdP
|
||||
state: State parameter
|
||||
error: Error code (if authorization failed)
|
||||
|
||||
Returns:
|
||||
302 redirect to next URL with session cookie
|
||||
"""
|
||||
# Check for errors
|
||||
error = request.query_params.get("error")
|
||||
if error:
|
||||
error_description = request.query_params.get(
|
||||
"error_description", "Authorization failed"
|
||||
)
|
||||
logger.error(f"OAuth login error: {error} - {error_description}")
|
||||
login_url = str(request.url_for("oauth_login"))
|
||||
return HTMLResponse(
|
||||
f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login Failed</title></head>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Error: {error}</p>
|
||||
<p>{error_description}</p>
|
||||
<p><a href="{login_url}">Try again</a></p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Extract code and state
|
||||
code = request.query_params.get("code")
|
||||
state = request.query_params.get("state")
|
||||
|
||||
if not code or not state:
|
||||
return HTMLResponse(
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Invalid Request</title></head>
|
||||
<body>
|
||||
<h1>Invalid Request</h1>
|
||||
<p>Missing code or state parameter</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
storage = oauth_ctx["storage"]
|
||||
oauth_client = oauth_ctx["oauth_client"]
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Retrieve code_verifier from session storage (if using PKCE)
|
||||
code_verifier = ""
|
||||
if oauth_client:
|
||||
# For Keycloak (external IdP), we stored the code_verifier in the session
|
||||
oauth_session = await storage.get_oauth_session(state)
|
||||
if oauth_session:
|
||||
# code_verifier was stored in mcp_authorization_code field
|
||||
code_verifier = oauth_session.get("mcp_authorization_code", "")
|
||||
# Clean up the temporary session
|
||||
# Note: We don't have delete_oauth_session method, but it will expire after TTL
|
||||
|
||||
# Exchange authorization code for tokens
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
callback_uri = f"{mcp_server_url}/oauth/login-callback"
|
||||
|
||||
try:
|
||||
if oauth_client:
|
||||
# External IdP mode (Keycloak)
|
||||
# Use PKCE if we have a code_verifier
|
||||
if not oauth_client.token_endpoint:
|
||||
await oauth_client.discover()
|
||||
|
||||
token_params = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": callback_uri,
|
||||
"client_id": oauth_client.client_id,
|
||||
"client_secret": oauth_client.client_secret,
|
||||
}
|
||||
|
||||
# Add code_verifier if we have one (PKCE)
|
||||
if code_verifier:
|
||||
token_params["code_verifier"] = code_verifier
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.post(
|
||||
oauth_client.token_endpoint,
|
||||
data=token_params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
else:
|
||||
# Integrated mode (Nextcloud OIDC)
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": callback_uri,
|
||||
"client_id": oauth_config["client_id"],
|
||||
"client_secret": oauth_config["client_secret"],
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_body = (
|
||||
e.response.text if hasattr(e.response, "text") else str(e.response.content)
|
||||
)
|
||||
logger.error(
|
||||
f"Token exchange failed: HTTP {e.response.status_code} - {error_body}"
|
||||
)
|
||||
return HTMLResponse(
|
||||
f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login Failed</title></head>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Failed to exchange authorization code for tokens</p>
|
||||
<p>HTTP {e.response.status_code}: {error_body}</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=500,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Token exchange failed: {e}")
|
||||
return HTMLResponse(
|
||||
f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login Failed</title></head>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Failed to exchange authorization code for tokens</p>
|
||||
<p>Error: {e}</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
refresh_token = token_data.get("refresh_token")
|
||||
id_token = token_data.get("id_token")
|
||||
|
||||
logger.info(f"Token exchange response keys: {token_data.keys()}")
|
||||
logger.info(f"Refresh token present: {refresh_token is not None}")
|
||||
logger.info(f"ID token present: {id_token is not None}")
|
||||
|
||||
# Decode ID token to get user info
|
||||
try:
|
||||
userinfo = jwt.decode(id_token, options={"verify_signature": False})
|
||||
user_id = userinfo.get("sub")
|
||||
username = userinfo.get("preferred_username") or userinfo.get("email")
|
||||
logger.info(f"Browser login successful: {username} (sub={user_id})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode ID token: {e}")
|
||||
user_id = f"user-{secrets.token_hex(8)}"
|
||||
username = "unknown"
|
||||
|
||||
# Store refresh token (for background jobs ONLY)
|
||||
if refresh_token:
|
||||
logger.info(f"Storing refresh token for user_id: {user_id}")
|
||||
await storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
refresh_token=refresh_token,
|
||||
expires_at=None,
|
||||
flow_type="browser", # Browser-based login flow
|
||||
)
|
||||
logger.info(f"✓ Refresh token stored successfully for user_id: {user_id}")
|
||||
else:
|
||||
logger.warning("No refresh token in token response - cannot store session")
|
||||
|
||||
# Query and cache user profile (for browser UI display)
|
||||
access_token = token_data.get("access_token")
|
||||
if access_token:
|
||||
try:
|
||||
# Get the OAuth context to determine correct userinfo endpoint
|
||||
oauth_ctx = getattr(request.app.state, "oauth_context", {})
|
||||
userinfo_endpoint = await _get_userinfo_endpoint(oauth_ctx)
|
||||
|
||||
if userinfo_endpoint:
|
||||
# Query userinfo endpoint with fresh access token
|
||||
profile_data = await _query_idp_userinfo(
|
||||
access_token, userinfo_endpoint
|
||||
)
|
||||
|
||||
if profile_data:
|
||||
# Cache profile for browser UI (no token needed to display)
|
||||
await storage.store_user_profile(user_id, profile_data)
|
||||
logger.info(f"✓ User profile cached for {user_id}")
|
||||
else:
|
||||
logger.warning(f"Failed to query userinfo endpoint for {user_id}")
|
||||
else:
|
||||
logger.warning("Could not determine userinfo endpoint")
|
||||
except Exception as e:
|
||||
logger.error(f"Error caching user profile: {e}")
|
||||
# Continue anyway - profile cache is optional for browser UI
|
||||
|
||||
# Create response and set session cookie
|
||||
response = RedirectResponse("/user/page", status_code=302)
|
||||
response.set_cookie(
|
||||
key="mcp_session",
|
||||
value=user_id,
|
||||
max_age=86400 * 30, # 30 days
|
||||
httponly=True,
|
||||
secure=False, # Set to True in production with HTTPS
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
logger.info(f"Session cookie set for user: {username}")
|
||||
return response
|
||||
|
||||
|
||||
async def oauth_logout(request: Request) -> RedirectResponse:
|
||||
"""Browser OAuth logout - clears session cookie.
|
||||
|
||||
Query parameters:
|
||||
next: Optional URL to redirect to after logout (default: /oauth/login)
|
||||
|
||||
Returns:
|
||||
302 redirect with cleared session cookie
|
||||
"""
|
||||
next_url = request.query_params.get("next", "/oauth/login")
|
||||
|
||||
# TODO: Optionally revoke refresh token from storage
|
||||
# session_id = request.cookies.get("mcp_session")
|
||||
# if session_id:
|
||||
# await storage.delete_refresh_token(session_id)
|
||||
|
||||
response = RedirectResponse(next_url, status_code=302)
|
||||
response.delete_cookie("mcp_session")
|
||||
|
||||
logger.info("User logged out, session cookie cleared")
|
||||
return response
|
||||
@@ -1,16 +1,15 @@
|
||||
"""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__)
|
||||
|
||||
|
||||
@@ -81,6 +80,7 @@ async def register_client(
|
||||
redirect_uris: list[str] | None = None,
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str = "Bearer",
|
||||
resource_url: str | None = None,
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
|
||||
@@ -92,6 +92,7 @@ async def register_client(
|
||||
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
|
||||
scopes: Space-separated list of scopes to request
|
||||
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
|
||||
|
||||
Returns:
|
||||
ClientInfo with registration details
|
||||
@@ -113,6 +114,10 @@ async def register_client(
|
||||
"token_type": token_type,
|
||||
}
|
||||
|
||||
# Add resource_url if provided (RFC 9728)
|
||||
if resource_url:
|
||||
client_metadata["resource_url"] = resource_url
|
||||
|
||||
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
||||
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
||||
|
||||
@@ -170,72 +175,6 @@ async def register_client(
|
||||
raise ValueError(f"Invalid registration response: missing {e}")
|
||||
|
||||
|
||||
def load_client_from_file(storage_path: Path) -> ClientInfo | None:
|
||||
"""
|
||||
Load client credentials from storage file.
|
||||
|
||||
Args:
|
||||
storage_path: Path to the JSON file containing client credentials
|
||||
|
||||
Returns:
|
||||
ClientInfo if file exists and is valid, None otherwise
|
||||
"""
|
||||
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)
|
||||
|
||||
client_info = ClientInfo.from_dict(data)
|
||||
|
||||
if client_info.is_expired:
|
||||
logger.warning(
|
||||
f"Stored client has expired (expired at {client_info.client_secret_expires_at})"
|
||||
)
|
||||
return None
|
||||
|
||||
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)")
|
||||
|
||||
return client_info
|
||||
|
||||
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||
logger.error(f"Failed to load client from file: {e}")
|
||||
return None
|
||||
|
||||
|
||||
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 delete_client(
|
||||
nextcloud_url: str,
|
||||
client_id: str,
|
||||
@@ -362,32 +301,34 @@ async def delete_client(
|
||||
return False
|
||||
|
||||
|
||||
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",
|
||||
resource_url: str | None = None,
|
||||
) -> 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")
|
||||
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
|
||||
|
||||
Returns:
|
||||
ClientInfo with valid credentials
|
||||
@@ -396,15 +337,18 @@ 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...")
|
||||
if resource_url:
|
||||
logger.info(f" with resource_url: {resource_url}")
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_url,
|
||||
registration_endpoint=registration_endpoint,
|
||||
@@ -412,9 +356,18 @@ async def load_or_register_client(
|
||||
redirect_uris=redirect_uris,
|
||||
scopes=scopes,
|
||||
token_type=token_type,
|
||||
resource_url=resource_url,
|
||||
)
|
||||
|
||||
# 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,239 @@
|
||||
"""
|
||||
MCP Client Registry for ADR-004 Progressive Consent Architecture.
|
||||
|
||||
This module manages the registry of allowed MCP clients that can authenticate
|
||||
via Flow 1. In production, this would integrate with Dynamic Client Registration
|
||||
(DCR) or a database of pre-registered clients.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPClientInfo:
|
||||
"""Information about a registered MCP client."""
|
||||
|
||||
client_id: str
|
||||
name: str
|
||||
redirect_uris: List[str]
|
||||
allowed_scopes: List[str]
|
||||
is_public: bool = True # Native clients are public (no client_secret)
|
||||
metadata: Optional[Dict] = None
|
||||
|
||||
|
||||
class ClientRegistry:
|
||||
"""
|
||||
Registry for MCP clients allowed to authenticate via Flow 1.
|
||||
|
||||
In production, this would:
|
||||
1. Support Dynamic Client Registration (DCR) per RFC 7591
|
||||
2. Integrate with IdP client registry
|
||||
3. Store client metadata in database
|
||||
4. Support client updates and revocation
|
||||
"""
|
||||
|
||||
def __init__(self, allow_dynamic_registration: bool = False):
|
||||
"""
|
||||
Initialize the client registry.
|
||||
|
||||
Args:
|
||||
allow_dynamic_registration: Whether to allow DCR for new clients
|
||||
"""
|
||||
self.allow_dynamic_registration = allow_dynamic_registration
|
||||
self._clients: Dict[str, MCPClientInfo] = {}
|
||||
self._load_static_clients()
|
||||
|
||||
def _load_static_clients(self):
|
||||
"""Load statically configured clients from environment."""
|
||||
# Load from ALLOWED_MCP_CLIENTS environment variable
|
||||
allowed_clients = os.getenv("ALLOWED_MCP_CLIENTS", "").strip()
|
||||
|
||||
if allowed_clients:
|
||||
# Parse comma-separated list
|
||||
for client_id in allowed_clients.split(","):
|
||||
client_id = client_id.strip()
|
||||
if client_id:
|
||||
# Create basic client info
|
||||
# In production, would load full metadata from database
|
||||
self._clients[client_id] = MCPClientInfo(
|
||||
client_id=client_id,
|
||||
name=self._get_client_name(client_id),
|
||||
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
||||
allowed_scopes=["openid", "profile", "email", "mcp-server:api"],
|
||||
is_public=True,
|
||||
)
|
||||
logger.info(f"Registered static client: {client_id}")
|
||||
|
||||
# Add well-known clients if not explicitly configured
|
||||
if not self._clients:
|
||||
self._add_well_known_clients()
|
||||
|
||||
def _get_client_name(self, client_id: str) -> str:
|
||||
"""Get human-readable name for client_id."""
|
||||
known_names = {
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"continue-dev": "Continue IDE Extension",
|
||||
"zed-editor": "Zed Editor",
|
||||
"vscode-mcp": "VS Code MCP Extension",
|
||||
"test-mcp-client": "Test MCP Client",
|
||||
}
|
||||
return known_names.get(client_id, client_id.replace("-", " ").title())
|
||||
|
||||
def _add_well_known_clients(self):
|
||||
"""Add well-known MCP clients for testing and development."""
|
||||
well_known = [
|
||||
MCPClientInfo(
|
||||
client_id="claude-desktop",
|
||||
name="Claude Desktop",
|
||||
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
||||
allowed_scopes=["openid", "profile", "email", "mcp-server:api"],
|
||||
is_public=True,
|
||||
metadata={"vendor": "Anthropic"},
|
||||
),
|
||||
MCPClientInfo(
|
||||
client_id="test-mcp-client",
|
||||
name="Test MCP Client",
|
||||
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
||||
allowed_scopes=["openid", "profile", "email", "mcp-server:api"],
|
||||
is_public=True,
|
||||
metadata={"purpose": "testing"},
|
||||
),
|
||||
]
|
||||
|
||||
for client in well_known:
|
||||
self._clients[client.client_id] = client
|
||||
logger.info(f"Registered well-known client: {client.client_id}")
|
||||
|
||||
def validate_client(
|
||||
self,
|
||||
client_id: str,
|
||||
redirect_uri: Optional[str] = None,
|
||||
scopes: Optional[List[str]] = None,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate a client_id and optionally its redirect_uri and scopes.
|
||||
|
||||
Args:
|
||||
client_id: The client identifier to validate
|
||||
redirect_uri: Optional redirect URI to validate
|
||||
scopes: Optional list of scopes to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
# Check if client exists
|
||||
client = self._clients.get(client_id)
|
||||
if not client:
|
||||
if self.allow_dynamic_registration:
|
||||
# In production, would attempt DCR here
|
||||
logger.info(f"Unknown client {client_id}, would attempt DCR")
|
||||
return True, None
|
||||
else:
|
||||
return False, f"Unknown client_id: {client_id}"
|
||||
|
||||
# Validate redirect_uri if provided
|
||||
if redirect_uri:
|
||||
if not self._validate_redirect_uri(client, redirect_uri):
|
||||
return False, f"Invalid redirect_uri for client {client_id}"
|
||||
|
||||
# Validate scopes if provided
|
||||
if scopes:
|
||||
invalid_scopes = set(scopes) - set(client.allowed_scopes)
|
||||
if invalid_scopes:
|
||||
return False, f"Invalid scopes for client {client_id}: {invalid_scopes}"
|
||||
|
||||
return True, None
|
||||
|
||||
def _validate_redirect_uri(self, client: MCPClientInfo, redirect_uri: str) -> bool:
|
||||
"""
|
||||
Validate redirect_uri against client's registered URIs.
|
||||
|
||||
Args:
|
||||
client: The client info
|
||||
redirect_uri: The URI to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
# Parse the redirect URI
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(redirect_uri)
|
||||
|
||||
# Check against registered patterns
|
||||
for pattern in client.redirect_uris:
|
||||
if "*" in pattern:
|
||||
# Handle wildcard port (localhost:*)
|
||||
pattern_base = pattern.replace(":*", "")
|
||||
if redirect_uri.startswith(pattern_base + ":"):
|
||||
# Validate it's localhost with a port
|
||||
if parsed.hostname in ["localhost", "127.0.0.1"]:
|
||||
return True
|
||||
elif redirect_uri == pattern:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def register_client(self, client_info: MCPClientInfo) -> bool:
|
||||
"""
|
||||
Register a new MCP client (DCR support).
|
||||
|
||||
Args:
|
||||
client_info: Client information to register
|
||||
|
||||
Returns:
|
||||
True if registered successfully
|
||||
"""
|
||||
if not self.allow_dynamic_registration:
|
||||
logger.warning(f"DCR disabled, cannot register {client_info.client_id}")
|
||||
return False
|
||||
|
||||
if client_info.client_id in self._clients:
|
||||
logger.warning(f"Client {client_info.client_id} already registered")
|
||||
return False
|
||||
|
||||
self._clients[client_info.client_id] = client_info
|
||||
logger.info(f"Dynamically registered client: {client_info.client_id}")
|
||||
|
||||
# In production, would persist to database
|
||||
return True
|
||||
|
||||
def get_client(self, client_id: str) -> Optional[MCPClientInfo]:
|
||||
"""
|
||||
Get client information.
|
||||
|
||||
Args:
|
||||
client_id: The client identifier
|
||||
|
||||
Returns:
|
||||
Client info if found, None otherwise
|
||||
"""
|
||||
return self._clients.get(client_id)
|
||||
|
||||
def list_clients(self) -> List[MCPClientInfo]:
|
||||
"""
|
||||
List all registered clients.
|
||||
|
||||
Returns:
|
||||
List of client information
|
||||
"""
|
||||
return list(self._clients.values())
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_registry: Optional[ClientRegistry] = None
|
||||
|
||||
|
||||
def get_client_registry() -> ClientRegistry:
|
||||
"""Get the global client registry instance."""
|
||||
global _registry
|
||||
if _registry is None:
|
||||
# Check if DCR is enabled
|
||||
allow_dcr = os.getenv("ENABLE_DCR", "false").lower() == "true"
|
||||
_registry = ClientRegistry(allow_dynamic_registration=allow_dcr)
|
||||
return _registry
|
||||
@@ -6,6 +6,8 @@ from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from ..client import NextcloudClient
|
||||
from ..config import get_settings
|
||||
from .token_exchange import exchange_token_for_audience
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,3 +65,81 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
|
||||
logger.error(f"Failed to extract OAuth context: {e}")
|
||||
logger.error("This may indicate the server is not running in OAuth mode")
|
||||
raise
|
||||
|
||||
|
||||
async def get_session_client_from_context(
|
||||
ctx: Context, base_url: str
|
||||
) -> NextcloudClient:
|
||||
"""
|
||||
Create NextcloudClient using RFC 8693 token exchange for session operations.
|
||||
|
||||
This implements the token exchange pattern where:
|
||||
1. Extract Flow 1 token from context (aud: "mcp-server")
|
||||
2. Exchange it for ephemeral Nextcloud token via RFC 8693
|
||||
3. Create client with delegated token (NOT stored)
|
||||
|
||||
Note: Nextcloud doesn't support OAuth scopes natively. Scopes are enforced
|
||||
by the MCP server via @require_scopes decorator, not by the IdP. Therefore,
|
||||
we don't pass scopes to the token exchange - the MCP server already validated
|
||||
permissions before calling this function.
|
||||
|
||||
Args:
|
||||
ctx: MCP request context containing session info
|
||||
base_url: Nextcloud base URL
|
||||
|
||||
Returns:
|
||||
NextcloudClient configured with ephemeral delegated token
|
||||
|
||||
Raises:
|
||||
AttributeError: If context doesn't contain expected OAuth session data
|
||||
RuntimeError: If token exchange fails
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
# Check if token exchange is enabled
|
||||
if not settings.enable_token_exchange:
|
||||
logger.info("Token exchange disabled, falling back to standard OAuth flow")
|
||||
return get_client_from_context(ctx, base_url)
|
||||
|
||||
try:
|
||||
# Extract Flow 1 token from context
|
||||
if hasattr(ctx.request_context.request, "user") and hasattr(
|
||||
ctx.request_context.request.user, "access_token"
|
||||
):
|
||||
access_token: AccessToken = ctx.request_context.request.user.access_token
|
||||
flow1_token = access_token.token
|
||||
username = access_token.resource # Username stored during verification
|
||||
logger.debug(f"Retrieved Flow 1 token for user: {username}")
|
||||
else:
|
||||
logger.error("No Flow 1 token found in request context")
|
||||
raise AttributeError("No access token found in OAuth request context")
|
||||
|
||||
if not username:
|
||||
logger.error("No username found in access token resource field")
|
||||
raise ValueError("Username not available in OAuth token context")
|
||||
|
||||
logger.info("Exchanging client token for Nextcloud API token (pure RFC 8693)")
|
||||
|
||||
# Perform pure RFC 8693 token exchange (no refresh tokens)
|
||||
# Note: We don't pass scopes since Nextcloud doesn't enforce them.
|
||||
# The MCP server's @require_scopes decorator handles authorization.
|
||||
exchanged_token, expires_in = await exchange_token_for_audience(
|
||||
subject_token=flow1_token,
|
||||
requested_audience="nextcloud",
|
||||
requested_scopes=None, # Nextcloud doesn't support scopes
|
||||
)
|
||||
|
||||
logger.info(f"Pure token exchange successful. Token expires in {expires_in}s")
|
||||
|
||||
# Create client with exchanged token
|
||||
# This token is ephemeral (per-request) and NOT stored
|
||||
return NextcloudClient.from_token(
|
||||
base_url=base_url, token=exchanged_token, username=username
|
||||
)
|
||||
|
||||
except AttributeError as e:
|
||||
logger.error(f"Failed to extract OAuth context: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Token exchange failed: {e}")
|
||||
raise RuntimeError(f"Token exchange required but failed: {e}") from e
|
||||
|
||||
@@ -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,502 @@
|
||||
"""
|
||||
OAuth 2.0 Login Routes for ADR-004 Progressive Consent Architecture
|
||||
|
||||
Implements dual OAuth flows with explicit provisioning:
|
||||
|
||||
Flow 1: Client Authentication - MCP client authenticates directly to IdP
|
||||
- Client requests: Nextcloud MCP resource scopes (notes:*, calendar:*, etc.)
|
||||
- Token audience (aud): "mcp-server"
|
||||
- No server interception - IdP redirects directly to client
|
||||
- Client receives resource-scoped token for MCP session
|
||||
|
||||
Flow 2: Resource Provisioning - MCP server gets delegated Nextcloud access
|
||||
- Triggered by user calling provision_nextcloud_access tool
|
||||
- Server requests: openid, profile, email scopes, offline_access
|
||||
- Separate login flow outside MCP session, results in browser login for user
|
||||
- Token audience (aud): "nextcloud", redirect/callback to mcp server
|
||||
- Server receives refresh token for offline access
|
||||
- Client never sees this token
|
||||
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registry import get_client_registry
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
"""
|
||||
OAuth authorization endpoint for Flow 1: Client Authentication.
|
||||
|
||||
The client authenticates directly to the IdP with its own client_id.
|
||||
The server validates the client is authorized but does NOT intercept the callback.
|
||||
IdP redirects directly back to the client's redirect_uri.
|
||||
|
||||
Query parameters:
|
||||
response_type: Must be "code"
|
||||
client_id: MCP client identifier (required)
|
||||
redirect_uri: Client's localhost redirect URI (required)
|
||||
scope: Requested scopes (optional, defaults to "openid profile email")
|
||||
state: CSRF protection state (required)
|
||||
code_challenge: PKCE code challenge from client (required)
|
||||
code_challenge_method: PKCE method, must be "S256" (required)
|
||||
|
||||
Returns:
|
||||
302 redirect to IdP authorization endpoint
|
||||
"""
|
||||
# Extract parameters
|
||||
response_type = request.query_params.get("response_type")
|
||||
client_id = request.query_params.get("client_id")
|
||||
redirect_uri = request.query_params.get("redirect_uri")
|
||||
state = request.query_params.get("state")
|
||||
code_challenge = request.query_params.get("code_challenge")
|
||||
code_challenge_method = request.query_params.get("code_challenge_method", "S256")
|
||||
|
||||
# Validate required parameters
|
||||
if response_type != "code":
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "unsupported_response_type",
|
||||
"error_description": "Only 'code' response_type is supported",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not redirect_uri:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "redirect_uri is required",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate redirect_uri is localhost (RFC 8252 for native clients)
|
||||
if not redirect_uri.startswith(("http://localhost:", "http://127.0.0.1:")):
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "redirect_uri must be localhost for native clients",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not state:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "state parameter is required for CSRF protection",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not code_challenge:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "code_challenge is required (PKCE)",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if code_challenge_method != "S256":
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "code_challenge_method must be S256",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate client_id (required for Progressive Consent Flow 1)
|
||||
if not client_id:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "client_id is required",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate client using registry
|
||||
registry = get_client_registry()
|
||||
is_valid, error_msg = registry.validate_client(
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scopes=request.query_params.get("scope", "").split()
|
||||
if request.query_params.get("scope")
|
||||
else None,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(f"Client validation failed: {error_msg}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "unauthorized_client",
|
||||
"error_description": error_msg,
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Get OAuth context from app state
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
if not oauth_ctx:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth not configured on server",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
oauth_client = oauth_ctx["oauth_client"]
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Flow 1: Client authenticates directly to IdP WITHOUT server interception
|
||||
# CRITICAL: This is a direct pass-through to IdP
|
||||
# The IdP will redirect directly back to the client's callback
|
||||
# The MCP server does NOT see the IdP authorization code!
|
||||
|
||||
logger.info(
|
||||
f"Starting Progressive Consent Flow 1 - no server session needed, "
|
||||
f"client will handle IdP response directly at {redirect_uri}"
|
||||
)
|
||||
|
||||
# Use client's redirect_uri for DIRECT callback (bypasses server)
|
||||
callback_uri = redirect_uri
|
||||
|
||||
# Request resource scopes for MCP tools access
|
||||
# The token will have aud: "mcp-server" claim
|
||||
# Build scopes from NEXTCLOUD_OIDC_SCOPES config
|
||||
default_scopes = "openid profile email"
|
||||
resource_scopes = oauth_config.get("scopes", "")
|
||||
scopes = f"{default_scopes} {resource_scopes}".strip()
|
||||
|
||||
# Pass through client's state directly
|
||||
idp_state = state
|
||||
|
||||
# Use client's own client_id (client must be pre-registered at IdP)
|
||||
idp_client_id = client_id
|
||||
|
||||
logger.info("Flow 1 (Progressive Consent): Direct client auth to IdP")
|
||||
logger.info(f" Client ID: {client_id}")
|
||||
logger.info(f" Client will receive IdP code directly at: {callback_uri}")
|
||||
logger.info(f" Scopes: {scopes} (resource access for MCP tools)")
|
||||
|
||||
# Get authorization endpoint from OAuth client
|
||||
if oauth_client:
|
||||
# External IdP mode (Keycloak) - use oauth_client
|
||||
auth_url = await oauth_client.get_authorization_url(
|
||||
state=idp_state,
|
||||
code_challenge="", # Server doesn't use PKCE with IdP
|
||||
)
|
||||
logger.info(f"Redirecting to external IdP: {auth_url.split('?')[0]}")
|
||||
else:
|
||||
# Integrated mode (Nextcloud OIDC) - build URL directly
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if not discovery_url:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth discovery URL not configured",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Fetch authorization endpoint from discovery
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
|
||||
# IMPORTANT: Replace internal Docker hostname with public URL for browser access
|
||||
# The discovery endpoint returns http://app/apps/oidc/authorize (internal)
|
||||
# But browsers need http://localhost:8080/apps/oidc/authorize (public)
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
# Parse internal and authorization endpoint to compare hostnames
|
||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||
auth_parsed = parse_url(authorization_endpoint)
|
||||
|
||||
# Check if authorization endpoint uses internal hostname
|
||||
if auth_parsed.hostname == internal_parsed.hostname:
|
||||
# Replace internal hostname+port with public URL
|
||||
# Keep the path from authorization_endpoint
|
||||
public_parsed = parse_url(public_issuer)
|
||||
authorization_endpoint = (
|
||||
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
|
||||
)
|
||||
if auth_parsed.query:
|
||||
authorization_endpoint += f"?{auth_parsed.query}"
|
||||
logger.info(
|
||||
f"Rewrote authorization endpoint for browser access: {authorization_endpoint}"
|
||||
)
|
||||
|
||||
idp_params = {
|
||||
"client_id": idp_client_id,
|
||||
"redirect_uri": callback_uri,
|
||||
"response_type": "code",
|
||||
"scope": scopes,
|
||||
"state": idp_state,
|
||||
"prompt": "consent", # Ensure refresh token
|
||||
}
|
||||
|
||||
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
|
||||
logger.info(f"Redirecting to Nextcloud OIDC: {auth_url.split('?')[0]}")
|
||||
|
||||
return RedirectResponse(auth_url, status_code=302)
|
||||
|
||||
|
||||
async def oauth_authorize_nextcloud(
|
||||
request: Request,
|
||||
) -> RedirectResponse | JSONResponse:
|
||||
"""
|
||||
OAuth authorization endpoint for Flow 2: Resource Provisioning.
|
||||
|
||||
This endpoint is used by the provision_nextcloud_access MCP tool
|
||||
to initiate delegated resource access to Nextcloud. Requires a separate
|
||||
login flow outside of the MCP session.
|
||||
|
||||
Query parameters:
|
||||
state: Session state for tracking
|
||||
|
||||
Returns:
|
||||
302 redirect to IdP authorization endpoint
|
||||
"""
|
||||
state = request.query_params.get("state")
|
||||
if not state:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "state parameter is required",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
if not oauth_ctx:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth not configured on server",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Get MCP server's OAuth client credentials
|
||||
mcp_server_client_id = os.getenv(
|
||||
"MCP_SERVER_CLIENT_ID", oauth_config.get("client_id")
|
||||
)
|
||||
if not mcp_server_client_id:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "MCP server OAuth client not configured",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback-nextcloud"
|
||||
|
||||
# Flow 2: Server only needs identity + offline access (no resource scopes)
|
||||
# Resource scopes are requested by client in Flow 1
|
||||
scopes = "openid profile email offline_access"
|
||||
|
||||
# Get authorization endpoint
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if not discovery_url:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth discovery URL not configured",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
|
||||
# Fix internal hostname for browser access
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||
auth_parsed = parse_url(authorization_endpoint)
|
||||
|
||||
if auth_parsed.hostname == internal_parsed.hostname:
|
||||
public_parsed = parse_url(public_issuer)
|
||||
authorization_endpoint = (
|
||||
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
|
||||
)
|
||||
|
||||
# Build authorization URL
|
||||
idp_params = {
|
||||
"client_id": mcp_server_client_id,
|
||||
"redirect_uri": callback_uri,
|
||||
"response_type": "code",
|
||||
"scope": scopes,
|
||||
"state": state,
|
||||
"prompt": "consent", # Force consent to show resource access
|
||||
"access_type": "offline", # Request refresh token
|
||||
}
|
||||
|
||||
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
|
||||
logger.info("Flow 2: Redirecting to IdP for resource provisioning")
|
||||
|
||||
return RedirectResponse(auth_url, status_code=302)
|
||||
|
||||
|
||||
async def oauth_callback_nextcloud(request: Request):
|
||||
"""
|
||||
OAuth callback endpoint for Flow 2: Resource Provisioning.
|
||||
|
||||
The IdP redirects here after user grants delegated resource access.
|
||||
Server stores the master refresh token for offline access.
|
||||
|
||||
Query parameters:
|
||||
code: Authorization code from IdP
|
||||
state: State parameter (session identifier)
|
||||
error: Error code (if authorization failed)
|
||||
|
||||
Returns:
|
||||
JSON response or HTML success page
|
||||
"""
|
||||
# Check for errors from IdP
|
||||
error = request.query_params.get("error")
|
||||
if error:
|
||||
error_description = request.query_params.get(
|
||||
"error_description", "Authorization failed"
|
||||
)
|
||||
logger.error(f"Flow 2 authorization error: {error} - {error_description}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": error,
|
||||
"error_description": error_description,
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
code = request.query_params.get("code")
|
||||
state = request.query_params.get("state")
|
||||
|
||||
if not code or not state:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "code and state parameters are required",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
storage: RefreshTokenStorage = oauth_ctx["storage"]
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Exchange code for tokens
|
||||
mcp_server_client_id = os.getenv(
|
||||
"MCP_SERVER_CLIENT_ID", oauth_config.get("client_id")
|
||||
)
|
||||
mcp_server_client_secret = os.getenv(
|
||||
"MCP_SERVER_CLIENT_SECRET", oauth_config.get("client_secret")
|
||||
)
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback-nextcloud"
|
||||
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
# Exchange code for tokens
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": callback_uri,
|
||||
"client_id": mcp_server_client_id,
|
||||
"client_secret": mcp_server_client_secret,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
refresh_token = token_data.get("refresh_token")
|
||||
id_token = token_data.get("id_token")
|
||||
|
||||
# Decode ID token to get user info
|
||||
try:
|
||||
userinfo = jwt.decode(id_token, options={"verify_signature": False})
|
||||
user_id = userinfo.get("sub")
|
||||
username = userinfo.get("preferred_username") or userinfo.get("email")
|
||||
logger.info(f"Flow 2: User {username} provisioned resource access")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode ID token: {e}")
|
||||
user_id = "unknown"
|
||||
|
||||
# Store master refresh token for Flow 2
|
||||
if refresh_token:
|
||||
# Parse granted scopes from token response
|
||||
granted_scopes = (
|
||||
token_data.get("scope", "").split() if token_data.get("scope") else None
|
||||
)
|
||||
|
||||
await storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
refresh_token=refresh_token,
|
||||
flow_type="flow2",
|
||||
token_audience="nextcloud",
|
||||
provisioning_client_id=state, # Store which client initiated provisioning
|
||||
scopes=granted_scopes,
|
||||
expires_at=None, # Refresh tokens typically don't expire
|
||||
)
|
||||
logger.info(f"Stored Flow 2 master refresh token for user {user_id}")
|
||||
|
||||
# Return success HTML page
|
||||
success_html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Nextcloud Access Provisioned</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; }
|
||||
.success { color: green; }
|
||||
.info { margin-top: 20px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="success">✓ Nextcloud Access Provisioned</h1>
|
||||
<p>The MCP server now has offline access to your Nextcloud resources.</p>
|
||||
<p class="info">You can close this window and return to your MCP client.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
return HTMLResponse(content=success_html, status_code=200)
|
||||
@@ -0,0 +1,361 @@
|
||||
"""
|
||||
Token Verifier for ADR-004 Progressive Consent Architecture.
|
||||
|
||||
This module implements token verification with strict audience separation:
|
||||
- Flow 1 tokens have aud: <mcp-client-id> for MCP authentication
|
||||
- Flow 2 tokens have aud: "nextcloud" for resource access
|
||||
- Token Broker manages the exchange between audiences
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProgressiveConsentTokenVerifier:
|
||||
"""
|
||||
Token verifier for Progressive Consent dual OAuth flows.
|
||||
|
||||
This verifier:
|
||||
1. Validates Flow 1 tokens (aud: <mcp-client-id>) for MCP authentication
|
||||
2. Checks if user has provisioned Nextcloud access (Flow 2)
|
||||
3. Uses Token Broker to obtain aud: "nextcloud" tokens when needed
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token_storage: RefreshTokenStorage,
|
||||
token_broker: Optional[TokenBrokerService] = None,
|
||||
oidc_discovery_url: Optional[str] = None,
|
||||
nextcloud_host: Optional[str] = None,
|
||||
encryption_key: Optional[str] = None,
|
||||
mcp_client_id: Optional[str] = None,
|
||||
introspection_uri: Optional[str] = None,
|
||||
client_secret: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the Progressive Consent token verifier.
|
||||
|
||||
Args:
|
||||
token_storage: Storage for refresh tokens
|
||||
token_broker: Token broker service (created if not provided)
|
||||
oidc_discovery_url: OIDC provider discovery URL
|
||||
nextcloud_host: Nextcloud server URL
|
||||
encryption_key: Fernet key for token encryption
|
||||
mcp_client_id: MCP server OAuth client ID for audience validation
|
||||
introspection_uri: OAuth introspection endpoint URL (for opaque tokens)
|
||||
client_secret: OAuth client secret (required for introspection)
|
||||
"""
|
||||
self.storage = token_storage
|
||||
self.oidc_discovery_url = oidc_discovery_url or os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
f"{os.getenv('NEXTCLOUD_HOST')}/.well-known/openid-configuration",
|
||||
)
|
||||
self.nextcloud_host = nextcloud_host or os.getenv("NEXTCLOUD_HOST")
|
||||
self.encryption_key = encryption_key or os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
self.mcp_client_id = mcp_client_id or os.getenv("OIDC_CLIENT_ID")
|
||||
self.introspection_uri = introspection_uri
|
||||
self.client_secret = client_secret or os.getenv("OIDC_CLIENT_SECRET")
|
||||
|
||||
# HTTP client for introspection requests
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
if self.introspection_uri and self.mcp_client_id and self.client_secret:
|
||||
self._http_client = httpx.AsyncClient(timeout=10.0)
|
||||
logger.info(f"Introspection support enabled: {introspection_uri}")
|
||||
elif self.introspection_uri:
|
||||
logger.warning(
|
||||
"Introspection URI provided but missing client credentials - introspection disabled"
|
||||
)
|
||||
|
||||
# Create token broker if not provided
|
||||
if token_broker:
|
||||
self.token_broker = token_broker
|
||||
elif self.encryption_key:
|
||||
self.token_broker = TokenBrokerService(
|
||||
storage=token_storage,
|
||||
oidc_discovery_url=self.oidc_discovery_url,
|
||||
nextcloud_host=self.nextcloud_host,
|
||||
encryption_key=self.encryption_key,
|
||||
)
|
||||
else:
|
||||
self.token_broker = None
|
||||
logger.warning("Token broker not available - encryption key missing")
|
||||
|
||||
async def verify_token(self, token: str) -> Optional[AccessToken]:
|
||||
"""
|
||||
Verify a Flow 1 token (aud: <mcp-client-id>).
|
||||
|
||||
This validates that:
|
||||
1. Token has correct audience for MCP server (matches client ID)
|
||||
2. Token is not expired
|
||||
3. Token has valid signature (if verification enabled)
|
||||
|
||||
Supports both JWT and opaque tokens:
|
||||
- JWT tokens: Decoded directly from payload
|
||||
- Opaque tokens: Validated via introspection endpoint (RFC 7662)
|
||||
|
||||
Args:
|
||||
token: Access token from Flow 1 (JWT or opaque)
|
||||
|
||||
Returns:
|
||||
AccessToken if valid, None otherwise
|
||||
"""
|
||||
logger.info("🔐 verify_token called - attempting to validate token")
|
||||
logger.info(f"Token (first 50 chars): {token[:50]}...")
|
||||
logger.info(f"Expected MCP client ID: {self.mcp_client_id}")
|
||||
|
||||
# Check if token is JWT format (has 3 parts separated by dots)
|
||||
is_jwt = "." in token and token.count(".") == 2
|
||||
logger.info(f"Token format: {'JWT' if is_jwt else 'opaque'}")
|
||||
|
||||
if is_jwt:
|
||||
# Try JWT verification
|
||||
return await self._verify_jwt_token(token)
|
||||
else:
|
||||
# Fall back to introspection for opaque tokens
|
||||
return await self._verify_opaque_token(token)
|
||||
|
||||
async def _verify_jwt_token(self, token: str) -> Optional[AccessToken]:
|
||||
"""Verify JWT token by decoding payload."""
|
||||
try:
|
||||
# Decode without signature verification (IdP handles that)
|
||||
# In production, would verify signature with IdP public key
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
logger.info(f"Token payload decoded: {payload}")
|
||||
|
||||
# CRITICAL: Verify audience is for MCP server (Flow 1)
|
||||
audiences = payload.get("aud", [])
|
||||
if isinstance(audiences, str):
|
||||
audiences = [audiences]
|
||||
|
||||
# Audience validation:
|
||||
# - Accept tokens with no audience (will validate via introspection if needed)
|
||||
# - Accept tokens with MCP client ID in audience (Keycloak multi-audience)
|
||||
# - Accept tokens with resource URL in audience (Nextcloud JWT redirect URI)
|
||||
# - Reject tokens with "nextcloud" audience only (wrong flow)
|
||||
if audiences:
|
||||
# Check if MCP client ID is in the audience (Keycloak multi-audience)
|
||||
if self.mcp_client_id in audiences:
|
||||
logger.debug(
|
||||
f"Token has audience {audiences} - MCP client ID present"
|
||||
)
|
||||
# Check if this is a Nextcloud-only token (wrong flow)
|
||||
elif audiences == ["nextcloud"]:
|
||||
logger.warning(
|
||||
f"Token rejected: Nextcloud-only audience {audiences}"
|
||||
)
|
||||
logger.error(
|
||||
"Received Nextcloud token in MCP context - "
|
||||
"client may be using wrong token"
|
||||
)
|
||||
return None
|
||||
# Otherwise accept (likely resource URL audience from Nextcloud JWT)
|
||||
else:
|
||||
logger.info(
|
||||
f"Token has audience {audiences} (resource URL or non-standard) - accepting"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Token has no audience claim - accepting for MCP server validation"
|
||||
)
|
||||
|
||||
# Check expiry
|
||||
exp = payload.get("exp", 0)
|
||||
if exp < datetime.now(timezone.utc).timestamp():
|
||||
logger.warning(
|
||||
f"❌ Token expired: exp={exp}, now={datetime.now(timezone.utc).timestamp()}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Extract user info
|
||||
user_id = payload.get("sub", "unknown")
|
||||
client_id = payload.get("client_id", "unknown")
|
||||
scopes = payload.get("scope", "").split()
|
||||
exp = payload.get("exp", None)
|
||||
|
||||
logger.info(
|
||||
f"✅ Token validation successful! user={user_id}, scopes={scopes}"
|
||||
)
|
||||
|
||||
# Create AccessToken for MCP framework
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=client_id,
|
||||
scopes=scopes,
|
||||
expires_at=exp,
|
||||
resource=user_id, # Store user_id in resource field (RFC 8707)
|
||||
)
|
||||
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"❌ Invalid token (JWT decode failed): {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Token verification failed with exception: {e}")
|
||||
return None
|
||||
|
||||
async def _verify_opaque_token(self, token: str) -> Optional[AccessToken]:
|
||||
"""
|
||||
Verify opaque token via introspection endpoint (RFC 7662).
|
||||
|
||||
Args:
|
||||
token: Opaque access token
|
||||
|
||||
Returns:
|
||||
AccessToken if active and valid, None otherwise
|
||||
"""
|
||||
if not self._http_client or not self.introspection_uri:
|
||||
logger.error(
|
||||
"❌ Cannot verify opaque token - introspection not configured. "
|
||||
"Set introspection_uri and client credentials."
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
logger.info(f"Introspecting token at {self.introspection_uri}")
|
||||
|
||||
# Call introspection endpoint (requires client authentication)
|
||||
response = await self._http_client.post(
|
||||
self.introspection_uri,
|
||||
data={"token": token},
|
||||
auth=(self.mcp_client_id, self.client_secret),
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
f"❌ Introspection failed: HTTP {response.status_code} - {response.text[:200]}"
|
||||
)
|
||||
return None
|
||||
|
||||
introspection_data = response.json()
|
||||
logger.info(f"Introspection response: {introspection_data}")
|
||||
|
||||
# Check if token is active
|
||||
if not introspection_data.get("active", False):
|
||||
logger.warning("❌ Token introspection returned active=false")
|
||||
return None
|
||||
|
||||
# Extract user info
|
||||
user_id = introspection_data.get("sub") or introspection_data.get(
|
||||
"username"
|
||||
)
|
||||
if not user_id:
|
||||
logger.error("❌ No username found in introspection response")
|
||||
return None
|
||||
|
||||
# Extract scopes (space-separated string)
|
||||
scope_string = introspection_data.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
|
||||
# Extract client ID and expiration
|
||||
client_id = introspection_data.get("client_id", "unknown")
|
||||
exp = introspection_data.get("exp")
|
||||
|
||||
logger.info(f"✅ Opaque token validated! user={user_id}, scopes={scopes}")
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=client_id,
|
||||
scopes=scopes,
|
||||
expires_at=int(exp) if exp else None,
|
||||
resource=user_id,
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("❌ Timeout while introspecting token")
|
||||
return None
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"❌ Network error during introspection: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Introspection failed with exception: {e}")
|
||||
return None
|
||||
|
||||
async def check_provisioning(self, user_id: str) -> bool:
|
||||
"""
|
||||
Check if user has provisioned Nextcloud access (Flow 2).
|
||||
|
||||
Args:
|
||||
user_id: User identifier from Flow 1 token
|
||||
|
||||
Returns:
|
||||
True if user has completed Flow 2, False otherwise
|
||||
"""
|
||||
if not self.storage:
|
||||
return False
|
||||
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
return refresh_data is not None
|
||||
|
||||
async def get_nextcloud_token(self, user_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get a Nextcloud access token (aud: "nextcloud") for the user.
|
||||
|
||||
This uses the Token Broker to:
|
||||
1. Check for cached Nextcloud token
|
||||
2. If expired, refresh using stored master refresh token
|
||||
3. Return token with aud: "nextcloud" for API access
|
||||
|
||||
Args:
|
||||
user_id: User identifier from Flow 1 token
|
||||
|
||||
Returns:
|
||||
Nextcloud access token if provisioned, None otherwise
|
||||
"""
|
||||
if not self.token_broker:
|
||||
logger.error("Token broker not available")
|
||||
return None
|
||||
|
||||
# Check if user has provisioned access
|
||||
if not await self.check_provisioning(user_id):
|
||||
logger.info(f"User {user_id} has not provisioned Nextcloud access")
|
||||
return None
|
||||
|
||||
# Get or refresh Nextcloud token
|
||||
try:
|
||||
nextcloud_token = await self.token_broker.get_nextcloud_token(user_id)
|
||||
if nextcloud_token:
|
||||
logger.debug(f"Obtained Nextcloud token for user {user_id}")
|
||||
return nextcloud_token
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Nextcloud token: {e}")
|
||||
return None
|
||||
|
||||
async def validate_scopes(
|
||||
self, token: AccessToken, required_scopes: list[str]
|
||||
) -> bool:
|
||||
"""
|
||||
Validate that token has required scopes.
|
||||
|
||||
Args:
|
||||
token: The access token
|
||||
required_scopes: List of required scopes
|
||||
|
||||
Returns:
|
||||
True if all required scopes present, False otherwise
|
||||
"""
|
||||
token_scopes = set(token.scopes) if token.scopes else set()
|
||||
required = set(required_scopes)
|
||||
|
||||
missing = required - token_scopes
|
||||
if missing:
|
||||
logger.debug(f"Token missing required scopes: {missing}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def close(self):
|
||||
"""Clean up resources."""
|
||||
if self.token_broker:
|
||||
await self.token_broker.close()
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Provisioning decorator for ADR-004 Progressive Consent Architecture.
|
||||
|
||||
This decorator ensures users have completed Flow 2 (Resource Provisioning)
|
||||
before accessing Nextcloud resources.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from mcp.server.fastmcp import Context
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def require_provisioning(func: Callable) -> Callable:
|
||||
"""
|
||||
Decorator that checks if user has provisioned Nextcloud access (Flow 2).
|
||||
|
||||
This decorator:
|
||||
1. Extracts user_id from the MCP token (Flow 1)
|
||||
2. Checks if user has completed Flow 2 provisioning
|
||||
3. Returns helpful error message if not provisioned
|
||||
4. Allows access if provisioned
|
||||
|
||||
Usage:
|
||||
@mcp.tool()
|
||||
@require_provisioning
|
||||
async def list_notes(ctx: Context):
|
||||
# Tool implementation
|
||||
pass
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Extract context from arguments
|
||||
ctx = None
|
||||
for arg in args:
|
||||
if isinstance(arg, Context):
|
||||
ctx = arg
|
||||
break
|
||||
if not ctx:
|
||||
ctx = kwargs.get("ctx")
|
||||
|
||||
if not ctx:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Context not found - cannot verify provisioning",
|
||||
)
|
||||
)
|
||||
|
||||
# Check if we're in BasicAuth mode - if so, skip provisioning check
|
||||
# In BasicAuth mode, there's no OAuth and no provisioning needed
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
if hasattr(lifespan_ctx, "client"):
|
||||
# BasicAuth mode - no provisioning needed, just proceed
|
||||
logger.debug("BasicAuth mode detected - skipping provisioning check")
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Check if we're in token exchange mode - if so, skip provisioning check
|
||||
# In token exchange mode, tokens are exchanged per-request (no stored refresh tokens)
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_token_exchange:
|
||||
# Token exchange mode - per-request exchange, no provisioning needed
|
||||
logger.debug("Token exchange mode detected - skipping provisioning check")
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Progressive Consent mode (offline access) - check if user has completed Flow 2 provisioning
|
||||
# Get user_id from authorization token
|
||||
user_id = None
|
||||
if hasattr(ctx, "authorization") and ctx.authorization:
|
||||
try:
|
||||
import jwt
|
||||
|
||||
token = ctx.authorization.token
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
user_id = payload.get("sub")
|
||||
logger.debug(f"Checking provisioning for user: {user_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract user_id from token: {e}")
|
||||
|
||||
if not user_id:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Cannot determine user identity for provisioning check",
|
||||
)
|
||||
)
|
||||
|
||||
# Check provisioning status
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
refresh_data = await storage.get_refresh_token(user_id)
|
||||
|
||||
if not refresh_data:
|
||||
# User has not completed Flow 2 - provide helpful error
|
||||
logger.info(
|
||||
f"User {user_id} attempted to use Nextcloud tool without provisioning"
|
||||
)
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=(
|
||||
"Nextcloud access not provisioned. "
|
||||
"Please run the 'provision_nextcloud_access' tool first to authorize "
|
||||
"the MCP server to access Nextcloud on your behalf. "
|
||||
"This is a one-time setup required for security."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"User {user_id} has provisioned access - proceeding with tool execution"
|
||||
)
|
||||
|
||||
# User has provisioned - allow access
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def require_provisioning_or_suggest(func: Callable) -> Callable:
|
||||
"""
|
||||
Softer version that suggests provisioning but doesn't block.
|
||||
|
||||
This decorator:
|
||||
1. Checks provisioning status
|
||||
2. Logs a warning if not provisioned
|
||||
3. Still allows the function to proceed
|
||||
4. Can be used for read-only operations that might work without explicit provisioning
|
||||
|
||||
Usage:
|
||||
@mcp.tool()
|
||||
@require_provisioning_or_suggest
|
||||
async def list_tools(ctx: Context):
|
||||
# Tool implementation
|
||||
pass
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Extract context from arguments
|
||||
ctx = None
|
||||
for arg in args:
|
||||
if isinstance(arg, Context):
|
||||
ctx = arg
|
||||
break
|
||||
if not ctx:
|
||||
ctx = kwargs.get("ctx")
|
||||
|
||||
if ctx:
|
||||
# Try to check provisioning status
|
||||
try:
|
||||
# Get user_id from authorization token
|
||||
user_id = None
|
||||
if hasattr(ctx, "authorization") and ctx.authorization:
|
||||
import jwt
|
||||
|
||||
token = ctx.authorization.token
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
user_id = payload.get("sub")
|
||||
|
||||
if user_id:
|
||||
# Check provisioning status
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
refresh_data = await storage.get_refresh_token(user_id)
|
||||
|
||||
if not refresh_data:
|
||||
logger.info(
|
||||
f"User {user_id} has not provisioned Nextcloud access. "
|
||||
"Some features may not work. Consider running "
|
||||
"'provision_nextcloud_access' tool."
|
||||
)
|
||||
else:
|
||||
logger.debug(f"User {user_id} has provisioned access")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not check provisioning status: {e}")
|
||||
|
||||
# Always proceed with the function
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
"""Scope-based authorization for MCP tools."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
@@ -33,6 +34,23 @@ class InsufficientScopeError(ScopeAuthorizationError):
|
||||
)
|
||||
|
||||
|
||||
class ProvisioningRequiredError(ScopeAuthorizationError):
|
||||
"""Raised when Nextcloud resource access requires provisioning (Flow 2).
|
||||
|
||||
In Progressive Consent mode, users must explicitly provision Nextcloud
|
||||
access using the provision_nextcloud_access MCP tool.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str | None = None):
|
||||
super().__init__(
|
||||
message
|
||||
or (
|
||||
"Nextcloud resource access not provisioned. "
|
||||
"Please run the 'provision_nextcloud_access' tool to grant access."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def require_scopes(*required_scopes: str):
|
||||
"""
|
||||
Decorator to require specific OAuth scopes for MCP tool execution.
|
||||
@@ -109,6 +127,58 @@ def require_scopes(*required_scopes: str):
|
||||
token_scopes = set(access_token.scopes or [])
|
||||
required_scopes_set = set(required_scopes)
|
||||
|
||||
# Check if Progressive Consent is enabled
|
||||
enable_progressive = (
|
||||
os.getenv("ENABLE_PROGRESSIVE_CONSENT", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# In Progressive Consent mode, check if Nextcloud scopes require provisioning
|
||||
if enable_progressive:
|
||||
# Check if any required scopes are Nextcloud-specific
|
||||
nextcloud_scopes = [
|
||||
s
|
||||
for s in required_scopes
|
||||
if any(
|
||||
s.startswith(prefix)
|
||||
for prefix in [
|
||||
"notes:",
|
||||
"calendar:",
|
||||
"contacts:",
|
||||
"files:",
|
||||
"tables:",
|
||||
"deck:",
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
if nextcloud_scopes:
|
||||
# Check if user has completed Flow 2 provisioning
|
||||
# This would be indicated by having a stored refresh token
|
||||
# In production, we'd check the token broker or storage
|
||||
# For now, we check if the token has the required scopes
|
||||
# (Flow 1 tokens won't have Nextcloud scopes)
|
||||
has_nextcloud_scopes = any(
|
||||
s.startswith(prefix)
|
||||
for s in token_scopes
|
||||
for prefix in [
|
||||
"notes:",
|
||||
"calendar:",
|
||||
"contacts:",
|
||||
"files:",
|
||||
"tables:",
|
||||
"deck:",
|
||||
]
|
||||
)
|
||||
|
||||
if not has_nextcloud_scopes:
|
||||
error_msg = (
|
||||
f"Access denied to {func.__name__}: "
|
||||
f"Nextcloud resource access not provisioned. "
|
||||
f"Please run the 'provision_nextcloud_access' tool first."
|
||||
)
|
||||
logger.warning(error_msg)
|
||||
raise ProvisioningRequiredError(error_msg)
|
||||
|
||||
# Check if all required scopes are present
|
||||
missing_scopes = required_scopes_set - token_scopes
|
||||
if missing_scopes:
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Session-based authentication backend for Starlette routes.
|
||||
|
||||
Provides browser-based authentication for admin UI routes, separate from
|
||||
MCP's OAuth authentication flow.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from starlette.authentication import (
|
||||
AuthCredentials,
|
||||
AuthenticationBackend,
|
||||
SimpleUser,
|
||||
)
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SessionAuthBackend(AuthenticationBackend):
|
||||
"""Authentication backend using signed session cookies.
|
||||
|
||||
For BasicAuth mode: Always authenticates as the configured user.
|
||||
For OAuth mode: Checks for valid session cookie with stored refresh token.
|
||||
"""
|
||||
|
||||
def __init__(self, oauth_enabled: bool = False):
|
||||
"""Initialize session authentication backend.
|
||||
|
||||
Args:
|
||||
oauth_enabled: Whether OAuth mode is enabled
|
||||
"""
|
||||
self.oauth_enabled = oauth_enabled
|
||||
|
||||
async def authenticate(
|
||||
self, conn: HTTPConnection
|
||||
) -> tuple[AuthCredentials, SimpleUser] | None:
|
||||
"""Authenticate the request based on session cookie or BasicAuth mode.
|
||||
|
||||
This backend is only applied to browser routes (/user/*) via a separate
|
||||
Starlette app mount. FastMCP routes use their own OAuth Bearer token
|
||||
authentication.
|
||||
|
||||
Args:
|
||||
conn: HTTP connection
|
||||
|
||||
Returns:
|
||||
Tuple of (credentials, user) if authenticated, None otherwise
|
||||
"""
|
||||
# BasicAuth mode: Always authenticated as the configured user
|
||||
if not self.oauth_enabled:
|
||||
username = os.getenv("NEXTCLOUD_USERNAME", "admin")
|
||||
return AuthCredentials(["authenticated", "admin"]), SimpleUser(username)
|
||||
|
||||
# OAuth mode: Check for session cookie
|
||||
session_id = conn.cookies.get("mcp_session")
|
||||
logger.info(
|
||||
f"Session authentication check - cookie present: {session_id is not None}, path: {conn.url.path}"
|
||||
)
|
||||
if not session_id:
|
||||
logger.info("No session cookie found - redirecting to login")
|
||||
return None
|
||||
|
||||
logger.info(f"Found session cookie: {session_id[:16]}...")
|
||||
|
||||
# Get OAuth context from app state
|
||||
oauth_context = getattr(conn.app.state, "oauth_context", None)
|
||||
if not oauth_context:
|
||||
logger.warning("OAuth context not available in app state")
|
||||
return None
|
||||
|
||||
# Validate session
|
||||
storage = oauth_context.get("storage")
|
||||
if not storage:
|
||||
logger.warning("OAuth storage not available")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Check if user has refresh token (indicates logged-in session)
|
||||
logger.info(f"Looking up refresh token for session: {session_id[:16]}...")
|
||||
token_data = await storage.get_refresh_token(session_id)
|
||||
if not token_data:
|
||||
logger.warning(
|
||||
f"No refresh token found for session {session_id[:16]}..."
|
||||
)
|
||||
return None
|
||||
|
||||
# Session is valid - use session_id (which is user_id from ID token) as username
|
||||
username = session_id
|
||||
logger.info(f"✓ Session authenticated successfully: {username[:16]}...")
|
||||
|
||||
return AuthCredentials(["authenticated"]), SimpleUser(username)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Session validation error: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,588 @@
|
||||
"""
|
||||
Token Broker Service for ADR-004 Progressive Consent Architecture.
|
||||
|
||||
This service manages the lifecycle of Nextcloud access tokens, implementing
|
||||
the dual OAuth flow pattern where:
|
||||
1. MCP clients authenticate to MCP server with aud:"mcp-server" tokens
|
||||
2. MCP server uses stored refresh tokens to obtain aud:"nextcloud" tokens
|
||||
|
||||
The Token Broker provides:
|
||||
- Automatic token refresh when expired
|
||||
- Short-lived token caching (5-minute TTL)
|
||||
- Master refresh token rotation
|
||||
- Audience-specific token validation
|
||||
- Session vs background token separation (RFC 8693)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TokenCache:
|
||||
"""In-memory cache for short-lived Nextcloud access tokens."""
|
||||
|
||||
def __init__(self, ttl_seconds: int = 300, early_refresh_seconds: int = 30):
|
||||
"""
|
||||
Initialize the token cache.
|
||||
|
||||
Args:
|
||||
ttl_seconds: Default TTL for cached tokens (5 minutes default)
|
||||
early_refresh_seconds: How many seconds before expiry to trigger early refresh (30s default)
|
||||
"""
|
||||
self._cache: Dict[str, Tuple[str, datetime]] = {}
|
||||
self._ttl = timedelta(seconds=ttl_seconds)
|
||||
self._early_refresh = timedelta(seconds=early_refresh_seconds)
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get(self, user_id: str) -> Optional[str]:
|
||||
"""Get cached token if valid."""
|
||||
async with self._lock:
|
||||
if user_id not in self._cache:
|
||||
return None
|
||||
|
||||
token, expiry = self._cache[user_id]
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Check if token has expired
|
||||
if now >= expiry:
|
||||
del self._cache[user_id]
|
||||
logger.debug(f"Cached token expired for user {user_id}")
|
||||
return None
|
||||
|
||||
# Check if token will expire soon (refresh early)
|
||||
if now >= expiry - self._early_refresh:
|
||||
logger.debug(f"Cached token expiring soon for user {user_id}")
|
||||
return None
|
||||
|
||||
logger.debug(f"Using cached token for user {user_id}")
|
||||
return token
|
||||
|
||||
async def set(self, user_id: str, token: str, expires_in: int = None):
|
||||
"""Store token in cache."""
|
||||
async with self._lock:
|
||||
# Use provided expiry or default TTL
|
||||
if expires_in:
|
||||
expiry = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
||||
else:
|
||||
expiry = datetime.now(timezone.utc) + self._ttl
|
||||
|
||||
self._cache[user_id] = (token, expiry)
|
||||
logger.debug(f"Cached token for user {user_id} until {expiry}")
|
||||
|
||||
async def invalidate(self, user_id: str):
|
||||
"""Remove token from cache."""
|
||||
async with self._lock:
|
||||
if user_id in self._cache:
|
||||
del self._cache[user_id]
|
||||
logger.debug(f"Invalidated cached token for user {user_id}")
|
||||
|
||||
|
||||
class TokenBrokerService:
|
||||
"""
|
||||
Manages token lifecycle for the Progressive Consent architecture.
|
||||
|
||||
This service handles:
|
||||
- Getting or refreshing Nextcloud access tokens
|
||||
- Managing a short-lived token cache
|
||||
- Refreshing master refresh tokens periodically
|
||||
- Validating token audiences
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage: RefreshTokenStorage,
|
||||
oidc_discovery_url: str,
|
||||
nextcloud_host: str,
|
||||
encryption_key: str,
|
||||
cache_ttl: int = 300,
|
||||
cache_early_refresh: int = 30,
|
||||
):
|
||||
"""
|
||||
Initialize the Token Broker Service.
|
||||
|
||||
Args:
|
||||
storage: Database storage for refresh tokens
|
||||
oidc_discovery_url: OIDC provider discovery URL
|
||||
nextcloud_host: Nextcloud server URL
|
||||
encryption_key: Fernet key for token encryption
|
||||
cache_ttl: Cache TTL in seconds (default: 5 minutes)
|
||||
cache_early_refresh: Early refresh threshold in seconds (default: 30 seconds)
|
||||
"""
|
||||
self.storage = storage
|
||||
self.oidc_discovery_url = oidc_discovery_url
|
||||
self.nextcloud_host = nextcloud_host
|
||||
self.fernet = Fernet(
|
||||
encryption_key.encode()
|
||||
if isinstance(encryption_key, str)
|
||||
else encryption_key
|
||||
)
|
||||
self.cache = TokenCache(cache_ttl, cache_early_refresh)
|
||||
self._oidc_config = None
|
||||
self._http_client = None
|
||||
|
||||
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=httpx.Timeout(30.0), follow_redirects=True
|
||||
)
|
||||
return self._http_client
|
||||
|
||||
async def _get_oidc_config(self) -> dict:
|
||||
"""Get OIDC configuration from discovery endpoint."""
|
||||
if self._oidc_config is None:
|
||||
client = await self._get_http_client()
|
||||
response = await client.get(self.oidc_discovery_url)
|
||||
response.raise_for_status()
|
||||
self._oidc_config = response.json()
|
||||
return self._oidc_config
|
||||
|
||||
async def get_nextcloud_token(self, user_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get a valid Nextcloud access token for the user.
|
||||
|
||||
DEPRECATED: This method uses the old pattern of stored refresh tokens
|
||||
for all operations. Use get_session_token() or get_background_token()
|
||||
instead for proper session/background separation.
|
||||
|
||||
This method:
|
||||
1. Checks the cache for a valid token
|
||||
2. If not cached, checks for stored refresh token
|
||||
3. If refresh token exists, obtains new access token
|
||||
4. Caches the new token for future requests
|
||||
|
||||
Args:
|
||||
user_id: The user identifier
|
||||
|
||||
Returns:
|
||||
Valid Nextcloud access token or None if not provisioned
|
||||
"""
|
||||
# Check cache first
|
||||
cached_token = await self.cache.get(user_id)
|
||||
if cached_token:
|
||||
return cached_token
|
||||
|
||||
# Get stored refresh token
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
if not refresh_data:
|
||||
logger.info(f"No refresh token found for user {user_id}")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Decrypt refresh token
|
||||
encrypted_token = refresh_data["refresh_token"]
|
||||
refresh_token = self.fernet.decrypt(encrypted_token.encode()).decode()
|
||||
|
||||
# Exchange refresh token for new access token
|
||||
access_token, expires_in = await self._refresh_access_token(refresh_token)
|
||||
|
||||
# Cache the new token
|
||||
await self.cache.set(user_id, access_token, expires_in)
|
||||
|
||||
return access_token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Nextcloud token for user {user_id}: {e}")
|
||||
# Invalidate cache on error
|
||||
await self.cache.invalidate(user_id)
|
||||
return None
|
||||
|
||||
async def get_session_token(
|
||||
self,
|
||||
flow1_token: str,
|
||||
required_scopes: list[str],
|
||||
requested_audience: str = "nextcloud",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get ephemeral token for MCP session operations (on-demand).
|
||||
|
||||
This implements the correct Progressive Consent pattern where:
|
||||
1. Client provides Flow 1 token (aud: "mcp-server")
|
||||
2. Server exchanges it for ephemeral Nextcloud token
|
||||
3. Token is NOT stored, only used for current operation
|
||||
|
||||
Key properties:
|
||||
- On-demand generation during tool execution
|
||||
- Ephemeral (not stored, discarded after use)
|
||||
- Limited scopes (only what tool needs)
|
||||
- Short-lived (5 minutes)
|
||||
|
||||
Args:
|
||||
flow1_token: The MCP session token (aud: "mcp-server")
|
||||
required_scopes: Minimal scopes needed for this operation
|
||||
requested_audience: Target audience (usually "nextcloud")
|
||||
|
||||
Returns:
|
||||
Ephemeral Nextcloud access token or None if exchange fails
|
||||
"""
|
||||
try:
|
||||
# Perform RFC 8693 token exchange
|
||||
delegated_token, expires_in = await exchange_token_for_delegation(
|
||||
flow1_token=flow1_token,
|
||||
requested_scopes=required_scopes,
|
||||
requested_audience=requested_audience,
|
||||
)
|
||||
|
||||
# NOTE: We intentionally do NOT cache session tokens
|
||||
# They are ephemeral and should be discarded after use
|
||||
logger.info(
|
||||
f"Generated ephemeral session token with scopes: {required_scopes}, "
|
||||
f"expires in {expires_in}s"
|
||||
)
|
||||
|
||||
return delegated_token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get session token: {e}")
|
||||
return None
|
||||
|
||||
async def get_background_token(
|
||||
self, user_id: str, required_scopes: list[str]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get token for background job operations (uses stored refresh token).
|
||||
|
||||
This is for background/offline operations that run without user interaction.
|
||||
Uses the stored refresh token from Flow 2 provisioning.
|
||||
|
||||
Key properties:
|
||||
- Uses stored refresh token from Flow 2
|
||||
- Different scopes than session tokens
|
||||
- Longer-lived for background operations
|
||||
- Can be cached for efficiency
|
||||
|
||||
Args:
|
||||
user_id: The user identifier
|
||||
required_scopes: Scopes needed for background operation
|
||||
|
||||
Returns:
|
||||
Nextcloud access token for background operations or None if not provisioned
|
||||
"""
|
||||
# Check cache first (background tokens can be cached)
|
||||
cache_key = f"{user_id}:background:{','.join(sorted(required_scopes))}"
|
||||
cached_token = await self.cache.get(cache_key)
|
||||
if cached_token:
|
||||
return cached_token
|
||||
|
||||
# Get stored refresh token
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
if not refresh_data:
|
||||
logger.info(f"No refresh token found for user {user_id}")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Decrypt refresh token
|
||||
encrypted_token = refresh_data["refresh_token"]
|
||||
refresh_token = self.fernet.decrypt(encrypted_token.encode()).decode()
|
||||
|
||||
# Get token with specific scopes for background operation
|
||||
access_token, expires_in = await self._refresh_access_token_with_scopes(
|
||||
refresh_token, required_scopes
|
||||
)
|
||||
|
||||
# Cache the background token
|
||||
await self.cache.set(cache_key, access_token, expires_in)
|
||||
|
||||
logger.info(
|
||||
f"Generated background token for user {user_id} with scopes: {required_scopes}"
|
||||
)
|
||||
|
||||
return access_token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get background token for user {user_id}: {e}")
|
||||
await self.cache.invalidate(cache_key)
|
||||
return None
|
||||
|
||||
async def _refresh_access_token(self, refresh_token: str) -> Tuple[str, int]:
|
||||
"""
|
||||
Exchange refresh token for new access token.
|
||||
|
||||
DEPRECATED: Use _refresh_access_token_with_scopes() for scope-specific requests.
|
||||
|
||||
Args:
|
||||
refresh_token: The refresh token
|
||||
|
||||
Returns:
|
||||
Tuple of (access_token, expires_in_seconds)
|
||||
"""
|
||||
config = await self._get_oidc_config()
|
||||
token_endpoint = config["token_endpoint"]
|
||||
|
||||
client = await self._get_http_client()
|
||||
|
||||
# Request new access token using refresh token
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"scope": "openid profile email notes:read notes:write calendar:read calendar:write",
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
token_endpoint,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"Token refresh failed: {response.status_code} - {response.text}"
|
||||
)
|
||||
raise Exception(f"Token refresh failed: {response.status_code}")
|
||||
|
||||
token_data = response.json()
|
||||
access_token = token_data["access_token"]
|
||||
expires_in = token_data.get("expires_in", 3600) # Default 1 hour
|
||||
|
||||
# Validate audience
|
||||
await self._validate_token_audience(access_token, "nextcloud")
|
||||
|
||||
logger.info(f"Refreshed access token (expires in {expires_in}s)")
|
||||
return access_token, expires_in
|
||||
|
||||
async def _refresh_access_token_with_scopes(
|
||||
self, refresh_token: str, required_scopes: list[str]
|
||||
) -> Tuple[str, int]:
|
||||
"""
|
||||
Exchange refresh token for new access token with specific scopes.
|
||||
|
||||
This method implements scope downscoping for least privilege.
|
||||
|
||||
Args:
|
||||
refresh_token: The refresh token
|
||||
required_scopes: Minimal scopes needed for this operation
|
||||
|
||||
Returns:
|
||||
Tuple of (access_token, expires_in_seconds)
|
||||
"""
|
||||
config = await self._get_oidc_config()
|
||||
token_endpoint = config["token_endpoint"]
|
||||
|
||||
client = await self._get_http_client()
|
||||
|
||||
# Always include basic OpenID scopes
|
||||
scopes = list(set(["openid", "profile", "email"] + required_scopes))
|
||||
|
||||
# Request new access token with specific scopes
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"scope": " ".join(scopes),
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
token_endpoint,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"Token refresh with scopes failed: {response.status_code} - {response.text}"
|
||||
)
|
||||
raise Exception(f"Token refresh failed: {response.status_code}")
|
||||
|
||||
token_data = response.json()
|
||||
access_token = token_data["access_token"]
|
||||
expires_in = token_data.get("expires_in", 3600) # Default 1 hour
|
||||
|
||||
# Validate audience
|
||||
await self._validate_token_audience(access_token, "nextcloud")
|
||||
|
||||
logger.info(
|
||||
f"Refreshed access token with scopes {scopes} (expires in {expires_in}s)"
|
||||
)
|
||||
return access_token, expires_in
|
||||
|
||||
async def _validate_token_audience(self, token: str, expected_audience: str):
|
||||
"""
|
||||
Validate that token has correct audience claim.
|
||||
|
||||
Args:
|
||||
token: JWT token to validate
|
||||
expected_audience: Expected audience value
|
||||
|
||||
Raises:
|
||||
ValueError: If audience doesn't match
|
||||
"""
|
||||
try:
|
||||
# Decode without verification to check claims
|
||||
# In production, should verify signature
|
||||
claims = jwt.decode(token, options={"verify_signature": False})
|
||||
|
||||
audience = claims.get("aud", [])
|
||||
if isinstance(audience, str):
|
||||
audience = [audience]
|
||||
|
||||
if expected_audience not in audience:
|
||||
raise ValueError(
|
||||
f"Token audience {audience} doesn't include {expected_audience}"
|
||||
)
|
||||
|
||||
except jwt.DecodeError as e:
|
||||
# Token might be opaque, skip validation
|
||||
logger.debug(f"Cannot decode token for audience validation: {e}")
|
||||
|
||||
async def refresh_master_token(self, user_id: str) -> bool:
|
||||
"""
|
||||
Refresh the master refresh token (periodic rotation).
|
||||
|
||||
This should be called periodically (e.g., daily) to rotate
|
||||
refresh tokens for security.
|
||||
|
||||
Args:
|
||||
user_id: The user identifier
|
||||
|
||||
Returns:
|
||||
True if refresh successful, False otherwise
|
||||
"""
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
if not refresh_data:
|
||||
logger.warning(f"No refresh token to rotate for user {user_id}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Decrypt current refresh token
|
||||
encrypted_token = refresh_data["refresh_token"]
|
||||
current_refresh_token = self.fernet.decrypt(
|
||||
encrypted_token.encode()
|
||||
).decode()
|
||||
|
||||
# Get OIDC configuration
|
||||
config = await self._get_oidc_config()
|
||||
token_endpoint = config["token_endpoint"]
|
||||
|
||||
client = await self._get_http_client()
|
||||
|
||||
# Request new refresh token
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": current_refresh_token,
|
||||
"scope": "openid profile email offline_access notes:read notes:write calendar:read calendar:write",
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
token_endpoint,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Master token refresh failed: {response.status_code}")
|
||||
return False
|
||||
|
||||
token_data = response.json()
|
||||
new_refresh_token = token_data.get("refresh_token")
|
||||
|
||||
if new_refresh_token and new_refresh_token != current_refresh_token:
|
||||
# Encrypt and store new refresh token
|
||||
encrypted_new = self.fernet.encrypt(new_refresh_token.encode()).decode()
|
||||
await self.storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
refresh_token=encrypted_new,
|
||||
expires_at=datetime.now(timezone.utc)
|
||||
+ timedelta(days=90), # 90-day expiry
|
||||
)
|
||||
logger.info(f"Rotated master refresh token for user {user_id}")
|
||||
|
||||
# Invalidate cached access token
|
||||
await self.cache.invalidate(user_id)
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh master token for user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
async def has_nextcloud_provisioning(self, user_id: str) -> bool:
|
||||
"""
|
||||
Check if user has provisioned Nextcloud access (Flow 2).
|
||||
|
||||
Args:
|
||||
user_id: The user identifier
|
||||
|
||||
Returns:
|
||||
True if user has stored refresh token, False otherwise
|
||||
"""
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
return refresh_data is not None
|
||||
|
||||
async def revoke_nextcloud_access(self, user_id: str) -> bool:
|
||||
"""
|
||||
Revoke stored Nextcloud access for a user.
|
||||
|
||||
This removes stored refresh tokens and clears cache.
|
||||
|
||||
Args:
|
||||
user_id: The user identifier
|
||||
|
||||
Returns:
|
||||
True if revocation successful
|
||||
"""
|
||||
try:
|
||||
# Get refresh token for revocation at IdP
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
if refresh_data:
|
||||
try:
|
||||
# Attempt to revoke at IdP
|
||||
encrypted_token = refresh_data["refresh_token"]
|
||||
refresh_token = self.fernet.decrypt(
|
||||
encrypted_token.encode()
|
||||
).decode()
|
||||
await self._revoke_token_at_idp(refresh_token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to revoke at IdP: {e}")
|
||||
|
||||
# Remove from storage
|
||||
await self.storage.delete_refresh_token(user_id)
|
||||
|
||||
# Clear cache
|
||||
await self.cache.invalidate(user_id)
|
||||
|
||||
logger.info(f"Revoked Nextcloud access for user {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to revoke access for user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _revoke_token_at_idp(self, token: str):
|
||||
"""Revoke token at the IdP if revocation endpoint exists."""
|
||||
config = await self._get_oidc_config()
|
||||
revocation_endpoint = config.get("revocation_endpoint")
|
||||
|
||||
if not revocation_endpoint:
|
||||
logger.debug("No revocation endpoint available")
|
||||
return
|
||||
|
||||
client = await self._get_http_client()
|
||||
|
||||
data = {"token": token, "token_type_hint": "refresh_token"}
|
||||
|
||||
response = await client.post(
|
||||
revocation_endpoint,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info("Token revoked at IdP")
|
||||
else:
|
||||
logger.warning(f"Token revocation returned {response.status_code}")
|
||||
|
||||
async def close(self):
|
||||
"""Clean up resources."""
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
@@ -0,0 +1,592 @@
|
||||
"""RFC 8693 Token Exchange implementation for ADR-004 Progressive Consent.
|
||||
|
||||
This module implements the token exchange pattern to convert Flow 1 MCP tokens
|
||||
(aud: "mcp-server") into ephemeral delegated Nextcloud tokens (aud: "nextcloud")
|
||||
for session operations.
|
||||
|
||||
Key Properties:
|
||||
- On-demand generation during tool execution
|
||||
- Ephemeral tokens (NOT stored, discarded after use)
|
||||
- Limited scopes (only what tool needs)
|
||||
- Short-lived (5 minutes default)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
|
||||
from ..config import get_settings
|
||||
from .refresh_token_storage import RefreshTokenStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TokenExchangeService:
|
||||
"""Implements RFC 8693 OAuth 2.0 Token Exchange."""
|
||||
|
||||
# RFC 8693 Grant Type
|
||||
TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
|
||||
# RFC 8693 Token Type Identifiers
|
||||
TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token"
|
||||
TOKEN_TYPE_JWT = "urn:ietf:params:oauth:token-type:jwt"
|
||||
TOKEN_TYPE_ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
oidc_discovery_url: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
client_secret: Optional[str] = None,
|
||||
nextcloud_host: Optional[str] = None,
|
||||
):
|
||||
"""Initialize token exchange service.
|
||||
|
||||
Args:
|
||||
oidc_discovery_url: OIDC discovery endpoint URL
|
||||
client_id: OAuth client ID for token exchange
|
||||
client_secret: OAuth client secret
|
||||
nextcloud_host: Nextcloud instance URL
|
||||
"""
|
||||
settings = get_settings()
|
||||
self.oidc_discovery_url = oidc_discovery_url or settings.oidc_discovery_url
|
||||
self.client_id = client_id or settings.oidc_client_id
|
||||
self.client_secret = client_secret or settings.oidc_client_secret
|
||||
self.nextcloud_host = nextcloud_host or settings.nextcloud_host
|
||||
|
||||
self._token_endpoint: Optional[str] = None
|
||||
self._jwks_uri: Optional[str] = None
|
||||
self._discovery_cache: Optional[Dict[str, Any]] = None
|
||||
self._discovery_cache_time: float = 0
|
||||
self._discovery_cache_ttl: float = 3600 # 1 hour
|
||||
|
||||
# Storage for Progressive Consent (refresh tokens) - only needed for delegation
|
||||
# NOT needed for pure RFC 8693 exchange (MCP tools)
|
||||
self.storage: Optional[RefreshTokenStorage] = None
|
||||
|
||||
# Create HTTP client
|
||||
self.http_client = httpx.AsyncClient(
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
if self.storage:
|
||||
await self.storage.initialize()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit."""
|
||||
await self.close()
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client and storage."""
|
||||
await self.http_client.aclose()
|
||||
# RefreshTokenStorage doesn't have a close method
|
||||
|
||||
async def _ensure_storage(self):
|
||||
"""Lazily initialize storage for Progressive Consent operations.
|
||||
|
||||
Only needed for delegation operations that use refresh tokens.
|
||||
NOT needed for pure RFC 8693 exchange (MCP tools).
|
||||
"""
|
||||
if self.storage is None:
|
||||
self.storage = RefreshTokenStorage.from_env()
|
||||
await self.storage.initialize()
|
||||
|
||||
async def _discover_endpoints(self) -> Dict[str, Any]:
|
||||
"""Discover OIDC endpoints from discovery URL.
|
||||
|
||||
Returns:
|
||||
Discovery document containing endpoint URLs
|
||||
"""
|
||||
# Check cache
|
||||
if (
|
||||
self._discovery_cache
|
||||
and (time.time() - self._discovery_cache_time) < self._discovery_cache_ttl
|
||||
):
|
||||
return self._discovery_cache
|
||||
|
||||
if not self.oidc_discovery_url:
|
||||
# Fallback to Nextcloud OIDC if no discovery URL
|
||||
self.oidc_discovery_url = urljoin(
|
||||
self.nextcloud_host, "/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.http_client.get(self.oidc_discovery_url)
|
||||
response.raise_for_status()
|
||||
|
||||
self._discovery_cache = response.json()
|
||||
self._discovery_cache_time = time.time()
|
||||
|
||||
# Cache frequently used endpoints
|
||||
self._token_endpoint = self._discovery_cache.get("token_endpoint")
|
||||
self._jwks_uri = self._discovery_cache.get("jwks_uri")
|
||||
|
||||
return self._discovery_cache
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to discover OIDC endpoints: {e}")
|
||||
raise
|
||||
|
||||
async def exchange_token_for_delegation(
|
||||
self,
|
||||
flow1_token: str,
|
||||
requested_scopes: list[str],
|
||||
requested_audience: str = "nextcloud",
|
||||
) -> Tuple[str, int]:
|
||||
"""Exchange Flow 1 MCP token for delegated Nextcloud token.
|
||||
|
||||
This implements RFC 8693 Token Exchange for on-behalf-of delegation.
|
||||
|
||||
Args:
|
||||
flow1_token: The MCP session token (aud: "mcp-server")
|
||||
requested_scopes: Scopes needed for this operation
|
||||
requested_audience: Target audience (usually "nextcloud")
|
||||
|
||||
Returns:
|
||||
Tuple of (delegated_token, expires_in)
|
||||
|
||||
Raises:
|
||||
ValueError: If token validation fails
|
||||
RuntimeError: If provisioning not completed or exchange fails
|
||||
"""
|
||||
# 1. Validate Flow 1 token audience
|
||||
await self._validate_flow1_token(flow1_token)
|
||||
|
||||
# 2. Extract user ID from token
|
||||
user_id = self._extract_user_id(flow1_token)
|
||||
|
||||
# 3. Check user has provisioned Nextcloud access (Flow 2)
|
||||
if not await self._check_provisioning(user_id):
|
||||
raise RuntimeError(
|
||||
"Nextcloud access not provisioned. "
|
||||
"User must complete Flow 2 provisioning first."
|
||||
)
|
||||
|
||||
# 4. Get stored refresh token for user (from Flow 2)
|
||||
refresh_token = await self._get_user_refresh_token(user_id)
|
||||
if not refresh_token:
|
||||
raise RuntimeError(
|
||||
"No refresh token found. User must complete provisioning."
|
||||
)
|
||||
|
||||
# 5. Perform token exchange with IdP
|
||||
delegated_token, expires_in = await self._perform_token_exchange(
|
||||
subject_token=flow1_token,
|
||||
refresh_token=refresh_token,
|
||||
requested_scopes=requested_scopes,
|
||||
requested_audience=requested_audience,
|
||||
)
|
||||
|
||||
# 6. Log the exchange for audit trail
|
||||
logger.info(
|
||||
f"Token exchange completed for user {user_id}: "
|
||||
f"scopes={requested_scopes}, audience={requested_audience}, "
|
||||
f"expires_in={expires_in}s"
|
||||
)
|
||||
|
||||
return delegated_token, expires_in
|
||||
|
||||
async def exchange_token_for_audience(
|
||||
self,
|
||||
subject_token: str,
|
||||
requested_audience: str = "nextcloud",
|
||||
requested_scopes: list[str] | None = None,
|
||||
) -> Tuple[str, int]:
|
||||
"""
|
||||
Pure RFC 8693 token exchange (no refresh tokens required).
|
||||
|
||||
This implements stateless per-request token exchange where:
|
||||
1. Client token has aud: <client-id> (e.g., "nextcloud-mcp-server")
|
||||
2. Exchange for token with aud: "nextcloud" (for API access)
|
||||
3. NO refresh tokens or provisioning required
|
||||
|
||||
Use case: All MCP tool calls (request-time operations).
|
||||
NOT for background jobs (which use refresh tokens separately).
|
||||
|
||||
Args:
|
||||
subject_token: Token being exchanged (from MCP client)
|
||||
requested_audience: Target audience (usually "nextcloud")
|
||||
requested_scopes: Optional scopes (may not be supported by all IdPs)
|
||||
|
||||
Returns:
|
||||
Tuple of (access_token, expires_in)
|
||||
|
||||
Raises:
|
||||
ValueError: If token validation fails
|
||||
RuntimeError: If exchange fails
|
||||
"""
|
||||
# 1. Validate subject token (accepts both "mcp-server" and client_id)
|
||||
await self._validate_flow1_token(subject_token)
|
||||
|
||||
# 2. Extract user ID for logging
|
||||
user_id = self._extract_user_id(subject_token)
|
||||
|
||||
# 3. Discover token endpoint
|
||||
discovery = await self._discover_endpoints()
|
||||
token_endpoint = discovery.get("token_endpoint")
|
||||
|
||||
if not token_endpoint:
|
||||
raise RuntimeError("No token endpoint found in discovery")
|
||||
|
||||
# 4. Build pure RFC 8693 exchange request (subject_token ONLY)
|
||||
data = {
|
||||
"grant_type": self.TOKEN_EXCHANGE_GRANT,
|
||||
"subject_token": subject_token,
|
||||
"subject_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
|
||||
"requested_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
|
||||
"audience": requested_audience,
|
||||
}
|
||||
|
||||
# Add scopes if provided (may not be supported by all providers)
|
||||
if requested_scopes:
|
||||
data["scope"] = " ".join(requested_scopes)
|
||||
|
||||
# Add client credentials
|
||||
if self.client_id and self.client_secret:
|
||||
data["client_id"] = self.client_id
|
||||
data["client_secret"] = self.client_secret
|
||||
|
||||
try:
|
||||
# Perform exchange
|
||||
logger.debug(f"Exchanging token for audience={requested_audience}")
|
||||
response = await self.http_client.post(
|
||||
token_endpoint,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
access_token = result.get("access_token")
|
||||
expires_in = result.get("expires_in", 300)
|
||||
|
||||
if not access_token:
|
||||
raise RuntimeError("No access token in exchange response")
|
||||
|
||||
logger.info(
|
||||
f"Pure RFC 8693 token exchange successful for user {user_id}: "
|
||||
f"audience={requested_audience}, expires_in={expires_in}s"
|
||||
)
|
||||
|
||||
return access_token, expires_in
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Token exchange failed: {e.response.text}")
|
||||
raise RuntimeError(f"Token exchange failed: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Token exchange error: {e}")
|
||||
raise
|
||||
|
||||
async def _validate_flow1_token(self, token: str):
|
||||
"""Validate that token has correct audience for MCP server.
|
||||
|
||||
Accepts either:
|
||||
- "mcp-server" (Progressive Consent legacy)
|
||||
- self.client_id (external IdP, e.g., "nextcloud-mcp-server")
|
||||
|
||||
Args:
|
||||
token: JWT token to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If token is invalid or has wrong audience
|
||||
"""
|
||||
try:
|
||||
# Decode without verification first to check audience
|
||||
# In production, should verify signature against JWKS
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
|
||||
# Check audience
|
||||
audience = payload.get("aud", [])
|
||||
if isinstance(audience, str):
|
||||
audience = [audience]
|
||||
|
||||
# Accept either "mcp-server" (Progressive Consent) or client_id (external IdP)
|
||||
valid_audiences = ["mcp-server"]
|
||||
if self.client_id:
|
||||
valid_audiences.append(self.client_id)
|
||||
|
||||
if not any(aud in audience for aud in valid_audiences):
|
||||
raise ValueError(
|
||||
f"Invalid token audience. Expected one of {valid_audiences}, got {audience}"
|
||||
)
|
||||
|
||||
# Check expiration
|
||||
exp = payload.get("exp", 0)
|
||||
if exp < time.time():
|
||||
raise ValueError("Token has expired")
|
||||
|
||||
except jwt.DecodeError as e:
|
||||
raise ValueError(f"Invalid JWT token: {e}")
|
||||
|
||||
def _extract_user_id(self, token: str) -> str:
|
||||
"""Extract user ID from JWT token.
|
||||
|
||||
Args:
|
||||
token: JWT token
|
||||
|
||||
Returns:
|
||||
User ID from token
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
|
||||
# Try standard claims in order of preference
|
||||
user_id = (
|
||||
payload.get("sub")
|
||||
or payload.get("preferred_username")
|
||||
or payload.get("email")
|
||||
or payload.get("name")
|
||||
)
|
||||
|
||||
if not user_id:
|
||||
raise ValueError("No user identifier in token")
|
||||
|
||||
return user_id
|
||||
|
||||
except jwt.DecodeError as e:
|
||||
raise ValueError(f"Failed to extract user ID: {e}")
|
||||
|
||||
async def _check_provisioning(self, user_id: str) -> bool:
|
||||
"""Check if user has completed Flow 2 provisioning.
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
|
||||
Returns:
|
||||
True if provisioned, False otherwise
|
||||
"""
|
||||
await self._ensure_storage()
|
||||
token_data = await self.storage.get_refresh_token(user_id)
|
||||
return token_data is not None
|
||||
|
||||
async def _get_user_refresh_token(self, user_id: str) -> Optional[str]:
|
||||
"""Get stored refresh token for user from Flow 2 provisioning.
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
|
||||
Returns:
|
||||
Refresh token if found, None otherwise
|
||||
"""
|
||||
await self._ensure_storage()
|
||||
token_data = await self.storage.get_refresh_token(user_id)
|
||||
if token_data:
|
||||
return token_data.get("refresh_token")
|
||||
return None
|
||||
|
||||
async def _perform_token_exchange(
|
||||
self,
|
||||
subject_token: str,
|
||||
refresh_token: str,
|
||||
requested_scopes: list[str],
|
||||
requested_audience: str,
|
||||
) -> Tuple[str, int]:
|
||||
"""Perform RFC 8693 token exchange with IdP.
|
||||
|
||||
Args:
|
||||
subject_token: The token being exchanged (Flow 1 token)
|
||||
refresh_token: User's stored refresh token for delegation
|
||||
requested_scopes: Minimal scopes for this operation
|
||||
requested_audience: Target audience
|
||||
|
||||
Returns:
|
||||
Tuple of (access_token, expires_in)
|
||||
"""
|
||||
# Discover token endpoint
|
||||
discovery = await self._discover_endpoints()
|
||||
token_endpoint = discovery.get("token_endpoint")
|
||||
|
||||
if not token_endpoint:
|
||||
raise RuntimeError("No token endpoint found in discovery")
|
||||
|
||||
# Build token exchange request per RFC 8693
|
||||
data = {
|
||||
# Token exchange grant type
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
# The token we're exchanging (Flow 1 MCP token)
|
||||
"subject_token": subject_token,
|
||||
"subject_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
|
||||
# Use refresh token as actor token (proves we have delegation rights)
|
||||
"actor_token": refresh_token,
|
||||
"actor_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
|
||||
# Requested token properties
|
||||
"requested_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
|
||||
"audience": requested_audience,
|
||||
"scope": " ".join(requested_scopes),
|
||||
}
|
||||
|
||||
# Add client credentials if configured
|
||||
if self.client_id and self.client_secret:
|
||||
data["client_id"] = self.client_id
|
||||
data["client_secret"] = self.client_secret
|
||||
|
||||
try:
|
||||
# Attempt RFC 8693 token exchange
|
||||
response = await self.http_client.post(
|
||||
token_endpoint,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
if response.status_code == 400:
|
||||
# Token exchange might not be supported, fall back to refresh grant
|
||||
logger.info(
|
||||
"Token exchange not supported, falling back to refresh grant"
|
||||
)
|
||||
return await self._fallback_refresh_grant(
|
||||
refresh_token=refresh_token,
|
||||
requested_scopes=requested_scopes,
|
||||
token_endpoint=token_endpoint,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
access_token = result.get("access_token")
|
||||
expires_in = result.get("expires_in", 300) # Default 5 minutes
|
||||
|
||||
if not access_token:
|
||||
raise RuntimeError("No access token in exchange response")
|
||||
|
||||
return access_token, expires_in
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Token exchange failed: {e.response.text}")
|
||||
raise RuntimeError(f"Token exchange failed: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Token exchange error: {e}")
|
||||
raise
|
||||
|
||||
async def _fallback_refresh_grant(
|
||||
self, refresh_token: str, requested_scopes: list[str], token_endpoint: str
|
||||
) -> Tuple[str, int]:
|
||||
"""Fallback to standard refresh token grant if token exchange not supported.
|
||||
|
||||
This is less secure than token exchange but provides compatibility.
|
||||
|
||||
Args:
|
||||
refresh_token: User's stored refresh token
|
||||
requested_scopes: Minimal scopes for this operation
|
||||
token_endpoint: Token endpoint URL
|
||||
|
||||
Returns:
|
||||
Tuple of (access_token, expires_in)
|
||||
"""
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"scope": " ".join(requested_scopes), # Request minimal scopes
|
||||
}
|
||||
|
||||
# Add client credentials if configured
|
||||
if self.client_id and self.client_secret:
|
||||
data["client_id"] = self.client_id
|
||||
data["client_secret"] = self.client_secret
|
||||
|
||||
try:
|
||||
response = await self.http_client.post(
|
||||
token_endpoint,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
|
||||
access_token = result.get("access_token")
|
||||
expires_in = result.get("expires_in", 300) # Default 5 minutes
|
||||
|
||||
if not access_token:
|
||||
raise RuntimeError("No access token in refresh response")
|
||||
|
||||
# Log that we're using fallback
|
||||
logger.warning(
|
||||
f"Using refresh grant fallback for token exchange. "
|
||||
f"Scopes: {requested_scopes}"
|
||||
)
|
||||
|
||||
return access_token, expires_in
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Refresh grant failed: {e.response.text}")
|
||||
raise RuntimeError(f"Refresh grant failed: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Refresh grant error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_token_exchange_service: Optional[TokenExchangeService] = None
|
||||
|
||||
|
||||
async def get_token_exchange_service() -> TokenExchangeService:
|
||||
"""Get or create the singleton token exchange service.
|
||||
|
||||
Note: Storage is initialized lazily only when needed for delegation operations.
|
||||
Pure RFC 8693 exchange (MCP tools) doesn't require storage.
|
||||
|
||||
Returns:
|
||||
TokenExchangeService instance
|
||||
"""
|
||||
global _token_exchange_service
|
||||
|
||||
if _token_exchange_service is None:
|
||||
_token_exchange_service = TokenExchangeService()
|
||||
# Storage is initialized lazily via _ensure_storage() when needed
|
||||
|
||||
return _token_exchange_service
|
||||
|
||||
|
||||
async def exchange_token_for_delegation(
|
||||
flow1_token: str, requested_scopes: list[str], requested_audience: str = "nextcloud"
|
||||
) -> Tuple[str, int]:
|
||||
"""Convenience function to exchange tokens (Progressive Consent with refresh tokens).
|
||||
|
||||
NOTE: This is for background jobs only. For MCP tool calls, use exchange_token_for_audience().
|
||||
|
||||
Args:
|
||||
flow1_token: The MCP session token (aud: "mcp-server")
|
||||
requested_scopes: Scopes needed for this operation
|
||||
requested_audience: Target audience (usually "nextcloud")
|
||||
|
||||
Returns:
|
||||
Tuple of (delegated_token, expires_in)
|
||||
"""
|
||||
service = await get_token_exchange_service()
|
||||
return await service.exchange_token_for_delegation(
|
||||
flow1_token=flow1_token,
|
||||
requested_scopes=requested_scopes,
|
||||
requested_audience=requested_audience,
|
||||
)
|
||||
|
||||
|
||||
async def exchange_token_for_audience(
|
||||
subject_token: str,
|
||||
requested_audience: str = "nextcloud",
|
||||
requested_scopes: list[str] | None = None,
|
||||
) -> Tuple[str, int]:
|
||||
"""Convenience function for pure RFC 8693 token exchange (no refresh tokens).
|
||||
|
||||
Use this for ALL MCP tool calls (request-time operations).
|
||||
|
||||
Args:
|
||||
subject_token: Token being exchanged (from MCP client)
|
||||
requested_audience: Target audience (usually "nextcloud")
|
||||
requested_scopes: Optional scopes (may not be supported by all IdPs)
|
||||
|
||||
Returns:
|
||||
Tuple of (access_token, expires_in)
|
||||
"""
|
||||
service = await get_token_exchange_service()
|
||||
return await service.exchange_token_for_audience(
|
||||
subject_token=subject_token,
|
||||
requested_audience=requested_audience,
|
||||
requested_scopes=requested_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)
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
"""User info routes for the MCP server admin UI.
|
||||
|
||||
Provides browser-based endpoints to view information about the currently
|
||||
authenticated user. Uses session-based authentication with OAuth flow.
|
||||
|
||||
For BasicAuth mode: Shows configured user info (no login needed).
|
||||
For OAuth mode: Requires browser-based OAuth login to establish session.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from starlette.authentication import requires
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
|
||||
"""Get the correct userinfo endpoint based on OAuth mode.
|
||||
|
||||
Args:
|
||||
oauth_ctx: OAuth context from app.state
|
||||
|
||||
Returns:
|
||||
Userinfo endpoint URL, or None if unavailable
|
||||
"""
|
||||
oauth_client = oauth_ctx.get("oauth_client")
|
||||
|
||||
# External IdP mode (Keycloak): use oauth_client's userinfo endpoint
|
||||
if oauth_client:
|
||||
# Ensure discovery has been performed
|
||||
if not oauth_client.userinfo_endpoint:
|
||||
try:
|
||||
await oauth_client.discover()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to discover IdP endpoints: {e}")
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"Using external IdP userinfo endpoint: {oauth_client.userinfo_endpoint}"
|
||||
)
|
||||
return oauth_client.userinfo_endpoint
|
||||
|
||||
# Integrated mode (Nextcloud): query discovery document
|
||||
oauth_config = oauth_ctx.get("config")
|
||||
if not oauth_config:
|
||||
return None
|
||||
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if not discovery_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
userinfo_endpoint = discovery.get("userinfo_endpoint")
|
||||
|
||||
if userinfo_endpoint:
|
||||
logger.debug(
|
||||
f"Using Nextcloud userinfo endpoint from discovery: {userinfo_endpoint}"
|
||||
)
|
||||
return userinfo_endpoint
|
||||
|
||||
logger.warning("No userinfo_endpoint in discovery document")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to query discovery document for userinfo endpoint: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _query_idp_userinfo(
|
||||
access_token_str: str, userinfo_uri: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""Query the IdP's userinfo endpoint.
|
||||
|
||||
Args:
|
||||
access_token_str: The access token string
|
||||
userinfo_uri: The userinfo endpoint URI
|
||||
|
||||
Returns:
|
||||
User info dictionary from IdP, or None if query fails
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
userinfo_uri,
|
||||
headers={"Authorization": f"Bearer {access_token_str}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to query IdP userinfo endpoint: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _get_user_info(request: Request) -> dict[str, Any]:
|
||||
"""Get user information for the currently authenticated user.
|
||||
|
||||
IMPORTANT: This function reads from cached profile data stored at login time.
|
||||
It does NOT perform token refresh or query the IdP on every request. The
|
||||
profile was cached once during oauth_login_callback and is displayed from
|
||||
storage thereafter.
|
||||
|
||||
This is for BROWSER UI DISPLAY ONLY. Do not use this for authorization
|
||||
decisions or background job authentication.
|
||||
|
||||
Args:
|
||||
request: Starlette request object (must be authenticated)
|
||||
|
||||
Returns:
|
||||
Dictionary containing user information from cache
|
||||
"""
|
||||
username = request.user.display_name
|
||||
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
||||
|
||||
# BasicAuth mode
|
||||
if not oauth_ctx:
|
||||
return {
|
||||
"username": username,
|
||||
"auth_mode": "basic",
|
||||
"nextcloud_host": os.getenv("NEXTCLOUD_HOST", "unknown"),
|
||||
}
|
||||
|
||||
# OAuth mode - read cached profile from browser session
|
||||
storage = oauth_ctx.get("storage")
|
||||
session_id = request.cookies.get("mcp_session")
|
||||
|
||||
if not storage or not session_id:
|
||||
return {
|
||||
"error": "Session not found",
|
||||
"username": username,
|
||||
"auth_mode": "oauth",
|
||||
}
|
||||
|
||||
try:
|
||||
# Check if background access was granted (refresh token exists)
|
||||
token_data = await storage.get_refresh_token(session_id)
|
||||
background_access_granted = token_data is not None
|
||||
|
||||
# Retrieve cached user profile (no token operations!)
|
||||
profile_data = await storage.get_user_profile(session_id)
|
||||
|
||||
# Build user context
|
||||
user_context = {
|
||||
"username": username, # From request.user.display_name (session_id)
|
||||
"auth_mode": "oauth",
|
||||
"session_id": session_id[:16] + "...", # Truncated for security
|
||||
"background_access_granted": background_access_granted,
|
||||
}
|
||||
|
||||
# Include cached profile if available
|
||||
if profile_data:
|
||||
user_context["idp_profile"] = profile_data
|
||||
logger.debug(f"Loaded cached profile for {session_id[:16]}...")
|
||||
else:
|
||||
logger.warning(f"No cached profile found for {session_id[:16]}...")
|
||||
user_context["idp_profile_error"] = (
|
||||
"Profile not cached. Try logging out and back in."
|
||||
)
|
||||
|
||||
return user_context
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
logger.error(f"Error retrieving user info: {e}")
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
return {
|
||||
"error": f"Failed to retrieve user info: {e}",
|
||||
"username": username,
|
||||
"auth_mode": "oauth",
|
||||
}
|
||||
|
||||
|
||||
@requires("authenticated", redirect="oauth_login")
|
||||
async def user_info_json(request: Request) -> JSONResponse:
|
||||
"""User info endpoint - returns JSON with current user information.
|
||||
|
||||
Requires authentication via session cookie (redirects to oauth_login route if not authenticated).
|
||||
|
||||
Args:
|
||||
request: Starlette request object
|
||||
|
||||
Returns:
|
||||
JSON response with user information
|
||||
"""
|
||||
user_info = await _get_user_info(request)
|
||||
return JSONResponse(user_info)
|
||||
|
||||
|
||||
@requires("authenticated", redirect="oauth_login")
|
||||
async def user_info_html(request: Request) -> HTMLResponse:
|
||||
"""User info page - returns HTML with current user information.
|
||||
|
||||
Requires authentication via session cookie (redirects to oauth_login route if not authenticated).
|
||||
|
||||
Args:
|
||||
request: Starlette request object
|
||||
|
||||
Returns:
|
||||
HTML response with formatted user information
|
||||
"""
|
||||
user_context = await _get_user_info(request)
|
||||
|
||||
# Check for error
|
||||
if "error" in user_context and user_context["error"] != "":
|
||||
# Get login URL dynamically
|
||||
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
||||
login_url = str(request.url_for("oauth_login")) if oauth_ctx else "/oauth/login"
|
||||
|
||||
error_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error - Nextcloud MCP Server</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
.container {{
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}}
|
||||
h1 {{
|
||||
color: #d32f2f;
|
||||
margin-top: 0;
|
||||
}}
|
||||
.error {{
|
||||
background-color: #ffebee;
|
||||
border-left: 4px solid #d32f2f;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Error Retrieving User Info</h1>
|
||||
<div class="error">
|
||||
<strong>Error:</strong> {user_context["error"]}
|
||||
</div>
|
||||
<p><a href="{login_url}">Login again</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(content=error_html)
|
||||
|
||||
# Build HTML response
|
||||
auth_mode = user_context.get("auth_mode", "unknown")
|
||||
username = user_context.get("username", "unknown")
|
||||
|
||||
# Get logout URL dynamically for OAuth mode
|
||||
logout_url = ""
|
||||
if auth_mode == "oauth":
|
||||
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
||||
logout_url = (
|
||||
str(request.url_for("oauth_logout")) if oauth_ctx else "/oauth/logout"
|
||||
)
|
||||
|
||||
# Build host info HTML (BasicAuth only)
|
||||
host_info_html = ""
|
||||
if auth_mode == "basic":
|
||||
nextcloud_host = user_context.get("nextcloud_host", "unknown")
|
||||
host_info_html = f"""
|
||||
<h2>Connection</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<td><strong>Nextcloud Host</strong></td>
|
||||
<td>{nextcloud_host}</td>
|
||||
</tr>
|
||||
</table>
|
||||
"""
|
||||
|
||||
# Build session info HTML (OAuth only)
|
||||
session_info_html = ""
|
||||
if auth_mode == "oauth" and "session_id" in user_context:
|
||||
session_id = user_context.get("session_id", "unknown")
|
||||
session_info_html = f"""
|
||||
<h2>Session Information</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<td><strong>Session ID</strong></td>
|
||||
<td><code>{session_id}</code></td>
|
||||
</tr>
|
||||
</table>
|
||||
"""
|
||||
|
||||
# Build IdP profile HTML
|
||||
idp_profile_html = ""
|
||||
if "idp_profile" in user_context:
|
||||
idp_profile = user_context["idp_profile"]
|
||||
idp_profile_html = "<h2>Identity Provider Profile</h2><table>"
|
||||
for key, value in idp_profile.items():
|
||||
# Handle list values
|
||||
if isinstance(value, list):
|
||||
value_str = ", ".join(str(v) for v in value)
|
||||
else:
|
||||
value_str = str(value)
|
||||
idp_profile_html += f"""
|
||||
<tr>
|
||||
<td><strong>{key}</strong></td>
|
||||
<td>{value_str}</td>
|
||||
</tr>
|
||||
"""
|
||||
idp_profile_html += "</table>"
|
||||
elif "idp_profile_error" in user_context:
|
||||
idp_profile_html = f"""
|
||||
<h2>Identity Provider Profile</h2>
|
||||
<div class="warning">{user_context["idp_profile_error"]}</div>
|
||||
"""
|
||||
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Info - Nextcloud MCP Server</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
.container {{
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}}
|
||||
h1 {{
|
||||
color: #0082c9;
|
||||
margin-top: 0;
|
||||
border-bottom: 2px solid #0082c9;
|
||||
padding-bottom: 10px;
|
||||
}}
|
||||
h2 {{
|
||||
color: #333;
|
||||
margin-top: 30px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-bottom: 5px;
|
||||
}}
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
}}
|
||||
td {{
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}}
|
||||
td:first-child {{
|
||||
width: 200px;
|
||||
color: #666;
|
||||
}}
|
||||
code {{
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}}
|
||||
.badge {{
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
.badge-oauth {{
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}}
|
||||
.badge-basic {{
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
}}
|
||||
.warning {{
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
color: #856404;
|
||||
}}
|
||||
.logout {{
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}}
|
||||
.button {{
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background-color: #d32f2f;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
}}
|
||||
.button:hover {{
|
||||
background-color: #b71c1c;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Nextcloud MCP Server - User Info</h1>
|
||||
|
||||
<h2>Authentication</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<td><strong>Username</strong></td>
|
||||
<td>{username}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Authentication Mode</strong></td>
|
||||
<td><span class="badge badge-{auth_mode}">{auth_mode}</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{host_info_html}
|
||||
{session_info_html}
|
||||
{idp_profile_html}
|
||||
|
||||
{f'<div class="logout"><a href="{logout_url}" class="button">Logout</a></div>' if auth_mode == "oauth" else ""}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return HTMLResponse(content=html_content)
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging.config
|
||||
import os
|
||||
from typing import Any
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
@@ -118,3 +119,54 @@ def get_document_processor_config() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
"""Application settings from environment variables."""
|
||||
|
||||
# OAuth/OIDC settings
|
||||
oidc_discovery_url: Optional[str] = None
|
||||
oidc_client_id: Optional[str] = None
|
||||
oidc_client_secret: Optional[str] = None
|
||||
|
||||
# Nextcloud settings
|
||||
nextcloud_host: Optional[str] = None
|
||||
nextcloud_username: Optional[str] = None
|
||||
nextcloud_password: Optional[str] = None
|
||||
|
||||
# Progressive Consent settings (always enabled - no flag needed)
|
||||
enable_token_exchange: bool = False
|
||||
enable_offline_access: bool = False
|
||||
|
||||
# Token settings
|
||||
token_encryption_key: Optional[str] = None
|
||||
token_storage_db: Optional[str] = None
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Get application settings from environment variables.
|
||||
|
||||
Returns:
|
||||
Settings object with configuration values
|
||||
"""
|
||||
return Settings(
|
||||
# OAuth/OIDC settings
|
||||
oidc_discovery_url=os.getenv("OIDC_DISCOVERY_URL"),
|
||||
oidc_client_id=os.getenv("OIDC_CLIENT_ID"),
|
||||
oidc_client_secret=os.getenv("OIDC_CLIENT_SECRET"),
|
||||
# Nextcloud settings
|
||||
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
||||
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
|
||||
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
|
||||
# Progressive Consent settings (always enabled)
|
||||
enable_token_exchange=(
|
||||
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
|
||||
),
|
||||
enable_offline_access=(
|
||||
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
|
||||
),
|
||||
# Token settings
|
||||
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
|
||||
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
|
||||
)
|
||||
|
||||
@@ -3,14 +3,22 @@
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
|
||||
def get_client(ctx: Context) -> NextcloudClient:
|
||||
async def get_client(ctx: Context) -> NextcloudClient:
|
||||
"""
|
||||
Get the appropriate Nextcloud client based on authentication mode.
|
||||
|
||||
In BasicAuth mode, returns the shared client from lifespan context.
|
||||
In OAuth mode, creates a new client per-request using the OAuth context.
|
||||
This function handles three modes:
|
||||
1. BasicAuth mode: Returns shared client from lifespan context
|
||||
2. OAuth pass-through mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
||||
Verifies Flow 1 token and passes it to Nextcloud
|
||||
3. OAuth token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
||||
Exchanges Flow 1 token for ephemeral Nextcloud token via RFC 8693
|
||||
|
||||
Note: Nextcloud doesn't support OAuth scopes natively. Scopes are enforced
|
||||
by the MCP server via @require_scopes decorator, not by the IdP.
|
||||
|
||||
This function automatically detects the authentication mode by checking
|
||||
the type of the lifespan context.
|
||||
@@ -28,21 +36,34 @@ def get_client(ctx: Context) -> NextcloudClient:
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def my_tool(ctx: Context):
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.capabilities()
|
||||
```
|
||||
"""
|
||||
settings = get_settings()
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
|
||||
# Try BasicAuth mode first (has 'client' attribute)
|
||||
# BasicAuth mode - use shared client (no token exchange)
|
||||
if hasattr(lifespan_ctx, "client"):
|
||||
return lifespan_ctx.client
|
||||
|
||||
# OAuth mode (has 'nextcloud_host' attribute)
|
||||
if hasattr(lifespan_ctx, "nextcloud_host"):
|
||||
from nextcloud_mcp_server.auth import get_client_from_context
|
||||
# Check if token exchange is enabled
|
||||
if settings.enable_token_exchange:
|
||||
from nextcloud_mcp_server.auth.context_helper import (
|
||||
get_session_client_from_context,
|
||||
)
|
||||
|
||||
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
|
||||
# Token exchange mode: Exchange Flow 1 token for ephemeral Nextcloud token
|
||||
return await get_session_client_from_context(
|
||||
ctx, lifespan_ctx.nextcloud_host
|
||||
)
|
||||
else:
|
||||
# Pass-through mode (default): Verify and pass Flow 1 token to Nextcloud
|
||||
from nextcloud_mcp_server.auth import get_client_from_context
|
||||
|
||||
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
|
||||
|
||||
# Unknown context type
|
||||
raise AttributeError(
|
||||
|
||||
@@ -22,7 +22,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
@require_scopes("calendar:read")
|
||||
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
||||
"""List all available calendars for the user"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
calendars_data = await client.calendar.list_calendars()
|
||||
|
||||
calendars = [Calendar(**cal_data) for cal_data in calendars_data]
|
||||
@@ -79,7 +79,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with event creation result
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
event_data = {
|
||||
"title": title,
|
||||
@@ -139,7 +139,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
List of events matching the filters
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Convert YYYY-MM-DD format dates to datetime objects
|
||||
start_datetime = None
|
||||
@@ -214,7 +214,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
ctx: Context,
|
||||
):
|
||||
"""Get detailed information about a specific event"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
event_data, etag = await client.calendar.get_event(calendar_name, event_uid)
|
||||
return event_data
|
||||
|
||||
@@ -248,7 +248,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
etag: str = "",
|
||||
):
|
||||
"""Update any aspect of an existing event"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Build update data with only non-None values
|
||||
event_data = {}
|
||||
@@ -299,7 +299,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
ctx: Context,
|
||||
):
|
||||
"""Delete a calendar event"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -342,7 +342,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with meeting creation result
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Combine date and time for start_datetime
|
||||
start_datetime = f"{date}T{time}:00"
|
||||
@@ -377,7 +377,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
limit: int = 10,
|
||||
):
|
||||
"""Get upcoming events in next N days"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
now = dt.datetime.now()
|
||||
end_datetime = now + dt.timedelta(days=days_ahead)
|
||||
@@ -447,7 +447,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
List of available time slots with start/end times and duration
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Parse attendees
|
||||
attendee_list = []
|
||||
@@ -549,7 +549,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Summary of operation results including counts and details
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
if operation not in ["update", "delete", "move"]:
|
||||
raise ValueError("Operation must be 'update', 'delete', or 'move'")
|
||||
@@ -772,7 +772,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Result of the calendar management operation
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
if action == "list":
|
||||
return await client.calendar.list_calendars()
|
||||
@@ -839,7 +839,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
List of todos matching the filters
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Build filters dictionary
|
||||
filters = {}
|
||||
@@ -890,7 +890,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with todo creation result
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
todo_data = {
|
||||
"summary": summary,
|
||||
@@ -939,7 +939,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with todo update result
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Build update data with only non-None values
|
||||
todo_data = {}
|
||||
@@ -981,7 +981,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with deletion status
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -1005,7 +1005,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
List of todos matching the filters from all calendars
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Build filters dictionary
|
||||
filters = {}
|
||||
|
||||
@@ -14,14 +14,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
@require_scopes("contacts:read")
|
||||
async def nc_contacts_list_addressbooks(ctx: Context):
|
||||
"""List all addressbooks for the user."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.list_addressbooks()
|
||||
|
||||
@mcp.tool()
|
||||
@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)
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -35,7 +35,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
name: The name of the addressbook.
|
||||
display_name: The display name of the addressbook.
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.create_addressbook(
|
||||
name=name, display_name=display_name
|
||||
)
|
||||
@@ -44,7 +44,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
@require_scopes("contacts:write")
|
||||
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
||||
"""Delete an addressbook."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.delete_addressbook(name=name)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -59,7 +59,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
uid: The unique ID for the contact.
|
||||
contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}.
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.create_contact(
|
||||
addressbook=addressbook, uid=uid, contact_data=contact_data
|
||||
)
|
||||
@@ -68,7 +68,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
@require_scopes("contacts:write")
|
||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||
"""Delete a contact."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -84,7 +84,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
contact_data: A dictionary with the contact's updated details, e.g. {"fn": "Jane Doe", "email": "jane.doe@example.com"}.
|
||||
etag: Optional ETag for optimistic concurrency control.
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.update_contact(
|
||||
addressbook=addressbook, uid=uid, contact_data=contact_data, etag=etag
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
async def cookbook_get_version():
|
||||
"""Get the Cookbook app and API version"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
version_data = await client.cookbook.get_version()
|
||||
return Version(**version_data)
|
||||
|
||||
@@ -41,7 +41,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
async def cookbook_get_config():
|
||||
"""Get the Cookbook app configuration"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
config_data = await client.cookbook.get_config()
|
||||
return CookbookConfig(**config_data)
|
||||
|
||||
@@ -49,7 +49,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
async def nc_cookbook_get_recipe_resource(recipe_id: int):
|
||||
"""Get a recipe by ID using resource URI"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
recipe_data = await client.cookbook.get_recipe(recipe_id)
|
||||
return Recipe(**recipe_data)
|
||||
@@ -77,7 +77,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
|
||||
This extracts recipe data from websites that use schema.org Recipe markup.
|
||||
Many popular recipe sites support this standard."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
recipe_data = await client.cookbook.import_recipe(url)
|
||||
recipe = Recipe(**recipe_data)
|
||||
@@ -131,7 +131,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
||||
"""Get all recipes in the database"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
recipes_data = await client.cookbook.list_recipes()
|
||||
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||
@@ -156,7 +156,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
@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)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
recipe_data = await client.cookbook.get_recipe(recipe_id)
|
||||
return Recipe(**recipe_data)
|
||||
@@ -199,7 +199,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
Optional: All other recipe fields following schema.org/Recipe format.
|
||||
|
||||
Times should be in ISO8601 duration format (e.g., 'PT30M' for 30 minutes)."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
recipe_data = {"name": name}
|
||||
if description:
|
||||
@@ -276,7 +276,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
"""Update an existing recipe.
|
||||
|
||||
Provide only the fields you want to update. Unspecified fields remain unchanged."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# First get the current recipe
|
||||
try:
|
||||
@@ -352,7 +352,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
) -> DeleteRecipeResponse:
|
||||
"""Delete a recipe permanently"""
|
||||
logger.info("Deleting recipe %s", recipe_id)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
message = await client.cookbook.delete_recipe(recipe_id)
|
||||
return DeleteRecipeResponse(
|
||||
@@ -386,7 +386,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
query: str, ctx: Context
|
||||
) -> SearchRecipesResponse:
|
||||
"""Search for recipes by keywords, tags, and categories"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
recipes_data = await client.cookbook.search_recipes(query)
|
||||
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||
@@ -422,7 +422,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
"""Get all known categories.
|
||||
|
||||
Note: A category name of '*' indicates recipes with no category."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
categories_data = await client.cookbook.list_categories()
|
||||
categories = [Category(**c) for c in categories_data]
|
||||
@@ -451,7 +451,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
"""Get all recipes in a specific category.
|
||||
|
||||
Use '_' as the category name to get recipes with no category."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
recipes_data = await client.cookbook.get_recipes_in_category(category)
|
||||
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||
@@ -483,7 +483,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
||||
"""Get all known keywords/tags"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
keywords_data = await client.cookbook.list_keywords()
|
||||
keywords = [Keyword(**k) for k in keywords_data]
|
||||
@@ -510,7 +510,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
keywords: list[str], ctx: Context
|
||||
) -> ListRecipesResponse:
|
||||
"""Get all recipes that have specific keywords/tags"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
recipes_data = await client.cookbook.get_recipes_with_keywords(keywords)
|
||||
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||
@@ -552,7 +552,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
folder: Recipe folder path in user's files
|
||||
update_interval: Automatic rescan interval in minutes
|
||||
print_image: Whether to print images with recipes"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
config_data = {}
|
||||
if folder is not None:
|
||||
@@ -587,7 +587,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
"""Trigger a rescan of all recipes into the caching database.
|
||||
|
||||
This rebuilds the search index and should be used after manual file changes."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
message = await client.cookbook.reindex()
|
||||
return ReindexResponse(status_code=200, message=message)
|
||||
|
||||
@@ -31,7 +31,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
"""List all Nextcloud Deck boards"""
|
||||
ctx: Context = mcp.get_context()
|
||||
await ctx.warning("This message is deprecated, use the deck_get_board instead")
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
boards = await client.deck.get_boards()
|
||||
return [board.model_dump() for board in boards]
|
||||
|
||||
@@ -42,7 +42,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_board tool instead"
|
||||
)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
board = await client.deck.get_board(board_id)
|
||||
return board.model_dump()
|
||||
|
||||
@@ -53,7 +53,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_stacks tool instead"
|
||||
)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
stacks = await client.deck.get_stacks(board_id)
|
||||
return [stack.model_dump() for stack in stacks]
|
||||
|
||||
@@ -64,7 +64,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_stack tool instead"
|
||||
)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
stack = await client.deck.get_stack(board_id, stack_id)
|
||||
return stack.model_dump()
|
||||
|
||||
@@ -75,7 +75,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_cards tool instead"
|
||||
)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
stack = await client.deck.get_stack(board_id, stack_id)
|
||||
if stack.cards:
|
||||
return [card.model_dump() for card in stack.cards]
|
||||
@@ -88,7 +88,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_card tool instead"
|
||||
)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
card = await client.deck.get_card(board_id, stack_id, card_id)
|
||||
return card.model_dump()
|
||||
|
||||
@@ -99,7 +99,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_labels tool instead"
|
||||
)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
board = await client.deck.get_board(board_id)
|
||||
return [label.model_dump() for label in board.labels]
|
||||
|
||||
@@ -110,7 +110,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_label tool instead"
|
||||
)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
label = await client.deck.get_label(board_id, label_id)
|
||||
return label.model_dump()
|
||||
|
||||
@@ -120,7 +120,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
||||
"""Get all Nextcloud Deck boards"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
boards = await client.deck.get_boards()
|
||||
return boards
|
||||
|
||||
@@ -128,7 +128,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
@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)
|
||||
client = await get_client(ctx)
|
||||
board = await client.deck.get_board(board_id)
|
||||
return board
|
||||
|
||||
@@ -136,7 +136,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
@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)
|
||||
client = await get_client(ctx)
|
||||
stacks = await client.deck.get_stacks(board_id)
|
||||
return stacks
|
||||
|
||||
@@ -144,7 +144,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
@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)
|
||||
client = await get_client(ctx)
|
||||
stack = await client.deck.get_stack(board_id, stack_id)
|
||||
return stack
|
||||
|
||||
@@ -154,7 +154,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
ctx: Context, board_id: int, stack_id: int
|
||||
) -> list[DeckCard]:
|
||||
"""Get all cards in a Nextcloud Deck stack"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
stack = await client.deck.get_stack(board_id, stack_id)
|
||||
if stack.cards:
|
||||
return stack.cards
|
||||
@@ -166,7 +166,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> DeckCard:
|
||||
"""Get details of a specific Nextcloud Deck card"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
card = await client.deck.get_card(board_id, stack_id, card_id)
|
||||
return card
|
||||
|
||||
@@ -174,7 +174,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
@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)
|
||||
client = await get_client(ctx)
|
||||
board = await client.deck.get_board(board_id)
|
||||
return board.labels
|
||||
|
||||
@@ -182,7 +182,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
@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)
|
||||
client = await get_client(ctx)
|
||||
label = await client.deck.get_label(board_id, label_id)
|
||||
return label
|
||||
|
||||
@@ -199,7 +199,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
title: The title of the new board
|
||||
color: The hexadecimal color of the new board (e.g. FF0000)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
board = await client.deck.create_board(title, color)
|
||||
return CreateBoardResponse(id=board.id, title=board.title, color=board.color)
|
||||
|
||||
@@ -217,7 +217,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
title: The title of the new stack
|
||||
order: Order for sorting the stacks
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
stack = await client.deck.create_stack(board_id, title, order)
|
||||
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
||||
|
||||
@@ -238,7 +238,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
title: New title for the stack
|
||||
order: New order for the stack
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.update_stack(board_id, stack_id, title, order)
|
||||
return StackOperationResponse(
|
||||
success=True,
|
||||
@@ -258,7 +258,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
board_id: The ID of the board
|
||||
stack_id: The ID of the stack
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.delete_stack(board_id, stack_id)
|
||||
return StackOperationResponse(
|
||||
success=True,
|
||||
@@ -291,7 +291,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
description: Description of the card
|
||||
duedate: Due date of the card (ISO-8601 format)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
card = await client.deck.create_card(
|
||||
board_id, stack_id, title, type, order, description, duedate
|
||||
)
|
||||
@@ -333,7 +333,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
archived: Whether the card should be archived
|
||||
done: Completion date for the card (ISO-8601 format)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.update_card(
|
||||
board_id,
|
||||
stack_id,
|
||||
@@ -367,7 +367,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
stack_id: The ID of the stack
|
||||
card_id: The ID of the card
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.delete_card(board_id, stack_id, card_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
@@ -389,7 +389,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
stack_id: The ID of the stack
|
||||
card_id: The ID of the card
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.archive_card(board_id, stack_id, card_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
@@ -411,7 +411,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
stack_id: The ID of the stack
|
||||
card_id: The ID of the card
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.unarchive_card(board_id, stack_id, card_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
@@ -440,7 +440,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
order: New position in the target stack
|
||||
target_stack_id: The ID of the target stack
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.reorder_card(
|
||||
board_id, stack_id, card_id, order, target_stack_id
|
||||
)
|
||||
@@ -465,7 +465,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
title: The title of the new label
|
||||
color: The color of the new label (hex format without #)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
label = await client.deck.create_label(board_id, title, color)
|
||||
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
||||
|
||||
@@ -486,7 +486,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
title: New title for the label
|
||||
color: New color for the label (hex format without #)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.update_label(board_id, label_id, title, color)
|
||||
return LabelOperationResponse(
|
||||
success=True,
|
||||
@@ -506,7 +506,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
board_id: The ID of the board
|
||||
label_id: The ID of the label
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.delete_label(board_id, label_id)
|
||||
return LabelOperationResponse(
|
||||
success=True,
|
||||
@@ -529,7 +529,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
card_id: The ID of the card
|
||||
label_id: The ID of the label to assign
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.assign_label_to_card(board_id, stack_id, card_id, label_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
@@ -552,7 +552,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
card_id: The ID of the card
|
||||
label_id: The ID of the label to remove
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.remove_label_from_card(board_id, stack_id, card_id, label_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
@@ -576,7 +576,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
card_id: The ID of the card
|
||||
user_id: The user ID to assign
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.assign_user_to_card(board_id, stack_id, card_id, user_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
@@ -599,7 +599,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
card_id: The ID of the card
|
||||
user_id: The user ID to unassign
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.unassign_user_from_card(board_id, stack_id, card_id, user_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
|
||||
@@ -28,7 +28,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
ctx: Context = (
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
settings_data = await client.notes.get_settings()
|
||||
return NotesSettings(**settings_data)
|
||||
|
||||
@@ -36,7 +36,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
async def nc_notes_get_attachment_resource(note_id: int, attachment_filename: str):
|
||||
"""Get a specific attachment from a note"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
# Assuming a method get_note_attachment exists in the client
|
||||
# This method should return the raw content and determine the mime type
|
||||
content, mime_type = await client.webdav.get_note_attachment(
|
||||
@@ -58,7 +58,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
"""Get user note using note id"""
|
||||
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.get_note(note_id)
|
||||
return Note(**note_data)
|
||||
@@ -90,7 +90,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
title: str, content: str, category: str, ctx: Context
|
||||
) -> CreateNoteResponse:
|
||||
"""Create a new note (requires notes:write scope)"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.create_note(
|
||||
title=title,
|
||||
@@ -147,7 +147,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
If the note has been modified by someone else since you retrieved it,
|
||||
the update will fail with a 412 error."""
|
||||
logger.info("Updating note %s", note_id)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.update(
|
||||
note_id=note_id,
|
||||
@@ -204,7 +204,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
between the note and what will be appended."""
|
||||
|
||||
logger.info("Appending content to note %s", note_id)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.append_content(
|
||||
note_id=note_id, content=content
|
||||
@@ -249,7 +249,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
@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 notes:read scope)."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
search_results_raw = await client.notes_search_notes(query=query)
|
||||
|
||||
@@ -295,7 +295,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
||||
"""Get a specific note by its ID (requires notes:read scope)"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.get_note(note_id)
|
||||
return Note(**note_data)
|
||||
@@ -326,7 +326,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
note_id: int, attachment_filename: str, ctx: Context
|
||||
) -> dict[str, str]:
|
||||
"""Get a specific attachment from a note"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
content, mime_type = await client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename
|
||||
@@ -371,7 +371,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
||||
"""Delete a note permanently"""
|
||||
logger.info("Deleting note %s", note_id)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
await client.notes.delete_note(note_id)
|
||||
return DeleteNoteResponse(
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
MCP Tools for OAuth and Provisioning Management (ADR-004 Progressive Consent).
|
||||
|
||||
This module provides MCP tools that enable users to explicitly provision
|
||||
Nextcloud access using the Flow 2 (Resource Provisioning) OAuth flow.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from mcp.server.fastmcp import Context
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProvisioningStatus(BaseModel):
|
||||
"""Status of Nextcloud provisioning for a user."""
|
||||
|
||||
is_provisioned: bool = Field(description="Whether Nextcloud access is provisioned")
|
||||
provisioned_at: Optional[str] = Field(
|
||||
None, description="ISO timestamp when provisioned"
|
||||
)
|
||||
client_id: Optional[str] = Field(
|
||||
None, description="Client ID that initiated the original Flow 1"
|
||||
)
|
||||
scopes: Optional[list[str]] = Field(None, description="Granted scopes")
|
||||
flow_type: Optional[str] = Field(
|
||||
None, description="Type of flow used ('hybrid', 'flow1', 'flow2')"
|
||||
)
|
||||
|
||||
|
||||
class ProvisioningResult(BaseModel):
|
||||
"""Result of provisioning attempt."""
|
||||
|
||||
success: bool = Field(description="Whether provisioning was initiated")
|
||||
authorization_url: Optional[str] = Field(
|
||||
None, description="URL for user to complete OAuth authorization"
|
||||
)
|
||||
message: str = Field(description="Status message for the user")
|
||||
already_provisioned: bool = Field(
|
||||
False, description="Whether access was already provisioned"
|
||||
)
|
||||
|
||||
|
||||
class RevocationResult(BaseModel):
|
||||
"""Result of access revocation."""
|
||||
|
||||
success: bool = Field(description="Whether revocation succeeded")
|
||||
message: str = Field(description="Status message for the user")
|
||||
|
||||
|
||||
async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningStatus:
|
||||
"""
|
||||
Check the provisioning status for Nextcloud access.
|
||||
|
||||
This checks whether the user has completed Flow 2 to provision
|
||||
offline access to Nextcloud resources.
|
||||
|
||||
Args:
|
||||
mcp: MCP context
|
||||
user_id: User identifier
|
||||
|
||||
Returns:
|
||||
ProvisioningStatus with current provisioning state
|
||||
"""
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
token_data = await storage.get_refresh_token(user_id)
|
||||
|
||||
if not token_data:
|
||||
return ProvisioningStatus(is_provisioned=False)
|
||||
|
||||
# Convert timestamp to ISO format if present
|
||||
provisioned_at_str = None
|
||||
if token_data.get("provisioned_at"):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
dt = datetime.fromtimestamp(token_data["provisioned_at"], tz=timezone.utc)
|
||||
provisioned_at_str = dt.isoformat()
|
||||
|
||||
return ProvisioningStatus(
|
||||
is_provisioned=True,
|
||||
provisioned_at=provisioned_at_str,
|
||||
client_id=token_data.get("provisioning_client_id"),
|
||||
scopes=token_data.get("scopes"),
|
||||
flow_type=token_data.get("flow_type", "hybrid"),
|
||||
)
|
||||
|
||||
|
||||
def generate_oauth_url_for_flow2(
|
||||
oidc_discovery_url: str,
|
||||
server_client_id: str,
|
||||
redirect_uri: str,
|
||||
state: str,
|
||||
scopes: list[str],
|
||||
) -> str:
|
||||
"""
|
||||
Generate OAuth authorization URL for Flow 2 (Resource Provisioning).
|
||||
|
||||
This creates the URL that the MCP server uses to get delegated
|
||||
access to Nextcloud on behalf of the user.
|
||||
|
||||
Args:
|
||||
oidc_discovery_url: OIDC provider discovery URL
|
||||
server_client_id: MCP server's OAuth client ID
|
||||
redirect_uri: Callback URL for the MCP server
|
||||
state: CSRF protection state
|
||||
scopes: List of scopes to request
|
||||
|
||||
Returns:
|
||||
Complete authorization URL for Flow 2
|
||||
"""
|
||||
# Extract base URL from discovery URL
|
||||
# Format: https://example.com/.well-known/openid-configuration
|
||||
# We need: https://example.com/apps/oidc/authorize
|
||||
base_url = oidc_discovery_url.replace("/.well-known/openid-configuration", "")
|
||||
auth_endpoint = f"{base_url}/apps/oidc/authorize"
|
||||
|
||||
# Build OAuth parameters
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": server_client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": " ".join(scopes),
|
||||
"state": state,
|
||||
# Request offline access for background operations
|
||||
"access_type": "offline",
|
||||
"prompt": "consent", # Force consent screen to show scopes
|
||||
}
|
||||
|
||||
return f"{auth_endpoint}?{urlencode(params)}"
|
||||
|
||||
|
||||
async def provision_nextcloud_access(
|
||||
ctx: Context, user_id: Optional[str] = None
|
||||
) -> ProvisioningResult:
|
||||
"""
|
||||
MCP Tool: Provision offline access to Nextcloud resources.
|
||||
|
||||
This tool initiates Flow 2 of the Progressive Consent architecture,
|
||||
allowing the MCP server to obtain delegated access to Nextcloud APIs.
|
||||
|
||||
The user must complete the OAuth flow in their browser to grant access.
|
||||
|
||||
Args:
|
||||
ctx: MCP context with user's Flow 1 token
|
||||
user_id: Optional user identifier (extracted from token if not provided)
|
||||
|
||||
Returns:
|
||||
ProvisioningResult with authorization URL or status
|
||||
"""
|
||||
try:
|
||||
# Extract user ID from the MCP access token (Flow 1 token)
|
||||
if not user_id:
|
||||
# Get the authorization token from context
|
||||
if hasattr(ctx, "authorization") and ctx.authorization:
|
||||
token = ctx.authorization.token
|
||||
# Decode token to get user info
|
||||
try:
|
||||
import jwt
|
||||
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
user_id = payload.get("sub", "unknown")
|
||||
logger.info(f"Extracted user_id from Flow 1 token: {user_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode token: {e}")
|
||||
user_id = "default_user"
|
||||
else:
|
||||
user_id = "default_user"
|
||||
|
||||
# Check if already provisioned
|
||||
status = await get_provisioning_status(ctx, user_id)
|
||||
if status.is_provisioned:
|
||||
return ProvisioningResult(
|
||||
success=True,
|
||||
already_provisioned=True,
|
||||
message=(
|
||||
f"Nextcloud access is already provisioned (since {status.provisioned_at}). "
|
||||
"Use 'revoke_nextcloud_access' if you want to re-provision."
|
||||
),
|
||||
)
|
||||
|
||||
# Get configuration
|
||||
enable_progressive = (
|
||||
os.getenv("ENABLE_PROGRESSIVE_CONSENT", "false").lower() == "true"
|
||||
)
|
||||
if not enable_progressive:
|
||||
return ProvisioningResult(
|
||||
success=False,
|
||||
message=(
|
||||
"Progressive Consent is not enabled. "
|
||||
"Set ENABLE_PROGRESSIVE_CONSENT=true to use this feature."
|
||||
),
|
||||
)
|
||||
|
||||
# Get MCP server's OAuth client credentials
|
||||
server_client_id = os.getenv("MCP_SERVER_CLIENT_ID")
|
||||
if not server_client_id:
|
||||
# In production, would use Dynamic Client Registration here
|
||||
return ProvisioningResult(
|
||||
success=False,
|
||||
message=(
|
||||
"MCP server OAuth client not configured. "
|
||||
"Administrator must set MCP_SERVER_CLIENT_ID."
|
||||
),
|
||||
)
|
||||
|
||||
# Generate OAuth URL for Flow 2
|
||||
oidc_discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
f"{os.getenv('NEXTCLOUD_HOST')}/.well-known/openid-configuration",
|
||||
)
|
||||
|
||||
# Generate secure state for CSRF protection
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Store state in session for validation on callback
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
# Create OAuth session for Flow 2
|
||||
session_id = f"flow2_{user_id}_{secrets.token_hex(8)}"
|
||||
redirect_uri = f"{os.getenv('NEXTCLOUD_MCP_SERVER_URL', 'http://localhost:8000')}/oauth/callback-nextcloud"
|
||||
|
||||
await storage.store_oauth_session(
|
||||
session_id=session_id,
|
||||
client_redirect_uri="", # No client redirect for Flow 2
|
||||
state=state,
|
||||
flow_type="flow2",
|
||||
is_provisioning=True,
|
||||
ttl_seconds=600, # 10 minute TTL
|
||||
)
|
||||
|
||||
# Define scopes for Nextcloud access
|
||||
scopes = [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"offline_access", # Critical for background operations
|
||||
"notes:read",
|
||||
"notes:write",
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
"contacts:read",
|
||||
"contacts:write",
|
||||
"files:read",
|
||||
"files:write",
|
||||
]
|
||||
|
||||
# Generate authorization URL
|
||||
auth_url = generate_oauth_url_for_flow2(
|
||||
oidc_discovery_url=oidc_discovery_url,
|
||||
server_client_id=server_client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
state=state,
|
||||
scopes=scopes,
|
||||
)
|
||||
|
||||
return ProvisioningResult(
|
||||
success=True,
|
||||
authorization_url=auth_url,
|
||||
message=(
|
||||
"Please visit the authorization URL to grant the MCP server "
|
||||
"offline access to your Nextcloud resources. This is a one-time "
|
||||
"setup that allows the server to access Nextcloud on your behalf "
|
||||
"even when you're not actively connected."
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initiate provisioning: {e}")
|
||||
return ProvisioningResult(
|
||||
success=False,
|
||||
message=f"Failed to initiate provisioning: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
async def revoke_nextcloud_access(
|
||||
ctx: Context, user_id: Optional[str] = None
|
||||
) -> RevocationResult:
|
||||
"""
|
||||
MCP Tool: Revoke offline access to Nextcloud resources.
|
||||
|
||||
This tool removes the stored refresh token and revokes access
|
||||
that was granted via Flow 2.
|
||||
|
||||
Args:
|
||||
mcp: MCP context
|
||||
user_id: Optional user identifier
|
||||
|
||||
Returns:
|
||||
RevocationResult with status
|
||||
"""
|
||||
try:
|
||||
# Get user ID from context if not provided
|
||||
if not user_id:
|
||||
user_id = (
|
||||
ctx.context.get("user_id", "default_user")
|
||||
if hasattr(ctx, "context")
|
||||
else "default_user"
|
||||
)
|
||||
|
||||
# Check current status
|
||||
status = await get_provisioning_status(ctx, user_id)
|
||||
if not status.is_provisioned:
|
||||
return RevocationResult(
|
||||
success=True,
|
||||
message="No Nextcloud access to revoke.",
|
||||
)
|
||||
|
||||
# Initialize Token Broker to handle revocation
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
if not encryption_key:
|
||||
return RevocationResult(
|
||||
success=False,
|
||||
message="Token encryption key not configured.",
|
||||
)
|
||||
|
||||
broker = TokenBrokerService(
|
||||
storage=storage,
|
||||
oidc_discovery_url=os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
f"{os.getenv('NEXTCLOUD_HOST')}/.well-known/openid-configuration",
|
||||
),
|
||||
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
||||
encryption_key=encryption_key,
|
||||
)
|
||||
|
||||
# Revoke access
|
||||
success = await broker.revoke_nextcloud_access(user_id)
|
||||
|
||||
if success:
|
||||
return RevocationResult(
|
||||
success=True,
|
||||
message=(
|
||||
"Successfully revoked Nextcloud access. "
|
||||
"You can run 'provision_nextcloud_access' again if needed."
|
||||
),
|
||||
)
|
||||
else:
|
||||
return RevocationResult(
|
||||
success=False,
|
||||
message="Failed to revoke access. Please try again.",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to revoke access: {e}")
|
||||
return RevocationResult(
|
||||
success=False,
|
||||
message=f"Failed to revoke access: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
async def check_provisioning_status(
|
||||
ctx: Context, user_id: Optional[str] = None
|
||||
) -> ProvisioningStatus:
|
||||
"""
|
||||
MCP Tool: Check the current provisioning status.
|
||||
|
||||
This tool allows users to check whether they have provisioned
|
||||
Nextcloud access and see details about their current authorization.
|
||||
|
||||
Args:
|
||||
mcp: MCP context
|
||||
user_id: Optional user identifier
|
||||
|
||||
Returns:
|
||||
ProvisioningStatus with current state
|
||||
"""
|
||||
# Get user ID from context if not provided
|
||||
if not user_id:
|
||||
user_id = (
|
||||
ctx.context.get("user_id", "default_user")
|
||||
if hasattr(ctx, "context")
|
||||
else "default_user"
|
||||
)
|
||||
|
||||
return await get_provisioning_status(ctx, user_id)
|
||||
|
||||
|
||||
# Register MCP tools
|
||||
def register_oauth_tools(mcp):
|
||||
"""Register OAuth and provisioning tools with the MCP server."""
|
||||
|
||||
@mcp.tool(
|
||||
name="provision_nextcloud_access",
|
||||
description=(
|
||||
"Provision offline access to Nextcloud resources. "
|
||||
"This is required before using Nextcloud tools. "
|
||||
"You'll need to complete an OAuth authorization in your browser."
|
||||
),
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def tool_provision_access(
|
||||
ctx: Context,
|
||||
user_id: Optional[str] = None,
|
||||
) -> ProvisioningResult:
|
||||
return await provision_nextcloud_access(ctx, user_id)
|
||||
|
||||
@mcp.tool(
|
||||
name="revoke_nextcloud_access",
|
||||
description="Revoke offline access to Nextcloud resources.",
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def tool_revoke_access(
|
||||
ctx: Context, user_id: Optional[str] = None
|
||||
) -> RevocationResult:
|
||||
return await revoke_nextcloud_access(ctx, user_id)
|
||||
|
||||
@mcp.tool(
|
||||
name="check_provisioning_status",
|
||||
description="Check whether Nextcloud access is provisioned.",
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def tool_check_status(
|
||||
ctx: Context, user_id: Optional[str] = None
|
||||
) -> ProvisioningStatus:
|
||||
return await check_provisioning_status(ctx, user_id)
|
||||
@@ -45,7 +45,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
JSON string with share information including share ID
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
share_data = await client.sharing.create_share(
|
||||
path=path,
|
||||
share_with=share_with,
|
||||
@@ -67,7 +67,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
JSON string confirming deletion
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.sharing.delete_share(share_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Share {share_id} deleted"}, indent=2
|
||||
@@ -87,7 +87,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
JSON string with share information
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
share_data = await client.sharing.get_share(share_id)
|
||||
return json.dumps(share_data, indent=2)
|
||||
|
||||
@@ -106,7 +106,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
JSON string with list of shares
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
shares = await client.sharing.list_shares(
|
||||
path=path, shared_with_me=shared_with_me
|
||||
)
|
||||
@@ -133,7 +133,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
JSON string with updated share information
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
share_data = await client.sharing.update_share(
|
||||
share_id=share_id, permissions=permissions
|
||||
)
|
||||
|
||||
@@ -14,14 +14,14 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
@require_scopes("tables:read")
|
||||
async def nc_tables_list_tables(ctx: Context):
|
||||
"""List all tables available to the user"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.list_tables()
|
||||
|
||||
@mcp.tool()
|
||||
@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)
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.get_table_schema(table_id)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -33,7 +33,7 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
offset: int | None = None,
|
||||
):
|
||||
"""Read rows from a table with optional pagination"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -43,7 +43,7 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
|
||||
Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42}
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.create_row(table_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -53,12 +53,12 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
|
||||
Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99}
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.update_row(row_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
@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)
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.delete_row(row_id)
|
||||
|
||||
@@ -28,7 +28,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
DirectoryListing with files, total_count, directories_count, files_count, and total_size
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
items = await client.webdav.list_directory(path)
|
||||
|
||||
# Convert to FileInfo models
|
||||
@@ -76,7 +76,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
result = await nc_webdav_read_file("Images/photo.jpg")
|
||||
logger.info(result['encoding']) # 'base64'
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
content, content_type = await client.webdav.read_file(path)
|
||||
|
||||
# Check if this is a parseable document (PDF, DOCX, etc.)
|
||||
@@ -143,7 +143,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with status_code indicating success
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Handle base64 encoded content
|
||||
if content_type and "base64" in content_type.lower():
|
||||
@@ -167,7 +167,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with status_code (201 for created, 405 if already exists)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.webdav.create_directory(path)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -181,7 +181,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with status_code indicating result (404 if not found)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.webdav.delete_resource(path)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -199,7 +199,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.webdav.move_resource(
|
||||
source_path, destination_path, overwrite
|
||||
)
|
||||
@@ -219,7 +219,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.webdav.copy_resource(
|
||||
source_path, destination_path, overwrite
|
||||
)
|
||||
@@ -249,7 +249,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
SearchFilesResponse with list of matching files
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Build where conditions based on filters
|
||||
conditions = []
|
||||
@@ -355,7 +355,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
SearchFilesResponse with list of matching files
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
results = await client.webdav.find_by_name(
|
||||
pattern=pattern, scope=scope, limit=limit
|
||||
)
|
||||
@@ -382,7 +382,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
SearchFilesResponse with list of matching files
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
results = await client.webdav.find_by_type(
|
||||
mime_type=mime_type, scope=scope, limit=limit
|
||||
)
|
||||
@@ -408,7 +408,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
SearchFilesResponse with list of favorite files
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
results = await client.webdav.list_favorites(scope=scope, limit=limit)
|
||||
file_infos = [FileInfo(**result) for result in results]
|
||||
return SearchFilesResponse(
|
||||
|
||||
+12
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.21.0"
|
||||
version = "0.24.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"}
|
||||
@@ -18,7 +18,9 @@ dependencies = [
|
||||
"pydantic>=2.11.4",
|
||||
"click>=8.1.8",
|
||||
"caldav",
|
||||
"pyjwt[crypto]>=2.8.0", # Async I/O library for better compatibility
|
||||
"pyjwt[crypto]>=2.8.0",
|
||||
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
|
||||
"authlib>=1.6.5",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
@@ -50,6 +52,7 @@ markers = [
|
||||
"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",
|
||||
@@ -65,6 +68,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"]
|
||||
|
||||
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
|
||||
+506
-1
@@ -1120,6 +1120,37 @@ async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_serv
|
||||
)
|
||||
|
||||
|
||||
async def get_mcp_server_resource_metadata(mcp_base_url: str) -> dict:
|
||||
"""
|
||||
Fetch MCP server's Protected Resource Metadata (RFC 9470).
|
||||
|
||||
This retrieves the MCP server's resource information including:
|
||||
- resource: The MCP server's client ID (used as audience for tokens)
|
||||
- authorization_servers: List of trusted OAuth servers
|
||||
- scopes_supported: Available scopes
|
||||
|
||||
Args:
|
||||
mcp_base_url: Base URL of the MCP server (e.g., "http://localhost:8001")
|
||||
WITHOUT the /mcp path component
|
||||
|
||||
Returns:
|
||||
Dict with resource metadata
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If metadata endpoint is not available
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
prm_url = f"{mcp_base_url}/.well-known/oauth-protected-resource"
|
||||
logger.debug(f"Fetching resource metadata from: {prm_url}")
|
||||
|
||||
response = await http_client.get(prm_url)
|
||||
response.raise_for_status()
|
||||
metadata = response.json()
|
||||
|
||||
logger.debug(f"Resource metadata: {metadata}")
|
||||
return metadata
|
||||
|
||||
|
||||
async def _create_oauth_client_with_scopes(
|
||||
callback_url: str,
|
||||
client_name: str,
|
||||
@@ -1514,11 +1545,24 @@ async def playwright_oauth_token(
|
||||
logger.info(f"Using shared OAuth client: {client_id[:16]}...")
|
||||
logger.info(f"Using real callback server at: {callback_url}")
|
||||
|
||||
# Fetch MCP server's resource metadata to get correct audience
|
||||
mcp_server_base_url = "http://localhost:8001"
|
||||
try:
|
||||
resource_metadata = await get_mcp_server_resource_metadata(mcp_server_base_url)
|
||||
resource_id = resource_metadata.get("resource")
|
||||
if resource_id:
|
||||
logger.info(f"MCP server resource ID (for audience): {resource_id[:16]}...")
|
||||
else:
|
||||
logger.warning("No resource ID in metadata - token may have wrong audience")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch resource metadata: {e}")
|
||||
resource_id = None
|
||||
|
||||
# Generate unique state parameter for this OAuth flow
|
||||
state = secrets.token_urlsafe(32)
|
||||
logger.debug(f"Generated state: {state[:16]}...")
|
||||
|
||||
# Construct authorization URL with state parameter
|
||||
# Construct authorization URL with state and resource parameters
|
||||
auth_url = (
|
||||
f"{authorization_endpoint}?"
|
||||
f"response_type=code&"
|
||||
@@ -1528,6 +1572,11 @@ async def playwright_oauth_token(
|
||||
f"scope=openid%20profile%20email%20notes:read%20notes:write%20calendar:read%20calendar:write%20contacts:read%20contacts:write%20cookbook:read%20cookbook:write%20deck:read%20deck:write%20tables:read%20tables:write%20files:read%20files:write%20sharing:read%20sharing:write"
|
||||
)
|
||||
|
||||
# Add resource parameter (RFC 8707) if available
|
||||
if resource_id:
|
||||
auth_url += f"&resource={quote(resource_id, safe='')}"
|
||||
logger.debug(f"Added resource parameter to auth URL: {resource_id[:16]}...")
|
||||
|
||||
# Async browser automation using pytest-playwright's browser fixture
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
@@ -1745,6 +1794,7 @@ async def _get_oauth_token_with_scopes(
|
||||
shared_oauth_client_credentials,
|
||||
oauth_callback_server,
|
||||
scopes: str,
|
||||
resource: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Helper function to obtain OAuth token with specific scopes.
|
||||
@@ -1754,6 +1804,7 @@ async def _get_oauth_token_with_scopes(
|
||||
shared_oauth_client_credentials: Tuple of OAuth client credentials
|
||||
oauth_callback_server: OAuth callback server fixture
|
||||
scopes: Space-separated list of scopes (e.g., "openid profile email notes:read")
|
||||
resource: Optional resource parameter (RFC 8707) for token audience
|
||||
|
||||
Returns:
|
||||
OAuth access token string with requested scopes
|
||||
@@ -1783,6 +1834,25 @@ async def _get_oauth_token_with_scopes(
|
||||
logger.info(f"Using shared OAuth client: {client_id[:16]}...")
|
||||
logger.info(f"Using real callback server at: {callback_url}")
|
||||
|
||||
# If no resource provided, fetch from MCP server metadata
|
||||
if resource is None:
|
||||
mcp_server_base_url = "http://localhost:8001"
|
||||
try:
|
||||
resource_metadata = await get_mcp_server_resource_metadata(
|
||||
mcp_server_base_url
|
||||
)
|
||||
resource = resource_metadata.get("resource")
|
||||
if resource:
|
||||
logger.info(
|
||||
f"MCP server resource ID (for audience): {resource[:16]}..."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"No resource ID in metadata - token may have wrong audience"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch resource metadata: {e}")
|
||||
|
||||
# Generate unique state parameter for this OAuth flow
|
||||
state = secrets.token_urlsafe(32)
|
||||
logger.debug(f"Generated state: {state[:16]}...")
|
||||
@@ -1800,6 +1870,11 @@ async def _get_oauth_token_with_scopes(
|
||||
f"scope={scopes_encoded}"
|
||||
)
|
||||
|
||||
# Add resource parameter (RFC 8707) if available
|
||||
if resource:
|
||||
auth_url += f"&resource={quote(resource, safe='')}"
|
||||
logger.debug(f"Added resource parameter to auth URL: {resource[:16]}...")
|
||||
|
||||
# Async browser automation using pytest-playwright's browser fixture
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
@@ -2456,3 +2531,433 @@ async def test_user_in_group(nc_client: NextcloudClient, test_user, test_group):
|
||||
logger.debug(f"Added user {user_config['userid']} to group {groupid}")
|
||||
|
||||
yield (user_config, groupid)
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# Keycloak External IdP OAuth Fixtures
|
||||
# ===========================================================================================
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def keycloak_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
||||
"""
|
||||
Fixture to obtain Keycloak OAuth client credentials for external IdP testing.
|
||||
|
||||
Uses pre-configured client from keycloak/realm-export.json (no DCR needed).
|
||||
The client (nextcloud-mcp-server) is already configured with:
|
||||
- serviceAccountsEnabled=true
|
||||
- token.exchange.grant.enabled=true
|
||||
- client.token.exchange.standard.enabled=true
|
||||
|
||||
Returns:
|
||||
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
|
||||
"""
|
||||
# Get Keycloak configuration from environment
|
||||
keycloak_discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
"http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
|
||||
)
|
||||
client_id = os.getenv("OIDC_CLIENT_ID", "nextcloud-mcp-server")
|
||||
client_secret = os.getenv("OIDC_CLIENT_SECRET", "mcp-secret-change-in-production")
|
||||
|
||||
if not all([keycloak_discovery_url, client_id, client_secret]):
|
||||
pytest.skip(
|
||||
"Keycloak OAuth requires OIDC_DISCOVERY_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET"
|
||||
)
|
||||
|
||||
# Get callback URL from the real callback server
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
logger.info("Setting up Keycloak external IdP OAuth client credentials...")
|
||||
logger.info(f"Using Keycloak discovery URL: {keycloak_discovery_url}")
|
||||
logger.info(f"Using static client credentials: {client_id}")
|
||||
logger.info(f"Using real callback server at: {callback_url}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
# OIDC Discovery
|
||||
discovery_response = await http_client.get(keycloak_discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
token_endpoint = oidc_config.get("token_endpoint")
|
||||
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||
|
||||
if not token_endpoint or not authorization_endpoint:
|
||||
raise ValueError(
|
||||
"Keycloak OIDC discovery missing required endpoints (token_endpoint or authorization_endpoint)"
|
||||
)
|
||||
|
||||
logger.info(f"✓ Discovered token endpoint: {token_endpoint}")
|
||||
logger.info(f"✓ Discovered authorization endpoint: {authorization_endpoint}")
|
||||
|
||||
yield (
|
||||
client_id,
|
||||
client_secret,
|
||||
callback_url,
|
||||
token_endpoint,
|
||||
authorization_endpoint,
|
||||
)
|
||||
|
||||
# No cleanup needed - client is pre-configured in realm export
|
||||
|
||||
|
||||
async def _get_keycloak_oauth_token(
|
||||
browser,
|
||||
keycloak_oauth_client_credentials,
|
||||
oauth_callback_server,
|
||||
scopes: str,
|
||||
username: str = "admin",
|
||||
password: str = "admin",
|
||||
) -> str:
|
||||
"""
|
||||
Helper function to obtain OAuth token from Keycloak using Playwright.
|
||||
|
||||
Args:
|
||||
browser: Playwright browser instance
|
||||
keycloak_oauth_client_credentials: Tuple of Keycloak OAuth client credentials
|
||||
oauth_callback_server: OAuth callback server fixture
|
||||
scopes: Space-separated list of scopes
|
||||
username: Keycloak username (default: admin)
|
||||
password: Keycloak password (default: admin)
|
||||
|
||||
Returns:
|
||||
OAuth access token string from Keycloak
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
# Get auth_states dict from callback server
|
||||
auth_states, _ = oauth_callback_server
|
||||
|
||||
# Unpack Keycloak client credentials
|
||||
client_id, client_secret, callback_url, token_endpoint, authorization_endpoint = (
|
||||
keycloak_oauth_client_credentials
|
||||
)
|
||||
|
||||
logger.info(f"Starting Playwright-based Keycloak OAuth flow with scopes: {scopes}")
|
||||
logger.info(f"Using Keycloak client: {client_id}")
|
||||
logger.info(f"Using real callback server at: {callback_url}")
|
||||
logger.info(f"Authenticating as Keycloak user: {username}")
|
||||
|
||||
# Generate unique state parameter for this OAuth flow
|
||||
state = secrets.token_urlsafe(32)
|
||||
logger.debug(f"Generated state: {state[:16]}...")
|
||||
|
||||
# Generate PKCE parameters (required by Keycloak client configuration)
|
||||
code_verifier = secrets.token_urlsafe(64) # 86 chars base64url
|
||||
code_challenge = (
|
||||
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
logger.debug(f"Generated PKCE code_challenge: {code_challenge[:20]}...")
|
||||
|
||||
# URL-encode scopes
|
||||
scopes_encoded = quote(scopes, safe="")
|
||||
|
||||
# Construct authorization URL with state, scopes, and PKCE parameters
|
||||
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}&"
|
||||
f"code_challenge={code_challenge}&"
|
||||
f"code_challenge_method=S256"
|
||||
)
|
||||
|
||||
logger.info(f"Authorization URL: {auth_url[:100]}...")
|
||||
|
||||
# Create browser context and page
|
||||
context = await browser.new_context()
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
# Navigate to Keycloak authorization endpoint
|
||||
logger.info("Navigating to Keycloak authorization endpoint...")
|
||||
await page.goto(auth_url, wait_until="networkidle", timeout=30000)
|
||||
|
||||
# Handle Keycloak login page
|
||||
# Keycloak uses input#username and input#password (different from Nextcloud)
|
||||
logger.info(f"Filling Keycloak login credentials for {username}...")
|
||||
await page.wait_for_selector("input#username", timeout=10000)
|
||||
await page.fill("input#username", username)
|
||||
await page.fill("input#password", password)
|
||||
|
||||
logger.info("Submitting Keycloak login form...")
|
||||
# Submit the form and wait for navigation
|
||||
# Use JavaScript to submit the form directly (more reliable than clicking button)
|
||||
async with page.expect_navigation(timeout=30000):
|
||||
await page.evaluate("document.querySelector('form').submit()")
|
||||
|
||||
logger.info(f"Keycloak login submitted for {username}, redirected to callback")
|
||||
|
||||
# Check if we need to handle consent screen
|
||||
# Keycloak consent screen has "Yes" button
|
||||
consent_button = page.locator('input[name="accept"][value="Yes"]')
|
||||
if await consent_button.count() > 0:
|
||||
logger.info("Keycloak consent screen detected, clicking Yes...")
|
||||
await consent_button.click()
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
logger.info("Keycloak consent granted")
|
||||
|
||||
# Wait for callback server to receive auth code with timeout
|
||||
logger.info(f"Waiting for auth code with state: {state[:16]}...")
|
||||
timeout = 30 # seconds
|
||||
start_time = time.time()
|
||||
auth_code = None
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
if state in auth_states:
|
||||
auth_code = auth_states[state]
|
||||
logger.info("Auth code received from callback server")
|
||||
break
|
||||
await anyio.sleep(0.1)
|
||||
else:
|
||||
raise TimeoutError(
|
||||
f"Auth code not received within {timeout}s. State: {state[:16]}..."
|
||||
)
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
# Exchange authorization code for access token (with PKCE code_verifier)
|
||||
logger.info("Exchanging authorization code for access token with PKCE...")
|
||||
async with httpx.AsyncClient(timeout=30.0) as token_client:
|
||||
token_response = await token_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"redirect_uri": callback_url,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code_verifier": code_verifier, # PKCE verifier
|
||||
},
|
||||
)
|
||||
|
||||
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(
|
||||
f"Successfully obtained Keycloak OAuth access token with scopes: {scopes}"
|
||||
)
|
||||
return access_token
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def keycloak_oauth_token(
|
||||
anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server
|
||||
) -> str:
|
||||
"""
|
||||
Fixture to obtain an OAuth access token from Keycloak using Playwright automation.
|
||||
|
||||
This fixture tests the external IdP flow where:
|
||||
1. User authenticates with Keycloak (external IdP)
|
||||
2. Keycloak issues an access token with Nextcloud custom scopes
|
||||
3. Token is used to access Nextcloud APIs via user_oidc app validation
|
||||
|
||||
The Nextcloud custom scopes (notes:read, calendar:write, etc.) are now defined
|
||||
in Keycloak's realm configuration and can be requested in the OAuth flow.
|
||||
|
||||
Returns:
|
||||
OAuth access token from Keycloak for the admin user with full scopes
|
||||
"""
|
||||
# Standard OIDC scopes + Nextcloud custom scopes (now defined in Keycloak realm)
|
||||
default_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"
|
||||
|
||||
return await _get_keycloak_oauth_token(
|
||||
browser,
|
||||
keycloak_oauth_client_credentials,
|
||||
oauth_callback_server,
|
||||
scopes=default_scopes,
|
||||
username="admin",
|
||||
password="admin",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def keycloak_oauth_token_read_only(
|
||||
anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server
|
||||
) -> str:
|
||||
"""
|
||||
Fixture to obtain a Keycloak OAuth token with only read scopes.
|
||||
|
||||
This token will only be able to perform read operations and should
|
||||
have write tools filtered out from the tool list.
|
||||
|
||||
Returns:
|
||||
OAuth access token from Keycloak for test_read_only user with read-only scopes
|
||||
"""
|
||||
return await _get_keycloak_oauth_token(
|
||||
browser,
|
||||
keycloak_oauth_client_credentials,
|
||||
oauth_callback_server,
|
||||
scopes=DEFAULT_READ_SCOPES,
|
||||
username="test_read_only",
|
||||
password="test123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def keycloak_oauth_token_write_only(
|
||||
anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server
|
||||
) -> str:
|
||||
"""
|
||||
Fixture to obtain a Keycloak OAuth token with only write scopes.
|
||||
|
||||
This token will only be able to perform write operations and should
|
||||
have read tools filtered out from the tool list.
|
||||
|
||||
Returns:
|
||||
OAuth access token from Keycloak for test_write_only user with write-only scopes
|
||||
"""
|
||||
return await _get_keycloak_oauth_token(
|
||||
browser,
|
||||
keycloak_oauth_client_credentials,
|
||||
oauth_callback_server,
|
||||
scopes=DEFAULT_WRITE_SCOPES,
|
||||
username="test_write_only",
|
||||
password="test123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def keycloak_oauth_token_no_custom_scopes(
|
||||
anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server
|
||||
) -> str:
|
||||
"""
|
||||
Fixture to obtain a Keycloak OAuth token with NO custom scopes.
|
||||
|
||||
Tests the security behavior when a user grants only default OIDC scopes
|
||||
(openid, profile, email) but declines application-specific scopes.
|
||||
|
||||
Expected behavior: Should see 0 tools (all tools require custom scopes).
|
||||
|
||||
Returns:
|
||||
OAuth access token from Keycloak for test_no_scopes user with no custom scopes
|
||||
"""
|
||||
return await _get_keycloak_oauth_token(
|
||||
browser,
|
||||
keycloak_oauth_client_credentials,
|
||||
oauth_callback_server,
|
||||
scopes="openid profile email", # No custom scopes
|
||||
username="test_no_scopes",
|
||||
password="test123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_keycloak_client(
|
||||
anyio_backend, keycloak_oauth_token
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Session-scoped fixture providing an MCP client session authenticated with Keycloak tokens.
|
||||
|
||||
This MCP client connects to the mcp-keycloak service (port 8002) which is configured
|
||||
to use Keycloak as an external identity provider. The token flow is:
|
||||
|
||||
1. Keycloak issues OAuth token (via keycloak_oauth_token fixture)
|
||||
2. MCP client uses token to authenticate with MCP server
|
||||
3. MCP server validates token via Nextcloud user_oidc app
|
||||
4. MCP server uses validated token to access Nextcloud APIs
|
||||
|
||||
This tests ADR-002 external IdP integration.
|
||||
|
||||
Yields:
|
||||
MCP client session for testing tools/resources with Keycloak auth
|
||||
"""
|
||||
mcp_url = "http://localhost:8002/mcp"
|
||||
logger.info(f"Creating MCP client session for Keycloak external IdP at {mcp_url}")
|
||||
logger.info("Using Keycloak OAuth token for authentication")
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url=mcp_url, token=keycloak_oauth_token, client_name="Keycloak External IdP MCP"
|
||||
):
|
||||
logger.info("✓ MCP client session established with Keycloak authentication")
|
||||
yield session
|
||||
logger.info("✓ MCP client session closed")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_keycloak_client_read_only(
|
||||
anyio_backend, keycloak_oauth_token_read_only
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
MCP client session authenticated with Keycloak read-only token.
|
||||
|
||||
This client should only see read tools and should get filtered
|
||||
write tools based on token scopes.
|
||||
|
||||
Uses JWT tokens because they embed scope information in claims,
|
||||
enabling proper scope-based tool filtering.
|
||||
"""
|
||||
mcp_url = "http://localhost:8002/mcp"
|
||||
logger.info(f"Creating read-only MCP client session for Keycloak at {mcp_url}")
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url=mcp_url,
|
||||
token=keycloak_oauth_token_read_only,
|
||||
client_name="Keycloak Read-Only MCP",
|
||||
):
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_keycloak_client_write_only(
|
||||
anyio_backend, keycloak_oauth_token_write_only
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
MCP client session authenticated with Keycloak write-only token.
|
||||
|
||||
This client should only see write tools and should get filtered
|
||||
read tools based on token scopes.
|
||||
|
||||
Uses JWT tokens because they embed scope information in claims,
|
||||
enabling proper scope-based tool filtering.
|
||||
"""
|
||||
mcp_url = "http://localhost:8002/mcp"
|
||||
logger.info(f"Creating write-only MCP client session for Keycloak at {mcp_url}")
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url=mcp_url,
|
||||
token=keycloak_oauth_token_write_only,
|
||||
client_name="Keycloak Write-Only MCP",
|
||||
):
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_keycloak_client_no_custom_scopes(
|
||||
anyio_backend, keycloak_oauth_token_no_custom_scopes
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
MCP client session authenticated with Keycloak token without custom scopes.
|
||||
|
||||
This client has only OIDC default scopes (openid, profile, email) without
|
||||
application-specific scopes (notes:read, notes:write, etc.).
|
||||
|
||||
Expected behavior: Should see 0 tools (all tools require custom scopes).
|
||||
|
||||
Uses JWT tokens because they embed scope information in claims,
|
||||
enabling proper scope-based tool filtering.
|
||||
"""
|
||||
mcp_url = "http://localhost:8002/mcp"
|
||||
logger.info(
|
||||
f"Creating no-custom-scopes MCP client session for Keycloak at {mcp_url}"
|
||||
)
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url=mcp_url,
|
||||
token=keycloak_oauth_token_no_custom_scopes,
|
||||
client_name="Keycloak No Custom Scopes MCP",
|
||||
):
|
||||
yield session
|
||||
|
||||
@@ -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,380 @@
|
||||
"""Integration tests for RFC 8693 Token Exchange with Keycloak.
|
||||
|
||||
These tests validate the complete token exchange flow:
|
||||
1. Obtain client token from Keycloak
|
||||
2. Exchange for Nextcloud-audience token via RFC 8693
|
||||
3. Use exchanged token to access Nextcloud APIs
|
||||
4. Verify CRUD operations work with exchanged tokens
|
||||
|
||||
Requirements:
|
||||
- Keycloak running with nextcloud-mcp realm configured
|
||||
- Nextcloud running with user_oidc app configured
|
||||
- Standard Token Exchange enabled on both clients
|
||||
- token-exchange-nextcloud scope configured
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def keycloak_base_url() -> str:
|
||||
"""Keycloak base URL (external)."""
|
||||
return "http://localhost:8888"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def keycloak_token_url(keycloak_base_url: str) -> str:
|
||||
"""Keycloak token endpoint URL."""
|
||||
return f"{keycloak_base_url}/realms/nextcloud-mcp/protocol/openid-connect/token"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def nextcloud_base_url() -> str:
|
||||
"""Nextcloud base URL."""
|
||||
return "http://localhost:8080"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def http_client() -> httpx.AsyncClient:
|
||||
"""Async HTTP client for API requests."""
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def keycloak_client_token(
|
||||
http_client: httpx.AsyncClient, keycloak_token_url: str
|
||||
) -> str:
|
||||
"""Get client token from Keycloak using password grant.
|
||||
|
||||
Returns token with aud: ["nextcloud-mcp-server", "nextcloud"]
|
||||
"""
|
||||
response = await http_client.post(
|
||||
keycloak_token_url,
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"client_id": "nextcloud-mcp-server",
|
||||
"client_secret": "mcp-secret-change-in-production",
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"scope": "openid profile email offline_access notes:read notes:write",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
return token_data["access_token"]
|
||||
|
||||
|
||||
async def exchange_token(
|
||||
http_client: httpx.AsyncClient,
|
||||
token_url: str,
|
||||
subject_token: str,
|
||||
audience: str = "nextcloud",
|
||||
) -> dict[str, Any]:
|
||||
"""Exchange token using RFC 8693.
|
||||
|
||||
Args:
|
||||
http_client: HTTP client
|
||||
token_url: Token endpoint URL
|
||||
subject_token: Token to exchange
|
||||
audience: Target audience
|
||||
|
||||
Returns:
|
||||
Token response with access_token and expires_in
|
||||
"""
|
||||
response = await http_client.post(
|
||||
token_url,
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
"client_id": "nextcloud-mcp-server",
|
||||
"client_secret": "mcp-secret-change-in-production",
|
||||
"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",
|
||||
"audience": audience,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def decode_token_claims(token: str) -> dict[str, Any]:
|
||||
"""Decode JWT token claims without verification.
|
||||
|
||||
Args:
|
||||
token: JWT token
|
||||
|
||||
Returns:
|
||||
Token claims
|
||||
"""
|
||||
return jwt.decode(token, options={"verify_signature": False})
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.keycloak
|
||||
class TestKeycloakTokenExchange:
|
||||
"""Test RFC 8693 Token Exchange with Keycloak."""
|
||||
|
||||
async def test_token_exchange_basic(
|
||||
self,
|
||||
http_client: httpx.AsyncClient,
|
||||
keycloak_token_url: str,
|
||||
keycloak_client_token: str,
|
||||
):
|
||||
"""Test basic token exchange flow."""
|
||||
# Verify initial token has both audiences
|
||||
initial_claims = decode_token_claims(keycloak_client_token)
|
||||
assert "nextcloud-mcp-server" in initial_claims["aud"]
|
||||
assert "nextcloud" in initial_claims["aud"]
|
||||
assert initial_claims["azp"] == "nextcloud-mcp-server"
|
||||
|
||||
# Exchange for Nextcloud-audience token
|
||||
exchange_response = await exchange_token(
|
||||
http_client, keycloak_token_url, keycloak_client_token
|
||||
)
|
||||
|
||||
assert "access_token" in exchange_response
|
||||
assert "expires_in" in exchange_response
|
||||
assert exchange_response["expires_in"] > 0
|
||||
|
||||
# Verify exchanged token has correct audience
|
||||
exchanged_token = exchange_response["access_token"]
|
||||
exchanged_claims = decode_token_claims(exchanged_token)
|
||||
|
||||
assert exchanged_claims["aud"] == "nextcloud"
|
||||
assert exchanged_claims["azp"] == "nextcloud-mcp-server"
|
||||
assert exchanged_claims["sub"] == initial_claims["sub"]
|
||||
|
||||
async def test_token_exchange_with_nextcloud_api(
|
||||
self,
|
||||
http_client: httpx.AsyncClient,
|
||||
keycloak_token_url: str,
|
||||
keycloak_client_token: str,
|
||||
nextcloud_base_url: str,
|
||||
):
|
||||
"""Test exchanged token works with Nextcloud APIs."""
|
||||
# Exchange token
|
||||
exchange_response = await exchange_token(
|
||||
http_client, keycloak_token_url, keycloak_client_token
|
||||
)
|
||||
nextcloud_token = exchange_response["access_token"]
|
||||
|
||||
# Call Nextcloud Capabilities API
|
||||
response = await http_client.get(
|
||||
f"{nextcloud_base_url}/ocs/v1.php/cloud/capabilities",
|
||||
headers={
|
||||
"Authorization": f"Bearer {nextcloud_token}",
|
||||
"OCS-APIRequest": "true",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Verify response contains OCS data
|
||||
assert "ocs" in response.text.lower()
|
||||
|
||||
async def test_token_exchange_multiple_times(
|
||||
self,
|
||||
http_client: httpx.AsyncClient,
|
||||
keycloak_token_url: str,
|
||||
keycloak_client_token: str,
|
||||
):
|
||||
"""Test multiple exchanges from same client token (stateless)."""
|
||||
# Exchange token three times
|
||||
tokens = []
|
||||
for _ in range(3):
|
||||
exchange_response = await exchange_token(
|
||||
http_client, keycloak_token_url, keycloak_client_token
|
||||
)
|
||||
tokens.append(exchange_response["access_token"])
|
||||
|
||||
# All exchanges should succeed
|
||||
assert len(tokens) == 3
|
||||
|
||||
# Tokens should be different (fresh ephemeral tokens)
|
||||
# Note: Keycloak may cache, so tokens might be identical
|
||||
# The important thing is that all exchanges succeeded
|
||||
|
||||
async def test_token_exchange_crud_operations(
|
||||
self,
|
||||
http_client: httpx.AsyncClient,
|
||||
keycloak_token_url: str,
|
||||
keycloak_client_token: str,
|
||||
nextcloud_base_url: str,
|
||||
):
|
||||
"""Test CRUD operations with exchanged tokens."""
|
||||
notes_api = f"{nextcloud_base_url}/index.php/apps/notes/api/v1/notes"
|
||||
|
||||
# Step 1: Exchange token for CREATE
|
||||
exchange_response = await exchange_token(
|
||||
http_client, keycloak_token_url, keycloak_client_token
|
||||
)
|
||||
create_token = exchange_response["access_token"]
|
||||
|
||||
# Step 2: Create a test note
|
||||
create_response = await http_client.post(
|
||||
notes_api,
|
||||
headers={"Authorization": f"Bearer {create_token}"},
|
||||
json={
|
||||
"title": "Token Exchange Test",
|
||||
"content": "This note was created using an RFC 8693 exchanged token!",
|
||||
"category": "Test",
|
||||
},
|
||||
)
|
||||
create_response.raise_for_status()
|
||||
note_data = create_response.json()
|
||||
note_id = note_data["id"]
|
||||
|
||||
assert note_data["title"] == "Token Exchange Test"
|
||||
assert note_data["category"] == "Test"
|
||||
|
||||
# Step 3: Exchange token again for READ (simulate new request)
|
||||
exchange_response = await exchange_token(
|
||||
http_client, keycloak_token_url, keycloak_client_token
|
||||
)
|
||||
read_token = exchange_response["access_token"]
|
||||
|
||||
# Step 4: Read the note back
|
||||
read_response = await http_client.get(
|
||||
f"{notes_api}/{note_id}",
|
||||
headers={"Authorization": f"Bearer {read_token}"},
|
||||
)
|
||||
read_response.raise_for_status()
|
||||
read_data = read_response.json()
|
||||
|
||||
assert read_data["id"] == note_id
|
||||
assert read_data["title"] == "Token Exchange Test"
|
||||
assert "RFC 8693 exchanged token" in read_data["content"]
|
||||
|
||||
# Step 5: Exchange token again for DELETE
|
||||
exchange_response = await exchange_token(
|
||||
http_client, keycloak_token_url, keycloak_client_token
|
||||
)
|
||||
delete_token = exchange_response["access_token"]
|
||||
|
||||
# Step 6: Delete the note
|
||||
delete_response = await http_client.delete(
|
||||
f"{notes_api}/{note_id}",
|
||||
headers={"Authorization": f"Bearer {delete_token}"},
|
||||
)
|
||||
# Notes API returns the deleted note or empty array
|
||||
assert delete_response.status_code in (200, 204)
|
||||
|
||||
async def test_token_claims_preservation(
|
||||
self,
|
||||
http_client: httpx.AsyncClient,
|
||||
keycloak_token_url: str,
|
||||
keycloak_client_token: str,
|
||||
):
|
||||
"""Test that important claims are preserved during exchange."""
|
||||
initial_claims = decode_token_claims(keycloak_client_token)
|
||||
|
||||
# Exchange token
|
||||
exchange_response = await exchange_token(
|
||||
http_client, keycloak_token_url, keycloak_client_token
|
||||
)
|
||||
exchanged_token = exchange_response["access_token"]
|
||||
exchanged_claims = decode_token_claims(exchanged_token)
|
||||
|
||||
# Subject (user ID) should be preserved
|
||||
assert exchanged_claims["sub"] == initial_claims["sub"]
|
||||
|
||||
# Authorized party should show delegation
|
||||
assert exchanged_claims["azp"] == "nextcloud-mcp-server"
|
||||
|
||||
# Audience should be filtered to target
|
||||
assert exchanged_claims["aud"] == "nextcloud"
|
||||
|
||||
# Token should have expiration
|
||||
assert "exp" in exchanged_claims
|
||||
assert exchanged_claims["exp"] > 0
|
||||
|
||||
async def test_token_exchange_scope_configuration(
|
||||
self, http_client: httpx.AsyncClient, keycloak_token_url: str
|
||||
):
|
||||
"""Test that token-exchange-nextcloud scope is configured as default.
|
||||
|
||||
Since token-exchange-nextcloud is a default scope for nextcloud-mcp-server,
|
||||
all tokens should have the nextcloud audience available for exchange.
|
||||
"""
|
||||
# Get a token - should automatically include default scopes
|
||||
response = await http_client.post(
|
||||
keycloak_token_url,
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"client_id": "nextcloud-mcp-server",
|
||||
"client_secret": "mcp-secret-change-in-production",
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"scope": "openid profile email",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
token = response.json()["access_token"]
|
||||
|
||||
# Verify token has nextcloud in aud (from default token-exchange-nextcloud scope)
|
||||
claims = decode_token_claims(token)
|
||||
assert "nextcloud" in claims.get("aud", [])
|
||||
|
||||
# Exchange should succeed
|
||||
exchange_response = await http_client.post(
|
||||
keycloak_token_url,
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
"client_id": "nextcloud-mcp-server",
|
||||
"client_secret": "mcp-secret-change-in-production",
|
||||
"subject_token": token,
|
||||
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||
"audience": "nextcloud",
|
||||
},
|
||||
)
|
||||
|
||||
# Should succeed because token-exchange-nextcloud is a default scope
|
||||
assert exchange_response.status_code == 200
|
||||
exchanged_data = exchange_response.json()
|
||||
assert "access_token" in exchanged_data
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.keycloak
|
||||
class TestTokenExchangeService:
|
||||
"""Test the TokenExchangeService implementation."""
|
||||
|
||||
async def test_exchange_token_for_audience(
|
||||
self, keycloak_client_token: str, keycloak_token_url: str
|
||||
):
|
||||
"""Test the exchange_token_for_audience function."""
|
||||
from nextcloud_mcp_server.auth.token_exchange import (
|
||||
TokenExchangeService,
|
||||
)
|
||||
|
||||
# Create service
|
||||
service = TokenExchangeService(
|
||||
oidc_discovery_url="http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
|
||||
client_id="nextcloud-mcp-server",
|
||||
client_secret="mcp-secret-change-in-production",
|
||||
)
|
||||
|
||||
try:
|
||||
# Exchange token
|
||||
exchanged_token, expires_in = await service.exchange_token_for_audience(
|
||||
subject_token=keycloak_client_token,
|
||||
requested_audience="nextcloud",
|
||||
)
|
||||
|
||||
# Verify exchange succeeded
|
||||
assert exchanged_token is not None
|
||||
assert isinstance(exchanged_token, str)
|
||||
assert expires_in > 0
|
||||
|
||||
# Verify token has correct claims
|
||||
claims = decode_token_claims(exchanged_token)
|
||||
assert claims["aud"] == "nextcloud"
|
||||
assert claims["azp"] == "nextcloud-mcp-server"
|
||||
|
||||
finally:
|
||||
await service.close()
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Manual OAuth Flow Testing
|
||||
|
||||
This directory contains manual test scripts for OAuth flows that require browser interaction.
|
||||
|
||||
## ADR-004 OAuth Hybrid Flow Test
|
||||
|
||||
The `test_adr004_oauth_flow.py` script tests the complete OAuth flow described in ADR-004.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Install Playwright browsers:**
|
||||
```bash
|
||||
uv run playwright install firefox
|
||||
```
|
||||
|
||||
2. **Start MCP server with OAuth enabled:**
|
||||
|
||||
For Nextcloud OIDC:
|
||||
```bash
|
||||
export ENABLE_OFFLINE_ACCESS=true
|
||||
export TOKEN_ENCRYPTION_KEY=$(uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
|
||||
docker-compose up --build -d mcp-oauth
|
||||
```
|
||||
|
||||
For Keycloak:
|
||||
```bash
|
||||
export ENABLE_OFFLINE_ACCESS=true
|
||||
export TOKEN_ENCRYPTION_KEY=$(uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
|
||||
docker-compose up --build -d mcp-keycloak
|
||||
```
|
||||
|
||||
### Running the Test
|
||||
|
||||
**Test with Nextcloud OIDC:**
|
||||
```bash
|
||||
uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud
|
||||
```
|
||||
|
||||
**Test with Keycloak:**
|
||||
```bash
|
||||
uv run python tests/manual/test_adr004_oauth_flow.py --provider keycloak
|
||||
```
|
||||
|
||||
**Headless mode:**
|
||||
```bash
|
||||
uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud --headless
|
||||
```
|
||||
@@ -0,0 +1,203 @@
|
||||
# ADR-004 OAuth Flow Testing Instructions
|
||||
|
||||
## Automated Integration Test (Recommended)
|
||||
|
||||
The ADR-004 Hybrid Flow is now fully tested via automated integration tests using Playwright:
|
||||
|
||||
```bash
|
||||
# Run all ADR-004 tests
|
||||
uv run pytest tests/server/oauth/test_adr004_hybrid_flow.py --browser firefox -v
|
||||
|
||||
# Run specific test
|
||||
uv run pytest tests/server/oauth/test_adr004_hybrid_flow.py::test_adr004_hybrid_flow_tool_execution --browser firefox -v
|
||||
```
|
||||
|
||||
These tests verify:
|
||||
- ✅ PKCE code challenge/verifier flow
|
||||
- ✅ MCP server intercepts OAuth callback
|
||||
- ✅ Master refresh token storage
|
||||
- ✅ Client receives MCP access token
|
||||
- ✅ MCP session establishment with hybrid flow token
|
||||
- ✅ Tool execution using stored refresh tokens
|
||||
- ✅ Multiple operations without re-authentication
|
||||
|
||||
## Manual Test (Legacy)
|
||||
|
||||
For manual testing or debugging, you can use the standalone test script:
|
||||
|
||||
```bash
|
||||
# Make sure port 8765 is available
|
||||
lsof -ti:8765 | xargs kill -9 2>/dev/null
|
||||
|
||||
# Run the test
|
||||
uv run python tests/manual/test_adr004_manual.py --provider nextcloud
|
||||
```
|
||||
|
||||
## Expected Flow
|
||||
|
||||
### 1. Test Script Starts
|
||||
```
|
||||
======================================================================
|
||||
ADR-004 MANUAL OAUTH FLOW TEST
|
||||
======================================================================
|
||||
Provider: nextcloud
|
||||
MCP Server: http://localhost:8001
|
||||
Nextcloud: http://localhost:8080
|
||||
======================================================================
|
||||
|
||||
✓ Generated PKCE challenge: gxQLsYDJ...
|
||||
✓ Started callback server at http://localhost:8765/callback
|
||||
```
|
||||
|
||||
### 2. Open OAuth URL in Browser
|
||||
The script will print:
|
||||
```
|
||||
======================================================================
|
||||
STEP 1: AUTHORIZE THE MCP SERVER
|
||||
======================================================================
|
||||
|
||||
📋 Open this URL in your browser:
|
||||
|
||||
http://localhost:8001/oauth/authorize?response_type=code&...
|
||||
|
||||
📌 What will happen:
|
||||
1. You'll be redirected to Nextcloud/Keycloak login
|
||||
2. Login with username: admin, password: admin
|
||||
3. You'll see a consent screen asking to authorize the MCP server
|
||||
4. Click 'Authorize' or 'Allow'
|
||||
5. You'll be redirected to localhost:8765/callback
|
||||
6. The authorization code will appear in the terminal
|
||||
```
|
||||
|
||||
### 3. Browser Flow
|
||||
1. **Nextcloud Login** - You see the Nextcloud login page
|
||||
2. **Enter Credentials** - admin/admin
|
||||
3. **Consent Screen** - "Authorize Nextcloud MCP Server (jwt) to access your account?"
|
||||
4. **Click Authorize**
|
||||
5. **Redirect Chain**:
|
||||
- Nextcloud redirects to: `http://localhost:8001/oauth/callback?code=...`
|
||||
- MCP server processes the code
|
||||
- MCP server redirects to: `http://localhost:8765/callback?code=mcp-code-...&state=...`
|
||||
- Browser reaches the test script's callback server
|
||||
- You see: "✓ Authorization Successful - You can close this window"
|
||||
|
||||
### 4. Test Script Continues
|
||||
```
|
||||
✓ Received authorization code!
|
||||
Code: mcp-code-xyz...
|
||||
✓ State parameter verified (CSRF protection)
|
||||
|
||||
======================================================================
|
||||
STEP 2: EXCHANGE CODE FOR ACCESS TOKEN
|
||||
======================================================================
|
||||
|
||||
✓ Successfully received access token
|
||||
Token: eyJhbGciOiJSUzI1Ni...
|
||||
Type: Bearer
|
||||
Expires: 3600s
|
||||
|
||||
======================================================================
|
||||
STEP 3: CALL MCP TOOL WITH ACCESS TOKEN
|
||||
======================================================================
|
||||
|
||||
✓ MCP tool call succeeded!
|
||||
Result: {...}
|
||||
|
||||
======================================================================
|
||||
🎉 ADR-004 OAUTH FLOW TEST - SUCCESS
|
||||
======================================================================
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Browser Gets Stuck at "localhost:8765 refused to connect"
|
||||
|
||||
**Problem**: The callback server on port 8765 isn't accessible.
|
||||
|
||||
**Solutions**:
|
||||
1. Check firewall isn't blocking port 8765
|
||||
2. Verify the test script is still running
|
||||
3. Check another process isn't using port 8765:
|
||||
```bash
|
||||
lsof -ti:8765
|
||||
```
|
||||
|
||||
### Browser Shows "localhost:8765 - ERR_CONNECTION_REFUSED"
|
||||
|
||||
**Problem**: The callback server stopped or never started.
|
||||
|
||||
**Solution**:
|
||||
1. Check the test script output - it should say "✓ Started callback server"
|
||||
2. Restart the test script
|
||||
3. Manually test the callback server:
|
||||
```bash
|
||||
curl http://localhost:8765/callback?code=test&state=test
|
||||
```
|
||||
Should return HTML page with "Authorization Successful"
|
||||
|
||||
### "Session not found or expired" Error
|
||||
|
||||
**Problem**: Took too long between steps (>10 minutes).
|
||||
|
||||
**Solution**: Restart the test - sessions expire after 10 minutes.
|
||||
|
||||
### Client ID is None
|
||||
|
||||
**Problem**: OAuth client credentials not loaded.
|
||||
|
||||
**Solution**: Rebuild the MCP server:
|
||||
```bash
|
||||
docker-compose up --build -d mcp-oauth
|
||||
```
|
||||
|
||||
### Nextcloud Shows "Invalid redirect_uri"
|
||||
|
||||
**Problem**: The redirect URI isn't registered for the OAuth client.
|
||||
|
||||
**Solution**: Check registered URIs:
|
||||
```bash
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud -e \
|
||||
"SELECT c.client_identifier, r.redirect_uri FROM oc_oidc_clients c \
|
||||
LEFT JOIN oc_oidc_redirect_uris r ON c.id = r.client_id \
|
||||
WHERE c.name LIKE '%MCP%';"
|
||||
```
|
||||
|
||||
Should show: `http://localhost:8001/oauth/callback`
|
||||
|
||||
## Manual Test Without Script
|
||||
|
||||
If the automated test doesn't work, you can test manually:
|
||||
|
||||
1. **Start callback server manually**:
|
||||
```bash
|
||||
python3 -m http.server 8765
|
||||
```
|
||||
|
||||
2. **Open OAuth URL in browser** (get from test script output or build manually):
|
||||
```
|
||||
http://localhost:8001/oauth/authorize?response_type=code&client_id=test-mcp-client&redirect_uri=http://localhost:8765/callback&scope=openid+profile+email+offline_access&state=TEST&code_challenge=CHALLENGE&code_challenge_method=S256
|
||||
```
|
||||
|
||||
3. **Complete login** at Nextcloud
|
||||
|
||||
4. **Browser should redirect** to `http://localhost:8765/callback?code=mcp-code-...&state=TEST`
|
||||
|
||||
5. **Copy the code** from the URL and exchange it:
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/oauth/token \
|
||||
-d "grant_type=authorization_code" \
|
||||
-d "code=<MCP_CODE_HERE>" \
|
||||
-d "code_verifier=<VERIFIER_HERE>" \
|
||||
-d "redirect_uri=http://localhost:8765/callback" \
|
||||
-d "client_id=test-mcp-client"
|
||||
```
|
||||
|
||||
## Expected Database State After Success
|
||||
|
||||
```bash
|
||||
# Check refresh token was stored
|
||||
docker compose exec mcp-oauth sh -c \
|
||||
"sqlite3 /app/data/tokens.db 'SELECT user_id, created_at FROM refresh_tokens;'"
|
||||
```
|
||||
|
||||
Should show an entry for the authenticated user.
|
||||
@@ -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,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ADR-004 Manual OAuth Flow Test
|
||||
|
||||
This is a simplified version that doesn't use Playwright automation.
|
||||
Instead, it prints URLs and waits for manual browser interaction.
|
||||
|
||||
Usage:
|
||||
uv run python tests/manual/test_adr004_manual.py --provider nextcloud
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
from base64 import urlsafe_b64encode
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from threading import Thread
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CallbackHandler(BaseHTTPRequestHandler):
|
||||
"""Handles OAuth callback redirect to localhost"""
|
||||
|
||||
authorization_code = None
|
||||
state = None
|
||||
|
||||
def do_GET(self):
|
||||
"""Handle GET request with authorization code"""
|
||||
parsed = urlparse(self.path)
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
# Ignore favicon requests
|
||||
if parsed.path == "/favicon.ico":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "image/x-icon")
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
CallbackHandler.authorization_code = params.get("code", [None])[0]
|
||||
CallbackHandler.state = params.get("state", [None])[0]
|
||||
|
||||
# Send success page
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
|
||||
code_display = (
|
||||
CallbackHandler.authorization_code[:50] + "..."
|
||||
if CallbackHandler.authorization_code
|
||||
else "No code received"
|
||||
)
|
||||
|
||||
html = """
|
||||
<html>
|
||||
<head><title>Authorization Success</title></head>
|
||||
<body>
|
||||
<h1 style="color: green;">✓ Authorization Successful</h1>
|
||||
<p>Authorization code received. You can close this window and return to the terminal.</p>
|
||||
<code style="background: #f0f0f0; padding: 10px; display: block; margin: 10px 0;">
|
||||
{}
|
||||
</code>
|
||||
</body>
|
||||
</html>
|
||||
""".format(code_display)
|
||||
self.wfile.write(html.encode())
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Log HTTP requests"""
|
||||
logger.info(f"Callback server: {format % args}")
|
||||
|
||||
|
||||
def generate_pkce_challenge():
|
||||
"""Generate PKCE code verifier and challenge"""
|
||||
code_verifier = secrets.token_urlsafe(32)
|
||||
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||
code_challenge = urlsafe_b64encode(digest).decode().rstrip("=")
|
||||
return code_verifier, code_challenge
|
||||
|
||||
|
||||
async def test_oauth_manual(
|
||||
provider: str,
|
||||
mcp_server_url: str,
|
||||
nextcloud_host: str,
|
||||
):
|
||||
"""
|
||||
Manual OAuth flow test - prints URLs for manual browser interaction.
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("ADR-004 MANUAL OAUTH FLOW TEST")
|
||||
print("=" * 70)
|
||||
print(f"Provider: {provider}")
|
||||
print(f"MCP Server: {mcp_server_url}")
|
||||
print(f"Nextcloud: {nextcloud_host}")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
# Generate PKCE challenge
|
||||
code_verifier, code_challenge = generate_pkce_challenge()
|
||||
logger.info(f"✓ Generated PKCE challenge: {code_challenge[:16]}...")
|
||||
|
||||
# Generate state for CSRF protection
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Start local HTTP server for OAuth callback
|
||||
callback_port = 8765
|
||||
redirect_uri = f"http://localhost:{callback_port}/callback"
|
||||
|
||||
server = HTTPServer(("localhost", callback_port), CallbackHandler)
|
||||
server_thread = Thread(target=server.serve_forever, daemon=True)
|
||||
server_thread.start()
|
||||
logger.info(f"✓ Started callback server at {redirect_uri}")
|
||||
|
||||
try:
|
||||
# Build authorization URL
|
||||
auth_params = {
|
||||
"response_type": "code",
|
||||
"client_id": "test-mcp-client",
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": "openid profile email offline_access notes:read notes:write",
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
|
||||
auth_url = f"{mcp_server_url}/oauth/authorize?{urlencode(auth_params)}"
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("STEP 1: AUTHORIZE THE MCP SERVER")
|
||||
print("=" * 70)
|
||||
print("\n📋 Open this URL in your browser:\n")
|
||||
print(f" {auth_url}")
|
||||
print("\n📌 What will happen:")
|
||||
print(" 1. You'll be redirected to Nextcloud/Keycloak login")
|
||||
print(" 2. Login with username: admin, password: admin")
|
||||
print(" 3. You'll see a consent screen asking to authorize the MCP server")
|
||||
print(" 4. Click 'Authorize' or 'Allow'")
|
||||
print(" 5. You'll be redirected to localhost:8765/callback")
|
||||
print(" 6. The authorization code will appear in the terminal\n")
|
||||
print("=" * 70)
|
||||
print("\n⏳ Waiting for authorization... (timeout: 5 minutes)\n")
|
||||
|
||||
# Wait for authorization code (with timeout)
|
||||
timeout = 300 # 5 minutes
|
||||
elapsed = 0
|
||||
while not CallbackHandler.authorization_code and elapsed < timeout:
|
||||
await asyncio.sleep(1)
|
||||
elapsed += 1
|
||||
|
||||
if not CallbackHandler.authorization_code:
|
||||
raise RuntimeError("Timeout waiting for authorization code")
|
||||
|
||||
authorization_code = CallbackHandler.authorization_code
|
||||
returned_state = CallbackHandler.state
|
||||
|
||||
print("\n✓ Received authorization code!")
|
||||
logger.info(f"Code: {authorization_code[:16]}...")
|
||||
|
||||
# Verify state
|
||||
if returned_state != state:
|
||||
raise RuntimeError(
|
||||
f"State mismatch! Expected {state}, got {returned_state}"
|
||||
)
|
||||
logger.info("✓ State parameter verified (CSRF protection)")
|
||||
|
||||
# Exchange authorization code for access token
|
||||
print("\n" + "=" * 70)
|
||||
print("STEP 2: EXCHANGE CODE FOR ACCESS TOKEN")
|
||||
print("=" * 70)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
f"{mcp_server_url}/oauth/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": authorization_code,
|
||||
"code_verifier": code_verifier,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": "test-mcp-client",
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
print(f"\n❌ Token exchange failed: {token_response.status_code}")
|
||||
print(f"Response: {token_response.text}")
|
||||
raise RuntimeError("Token exchange failed")
|
||||
|
||||
token_data = token_response.json()
|
||||
access_token = token_data["access_token"]
|
||||
|
||||
print("\n✓ Successfully received access token")
|
||||
print(f" Token: {access_token[:30]}...")
|
||||
print(f" Type: {token_data.get('token_type', 'Bearer')}")
|
||||
print(f" Expires: {token_data.get('expires_in', 'unknown')}s")
|
||||
|
||||
# Test MCP tool call
|
||||
print("\n" + "=" * 70)
|
||||
print("STEP 3: CALL MCP TOOL WITH ACCESS TOKEN")
|
||||
print("=" * 70)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
mcp_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "nc_notes_search_notes",
|
||||
"arguments": {"query": "test"},
|
||||
},
|
||||
}
|
||||
|
||||
mcp_response = await client.post(
|
||||
f"{mcp_server_url}/mcp",
|
||||
json=mcp_request,
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if mcp_response.status_code != 200:
|
||||
print(f"\n❌ MCP tool call failed: {mcp_response.status_code}")
|
||||
print(f"Response: {mcp_response.text}")
|
||||
raise RuntimeError("MCP tool call failed")
|
||||
|
||||
mcp_result = mcp_response.json()
|
||||
|
||||
if "error" in mcp_result:
|
||||
print(f"\n❌ MCP tool returned error: {mcp_result['error']}")
|
||||
raise RuntimeError(f"MCP tool error: {mcp_result['error']}")
|
||||
|
||||
print("\n✓ MCP tool call succeeded!")
|
||||
print(f" Result: {mcp_result.get('result', {})}")
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 70)
|
||||
print("🎉 ADR-004 OAUTH FLOW TEST - SUCCESS")
|
||||
print("=" * 70)
|
||||
print(f"Provider: {provider}")
|
||||
print(f"MCP Server: {mcp_server_url}")
|
||||
print(f"Nextcloud: {nextcloud_host}")
|
||||
print("")
|
||||
print("✓ User consented to MCP server access")
|
||||
print("✓ User consented to offline_access (refresh tokens)")
|
||||
print("✓ MCP server stored master refresh token")
|
||||
print("✓ Client received MCP access token via PKCE")
|
||||
print("✓ MCP tool call succeeded")
|
||||
print("✓ MCP server exchanged tokens in background")
|
||||
print("✓ Nextcloud data fetched successfully")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
return {"success": True}
|
||||
|
||||
finally:
|
||||
server.shutdown()
|
||||
logger.info("Stopped callback server")
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Manual test for ADR-004 OAuth Hybrid Flow"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
choices=["nextcloud", "keycloak"],
|
||||
required=True,
|
||||
help="OAuth provider to test",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--mcp-server-url",
|
||||
default="http://localhost:8001",
|
||||
help="MCP server URL (default: http://localhost:8001)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--nextcloud-host",
|
||||
default="http://localhost:8080",
|
||||
help="Nextcloud host URL (default: http://localhost:8080)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
result = await test_oauth_manual(
|
||||
provider=args.provider,
|
||||
mcp_server_url=args.mcp_server_url,
|
||||
nextcloud_host=args.nextcloud_host,
|
||||
)
|
||||
|
||||
return 0 if result["success"] else 1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n⚠️ Test interrupted by user")
|
||||
return 1
|
||||
except Exception as e:
|
||||
logger.error(f"OAuth flow test failed: {e}", exc_info=True)
|
||||
print("\n" + "=" * 70)
|
||||
print("❌ ADR-004 OAUTH FLOW TEST - FAILED")
|
||||
print("=" * 70)
|
||||
print(f"Error: {e}")
|
||||
print("=" * 70)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
exit(exit_code)
|
||||
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ADR-004 OAuth Flow Test Script
|
||||
|
||||
Tests the complete Hybrid Flow implementation:
|
||||
1. User initiates OAuth at MCP server /oauth/authorize
|
||||
2. User consents to MCP server access (IdP)
|
||||
3. User consents to MCP server accessing Nextcloud (IdP/Nextcloud)
|
||||
4. MCP server receives master refresh token
|
||||
5. Client receives MCP access token
|
||||
6. Client calls MCP tool
|
||||
7. MCP server exchanges master refresh token for Nextcloud access token
|
||||
8. MCP server fetches data from Nextcloud on behalf of user
|
||||
|
||||
Usage:
|
||||
# Test with Nextcloud OIDC app
|
||||
uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud
|
||||
|
||||
# Test with Keycloak
|
||||
uv run python tests/manual/test_adr004_oauth_flow.py --provider keycloak
|
||||
|
||||
Requirements:
|
||||
- MCP server running with OAuth enabled
|
||||
- System web browser
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import webbrowser
|
||||
from base64 import urlsafe_b64encode
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from threading import Thread
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CallbackHandler(BaseHTTPRequestHandler):
|
||||
"""Handles OAuth callback redirect to localhost"""
|
||||
|
||||
authorization_code = None
|
||||
state = None
|
||||
|
||||
def do_GET(self):
|
||||
"""Handle GET request with authorization code"""
|
||||
parsed = urlparse(self.path)
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
# Ignore favicon requests
|
||||
if parsed.path == "/favicon.ico":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "image/x-icon")
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
CallbackHandler.authorization_code = params.get("code", [None])[0]
|
||||
CallbackHandler.state = params.get("state", [None])[0]
|
||||
|
||||
# Send success page
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
|
||||
code_display = (
|
||||
CallbackHandler.authorization_code[:50] + "..."
|
||||
if CallbackHandler.authorization_code
|
||||
else "No code received"
|
||||
)
|
||||
|
||||
html = """
|
||||
<html>
|
||||
<head><title>Authorization Success</title></head>
|
||||
<body>
|
||||
<h1 style="color: green;">✓ Authorization Successful</h1>
|
||||
<p>Authorization code received. You can close this window and return to the terminal.</p>
|
||||
<code style="background: #f0f0f0; padding: 10px; display: block; margin: 10px 0;">
|
||||
{}
|
||||
</code>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>
|
||||
""".format(code_display)
|
||||
self.wfile.write(html.encode())
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Log HTTP requests"""
|
||||
logger.info(f"Callback: {format % args}")
|
||||
|
||||
|
||||
def generate_pkce_challenge():
|
||||
"""Generate PKCE code verifier and challenge"""
|
||||
code_verifier = secrets.token_urlsafe(32)
|
||||
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||
code_challenge = urlsafe_b64encode(digest).decode().rstrip("=")
|
||||
return code_verifier, code_challenge
|
||||
|
||||
|
||||
# Note: Playwright automation functions removed - using system browser instead
|
||||
|
||||
|
||||
async def test_oauth_flow(
|
||||
provider: str,
|
||||
mcp_server_url: str,
|
||||
nextcloud_host: str,
|
||||
username: str,
|
||||
password: str,
|
||||
):
|
||||
"""
|
||||
Test complete ADR-004 OAuth flow using system browser.
|
||||
|
||||
Args:
|
||||
provider: "nextcloud" or "keycloak"
|
||||
mcp_server_url: MCP server URL (e.g., http://localhost:8001)
|
||||
nextcloud_host: Nextcloud instance URL
|
||||
username: Test user username (for documentation)
|
||||
password: Test user password (for documentation)
|
||||
"""
|
||||
logger.info(f"Starting ADR-004 OAuth flow test with provider: {provider}")
|
||||
logger.info(f"MCP Server: {mcp_server_url}")
|
||||
logger.info(f"Nextcloud Host: {nextcloud_host}")
|
||||
|
||||
# Generate PKCE challenge
|
||||
code_verifier, code_challenge = generate_pkce_challenge()
|
||||
logger.info(f"✓ Generated PKCE challenge: {code_challenge[:16]}...")
|
||||
|
||||
# Generate state for CSRF protection
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Start local HTTP server for OAuth callback
|
||||
callback_port = 8765
|
||||
redirect_uri = f"http://localhost:{callback_port}/callback"
|
||||
|
||||
server = HTTPServer(("localhost", callback_port), CallbackHandler)
|
||||
server_thread = Thread(target=server.serve_forever, daemon=True)
|
||||
server_thread.start()
|
||||
logger.info(f"✓ Started callback server at {redirect_uri}")
|
||||
|
||||
try:
|
||||
# Step 1: Build authorization URL
|
||||
auth_params = {
|
||||
"response_type": "code",
|
||||
"client_id": "test-mcp-client",
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": "openid profile email offline_access notes:read notes:write",
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
|
||||
auth_url = f"{mcp_server_url}/oauth/authorize?{urlencode(auth_params)}"
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("STEP 1: AUTHORIZE IN BROWSER")
|
||||
print("=" * 70)
|
||||
print(f"\n📋 Opening browser to: {auth_url[:80]}...")
|
||||
print(f"\n📌 Login with: {username} / {password}")
|
||||
print("📌 Then authorize the MCP server")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
# Step 2: Open system browser
|
||||
logger.info("Opening system browser for OAuth flow...")
|
||||
webbrowser.open(auth_url)
|
||||
|
||||
logger.info("⏳ Waiting for authorization callback (timeout: 5 minutes)...")
|
||||
|
||||
# Wait for callback
|
||||
timeout = 300 # 5 minutes
|
||||
elapsed = 0
|
||||
while not CallbackHandler.authorization_code and elapsed < timeout:
|
||||
await asyncio.sleep(1)
|
||||
elapsed += 1
|
||||
|
||||
if not CallbackHandler.authorization_code:
|
||||
raise RuntimeError("Timeout waiting for authorization code")
|
||||
|
||||
# Step 3: Verify we received authorization code
|
||||
authorization_code = CallbackHandler.authorization_code
|
||||
returned_state = CallbackHandler.state
|
||||
|
||||
if not authorization_code:
|
||||
raise RuntimeError("Failed to receive authorization code from callback")
|
||||
|
||||
logger.info(f"✓ Received MCP authorization code: {authorization_code[:16]}...")
|
||||
|
||||
# Verify state matches (CSRF protection)
|
||||
if returned_state != state:
|
||||
raise RuntimeError(
|
||||
f"State mismatch! Expected {state}, got {returned_state}"
|
||||
)
|
||||
logger.info("✓ State parameter verified (CSRF protection)")
|
||||
|
||||
# Step 4: Exchange authorization code for access token
|
||||
logger.info("Exchanging authorization code for access token...")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
f"{mcp_server_url}/oauth/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": authorization_code,
|
||||
"code_verifier": code_verifier,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": "test-mcp-client",
|
||||
},
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
logger.error(f"Token exchange failed: {token_response.status_code}")
|
||||
logger.error(f"Response: {token_response.text}")
|
||||
raise RuntimeError(
|
||||
f"Token exchange failed: {token_response.status_code}"
|
||||
)
|
||||
|
||||
token_data = token_response.json()
|
||||
access_token = token_data["access_token"]
|
||||
|
||||
logger.info("✓ Successfully received access token")
|
||||
logger.info(f" Token: {access_token[:20]}...")
|
||||
logger.info(f" Type: {token_data.get('token_type', 'Bearer')}")
|
||||
logger.info(f" Expires in: {token_data.get('expires_in', 'unknown')}s")
|
||||
|
||||
# Step 5: Use access token to call MCP tool
|
||||
logger.info("Testing MCP tool call with access token...")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Call MCP server to list notes (this will trigger token exchange in background)
|
||||
mcp_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "nc_notes_search_notes",
|
||||
"arguments": {"query": "test"},
|
||||
},
|
||||
}
|
||||
|
||||
mcp_response = await client.post(
|
||||
f"{mcp_server_url}/mcp",
|
||||
json=mcp_request,
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if mcp_response.status_code != 200:
|
||||
logger.error(f"MCP tool call failed: {mcp_response.status_code}")
|
||||
logger.error(f"Response: {mcp_response.text}")
|
||||
raise RuntimeError(f"MCP tool call failed: {mcp_response.status_code}")
|
||||
|
||||
mcp_result = mcp_response.json()
|
||||
|
||||
if "error" in mcp_result:
|
||||
logger.error(f"MCP tool returned error: {mcp_result['error']}")
|
||||
raise RuntimeError(f"MCP tool error: {mcp_result['error']}")
|
||||
|
||||
logger.info("✓ MCP tool call succeeded!")
|
||||
logger.info(f" Result: {mcp_result.get('result', {})}")
|
||||
|
||||
# Step 6: Verify refresh token storage
|
||||
logger.info("Verifying refresh token storage...")
|
||||
|
||||
# Check if refresh token was stored (requires database access)
|
||||
# This would require accessing the SQLite database directly
|
||||
logger.info("✓ OAuth flow completed successfully!")
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 70)
|
||||
print("ADR-004 OAUTH FLOW TEST - SUCCESS")
|
||||
print("=" * 70)
|
||||
print(f"Provider: {provider}")
|
||||
print(f"MCP Server: {mcp_server_url}")
|
||||
print(f"Nextcloud: {nextcloud_host}")
|
||||
print(f"User: {username}")
|
||||
print("")
|
||||
print("✓ User consented to MCP server access")
|
||||
print("✓ User consented to offline_access (refresh tokens)")
|
||||
print("✓ MCP server stored master refresh token")
|
||||
print("✓ Client received MCP access token")
|
||||
print("✓ MCP tool call succeeded")
|
||||
print("✓ MCP server exchanged tokens in background")
|
||||
print("✓ Nextcloud data fetched successfully")
|
||||
print("=" * 70)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"access_token": access_token,
|
||||
"provider": provider,
|
||||
}
|
||||
|
||||
finally:
|
||||
server.shutdown()
|
||||
logger.info("Stopped callback server")
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Test ADR-004 OAuth Hybrid Flow",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Test with Nextcloud OIDC
|
||||
uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud
|
||||
|
||||
# Test with Keycloak
|
||||
uv run python tests/manual/test_adr004_oauth_flow.py --provider keycloak
|
||||
|
||||
# Headless mode
|
||||
uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud --headless
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
choices=["nextcloud", "keycloak"],
|
||||
required=True,
|
||||
help="OAuth provider to test (nextcloud or keycloak)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--mcp-server-url",
|
||||
default="http://localhost:8001",
|
||||
help="MCP server URL (default: http://localhost:8001 for OAuth)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--nextcloud-host",
|
||||
default="http://localhost:8080",
|
||||
help="Nextcloud host URL (default: http://localhost:8080)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--username", default="admin", help="Test user username (default: admin)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--password", default="admin", help="Test user password (default: admin)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
result = await test_oauth_flow(
|
||||
provider=args.provider,
|
||||
mcp_server_url=args.mcp_server_url,
|
||||
nextcloud_host=args.nextcloud_host,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
)
|
||||
|
||||
return 0 if result["success"] else 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OAuth flow test failed: {e}", exc_info=True)
|
||||
print("\n" + "=" * 70)
|
||||
print("ADR-004 OAUTH FLOW TEST - FAILED")
|
||||
print("=" * 70)
|
||||
print(f"Error: {e}")
|
||||
print("=" * 70)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
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,68 @@
|
||||
"""Unit tests for user info routes.
|
||||
|
||||
Note: Most unit tests were removed as they relied on the old _get_user_info API.
|
||||
The new browser OAuth session-based implementation is covered by integration tests
|
||||
in tests/server/oauth/test_userinfo_integration.py which test the full OAuth flow
|
||||
with real browser sessions, token storage, and IdP interactions.
|
||||
|
||||
These unit tests cover only the simple _query_idp_userinfo helper function.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
async def test_query_idp_userinfo_success(mocker):
|
||||
"""Test successful IdP userinfo query."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"sub": "alice",
|
||||
"email": "alice@example.com",
|
||||
"name": "Alice Smith",
|
||||
}
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
# Mock the async context manager properly
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.auth.userinfo_routes.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
result = await _query_idp_userinfo("test_token", "https://example.com/userinfo")
|
||||
|
||||
assert result == {
|
||||
"sub": "alice",
|
||||
"email": "alice@example.com",
|
||||
"name": "Alice Smith",
|
||||
}
|
||||
mock_client.get.assert_called_once_with(
|
||||
"https://example.com/userinfo",
|
||||
headers={"Authorization": "Bearer test_token"},
|
||||
)
|
||||
|
||||
|
||||
async def test_query_idp_userinfo_failure(mocker):
|
||||
"""Test IdP userinfo query failure handling."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = Exception("Network error")
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.auth.userinfo_routes.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
result = await _query_idp_userinfo("test_token", "https://example.com/userinfo")
|
||||
|
||||
assert result is None
|
||||
@@ -3,8 +3,8 @@ 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="Bearer" → Opaque tokens (standard OAuth2)
|
||||
- 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
|
||||
@@ -208,12 +208,14 @@ async def test_dcr_respects_jwt_token_type(
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test that DCR honors token_type=JWT and issues JWT-formatted tokens.
|
||||
Test that DCR honors token_type=jwt and issues JWT-formatted tokens.
|
||||
|
||||
This verifies:
|
||||
1. Client registration with token_type="JWT" succeeds
|
||||
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:
|
||||
@@ -232,15 +234,15 @@ async def test_dcr_respects_jwt_token_type(
|
||||
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...")
|
||||
# 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",
|
||||
token_type="jwt",
|
||||
)
|
||||
|
||||
logger.info(f"Registered JWT client: {client_info.client_id[:16]}...")
|
||||
@@ -278,7 +280,7 @@ async def test_dcr_respects_jwt_token_type(
|
||||
assert "notes:write" in scopes, "JWT scope claim missing notes:write"
|
||||
|
||||
logger.info(
|
||||
f"✅ DCR with token_type=JWT works correctly! "
|
||||
f"✅ DCR with token_type=jwt works correctly! "
|
||||
f"Token is JWT format with scope claim: {payload['scope']}"
|
||||
)
|
||||
|
||||
@@ -290,12 +292,14 @@ async def test_dcr_respects_bearer_token_type(
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test that DCR honors token_type=Bearer and issues opaque tokens.
|
||||
Test that DCR honors token_type=opaque and issues opaque tokens.
|
||||
|
||||
This verifies:
|
||||
1. Client registration with token_type="Bearer" succeeds
|
||||
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:
|
||||
@@ -314,18 +318,18 @@ async def test_dcr_respects_bearer_token_type(
|
||||
token_endpoint = oidc_config.get("token_endpoint")
|
||||
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||
|
||||
# Register client with token_type="Bearer" (opaque tokens)
|
||||
logger.info("Registering OAuth client with token_type=Bearer...")
|
||||
# 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 - Bearer Token Type",
|
||||
client_name="DCR Test - Opaque Token Type",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email notes:read notes:write",
|
||||
token_type="Bearer",
|
||||
token_type="opaque",
|
||||
)
|
||||
|
||||
logger.info(f"Registered Bearer client: {client_info.client_id[:16]}...")
|
||||
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(
|
||||
@@ -353,7 +357,7 @@ async def test_dcr_respects_bearer_token_type(
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
f"✅ DCR with token_type=Bearer works correctly! "
|
||||
f"✅ DCR with token_type=opaque works correctly! "
|
||||
f"Token is opaque (not JWT format): {access_token[:30]}..."
|
||||
)
|
||||
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
@@ -394,11 +394,13 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
||||
nc_mcp_oauth_client_no_custom_scopes,
|
||||
):
|
||||
"""
|
||||
Test that a JWT token with only OIDC default scopes (no nc:read or nc:write) returns 0 tools.
|
||||
Test that a JWT token with only OIDC default scopes shows only OAuth provisioning 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 nc:read or nc:write.
|
||||
All tools require at least one custom scope, so they should all be filtered out.
|
||||
Expected: JWT token has scopes=['openid', 'profile', 'email'] but no resource scopes.
|
||||
- Resource tools (notes:*, calendar:*, etc.) are filtered out
|
||||
- OAuth provisioning tools (requiring only 'openid') remain visible
|
||||
so users can provision Nextcloud access after authentication
|
||||
"""
|
||||
import logging
|
||||
|
||||
@@ -410,16 +412,24 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(
|
||||
f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 0)"
|
||||
f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 3 OAuth tools)"
|
||||
)
|
||||
|
||||
# All tools require nc:read or nc:write, so should be filtered out
|
||||
assert len(tool_names) == 0, (
|
||||
f"Expected 0 tools but got {len(tool_names)}: {tool_names[:10]}"
|
||||
# Only OAuth provisioning tools should be visible (they require 'openid' scope)
|
||||
expected_oauth_tools = [
|
||||
"provision_nextcloud_access",
|
||||
"revoke_nextcloud_access",
|
||||
"check_provisioning_status",
|
||||
]
|
||||
|
||||
assert set(tool_names) == set(expected_oauth_tools), (
|
||||
f"Expected only OAuth provisioning tools {expected_oauth_tools} "
|
||||
f"but got {tool_names}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ JWT token without custom scopes correctly returns 0 tools (all filtered out)"
|
||||
f"✅ JWT token with only openid scope correctly shows {len(tool_names)} OAuth provisioning tools, "
|
||||
"resource tools filtered out"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
"""Unit tests for RFC 8693 Token Exchange (ADR-004).
|
||||
|
||||
Tests the critical token exchange pattern that separates:
|
||||
- Session tokens (ephemeral, on-demand)
|
||||
- Background tokens (stored refresh tokens)
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
from nextcloud_mcp_server.auth.token_exchange import TokenExchangeService
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def token_storage():
|
||||
"""Create test token storage."""
|
||||
import tempfile
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
# Generate valid Fernet key
|
||||
encryption_key = Fernet.generate_key()
|
||||
|
||||
# Create temporary database file
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||
db_path = tmp.name
|
||||
|
||||
storage = RefreshTokenStorage(db_path=db_path, encryption_key=encryption_key)
|
||||
await storage.initialize()
|
||||
|
||||
# Expose encryption key for tests that need to manually encrypt/decrypt
|
||||
storage._test_encryption_key = encryption_key
|
||||
|
||||
yield storage
|
||||
|
||||
# Cleanup
|
||||
if os.path.exists(db_path):
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def token_exchange_service(token_storage):
|
||||
"""Create test token exchange service."""
|
||||
service = TokenExchangeService(
|
||||
oidc_discovery_url="http://test-idp/.well-known/openid-configuration",
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
nextcloud_host="http://test-nextcloud",
|
||||
)
|
||||
service.storage = token_storage
|
||||
yield service
|
||||
await service.http_client.aclose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def token_broker(token_storage):
|
||||
"""Create test token broker service."""
|
||||
# Use the same encryption key as storage
|
||||
encryption_key = token_storage._test_encryption_key
|
||||
|
||||
broker = TokenBrokerService(
|
||||
storage=token_storage,
|
||||
oidc_discovery_url="http://test-idp/.well-known/openid-configuration",
|
||||
nextcloud_host="http://test-nextcloud",
|
||||
encryption_key=encryption_key,
|
||||
cache_ttl=300,
|
||||
cache_early_refresh=30,
|
||||
)
|
||||
yield broker
|
||||
await broker.close()
|
||||
|
||||
|
||||
def create_test_jwt(
|
||||
user_id: str = "testuser", audience: str = "mcp-server", expires_in: int = 3600
|
||||
) -> str:
|
||||
"""Create a test JWT token."""
|
||||
import time
|
||||
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"aud": audience,
|
||||
"exp": int(time.time()) + expires_in,
|
||||
"iat": int(time.time()),
|
||||
"iss": "http://test-idp",
|
||||
}
|
||||
|
||||
# For testing, we don't sign the token (uses 'none' algorithm)
|
||||
# In production, tokens would be properly signed
|
||||
return jwt.encode(payload, "", algorithm="none")
|
||||
|
||||
|
||||
class TestTokenExchange:
|
||||
"""Test RFC 8693 token exchange implementation."""
|
||||
|
||||
async def test_validate_flow1_token_success(self, token_exchange_service):
|
||||
"""Test validation of Flow 1 token with correct audience."""
|
||||
# Create token with correct audience
|
||||
flow1_token = create_test_jwt(audience="mcp-server")
|
||||
|
||||
# Should not raise an exception
|
||||
await token_exchange_service._validate_flow1_token(flow1_token)
|
||||
|
||||
async def test_validate_flow1_token_wrong_audience(self, token_exchange_service):
|
||||
"""Test validation fails with wrong audience."""
|
||||
# Create token with wrong audience
|
||||
flow1_token = create_test_jwt(audience="nextcloud")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid token audience"):
|
||||
await token_exchange_service._validate_flow1_token(flow1_token)
|
||||
|
||||
async def test_validate_flow1_token_expired(self, token_exchange_service):
|
||||
"""Test validation fails with expired token."""
|
||||
# Create expired token
|
||||
flow1_token = create_test_jwt(audience="mcp-server", expires_in=-3600)
|
||||
|
||||
with pytest.raises(ValueError, match="Token has expired"):
|
||||
await token_exchange_service._validate_flow1_token(flow1_token)
|
||||
|
||||
async def test_extract_user_id(self, token_exchange_service):
|
||||
"""Test extraction of user ID from token."""
|
||||
flow1_token = create_test_jwt(user_id="alice")
|
||||
|
||||
user_id = token_exchange_service._extract_user_id(flow1_token)
|
||||
assert user_id == "alice"
|
||||
|
||||
async def test_check_provisioning_not_provisioned(self, token_exchange_service):
|
||||
"""Test provisioning check when user not provisioned."""
|
||||
result = await token_exchange_service._check_provisioning("unknown_user")
|
||||
assert result is False
|
||||
|
||||
async def test_check_provisioning_is_provisioned(
|
||||
self, token_exchange_service, token_storage
|
||||
):
|
||||
"""Test provisioning check when user is provisioned."""
|
||||
# Store a refresh token for user
|
||||
await token_storage.store_refresh_token(
|
||||
user_id="alice", refresh_token="encrypted_refresh_token", flow_type="flow2"
|
||||
)
|
||||
|
||||
result = await token_exchange_service._check_provisioning("alice")
|
||||
assert result is True
|
||||
|
||||
async def test_exchange_token_not_provisioned(self, token_exchange_service):
|
||||
"""Test token exchange fails when user not provisioned."""
|
||||
flow1_token = create_test_jwt(user_id="unprovisioneduser")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Nextcloud access not provisioned"):
|
||||
await token_exchange_service.exchange_token_for_delegation(
|
||||
flow1_token=flow1_token,
|
||||
requested_scopes=["notes:read"],
|
||||
requested_audience="nextcloud",
|
||||
)
|
||||
|
||||
async def test_exchange_token_with_fallback(
|
||||
self, token_exchange_service, token_storage
|
||||
):
|
||||
"""Test token exchange with refresh grant fallback."""
|
||||
# Store a refresh token for user
|
||||
await token_storage.store_refresh_token(
|
||||
user_id="alice", refresh_token="test_refresh_token", flow_type="flow2"
|
||||
)
|
||||
|
||||
# Create Flow 1 token
|
||||
flow1_token = create_test_jwt(user_id="alice", audience="mcp-server")
|
||||
|
||||
# Mock HTTP client for token endpoint
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"access_token": "delegated_token_12345",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 300, # 5 minutes
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
token_exchange_service.http_client, "post", return_value=mock_response
|
||||
):
|
||||
# Mock discovery endpoint
|
||||
with patch.object(
|
||||
token_exchange_service,
|
||||
"_discover_endpoints",
|
||||
return_value={"token_endpoint": "http://test-idp/token"},
|
||||
):
|
||||
# Perform exchange
|
||||
(
|
||||
token,
|
||||
expires_in,
|
||||
) = await token_exchange_service.exchange_token_for_delegation(
|
||||
flow1_token=flow1_token,
|
||||
requested_scopes=["notes:read"],
|
||||
requested_audience="nextcloud",
|
||||
)
|
||||
|
||||
assert token == "delegated_token_12345"
|
||||
assert expires_in == 300
|
||||
|
||||
|
||||
class TestTokenBroker:
|
||||
"""Test Token Broker session/background separation."""
|
||||
|
||||
async def test_get_session_token(self, token_broker, token_storage):
|
||||
"""Test getting ephemeral session token via exchange."""
|
||||
# Store refresh token for user
|
||||
await token_storage.store_refresh_token(
|
||||
user_id="alice", refresh_token="test_refresh_token", flow_type="flow2"
|
||||
)
|
||||
|
||||
# Create Flow 1 token
|
||||
flow1_token = create_test_jwt(user_id="alice", audience="mcp-server")
|
||||
|
||||
# Mock token exchange
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.token_broker.exchange_token_for_delegation",
|
||||
return_value=("ephemeral_token_xyz", 300),
|
||||
):
|
||||
token = await token_broker.get_session_token(
|
||||
flow1_token=flow1_token,
|
||||
required_scopes=["notes:read"],
|
||||
requested_audience="nextcloud",
|
||||
)
|
||||
|
||||
assert token == "ephemeral_token_xyz"
|
||||
|
||||
# Verify token is NOT cached (ephemeral)
|
||||
cached = await token_broker.cache.get("alice")
|
||||
assert cached is None # Should not be in cache
|
||||
|
||||
async def test_get_background_token(self, token_broker, token_storage):
|
||||
"""Test getting background token with stored refresh."""
|
||||
# Store encrypted refresh token for user
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
# Use the same encryption key as token_storage/token_broker
|
||||
fernet = Fernet(token_storage._test_encryption_key)
|
||||
encrypted_token = fernet.encrypt(b"background_refresh_token").decode()
|
||||
|
||||
await token_storage.store_refresh_token(
|
||||
user_id="alice", refresh_token=encrypted_token, flow_type="flow2"
|
||||
)
|
||||
|
||||
# Mock OIDC config and token response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"access_token": "background_token_abc",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600, # 1 hour
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
token_broker,
|
||||
"_get_oidc_config",
|
||||
return_value={"token_endpoint": "http://test/token"},
|
||||
):
|
||||
with patch.object(token_broker, "_get_http_client") as mock_client:
|
||||
mock_client.return_value.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Mock audience validation
|
||||
with patch.object(
|
||||
token_broker, "_validate_token_audience", return_value=None
|
||||
):
|
||||
token = await token_broker.get_background_token(
|
||||
user_id="alice", required_scopes=["notes:sync", "files:sync"]
|
||||
)
|
||||
|
||||
assert token == "background_token_abc"
|
||||
|
||||
# Verify token IS cached (background tokens can be cached)
|
||||
cache_key = "alice:background:files:sync,notes:sync"
|
||||
cached = await token_broker.cache.get(cache_key)
|
||||
assert cached == "background_token_abc"
|
||||
|
||||
async def test_session_background_separation(self, token_broker, token_storage):
|
||||
"""Test that session and background tokens are kept separate."""
|
||||
# Store refresh token
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
# Use the same encryption key as token_storage/token_broker
|
||||
fernet = Fernet(token_storage._test_encryption_key)
|
||||
encrypted_token = fernet.encrypt(b"master_refresh_token").decode()
|
||||
|
||||
await token_storage.store_refresh_token(
|
||||
user_id="alice", refresh_token=encrypted_token, flow_type="flow2"
|
||||
)
|
||||
|
||||
flow1_token = create_test_jwt(user_id="alice", audience="mcp-server")
|
||||
|
||||
# Mock different tokens for session vs background
|
||||
session_token = "ephemeral_session_123"
|
||||
background_token = "cached_background_456"
|
||||
|
||||
# Get session token
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.token_broker.exchange_token_for_delegation",
|
||||
return_value=(session_token, 300),
|
||||
):
|
||||
session_result = await token_broker.get_session_token(
|
||||
flow1_token=flow1_token, required_scopes=["notes:read"]
|
||||
)
|
||||
assert session_result == session_token
|
||||
|
||||
# Get background token
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"access_token": background_token,
|
||||
"expires_in": 3600,
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
token_broker,
|
||||
"_get_oidc_config",
|
||||
return_value={"token_endpoint": "http://test/token"},
|
||||
):
|
||||
with patch.object(token_broker, "_get_http_client") as mock_client:
|
||||
mock_client.return_value.post = AsyncMock(return_value=mock_response)
|
||||
with patch.object(
|
||||
token_broker, "_validate_token_audience", return_value=None
|
||||
):
|
||||
background_result = await token_broker.get_background_token(
|
||||
user_id="alice", required_scopes=["notes:sync"]
|
||||
)
|
||||
assert background_result == background_token
|
||||
|
||||
# Verify they are different tokens
|
||||
assert session_result != background_result
|
||||
|
||||
# Verify session token not cached
|
||||
assert await token_broker.cache.get("alice") is None
|
||||
|
||||
# Verify background token IS cached
|
||||
cache_key = "alice:background:notes:sync"
|
||||
assert await token_broker.cache.get(cache_key) == background_token
|
||||
|
||||
|
||||
class TestScopeDownscoping:
|
||||
"""Test that tokens request only necessary scopes."""
|
||||
|
||||
async def test_session_token_minimal_scopes(
|
||||
self, token_exchange_service, token_storage
|
||||
):
|
||||
"""Test session tokens request minimal scopes."""
|
||||
# Store refresh token
|
||||
await token_storage.store_refresh_token(
|
||||
user_id="alice", refresh_token="test_refresh_token", flow_type="flow2"
|
||||
)
|
||||
|
||||
flow1_token = create_test_jwt(user_id="alice", audience="mcp-server")
|
||||
|
||||
# Track what scopes are requested
|
||||
requested_scopes = None
|
||||
|
||||
async def mock_post(url, data, headers=None):
|
||||
nonlocal requested_scopes
|
||||
requested_scopes = data.get("scope", "").split()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"access_token": "scoped_token",
|
||||
"expires_in": 300,
|
||||
}
|
||||
return mock_response
|
||||
|
||||
with patch.object(
|
||||
token_exchange_service.http_client, "post", side_effect=mock_post
|
||||
):
|
||||
with patch.object(
|
||||
token_exchange_service,
|
||||
"_discover_endpoints",
|
||||
return_value={"token_endpoint": "http://test/token"},
|
||||
):
|
||||
await token_exchange_service.exchange_token_for_delegation(
|
||||
flow1_token=flow1_token,
|
||||
requested_scopes=["notes:read"], # Only read scope
|
||||
requested_audience="nextcloud",
|
||||
)
|
||||
|
||||
# Verify only requested scope was included
|
||||
assert "notes:read" in requested_scopes
|
||||
assert "notes:write" not in requested_scopes
|
||||
assert "calendar:write" not in requested_scopes
|
||||
|
||||
async def test_background_token_different_scopes(self, token_broker, token_storage):
|
||||
"""Test background tokens can request different scopes than session."""
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
# Use the same encryption key as token_storage/token_broker
|
||||
fernet = Fernet(token_storage._test_encryption_key)
|
||||
encrypted_token = fernet.encrypt(b"refresh_token").decode()
|
||||
|
||||
await token_storage.store_refresh_token(
|
||||
user_id="alice", refresh_token=encrypted_token, flow_type="flow2"
|
||||
)
|
||||
|
||||
# Track requested scopes
|
||||
requested_scopes = None
|
||||
|
||||
async def mock_post(url, data, headers=None):
|
||||
nonlocal requested_scopes
|
||||
requested_scopes = data.get("scope", "").split()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"access_token": "background_sync_token",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
return mock_response
|
||||
|
||||
with patch.object(
|
||||
token_broker,
|
||||
"_get_oidc_config",
|
||||
return_value={"token_endpoint": "http://test/token"},
|
||||
):
|
||||
with patch.object(token_broker, "_get_http_client") as mock_client:
|
||||
mock_client.return_value.post = mock_post
|
||||
with patch.object(
|
||||
token_broker, "_validate_token_audience", return_value=None
|
||||
):
|
||||
await token_broker.get_background_token(
|
||||
user_id="alice",
|
||||
required_scopes=["notes:sync", "files:sync", "calendar:sync"],
|
||||
)
|
||||
|
||||
# Verify sync scopes were requested
|
||||
assert "notes:sync" in requested_scopes
|
||||
assert "files:sync" in requested_scopes
|
||||
assert "calendar:sync" in requested_scopes
|
||||
# Basic OIDC scopes should also be included
|
||||
assert "openid" in requested_scopes
|
||||
assert "profile" in requested_scopes
|
||||
+11
-10
@@ -23,7 +23,6 @@ def clean_env(monkeypatch):
|
||||
"NEXTCLOUD_PASSWORD",
|
||||
"NEXTCLOUD_OIDC_CLIENT_ID",
|
||||
"NEXTCLOUD_OIDC_CLIENT_SECRET",
|
||||
"NEXTCLOUD_OIDC_CLIENT_STORAGE",
|
||||
"NEXTCLOUD_OIDC_SCOPES",
|
||||
"NEXTCLOUD_OIDC_TOKEN_TYPE",
|
||||
"NEXTCLOUD_MCP_SERVER_URL",
|
||||
@@ -240,9 +239,6 @@ def test_default_values(runner, clean_env, monkeypatch):
|
||||
"NEXTCLOUD_OIDC_TOKEN_TYPE"
|
||||
),
|
||||
"NEXTCLOUD_MCP_SERVER_URL": os.environ.get("NEXTCLOUD_MCP_SERVER_URL"),
|
||||
"NEXTCLOUD_OIDC_CLIENT_STORAGE": os.environ.get(
|
||||
"NEXTCLOUD_OIDC_CLIENT_STORAGE"
|
||||
),
|
||||
}
|
||||
)
|
||||
raise SystemExit(0)
|
||||
@@ -253,15 +249,20 @@ def test_default_values(runner, clean_env, monkeypatch):
|
||||
_ = runner.invoke(run, [])
|
||||
|
||||
# Verify default values
|
||||
assert (
|
||||
captured_env["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"
|
||||
assert captured_env["NEXTCLOUD_OIDC_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"
|
||||
)
|
||||
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "bearer"
|
||||
assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://localhost:8000"
|
||||
assert (
|
||||
captured_env["NEXTCLOUD_OIDC_CLIENT_STORAGE"] == ".nextcloud_oauth_client.json"
|
||||
)
|
||||
|
||||
|
||||
def test_oauth_token_type_case_normalization(runner, clean_env, monkeypatch):
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
Unit tests for Token Broker Service (ADR-004 Progressive Consent).
|
||||
|
||||
Tests the token management, caching, and refresh logic without
|
||||
requiring real network calls or database connections.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService, TokenCache
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def encryption_key():
|
||||
"""Generate test encryption key."""
|
||||
return Fernet.generate_key().decode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_storage():
|
||||
"""Mock RefreshTokenStorage."""
|
||||
storage = AsyncMock()
|
||||
storage.get_refresh_token = AsyncMock(return_value=None)
|
||||
storage.store_refresh_token = AsyncMock()
|
||||
storage.delete_refresh_token = AsyncMock()
|
||||
return storage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_oidc_config():
|
||||
"""Mock OIDC configuration."""
|
||||
return {
|
||||
"issuer": "https://idp.example.com",
|
||||
"token_endpoint": "https://idp.example.com/token",
|
||||
"revocation_endpoint": "https://idp.example.com/revoke",
|
||||
"jwks_uri": "https://idp.example.com/jwks",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def token_broker(mock_storage, encryption_key):
|
||||
"""Create TokenBrokerService instance."""
|
||||
broker = TokenBrokerService(
|
||||
storage=mock_storage,
|
||||
oidc_discovery_url="https://idp.example.com/.well-known/openid-configuration",
|
||||
nextcloud_host="https://nextcloud.example.com",
|
||||
encryption_key=encryption_key,
|
||||
cache_ttl=300,
|
||||
)
|
||||
yield broker
|
||||
await broker.close()
|
||||
|
||||
|
||||
class TestTokenCache:
|
||||
"""Test the TokenCache component."""
|
||||
|
||||
async def test_cache_stores_and_retrieves_token(self):
|
||||
"""Test basic cache storage and retrieval."""
|
||||
cache = TokenCache(ttl_seconds=60)
|
||||
|
||||
# Store token with sufficient expiry time (more than 30s threshold)
|
||||
await cache.set("user1", "test_token", expires_in=120)
|
||||
|
||||
# Retrieve token
|
||||
token = await cache.get("user1")
|
||||
assert token == "test_token"
|
||||
|
||||
async def test_cache_respects_ttl(self):
|
||||
"""Test that cache respects TTL."""
|
||||
# Create cache with 1 second TTL and 0 second early refresh
|
||||
cache = TokenCache(ttl_seconds=1, early_refresh_seconds=0)
|
||||
|
||||
# Store token
|
||||
await cache.set("user1", "test_token")
|
||||
|
||||
# Token should be available immediately
|
||||
assert await cache.get("user1") == "test_token"
|
||||
|
||||
# Wait for TTL to expire
|
||||
await asyncio.sleep(1.1)
|
||||
|
||||
# Token should be expired
|
||||
assert await cache.get("user1") is None
|
||||
|
||||
async def test_cache_early_refresh(self):
|
||||
"""Test that cache returns None for tokens expiring soon."""
|
||||
cache = TokenCache(ttl_seconds=60)
|
||||
|
||||
# Store token that expires in 25 seconds (less than 30s threshold)
|
||||
await cache.set("user1", "test_token", expires_in=25)
|
||||
|
||||
# Should return None as it's expiring soon (within 30s)
|
||||
assert await cache.get("user1") is None
|
||||
|
||||
async def test_cache_invalidation(self):
|
||||
"""Test cache invalidation."""
|
||||
cache = TokenCache(ttl_seconds=60)
|
||||
|
||||
# Store and verify token
|
||||
await cache.set("user1", "test_token")
|
||||
assert await cache.get("user1") == "test_token"
|
||||
|
||||
# Invalidate
|
||||
await cache.invalidate("user1")
|
||||
|
||||
# Should be removed
|
||||
assert await cache.get("user1") is None
|
||||
|
||||
|
||||
class TestTokenBrokerService:
|
||||
"""Test the TokenBrokerService."""
|
||||
|
||||
async def test_has_nextcloud_provisioning(self, token_broker, mock_storage):
|
||||
"""Test checking if user has provisioned Nextcloud access."""
|
||||
# No provisioning
|
||||
mock_storage.get_refresh_token.return_value = None
|
||||
assert await token_broker.has_nextcloud_provisioning("user1") is False
|
||||
|
||||
# Has provisioning
|
||||
mock_storage.get_refresh_token.return_value = {
|
||||
"refresh_token": "encrypted_token",
|
||||
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
|
||||
}
|
||||
assert await token_broker.has_nextcloud_provisioning("user1") is True
|
||||
|
||||
async def test_get_nextcloud_token_from_cache(self, token_broker):
|
||||
"""Test getting token from cache."""
|
||||
# Pre-populate cache
|
||||
await token_broker.cache.set("user1", "cached_token", expires_in=300)
|
||||
|
||||
# Should return cached token without calling storage
|
||||
token = await token_broker.get_nextcloud_token("user1")
|
||||
assert token == "cached_token"
|
||||
token_broker.storage.get_refresh_token.assert_not_called()
|
||||
|
||||
async def test_get_nextcloud_token_refresh(
|
||||
self, token_broker, mock_storage, encryption_key, mock_oidc_config
|
||||
):
|
||||
"""Test getting token via refresh when not cached."""
|
||||
# Setup encrypted refresh token in storage
|
||||
fernet = Fernet(encryption_key.encode())
|
||||
encrypted_token = fernet.encrypt(b"test_refresh_token").decode()
|
||||
mock_storage.get_refresh_token.return_value = {
|
||||
"refresh_token": encrypted_token,
|
||||
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
|
||||
}
|
||||
|
||||
# Mock HTTP client for token refresh
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"access_token": "new_access_token",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
token_broker, "_get_oidc_config", return_value=mock_oidc_config
|
||||
):
|
||||
with patch.object(token_broker, "_get_http_client") as mock_client:
|
||||
mock_client.return_value.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Get token (should refresh)
|
||||
token = await token_broker.get_nextcloud_token("user1")
|
||||
|
||||
assert token == "new_access_token"
|
||||
# Verify token was cached
|
||||
cached = await token_broker.cache.get("user1")
|
||||
assert cached == "new_access_token"
|
||||
|
||||
async def test_get_nextcloud_token_no_provisioning(
|
||||
self, token_broker, mock_storage
|
||||
):
|
||||
"""Test getting token when user hasn't provisioned."""
|
||||
mock_storage.get_refresh_token.return_value = None
|
||||
|
||||
token = await token_broker.get_nextcloud_token("user1")
|
||||
assert token is None
|
||||
|
||||
async def test_refresh_master_token(
|
||||
self, token_broker, mock_storage, encryption_key, mock_oidc_config
|
||||
):
|
||||
"""Test master refresh token rotation."""
|
||||
# Setup current refresh token
|
||||
fernet = Fernet(encryption_key.encode())
|
||||
encrypted_token = fernet.encrypt(b"current_refresh_token").decode()
|
||||
mock_storage.get_refresh_token.return_value = {
|
||||
"refresh_token": encrypted_token,
|
||||
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
|
||||
}
|
||||
|
||||
# Mock successful refresh response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"access_token": "new_access",
|
||||
"refresh_token": "new_refresh_token",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
token_broker, "_get_oidc_config", return_value=mock_oidc_config
|
||||
):
|
||||
with patch.object(token_broker, "_get_http_client") as mock_client:
|
||||
mock_client.return_value.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Rotate token
|
||||
success = await token_broker.refresh_master_token("user1")
|
||||
|
||||
assert success is True
|
||||
# Verify new token was stored
|
||||
mock_storage.store_refresh_token.assert_called_once()
|
||||
call_args = mock_storage.store_refresh_token.call_args[1]
|
||||
assert call_args["user_id"] == "user1"
|
||||
# Decrypt to verify it's the new token
|
||||
stored_token = fernet.decrypt(
|
||||
call_args["refresh_token"].encode()
|
||||
).decode()
|
||||
assert stored_token == "new_refresh_token"
|
||||
|
||||
async def test_refresh_master_token_no_rotation(
|
||||
self, token_broker, mock_storage, encryption_key, mock_oidc_config
|
||||
):
|
||||
"""Test when IdP returns same refresh token (no rotation)."""
|
||||
# Setup current refresh token
|
||||
fernet = Fernet(encryption_key.encode())
|
||||
encrypted_token = fernet.encrypt(b"same_refresh_token").decode()
|
||||
mock_storage.get_refresh_token.return_value = {
|
||||
"refresh_token": encrypted_token,
|
||||
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
|
||||
}
|
||||
|
||||
# Mock response with same refresh token
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"access_token": "new_access",
|
||||
"refresh_token": "same_refresh_token",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
token_broker, "_get_oidc_config", return_value=mock_oidc_config
|
||||
):
|
||||
with patch.object(token_broker, "_get_http_client") as mock_client:
|
||||
mock_client.return_value.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
success = await token_broker.refresh_master_token("user1")
|
||||
|
||||
assert success is True
|
||||
# Should not store if token didn't change
|
||||
mock_storage.store_refresh_token.assert_not_called()
|
||||
|
||||
async def test_revoke_nextcloud_access(
|
||||
self, token_broker, mock_storage, encryption_key, mock_oidc_config
|
||||
):
|
||||
"""Test revoking Nextcloud access."""
|
||||
# Setup refresh token for revocation
|
||||
fernet = Fernet(encryption_key.encode())
|
||||
encrypted_token = fernet.encrypt(b"token_to_revoke").decode()
|
||||
mock_storage.get_refresh_token.return_value = {
|
||||
"refresh_token": encrypted_token,
|
||||
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
|
||||
}
|
||||
|
||||
# Mock revocation response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(
|
||||
token_broker, "_get_oidc_config", return_value=mock_oidc_config
|
||||
):
|
||||
with patch.object(token_broker, "_get_http_client") as mock_client:
|
||||
mock_client.return_value.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Pre-populate cache
|
||||
await token_broker.cache.set("user1", "cached_token")
|
||||
|
||||
# Revoke access
|
||||
success = await token_broker.revoke_nextcloud_access("user1")
|
||||
|
||||
assert success is True
|
||||
# Verify token was deleted from storage
|
||||
mock_storage.delete_refresh_token.assert_called_once_with("user1")
|
||||
# Verify cache was cleared
|
||||
assert await token_broker.cache.get("user1") is None
|
||||
|
||||
async def test_validate_token_audience(self, token_broker):
|
||||
"""Test token audience validation."""
|
||||
# Create test token with audience
|
||||
test_payload = {
|
||||
"sub": "user1",
|
||||
"aud": ["nextcloud", "other-service"],
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
}
|
||||
test_token = jwt.encode(test_payload, "secret", algorithm="HS256")
|
||||
|
||||
# Should not raise for correct audience
|
||||
await token_broker._validate_token_audience(test_token, "nextcloud")
|
||||
|
||||
# Should raise for wrong audience
|
||||
with pytest.raises(ValueError, match="doesn't include wrong-audience"):
|
||||
await token_broker._validate_token_audience(test_token, "wrong-audience")
|
||||
|
||||
async def test_token_refresh_with_network_error(
|
||||
self, token_broker, mock_storage, encryption_key
|
||||
):
|
||||
"""Test handling network errors during token refresh."""
|
||||
# Setup encrypted refresh token
|
||||
fernet = Fernet(encryption_key.encode())
|
||||
encrypted_token = fernet.encrypt(b"test_refresh_token").decode()
|
||||
mock_storage.get_refresh_token.return_value = {
|
||||
"refresh_token": encrypted_token,
|
||||
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
|
||||
}
|
||||
|
||||
# Mock network error
|
||||
with patch.object(token_broker, "_get_http_client") as mock_client:
|
||||
mock_client.return_value.post = AsyncMock(
|
||||
side_effect=httpx.NetworkError("Connection failed")
|
||||
)
|
||||
|
||||
# Should return None on error
|
||||
token = await token_broker.get_nextcloud_token("user1")
|
||||
assert token is None
|
||||
|
||||
# Cache should be invalidated
|
||||
assert await token_broker.cache.get("user1") is None
|
||||
|
||||
async def test_concurrent_cache_access(self, token_broker):
|
||||
"""Test concurrent access to token cache."""
|
||||
# Pre-populate cache
|
||||
await token_broker.cache.set("user1", "token1", expires_in=300)
|
||||
await token_broker.cache.set("user2", "token2", expires_in=300)
|
||||
|
||||
# Concurrent reads
|
||||
results = await asyncio.gather(
|
||||
token_broker.cache.get("user1"),
|
||||
token_broker.cache.get("user2"),
|
||||
token_broker.cache.get("user1"),
|
||||
token_broker.cache.get("user2"),
|
||||
)
|
||||
|
||||
assert results == ["token1", "token2", "token1", "token2"]
|
||||
Vendored
+1
-1
Submodule third_party/oidc updated: e4659c79ef...b2aa75e04f
Reference in New Issue
Block a user