Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92f2d74637 | |||
| 656acc2c1f | |||
| c726e25e8b | |||
| 355bd1bad3 | |||
| 989d3f2857 | |||
| 92d5cd4e26 | |||
| 5823286907 | |||
| 7fb6613bc2 | |||
| cd6f0ffa63 | |||
| 5d98858bb6 | |||
| af7c752cc1 | |||
| 2526390ce8 | |||
| 0b5571f3d7 | |||
| 059f37d093 | |||
| 28ad0aefbf | |||
| 6ce9599757 | |||
| 1cdf148899 | |||
| 8b16d79d6c | |||
| 45cc4c68fc | |||
| b4c98b25ee | |||
| 1176479ec1 | |||
| 0f8b1c6325 | |||
| fdb7b87baf | |||
| 47fb562326 | |||
| 1fae6920be | |||
| 184415eca1 | |||
| 658fd7e138 | |||
| a5d2025797 | |||
| f43343356e | |||
| 0a53aa5fcd | |||
| abd43f8028 | |||
| e7157ab256 | |||
| 08aaa85ab3 | |||
| ecab777efa | |||
| c960560716 | |||
| 023927afff | |||
| 3a87b33288 | |||
| c8ebd9c089 | |||
| 5947fff13f | |||
| a9e5c687b8 | |||
| 9d1a84af5a | |||
| d09ebf20cc | |||
| 0d14c75eb1 | |||
| ba597634bd | |||
| 1a6ce0fa7d | |||
| 3df0b06cd1 | |||
| 0b8afec494 | |||
| bd69e68dd5 | |||
| 148573e28b | |||
| 5d81d60262 | |||
| b86e798ba8 | |||
| a7d623733b | |||
| 3311b20ef6 | |||
| 28c7f1cdbd | |||
| 2713f74be6 | |||
| e3c5a87b22 | |||
| 53cf223a56 | |||
| 6bfde0de1f | |||
| 8cf3264914 | |||
| ed2f400ed8 | |||
| 6ba598afd1 | |||
| d0bfecea97 | |||
| bf0a4ac5d3 | |||
| 3da6feba41 | |||
| 1224090469 | |||
| aa624401c3 | |||
| 61e867397c | |||
| db1e0606ad | |||
| 33cf0fee9b | |||
| b2fd4da9fe | |||
| 16cd2e27cb | |||
| e28af5453b | |||
| 87ec3c4f5b | |||
| 989749530c | |||
| 2d46959d01 | |||
| 59fdcd123a | |||
| b79c54cc6a | |||
| fe3fbe95a1 | |||
| 8fe7d81e57 | |||
| 8b5c2395b5 | |||
| 5796e2ba54 | |||
| 37141ea79f | |||
| 68126f6fe3 | |||
| 78b934ffa6 | |||
| 01a9ad5278 | |||
| b67a566902 | |||
| c9e8a56355 | |||
| 785ba5bf09 | |||
| 159ffb6110 | |||
| 70139c4782 | |||
| a922187489 | |||
| 1ba6a142f5 | |||
| 79478f2483 | |||
| 4721a5da52 | |||
| be2b683604 | |||
| 9fd3d92a0f | |||
| ceebda071f | |||
| 26fc48dc46 | |||
| 3edc226d17 | |||
| 7384b47795 | |||
| b62d275dc9 | |||
| a0fa0230ab | |||
| 7314097483 | |||
| 3d070f74c5 | |||
| 80366a4e1e | |||
| 91941a9ece | |||
| 8fd6f4158f | |||
| b8e6539b6f | |||
| fe53e93fe9 | |||
| 71d4c44b05 | |||
| 8261048741 | |||
| 6443aca743 | |||
| a1b5e676e9 | |||
| 1d9168f614 | |||
| 9229440a58 | |||
| e507f29e83 | |||
| 5ac6d8d396 | |||
| ab71003c5d | |||
| 726b71eea1 | |||
| 3e50924169 | |||
| b2773317ef | |||
| dce3ca9a70 | |||
| 18e5baf2a5 | |||
| 24bc29ea64 | |||
| 44e7e2e09b | |||
| bcc0bfee8d | |||
| 0f31d16158 | |||
| 7c0b84d398 | |||
| f51b27ba19 | |||
| 010eb40d5c | |||
| 960d060d27 | |||
| 76e6c12b56 | |||
| 76e305006c | |||
| 8887aa241a |
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@edd85d61533cbba7b57ed0ca4af1750b1fdfd3c4 # v1.0.55
|
||||
uses: anthropics/claude-code-action@cd77b50d2b0808657f8e6774085c8bf54484351c # v1.0.72
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
allowed_bots: "renovate-bot-cbcoutinho"
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@edd85d61533cbba7b57ed0ca4af1750b1fdfd3c4 # v1.0.55
|
||||
uses: anthropics/claude-code-action@cd77b50d2b0808657f8e6774085c8bf54484351c # v1.0.72
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
|
||||
- name: Wait for Nextcloud to be ready
|
||||
run: |
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: rag-evaluation-results
|
||||
path: |
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Install Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Build
|
||||
|
||||
+158
-33
@@ -1,4 +1,4 @@
|
||||
name: Docker Compose Action
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -11,78 +11,203 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
run: uv run --frozen ruff format --diff
|
||||
- name: Linting
|
||||
run: |
|
||||
uv run --frozen ruff check
|
||||
- name: Linting
|
||||
run: |
|
||||
uv run --frozen ty check -- nextcloud_mcp_server
|
||||
run: uv run --frozen ruff check
|
||||
- name: Type check
|
||||
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@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Run unit tests
|
||||
run: uv run pytest -v -m unit -o "addopts=-p no:asyncio"
|
||||
|
||||
integration-test:
|
||||
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.14@sha256:9bf3fae91aad4dca3eff02c1f71df8d5c6705a349065fb537aa5c5ef578f1013"
|
||||
# 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:d53f6cb35b0712aa890a5e4a8ca21043d6fcd390f38c55b710816dd7cbc2edc0"
|
||||
|
||||
# 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:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
- name: Set up php 8.4
|
||||
- name: Set up PHP 8.4
|
||||
if: matrix.mode != 'single-user'
|
||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0
|
||||
with:
|
||||
php-version: 8.4
|
||||
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: 24
|
||||
|
||||
- name: Build Astrolabe app
|
||||
if: matrix.mode != 'single-user'
|
||||
run: |
|
||||
cd third_party/oidc
|
||||
composer install --no-dev
|
||||
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
cd third_party/astrolabe
|
||||
composer install --no-dev --optimize-autoloader
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
# Start services with the appropriate profile
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
#compose-flags: "--profile qdrant"
|
||||
compose-flags: "--profile ${{ matrix.profile }}"
|
||||
up-flags: "--build"
|
||||
env:
|
||||
MCP_SERVER_URL: ${{ matrix.mcp-internal-url }}
|
||||
NEXTCLOUD_IMAGE: ${{ matrix.nextcloud_image }}
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: |
|
||||
uv run playwright install chromium --with-deps
|
||||
- name: Install Playwright
|
||||
if: matrix.needs-playwright
|
||||
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: |
|
||||
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
|
||||
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))
|
||||
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
|
||||
fi
|
||||
echo "Attempt $attempt/$max_attempts: Service not ready, sleeping for 5 seconds..."
|
||||
echo "Attempt $attempt/$max_attempts: Not ready, sleeping 5s..."
|
||||
sleep 5
|
||||
done
|
||||
echo "Service is ready (returned 401)."
|
||||
echo "Nextcloud is ready."
|
||||
|
||||
# Add subsequent steps here, e.g., running tests
|
||||
- name: Run tests
|
||||
# Wait for the MCP service to be healthy
|
||||
- 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:
|
||||
NEXTCLOUD_HOST: "http://localhost:8080"
|
||||
NEXTCLOUD_USERNAME: "admin"
|
||||
NEXTCLOUD_PASSWORD: "admin"
|
||||
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
|
||||
|
||||
@@ -5,6 +5,57 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
||||
|
||||
## v0.65.0 (2026-03-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- **auth**: implement OAuth AS proxy to fix audience mismatch (ADR-023)
|
||||
- **ci**: add Nextcloud version matrix (NC 31, 32, 33)
|
||||
- **helm**: add login-flow auth mode to Helm chart (ADR-022)
|
||||
- add Docker Compose profiles and Login Flow v2 service
|
||||
|
||||
### Fix
|
||||
|
||||
- replace assert with proper guard and invalidate scope cache after provisioning
|
||||
- disable NC rate limiting in dev/CI and add token endpoint diagnostics
|
||||
- address review feedback — security, caching, CI 429 retry
|
||||
- skip keycloak hook when profile inactive and update stale PRM test
|
||||
- address remaining PR #589 review findings
|
||||
- address PR #589 review findings
|
||||
- address PR review issues for Login Flow v2
|
||||
- address PR #589 review feedback (round 2)
|
||||
- **ci**: remove dev OIDC mount to fix HTTP 500 in single-user/multi-user-basic
|
||||
- **ci**: fix health check timeout and per-profile MCP server URL routing
|
||||
- **ci**: fix PHP gating, add multi-user-basic matrix entry, upload debug artifacts
|
||||
- address PR #589 review feedback for Login Flow v2
|
||||
- **ci**: fix integration test collection and skip Playwright in CI
|
||||
- **test**: fix 17 pre-existing unit test failures and add astrolabe CI build
|
||||
- **ci**: keep third_party mount, always build submodules in CI
|
||||
- **ci**: revert accidental third_party mount, use compose override for OIDC
|
||||
- **ci**: don't block integration matrix on unit-test failures
|
||||
|
||||
## v0.64.5 (2026-03-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- handle pythonvCard4 dict-format fields and missing phone numbers (#601)
|
||||
|
||||
## v0.64.4 (2026-02-26)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency icalendar to v7
|
||||
|
||||
## v0.64.3 (2026-02-21)
|
||||
|
||||
### Fix
|
||||
|
||||
- address PR #574 fourth review round
|
||||
- address PR #574 third review round
|
||||
- address PR #574 second review round
|
||||
- address PR #574 review comments
|
||||
- wrap raw list returns in response models to produce single TextContent block
|
||||
|
||||
## v0.64.2 (2026-02-20)
|
||||
|
||||
### Fix
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:f3fa41d74a768c2fce8016b98c191ae8c1bacd8f1152870a3f9f87d350920b7c
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.4@sha256:4cac394b6b72846f8a85a7a0e577c6d61d4e17fe2ccee65d9451a8b3c9efb4ac /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.7@sha256:edd1fd89f3e5b005814cc8f777610445d7b7e3ed05361f9ddfae67bebfe8456a /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
+2
-2
@@ -12,12 +12,12 @@
|
||||
# - Per-session app password authentication
|
||||
# - Multi-user support via Smithery session config
|
||||
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:f3fa41d74a768c2fce8016b98c191ae8c1bacd8f1152870a3f9f87d350920b7c
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv for fast dependency management
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.4@sha256:4cac394b6b72846f8a85a7a0e577c6d61d4e17fe2ccee65d9451a8b3c9efb4ac /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.7@sha256:edd1fd89f3e5b005814cc8f777610445d7b7e3ed05361f9ddfae67bebfe8456a /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
@@ -55,6 +55,15 @@ http://127.0.0.1:8000/sse
|
||||
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:**
|
||||
- Connect your MCP client (Claude Desktop, IDEs, `mcp dev`, etc.)
|
||||
- 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
|
||||
|
||||
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
|
||||
- Simple setup: username + app password in environment variables
|
||||
- All clients access Nextcloud as the same user
|
||||
- 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
|
||||
- Per-user scopes and permissions (clients only see tools they're authorized for)
|
||||
- More secure: tokens expire, credentials never shared with server
|
||||
- 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):**
|
||||
- MCP clients use BasicAuth (simple, stateless)
|
||||
- Admin operations use OAuth (webhooks, background sync)
|
||||
- Best for: Nextcloud deployments with admin-managed webhooks and semantic search
|
||||
- Requires: `ENABLE_MULTI_USER_BASIC_AUTH=true` + `ENABLE_OFFLINE_ACCESS=true`
|
||||
**Multi-User (Login Flow v2):**
|
||||
- Uses Nextcloud's native Login Flow v2 to obtain per-user app passwords
|
||||
- No OAuth patches required — works with stock Nextcloud
|
||||
- Each user authenticates via browser, server manages app passwords
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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 "===================================================================="
|
||||
|
||||
# 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
|
||||
echo "Waiting for Keycloak realm to be available..."
|
||||
MAX_RETRIES=30
|
||||
|
||||
@@ -2,12 +2,30 @@
|
||||
|
||||
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)"
|
||||
php /var/www/html/occ app:enable astrolabe
|
||||
else
|
||||
echo "astrolabe app not found, installing from app store..."
|
||||
php /var/www/html/occ app:install astrolabe
|
||||
php /var/www/html/occ app:enable astrolabe
|
||||
fi
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
|
||||
set -e
|
||||
|
||||
# The MCP multi-user BasicAuth service runs on port 8000 inside the container
|
||||
# From Nextcloud's perspective (inside Docker network), we reach it via service name
|
||||
MCP_SERVER_URL="${MCP_SERVER_URL:-http://mcp-multi-user-basic:8000}"
|
||||
if [ -z "${MCP_SERVER_URL:-}" ]; then
|
||||
echo "MCP_SERVER_URL not set, skipping Astrolabe MCP server URL configuration"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Configuring MCP server URL: $MCP_SERVER_URL"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.57.70"
|
||||
version = "0.58.3"
|
||||
tag_format = "nextcloud-mcp-server-$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
|
||||
@@ -14,6 +14,105 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Configurable resource limits
|
||||
- Grafana dashboard annotations
|
||||
|
||||
## nextcloud-mcp-server-0.58.3 (2026-03-16)
|
||||
|
||||
## nextcloud-mcp-server-0.58.2 (2026-03-14)
|
||||
|
||||
## nextcloud-mcp-server-0.58.1 (2026-03-03)
|
||||
|
||||
## nextcloud-mcp-server-0.58.0 (2026-03-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- **auth**: implement OAuth AS proxy to fix audience mismatch (ADR-023)
|
||||
- **ci**: add Nextcloud version matrix (NC 31, 32, 33)
|
||||
- **helm**: add login-flow auth mode to Helm chart (ADR-022)
|
||||
- add Docker Compose profiles and Login Flow v2 service
|
||||
|
||||
### Fix
|
||||
|
||||
- replace assert with proper guard and invalidate scope cache after provisioning
|
||||
- disable NC rate limiting in dev/CI and add token endpoint diagnostics
|
||||
- address review feedback — security, caching, CI 429 retry
|
||||
- skip keycloak hook when profile inactive and update stale PRM test
|
||||
- address remaining PR #589 review findings
|
||||
- address PR #589 review findings
|
||||
- address PR review issues for Login Flow v2
|
||||
- address PR #589 review feedback (round 2)
|
||||
- **ci**: remove dev OIDC mount to fix HTTP 500 in single-user/multi-user-basic
|
||||
- **ci**: fix health check timeout and per-profile MCP server URL routing
|
||||
- **ci**: fix PHP gating, add multi-user-basic matrix entry, upload debug artifacts
|
||||
- address PR #589 review feedback for Login Flow v2
|
||||
- **ci**: fix integration test collection and skip Playwright in CI
|
||||
- **test**: fix 17 pre-existing unit test failures and add astrolabe CI build
|
||||
- **ci**: keep third_party mount, always build submodules in CI
|
||||
- **ci**: revert accidental third_party mount, use compose override for OIDC
|
||||
- **ci**: don't block integration matrix on unit-test failures
|
||||
|
||||
## nextcloud-mcp-server-0.57.94 (2026-03-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- handle pythonvCard4 dict-format fields and missing phone numbers (#601)
|
||||
|
||||
## nextcloud-mcp-server-0.57.93 (2026-03-03)
|
||||
|
||||
## nextcloud-mcp-server-0.57.92 (2026-03-02)
|
||||
|
||||
## nextcloud-mcp-server-0.57.91 (2026-03-02)
|
||||
|
||||
## nextcloud-mcp-server-0.57.90 (2026-03-01)
|
||||
|
||||
## nextcloud-mcp-server-0.57.89 (2026-03-01)
|
||||
|
||||
## nextcloud-mcp-server-0.57.88 (2026-03-01)
|
||||
|
||||
## nextcloud-mcp-server-0.57.87 (2026-03-01)
|
||||
|
||||
## nextcloud-mcp-server-0.57.86 (2026-02-26)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency icalendar to v7
|
||||
|
||||
## nextcloud-mcp-server-0.57.85 (2026-02-25)
|
||||
|
||||
## nextcloud-mcp-server-0.57.84 (2026-02-25)
|
||||
|
||||
## nextcloud-mcp-server-0.57.83 (2026-02-25)
|
||||
|
||||
## nextcloud-mcp-server-0.57.82 (2026-02-25)
|
||||
|
||||
## nextcloud-mcp-server-0.57.81 (2026-02-25)
|
||||
|
||||
## nextcloud-mcp-server-0.57.80 (2026-02-24)
|
||||
|
||||
## nextcloud-mcp-server-0.57.79 (2026-02-24)
|
||||
|
||||
## nextcloud-mcp-server-0.57.78 (2026-02-24)
|
||||
|
||||
## nextcloud-mcp-server-0.57.77 (2026-02-24)
|
||||
|
||||
## nextcloud-mcp-server-0.57.76 (2026-02-24)
|
||||
|
||||
## nextcloud-mcp-server-0.57.75 (2026-02-23)
|
||||
|
||||
## nextcloud-mcp-server-0.57.74 (2026-02-21)
|
||||
|
||||
## nextcloud-mcp-server-0.57.73 (2026-02-21)
|
||||
|
||||
### Fix
|
||||
|
||||
- address PR #574 fourth review round
|
||||
- address PR #574 third review round
|
||||
- address PR #574 second review round
|
||||
- address PR #574 review comments
|
||||
- wrap raw list returns in response models to produce single TextContent block
|
||||
|
||||
## nextcloud-mcp-server-0.57.72 (2026-02-20)
|
||||
|
||||
## nextcloud-mcp-server-0.57.71 (2026-02-20)
|
||||
|
||||
## nextcloud-mcp-server-0.57.70 (2026-02-20)
|
||||
|
||||
### Fix
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
dependencies:
|
||||
- name: qdrant
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
version: 1.16.3
|
||||
version: 1.17.0
|
||||
- name: ollama
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
version: 1.43.0
|
||||
digest: sha256:533eda3fdb4bd92cdafee49bd7b0428fc87d21b509032442c04ed645900e464a
|
||||
generated: "2026-02-16T11:16:41.257136832Z"
|
||||
version: 1.47.0
|
||||
digest: sha256:08d589dd1b3386e8e8a2ac2c03a2194218ab12ed9e02016e7b981e554385dd11
|
||||
generated: "2026-03-02T11:15:27.688786078Z"
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: nextcloud-mcp-server
|
||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||
type: application
|
||||
version: 0.57.70
|
||||
appVersion: "0.64.2"
|
||||
version: 0.58.3
|
||||
appVersion: "0.65.0"
|
||||
keywords:
|
||||
- nextcloud
|
||||
- mcp
|
||||
@@ -25,12 +25,21 @@ annotations:
|
||||
# Grafana dashboard support
|
||||
grafana_dashboard: "true"
|
||||
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:
|
||||
- name: qdrant
|
||||
version: "1.16.3"
|
||||
version: "1.17.0"
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
condition: qdrant.networkMode.deploySubchart
|
||||
- name: ollama
|
||||
version: "1.43.0"
|
||||
version: "1.47.0"
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
condition: ollama.enabled
|
||||
|
||||
@@ -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.
|
||||
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 }}
|
||||
|
||||
{{- if .Values.documentProcessing.enabled }}
|
||||
@@ -169,6 +191,12 @@ After migrating, remove the deprecated settings:
|
||||
================================================================================
|
||||
{{- 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:
|
||||
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
- 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 }}
|
||||
|
||||
{{/*
|
||||
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
|
||||
*/}}
|
||||
@@ -147,6 +158,8 @@ Checks new dataStorage.enabled OR legacy persistence configs
|
||||
true
|
||||
{{- else if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled -}}
|
||||
true
|
||||
{{- else if eq .Values.auth.mode "login-flow" -}}
|
||||
true
|
||||
{{- else if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled -}}
|
||||
true
|
||||
{{- else -}}
|
||||
|
||||
@@ -46,8 +46,10 @@ spec:
|
||||
args:
|
||||
- "--transport"
|
||||
- "{{ .Values.mcp.transport }}"
|
||||
{{- if eq .Values.auth.mode "oauth" }}
|
||||
{{- if or (eq .Values.auth.mode "oauth") (eq .Values.auth.mode "login-flow") }}
|
||||
- "--oauth"
|
||||
{{- end }}
|
||||
{{- if eq .Values.auth.mode "oauth" }}
|
||||
- "--oauth-token-type"
|
||||
- "{{ .Values.auth.oauth.tokenType }}"
|
||||
{{- end }}
|
||||
@@ -134,6 +136,21 @@ spec:
|
||||
name: {{ include "nextcloud-mcp-server.oauthSecretName" . }}
|
||||
key: {{ .Values.auth.oauth.clientSecretKey }}
|
||||
{{- 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 }}
|
||||
{{- if .Values.documentProcessing.enabled }}
|
||||
# Document processing
|
||||
@@ -282,7 +299,7 @@ spec:
|
||||
volumeMounts:
|
||||
- name: 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
|
||||
mountPath: /app/.oauth
|
||||
{{- end }}
|
||||
@@ -294,7 +311,7 @@ spec:
|
||||
volumes:
|
||||
- name: tmp
|
||||
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
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
||||
|
||||
@@ -16,6 +16,21 @@ spec:
|
||||
storage: {{ .Values.auth.oauth.persistence.size }}
|
||||
{{- 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) }}
|
||||
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
|
||||
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
|
||||
|
||||
@@ -45,3 +45,17 @@ data:
|
||||
{{ .Values.auth.oauth.clientSecretKey }}: {{ .Values.auth.oauth.clientSecret | b64enc | quote }}
|
||||
{{- 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: ""
|
||||
|
||||
# Authentication configuration
|
||||
# Choose one mode: "basic", "multi-user-basic", or "oauth"
|
||||
# Choose one mode: "basic", "multi-user-basic", "oauth", or "login-flow"
|
||||
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)
|
||||
# multi-user-basic: Multi-user with BasicAuth pass-through (credentials in request headers)
|
||||
# oauth: Uses OAuth2/OIDC (experimental, requires patches)
|
||||
# login-flow: Multi-user via Nextcloud Login Flow v2 (experimental, ADR-022)
|
||||
mode: basic
|
||||
|
||||
# Basic authentication settings (single-user mode)
|
||||
@@ -139,6 +140,21 @@ auth:
|
||||
# Use existing PVC
|
||||
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
|
||||
# Persistent volume for /app/data directory
|
||||
# Used for: token databases, qdrant persistent storage, and any app data
|
||||
@@ -147,6 +163,7 @@ dataStorage:
|
||||
# Enable persistent storage for /app/data
|
||||
# Set to true when using:
|
||||
# - 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)
|
||||
# - Any feature requiring persistent app data
|
||||
# Set to false for basic auth without persistence (uses emptyDir)
|
||||
|
||||
+60
-7
@@ -19,24 +19,25 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: docker.io/library/redis:alpine@sha256:fd83658b0e40e2164617d262f13c02ca9ee9e1e6b276fd2fa06617e09bd5c780
|
||||
image: docker.io/library/redis:alpine@sha256:2afba59292f25f5d1af200496db41bea2c6c816b059f57ae74703a50a03a27d0
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.6@sha256:0e1084cc59df77bec7d6bb29d9ac6939da8372512237a9c51f74ff0a970524f2
|
||||
image: ${NEXTCLOUD_IMAGE:-docker.io/library/nextcloud:32.0.6@sha256:5c4e09f72f096cd68379a8ae69f71e61d13da5a07430fc4a17c702a14e6a4267}
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8080:80
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
- keycloak
|
||||
volumes:
|
||||
- nextcloud:/var/www/html
|
||||
- ./app-hooks:/docker-entrypoint-hooks.d:ro
|
||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
||||
# The post-installation hook will register /opt/apps as an additional app directory
|
||||
#- ./third_party:/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:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
- NEXTCLOUD_ADMIN_USER=admin
|
||||
@@ -46,6 +47,7 @@ services:
|
||||
- MYSQL_USER=nextcloud
|
||||
- MYSQL_HOST=db
|
||||
- REDIS_HOST=redis
|
||||
- MCP_SERVER_URL=${MCP_SERVER_URL:-}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"]
|
||||
interval: 10s
|
||||
@@ -60,7 +62,7 @@ services:
|
||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
unstructured:
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:3b9280eb9aa53d76a8f4a2465400ae747774d4bfd71dd73d603353b0b55c435d
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:ba6cb073af079c498e9466a5a9152ba4b6c9cad12efeeaf053ba383023d5db08
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8002:8000
|
||||
@@ -123,6 +125,8 @@ services:
|
||||
# Tune these based on your embedding model and content type
|
||||
# - DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default: 512)
|
||||
# - DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words (default: 50, recommended: 10-20% of chunk size)
|
||||
profiles:
|
||||
- single-user
|
||||
|
||||
mcp-multi-user-basic:
|
||||
build: .
|
||||
@@ -142,7 +146,9 @@ services:
|
||||
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||
|
||||
# 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
|
||||
|
||||
- ENABLE_SEMANTIC_SEARCH=true
|
||||
@@ -157,6 +163,8 @@ services:
|
||||
# NO admin credentials - credentials come from client Authorization header
|
||||
volumes:
|
||||
- multi-user-basic-data:/app/data
|
||||
profiles:
|
||||
- multi-user-basic
|
||||
|
||||
mcp-oauth:
|
||||
build: .
|
||||
@@ -179,7 +187,7 @@ services:
|
||||
|
||||
# Refresh token storage (ADR-002 Tier 1)
|
||||
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||
- TOKEN_ENCRYPTION_KEY=Qh60VwZQsM7CLtSMunzC0gIGPBT948S6VSawUkODtvU=
|
||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
|
||||
# ADR-005: Multi-audience mode (default - ENABLE_TOKEN_EXCHANGE=false)
|
||||
@@ -205,9 +213,11 @@ services:
|
||||
volumes:
|
||||
- oauth-client-storage:/app/.oauth
|
||||
- oauth-tokens:/app/data
|
||||
profiles:
|
||||
- oauth
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.5.3@sha256:5a236ae4dd8ece77490115bace15a11a4d15e9cbcf58a490b95a7da2cd71d32a
|
||||
image: quay.io/keycloak/keycloak:26.5.4@sha256:ae8efb0d218d8921334b03a2dbee7069a0b868240691c50a3ffc9f42fabba8b4
|
||||
command:
|
||||
- "start-dev"
|
||||
- "--import-realm"
|
||||
@@ -227,6 +237,8 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
profiles:
|
||||
- keycloak
|
||||
|
||||
mcp-keycloak:
|
||||
build: .
|
||||
@@ -272,6 +284,45 @@ services:
|
||||
volumes:
|
||||
- keycloak-tokens:/app/data
|
||||
- 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)
|
||||
# Test with: docker compose --profile smithery up smithery
|
||||
@@ -318,6 +369,8 @@ volumes:
|
||||
oauth-tokens:
|
||||
keycloak-tokens:
|
||||
keycloak-oauth-storage:
|
||||
login-flow-data:
|
||||
login-flow-oauth-storage:
|
||||
qdrant-data:
|
||||
mcp-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
|
||||
"""
|
||||
|
||||
from nextcloud_mcp_server.api.access import (
|
||||
get_user_access,
|
||||
list_supported_scopes,
|
||||
update_user_scopes,
|
||||
)
|
||||
|
||||
# Re-export all public functions for backward compatibility
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
__version__,
|
||||
@@ -44,6 +50,10 @@ from nextcloud_mcp_server.api.webhooks import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Access endpoints (from access.py)
|
||||
"get_user_access",
|
||||
"update_user_scopes",
|
||||
"list_supported_scopes",
|
||||
# Version
|
||||
"__version__",
|
||||
# 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),
|
||||
}
|
||||
)
|
||||
@@ -235,24 +235,26 @@ async def get_server_status(request: Request) -> JSONResponse:
|
||||
if mode == AuthMode.MULTI_USER_BASIC:
|
||||
response_data["supports_app_passwords"] = settings.enable_offline_access
|
||||
|
||||
# Include OIDC configuration if OAuth is available
|
||||
# This includes OAuth mode AND hybrid mode (multi_user_basic + offline_access)
|
||||
# Astrolabe needs OIDC config to discover IdP for OAuth flow in hybrid mode
|
||||
oauth_provisioning_available = auth_mode == "oauth" or (
|
||||
mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access
|
||||
)
|
||||
if oauth_provisioning_available:
|
||||
# Provide IdP discovery information for NC PHP app
|
||||
oidc_config = {}
|
||||
# Include OIDC configuration for client discovery (e.g. Astrolabe PHP app).
|
||||
# Always attempt to provide oidc.discovery_url so clients can discover the
|
||||
# IdP regardless of the current auth mode. This enables smoother transitions
|
||||
# between auth modes and lets Astrolabe pre-discover OIDC endpoints.
|
||||
oidc_config: dict[str, str] = {}
|
||||
|
||||
if settings.oidc_discovery_url:
|
||||
oidc_config["discovery_url"] = settings.oidc_discovery_url
|
||||
if settings.oidc_discovery_url:
|
||||
# Explicit OIDC_DISCOVERY_URL takes precedence
|
||||
oidc_config["discovery_url"] = settings.oidc_discovery_url
|
||||
elif settings.nextcloud_host:
|
||||
# Auto-derive from NEXTCLOUD_HOST — Nextcloud exposes OIDC discovery
|
||||
# at the standard well-known path when user_oidc is enabled
|
||||
host = settings.nextcloud_host.rstrip("/")
|
||||
oidc_config["discovery_url"] = f"{host}/.well-known/openid-configuration"
|
||||
|
||||
if settings.oidc_issuer:
|
||||
oidc_config["issuer"] = settings.oidc_issuer
|
||||
if settings.oidc_issuer:
|
||||
oidc_config["issuer"] = settings.oidc_issuer
|
||||
|
||||
if oidc_config:
|
||||
response_data["oidc"] = oidc_config
|
||||
if oidc_config:
|
||||
response_data["oidc"] = oidc_config
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
|
||||
@@ -288,10 +288,23 @@ async def provision_app_password(request: Request) -> JSONResponse:
|
||||
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
|
||||
try:
|
||||
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)
|
||||
logger.info(f"Provisioned app password for user: {username}")
|
||||
@@ -300,6 +313,7 @@ async def provision_app_password(request: Request) -> JSONResponse:
|
||||
{
|
||||
"success": True,
|
||||
"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_pdf_preview,
|
||||
get_server_status,
|
||||
get_user_access,
|
||||
get_user_session,
|
||||
get_vector_sync_status,
|
||||
list_supported_scopes,
|
||||
list_webhooks,
|
||||
provision_app_password,
|
||||
revoke_user_access,
|
||||
unified_search,
|
||||
update_user_scopes,
|
||||
vector_search,
|
||||
)
|
||||
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.keycloak_oauth import KeycloakOAuthClient
|
||||
from nextcloud_mcp_server.auth.oauth_routes import (
|
||||
oauth_as_metadata,
|
||||
oauth_authorize,
|
||||
oauth_authorize_nextcloud,
|
||||
oauth_callback,
|
||||
oauth_callback_nextcloud,
|
||||
oauth_register_proxy,
|
||||
oauth_token_endpoint,
|
||||
)
|
||||
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.unified_verifier import UnifiedTokenVerifier
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
@@ -123,6 +129,7 @@ from nextcloud_mcp_server.server import (
|
||||
configure_tables_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.vector import processor_task, scanner_task
|
||||
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)"
|
||||
)
|
||||
|
||||
# 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)
|
||||
if oauth_enabled:
|
||||
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()
|
||||
|
||||
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
|
||||
async def starlette_lifespan(app: Starlette):
|
||||
# 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
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
async with _mcp_session_with_login_flow():
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -1935,8 +1983,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
)
|
||||
|
||||
# Run MCP session manager and yield
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
async with _mcp_session_with_login_flow():
|
||||
try:
|
||||
yield
|
||||
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."
|
||||
)
|
||||
# Just run MCP session manager without vector sync
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
async with _mcp_session_with_login_flow():
|
||||
yield
|
||||
|
||||
else:
|
||||
@@ -1976,8 +2022,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
logger.warning(
|
||||
"Vector sync enabled but TOKEN_ENCRYPTION_KEY not set"
|
||||
)
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
async with _mcp_session_with_login_flow():
|
||||
yield
|
||||
|
||||
# Health check endpoints for Kubernetes probes
|
||||
@@ -2208,10 +2253,27 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
routes.append(
|
||||
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(
|
||||
"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}/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/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).
|
||||
This is used as the audience in access tokens via the resource parameter (RFC 8707).
|
||||
The introspection controller matches this URL to the MCP server's client via resource_url field.
|
||||
"""
|
||||
# Use PUBLIC_ISSUER_URL for authorization server since external clients
|
||||
# (like Claude) need the publicly accessible URL, not internal Docker URLs
|
||||
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
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)
|
||||
# Use the MCP server's public 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
|
||||
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(
|
||||
{
|
||||
"resource": f"{mcp_server_url}/mcp", # RFC 9728: must be a URL
|
||||
"scopes_supported": supported_scopes,
|
||||
"authorization_servers": [public_issuer_url],
|
||||
"authorization_servers": [mcp_server_url],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"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)
|
||||
if oauth_enabled:
|
||||
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
|
||||
# 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"
|
||||
)
|
||||
|
||||
# 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
|
||||
@app.middleware("http")
|
||||
async def log_auth_headers(request, call_next):
|
||||
|
||||
@@ -83,6 +83,7 @@ async def register_client(
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str | None = "Bearer",
|
||||
resource_url: str | None = None,
|
||||
max_retries: int = 3,
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
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).
|
||||
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
|
||||
max_retries: Maximum number of retries for 429 responses (default: 3)
|
||||
|
||||
Returns:
|
||||
ClientInfo with registration details
|
||||
@@ -135,57 +137,91 @@ async def register_client(
|
||||
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
||||
|
||||
async with nextcloud_httpx_client(timeout=30.0) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
registration_endpoint,
|
||||
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"
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = await client.post(
|
||||
registration_endpoint,
|
||||
json=client_metadata,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
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"),
|
||||
)
|
||||
if response.status_code == 429:
|
||||
# Rate limited - retry with exponential backoff
|
||||
if attempt < max_retries - 1:
|
||||
retry_after = int(response.headers.get("Retry-After", 2))
|
||||
wait_time = min(retry_after, 2**attempt)
|
||||
logger.warning(
|
||||
f"Rate limited (429) registering client, "
|
||||
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
|
||||
)
|
||||
await anyio.sleep(wait_time)
|
||||
continue
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to register client after {max_retries} attempts: Rate limited (429)"
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
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}")
|
||||
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(
|
||||
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(
|
||||
|
||||
@@ -142,8 +142,8 @@ class ClientRegistry:
|
||||
if not self._validate_redirect_uri(client, redirect_uri):
|
||||
return False, f"Invalid redirect_uri for client {client_id}"
|
||||
|
||||
# Validate scopes if provided
|
||||
if scopes:
|
||||
# Validate scopes if provided (wildcard "*" allows all scopes)
|
||||
if scopes and "*" not in client.allowed_scopes:
|
||||
invalid_scopes = set(scopes) - set(client.allowed_scopes)
|
||||
if 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
|
||||
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]:
|
||||
"""
|
||||
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:
|
||||
|
||||
Flow 1: Client Authentication - MCP client authenticates directly to IdP
|
||||
- Client requests: Nextcloud MCP resource scopes (notes:*, calendar:*, etc.)
|
||||
- Token audience (aud): "mcp-server"
|
||||
- No server interception - IdP redirects directly to client
|
||||
- Client receives resource-scoped token for MCP session
|
||||
Flow 1: Client Authentication (AS Proxy mode, ADR-023)
|
||||
- MCP server acts as its own OAuth Authorization Server
|
||||
- Proxies DCR, authorization, and token endpoints to Nextcloud
|
||||
- Uses MCP server's own client_id so tokens have correct audience
|
||||
- Client exchanges proxy authorization code for Nextcloud token
|
||||
|
||||
Flow 2: Resource Provisioning - MCP server gets delegated Nextcloud access
|
||||
- Triggered by user calling provision_nextcloud_access tool
|
||||
@@ -25,6 +25,8 @@ import os
|
||||
import secrets
|
||||
import time
|
||||
from base64 import urlsafe_b64encode
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
@@ -41,13 +43,113 @@ from ..http import nextcloud_httpx_client
|
||||
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:
|
||||
"""
|
||||
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 server validates the client is authorized but does NOT intercept the callback.
|
||||
IdP redirects directly back to the client's redirect_uri.
|
||||
The MCP server acts as its own OAuth Authorization Server, proxying
|
||||
the authorization to Nextcloud. This ensures tokens have the correct
|
||||
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:
|
||||
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)
|
||||
|
||||
Returns:
|
||||
302 redirect to IdP authorization endpoint
|
||||
302 redirect to Nextcloud authorization endpoint
|
||||
"""
|
||||
# Clean up expired entries periodically
|
||||
_cleanup_expired_proxy_codes()
|
||||
|
||||
# Extract parameters
|
||||
response_type = request.query_params.get("response_type")
|
||||
client_id = request.query_params.get("client_id")
|
||||
@@ -125,7 +230,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate client_id (required for Flow 1)
|
||||
# Validate client_id (required)
|
||||
if not client_id:
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -166,102 +271,89 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
oauth_client = oauth_ctx["oauth_client"]
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Flow 1: Client authenticates directly to IdP WITHOUT server interception
|
||||
# CRITICAL: This is a direct pass-through to IdP
|
||||
# The IdP will redirect directly back to the client's callback
|
||||
# The MCP server does NOT see the IdP authorization code!
|
||||
# AS Proxy: Store client's params and redirect to Nextcloud with MCP server's credentials
|
||||
# PKCE is validated locally when the client exchanges the proxy_code at /oauth/token.
|
||||
# We do NOT forward PKCE to Nextcloud — the MCP server is a confidential client.
|
||||
server_state = secrets.token_urlsafe(32)
|
||||
|
||||
logger.info(
|
||||
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
|
||||
requested_scope = request.query_params.get("scope", "")
|
||||
default_scopes = "openid profile email"
|
||||
resource_scopes = oauth_config.get("scopes", "")
|
||||
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
|
||||
idp_state = state
|
||||
# Store session for callback
|
||||
_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)
|
||||
idp_client_id = client_id
|
||||
# Use MCP server's own client_id with Nextcloud
|
||||
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(f" Client ID: {client_id}")
|
||||
logger.info(f" Client will receive IdP code directly at: {callback_uri}")
|
||||
logger.info(f" Scopes: {scopes} (resource access for MCP tools)")
|
||||
logger.info("AS Proxy: Intermediary authorization flow")
|
||||
logger.info(f" Client: {client_id}")
|
||||
logger.info(f" MCP server client_id: {mcp_server_client_id}")
|
||||
logger.info(f" Server callback: {callback_uri}")
|
||||
logger.info(f" Scopes: {scopes}")
|
||||
|
||||
# Get authorization endpoint from OAuth client
|
||||
if oauth_client:
|
||||
# External IdP mode (Keycloak) - use oauth_client
|
||||
auth_url = await oauth_client.get_authorization_url(
|
||||
state=idp_state,
|
||||
code_challenge="", # Server doesn't use PKCE with IdP
|
||||
# Discover Nextcloud authorization endpoint
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if not discovery_url:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth discovery URL not configured",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
logger.info(f"Redirecting to external IdP: {auth_url.split('?')[0]}")
|
||||
else:
|
||||
# Integrated mode (Nextcloud OIDC) - build URL directly
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if not discovery_url:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth discovery URL not configured",
|
||||
},
|
||||
status_code=500,
|
||||
|
||||
discovery = await _get_cached_discovery(discovery_url)
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
|
||||
# Replace internal Docker hostname with public URL for browser access
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||
auth_parsed = parse_url(authorization_endpoint)
|
||||
|
||||
if auth_parsed.hostname == internal_parsed.hostname:
|
||||
public_parsed = parse_url(public_issuer)
|
||||
authorization_endpoint = (
|
||||
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
|
||||
)
|
||||
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
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
# Redirect to Nextcloud with MCP server's own client_id (no PKCE — confidential client)
|
||||
idp_params = {
|
||||
"client_id": mcp_server_client_id,
|
||||
"redirect_uri": callback_uri,
|
||||
"response_type": "code",
|
||||
"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
|
||||
# The discovery endpoint returns http://app/apps/oidc/authorize (internal)
|
||||
# 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]}")
|
||||
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)
|
||||
|
||||
@@ -355,11 +447,8 @@ async def oauth_authorize_nextcloud(
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
discovery = await _get_cached_discovery(discovery_url)
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
|
||||
# Fix internal hostname for browser access
|
||||
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"
|
||||
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
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"]
|
||||
|
||||
# Build token exchange params
|
||||
token_params = {
|
||||
@@ -599,6 +694,11 @@ async def oauth_callback(request: Request):
|
||||
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
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
if not oauth_ctx:
|
||||
@@ -641,3 +741,580 @@ async def oauth_callback(request: Request):
|
||||
},
|
||||
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."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from functools import wraps
|
||||
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.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
|
||||
|
||||
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):
|
||||
"""Raised when a request lacks required scopes."""
|
||||
@@ -120,13 +129,61 @@ def require_scopes(*required_scopes: str):
|
||||
)
|
||||
|
||||
if access_token is None:
|
||||
# Not in OAuth mode (BasicAuth or no auth)
|
||||
# In BasicAuth mode, all operations are allowed
|
||||
# No OAuth token — BasicAuth mode bypasses scope checks
|
||||
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)
|
||||
|
||||
# ── 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
|
||||
token_scopes = set(access_token.scopes or [])
|
||||
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(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
|
||||
|
||||
# ── 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:
|
||||
"""
|
||||
|
||||
@@ -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"
|
||||
@@ -274,6 +274,7 @@ class ContactsClient(BaseNextcloudClient):
|
||||
"nickname": contact.nickname,
|
||||
"birthday": contact.bday,
|
||||
"email": contact.email,
|
||||
"tel": contact.tel,
|
||||
},
|
||||
"addressdata": addressdata,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import socket
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
|
||||
class DeploymentMode(Enum):
|
||||
@@ -169,31 +169,32 @@ class Settings:
|
||||
# Optional: If not set, mode is auto-detected from other settings
|
||||
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
|
||||
# oauth_token_exchange, smithery
|
||||
deployment_mode: Optional[str] = None
|
||||
deployment_mode: str | None = None
|
||||
|
||||
# OAuth/OIDC settings
|
||||
oidc_discovery_url: Optional[str] = None
|
||||
oidc_client_id: Optional[str] = None
|
||||
oidc_client_secret: Optional[str] = None
|
||||
oidc_issuer: Optional[str] = None
|
||||
oidc_discovery_url: str | None = None
|
||||
oidc_client_id: str | None = None
|
||||
oidc_client_secret: str | None = None
|
||||
oidc_issuer: str | None = None
|
||||
|
||||
# Nextcloud settings
|
||||
nextcloud_host: Optional[str] = None
|
||||
nextcloud_username: Optional[str] = None
|
||||
nextcloud_password: Optional[str] = None
|
||||
nextcloud_host: str | None = None
|
||||
nextcloud_username: str | None = None
|
||||
nextcloud_password: str | None = None
|
||||
nextcloud_app_password: str | None = None # Preferred over nextcloud_password
|
||||
|
||||
# Nextcloud SSL/TLS settings
|
||||
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)
|
||||
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
|
||||
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
|
||||
nextcloud_mcp_server_url: str | None = None # MCP server URL (used as audience)
|
||||
nextcloud_resource_uri: str | None = None # Nextcloud resource identifier
|
||||
|
||||
# Token verification endpoints
|
||||
jwks_uri: Optional[str] = None
|
||||
introspection_uri: Optional[str] = None
|
||||
userinfo_uri: Optional[str] = None
|
||||
jwks_uri: str | None = None
|
||||
introspection_uri: str | None = None
|
||||
userinfo_uri: str | None = None
|
||||
|
||||
# Progressive Consent settings (always enabled - no flag needed)
|
||||
enable_token_exchange: bool = False
|
||||
@@ -204,6 +205,9 @@ class Settings:
|
||||
# and passes them through to Nextcloud APIs (no storage, stateless)
|
||||
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_ttl: int = 300 # seconds (5 minutes default)
|
||||
|
||||
@@ -214,8 +218,8 @@ class Settings:
|
||||
# TOKEN_STORAGE_DB: Path to SQLite database for persistent storage.
|
||||
# Used for webhook tracking (all modes) and OAuth token storage.
|
||||
# Defaults to /tmp/tokens.db
|
||||
token_encryption_key: Optional[str] = None
|
||||
token_storage_db: Optional[str] = None
|
||||
token_encryption_key: str | None = None
|
||||
token_storage_db: str | None = None
|
||||
|
||||
# Vector sync settings (ADR-007)
|
||||
vector_sync_enabled: bool = False
|
||||
@@ -225,19 +229,19 @@ class Settings:
|
||||
vector_sync_user_poll_interval: int = 60 # seconds - OAuth mode user discovery
|
||||
|
||||
# Qdrant settings (mutually exclusive modes)
|
||||
qdrant_url: Optional[str] = None # Network mode: http://qdrant:6333
|
||||
qdrant_location: Optional[str] = None # Local mode: :memory: or /path/to/data
|
||||
qdrant_api_key: Optional[str] = None
|
||||
qdrant_url: str | None = None # Network mode: http://qdrant:6333
|
||||
qdrant_location: str | None = None # Local mode: :memory: or /path/to/data
|
||||
qdrant_api_key: str | None = None
|
||||
qdrant_collection: str = "nextcloud_content"
|
||||
|
||||
# Ollama settings (for embeddings)
|
||||
ollama_base_url: Optional[str] = None
|
||||
ollama_base_url: str | None = None
|
||||
ollama_embedding_model: str = "nomic-embed-text"
|
||||
ollama_verify_ssl: bool = True
|
||||
|
||||
# OpenAI settings (for embeddings)
|
||||
openai_api_key: Optional[str] = None
|
||||
openai_base_url: Optional[str] = None
|
||||
openai_api_key: str | None = None
|
||||
openai_base_url: str | None = None
|
||||
openai_embedding_model: str = "text-embedding-3-small"
|
||||
|
||||
# Document chunking settings (for vector embeddings)
|
||||
@@ -247,7 +251,7 @@ class Settings:
|
||||
# Observability settings
|
||||
metrics_enabled: bool = True
|
||||
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_service_name: str = "nextcloud-mcp-server"
|
||||
otel_traces_sampler: str = "always_on"
|
||||
@@ -523,6 +527,7 @@ def get_settings() -> Settings:
|
||||
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
||||
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
|
||||
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
|
||||
nextcloud_app_password=os.getenv("NEXTCLOUD_APP_PASSWORD"),
|
||||
# Nextcloud SSL/TLS settings
|
||||
nextcloud_verify_ssl=(
|
||||
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
|
||||
@@ -544,6 +549,8 @@ def get_settings() -> Settings:
|
||||
enable_multi_user_basic_auth=(
|
||||
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_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
|
||||
# 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_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.config import (
|
||||
DeploymentMode,
|
||||
@@ -78,6 +80,11 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
|
||||
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)
|
||||
if hasattr(lifespan_ctx, "client"):
|
||||
return lifespan_ctx.client
|
||||
@@ -245,3 +252,51 @@ def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient:
|
||||
username=username,
|
||||
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",
|
||||
}
|
||||
)
|
||||
@@ -34,6 +34,12 @@ class CalendarEventSummary(BaseModel):
|
||||
status: Optional[str] = Field(
|
||||
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
|
||||
)
|
||||
calendar_name: Optional[str] = Field(
|
||||
None, description="Calendar containing this event"
|
||||
)
|
||||
calendar_display_name: Optional[str] = Field(
|
||||
None, description="Display name of calendar containing this event"
|
||||
)
|
||||
|
||||
|
||||
class CalendarEvent(CalendarEventSummary):
|
||||
|
||||
@@ -261,6 +261,20 @@ class CreateLabelResponse(BaseResponse):
|
||||
color: str = Field(description="The created label color")
|
||||
|
||||
|
||||
class ListCardsResponse(BaseResponse):
|
||||
"""Response model for listing deck cards."""
|
||||
|
||||
cards: list[DeckCard] = Field(description="List of deck cards")
|
||||
total: int = Field(description="Total number of cards")
|
||||
|
||||
|
||||
class ListLabelsResponse(BaseResponse):
|
||||
"""Response model for listing deck labels."""
|
||||
|
||||
labels: list[DeckLabel] = Field(description="List of deck labels")
|
||||
total: int = Field(description="Total number of labels")
|
||||
|
||||
|
||||
class LabelOperationResponse(StatusResponse):
|
||||
"""Response model for label operations like update/delete."""
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -9,15 +9,46 @@ from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.calendar import (
|
||||
Calendar,
|
||||
CalendarEventSummary,
|
||||
ListCalendarsResponse,
|
||||
ListEventsResponse,
|
||||
ListTodosResponse,
|
||||
Todo,
|
||||
UpcomingEventsResponse,
|
||||
)
|
||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _event_dict_to_summary(event: dict) -> CalendarEventSummary:
|
||||
"""Convert a raw event dict from the calendar client to a CalendarEventSummary."""
|
||||
raw_categories = event.get("categories", [])
|
||||
if isinstance(raw_categories, str):
|
||||
categories = [c.strip() for c in raw_categories.split(",") if c.strip()]
|
||||
else:
|
||||
categories = raw_categories
|
||||
|
||||
start = event.get("start_datetime", "")
|
||||
if not start:
|
||||
logger.debug("Event %s has no start_datetime", event.get("uid", "unknown"))
|
||||
|
||||
return CalendarEventSummary(
|
||||
uid=event.get("uid", ""),
|
||||
summary=event.get("title", ""),
|
||||
start=start,
|
||||
end=event.get("end_datetime"),
|
||||
all_day=event.get("all_day", False),
|
||||
location=event.get("location") or None,
|
||||
description=event.get("description") or None,
|
||||
categories=categories,
|
||||
status=event.get("status"),
|
||||
calendar_name=event.get("calendar_name"),
|
||||
calendar_display_name=event.get("calendar_display_name")
|
||||
or event.get("calendar_name"),
|
||||
)
|
||||
|
||||
|
||||
def configure_calendar_tools(mcp: FastMCP):
|
||||
# Calendar tools
|
||||
@mcp.tool(
|
||||
@@ -204,7 +235,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
end_datetime=end_datetime,
|
||||
filters=filters if filters else None,
|
||||
)
|
||||
return events[:limit]
|
||||
events = events[:limit]
|
||||
else:
|
||||
# Search in specific calendar
|
||||
events = await client.calendar.get_calendar_events(
|
||||
@@ -214,11 +245,25 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Enrich events with calendar context for per-event mapping.
|
||||
# Note: calendar_display_name is not available here without an
|
||||
# extra list_calendars() call; the response-level calendar_name
|
||||
# already identifies the calendar for single-calendar queries.
|
||||
for event in events:
|
||||
event["calendar_name"] = calendar_name
|
||||
|
||||
# Apply filters if provided
|
||||
if filters:
|
||||
events = client.calendar._apply_event_filters(events, filters)
|
||||
|
||||
return events
|
||||
summaries = [_event_dict_to_summary(e) for e in events]
|
||||
return ListEventsResponse(
|
||||
events=summaries,
|
||||
calendar_name=None if search_all_calendars else calendar_name,
|
||||
start_date=start_date or None,
|
||||
end_date=end_date or None,
|
||||
total_found=len(summaries),
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Calendar Event",
|
||||
@@ -420,12 +465,15 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
if calendar_name:
|
||||
# Get events from specific calendar
|
||||
return await client.calendar.get_calendar_events(
|
||||
events = await client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=now,
|
||||
end_datetime=end_datetime,
|
||||
limit=limit,
|
||||
)
|
||||
# calendar_display_name not available without extra API call
|
||||
for event in events:
|
||||
event["calendar_name"] = calendar_name
|
||||
else:
|
||||
# Get events from all calendars
|
||||
all_calendars = await client.calendar.list_calendars()
|
||||
@@ -433,17 +481,16 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
for calendar in all_calendars:
|
||||
try:
|
||||
events = await client.calendar.get_calendar_events(
|
||||
cal_events = await client.calendar.get_calendar_events(
|
||||
calendar_name=calendar["name"],
|
||||
start_datetime=now,
|
||||
end_datetime=end_datetime,
|
||||
limit=limit,
|
||||
)
|
||||
# Add calendar info to each event
|
||||
for event in events:
|
||||
for event in cal_events:
|
||||
event["calendar_name"] = calendar["name"]
|
||||
event["calendar_display_name"] = calendar["display_name"]
|
||||
all_events.extend(events)
|
||||
all_events.extend(cal_events)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error getting events from calendar {calendar['name']}: {e}"
|
||||
@@ -452,7 +499,14 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
# Sort by start time and limit
|
||||
all_events.sort(key=lambda x: x.get("start_datetime", ""))
|
||||
return all_events[:limit]
|
||||
events = all_events[:limit]
|
||||
|
||||
summaries = [_event_dict_to_summary(e) for e in events]
|
||||
return UpcomingEventsResponse(
|
||||
events=summaries,
|
||||
days_ahead=days_ahead,
|
||||
calendar_name=calendar_name or None,
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="Find Availability",
|
||||
|
||||
@@ -1,15 +1,95 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.contacts import (
|
||||
AddressBook,
|
||||
Contact,
|
||||
ContactField,
|
||||
ListAddressBooksResponse,
|
||||
ListContactsResponse,
|
||||
)
|
||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_vcard_fields(
|
||||
raw_values: str | dict | list | None, field_type: str
|
||||
) -> list[ContactField]:
|
||||
"""Parse polymorphic vCard field data into a list of ContactField.
|
||||
|
||||
pythonvCard4 returns field values in several shapes:
|
||||
- ``str`` – plain value, e.g. ``"alice@example.com"``
|
||||
- ``dict`` – ``{'value': '...', 'type': ['HOME', 'PREF']}``
|
||||
- ``list`` – a list whose items are any of the above
|
||||
|
||||
The ``PREF`` type parameter is treated as a *preferred* flag rather than a
|
||||
label. All other type values are lowercased and joined with ``", "``.
|
||||
"""
|
||||
if raw_values is None:
|
||||
return []
|
||||
|
||||
items: list[str | dict] = (
|
||||
raw_values if isinstance(raw_values, list) else [raw_values]
|
||||
)
|
||||
|
||||
fields: list[ContactField] = []
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
value = str(item.get("value", ""))
|
||||
if not value:
|
||||
continue
|
||||
raw_types: list[str] = item.get("type") or []
|
||||
preferred = any(t.upper() == "PREF" for t in raw_types)
|
||||
labels = [t.lower() for t in raw_types if t.upper() != "PREF"]
|
||||
fields.append(
|
||||
ContactField(
|
||||
type=field_type,
|
||||
value=value,
|
||||
label=", ".join(labels) if labels else None,
|
||||
preferred=preferred,
|
||||
)
|
||||
)
|
||||
elif isinstance(item, str) and item:
|
||||
fields.append(ContactField(type=field_type, value=item))
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
def _raw_contact_to_model(raw: dict) -> Contact:
|
||||
"""Convert a raw contact dict from the contacts client to a Contact model.
|
||||
|
||||
Maps fullname, nickname, birthday, email, and tel fields.
|
||||
Email/tel values may be plain strings, dicts with ``value``/``type`` keys,
|
||||
or lists of either – see :func:`_parse_vcard_fields`.
|
||||
"""
|
||||
contact_info = raw.get("contact", {})
|
||||
|
||||
emails = _parse_vcard_fields(contact_info.get("email"), "email")
|
||||
phones = _parse_vcard_fields(contact_info.get("tel"), "phone")
|
||||
|
||||
# Nickname goes into custom_fields (no dedicated model field)
|
||||
custom_fields: dict[str, Any] = {}
|
||||
nickname = contact_info.get("nickname")
|
||||
if nickname:
|
||||
custom_fields["nickname"] = nickname
|
||||
|
||||
return Contact(
|
||||
uid=raw["vcard_id"],
|
||||
fn=contact_info.get("fullname", ""),
|
||||
etag=raw.get("getetag"),
|
||||
birthday=contact_info.get("birthday"),
|
||||
emails=emails,
|
||||
phones=phones,
|
||||
custom_fields=custom_fields,
|
||||
)
|
||||
|
||||
|
||||
def configure_contacts_tools(mcp: FastMCP):
|
||||
# Contacts tools
|
||||
@mcp.tool(
|
||||
@@ -18,10 +98,23 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("contacts:read")
|
||||
@instrument_tool
|
||||
async def nc_contacts_list_addressbooks(ctx: Context):
|
||||
async def nc_contacts_list_addressbooks(ctx: Context) -> ListAddressBooksResponse:
|
||||
"""List all addressbooks for the user."""
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.list_addressbooks()
|
||||
addressbooks_data = await client.contacts.list_addressbooks()
|
||||
addressbooks = [
|
||||
AddressBook(
|
||||
# ab["name"] is a short slug like "contacts", not a full CardDAV URI;
|
||||
# all tools use it as a path segment: f"{carddav_path}/{name}/"
|
||||
uri=ab["name"],
|
||||
displayname=ab.get("display_name", ab["name"]),
|
||||
ctag=ab.get("getctag"),
|
||||
)
|
||||
for ab in addressbooks_data
|
||||
]
|
||||
return ListAddressBooksResponse(
|
||||
addressbooks=addressbooks, total_count=len(addressbooks)
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="List Contacts",
|
||||
@@ -29,10 +122,22 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("contacts:read")
|
||||
@instrument_tool
|
||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
||||
"""List all contacts in the specified addressbook."""
|
||||
async def nc_contacts_list_contacts(
|
||||
ctx: Context, *, addressbook: str
|
||||
) -> ListContactsResponse:
|
||||
"""List all contacts in the specified addressbook.
|
||||
|
||||
Args:
|
||||
addressbook: The URI slug of the addressbook (e.g. "contacts"),
|
||||
not the display name. Use nc_contacts_list_addressbooks to
|
||||
find available URI slugs.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||
contacts_data = await client.contacts.list_contacts(addressbook=addressbook)
|
||||
contacts = [_raw_contact_to_model(c) for c in contacts_data]
|
||||
return ListContactsResponse(
|
||||
contacts=contacts, addressbook=addressbook, total_count=len(contacts)
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="Create Address Book",
|
||||
@@ -79,7 +184,9 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
"""Create a new contact.
|
||||
|
||||
Args:
|
||||
addressbook: The name of the addressbook to create the contact in.
|
||||
addressbook: The URI slug of the addressbook (e.g. "contacts"),
|
||||
not the display name. Use nc_contacts_list_addressbooks to
|
||||
find available URI slugs.
|
||||
uid: The unique ID for the contact.
|
||||
contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}.
|
||||
"""
|
||||
@@ -97,7 +204,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
@require_scopes("contacts:write")
|
||||
@instrument_tool
|
||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||
"""Delete a contact."""
|
||||
"""Delete a contact.
|
||||
|
||||
Args:
|
||||
addressbook: The URI slug of the addressbook (e.g. "contacts"),
|
||||
not the display name. Use nc_contacts_list_addressbooks to
|
||||
find available URI slugs.
|
||||
uid: The unique ID of the contact to delete.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
||||
|
||||
@@ -113,7 +227,9 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
"""Update an existing contact while preserving all existing properties.
|
||||
|
||||
Args:
|
||||
addressbook: The name of the addressbook containing the contact.
|
||||
addressbook: The URI slug of the addressbook (e.g. "contacts"),
|
||||
not the display name. Use nc_contacts_list_addressbooks to
|
||||
find available URI slugs.
|
||||
uid: The unique ID of the contact to update.
|
||||
contact_data: A dictionary with the contact's updated details, e.g. {"fn": "Jane Doe", "email": "jane.doe@example.com"}.
|
||||
etag: Optional ETag for optimistic concurrency control.
|
||||
|
||||
@@ -17,6 +17,10 @@ from nextcloud_mcp_server.models.deck import (
|
||||
DeckLabel,
|
||||
DeckStack,
|
||||
LabelOperationResponse,
|
||||
ListBoardsResponse,
|
||||
ListCardsResponse,
|
||||
ListLabelsResponse,
|
||||
ListStacksResponse,
|
||||
StackOperationResponse,
|
||||
)
|
||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||
@@ -103,7 +107,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
client = await get_client(ctx)
|
||||
board = await client.deck.get_board(board_id)
|
||||
return [label.model_dump() for label in board.labels]
|
||||
return [label.model_dump() for label in (board.labels or [])]
|
||||
|
||||
@mcp.resource("nc://Deck/boards/{board_id}/labels/{label_id}")
|
||||
async def deck_label_resource(board_id: int, label_id: int):
|
||||
@@ -124,11 +128,11 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
||||
async def deck_get_boards(ctx: Context) -> ListBoardsResponse:
|
||||
"""Get all Nextcloud Deck boards"""
|
||||
client = await get_client(ctx)
|
||||
boards = await client.deck.get_boards()
|
||||
return boards
|
||||
return ListBoardsResponse(boards=boards, total=len(boards))
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Deck Board",
|
||||
@@ -148,11 +152,11 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
||||
async def deck_get_stacks(ctx: Context, board_id: int) -> ListStacksResponse:
|
||||
"""Get all stacks in a Nextcloud Deck board"""
|
||||
client = await get_client(ctx)
|
||||
stacks = await client.deck.get_stacks(board_id)
|
||||
return stacks
|
||||
return ListStacksResponse(stacks=stacks, total=len(stacks))
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Deck Stack",
|
||||
@@ -174,13 +178,12 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
@instrument_tool
|
||||
async def deck_get_cards(
|
||||
ctx: Context, board_id: int, stack_id: int
|
||||
) -> list[DeckCard]:
|
||||
) -> ListCardsResponse:
|
||||
"""Get all cards in a Nextcloud Deck stack"""
|
||||
client = await get_client(ctx)
|
||||
stack = await client.deck.get_stack(board_id, stack_id)
|
||||
if stack.cards:
|
||||
return stack.cards
|
||||
return []
|
||||
cards = stack.cards or []
|
||||
return ListCardsResponse(cards=cards, total=len(cards))
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Deck Card",
|
||||
@@ -202,11 +205,12 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
||||
async def deck_get_labels(ctx: Context, board_id: int) -> ListLabelsResponse:
|
||||
"""Get all labels in a Nextcloud Deck board"""
|
||||
client = await get_client(ctx)
|
||||
board = await client.deck.get_board(board_id)
|
||||
return board.labels
|
||||
labels = board.labels or []
|
||||
return ListLabelsResponse(labels=labels, total=len(labels))
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Deck Label",
|
||||
|
||||
@@ -12,9 +12,6 @@ from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
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.types import ToolAnnotations
|
||||
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.storage import RefreshTokenStorage
|
||||
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 ..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"
|
||||
|
||||
|
||||
class ProvisioningStatus(BaseModel):
|
||||
"""Status of Nextcloud provisioning for a user."""
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.tables import ListTablesResponse, Table
|
||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,10 +19,12 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("tables:read")
|
||||
@instrument_tool
|
||||
async def nc_tables_list_tables(ctx: Context):
|
||||
async def nc_tables_list_tables(ctx: Context) -> ListTablesResponse:
|
||||
"""List all tables available to the user"""
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.list_tables()
|
||||
tables_data = await client.tables.list_tables()
|
||||
tables = [Table(**t) for t in tables_data]
|
||||
return ListTablesResponse(tables=tables, total_count=len(tables))
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Table Schema",
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.64.2"
|
||||
version = "0.65.0"
|
||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||
authors = [
|
||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||
@@ -13,7 +13,7 @@ dependencies = [
|
||||
"mcp[cli] (>=1.26,<1.27)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
|
||||
"icalendar (>=6.0.0,<7.0.0)",
|
||||
"icalendar (>=7.0.2,<7.1.0)",
|
||||
"pythonvcard4>=0.2.0",
|
||||
"pydantic>=2.11.4",
|
||||
"click>=8.1.8",
|
||||
@@ -74,6 +74,8 @@ markers = [
|
||||
"oauth: OAuth tests requiring Playwright (slowest)",
|
||||
"smoke: Critical path smoke tests for quick validation",
|
||||
"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 = [
|
||||
"tests",
|
||||
|
||||
+15
-1
@@ -7,8 +7,22 @@
|
||||
"dependencyDashboard": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": ["pillow"],
|
||||
"matchPackageNames": [
|
||||
"pillow"
|
||||
],
|
||||
"allowedVersions": "<12.0.0"
|
||||
}
|
||||
],
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": [
|
||||
"/^\\.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.
|
||||
"""
|
||||
# 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
|
||||
# This allows multiple concurrent OAuth flows
|
||||
auth_states = {}
|
||||
|
||||
@@ -27,7 +27,7 @@ from playwright.async_api import Page
|
||||
|
||||
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):
|
||||
@@ -899,7 +899,7 @@ def clear_stale_test_state(clear_preferences: bool = False) -> None:
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
@pytest.mark.multi_user_basic
|
||||
async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
browser,
|
||||
nc_client,
|
||||
@@ -1246,7 +1246,7 @@ async def verify_app_password_deleted(username: str) -> bool:
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
@pytest.mark.multi_user_basic
|
||||
async def test_revoke_background_sync_access(
|
||||
browser,
|
||||
nc_client,
|
||||
|
||||
@@ -35,7 +35,7 @@ from tests.integration.test_astrolabe_multi_user_background_sync import (
|
||||
|
||||
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(
|
||||
@@ -101,7 +101,7 @@ async def navigate_to_astrolabe_main(page: Page):
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
@pytest.mark.multi_user_basic
|
||||
@pytest.mark.timeout(
|
||||
300
|
||||
) # 5 minutes - this test involves OAuth, app password, and vector sync
|
||||
|
||||
@@ -30,7 +30,7 @@ import anyio
|
||||
import pytest
|
||||
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__)
|
||||
|
||||
@@ -334,7 +334,7 @@ def delete_user_credentials(username: str) -> bool:
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
@pytest.mark.multi_user_basic
|
||||
async def test_app_password_storage_and_cleanup(
|
||||
browser,
|
||||
nc_client,
|
||||
@@ -440,7 +440,7 @@ async def test_app_password_storage_and_cleanup(
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
@pytest.mark.multi_user_basic
|
||||
async def test_credential_isolation_between_users(
|
||||
browser,
|
||||
nc_client,
|
||||
@@ -549,7 +549,7 @@ async def test_credential_isolation_between_users(
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
@pytest.mark.multi_user_basic
|
||||
async def test_credential_revoke_and_reprovision(
|
||||
browser,
|
||||
nc_client,
|
||||
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.multi_user_basic
|
||||
async def test_basic_auth_pass_through_notes_search(nc_mcp_basic_auth_client):
|
||||
"""Test BasicAuth pass-through with notes search tool."""
|
||||
# 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.multi_user_basic
|
||||
async def test_basic_auth_pass_through_notes_create(nc_mcp_basic_auth_client):
|
||||
"""Test BasicAuth pass-through with notes create tool."""
|
||||
# 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.multi_user_basic
|
||||
async def test_basic_auth_pass_through_get_note(nc_mcp_basic_auth_client):
|
||||
"""Test BasicAuth pass-through with get note tool."""
|
||||
# First create a note to get
|
||||
|
||||
@@ -34,7 +34,7 @@ async def test_query_idp_userinfo_success(mocker):
|
||||
mock_client.__aexit__.return_value = None
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.auth.userinfo_routes.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.auth.userinfo_routes.nextcloud_httpx_client",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
@@ -59,7 +59,7 @@ async def test_query_idp_userinfo_failure(mocker):
|
||||
mock_client.__aexit__.return_value = None
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.auth.userinfo_routes.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.auth.userinfo_routes.nextcloud_httpx_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__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def nextcloud_host() -> str:
|
||||
@@ -114,7 +116,6 @@ async def test_oauth_clients(
|
||||
logger.info("Test OAuth clients fixture complete")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_introspection_requires_client_authentication(
|
||||
oidc_endpoints: dict[str, str],
|
||||
):
|
||||
@@ -284,7 +285,6 @@ async def _obtain_token_for_client(
|
||||
return access_token
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_client_cannot_introspect_other_clients_tokens(
|
||||
playwright_oauth_token: str,
|
||||
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(
|
||||
browser,
|
||||
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(
|
||||
test_oauth_clients: dict[str, tuple[str, str]],
|
||||
oidc_endpoints: dict[str, str],
|
||||
|
||||
@@ -78,11 +78,10 @@ async def test_deck_board_view_permissions(
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
# The response is directly a list of boards
|
||||
if not isinstance(response_data, list):
|
||||
response_data = [response_data] if response_data else []
|
||||
board_ids = [b["id"] for b in response_data]
|
||||
logger.info(f"Bob can see {len(response_data)} boards: {board_ids}")
|
||||
# Response is a ListBoardsResponse with a "boards" field
|
||||
board_list = response_data.get("boards", [])
|
||||
board_ids = [b["id"] for b in board_list]
|
||||
logger.info(f"Bob can see {len(board_list)} boards: {board_ids}")
|
||||
|
||||
# Bob should see the shared board
|
||||
if board_id in board_ids:
|
||||
@@ -98,11 +97,10 @@ async def test_deck_board_view_permissions(
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
# The response is directly a list of boards
|
||||
if not isinstance(response_data, list):
|
||||
response_data = [response_data] if response_data else []
|
||||
board_ids = [b["id"] for b in response_data]
|
||||
logger.info(f"Diana can see {len(response_data)} boards")
|
||||
# Response is a ListBoardsResponse with a "boards" field
|
||||
board_list = response_data.get("boards", [])
|
||||
board_ids = [b["id"] for b in board_list]
|
||||
logger.info(f"Diana can see {len(board_list)} boards")
|
||||
|
||||
# Diana should NOT see the board
|
||||
assert board_id not in board_ids, "Diana should not see board without ACL"
|
||||
@@ -313,10 +311,9 @@ async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
# The response is directly a list of boards
|
||||
if not isinstance(response_data, list):
|
||||
response_data = [response_data] if response_data else []
|
||||
board_ids = [b["id"] for b in response_data]
|
||||
# Response is a ListBoardsResponse with a "boards" field
|
||||
board_list = response_data.get("boards", [])
|
||||
board_ids = [b["id"] for b in board_list]
|
||||
logger.info(f"Alice can see boards: {board_ids}")
|
||||
|
||||
# Alice should NOT see Bob's board
|
||||
@@ -332,10 +329,9 @@ async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
# The response is directly a list of boards
|
||||
if not isinstance(response_data, list):
|
||||
response_data = [response_data] if response_data else []
|
||||
board_ids = [b["id"] for b in response_data]
|
||||
# Response is a ListBoardsResponse with a "boards" field
|
||||
board_list = response_data.get("boards", [])
|
||||
board_ids = [b["id"] for b in board_list]
|
||||
logger.info(f"Bob can see boards: {board_ids}")
|
||||
|
||||
# Bob should NOT see Alice's board
|
||||
|
||||
@@ -18,6 +18,7 @@ import pytest
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
async def test_prm_endpoint():
|
||||
"""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 "notes:read" 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 "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.oauth
|
||||
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."""
|
||||
|
||||
@@ -108,6 +110,7 @@ async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
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."""
|
||||
|
||||
@@ -156,6 +159,7 @@ async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_onl
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
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."""
|
||||
|
||||
@@ -389,6 +393,7 @@ async def test_scope_metadata_coverage(nc_mcp_client):
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
||||
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.oauth
|
||||
async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only):
|
||||
"""
|
||||
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.oauth
|
||||
async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only):
|
||||
"""
|
||||
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.oauth
|
||||
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.
|
||||
|
||||
@@ -61,14 +61,12 @@ async def token_exchange_service(token_storage):
|
||||
@pytest.fixture
|
||||
async def token_broker(token_storage):
|
||||
"""Create test token broker service."""
|
||||
# Use the same encryption key as storage
|
||||
encryption_key = token_storage._test_encryption_key
|
||||
|
||||
broker = TokenBrokerService(
|
||||
storage=token_storage,
|
||||
oidc_discovery_url="http://test-idp/.well-known/openid-configuration",
|
||||
nextcloud_host="http://test-nextcloud",
|
||||
encryption_key=encryption_key,
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
cache_ttl=300,
|
||||
cache_early_refresh=30,
|
||||
)
|
||||
|
||||
+15
-21
@@ -683,17 +683,15 @@ async def test_mcp_calendar_workflow(
|
||||
f"MCP list events failed: {list_result.content}"
|
||||
)
|
||||
|
||||
events_data = json.loads(list_result.content[0].text)
|
||||
events_response = json.loads(list_result.content[0].text)
|
||||
|
||||
# Debug output to understand what nc_calendar_list_events returns
|
||||
logger.info(f"list_events result type: {type(events_data)}")
|
||||
logger.info(f"list_events result content: {events_data}")
|
||||
|
||||
# Handle single event returned as dict instead of list (same fix as calendars)
|
||||
if isinstance(events_data, dict):
|
||||
# Single event returned as dict instead of list
|
||||
events_data = [events_data]
|
||||
logger.info(f"list_events result type: {type(events_response)}")
|
||||
logger.info(f"list_events result content: {events_response}")
|
||||
|
||||
# Response is now a ListEventsResponse with an "events" field
|
||||
assert isinstance(events_response, dict), "Expected response dict"
|
||||
events_data = events_response.get("events", [])
|
||||
assert isinstance(events_data, list), "Expected events list"
|
||||
|
||||
# Our created event should be in the list
|
||||
@@ -706,7 +704,7 @@ async def test_mcp_calendar_workflow(
|
||||
assert found_event is not None, (
|
||||
f"Created event {event_uid} not found in events list"
|
||||
)
|
||||
assert found_event["title"] == test_event_title
|
||||
assert found_event["summary"] == test_event_title
|
||||
|
||||
# 6. Test list events across all calendars
|
||||
logger.info("Testing nc_calendar_list_events across all calendars")
|
||||
@@ -727,13 +725,11 @@ async def test_mcp_calendar_workflow(
|
||||
f"MCP list all events failed: {all_list_result.content}"
|
||||
)
|
||||
|
||||
all_events_data = json.loads(all_list_result.content[0].text)
|
||||
|
||||
# Handle single event returned as dict instead of list (same fix as calendars)
|
||||
if isinstance(all_events_data, dict):
|
||||
# Single event returned as dict instead of list
|
||||
all_events_data = [all_events_data]
|
||||
all_events_response = json.loads(all_list_result.content[0].text)
|
||||
|
||||
# Response is now a ListEventsResponse with an "events" field
|
||||
assert isinstance(all_events_response, dict), "Expected response dict"
|
||||
all_events_data = all_events_response.get("events", [])
|
||||
assert isinstance(all_events_data, list), "Expected events list"
|
||||
|
||||
# Our event should still be found when searching all calendars
|
||||
@@ -780,13 +776,11 @@ async def test_mcp_calendar_workflow(
|
||||
f"MCP upcoming events failed: {upcoming_result.content}"
|
||||
)
|
||||
|
||||
upcoming_events = json.loads(upcoming_result.content[0].text)
|
||||
|
||||
# Handle single event returned as dict instead of list (same fix as other tools)
|
||||
if isinstance(upcoming_events, dict):
|
||||
# Single event returned as dict instead of list
|
||||
upcoming_events = [upcoming_events]
|
||||
upcoming_response = json.loads(upcoming_result.content[0].text)
|
||||
|
||||
# Response is now an UpcomingEventsResponse with an "events" field
|
||||
assert isinstance(upcoming_response, dict), "Expected response dict"
|
||||
upcoming_events = upcoming_response.get("events", [])
|
||||
assert isinstance(upcoming_events, list), "Expected upcoming events list"
|
||||
|
||||
# 10. Delete event via MCP
|
||||
|
||||
@@ -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():
|
||||
"""Mock OpenTelemetry tracer."""
|
||||
with patch(
|
||||
"nextcloud_mcp_server.observability.tracing.trace_operation"
|
||||
"nextcloud_mcp_server.observability.metrics.trace_operation"
|
||||
) as mock_trace:
|
||||
# Configure mock to act as a context manager that allows exceptions to propagate
|
||||
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."""
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
@@ -203,7 +203,7 @@ async def test_provision_app_password_success(temp_storage, mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_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):
|
||||
"""Test that failed Nextcloud validation returns 401."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
@@ -251,7 +251,7 @@ async def test_provision_app_password_nextcloud_validation_fails(mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_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)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
@@ -374,7 +374,7 @@ async def test_delete_app_password_success(temp_storage, mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_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."""
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
@@ -422,7 +422,7 @@ async def test_delete_app_password_not_found(temp_storage, mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_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):
|
||||
"""Test that invalid credentials returns 401 for deletion."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
@@ -465,7 +465,7 @@ async def test_delete_app_password_invalid_credentials(mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_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):
|
||||
"""Test that rate limiting blocks excessive provisioning attempts."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
@@ -539,7 +539,7 @@ async def test_provision_app_password_rate_limiting(mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_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):
|
||||
"""Test that rate limiting is applied per user, not globally."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
@@ -602,7 +602,7 @@ async def test_rate_limiting_is_per_user(mocker):
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_client",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ def create_mock_settings(
|
||||
oidc_issuer: str | None = None,
|
||||
vector_sync_enabled: bool = False,
|
||||
nextcloud_url: str = "http://localhost",
|
||||
nextcloud_host: str | None = "http://localhost",
|
||||
enable_token_exchange: bool = False,
|
||||
mcp_client_id: str | None = None,
|
||||
mcp_client_secret: str | None = None,
|
||||
@@ -49,6 +50,7 @@ def create_mock_settings(
|
||||
settings.oidc_issuer = oidc_issuer
|
||||
settings.vector_sync_enabled = vector_sync_enabled
|
||||
settings.nextcloud_url = nextcloud_url
|
||||
settings.nextcloud_host = nextcloud_host
|
||||
settings.enable_token_exchange = enable_token_exchange
|
||||
settings.mcp_client_id = mcp_client_id
|
||||
settings.mcp_client_secret = mcp_client_secret
|
||||
@@ -70,10 +72,11 @@ class TestStatusEndpointOidcConfig:
|
||||
# get_settings and detect_auth_mode are imported inside the function
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
@@ -107,10 +110,11 @@ class TestStatusEndpointOidcConfig:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
@@ -131,14 +135,16 @@ class TestStatusEndpointOidcConfig:
|
||||
enable_offline_access=False, # Key difference: no offline access
|
||||
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
||||
oidc_issuer="http://keycloak/realms/test",
|
||||
nextcloud_host=None,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
@@ -167,10 +173,11 @@ class TestStatusEndpointOidcConfig:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.OAUTH_SINGLE_AUDIENCE,
|
||||
),
|
||||
):
|
||||
@@ -192,20 +199,22 @@ class TestStatusEndpointOidcConfig:
|
||||
)
|
||||
|
||||
def test_single_user_basic_no_oidc(self):
|
||||
"""Test that single-user BasicAuth mode doesn't return OIDC config."""
|
||||
"""Test that single-user BasicAuth mode doesn't return OIDC config when no host."""
|
||||
mock_settings = create_mock_settings(
|
||||
enable_multi_user_basic=False,
|
||||
enable_offline_access=False,
|
||||
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
||||
oidc_issuer="http://keycloak/realms/test",
|
||||
nextcloud_host=None,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||
),
|
||||
):
|
||||
@@ -235,10 +244,11 @@ class TestStatusEndpointOidcConfig:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
@@ -267,10 +277,11 @@ class TestStatusEndpointOidcConfig:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
@@ -295,10 +306,11 @@ class TestStatusEndpointBasicResponse:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||
),
|
||||
):
|
||||
@@ -320,10 +332,11 @@ class TestStatusEndpointBasicResponse:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||
),
|
||||
):
|
||||
@@ -335,3 +348,127 @@ class TestStatusEndpointBasicResponse:
|
||||
data = response.json()
|
||||
|
||||
assert data["vector_sync_enabled"] is True
|
||||
|
||||
|
||||
class TestStatusEndpointOidcAutoDerivation:
|
||||
"""Tests for OIDC discovery_url auto-derivation from NEXTCLOUD_HOST."""
|
||||
|
||||
def test_derives_discovery_url_from_nextcloud_host(self):
|
||||
"""Test that discovery_url is auto-derived from nextcloud_url when not explicit."""
|
||||
mock_settings = create_mock_settings(
|
||||
oidc_discovery_url=None,
|
||||
oidc_issuer=None,
|
||||
)
|
||||
mock_settings.nextcloud_host = "https://cloud.example.com"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "oidc" in data
|
||||
assert (
|
||||
data["oidc"]["discovery_url"]
|
||||
== "https://cloud.example.com/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
def test_derives_discovery_url_strips_trailing_slash(self):
|
||||
"""Test that trailing slash on nextcloud_host is stripped."""
|
||||
mock_settings = create_mock_settings(
|
||||
oidc_discovery_url=None,
|
||||
oidc_issuer=None,
|
||||
)
|
||||
mock_settings.nextcloud_host = "https://cloud.example.com/"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "oidc" in data
|
||||
assert (
|
||||
data["oidc"]["discovery_url"]
|
||||
== "https://cloud.example.com/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
def test_explicit_discovery_url_takes_precedence(self):
|
||||
"""Test that explicit OIDC_DISCOVERY_URL overrides auto-derivation."""
|
||||
mock_settings = create_mock_settings(
|
||||
oidc_discovery_url="https://keycloak.example.com/.well-known/openid-configuration",
|
||||
oidc_issuer=None,
|
||||
)
|
||||
mock_settings.nextcloud_host = "https://cloud.example.com"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "oidc" in data
|
||||
assert (
|
||||
data["oidc"]["discovery_url"]
|
||||
== "https://keycloak.example.com/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
def test_no_oidc_when_no_host_and_no_discovery_url(self):
|
||||
"""Test that oidc block is absent when neither host nor discovery_url is set."""
|
||||
mock_settings = create_mock_settings(
|
||||
oidc_discovery_url=None,
|
||||
oidc_issuer=None,
|
||||
)
|
||||
mock_settings.nextcloud_host = None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.management.get_settings",
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.management.detect_auth_mode",
|
||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "oidc" not in data
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.models.contacts import (
|
||||
Contact,
|
||||
ListContactsResponse,
|
||||
)
|
||||
from nextcloud_mcp_server.models.notes import (
|
||||
CreateNoteResponse,
|
||||
Note,
|
||||
@@ -12,6 +16,8 @@ from nextcloud_mcp_server.models.semantic import (
|
||||
SamplingSearchResponse,
|
||||
SemanticSearchResult,
|
||||
)
|
||||
from nextcloud_mcp_server.server.calendar import _event_dict_to_summary
|
||||
from nextcloud_mcp_server.server.contacts import _raw_contact_to_model
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -267,3 +273,365 @@ def test_sampling_search_response_serialization():
|
||||
assert data["model_used"] == "claude-3-5-sonnet"
|
||||
assert data["stop_reason"] == "maxTokens"
|
||||
assert data["success"] is True
|
||||
|
||||
|
||||
def _map_contact(raw: dict) -> Contact:
|
||||
"""Thin wrapper around the production mapping function for test readability."""
|
||||
return _raw_contact_to_model(raw)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_contact_mapping_preserves_email_birthday_nickname():
|
||||
"""Test that list_contacts mapping preserves email, birthday, and nickname.
|
||||
|
||||
Regression test for PR #574: the original mapping only kept uid, fn, etag
|
||||
and silently dropped email, birthday, and nickname.
|
||||
"""
|
||||
raw_contact = {
|
||||
"vcard_id": "abc-123",
|
||||
"getetag": '"etag-val"',
|
||||
"contact": {
|
||||
"fullname": "Jane Doe",
|
||||
"email": "jane@example.com",
|
||||
"birthday": "1990-05-15",
|
||||
"nickname": "JD",
|
||||
},
|
||||
}
|
||||
|
||||
contact = _map_contact(raw_contact)
|
||||
|
||||
assert contact.uid == "abc-123"
|
||||
assert contact.fn == "Jane Doe"
|
||||
assert contact.etag == '"etag-val"'
|
||||
assert contact.birthday == "1990-05-15"
|
||||
assert len(contact.emails) == 1
|
||||
assert contact.emails[0].value == "jane@example.com"
|
||||
assert contact.emails[0].type == "email"
|
||||
assert contact.custom_fields["nickname"] == "JD"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_contact_mapping_multiple_emails():
|
||||
"""Test that multiple emails are mapped correctly."""
|
||||
raw_contact = {
|
||||
"vcard_id": "def-456",
|
||||
"contact": {
|
||||
"fullname": "John Smith",
|
||||
"email": ["john@work.com", "john@home.com"],
|
||||
},
|
||||
}
|
||||
|
||||
contact = _map_contact(raw_contact)
|
||||
|
||||
assert len(contact.emails) == 2
|
||||
assert contact.emails[0].value == "john@work.com"
|
||||
assert contact.emails[1].value == "john@home.com"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_contact_mapping_missing_optional_fields():
|
||||
"""Test mapping when email, birthday, and nickname are absent."""
|
||||
raw_contact = {
|
||||
"vcard_id": "ghi-789",
|
||||
"contact": {"fullname": "No Details"},
|
||||
}
|
||||
|
||||
contact = _map_contact(raw_contact)
|
||||
|
||||
assert contact.uid == "ghi-789"
|
||||
assert contact.fn == "No Details"
|
||||
assert contact.birthday is None
|
||||
assert contact.emails == []
|
||||
assert contact.custom_fields == {}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_list_contacts_response_wraps_contacts():
|
||||
"""Test ListContactsResponse wraps contacts correctly for MCP output."""
|
||||
contacts = [
|
||||
_map_contact(
|
||||
{
|
||||
"vcard_id": "a",
|
||||
"getetag": '"e1"',
|
||||
"contact": {
|
||||
"fullname": "Alice",
|
||||
"email": "alice@test.com",
|
||||
"birthday": "2000-01-01",
|
||||
"nickname": "Ali",
|
||||
},
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
response = ListContactsResponse(
|
||||
contacts=contacts, addressbook="personal", total_count=1
|
||||
)
|
||||
|
||||
data = response.model_dump()
|
||||
assert data["total_count"] == 1
|
||||
assert len(data["contacts"]) == 1
|
||||
c = data["contacts"][0]
|
||||
assert c["birthday"] == "2000-01-01"
|
||||
assert c["emails"][0]["value"] == "alice@test.com"
|
||||
assert c["custom_fields"]["nickname"] == "Ali"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_contact_mapping_dict_format_emails():
|
||||
"""Regression for #601: pythonvCard4 returns dicts, not plain strings."""
|
||||
raw_contact = {
|
||||
"vcard_id": "dict-email-1",
|
||||
"contact": {
|
||||
"fullname": "Evrim Yilmaz",
|
||||
"email": [
|
||||
{"value": "evrim@example.com", "type": ["HOME"]},
|
||||
{"value": "evrim@work.com", "type": ["WORK"]},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
contact = _map_contact(raw_contact)
|
||||
|
||||
assert len(contact.emails) == 2
|
||||
assert contact.emails[0].value == "evrim@example.com"
|
||||
assert contact.emails[0].label == "home"
|
||||
assert contact.emails[1].value == "evrim@work.com"
|
||||
assert contact.emails[1].label == "work"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_contact_mapping_dict_format_phones():
|
||||
"""Phones from dict-format tel field are parsed into Contact.phones."""
|
||||
raw_contact = {
|
||||
"vcard_id": "dict-tel-1",
|
||||
"contact": {
|
||||
"fullname": "Phone User",
|
||||
"tel": [
|
||||
{"value": "+1-555-0100", "type": ["CELL"]},
|
||||
{"value": "+1-555-0200", "type": ["WORK", "VOICE"]},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
contact = _map_contact(raw_contact)
|
||||
|
||||
assert len(contact.phones) == 2
|
||||
assert contact.phones[0].value == "+1-555-0100"
|
||||
assert contact.phones[0].type == "phone"
|
||||
assert contact.phones[0].label == "cell"
|
||||
assert contact.phones[1].value == "+1-555-0200"
|
||||
assert contact.phones[1].label == "work, voice"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_contact_mapping_pref_flag_extraction():
|
||||
"""PREF type is extracted as preferred=True, not included in labels."""
|
||||
raw_contact = {
|
||||
"vcard_id": "pref-1",
|
||||
"contact": {
|
||||
"fullname": "Pref User",
|
||||
"email": [
|
||||
{"value": "pref@example.com", "type": ["HOME", "PREF"]},
|
||||
{"value": "other@example.com", "type": ["WORK"]},
|
||||
],
|
||||
"tel": [
|
||||
{"value": "+1-555-0001", "type": ["pref", "CELL"]},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
contact = _map_contact(raw_contact)
|
||||
|
||||
assert contact.emails[0].preferred is True
|
||||
assert contact.emails[0].label == "home" # PREF stripped from label
|
||||
assert contact.emails[1].preferred is False
|
||||
assert contact.primary_email == "pref@example.com"
|
||||
|
||||
assert contact.phones[0].preferred is True
|
||||
assert contact.phones[0].label == "cell"
|
||||
assert contact.primary_phone == "+1-555-0001"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_contact_mapping_backward_compat_plain_strings():
|
||||
"""Plain string emails/phones still work (backward compatibility)."""
|
||||
raw_contact = {
|
||||
"vcard_id": "compat-1",
|
||||
"contact": {
|
||||
"fullname": "Plain String",
|
||||
"email": "plain@example.com",
|
||||
"tel": "+1-555-9999",
|
||||
},
|
||||
}
|
||||
|
||||
contact = _map_contact(raw_contact)
|
||||
|
||||
assert len(contact.emails) == 1
|
||||
assert contact.emails[0].value == "plain@example.com"
|
||||
assert contact.emails[0].label is None
|
||||
assert contact.emails[0].preferred is False
|
||||
|
||||
assert len(contact.phones) == 1
|
||||
assert contact.phones[0].value == "+1-555-9999"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_contact_mapping_empty_type_list():
|
||||
"""Dict with empty or missing type list produces no label."""
|
||||
raw_contact = {
|
||||
"vcard_id": "empty-type-1",
|
||||
"contact": {
|
||||
"fullname": "No Type",
|
||||
"email": {"value": "notype@example.com", "type": []},
|
||||
},
|
||||
}
|
||||
|
||||
contact = _map_contact(raw_contact)
|
||||
|
||||
assert len(contact.emails) == 1
|
||||
assert contact.emails[0].value == "notype@example.com"
|
||||
assert contact.emails[0].label is None
|
||||
assert contact.emails[0].preferred is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_contact_mapping_multiple_dict_emails_with_labels():
|
||||
"""Multiple dict-format emails preserve individual labels."""
|
||||
raw_contact = {
|
||||
"vcard_id": "multi-label-1",
|
||||
"contact": {
|
||||
"fullname": "Multi Label",
|
||||
"email": [
|
||||
{"value": "home@example.com", "type": ["HOME", "PREF"]},
|
||||
{"value": "work@example.com", "type": ["WORK"]},
|
||||
{"value": "other@example.com"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
contact = _map_contact(raw_contact)
|
||||
|
||||
assert len(contact.emails) == 3
|
||||
assert contact.emails[0].value == "home@example.com"
|
||||
assert contact.emails[0].label == "home"
|
||||
assert contact.emails[0].preferred is True
|
||||
assert contact.emails[1].value == "work@example.com"
|
||||
assert contact.emails[1].label == "work"
|
||||
assert contact.emails[1].preferred is False
|
||||
assert contact.emails[2].value == "other@example.com"
|
||||
assert contact.emails[2].label is None
|
||||
assert contact.primary_email == "home@example.com"
|
||||
|
||||
|
||||
# ============= _event_dict_to_summary tests =============
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_event_dict_to_summary_basic():
|
||||
"""Test basic mapping with all fields populated."""
|
||||
event = {
|
||||
"uid": "evt-001",
|
||||
"title": "Team Standup",
|
||||
"start_datetime": "2025-07-28T09:00:00",
|
||||
"end_datetime": "2025-07-28T09:30:00",
|
||||
"all_day": False,
|
||||
"location": "Room 42",
|
||||
"description": "Daily sync",
|
||||
"categories": ["work", "meeting"],
|
||||
"status": "CONFIRMED",
|
||||
"calendar_name": "office",
|
||||
"calendar_display_name": "Office Calendar",
|
||||
}
|
||||
|
||||
summary = _event_dict_to_summary(event)
|
||||
|
||||
assert summary.uid == "evt-001"
|
||||
assert summary.summary == "Team Standup"
|
||||
assert summary.start == "2025-07-28T09:00:00"
|
||||
assert summary.end == "2025-07-28T09:30:00"
|
||||
assert summary.all_day is False
|
||||
assert summary.location == "Room 42"
|
||||
assert summary.description == "Daily sync"
|
||||
assert summary.categories == ["work", "meeting"]
|
||||
assert summary.status == "CONFIRMED"
|
||||
assert summary.calendar_name == "office"
|
||||
assert summary.calendar_display_name == "Office Calendar"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_event_dict_to_summary_categories_string():
|
||||
"""Test that comma-separated category string is split into a list."""
|
||||
event = {
|
||||
"uid": "evt-002",
|
||||
"title": "Review",
|
||||
"categories": "work, meeting, important",
|
||||
}
|
||||
|
||||
summary = _event_dict_to_summary(event)
|
||||
|
||||
assert summary.categories == ["work", "meeting", "important"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_event_dict_to_summary_categories_list_passthrough():
|
||||
"""Test that a list of categories passes through unchanged."""
|
||||
event = {
|
||||
"uid": "evt-003",
|
||||
"title": "Review",
|
||||
"categories": ["personal", "health"],
|
||||
}
|
||||
|
||||
summary = _event_dict_to_summary(event)
|
||||
|
||||
assert summary.categories == ["personal", "health"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_event_dict_to_summary_falsy_location_description():
|
||||
"""Test that empty/falsy location and description are coerced to None."""
|
||||
event = {
|
||||
"uid": "evt-004",
|
||||
"title": "Quick Chat",
|
||||
"location": "",
|
||||
"description": "",
|
||||
}
|
||||
|
||||
summary = _event_dict_to_summary(event)
|
||||
|
||||
assert summary.location is None
|
||||
assert summary.description is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_event_dict_to_summary_missing_optional_fields():
|
||||
"""Test mapping with only required fields present."""
|
||||
event = {"uid": "evt-005", "title": "Minimal Event"}
|
||||
|
||||
summary = _event_dict_to_summary(event)
|
||||
|
||||
assert summary.uid == "evt-005"
|
||||
assert summary.summary == "Minimal Event"
|
||||
assert summary.start == ""
|
||||
assert summary.end is None
|
||||
assert summary.all_day is False
|
||||
assert summary.location is None
|
||||
assert summary.description is None
|
||||
assert summary.categories == []
|
||||
assert summary.status is None
|
||||
assert summary.calendar_name is None
|
||||
assert summary.calendar_display_name is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_event_dict_to_summary_calendar_name_without_display_name():
|
||||
"""Test single-calendar path: calendar_name set, display_name absent falls back."""
|
||||
event = {
|
||||
"uid": "evt-006",
|
||||
"title": "Personal Errand",
|
||||
"calendar_name": "personal",
|
||||
}
|
||||
|
||||
summary = _event_dict_to_summary(event)
|
||||
|
||||
assert summary.calendar_name == "personal"
|
||||
assert summary.calendar_display_name == "personal"
|
||||
|
||||
@@ -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
@@ -1166,15 +1166,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "icalendar"
|
||||
version = "6.3.2"
|
||||
version = "7.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/70/458092b3e7c15783423fe64d07e63ea3311a597e723be6a1060513e3db93/icalendar-6.3.2.tar.gz", hash = "sha256:e0c10ecbfcebe958d33af7d491f6e6b7580d11d475f2eeb29532d0424f9110a1", size = 178422, upload-time = "2025-11-05T12:49:32.286Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/51/43458f01e229763b05dd937b5e9d41ef506b6eb8b4bf939f8ea34350b853/icalendar-7.0.2.tar.gz", hash = "sha256:de844ff5cde32f539bea7644e36d8494032a926b933bedb92621f2f239760806", size = 440039, upload-time = "2026-02-24T16:13:42.887Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ee/2ff96bb5bd88fe03ab90aedf5180f96dc0f3ae4648ca264b473055bcaaff/icalendar-6.3.2-py3-none-any.whl", hash = "sha256:d400e9c9bb8c025e5a3c77c236941bb690494be52528a0b43cc7e8b7c9505064", size = 242403, upload-time = "2025-11-05T12:49:30.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/ab/e0d44b1de0beb703bbc507ca064300b34046f9f9628f052d1a97ffa61b95/icalendar-7.0.2-py3-none-any.whl", hash = "sha256:ad31a5825b39522a30b073c6ced3ffcdf6c02cbb7dab69ba2e4de32ddbf77cc9", size = 437913, upload-time = "2026-02-24T16:13:40.631Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1988,7 +1989,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.64.2"
|
||||
version = "0.65.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -2051,7 +2052,7 @@ requires-dist = [
|
||||
{ name = "click", specifier = ">=8.1.8" },
|
||||
{ name = "fastembed", specifier = ">=0.7.3" },
|
||||
{ name = "httpx", specifier = ">=0.28.1,<0.29.0" },
|
||||
{ name = "icalendar", specifier = ">=6.0.0,<7.0.0" },
|
||||
{ name = "icalendar", specifier = ">=7.0.2,<7.1.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.0.0" },
|
||||
{ name = "markdownify", specifier = ">=0.14.1" },
|
||||
@@ -3383,7 +3384,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "recurring-ical-events"
|
||||
version = "3.8.0"
|
||||
version = "3.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "icalendar" },
|
||||
@@ -3391,9 +3392,9 @@ dependencies = [
|
||||
{ name = "tzdata" },
|
||||
{ name = "x-wr-timezone" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/90/05dfcc02ecf58bd170305c88db9e3e3933aa73a1f8abac2b326c7fdc1a98/recurring_ical_events-3.8.0.tar.gz", hash = "sha256:3e8c7c35d9bd8956a7ab91afad51477c60d972e1236d3fd1b55087a66bce7d04", size = 602665, upload-time = "2025-06-10T13:23:50.662Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/d4/51c9361bb0efb2290dfd850c036b49acb502794e0fe9cc3520dbf60fd7db/recurring_ical_events-3.8.1.tar.gz", hash = "sha256:c3eb2490a00559fb963d2bdee39acf2f287c91c07dcea4ce80ade1c60a8c3acf", size = 603730, upload-time = "2026-02-18T11:45:53.272Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/25/88a4218cccae06ce6b15e41d2f263dd4a73e8e8cbe41537cd7784a17479b/recurring_ical_events-3.8.0-py3-none-any.whl", hash = "sha256:cf958eb17c92d4dca5c621e44c2b3fffd4ba700dca0db66287c5dc11438f63ba", size = 238228, upload-time = "2025-06-10T13:23:49.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/67/4d4aead359164de68d30ee67efcdbe3784063cb21535c85b9a9a03dd2ebb/recurring_ical_events-3.8.1-py3-none-any.whl", hash = "sha256:3bb3aaa0c87a4d3ab5951360480686bd69f1512945f478be6a2c0f141da0bf78", size = 238286, upload-time = "2026-02-18T11:45:51.631Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3954,11 +3955,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.2"
|
||||
version = "2025.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user