Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c305a549d3 | |||
| 1f1dd94598 | |||
| 01ad2b3d21 | |||
| e4cddef343 | |||
| f15baefe7e | |||
| 6affad1c8b | |||
| 370c3ff444 | |||
| e486e92f91 | |||
| 7465e962d4 | |||
| 99fe764c5e | |||
| 46f896b526 | |||
| a61572e8ef | |||
| a474996df4 | |||
| 5d6dd5ad38 | |||
| 21e4d3effd | |||
| 817df43af1 | |||
| 906b9d892c | |||
| 534723c9f6 | |||
| 1d5832ed3a | |||
| 844bd589e0 | |||
| 127af15623 | |||
| ff5fc5d5b2 | |||
| 158865d99f | |||
| 94674eca27 | |||
| a8b5d6e701 | |||
| e0675b2127 | |||
| 86582bdb8f | |||
| dc8009a785 | |||
| b5e658e1ff | |||
| 6a19c2d136 | |||
| 99e359ffbf | |||
| f16f4e8cb5 | |||
| 8597f2a272 | |||
| 11f67e2bc4 | |||
| 2e49a16e49 | |||
| 713fddeaa5 | |||
| 0dfefb0516 | |||
| 63d2aeaa43 | |||
| 07f0a7c0dc | |||
| 84bde6d5ed | |||
| 9695f8a6d7 | |||
| a2c410e8d2 | |||
| 271b5f6155 | |||
| ba4f7c1429 | |||
| c763e96596 | |||
| 23e9cbaec5 | |||
| ddd5defa40 | |||
| 723dcc524d | |||
| 46eba0a693 | |||
| b61980a623 | |||
| 65cc894e21 | |||
| 700996e100 | |||
| 546f0c0674 | |||
| e625eab689 | |||
| ef9e1b3ff8 | |||
| dd23191987 | |||
| 55312b1032 | |||
| a987643f8e |
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
|
||||
uses: anthropics/claude-code-action@1b8ee3b94104046d71fde52ec3557651ad8c0d71 # v1.0.29
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
|
||||
uses: anthropics/claude-code-action@1b8ee3b94104046d71fde52ec3557651ad8c0d71 # v1.0.29
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Run docker compose with vector sync
|
||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
||||
with:
|
||||
compose-file: |
|
||||
./docker-compose.yml
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
|
||||
- name: Wait for Nextcloud to be ready
|
||||
run: |
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
- name: Install Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Build
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -66,14 +66,14 @@ jobs:
|
||||
|
||||
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
#compose-flags: "--profile qdrant"
|
||||
up-flags: "--build"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: |
|
||||
|
||||
@@ -5,6 +5,36 @@ 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.61.0 (2026-01-14)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add rate limiting and extract helpers for app password endpoints
|
||||
|
||||
### Fix
|
||||
|
||||
- Add missing annotations for deck remove/unassign operations
|
||||
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
|
||||
|
||||
### Refactor
|
||||
|
||||
- Use get_settings() for vector sync enabled check
|
||||
- Extract storage helper and improve PHP error handling
|
||||
|
||||
## v0.60.4 (2026-01-12)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
|
||||
|
||||
## v0.60.3 (2025-12-31)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deck**: Always preserve fields in update_card for partial updates
|
||||
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
||||
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
||||
|
||||
## v0.60.2 (2025-12-29)
|
||||
|
||||
### Fix
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:7325cf245c9df8e53b45da8f40b845368fd847065e10461fa6799e916bb82649
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.24@sha256:816fdce3387ed2142e37d2e56e1b1b97ccc1ea87731ba199dc8a25c04e4997c5 /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:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:7325cf245c9df8e53b45da8f40b845368fd847065e10461fa6799e916bb82649
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv for fast dependency management
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.24@sha256:816fdce3387ed2142e37d2e56e1b1b97ccc1ea87731ba199dc8a25c04e4997c5 /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Configure MCP server URL for Astrolabe background sync
|
||||
# This URL is used by Astrolabe to send app passwords to the MCP server
|
||||
|
||||
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}"
|
||||
|
||||
echo "Configuring MCP server URL: $MCP_SERVER_URL"
|
||||
|
||||
# Set the mcp_server_url in config.php via occ
|
||||
php occ config:system:set mcp_server_url --value="$MCP_SERVER_URL"
|
||||
|
||||
echo "MCP server URL configured successfully"
|
||||
@@ -4,6 +4,6 @@ dependencies:
|
||||
version: 1.16.3
|
||||
- name: ollama
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
version: 1.36.0
|
||||
digest: sha256:7f0979ec4110ff41ebeb55bf586b41366a350cc39fe65a2da7d2da03f723fe9b
|
||||
generated: "2025-12-22T11:09:39.166328543Z"
|
||||
version: 1.37.0
|
||||
digest: sha256:0ce3bb4b5e95a3b8fde3f5f374d7b62aeafcb0dcf8a60b9d95978530b6c05b68
|
||||
generated: "2026-01-08T11:11:12.857375888Z"
|
||||
|
||||
@@ -3,7 +3,7 @@ name: nextcloud-mcp-server
|
||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||
type: application
|
||||
version: 0.56.2
|
||||
appVersion: "0.60.2"
|
||||
appVersion: "0.61.0"
|
||||
keywords:
|
||||
- nextcloud
|
||||
- mcp
|
||||
@@ -31,6 +31,6 @@ dependencies:
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
condition: qdrant.networkMode.deploySubchart
|
||||
- name: ollama
|
||||
version: "1.36.0"
|
||||
version: "1.37.0"
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
condition: ollama.enabled
|
||||
|
||||
+5
-5
@@ -23,7 +23,7 @@ services:
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
|
||||
image: docker.io/library/nextcloud:32.0.3@sha256:1a75afcd53b38aa72205ab38a66121ed9f9e8c99f4e70b0dccc858e60ad57b7d
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8080:80
|
||||
@@ -54,14 +54,14 @@ services:
|
||||
retries: 30
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine@sha256:052b75ab72f690f33debaa51c7e08d9b969a0447a133eb2b99cc905d9188cb2b
|
||||
image: docker.io/library/nginx:alpine@sha256:c083c3799197cfff91fe5c3c558db3d2eea65ccbbfd419fa42a64d2c39a24027
|
||||
restart: always
|
||||
volumes:
|
||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
unstructured:
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:54282d3a25f33fd6cf69bc45b3d37770f213593f58b6dfe5e85fe546376b2807
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:db5fcc831eb673ec835c41e8d47f993fdde276562285d6837cebb03f958536a2
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8002:8000
|
||||
@@ -211,7 +211,7 @@ services:
|
||||
- oauth-tokens:/app/data
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
|
||||
image: quay.io/keycloak/keycloak:26.5.0@sha256:5fdd7cda82e58775ed124294c7e16fabc33166d38dfc4aabebda7d64e7a964bf
|
||||
command:
|
||||
- "start-dev"
|
||||
- "--import-realm"
|
||||
@@ -299,7 +299,7 @@ services:
|
||||
- smithery
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
|
||||
image: docker.io/qdrant/qdrant:v1.16.3@sha256:0425e3e03e7fd9b3dc95c4214546afe19de2eb2e28ca621441a56663ac6e1f46
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:6333:6333 # REST API
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
# Webhook Management Guide
|
||||
|
||||
This guide explains how to enable and disable webhooks for vector sync in each MCP server deployment mode. Webhooks enable near-real-time synchronization of content changes to the vector database, complementing the default polling-based sync.
|
||||
|
||||
**Related ADRs:**
|
||||
- ADR-010: Webhook-Based Vector Sync
|
||||
- ADR-020: Deployment Modes and Configuration Validation
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before enabling webhooks, ensure:
|
||||
|
||||
1. **Nextcloud 30+** with `webhook_listeners` app enabled
|
||||
2. **Astrolabe app** installed in Nextcloud (provides settings UI and credentials API)
|
||||
3. **MCP server** accessible from Nextcloud via HTTP(S)
|
||||
4. **Vector sync enabled** on the MCP server
|
||||
|
||||
## Webhook Architecture Overview
|
||||
|
||||
The webhook system has two components:
|
||||
|
||||
1. **Webhook Registration** - Configuring Nextcloud to send change notifications to the MCP server
|
||||
2. **Background Sync Credentials** - Allowing the MCP server to access Nextcloud APIs on behalf of users
|
||||
|
||||
Both must be configured for webhooks to function properly.
|
||||
|
||||
## Deployment Mode Specifics
|
||||
|
||||
### 1. Single-User BasicAuth
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://localhost:8080
|
||||
NEXTCLOUD_USERNAME=admin
|
||||
NEXTCLOUD_PASSWORD=password
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Enable Webhooks:**
|
||||
1. Register webhooks using occ commands (requires Nextcloud admin):
|
||||
```bash
|
||||
# Enable webhook_listeners app
|
||||
php occ app:enable webhook_listeners
|
||||
|
||||
# Register webhooks for vector sync
|
||||
php occ webhook_listeners:add \
|
||||
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
|
||||
--uri "http://mcp-server:8000/webhooks/nextcloud" \
|
||||
--method POST
|
||||
|
||||
# Repeat for other events (see Event Types below)
|
||||
```
|
||||
|
||||
2. Optionally reduce polling frequency:
|
||||
```bash
|
||||
VECTOR_SYNC_SCAN_INTERVAL=86400 # 24 hours
|
||||
```
|
||||
|
||||
**Disable Webhooks:**
|
||||
```bash
|
||||
# List registered webhooks
|
||||
php occ webhook_listeners:list
|
||||
|
||||
# Remove specific webhook by ID
|
||||
php occ webhook_listeners:remove <webhook-id>
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Simplest mode - admin credentials used for all operations
|
||||
- No per-user provisioning required
|
||||
- Background sync runs as the configured admin user
|
||||
|
||||
---
|
||||
|
||||
### 2. Multi-User BasicAuth Pass-Through
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||
ENABLE_BACKGROUND_OPERATIONS=true
|
||||
TOKEN_ENCRYPTION_KEY=<key>
|
||||
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
# OAuth client for Astrolabe API access
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||
```
|
||||
|
||||
**Credential Architecture:**
|
||||
This mode uses **two separate credential mechanisms**:
|
||||
|
||||
1. **OAuth Session** (for management API access, including webhooks):
|
||||
- Obtained via browser OAuth flow (`/oauth/login`)
|
||||
- Stores refresh token in MCP server's `tokens.db`
|
||||
- Used for webhook registration/management APIs
|
||||
|
||||
2. **App Password** (for background sync):
|
||||
- Generated in Nextcloud Security settings
|
||||
- Stored encrypted in Nextcloud's `oc_preferences` via Astrolabe
|
||||
- Used by background scanners to access Nextcloud APIs
|
||||
|
||||
**Enable Webhooks:**
|
||||
|
||||
#### Step 1: Complete OAuth Login (for Management API)
|
||||
Users must authorize the MCP server to access their Nextcloud:
|
||||
|
||||
1. Navigate to **Nextcloud Settings → Astrolabe** (Personal settings)
|
||||
2. Click **"Authorize via OAuth"** under "Option 1"
|
||||
3. Complete OAuth consent flow
|
||||
4. Verify the page shows "Background Sync Access: Active"
|
||||
|
||||
#### Step 2: Configure App Password (for Background Sync)
|
||||
Since OAuth refresh tokens have short expiry, users should also configure an app password:
|
||||
|
||||
1. Navigate to **Nextcloud Settings → Security**
|
||||
2. Generate a new app password (name it "Astrolabe" or "MCP Server")
|
||||
3. Return to **Nextcloud Settings → Astrolabe**
|
||||
4. Under "Option 2: App Password", paste the app password
|
||||
5. Click **Save**
|
||||
|
||||
#### Step 3: Register Webhooks (Admin)
|
||||
Same as Single-User BasicAuth:
|
||||
```bash
|
||||
php occ webhook_listeners:add \
|
||||
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
|
||||
--uri "http://mcp-server:8003/webhooks/nextcloud" \
|
||||
--method POST
|
||||
```
|
||||
|
||||
**Disable Webhooks:**
|
||||
|
||||
*Per-User:*
|
||||
1. Navigate to **Nextcloud Settings → Astrolabe**
|
||||
2. Click **"Revoke Access"** (for OAuth tokens) or **"Revoke Access"** (for app password)
|
||||
|
||||
*System-Wide:*
|
||||
```bash
|
||||
php occ webhook_listeners:remove <webhook-id>
|
||||
```
|
||||
|
||||
**Troubleshooting:**
|
||||
|
||||
If OAuth login fails with "Access forbidden - Your client is not authorized":
|
||||
1. Check if OAuth client is registered:
|
||||
```sql
|
||||
SELECT id, name, client_identifier FROM oc_oidc_clients
|
||||
WHERE dcr = 1 ORDER BY id DESC LIMIT 5;
|
||||
```
|
||||
2. Restart MCP server to trigger DCR re-registration
|
||||
3. Verify `NEXTCLOUD_OIDC_CLIENT_ID` and `NEXTCLOUD_OIDC_CLIENT_SECRET` are set
|
||||
|
||||
If background sync fails with "User no longer provisioned":
|
||||
1. Verify app password is stored:
|
||||
```sql
|
||||
SELECT userid, configkey FROM oc_preferences
|
||||
WHERE appid = 'astrolabe' AND userid = 'username';
|
||||
```
|
||||
2. Ensure user completed **both** OAuth login AND app password setup
|
||||
|
||||
---
|
||||
|
||||
### 3. OAuth Single-Audience (Default OAuth Mode)
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
# No NEXTCLOUD_USERNAME/PASSWORD
|
||||
ENABLE_BACKGROUND_OPERATIONS=true
|
||||
TOKEN_ENCRYPTION_KEY=<key>
|
||||
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Enable Webhooks:**
|
||||
|
||||
#### Step 1: User Provisioning
|
||||
Users authorize via OAuth with `offline_access` scope:
|
||||
|
||||
1. MCP client initiates OAuth flow
|
||||
2. User consents to requested scopes including `offline_access`
|
||||
3. MCP server stores refresh token for background operations
|
||||
|
||||
Alternatively, via Astrolabe UI:
|
||||
1. Navigate to **Nextcloud Settings → Astrolabe**
|
||||
2. Click **"Authorize via OAuth"**
|
||||
3. Complete consent flow
|
||||
|
||||
#### Step 2: Register Webhooks (Admin)
|
||||
```bash
|
||||
php occ webhook_listeners:add \
|
||||
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
|
||||
--uri "http://mcp-server:8001/webhooks/nextcloud" \
|
||||
--method POST
|
||||
```
|
||||
|
||||
**Disable Webhooks:**
|
||||
|
||||
*Per-User:*
|
||||
- Via Astrolabe UI: Click "Disable Indexing" or "Disconnect"
|
||||
- Via MCP tool: Use `revoke_nextcloud_access` if available
|
||||
|
||||
*System-Wide:*
|
||||
```bash
|
||||
php occ webhook_listeners:remove <webhook-id>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. OAuth Token Exchange (RFC 8693)
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
ENABLE_TOKEN_EXCHANGE=true
|
||||
ENABLE_BACKGROUND_OPERATIONS=true
|
||||
TOKEN_ENCRYPTION_KEY=<key>
|
||||
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Enable/Disable Webhooks:**
|
||||
Same process as OAuth Single-Audience. The token exchange happens transparently when the MCP server accesses Nextcloud APIs.
|
||||
|
||||
---
|
||||
|
||||
### 5. Smithery Stateless
|
||||
|
||||
**Configuration:**
|
||||
- Configuration from session URL params
|
||||
- `VECTOR_SYNC_ENABLED=false` (required)
|
||||
|
||||
**Webhooks:**
|
||||
**Not supported.** This mode is stateless with no persistent storage or background operations.
|
||||
|
||||
---
|
||||
|
||||
## Webhook Event Types
|
||||
|
||||
Register these webhook events for full vector sync coverage:
|
||||
|
||||
### File/Note Events
|
||||
```bash
|
||||
# Use BeforeNodeDeletedEvent for deletions (includes node.id)
|
||||
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeWrittenEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCP\Files\Events\Node\BeforeNodeDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
```
|
||||
|
||||
### Calendar Events
|
||||
```bash
|
||||
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
```
|
||||
|
||||
### Tables Events
|
||||
```bash
|
||||
php occ webhook_listeners:add --event "OCA\Tables\Event\RowAddedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCA\Tables\Event\RowUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCA\Tables\Event\RowDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
```
|
||||
|
||||
## Webhook Presets (via Astrolabe UI)
|
||||
|
||||
The Astrolabe app provides preset webhook configurations that can be enabled/disabled via the Admin settings UI:
|
||||
|
||||
| Preset | Events Covered |
|
||||
|--------|----------------|
|
||||
| `notes_sync` | File create/update/delete for .md files |
|
||||
| `calendar_sync` | Calendar object events |
|
||||
| `tables_sync` | Tables row events |
|
||||
| `forms_sync` | Forms submission events |
|
||||
| `files_sync` | All file events (optional, high volume) |
|
||||
|
||||
**Enable Presets:**
|
||||
1. Navigate to **Nextcloud Settings → Astrolabe** (Admin settings)
|
||||
2. Toggle desired presets in "Webhook Configuration"
|
||||
|
||||
**Note:** Presets require the MCP server's management API to be accessible. The API uses OAuth bearer tokens from the user's session.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Webhook Authentication
|
||||
Configure `WEBHOOK_SECRET` to require authentication for incoming webhooks:
|
||||
|
||||
```bash
|
||||
# MCP Server
|
||||
WEBHOOK_SECRET=<generate-random-secret>
|
||||
|
||||
# Nextcloud webhook registration
|
||||
php occ webhook_listeners:add \
|
||||
--event "..." \
|
||||
--uri "$MCP_URL/webhooks/nextcloud" \
|
||||
--header "Authorization: Bearer <secret>"
|
||||
```
|
||||
|
||||
### Token Storage
|
||||
- Refresh tokens and app passwords are encrypted using `TOKEN_ENCRYPTION_KEY`
|
||||
- Store the key securely (environment variable, secrets manager)
|
||||
- Different users have isolated credential storage
|
||||
|
||||
## Monitoring
|
||||
|
||||
### MCP Server Logs
|
||||
```bash
|
||||
# Docker
|
||||
docker compose logs mcp-multi-user-basic | grep -i webhook
|
||||
|
||||
# Key log messages
|
||||
# - "Queued document from webhook: ..." - Success
|
||||
# - "Webhook authentication failed" - Auth error
|
||||
# - "User X no longer provisioned" - Missing credentials
|
||||
```
|
||||
|
||||
### Nextcloud Logs
|
||||
```bash
|
||||
docker compose exec app cat /var/www/html/data/nextcloud.log | \
|
||||
jq 'select(.message | contains("webhook"))' | tail
|
||||
```
|
||||
|
||||
### Database Checks
|
||||
```sql
|
||||
-- Check registered webhooks
|
||||
SELECT * FROM oc_webhook_listeners;
|
||||
|
||||
-- Check OAuth clients
|
||||
SELECT id, name, token_type FROM oc_oidc_clients WHERE dcr = 1;
|
||||
|
||||
-- Check user credentials in Astrolabe
|
||||
SELECT userid, configkey FROM oc_preferences WHERE appid = 'astrolabe';
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### "Access forbidden - Your client is not authorized to connect"
|
||||
**Cause:** OAuth client registration expired or not present in Nextcloud
|
||||
**Fix:** Restart MCP server to trigger DCR re-registration
|
||||
|
||||
### "User X no longer provisioned, stopping scanner"
|
||||
**Cause:** Background sync credentials missing or expired
|
||||
**Fix:** User must complete credential provisioning (see mode-specific steps)
|
||||
|
||||
### "Failed to fetch" in browser console during OAuth
|
||||
**Cause:** Network issue between browser and MCP server callback endpoint
|
||||
**Fix:** Verify MCP server is accessible at the configured `NEXTCLOUD_MCP_SERVER_URL`
|
||||
|
||||
### Webhooks not firing
|
||||
**Causes:**
|
||||
1. `webhook_listeners` app not enabled
|
||||
2. Webhook not registered for the event type
|
||||
3. Background job workers not running
|
||||
**Fix:**
|
||||
```bash
|
||||
php occ app:enable webhook_listeners
|
||||
php occ background:cron # or configure systemd cron
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Add app_passwords table for multi-user BasicAuth mode
|
||||
|
||||
This migration adds support for storing app passwords that are provisioned
|
||||
via Astrolabe's personal settings. This enables background sync in
|
||||
multi-user BasicAuth mode without requiring OAuth.
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-01-13 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "002"
|
||||
down_revision = "001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add app_passwords table for multi-user BasicAuth mode."""
|
||||
|
||||
# App passwords table for multi-user BasicAuth background sync
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS app_passwords (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
encrypted_password BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Index for efficient user lookups
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_app_passwords_updated
|
||||
ON app_passwords(updated_at)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop app_passwords table."""
|
||||
|
||||
op.execute("DROP INDEX IF EXISTS idx_app_passwords_updated")
|
||||
op.execute("DROP TABLE IF EXISTS app_passwords")
|
||||
@@ -10,12 +10,18 @@ All endpoints use OAuth bearer token authentication via UnifiedTokenVerifier.
|
||||
The PHP app obtains tokens through PKCE flow and uses them to access these endpoints.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from importlib.metadata import version
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
@@ -25,6 +31,23 @@ logger = logging.getLogger(__name__)
|
||||
# Get package version from metadata
|
||||
__version__ = version("nextcloud-mcp-server")
|
||||
|
||||
# App password format regex (Nextcloud format: xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
|
||||
APP_PASSWORD_PATTERN = re.compile(
|
||||
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$"
|
||||
)
|
||||
|
||||
# Timeout for Nextcloud API validation requests (seconds)
|
||||
NEXTCLOUD_VALIDATION_TIMEOUT = 10.0
|
||||
|
||||
# Rate limiting configuration for app password provisioning
|
||||
# Limits: 5 attempts per user per hour
|
||||
RATE_LIMIT_MAX_ATTEMPTS = 5
|
||||
RATE_LIMIT_WINDOW_SECONDS = 3600 # 1 hour
|
||||
|
||||
# In-memory rate limiter storage
|
||||
# Structure: {user_id: [(timestamp, success), ...]}
|
||||
_rate_limit_attempts: dict[str, list[tuple[float, bool]]] = defaultdict(list)
|
||||
|
||||
# Track server start time for uptime calculation
|
||||
_server_start_time = time.time()
|
||||
|
||||
@@ -181,6 +204,141 @@ def _validate_query_string(query: str, max_length: int = 10000) -> None:
|
||||
raise ValueError(f"Query too long: maximum {max_length} characters")
|
||||
|
||||
|
||||
async def _get_app_password_storage(request: Request) -> "RefreshTokenStorage":
|
||||
"""Get or initialize RefreshTokenStorage for app password operations.
|
||||
|
||||
Checks app.state.storage first, then falls back to creating from environment.
|
||||
This helper avoids repeated storage initialization logic across endpoints.
|
||||
|
||||
Args:
|
||||
request: Starlette request with app state
|
||||
|
||||
Returns:
|
||||
Initialized RefreshTokenStorage instance
|
||||
"""
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
storage = getattr(request.app.state, "storage", None)
|
||||
|
||||
if not storage:
|
||||
# Multi-user BasicAuth mode may not have oauth_context
|
||||
# Initialize storage from environment
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
return storage
|
||||
|
||||
|
||||
def _check_rate_limit(user_id: str) -> tuple[bool, int]:
|
||||
"""Check if user is rate limited for app password operations.
|
||||
|
||||
Implements a sliding window rate limiter to prevent brute-force attacks
|
||||
on the app password provisioning endpoint.
|
||||
|
||||
Args:
|
||||
user_id: User identifier to check
|
||||
|
||||
Returns:
|
||||
Tuple of (is_allowed, seconds_until_retry)
|
||||
- is_allowed: True if request should be allowed
|
||||
- seconds_until_retry: Seconds to wait if rate limited (0 if allowed)
|
||||
"""
|
||||
current_time = time.time()
|
||||
window_start = current_time - RATE_LIMIT_WINDOW_SECONDS
|
||||
|
||||
# Clean up old attempts outside the window
|
||||
_rate_limit_attempts[user_id] = [
|
||||
(ts, success)
|
||||
for ts, success in _rate_limit_attempts[user_id]
|
||||
if ts > window_start
|
||||
]
|
||||
|
||||
# Count recent attempts (both successful and failed)
|
||||
recent_attempts = len(_rate_limit_attempts[user_id])
|
||||
|
||||
if recent_attempts >= RATE_LIMIT_MAX_ATTEMPTS:
|
||||
# Find when the oldest attempt in the window will expire
|
||||
oldest_attempt = min(ts for ts, _ in _rate_limit_attempts[user_id])
|
||||
seconds_until_retry = int(
|
||||
oldest_attempt + RATE_LIMIT_WINDOW_SECONDS - current_time
|
||||
)
|
||||
return False, max(1, seconds_until_retry)
|
||||
|
||||
return True, 0
|
||||
|
||||
|
||||
def _record_rate_limit_attempt(user_id: str, success: bool) -> None:
|
||||
"""Record an app password provisioning attempt for rate limiting.
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
success: Whether the attempt was successful
|
||||
"""
|
||||
_rate_limit_attempts[user_id].append((time.time(), success))
|
||||
|
||||
|
||||
def _extract_basic_auth(
|
||||
request: Request, path_user_id: str
|
||||
) -> tuple[str, str, JSONResponse | None]:
|
||||
"""Extract and validate BasicAuth credentials from request.
|
||||
|
||||
Validates:
|
||||
1. Authorization header is present and valid BasicAuth format
|
||||
2. Username in credentials matches the path user_id
|
||||
|
||||
Args:
|
||||
request: Starlette request with Authorization header
|
||||
path_user_id: User ID from the URL path to verify against
|
||||
|
||||
Returns:
|
||||
Tuple of (username, password, error_response)
|
||||
- If successful: (username, password, None)
|
||||
- If failed: ("", "", JSONResponse with error)
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
if not auth_header or not auth_header.startswith("Basic "):
|
||||
return (
|
||||
"",
|
||||
"",
|
||||
JSONResponse(
|
||||
{"success": False, "error": "Missing BasicAuth credentials"},
|
||||
status_code=401,
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
# Decode BasicAuth
|
||||
encoded = auth_header.split(" ", 1)[1]
|
||||
decoded = base64.b64decode(encoded).decode("utf-8")
|
||||
username, password = decoded.split(":", 1)
|
||||
except Exception:
|
||||
return (
|
||||
"",
|
||||
"",
|
||||
JSONResponse(
|
||||
{"success": False, "error": "Invalid BasicAuth format"},
|
||||
status_code=401,
|
||||
),
|
||||
)
|
||||
|
||||
# Verify username matches path user_id
|
||||
if username != path_user_id:
|
||||
logger.warning(
|
||||
f"Username mismatch in app password operation for path user {path_user_id}"
|
||||
)
|
||||
return (
|
||||
"",
|
||||
"",
|
||||
JSONResponse(
|
||||
{"success": False, "error": "Username does not match path user_id"},
|
||||
status_code=403,
|
||||
),
|
||||
)
|
||||
|
||||
return username, password, None
|
||||
|
||||
|
||||
async def get_server_status(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/status - Server status and version.
|
||||
|
||||
@@ -510,6 +668,254 @@ async def revoke_user_access(request: Request) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
async def provision_app_password(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/users/{user_id}/app-password - Store app password for background sync.
|
||||
|
||||
This endpoint is used by Astrolabe (Nextcloud PHP app) to provision app passwords
|
||||
for multi-user BasicAuth mode background sync.
|
||||
|
||||
The request must include BasicAuth credentials where:
|
||||
- username: Nextcloud user ID (must match path user_id)
|
||||
- password: The app password being provisioned
|
||||
|
||||
The MCP server validates the app password against Nextcloud before storing it.
|
||||
This proves the user owns the password and has access to Nextcloud.
|
||||
|
||||
Security model:
|
||||
- User identity is verified via BasicAuth against Nextcloud
|
||||
- App password is encrypted before storage
|
||||
- Only the user who owns the password can provision it
|
||||
- Rate limited to prevent brute-force attacks
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
# Get user_id from path
|
||||
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,
|
||||
)
|
||||
|
||||
# Check rate limit before processing
|
||||
is_allowed, retry_after = _check_rate_limit(path_user_id)
|
||||
if not is_allowed:
|
||||
logger.warning(
|
||||
f"Rate limit exceeded for app password provisioning: {path_user_id}"
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Rate limit exceeded. Try again in {retry_after} seconds.",
|
||||
},
|
||||
status_code=429,
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
# Extract and validate BasicAuth credentials
|
||||
username, app_password, error_response = _extract_basic_auth(request, path_user_id)
|
||||
if error_response is not None:
|
||||
_record_rate_limit_attempt(path_user_id, success=False)
|
||||
return error_response
|
||||
|
||||
# Validate app password format
|
||||
if not APP_PASSWORD_PATTERN.match(app_password):
|
||||
_record_rate_limit_attempt(path_user_id, success=False)
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid app password format"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get Nextcloud host from settings
|
||||
settings = get_settings()
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
|
||||
if not nextcloud_host:
|
||||
logger.error("NEXTCLOUD_HOST not configured")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Server not configured"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Validate app password against Nextcloud
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
||||
# Use OCS API to verify credentials
|
||||
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||
response = await client.get(
|
||||
test_url,
|
||||
auth=(username, app_password),
|
||||
params={"format": "json"},
|
||||
headers={"OCS-APIRequest": "true"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
f"App password validation failed for user: HTTP {response.status_code}"
|
||||
)
|
||||
_record_rate_limit_attempt(path_user_id, success=False)
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid app password"},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Verify the user ID from response matches
|
||||
data = response.json()
|
||||
ocs_user_id = data.get("ocs", {}).get("data", {}).get("id")
|
||||
if ocs_user_id != username:
|
||||
logger.warning("User ID mismatch in OCS response")
|
||||
_record_rate_limit_attempt(path_user_id, success=False)
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "User ID mismatch"},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Failed to validate app password: {e}")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Failed to validate credentials"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Store the validated app password
|
||||
try:
|
||||
storage = await _get_app_password_storage(request)
|
||||
await storage.store_app_password(username, app_password)
|
||||
|
||||
_record_rate_limit_attempt(path_user_id, success=True)
|
||||
logger.info(f"Provisioned app password for user: {username}")
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"App password stored for {username}",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "provision_app_password")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def get_app_password_status(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/users/{user_id}/app-password - Check if user has provisioned app password.
|
||||
|
||||
Returns status of background sync access for multi-user BasicAuth mode.
|
||||
|
||||
Requires BasicAuth with the user's app password for authentication.
|
||||
"""
|
||||
# Get user_id from path
|
||||
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,
|
||||
)
|
||||
|
||||
# Extract and validate BasicAuth credentials
|
||||
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)
|
||||
app_password = await storage.get_app_password(username)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"user_id": username,
|
||||
"has_app_password": app_password is not None,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "get_app_password_status")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def delete_app_password(request: Request) -> JSONResponse:
|
||||
"""DELETE /api/v1/users/{user_id}/app-password - Delete stored app password.
|
||||
|
||||
Removes the user's app password from MCP server storage.
|
||||
|
||||
Requires BasicAuth with the user's credentials.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
# Get user_id from path
|
||||
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,
|
||||
)
|
||||
|
||||
# Extract and validate BasicAuth credentials
|
||||
username, password, error_response = _extract_basic_auth(request, path_user_id)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
# Validate credentials against Nextcloud
|
||||
settings = get_settings()
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
||||
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||
response = await client.get(
|
||||
test_url,
|
||||
auth=(username, password),
|
||||
params={"format": "json"},
|
||||
headers={"OCS-APIRequest": "true"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid credentials"},
|
||||
status_code=401,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Failed to validate credentials: {e}")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Failed to validate credentials"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
try:
|
||||
storage = await _get_app_password_storage(request)
|
||||
deleted = await storage.delete_app_password(username)
|
||||
|
||||
if deleted:
|
||||
logger.info(f"Deleted app password for user: {username}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"App password deleted for {username}",
|
||||
}
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": "No app password found to delete",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "delete_app_password")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def get_installed_apps(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/apps - Get list of installed Nextcloud apps.
|
||||
|
||||
|
||||
@@ -2012,7 +2012,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
checks["auth_mode"] = "multi_user_basic"
|
||||
checks["auth_configured"] = "ok"
|
||||
# Indicate if app passwords are supported (when offline_access enabled)
|
||||
checks["supports_app_passwords"] = settings.enable_offline_access
|
||||
checks["supports_app_passwords"] = get_settings().enable_offline_access
|
||||
elif mode == AuthMode.SINGLE_USER_BASIC:
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||
@@ -2029,9 +2029,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
|
||||
# Check Qdrant status if using network mode (external Qdrant service)
|
||||
# In-memory and persistent modes use embedded Qdrant, no external service to check
|
||||
vector_sync_enabled = (
|
||||
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
)
|
||||
# Note: get_settings() supports both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED
|
||||
settings = get_settings()
|
||||
vector_sync_enabled = settings.vector_sync_enabled
|
||||
qdrant_url = os.getenv("QDRANT_URL") # Only set in network mode
|
||||
|
||||
if vector_sync_enabled and qdrant_url:
|
||||
@@ -2114,13 +2114,16 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
if enable_management_apis:
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
create_webhook,
|
||||
delete_app_password,
|
||||
delete_webhook,
|
||||
get_app_password_status,
|
||||
get_chunk_context,
|
||||
get_installed_apps,
|
||||
get_server_status,
|
||||
get_user_session,
|
||||
get_vector_sync_status,
|
||||
list_webhooks,
|
||||
provision_app_password,
|
||||
revoke_user_access,
|
||||
unified_search,
|
||||
vector_search,
|
||||
@@ -2148,6 +2151,28 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
methods=["POST"],
|
||||
)
|
||||
)
|
||||
# App password endpoints for multi-user BasicAuth mode
|
||||
routes.append(
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
)
|
||||
)
|
||||
routes.append(
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
get_app_password_status,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
routes.append(
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
delete_app_password,
|
||||
methods=["DELETE"],
|
||||
)
|
||||
)
|
||||
routes.append(
|
||||
Route("/api/v1/vector-viz/search", vector_search, methods=["POST"])
|
||||
)
|
||||
@@ -2166,6 +2191,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
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/vector-viz/search, /api/v1/search, /api/v1/apps, "
|
||||
"/api/v1/webhooks"
|
||||
)
|
||||
|
||||
@@ -1240,6 +1240,180 @@ class RefreshTokenStorage:
|
||||
|
||||
return deleted
|
||||
|
||||
# ============================================================================
|
||||
# App Password Storage (multi-user BasicAuth mode)
|
||||
# ============================================================================
|
||||
|
||||
async def store_app_password(
|
||||
self,
|
||||
user_id: str,
|
||||
app_password: str,
|
||||
) -> None:
|
||||
"""
|
||||
Store encrypted app password for background sync (multi-user BasicAuth mode).
|
||||
|
||||
Args:
|
||||
user_id: Nextcloud user ID
|
||||
app_password: Nextcloud app password to store
|
||||
"""
|
||||
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."
|
||||
)
|
||||
|
||||
encrypted_password = self.cipher.encrypt(app_password.encode())
|
||||
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)
|
||||
VALUES (
|
||||
?,
|
||||
?,
|
||||
COALESCE((SELECT created_at FROM app_passwords WHERE user_id = ?), ?),
|
||||
?
|
||||
)
|
||||
""",
|
||||
(user_id, encrypted_password, user_id, now, now),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "insert", duration, "success")
|
||||
logger.info(f"Stored app password for user {user_id}")
|
||||
|
||||
except Exception:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "insert", duration, "error")
|
||||
raise
|
||||
|
||||
# Audit log
|
||||
await self._audit_log(
|
||||
event="store_app_password",
|
||||
user_id=user_id,
|
||||
auth_method="app_password",
|
||||
)
|
||||
|
||||
async def get_app_password(self, user_id: str) -> Optional[str]:
|
||||
"""
|
||||
Retrieve and decrypt app password for a user.
|
||||
|
||||
Args:
|
||||
user_id: Nextcloud user ID
|
||||
|
||||
Returns:
|
||||
Decrypted app password, 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 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 = row[0]
|
||||
decrypted_password = self.cipher.decrypt(encrypted_password).decode()
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "success")
|
||||
logger.debug(f"Retrieved app password for user {user_id}")
|
||||
|
||||
return decrypted_password
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "error")
|
||||
logger.error(f"Failed to decrypt app password for user {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def delete_app_password(self, user_id: str) -> bool:
|
||||
"""
|
||||
Delete app password for a user.
|
||||
|
||||
Args:
|
||||
user_id: Nextcloud user ID
|
||||
|
||||
Returns:
|
||||
True if password 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 app_passwords 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 app password for user {user_id}")
|
||||
await self._audit_log(
|
||||
event="delete_app_password",
|
||||
user_id=user_id,
|
||||
auth_method="app_password",
|
||||
)
|
||||
else:
|
||||
logger.debug(f"No app password to delete for user {user_id}")
|
||||
|
||||
return deleted
|
||||
|
||||
except Exception:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "delete", duration, "error")
|
||||
raise
|
||||
|
||||
async def get_all_app_password_user_ids(self) -> list[str]:
|
||||
"""
|
||||
Get list of all user IDs with stored app passwords.
|
||||
|
||||
Returns:
|
||||
List of user IDs
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT user_id FROM app_passwords ORDER BY updated_at DESC"
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
user_ids = [row[0] for row in rows]
|
||||
logger.debug(f"Found {len(user_ids)} users with app passwords")
|
||||
return user_ids
|
||||
|
||||
|
||||
async def generate_encryption_key() -> str:
|
||||
"""
|
||||
|
||||
@@ -20,6 +20,7 @@ from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -106,9 +107,9 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
||||
"status": str, # "syncing" or "idle"
|
||||
}
|
||||
"""
|
||||
# Check if vector sync is enabled
|
||||
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
if not vector_sync_enabled:
|
||||
# Check if vector sync is enabled (supports both old and new env var names)
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -127,10 +128,8 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
||||
# Get Qdrant client and query indexed count
|
||||
indexed_count = 0
|
||||
try:
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
# Count documents in collection
|
||||
@@ -634,7 +633,9 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
"""
|
||||
|
||||
# Check if vector sync is enabled (needed for Welcome tab)
|
||||
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
# Note: get_settings() supports both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED
|
||||
settings = get_settings()
|
||||
vector_sync_enabled = settings.vector_sync_enabled
|
||||
|
||||
# Render template
|
||||
template = _jinja_env.get_template("user_info.html")
|
||||
|
||||
@@ -386,11 +386,17 @@ class DeckClient(BaseNextcloudClient):
|
||||
order: int,
|
||||
target_stack_id: int,
|
||||
) -> None:
|
||||
# Use the non-API route /cards/{cardId}/reorder which correctly reads
|
||||
# stackId from the body. The API route /api/.../stacks/{stackId}/cards/...
|
||||
# has a parameter conflict where URL stackId overrides body stackId.
|
||||
# See: https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
|
||||
json_data = {"order": order, "stackId": target_stack_id}
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/reorder",
|
||||
f"/apps/deck/cards/{card_id}/reorder",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Labels
|
||||
|
||||
@@ -637,7 +637,9 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
@mcp.tool(
|
||||
title="Remove Label from Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
@@ -692,7 +694,9 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
@mcp.tool(
|
||||
title="Unassign User from Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Semantic search MCP tools using vector database."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import anyio
|
||||
from httpx import RequestError
|
||||
@@ -658,12 +657,11 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
after creating or updating content across all indexed apps.
|
||||
"""
|
||||
|
||||
# Check if vector sync is enabled
|
||||
vector_sync_enabled = (
|
||||
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
)
|
||||
# Check if vector sync is enabled (supports both old and new env var names)
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
if not vector_sync_enabled:
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return VectorSyncStatusResponse(
|
||||
indexed_count=0,
|
||||
pending_count=0,
|
||||
|
||||
@@ -8,8 +8,8 @@ Manages background vector sync for multi-user deployments:
|
||||
Authentication strategies are mutually exclusive by deployment mode:
|
||||
|
||||
Multi-user BasicAuth mode (ENABLE_MULTI_USER_BASIC_AUTH=true):
|
||||
- Uses app passwords obtained via Astrolabe Management API
|
||||
- Users provision via Astrolabe personal settings
|
||||
- Uses app passwords stored locally in MCP server's database
|
||||
- Users provision via Astrolabe personal settings, which sends to MCP API
|
||||
- OAuth is NOT used
|
||||
|
||||
OAuth mode (with external IdP like Keycloak):
|
||||
@@ -33,7 +33,6 @@ from anyio.streams.memory import (
|
||||
)
|
||||
from httpx import BasicAuth
|
||||
|
||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
|
||||
@@ -71,15 +70,18 @@ class UserSyncState:
|
||||
async def get_user_client_basic_auth(
|
||||
user_id: str,
|
||||
nextcloud_host: str,
|
||||
storage: "RefreshTokenStorage | None" = None,
|
||||
) -> NextcloudClient:
|
||||
"""Get an authenticated NextcloudClient using app password (BasicAuth mode).
|
||||
|
||||
For multi-user BasicAuth deployments where users provision app passwords
|
||||
via Astrolabe personal settings. OAuth is NOT used in this mode.
|
||||
via Astrolabe personal settings. The app password is stored locally in the
|
||||
MCP server's database after being provisioned through the management API.
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
nextcloud_host: Nextcloud base URL
|
||||
storage: Optional RefreshTokenStorage instance (created from env if not provided)
|
||||
|
||||
Returns:
|
||||
Authenticated NextcloudClient with BasicAuth
|
||||
@@ -87,21 +89,15 @@ async def get_user_client_basic_auth(
|
||||
Raises:
|
||||
NotProvisionedError: If user has not provisioned an app password
|
||||
"""
|
||||
settings = get_settings()
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||
raise NotProvisionedError(
|
||||
"Astrolabe client credentials not configured. "
|
||||
"Set OIDC_CLIENT_ID and OIDC_CLIENT_SECRET for app password retrieval."
|
||||
)
|
||||
# Get or create storage instance
|
||||
if storage is None:
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
astrolabe = AstrolabeClient(
|
||||
nextcloud_host=nextcloud_host,
|
||||
client_id=settings.oidc_client_id,
|
||||
client_secret=settings.oidc_client_secret,
|
||||
)
|
||||
|
||||
app_password = await astrolabe.get_user_app_password(user_id)
|
||||
# Retrieve app password from local storage
|
||||
app_password = await storage.get_app_password(user_id)
|
||||
|
||||
if not app_password:
|
||||
raise NotProvisionedError(
|
||||
@@ -419,8 +415,15 @@ async def user_manager_task(
|
||||
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
# Get current provisioned users
|
||||
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
|
||||
# Get current provisioned users based on mode
|
||||
if use_basic_auth:
|
||||
# BasicAuth mode: query app_passwords table
|
||||
provisioned_users = set(
|
||||
await refresh_token_storage.get_all_app_password_user_ids()
|
||||
)
|
||||
else:
|
||||
# OAuth mode: query refresh_tokens table
|
||||
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
|
||||
active_users = set(user_states.keys())
|
||||
|
||||
# Start scanners for new users
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.60.2"
|
||||
version = "0.61.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"}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"""Integration tests for app password provisioning via Astrolabe.
|
||||
"""Integration tests for app password provisioning via management API.
|
||||
|
||||
Tests the complete flow for multi-user BasicAuth mode:
|
||||
1. User stores app password via Astrolabe API
|
||||
2. MCP server retrieves it via OAuth client credentials
|
||||
3. Background sync uses it to access Nextcloud (NOT OAuth refresh tokens)
|
||||
1. User stores app password via management API endpoint
|
||||
2. MCP server stores it locally (encrypted)
|
||||
3. Background sync uses locally stored password to access Nextcloud
|
||||
|
||||
These tests verify that BasicAuth and OAuth are completely separate concerns
|
||||
with no fallback between them.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.vector.oauth_sync import (
|
||||
NotProvisionedError,
|
||||
get_user_client,
|
||||
@@ -21,140 +24,60 @@ from nextcloud_mcp_server.vector.oauth_sync import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_astrolabe_client_initialization():
|
||||
"""Test AstrolabeClient can be instantiated."""
|
||||
client = AstrolabeClient(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
)
|
||||
@pytest.fixture
|
||||
def encryption_key():
|
||||
"""Generate a test encryption key."""
|
||||
return Fernet.generate_key().decode()
|
||||
|
||||
assert client is not None
|
||||
assert client.nextcloud_host == "http://localhost:8080"
|
||||
assert client.client_id == "test-client"
|
||||
assert client.client_secret == "test-secret"
|
||||
assert client._token_cache is None
|
||||
|
||||
@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_provisioning.db"
|
||||
storage = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=encryption_key
|
||||
)
|
||||
await storage.initialize()
|
||||
yield storage
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_astrolabe_client_get_access_token_requires_oidc():
|
||||
"""Test that getting access token requires OIDC discovery endpoint."""
|
||||
client = AstrolabeClient(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
)
|
||||
async def test_basic_auth_mode_uses_local_storage(temp_storage, mocker):
|
||||
"""Test that BasicAuth mode uses locally stored app passwords.
|
||||
|
||||
# This will fail without proper OIDC setup, which is expected
|
||||
# The test verifies the client follows the OAuth client credentials flow
|
||||
try:
|
||||
token = await client.get_access_token()
|
||||
# If we get here, OIDC is configured
|
||||
assert token is not None
|
||||
except Exception as e:
|
||||
# Expected if OIDC not fully configured for test client
|
||||
# 400/401/403/404 all indicate the flow is working but credentials are invalid
|
||||
assert any(code in str(e) for code in ["400", "401", "403", "404"])
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_get_user_app_password_returns_none_for_unconfigured_user():
|
||||
"""Test that get_user_app_password returns None for users without app passwords."""
|
||||
# This requires valid OAuth client credentials
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||
pytest.skip("OAuth client credentials not configured")
|
||||
|
||||
client = AstrolabeClient(
|
||||
nextcloud_host=settings.nextcloud_host or "http://localhost:8080",
|
||||
client_id=settings.oidc_client_id,
|
||||
client_secret=settings.oidc_client_secret,
|
||||
)
|
||||
|
||||
# Try to get app password for a user that hasn't provisioned one
|
||||
try:
|
||||
app_password = await client.get_user_app_password("nonexistent_user")
|
||||
# Should return None for unconfigured user (404 response)
|
||||
assert app_password is None
|
||||
except Exception as e:
|
||||
# May fail with auth error if OAuth not fully configured
|
||||
assert any(code in str(e) for code in ["400", "401", "403", "404"])
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_basic_auth_mode_uses_app_password_only(mocker):
|
||||
"""Test that BasicAuth mode uses ONLY app passwords, NOT OAuth tokens.
|
||||
|
||||
In multi-user BasicAuth mode, OAuth refresh tokens are NOT used.
|
||||
This is a complete separation of concerns.
|
||||
In multi-user BasicAuth mode, app passwords are stored locally
|
||||
in the MCP server's database after being provisioned via the API.
|
||||
"""
|
||||
# Mock settings to have client credentials
|
||||
mock_settings = mocker.MagicMock()
|
||||
mock_settings.oidc_client_id = "test-client-id"
|
||||
mock_settings.oidc_client_secret = "test-client-secret"
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
|
||||
return_value=mock_settings,
|
||||
)
|
||||
# Store an app password in local storage
|
||||
await temp_storage.store_app_password("test_user", "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB")
|
||||
|
||||
# Mock AstrolabeClient to return an app password
|
||||
mock_astrolabe = mocker.AsyncMock()
|
||||
mock_astrolabe.get_user_app_password.return_value = "test-app-password-12345"
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
|
||||
return_value=mock_astrolabe,
|
||||
)
|
||||
|
||||
# Call get_user_client in BasicAuth mode
|
||||
_client = await get_user_client(
|
||||
# Call get_user_client_basic_auth with local storage
|
||||
client = await get_user_client_basic_auth(
|
||||
user_id="test_user",
|
||||
token_broker=None, # No token broker needed for BasicAuth mode
|
||||
nextcloud_host="http://localhost:8080",
|
||||
use_basic_auth=True,
|
||||
storage=temp_storage,
|
||||
)
|
||||
|
||||
# Verify app password was requested
|
||||
mock_astrolabe.get_user_app_password.assert_called_once_with("test_user")
|
||||
|
||||
# Verify client was created successfully with correct username
|
||||
assert _client is not None
|
||||
assert _client.username == "test_user"
|
||||
# Verify client was created with correct credentials
|
||||
assert client is not None
|
||||
assert client.username == "test_user"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_basic_auth_mode_raises_error_without_app_password(mocker):
|
||||
async def test_basic_auth_mode_raises_error_without_app_password(temp_storage):
|
||||
"""Test that BasicAuth mode raises NotProvisionedError if no app password.
|
||||
|
||||
There is NO fallback to OAuth - if no app password, user must provision one.
|
||||
"""
|
||||
# Mock settings to have client credentials
|
||||
mock_settings = mocker.MagicMock()
|
||||
mock_settings.oidc_client_id = "test-client-id"
|
||||
mock_settings.oidc_client_secret = "test-client-secret"
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
|
||||
return_value=mock_settings,
|
||||
)
|
||||
# Don't store any app password
|
||||
|
||||
# Mock AstrolabeClient to return None (no app password)
|
||||
mock_astrolabe = mocker.AsyncMock()
|
||||
mock_astrolabe.get_user_app_password.return_value = None
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
|
||||
return_value=mock_astrolabe,
|
||||
)
|
||||
|
||||
# Call get_user_client in BasicAuth mode - should raise NotProvisionedError
|
||||
# Call get_user_client_basic_auth - should raise NotProvisionedError
|
||||
with pytest.raises(NotProvisionedError) as exc_info:
|
||||
await get_user_client(
|
||||
await get_user_client_basic_auth(
|
||||
user_id="test_user",
|
||||
token_broker=None,
|
||||
nextcloud_host="http://localhost:8080",
|
||||
use_basic_auth=True,
|
||||
storage=temp_storage,
|
||||
)
|
||||
|
||||
# Verify error message mentions app password provisioning
|
||||
@@ -162,6 +85,33 @@ async def test_basic_auth_mode_raises_error_without_app_password(mocker):
|
||||
assert "test_user" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_get_user_client_dispatches_to_basic_auth(temp_storage, mocker):
|
||||
"""Test that get_user_client dispatches to BasicAuth mode correctly."""
|
||||
# Store an app password
|
||||
await temp_storage.store_app_password("alice", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
|
||||
|
||||
# Mock RefreshTokenStorage.from_env at the source module
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.auth.storage.RefreshTokenStorage.from_env",
|
||||
return_value=temp_storage,
|
||||
)
|
||||
# Also mock initialize since from_env returns an uninitialized instance
|
||||
mocker.patch.object(temp_storage, "initialize", return_value=None)
|
||||
|
||||
# Call get_user_client in BasicAuth mode
|
||||
client = await get_user_client(
|
||||
user_id="alice",
|
||||
token_broker=None, # No token broker needed for BasicAuth mode
|
||||
nextcloud_host="http://localhost:8080",
|
||||
use_basic_auth=True,
|
||||
)
|
||||
|
||||
# Verify client was created successfully
|
||||
assert client is not None
|
||||
assert client.username == "alice"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_oauth_mode_uses_refresh_token_only(mocker):
|
||||
"""Test that OAuth mode uses ONLY refresh tokens, NOT app passwords.
|
||||
@@ -183,7 +133,7 @@ async def test_oauth_mode_uses_refresh_token_only(mocker):
|
||||
use_basic_auth=False, # OAuth mode
|
||||
)
|
||||
|
||||
# Verify token broker was called (NOT Astrolabe)
|
||||
# Verify token broker was called
|
||||
mock_token_broker.get_background_token.assert_called_once()
|
||||
|
||||
|
||||
@@ -213,38 +163,6 @@ async def test_oauth_mode_raises_error_without_token(mocker):
|
||||
assert "test_user" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_get_user_client_basic_auth_function(mocker):
|
||||
"""Test the dedicated get_user_client_basic_auth function."""
|
||||
# Mock settings to have client credentials
|
||||
mock_settings = mocker.MagicMock()
|
||||
mock_settings.oidc_client_id = "test-client-id"
|
||||
mock_settings.oidc_client_secret = "test-client-secret"
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
|
||||
return_value=mock_settings,
|
||||
)
|
||||
|
||||
# Mock AstrolabeClient
|
||||
mock_astrolabe = mocker.AsyncMock()
|
||||
mock_astrolabe.get_user_app_password.return_value = "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
|
||||
return_value=mock_astrolabe,
|
||||
)
|
||||
|
||||
# Call dedicated function
|
||||
client = await get_user_client_basic_auth(
|
||||
user_id="alice",
|
||||
nextcloud_host="http://localhost:8080",
|
||||
)
|
||||
|
||||
assert client is not None
|
||||
assert client.username == "alice"
|
||||
mock_astrolabe.get_user_app_password.assert_called_once_with("alice")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_get_user_client_oauth_function(mocker):
|
||||
"""Test the dedicated get_user_client_oauth function."""
|
||||
@@ -276,3 +194,69 @@ async def test_oauth_mode_requires_token_broker():
|
||||
nextcloud_host="http://localhost:8080",
|
||||
use_basic_auth=False, # OAuth mode
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_multiple_users_basic_auth_mode(temp_storage, mocker):
|
||||
"""Test that multiple users can be provisioned independently."""
|
||||
# Store app passwords for multiple users
|
||||
users = {
|
||||
"alice": "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa",
|
||||
"bob": "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb",
|
||||
"charlie": "ccccc-ccccc-ccccc-ccccc-ccccc",
|
||||
}
|
||||
|
||||
for user_id, password in users.items():
|
||||
await temp_storage.store_app_password(user_id, password)
|
||||
|
||||
# Verify each user can get a client
|
||||
for user_id in users.keys():
|
||||
client = await get_user_client_basic_auth(
|
||||
user_id=user_id,
|
||||
nextcloud_host="http://localhost:8080",
|
||||
storage=temp_storage,
|
||||
)
|
||||
assert client is not None
|
||||
assert client.username == user_id
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_get_all_provisioned_users(temp_storage):
|
||||
"""Test that we can list all provisioned users for BasicAuth mode."""
|
||||
# Store app passwords for multiple users
|
||||
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
|
||||
await temp_storage.store_app_password("bob", "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb")
|
||||
|
||||
# Get all provisioned users
|
||||
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||
|
||||
assert len(user_ids) == 2
|
||||
assert "alice" in user_ids
|
||||
assert "bob" in user_ids
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_revoke_app_password(temp_storage):
|
||||
"""Test that deleting app password revokes background access."""
|
||||
# Provision user
|
||||
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
|
||||
|
||||
# Verify user is provisioned
|
||||
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||
assert "alice" in user_ids
|
||||
|
||||
# Revoke access
|
||||
deleted = await temp_storage.delete_app_password("alice")
|
||||
assert deleted is True
|
||||
|
||||
# Verify user is no longer provisioned
|
||||
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||
assert "alice" not in user_ids
|
||||
|
||||
# Verify get_user_client now raises NotProvisionedError
|
||||
with pytest.raises(NotProvisionedError):
|
||||
await get_user_client_basic_auth(
|
||||
user_id="alice",
|
||||
nextcloud_host="http://localhost:8080",
|
||||
storage=temp_storage,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Integration tests for Deck card reorder functionality.
|
||||
|
||||
Tests issue #469: Moving Deck card from one column (stack) to another not working.
|
||||
https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def board_with_two_stacks(nc_client: NextcloudClient):
|
||||
"""Create a temporary board with two stacks for testing card movement.
|
||||
|
||||
Yields:
|
||||
tuple: (board_data, source_stack_data, target_stack_data)
|
||||
"""
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
board_title = f"Reorder Test Board {unique_suffix}"
|
||||
board = None
|
||||
|
||||
logger.info(f"Creating board with two stacks: {board_title}")
|
||||
try:
|
||||
board = await nc_client.deck.create_board(board_title, "0000FF")
|
||||
board_id = board.id
|
||||
|
||||
# Create source stack (stack 1)
|
||||
source_stack = await nc_client.deck.create_stack(
|
||||
board_id, f"Source Stack {unique_suffix}", order=1
|
||||
)
|
||||
source_stack_data = {
|
||||
"id": source_stack.id,
|
||||
"title": source_stack.title,
|
||||
"order": source_stack.order,
|
||||
}
|
||||
logger.info(f"Created source stack with ID: {source_stack.id}")
|
||||
|
||||
# Create target stack (stack 2)
|
||||
target_stack = await nc_client.deck.create_stack(
|
||||
board_id, f"Target Stack {unique_suffix}", order=2
|
||||
)
|
||||
target_stack_data = {
|
||||
"id": target_stack.id,
|
||||
"title": target_stack.title,
|
||||
"order": target_stack.order,
|
||||
}
|
||||
logger.info(f"Created target stack with ID: {target_stack.id}")
|
||||
|
||||
board_data = {
|
||||
"id": board_id,
|
||||
"title": board.title,
|
||||
"color": board.color,
|
||||
}
|
||||
|
||||
yield (board_data, source_stack_data, target_stack_data)
|
||||
|
||||
finally:
|
||||
if board:
|
||||
logger.info(f"Cleaning up board ID: {board.id}")
|
||||
try:
|
||||
await nc_client.deck.delete_board(board.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up board: {e}")
|
||||
|
||||
|
||||
async def test_reorder_card_move_to_different_stack(
|
||||
nc_client: NextcloudClient, board_with_two_stacks: tuple
|
||||
):
|
||||
"""Test moving a card from one stack to another (issue #469).
|
||||
|
||||
This test reproduces the bug where the reorder_card API reports success
|
||||
but the card doesn't actually move to the target stack.
|
||||
"""
|
||||
board_data, source_stack_data, target_stack_data = board_with_two_stacks
|
||||
board_id = board_data["id"]
|
||||
source_stack_id = source_stack_data["id"]
|
||||
target_stack_id = target_stack_data["id"]
|
||||
|
||||
# Create a card in the source stack
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
card_title = f"Test Card {unique_suffix}"
|
||||
card = await nc_client.deck.create_card(
|
||||
board_id, source_stack_id, card_title, description="Card to be moved"
|
||||
)
|
||||
card_id = card.id
|
||||
logger.info(f"Created card ID: {card_id} in source stack ID: {source_stack_id}")
|
||||
|
||||
try:
|
||||
# Verify card is in source stack
|
||||
card_before = await nc_client.deck.get_card(board_id, source_stack_id, card_id)
|
||||
assert card_before.stackId == source_stack_id, (
|
||||
f"Card should start in source stack {source_stack_id}, "
|
||||
f"but is in {card_before.stackId}"
|
||||
)
|
||||
logger.info(f"Verified card is in source stack: {source_stack_id}")
|
||||
|
||||
# Move card to target stack
|
||||
logger.info(
|
||||
f"Moving card {card_id} from stack {source_stack_id} "
|
||||
f"to stack {target_stack_id}"
|
||||
)
|
||||
await nc_client.deck.reorder_card(
|
||||
board_id=board_id,
|
||||
stack_id=source_stack_id,
|
||||
card_id=card_id,
|
||||
order=0,
|
||||
target_stack_id=target_stack_id,
|
||||
)
|
||||
logger.info("reorder_card API call completed")
|
||||
|
||||
# Verify card moved to target stack
|
||||
# Note: After moving, the card should be accessible from the target stack
|
||||
card_after = await nc_client.deck.get_card(board_id, target_stack_id, card_id)
|
||||
assert card_after.stackId == target_stack_id, (
|
||||
f"Card should have moved to target stack {target_stack_id}, "
|
||||
f"but is in {card_after.stackId}"
|
||||
)
|
||||
logger.info(f"SUCCESS: Card moved to target stack {target_stack_id}")
|
||||
|
||||
finally:
|
||||
# Clean up - try to delete from target stack first, then source
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, target_stack_id, card_id)
|
||||
except Exception:
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up card: {e}")
|
||||
|
||||
|
||||
async def test_reorder_card_within_same_stack(
|
||||
nc_client: NextcloudClient, board_with_two_stacks: tuple
|
||||
):
|
||||
"""Test reordering a card within the same stack (should work)."""
|
||||
board_data, source_stack_data, _ = board_with_two_stacks
|
||||
board_id = board_data["id"]
|
||||
source_stack_id = source_stack_data["id"]
|
||||
|
||||
# Create two cards in the source stack
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
card1 = await nc_client.deck.create_card(
|
||||
board_id, source_stack_id, f"Card 1 {unique_suffix}", order=0
|
||||
)
|
||||
card2 = await nc_client.deck.create_card(
|
||||
board_id, source_stack_id, f"Card 2 {unique_suffix}", order=1
|
||||
)
|
||||
logger.info(f"Created cards {card1.id} (order 0) and {card2.id} (order 1)")
|
||||
|
||||
try:
|
||||
# Reorder card1 to position after card2
|
||||
await nc_client.deck.reorder_card(
|
||||
board_id=board_id,
|
||||
stack_id=source_stack_id,
|
||||
card_id=card1.id,
|
||||
order=2, # Move to position 2
|
||||
target_stack_id=source_stack_id, # Same stack
|
||||
)
|
||||
logger.info(f"Reordered card {card1.id} to order 2")
|
||||
|
||||
# Verify card is still in the same stack
|
||||
card_after = await nc_client.deck.get_card(board_id, source_stack_id, card1.id)
|
||||
assert card_after.stackId == source_stack_id
|
||||
logger.info("Card reorder within same stack succeeded")
|
||||
|
||||
finally:
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card1.id)
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card2.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up cards: {e}")
|
||||
@@ -89,8 +89,13 @@ async def test_create_operations_not_idempotent(nc_mcp_client: ClientSession):
|
||||
"""Verify create operations are marked as non-idempotent."""
|
||||
tools = await nc_mcp_client.list_tools()
|
||||
|
||||
# Exceptions: operations that are actually idempotent
|
||||
# - calendar_create_meeting: creates or returns existing meeting
|
||||
# - nc_webdav_create_directory: MKCOL returns 405 if exists (same end state)
|
||||
idempotent_exceptions = {"calendar_create_meeting", "nc_webdav_create_directory"}
|
||||
|
||||
for tool in tools.tools:
|
||||
if "create" in tool.name.lower() and "calendar_create_meeting" not in tool.name:
|
||||
if "create" in tool.name.lower() and tool.name not in idempotent_exceptions:
|
||||
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
|
||||
assert tool.annotations.idempotentHint is not True, (
|
||||
f"Create tool {tool.name} should not be idempotent (creates new resources)"
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Unit tests for App Password Storage functionality.
|
||||
|
||||
Tests the app password methods in RefreshTokenStorage for multi-user
|
||||
BasicAuth mode background sync.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
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_app_passwords.db"
|
||||
storage = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=encryption_key
|
||||
)
|
||||
await storage.initialize()
|
||||
yield storage
|
||||
|
||||
|
||||
async def test_store_app_password(temp_storage):
|
||||
"""Test storing an app password."""
|
||||
await temp_storage.store_app_password(
|
||||
user_id="testuser",
|
||||
app_password="JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB",
|
||||
)
|
||||
|
||||
# Verify it can be retrieved
|
||||
retrieved = await temp_storage.get_app_password("testuser")
|
||||
assert retrieved == "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB"
|
||||
|
||||
|
||||
async def test_store_app_password_replaces_existing(temp_storage):
|
||||
"""Test that storing a new app password replaces the existing one."""
|
||||
await temp_storage.store_app_password(
|
||||
user_id="testuser",
|
||||
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
|
||||
)
|
||||
await temp_storage.store_app_password(
|
||||
user_id="testuser",
|
||||
app_password="fffff-ggggg-hhhhh-iiiii-jjjjj",
|
||||
)
|
||||
|
||||
retrieved = await temp_storage.get_app_password("testuser")
|
||||
assert retrieved == "fffff-ggggg-hhhhh-iiiii-jjjjj"
|
||||
|
||||
|
||||
async def test_get_app_password_nonexistent(temp_storage):
|
||||
"""Test retrieving app password for non-existent user."""
|
||||
retrieved = await temp_storage.get_app_password("nonexistent")
|
||||
assert retrieved is None
|
||||
|
||||
|
||||
async def test_delete_app_password(temp_storage):
|
||||
"""Test deleting an app password."""
|
||||
await temp_storage.store_app_password(
|
||||
user_id="testuser",
|
||||
app_password="JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB",
|
||||
)
|
||||
|
||||
deleted = await temp_storage.delete_app_password("testuser")
|
||||
assert deleted is True
|
||||
|
||||
# Verify it's gone
|
||||
retrieved = await temp_storage.get_app_password("testuser")
|
||||
assert retrieved is None
|
||||
|
||||
|
||||
async def test_delete_app_password_nonexistent(temp_storage):
|
||||
"""Test deleting non-existent app password."""
|
||||
deleted = await temp_storage.delete_app_password("nonexistent")
|
||||
assert deleted is False
|
||||
|
||||
|
||||
async def test_get_all_app_password_user_ids(temp_storage):
|
||||
"""Test listing all users with app passwords."""
|
||||
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
|
||||
await temp_storage.store_app_password("bob", "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb")
|
||||
await temp_storage.store_app_password("charlie", "ccccc-ccccc-ccccc-ccccc-ccccc")
|
||||
|
||||
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||
assert len(user_ids) == 3
|
||||
assert "alice" in user_ids
|
||||
assert "bob" in user_ids
|
||||
assert "charlie" in user_ids
|
||||
|
||||
|
||||
async def test_get_all_app_password_user_ids_empty(temp_storage):
|
||||
"""Test listing users when none have app passwords."""
|
||||
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||
assert len(user_ids) == 0
|
||||
|
||||
|
||||
async def test_app_password_encryption(encryption_key):
|
||||
"""Test that app passwords are encrypted at rest."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_encryption.db"
|
||||
storage = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=encryption_key
|
||||
)
|
||||
await storage.initialize()
|
||||
|
||||
# Store a password
|
||||
test_password = "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB"
|
||||
await storage.store_app_password("testuser", test_password)
|
||||
|
||||
# Read directly from database to verify encryption
|
||||
import aiosqlite
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
async with db.execute(
|
||||
"SELECT encrypted_password FROM app_passwords WHERE user_id = ?",
|
||||
("testuser",),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
# The stored value should be encrypted (not plain text)
|
||||
encrypted_bytes = row[0]
|
||||
assert encrypted_bytes != test_password.encode()
|
||||
# Encrypted data should be longer due to Fernet overhead
|
||||
assert len(encrypted_bytes) > len(test_password)
|
||||
|
||||
|
||||
async def test_app_password_requires_encryption_key():
|
||||
"""Test that app password operations require encryption key."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_no_key.db"
|
||||
storage = RefreshTokenStorage(db_path=str(db_path), encryption_key=None)
|
||||
await storage.initialize()
|
||||
|
||||
# Storing should fail without encryption key
|
||||
with pytest.raises(RuntimeError, match="Encryption key not configured"):
|
||||
await storage.store_app_password(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
|
||||
# Getting should also fail without encryption key
|
||||
with pytest.raises(RuntimeError, match="Encryption key not configured"):
|
||||
await storage.get_app_password("testuser")
|
||||
|
||||
|
||||
async def test_multiple_users_independence(temp_storage):
|
||||
"""Test that different users maintain independent app passwords."""
|
||||
users = ["alice", "bob", "charlie", "diana"]
|
||||
|
||||
# Store unique passwords for each user
|
||||
for i, user in enumerate(users):
|
||||
password = (
|
||||
f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}-" * 4
|
||||
+ f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}"
|
||||
)
|
||||
await temp_storage.store_app_password(user, password)
|
||||
|
||||
# Verify each user has their correct password
|
||||
for user in users:
|
||||
expected = (
|
||||
f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}-" * 4
|
||||
+ f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}"
|
||||
)
|
||||
retrieved = await temp_storage.get_app_password(user)
|
||||
assert retrieved == expected
|
||||
|
||||
# Delete one user's password
|
||||
await temp_storage.delete_app_password("bob")
|
||||
|
||||
# Verify other users unchanged
|
||||
for user in ["alice", "charlie", "diana"]:
|
||||
retrieved = await temp_storage.get_app_password(user)
|
||||
assert retrieved is not None
|
||||
|
||||
# Verify bob's password is gone
|
||||
assert await temp_storage.get_app_password("bob") is None
|
||||
|
||||
|
||||
async def test_app_password_with_special_characters(temp_storage):
|
||||
"""Test storing passwords with various alphanumeric patterns."""
|
||||
# Nextcloud app passwords use alphanumeric characters
|
||||
passwords = [
|
||||
"AAAAA-BBBBB-CCCCC-DDDDD-EEEEE", # uppercase
|
||||
"aaaaa-bbbbb-ccccc-ddddd-eeeee", # lowercase
|
||||
"12345-67890-12345-67890-12345", # numbers
|
||||
"aB1cD-eF2gH-iJ3kL-mN4oP-qR5sT", # mixed
|
||||
]
|
||||
|
||||
for i, password in enumerate(passwords):
|
||||
user = f"user{i}"
|
||||
await temp_storage.store_app_password(user, password)
|
||||
retrieved = await temp_storage.get_app_password(user)
|
||||
assert retrieved == password
|
||||
|
||||
|
||||
async def test_decryption_with_wrong_key(encryption_key):
|
||||
"""Test that decryption fails with wrong key."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_wrong_key.db"
|
||||
|
||||
# Store with original key
|
||||
storage1 = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=encryption_key
|
||||
)
|
||||
await storage1.initialize()
|
||||
await storage1.store_app_password("testuser", "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB")
|
||||
|
||||
# Try to read with different key
|
||||
wrong_key = Fernet.generate_key().decode()
|
||||
storage2 = RefreshTokenStorage(db_path=str(db_path), encryption_key=wrong_key)
|
||||
await storage2.initialize()
|
||||
|
||||
# Decryption should fail and return None (graceful handling)
|
||||
retrieved = await storage2.get_app_password("testuser")
|
||||
assert retrieved is None
|
||||
@@ -0,0 +1,624 @@
|
||||
"""
|
||||
Unit tests for Management API app password endpoints.
|
||||
|
||||
Tests the REST API endpoints for multi-user BasicAuth mode app password management:
|
||||
- POST /api/v1/users/{user_id}/app-password - Provision app password
|
||||
- GET /api/v1/users/{user_id}/app-password - Check status
|
||||
- DELETE /api/v1/users/{user_id}/app-password - Delete app password
|
||||
"""
|
||||
|
||||
import base64
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
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 import management
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
delete_app_password,
|
||||
get_app_password_status,
|
||||
provision_app_password,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_rate_limit():
|
||||
"""Clear rate limit state before each test."""
|
||||
management._rate_limit_attempts.clear()
|
||||
yield
|
||||
management._rate_limit_attempts.clear()
|
||||
|
||||
|
||||
@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_management.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 management endpoints."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
get_app_password_status,
|
||||
methods=["GET"],
|
||||
),
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
delete_app_password,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
]
|
||||
)
|
||||
app.state.storage = storage
|
||||
return app
|
||||
|
||||
|
||||
async def test_provision_app_password_missing_auth():
|
||||
"""Test that missing auth returns 401."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post("/api/v1/users/testuser/app-password")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Missing BasicAuth" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_provision_app_password_invalid_auth_format():
|
||||
"""Test that invalid auth format returns 401."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={"Authorization": "Basic invalid-not-base64!!!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid BasicAuth" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_provision_app_password_username_mismatch():
|
||||
"""Test that username mismatch returns 403."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
# Try to provision for "testuser" but auth as "otheruser"
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "does not match" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_provision_app_password_invalid_format():
|
||||
"""Test that invalid app password format returns 400."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
# Use invalid password format (not xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header("testuser", "invalid-password")
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Invalid app password format" in response.json()["error"]
|
||||
|
||||
|
||||
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",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client for Nextcloud validation
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"ocs": {"data": {"id": "testuser"}}}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
# Create app with storage
|
||||
app = create_test_app(temp_storage)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "stored" in data["message"].lower()
|
||||
|
||||
# Verify password was stored
|
||||
stored_password = await temp_storage.get_app_password("testuser")
|
||||
assert stored_password == "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
|
||||
|
||||
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",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid app password" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_get_app_password_status_provisioned(temp_storage, mocker):
|
||||
"""Test checking status when app password is provisioned."""
|
||||
# Store an app password
|
||||
await temp_storage.store_app_password("testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
|
||||
|
||||
app = create_test_app(temp_storage)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["user_id"] == "testuser"
|
||||
assert data["has_app_password"] is True
|
||||
|
||||
|
||||
async def test_get_app_password_status_not_provisioned(temp_storage, mocker):
|
||||
"""Test checking status when app password is not provisioned."""
|
||||
app = create_test_app(temp_storage)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["user_id"] == "testuser"
|
||||
assert data["has_app_password"] is False
|
||||
|
||||
|
||||
async def test_get_app_password_status_username_mismatch():
|
||||
"""Test that username mismatch returns 403 for status check."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
get_app_password_status,
|
||||
methods=["GET"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_delete_app_password_success(temp_storage, mocker):
|
||||
"""Test successful app password deletion."""
|
||||
# Store an app password
|
||||
await temp_storage.store_app_password("testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
|
||||
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client for Nextcloud validation
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = create_test_app(temp_storage)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.delete(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "deleted" in data["message"].lower()
|
||||
|
||||
# Verify password was removed
|
||||
stored_password = await temp_storage.get_app_password("testuser")
|
||||
assert stored_password is None
|
||||
|
||||
|
||||
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",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client for Nextcloud validation
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = create_test_app(temp_storage)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.delete(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "no app password found" in data["message"].lower()
|
||||
|
||||
|
||||
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",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
delete_app_password,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.delete(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "wrong-password-xxxxx"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid credentials" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_delete_app_password_username_mismatch():
|
||||
"""Test that username mismatch returns 403 for deletion."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
delete_app_password,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.delete(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
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",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401 (failed validation)
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Make 5 failed attempts (should all return 401)
|
||||
for i in range(5):
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401, f"Attempt {i + 1} should return 401"
|
||||
|
||||
# 6th attempt should be rate limited (429)
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
assert response.status_code == 429
|
||||
assert "Rate limit exceeded" in response.json()["error"]
|
||||
assert "Retry-After" in response.headers
|
||||
|
||||
|
||||
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",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.management.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Make 5 failed attempts for user1 (hits rate limit)
|
||||
for _ in range(5):
|
||||
client.post(
|
||||
"/api/v1/users/user1/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"user1", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
# user1 should be rate limited
|
||||
response = client.post(
|
||||
"/api/v1/users/user1/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"user1", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
assert response.status_code == 429
|
||||
|
||||
# user2 should NOT be rate limited (different user)
|
||||
response = client.post(
|
||||
"/api/v1/users/user2/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"user2", "bbbbb-ccccc-ddddd-eeeee-fffff"
|
||||
)
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401 # Fails validation, but not rate limited
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
tag_format = "astrolabe-v$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
- name: Get version matrix
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@c2bf575a3516752db5ce2915499d3f694885e2c7 # v1.0.0
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
|
||||
php-lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
Vendored
+14
@@ -25,6 +25,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Requires external MCP server deployment
|
||||
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
|
||||
## astrolabe-v0.7.2 (2025-12-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
||||
|
||||
## astrolabe-v0.7.1 (2025-12-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
||||
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
|
||||
- **mcp**: Move all imports to the top of modules
|
||||
|
||||
## astrolabe-v0.7.0 (2025-12-26)
|
||||
|
||||
### Feat
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ Astrolabe connects to a semantic search service that understands the meaning of
|
||||
|
||||
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
|
||||
]]></description>
|
||||
<version>0.7.0</version>
|
||||
<version>0.7.2</version>
|
||||
<licence>agpl</licence>
|
||||
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
||||
<namespace>Astrolabe</namespace>
|
||||
|
||||
@@ -94,24 +94,90 @@ class CredentialsController extends Controller {
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Store encrypted app password
|
||||
// Store encrypted app password locally in Nextcloud
|
||||
try {
|
||||
$this->tokenStorage->storeBackgroundSyncPassword($userId, $appPassword);
|
||||
$this->logger->info("Successfully stored app password for user: $userId");
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'message' => 'App password saved successfully'
|
||||
], Http::STATUS_OK);
|
||||
$this->logger->info("Stored app password locally for user: $userId");
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to store app password for user $userId", [
|
||||
$this->logger->error("Failed to store app password locally for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Failed to save app password'
|
||||
'error' => 'Failed to save app password locally'
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
// Send app password to MCP server for background sync
|
||||
// Get MCP server URL from system config (set in config.php)
|
||||
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||
if (empty($mcpServerUrl)) {
|
||||
$this->logger->warning("MCP server URL not configured, app password stored locally only");
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'partial_success' => true,
|
||||
'local_storage' => true,
|
||||
'mcp_sync' => false,
|
||||
'message' => 'App password saved locally (MCP server not configured)'
|
||||
], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
try {
|
||||
$httpClient = $this->httpClientService->newClient();
|
||||
|
||||
// Send to MCP server with BasicAuth (user proves ownership of password)
|
||||
$mcpEndpoint = rtrim($mcpServerUrl, '/') . '/api/v1/users/' . urlencode($userId) . '/app-password';
|
||||
|
||||
$this->logger->debug("Sending app password to MCP server: $mcpEndpoint");
|
||||
|
||||
$response = $httpClient->post($mcpEndpoint, [
|
||||
'auth' => [$userId, $appPassword],
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
$body = json_decode($response->getBody(), true);
|
||||
|
||||
if ($statusCode === 200 && ($body['success'] ?? false)) {
|
||||
$this->logger->info("Successfully provisioned app password to MCP server for user: $userId");
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'partial_success' => false,
|
||||
'local_storage' => true,
|
||||
'mcp_sync' => true,
|
||||
'message' => 'App password saved successfully'
|
||||
], Http::STATUS_OK);
|
||||
} else {
|
||||
$error = $body['error'] ?? 'Unknown error';
|
||||
$this->logger->error("MCP server rejected app password for user $userId: $error");
|
||||
// Return partial success since it was stored locally but MCP sync failed
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'partial_success' => true,
|
||||
'local_storage' => true,
|
||||
'mcp_sync' => false,
|
||||
'message' => 'App password saved locally (MCP server sync failed)',
|
||||
'mcp_error' => $error
|
||||
], Http::STATUS_OK);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to send app password to MCP server for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
// Return partial success since it was stored locally but MCP was unreachable
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'partial_success' => true,
|
||||
'local_storage' => true,
|
||||
'mcp_sync' => false,
|
||||
'message' => 'App password saved locally (MCP server unreachable)',
|
||||
'mcp_error' => $e->getMessage()
|
||||
], Http::STATUS_OK);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "astrolabe",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": "^22.0.0",
|
||||
|
||||
Vendored
+5
-3
@@ -7,9 +7,10 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: '.',
|
||||
emptyOutDir: false,
|
||||
cssCodeSplit: false, // Bundle all CSS into entry points (Nextcloud doesn't load CSS chunks)
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, 'src/main.js'),
|
||||
'astrolabe-main': resolve(__dirname, 'src/main.js'),
|
||||
'astrolabe-adminSettings': resolve(__dirname, 'src/adminSettings.js'),
|
||||
'astrolabe-personalSettings': resolve(__dirname, 'src/personalSettings.js'),
|
||||
},
|
||||
@@ -17,9 +18,10 @@ export default defineConfig({
|
||||
entryFileNames: 'js/[name].mjs',
|
||||
chunkFileNames: 'js/[name]-[hash].chunk.mjs',
|
||||
assetFileNames: (assetInfo) => {
|
||||
// Output CSS to css/ directory, JS/other assets to js/
|
||||
// With cssCodeSplit:false, all CSS goes to a single file
|
||||
// Name it astrolabe-main.css to match Nextcloud's Util::addStyle expectation
|
||||
if (assetInfo.name && assetInfo.name.endsWith('.css')) {
|
||||
return 'css/[name][extname]';
|
||||
return 'css/astrolabe-main.css';
|
||||
}
|
||||
return 'js/[name][extname]';
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user