Merge pull request #589 from cbcoutinho/feat/docker-compose-profiles-login-flow
feat: Docker Compose profiles and Login Flow v2 integration tests
This commit is contained in:
+156
-31
@@ -1,4 +1,4 @@
|
|||||||
name: Docker Compose Action
|
name: Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -13,76 +13,201 @@ jobs:
|
|||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||||
- name: Check format
|
- name: Check format
|
||||||
run: |
|
run: uv run --frozen ruff format --diff
|
||||||
uv run --frozen ruff format --diff
|
|
||||||
- name: Linting
|
- name: Linting
|
||||||
run: |
|
run: uv run --frozen ruff check
|
||||||
uv run --frozen ruff check
|
- name: Type check
|
||||||
- name: Linting
|
run: uv run --frozen ty check -- nextcloud_mcp_server
|
||||||
run: |
|
|
||||||
uv run --frozen ty check -- nextcloud_mcp_server
|
|
||||||
|
|
||||||
|
unit-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [linting]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
- name: Install the latest version of uv
|
||||||
|
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||||
|
- name: Run unit tests
|
||||||
|
run: uv run pytest -v -m unit -o "addopts=-p no:asyncio"
|
||||||
|
|
||||||
integration-test:
|
integration-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: [linting]
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
nextcloud_version:
|
||||||
|
- "31"
|
||||||
|
- "32"
|
||||||
|
# - "33" # Disabled until all upstream apps support NC 33
|
||||||
|
mode:
|
||||||
|
- "single-user"
|
||||||
|
- "multi-user-basic"
|
||||||
|
- "oauth"
|
||||||
|
- "login-flow"
|
||||||
|
include:
|
||||||
|
# Version-specific image pins — Renovate updates these via customManagers
|
||||||
|
# renovate: datasource=docker depName=docker.io/library/nextcloud
|
||||||
|
- nextcloud_version: "31"
|
||||||
|
nextcloud_image: "docker.io/library/nextcloud:31.0.8@sha256:92bc503ea0c19789f402b0469ecfb8df1ffea81e2bf90a45bba39063a626aa00"
|
||||||
|
# renovate: datasource=docker depName=docker.io/library/nextcloud
|
||||||
|
- nextcloud_version: "32"
|
||||||
|
nextcloud_image: "docker.io/library/nextcloud:32.0.6@sha256:5c4e09f72f096cd68379a8ae69f71e61d13da5a07430fc4a17c702a14e6a4267"
|
||||||
|
# renovate: datasource=docker depName=docker.io/library/nextcloud
|
||||||
|
# Disabled until all upstream apps support NC 33
|
||||||
|
# - nextcloud_version: "33"
|
||||||
|
# nextcloud_image: "docker.io/library/nextcloud:33.0.0@sha256:ff2cbaab14c85e587b5541e3aff4216a8a484e06424ebae661581937c0c8da0c"
|
||||||
|
|
||||||
|
# Mode-specific properties
|
||||||
|
- mode: single-user
|
||||||
|
profile: single-user
|
||||||
|
markers: "(smoke and not oauth and not keycloak and not login_flow and not multi_user_basic) or (integration and not oauth and not keycloak and not login_flow and not multi_user_basic)"
|
||||||
|
wait-port: 8000
|
||||||
|
mcp-internal-url: "http://mcp:8000"
|
||||||
|
needs-playwright: false
|
||||||
|
extra-args: >-
|
||||||
|
--ignore=tests/integration/test_qdrant_collection_creation.py
|
||||||
|
--ignore=tests/rag_evaluation/
|
||||||
|
|
||||||
|
- mode: multi-user-basic
|
||||||
|
profile: multi-user-basic
|
||||||
|
markers: "multi_user_basic"
|
||||||
|
wait-port: 8003
|
||||||
|
mcp-internal-url: "http://mcp-multi-user-basic:8000"
|
||||||
|
needs-playwright: true
|
||||||
|
extra-args: ""
|
||||||
|
|
||||||
|
- mode: oauth
|
||||||
|
profile: oauth
|
||||||
|
markers: "oauth and not keycloak"
|
||||||
|
wait-port: 8001
|
||||||
|
mcp-internal-url: "http://mcp-oauth:8001"
|
||||||
|
needs-playwright: true
|
||||||
|
extra-args: ""
|
||||||
|
|
||||||
|
- mode: login-flow
|
||||||
|
profile: login-flow
|
||||||
|
markers: "login_flow"
|
||||||
|
wait-port: 8004
|
||||||
|
mcp-internal-url: "http://mcp-login-flow:8004"
|
||||||
|
needs-playwright: true
|
||||||
|
extra-args: ""
|
||||||
|
|
||||||
|
name: integration (${{ matrix.mode }} / nc${{ matrix.nextcloud_version }})
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
submodules: 'true'
|
submodules: 'true'
|
||||||
|
|
||||||
|
- name: Set up PHP 8.4
|
||||||
###### Required to build OIDC App ######
|
if: matrix.mode != 'single-user'
|
||||||
|
|
||||||
- name: Set up php 8.4
|
|
||||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0
|
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0
|
||||||
with:
|
with:
|
||||||
php-version: 8.4
|
php-version: 8.4
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- name: Install OIDC app composer dependencies
|
# OIDC app installed from app store (dev mount removed from docker-compose.yml)
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
if: matrix.mode != 'single-user'
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Build Astrolabe app
|
||||||
|
if: matrix.mode != 'single-user'
|
||||||
run: |
|
run: |
|
||||||
cd third_party/oidc
|
cd third_party/astrolabe
|
||||||
composer install --no-dev
|
composer install --no-dev --optimize-autoloader
|
||||||
|
npm ci
|
||||||
###### Required to build OIDC App ######
|
npm run build
|
||||||
|
|
||||||
|
|
||||||
|
# Start services with the appropriate profile
|
||||||
- name: Run docker compose
|
- name: Run docker compose
|
||||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||||
with:
|
with:
|
||||||
compose-file: "./docker-compose.yml"
|
compose-file: "./docker-compose.yml"
|
||||||
#compose-flags: "--profile qdrant"
|
compose-flags: "--profile ${{ matrix.profile }}"
|
||||||
up-flags: "--build"
|
up-flags: "--build"
|
||||||
|
env:
|
||||||
|
MCP_SERVER_URL: ${{ matrix.mcp-internal-url }}
|
||||||
|
NEXTCLOUD_IMAGE: ${{ matrix.nextcloud_image }}
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||||
|
|
||||||
- name: Install Playwright dependencies
|
- name: Install Playwright
|
||||||
run: |
|
if: matrix.needs-playwright
|
||||||
uv run playwright install chromium --with-deps
|
run: uv run playwright install chromium --with-deps
|
||||||
|
|
||||||
- name: Wait for service to be ready
|
# Wait for Nextcloud to be healthy
|
||||||
|
- name: Wait for Nextcloud
|
||||||
run: |
|
run: |
|
||||||
echo "Waiting for service at http://localhost:8080/ocs/v2.php/apps/serverinfo/api/v1/info to return 401..."
|
echo "Waiting for Nextcloud at http://localhost:8080..."
|
||||||
max_attempts=60
|
max_attempts=60
|
||||||
attempt=0
|
attempt=0
|
||||||
until curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8080/ocs/v2.php/apps/serverinfo/api/v1/info | grep -q "401"; do
|
until curl -sSf http://localhost:8080/status.php 2>/dev/null | grep -q '"installed":true'; do
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
if [ $attempt -ge $max_attempts ]; then
|
if [ $attempt -ge $max_attempts ]; then
|
||||||
echo "Service did not become ready in time."
|
echo "Nextcloud did not become ready in time."
|
||||||
|
docker compose logs app
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Attempt $attempt/$max_attempts: Service not ready, sleeping for 5 seconds..."
|
echo "Attempt $attempt/$max_attempts: Not ready, sleeping 5s..."
|
||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
echo "Service is ready (returned 401)."
|
echo "Nextcloud is ready."
|
||||||
|
|
||||||
# Add subsequent steps here, e.g., running tests
|
# Wait for the MCP service to be healthy
|
||||||
- name: Run tests
|
- name: Wait for MCP service (${{ matrix.mode }})
|
||||||
|
run: |
|
||||||
|
echo "Waiting for MCP service on port ${{ matrix.wait-port }}..."
|
||||||
|
max_attempts=30
|
||||||
|
attempt=0
|
||||||
|
until curl -o /dev/null -s -w "%{http_code}\n" http://localhost:${{ matrix.wait-port }}/health 2>/dev/null | grep -qE "200|404|405"; do
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
if [ $attempt -ge $max_attempts ]; then
|
||||||
|
echo "MCP service did not become ready in time."
|
||||||
|
docker compose --profile ${{ matrix.profile }} logs
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Attempt $attempt/$max_attempts: Not ready, sleeping 5s..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "MCP service is ready on port ${{ matrix.wait-port }}."
|
||||||
|
|
||||||
|
- name: Verify OIDC configuration
|
||||||
|
if: matrix.mode == 'oauth' || matrix.mode == 'login-flow'
|
||||||
|
run: |
|
||||||
|
echo "=== OIDC Discovery ==="
|
||||||
|
curl -s http://localhost:8080/.well-known/openid-configuration | jq .
|
||||||
|
echo "=== OIDC App Status ==="
|
||||||
|
docker compose exec -T app php occ app:list --output=json 2>/dev/null | jq '.enabled.oidc // "NOT INSTALLED"'
|
||||||
|
|
||||||
|
- name: Run tests (${{ matrix.mode }})
|
||||||
env:
|
env:
|
||||||
NEXTCLOUD_HOST: "http://localhost:8080"
|
NEXTCLOUD_HOST: "http://localhost:8080"
|
||||||
NEXTCLOUD_USERNAME: "admin"
|
NEXTCLOUD_USERNAME: "admin"
|
||||||
NEXTCLOUD_PASSWORD: "admin"
|
NEXTCLOUD_PASSWORD: "admin"
|
||||||
run: |
|
run: |
|
||||||
uv run pytest -v --log-cli-level=WARN -m unit -m smoke
|
uv run pytest -v \
|
||||||
|
--log-cli-level=WARN \
|
||||||
|
-m '${{ matrix.markers }}' \
|
||||||
|
-o "addopts=-p no:asyncio" \
|
||||||
|
--timeout=300 \
|
||||||
|
${{ matrix.extra-args }}
|
||||||
|
|
||||||
|
- name: Collect service logs on failure
|
||||||
|
if: failure()
|
||||||
|
run: docker compose --profile ${{ matrix.profile }} logs --tail=500 > /tmp/docker-compose-logs.txt 2>&1
|
||||||
|
|
||||||
|
- name: Upload debug artifacts
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
|
with:
|
||||||
|
name: debug-${{ matrix.mode }}-nc${{ matrix.nextcloud_version }}
|
||||||
|
path: |
|
||||||
|
/tmp/*.png
|
||||||
|
/tmp/docker-compose-logs.txt
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ http://127.0.0.1:8000/sse
|
|||||||
http://127.0.0.1:8000/mcp
|
http://127.0.0.1:8000/mcp
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Docker Compose Profiles** (for development/testing):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile single-user up -d # Port 8000
|
||||||
|
docker compose --profile multi-user-basic up -d # Port 8003
|
||||||
|
docker compose --profile oauth up -d # Port 8001
|
||||||
|
docker compose --profile login-flow up -d # Port 8004
|
||||||
|
```
|
||||||
|
|
||||||
**Next Steps:**
|
**Next Steps:**
|
||||||
- Connect your MCP client (Claude Desktop, IDEs, `mcp dev`, etc.)
|
- Connect your MCP client (Claude Desktop, IDEs, `mcp dev`, etc.)
|
||||||
- See [docs/installation.md](docs/installation.md) for other deployment options (local, Kubernetes)
|
- See [docs/installation.md](docs/installation.md) for other deployment options (local, Kubernetes)
|
||||||
@@ -99,25 +108,33 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
|
|||||||
|
|
||||||
### Authentication Modes
|
### Authentication Modes
|
||||||
|
|
||||||
The server supports three authentication modes:
|
The server supports four authentication modes:
|
||||||
|
|
||||||
**Single-User Mode (BasicAuth):**
|
**Single-User (BasicAuth):**
|
||||||
- One set of credentials shared by all MCP clients
|
- One set of credentials shared by all MCP clients
|
||||||
- Simple setup: username + app password in environment variables
|
- Simple setup: username + app password in environment variables
|
||||||
- All clients access Nextcloud as the same user
|
- All clients access Nextcloud as the same user
|
||||||
- Best for: Personal use, development, single-user deployments
|
- Best for: Personal use, development, single-user deployments
|
||||||
|
|
||||||
**Multi-User Mode (OAuth):**
|
**Multi-User (BasicAuth Pass-Through):**
|
||||||
|
- MCP clients send credentials via Authorization header
|
||||||
|
- Server passes through to Nextcloud (stateless by default)
|
||||||
|
- Optional offline access for background operations (`ENABLE_MULTI_USER_BASIC_AUTH=true`)
|
||||||
|
- Best for: Multi-user setups without OAuth infrastructure
|
||||||
|
|
||||||
|
**Multi-User (OAuth):**
|
||||||
- Each MCP client authenticates separately with their own Nextcloud account
|
- Each MCP client authenticates separately with their own Nextcloud account
|
||||||
- Per-user scopes and permissions (clients only see tools they're authorized for)
|
- Per-user scopes and permissions (clients only see tools they're authorized for)
|
||||||
- More secure: tokens expire, credentials never shared with server
|
- More secure: tokens expire, credentials never shared with server
|
||||||
- Best for: Teams, multi-user deployments, production environments with multiple users
|
- Best for: Teams, multi-user deployments, production environments with multiple users
|
||||||
|
- Requires: Patches to the `user_oidc` app (experimental)
|
||||||
|
|
||||||
**Hybrid Mode (Multi-User BasicAuth + OAuth):**
|
**Multi-User (Login Flow v2):**
|
||||||
- MCP clients use BasicAuth (simple, stateless)
|
- Uses Nextcloud's native Login Flow v2 to obtain per-user app passwords
|
||||||
- Admin operations use OAuth (webhooks, background sync)
|
- No OAuth patches required — works with stock Nextcloud
|
||||||
- Best for: Nextcloud deployments with admin-managed webhooks and semantic search
|
- Each user authenticates via browser, server manages app passwords
|
||||||
- Requires: `ENABLE_MULTI_USER_BASIC_AUTH=true` + `ENABLE_OFFLINE_ACCESS=true`
|
- Best for: Multi-user deployments without OAuth infrastructure (`ENABLE_LOGIN_FLOW=true`)
|
||||||
|
- Experimental: See [ADR-022](docs/ADR-022-deployment-mode-consolidation.md) for details
|
||||||
|
|
||||||
See [docs/authentication.md](docs/authentication.md) for detailed setup instructions.
|
See [docs/authentication.md](docs/authentication.md) for detailed setup instructions.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euox pipefail
|
||||||
|
echo "Disabling bruteforce protection and rate limiting for dev/CI..."
|
||||||
|
php /var/www/html/occ config:system:set auth.bruteforce.protection.enabled --value=false --type=boolean
|
||||||
|
php /var/www/html/occ config:system:set ratelimit.protection.enabled --value=false --type=boolean
|
||||||
|
echo "Bruteforce protection and rate limiting disabled."
|
||||||
@@ -13,6 +13,14 @@ echo "===================================================================="
|
|||||||
echo "Configuring user_oidc provider for Keycloak..."
|
echo "Configuring user_oidc provider for Keycloak..."
|
||||||
echo "===================================================================="
|
echo "===================================================================="
|
||||||
|
|
||||||
|
# Quick check: Is keycloak service in the Docker network?
|
||||||
|
# When the keycloak profile is not active, this hostname won't resolve.
|
||||||
|
if ! getent hosts keycloak >/dev/null 2>&1; then
|
||||||
|
echo " Keycloak service not detected in Docker network (profile not active)"
|
||||||
|
echo " Skipping keycloak provider configuration"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# Wait for Keycloak to be ready and realm to be available
|
# Wait for Keycloak to be ready and realm to be available
|
||||||
echo "Waiting for Keycloak realm to be available..."
|
echo "Waiting for Keycloak realm to be available..."
|
||||||
MAX_RETRIES=30
|
MAX_RETRIES=30
|
||||||
|
|||||||
@@ -2,12 +2,30 @@
|
|||||||
|
|
||||||
set -euox pipefail
|
set -euox pipefail
|
||||||
|
|
||||||
echo "Installing Astrolabe app from app store..."
|
echo "Installing Astrolabe app..."
|
||||||
|
|
||||||
if [ -d /var/www/html/custom_apps/astrolabe ]; then
|
# Check if development astrolabe app is mounted at /opt/apps/astrolabe
|
||||||
|
if [ -d /opt/apps/astrolabe ]; then
|
||||||
|
echo "Development astrolabe app found at /opt/apps/astrolabe"
|
||||||
|
|
||||||
|
# Remove any existing astrolabe app in custom_apps (from app store or old symlink)
|
||||||
|
if [ -e /var/www/html/custom_apps/astrolabe ]; then
|
||||||
|
echo "Removing existing astrolabe in custom_apps..."
|
||||||
|
rm -rf /var/www/html/custom_apps/astrolabe
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create symlink from custom_apps to the mounted development version
|
||||||
|
# Per Nextcloud docs: apps outside server root need symlinks in server root
|
||||||
|
echo "Creating symlink: custom_apps/astrolabe -> /opt/apps/astrolabe"
|
||||||
|
ln -sf /opt/apps/astrolabe /var/www/html/custom_apps/astrolabe
|
||||||
|
|
||||||
|
echo "Enabling astrolabe app from /opt/apps (development mode via symlink)"
|
||||||
|
php /var/www/html/occ app:enable astrolabe
|
||||||
|
elif [ -d /var/www/html/custom_apps/astrolabe ]; then
|
||||||
echo "astrolabe app directory found in custom_apps (already installed)"
|
echo "astrolabe app directory found in custom_apps (already installed)"
|
||||||
php /var/www/html/occ app:enable astrolabe
|
php /var/www/html/occ app:enable astrolabe
|
||||||
else
|
else
|
||||||
|
echo "astrolabe app not found, installing from app store..."
|
||||||
php /var/www/html/occ app:install astrolabe
|
php /var/www/html/occ app:install astrolabe
|
||||||
php /var/www/html/occ app:enable astrolabe
|
php /var/www/html/occ app:enable astrolabe
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# The MCP multi-user BasicAuth service runs on port 8000 inside the container
|
if [ -z "${MCP_SERVER_URL:-}" ]; then
|
||||||
# From Nextcloud's perspective (inside Docker network), we reach it via service name
|
echo "MCP_SERVER_URL not set, skipping Astrolabe MCP server URL configuration"
|
||||||
MCP_SERVER_URL="${MCP_SERVER_URL:-http://mcp-multi-user-basic:8000}"
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Configuring MCP server URL: $MCP_SERVER_URL"
|
echo "Configuring MCP server URL: $MCP_SERVER_URL"
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ annotations:
|
|||||||
# Grafana dashboard support
|
# Grafana dashboard support
|
||||||
grafana_dashboard: "true"
|
grafana_dashboard: "true"
|
||||||
grafana_dashboard_folder: "Nextcloud MCP"
|
grafana_dashboard_folder: "Nextcloud MCP"
|
||||||
|
artifacthub.io/changes: |
|
||||||
|
- kind: added
|
||||||
|
description: Login Flow v2 auth mode for Helm chart (ADR-022)
|
||||||
|
- kind: added
|
||||||
|
description: Multi-user BasicAuth guidance in post-install NOTES
|
||||||
|
- kind: added
|
||||||
|
description: Version and changelog info in post-install NOTES
|
||||||
|
- kind: changed
|
||||||
|
description: Updated appVersion to 0.64.4
|
||||||
dependencies:
|
dependencies:
|
||||||
- name: qdrant
|
- name: qdrant
|
||||||
version: "1.17.0"
|
version: "1.17.0"
|
||||||
|
|||||||
@@ -57,6 +57,28 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
|
|||||||
|
|
||||||
IMPORTANT: OAuth mode is experimental and requires patches to the user_oidc app.
|
IMPORTANT: OAuth mode is experimental and requires patches to the user_oidc app.
|
||||||
See: https://github.com/cbcoutinho/nextcloud-mcp-server#authentication
|
See: https://github.com/cbcoutinho/nextcloud-mcp-server#authentication
|
||||||
|
{{- else if eq .Values.auth.mode "multi-user-basic" }}
|
||||||
|
|
||||||
|
3. Multi-User BasicAuth Mode (Pass-Through):
|
||||||
|
- Users provide credentials via Authorization header
|
||||||
|
- Connected to: {{ .Values.nextcloud.host }}
|
||||||
|
{{- if .Values.auth.multiUserBasic.enableOfflineAccess }}
|
||||||
|
- Offline access: Enabled (background operations with app passwords)
|
||||||
|
- Token storage: {{ .Values.auth.multiUserBasic.tokenStorageDb }}
|
||||||
|
{{- else }}
|
||||||
|
- Offline access: Disabled (stateless pass-through)
|
||||||
|
{{- end }}
|
||||||
|
{{- else if eq .Values.auth.mode "login-flow" }}
|
||||||
|
|
||||||
|
3. Login Flow v2 Mode (Experimental, ADR-022):
|
||||||
|
- Server URL: {{ include "nextcloud-mcp-server.mcpServerUrl" . }}
|
||||||
|
- Connected to: {{ .Values.nextcloud.host }}
|
||||||
|
- Token storage: {{ .Values.auth.loginFlow.tokenStorageDb }}
|
||||||
|
|
||||||
|
Users authenticate via Nextcloud's native Login Flow v2 — no OAuth patches required.
|
||||||
|
Each user gets a per-device app password managed by the MCP server.
|
||||||
|
|
||||||
|
IMPORTANT: Login Flow v2 is experimental. See ADR-022 for details.
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{- if .Values.documentProcessing.enabled }}
|
{{- if .Values.documentProcessing.enabled }}
|
||||||
@@ -169,6 +191,12 @@ After migrating, remove the deprecated settings:
|
|||||||
================================================================================
|
================================================================================
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
Deployed version:
|
||||||
|
- Chart: {{ .Chart.Version }}
|
||||||
|
- App: {{ .Chart.AppVersion }}
|
||||||
|
|
||||||
|
Full changelog: https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/charts/nextcloud-mcp-server/CHANGELOG.md
|
||||||
|
|
||||||
For more information and documentation:
|
For more information and documentation:
|
||||||
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
|
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||||
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
||||||
|
|||||||
@@ -105,6 +105,17 @@ Create the name of the secret to use for OAuth
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create the name of the secret to use for Login Flow v2
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.loginFlowSecretName" -}}
|
||||||
|
{{- if .Values.auth.loginFlow.existingSecret }}
|
||||||
|
{{- .Values.auth.loginFlow.existingSecret }}
|
||||||
|
{{- else }}
|
||||||
|
{{- include "nextcloud-mcp-server.fullname" . }}-login-flow
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Create the name of the PVC to use for OAuth storage
|
Create the name of the PVC to use for OAuth storage
|
||||||
*/}}
|
*/}}
|
||||||
@@ -147,6 +158,8 @@ Checks new dataStorage.enabled OR legacy persistence configs
|
|||||||
true
|
true
|
||||||
{{- else if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled -}}
|
{{- else if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled -}}
|
||||||
true
|
true
|
||||||
|
{{- else if eq .Values.auth.mode "login-flow" -}}
|
||||||
|
true
|
||||||
{{- else if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled -}}
|
{{- else if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled -}}
|
||||||
true
|
true
|
||||||
{{- else -}}
|
{{- else -}}
|
||||||
|
|||||||
@@ -46,8 +46,10 @@ spec:
|
|||||||
args:
|
args:
|
||||||
- "--transport"
|
- "--transport"
|
||||||
- "{{ .Values.mcp.transport }}"
|
- "{{ .Values.mcp.transport }}"
|
||||||
{{- if eq .Values.auth.mode "oauth" }}
|
{{- if or (eq .Values.auth.mode "oauth") (eq .Values.auth.mode "login-flow") }}
|
||||||
- "--oauth"
|
- "--oauth"
|
||||||
|
{{- end }}
|
||||||
|
{{- if eq .Values.auth.mode "oauth" }}
|
||||||
- "--oauth-token-type"
|
- "--oauth-token-type"
|
||||||
- "{{ .Values.auth.oauth.tokenType }}"
|
- "{{ .Values.auth.oauth.tokenType }}"
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -134,6 +136,21 @@ spec:
|
|||||||
name: {{ include "nextcloud-mcp-server.oauthSecretName" . }}
|
name: {{ include "nextcloud-mcp-server.oauthSecretName" . }}
|
||||||
key: {{ .Values.auth.oauth.clientSecretKey }}
|
key: {{ .Values.auth.oauth.clientSecretKey }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- else if eq .Values.auth.mode "login-flow" }}
|
||||||
|
# Login Flow v2 mode (ADR-022)
|
||||||
|
- name: ENABLE_LOGIN_FLOW
|
||||||
|
value: "true"
|
||||||
|
- 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: TOKEN_STORAGE_DB
|
||||||
|
value: {{ .Values.auth.loginFlow.tokenStorageDb | quote }}
|
||||||
|
- name: TOKEN_ENCRYPTION_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "nextcloud-mcp-server.loginFlowSecretName" . }}
|
||||||
|
key: {{ .Values.auth.loginFlow.tokenEncryptionKeyKey }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Values.documentProcessing.enabled }}
|
{{- if .Values.documentProcessing.enabled }}
|
||||||
# Document processing
|
# Document processing
|
||||||
@@ -282,7 +299,7 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: tmp
|
- name: tmp
|
||||||
mountPath: /tmp
|
mountPath: /tmp
|
||||||
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled }}
|
{{- if or (and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled) (eq .Values.auth.mode "login-flow") }}
|
||||||
- name: oauth-storage
|
- name: oauth-storage
|
||||||
mountPath: /app/.oauth
|
mountPath: /app/.oauth
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -294,7 +311,7 @@ spec:
|
|||||||
volumes:
|
volumes:
|
||||||
- name: tmp
|
- name: tmp
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled }}
|
{{- if or (and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled) (eq .Values.auth.mode "login-flow") }}
|
||||||
- name: oauth-storage
|
- name: oauth-storage
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ spec:
|
|||||||
storage: {{ .Values.auth.oauth.persistence.size }}
|
storage: {{ .Values.auth.oauth.persistence.size }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
---
|
---
|
||||||
|
{{- if eq .Values.auth.mode "login-flow" }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ include "nextcloud-mcp-server.fullname" . }}-oauth-storage
|
||||||
|
labels:
|
||||||
|
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 100Mi
|
||||||
|
{{- end }}
|
||||||
|
---
|
||||||
{{- if and (eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true") (not .Values.dataStorage.existingClaim) }}
|
{{- if and (eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true") (not .Values.dataStorage.existingClaim) }}
|
||||||
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
|
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
|
||||||
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
|
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
|
||||||
|
|||||||
@@ -45,3 +45,17 @@ data:
|
|||||||
{{ .Values.auth.oauth.clientSecretKey }}: {{ .Values.auth.oauth.clientSecret | b64enc | quote }}
|
{{ .Values.auth.oauth.clientSecretKey }}: {{ .Values.auth.oauth.clientSecret | b64enc | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
---
|
||||||
|
{{- if eq .Values.auth.mode "login-flow" }}
|
||||||
|
{{- if not .Values.auth.loginFlow.existingSecret }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "nextcloud-mcp-server.fullname" . }}-login-flow
|
||||||
|
labels:
|
||||||
|
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
{{ .Values.auth.loginFlow.tokenEncryptionKeyKey }}: {{ .Values.auth.loginFlow.tokenEncryptionKey | b64enc | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -40,12 +40,13 @@ nextcloud:
|
|||||||
publicIssuerUrl: ""
|
publicIssuerUrl: ""
|
||||||
|
|
||||||
# Authentication configuration
|
# Authentication configuration
|
||||||
# Choose one mode: "basic", "multi-user-basic", or "oauth"
|
# Choose one mode: "basic", "multi-user-basic", "oauth", or "login-flow"
|
||||||
auth:
|
auth:
|
||||||
# Authentication mode: "basic", "multi-user-basic", or "oauth"
|
# Authentication mode: "basic", "multi-user-basic", "oauth", or "login-flow"
|
||||||
# basic: Single-user with username/password (recommended for personal use)
|
# basic: Single-user with username/password (recommended for personal use)
|
||||||
# multi-user-basic: Multi-user with BasicAuth pass-through (credentials in request headers)
|
# multi-user-basic: Multi-user with BasicAuth pass-through (credentials in request headers)
|
||||||
# oauth: Uses OAuth2/OIDC (experimental, requires patches)
|
# oauth: Uses OAuth2/OIDC (experimental, requires patches)
|
||||||
|
# login-flow: Multi-user via Nextcloud Login Flow v2 (experimental, ADR-022)
|
||||||
mode: basic
|
mode: basic
|
||||||
|
|
||||||
# Basic authentication settings (single-user mode)
|
# Basic authentication settings (single-user mode)
|
||||||
@@ -139,6 +140,21 @@ auth:
|
|||||||
# Use existing PVC
|
# Use existing PVC
|
||||||
existingClaim: ""
|
existingClaim: ""
|
||||||
|
|
||||||
|
# Login Flow v2 settings (experimental, ADR-022)
|
||||||
|
# Uses Nextcloud's native Login Flow v2 to obtain app passwords per user.
|
||||||
|
# No OAuth patches required — works with stock Nextcloud.
|
||||||
|
# See: docs/ADR-022-deployment-mode-consolidation.md
|
||||||
|
loginFlow:
|
||||||
|
# Token encryption key (required, ignored if existingSecret is set)
|
||||||
|
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
tokenEncryptionKey: ""
|
||||||
|
# Token storage database path
|
||||||
|
tokenStorageDb: "/app/data/tokens.db"
|
||||||
|
# Use existing secret instead of creating one
|
||||||
|
existingSecret: ""
|
||||||
|
# Key in the existing secret
|
||||||
|
tokenEncryptionKeyKey: "token_encryption_key"
|
||||||
|
|
||||||
# Data Storage Configuration
|
# Data Storage Configuration
|
||||||
# Persistent volume for /app/data directory
|
# Persistent volume for /app/data directory
|
||||||
# Used for: token databases, qdrant persistent storage, and any app data
|
# Used for: token databases, qdrant persistent storage, and any app data
|
||||||
@@ -147,6 +163,7 @@ dataStorage:
|
|||||||
# Enable persistent storage for /app/data
|
# Enable persistent storage for /app/data
|
||||||
# Set to true when using:
|
# Set to true when using:
|
||||||
# - Multi-user basic auth with offline access (stores tokens.db)
|
# - Multi-user basic auth with offline access (stores tokens.db)
|
||||||
|
# - Login flow mode (stores app passwords in tokens.db)
|
||||||
# - Qdrant persistent mode (stores vector database)
|
# - Qdrant persistent mode (stores vector database)
|
||||||
# - Any feature requiring persistent app data
|
# - Any feature requiring persistent app data
|
||||||
# Set to false for basic auth without persistence (uses emptyDir)
|
# Set to false for basic auth without persistence (uses emptyDir)
|
||||||
|
|||||||
+57
-4
@@ -23,20 +23,21 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: docker.io/library/nextcloud:32.0.6@sha256:5c4e09f72f096cd68379a8ae69f71e61d13da5a07430fc4a17c702a14e6a4267
|
image: ${NEXTCLOUD_IMAGE:-docker.io/library/nextcloud:32.0.6@sha256:5c4e09f72f096cd68379a8ae69f71e61d13da5a07430fc4a17c702a14e6a4267}
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8080:80
|
- 127.0.0.1:8080:80
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- db
|
- db
|
||||||
- keycloak
|
|
||||||
volumes:
|
volumes:
|
||||||
- nextcloud:/var/www/html
|
- nextcloud:/var/www/html
|
||||||
- ./app-hooks:/docker-entrypoint-hooks.d:ro
|
- ./app-hooks:/docker-entrypoint-hooks.d:ro
|
||||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
# 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
|
# The post-installation hook will register /opt/apps as an additional app directory
|
||||||
#- ./third_party:/opt/apps:ro
|
#- ./third_party:/opt/apps:ro
|
||||||
|
- ./third_party/astrolabe:/opt/apps/astrolabe:ro
|
||||||
|
#- ./third_party/oidc:/opt/apps/oidc:ro # Use app store version; dev mount lacks vendor/
|
||||||
environment:
|
environment:
|
||||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||||
- NEXTCLOUD_ADMIN_USER=admin
|
- NEXTCLOUD_ADMIN_USER=admin
|
||||||
@@ -46,6 +47,7 @@ services:
|
|||||||
- MYSQL_USER=nextcloud
|
- MYSQL_USER=nextcloud
|
||||||
- MYSQL_HOST=db
|
- MYSQL_HOST=db
|
||||||
- REDIS_HOST=redis
|
- REDIS_HOST=redis
|
||||||
|
- MCP_SERVER_URL=${MCP_SERVER_URL:-}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"]
|
test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -123,6 +125,8 @@ services:
|
|||||||
# Tune these based on your embedding model and content type
|
# Tune these based on your embedding model and content type
|
||||||
# - DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default: 512)
|
# - DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default: 512)
|
||||||
# - DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words (default: 50, recommended: 10-20% of chunk size)
|
# - DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words (default: 50, recommended: 10-20% of chunk size)
|
||||||
|
profiles:
|
||||||
|
- single-user
|
||||||
|
|
||||||
mcp-multi-user-basic:
|
mcp-multi-user-basic:
|
||||||
build: .
|
build: .
|
||||||
@@ -142,7 +146,9 @@ services:
|
|||||||
- ENABLE_BACKGROUND_OPERATIONS=true
|
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
|
|
||||||
# Token storage (required for middleware initialization)
|
# Token storage (required for middleware initialization)
|
||||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
# DEVELOPMENT ONLY - generate a fresh key for production:
|
||||||
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
- TOKEN_ENCRYPTION_KEY=fqqI4G51yBCOcu9cvv6wCUJB7sf_CK2za5ClC6b86yY=
|
||||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
- ENABLE_SEMANTIC_SEARCH=true
|
- ENABLE_SEMANTIC_SEARCH=true
|
||||||
@@ -157,6 +163,8 @@ services:
|
|||||||
# NO admin credentials - credentials come from client Authorization header
|
# NO admin credentials - credentials come from client Authorization header
|
||||||
volumes:
|
volumes:
|
||||||
- multi-user-basic-data:/app/data
|
- multi-user-basic-data:/app/data
|
||||||
|
profiles:
|
||||||
|
- multi-user-basic
|
||||||
|
|
||||||
mcp-oauth:
|
mcp-oauth:
|
||||||
build: .
|
build: .
|
||||||
@@ -179,7 +187,7 @@ services:
|
|||||||
|
|
||||||
# Refresh token storage (ADR-002 Tier 1)
|
# Refresh token storage (ADR-002 Tier 1)
|
||||||
- ENABLE_BACKGROUND_OPERATIONS=true
|
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
- TOKEN_ENCRYPTION_KEY=Qh60VwZQsM7CLtSMunzC0gIGPBT948S6VSawUkODtvU=
|
||||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
# ADR-005: Multi-audience mode (default - ENABLE_TOKEN_EXCHANGE=false)
|
# ADR-005: Multi-audience mode (default - ENABLE_TOKEN_EXCHANGE=false)
|
||||||
@@ -205,6 +213,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- oauth-client-storage:/app/.oauth
|
- oauth-client-storage:/app/.oauth
|
||||||
- oauth-tokens:/app/data
|
- oauth-tokens:/app/data
|
||||||
|
profiles:
|
||||||
|
- oauth
|
||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:26.5.4@sha256:ae8efb0d218d8921334b03a2dbee7069a0b868240691c50a3ffc9f42fabba8b4
|
image: quay.io/keycloak/keycloak:26.5.4@sha256:ae8efb0d218d8921334b03a2dbee7069a0b868240691c50a3ffc9f42fabba8b4
|
||||||
@@ -227,6 +237,8 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 30
|
retries: 30
|
||||||
|
profiles:
|
||||||
|
- keycloak
|
||||||
|
|
||||||
mcp-keycloak:
|
mcp-keycloak:
|
||||||
build: .
|
build: .
|
||||||
@@ -272,6 +284,45 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- keycloak-tokens:/app/data
|
- keycloak-tokens:/app/data
|
||||||
- keycloak-oauth-storage:/app/.oauth
|
- keycloak-oauth-storage:/app/.oauth
|
||||||
|
profiles:
|
||||||
|
- keycloak
|
||||||
|
|
||||||
|
# Login Flow v2 mode (ADR-022)
|
||||||
|
# Test with: docker compose --profile login-flow up --build -d
|
||||||
|
mcp-login-flow:
|
||||||
|
build: .
|
||||||
|
restart: always
|
||||||
|
# --oauth enables the OAuth/OIDC identity layer that Login Flow v2 builds on
|
||||||
|
# (user identity via OAuth session, Nextcloud access via app passwords)
|
||||||
|
command: ["--transport", "streamable-http", "--oauth", "--port", "8004"]
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:8004:8004
|
||||||
|
environment:
|
||||||
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
|
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8004
|
||||||
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# Login Flow v2 (ADR-022)
|
||||||
|
- ENABLE_LOGIN_FLOW=true
|
||||||
|
|
||||||
|
# Token storage (required for app password + session persistence)
|
||||||
|
# DEVELOPMENT ONLY - generate a fresh key for production:
|
||||||
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
- TOKEN_ENCRYPTION_KEY=rxJvkBf7ZBjZZDL4a1sSqjhmjawhmbRMSOGfK8HDyKU=
|
||||||
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
|
# Semantic search
|
||||||
|
- ENABLE_SEMANTIC_SEARCH=true
|
||||||
|
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||||
|
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||||
|
volumes:
|
||||||
|
- login-flow-data:/app/data
|
||||||
|
- login-flow-oauth-storage:/app/.oauth
|
||||||
|
profiles:
|
||||||
|
- login-flow
|
||||||
|
|
||||||
# Smithery stateless deployment mode (ADR-016)
|
# Smithery stateless deployment mode (ADR-016)
|
||||||
# Test with: docker compose --profile smithery up smithery
|
# Test with: docker compose --profile smithery up smithery
|
||||||
@@ -318,6 +369,8 @@ volumes:
|
|||||||
oauth-tokens:
|
oauth-tokens:
|
||||||
keycloak-tokens:
|
keycloak-tokens:
|
||||||
keycloak-oauth-storage:
|
keycloak-oauth-storage:
|
||||||
|
login-flow-data:
|
||||||
|
login-flow-oauth-storage:
|
||||||
qdrant-data:
|
qdrant-data:
|
||||||
mcp-data:
|
mcp-data:
|
||||||
multi-user-basic-data:
|
multi-user-basic-data:
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# ADR-023: OAuth Authorization Server Proxy
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-03-02
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
When the MCP server operates in OAuth mode (e.g., `mcp-login-flow` profile), MCP clients like Claude Code need to authenticate before calling any tools. The server advertises itself as an OAuth Protected Resource via RFC 9728 (Protected Resource Metadata / PRM), which tells clients where to find the Authorization Server.
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
The original design used a **pass-through** pattern for Flow 1 (client authentication):
|
||||||
|
|
||||||
|
1. PRM at `/.well-known/oauth-protected-resource` pointed `authorization_servers` to Nextcloud's public URL
|
||||||
|
2. Claude Code performed OIDC discovery on Nextcloud, used DCR to register its own client, and obtained tokens directly from Nextcloud
|
||||||
|
3. Tokens issued by Nextcloud had Claude Code's `client_id` as the `aud` (audience) claim
|
||||||
|
|
||||||
|
This caused an audience mismatch:
|
||||||
|
|
||||||
|
```
|
||||||
|
Token rejected: Missing MCP audience.
|
||||||
|
Got klehQp8uHCK9fu... (Claude Code's client_id),
|
||||||
|
need 8ilzB5ZPWr2Qt4... (MCP server's client_id) or http://localhost:8004
|
||||||
|
```
|
||||||
|
|
||||||
|
The `_has_mcp_audience()` check in `unified_verifier.py` correctly requires tokens to contain either the MCP server's `client_id` or its URL as the audience — but tokens obtained directly from Nextcloud by a third-party client will never have that audience.
|
||||||
|
|
||||||
|
This meant Claude Code could never authenticate → could never call `nc_auth_provision_access` → Login Flow v2 never triggered → the server was unusable.
|
||||||
|
|
||||||
|
### Why Not Just Relax Audience Validation?
|
||||||
|
|
||||||
|
Audience validation exists for security (RFC 7519 §4.1.3). Removing it would allow any valid Nextcloud token to access the MCP server, including tokens issued for completely different purposes.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Make the MCP server act as its own **OAuth Authorization Server proxy** (intermediary pattern). The MCP server advertises itself as the AS, handles client registration and authorization, but proxies the actual authentication to Nextcloud using its own credentials. This ensures all tokens have the correct audience.
|
||||||
|
|
||||||
|
### Flow Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Client MCP Server (AS Proxy) Nextcloud (IdP)
|
||||||
|
| | |
|
||||||
|
|-- POST /oauth/register ----->| ---- proxy DCR --------------->|
|
||||||
|
|<---- client_id, etc. --------|<---- client_id, etc. ----------|
|
||||||
|
| | |
|
||||||
|
|-- GET /oauth/authorize ----->| (store client params) |
|
||||||
|
| (client_id, redirect, | redirect with MCP's client_id |
|
||||||
|
| code_challenge, state) |------- GET /authorize -------->|
|
||||||
|
| | (MCP client_id, MCP callback) |
|
||||||
|
| | |
|
||||||
|
| | [user authenticates] |
|
||||||
|
| | |
|
||||||
|
| |<------ code + state -----------|
|
||||||
|
| | (exchange code server-side) |
|
||||||
|
| |------- POST /token ----------->|
|
||||||
|
| | (code, MCP client_id+secret) |
|
||||||
|
| |<------ NC token (aud=MCP) -----|
|
||||||
|
| | |
|
||||||
|
| | (generate proxy_code, store |
|
||||||
|
| | mapping to NC token) |
|
||||||
|
|<-- redirect to client -------| |
|
||||||
|
| (proxy_code, state) | |
|
||||||
|
| | |
|
||||||
|
|-- POST /oauth/token -------->| (verify PKCE, lookup code) |
|
||||||
|
| (proxy_code, code_verifier) | return stored NC token |
|
||||||
|
|<---- access_token -----------| |
|
||||||
|
| | |
|
||||||
|
|-- POST /mcp (Bearer token) ->| verify_access_token() |
|
||||||
|
| (NC token with aud=MCP ✓) | _has_mcp_audience() → PASS |
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Design Decisions
|
||||||
|
|
||||||
|
#### 1. PKCE Handling — Local Verification
|
||||||
|
|
||||||
|
The MCP server receives the client's `code_challenge` but does **not** forward it to Nextcloud. Instead:
|
||||||
|
|
||||||
|
- **Nextcloud side**: MCP server authenticates as a confidential client (`client_id` + `client_secret`), so PKCE is not required
|
||||||
|
- **Client side**: MCP server verifies PKCE locally when the client exchanges the proxy code at `/oauth/token`
|
||||||
|
|
||||||
|
This avoids the impossible situation where the server would need the `code_verifier` to exchange code with Nextcloud but doesn't have it (only the client does).
|
||||||
|
|
||||||
|
#### 2. In-Memory Proxy Code Storage
|
||||||
|
|
||||||
|
Proxy codes (the authorization codes issued by the AS proxy to clients) use in-memory storage rather than SQLite because:
|
||||||
|
|
||||||
|
- They have a 60-second TTL
|
||||||
|
- They are single-use (deleted on exchange)
|
||||||
|
- They only exist during the brief OAuth flow
|
||||||
|
- The MCP server is single-instance
|
||||||
|
|
||||||
|
#### 3. PRM Points to MCP Server
|
||||||
|
|
||||||
|
The `authorization_servers` field in the PRM response now points to the MCP server URL instead of Nextcloud's public URL. This is what triggers the entire proxy flow — clients discover the MCP server as their AS.
|
||||||
|
|
||||||
|
#### 4. DCR Proxy
|
||||||
|
|
||||||
|
Client registration requests at `/oauth/register` are proxied to Nextcloud's DCR endpoint. The resulting `client_id` is stored in the local `ClientRegistry` so that `/oauth/authorize` can validate it. The client receives the same DCR response it would get from Nextcloud directly.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### 1. Relax Audience Validation
|
||||||
|
|
||||||
|
Remove `_has_mcp_audience()` check entirely. **Rejected**: Violates RFC 7519 security model.
|
||||||
|
|
||||||
|
### 2. Client Pre-Registration
|
||||||
|
|
||||||
|
Require clients to register directly with Nextcloud and configure the MCP server with their `client_id`. **Rejected**: Poor UX, doesn't work with DCR-based clients like Claude Code.
|
||||||
|
|
||||||
|
### 3. Token Exchange (RFC 8693)
|
||||||
|
|
||||||
|
The MCP server could accept any Nextcloud token and exchange it for one with the correct audience. **Rejected**: Nextcloud's OIDC app doesn't support RFC 8693 token exchange. This was already explored in ADR-005.
|
||||||
|
|
||||||
|
### 4. Custom Audience Configuration
|
||||||
|
|
||||||
|
Add configuration to accept specific external `client_id` values as valid audiences. **Rejected**: Requires manual configuration per client, doesn't scale with DCR.
|
||||||
|
|
||||||
|
## New Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/.well-known/oauth-authorization-server` | GET | RFC 8414 AS metadata |
|
||||||
|
| `/oauth/authorize` | GET | Authorization (modified: intermediary, not pass-through) |
|
||||||
|
| `/oauth/token` | POST | Token exchange (proxy codes + refresh token proxy) |
|
||||||
|
| `/oauth/register` | POST | DCR proxy to Nextcloud |
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `nextcloud_mcp_server/auth/oauth_routes.py` | New: `oauth_as_metadata`, `oauth_register_proxy`, `oauth_token_endpoint`, `_oauth_callback_as_proxy`. Modified: `oauth_authorize` (intermediary pattern), `oauth_callback` (AS proxy routing) |
|
||||||
|
| `nextcloud_mcp_server/app.py` | New routes, PRM `authorization_servers` → MCP server URL, `app.state.supported_scopes` |
|
||||||
|
| `nextcloud_mcp_server/auth/client_registry.py` | New: `register_proxy_client()`, wildcard scope support |
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Tokens always have the correct audience — `_has_mcp_audience()` passes
|
||||||
|
- Works with any MCP client that implements RFC 9728 (PRM) discovery
|
||||||
|
- No changes needed to Nextcloud's OIDC configuration
|
||||||
|
- DCR still works transparently (clients register via proxy)
|
||||||
|
- Existing Flow 2 (resource provisioning) and browser login are unaffected
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- MCP server is now stateful during the OAuth flow (in-memory proxy codes)
|
||||||
|
- Extra network hop for token exchange (MCP server → Nextcloud → back)
|
||||||
|
- Token refresh requires proxying through the MCP server
|
||||||
|
- Single-instance limitation for proxy code storage (acceptable for current deployment model)
|
||||||
|
|
||||||
|
### Risks
|
||||||
|
|
||||||
|
- In-memory proxy codes are lost on server restart (mitigated by 60s TTL — user just retries)
|
||||||
|
- Discovery endpoint fetch during OAuth flow adds latency (could be cached)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [RFC 8414 — OAuth 2.0 Authorization Server Metadata](https://tools.ietf.org/html/rfc8414)
|
||||||
|
- [RFC 9728 — OAuth 2.0 Protected Resource Metadata](https://tools.ietf.org/html/rfc9728)
|
||||||
|
- [RFC 7636 — PKCE](https://tools.ietf.org/html/rfc7636)
|
||||||
|
- [RFC 7591 — Dynamic Client Registration](https://tools.ietf.org/html/rfc7591)
|
||||||
|
- ADR-004 — MCP Application OAuth (progressive consent architecture)
|
||||||
|
- ADR-005 — Token Audience Validation
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Add scopes and login flow sessions for Login Flow v2
|
||||||
|
|
||||||
|
This migration adds support for:
|
||||||
|
1. Scoped app passwords (scopes column + username column on app_passwords)
|
||||||
|
2. Login Flow v2 session tracking (login_flow_sessions table)
|
||||||
|
|
||||||
|
Nullable scopes preserves backward compat: NULL = legacy app password = all scopes allowed.
|
||||||
|
|
||||||
|
Revision ID: 003
|
||||||
|
Revises: 002
|
||||||
|
Create Date: 2026-02-27 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "003"
|
||||||
|
down_revision = "002"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add scopes/username to app_passwords and create login_flow_sessions."""
|
||||||
|
|
||||||
|
# Add scopes column (nullable JSON array, NULL = all scopes allowed)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE app_passwords ADD COLUMN scopes TEXT
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add username column (Nextcloud loginName from Login Flow v2)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE app_passwords ADD COLUMN username TEXT
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Login Flow v2 session tracking
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS login_flow_sessions (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
encrypted_poll_token BLOB NOT NULL,
|
||||||
|
poll_endpoint TEXT NOT NULL,
|
||||||
|
requested_scopes TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Index for efficient cleanup of expired sessions
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_flow_sessions_expires
|
||||||
|
ON login_flow_sessions(expires_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop login_flow_sessions and remove added columns."""
|
||||||
|
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_login_flow_sessions_expires")
|
||||||
|
op.execute("DROP TABLE IF EXISTS login_flow_sessions")
|
||||||
|
|
||||||
|
# SQLite doesn't support DROP COLUMN before 3.35.0
|
||||||
|
# Recreate app_passwords without the new columns
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE app_passwords_backup (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
encrypted_password BLOB NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO app_passwords_backup (user_id, encrypted_password, created_at, updated_at)
|
||||||
|
SELECT user_id, encrypted_password, created_at, updated_at FROM app_passwords
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("DROP TABLE app_passwords")
|
||||||
|
op.execute("ALTER TABLE app_passwords_backup RENAME TO app_passwords")
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_app_passwords_updated
|
||||||
|
ON app_passwords(updated_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
@@ -11,6 +11,12 @@ This package is organized into modules by domain:
|
|||||||
- visualization.py: Search and PDF visualization endpoints
|
- visualization.py: Search and PDF visualization endpoints
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.api.access import (
|
||||||
|
get_user_access,
|
||||||
|
list_supported_scopes,
|
||||||
|
update_user_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
# Re-export all public functions for backward compatibility
|
# Re-export all public functions for backward compatibility
|
||||||
from nextcloud_mcp_server.api.management import (
|
from nextcloud_mcp_server.api.management import (
|
||||||
__version__,
|
__version__,
|
||||||
@@ -44,6 +50,10 @@ from nextcloud_mcp_server.api.webhooks import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
# Access endpoints (from access.py)
|
||||||
|
"get_user_access",
|
||||||
|
"update_user_scopes",
|
||||||
|
"list_supported_scopes",
|
||||||
# Version
|
# Version
|
||||||
"__version__",
|
"__version__",
|
||||||
# Shared helpers (from management.py)
|
# Shared helpers (from management.py)
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Access and scope management API endpoints.
|
||||||
|
|
||||||
|
Provides REST API endpoints for querying and managing user access status
|
||||||
|
and application-level scopes for Login Flow v2 mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
|
||||||
|
from nextcloud_mcp_server.api.passwords import (
|
||||||
|
_extract_basic_auth,
|
||||||
|
_get_app_password_storage,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.auth.scope_authorization import invalidate_scope_cache
|
||||||
|
from nextcloud_mcp_server.models.auth import ALL_SUPPORTED_SCOPES
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_access(request: Request) -> JSONResponse:
|
||||||
|
"""GET /api/v1/users/{user_id}/access - Get user's provisioned access and scopes.
|
||||||
|
|
||||||
|
Returns the user's current provisioning status, granted scopes, and metadata.
|
||||||
|
Requires BasicAuth with the user's credentials.
|
||||||
|
"""
|
||||||
|
path_user_id = request.path_params.get("user_id")
|
||||||
|
if not path_user_id:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Missing user_id in path"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
username, _, error_response = _extract_basic_auth(request, path_user_id)
|
||||||
|
if error_response is not None:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage = await _get_app_password_storage(request)
|
||||||
|
data = await storage.get_app_password_with_scopes(username)
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"user_id": username,
|
||||||
|
"provisioned": False,
|
||||||
|
"scopes": None,
|
||||||
|
"username": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"user_id": username,
|
||||||
|
"provisioned": True,
|
||||||
|
"scopes": data["scopes"],
|
||||||
|
"username": data.get("username"),
|
||||||
|
"created_at": data.get("created_at"),
|
||||||
|
"updated_at": data.get("updated_at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = _sanitize_error_for_client(e, "get_user_access")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": error_msg},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_user_scopes(request: Request) -> JSONResponse:
|
||||||
|
"""PATCH /api/v1/users/{user_id}/scopes - Update user's application-level scopes.
|
||||||
|
|
||||||
|
Accepts JSON body with:
|
||||||
|
- scopes: list[str] - New scope set to apply
|
||||||
|
|
||||||
|
This only updates the stored scopes, not the app password itself.
|
||||||
|
The app password remains valid; scope enforcement is application-level.
|
||||||
|
|
||||||
|
Security note: This endpoint allows direct scope modification without
|
||||||
|
re-authenticating via Login Flow. The caller must authenticate with
|
||||||
|
valid BasicAuth credentials (user_id + app_password), which serves
|
||||||
|
as the authorization check.
|
||||||
|
"""
|
||||||
|
path_user_id = request.path_params.get("user_id")
|
||||||
|
if not path_user_id:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Missing user_id in path"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
username, _, error_response = _extract_basic_auth(request, path_user_id)
|
||||||
|
if error_response is not None:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Invalid JSON body"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
scopes = body.get("scopes")
|
||||||
|
if scopes is None or not isinstance(scopes, list):
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "scopes must be a list of strings"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate scopes
|
||||||
|
invalid = [s for s in scopes if s not in ALL_SUPPORTED_SCOPES]
|
||||||
|
if invalid:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f"Invalid scopes: {', '.join(invalid)}",
|
||||||
|
"valid_scopes": sorted(ALL_SUPPORTED_SCOPES),
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage = await _get_app_password_storage(request)
|
||||||
|
existing = await storage.get_app_password_with_scopes(username)
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "No app password provisioned for this user",
|
||||||
|
},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update scopes only (no decrypt/re-encrypt of the password)
|
||||||
|
await storage.update_app_password_scopes(
|
||||||
|
user_id=username,
|
||||||
|
scopes=scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invalidate scope cache so subsequent tool calls see updated scopes
|
||||||
|
invalidate_scope_cache(username)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"user_id": username,
|
||||||
|
"scopes": scopes,
|
||||||
|
"message": "Scopes updated successfully",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = _sanitize_error_for_client(e, "update_user_scopes")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": error_msg},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_supported_scopes(_: Request) -> JSONResponse:
|
||||||
|
"""GET /api/v1/scopes - List all supported application-level scopes."""
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"scopes": sorted(ALL_SUPPORTED_SCOPES),
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -288,10 +288,23 @@ async def provision_app_password(request: Request) -> JSONResponse:
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Parse optional scopes and username from request body
|
||||||
|
scopes = None
|
||||||
|
nc_username = None
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
scopes = body.get("scopes") # list[str] | None
|
||||||
|
nc_username = body.get("username") # Nextcloud loginName
|
||||||
|
except Exception:
|
||||||
|
pass # No JSON body = legacy call without scopes
|
||||||
|
|
||||||
# Store the validated app password
|
# Store the validated app password
|
||||||
try:
|
try:
|
||||||
storage = await _get_app_password_storage(request)
|
storage = await _get_app_password_storage(request)
|
||||||
await storage.store_app_password(username, app_password)
|
|
||||||
|
await storage.store_app_password_with_scopes(
|
||||||
|
username, app_password, scopes=scopes, username=nc_username
|
||||||
|
)
|
||||||
|
|
||||||
_record_rate_limit_attempt(path_user_id, success=True)
|
_record_rate_limit_attempt(path_user_id, success=True)
|
||||||
logger.info(f"Provisioned app password for user: {username}")
|
logger.info(f"Provisioned app password for user: {username}")
|
||||||
@@ -300,6 +313,7 @@ async def provision_app_password(request: Request) -> JSONResponse:
|
|||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"App password stored for {username}",
|
"message": f"App password stored for {username}",
|
||||||
|
"scopes": scopes,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+98
-19
@@ -40,12 +40,15 @@ from nextcloud_mcp_server.api import (
|
|||||||
get_installed_apps,
|
get_installed_apps,
|
||||||
get_pdf_preview,
|
get_pdf_preview,
|
||||||
get_server_status,
|
get_server_status,
|
||||||
|
get_user_access,
|
||||||
get_user_session,
|
get_user_session,
|
||||||
get_vector_sync_status,
|
get_vector_sync_status,
|
||||||
|
list_supported_scopes,
|
||||||
list_webhooks,
|
list_webhooks,
|
||||||
provision_app_password,
|
provision_app_password,
|
||||||
revoke_user_access,
|
revoke_user_access,
|
||||||
unified_search,
|
unified_search,
|
||||||
|
update_user_scopes,
|
||||||
vector_search,
|
vector_search,
|
||||||
)
|
)
|
||||||
from nextcloud_mcp_server.auth import (
|
from nextcloud_mcp_server.auth import (
|
||||||
@@ -63,13 +66,16 @@ from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
|||||||
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
|
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
|
||||||
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
|
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
|
||||||
from nextcloud_mcp_server.auth.oauth_routes import (
|
from nextcloud_mcp_server.auth.oauth_routes import (
|
||||||
|
oauth_as_metadata,
|
||||||
oauth_authorize,
|
oauth_authorize,
|
||||||
oauth_authorize_nextcloud,
|
oauth_authorize_nextcloud,
|
||||||
oauth_callback,
|
oauth_callback,
|
||||||
oauth_callback_nextcloud,
|
oauth_callback_nextcloud,
|
||||||
|
oauth_register_proxy,
|
||||||
|
oauth_token_endpoint,
|
||||||
)
|
)
|
||||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage, get_shared_storage
|
||||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
||||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||||
@@ -123,6 +129,7 @@ from nextcloud_mcp_server.server import (
|
|||||||
configure_tables_tools,
|
configure_tables_tools,
|
||||||
configure_webdav_tools,
|
configure_webdav_tools,
|
||||||
)
|
)
|
||||||
|
from nextcloud_mcp_server.server.auth_tools import register_auth_tools
|
||||||
from nextcloud_mcp_server.server.oauth_tools import register_oauth_tools
|
from nextcloud_mcp_server.server.oauth_tools import register_oauth_tools
|
||||||
from nextcloud_mcp_server.vector import processor_task, scanner_task
|
from nextcloud_mcp_server.vector import processor_task, scanner_task
|
||||||
from nextcloud_mcp_server.vector.oauth_sync import (
|
from nextcloud_mcp_server.vector.oauth_sync import (
|
||||||
@@ -1468,6 +1475,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
"Skipping provisioning tools registration (offline access not enabled)"
|
"Skipping provisioning tools registration (offline access not enabled)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Register Login Flow v2 auth tools (ADR-022)
|
||||||
|
if settings.enable_login_flow:
|
||||||
|
logger.info("Registering Login Flow v2 auth tools")
|
||||||
|
register_auth_tools(mcp)
|
||||||
|
|
||||||
# Override list_tools to filter based on user's token scopes (OAuth mode only)
|
# Override list_tools to filter based on user's token scopes (OAuth mode only)
|
||||||
if oauth_enabled:
|
if oauth_enabled:
|
||||||
original_list_tools = mcp._tool_manager.list_tools
|
original_list_tools = mcp._tool_manager.list_tools
|
||||||
@@ -1519,6 +1531,43 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
|
|
||||||
mcp_app = mcp.streamable_http_app()
|
mcp_app = mcp.streamable_http_app()
|
||||||
|
|
||||||
|
async def _login_flow_cleanup_loop() -> None:
|
||||||
|
"""Periodically clean up expired Login Flow v2 sessions and proxy codes."""
|
||||||
|
from nextcloud_mcp_server.auth.oauth_routes import ( # noqa: PLC0415
|
||||||
|
_cleanup_expired_proxy_codes,
|
||||||
|
)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
storage = await get_shared_storage()
|
||||||
|
count = await storage.delete_expired_login_flow_sessions()
|
||||||
|
if count:
|
||||||
|
logger.info(f"Cleaned up {count} expired login flow sessions")
|
||||||
|
# Also clean up expired AS proxy codes/sessions
|
||||||
|
_cleanup_expired_proxy_codes()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Login flow cleanup error: {e}")
|
||||||
|
await anyio.sleep(3600) # Every hour
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _maybe_login_flow_cleanup():
|
||||||
|
"""Start Login Flow cleanup task if enabled."""
|
||||||
|
if settings.enable_login_flow:
|
||||||
|
async with anyio.create_task_group() as tg:
|
||||||
|
tg.start_soon(_login_flow_cleanup_loop)
|
||||||
|
yield
|
||||||
|
tg.cancel_scope.cancel()
|
||||||
|
else:
|
||||||
|
yield
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _mcp_session_with_login_flow():
|
||||||
|
"""Start MCP session manager with optional Login Flow cleanup."""
|
||||||
|
async with AsyncExitStack() as stack:
|
||||||
|
await stack.enter_async_context(mcp.session_manager.run())
|
||||||
|
await stack.enter_async_context(_maybe_login_flow_cleanup())
|
||||||
|
yield
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def starlette_lifespan(app: Starlette):
|
async def starlette_lifespan(app: Starlette):
|
||||||
# Set OAuth context for OAuth login routes (ADR-004)
|
# Set OAuth context for OAuth login routes (ADR-004)
|
||||||
@@ -1752,8 +1801,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Run MCP session manager and yield
|
# Run MCP session manager and yield
|
||||||
async with AsyncExitStack() as stack:
|
async with _mcp_session_with_login_flow():
|
||||||
await stack.enter_async_context(mcp.session_manager.run())
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
@@ -1935,8 +1983,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Run MCP session manager and yield
|
# Run MCP session manager and yield
|
||||||
async with AsyncExitStack() as stack:
|
async with _mcp_session_with_login_flow():
|
||||||
await stack.enter_async_context(mcp.session_manager.run())
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
@@ -1955,8 +2002,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
"To enable, set NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET."
|
"To enable, set NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET."
|
||||||
)
|
)
|
||||||
# Just run MCP session manager without vector sync
|
# Just run MCP session manager without vector sync
|
||||||
async with AsyncExitStack() as stack:
|
async with _mcp_session_with_login_flow():
|
||||||
await stack.enter_async_context(mcp.session_manager.run())
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -1976,8 +2022,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"Vector sync enabled but TOKEN_ENCRYPTION_KEY not set"
|
"Vector sync enabled but TOKEN_ENCRYPTION_KEY not set"
|
||||||
)
|
)
|
||||||
async with AsyncExitStack() as stack:
|
async with _mcp_session_with_login_flow():
|
||||||
await stack.enter_async_context(mcp.session_manager.run())
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Health check endpoints for Kubernetes probes
|
# Health check endpoints for Kubernetes probes
|
||||||
@@ -2208,10 +2253,27 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
routes.append(
|
routes.append(
|
||||||
Route("/api/v1/webhooks/{webhook_id}", delete_webhook, methods=["DELETE"])
|
Route("/api/v1/webhooks/{webhook_id}", delete_webhook, methods=["DELETE"])
|
||||||
)
|
)
|
||||||
|
# Access and scope management endpoints (ADR-022)
|
||||||
|
routes.append(
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/access",
|
||||||
|
get_user_access,
|
||||||
|
methods=["GET"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
routes.append(
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/scopes",
|
||||||
|
update_user_scopes,
|
||||||
|
methods=["PATCH"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
routes.append(Route("/api/v1/scopes", list_supported_scopes, methods=["GET"]))
|
||||||
logger.info(
|
logger.info(
|
||||||
"Management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status, "
|
"Management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status, "
|
||||||
"/api/v1/users/{user_id}/session, /api/v1/users/{user_id}/revoke, "
|
"/api/v1/users/{user_id}/session, /api/v1/users/{user_id}/revoke, "
|
||||||
"/api/v1/users/{user_id}/app-password, "
|
"/api/v1/users/{user_id}/app-password, /api/v1/users/{user_id}/access, "
|
||||||
|
"/api/v1/users/{user_id}/scopes, /api/v1/scopes, "
|
||||||
"/api/v1/vector-viz/search, /api/v1/search, /api/v1/apps, "
|
"/api/v1/vector-viz/search, /api/v1/search, /api/v1/apps, "
|
||||||
"/api/v1/webhooks, /api/v1/pdf-preview"
|
"/api/v1/webhooks, /api/v1/pdf-preview"
|
||||||
)
|
)
|
||||||
@@ -2264,14 +2326,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
The 'resource' field is set to the MCP server's public URL (RFC 9728 requires a URL).
|
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).
|
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.
|
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")
|
|
||||||
if not public_issuer_url:
|
|
||||||
# Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set
|
|
||||||
public_issuer_url = os.getenv("NEXTCLOUD_HOST", "")
|
|
||||||
|
|
||||||
|
ADR-023: authorization_servers points to the MCP server itself (AS proxy)
|
||||||
|
so that clients authenticate through the proxy and tokens have correct audience.
|
||||||
|
"""
|
||||||
# RFC 9728 requires resource to be a URL (not a client ID)
|
# RFC 9728 requires resource to be a URL (not a client ID)
|
||||||
# Use the MCP server's public URL
|
# Use the MCP server's public URL
|
||||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL")
|
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL")
|
||||||
@@ -2283,11 +2341,14 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# This provides a single source of truth based on @require_scopes decorators
|
# This provides a single source of truth based on @require_scopes decorators
|
||||||
supported_scopes = discover_all_scopes(mcp)
|
supported_scopes = discover_all_scopes(mcp)
|
||||||
|
|
||||||
|
# ADR-023: Point authorization_servers to the MCP server itself.
|
||||||
|
# The MCP server acts as an OAuth AS proxy, forwarding to Nextcloud
|
||||||
|
# with its own client_id so tokens have the correct audience.
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{
|
{
|
||||||
"resource": f"{mcp_server_url}/mcp", # RFC 9728: must be a URL
|
"resource": f"{mcp_server_url}/mcp", # RFC 9728: must be a URL
|
||||||
"scopes_supported": supported_scopes,
|
"scopes_supported": supported_scopes,
|
||||||
"authorization_servers": [public_issuer_url],
|
"authorization_servers": [mcp_server_url],
|
||||||
"bearer_methods_supported": ["header"],
|
"bearer_methods_supported": ["header"],
|
||||||
"resource_signing_alg_values_supported": ["RS256"],
|
"resource_signing_alg_values_supported": ["RS256"],
|
||||||
}
|
}
|
||||||
@@ -2344,7 +2405,21 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# Multi-user BasicAuth uses hybrid mode with only Flow 2 (resource provisioning)
|
# Multi-user BasicAuth uses hybrid mode with only Flow 2 (resource provisioning)
|
||||||
if oauth_enabled:
|
if oauth_enabled:
|
||||||
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
|
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
|
||||||
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
|
|
||||||
|
# ADR-023: AS proxy endpoints — MCP server acts as its own OAuth AS
|
||||||
|
routes.append(Route("/oauth/token", oauth_token_endpoint, methods=["POST"]))
|
||||||
|
routes.append(Route("/oauth/register", oauth_register_proxy, methods=["POST"]))
|
||||||
|
routes.append(
|
||||||
|
Route(
|
||||||
|
"/.well-known/oauth-authorization-server",
|
||||||
|
oauth_as_metadata,
|
||||||
|
methods=["GET"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"OAuth AS proxy routes enabled: /oauth/authorize, /oauth/token, "
|
||||||
|
"/oauth/register, /.well-known/oauth-authorization-server (ADR-023)"
|
||||||
|
)
|
||||||
|
|
||||||
# Add browser OAuth login routes for Management API access
|
# Add browser OAuth login routes for Management API access
|
||||||
# Available in OAuth modes AND multi-user BasicAuth with offline access
|
# Available in OAuth modes AND multi-user BasicAuth with offline access
|
||||||
@@ -2453,6 +2528,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
"Routes: /user/* with SessionAuth, /mcp with FastMCP OAuth Bearer tokens"
|
"Routes: /user/* with SessionAuth, /mcp with FastMCP OAuth Bearer tokens"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Store supported scopes on app.state for AS metadata endpoint (ADR-023)
|
||||||
|
if oauth_enabled:
|
||||||
|
app.state.supported_scopes = discover_all_scopes(mcp)
|
||||||
|
|
||||||
# Add debugging middleware to log Authorization headers and client capabilities
|
# Add debugging middleware to log Authorization headers and client capabilities
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def log_auth_headers(request, call_next):
|
async def log_auth_headers(request, call_next):
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ async def register_client(
|
|||||||
scopes: str = "openid profile email",
|
scopes: str = "openid profile email",
|
||||||
token_type: str | None = "Bearer",
|
token_type: str | None = "Bearer",
|
||||||
resource_url: str | None = None,
|
resource_url: str | None = None,
|
||||||
|
max_retries: int = 3,
|
||||||
) -> ClientInfo:
|
) -> ClientInfo:
|
||||||
"""
|
"""
|
||||||
Register a new OAuth client using RFC 7591 Dynamic Client Registration.
|
Register a new OAuth client using RFC 7591 Dynamic Client Registration.
|
||||||
@@ -98,6 +99,7 @@ async def register_client(
|
|||||||
token_type: Type of access tokens (default: "Bearer", supports "JWT" for Nextcloud).
|
token_type: Type of access tokens (default: "Bearer", supports "JWT" for Nextcloud).
|
||||||
Set to None to omit this field (required for Keycloak and other standard providers).
|
Set to None to omit this field (required for Keycloak and other standard providers).
|
||||||
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
|
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
|
||||||
|
max_retries: Maximum number of retries for 429 responses (default: 3)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ClientInfo with registration details
|
ClientInfo with registration details
|
||||||
@@ -135,57 +137,91 @@ async def register_client(
|
|||||||
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
||||||
|
|
||||||
async with nextcloud_httpx_client(timeout=30.0) as client:
|
async with nextcloud_httpx_client(timeout=30.0) as client:
|
||||||
try:
|
for attempt in range(max_retries):
|
||||||
response = await client.post(
|
try:
|
||||||
registration_endpoint,
|
response = await client.post(
|
||||||
json=client_metadata,
|
registration_endpoint,
|
||||||
headers={"Content-Type": "application/json"},
|
json=client_metadata,
|
||||||
)
|
headers={"Content-Type": "application/json"},
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
client_info = response.json()
|
|
||||||
logger.info(
|
|
||||||
f"Successfully registered client: {client_info.get('client_id')}"
|
|
||||||
)
|
|
||||||
expires_at = dt.datetime.fromtimestamp(
|
|
||||||
client_info.get("client_secret_expires_at")
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"Client expires at: {expires_at} "
|
|
||||||
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log if RFC 7592 fields are present
|
|
||||||
has_reg_token = "registration_access_token" in client_info
|
|
||||||
has_reg_uri = "registration_client_uri" in client_info
|
|
||||||
if has_reg_token and has_reg_uri:
|
|
||||||
logger.info(
|
|
||||||
"RFC 7592 management fields received - client deletion will be supported"
|
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
logger.warning("RFC 7592 fields missing - client deletion may not work")
|
|
||||||
|
|
||||||
return ClientInfo(
|
if response.status_code == 429:
|
||||||
client_id=client_info["client_id"],
|
# Rate limited - retry with exponential backoff
|
||||||
client_secret=client_info["client_secret"],
|
if attempt < max_retries - 1:
|
||||||
client_id_issued_at=client_info.get(
|
retry_after = int(response.headers.get("Retry-After", 2))
|
||||||
"client_id_issued_at", int(time.time())
|
wait_time = min(retry_after, 2**attempt)
|
||||||
),
|
logger.warning(
|
||||||
client_secret_expires_at=client_info.get(
|
f"Rate limited (429) registering client, "
|
||||||
"client_secret_expires_at", int(time.time()) + 3600
|
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
|
||||||
),
|
)
|
||||||
redirect_uris=client_info.get("redirect_uris", redirect_uris),
|
await anyio.sleep(wait_time)
|
||||||
registration_access_token=client_info.get("registration_access_token"),
|
continue
|
||||||
registration_client_uri=client_info.get("registration_client_uri"),
|
else:
|
||||||
)
|
logger.error(
|
||||||
|
f"Failed to register client after {max_retries} attempts: Rate limited (429)"
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
response.raise_for_status()
|
||||||
logger.error(f"Failed to register client: HTTP {e.response.status_code}")
|
|
||||||
logger.error(f"Response: {e.response.text}")
|
client_info = response.json()
|
||||||
raise
|
logger.info(
|
||||||
except KeyError as e:
|
f"Successfully registered client: {client_info.get('client_id')}"
|
||||||
logger.error(f"Invalid response from registration endpoint: missing {e}")
|
)
|
||||||
raise ValueError(f"Invalid registration response: missing {e}")
|
expires_at = dt.datetime.fromtimestamp(
|
||||||
|
client_info.get("client_secret_expires_at")
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Client expires at: {expires_at} "
|
||||||
|
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log if RFC 7592 fields are present
|
||||||
|
has_reg_token = "registration_access_token" in client_info
|
||||||
|
has_reg_uri = "registration_client_uri" in client_info
|
||||||
|
if has_reg_token and has_reg_uri:
|
||||||
|
logger.info(
|
||||||
|
"RFC 7592 management fields received - client deletion will be supported"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"RFC 7592 fields missing - client deletion may not work"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ClientInfo(
|
||||||
|
client_id=client_info["client_id"],
|
||||||
|
client_secret=client_info["client_secret"],
|
||||||
|
client_id_issued_at=client_info.get(
|
||||||
|
"client_id_issued_at", int(time.time())
|
||||||
|
),
|
||||||
|
client_secret_expires_at=client_info.get(
|
||||||
|
"client_secret_expires_at", int(time.time()) + 3600
|
||||||
|
),
|
||||||
|
redirect_uris=client_info.get("redirect_uris", redirect_uris),
|
||||||
|
registration_access_token=client_info.get(
|
||||||
|
"registration_access_token"
|
||||||
|
),
|
||||||
|
registration_client_uri=client_info.get("registration_client_uri"),
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to register client: HTTP {e.response.status_code}"
|
||||||
|
)
|
||||||
|
logger.error(f"Response: {e.response.text}")
|
||||||
|
raise
|
||||||
|
except KeyError as e:
|
||||||
|
logger.error(
|
||||||
|
f"Invalid response from registration endpoint: missing {e}"
|
||||||
|
)
|
||||||
|
raise ValueError(f"Invalid registration response: missing {e}")
|
||||||
|
|
||||||
|
# Should not reach here, but raise if we do
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
"Registration failed after retries",
|
||||||
|
request=httpx.Request("POST", registration_endpoint),
|
||||||
|
response=httpx.Response(429),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def delete_client(
|
async def delete_client(
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ class ClientRegistry:
|
|||||||
if not self._validate_redirect_uri(client, redirect_uri):
|
if not self._validate_redirect_uri(client, redirect_uri):
|
||||||
return False, f"Invalid redirect_uri for client {client_id}"
|
return False, f"Invalid redirect_uri for client {client_id}"
|
||||||
|
|
||||||
# Validate scopes if provided
|
# Validate scopes if provided (wildcard "*" allows all scopes)
|
||||||
if scopes:
|
if scopes and "*" not in client.allowed_scopes:
|
||||||
invalid_scopes = set(scopes) - set(client.allowed_scopes)
|
invalid_scopes = set(scopes) - set(client.allowed_scopes)
|
||||||
if invalid_scopes:
|
if invalid_scopes:
|
||||||
return False, f"Invalid scopes for client {client_id}: {invalid_scopes}"
|
return False, f"Invalid scopes for client {client_id}: {invalid_scopes}"
|
||||||
@@ -202,6 +202,29 @@ class ClientRegistry:
|
|||||||
# In production, would persist to database
|
# In production, would persist to database
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def register_proxy_client(
|
||||||
|
self, client_id: str, redirect_uris: list[str], name: str = ""
|
||||||
|
) -> None:
|
||||||
|
"""Register a client discovered via DCR proxy.
|
||||||
|
|
||||||
|
When the MCP server acts as an OAuth AS proxy, clients register via
|
||||||
|
the proxy's /oauth/register endpoint. This method stores the client
|
||||||
|
locally so /oauth/authorize can validate it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client_id: Client identifier from Nextcloud DCR response
|
||||||
|
redirect_uris: Allowed redirect URIs
|
||||||
|
name: Optional human-readable name
|
||||||
|
"""
|
||||||
|
self._clients[client_id] = MCPClientInfo(
|
||||||
|
client_id=client_id,
|
||||||
|
name=name or f"DCR-{client_id[:8]}",
|
||||||
|
redirect_uris=redirect_uris or ["http://localhost:*", "http://127.0.0.1:*"],
|
||||||
|
allowed_scopes=["*"], # Nextcloud enforces actual scopes
|
||||||
|
is_public=True,
|
||||||
|
)
|
||||||
|
logger.info(f"Registered proxy client: {client_id}")
|
||||||
|
|
||||||
def get_client(self, client_id: str) -> Optional[MCPClientInfo]:
|
def get_client(self, client_id: str) -> Optional[MCPClientInfo]:
|
||||||
"""
|
"""
|
||||||
Get client information.
|
Get client information.
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""MCP elicitation helpers for Login Flow v2.
|
||||||
|
|
||||||
|
Provides a unified way to present login URLs to users, using MCP elicitation
|
||||||
|
when the client supports it, or falling back to returning the URL in a message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import Context
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginFlowConfirmation(BaseModel):
|
||||||
|
"""Schema for Login Flow v2 confirmation elicitation."""
|
||||||
|
|
||||||
|
acknowledged: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Check this box after completing login at the provided URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def present_login_url(
|
||||||
|
ctx: Context,
|
||||||
|
login_url: str,
|
||||||
|
message: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Present a login URL to the user via MCP elicitation or message.
|
||||||
|
|
||||||
|
Tries MCP elicitation first (ctx.elicit) for interactive clients.
|
||||||
|
Falls back to returning the URL as a plain message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: MCP context
|
||||||
|
login_url: URL the user should open in their browser
|
||||||
|
message: Optional custom message (defaults to standard Login Flow prompt)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
"accepted" if user acknowledged via elicitation,
|
||||||
|
"declined" if user declined,
|
||||||
|
"message_only" if elicitation not supported (URL returned in message)
|
||||||
|
"""
|
||||||
|
if message is None:
|
||||||
|
message = (
|
||||||
|
f"Please log in to Nextcloud to grant access:\n\n"
|
||||||
|
f"{login_url}\n\n"
|
||||||
|
f"Open this URL in your browser, log in, and grant the requested permissions. "
|
||||||
|
f"Then check the box below and click OK."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hasattr(ctx, "elicit"):
|
||||||
|
logger.debug(
|
||||||
|
"Elicitation not available (no elicit method), returning URL in message"
|
||||||
|
)
|
||||||
|
return "message_only"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await ctx.elicit(
|
||||||
|
message=message,
|
||||||
|
schema=LoginFlowConfirmation,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.action == "accept":
|
||||||
|
if hasattr(result, "data") and not result.data.acknowledged: # type: ignore[union-attr]
|
||||||
|
logger.warning(
|
||||||
|
"User accepted login flow without checking the acknowledged box — "
|
||||||
|
"login completion will be verified via polling"
|
||||||
|
)
|
||||||
|
logger.info("User acknowledged login flow completion")
|
||||||
|
return "accepted"
|
||||||
|
elif result.action == "decline":
|
||||||
|
logger.info("User declined login flow")
|
||||||
|
return "declined"
|
||||||
|
else:
|
||||||
|
logger.info("User cancelled login flow")
|
||||||
|
return "cancelled"
|
||||||
|
|
||||||
|
except NotImplementedError:
|
||||||
|
# Elicitation not supported by this client/SDK - fall back to message
|
||||||
|
logger.debug("Elicitation not available, returning URL in message")
|
||||||
|
return "message_only"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Elicitation failed unexpectedly ({type(e).__name__}: {e}), "
|
||||||
|
"falling back to message"
|
||||||
|
)
|
||||||
|
return "message_only"
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Nextcloud Login Flow v2 HTTP client.
|
||||||
|
|
||||||
|
Implements the Nextcloud Login Flow v2 protocol for obtaining app passwords.
|
||||||
|
See: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
||||||
|
|
||||||
|
The flow has two steps:
|
||||||
|
1. Initiate: POST /index.php/login/v2 → returns login URL + poll endpoint/token
|
||||||
|
2. Poll: POST to poll endpoint with token → returns server URL, loginName, appPassword
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.http import nextcloud_httpx_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginFlowInitResponse(BaseModel):
|
||||||
|
"""Response from initiating Login Flow v2."""
|
||||||
|
|
||||||
|
login_url: str = Field(description="URL to present to the user for browser login")
|
||||||
|
poll_endpoint: str = Field(description="URL to poll for flow completion")
|
||||||
|
poll_token: str = Field(description="Token to use when polling")
|
||||||
|
|
||||||
|
|
||||||
|
class LoginFlowPollResult(BaseModel):
|
||||||
|
"""Result of polling Login Flow v2."""
|
||||||
|
|
||||||
|
status: str = Field(description="Flow status: 'pending', 'completed', or 'expired'")
|
||||||
|
server: str | None = Field(None, description="Nextcloud server URL (on completion)")
|
||||||
|
login_name: str | None = Field(
|
||||||
|
None, description="Nextcloud login name (on completion)"
|
||||||
|
)
|
||||||
|
app_password: str | None = Field(
|
||||||
|
None, description="Generated app password (on completion)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginFlowV2Client:
|
||||||
|
"""HTTP client for Nextcloud Login Flow v2.
|
||||||
|
|
||||||
|
This client handles the two-step Login Flow v2 process:
|
||||||
|
1. Initiate a flow to get a login URL for the user
|
||||||
|
2. Poll for completion to receive the app password
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nextcloud_host: Base URL of the Nextcloud instance
|
||||||
|
verify_ssl: SSL verification setting (True, False, or SSLContext)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
nextcloud_host: str,
|
||||||
|
verify_ssl: bool | ssl.SSLContext = True,
|
||||||
|
):
|
||||||
|
self.nextcloud_host = nextcloud_host.rstrip("/")
|
||||||
|
self.verify_ssl = verify_ssl
|
||||||
|
|
||||||
|
async def initiate(
|
||||||
|
self, user_agent: str = "nextcloud-mcp-server"
|
||||||
|
) -> LoginFlowInitResponse:
|
||||||
|
"""Initiate Login Flow v2 by sending an HTTP POST to the Nextcloud instance.
|
||||||
|
|
||||||
|
Makes an outbound HTTP request to POST /index.php/login/v2 on the
|
||||||
|
configured Nextcloud server to start a new login flow.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_agent: User-Agent string for the app password name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LoginFlowInitResponse with login URL and poll credentials
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPStatusError: If the Nextcloud server returns an error
|
||||||
|
"""
|
||||||
|
url = f"{self.nextcloud_host}/index.php/login/v2"
|
||||||
|
|
||||||
|
async with nextcloud_httpx_client(
|
||||||
|
verify=self.verify_ssl, timeout=15.0
|
||||||
|
) as client:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
headers={"User-Agent": user_agent},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
poll_data = data.get("poll", {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = LoginFlowInitResponse(
|
||||||
|
login_url=data["login"],
|
||||||
|
poll_endpoint=poll_data["endpoint"],
|
||||||
|
poll_token=poll_data["token"],
|
||||||
|
)
|
||||||
|
except KeyError as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"Malformed Login Flow v2 initiate response from Nextcloud (missing key: {e})"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
logger.info(f"Login Flow v2 initiated: login_url={result.login_url[:60]}...")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def poll(self, poll_endpoint: str, poll_token: str) -> LoginFlowPollResult:
|
||||||
|
"""Poll for Login Flow v2 completion by sending an HTTP POST to the Nextcloud instance.
|
||||||
|
|
||||||
|
Makes an outbound HTTP request to the poll endpoint provided by the
|
||||||
|
initiate response. Nextcloud returns:
|
||||||
|
- 200 with credentials when the user completes login
|
||||||
|
- 404 when still pending
|
||||||
|
- Other errors for expired/invalid flows
|
||||||
|
|
||||||
|
Args:
|
||||||
|
poll_endpoint: URL to poll (from initiate response)
|
||||||
|
poll_token: Token for polling (from initiate response)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LoginFlowPollResult with status and optional credentials
|
||||||
|
"""
|
||||||
|
async with nextcloud_httpx_client(
|
||||||
|
verify=self.verify_ssl, timeout=10.0
|
||||||
|
) as client:
|
||||||
|
response = await client.post(
|
||||||
|
poll_endpoint,
|
||||||
|
data={"token": poll_token},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
logger.info(
|
||||||
|
f"Login Flow v2 completed: server={data.get('server')}, "
|
||||||
|
f"loginName={data.get('loginName')}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return LoginFlowPollResult(
|
||||||
|
status="completed",
|
||||||
|
server=data["server"],
|
||||||
|
login_name=data["loginName"],
|
||||||
|
app_password=data["appPassword"],
|
||||||
|
)
|
||||||
|
except KeyError as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"Malformed Login Flow v2 poll response from Nextcloud (missing key: {e})"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
logger.debug("Login Flow v2 still pending")
|
||||||
|
return LoginFlowPollResult(status="pending")
|
||||||
|
|
||||||
|
# Any other status indicates the flow has expired or is invalid
|
||||||
|
logger.warning(
|
||||||
|
f"Login Flow v2 poll returned unexpected status: {response.status_code}"
|
||||||
|
)
|
||||||
|
return LoginFlowPollResult(status="expired")
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
OAuth 2.0 Login Routes for ADR-004 (Offline Access Architecture)
|
OAuth 2.0 Login Routes for ADR-004 (Offline Access Architecture) and ADR-023 (AS Proxy)
|
||||||
|
|
||||||
Implements dual OAuth flows with optional offline access provisioning:
|
Implements dual OAuth flows with optional offline access provisioning:
|
||||||
|
|
||||||
Flow 1: Client Authentication - MCP client authenticates directly to IdP
|
Flow 1: Client Authentication (AS Proxy mode, ADR-023)
|
||||||
- Client requests: Nextcloud MCP resource scopes (notes:*, calendar:*, etc.)
|
- MCP server acts as its own OAuth Authorization Server
|
||||||
- Token audience (aud): "mcp-server"
|
- Proxies DCR, authorization, and token endpoints to Nextcloud
|
||||||
- No server interception - IdP redirects directly to client
|
- Uses MCP server's own client_id so tokens have correct audience
|
||||||
- Client receives resource-scoped token for MCP session
|
- Client exchanges proxy authorization code for Nextcloud token
|
||||||
|
|
||||||
Flow 2: Resource Provisioning - MCP server gets delegated Nextcloud access
|
Flow 2: Resource Provisioning - MCP server gets delegated Nextcloud access
|
||||||
- Triggered by user calling provision_nextcloud_access tool
|
- Triggered by user calling provision_nextcloud_access tool
|
||||||
@@ -25,6 +25,8 @@ import os
|
|||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
from base64 import urlsafe_b64encode
|
from base64 import urlsafe_b64encode
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from urllib.parse import urlparse as parse_url
|
from urllib.parse import urlparse as parse_url
|
||||||
|
|
||||||
@@ -41,13 +43,113 @@ from ..http import nextcloud_httpx_client
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# In-memory proxy code store for AS proxy flow (ADR-023)
|
||||||
|
# Proxy codes are ephemeral (60s TTL), single-instance, so in-memory is fine.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProxyCodeEntry:
|
||||||
|
"""Stores state for a proxy authorization code issued by the AS proxy.
|
||||||
|
|
||||||
|
Proxy codes have a 60-second TTL as a security mitigation: they are
|
||||||
|
single-use, ephemeral codes that bridge the AS proxy callback and the
|
||||||
|
client's token exchange. The short window limits replay risk.
|
||||||
|
"""
|
||||||
|
|
||||||
|
client_id: str
|
||||||
|
client_redirect_uri: str
|
||||||
|
client_state: str
|
||||||
|
code_challenge: str
|
||||||
|
code_challenge_method: str
|
||||||
|
nc_token_response: dict[str, Any] # Full JSON token response from Nextcloud
|
||||||
|
created_at: float = field(default_factory=time.time)
|
||||||
|
expires_at: float = field(default_factory=lambda: time.time() + 60)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
return time.time() > self.expires_at
|
||||||
|
|
||||||
|
|
||||||
|
# Server-side state for AS proxy authorize → callback mapping
|
||||||
|
@dataclass
|
||||||
|
class ASProxySession:
|
||||||
|
"""Stores state between /oauth/authorize and the Nextcloud callback.
|
||||||
|
|
||||||
|
Sessions have a 600-second (10 minute) TTL to allow time for the user
|
||||||
|
to complete the browser-based authorization flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
client_id: str
|
||||||
|
client_redirect_uri: str
|
||||||
|
client_state: str
|
||||||
|
code_challenge: str
|
||||||
|
code_challenge_method: str
|
||||||
|
requested_scopes: str
|
||||||
|
created_at: float = field(default_factory=time.time)
|
||||||
|
expires_at: float = field(default_factory=lambda: time.time() + 600)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
return time.time() > self.expires_at
|
||||||
|
|
||||||
|
|
||||||
|
# In-memory stores (single-instance, ephemeral)
|
||||||
|
_proxy_codes: dict[str, ProxyCodeEntry] = {}
|
||||||
|
_as_proxy_sessions: dict[str, ASProxySession] = {}
|
||||||
|
|
||||||
|
# OIDC discovery document cache (URL → (expires_at, data))
|
||||||
|
_discovery_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||||
|
_DISCOVERY_CACHE_TTL = 300 # 5 minutes
|
||||||
|
|
||||||
|
# DCR rate limiting (IP → [timestamps])
|
||||||
|
_dcr_rate_limit: dict[str, list[float]] = {}
|
||||||
|
_DCR_RATE_LIMIT_MAX = 10 # max requests
|
||||||
|
_DCR_RATE_LIMIT_WINDOW = 60 # per 60 seconds
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_cached_discovery(url: str) -> dict[str, Any]:
|
||||||
|
"""Fetch OIDC discovery document with caching (5-minute TTL)."""
|
||||||
|
now = time.time()
|
||||||
|
if url in _discovery_cache:
|
||||||
|
expires_at, data = _discovery_cache[url]
|
||||||
|
if now < expires_at:
|
||||||
|
return data
|
||||||
|
async with nextcloud_httpx_client() as http_client:
|
||||||
|
response = await http_client.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
_discovery_cache[url] = (now + _DISCOVERY_CACHE_TTL, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_expired_proxy_codes() -> None:
|
||||||
|
"""Remove expired proxy codes and sessions."""
|
||||||
|
now = time.time()
|
||||||
|
expired_codes = [k for k, v in _proxy_codes.items() if now > v.expires_at]
|
||||||
|
for k in expired_codes:
|
||||||
|
del _proxy_codes[k]
|
||||||
|
expired_sessions = [k for k, v in _as_proxy_sessions.items() if now > v.expires_at]
|
||||||
|
for k in expired_sessions:
|
||||||
|
del _as_proxy_sessions[k]
|
||||||
|
|
||||||
|
|
||||||
async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||||
"""
|
"""
|
||||||
OAuth authorization endpoint for Flow 1: Client Authentication.
|
OAuth authorization endpoint — AS Proxy intermediary (ADR-023).
|
||||||
|
|
||||||
The client authenticates directly to the IdP with its own client_id.
|
The MCP server acts as its own OAuth Authorization Server, proxying
|
||||||
The server validates the client is authorized but does NOT intercept the callback.
|
the authorization to Nextcloud. This ensures tokens have the correct
|
||||||
IdP redirects directly back to the client's redirect_uri.
|
audience (MCP server's client_id) instead of the MCP client's client_id.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Client sends authorize request with its own client_id + PKCE
|
||||||
|
2. Server stores client params, generates server-side state
|
||||||
|
3. Server redirects to Nextcloud with MCP server's own client_id
|
||||||
|
4. Nextcloud callback returns to /oauth/callback (flow_type=as_proxy)
|
||||||
|
5. Server exchanges code, generates proxy_code for client
|
||||||
|
6. Client exchanges proxy_code at /oauth/token
|
||||||
|
|
||||||
Query parameters:
|
Query parameters:
|
||||||
response_type: Must be "code"
|
response_type: Must be "code"
|
||||||
@@ -59,8 +161,11 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
code_challenge_method: PKCE method, must be "S256" (required)
|
code_challenge_method: PKCE method, must be "S256" (required)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
302 redirect to IdP authorization endpoint
|
302 redirect to Nextcloud authorization endpoint
|
||||||
"""
|
"""
|
||||||
|
# Clean up expired entries periodically
|
||||||
|
_cleanup_expired_proxy_codes()
|
||||||
|
|
||||||
# Extract parameters
|
# Extract parameters
|
||||||
response_type = request.query_params.get("response_type")
|
response_type = request.query_params.get("response_type")
|
||||||
client_id = request.query_params.get("client_id")
|
client_id = request.query_params.get("client_id")
|
||||||
@@ -125,7 +230,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate client_id (required for Flow 1)
|
# Validate client_id (required)
|
||||||
if not client_id:
|
if not client_id:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{
|
{
|
||||||
@@ -166,102 +271,89 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
oauth_client = oauth_ctx["oauth_client"]
|
|
||||||
oauth_config = oauth_ctx["config"]
|
oauth_config = oauth_ctx["config"]
|
||||||
|
|
||||||
# Flow 1: Client authenticates directly to IdP WITHOUT server interception
|
# AS Proxy: Store client's params and redirect to Nextcloud with MCP server's credentials
|
||||||
# CRITICAL: This is a direct pass-through to IdP
|
# PKCE is validated locally when the client exchanges the proxy_code at /oauth/token.
|
||||||
# The IdP will redirect directly back to the client's callback
|
# We do NOT forward PKCE to Nextcloud — the MCP server is a confidential client.
|
||||||
# The MCP server does NOT see the IdP authorization code!
|
server_state = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
logger.info(
|
requested_scope = request.query_params.get("scope", "")
|
||||||
f"Starting 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"
|
default_scopes = "openid profile email"
|
||||||
resource_scopes = oauth_config.get("scopes", "")
|
resource_scopes = oauth_config.get("scopes", "")
|
||||||
scopes = f"{default_scopes} {resource_scopes}".strip()
|
scopes = f"{default_scopes} {resource_scopes}".strip()
|
||||||
|
if requested_scope:
|
||||||
|
# Merge client-requested scopes with server defaults
|
||||||
|
all_scopes = set(scopes.split()) | set(requested_scope.split())
|
||||||
|
scopes = " ".join(sorted(all_scopes))
|
||||||
|
|
||||||
# Pass through client's state directly
|
# Store session for callback
|
||||||
idp_state = state
|
_as_proxy_sessions[server_state] = ASProxySession(
|
||||||
|
client_id=client_id,
|
||||||
|
client_redirect_uri=redirect_uri,
|
||||||
|
client_state=state,
|
||||||
|
code_challenge=code_challenge,
|
||||||
|
code_challenge_method=code_challenge_method,
|
||||||
|
requested_scopes=scopes,
|
||||||
|
)
|
||||||
|
|
||||||
# Use client's own client_id (client must be pre-registered at IdP)
|
# Use MCP server's own client_id with Nextcloud
|
||||||
idp_client_id = client_id
|
mcp_server_client_id = os.getenv(
|
||||||
|
"MCP_SERVER_CLIENT_ID", oauth_config.get("client_id")
|
||||||
|
)
|
||||||
|
mcp_server_url = oauth_config["mcp_server_url"]
|
||||||
|
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||||
|
|
||||||
logger.info("Flow 1: Direct client auth to IdP")
|
logger.info("AS Proxy: Intermediary authorization flow")
|
||||||
logger.info(f" Client ID: {client_id}")
|
logger.info(f" Client: {client_id}")
|
||||||
logger.info(f" Client will receive IdP code directly at: {callback_uri}")
|
logger.info(f" MCP server client_id: {mcp_server_client_id}")
|
||||||
logger.info(f" Scopes: {scopes} (resource access for MCP tools)")
|
logger.info(f" Server callback: {callback_uri}")
|
||||||
|
logger.info(f" Scopes: {scopes}")
|
||||||
|
|
||||||
# Get authorization endpoint from OAuth client
|
# Discover Nextcloud authorization endpoint
|
||||||
if oauth_client:
|
discovery_url = oauth_config.get("discovery_url")
|
||||||
# External IdP mode (Keycloak) - use oauth_client
|
if not discovery_url:
|
||||||
auth_url = await oauth_client.get_authorization_url(
|
return JSONResponse(
|
||||||
state=idp_state,
|
{
|
||||||
code_challenge="", # Server doesn't use PKCE with IdP
|
"error": "server_error",
|
||||||
|
"error_description": "OAuth discovery URL not configured",
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
)
|
)
|
||||||
logger.info(f"Redirecting to external IdP: {auth_url.split('?')[0]}")
|
|
||||||
else:
|
discovery = await _get_cached_discovery(discovery_url)
|
||||||
# Integrated mode (Nextcloud OIDC) - build URL directly
|
authorization_endpoint = discovery["authorization_endpoint"]
|
||||||
discovery_url = oauth_config.get("discovery_url")
|
|
||||||
if not discovery_url:
|
# Replace internal Docker hostname with public URL for browser access
|
||||||
return JSONResponse(
|
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||||
{
|
if public_issuer:
|
||||||
"error": "server_error",
|
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||||
"error_description": "OAuth discovery URL not configured",
|
auth_parsed = parse_url(authorization_endpoint)
|
||||||
},
|
|
||||||
status_code=500,
|
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}"
|
||||||
|
)
|
||||||
|
if auth_parsed.query:
|
||||||
|
authorization_endpoint += f"?{auth_parsed.query}"
|
||||||
|
logger.info(
|
||||||
|
f"Rewrote authorization endpoint for browser access: {authorization_endpoint}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fetch authorization endpoint from discovery
|
# Redirect to Nextcloud with MCP server's own client_id (no PKCE — confidential client)
|
||||||
async with nextcloud_httpx_client() as http_client:
|
idp_params = {
|
||||||
response = await http_client.get(discovery_url)
|
"client_id": mcp_server_client_id,
|
||||||
response.raise_for_status()
|
"redirect_uri": callback_uri,
|
||||||
discovery = response.json()
|
"response_type": "code",
|
||||||
authorization_endpoint = discovery["authorization_endpoint"]
|
"scope": scopes,
|
||||||
|
"state": server_state,
|
||||||
|
"prompt": "consent",
|
||||||
|
"resource": f"{mcp_server_url}/mcp", # MCP server audience
|
||||||
|
}
|
||||||
|
|
||||||
# IMPORTANT: Replace internal Docker hostname with public URL for browser access
|
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
|
||||||
# The discovery endpoint returns http://app/apps/oidc/authorize (internal)
|
logger.info(f"Redirecting to Nextcloud OIDC: {auth_url.split('?')[0]}")
|
||||||
# But browsers need http://localhost:8080/apps/oidc/authorize (public)
|
|
||||||
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
|
|
||||||
"resource": f"{oauth_config['mcp_server_url']}/mcp", # MCP server audience
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
return RedirectResponse(auth_url, status_code=302)
|
||||||
|
|
||||||
@@ -355,11 +447,8 @@ async def oauth_authorize_nextcloud(
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with nextcloud_httpx_client() as http_client:
|
discovery = await _get_cached_discovery(discovery_url)
|
||||||
response = await http_client.get(discovery_url)
|
authorization_endpoint = discovery["authorization_endpoint"]
|
||||||
response.raise_for_status()
|
|
||||||
discovery = response.json()
|
|
||||||
authorization_endpoint = discovery["authorization_endpoint"]
|
|
||||||
|
|
||||||
# Fix internal hostname for browser access
|
# Fix internal hostname for browser access
|
||||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||||
@@ -461,11 +550,17 @@ async def oauth_callback_nextcloud(request: Request):
|
|||||||
callback_uri = f"{mcp_server_url}/oauth/callback"
|
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||||
|
|
||||||
discovery_url = oauth_config.get("discovery_url")
|
discovery_url = oauth_config.get("discovery_url")
|
||||||
async with nextcloud_httpx_client() as http_client:
|
if not discovery_url:
|
||||||
response = await http_client.get(discovery_url)
|
return JSONResponse(
|
||||||
response.raise_for_status()
|
{
|
||||||
discovery = response.json()
|
"error": "server_error",
|
||||||
token_endpoint = discovery["token_endpoint"]
|
"error_description": "OIDC discovery URL not configured",
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
discovery = await _get_cached_discovery(discovery_url)
|
||||||
|
token_endpoint = discovery["token_endpoint"]
|
||||||
|
|
||||||
# Build token exchange params
|
# Build token exchange params
|
||||||
token_params = {
|
token_params = {
|
||||||
@@ -599,6 +694,11 @@ async def oauth_callback(request: Request):
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check AS proxy sessions first (in-memory, ADR-023)
|
||||||
|
if state in _as_proxy_sessions:
|
||||||
|
logger.info("Routing to AS proxy callback (ADR-023)")
|
||||||
|
return await _oauth_callback_as_proxy(request, state)
|
||||||
|
|
||||||
# Lookup OAuth session to determine flow type
|
# Lookup OAuth session to determine flow type
|
||||||
oauth_ctx = request.app.state.oauth_context
|
oauth_ctx = request.app.state.oauth_context
|
||||||
if not oauth_ctx:
|
if not oauth_ctx:
|
||||||
@@ -641,3 +741,580 @@ async def oauth_callback(request: Request):
|
|||||||
},
|
},
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AS Proxy endpoints (ADR-023)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _oauth_callback_as_proxy(
|
||||||
|
request: Request, server_state: str
|
||||||
|
) -> RedirectResponse | JSONResponse:
|
||||||
|
"""
|
||||||
|
Handle Nextcloud callback for the AS proxy flow.
|
||||||
|
|
||||||
|
Exchanges the Nextcloud auth code for tokens server-side, generates a
|
||||||
|
proxy authorization code, and redirects back to the client.
|
||||||
|
"""
|
||||||
|
# Check for errors from Nextcloud
|
||||||
|
error = request.query_params.get("error")
|
||||||
|
if error:
|
||||||
|
error_description = request.query_params.get(
|
||||||
|
"error_description", "Authorization failed"
|
||||||
|
)
|
||||||
|
logger.error(f"AS proxy callback error: {error} - {error_description}")
|
||||||
|
|
||||||
|
# Retrieve session to redirect back to client with error
|
||||||
|
session = _as_proxy_sessions.pop(server_state, None)
|
||||||
|
if session:
|
||||||
|
params = urlencode(
|
||||||
|
{
|
||||||
|
"error": error,
|
||||||
|
"error_description": error_description,
|
||||||
|
"state": session.client_state,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return RedirectResponse(
|
||||||
|
f"{session.client_redirect_uri}?{params}", status_code=302
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": error, "error_description": error_description},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
code = request.query_params.get("code")
|
||||||
|
if not code:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "code parameter is required",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retrieve and consume the session (one-time use)
|
||||||
|
session = _as_proxy_sessions.pop(server_state, None)
|
||||||
|
if not session:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Unknown or expired server state",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
if session.is_expired:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Authorization session expired",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get OAuth context
|
||||||
|
oauth_ctx = request.app.state.oauth_context
|
||||||
|
oauth_config = oauth_ctx["config"]
|
||||||
|
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not mcp_server_client_id or not mcp_server_client_secret:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "server_error",
|
||||||
|
"error_description": "MCP server OAuth credentials not configured",
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
mcp_server_url = oauth_config["mcp_server_url"]
|
||||||
|
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||||
|
|
||||||
|
# Discover token endpoint
|
||||||
|
discovery_url = oauth_config.get("discovery_url")
|
||||||
|
if not discovery_url:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "server_error",
|
||||||
|
"error_description": "OIDC discovery URL not configured",
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
discovery = await _get_cached_discovery(discovery_url)
|
||||||
|
token_endpoint = discovery["token_endpoint"]
|
||||||
|
|
||||||
|
# Exchange auth code with Nextcloud (server-side, confidential client, no PKCE)
|
||||||
|
token_params = {
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": callback_uri,
|
||||||
|
"client_id": mcp_server_client_id,
|
||||||
|
"client_secret": mcp_server_client_secret,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with nextcloud_httpx_client() as http_client:
|
||||||
|
response = await http_client.post(token_endpoint, data=token_params)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(
|
||||||
|
f"AS proxy token exchange failed: {response.status_code} {response.text}"
|
||||||
|
)
|
||||||
|
params = urlencode(
|
||||||
|
{
|
||||||
|
"error": "server_error",
|
||||||
|
"error_description": "Failed to exchange authorization code",
|
||||||
|
"state": session.client_state,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return RedirectResponse(
|
||||||
|
f"{session.client_redirect_uri}?{params}", status_code=302
|
||||||
|
)
|
||||||
|
|
||||||
|
nc_token_response = response.json()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"AS proxy: Successfully exchanged code for Nextcloud token "
|
||||||
|
f"(token_type={nc_token_response.get('token_type')})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate a proxy authorization code for the client
|
||||||
|
proxy_code = secrets.token_urlsafe(32)
|
||||||
|
_proxy_codes[proxy_code] = ProxyCodeEntry(
|
||||||
|
client_id=session.client_id,
|
||||||
|
client_redirect_uri=session.client_redirect_uri,
|
||||||
|
client_state=session.client_state,
|
||||||
|
code_challenge=session.code_challenge,
|
||||||
|
code_challenge_method=session.code_challenge_method,
|
||||||
|
nc_token_response=nc_token_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redirect back to client with proxy_code and client's original state
|
||||||
|
redirect_params = urlencode({"code": proxy_code, "state": session.client_state})
|
||||||
|
redirect_url = f"{session.client_redirect_uri}?{redirect_params}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"AS proxy: Redirecting to client with proxy_code (client_id={session.client_id})"
|
||||||
|
)
|
||||||
|
return RedirectResponse(redirect_url, status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_pkce_s256(code_verifier: str, code_challenge: str) -> bool:
|
||||||
|
"""Verify PKCE S256 code_verifier against stored code_challenge.
|
||||||
|
|
||||||
|
Per RFC 7636 Section 4.6:
|
||||||
|
code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
|
||||||
|
"""
|
||||||
|
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
||||||
|
computed_challenge = urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||||
|
return secrets.compare_digest(computed_challenge, code_challenge)
|
||||||
|
|
||||||
|
|
||||||
|
async def oauth_token_endpoint(request: Request) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
OAuth token endpoint for AS proxy (ADR-023).
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- grant_type=authorization_code: Exchange proxy_code for Nextcloud token
|
||||||
|
- grant_type=refresh_token: Proxy refresh request to Nextcloud
|
||||||
|
|
||||||
|
Form parameters:
|
||||||
|
grant_type: "authorization_code" or "refresh_token"
|
||||||
|
code: Proxy authorization code (for authorization_code grant)
|
||||||
|
redirect_uri: Must match the original redirect_uri
|
||||||
|
code_verifier: PKCE verifier (for authorization_code grant)
|
||||||
|
client_id: Client identifier
|
||||||
|
client_secret: Client secret (optional for public clients)
|
||||||
|
refresh_token: Refresh token (for refresh_token grant)
|
||||||
|
"""
|
||||||
|
# Parse form body
|
||||||
|
form = await request.form()
|
||||||
|
grant_type = form.get("grant_type")
|
||||||
|
|
||||||
|
if grant_type == "authorization_code":
|
||||||
|
return await _token_authorization_code(request, form)
|
||||||
|
elif grant_type == "refresh_token":
|
||||||
|
return await _token_refresh(request, form)
|
||||||
|
else:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "unsupported_grant_type",
|
||||||
|
"error_description": f"Unsupported grant_type: {grant_type}",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _token_authorization_code(request: Request, form) -> JSONResponse:
|
||||||
|
"""Handle authorization_code grant type at the token endpoint."""
|
||||||
|
code = form.get("code")
|
||||||
|
redirect_uri = form.get("redirect_uri")
|
||||||
|
code_verifier = form.get("code_verifier")
|
||||||
|
client_id = form.get("client_id")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"AS proxy token: received code=%s client_id=%s redirect_uri=%s "
|
||||||
|
"code_verifier=%s",
|
||||||
|
code[:8] + "..." if code else None,
|
||||||
|
client_id,
|
||||||
|
redirect_uri,
|
||||||
|
"present" if code_verifier else "missing",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
logger.warning("AS proxy token: Missing 'code' parameter")
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "invalid_request", "error_description": "code is required"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Look up and consume proxy code (one-time use)
|
||||||
|
entry = _proxy_codes.pop(code, None)
|
||||||
|
if not entry:
|
||||||
|
logger.warning(
|
||||||
|
"AS proxy token: Invalid or expired code (active_codes=%d)",
|
||||||
|
len(_proxy_codes),
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "Invalid or expired authorization code",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
if entry.is_expired:
|
||||||
|
age = time.time() - entry.created_at
|
||||||
|
logger.warning("AS proxy token: Proxy code expired (age=%.1fs, TTL=60s)", age)
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "Authorization code has expired",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate client_id (required per RFC 6749 Section 4.1.3)
|
||||||
|
if not client_id:
|
||||||
|
logger.warning("AS proxy token: Missing 'client_id' parameter")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "client_id is required",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
if client_id != entry.client_id:
|
||||||
|
logger.warning(
|
||||||
|
"AS proxy token: client_id mismatch (got=%s, expected=%s)",
|
||||||
|
client_id,
|
||||||
|
entry.client_id,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "client_id mismatch",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate redirect_uri (required per RFC 6749 Section 4.1.3)
|
||||||
|
if not redirect_uri:
|
||||||
|
logger.warning("AS proxy token: Missing 'redirect_uri' parameter")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "redirect_uri is required",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
if redirect_uri != entry.client_redirect_uri:
|
||||||
|
logger.warning(
|
||||||
|
"AS proxy token: redirect_uri mismatch (got=%s, expected=%s)",
|
||||||
|
redirect_uri,
|
||||||
|
entry.client_redirect_uri,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "redirect_uri mismatch",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify PKCE (always required — oauth_authorize mandates code_challenge)
|
||||||
|
if not entry.code_challenge:
|
||||||
|
logger.error("AS proxy token: code_challenge missing from stored entry")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "server_error",
|
||||||
|
"error_description": "Internal state error: missing PKCE challenge",
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not code_verifier:
|
||||||
|
logger.warning("AS proxy token: Missing 'code_verifier' (PKCE required)")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "code_verifier is required (PKCE)",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _verify_pkce_s256(code_verifier, entry.code_challenge):
|
||||||
|
logger.warning(f"PKCE verification failed for client {entry.client_id}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "PKCE verification failed",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"AS proxy token: Returning Nextcloud token for client {entry.client_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return the stored Nextcloud token response directly
|
||||||
|
return JSONResponse(entry.nc_token_response)
|
||||||
|
|
||||||
|
|
||||||
|
async def _token_refresh(request: Request, form) -> JSONResponse:
|
||||||
|
"""Handle refresh_token grant type by proxying to Nextcloud."""
|
||||||
|
refresh_token = form.get("refresh_token")
|
||||||
|
if not refresh_token:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "refresh_token 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"]
|
||||||
|
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not mcp_server_client_id or not mcp_server_client_secret:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "server_error",
|
||||||
|
"error_description": "MCP server OAuth credentials not configured",
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
mcp_server_url = oauth_config["mcp_server_url"]
|
||||||
|
|
||||||
|
# Discover token endpoint
|
||||||
|
discovery_url = oauth_config.get("discovery_url")
|
||||||
|
if not discovery_url:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "server_error",
|
||||||
|
"error_description": "OIDC discovery URL not configured",
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
discovery = await _get_cached_discovery(discovery_url)
|
||||||
|
token_endpoint = discovery["token_endpoint"]
|
||||||
|
|
||||||
|
# Proxy refresh request to Nextcloud
|
||||||
|
token_params = {
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"client_id": mcp_server_client_id,
|
||||||
|
"client_secret": mcp_server_client_secret,
|
||||||
|
"resource": f"{mcp_server_url}/mcp",
|
||||||
|
}
|
||||||
|
|
||||||
|
async with nextcloud_httpx_client() as http_client:
|
||||||
|
response = await http_client.post(token_endpoint, data=token_params)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(
|
||||||
|
f"AS proxy token refresh failed: {response.status_code} {response.text}"
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "Token refresh failed",
|
||||||
|
},
|
||||||
|
status_code=response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(response.json())
|
||||||
|
|
||||||
|
|
||||||
|
async def oauth_register_proxy(request: Request) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
DCR proxy endpoint for AS proxy (ADR-023).
|
||||||
|
|
||||||
|
Proxies Dynamic Client Registration requests to Nextcloud's OIDC endpoint
|
||||||
|
and registers the resulting client in the local ClientRegistry.
|
||||||
|
|
||||||
|
This allows MCP clients to register via the MCP server (their AS) rather
|
||||||
|
than directly with Nextcloud (which would produce tokens with wrong audience).
|
||||||
|
"""
|
||||||
|
# Parse JSON body
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Request body must be valid JSON",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get OAuth context for Nextcloud endpoint
|
||||||
|
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"]
|
||||||
|
nextcloud_host = oauth_config["nextcloud_host"]
|
||||||
|
|
||||||
|
# Rate limit DCR requests per client IP
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
now = time.time()
|
||||||
|
timestamps = _dcr_rate_limit.get(client_ip, [])
|
||||||
|
# Remove timestamps outside the window
|
||||||
|
timestamps = [t for t in timestamps if now - t < _DCR_RATE_LIMIT_WINDOW]
|
||||||
|
if len(timestamps) >= _DCR_RATE_LIMIT_MAX:
|
||||||
|
logger.warning(f"DCR rate limit exceeded for {client_ip}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "too_many_requests",
|
||||||
|
"error_description": "Rate limit exceeded for client registration",
|
||||||
|
},
|
||||||
|
status_code=429,
|
||||||
|
headers={"Retry-After": str(_DCR_RATE_LIMIT_WINDOW)},
|
||||||
|
)
|
||||||
|
timestamps.append(now)
|
||||||
|
_dcr_rate_limit[client_ip] = timestamps
|
||||||
|
|
||||||
|
# Discover registration endpoint from OIDC discovery (prefer over hardcoded path)
|
||||||
|
discovery_url = oauth_config.get("discovery_url")
|
||||||
|
if discovery_url:
|
||||||
|
try:
|
||||||
|
discovery = await _get_cached_discovery(discovery_url)
|
||||||
|
registration_endpoint = discovery.get(
|
||||||
|
"registration_endpoint", f"{nextcloud_host}/apps/oidc/register"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to fetch OIDC discovery for DCR endpoint, using fallback"
|
||||||
|
)
|
||||||
|
registration_endpoint = f"{nextcloud_host}/apps/oidc/register"
|
||||||
|
else:
|
||||||
|
registration_endpoint = f"{nextcloud_host}/apps/oidc/register"
|
||||||
|
|
||||||
|
logger.info(f"DCR proxy: Forwarding registration to {registration_endpoint}")
|
||||||
|
|
||||||
|
async with nextcloud_httpx_client() as http_client:
|
||||||
|
response = await http_client.post(
|
||||||
|
registration_endpoint,
|
||||||
|
json=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
logger.error(
|
||||||
|
f"DCR proxy: Nextcloud registration failed: {response.status_code} {response.text}"
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
response.json()
|
||||||
|
if response.headers.get("content-type", "").startswith("application/json")
|
||||||
|
else {
|
||||||
|
"error": "server_error",
|
||||||
|
"error_description": f"Upstream registration failed: {response.status_code}",
|
||||||
|
},
|
||||||
|
status_code=response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
nc_response = response.json()
|
||||||
|
new_client_id = nc_response.get("client_id")
|
||||||
|
|
||||||
|
if new_client_id:
|
||||||
|
# Register in local ClientRegistry so /oauth/authorize accepts it
|
||||||
|
redirect_uris = nc_response.get("redirect_uris", [])
|
||||||
|
client_name = nc_response.get("client_name", "")
|
||||||
|
registry = get_client_registry()
|
||||||
|
registry.register_proxy_client(
|
||||||
|
client_id=new_client_id,
|
||||||
|
redirect_uris=redirect_uris,
|
||||||
|
name=client_name,
|
||||||
|
)
|
||||||
|
logger.info(f"DCR proxy: Registered client {new_client_id} in local registry")
|
||||||
|
|
||||||
|
return JSONResponse(nc_response, status_code=response.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
async def oauth_as_metadata(request: Request) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
RFC 8414 OAuth Authorization Server Metadata endpoint (ADR-023).
|
||||||
|
|
||||||
|
Advertises the MCP server as its own OAuth Authorization Server so that
|
||||||
|
MCP clients (e.g., Claude Code) authenticate through the proxy rather
|
||||||
|
than directly with Nextcloud.
|
||||||
|
"""
|
||||||
|
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||||
|
|
||||||
|
# Dynamically discover scopes from registered tools if available
|
||||||
|
scopes_supported = ["openid", "profile", "email"]
|
||||||
|
app_scopes = getattr(request.app.state, "supported_scopes", None)
|
||||||
|
if app_scopes:
|
||||||
|
scopes_supported = app_scopes
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"issuer": mcp_server_url,
|
||||||
|
"authorization_endpoint": f"{mcp_server_url}/oauth/authorize",
|
||||||
|
"token_endpoint": f"{mcp_server_url}/oauth/token",
|
||||||
|
"registration_endpoint": f"{mcp_server_url}/oauth/register",
|
||||||
|
"response_types_supported": ["code"],
|
||||||
|
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||||
|
"code_challenge_methods_supported": ["S256"],
|
||||||
|
"token_endpoint_auth_methods_supported": [
|
||||||
|
"client_secret_post",
|
||||||
|
"client_secret_basic",
|
||||||
|
"none",
|
||||||
|
],
|
||||||
|
"scopes_supported": scopes_supported,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Scope-based authorization for MCP tools."""
|
"""Scope-based authorization for MCP tools."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
@@ -9,10 +10,18 @@ from mcp.server.auth.provider import AccessToken
|
|||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
|
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.storage import get_shared_storage
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Scopes that only assert identity (OIDC standard claims).
|
||||||
|
# Tools requiring *only* these scopes (e.g. auth provisioning tools) must
|
||||||
|
# bypass the Login Flow v2 "is the user provisioned?" check — otherwise the
|
||||||
|
# very tools that *create* app passwords would be blocked for unprovisioned
|
||||||
|
# users, creating a circular dependency.
|
||||||
|
IDENTITY_ONLY_SCOPES: frozenset[str] = frozenset({"openid", "profile", "email"})
|
||||||
|
|
||||||
|
|
||||||
class ScopeAuthorizationError(Exception):
|
class ScopeAuthorizationError(Exception):
|
||||||
"""Raised when a request lacks required scopes."""
|
"""Raised when a request lacks required scopes."""
|
||||||
@@ -120,13 +129,61 @@ def require_scopes(*required_scopes: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if access_token is None:
|
if access_token is None:
|
||||||
# Not in OAuth mode (BasicAuth or no auth)
|
# No OAuth token — BasicAuth mode bypasses scope checks
|
||||||
# In BasicAuth mode, all operations are allowed
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"No access token present for {func_name} - allowing (BasicAuth mode)"
|
f"No access token for {func_name} - allowing (BasicAuth mode)"
|
||||||
)
|
)
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
# ── Login Flow v2: Check stored app password scopes ──
|
||||||
|
# In Login Flow v2 multi-user mode, OAuth tokens provide MCP session
|
||||||
|
# identity only. Nextcloud API access uses stored app passwords.
|
||||||
|
# Check if the user has a stored app password with appropriate scopes.
|
||||||
|
if get_settings().enable_login_flow and not set(required_scopes).issubset(
|
||||||
|
IDENTITY_ONLY_SCOPES
|
||||||
|
):
|
||||||
|
from nextcloud_mcp_server.auth.token_utils import ( # noqa: PLC0415
|
||||||
|
extract_user_id_from_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = await extract_user_id_from_token(ctx)
|
||||||
|
if user_id and user_id != "default_user":
|
||||||
|
stored_scopes = await _get_stored_scopes(user_id)
|
||||||
|
|
||||||
|
if stored_scopes is None:
|
||||||
|
# No stored app password → require provisioning
|
||||||
|
error_msg = (
|
||||||
|
f"Access denied to {func_name}: "
|
||||||
|
f"Nextcloud access not provisioned. "
|
||||||
|
f"Please call 'nc_auth_provision_access' first."
|
||||||
|
)
|
||||||
|
logger.warning(error_msg)
|
||||||
|
raise ProvisioningRequiredError(error_msg)
|
||||||
|
|
||||||
|
if stored_scopes == "all":
|
||||||
|
# NULL scopes in DB = legacy app password = all allowed
|
||||||
|
logger.debug(
|
||||||
|
f"Stored app password scope check passed for {func_name}: all scopes"
|
||||||
|
)
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
# Check stored scopes against required
|
||||||
|
stored_set = set(stored_scopes)
|
||||||
|
missing = set(required_scopes) - stored_set
|
||||||
|
if missing:
|
||||||
|
error_msg = (
|
||||||
|
f"Access denied to {func_name}: "
|
||||||
|
f"Missing scopes: {', '.join(sorted(missing))}. "
|
||||||
|
f"Call 'nc_auth_update_scopes' to add permissions."
|
||||||
|
)
|
||||||
|
logger.warning(error_msg)
|
||||||
|
raise InsufficientScopeError(list(missing), error_msg)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Stored app password scope check passed for {func_name}"
|
||||||
|
)
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
# Extract scopes from access token
|
# Extract scopes from access token
|
||||||
token_scopes = set(access_token.scopes or [])
|
token_scopes = set(access_token.scopes or [])
|
||||||
required_scopes_set = set(required_scopes)
|
required_scopes_set = set(required_scopes)
|
||||||
@@ -416,3 +473,47 @@ def discover_all_scopes(mcp) -> list[str]:
|
|||||||
|
|
||||||
# Return sorted list of unique scopes
|
# Return sorted list of unique scopes
|
||||||
return sorted(all_scopes)
|
return sorted(all_scopes)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Login Flow v2 helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Scope cache: user_id → (expires_at, scopes)
|
||||||
|
_scope_cache: dict[str, tuple[float, list[str] | str | None]] = {}
|
||||||
|
_SCOPE_CACHE_TTL = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_scope_cache(user_id: str) -> None:
|
||||||
|
"""Remove cached scopes for a user (call when scopes are updated)."""
|
||||||
|
_scope_cache.pop(user_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_stored_scopes(user_id: str) -> list[str] | str | None:
|
||||||
|
"""Look up stored app password scopes for a user (with TTL cache).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- list[str]: Specific scopes granted
|
||||||
|
- "all": NULL scopes in DB (legacy = all allowed)
|
||||||
|
- None: No stored app password (provisioning required)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Storage/infrastructure exceptions propagate to the caller
|
||||||
|
(require_scopes decorator) for proper MCP error responses.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
if user_id in _scope_cache:
|
||||||
|
expires_at, cached = _scope_cache[user_id]
|
||||||
|
if now < expires_at:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
storage = await get_shared_storage()
|
||||||
|
|
||||||
|
data = await storage.get_app_password_with_scopes(user_id)
|
||||||
|
if data is None:
|
||||||
|
result = None
|
||||||
|
elif data["scopes"] is None:
|
||||||
|
result = "all"
|
||||||
|
else:
|
||||||
|
result = data["scopes"]
|
||||||
|
|
||||||
|
_scope_cache[user_id] = (now + _SCOPE_CACHE_TTL, result)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -1477,6 +1477,420 @@ class RefreshTokenStorage:
|
|||||||
|
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
# ── Login Flow v2: Scoped App Passwords ──────────────────────────────
|
||||||
|
|
||||||
|
async def store_app_password_with_scopes(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
app_password: str,
|
||||||
|
scopes: list[str] | None = None,
|
||||||
|
username: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Store encrypted app password with optional scopes and Nextcloud username.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: MCP user ID (identity from OAuth token or session)
|
||||||
|
app_password: Nextcloud app password to encrypt and store
|
||||||
|
scopes: List of granted scopes (None = all scopes allowed)
|
||||||
|
username: Nextcloud loginName from Login Flow v2 response
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any scope is not in ALL_SUPPORTED_SCOPES
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
if not self.cipher:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Encryption key not configured. "
|
||||||
|
"Set TOKEN_ENCRYPTION_KEY for app password storage."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Defense-in-depth: validate scopes at storage layer
|
||||||
|
if scopes is not None:
|
||||||
|
from nextcloud_mcp_server.models.auth import ( # noqa: PLC0415
|
||||||
|
ALL_SUPPORTED_SCOPES,
|
||||||
|
)
|
||||||
|
|
||||||
|
invalid = [s for s in scopes if s not in ALL_SUPPORTED_SCOPES]
|
||||||
|
if invalid:
|
||||||
|
raise ValueError(f"Invalid scopes: {invalid}")
|
||||||
|
|
||||||
|
encrypted_password = self.cipher.encrypt(app_password.encode())
|
||||||
|
scopes_json = json.dumps(scopes) if scopes is not None else None
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO app_passwords
|
||||||
|
(user_id, encrypted_password, created_at, updated_at, scopes, username)
|
||||||
|
VALUES (
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
COALESCE((SELECT created_at FROM app_passwords WHERE user_id = ?), ?),
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
encrypted_password,
|
||||||
|
user_id,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
scopes_json,
|
||||||
|
username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "insert", duration, "success")
|
||||||
|
logger.info(
|
||||||
|
f"Stored scoped app password for user {user_id} "
|
||||||
|
f"(scopes={'all' if scopes is None else len(scopes)}, "
|
||||||
|
f"username={username or 'N/A'})"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "insert", duration, "error")
|
||||||
|
raise
|
||||||
|
|
||||||
|
await self._audit_log(
|
||||||
|
event="store_app_password_with_scopes",
|
||||||
|
user_id=user_id,
|
||||||
|
auth_method="app_password",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_app_password_with_scopes(self, user_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Retrieve app password with scopes and metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: MCP user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: app_password, scopes, username, created_at, updated_at
|
||||||
|
or None if not found
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
if not self.cipher:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Encryption key not configured. "
|
||||||
|
"Set TOKEN_ENCRYPTION_KEY for app password retrieval."
|
||||||
|
)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
async with db.execute(
|
||||||
|
"""
|
||||||
|
SELECT encrypted_password, scopes, username, created_at, updated_at
|
||||||
|
FROM app_passwords WHERE user_id = ?
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
logger.debug(f"No app password found for user {user_id}")
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "select", duration, "success")
|
||||||
|
return None
|
||||||
|
|
||||||
|
encrypted_password, scopes_json, username, created_at, updated_at = row
|
||||||
|
decrypted_password = self.cipher.decrypt(encrypted_password).decode()
|
||||||
|
scopes = json.loads(scopes_json) if scopes_json else None
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "select", duration, "success")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"app_password": decrypted_password,
|
||||||
|
"scopes": scopes,
|
||||||
|
"username": username,
|
||||||
|
"created_at": created_at,
|
||||||
|
"updated_at": updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "select", duration, "error")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def update_app_password_scopes(self, user_id: str, scopes: list[str]) -> bool:
|
||||||
|
"""Update only the scopes for an existing app password (no decrypt/re-encrypt).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: MCP user ID
|
||||||
|
scopes: New scope list
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a row was updated, False if user not found
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
scopes_json = json.dumps(scopes)
|
||||||
|
now = int(time.time())
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"UPDATE app_passwords SET scopes = ?, updated_at = ? WHERE user_id = ?",
|
||||||
|
(scopes_json, now, user_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
updated = cursor.rowcount > 0
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "update", duration, "success")
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
await self._audit_log(
|
||||||
|
event="update_app_password_scopes",
|
||||||
|
user_id=user_id,
|
||||||
|
auth_method="app_password",
|
||||||
|
)
|
||||||
|
|
||||||
|
return updated
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "update", duration, "error")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# ── Login Flow v2: Session Tracking ──────────────────────────────────
|
||||||
|
|
||||||
|
async def store_login_flow_session(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
poll_token: str,
|
||||||
|
poll_endpoint: str,
|
||||||
|
requested_scopes: list[str] | None = None,
|
||||||
|
expires_at: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Store a Login Flow v2 polling session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: MCP user ID
|
||||||
|
poll_token: Token for polling (will be encrypted)
|
||||||
|
poll_endpoint: URL to poll for completion
|
||||||
|
requested_scopes: Scopes requested in this flow
|
||||||
|
expires_at: Expiration timestamp (defaults to 20 minutes from now)
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
if not self.cipher:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Encryption key not configured. "
|
||||||
|
"Set TOKEN_ENCRYPTION_KEY for login flow session storage."
|
||||||
|
)
|
||||||
|
|
||||||
|
encrypted_token = self.cipher.encrypt(poll_token.encode())
|
||||||
|
scopes_json = json.dumps(requested_scopes) if requested_scopes else None
|
||||||
|
now = int(time.time())
|
||||||
|
if expires_at is None:
|
||||||
|
expires_at = now + 1200 # 20 minutes default
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO login_flow_sessions
|
||||||
|
(user_id, encrypted_poll_token, poll_endpoint, requested_scopes,
|
||||||
|
created_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
encrypted_token,
|
||||||
|
poll_endpoint,
|
||||||
|
scopes_json,
|
||||||
|
now,
|
||||||
|
expires_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "insert", duration, "success")
|
||||||
|
logger.info(f"Stored login flow session for user {user_id}")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "insert", duration, "error")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_login_flow_session(self, user_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Retrieve a pending Login Flow v2 session.
|
||||||
|
|
||||||
|
Returns None if session doesn't exist or has expired.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: MCP user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: poll_token, poll_endpoint, requested_scopes, created_at, expires_at
|
||||||
|
or None if not found/expired
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
if not self.cipher:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Encryption key not configured. "
|
||||||
|
"Set TOKEN_ENCRYPTION_KEY for login flow session retrieval."
|
||||||
|
)
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
async with db.execute(
|
||||||
|
"""
|
||||||
|
SELECT encrypted_poll_token, poll_endpoint, requested_scopes,
|
||||||
|
created_at, expires_at
|
||||||
|
FROM login_flow_sessions
|
||||||
|
WHERE user_id = ? AND expires_at > ?
|
||||||
|
""",
|
||||||
|
(user_id, now),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "select", duration, "success")
|
||||||
|
return None
|
||||||
|
|
||||||
|
encrypted_token, poll_endpoint, scopes_json, created_at, expires_at = row
|
||||||
|
poll_token = self.cipher.decrypt(encrypted_token).decode()
|
||||||
|
requested_scopes = json.loads(scopes_json) if scopes_json else None
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "select", duration, "success")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"poll_token": poll_token,
|
||||||
|
"poll_endpoint": poll_endpoint,
|
||||||
|
"requested_scopes": requested_scopes,
|
||||||
|
"created_at": created_at,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "select", duration, "error")
|
||||||
|
logger.error(
|
||||||
|
f"Failed to retrieve login flow session for user {user_id}: {e}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def delete_login_flow_session(self, user_id: str) -> bool:
|
||||||
|
"""Delete a Login Flow v2 session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: MCP user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if session was deleted, False if not found
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"DELETE FROM login_flow_sessions WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
deleted = cursor.rowcount > 0
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "delete", duration, "success")
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
logger.info(f"Deleted login flow session for user {user_id}")
|
||||||
|
await self._audit_log(
|
||||||
|
event="delete_login_flow_session",
|
||||||
|
user_id=user_id,
|
||||||
|
auth_method="login_flow",
|
||||||
|
)
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "delete", duration, "error")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def delete_expired_login_flow_sessions(self) -> int:
|
||||||
|
"""Delete all expired Login Flow v2 sessions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of sessions deleted
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"DELETE FROM login_flow_sessions WHERE expires_at <= ?",
|
||||||
|
(now,),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
count = cursor.rowcount
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "delete", duration, "success")
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
logger.info(f"Cleaned up {count} expired login flow sessions")
|
||||||
|
await self._audit_log(
|
||||||
|
event="delete_expired_login_flow_sessions",
|
||||||
|
user_id="system",
|
||||||
|
auth_method="login_flow",
|
||||||
|
)
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "delete", duration, "error")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
_shared_instance: RefreshTokenStorage | None = None
|
||||||
|
_shared_lock: anyio.Lock = anyio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_shared_storage() -> RefreshTokenStorage:
|
||||||
|
"""Get the process-wide RefreshTokenStorage singleton (lock-protected).
|
||||||
|
|
||||||
|
All modules that need storage should use this function instead of
|
||||||
|
creating their own lazy singletons. The lock ensures thread-safe
|
||||||
|
initialization on concurrent first-access.
|
||||||
|
"""
|
||||||
|
global _shared_instance
|
||||||
|
async with _shared_lock:
|
||||||
|
if _shared_instance is None:
|
||||||
|
_shared_instance = RefreshTokenStorage.from_env()
|
||||||
|
await _shared_instance.initialize()
|
||||||
|
return _shared_instance
|
||||||
|
|
||||||
|
|
||||||
async def generate_encryption_key() -> str:
|
async def generate_encryption_key() -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Token utility functions for extracting user identity from MCP access tokens.
|
||||||
|
|
||||||
|
Extracted from server/oauth_tools.py to break circular import dependencies
|
||||||
|
between server/ and auth/ layers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||||
|
from mcp.server.auth.provider import AccessToken
|
||||||
|
from mcp.server.fastmcp import Context
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_user_id_from_token(ctx: Context) -> str:
|
||||||
|
"""Extract user_id from the MCP access token (Flow 1).
|
||||||
|
|
||||||
|
Handles both JWT and opaque tokens:
|
||||||
|
- JWT: Decode and extract 'sub' claim
|
||||||
|
- Opaque: Call userinfo endpoint to get 'sub'
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: MCP context with access token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
user_id extracted from token, or "default_user" as fallback
|
||||||
|
"""
|
||||||
|
# Use MCP SDK's get_access_token() which uses contextvars
|
||||||
|
access_token: AccessToken | None = get_access_token()
|
||||||
|
|
||||||
|
if not access_token or not access_token.token:
|
||||||
|
logger.warning(" ✗ No access token found via get_access_token()")
|
||||||
|
return "default_user"
|
||||||
|
|
||||||
|
token = access_token.token
|
||||||
|
is_jwt = "." in token and token.count(".") >= 2
|
||||||
|
logger.info(f" Token type: {'JWT' if is_jwt else 'Opaque'}")
|
||||||
|
|
||||||
|
# Try JWT decode first
|
||||||
|
if is_jwt:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, options={"verify_signature": False})
|
||||||
|
user_id = payload.get("sub", "unknown")
|
||||||
|
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
|
||||||
|
return user_id
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f" ✗ JWT decode failed: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
# Opaque token - call userinfo endpoint
|
||||||
|
logger.info(" Opaque token detected, calling userinfo endpoint...")
|
||||||
|
try:
|
||||||
|
# Get userinfo endpoint from OIDC discovery
|
||||||
|
oidc_discovery_uri = os.getenv(
|
||||||
|
"OIDC_DISCOVERY_URI",
|
||||||
|
"http://localhost:8080/.well-known/openid-configuration",
|
||||||
|
)
|
||||||
|
async with nextcloud_httpx_client() as http_client:
|
||||||
|
discovery_response = await http_client.get(oidc_discovery_uri)
|
||||||
|
discovery_response.raise_for_status()
|
||||||
|
discovery = discovery_response.json()
|
||||||
|
userinfo_endpoint = discovery.get("userinfo_endpoint")
|
||||||
|
|
||||||
|
if userinfo_endpoint:
|
||||||
|
userinfo = await _query_idp_userinfo(token, userinfo_endpoint)
|
||||||
|
if userinfo:
|
||||||
|
user_id = userinfo.get("sub", "unknown")
|
||||||
|
logger.info(f" ✓ Userinfo query successful: user_id={user_id}")
|
||||||
|
return user_id
|
||||||
|
else:
|
||||||
|
logger.error(" ✗ Userinfo query failed")
|
||||||
|
else:
|
||||||
|
logger.error(" ✗ No userinfo_endpoint available")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f" ✗ Userinfo query failed: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
# Fallback
|
||||||
|
logger.warning(" Using fallback user_id: default_user")
|
||||||
|
return "default_user"
|
||||||
@@ -5,7 +5,7 @@ import socket
|
|||||||
import ssl
|
import ssl
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
class DeploymentMode(Enum):
|
class DeploymentMode(Enum):
|
||||||
@@ -169,31 +169,32 @@ class Settings:
|
|||||||
# Optional: If not set, mode is auto-detected from other settings
|
# Optional: If not set, mode is auto-detected from other settings
|
||||||
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
|
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
|
||||||
# oauth_token_exchange, smithery
|
# oauth_token_exchange, smithery
|
||||||
deployment_mode: Optional[str] = None
|
deployment_mode: str | None = None
|
||||||
|
|
||||||
# OAuth/OIDC settings
|
# OAuth/OIDC settings
|
||||||
oidc_discovery_url: Optional[str] = None
|
oidc_discovery_url: str | None = None
|
||||||
oidc_client_id: Optional[str] = None
|
oidc_client_id: str | None = None
|
||||||
oidc_client_secret: Optional[str] = None
|
oidc_client_secret: str | None = None
|
||||||
oidc_issuer: Optional[str] = None
|
oidc_issuer: str | None = None
|
||||||
|
|
||||||
# Nextcloud settings
|
# Nextcloud settings
|
||||||
nextcloud_host: Optional[str] = None
|
nextcloud_host: str | None = None
|
||||||
nextcloud_username: Optional[str] = None
|
nextcloud_username: str | None = None
|
||||||
nextcloud_password: Optional[str] = None
|
nextcloud_password: str | None = None
|
||||||
|
nextcloud_app_password: str | None = None # Preferred over nextcloud_password
|
||||||
|
|
||||||
# Nextcloud SSL/TLS settings
|
# Nextcloud SSL/TLS settings
|
||||||
nextcloud_verify_ssl: bool = True
|
nextcloud_verify_ssl: bool = True
|
||||||
nextcloud_ca_bundle: Optional[str] = None
|
nextcloud_ca_bundle: str | None = None
|
||||||
|
|
||||||
# ADR-005: Token Audience Validation (required for OAuth mode)
|
# ADR-005: Token Audience Validation (required for OAuth mode)
|
||||||
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
|
nextcloud_mcp_server_url: str | None = None # MCP server URL (used as audience)
|
||||||
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
|
nextcloud_resource_uri: str | None = None # Nextcloud resource identifier
|
||||||
|
|
||||||
# Token verification endpoints
|
# Token verification endpoints
|
||||||
jwks_uri: Optional[str] = None
|
jwks_uri: str | None = None
|
||||||
introspection_uri: Optional[str] = None
|
introspection_uri: str | None = None
|
||||||
userinfo_uri: Optional[str] = None
|
userinfo_uri: str | None = None
|
||||||
|
|
||||||
# Progressive Consent settings (always enabled - no flag needed)
|
# Progressive Consent settings (always enabled - no flag needed)
|
||||||
enable_token_exchange: bool = False
|
enable_token_exchange: bool = False
|
||||||
@@ -204,6 +205,9 @@ class Settings:
|
|||||||
# and passes them through to Nextcloud APIs (no storage, stateless)
|
# and passes them through to Nextcloud APIs (no storage, stateless)
|
||||||
enable_multi_user_basic_auth: bool = False
|
enable_multi_user_basic_auth: bool = False
|
||||||
|
|
||||||
|
# Login Flow v2 settings (ADR-022)
|
||||||
|
enable_login_flow: bool = False
|
||||||
|
|
||||||
# Token exchange cache settings
|
# Token exchange cache settings
|
||||||
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
|
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
|
||||||
|
|
||||||
@@ -214,8 +218,8 @@ class Settings:
|
|||||||
# TOKEN_STORAGE_DB: Path to SQLite database for persistent storage.
|
# TOKEN_STORAGE_DB: Path to SQLite database for persistent storage.
|
||||||
# Used for webhook tracking (all modes) and OAuth token storage.
|
# Used for webhook tracking (all modes) and OAuth token storage.
|
||||||
# Defaults to /tmp/tokens.db
|
# Defaults to /tmp/tokens.db
|
||||||
token_encryption_key: Optional[str] = None
|
token_encryption_key: str | None = None
|
||||||
token_storage_db: Optional[str] = None
|
token_storage_db: str | None = None
|
||||||
|
|
||||||
# Vector sync settings (ADR-007)
|
# Vector sync settings (ADR-007)
|
||||||
vector_sync_enabled: bool = False
|
vector_sync_enabled: bool = False
|
||||||
@@ -225,19 +229,19 @@ class Settings:
|
|||||||
vector_sync_user_poll_interval: int = 60 # seconds - OAuth mode user discovery
|
vector_sync_user_poll_interval: int = 60 # seconds - OAuth mode user discovery
|
||||||
|
|
||||||
# Qdrant settings (mutually exclusive modes)
|
# Qdrant settings (mutually exclusive modes)
|
||||||
qdrant_url: Optional[str] = None # Network mode: http://qdrant:6333
|
qdrant_url: str | None = None # Network mode: http://qdrant:6333
|
||||||
qdrant_location: Optional[str] = None # Local mode: :memory: or /path/to/data
|
qdrant_location: str | None = None # Local mode: :memory: or /path/to/data
|
||||||
qdrant_api_key: Optional[str] = None
|
qdrant_api_key: str | None = None
|
||||||
qdrant_collection: str = "nextcloud_content"
|
qdrant_collection: str = "nextcloud_content"
|
||||||
|
|
||||||
# Ollama settings (for embeddings)
|
# Ollama settings (for embeddings)
|
||||||
ollama_base_url: Optional[str] = None
|
ollama_base_url: str | None = None
|
||||||
ollama_embedding_model: str = "nomic-embed-text"
|
ollama_embedding_model: str = "nomic-embed-text"
|
||||||
ollama_verify_ssl: bool = True
|
ollama_verify_ssl: bool = True
|
||||||
|
|
||||||
# OpenAI settings (for embeddings)
|
# OpenAI settings (for embeddings)
|
||||||
openai_api_key: Optional[str] = None
|
openai_api_key: str | None = None
|
||||||
openai_base_url: Optional[str] = None
|
openai_base_url: str | None = None
|
||||||
openai_embedding_model: str = "text-embedding-3-small"
|
openai_embedding_model: str = "text-embedding-3-small"
|
||||||
|
|
||||||
# Document chunking settings (for vector embeddings)
|
# Document chunking settings (for vector embeddings)
|
||||||
@@ -247,7 +251,7 @@ class Settings:
|
|||||||
# Observability settings
|
# Observability settings
|
||||||
metrics_enabled: bool = True
|
metrics_enabled: bool = True
|
||||||
metrics_port: int = 9090
|
metrics_port: int = 9090
|
||||||
otel_exporter_otlp_endpoint: Optional[str] = None
|
otel_exporter_otlp_endpoint: str | None = None
|
||||||
otel_exporter_verify_ssl: bool = False
|
otel_exporter_verify_ssl: bool = False
|
||||||
otel_service_name: str = "nextcloud-mcp-server"
|
otel_service_name: str = "nextcloud-mcp-server"
|
||||||
otel_traces_sampler: str = "always_on"
|
otel_traces_sampler: str = "always_on"
|
||||||
@@ -523,6 +527,7 @@ def get_settings() -> Settings:
|
|||||||
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
||||||
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
|
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
|
||||||
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
|
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
|
||||||
|
nextcloud_app_password=os.getenv("NEXTCLOUD_APP_PASSWORD"),
|
||||||
# Nextcloud SSL/TLS settings
|
# Nextcloud SSL/TLS settings
|
||||||
nextcloud_verify_ssl=(
|
nextcloud_verify_ssl=(
|
||||||
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
|
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
|
||||||
@@ -544,6 +549,8 @@ def get_settings() -> Settings:
|
|||||||
enable_multi_user_basic_auth=(
|
enable_multi_user_basic_auth=(
|
||||||
os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true"
|
os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true"
|
||||||
),
|
),
|
||||||
|
# Login Flow v2 settings (ADR-022)
|
||||||
|
enable_login_flow=(os.getenv("ENABLE_LOGIN_FLOW", "false").lower() == "true"),
|
||||||
# Token exchange cache settings
|
# Token exchange cache settings
|
||||||
token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
|
token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
|
||||||
# Token and webhook storage settings (encryption key optional for webhook-only usage)
|
# Token and webhook storage settings (encryption key optional for webhook-only usage)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from nextcloud_mcp_server.auth.context_helper import (
|
|||||||
get_client_from_context,
|
get_client_from_context,
|
||||||
get_session_client_from_context,
|
get_session_client_from_context,
|
||||||
)
|
)
|
||||||
|
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
|
||||||
|
from nextcloud_mcp_server.auth.storage import get_shared_storage
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import (
|
from nextcloud_mcp_server.config import (
|
||||||
DeploymentMode,
|
DeploymentMode,
|
||||||
@@ -78,6 +80,11 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
|||||||
|
|
||||||
lifespan_ctx = ctx.request_context.lifespan_context
|
lifespan_ctx = ctx.request_context.lifespan_context
|
||||||
|
|
||||||
|
# Login Flow v2 multi-user mode: app password is REQUIRED for NC API access
|
||||||
|
# OAuth token is only used for MCP session identity, not NC API calls
|
||||||
|
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_login_flow:
|
||||||
|
return await _get_client_from_login_flow(ctx, lifespan_ctx.nextcloud_host)
|
||||||
|
|
||||||
# BasicAuth mode - use shared client (no token exchange)
|
# BasicAuth mode - use shared client (no token exchange)
|
||||||
if hasattr(lifespan_ctx, "client"):
|
if hasattr(lifespan_ctx, "client"):
|
||||||
return lifespan_ctx.client
|
return lifespan_ctx.client
|
||||||
@@ -245,3 +252,51 @@ def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient:
|
|||||||
username=username,
|
username=username,
|
||||||
auth=BasicAuth(username, password),
|
auth=BasicAuth(username, password),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_client_from_login_flow(
|
||||||
|
ctx: Context, nextcloud_host: str
|
||||||
|
) -> NextcloudClient:
|
||||||
|
"""Create NextcloudClient from stored Login Flow v2 app password.
|
||||||
|
|
||||||
|
In Login Flow v2 mode, the OAuth token only provides MCP session identity.
|
||||||
|
Nextcloud API calls always use the stored app password obtained via Login Flow v2.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: MCP context (used to extract user identity)
|
||||||
|
nextcloud_host: Nextcloud instance URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NextcloudClient with stored app password credentials
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProvisioningRequiredError: If no stored app password exists
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.auth.token_utils import ( # noqa: PLC0415
|
||||||
|
extract_user_id_from_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = await extract_user_id_from_token(ctx)
|
||||||
|
if not user_id or user_id == "default_user":
|
||||||
|
raise ProvisioningRequiredError(
|
||||||
|
"Cannot determine user identity from MCP token."
|
||||||
|
)
|
||||||
|
|
||||||
|
storage = await get_shared_storage()
|
||||||
|
|
||||||
|
app_data = await storage.get_app_password_with_scopes(user_id)
|
||||||
|
if not app_data:
|
||||||
|
raise ProvisioningRequiredError(
|
||||||
|
"Nextcloud access not provisioned. "
|
||||||
|
"Call nc_auth_provision_access to complete Login Flow."
|
||||||
|
)
|
||||||
|
|
||||||
|
username = app_data.get("username") or user_id
|
||||||
|
|
||||||
|
logger.debug(f"Creating Login Flow v2 client for {nextcloud_host} as {username}")
|
||||||
|
|
||||||
|
return NextcloudClient(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
username=username,
|
||||||
|
auth=BasicAuth(username, app_data["app_password"]),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Pydantic response models for Login Flow v2 auth tools."""
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.models.base import BaseResponse
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionAccessResponse(BaseResponse):
|
||||||
|
"""Response from nc_auth_provision_access tool."""
|
||||||
|
|
||||||
|
status: str = Field(
|
||||||
|
description="Provisioning status: 'login_required', 'already_provisioned', 'declined', 'cancelled', 'error'"
|
||||||
|
)
|
||||||
|
login_url: str | None = Field(
|
||||||
|
None, description="URL to open in browser for Nextcloud login"
|
||||||
|
)
|
||||||
|
message: str = Field(description="Human-readable status message")
|
||||||
|
user_id: str | None = Field(None, description="MCP user ID")
|
||||||
|
requested_scopes: list[str] | None = Field(
|
||||||
|
None, description="Scopes requested in this provisioning flow"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionStatusResponse(BaseResponse):
|
||||||
|
"""Response from nc_auth_check_status tool."""
|
||||||
|
|
||||||
|
status: str = Field(
|
||||||
|
description="Status: 'provisioned', 'pending', 'not_initiated', 'error'"
|
||||||
|
)
|
||||||
|
message: str = Field(description="Human-readable status message")
|
||||||
|
user_id: str | None = Field(None, description="MCP user ID")
|
||||||
|
scopes: list[str] | None = Field(
|
||||||
|
None, description="Granted scopes (None = all scopes)"
|
||||||
|
)
|
||||||
|
username: str | None = Field(None, description="Nextcloud username (loginName)")
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateScopesResponse(BaseResponse):
|
||||||
|
"""Response from nc_auth_update_scopes tool."""
|
||||||
|
|
||||||
|
status: str = Field(
|
||||||
|
description="Status: 'login_required', 'unchanged', 'declined', 'cancelled', 'error'"
|
||||||
|
)
|
||||||
|
login_url: str | None = Field(
|
||||||
|
None, description="URL for re-provisioning with new scopes"
|
||||||
|
)
|
||||||
|
message: str = Field(description="Human-readable status message")
|
||||||
|
previous_scopes: list[str] | None = Field(
|
||||||
|
None, description="Previously granted scopes"
|
||||||
|
)
|
||||||
|
new_scopes: list[str] | None = Field(None, description="Updated scope set")
|
||||||
|
|
||||||
|
|
||||||
|
# All supported application-level scopes (frozenset for O(1) membership tests)
|
||||||
|
ALL_SUPPORTED_SCOPES: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"notes:read",
|
||||||
|
"notes:write",
|
||||||
|
"calendar:read",
|
||||||
|
"calendar:write",
|
||||||
|
"todo:read",
|
||||||
|
"todo:write",
|
||||||
|
"contacts:read",
|
||||||
|
"contacts:write",
|
||||||
|
"files:read",
|
||||||
|
"files:write",
|
||||||
|
"tables:read",
|
||||||
|
"tables:write",
|
||||||
|
"deck:read",
|
||||||
|
"deck:write",
|
||||||
|
"cookbook:read",
|
||||||
|
"cookbook:write",
|
||||||
|
"sharing:read",
|
||||||
|
"sharing:write",
|
||||||
|
"news:read",
|
||||||
|
"news:write",
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
"""MCP tools for Login Flow v2 authentication (ADR-022).
|
||||||
|
|
||||||
|
Provides tools for users to provision Nextcloud access via Login Flow v2,
|
||||||
|
check provisioning status, and update granted scopes.
|
||||||
|
|
||||||
|
These tools work alongside (not replacing) the existing OAuth provisioning
|
||||||
|
tools during the migration period.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.elicitation import present_login_url
|
||||||
|
from nextcloud_mcp_server.auth.login_flow import LoginFlowV2Client
|
||||||
|
from nextcloud_mcp_server.auth.scope_authorization import (
|
||||||
|
invalidate_scope_cache,
|
||||||
|
require_scopes,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.auth.storage import get_shared_storage
|
||||||
|
from nextcloud_mcp_server.auth.token_utils import extract_user_id_from_token
|
||||||
|
from nextcloud_mcp_server.config import get_nextcloud_ssl_verify, get_settings
|
||||||
|
from nextcloud_mcp_server.models.auth import (
|
||||||
|
ALL_SUPPORTED_SCOPES,
|
||||||
|
ProvisionAccessResponse,
|
||||||
|
ProvisionStatusResponse,
|
||||||
|
UpdateScopesResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def register_auth_tools(mcp: FastMCP) -> None:
|
||||||
|
"""Register Login Flow v2 auth tools with the MCP server."""
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="nc_auth_provision_access",
|
||||||
|
title="Provision Nextcloud Access",
|
||||||
|
description=(
|
||||||
|
"Start Nextcloud Login Flow v2 to obtain an app password. "
|
||||||
|
"This is required before using any Nextcloud tools. "
|
||||||
|
"You will be given a URL to open in your browser to log in."
|
||||||
|
),
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False,
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@require_scopes("openid")
|
||||||
|
async def nc_auth_provision_access(
|
||||||
|
ctx: Context,
|
||||||
|
scopes: list[str] | None = None,
|
||||||
|
) -> ProvisionAccessResponse:
|
||||||
|
"""Provision Nextcloud access via Login Flow v2.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: MCP context
|
||||||
|
scopes: Requested application scopes (e.g. ["notes:read", "calendar:write"]).
|
||||||
|
If not specified, all available scopes are requested.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProvisionAccessResponse with login URL or status
|
||||||
|
"""
|
||||||
|
user_id = await extract_user_id_from_token(ctx)
|
||||||
|
if user_id == "default_user":
|
||||||
|
return ProvisionAccessResponse(
|
||||||
|
status="error",
|
||||||
|
message="Could not determine user identity from MCP token.",
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
storage = await get_shared_storage()
|
||||||
|
|
||||||
|
# Check if already provisioned
|
||||||
|
existing = await storage.get_app_password_with_scopes(user_id)
|
||||||
|
if existing:
|
||||||
|
return ProvisionAccessResponse(
|
||||||
|
status="already_provisioned",
|
||||||
|
message=(
|
||||||
|
f"Nextcloud access already provisioned for {user_id}. "
|
||||||
|
f"Scopes: {existing['scopes'] or 'all'}. "
|
||||||
|
f"Use nc_auth_update_scopes to modify permissions."
|
||||||
|
),
|
||||||
|
user_id=user_id,
|
||||||
|
requested_scopes=existing["scopes"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine scopes
|
||||||
|
requested_scopes = scopes if scopes else list(ALL_SUPPORTED_SCOPES)
|
||||||
|
|
||||||
|
# Validate requested scopes
|
||||||
|
invalid_scopes = [s for s in requested_scopes if s not in ALL_SUPPORTED_SCOPES]
|
||||||
|
if invalid_scopes:
|
||||||
|
return ProvisionAccessResponse(
|
||||||
|
status="error",
|
||||||
|
message=f"Invalid scopes: {', '.join(invalid_scopes)}. "
|
||||||
|
f"Valid scopes: {', '.join(sorted(ALL_SUPPORTED_SCOPES))}",
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initiate Login Flow v2
|
||||||
|
settings = get_settings()
|
||||||
|
nextcloud_host = settings.nextcloud_host
|
||||||
|
if not nextcloud_host:
|
||||||
|
return ProvisionAccessResponse(
|
||||||
|
status="error",
|
||||||
|
message="NEXTCLOUD_HOST not configured on the server.",
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
flow_client = LoginFlowV2Client(
|
||||||
|
nextcloud_host=nextcloud_host,
|
||||||
|
verify_ssl=get_nextcloud_ssl_verify(),
|
||||||
|
)
|
||||||
|
init_response = await flow_client.initiate()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initiate Login Flow v2: {e}")
|
||||||
|
return ProvisionAccessResponse(
|
||||||
|
status="error",
|
||||||
|
message=f"Failed to start login flow: {e}",
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the polling session
|
||||||
|
await storage.store_login_flow_session(
|
||||||
|
user_id=user_id,
|
||||||
|
poll_token=init_response.poll_token,
|
||||||
|
poll_endpoint=init_response.poll_endpoint,
|
||||||
|
requested_scopes=requested_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Present login URL to user via elicitation
|
||||||
|
elicitation_result = await present_login_url(ctx, init_response.login_url)
|
||||||
|
|
||||||
|
if elicitation_result == "declined":
|
||||||
|
await storage.delete_login_flow_session(user_id)
|
||||||
|
return ProvisionAccessResponse(
|
||||||
|
status="declined",
|
||||||
|
message="Login flow declined. Call nc_auth_provision_access again to retry.",
|
||||||
|
user_id=user_id,
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if elicitation_result == "cancelled":
|
||||||
|
await storage.delete_login_flow_session(user_id)
|
||||||
|
return ProvisionAccessResponse(
|
||||||
|
status="cancelled",
|
||||||
|
message="Login flow cancelled. Call nc_auth_provision_access again to retry.",
|
||||||
|
user_id=user_id,
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
message = (
|
||||||
|
f"Please open this URL in your browser to log in to Nextcloud:\n\n"
|
||||||
|
f"{init_response.login_url}\n\n"
|
||||||
|
f"After logging in, call nc_auth_check_status to complete provisioning."
|
||||||
|
)
|
||||||
|
|
||||||
|
if elicitation_result == "accepted":
|
||||||
|
message = (
|
||||||
|
"Login acknowledged. Call nc_auth_check_status to verify "
|
||||||
|
"and complete provisioning."
|
||||||
|
)
|
||||||
|
return ProvisionAccessResponse(
|
||||||
|
status="pending",
|
||||||
|
login_url=init_response.login_url,
|
||||||
|
message=message,
|
||||||
|
user_id=user_id,
|
||||||
|
requested_scopes=requested_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProvisionAccessResponse(
|
||||||
|
status="login_required",
|
||||||
|
login_url=init_response.login_url,
|
||||||
|
message=message,
|
||||||
|
user_id=user_id,
|
||||||
|
requested_scopes=requested_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="nc_auth_check_status",
|
||||||
|
title="Check Nextcloud Access Status",
|
||||||
|
description=(
|
||||||
|
"Check if Nextcloud access has been provisioned. "
|
||||||
|
"If a Login Flow is pending, this will poll for completion. "
|
||||||
|
"Recommended polling interval: 5 seconds."
|
||||||
|
),
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
idempotentHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@require_scopes("openid")
|
||||||
|
async def nc_auth_check_status(
|
||||||
|
ctx: Context,
|
||||||
|
) -> ProvisionStatusResponse:
|
||||||
|
"""Check provisioning status and poll pending Login Flows.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProvisionStatusResponse with current status
|
||||||
|
"""
|
||||||
|
user_id = await extract_user_id_from_token(ctx)
|
||||||
|
if user_id == "default_user":
|
||||||
|
return ProvisionStatusResponse(
|
||||||
|
status="error",
|
||||||
|
message="Could not determine user identity from MCP token.",
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
storage = await get_shared_storage()
|
||||||
|
|
||||||
|
# Check for existing app password
|
||||||
|
existing = await storage.get_app_password_with_scopes(user_id)
|
||||||
|
if existing:
|
||||||
|
return ProvisionStatusResponse(
|
||||||
|
status="provisioned",
|
||||||
|
message=f"Nextcloud access is provisioned for {existing.get('username') or user_id}.",
|
||||||
|
user_id=user_id,
|
||||||
|
scopes=existing["scopes"],
|
||||||
|
username=existing.get("username"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for pending login flow session
|
||||||
|
try:
|
||||||
|
session = await storage.get_login_flow_session(user_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to check login flow session for {user_id}: {e}")
|
||||||
|
return ProvisionStatusResponse(
|
||||||
|
status="error",
|
||||||
|
message=f"Failed to check login flow session: {e}",
|
||||||
|
user_id=user_id,
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
if not session:
|
||||||
|
return ProvisionStatusResponse(
|
||||||
|
status="not_initiated",
|
||||||
|
message=(
|
||||||
|
"No provisioning in progress. "
|
||||||
|
"Call nc_auth_provision_access to start."
|
||||||
|
),
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Poll the Login Flow
|
||||||
|
settings = get_settings()
|
||||||
|
nextcloud_host = settings.nextcloud_host
|
||||||
|
if not nextcloud_host:
|
||||||
|
return ProvisionStatusResponse(
|
||||||
|
status="error",
|
||||||
|
message="NEXTCLOUD_HOST not configured.",
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
flow_client = LoginFlowV2Client(
|
||||||
|
nextcloud_host=nextcloud_host,
|
||||||
|
verify_ssl=get_nextcloud_ssl_verify(),
|
||||||
|
)
|
||||||
|
poll_result = await flow_client.poll(
|
||||||
|
poll_endpoint=session["poll_endpoint"],
|
||||||
|
poll_token=session["poll_token"],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to poll Login Flow v2: {e}")
|
||||||
|
return ProvisionStatusResponse(
|
||||||
|
status="error",
|
||||||
|
message=f"Failed to check login status: {e}",
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if poll_result.status == "completed":
|
||||||
|
# Store the app password with scopes
|
||||||
|
if poll_result.app_password is None:
|
||||||
|
return ProvisionStatusResponse(
|
||||||
|
status="error",
|
||||||
|
message="Login Flow completed but no app password was returned.",
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
await storage.store_app_password_with_scopes(
|
||||||
|
user_id=user_id,
|
||||||
|
app_password=poll_result.app_password,
|
||||||
|
scopes=session.get("requested_scopes"),
|
||||||
|
username=poll_result.login_name,
|
||||||
|
)
|
||||||
|
invalidate_scope_cache(user_id)
|
||||||
|
|
||||||
|
# Clean up the flow session
|
||||||
|
await storage.delete_login_flow_session(user_id)
|
||||||
|
|
||||||
|
return ProvisionStatusResponse(
|
||||||
|
status="provisioned",
|
||||||
|
message=f"Nextcloud access provisioned successfully as {poll_result.login_name}.",
|
||||||
|
user_id=user_id,
|
||||||
|
scopes=session.get("requested_scopes"),
|
||||||
|
username=poll_result.login_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if poll_result.status == "expired":
|
||||||
|
# Clean up expired session
|
||||||
|
await storage.delete_login_flow_session(user_id)
|
||||||
|
return ProvisionStatusResponse(
|
||||||
|
status="not_initiated",
|
||||||
|
message=(
|
||||||
|
"Login flow expired. "
|
||||||
|
"Call nc_auth_provision_access to start a new one."
|
||||||
|
),
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Still pending
|
||||||
|
return ProvisionStatusResponse(
|
||||||
|
status="pending",
|
||||||
|
message=(
|
||||||
|
"Login flow is still pending. "
|
||||||
|
"Please complete the login in your browser, then call this tool again."
|
||||||
|
),
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="nc_auth_update_scopes",
|
||||||
|
title="Update Nextcloud Access Scopes",
|
||||||
|
description=(
|
||||||
|
"Update the scopes for your Nextcloud access. "
|
||||||
|
"This starts a new Login Flow with the combined scope set. "
|
||||||
|
"The current app password remains valid until the new one is obtained."
|
||||||
|
),
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False,
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@require_scopes("openid")
|
||||||
|
async def nc_auth_update_scopes(
|
||||||
|
ctx: Context,
|
||||||
|
add_scopes: list[str] | None = None,
|
||||||
|
remove_scopes: list[str] | None = None,
|
||||||
|
) -> UpdateScopesResponse:
|
||||||
|
"""Update granted scopes by re-provisioning with merged scope set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: MCP context
|
||||||
|
add_scopes: Scopes to add to the current set
|
||||||
|
remove_scopes: Scopes to remove from the current set
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UpdateScopesResponse with new login URL or status
|
||||||
|
"""
|
||||||
|
user_id = await extract_user_id_from_token(ctx)
|
||||||
|
if user_id == "default_user":
|
||||||
|
return UpdateScopesResponse(
|
||||||
|
status="error",
|
||||||
|
message="Could not determine user identity from MCP token.",
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not add_scopes and not remove_scopes:
|
||||||
|
return UpdateScopesResponse(
|
||||||
|
status="error",
|
||||||
|
message="Provide add_scopes and/or remove_scopes to update.",
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
storage = await get_shared_storage()
|
||||||
|
|
||||||
|
# Get current state - require existing provisioning
|
||||||
|
existing = await storage.get_app_password_with_scopes(user_id)
|
||||||
|
if existing is None:
|
||||||
|
return UpdateScopesResponse(
|
||||||
|
status="error",
|
||||||
|
message="Not provisioned. Call nc_auth_provision_access first.",
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
previous_scopes = existing["scopes"]
|
||||||
|
|
||||||
|
# Compute new scope set
|
||||||
|
current_set = (
|
||||||
|
set(previous_scopes) if previous_scopes else set(ALL_SUPPORTED_SCOPES)
|
||||||
|
)
|
||||||
|
if add_scopes:
|
||||||
|
invalid = [s for s in add_scopes if s not in ALL_SUPPORTED_SCOPES]
|
||||||
|
if invalid:
|
||||||
|
return UpdateScopesResponse(
|
||||||
|
status="error",
|
||||||
|
message=f"Invalid scopes: {', '.join(invalid)}",
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
current_set.update(add_scopes)
|
||||||
|
if remove_scopes:
|
||||||
|
current_set -= set(remove_scopes)
|
||||||
|
|
||||||
|
new_scopes = sorted(current_set)
|
||||||
|
|
||||||
|
if not new_scopes:
|
||||||
|
return UpdateScopesResponse(
|
||||||
|
status="error",
|
||||||
|
message="Cannot remove all scopes. At least one scope must remain.",
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# No-op detection: skip Login Flow if scopes are unchanged
|
||||||
|
previous_scopes_set = (
|
||||||
|
set(previous_scopes) if previous_scopes else set(ALL_SUPPORTED_SCOPES)
|
||||||
|
)
|
||||||
|
if set(new_scopes) == previous_scopes_set:
|
||||||
|
return UpdateScopesResponse(
|
||||||
|
status="unchanged",
|
||||||
|
message="Requested scopes match current scopes. No changes needed.",
|
||||||
|
previous_scopes=previous_scopes,
|
||||||
|
new_scopes=new_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initiate new Login Flow v2
|
||||||
|
# Note: existing app password stays valid until the new flow completes.
|
||||||
|
# store_app_password_with_scopes() does an upsert, so the old password
|
||||||
|
# is replaced atomically when nc_auth_check_status stores the new one.
|
||||||
|
settings = get_settings()
|
||||||
|
nextcloud_host = settings.nextcloud_host
|
||||||
|
if not nextcloud_host:
|
||||||
|
return UpdateScopesResponse(
|
||||||
|
status="error",
|
||||||
|
message="NEXTCLOUD_HOST not configured.",
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
flow_client = LoginFlowV2Client(
|
||||||
|
nextcloud_host=nextcloud_host,
|
||||||
|
verify_ssl=get_nextcloud_ssl_verify(),
|
||||||
|
)
|
||||||
|
init_response = await flow_client.initiate()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initiate Login Flow v2 for scope update: {e}")
|
||||||
|
return UpdateScopesResponse(
|
||||||
|
status="error",
|
||||||
|
message=f"Failed to start re-provisioning flow: {e}",
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store new flow session
|
||||||
|
await storage.store_login_flow_session(
|
||||||
|
user_id=user_id,
|
||||||
|
poll_token=init_response.poll_token,
|
||||||
|
poll_endpoint=init_response.poll_endpoint,
|
||||||
|
requested_scopes=new_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Present login URL
|
||||||
|
elicitation_result = await present_login_url(ctx, init_response.login_url)
|
||||||
|
|
||||||
|
if elicitation_result == "declined":
|
||||||
|
await storage.delete_login_flow_session(user_id)
|
||||||
|
return UpdateScopesResponse(
|
||||||
|
status="declined",
|
||||||
|
message="Scope update declined. Call nc_auth_update_scopes again to retry.",
|
||||||
|
previous_scopes=previous_scopes if previous_scopes else None,
|
||||||
|
new_scopes=new_scopes,
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if elicitation_result == "cancelled":
|
||||||
|
await storage.delete_login_flow_session(user_id)
|
||||||
|
return UpdateScopesResponse(
|
||||||
|
status="cancelled",
|
||||||
|
message="Scope update cancelled. Call nc_auth_update_scopes again to retry.",
|
||||||
|
previous_scopes=previous_scopes if previous_scopes else None,
|
||||||
|
new_scopes=new_scopes,
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
message = (
|
||||||
|
f"Scope update requires re-authentication.\n\n"
|
||||||
|
f"Please open this URL to log in:\n{init_response.login_url}\n\n"
|
||||||
|
f"After logging in, call nc_auth_check_status to complete."
|
||||||
|
)
|
||||||
|
|
||||||
|
if elicitation_result == "accepted":
|
||||||
|
message = (
|
||||||
|
"Login acknowledged for scope update. "
|
||||||
|
"Call nc_auth_check_status to verify and complete."
|
||||||
|
)
|
||||||
|
|
||||||
|
return UpdateScopesResponse(
|
||||||
|
status="login_required",
|
||||||
|
login_url=init_response.login_url,
|
||||||
|
message=message,
|
||||||
|
previous_scopes=previous_scopes if previous_scopes else None,
|
||||||
|
new_scopes=new_scopes,
|
||||||
|
)
|
||||||
@@ -12,9 +12,6 @@ from datetime import datetime, timezone
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import jwt
|
|
||||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
|
||||||
from mcp.server.auth.provider import AccessToken
|
|
||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -23,80 +20,16 @@ from nextcloud_mcp_server.auth import require_scopes
|
|||||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
|
||||||
|
# Re-export for backward compatibility — canonical location is auth.token_utils
|
||||||
|
from nextcloud_mcp_server.auth.token_utils import (
|
||||||
|
extract_user_id_from_token as extract_user_id_from_token, # noqa: PLC0414
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
from ..http import nextcloud_httpx_client
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def extract_user_id_from_token(ctx: Context) -> str:
|
|
||||||
"""Extract user_id from the MCP access token (Flow 1).
|
|
||||||
|
|
||||||
Handles both JWT and opaque tokens:
|
|
||||||
- JWT: Decode and extract 'sub' claim
|
|
||||||
- Opaque: Call userinfo endpoint to get 'sub'
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ctx: MCP context with access token
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
user_id extracted from token, or "default_user" as fallback
|
|
||||||
"""
|
|
||||||
# Use MCP SDK's get_access_token() which uses contextvars
|
|
||||||
access_token: AccessToken | None = get_access_token()
|
|
||||||
|
|
||||||
if not access_token or not access_token.token:
|
|
||||||
logger.warning(" ✗ No access token found via get_access_token()")
|
|
||||||
return "default_user"
|
|
||||||
|
|
||||||
token = access_token.token
|
|
||||||
is_jwt = "." in token and token.count(".") >= 2
|
|
||||||
logger.info(f" Token type: {'JWT' if is_jwt else 'Opaque'}")
|
|
||||||
|
|
||||||
# Try JWT decode first
|
|
||||||
if is_jwt:
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, options={"verify_signature": False})
|
|
||||||
user_id = payload.get("sub", "unknown")
|
|
||||||
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
|
|
||||||
return user_id
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f" ✗ JWT decode failed: {type(e).__name__}: {e}")
|
|
||||||
|
|
||||||
# Opaque token - call userinfo endpoint
|
|
||||||
logger.info(" Opaque token detected, calling userinfo endpoint...")
|
|
||||||
try:
|
|
||||||
# Get userinfo endpoint from OIDC discovery
|
|
||||||
oidc_discovery_uri = os.getenv(
|
|
||||||
"OIDC_DISCOVERY_URI",
|
|
||||||
"http://localhost:8080/.well-known/openid-configuration",
|
|
||||||
)
|
|
||||||
async with nextcloud_httpx_client() as http_client:
|
|
||||||
discovery_response = await http_client.get(oidc_discovery_uri)
|
|
||||||
discovery_response.raise_for_status()
|
|
||||||
discovery = discovery_response.json()
|
|
||||||
userinfo_endpoint = discovery.get("userinfo_endpoint")
|
|
||||||
|
|
||||||
if userinfo_endpoint:
|
|
||||||
userinfo = await _query_idp_userinfo(token, userinfo_endpoint)
|
|
||||||
if userinfo:
|
|
||||||
user_id = userinfo.get("sub", "unknown")
|
|
||||||
logger.info(f" ✓ Userinfo query successful: user_id={user_id}")
|
|
||||||
return user_id
|
|
||||||
else:
|
|
||||||
logger.error(" ✗ Userinfo query failed")
|
|
||||||
else:
|
|
||||||
logger.error(" ✗ No userinfo_endpoint available")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f" ✗ Userinfo query failed: {type(e).__name__}: {e}")
|
|
||||||
|
|
||||||
# Fallback
|
|
||||||
logger.warning(" Using fallback user_id: default_user")
|
|
||||||
return "default_user"
|
|
||||||
|
|
||||||
|
|
||||||
class ProvisioningStatus(BaseModel):
|
class ProvisioningStatus(BaseModel):
|
||||||
"""Status of Nextcloud provisioning for a user."""
|
"""Status of Nextcloud provisioning for a user."""
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ markers = [
|
|||||||
"oauth: OAuth tests requiring Playwright (slowest)",
|
"oauth: OAuth tests requiring Playwright (slowest)",
|
||||||
"smoke: Critical path smoke tests for quick validation",
|
"smoke: Critical path smoke tests for quick validation",
|
||||||
"keycloak: OAuth tests that utilize keycloak external identity provider",
|
"keycloak: OAuth tests that utilize keycloak external identity provider",
|
||||||
|
"login_flow: Login Flow v2 integration tests (ADR-022)",
|
||||||
|
"multi_user_basic: Multi-user BasicAuth pass-through tests (ADR-020)",
|
||||||
]
|
]
|
||||||
testpaths = [
|
testpaths = [
|
||||||
"tests",
|
"tests",
|
||||||
|
|||||||
@@ -10,5 +10,15 @@
|
|||||||
"matchPackageNames": ["pillow"],
|
"matchPackageNames": ["pillow"],
|
||||||
"allowedVersions": "<12.0.0"
|
"allowedVersions": "<12.0.0"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"customManagers": [
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"fileMatch": ["^\\.github/workflows/test\\.yml$"],
|
||||||
|
"matchStrings": [
|
||||||
|
"nextcloud_image:\\s*\"(?<depName>[^:]+):(?<currentValue>[^@]+)@(?<currentDigest>sha256:[a-f0-9]+)\""
|
||||||
|
],
|
||||||
|
"datasourceTemplate": "docker"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1109,13 +1109,6 @@ def oauth_callback_server():
|
|||||||
|
|
||||||
The server automatically shuts down when the fixture is torn down.
|
The server automatically shuts down when the fixture is torn down.
|
||||||
"""
|
"""
|
||||||
# Skip OAuth tests in GitHub Actions - Playwright browser automation
|
|
||||||
# has issues with localhost callback server in CI environment
|
|
||||||
# if os.getenv("GITHUB_ACTIONS"):
|
|
||||||
# pytest.skip(
|
|
||||||
# "OAuth tests with browser automation not supported in GitHub Actions CI"
|
|
||||||
# )
|
|
||||||
|
|
||||||
# Use a dict to store auth codes keyed by state parameter
|
# Use a dict to store auth codes keyed by state parameter
|
||||||
# This allows multiple concurrent OAuth flows
|
# This allows multiple concurrent OAuth flows
|
||||||
auth_states = {}
|
auth_states = {}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from playwright.async_api import Page
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
pytestmark = [pytest.mark.integration, pytest.mark.multi_user_basic]
|
||||||
|
|
||||||
|
|
||||||
async def login_to_nextcloud(page: Page, username: str, password: str):
|
async def login_to_nextcloud(page: Page, username: str, password: str):
|
||||||
@@ -899,7 +899,7 @@ def clear_stale_test_state(clear_preferences: bool = False) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.oauth
|
@pytest.mark.multi_user_basic
|
||||||
async def test_multi_user_astrolabe_background_sync_enablement(
|
async def test_multi_user_astrolabe_background_sync_enablement(
|
||||||
browser,
|
browser,
|
||||||
nc_client,
|
nc_client,
|
||||||
@@ -1246,7 +1246,7 @@ async def verify_app_password_deleted(username: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.oauth
|
@pytest.mark.multi_user_basic
|
||||||
async def test_revoke_background_sync_access(
|
async def test_revoke_background_sync_access(
|
||||||
browser,
|
browser,
|
||||||
nc_client,
|
nc_client,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from tests.integration.test_astrolabe_multi_user_background_sync import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
pytestmark = [pytest.mark.integration, pytest.mark.multi_user_basic]
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_vector_sync(
|
async def wait_for_vector_sync(
|
||||||
@@ -101,7 +101,7 @@ async def navigate_to_astrolabe_main(page: Page):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.oauth
|
@pytest.mark.multi_user_basic
|
||||||
@pytest.mark.timeout(
|
@pytest.mark.timeout(
|
||||||
300
|
300
|
||||||
) # 5 minutes - this test involves OAuth, app password, and vector sync
|
) # 5 minutes - this test involves OAuth, app password, and vector sync
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import anyio
|
|||||||
import pytest
|
import pytest
|
||||||
from playwright.async_api import Page
|
from playwright.async_api import Page
|
||||||
|
|
||||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
pytestmark = [pytest.mark.integration, pytest.mark.multi_user_basic]
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -334,7 +334,7 @@ def delete_user_credentials(username: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.oauth
|
@pytest.mark.multi_user_basic
|
||||||
async def test_app_password_storage_and_cleanup(
|
async def test_app_password_storage_and_cleanup(
|
||||||
browser,
|
browser,
|
||||||
nc_client,
|
nc_client,
|
||||||
@@ -440,7 +440,7 @@ async def test_app_password_storage_and_cleanup(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.oauth
|
@pytest.mark.multi_user_basic
|
||||||
async def test_credential_isolation_between_users(
|
async def test_credential_isolation_between_users(
|
||||||
browser,
|
browser,
|
||||||
nc_client,
|
nc_client,
|
||||||
@@ -549,7 +549,7 @@ async def test_credential_isolation_between_users(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.oauth
|
@pytest.mark.multi_user_basic
|
||||||
async def test_credential_revoke_and_reprovision(
|
async def test_credential_revoke_and_reprovision(
|
||||||
browser,
|
browser,
|
||||||
nc_client,
|
nc_client,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import pytest
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.multi_user_basic
|
||||||
async def test_basic_auth_pass_through_notes_search(nc_mcp_basic_auth_client):
|
async def test_basic_auth_pass_through_notes_search(nc_mcp_basic_auth_client):
|
||||||
"""Test BasicAuth pass-through with notes search tool."""
|
"""Test BasicAuth pass-through with notes search tool."""
|
||||||
# Call tool - BasicAuth header is set at connection level by fixture
|
# Call tool - BasicAuth header is set at connection level by fixture
|
||||||
@@ -27,6 +28,7 @@ async def test_basic_auth_pass_through_notes_search(nc_mcp_basic_auth_client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.multi_user_basic
|
||||||
async def test_basic_auth_pass_through_notes_create(nc_mcp_basic_auth_client):
|
async def test_basic_auth_pass_through_notes_create(nc_mcp_basic_auth_client):
|
||||||
"""Test BasicAuth pass-through with notes create tool."""
|
"""Test BasicAuth pass-through with notes create tool."""
|
||||||
# Create a note using BasicAuth
|
# Create a note using BasicAuth
|
||||||
@@ -47,6 +49,7 @@ async def test_basic_auth_pass_through_notes_create(nc_mcp_basic_auth_client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.multi_user_basic
|
||||||
async def test_basic_auth_pass_through_get_note(nc_mcp_basic_auth_client):
|
async def test_basic_auth_pass_through_get_note(nc_mcp_basic_auth_client):
|
||||||
"""Test BasicAuth pass-through with get note tool."""
|
"""Test BasicAuth pass-through with get note tool."""
|
||||||
# First create a note to get
|
# First create a note to get
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async def test_query_idp_userinfo_success(mocker):
|
|||||||
mock_client.__aexit__.return_value = None
|
mock_client.__aexit__.return_value = None
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.auth.userinfo_routes.httpx.AsyncClient",
|
"nextcloud_mcp_server.auth.userinfo_routes.nextcloud_httpx_client",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ async def test_query_idp_userinfo_failure(mocker):
|
|||||||
mock_client.__aexit__.return_value = None
|
mock_client.__aexit__.return_value = None
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.auth.userinfo_routes.httpx.AsyncClient",
|
"nextcloud_mcp_server.auth.userinfo_routes.nextcloud_httpx_client",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
"""Fixtures for Login Flow v2 integration tests.
|
||||||
|
|
||||||
|
These fixtures handle the complete provisioning flow:
|
||||||
|
1. Create OAuth client for the login-flow MCP server (port 8004)
|
||||||
|
2. Obtain OAuth token via Playwright browser automation
|
||||||
|
3. Connect MCP client session with OAuth token
|
||||||
|
4. Complete Login Flow v2 provisioning (browser login → app password)
|
||||||
|
5. Run MCP tools against the provisioned session
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from typing import Any, AsyncGenerator
|
||||||
|
from urllib.parse import quote, urlparse, urlunparse
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from mcp import ClientSession
|
||||||
|
from mcp.types import ElicitRequestParams, ElicitResult
|
||||||
|
|
||||||
|
from tests.conftest import (
|
||||||
|
DEFAULT_FULL_SCOPES,
|
||||||
|
_handle_oauth_consent_screen,
|
||||||
|
create_mcp_client_session,
|
||||||
|
get_mcp_server_resource_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LOGIN_FLOW_MCP_URL = "http://localhost:8004/mcp"
|
||||||
|
LOGIN_FLOW_MCP_BASE_URL = "http://localhost:8004"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def login_flow_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
||||||
|
"""Create OAuth client credentials for the login-flow MCP server (port 8004).
|
||||||
|
|
||||||
|
Uses Dynamic Client Registration against Nextcloud's OIDC endpoint.
|
||||||
|
The client only needs openid/profile/email scopes since Login Flow v2
|
||||||
|
uses app passwords for Nextcloud API access, not OAuth tokens.
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.auth.client_registration import (
|
||||||
|
delete_client,
|
||||||
|
register_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
if not nextcloud_host:
|
||||||
|
pytest.skip("Login Flow tests require NEXTCLOUD_HOST")
|
||||||
|
|
||||||
|
auth_states, callback_url = oauth_callback_server
|
||||||
|
|
||||||
|
logger.info("Setting up OAuth client for login-flow MCP server (port 8004)...")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||||
|
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||||
|
discovery_response = await http_client.get(discovery_url)
|
||||||
|
discovery_response.raise_for_status()
|
||||||
|
oidc_config = discovery_response.json()
|
||||||
|
|
||||||
|
token_endpoint = oidc_config["token_endpoint"]
|
||||||
|
authorization_endpoint = oidc_config["authorization_endpoint"]
|
||||||
|
registration_endpoint = oidc_config["registration_endpoint"]
|
||||||
|
|
||||||
|
# Login flow only needs identity scopes for the MCP session;
|
||||||
|
# we also request resource scopes so the token passes the MCP server's
|
||||||
|
# scope validation (the server advertises these scopes).
|
||||||
|
client_info = await register_client(
|
||||||
|
nextcloud_url=nextcloud_host,
|
||||||
|
registration_endpoint=registration_endpoint,
|
||||||
|
client_name="Pytest - Login Flow Test Client",
|
||||||
|
redirect_uris=[callback_url],
|
||||||
|
scopes=DEFAULT_FULL_SCOPES,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Login Flow OAuth client ready: {client_info.client_id[:16]}...")
|
||||||
|
|
||||||
|
yield (
|
||||||
|
client_info.client_id,
|
||||||
|
client_info.client_secret,
|
||||||
|
callback_url,
|
||||||
|
token_endpoint,
|
||||||
|
authorization_endpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
try:
|
||||||
|
await delete_client(
|
||||||
|
nextcloud_url=nextcloud_host,
|
||||||
|
client_id=client_info.client_id,
|
||||||
|
registration_access_token=client_info.registration_access_token,
|
||||||
|
client_secret=client_info.client_secret,
|
||||||
|
registration_client_uri=client_info.registration_client_uri,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Cleaned up Login Flow OAuth client: {client_info.client_id[:16]}..."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to clean up Login Flow OAuth client: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def login_flow_oauth_token(
|
||||||
|
anyio_backend, browser, login_flow_oauth_client_credentials, oauth_callback_server
|
||||||
|
) -> str:
|
||||||
|
"""Obtain OAuth token for the login-flow MCP server.
|
||||||
|
|
||||||
|
Uses Playwright browser automation to complete the OAuth flow against
|
||||||
|
Nextcloud, obtaining a token suitable for the port 8004 MCP session.
|
||||||
|
"""
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||||
|
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||||
|
|
||||||
|
if not all([nextcloud_host, username, password]):
|
||||||
|
pytest.skip(
|
||||||
|
"Login Flow OAuth requires NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_states, _ = oauth_callback_server
|
||||||
|
client_id, client_secret, callback_url, token_endpoint, authorization_endpoint = (
|
||||||
|
login_flow_oauth_client_credentials
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch resource metadata from port 8004 for audience
|
||||||
|
try:
|
||||||
|
resource_metadata = await get_mcp_server_resource_metadata(
|
||||||
|
LOGIN_FLOW_MCP_BASE_URL
|
||||||
|
)
|
||||||
|
resource_id = resource_metadata.get("resource")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch resource metadata from port 8004: {e}")
|
||||||
|
resource_id = None
|
||||||
|
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
scopes_encoded = quote(DEFAULT_FULL_SCOPES, safe="")
|
||||||
|
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
if resource_id:
|
||||||
|
auth_url += f"&resource={quote(resource_id, safe='')}"
|
||||||
|
|
||||||
|
context = await browser.new_context(ignore_https_errors=True)
|
||||||
|
page = await context.new_page()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
|
||||||
|
current_url = page.url
|
||||||
|
|
||||||
|
if "/login" in current_url or "/index.php/login" in current_url:
|
||||||
|
await page.wait_for_selector('input[name="user"]', timeout=10000)
|
||||||
|
await page.fill('input[name="user"]', username)
|
||||||
|
await page.fill('input[name="password"]', password)
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=60000)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _handle_oauth_consent_screen(page, username)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
while state not in auth_states:
|
||||||
|
if time.time() - start_time > 30:
|
||||||
|
raise TimeoutError("Timeout waiting for OAuth callback")
|
||||||
|
await anyio.sleep(0.5)
|
||||||
|
|
||||||
|
auth_code = auth_states[state]
|
||||||
|
finally:
|
||||||
|
await context.close()
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||||
|
token_response = await http_client.post(
|
||||||
|
token_endpoint,
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": auth_code,
|
||||||
|
"redirect_uri": callback_url,
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
token_response.raise_for_status()
|
||||||
|
token_data = token_response.json()
|
||||||
|
access_token = token_data["access_token"]
|
||||||
|
|
||||||
|
logger.info("Successfully obtained OAuth token for login-flow MCP server")
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_login_flow_url(login_url: str) -> str:
|
||||||
|
"""Rewrite internal Docker URLs to host-accessible URLs.
|
||||||
|
|
||||||
|
The MCP server runs inside Docker with NEXTCLOUD_HOST=http://app:80,
|
||||||
|
so Login Flow v2 URLs use the internal hostname. Playwright runs on
|
||||||
|
the host and needs localhost:8080 instead.
|
||||||
|
"""
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||||
|
target = urlparse(nextcloud_host)
|
||||||
|
parsed = urlparse(login_url)
|
||||||
|
if parsed.hostname == "app":
|
||||||
|
parsed = parsed._replace(scheme=target.scheme, netloc=target.netloc)
|
||||||
|
return urlunparse(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
async def _complete_login_flow_v2(browser, login_url: str) -> None:
|
||||||
|
"""Complete Nextcloud Login Flow v2 in a browser.
|
||||||
|
|
||||||
|
The full Nextcloud Login Flow v2 has these steps:
|
||||||
|
1. "Connect to your account" page → click "Log in" button
|
||||||
|
2. Login form → fill username/password, submit
|
||||||
|
(if already logged in via session cookie, this step is skipped)
|
||||||
|
3. "Account access" grant page → click "Grant access" button
|
||||||
|
4. Password confirmation dialog → enter password, click "Confirm"
|
||||||
|
5. "Account connected" success page
|
||||||
|
|
||||||
|
Args:
|
||||||
|
browser: Playwright browser instance
|
||||||
|
login_url: URL from Login Flow v2 initiation (e.g., /login/v2/flow/...)
|
||||||
|
"""
|
||||||
|
username = os.getenv("NEXTCLOUD_USERNAME", "admin")
|
||||||
|
password = os.getenv("NEXTCLOUD_PASSWORD", "admin")
|
||||||
|
|
||||||
|
# Rewrite internal Docker URL to host-accessible URL
|
||||||
|
login_url = _rewrite_login_flow_url(login_url)
|
||||||
|
|
||||||
|
context = await browser.new_context(ignore_https_errors=True)
|
||||||
|
page = await context.new_page()
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Opening Login Flow v2 URL: {login_url[:80]}...")
|
||||||
|
await page.goto(login_url, wait_until="networkidle", timeout=60000)
|
||||||
|
logger.info(f"Step 1 - Current URL: {page.url}")
|
||||||
|
|
||||||
|
# Step 1: "Connect to your account" page - click "Log in"
|
||||||
|
login_btn = page.get_by_role("button", name="Log in")
|
||||||
|
try:
|
||||||
|
await login_btn.wait_for(timeout=10000)
|
||||||
|
await login_btn.click()
|
||||||
|
logger.info("Clicked 'Log in' on Connect page")
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||||
|
except Exception:
|
||||||
|
logger.info("No 'Log in' button - may already be on login/grant page")
|
||||||
|
|
||||||
|
logger.info(f"Step 2 - Current URL: {page.url}")
|
||||||
|
|
||||||
|
# Step 2: Login form (only if not already logged in)
|
||||||
|
# If the user has an active session, they skip straight to the grant page.
|
||||||
|
user_field = page.locator('input[name="user"]')
|
||||||
|
if await user_field.count() > 0:
|
||||||
|
logger.info("Login form detected, filling credentials...")
|
||||||
|
await user_field.fill(username)
|
||||||
|
await page.locator('input[name="password"]').fill(password)
|
||||||
|
await page.get_by_role("button", name="Log in", exact=True).click()
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=60000)
|
||||||
|
logger.info(f"After login: {page.url}")
|
||||||
|
else:
|
||||||
|
logger.info("No login form - already logged in via session")
|
||||||
|
|
||||||
|
# Step 3: "Account access" grant page - click "Grant access"
|
||||||
|
grant_btn = page.get_by_role("button", name="Grant access")
|
||||||
|
try:
|
||||||
|
await grant_btn.wait_for(timeout=15000)
|
||||||
|
await grant_btn.click()
|
||||||
|
logger.info("Clicked 'Grant access'")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"No Grant access button: {e}")
|
||||||
|
await page.screenshot(path="/tmp/login_flow_no_grant.png")
|
||||||
|
|
||||||
|
# Step 4: Password confirmation dialog
|
||||||
|
# Nextcloud shows "Authentication required" dialog after clicking Grant access
|
||||||
|
confirm_password = page.get_by_role("dialog").get_by_role(
|
||||||
|
"textbox", name="Password"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await confirm_password.wait_for(timeout=10000)
|
||||||
|
logger.info("Password confirmation dialog detected")
|
||||||
|
await confirm_password.fill(password)
|
||||||
|
|
||||||
|
# Wait for Confirm button to become enabled after filling password
|
||||||
|
confirm_btn = page.get_by_role("dialog").get_by_role(
|
||||||
|
"button", name="Confirm"
|
||||||
|
)
|
||||||
|
await confirm_btn.wait_for(timeout=5000)
|
||||||
|
await confirm_btn.click()
|
||||||
|
logger.info("Clicked 'Confirm' in password dialog")
|
||||||
|
except Exception:
|
||||||
|
logger.info(
|
||||||
|
"No password confirmation dialog (may have been auto-confirmed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 5: Wait for "Account connected" success page
|
||||||
|
try:
|
||||||
|
await page.get_by_text("Account connected").wait_for(timeout=15000)
|
||||||
|
logger.info("Login Flow v2 completed: Account connected!")
|
||||||
|
except Exception:
|
||||||
|
# The grant may have completed without the success page being visible
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=10000)
|
||||||
|
logger.info(f"Login Flow v2 done. Final URL: {page.url}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await context.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def nc_mcp_login_flow_client(
|
||||||
|
anyio_backend,
|
||||||
|
login_flow_oauth_token: str,
|
||||||
|
browser,
|
||||||
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
|
"""MCP client session connected to the login-flow server (port 8004).
|
||||||
|
|
||||||
|
This fixture:
|
||||||
|
1. Connects to the MCP server with an OAuth token
|
||||||
|
2. Calls nc_auth_provision_access to start Login Flow v2
|
||||||
|
3. Completes the browser login to get an app password
|
||||||
|
4. Calls nc_auth_check_status to finalize provisioning
|
||||||
|
5. Yields the provisioned MCP client session
|
||||||
|
|
||||||
|
All subsequent tool calls will use the stored app password.
|
||||||
|
"""
|
||||||
|
# Create an elicitation callback that extracts the login URL
|
||||||
|
# and completes the Login Flow v2 in the browser
|
||||||
|
login_url_holder: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def elicitation_callback(
|
||||||
|
context: Any,
|
||||||
|
params: ElicitRequestParams,
|
||||||
|
) -> ElicitResult:
|
||||||
|
"""Handle elicitation from nc_auth_provision_access.
|
||||||
|
|
||||||
|
Extracts the login URL from the elicitation message and
|
||||||
|
completes the Login Flow v2 browser login.
|
||||||
|
"""
|
||||||
|
message = params.message
|
||||||
|
logger.info(f"Elicitation received: {message[:100]}...")
|
||||||
|
|
||||||
|
# Extract login URL from elicitation message
|
||||||
|
for line in message.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("http") and "/login/v2/" in stripped:
|
||||||
|
login_url_holder["url"] = stripped
|
||||||
|
logger.info(f"Extracted login URL: {stripped[:80]}...")
|
||||||
|
break
|
||||||
|
|
||||||
|
if "url" in login_url_holder:
|
||||||
|
# Complete the Login Flow v2 in the browser
|
||||||
|
await _complete_login_flow_v2(browser, login_url_holder["url"])
|
||||||
|
|
||||||
|
# Return acceptance
|
||||||
|
return ElicitResult(
|
||||||
|
action="accept",
|
||||||
|
content={"acknowledged": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
async for session in create_mcp_client_session(
|
||||||
|
url=LOGIN_FLOW_MCP_URL,
|
||||||
|
token=login_flow_oauth_token,
|
||||||
|
client_name="Login Flow MCP",
|
||||||
|
elicitation_callback=elicitation_callback,
|
||||||
|
):
|
||||||
|
# Step 1: Provision access via Login Flow v2
|
||||||
|
logger.info("Starting Login Flow v2 provisioning...")
|
||||||
|
provision_result = await session.call_tool(
|
||||||
|
"nc_auth_provision_access",
|
||||||
|
{"scopes": None}, # Request all scopes
|
||||||
|
)
|
||||||
|
|
||||||
|
provision_data = json.loads(provision_result.content[0].text)
|
||||||
|
logger.info(f"Provision result: {provision_data.get('status')}")
|
||||||
|
|
||||||
|
# If elicitation didn't fire (client doesn't support it),
|
||||||
|
# extract URL from the response and complete flow manually
|
||||||
|
if provision_data.get("status") == "login_required":
|
||||||
|
login_url = provision_data.get("login_url")
|
||||||
|
if login_url and "url" not in login_url_holder:
|
||||||
|
logger.info("Completing Login Flow v2 from response URL...")
|
||||||
|
await _complete_login_flow_v2(browser, login_url)
|
||||||
|
|
||||||
|
# Step 2: Poll for completion
|
||||||
|
logger.info("Polling Login Flow v2 status...")
|
||||||
|
max_attempts = 15
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
status_result = await session.call_tool("nc_auth_check_status", {})
|
||||||
|
status_data = json.loads(status_result.content[0].text)
|
||||||
|
status = status_data.get("status")
|
||||||
|
logger.info(f"Status check {attempt + 1}/{max_attempts}: {status}")
|
||||||
|
|
||||||
|
if status == "provisioned":
|
||||||
|
logger.info(
|
||||||
|
f"Login Flow v2 provisioned! Username: {status_data.get('username')}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if status in ("not_initiated", "error"):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Login Flow v2 failed: {status_data.get('message')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await anyio.sleep(2)
|
||||||
|
else:
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Login Flow v2 did not complete after {max_attempts} attempts"
|
||||||
|
)
|
||||||
|
|
||||||
|
yield session
|
||||||
@@ -0,0 +1,652 @@
|
|||||||
|
"""Integration tests for Login Flow v2 (ADR-022).
|
||||||
|
|
||||||
|
Tests the complete Login Flow v2 provisioning and verifies all MCP tools
|
||||||
|
work through the stored app password. This validates the end-to-end flow:
|
||||||
|
|
||||||
|
OAuth token (MCP session) → Login Flow v2 (browser) → App password → Nextcloud API
|
||||||
|
|
||||||
|
Test categories:
|
||||||
|
1. Auth tools: provision, check status, scope management
|
||||||
|
2. Notes: CRUD operations
|
||||||
|
3. Calendar: events and todos
|
||||||
|
4. Contacts: address book and contact operations
|
||||||
|
5. Files (WebDAV): directory listing, file operations
|
||||||
|
6. Deck: board management
|
||||||
|
7. Cookbook: recipe operations
|
||||||
|
8. Tables: table operations
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from mcp import ClientSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
pytestmark = [pytest.mark.login_flow, pytest.mark.integration]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth tools
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginFlowAuthTools:
|
||||||
|
"""Test Login Flow v2 auth tools."""
|
||||||
|
|
||||||
|
async def test_check_status_provisioned(
|
||||||
|
self, nc_mcp_login_flow_client: ClientSession
|
||||||
|
):
|
||||||
|
"""After fixture setup, status should be 'provisioned'."""
|
||||||
|
result = await nc_mcp_login_flow_client.call_tool("nc_auth_check_status", {})
|
||||||
|
data = json.loads(result.content[0].text)
|
||||||
|
assert data["status"] == "provisioned"
|
||||||
|
assert data["username"] is not None
|
||||||
|
assert data["scopes"] is not None
|
||||||
|
logger.info(f"Provisioned as: {data['username']}, scopes: {data['scopes']}")
|
||||||
|
|
||||||
|
async def test_provision_access_already_provisioned(
|
||||||
|
self, nc_mcp_login_flow_client: ClientSession
|
||||||
|
):
|
||||||
|
"""Calling provision when already provisioned returns 'already_provisioned'."""
|
||||||
|
result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_auth_provision_access", {}
|
||||||
|
)
|
||||||
|
data = json.loads(result.content[0].text)
|
||||||
|
assert data["status"] == "already_provisioned"
|
||||||
|
assert "already provisioned" in data["message"].lower()
|
||||||
|
|
||||||
|
async def test_list_tools_includes_auth_tools(
|
||||||
|
self, nc_mcp_login_flow_client: ClientSession
|
||||||
|
):
|
||||||
|
"""Login Flow server should expose auth tools."""
|
||||||
|
tools = await nc_mcp_login_flow_client.list_tools()
|
||||||
|
tool_names = [t.name for t in tools.tools]
|
||||||
|
assert "nc_auth_provision_access" in tool_names
|
||||||
|
assert "nc_auth_check_status" in tool_names
|
||||||
|
assert "nc_auth_update_scopes" in tool_names
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Notes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginFlowNotes:
|
||||||
|
"""Test Notes CRUD via Login Flow v2 app password."""
|
||||||
|
|
||||||
|
async def test_notes_crud(self, nc_mcp_login_flow_client: ClientSession):
|
||||||
|
"""Full Notes CRUD: create → read → update → search → delete."""
|
||||||
|
suffix = uuid.uuid4().hex[:8]
|
||||||
|
title = f"LoginFlow Test {suffix}"
|
||||||
|
content = f"Content for {suffix}"
|
||||||
|
category = "LoginFlowTest"
|
||||||
|
|
||||||
|
# Create
|
||||||
|
create_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_notes_create_note",
|
||||||
|
{"title": title, "content": content, "category": category},
|
||||||
|
)
|
||||||
|
assert create_result.isError is False, (
|
||||||
|
f"Create failed: {create_result.content[0].text}"
|
||||||
|
)
|
||||||
|
note = json.loads(create_result.content[0].text)
|
||||||
|
note_id = note["id"]
|
||||||
|
etag = note["etag"]
|
||||||
|
logger.info(f"Created note {note_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read
|
||||||
|
read_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_notes_get_note", {"note_id": note_id}
|
||||||
|
)
|
||||||
|
assert read_result.isError is False
|
||||||
|
read_data = json.loads(read_result.content[0].text)
|
||||||
|
assert read_data["title"] == title
|
||||||
|
assert read_data["content"] == content
|
||||||
|
|
||||||
|
# Update (title, content, category are all required params)
|
||||||
|
updated_content = f"Updated content for {suffix}"
|
||||||
|
update_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_notes_update_note",
|
||||||
|
{
|
||||||
|
"note_id": note_id,
|
||||||
|
"title": title,
|
||||||
|
"content": updated_content,
|
||||||
|
"category": category,
|
||||||
|
"etag": etag,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert update_result.isError is False, (
|
||||||
|
f"Update failed: {update_result.content[0].text}"
|
||||||
|
)
|
||||||
|
updated = json.loads(update_result.content[0].text)
|
||||||
|
# UpdateNoteResponse returns id, title, category, etag (no content)
|
||||||
|
assert updated["title"] == title
|
||||||
|
assert "etag" in updated
|
||||||
|
|
||||||
|
# Append
|
||||||
|
append_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_notes_append_content",
|
||||||
|
{"note_id": note_id, "content": "\n\nAppended text"},
|
||||||
|
)
|
||||||
|
assert append_result.isError is False
|
||||||
|
|
||||||
|
# Search
|
||||||
|
search_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_notes_search_notes", {"query": suffix}
|
||||||
|
)
|
||||||
|
assert search_result.isError is False
|
||||||
|
search_data = json.loads(search_result.content[0].text)
|
||||||
|
assert search_data["total_found"] >= 1
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Delete
|
||||||
|
await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_notes_delete_note", {"note_id": note_id}
|
||||||
|
)
|
||||||
|
logger.info(f"Deleted note {note_id}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Calendar Events
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginFlowCalendarEvents:
|
||||||
|
"""Test Calendar event operations via Login Flow v2."""
|
||||||
|
|
||||||
|
async def test_calendar_events_workflow(
|
||||||
|
self, nc_mcp_login_flow_client: ClientSession
|
||||||
|
):
|
||||||
|
"""List calendars → create event → get event → delete event."""
|
||||||
|
# List calendars
|
||||||
|
cal_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_calendar_list_calendars", {}
|
||||||
|
)
|
||||||
|
assert cal_result.isError is False
|
||||||
|
cal_data = json.loads(cal_result.content[0].text)
|
||||||
|
calendars = cal_data.get("calendars", [])
|
||||||
|
assert len(calendars) > 0
|
||||||
|
calendar_name = calendars[0].get("name", "personal")
|
||||||
|
logger.info(f"Using calendar: {calendar_name}")
|
||||||
|
|
||||||
|
suffix = uuid.uuid4().hex[:8]
|
||||||
|
event_title = f"LoginFlow Event {suffix}"
|
||||||
|
|
||||||
|
# Create event (uses start_datetime/end_datetime)
|
||||||
|
create_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_calendar_create_event",
|
||||||
|
{
|
||||||
|
"calendar_name": calendar_name,
|
||||||
|
"title": event_title,
|
||||||
|
"start_datetime": "2026-03-01T10:00:00",
|
||||||
|
"end_datetime": "2026-03-01T11:00:00",
|
||||||
|
"description": f"Test event for login flow {suffix}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert create_result.isError is False, (
|
||||||
|
f"Create event failed: {create_result.content[0].text}"
|
||||||
|
)
|
||||||
|
event_data = json.loads(create_result.content[0].text)
|
||||||
|
event_uid = event_data.get("uid") or event_data.get("event_uid")
|
||||||
|
logger.info(f"Created event: {event_uid}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get event
|
||||||
|
get_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_calendar_get_event",
|
||||||
|
{"calendar_name": calendar_name, "event_uid": event_uid},
|
||||||
|
)
|
||||||
|
assert get_result.isError is False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Delete event
|
||||||
|
await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_calendar_delete_event",
|
||||||
|
{"calendar_name": calendar_name, "event_uid": event_uid},
|
||||||
|
)
|
||||||
|
logger.info(f"Deleted event {event_uid}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Calendar Todos
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginFlowCalendarTodos:
|
||||||
|
"""Test Calendar todo (VTODO) operations via Login Flow v2."""
|
||||||
|
|
||||||
|
async def test_todo_workflow(self, nc_mcp_login_flow_client: ClientSession):
|
||||||
|
"""Create todo → list todos → update todo → delete todo."""
|
||||||
|
cal_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_calendar_list_calendars", {}
|
||||||
|
)
|
||||||
|
cal_data = json.loads(cal_result.content[0].text)
|
||||||
|
calendars = cal_data.get("calendars", [])
|
||||||
|
calendar_name = calendars[0].get("name", "personal")
|
||||||
|
|
||||||
|
suffix = uuid.uuid4().hex[:8]
|
||||||
|
todo_title = f"LoginFlow Todo {suffix}"
|
||||||
|
|
||||||
|
# Create todo (uses 'summary', not 'title')
|
||||||
|
create_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_calendar_create_todo",
|
||||||
|
{
|
||||||
|
"calendar_name": calendar_name,
|
||||||
|
"summary": todo_title,
|
||||||
|
"description": f"Test todo {suffix}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if create_result.isError:
|
||||||
|
error_text = create_result.content[0].text
|
||||||
|
if "AuthorizationError" in error_text:
|
||||||
|
pytest.skip(
|
||||||
|
f"Calendar '{calendar_name}' does not support VTODO: {error_text}"
|
||||||
|
)
|
||||||
|
raise AssertionError(f"Create todo failed: {error_text}")
|
||||||
|
todo_data = json.loads(create_result.content[0].text)
|
||||||
|
todo_uid = todo_data.get("uid") or todo_data.get("todo_uid")
|
||||||
|
logger.info(f"Created todo: {todo_uid}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# List todos
|
||||||
|
list_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_calendar_list_todos",
|
||||||
|
{"calendar_name": calendar_name},
|
||||||
|
)
|
||||||
|
assert list_result.isError is False
|
||||||
|
|
||||||
|
# Update todo
|
||||||
|
update_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_calendar_update_todo",
|
||||||
|
{
|
||||||
|
"calendar_name": calendar_name,
|
||||||
|
"todo_uid": todo_uid,
|
||||||
|
"percent_complete": 50,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert update_result.isError is False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_calendar_delete_todo",
|
||||||
|
{"calendar_name": calendar_name, "todo_uid": todo_uid},
|
||||||
|
)
|
||||||
|
logger.info(f"Deleted todo {todo_uid}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Contacts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginFlowContacts:
|
||||||
|
"""Test Contacts (CardDAV) operations via Login Flow v2."""
|
||||||
|
|
||||||
|
async def test_contacts_workflow(self, nc_mcp_login_flow_client: ClientSession):
|
||||||
|
"""Create addressbook → create contact → list contacts → cleanup."""
|
||||||
|
suffix = uuid.uuid4().hex[:8]
|
||||||
|
ab_name = f"lf-test-{suffix}"
|
||||||
|
contact_uid = f"login-flow-test-{suffix}"
|
||||||
|
contact_fn = f"LoginFlow Contact {suffix}"
|
||||||
|
|
||||||
|
# List address books (basic smoke test)
|
||||||
|
ab_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_contacts_list_addressbooks", {}
|
||||||
|
)
|
||||||
|
assert ab_result.isError is False
|
||||||
|
|
||||||
|
# Create a temporary address book for isolation
|
||||||
|
create_ab_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_contacts_create_addressbook",
|
||||||
|
{"name": ab_name, "display_name": f"Login Flow Test {suffix}"},
|
||||||
|
)
|
||||||
|
assert create_ab_result.isError is False, (
|
||||||
|
f"Create addressbook failed: {create_ab_result.content[0].text}"
|
||||||
|
)
|
||||||
|
logger.info(f"Created address book: {ab_name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create contact (requires addressbook, uid, contact_data dict)
|
||||||
|
create_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_contacts_create_contact",
|
||||||
|
{
|
||||||
|
"addressbook": ab_name,
|
||||||
|
"uid": contact_uid,
|
||||||
|
"contact_data": {
|
||||||
|
"fn": contact_fn,
|
||||||
|
"email": f"test-{suffix}@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert create_result.isError is False, (
|
||||||
|
f"Create contact failed: {create_result.content[0].text}"
|
||||||
|
)
|
||||||
|
logger.info(f"Created contact: {contact_uid}")
|
||||||
|
|
||||||
|
# List contacts in our clean addressbook
|
||||||
|
# Note: may fail due to server-side Pydantic bug where ContactField.value
|
||||||
|
# is a dict (structured email) but model expects string
|
||||||
|
list_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_contacts_list_contacts",
|
||||||
|
{"addressbook": ab_name},
|
||||||
|
)
|
||||||
|
if list_result.isError:
|
||||||
|
error_text = list_result.content[0].text
|
||||||
|
if "ContactField" in error_text:
|
||||||
|
logger.warning(
|
||||||
|
f"Known server bug: ContactField validation: {error_text}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise AssertionError(f"List contacts failed: {error_text}")
|
||||||
|
else:
|
||||||
|
list_data = json.loads(list_result.content[0].text)
|
||||||
|
contacts = list_data.get("contacts", [])
|
||||||
|
contact_uids = [c.get("uid", "") for c in contacts]
|
||||||
|
assert contact_uid in contact_uids, (
|
||||||
|
f"Created contact {contact_uid} not found in list"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete contact
|
||||||
|
await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_contacts_delete_contact",
|
||||||
|
{"addressbook": ab_name, "uid": contact_uid},
|
||||||
|
)
|
||||||
|
logger.info(f"Deleted contact {contact_uid}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Always clean up the temporary address book
|
||||||
|
await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_contacts_delete_addressbook",
|
||||||
|
{"name": ab_name},
|
||||||
|
)
|
||||||
|
logger.info(f"Deleted address book {ab_name}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Files (WebDAV)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginFlowFiles:
|
||||||
|
"""Test WebDAV file operations via Login Flow v2."""
|
||||||
|
|
||||||
|
async def test_file_operations(self, nc_mcp_login_flow_client: ClientSession):
|
||||||
|
"""Create dir → write file → read file → list dir → delete."""
|
||||||
|
suffix = uuid.uuid4().hex[:8]
|
||||||
|
dir_path = f"/LoginFlowTest_{suffix}"
|
||||||
|
file_path = f"{dir_path}/test_file.txt"
|
||||||
|
file_content = f"Hello from Login Flow v2 test {suffix}"
|
||||||
|
|
||||||
|
# Create directory
|
||||||
|
mkdir_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_webdav_create_directory", {"path": dir_path}
|
||||||
|
)
|
||||||
|
assert mkdir_result.isError is False, (
|
||||||
|
f"Create dir failed: {mkdir_result.content[0].text}"
|
||||||
|
)
|
||||||
|
logger.info(f"Created directory: {dir_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Write file
|
||||||
|
write_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_webdav_write_file",
|
||||||
|
{"path": file_path, "content": file_content},
|
||||||
|
)
|
||||||
|
assert write_result.isError is False
|
||||||
|
|
||||||
|
# Read file
|
||||||
|
read_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_webdav_read_file", {"path": file_path}
|
||||||
|
)
|
||||||
|
assert read_result.isError is False
|
||||||
|
read_data = json.loads(read_result.content[0].text)
|
||||||
|
assert file_content in read_data.get("content", "")
|
||||||
|
|
||||||
|
# List directory (response uses 'files' field, each with 'name')
|
||||||
|
list_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_webdav_list_directory", {"path": dir_path}
|
||||||
|
)
|
||||||
|
assert list_result.isError is False
|
||||||
|
list_data = json.loads(list_result.content[0].text)
|
||||||
|
files = list_data.get("files", [])
|
||||||
|
file_names = [f.get("name", "") for f in files]
|
||||||
|
assert "test_file.txt" in file_names
|
||||||
|
|
||||||
|
# Find files by name (uses 'pattern' and 'scope')
|
||||||
|
search_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_webdav_find_by_name",
|
||||||
|
{"pattern": "test_file.txt", "scope": dir_path},
|
||||||
|
)
|
||||||
|
assert search_result.isError is False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up: delete file then directory
|
||||||
|
await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_webdav_delete_resource", {"path": file_path}
|
||||||
|
)
|
||||||
|
await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_webdav_delete_resource", {"path": dir_path}
|
||||||
|
)
|
||||||
|
logger.info(f"Cleaned up {dir_path}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Deck
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginFlowDeck:
|
||||||
|
"""Test Deck (Kanban) operations via Login Flow v2."""
|
||||||
|
|
||||||
|
async def test_deck_board_workflow(self, nc_mcp_login_flow_client: ClientSession):
|
||||||
|
"""Create board → list boards → get board details."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
suffix = uuid.uuid4().hex[:8]
|
||||||
|
board_title = f"LoginFlow Board {suffix}"
|
||||||
|
board_id = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create board (requires title and color)
|
||||||
|
create_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"deck_create_board", {"title": board_title, "color": "0076D1"}
|
||||||
|
)
|
||||||
|
assert create_result.isError is False, (
|
||||||
|
f"Create board failed: {create_result.content[0].text}"
|
||||||
|
)
|
||||||
|
board_data = json.loads(create_result.content[0].text)
|
||||||
|
board_id = board_data.get("id") or board_data.get("board_id")
|
||||||
|
logger.info(f"Created board: {board_id}")
|
||||||
|
|
||||||
|
# List boards (tool name is deck_get_boards)
|
||||||
|
list_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"deck_get_boards", {}
|
||||||
|
)
|
||||||
|
assert list_result.isError is False
|
||||||
|
boards_data = json.loads(list_result.content[0].text)
|
||||||
|
boards = boards_data.get("boards", [])
|
||||||
|
board_ids = [b.get("id") for b in boards]
|
||||||
|
assert board_id in board_ids
|
||||||
|
|
||||||
|
# Get board details
|
||||||
|
detail_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"deck_get_board", {"board_id": board_id}
|
||||||
|
)
|
||||||
|
assert detail_result.isError is False
|
||||||
|
finally:
|
||||||
|
# Clean up board via Deck REST API (no MCP delete_board tool exists)
|
||||||
|
if board_id is not None:
|
||||||
|
nc_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||||
|
nc_user = os.getenv("NEXTCLOUD_USERNAME", "admin")
|
||||||
|
nc_pass = os.getenv("NEXTCLOUD_PASSWORD", "admin")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=nc_host,
|
||||||
|
auth=httpx.BasicAuth(nc_user, nc_pass),
|
||||||
|
headers={"OCS-APIREQUEST": "true"},
|
||||||
|
) as client:
|
||||||
|
resp = await client.delete(
|
||||||
|
f"/apps/deck/api/v1.0/boards/{board_id}"
|
||||||
|
)
|
||||||
|
logger.info(f"Board cleanup: {board_id} → {resp.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Board cleanup failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tables
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginFlowTables:
|
||||||
|
"""Test Tables operations via Login Flow v2."""
|
||||||
|
|
||||||
|
@pytest.mark.xfail(
|
||||||
|
reason="Server-side Pydantic bug: Table.owner_display_name required but missing from API",
|
||||||
|
strict=False,
|
||||||
|
)
|
||||||
|
async def test_tables_list(self, nc_mcp_login_flow_client: ClientSession):
|
||||||
|
"""List tables (may be empty but should not error)."""
|
||||||
|
result = await nc_mcp_login_flow_client.call_tool("nc_tables_list_tables", {})
|
||||||
|
assert result.isError is False, f"List tables failed: {result.content[0].text}"
|
||||||
|
data = json.loads(result.content[0].text)
|
||||||
|
logger.info(f"Tables: {data}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cookbook
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginFlowCookbook:
|
||||||
|
"""Test Cookbook operations via Login Flow v2."""
|
||||||
|
|
||||||
|
async def test_cookbook_list_and_categories(
|
||||||
|
self, nc_mcp_login_flow_client: ClientSession
|
||||||
|
):
|
||||||
|
"""List recipes and categories (may be empty but should not error)."""
|
||||||
|
# List recipes
|
||||||
|
list_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_cookbook_list_recipes", {}
|
||||||
|
)
|
||||||
|
assert list_result.isError is False
|
||||||
|
|
||||||
|
# List categories
|
||||||
|
cat_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_cookbook_list_categories", {}
|
||||||
|
)
|
||||||
|
assert cat_result.isError is False
|
||||||
|
|
||||||
|
async def test_cookbook_create_and_delete(
|
||||||
|
self, nc_mcp_login_flow_client: ClientSession
|
||||||
|
):
|
||||||
|
"""Create recipe → get recipe → delete recipe."""
|
||||||
|
suffix = uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
create_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_cookbook_create_recipe",
|
||||||
|
{
|
||||||
|
"name": f"LoginFlow Recipe {suffix}",
|
||||||
|
"description": f"Test recipe {suffix}",
|
||||||
|
"ingredients": ["flour", "sugar", "butter"],
|
||||||
|
"instructions": ["Mix ingredients", "Bake at 350F"],
|
||||||
|
"keywords": "test,login-flow", # keywords is a string, not list
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert create_result.isError is False, (
|
||||||
|
f"Create recipe failed: {create_result.content[0].text}"
|
||||||
|
)
|
||||||
|
recipe_data = json.loads(create_result.content[0].text)
|
||||||
|
recipe_id = recipe_data.get("id") or recipe_data.get("recipe_id")
|
||||||
|
logger.info(f"Created recipe: {recipe_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get recipe (may fail due to server-side Pydantic bug with recipeYield=None)
|
||||||
|
get_result = await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_cookbook_get_recipe", {"recipe_id": recipe_id}
|
||||||
|
)
|
||||||
|
if get_result.isError:
|
||||||
|
error_text = get_result.content[0].text
|
||||||
|
if "recipeYield" in error_text:
|
||||||
|
logger.warning(
|
||||||
|
f"Known server bug: Recipe.recipeYield validation: {error_text}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise AssertionError(f"Get recipe failed: {error_text}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if recipe_id:
|
||||||
|
await nc_mcp_login_flow_client.call_tool(
|
||||||
|
"nc_cookbook_delete_recipe", {"recipe_id": recipe_id}
|
||||||
|
)
|
||||||
|
logger.info(f"Deleted recipe {recipe_id}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Connectivity & Tool Listing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginFlowConnectivity:
|
||||||
|
"""Basic connectivity and tool listing tests."""
|
||||||
|
|
||||||
|
async def test_list_tools(self, nc_mcp_login_flow_client: ClientSession):
|
||||||
|
"""Verify key tools are available."""
|
||||||
|
tools = await nc_mcp_login_flow_client.list_tools()
|
||||||
|
tool_names = [t.name for t in tools.tools]
|
||||||
|
|
||||||
|
# Auth tools (Login Flow v2 specific)
|
||||||
|
assert "nc_auth_provision_access" in tool_names
|
||||||
|
assert "nc_auth_check_status" in tool_names
|
||||||
|
assert "nc_auth_update_scopes" in tool_names
|
||||||
|
|
||||||
|
# Standard Nextcloud tools (verified against server/test_mcp.py)
|
||||||
|
expected = [
|
||||||
|
"nc_notes_create_note",
|
||||||
|
"nc_notes_search_notes",
|
||||||
|
"nc_notes_get_note",
|
||||||
|
"nc_notes_update_note",
|
||||||
|
"nc_notes_delete_note",
|
||||||
|
"nc_notes_append_content",
|
||||||
|
"nc_calendar_list_calendars",
|
||||||
|
"nc_calendar_create_event",
|
||||||
|
"nc_calendar_list_events",
|
||||||
|
"nc_calendar_get_event",
|
||||||
|
"nc_calendar_delete_event",
|
||||||
|
"nc_calendar_list_todos",
|
||||||
|
"nc_calendar_create_todo",
|
||||||
|
"nc_calendar_update_todo",
|
||||||
|
"nc_calendar_delete_todo",
|
||||||
|
"nc_contacts_list_addressbooks",
|
||||||
|
"nc_contacts_create_contact",
|
||||||
|
"nc_contacts_list_contacts",
|
||||||
|
"nc_contacts_delete_contact",
|
||||||
|
"nc_webdav_list_directory",
|
||||||
|
"nc_webdav_read_file",
|
||||||
|
"nc_webdav_write_file",
|
||||||
|
"nc_webdav_create_directory",
|
||||||
|
"nc_webdav_delete_resource",
|
||||||
|
"nc_webdav_find_by_name",
|
||||||
|
"deck_create_board",
|
||||||
|
"deck_get_boards",
|
||||||
|
"deck_get_board",
|
||||||
|
"nc_tables_list_tables",
|
||||||
|
"nc_cookbook_list_recipes",
|
||||||
|
"nc_cookbook_create_recipe",
|
||||||
|
"nc_cookbook_get_recipe",
|
||||||
|
"nc_cookbook_delete_recipe",
|
||||||
|
"nc_cookbook_list_categories",
|
||||||
|
]
|
||||||
|
|
||||||
|
for tool in expected:
|
||||||
|
assert tool in tool_names, f"Expected tool '{tool}' not found"
|
||||||
|
|
||||||
|
async def test_list_resources(self, nc_mcp_login_flow_client: ClientSession):
|
||||||
|
"""Verify resource templates are available."""
|
||||||
|
templates = await nc_mcp_login_flow_client.list_resource_templates()
|
||||||
|
logger.info(f"Resource templates: {len(templates.resourceTemplates)}")
|
||||||
@@ -27,6 +27,8 @@ from ...conftest import _handle_oauth_consent_screen
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def nextcloud_host() -> str:
|
def nextcloud_host() -> str:
|
||||||
@@ -114,7 +116,6 @@ async def test_oauth_clients(
|
|||||||
logger.info("Test OAuth clients fixture complete")
|
logger.info("Test OAuth clients fixture complete")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
async def test_introspection_requires_client_authentication(
|
async def test_introspection_requires_client_authentication(
|
||||||
oidc_endpoints: dict[str, str],
|
oidc_endpoints: dict[str, str],
|
||||||
):
|
):
|
||||||
@@ -284,7 +285,6 @@ async def _obtain_token_for_client(
|
|||||||
return access_token
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
async def test_client_cannot_introspect_other_clients_tokens(
|
async def test_client_cannot_introspect_other_clients_tokens(
|
||||||
playwright_oauth_token: str,
|
playwright_oauth_token: str,
|
||||||
shared_oauth_client_credentials: tuple,
|
shared_oauth_client_credentials: tuple,
|
||||||
@@ -344,7 +344,6 @@ async def test_client_cannot_introspect_other_clients_tokens(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
async def test_introspection_with_resource_parameter(
|
async def test_introspection_with_resource_parameter(
|
||||||
browser,
|
browser,
|
||||||
oauth_callback_server,
|
oauth_callback_server,
|
||||||
@@ -440,7 +439,6 @@ async def test_introspection_with_resource_parameter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
async def test_introspection_returns_inactive_for_invalid_token(
|
async def test_introspection_returns_inactive_for_invalid_token(
|
||||||
test_oauth_clients: dict[str, tuple[str, str]],
|
test_oauth_clients: dict[str, tuple[str, str]],
|
||||||
oidc_endpoints: dict[str, str],
|
oidc_endpoints: dict[str, str],
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import pytest
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.oauth
|
||||||
async def test_prm_endpoint():
|
async def test_prm_endpoint():
|
||||||
"""Test that the Protected Resource Metadata endpoint returns correct data."""
|
"""Test that the Protected Resource Metadata endpoint returns correct data."""
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ async def test_prm_endpoint():
|
|||||||
assert prm_data["resource"] == "http://localhost:8001/mcp"
|
assert prm_data["resource"] == "http://localhost:8001/mcp"
|
||||||
assert "notes:read" in prm_data["scopes_supported"]
|
assert "notes:read" in prm_data["scopes_supported"]
|
||||||
assert "notes:write" in prm_data["scopes_supported"]
|
assert "notes:write" in prm_data["scopes_supported"]
|
||||||
assert "http://localhost:8080" in prm_data["authorization_servers"]
|
assert "http://localhost:8001" in prm_data["authorization_servers"]
|
||||||
assert "header" in prm_data["bearer_methods_supported"]
|
assert "header" in prm_data["bearer_methods_supported"]
|
||||||
assert "RS256" in prm_data["resource_signing_alg_values_supported"]
|
assert "RS256" in prm_data["resource_signing_alg_values_supported"]
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ async def test_basicauth_shows_all_tools(nc_mcp_client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.oauth
|
||||||
async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only):
|
async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only):
|
||||||
"""Test that a token with only read scopes filters out write tools."""
|
"""Test that a token with only read scopes filters out write tools."""
|
||||||
|
|
||||||
@@ -108,6 +110,7 @@ async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.oauth
|
||||||
async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_only):
|
async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_only):
|
||||||
"""Test that a token with only write scopes filters out read tools."""
|
"""Test that a token with only write scopes filters out read tools."""
|
||||||
|
|
||||||
@@ -156,6 +159,7 @@ async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_onl
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.oauth
|
||||||
async def test_full_access_token_shows_all_tools(nc_mcp_oauth_client_full_access):
|
async def test_full_access_token_shows_all_tools(nc_mcp_oauth_client_full_access):
|
||||||
"""Test that a token with both read and write scopes scopes can see all tools."""
|
"""Test that a token with both read and write scopes scopes can see all tools."""
|
||||||
|
|
||||||
@@ -389,6 +393,7 @@ async def test_scope_metadata_coverage(nc_mcp_client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.oauth
|
||||||
async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
||||||
nc_mcp_oauth_client_no_custom_scopes,
|
nc_mcp_oauth_client_no_custom_scopes,
|
||||||
):
|
):
|
||||||
@@ -433,6 +438,7 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.oauth
|
||||||
async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only):
|
async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only):
|
||||||
"""
|
"""
|
||||||
Test JWT with only nc:read scope consented.
|
Test JWT with only nc:read scope consented.
|
||||||
@@ -470,6 +476,7 @@ async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.oauth
|
||||||
async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only):
|
async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only):
|
||||||
"""
|
"""
|
||||||
Test JWT with only nc:write scope consented.
|
Test JWT with only nc:write scope consented.
|
||||||
@@ -507,6 +514,7 @@ async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.oauth
|
||||||
async def test_jwt_consent_scenarios_full_access(nc_mcp_oauth_client_full_access):
|
async def test_jwt_consent_scenarios_full_access(nc_mcp_oauth_client_full_access):
|
||||||
"""
|
"""
|
||||||
Test JWT with both nc:read and nc:write scopes consented.
|
Test JWT with both nc:read and nc:write scopes consented.
|
||||||
|
|||||||
@@ -61,14 +61,12 @@ async def token_exchange_service(token_storage):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def token_broker(token_storage):
|
async def token_broker(token_storage):
|
||||||
"""Create test token broker service."""
|
"""Create test token broker service."""
|
||||||
# Use the same encryption key as storage
|
|
||||||
encryption_key = token_storage._test_encryption_key
|
|
||||||
|
|
||||||
broker = TokenBrokerService(
|
broker = TokenBrokerService(
|
||||||
storage=token_storage,
|
storage=token_storage,
|
||||||
oidc_discovery_url="http://test-idp/.well-known/openid-configuration",
|
oidc_discovery_url="http://test-idp/.well-known/openid-configuration",
|
||||||
nextcloud_host="http://test-nextcloud",
|
nextcloud_host="http://test-nextcloud",
|
||||||
encryption_key=encryption_key,
|
client_id="test-client",
|
||||||
|
client_secret="test-secret",
|
||||||
cache_ttl=300,
|
cache_ttl=300,
|
||||||
cache_early_refresh=30,
|
cache_early_refresh=30,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""Unit tests for access.py REST API endpoints.
|
||||||
|
|
||||||
|
Tests the REST API endpoints for user access and scope management:
|
||||||
|
- GET /api/v1/users/{user_id}/access - Get user's provisioned access and scopes
|
||||||
|
- PATCH /api/v1/users/{user_id}/scopes - Update user's application-level scopes
|
||||||
|
- GET /api/v1/scopes - List all supported scopes
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.routing import Route
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.api.access import (
|
||||||
|
get_user_access,
|
||||||
|
list_supported_scopes,
|
||||||
|
update_user_scopes,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
from nextcloud_mcp_server.models.auth import ALL_SUPPORTED_SCOPES
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def encryption_key():
|
||||||
|
"""Generate a test encryption key."""
|
||||||
|
return Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def temp_storage(encryption_key):
|
||||||
|
"""Create temporary storage instance with encryption for testing."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "test_access.db"
|
||||||
|
storage = RefreshTokenStorage(
|
||||||
|
db_path=str(db_path), encryption_key=encryption_key
|
||||||
|
)
|
||||||
|
await storage.initialize()
|
||||||
|
yield storage
|
||||||
|
|
||||||
|
|
||||||
|
def create_basic_auth_header(username: str, password: str) -> str:
|
||||||
|
"""Create BasicAuth header value."""
|
||||||
|
credentials = f"{username}:{password}"
|
||||||
|
encoded = base64.b64encode(credentials.encode()).decode()
|
||||||
|
return f"Basic {encoded}"
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_app(storage):
|
||||||
|
"""Create a test Starlette app with the access endpoints."""
|
||||||
|
app = Starlette(
|
||||||
|
routes=[
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/access",
|
||||||
|
get_user_access,
|
||||||
|
methods=["GET"],
|
||||||
|
),
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/scopes",
|
||||||
|
update_user_scopes,
|
||||||
|
methods=["PATCH"],
|
||||||
|
),
|
||||||
|
Route(
|
||||||
|
"/api/v1/scopes",
|
||||||
|
list_supported_scopes,
|
||||||
|
methods=["GET"],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
app.state.storage = storage
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetUserAccess:
|
||||||
|
"""Tests for GET /api/v1/users/{user_id}/access."""
|
||||||
|
|
||||||
|
async def test_not_provisioned(self, temp_storage):
|
||||||
|
"""Returns provisioned=False when no app password stored."""
|
||||||
|
app = create_test_app(temp_storage)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/users/alice/access",
|
||||||
|
headers={"Authorization": create_basic_auth_header("alice", "pw")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["provisioned"] is False
|
||||||
|
assert data["scopes"] is None
|
||||||
|
|
||||||
|
async def test_provisioned_with_scopes(self, temp_storage):
|
||||||
|
"""Returns provisioned=True with scopes when app password exists."""
|
||||||
|
await temp_storage.store_app_password_with_scopes(
|
||||||
|
user_id="alice",
|
||||||
|
app_password="test-app-pw",
|
||||||
|
scopes=["notes:read", "calendar:write"],
|
||||||
|
username="alice_nc",
|
||||||
|
)
|
||||||
|
|
||||||
|
app = create_test_app(temp_storage)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/users/alice/access",
|
||||||
|
headers={"Authorization": create_basic_auth_header("alice", "pw")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["provisioned"] is True
|
||||||
|
assert set(data["scopes"]) == {"notes:read", "calendar:write"}
|
||||||
|
assert data["username"] == "alice_nc"
|
||||||
|
|
||||||
|
async def test_missing_auth_header(self, temp_storage):
|
||||||
|
"""Returns 401 when no Authorization header."""
|
||||||
|
app = create_test_app(temp_storage)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/users/alice/access")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
async def test_user_id_mismatch(self, temp_storage):
|
||||||
|
"""Returns 403 when path user_id doesn't match auth credentials."""
|
||||||
|
app = create_test_app(temp_storage)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/users/alice/access",
|
||||||
|
headers={"Authorization": create_basic_auth_header("bob", "pw")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateUserScopes:
|
||||||
|
"""Tests for PATCH /api/v1/users/{user_id}/scopes."""
|
||||||
|
|
||||||
|
async def test_update_valid_scopes(self, temp_storage):
|
||||||
|
"""Successfully updates scopes for a provisioned user."""
|
||||||
|
await temp_storage.store_app_password_with_scopes(
|
||||||
|
user_id="alice",
|
||||||
|
app_password="test-app-pw",
|
||||||
|
scopes=["notes:read"],
|
||||||
|
username="alice_nc",
|
||||||
|
)
|
||||||
|
|
||||||
|
app = create_test_app(temp_storage)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/v1/users/alice/scopes",
|
||||||
|
headers={"Authorization": create_basic_auth_header("alice", "pw")},
|
||||||
|
json={"scopes": ["notes:read", "notes:write", "calendar:read"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert set(data["scopes"]) == {"notes:read", "notes:write", "calendar:read"}
|
||||||
|
|
||||||
|
async def test_invalid_scopes(self, temp_storage):
|
||||||
|
"""Returns 400 for invalid scope names."""
|
||||||
|
await temp_storage.store_app_password_with_scopes(
|
||||||
|
user_id="alice",
|
||||||
|
app_password="test-app-pw",
|
||||||
|
scopes=["notes:read"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app = create_test_app(temp_storage)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/v1/users/alice/scopes",
|
||||||
|
headers={"Authorization": create_basic_auth_header("alice", "pw")},
|
||||||
|
json={"scopes": ["notes:read", "invalid:scope"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "invalid:scope" in data["error"]
|
||||||
|
|
||||||
|
async def test_user_not_provisioned(self, temp_storage):
|
||||||
|
"""Returns 404 when user has no app password."""
|
||||||
|
app = create_test_app(temp_storage)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/v1/users/alice/scopes",
|
||||||
|
headers={"Authorization": create_basic_auth_header("alice", "pw")},
|
||||||
|
json={"scopes": ["notes:read"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
|
||||||
|
async def test_missing_scopes_field(self, temp_storage):
|
||||||
|
"""Returns 400 when scopes field is missing from body."""
|
||||||
|
app = create_test_app(temp_storage)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/v1/users/alice/scopes",
|
||||||
|
headers={"Authorization": create_basic_auth_header("alice", "pw")},
|
||||||
|
json={"something_else": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
async def test_invalid_json_body(self, temp_storage):
|
||||||
|
"""Returns 400 for invalid JSON body."""
|
||||||
|
app = create_test_app(temp_storage)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/v1/users/alice/scopes",
|
||||||
|
headers={
|
||||||
|
"Authorization": create_basic_auth_header("alice", "pw"),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
content=b"not json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestListSupportedScopes:
|
||||||
|
"""Tests for GET /api/v1/scopes."""
|
||||||
|
|
||||||
|
async def test_returns_all_scopes(self, temp_storage):
|
||||||
|
"""Returns all supported scopes sorted."""
|
||||||
|
app = create_test_app(temp_storage)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/scopes")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert set(data["scopes"]) == ALL_SUPPORTED_SCOPES
|
||||||
|
# Verify it's sorted
|
||||||
|
assert data["scopes"] == sorted(data["scopes"])
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Unit tests for Login Flow v2 MCP auth tools.
|
||||||
|
|
||||||
|
Tests the auth tools logic with mocked storage and Login Flow client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
from nextcloud_mcp_server.models.auth import ALL_SUPPORTED_SCOPES
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def encryption_key():
|
||||||
|
"""Generate a test encryption key."""
|
||||||
|
return Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def temp_storage(encryption_key):
|
||||||
|
"""Create temporary storage with encryption for testing."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "test_auth_tools.db"
|
||||||
|
storage = RefreshTokenStorage(
|
||||||
|
db_path=str(db_path), encryption_key=encryption_key
|
||||||
|
)
|
||||||
|
await storage.initialize()
|
||||||
|
yield storage
|
||||||
|
|
||||||
|
|
||||||
|
async def test_store_app_password_with_scopes(temp_storage):
|
||||||
|
"""Test storing app password with scopes."""
|
||||||
|
await temp_storage.store_app_password_with_scopes(
|
||||||
|
user_id="alice",
|
||||||
|
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
|
||||||
|
scopes=["notes:read", "notes:write"],
|
||||||
|
username="alice_nc",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = await temp_storage.get_app_password_with_scopes("alice")
|
||||||
|
assert data is not None
|
||||||
|
assert data["app_password"] == "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
assert data["scopes"] == ["notes:read", "notes:write"]
|
||||||
|
assert data["username"] == "alice_nc"
|
||||||
|
assert data["created_at"] is not None
|
||||||
|
assert data["updated_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_store_app_password_null_scopes(temp_storage):
|
||||||
|
"""Test storing app password with NULL scopes (all allowed)."""
|
||||||
|
await temp_storage.store_app_password_with_scopes(
|
||||||
|
user_id="bob",
|
||||||
|
app_password="fffff-ggggg-hhhhh-iiiii-jjjjj",
|
||||||
|
scopes=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = await temp_storage.get_app_password_with_scopes("bob")
|
||||||
|
assert data is not None
|
||||||
|
assert data["scopes"] is None # NULL = all scopes allowed
|
||||||
|
assert data["username"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_store_app_password_with_scopes_replaces(temp_storage):
|
||||||
|
"""Test that storing replaces existing record."""
|
||||||
|
await temp_storage.store_app_password_with_scopes(
|
||||||
|
user_id="alice",
|
||||||
|
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
|
||||||
|
scopes=["notes:read"],
|
||||||
|
)
|
||||||
|
await temp_storage.store_app_password_with_scopes(
|
||||||
|
user_id="alice",
|
||||||
|
app_password="xxxxx-yyyyy-zzzzz-aaaaa-bbbbb",
|
||||||
|
scopes=["notes:read", "calendar:read"],
|
||||||
|
username="alice_nc",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = await temp_storage.get_app_password_with_scopes("alice")
|
||||||
|
assert data["app_password"] == "xxxxx-yyyyy-zzzzz-aaaaa-bbbbb"
|
||||||
|
assert data["scopes"] == ["notes:read", "calendar:read"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_app_password_with_scopes_nonexistent(temp_storage):
|
||||||
|
"""Test getting scoped password for non-existent user."""
|
||||||
|
data = await temp_storage.get_app_password_with_scopes("nonexistent")
|
||||||
|
assert data is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Login Flow Session Tests ──
|
||||||
|
|
||||||
|
|
||||||
|
async def test_store_and_get_login_flow_session(temp_storage):
|
||||||
|
"""Test storing and retrieving a login flow session."""
|
||||||
|
await temp_storage.store_login_flow_session(
|
||||||
|
user_id="alice",
|
||||||
|
poll_token="secret-poll-token",
|
||||||
|
poll_endpoint="https://cloud.example.com/login/v2/poll",
|
||||||
|
requested_scopes=["notes:read", "notes:write"],
|
||||||
|
)
|
||||||
|
|
||||||
|
session = await temp_storage.get_login_flow_session("alice")
|
||||||
|
assert session is not None
|
||||||
|
assert session["poll_token"] == "secret-poll-token"
|
||||||
|
assert session["poll_endpoint"] == "https://cloud.example.com/login/v2/poll"
|
||||||
|
assert session["requested_scopes"] == ["notes:read", "notes:write"]
|
||||||
|
assert session["created_at"] is not None
|
||||||
|
assert session["expires_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_login_flow_session_nonexistent(temp_storage):
|
||||||
|
"""Test getting session for user with no pending flow."""
|
||||||
|
session = await temp_storage.get_login_flow_session("nonexistent")
|
||||||
|
assert session is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_login_flow_session_expired(temp_storage):
|
||||||
|
"""Test that expired sessions are not returned."""
|
||||||
|
await temp_storage.store_login_flow_session(
|
||||||
|
user_id="alice",
|
||||||
|
poll_token="expired-token",
|
||||||
|
poll_endpoint="https://cloud.example.com/login/v2/poll",
|
||||||
|
expires_at=1, # Expired long ago
|
||||||
|
)
|
||||||
|
|
||||||
|
session = await temp_storage.get_login_flow_session("alice")
|
||||||
|
assert session is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_login_flow_session(temp_storage):
|
||||||
|
"""Test deleting a login flow session."""
|
||||||
|
await temp_storage.store_login_flow_session(
|
||||||
|
user_id="alice",
|
||||||
|
poll_token="token",
|
||||||
|
poll_endpoint="https://cloud.example.com/poll",
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted = await temp_storage.delete_login_flow_session("alice")
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
# Verify it's gone
|
||||||
|
session = await temp_storage.get_login_flow_session("alice")
|
||||||
|
assert session is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_login_flow_session_nonexistent(temp_storage):
|
||||||
|
"""Test deleting a non-existent session returns False."""
|
||||||
|
deleted = await temp_storage.delete_login_flow_session("nonexistent")
|
||||||
|
assert deleted is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_expired_login_flow_sessions(temp_storage):
|
||||||
|
"""Test cleanup of expired sessions."""
|
||||||
|
# Store 2 expired and 1 valid session
|
||||||
|
await temp_storage.store_login_flow_session(
|
||||||
|
user_id="expired1",
|
||||||
|
poll_token="t1",
|
||||||
|
poll_endpoint="https://cloud.example.com/poll",
|
||||||
|
expires_at=1,
|
||||||
|
)
|
||||||
|
await temp_storage.store_login_flow_session(
|
||||||
|
user_id="expired2",
|
||||||
|
poll_token="t2",
|
||||||
|
poll_endpoint="https://cloud.example.com/poll",
|
||||||
|
expires_at=2,
|
||||||
|
)
|
||||||
|
await temp_storage.store_login_flow_session(
|
||||||
|
user_id="valid",
|
||||||
|
poll_token="t3",
|
||||||
|
poll_endpoint="https://cloud.example.com/poll",
|
||||||
|
# Default expiry = 20 minutes from now
|
||||||
|
)
|
||||||
|
|
||||||
|
count = await temp_storage.delete_expired_login_flow_sessions()
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
# Valid session should still exist
|
||||||
|
session = await temp_storage.get_login_flow_session("valid")
|
||||||
|
assert session is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Response Model Tests ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_supported_scopes():
|
||||||
|
"""Test that ALL_SUPPORTED_SCOPES contains expected scopes."""
|
||||||
|
assert "notes:read" in ALL_SUPPORTED_SCOPES
|
||||||
|
assert "notes:write" in ALL_SUPPORTED_SCOPES
|
||||||
|
assert "calendar:read" in ALL_SUPPORTED_SCOPES
|
||||||
|
assert "files:read" in ALL_SUPPORTED_SCOPES
|
||||||
|
assert "deck:read" in ALL_SUPPORTED_SCOPES
|
||||||
|
# Scopes should be in pairs (read/write)
|
||||||
|
read_scopes = [s for s in ALL_SUPPORTED_SCOPES if s.endswith(":read")]
|
||||||
|
write_scopes = [s for s in ALL_SUPPORTED_SCOPES if s.endswith(":write")]
|
||||||
|
assert len(read_scopes) == len(write_scopes)
|
||||||
@@ -32,7 +32,7 @@ def mock_metrics():
|
|||||||
def mock_tracer():
|
def mock_tracer():
|
||||||
"""Mock OpenTelemetry tracer."""
|
"""Mock OpenTelemetry tracer."""
|
||||||
with patch(
|
with patch(
|
||||||
"nextcloud_mcp_server.observability.tracing.trace_operation"
|
"nextcloud_mcp_server.observability.metrics.trace_operation"
|
||||||
) as mock_trace:
|
) as mock_trace:
|
||||||
# Configure mock to act as a context manager that allows exceptions to propagate
|
# Configure mock to act as a context manager that allows exceptions to propagate
|
||||||
mock_trace.return_value.__enter__ = MagicMock(return_value=None)
|
mock_trace.return_value.__enter__ = MagicMock(return_value=None)
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Unit tests for Login Flow v2 HTTP client.
|
||||||
|
|
||||||
|
Tests the LoginFlowV2Client with mocked HTTP responses for:
|
||||||
|
- Flow initiation (POST /index.php/login/v2)
|
||||||
|
- Flow polling (completed, pending, expired)
|
||||||
|
- Error handling
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.login_flow import (
|
||||||
|
LoginFlowInitResponse,
|
||||||
|
LoginFlowPollResult,
|
||||||
|
LoginFlowV2Client,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def flow_client():
|
||||||
|
"""Create a LoginFlowV2Client for testing."""
|
||||||
|
return LoginFlowV2Client(
|
||||||
|
nextcloud_host="https://cloud.example.com",
|
||||||
|
verify_ssl=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_response(status_code: int, json_data: dict) -> MagicMock:
|
||||||
|
"""Create a mock httpx response."""
|
||||||
|
response = MagicMock()
|
||||||
|
response.status_code = status_code
|
||||||
|
response.json.return_value = json_data
|
||||||
|
response.raise_for_status = MagicMock()
|
||||||
|
if status_code >= 400:
|
||||||
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
|
response.raise_for_status.side_effect = HTTPStatusError(
|
||||||
|
"error", request=MagicMock(), response=response
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def test_initiate_success(flow_client):
|
||||||
|
"""Test successful Login Flow v2 initiation."""
|
||||||
|
mock_response = _mock_response(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"login": "https://cloud.example.com/login/v2/grant?token=abc123",
|
||||||
|
"poll": {
|
||||||
|
"endpoint": "https://cloud.example.com/login/v2/poll",
|
||||||
|
"token": "secret-poll-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.auth.login_flow.nextcloud_httpx_client",
|
||||||
|
return_value=mock_client,
|
||||||
|
):
|
||||||
|
result = await flow_client.initiate()
|
||||||
|
|
||||||
|
assert isinstance(result, LoginFlowInitResponse)
|
||||||
|
assert result.login_url == "https://cloud.example.com/login/v2/grant?token=abc123"
|
||||||
|
assert result.poll_endpoint == "https://cloud.example.com/login/v2/poll"
|
||||||
|
assert result.poll_token == "secret-poll-token"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_poll_completed(flow_client):
|
||||||
|
"""Test polling when user has completed login."""
|
||||||
|
mock_response = _mock_response(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"server": "https://cloud.example.com",
|
||||||
|
"loginName": "alice",
|
||||||
|
"appPassword": "aaaaa-bbbbb-ccccc-ddddd-eeeee",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.auth.login_flow.nextcloud_httpx_client",
|
||||||
|
return_value=mock_client,
|
||||||
|
):
|
||||||
|
result = await flow_client.poll(
|
||||||
|
poll_endpoint="https://cloud.example.com/login/v2/poll",
|
||||||
|
poll_token="secret-poll-token",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, LoginFlowPollResult)
|
||||||
|
assert result.status == "completed"
|
||||||
|
assert result.server == "https://cloud.example.com"
|
||||||
|
assert result.login_name == "alice"
|
||||||
|
assert result.app_password == "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_poll_pending(flow_client):
|
||||||
|
"""Test polling when login is still pending."""
|
||||||
|
mock_response = _mock_response(404, {})
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.auth.login_flow.nextcloud_httpx_client",
|
||||||
|
return_value=mock_client,
|
||||||
|
):
|
||||||
|
result = await flow_client.poll(
|
||||||
|
poll_endpoint="https://cloud.example.com/login/v2/poll",
|
||||||
|
poll_token="secret-poll-token",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "pending"
|
||||||
|
assert result.server is None
|
||||||
|
assert result.app_password is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_poll_expired(flow_client):
|
||||||
|
"""Test polling when flow has expired."""
|
||||||
|
mock_response = _mock_response(403, {})
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.auth.login_flow.nextcloud_httpx_client",
|
||||||
|
return_value=mock_client,
|
||||||
|
):
|
||||||
|
result = await flow_client.poll(
|
||||||
|
poll_endpoint="https://cloud.example.com/login/v2/poll",
|
||||||
|
poll_token="expired-token",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "expired"
|
||||||
|
assert result.app_password is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_initiate_with_custom_user_agent(flow_client):
|
||||||
|
"""Test that custom user agent is passed in the request."""
|
||||||
|
mock_response = _mock_response(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"login": "https://cloud.example.com/login/v2/grant?token=abc",
|
||||||
|
"poll": {
|
||||||
|
"endpoint": "https://cloud.example.com/login/v2/poll",
|
||||||
|
"token": "tok",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.auth.login_flow.nextcloud_httpx_client",
|
||||||
|
return_value=mock_client,
|
||||||
|
):
|
||||||
|
await flow_client.initiate(user_agent="my-custom-agent")
|
||||||
|
|
||||||
|
# Verify the user agent was passed
|
||||||
|
call_kwargs = mock_client.post.call_args
|
||||||
|
assert call_kwargs.kwargs["headers"]["User-Agent"] == "my-custom-agent"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_flow_init_response_model():
|
||||||
|
"""Test LoginFlowInitResponse Pydantic model validation."""
|
||||||
|
resp = LoginFlowInitResponse(
|
||||||
|
login_url="https://cloud.example.com/login",
|
||||||
|
poll_endpoint="https://cloud.example.com/poll",
|
||||||
|
poll_token="token123",
|
||||||
|
)
|
||||||
|
assert resp.login_url == "https://cloud.example.com/login"
|
||||||
|
assert resp.poll_endpoint == "https://cloud.example.com/poll"
|
||||||
|
assert resp.poll_token == "token123"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_flow_poll_result_model():
|
||||||
|
"""Test LoginFlowPollResult Pydantic model validation."""
|
||||||
|
# Completed result
|
||||||
|
completed = LoginFlowPollResult(
|
||||||
|
status="completed",
|
||||||
|
server="https://cloud.example.com",
|
||||||
|
login_name="bob",
|
||||||
|
app_password="xxxxx-yyyyy-zzzzz-aaaaa-bbbbb",
|
||||||
|
)
|
||||||
|
assert completed.status == "completed"
|
||||||
|
assert completed.login_name == "bob"
|
||||||
|
|
||||||
|
# Pending result
|
||||||
|
pending = LoginFlowPollResult(status="pending")
|
||||||
|
assert pending.status == "pending"
|
||||||
|
assert pending.server is None
|
||||||
|
assert pending.app_password is None
|
||||||
@@ -184,7 +184,7 @@ async def test_provision_app_password_success(temp_storage, mocker):
|
|||||||
"""Test successful app password provisioning."""
|
"""Test successful app password provisioning."""
|
||||||
# Mock settings (imported locally in the function)
|
# Mock settings (imported locally in the function)
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||||
return_value=MagicMock(
|
return_value=MagicMock(
|
||||||
nextcloud_host="http://localhost:8080",
|
nextcloud_host="http://localhost:8080",
|
||||||
nextcloud_verify_ssl=True,
|
nextcloud_verify_ssl=True,
|
||||||
@@ -203,7 +203,7 @@ async def test_provision_app_password_success(temp_storage, mocker):
|
|||||||
mock_client.__aexit__ = AsyncMock()
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_client",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ async def test_provision_app_password_success(temp_storage, mocker):
|
|||||||
async def test_provision_app_password_nextcloud_validation_fails(mocker):
|
async def test_provision_app_password_nextcloud_validation_fails(mocker):
|
||||||
"""Test that failed Nextcloud validation returns 401."""
|
"""Test that failed Nextcloud validation returns 401."""
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||||
return_value=MagicMock(
|
return_value=MagicMock(
|
||||||
nextcloud_host="http://localhost:8080",
|
nextcloud_host="http://localhost:8080",
|
||||||
nextcloud_verify_ssl=True,
|
nextcloud_verify_ssl=True,
|
||||||
@@ -251,7 +251,7 @@ async def test_provision_app_password_nextcloud_validation_fails(mocker):
|
|||||||
mock_client.__aexit__ = AsyncMock()
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_client",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -356,7 +356,7 @@ async def test_delete_app_password_success(temp_storage, mocker):
|
|||||||
|
|
||||||
# Mock settings (imported locally in the function)
|
# Mock settings (imported locally in the function)
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||||
return_value=MagicMock(
|
return_value=MagicMock(
|
||||||
nextcloud_host="http://localhost:8080",
|
nextcloud_host="http://localhost:8080",
|
||||||
nextcloud_verify_ssl=True,
|
nextcloud_verify_ssl=True,
|
||||||
@@ -374,7 +374,7 @@ async def test_delete_app_password_success(temp_storage, mocker):
|
|||||||
mock_client.__aexit__ = AsyncMock()
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_client",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -404,7 +404,7 @@ async def test_delete_app_password_not_found(temp_storage, mocker):
|
|||||||
"""Test deleting non-existent app password."""
|
"""Test deleting non-existent app password."""
|
||||||
# Mock settings (imported locally in the function)
|
# Mock settings (imported locally in the function)
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||||
return_value=MagicMock(
|
return_value=MagicMock(
|
||||||
nextcloud_host="http://localhost:8080",
|
nextcloud_host="http://localhost:8080",
|
||||||
nextcloud_verify_ssl=True,
|
nextcloud_verify_ssl=True,
|
||||||
@@ -422,7 +422,7 @@ async def test_delete_app_password_not_found(temp_storage, mocker):
|
|||||||
mock_client.__aexit__ = AsyncMock()
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_client",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -447,7 +447,7 @@ async def test_delete_app_password_not_found(temp_storage, mocker):
|
|||||||
async def test_delete_app_password_invalid_credentials(mocker):
|
async def test_delete_app_password_invalid_credentials(mocker):
|
||||||
"""Test that invalid credentials returns 401 for deletion."""
|
"""Test that invalid credentials returns 401 for deletion."""
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||||
return_value=MagicMock(
|
return_value=MagicMock(
|
||||||
nextcloud_host="http://localhost:8080",
|
nextcloud_host="http://localhost:8080",
|
||||||
nextcloud_verify_ssl=True,
|
nextcloud_verify_ssl=True,
|
||||||
@@ -465,7 +465,7 @@ async def test_delete_app_password_invalid_credentials(mocker):
|
|||||||
mock_client.__aexit__ = AsyncMock()
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_client",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -521,7 +521,7 @@ async def test_delete_app_password_username_mismatch():
|
|||||||
async def test_provision_app_password_rate_limiting(mocker):
|
async def test_provision_app_password_rate_limiting(mocker):
|
||||||
"""Test that rate limiting blocks excessive provisioning attempts."""
|
"""Test that rate limiting blocks excessive provisioning attempts."""
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||||
return_value=MagicMock(
|
return_value=MagicMock(
|
||||||
nextcloud_host="http://localhost:8080",
|
nextcloud_host="http://localhost:8080",
|
||||||
nextcloud_verify_ssl=True,
|
nextcloud_verify_ssl=True,
|
||||||
@@ -539,7 +539,7 @@ async def test_provision_app_password_rate_limiting(mocker):
|
|||||||
mock_client.__aexit__ = AsyncMock()
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_client",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -584,7 +584,7 @@ async def test_provision_app_password_rate_limiting(mocker):
|
|||||||
async def test_rate_limiting_is_per_user(mocker):
|
async def test_rate_limiting_is_per_user(mocker):
|
||||||
"""Test that rate limiting is applied per user, not globally."""
|
"""Test that rate limiting is applied per user, not globally."""
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||||
return_value=MagicMock(
|
return_value=MagicMock(
|
||||||
nextcloud_host="http://localhost:8080",
|
nextcloud_host="http://localhost:8080",
|
||||||
nextcloud_verify_ssl=True,
|
nextcloud_verify_ssl=True,
|
||||||
@@ -602,7 +602,7 @@ async def test_rate_limiting_is_per_user(mocker):
|
|||||||
mock_client.__aexit__ = AsyncMock()
|
mock_client.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_client",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -70,10 +70,11 @@ class TestStatusEndpointOidcConfig:
|
|||||||
# get_settings and detect_auth_mode are imported inside the function
|
# get_settings and detect_auth_mode are imported inside the function
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
"nextcloud_mcp_server.api.management.get_settings",
|
||||||
|
return_value=mock_settings,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||||
return_value=AuthMode.MULTI_USER_BASIC,
|
return_value=AuthMode.MULTI_USER_BASIC,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -107,10 +108,11 @@ class TestStatusEndpointOidcConfig:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
"nextcloud_mcp_server.api.management.get_settings",
|
||||||
|
return_value=mock_settings,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||||
return_value=AuthMode.MULTI_USER_BASIC,
|
return_value=AuthMode.MULTI_USER_BASIC,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -135,10 +137,11 @@ class TestStatusEndpointOidcConfig:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
"nextcloud_mcp_server.api.management.get_settings",
|
||||||
|
return_value=mock_settings,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||||
return_value=AuthMode.MULTI_USER_BASIC,
|
return_value=AuthMode.MULTI_USER_BASIC,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -167,10 +170,11 @@ class TestStatusEndpointOidcConfig:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
"nextcloud_mcp_server.api.management.get_settings",
|
||||||
|
return_value=mock_settings,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||||
return_value=AuthMode.OAUTH_SINGLE_AUDIENCE,
|
return_value=AuthMode.OAUTH_SINGLE_AUDIENCE,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -202,10 +206,11 @@ class TestStatusEndpointOidcConfig:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
"nextcloud_mcp_server.api.management.get_settings",
|
||||||
|
return_value=mock_settings,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -235,10 +240,11 @@ class TestStatusEndpointOidcConfig:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
"nextcloud_mcp_server.api.management.get_settings",
|
||||||
|
return_value=mock_settings,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||||
return_value=AuthMode.MULTI_USER_BASIC,
|
return_value=AuthMode.MULTI_USER_BASIC,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -267,10 +273,11 @@ class TestStatusEndpointOidcConfig:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
"nextcloud_mcp_server.api.management.get_settings",
|
||||||
|
return_value=mock_settings,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||||
return_value=AuthMode.MULTI_USER_BASIC,
|
return_value=AuthMode.MULTI_USER_BASIC,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -295,10 +302,11 @@ class TestStatusEndpointBasicResponse:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
"nextcloud_mcp_server.api.management.get_settings",
|
||||||
|
return_value=mock_settings,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -320,10 +328,11 @@ class TestStatusEndpointBasicResponse:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
"nextcloud_mcp_server.api.management.get_settings",
|
||||||
|
return_value=mock_settings,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Unit tests for @require_scopes with stored app passwords (Login Flow v2).
|
||||||
|
|
||||||
|
Tests the third enforcement mode in scope_authorization.py that checks
|
||||||
|
application-level scopes stored alongside app passwords.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.scope_authorization import (
|
||||||
|
_get_stored_scopes,
|
||||||
|
_scope_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_scope_cache():
|
||||||
|
"""Clear scope cache before each test."""
|
||||||
|
_scope_cache.clear()
|
||||||
|
yield
|
||||||
|
_scope_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_stored_scopes_with_scopes():
|
||||||
|
"""Test getting specific scopes from storage."""
|
||||||
|
mock_storage = AsyncMock()
|
||||||
|
mock_storage.get_app_password_with_scopes.return_value = {
|
||||||
|
"app_password": "xxxxx",
|
||||||
|
"scopes": ["notes:read", "calendar:read"],
|
||||||
|
"username": "alice",
|
||||||
|
"created_at": 1000,
|
||||||
|
"updated_at": 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.auth.scope_authorization.get_shared_storage",
|
||||||
|
return_value=mock_storage,
|
||||||
|
):
|
||||||
|
result = await _get_stored_scopes("alice")
|
||||||
|
|
||||||
|
assert result == ["notes:read", "calendar:read"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_stored_scopes_null_scopes():
|
||||||
|
"""Test that NULL scopes returns 'all'."""
|
||||||
|
mock_storage = AsyncMock()
|
||||||
|
mock_storage.get_app_password_with_scopes.return_value = {
|
||||||
|
"app_password": "xxxxx",
|
||||||
|
"scopes": None,
|
||||||
|
"username": "bob",
|
||||||
|
"created_at": 1000,
|
||||||
|
"updated_at": 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.auth.scope_authorization.get_shared_storage",
|
||||||
|
return_value=mock_storage,
|
||||||
|
):
|
||||||
|
result = await _get_stored_scopes("bob")
|
||||||
|
|
||||||
|
assert result == "all"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_stored_scopes_no_password():
|
||||||
|
"""Test that missing app password returns None."""
|
||||||
|
mock_storage = AsyncMock()
|
||||||
|
mock_storage.get_app_password_with_scopes.return_value = None
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.auth.scope_authorization.get_shared_storage",
|
||||||
|
return_value=mock_storage,
|
||||||
|
):
|
||||||
|
result = await _get_stored_scopes("nobody")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_stored_scopes_storage_error():
|
||||||
|
"""Test that storage errors propagate to the caller."""
|
||||||
|
mock_storage = AsyncMock()
|
||||||
|
mock_storage.get_app_password_with_scopes.side_effect = RuntimeError("DB error")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nextcloud_mcp_server.auth.scope_authorization.get_shared_storage",
|
||||||
|
return_value=mock_storage,
|
||||||
|
),
|
||||||
|
pytest.raises(RuntimeError, match="DB error"),
|
||||||
|
):
|
||||||
|
await _get_stored_scopes("alice")
|
||||||
Vendored
+1
-1
Submodule third_party/astrolabe updated: c079a70af8...af53f1c02f
Vendored
+1
-1
Submodule third_party/notes updated: e5c119ae2d...a45b7614ba
Vendored
+1
-1
Submodule third_party/oidc updated: 8473b8497b...382938358d
Reference in New Issue
Block a user