Merge branch 'master' into feature/introduce_files_parsing_with_unstructured_service_for_webdav_files_retrieval
This commit is contained in:
@@ -25,6 +25,25 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
- name: Set up php 8.4
|
||||
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
coverage: none
|
||||
|
||||
- name: Install OIDC app composer dependencies
|
||||
run: |
|
||||
cd third_party/oidc
|
||||
composer install --no-dev
|
||||
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
||||
@@ -62,4 +81,4 @@ jobs:
|
||||
NEXTCLOUD_USERNAME: "admin"
|
||||
NEXTCLOUD_PASSWORD: "admin"
|
||||
run: |
|
||||
uv run pytest -v --log-level=INFO
|
||||
uv run pytest -v --log-cli-level=INFO
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[submodule "oidc"]
|
||||
path = third_party/oidc
|
||||
url = https://github.com/cbcoutinho/oidc
|
||||
[submodule "third_party/oidc"]
|
||||
path = third_party/oidc
|
||||
url = https://github.com/cbcoutinho/oidc
|
||||
@@ -1,3 +1,19 @@
|
||||
## v0.18.0 (2025-10-23)
|
||||
|
||||
### Feat
|
||||
|
||||
- **server**: Add support for custom OIDC scopes and permissions via JWTs
|
||||
- Initialize JWT-scoped tools
|
||||
|
||||
### Fix
|
||||
|
||||
- Use occ-created OAuth clients with allowed_scopes for all tests
|
||||
- Separate OAuth fixtures for opaque vs JWT tokens
|
||||
|
||||
### Refactor
|
||||
|
||||
- Update JWT client to use DCR, re-enable tool filtering
|
||||
|
||||
## v0.17.1 (2025-10-20)
|
||||
|
||||
### Fix
|
||||
|
||||
@@ -254,3 +254,15 @@ uv run pytest tests/server/test_oauth*.py -v
|
||||
- **`pyproject.toml`** - Python project configuration using uv for dependency management
|
||||
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
|
||||
- **`docker-compose.yml`** - Complete development environment with Nextcloud + database
|
||||
|
||||
## Integration testing with docker
|
||||
|
||||
### Nextcloud
|
||||
|
||||
- The `app` container is running nextcloud.
|
||||
- Use `docker compose exec app php occ ...` to get a list of available commands
|
||||
|
||||
### Mariadb
|
||||
|
||||
- The `db` container is running mariadb
|
||||
- Use `docker compose exec db mariadb -u [user] -p [password] [database]` to execute queries. Check the docker-compose file for credentials
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.9.4-python3.11-alpine@sha256:1a51c7710eaf839fa3365329ad993b48d17ddd9ab0f0672efaa9b09f407ebf44
|
||||
FROM ghcr.io/astral-sh/uv:0.9.5-python3.11-alpine@sha256:64ecec379ff82bea84b8a80c0b374f6392bcd54aa52f8c63c12f510f9c0b214d
|
||||
|
||||
# Install git (required for caldav dependency from git)
|
||||
RUN apk add --no-cache git
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
|
||||
+1
-1
@@ -6,7 +6,7 @@ echo "Installing and configuring Calendar app..."
|
||||
|
||||
# Enable calendar app
|
||||
php /var/www/html/occ app:enable calendar
|
||||
php /var/www/html/occ app:enable --force tasks # Not currently supported on 32
|
||||
php /var/www/html/occ app:enable tasks
|
||||
|
||||
# Wait for calendar app to be fully initialized
|
||||
echo "Waiting for calendar app to initialize..."
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
echo "Installing and configuring OIDC app for testing..."
|
||||
|
||||
# Check if development OIDC app is mounted at /opt/apps/oidc
|
||||
if [ -d /opt/apps/oidc ]; then
|
||||
echo "Development OIDC app found at /opt/apps/oidc"
|
||||
|
||||
# Remove any existing OIDC app in custom_apps (from app store or old symlink)
|
||||
if [ -e /var/www/html/custom_apps/oidc ]; then
|
||||
echo "Removing existing OIDC in custom_apps..."
|
||||
rm -rf /var/www/html/custom_apps/oidc
|
||||
fi
|
||||
|
||||
# Create symlink from custom_apps to the mounted development version
|
||||
# Per Nextcloud docs: apps outside server root need symlinks in server root
|
||||
echo "Creating symlink: custom_apps/oidc -> /opt/apps/oidc"
|
||||
ln -sf /opt/apps/oidc /var/www/html/custom_apps/oidc
|
||||
|
||||
echo "Enabling OIDC app from /opt/apps (development mode via symlink)"
|
||||
php /var/www/html/occ app:enable oidc
|
||||
elif [ -d /var/www/html/custom_apps/oidc ]; then
|
||||
echo "OIDC app directory found in custom_apps (already installed)"
|
||||
php /var/www/html/occ app:enable oidc
|
||||
else
|
||||
echo "OIDC app not found, installing from app store..."
|
||||
php /var/www/html/occ app:install oidc
|
||||
php /var/www/html/occ app:enable oidc
|
||||
fi
|
||||
|
||||
# Configure OIDC Identity Provider with dynamic client registration enabled
|
||||
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
|
||||
php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean
|
||||
|
||||
echo "OIDC app installed and configured successfully"
|
||||
+2
-11
@@ -2,21 +2,12 @@
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
echo "Installing and configuring OIDC apps for testing..."
|
||||
|
||||
# Enable the OIDC Identity Provider app
|
||||
php /var/www/html/occ app:enable oidc
|
||||
echo "Installing and configuring user_oidc app for testing..."
|
||||
|
||||
# Enable the user_oidc app (OIDC client for bearer token validation)
|
||||
php /var/www/html/occ app:enable user_oidc
|
||||
|
||||
patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch
|
||||
|
||||
# Configure OIDC Identity Provider with dynamic client registration enabled
|
||||
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
|
||||
php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean
|
||||
|
||||
# Configure user_oidc to validate bearer tokens from the OIDC Identity Provider
|
||||
php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
|
||||
|
||||
echo "OIDC apps installed and configured successfully"
|
||||
patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch
|
||||
+31
-4
@@ -21,7 +21,7 @@ services:
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.0@sha256:4fbd72f05b5e6b82e078542b6cb2ecf021d2f8b5045454ffa7f4e080e488b375
|
||||
image: docker.io/library/nextcloud:32.0.0@sha256:f9bec5c77a8d5603354b990550a4d24487deae6e589dd20ce870e43e28460e18
|
||||
restart: always
|
||||
ports:
|
||||
- 0.0.0.0:8080:80
|
||||
@@ -31,6 +31,9 @@ services:
|
||||
volumes:
|
||||
- nextcloud:/var/www/html
|
||||
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro
|
||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
||||
# The post-installation hook will register /opt/apps as an additional app directory
|
||||
- ./third_party/oidc:/opt/apps/oidc:ro
|
||||
environment:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
- NEXTCLOUD_ADMIN_USER=admin
|
||||
@@ -86,13 +89,37 @@ services:
|
||||
- 127.0.0.1:8001:8001
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.0.1:8001
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://127.0.0.1:8080
|
||||
# No USERNAME/PASSWORD - will use OAuth
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth/nextcloud_oauth_client.json
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
||||
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration
|
||||
# Client credentials will be registered and stored in volume on first startup
|
||||
volumes:
|
||||
- oauth-client-storage:/app/.oauth
|
||||
|
||||
mcp-oauth-jwt:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
|
||||
restart: always
|
||||
depends_on:
|
||||
- app
|
||||
ports:
|
||||
- 127.0.0.1:8002:8002
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth-jwt/nextcloud_oauth_client.json
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
||||
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
||||
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration (DCR)
|
||||
# Client will be registered with token_type=JWT on first startup
|
||||
volumes:
|
||||
- oauth-jwt-client-storage:/app/.oauth-jwt
|
||||
|
||||
volumes:
|
||||
nextcloud:
|
||||
db:
|
||||
oauth-client-storage:
|
||||
oauth-jwt-client-storage:
|
||||
|
||||
@@ -0,0 +1,899 @@
|
||||
# JWT OAuth Reference - Nextcloud MCP Server
|
||||
|
||||
**Last Updated:** 2025-10-23
|
||||
**Status:** Production Ready
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [JWT vs Opaque Tokens](#jwt-vs-opaque-tokens)
|
||||
- [Scope-Based Authorization](#scope-based-authorization)
|
||||
- [Configuration](#configuration)
|
||||
- [Architecture](#architecture)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Production Deployment](#production-deployment)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 9068) and **opaque** tokens. JWT tokens are recommended for production use as they enable:
|
||||
|
||||
- **Faster validation** - No HTTP call needed for token verification
|
||||
- **Direct scope extraction** - Scopes embedded in token claims
|
||||
- **Dynamic tool filtering** - Users only see tools they have permission to use
|
||||
- **Signature verification** - Cryptographic validation using JWKS
|
||||
|
||||
### Key Features
|
||||
|
||||
- ✅ **JWT Token Support** - RFC 9068 compliant access tokens with RS256 signatures
|
||||
- ✅ **Custom Scopes** - `nc:read` and `nc:write` for read/write access control
|
||||
- ✅ **Dynamic Tool Filtering** - Tools filtered based on user's token scopes
|
||||
- ✅ **Scope Challenges** - RFC-compliant `WWW-Authenticate` headers for insufficient scopes
|
||||
- ✅ **Protected Resource Metadata** - RFC 8959 endpoint for scope discovery
|
||||
- ✅ **Backward Compatible** - BasicAuth mode bypasses all scope checks
|
||||
|
||||
### Supported Scopes
|
||||
|
||||
| Scope | Description | Tool Count |
|
||||
|-------|-------------|------------|
|
||||
| `nc:read` | Read-only access to Nextcloud data | 36 tools |
|
||||
| `nc:write` | Write access to create/modify/delete data | 54 tools |
|
||||
|
||||
All MCP tools (90 total) require at least one of these scopes. Standard OIDC scopes (`openid`, `profile`, `email`) are also supported.
|
||||
|
||||
---
|
||||
|
||||
## JWT vs Opaque Tokens
|
||||
|
||||
The Nextcloud OIDC app supports two token formats, configured per-client:
|
||||
|
||||
### JWT Tokens (Recommended)
|
||||
|
||||
**Advantages:**
|
||||
- ✅ Fast validation - JWT signature verified locally using JWKS
|
||||
- ✅ Direct scope extraction from `scope` claim in payload
|
||||
- ✅ Standard approach (RFC 9068)
|
||||
- ✅ No additional HTTP calls for validation
|
||||
|
||||
**Disadvantages:**
|
||||
- ⚠️ Larger size (~800-1200 chars vs 72 chars for opaque)
|
||||
- ⚠️ Token payload visible to client (not an issue for access tokens)
|
||||
|
||||
**Token Structure:**
|
||||
```json
|
||||
{
|
||||
"header": {
|
||||
"typ": "at+JWT",
|
||||
"alg": "RS256",
|
||||
"kid": "..."
|
||||
},
|
||||
"payload": {
|
||||
"iss": "http://localhost:8080",
|
||||
"sub": "admin",
|
||||
"aud": "client_id",
|
||||
"exp": 1234567890,
|
||||
"iat": 1234567890,
|
||||
"scope": "openid profile email nc:read nc:write",
|
||||
"client_id": "...",
|
||||
"jti": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Opaque Tokens
|
||||
|
||||
**Advantages:**
|
||||
- ✅ Smaller size (72 characters)
|
||||
- ✅ No payload visible to client
|
||||
- ✅ Direct scope access via introspection endpoint (RFC 7662)
|
||||
|
||||
**Disadvantages:**
|
||||
- ❌ Higher latency - Requires HTTP call to introspection endpoint
|
||||
- ❌ Slower than JWT signature verification (network roundtrip)
|
||||
|
||||
**Validation Method:**
|
||||
Opaque tokens are validated using the **introspection endpoint** (`/apps/oidc/introspect`), which returns:
|
||||
- Token active status
|
||||
- Scope claim (direct access, no inference needed)
|
||||
- User information (`sub`, `username`)
|
||||
- Token metadata (`exp`, `iat`, `client_id`)
|
||||
|
||||
Falls back to userinfo endpoint only if introspection is unavailable.
|
||||
|
||||
**When to Use:**
|
||||
- Use **JWT tokens** for production (better performance, no HTTP call)
|
||||
- Use **opaque tokens** for compatibility with clients that don't support JWT
|
||||
|
||||
---
|
||||
|
||||
## Scope-Based Authorization
|
||||
|
||||
### Scope Definitions
|
||||
|
||||
The MCP server uses **coarse-grained scopes** for simplicity:
|
||||
|
||||
| Scope | Operations | Examples |
|
||||
|-------|------------|----------|
|
||||
| `nc:read` | Read-only access | Get notes, search files, list calendars, read contacts |
|
||||
| `nc:write` | Write operations | Create notes, update events, delete files, modify contacts |
|
||||
|
||||
### Standard OIDC Scopes
|
||||
|
||||
| Scope | Description | Required |
|
||||
|-------|-------------|----------|
|
||||
| `openid` | OIDC authentication | Yes |
|
||||
| `profile` | User profile information | Recommended |
|
||||
| `email` | Email address | Recommended |
|
||||
|
||||
### Recommended Configurations
|
||||
|
||||
**Full Access:**
|
||||
```
|
||||
openid profile email nc:read nc:write
|
||||
```
|
||||
|
||||
**Read-Only:**
|
||||
```
|
||||
openid profile email nc:read
|
||||
```
|
||||
|
||||
**No Custom Scopes (OIDC only):**
|
||||
```
|
||||
openid profile email
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
All 90 MCP tools are decorated with scope requirements:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_notes_get_note(note_id: int, ctx: Context):
|
||||
"""Get a note by ID (requires nc:read scope)"""
|
||||
...
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_notes_create_note(title: str, content: str, ctx: Context):
|
||||
"""Create a note (requires nc:write scope)"""
|
||||
...
|
||||
```
|
||||
|
||||
**Coverage:**
|
||||
- ✅ 36 read tools decorated with `@require_scopes("nc:read")`
|
||||
- ✅ 54 write tools decorated with `@require_scopes("nc:write")`
|
||||
- ✅ 90/90 tools covered (100%)
|
||||
|
||||
### Dynamic Tool Filtering
|
||||
|
||||
The MCP server implements **dynamic tool filtering** - users only see tools they have permission to use:
|
||||
|
||||
**JWT with `nc:read` only:**
|
||||
- `list_tools()` returns 36 read-only tools
|
||||
- Write tools are hidden from the tool list
|
||||
|
||||
**JWT with `nc:write` only:**
|
||||
- `list_tools()` returns 54 write-only tools
|
||||
- Read tools are hidden from the tool list
|
||||
|
||||
**JWT with both scopes:**
|
||||
- `list_tools()` returns all 90 tools
|
||||
|
||||
**JWT with no custom scopes:**
|
||||
- `list_tools()` returns 0 tools (all require `nc:read` or `nc:write`)
|
||||
|
||||
**BasicAuth mode:**
|
||||
- `list_tools()` returns all 90 tools (no filtering)
|
||||
|
||||
### Scope Challenges
|
||||
|
||||
When a tool is called without required scopes, the server returns a `403 Forbidden` response with a `WWW-Authenticate` header:
|
||||
|
||||
```http
|
||||
HTTP/1.1 403 Forbidden
|
||||
WWW-Authenticate: Bearer error="insufficient_scope",
|
||||
scope="nc:write",
|
||||
resource_metadata="http://server/.well-known/oauth-protected-resource"
|
||||
```
|
||||
|
||||
This enables **step-up authorization** - clients can detect missing scopes and trigger re-authentication to obtain additional permissions.
|
||||
|
||||
### Protected Resource Metadata (PRM)
|
||||
|
||||
The server implements RFC 8959's Protected Resource Metadata endpoint:
|
||||
|
||||
**Endpoint:** `GET /.well-known/oauth-protected-resource`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"resource": "http://localhost:8002",
|
||||
"scopes_supported": ["nc:read", "nc:write"],
|
||||
"authorization_servers": ["http://localhost:8080"],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_signing_alg_values_supported": ["RS256"]
|
||||
}
|
||||
```
|
||||
|
||||
This allows OAuth clients to discover supported scopes before requesting authorization.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Docker Services
|
||||
|
||||
The development environment includes three MCP server variants:
|
||||
|
||||
| Service | Port | Auth Type | Token Type | Use Case |
|
||||
|---------|------|-----------|------------|----------|
|
||||
| `mcp` | 8000 | BasicAuth | N/A | Development, testing |
|
||||
| `mcp-oauth` | 8001 | OAuth | Opaque | Standard OAuth flows |
|
||||
| `mcp-oauth-jwt` | 8002 | OAuth | JWT | Production, JWT testing |
|
||||
|
||||
### JWT Service Configuration
|
||||
|
||||
The `mcp-oauth-jwt` service uses **Dynamic Client Registration (DCR)** by default:
|
||||
|
||||
**Default Configuration (DCR):**
|
||||
```yaml
|
||||
mcp-oauth-jwt:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
|
||||
ports:
|
||||
- 127.0.0.1:8002:8002
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
||||
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
||||
volumes:
|
||||
- ./oauth-storage:/app/.oauth # Optional: persist DCR credentials
|
||||
```
|
||||
|
||||
**With Pre-Configured Credentials:**
|
||||
```yaml
|
||||
mcp-oauth-jwt:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
|
||||
ports:
|
||||
- 127.0.0.1:8002:8002
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- NEXTCLOUD_OIDC_CLIENT_ID=<your_client_id> # Skips DCR
|
||||
- NEXTCLOUD_OIDC_CLIENT_SECRET=<your_client_secret> # Skips DCR
|
||||
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- **No credentials needed** - DCR automatically registers the client on first start
|
||||
- **Credentials persist** - Saved to `.nextcloud_oauth_client.json` and reused
|
||||
- **JWT tokens** - Set `TOKEN_TYPE=jwt` for better performance
|
||||
- **Pre-configured credentials** - Providing `CLIENT_ID`/`CLIENT_SECRET` skips DCR
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `NEXTCLOUD_HOST` | Nextcloud base URL | `http://localhost:8080` |
|
||||
| `NEXTCLOUD_MCP_SERVER_URL` | MCP server external URL for OAuth callbacks | (required in OAuth mode) |
|
||||
| `NEXTCLOUD_PUBLIC_ISSUER_URL` | Public issuer URL for JWT validation | (uses `NEXTCLOUD_HOST`) |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured OAuth client ID | (optional - uses DCR if unset) |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured OAuth client secret | (optional - uses DCR if unset) |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | Path to persist DCR-registered credentials | `.nextcloud_oauth_client.json` |
|
||||
| `NEXTCLOUD_OIDC_SCOPES` | Space-separated scopes to request | `"openid profile email nc:read nc:write"` |
|
||||
| `NEXTCLOUD_OIDC_TOKEN_TYPE` | Token format: `"jwt"` or `"Bearer"` | `"Bearer"` |
|
||||
|
||||
### Dynamic Client Registration (DCR)
|
||||
|
||||
The MCP server supports **automatic OAuth client registration** using the OIDC Discovery registration endpoint. This eliminates the need for manual client creation in most cases.
|
||||
|
||||
**How It Works:**
|
||||
|
||||
When the MCP server starts in OAuth mode, it follows this **three-tier credential loading strategy**:
|
||||
|
||||
```
|
||||
1. Environment Variables (Highest Priority)
|
||||
├─ NEXTCLOUD_OIDC_CLIENT_ID
|
||||
└─ NEXTCLOUD_OIDC_CLIENT_SECRET
|
||||
|
||||
2. Storage File (Second Priority)
|
||||
└─ NEXTCLOUD_OIDC_CLIENT_STORAGE (.nextcloud_oauth_client.json)
|
||||
|
||||
3. Dynamic Client Registration (Automatic Fallback)
|
||||
├─ Discovers registration endpoint from /.well-known/openid-configuration
|
||||
├─ Registers new client with requested scopes and token type
|
||||
├─ Saves credentials to storage file for future use
|
||||
└─ Client credentials persist across restarts
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
|
||||
DCR automatically configures the client based on environment variables:
|
||||
|
||||
```bash
|
||||
# Minimal DCR configuration (no credentials needed!)
|
||||
export NEXTCLOUD_HOST=http://localhost:8080
|
||||
export NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||
export NEXTCLOUD_OIDC_SCOPES="openid profile email nc:read nc:write"
|
||||
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt # or "Bearer" for opaque tokens
|
||||
```
|
||||
|
||||
**Credential Storage:**
|
||||
|
||||
- Registered credentials are saved to `NEXTCLOUD_OIDC_CLIENT_STORAGE` (default: `.nextcloud_oauth_client.json`)
|
||||
- File has restrictive permissions (0600 - owner read/write only)
|
||||
- Credentials are reused on subsequent starts (no re-registration needed)
|
||||
- Storage file is checked for expiration (auto-regenerates if expired)
|
||||
|
||||
**Format:**
|
||||
```json
|
||||
{
|
||||
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
|
||||
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
|
||||
"client_id_issued_at": 1761097039,
|
||||
"client_secret_expires_at": 2076457039,
|
||||
"redirect_uris": ["http://localhost:8000/oauth/callback"]
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Zero-configuration OAuth setup
|
||||
- ✅ Automatic credential management
|
||||
- ✅ Supports both JWT and opaque tokens
|
||||
- ✅ Credentials persist across container restarts
|
||||
- ✅ Automatic re-registration if credentials expire
|
||||
- ✅ Properly sets `allowed_scopes` for JWT token validation
|
||||
|
||||
### Manual Client Creation
|
||||
|
||||
Manual client creation is **optional** but may be preferred when:
|
||||
- You want explicit control over client configuration
|
||||
- You're deploying to production environments with strict security policies
|
||||
- You need to pre-provision OAuth clients before deployment
|
||||
|
||||
**Create Client via OCC Command:**
|
||||
|
||||
```bash
|
||||
docker compose exec app php occ oidc:create \
|
||||
--token_type=jwt \
|
||||
--allowed_scopes="openid profile email nc:read nc:write" \
|
||||
"Nextcloud MCP Server" \
|
||||
"http://localhost:8000/oauth/callback"
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
|
||||
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
|
||||
"token_type": "jwt",
|
||||
"allowed_scopes": "openid profile email nc:read nc:write"
|
||||
}
|
||||
```
|
||||
|
||||
**Configure MCP Server with Pre-Configured Credentials:**
|
||||
|
||||
```bash
|
||||
# Option 1: Environment variables (highest priority)
|
||||
export NEXTCLOUD_OIDC_CLIENT_ID="<client_id>"
|
||||
export NEXTCLOUD_OIDC_CLIENT_SECRET="<client_secret>"
|
||||
export NEXTCLOUD_OIDC_TOKEN_TYPE="jwt"
|
||||
|
||||
# Option 2: Storage file (second priority)
|
||||
# Save the JSON response to .nextcloud_oauth_client.json
|
||||
# Server will automatically load it on startup
|
||||
```
|
||||
|
||||
When credentials are provided via environment variables or storage file, **DCR is skipped**.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Overview
|
||||
|
||||
```
|
||||
┌──────────────────┐ OAuth Flow ┌──────────────────┐
|
||||
│ OAuth Client │<─────────────────────>│ Nextcloud OIDC │
|
||||
│ (Claude, etc) │ │ Server │
|
||||
└────────┬─────────┘ └────────┬─────────┘
|
||||
│ │
|
||||
│ JWT Access Token │
|
||||
│ { │
|
||||
│ "scope": "openid nc:read nc:write" │
|
||||
│ ... │
|
||||
│ } │
|
||||
│ │
|
||||
v │
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Nextcloud MCP Server │
|
||||
│ ┌───────────────────────────────────────────────────┐ │
|
||||
│ │ NextcloudTokenVerifier │ │
|
||||
│ │ - JWT signature verification (JWKS) │ │
|
||||
│ │ - Introspection endpoint (opaque tokens) │ │
|
||||
│ │ - Userinfo fallback (last resort) │ │
|
||||
│ └───────────────────┬───────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ v │
|
||||
│ ┌───────────────────────────────────────────────────┐ │
|
||||
│ │ Dynamic Tool Filtering (list_tools) │ │
|
||||
│ │ - Get user scopes from verified token │ │
|
||||
│ │ - Filter tools based on @require_scopes metadata │ │
|
||||
│ │ - Return only accessible tools │ │
|
||||
│ └───────────────────┬───────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ v │
|
||||
│ ┌───────────────────────────────────────────────────┐ │
|
||||
│ │ Tool Execution (@require_scopes decorator) │ │
|
||||
│ │ - Check token scopes before execution │ │
|
||||
│ │ - Raise InsufficientScopeError if missing │ │
|
||||
│ │ - Return 403 with WWW-Authenticate header │ │
|
||||
│ └───────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
**1. Token Verification** (`nextcloud_mcp_server/auth/token_verifier.py`)
|
||||
- **Three-tier validation strategy:**
|
||||
1. **JWT verification** (lines 116-124): JWKS signature validation for JWT tokens
|
||||
2. **Introspection** (lines 126-134): RFC 7662 endpoint for opaque tokens
|
||||
3. **Userinfo fallback** (lines 137-142): Last resort if introspection unavailable
|
||||
- Scope extraction from token payload (JWT) or introspection response (opaque)
|
||||
- Token caching with TTL to reduce repeated validations
|
||||
- Supports both access token formats transparently
|
||||
|
||||
**2. Scope Authorization** (`nextcloud_mcp_server/auth/scope_authorization.py`)
|
||||
- `@require_scopes()` decorator for tools
|
||||
- `get_required_scopes()` - Extract scope requirements from functions
|
||||
- `has_required_scopes()` - Check if user has necessary scopes
|
||||
- `InsufficientScopeError` exception for WWW-Authenticate challenges
|
||||
|
||||
**3. Dynamic Filtering** (`nextcloud_mcp_server/app.py:433-488`)
|
||||
- Overrides FastMCP's `list_tools()` method
|
||||
- Filters based on user's JWT token scopes
|
||||
- Only active in OAuth mode
|
||||
- Bypassed in BasicAuth mode
|
||||
|
||||
**4. PRM Endpoint** (`nextcloud_mcp_server/app.py:503-532`)
|
||||
- `GET /.well-known/oauth-protected-resource`
|
||||
- Advertises `["nc:read", "nc:write"]`
|
||||
- RFC 8959 compliant
|
||||
|
||||
**5. Exception Handler** (`nextcloud_mcp_server/app.py:540-563`)
|
||||
- Catches `InsufficientScopeError`
|
||||
- Returns 403 with `WWW-Authenticate` header
|
||||
- Includes missing scopes and PRM endpoint URL
|
||||
|
||||
### Token Validation Flow
|
||||
|
||||
The `NextcloudTokenVerifier` implements a **cascading validation strategy** that handles both JWT and opaque tokens efficiently:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ verify_token(token) │
|
||||
│ (nextcloud_mcp_server/auth/token_verifier.py:88-142) │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
├──> 1. Check cache (lines 106-109)
|
||||
│ ├─ Hit: Return cached AccessToken
|
||||
│ └─ Miss: Continue to validation
|
||||
│
|
||||
├──> 2. JWT Format Check (lines 112-124)
|
||||
│ ├─ Token has 3 parts (header.payload.signature)?
|
||||
│ │ └─ Yes: Attempt JWT verification
|
||||
│ │ ├─ Verify signature with JWKS (RS256)
|
||||
│ │ ├─ Validate issuer, expiration
|
||||
│ │ ├─ Extract scopes from payload
|
||||
│ │ └─ Success: Return AccessToken
|
||||
│ └─ Fail/Not JWT: Continue to introspection
|
||||
│
|
||||
├──> 3. Introspection (lines 126-134)
|
||||
│ ├─ POST to /apps/oidc/introspect
|
||||
│ ├─ Authenticate with client credentials
|
||||
│ ├─ Response contains:
|
||||
│ │ • active: true/false
|
||||
│ │ • scope: "openid nc:read nc:write"
|
||||
│ │ • sub, exp, iat, client_id
|
||||
│ ├─ Extract scopes from response
|
||||
│ └─ Success: Return AccessToken
|
||||
│
|
||||
└──> 4. Userinfo Fallback (lines 137-142)
|
||||
├─ GET /apps/oidc/userinfo
|
||||
├─ Bearer token in Authorization header
|
||||
├─ Infer scopes from response claims
|
||||
└─ Return AccessToken or None
|
||||
```
|
||||
|
||||
**Validation Priorities:**
|
||||
|
||||
| Token Type | Method | Performance | Scope Access | Code Reference |
|
||||
|------------|--------|-------------|--------------|----------------|
|
||||
| JWT | JWKS Signature | ⚡ Fastest (local) | Direct (`scope` claim) | `token_verifier.py:156-234` |
|
||||
| Opaque | Introspection | 🔄 Medium (HTTP) | Direct (`scope` field) | `token_verifier.py:236-328` |
|
||||
| Any | Userinfo | 🐌 Slowest (HTTP + inference) | Inferred (from claims) | `token_verifier.py:330-386` |
|
||||
|
||||
**Configuration** (`nextcloud_mcp_server/app.py:391-399`):
|
||||
```python
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
nextcloud_host=nextcloud_host,
|
||||
userinfo_uri=userinfo_uri,
|
||||
jwks_uri=jwks_uri, # Enables JWT verification
|
||||
issuer=jwt_validation_issuer, # For JWT issuer validation
|
||||
introspection_uri=introspection_uri, # Enables introspection for opaque tokens
|
||||
client_id=client_id, # Required for introspection auth
|
||||
client_secret=client_secret, # Required for introspection auth
|
||||
)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Infrastructure
|
||||
|
||||
The test suite includes comprehensive coverage for JWT OAuth and scope authorization:
|
||||
|
||||
**Test Files:**
|
||||
- `tests/server/test_scope_authorization.py` - Scope-based authorization tests (4 tests)
|
||||
- `tests/server/test_mcp_oauth_jwt.py` - JWT OAuth integration tests
|
||||
- `tests/conftest.py` - Shared fixtures for JWT testing
|
||||
|
||||
### Consent Scenario Tests
|
||||
|
||||
Four test scenarios verify scope-based tool filtering with different consent levels:
|
||||
|
||||
#### 1. No Custom Scopes (0 tools)
|
||||
```bash
|
||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_with_no_custom_scopes_returns_zero_tools -v
|
||||
```
|
||||
|
||||
**Scenario:** JWT token with only OIDC defaults (`openid profile email`)
|
||||
**Expected:** 0 tools returned (all require `nc:read` or `nc:write`)
|
||||
**Verifies:** Security - users who decline custom scopes cannot access any MCP tools
|
||||
|
||||
#### 2. Read-Only Access (36 tools)
|
||||
```bash
|
||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_read_only -v
|
||||
```
|
||||
|
||||
**Scenario:** JWT token with `nc:read` only
|
||||
**Expected:** 36 read-only tools visible, write tools hidden
|
||||
**Verifies:** Read tools accessible, write tools filtered out
|
||||
|
||||
#### 3. Write-Only Access (54 tools)
|
||||
```bash
|
||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_write_only -v
|
||||
```
|
||||
|
||||
**Scenario:** JWT token with `nc:write` only
|
||||
**Expected:** 54 write tools visible, read tools hidden
|
||||
**Verifies:** Write tools accessible, read tools filtered out
|
||||
|
||||
#### 4. Full Access (90 tools)
|
||||
```bash
|
||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_full_access -v
|
||||
```
|
||||
|
||||
**Scenario:** JWT token with both `nc:read` and `nc:write`
|
||||
**Expected:** All 90 tools visible
|
||||
**Verifies:** Full access when user grants all custom scopes
|
||||
|
||||
### Test Fixtures
|
||||
|
||||
**OAuth Client Fixtures:**
|
||||
- `read_only_oauth_client_credentials` - Client with `nc:read` only
|
||||
- `write_only_oauth_client_credentials` - Client with `nc:write` only
|
||||
- `full_access_oauth_client_credentials` - Client with both scopes
|
||||
- `no_custom_scopes_oauth_client_credentials` - Client with OIDC defaults only
|
||||
|
||||
**Token Fixtures:**
|
||||
- `playwright_oauth_token_read_only` - Obtains token with `nc:read`
|
||||
- `playwright_oauth_token_write_only` - Obtains token with `nc:write`
|
||||
- `playwright_oauth_token_full_access` - Obtains token with both scopes
|
||||
- `playwright_oauth_token_no_custom_scopes` - Obtains token with no custom scopes
|
||||
|
||||
**MCP Client Fixtures:**
|
||||
- `nc_mcp_oauth_client_read_only` - MCP session with read-only token
|
||||
- `nc_mcp_oauth_client_write_only` - MCP session with write-only token
|
||||
- `nc_mcp_oauth_client_full_access` - MCP session with full access token
|
||||
- `nc_mcp_oauth_client_no_custom_scopes` - MCP session with no custom scopes
|
||||
|
||||
### Running Tests
|
||||
|
||||
**All consent scenario tests:**
|
||||
```bash
|
||||
uv run pytest tests/server/test_scope_authorization.py -v
|
||||
```
|
||||
|
||||
**JWT OAuth integration tests:**
|
||||
```bash
|
||||
uv run pytest tests/server/test_mcp_oauth_jwt.py -v --browser firefox
|
||||
```
|
||||
|
||||
**With visible browser (debugging):**
|
||||
```bash
|
||||
uv run pytest tests/server/test_mcp_oauth_jwt.py -v --browser firefox --headed
|
||||
```
|
||||
|
||||
### Test Configuration
|
||||
|
||||
**Playwright Browser:**
|
||||
- Default: Chromium
|
||||
- Recommended for CI: Firefox (`--browser firefox`)
|
||||
- Debugging: Add `--headed` flag
|
||||
|
||||
**OAuth Flow:**
|
||||
- Uses automated Playwright browser automation
|
||||
- Completes OAuth consent flow programmatically
|
||||
- Creates separate OAuth client for each scenario
|
||||
- Each user gets unique access token
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: JWT Issuer Validation Failed
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
WARNING JWT issuer validation failed: Invalid issuer
|
||||
WARNING JWT verification failed, will try other methods
|
||||
✅ Extracted scopes from access token: {'openid', 'profile'}
|
||||
```
|
||||
|
||||
**Cause:** Token's `iss` claim doesn't match expected issuer URL. This often happens when:
|
||||
- Using `localhost` vs `127.0.0.1` inconsistently
|
||||
- MCP server uses internal URL but clients use public URL
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Option 1: Use consistent URLs
|
||||
export NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
# Ensure all test fixtures also use localhost:8080
|
||||
|
||||
# Option 2: Check discovery document
|
||||
curl http://localhost:8080/.well-known/openid-configuration | jq .issuer
|
||||
# Use this exact issuer in NEXTCLOUD_PUBLIC_ISSUER_URL
|
||||
```
|
||||
|
||||
**Impact if not fixed:**
|
||||
- JWT validation falls back to userinfo endpoint
|
||||
- Scopes inferred from userinfo (only standard OIDC scopes, no custom scopes)
|
||||
- Result: 0 tools visible or incorrect tool filtering
|
||||
|
||||
### Issue: Scopes Not Present in JWT
|
||||
|
||||
**Symptom:** JWT token doesn't contain `scope` claim or contains empty string
|
||||
|
||||
**Cause:** Client's `allowed_scopes` is empty or not configured
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check client configuration
|
||||
docker compose exec app php occ oidc:list
|
||||
|
||||
# Look for allowed_scopes in output
|
||||
# If empty, recreate client with --allowed_scopes
|
||||
docker compose exec app php occ oidc:create \
|
||||
--token_type=jwt \
|
||||
--allowed_scopes="openid profile email nc:read nc:write" \
|
||||
"Client Name" \
|
||||
"http://callback/url"
|
||||
```
|
||||
|
||||
### Issue: All Tools Visible Despite Read-Only Token
|
||||
|
||||
**Symptom:** User with `nc:read` token can see all 90 tools including write tools
|
||||
|
||||
**Cause:** Server running in BasicAuth mode, not OAuth mode
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Verify OAuth mode is active
|
||||
docker compose logs mcp-oauth-jwt | grep "OAuth mode"
|
||||
|
||||
# Should see: "Running in OAuth mode"
|
||||
|
||||
# If not, check environment variables:
|
||||
docker compose exec mcp-oauth-jwt env | grep NEXTCLOUD_OIDC
|
||||
|
||||
# Ensure no NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD set
|
||||
```
|
||||
|
||||
### Verifying DCR Scope Configuration
|
||||
|
||||
DCR **now properly sets `allowed_scopes`** when the `scope` parameter is provided during registration.
|
||||
|
||||
**To verify DCR scopes are working:**
|
||||
|
||||
```bash
|
||||
# Check the registered client's allowed_scopes via database
|
||||
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
|
||||
-e "SELECT name, allowed_scopes FROM oc_oauth2_clients WHERE name LIKE 'DCR-%' ORDER BY id DESC LIMIT 1;"
|
||||
|
||||
# Should show your requested scopes (e.g., "openid profile email nc:read nc:write")
|
||||
```
|
||||
|
||||
**If scopes are missing:**
|
||||
1. Ensure `NEXTCLOUD_OIDC_SCOPES` environment variable is set correctly
|
||||
2. Check MCP server startup logs for the scopes being requested
|
||||
3. Verify DCR is enabled in Nextcloud OIDC app settings
|
||||
4. Delete `.nextcloud_oauth_client.json` and restart to force re-registration
|
||||
|
||||
### Issue: Token Type Case Sensitivity
|
||||
|
||||
**Symptom:** JWT tokens not generated even though `token_type=JWT` set
|
||||
|
||||
**Cause:** OIDC app checks `token_type === 'jwt'` (lowercase)
|
||||
|
||||
**Solution:** Always use lowercase:
|
||||
```bash
|
||||
# Correct
|
||||
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
||||
|
||||
# Incorrect (will generate opaque tokens)
|
||||
export NEXTCLOUD_OIDC_TOKEN_TYPE=JWT
|
||||
```
|
||||
|
||||
### Issue: Missing WWW-Authenticate Header
|
||||
|
||||
**Symptom:** 403 error doesn't include `WWW-Authenticate` header
|
||||
|
||||
**Cause:** Server not in OAuth mode, or exception not being caught
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check server logs for OAuth mode
|
||||
docker compose logs mcp-oauth-jwt | grep "WWW-Authenticate scope challenges enabled"
|
||||
|
||||
# Should see this during startup
|
||||
|
||||
# Check exception handling
|
||||
docker compose logs mcp-oauth-jwt | grep "InsufficientScopeError"
|
||||
```
|
||||
|
||||
### Debugging Tools
|
||||
|
||||
**Check JWT contents:**
|
||||
```bash
|
||||
# Decode JWT (base64 decode the payload)
|
||||
echo "JWT_PAYLOAD_PART" | base64 -d | jq .
|
||||
```
|
||||
|
||||
**Check database scopes:**
|
||||
```bash
|
||||
# View access tokens with scopes
|
||||
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
|
||||
-e "SELECT id, client_id, user_id, scope FROM oc_oidc_access_tokens ORDER BY id DESC LIMIT 5;"
|
||||
|
||||
# View user consents
|
||||
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
|
||||
-e "SELECT user_id, client_id, scopes_granted FROM oc_oidc_user_consents;"
|
||||
```
|
||||
|
||||
**Check server logs:**
|
||||
```bash
|
||||
# Follow JWT verification logs
|
||||
docker compose logs -f mcp-oauth-jwt | grep -E "JWT|scope|tool"
|
||||
|
||||
# Check for issuer mismatches
|
||||
docker compose logs mcp-oauth-jwt | grep -i issuer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Deployment Checklist
|
||||
|
||||
✅ **Use JWT Tokens** - Enable `token_type=jwt` for better performance
|
||||
✅ **Configure Allowed Scopes** - Always set `allowed_scopes` on OAuth clients
|
||||
✅ **Use Pre-Configured Clients** - Avoid DCR limitation with manual client creation
|
||||
✅ **Consistent URLs** - Use same URL for `NEXTCLOUD_HOST` and `PUBLIC_ISSUER_URL`
|
||||
✅ **Secure Credentials** - Store client credentials securely (environment variables or secrets management)
|
||||
✅ **Monitor Token Size** - JWT tokens are 10-15x larger than opaque (not usually an issue)
|
||||
✅ **Enable Logging** - Configure appropriate log levels for JWT verification
|
||||
|
||||
### Production Configuration Example
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (production)
|
||||
mcp-oauth-jwt:
|
||||
image: ghcr.io/yourusername/nextcloud-mcp-server:latest
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||
- NEXTCLOUD_MCP_SERVER_URL=https://mcp.example.com
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=https://nextcloud.example.com
|
||||
- NEXTCLOUD_OIDC_CLIENT_ID=${JWT_CLIENT_ID}
|
||||
- NEXTCLOUD_OIDC_CLIENT_SECRET=${JWT_CLIENT_SECRET}
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
||||
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
||||
ports:
|
||||
- "8002:8002"
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
**Token Storage:**
|
||||
- Never commit credentials to version control
|
||||
- Use environment variables or secrets management
|
||||
- Rotate client secrets periodically
|
||||
|
||||
**Scope Configuration:**
|
||||
- Grant minimum necessary scopes to clients
|
||||
- Use read-only tokens for AI assistants that don't need write access
|
||||
- Review OAuth client list regularly
|
||||
|
||||
**Network Security:**
|
||||
- Use HTTPS in production
|
||||
- Ensure issuer URL matches public URL
|
||||
- Configure proper CORS headers
|
||||
|
||||
### Monitoring
|
||||
|
||||
**Key Metrics:**
|
||||
- JWT verification success/failure rate
|
||||
- Scope challenge frequency (indicates clients with insufficient scopes)
|
||||
- Token validation latency
|
||||
- Tool execution by scope (identify unused scopes)
|
||||
|
||||
**Log Patterns:**
|
||||
```bash
|
||||
# Success
|
||||
INFO JWT verified successfully for user: admin
|
||||
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'nc:read', 'nc:write'}
|
||||
|
||||
# Failures
|
||||
WARNING JWT issuer validation failed: Invalid issuer
|
||||
WARNING Missing required scopes: nc:write
|
||||
```
|
||||
|
||||
### Known Limitations
|
||||
|
||||
1. **No Fine-Grained Scopes** - Only coarse `nc:read` and `nc:write` (not per-app scopes)
|
||||
2. **No Refresh Token Support** - Tokens must be reacquired when expired
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
**Potential Improvements:**
|
||||
- Per-app scopes (`nc:notes:read`, `nc:calendar:write`)
|
||||
- Resource-level filtering (apply to MCP resources, not just tools)
|
||||
- Automatic scope discovery from decorated tools
|
||||
- Admin UI for scope management
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
### Standards
|
||||
|
||||
- [RFC 9068: JWT Profile for OAuth 2.0 Access Tokens](https://www.rfc-editor.org/rfc/rfc9068.html)
|
||||
- [RFC 7519: JSON Web Token (JWT)](https://www.rfc-editor.org/rfc/rfc7519.html)
|
||||
- [RFC 7517: JSON Web Key (JWK)](https://www.rfc-editor.org/rfc/rfc7517.html)
|
||||
- [RFC 8959: Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc8959.html)
|
||||
- [RFC 7662: OAuth 2.0 Token Introspection](https://www.rfc-editor.org/rfc/rfc7662.html)
|
||||
|
||||
### Related Documentation
|
||||
|
||||
- [OAuth Setup Guide](oauth-setup.md) - Complete OAuth configuration guide
|
||||
- [OAuth Architecture](oauth-architecture.md) - Detailed architecture documentation
|
||||
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common OAuth issues and solutions
|
||||
- [Authentication Guide](authentication.md) - BasicAuth vs OAuth comparison
|
||||
|
||||
### External Resources
|
||||
|
||||
- [Nextcloud OIDC App](https://github.com/H2CK/oidc) - OIDC identity provider for Nextcloud
|
||||
- [PyJWT Documentation](https://pyjwt.readthedocs.io/) - JWT library used for verification
|
||||
- [FastMCP Documentation](https://github.com/jlowin/fastmcp) - MCP server framework
|
||||
|
||||
---
|
||||
|
||||
**Implementation Date:** 2025-10-21 to 2025-10-23
|
||||
**Version:** 1.0.0
|
||||
**Status:** ✅ Production Ready
|
||||
@@ -41,7 +41,7 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
"""Fixture with surgical exception handling for pytest-asyncio incompatibility."""
|
||||
try:
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://127.0.0.1:8000/mcp", client_name="Basic MCP"
|
||||
url="http://localhost:8000/mcp", client_name="Basic MCP"
|
||||
):
|
||||
yield session
|
||||
except RuntimeError as e:
|
||||
@@ -88,7 +88,7 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
|
||||
async def create_and_hold_session():
|
||||
"""Runs in isolated task - creates session and keeps it alive."""
|
||||
async with streamablehttp_client("http://127.0.0.1:8000/mcp") as (read_stream, write_stream, _):
|
||||
async with streamablehttp_client("http://localhost:8000/mcp") as (read_stream, write_stream, _):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
session_holder["session"] = session
|
||||
@@ -141,7 +141,7 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
@pytest.fixture(scope="function") # Changed from session
|
||||
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
"""Function-scoped fixture with natural LIFO cleanup."""
|
||||
async with streamablehttp_client("http://127.0.0.1:8000/mcp") as (read_stream, write_stream, _):
|
||||
async with streamablehttp_client("http://localhost:8000/mcp") as (read_stream, write_stream, _):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
yield session
|
||||
@@ -150,11 +150,11 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
@pytest.fixture(scope="function")
|
||||
async def multi_mcp_clients() -> AsyncGenerator[tuple[ClientSession, ClientSession], Any]:
|
||||
"""Multiple clients with guaranteed LIFO cleanup through nesting."""
|
||||
async with streamablehttp_client("http://127.0.0.1:8000/mcp") as (read1, write1, _):
|
||||
async with streamablehttp_client("http://localhost:8000/mcp") as (read1, write1, _):
|
||||
async with ClientSession(read1, write1) as session1:
|
||||
await session1.initialize()
|
||||
|
||||
async with streamablehttp_client("http://127.0.0.1:8001/mcp") as (read2, write2, _):
|
||||
async with streamablehttp_client("http://localhost:8001/mcp") as (read2, write2, _):
|
||||
async with ClientSession(read2, write2) as session2:
|
||||
await session2.initialize()
|
||||
yield session1, session2
|
||||
@@ -195,7 +195,7 @@ async def multi_mcp_clients() -> AsyncGenerator[tuple[ClientSession, ClientSessi
|
||||
# Fixtures work naturally with trio
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
async with streamablehttp_client("http://127.0.0.1:8000/mcp") as (read, write, _):
|
||||
async with streamablehttp_client("http://localhost:8000/mcp") as (read, write, _):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
yield session
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
# Testing OIDC Consent Feature
|
||||
|
||||
This guide explains how to test the OIDC consent feature using the development version of the OIDC app mounted into the Docker environment.
|
||||
|
||||
## Setup
|
||||
|
||||
### Volume Mount Configuration
|
||||
|
||||
The development OIDC app is mounted from `~/Software/oidc` into the container at `/opt/apps/oidc`:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
volumes:
|
||||
- ../Software/oidc:/opt/apps/oidc:ro
|
||||
```
|
||||
|
||||
**Why mount outside `/var/www/html/`?**
|
||||
- The Nextcloud container uses `rsync` to initialize `/var/www/html/` from the image
|
||||
- Mounting inside that path causes conflicts (rsync tries to delete mounted directories)
|
||||
- Mounting to `/opt/apps/oidc` avoids rsync entirely
|
||||
- Nextcloud supports multiple app directories via the `apps_paths` configuration
|
||||
|
||||
**How multiple app paths work:**
|
||||
- Nextcloud can load apps from multiple directories
|
||||
- The post-installation hook registers `/opt/apps` as an additional app directory (index 2)
|
||||
- Apps in default paths (index 0 and 1) are still available
|
||||
- All directories are scanned for apps, but `/opt/apps` is read-only
|
||||
|
||||
This setup allows you to:
|
||||
- Test changes without rebuilding containers
|
||||
- Avoid needing npm/node in the container (JS already built on host)
|
||||
- Iterate quickly on development
|
||||
- Install other Nextcloud apps normally (custom_apps remains writable)
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Mount Development App**: Docker mounts `~/Software/oidc` to `/opt/apps/oidc` (outside Nextcloud's path)
|
||||
2. **Register App Path**: The `10-install-oidc-app.sh` hook configures `/opt/apps` as an additional app directory
|
||||
3. **Enable App**: The hook enables the OIDC app from `/opt/apps/oidc`
|
||||
4. **Run Migrations**: Nextcloud detects pending migrations and runs them automatically
|
||||
5. **Configure OIDC**: Dynamic client registration and PKCE are enabled
|
||||
|
||||
## Starting the Stack
|
||||
|
||||
```bash
|
||||
cd ~/Projects/nextcloud-mcp-server
|
||||
|
||||
# Start fresh (recommended for first test)
|
||||
docker compose down -v
|
||||
docker compose up -d
|
||||
|
||||
# Wait for initialization (check logs)
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
The post-installation hooks will:
|
||||
1. Configure custom_apps path (already done)
|
||||
2. Enable OIDC app from mounted directory
|
||||
3. Run database migrations (including consent table creation)
|
||||
4. Configure OIDC settings
|
||||
|
||||
## Verifying Installation
|
||||
|
||||
### Before Container Restart
|
||||
|
||||
Before running `docker compose up -d`, the consent feature will NOT be active:
|
||||
- ❌ No `oc_oidc_user_consents` table in database
|
||||
- ❌ Migration 0015 not applied yet
|
||||
- ❌ ConsentController class not loaded
|
||||
- ❌ Consent routes not registered
|
||||
|
||||
You can verify this with:
|
||||
```bash
|
||||
# Check migrations applied (should stop at 0014)
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT version FROM oc_migrations WHERE app = 'oidc' ORDER BY version DESC LIMIT 3;" nextcloud
|
||||
|
||||
# Check for consent table (should return empty)
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SHOW TABLES LIKE 'oc_oidc_user_consents';" nextcloud
|
||||
```
|
||||
|
||||
### After Container Restart
|
||||
|
||||
After `docker compose up -d` with the mounted OIDC directory, the consent feature should be active:
|
||||
- ✅ `oc_oidc_user_consents` table exists
|
||||
- ✅ Migration 0015 (Version0015Date20251123100100) applied
|
||||
- ✅ ConsentController routes registered
|
||||
- ✅ Consent screen appears during OAuth flows
|
||||
|
||||
### Check App Status
|
||||
|
||||
```bash
|
||||
docker compose exec app php occ app:list | grep -A 2 oidc
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
- oidc: 1.10.0 (enabled)
|
||||
```
|
||||
|
||||
### Verify App Paths Configuration
|
||||
|
||||
Verify that `/opt/apps` is registered as an additional app directory:
|
||||
|
||||
```bash
|
||||
# Check configured app paths
|
||||
docker compose exec app php occ config:system:get apps_paths
|
||||
|
||||
# Verify the mount is accessible
|
||||
docker compose exec app ls -la /opt/apps/oidc/
|
||||
|
||||
# Verify custom_apps is writable (for normal app installation)
|
||||
docker compose exec -u www-data app touch /var/www/html/custom_apps/.test && echo "✅ custom_apps is writable" || echo "❌ custom_apps NOT writable"
|
||||
docker compose exec app rm -f /var/www/html/custom_apps/.test
|
||||
```
|
||||
|
||||
Expected: Output should show multiple app paths including index 2 (/opt/apps).
|
||||
|
||||
### Verify Consent Files
|
||||
|
||||
```bash
|
||||
# Check controller exists in mounted location
|
||||
docker compose exec app ls -la /opt/apps/oidc/lib/Controller/ConsentController.php
|
||||
|
||||
# Check Vue component exists
|
||||
docker compose exec app ls -la /opt/apps/oidc/src/Consent.vue
|
||||
|
||||
# Check built JS exists
|
||||
docker compose exec app ls -lh /opt/apps/oidc/js/oidc-consent.js
|
||||
```
|
||||
|
||||
### Verify Database Migration
|
||||
|
||||
**Note**: These checks will only pass after restarting containers with the mounted OIDC app.
|
||||
|
||||
```bash
|
||||
# Check if consent table exists
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SHOW TABLES LIKE 'oc_oidc_user_consents';"
|
||||
|
||||
# Check table structure
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "DESCRIBE oc_oidc_user_consents;"
|
||||
|
||||
# Verify migration 0015 was applied
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT app, version FROM oc_migrations WHERE app = 'oidc' AND version LIKE '%0015%';"
|
||||
```
|
||||
|
||||
Expected table structure:
|
||||
- id: int(10) unsigned, auto_increment, primary key
|
||||
- user_id: varchar(256), not null
|
||||
- client_id: int(10) unsigned, not null
|
||||
- scopes_granted: varchar(512), not null
|
||||
- created_at: int(10) unsigned, not null
|
||||
- updated_at: int(10) unsigned, not null
|
||||
- expires_at: int(10) unsigned, nullable
|
||||
|
||||
### Verify Routes
|
||||
|
||||
```bash
|
||||
docker compose exec app php occ router:list | grep consent
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
oidc.Consent.show GET apps/oidc/consent
|
||||
oidc.Consent.grant POST apps/oidc/consent/grant
|
||||
oidc.Consent.deny POST apps/oidc/consent/deny
|
||||
```
|
||||
|
||||
## Testing the Consent Flow
|
||||
|
||||
### 1. Create an OAuth Client
|
||||
|
||||
The JWT client is automatically created by the post-installation hooks:
|
||||
|
||||
```bash
|
||||
# Check if JWT client exists
|
||||
docker compose exec app cat /var/www/html/.oauth-jwt/nextcloud_oauth_client.json
|
||||
```
|
||||
|
||||
### 2. Initiate Authorization Flow
|
||||
|
||||
You can test using the MCP OAuth container or manually:
|
||||
|
||||
**Option A: Using MCP OAuth container**
|
||||
```bash
|
||||
# The mcp-oauth-jwt container will trigger the OAuth flow
|
||||
docker compose logs -f mcp-oauth-jwt
|
||||
```
|
||||
|
||||
**Option B: Manual browser test**
|
||||
1. Get client_id from the JWT client JSON
|
||||
2. Visit in browser:
|
||||
```
|
||||
http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:8002/oauth/callback&scope=openid+profile+email+nc:read+nc:write&state=test123
|
||||
```
|
||||
|
||||
### 3. Expected Behavior
|
||||
|
||||
**First Authorization:**
|
||||
1. User logs in (if not already authenticated)
|
||||
2. **Consent screen appears** with:
|
||||
- Application name: "Nextcloud MCP Server JWT"
|
||||
- List of requested scopes with descriptions:
|
||||
- ✓ Basic authentication (openid) - required, cannot deselect
|
||||
- ✓ Profile information (profile)
|
||||
- ✓ Email address (email)
|
||||
- ✓ nc:read (custom scope, shown as-is)
|
||||
- ✓ nc:write (custom scope, shown as-is)
|
||||
- "Allow" and "Deny" buttons
|
||||
3. User selects scopes and clicks "Allow"
|
||||
4. Authorization proceeds with selected scopes
|
||||
5. Consent is stored in database
|
||||
|
||||
**Subsequent Authorizations:**
|
||||
- Same scopes → No consent screen (uses stored consent)
|
||||
- Different scopes → Consent screen appears again
|
||||
- If user clicks "Deny" → Returns `error=access_denied` to client
|
||||
|
||||
### 4. Verify Consent Stored
|
||||
|
||||
After granting consent:
|
||||
|
||||
```bash
|
||||
# View all stored consents with formatted timestamps
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "
|
||||
SELECT
|
||||
user_id,
|
||||
client_id,
|
||||
scopes_granted,
|
||||
FROM_UNIXTIME(created_at) as created,
|
||||
FROM_UNIXTIME(updated_at) as updated,
|
||||
FROM_UNIXTIME(expires_at) as expires
|
||||
FROM oc_oidc_user_consents;
|
||||
" nextcloud
|
||||
|
||||
# Or for a compact view:
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT * FROM oc_oidc_user_consents;" nextcloud
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Consent Screen Not Appearing
|
||||
|
||||
**Check browser console** (F12 → Console tab):
|
||||
```
|
||||
# Look for JS errors like:
|
||||
Failed to load resource: js/oidc-consent.js
|
||||
```
|
||||
|
||||
**Check Nextcloud logs:**
|
||||
```bash
|
||||
docker compose exec app tail -f /var/www/html/data/nextcloud.log | grep -i consent
|
||||
```
|
||||
|
||||
**Verify JS file loaded:**
|
||||
```bash
|
||||
# Check file exists and has correct size (~73KB)
|
||||
docker compose exec app ls -lh /opt/apps/oidc/js/oidc-consent.js
|
||||
```
|
||||
|
||||
**Clear Nextcloud caches:**
|
||||
```bash
|
||||
docker compose exec app php occ maintenance:repair
|
||||
docker compose restart app
|
||||
```
|
||||
|
||||
### Migration Didn't Run
|
||||
|
||||
**Check which migrations have been applied:**
|
||||
```bash
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT app, version FROM oc_migrations WHERE app = 'oidc' ORDER BY version;" nextcloud
|
||||
```
|
||||
|
||||
Expected to see `Version0015Date20251123100100` in the list.
|
||||
|
||||
**Manually trigger migrations:**
|
||||
```bash
|
||||
# Disable and re-enable app (triggers all pending migrations)
|
||||
docker compose exec app php occ app:disable oidc
|
||||
docker compose exec app php occ app:enable oidc
|
||||
|
||||
# Verify migration 0015 was applied
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT version FROM oc_migrations WHERE app = 'oidc' AND version LIKE '%0015%';" nextcloud
|
||||
```
|
||||
|
||||
### Routes Not Registered
|
||||
|
||||
If `router:list` doesn't show consent routes:
|
||||
|
||||
```bash
|
||||
# The autoloader might not have picked up new classes
|
||||
# Restart the container
|
||||
docker compose restart app
|
||||
|
||||
# Wait for it to be ready
|
||||
sleep 10
|
||||
|
||||
# Try again
|
||||
docker compose exec app php occ router:list | grep consent
|
||||
```
|
||||
|
||||
If still not working, check if ConsentController is accessible:
|
||||
```bash
|
||||
docker compose exec app php -r "
|
||||
require_once '/var/www/html/lib/base.php';
|
||||
\$class = 'OCA\\OIDCIdentityProvider\\Controller\\ConsentController';
|
||||
if (class_exists(\$class)) {
|
||||
echo \"Class exists\n\";
|
||||
} else {
|
||||
echo \"Class not found\n\";
|
||||
}
|
||||
"
|
||||
```
|
||||
|
||||
## Making Changes
|
||||
|
||||
### Frontend Changes (Vue.js)
|
||||
|
||||
1. Edit source file on host:
|
||||
```bash
|
||||
cd ~/Software/oidc
|
||||
# Edit src/Consent.vue
|
||||
```
|
||||
|
||||
2. Rebuild JS:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
3. Refresh browser (container sees changes immediately via volume mount at /opt/apps/oidc)
|
||||
|
||||
### Backend Changes (PHP)
|
||||
|
||||
1. Edit files on host:
|
||||
```bash
|
||||
cd ~/Software/oidc
|
||||
# Edit lib/Controller/ConsentController.php or other PHP files
|
||||
```
|
||||
|
||||
2. Changes are immediately visible (PHP is interpreted, no build step)
|
||||
|
||||
3. For new classes or major changes, restart container:
|
||||
```bash
|
||||
docker compose restart app
|
||||
```
|
||||
|
||||
### Database Schema Changes
|
||||
|
||||
If you modify the migration:
|
||||
|
||||
```bash
|
||||
# Changes won't be picked up if migration already ran
|
||||
# Need to recreate the database:
|
||||
docker compose down -v # Removes volumes
|
||||
docker compose up -d # Fresh start with clean DB
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
### Reset Everything
|
||||
|
||||
```bash
|
||||
cd ~/Projects/nextcloud-mcp-server
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
This removes:
|
||||
- All containers
|
||||
- Database volume (all data)
|
||||
- OAuth client credentials
|
||||
|
||||
### Keep Data, Restart App
|
||||
|
||||
```bash
|
||||
docker compose restart app
|
||||
```
|
||||
|
||||
This preserves:
|
||||
- Database (consents, clients, users)
|
||||
- OAuth client credentials
|
||||
|
||||
## Development Workflow Summary
|
||||
|
||||
1. **Make changes** in `~/Software/oidc`
|
||||
2. **Build JS** if you changed Vue files: `npm run build`
|
||||
3. **Test immediately** - refresh browser or restart container
|
||||
4. **No need** to rebuild Docker images or reinstall app
|
||||
5. **Iterate quickly** with instant feedback
|
||||
|
||||
## Production Deployment
|
||||
|
||||
When ready to deploy:
|
||||
|
||||
1. **Create patch file** (already done):
|
||||
```bash
|
||||
cd ~/Software/oidc
|
||||
git format-patch master --stdout > user-consent-feature.patch
|
||||
```
|
||||
|
||||
2. **Test patch** in clean environment:
|
||||
```bash
|
||||
# In a production-like environment
|
||||
cd /path/to/production/oidc
|
||||
git apply user-consent-feature.patch
|
||||
npm install
|
||||
npm run build
|
||||
php occ app:disable oidc
|
||||
php occ app:enable oidc
|
||||
```
|
||||
|
||||
3. **Verify migration** runs automatically on app enable
|
||||
|
||||
4. **Submit pull request** to upstream repository
|
||||
+334
-77
@@ -11,9 +11,16 @@ from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
from nextcloud_mcp_server.auth import NextcloudTokenVerifier, load_or_register_client
|
||||
from nextcloud_mcp_server.auth import (
|
||||
InsufficientScopeError,
|
||||
NextcloudTokenVerifier,
|
||||
get_access_token_scopes,
|
||||
has_required_scopes,
|
||||
is_jwt_token,
|
||||
)
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import LOGGING_CONFIG, setup_logging
|
||||
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
|
||||
@@ -135,6 +142,95 @@ def is_oauth_mode() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def load_oauth_client_credentials(
|
||||
nextcloud_host: str, registration_endpoint: str | None
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Load OAuth client credentials from environment, storage file, or dynamic registration.
|
||||
|
||||
This consolidates the client loading logic that was duplicated across multiple functions.
|
||||
|
||||
Args:
|
||||
nextcloud_host: Nextcloud instance URL
|
||||
registration_endpoint: Dynamic registration endpoint URL (or None if not available)
|
||||
|
||||
Returns:
|
||||
Tuple of (client_id, client_secret)
|
||||
|
||||
Raises:
|
||||
ValueError: If credentials cannot be obtained
|
||||
"""
|
||||
# Try environment variables first
|
||||
client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID")
|
||||
client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET")
|
||||
|
||||
if client_id and client_secret:
|
||||
logger.info("Using pre-configured OAuth client credentials from environment")
|
||||
return (client_id, client_secret)
|
||||
|
||||
# Try loading from storage file
|
||||
storage_path = os.getenv(
|
||||
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registration import load_client_from_file
|
||||
|
||||
client_info = load_client_from_file(Path(storage_path))
|
||||
|
||||
if client_info:
|
||||
logger.info(
|
||||
f"Loaded OAuth client from storage: {client_info.client_id[:16]}..."
|
||||
)
|
||||
return (client_info.client_id, client_info.client_secret)
|
||||
|
||||
# Try dynamic registration if available
|
||||
if registration_endpoint:
|
||||
logger.info("Dynamic client registration available")
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
|
||||
|
||||
# Get scopes from environment or use defaults
|
||||
scopes = os.getenv(
|
||||
"NEXTCLOUD_OIDC_SCOPES", "openid profile email nc:read nc:write"
|
||||
)
|
||||
logger.info(f"Requesting OAuth scopes: {scopes}")
|
||||
|
||||
# Get token type from environment (Bearer or jwt)
|
||||
# Note: Must be lowercase "jwt" to match OIDC app's check
|
||||
token_type = os.getenv("NEXTCLOUD_OIDC_TOKEN_TYPE", "Bearer").lower()
|
||||
# Special case: "bearer" should remain capitalized for compatibility
|
||||
if token_type != "jwt":
|
||||
token_type = "Bearer"
|
||||
logger.info(f"Requesting token type: {token_type}")
|
||||
|
||||
# Load or register client
|
||||
from nextcloud_mcp_server.auth.client_registration import (
|
||||
load_or_register_client,
|
||||
)
|
||||
|
||||
client_info = await load_or_register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
storage_path=storage_path,
|
||||
client_name="Nextcloud MCP Server",
|
||||
redirect_uris=redirect_uris,
|
||||
scopes=scopes,
|
||||
token_type=token_type,
|
||||
)
|
||||
|
||||
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
|
||||
return (client_info.client_id, client_info.client_secret)
|
||||
|
||||
# No credentials available
|
||||
raise ValueError(
|
||||
"OAuth mode requires either:\n"
|
||||
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET environment variables, OR\n"
|
||||
"2. Pre-existing client credentials file at NEXTCLOUD_OIDC_CLIENT_STORAGE, OR\n"
|
||||
"3. Dynamic client registration enabled on Nextcloud OIDC app"
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
"""
|
||||
@@ -187,45 +283,24 @@ async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
|
||||
# Extract endpoints
|
||||
userinfo_uri = discovery["userinfo_endpoint"]
|
||||
registration_endpoint = discovery.get("registration_endpoint")
|
||||
introspection_uri = discovery.get("introspection_endpoint")
|
||||
|
||||
logger.info(f"Userinfo endpoint: {userinfo_uri}")
|
||||
if introspection_uri:
|
||||
logger.info(f"Introspection endpoint: {introspection_uri}")
|
||||
|
||||
# Handle client registration
|
||||
client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID")
|
||||
client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET")
|
||||
storage_path = os.getenv(
|
||||
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
|
||||
# Load OAuth client credentials
|
||||
client_id, client_secret = await load_oauth_client_credentials(
|
||||
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
|
||||
)
|
||||
|
||||
if client_id and client_secret:
|
||||
logger.info("Using pre-configured OAuth client credentials")
|
||||
elif registration_endpoint:
|
||||
logger.info("Dynamic client registration available")
|
||||
mcp_server_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
|
||||
|
||||
# Load or register client
|
||||
client_info = await load_or_register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
storage_path=storage_path,
|
||||
client_name="Nextcloud MCP Server",
|
||||
redirect_uris=redirect_uris,
|
||||
)
|
||||
|
||||
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
|
||||
else:
|
||||
raise ValueError(
|
||||
"OAuth mode requires either:\n"
|
||||
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET, OR\n"
|
||||
"2. Dynamic client registration enabled on Nextcloud OIDC app"
|
||||
)
|
||||
|
||||
# Create token verifier
|
||||
# Create token verifier with introspection support
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri
|
||||
nextcloud_host=nextcloud_host,
|
||||
userinfo_uri=userinfo_uri,
|
||||
introspection_uri=introspection_uri,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
|
||||
logger.info("OAuth initialization complete")
|
||||
@@ -278,59 +353,60 @@ async def setup_oauth_config():
|
||||
# Extract endpoints
|
||||
issuer = discovery["issuer"]
|
||||
userinfo_uri = discovery["userinfo_endpoint"]
|
||||
jwks_uri = discovery.get("jwks_uri")
|
||||
introspection_uri = discovery.get("introspection_endpoint")
|
||||
registration_endpoint = discovery.get("registration_endpoint")
|
||||
|
||||
# Allow override of public issuer URL for clients
|
||||
# (useful when MCP server accesses Nextcloud via internal URL
|
||||
# but needs to advertise a different URL to clients)
|
||||
logger.info("OIDC endpoints discovered:")
|
||||
logger.info(f" Issuer: {issuer}")
|
||||
logger.info(f" Userinfo: {userinfo_uri}")
|
||||
logger.info(f" JWKS: {jwks_uri}")
|
||||
if introspection_uri:
|
||||
logger.info(f" Introspection: {introspection_uri}")
|
||||
|
||||
# Allow override of public issuer URL for both client configuration and JWT validation
|
||||
# When clients access Nextcloud via a public URL (e.g., http://127.0.0.1:8080),
|
||||
# the OIDC app issues JWT tokens with that public URL in the 'iss' claim,
|
||||
# even though the MCP server accesses Nextcloud via an internal URL (e.g., http://app).
|
||||
# Therefore, we must validate JWT tokens against the public issuer, not the internal one.
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
public_issuer = public_issuer.rstrip("/")
|
||||
logger.info(f"Using public issuer URL for clients: {public_issuer}")
|
||||
logger.info(
|
||||
f"Using public issuer URL for clients and JWT validation: {public_issuer}"
|
||||
)
|
||||
# Use public issuer for both client configuration AND JWT validation
|
||||
issuer = public_issuer
|
||||
|
||||
# Handle client registration
|
||||
client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID")
|
||||
client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET")
|
||||
|
||||
if client_id and client_secret:
|
||||
logger.info("Using pre-configured OAuth client credentials")
|
||||
elif registration_endpoint:
|
||||
logger.info("Dynamic client registration available")
|
||||
storage_path = os.getenv(
|
||||
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
|
||||
)
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
|
||||
|
||||
# Load or register client
|
||||
client_info = await load_or_register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
storage_path=storage_path,
|
||||
client_name="Nextcloud MCP Server",
|
||||
redirect_uris=redirect_uris,
|
||||
)
|
||||
|
||||
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
|
||||
jwt_validation_issuer = public_issuer
|
||||
else:
|
||||
raise ValueError(
|
||||
"OAuth mode requires either:\n"
|
||||
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET, OR\n"
|
||||
"2. Dynamic client registration enabled on Nextcloud OIDC app"
|
||||
)
|
||||
# Use discovered issuer for both
|
||||
jwt_validation_issuer = issuer
|
||||
|
||||
# Create token verifier
|
||||
# Load OAuth client credentials
|
||||
client_id, client_secret = await load_oauth_client_credentials(
|
||||
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
|
||||
)
|
||||
|
||||
# Create token verifier with JWT support and introspection
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri
|
||||
nextcloud_host=nextcloud_host,
|
||||
userinfo_uri=userinfo_uri,
|
||||
jwks_uri=jwks_uri, # Enable JWT verification if available
|
||||
issuer=jwt_validation_issuer, # Use original issuer for JWT validation
|
||||
introspection_uri=introspection_uri, # Enable introspection for opaque tokens
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
|
||||
# Create auth settings
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
|
||||
# Note: We don't set required_scopes here anymore.
|
||||
# Scopes are now advertised via PRM endpoint and enforced per-tool.
|
||||
# This allows dynamic tool filtering based on user's actual token scopes.
|
||||
auth_settings = AuthSettings(
|
||||
issuer_url=AnyHttpUrl(issuer),
|
||||
resource_server_url=AnyHttpUrl(mcp_server_url),
|
||||
required_scopes=["openid", "profile"],
|
||||
)
|
||||
|
||||
logger.info("OAuth configuration complete")
|
||||
@@ -393,6 +469,54 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
f"Unknown app: {app_name}. Available apps: {list(available_apps.keys())}"
|
||||
)
|
||||
|
||||
# Override list_tools to filter based on user's token scopes (OAuth mode only)
|
||||
if oauth_enabled:
|
||||
original_list_tools = mcp._tool_manager.list_tools
|
||||
|
||||
def list_tools_filtered():
|
||||
"""List tools filtered by user's token scopes (JWT tokens only)."""
|
||||
# Get user's scopes from token using MCP SDK's contextvar
|
||||
# This works for all request types including list_tools
|
||||
user_scopes = get_access_token_scopes()
|
||||
is_jwt = is_jwt_token()
|
||||
logger.info(
|
||||
f"🔍 list_tools called - Token type: {'JWT' if is_jwt else 'opaque/none'}, "
|
||||
f"User scopes: {user_scopes}"
|
||||
)
|
||||
|
||||
# Get all tools
|
||||
all_tools = original_list_tools()
|
||||
|
||||
# Only filter for JWT tokens (opaque tokens show all tools)
|
||||
# JWT tokens have scopes embedded, so we can reliably filter
|
||||
# Opaque tokens may not have accurate scope information from introspection
|
||||
if is_jwt and user_scopes:
|
||||
allowed_tools = [
|
||||
tool
|
||||
for tool in all_tools
|
||||
if has_required_scopes(tool.fn, user_scopes)
|
||||
]
|
||||
logger.info(
|
||||
f"✂️ JWT scope filtering: {len(allowed_tools)}/{len(all_tools)} tools "
|
||||
f"available for scopes: {user_scopes}"
|
||||
)
|
||||
else:
|
||||
# Opaque token, BasicAuth mode, or no token - show all tools
|
||||
allowed_tools = all_tools
|
||||
reason = (
|
||||
"opaque token (no filtering)"
|
||||
if not is_jwt and user_scopes
|
||||
else "no token/BasicAuth"
|
||||
)
|
||||
logger.info(f"📋 Showing all {len(all_tools)} tools ({reason})")
|
||||
|
||||
# Return the Tool objects directly (they're already in the correct format)
|
||||
return allowed_tools
|
||||
|
||||
# Replace the tool manager's list_tools method
|
||||
mcp._tool_manager.list_tools = list_tools_filtered
|
||||
logger.info("Dynamic tool filtering enabled for OAuth mode (JWT tokens only)")
|
||||
|
||||
if transport == "sse":
|
||||
mcp_app = mcp.sse_app()
|
||||
lifespan = None
|
||||
@@ -405,7 +529,71 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
yield
|
||||
|
||||
app = Starlette(routes=[Mount("/", app=mcp_app)], lifespan=lifespan)
|
||||
# Add Protected Resource Metadata (PRM) endpoint for OAuth mode
|
||||
routes = []
|
||||
if oauth_enabled:
|
||||
|
||||
def oauth_protected_resource_metadata(request):
|
||||
"""RFC 8959 Protected Resource Metadata endpoint."""
|
||||
mcp_server_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
# Use PUBLIC_ISSUER_URL for authorization server since external clients
|
||||
# (like Claude) need the publicly accessible URL, not internal Docker URLs
|
||||
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if not public_issuer_url:
|
||||
# Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set
|
||||
public_issuer_url = os.getenv("NEXTCLOUD_HOST", "")
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"resource": mcp_server_url,
|
||||
"scopes_supported": ["nc:read", "nc:write"],
|
||||
"authorization_servers": [public_issuer_url],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_signing_alg_values_supported": ["RS256"],
|
||||
}
|
||||
)
|
||||
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-protected-resource",
|
||||
oauth_protected_resource_metadata,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
logger.info("Protected Resource Metadata (PRM) endpoint enabled")
|
||||
|
||||
routes.append(Mount("/", app=mcp_app))
|
||||
app = Starlette(routes=routes, lifespan=lifespan)
|
||||
|
||||
# Add exception handler for scope challenges (OAuth mode only)
|
||||
if oauth_enabled:
|
||||
|
||||
@app.exception_handler(InsufficientScopeError)
|
||||
async def handle_insufficient_scope(request, exc: InsufficientScopeError):
|
||||
"""Return 403 with WWW-Authenticate header for scope challenges."""
|
||||
resource_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
scope_str = " ".join(exc.missing_scopes)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
headers={
|
||||
"WWW-Authenticate": (
|
||||
f'Bearer error="insufficient_scope", '
|
||||
f'scope="{scope_str}", '
|
||||
f'resource_metadata="{resource_url}/.well-known/oauth-protected-resource"'
|
||||
)
|
||||
},
|
||||
content={
|
||||
"error": "insufficient_scope",
|
||||
"scopes_required": exc.missing_scopes,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info("WWW-Authenticate scope challenge handler enabled")
|
||||
|
||||
return app
|
||||
|
||||
@@ -471,6 +659,41 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
show_default=True,
|
||||
help="MCP server URL for OAuth callbacks (can also use NEXTCLOUD_MCP_SERVER_URL env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--nextcloud-host",
|
||||
envvar="NEXTCLOUD_HOST",
|
||||
help="Nextcloud instance URL (can also use NEXTCLOUD_HOST env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--nextcloud-username",
|
||||
envvar="NEXTCLOUD_USERNAME",
|
||||
help="Nextcloud username for BasicAuth (can also use NEXTCLOUD_USERNAME env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--nextcloud-password",
|
||||
envvar="NEXTCLOUD_PASSWORD",
|
||||
help="Nextcloud password for BasicAuth (can also use NEXTCLOUD_PASSWORD env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--oauth-scopes",
|
||||
envvar="NEXTCLOUD_OIDC_SCOPES",
|
||||
default="openid profile email nc:read nc:write",
|
||||
show_default=True,
|
||||
help="OAuth scopes to request (can also use NEXTCLOUD_OIDC_SCOPES env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--oauth-token-type",
|
||||
envvar="NEXTCLOUD_OIDC_TOKEN_TYPE",
|
||||
default="bearer",
|
||||
show_default=True,
|
||||
type=click.Choice(["bearer", "jwt"], case_sensitive=False),
|
||||
help="OAuth token type (can also use NEXTCLOUD_OIDC_TOKEN_TYPE env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--public-issuer-url",
|
||||
envvar="NEXTCLOUD_PUBLIC_ISSUER_URL",
|
||||
help="Public issuer URL for OAuth (can also use NEXTCLOUD_PUBLIC_ISSUER_URL env var)",
|
||||
)
|
||||
def run(
|
||||
host: str,
|
||||
port: int,
|
||||
@@ -482,6 +705,12 @@ def run(
|
||||
oauth_client_secret: str | None,
|
||||
oauth_storage_path: str,
|
||||
mcp_server_url: str,
|
||||
nextcloud_host: str | None,
|
||||
nextcloud_username: str | None,
|
||||
nextcloud_password: str | None,
|
||||
oauth_scopes: str,
|
||||
oauth_token_type: str,
|
||||
public_issuer_url: str | None,
|
||||
):
|
||||
"""
|
||||
Run the Nextcloud MCP server.
|
||||
@@ -493,24 +722,52 @@ def run(
|
||||
|
||||
\b
|
||||
Examples:
|
||||
# BasicAuth mode (legacy)
|
||||
# BasicAuth mode with CLI options
|
||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com \\
|
||||
--nextcloud-username=admin --nextcloud-password=secret
|
||||
|
||||
# BasicAuth mode with env vars (recommended for credentials)
|
||||
$ export NEXTCLOUD_HOST=https://cloud.example.com
|
||||
$ export NEXTCLOUD_USERNAME=admin
|
||||
$ export NEXTCLOUD_PASSWORD=secret
|
||||
$ nextcloud-mcp-server --host 0.0.0.0 --port 8000
|
||||
|
||||
# OAuth mode with auto-registration
|
||||
$ nextcloud-mcp-server --oauth
|
||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth
|
||||
|
||||
# OAuth mode with pre-configured client
|
||||
$ nextcloud-mcp-server --oauth --oauth-client-id=xxx --oauth-client-secret=yyy
|
||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
|
||||
--oauth-client-id=xxx --oauth-client-secret=yyy
|
||||
|
||||
# OAuth mode with custom scopes and JWT tokens
|
||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
|
||||
--oauth-scopes="openid nc:read" --oauth-token-type=jwt
|
||||
|
||||
# OAuth with public issuer URL (for Docker/proxy setups)
|
||||
$ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\
|
||||
--public-issuer-url=http://localhost:8080
|
||||
"""
|
||||
# Set OAuth env vars from CLI options if provided
|
||||
# Set env vars from CLI options if provided
|
||||
if nextcloud_host:
|
||||
os.environ["NEXTCLOUD_HOST"] = nextcloud_host
|
||||
if nextcloud_username:
|
||||
os.environ["NEXTCLOUD_USERNAME"] = nextcloud_username
|
||||
if nextcloud_password:
|
||||
os.environ["NEXTCLOUD_PASSWORD"] = nextcloud_password
|
||||
if oauth_client_id:
|
||||
os.environ["NEXTCLOUD_OIDC_CLIENT_ID"] = oauth_client_id
|
||||
if oauth_client_secret:
|
||||
os.environ["NEXTCLOUD_OIDC_CLIENT_SECRET"] = oauth_client_secret
|
||||
if oauth_storage_path:
|
||||
os.environ["NEXTCLOUD_OIDC_CLIENT_STORAGE"] = oauth_storage_path
|
||||
if oauth_scopes:
|
||||
os.environ["NEXTCLOUD_OIDC_SCOPES"] = oauth_scopes
|
||||
if oauth_token_type:
|
||||
os.environ["NEXTCLOUD_OIDC_TOKEN_TYPE"] = oauth_token_type
|
||||
if mcp_server_url:
|
||||
os.environ["NEXTCLOUD_MCP_SERVER_URL"] = mcp_server_url
|
||||
if public_issuer_url:
|
||||
os.environ["NEXTCLOUD_PUBLIC_ISSUER_URL"] = public_issuer_url
|
||||
|
||||
# Force OAuth mode if explicitly requested
|
||||
if oauth is True:
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
from .bearer_auth import BearerAuth
|
||||
from .client_registration import load_or_register_client, register_client
|
||||
from .context_helper import get_client_from_context
|
||||
from .scope_authorization import (
|
||||
InsufficientScopeError,
|
||||
ScopeAuthorizationError,
|
||||
check_scopes,
|
||||
get_access_token_scopes,
|
||||
get_required_scopes,
|
||||
has_required_scopes,
|
||||
is_jwt_token,
|
||||
require_scopes,
|
||||
)
|
||||
from .token_verifier import NextcloudTokenVerifier
|
||||
|
||||
__all__ = [
|
||||
@@ -11,4 +21,12 @@ __all__ = [
|
||||
"register_client",
|
||||
"load_or_register_client",
|
||||
"get_client_from_context",
|
||||
"require_scopes",
|
||||
"ScopeAuthorizationError",
|
||||
"InsufficientScopeError",
|
||||
"check_scopes",
|
||||
"get_access_token_scopes",
|
||||
"get_required_scopes",
|
||||
"has_required_scopes",
|
||||
"is_jwt_token",
|
||||
]
|
||||
|
||||
@@ -68,6 +68,7 @@ async def register_client(
|
||||
client_name: str = "Nextcloud MCP Server",
|
||||
redirect_uris: list[str] | None = None,
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str = "Bearer",
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
|
||||
@@ -78,6 +79,7 @@ async def register_client(
|
||||
client_name: Name of the client application
|
||||
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
|
||||
scopes: Space-separated list of scopes to request
|
||||
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||
|
||||
Returns:
|
||||
ClientInfo with registration details
|
||||
@@ -96,6 +98,7 @@ async def register_client(
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": scopes,
|
||||
"token_type": token_type,
|
||||
}
|
||||
|
||||
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
||||
@@ -215,6 +218,8 @@ async def load_or_register_client(
|
||||
storage_path: str | Path,
|
||||
client_name: str = "Nextcloud MCP Server",
|
||||
redirect_uris: list[str] | None = None,
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str = "Bearer",
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Load client from storage or register a new one if not found/expired.
|
||||
@@ -231,6 +236,8 @@ async def load_or_register_client(
|
||||
storage_path: Path to store client credentials
|
||||
client_name: Name of the client application
|
||||
redirect_uris: List of redirect URIs
|
||||
scopes: Space-separated list of scopes to request (default: "openid profile email")
|
||||
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||
|
||||
Returns:
|
||||
ClientInfo with valid credentials
|
||||
@@ -253,6 +260,8 @@ async def load_or_register_client(
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name=client_name,
|
||||
redirect_uris=redirect_uris,
|
||||
scopes=scopes,
|
||||
token_type=token_type,
|
||||
)
|
||||
|
||||
# Save to storage
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Scope-based authorization for MCP tools."""
|
||||
|
||||
import logging
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.fastmcp import Context
|
||||
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScopeAuthorizationError(Exception):
|
||||
"""Raised when a request lacks required scopes."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InsufficientScopeError(ScopeAuthorizationError):
|
||||
"""Raised when request lacks required scopes (enables step-up auth).
|
||||
|
||||
This exception triggers a 403 response with WWW-Authenticate header
|
||||
containing the missing scopes, allowing clients to perform step-up
|
||||
authorization to obtain additional permissions.
|
||||
"""
|
||||
|
||||
def __init__(self, missing_scopes: list[str], message: str | None = None):
|
||||
self.missing_scopes = missing_scopes
|
||||
super().__init__(
|
||||
message or f"Missing required scopes: {', '.join(missing_scopes)}"
|
||||
)
|
||||
|
||||
|
||||
def require_scopes(*required_scopes: str):
|
||||
"""
|
||||
Decorator to require specific OAuth scopes for MCP tool execution.
|
||||
|
||||
This decorator:
|
||||
1. Stores scope requirements as function metadata (_required_scopes attribute)
|
||||
2. Checks that the access token contains all required scopes before execution
|
||||
3. Raises ScopeAuthorizationError if any required scope is missing
|
||||
|
||||
The stored metadata enables dynamic tool filtering - tools can be hidden from
|
||||
users who lack the necessary scopes.
|
||||
|
||||
Args:
|
||||
*required_scopes: Variable number of scope strings required (e.g., "nc:read", "nc:write")
|
||||
|
||||
Returns:
|
||||
Decorated function that checks scopes before execution
|
||||
|
||||
Example:
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_notes_get_note(ctx: Context, note_id: int):
|
||||
# This tool requires the nc:read scope
|
||||
...
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_notes_create_note(ctx: Context, ...):
|
||||
# This tool requires the nc:write scope
|
||||
...
|
||||
```
|
||||
|
||||
Raises:
|
||||
ScopeAuthorizationError: If required scopes are not present in the access token
|
||||
"""
|
||||
|
||||
def decorator(func: Callable):
|
||||
# Store scope requirements as function metadata for dynamic filtering
|
||||
func._required_scopes = list(required_scopes) # type: ignore
|
||||
|
||||
# Find which parameter receives the Context (FastMCP injects it by name)
|
||||
context_param_name = find_context_parameter(func)
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Extract context from kwargs (where FastMCP injected it)
|
||||
ctx: Context | None = (
|
||||
kwargs.get(context_param_name) if context_param_name else None
|
||||
)
|
||||
|
||||
if ctx is None:
|
||||
# No context parameter found - likely BasicAuth mode
|
||||
# In BasicAuth mode, all operations are allowed
|
||||
logger.debug(
|
||||
f"No context parameter for {func.__name__} - allowing (BasicAuth mode)"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Check if we're in OAuth mode (access token available)
|
||||
access_token: AccessToken | None = getattr(
|
||||
ctx.request_context, "access_token", None
|
||||
)
|
||||
|
||||
if access_token is None:
|
||||
# Not in OAuth mode (BasicAuth or no auth)
|
||||
# In BasicAuth mode, all operations are allowed
|
||||
logger.debug(
|
||||
f"No access token present for {func.__name__} - allowing (BasicAuth mode)"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Extract scopes from access token
|
||||
token_scopes = set(access_token.scopes or [])
|
||||
required_scopes_set = set(required_scopes)
|
||||
|
||||
# Check if all required scopes are present
|
||||
missing_scopes = required_scopes_set - token_scopes
|
||||
if missing_scopes:
|
||||
error_msg = (
|
||||
f"Access denied to {func.__name__}: "
|
||||
f"Missing required scopes: {', '.join(sorted(missing_scopes))}. "
|
||||
f"Token has scopes: {', '.join(sorted(token_scopes)) if token_scopes else 'none'}"
|
||||
)
|
||||
logger.warning(error_msg)
|
||||
raise InsufficientScopeError(list(missing_scopes), error_msg)
|
||||
|
||||
# All required scopes present - allow execution
|
||||
logger.debug(
|
||||
f"Scope authorization passed for {func.__name__}: {required_scopes}"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_access_token_scopes(ctx: Context | None = None) -> set[str]:
|
||||
"""
|
||||
Extract scopes from the authenticated user's access token.
|
||||
|
||||
This function uses MCP SDK's contextvar to access the token, which works
|
||||
across all request types including list_tools.
|
||||
|
||||
Args:
|
||||
ctx: FastMCP context object (unused, kept for compatibility)
|
||||
|
||||
Returns:
|
||||
Set of scope strings, empty set if no token or no scopes
|
||||
"""
|
||||
# Use MCP SDK's get_access_token() which uses contextvars
|
||||
# This works for all request types, including list_tools
|
||||
access_token: AccessToken | None = get_access_token()
|
||||
|
||||
if access_token is None:
|
||||
logger.debug("No access token found in auth context (likely BasicAuth mode)")
|
||||
return set()
|
||||
|
||||
scopes = set(access_token.scopes or [])
|
||||
logger.info(f"✅ Extracted scopes from access token: {scopes}")
|
||||
return scopes
|
||||
|
||||
|
||||
def check_scopes(ctx: Context, *required_scopes: str) -> tuple[bool, set[str]]:
|
||||
"""
|
||||
Check if the request context has all required scopes.
|
||||
|
||||
Utility function for manual scope checking without decorator.
|
||||
|
||||
Args:
|
||||
ctx: FastMCP context object
|
||||
*required_scopes: Variable number of required scope strings
|
||||
|
||||
Returns:
|
||||
Tuple of (has_all_scopes: bool, missing_scopes: set[str])
|
||||
|
||||
Example:
|
||||
```python
|
||||
async def my_tool(ctx: Context):
|
||||
has_scopes, missing = check_scopes(ctx, "nc:read", "nc:write")
|
||||
if not has_scopes:
|
||||
# Handle missing scopes
|
||||
...
|
||||
```
|
||||
"""
|
||||
token_scopes = get_access_token_scopes(ctx)
|
||||
|
||||
# If no access token, assume BasicAuth mode (all operations allowed)
|
||||
if not token_scopes and getattr(ctx.request_context, "access_token", None) is None:
|
||||
return True, set()
|
||||
|
||||
required_scopes_set = set(required_scopes)
|
||||
missing_scopes = required_scopes_set - token_scopes
|
||||
|
||||
return len(missing_scopes) == 0, missing_scopes
|
||||
|
||||
|
||||
def get_required_scopes(func: Callable) -> list[str]:
|
||||
"""
|
||||
Extract required scopes from a function decorated with @require_scopes.
|
||||
|
||||
Args:
|
||||
func: Function to check (may be decorated)
|
||||
|
||||
Returns:
|
||||
List of required scope strings, empty list if no scopes required
|
||||
|
||||
Example:
|
||||
```python
|
||||
@require_scopes("nc:read", "nc:write")
|
||||
async def my_tool():
|
||||
pass
|
||||
|
||||
scopes = get_required_scopes(my_tool) # ["nc:read", "nc:write"]
|
||||
```
|
||||
"""
|
||||
return getattr(func, "_required_scopes", [])
|
||||
|
||||
|
||||
def is_jwt_token() -> bool:
|
||||
"""
|
||||
Check if the current access token is in JWT format.
|
||||
|
||||
JWT tokens have 3 parts separated by dots (header.payload.signature).
|
||||
Opaque tokens are random strings without this structure.
|
||||
|
||||
Returns:
|
||||
True if current token is JWT format, False if opaque or no token
|
||||
"""
|
||||
access_token: AccessToken | None = get_access_token()
|
||||
|
||||
if access_token is None:
|
||||
logger.debug("No access token found - not JWT")
|
||||
return False
|
||||
|
||||
# JWT tokens have exactly 2 dots (3 parts)
|
||||
token_string = access_token.token
|
||||
is_jwt = "." in token_string and token_string.count(".") == 2
|
||||
|
||||
logger.debug(f"Token format check: is_jwt={is_jwt}")
|
||||
return is_jwt
|
||||
|
||||
|
||||
def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
|
||||
"""
|
||||
Check if a user has all scopes required by a function.
|
||||
|
||||
Used for dynamic tool filtering - determines if a tool should be visible
|
||||
to a user based on their token scopes.
|
||||
|
||||
Args:
|
||||
func: Function decorated with @require_scopes
|
||||
user_scopes: Set of scopes the user possesses
|
||||
|
||||
Returns:
|
||||
True if user has all required scopes (or no scopes required), False otherwise
|
||||
|
||||
Example:
|
||||
```python
|
||||
@require_scopes("nc:write")
|
||||
async def create_note():
|
||||
pass
|
||||
|
||||
user_scopes = {"nc:read", "nc:write"}
|
||||
can_see = has_required_scopes(create_note, user_scopes) # True
|
||||
|
||||
limited_user_scopes = {"nc:read"}
|
||||
can_see = has_required_scopes(create_note, limited_user_scopes) # False
|
||||
```
|
||||
"""
|
||||
required = get_required_scopes(func)
|
||||
|
||||
# No scopes required → always allow
|
||||
if not required:
|
||||
return True
|
||||
|
||||
# Empty user_scopes but scopes required → deny
|
||||
if not user_scopes:
|
||||
return False
|
||||
|
||||
# Check if user has all required scopes
|
||||
return set(required).issubset(user_scopes)
|
||||
@@ -5,6 +5,8 @@ import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -12,22 +14,33 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class NextcloudTokenVerifier(TokenVerifier):
|
||||
"""
|
||||
Validates access tokens using Nextcloud OIDC userinfo endpoint.
|
||||
Validates access tokens using JWT verification with JWKS or userinfo endpoint fallback.
|
||||
|
||||
This verifier:
|
||||
1. Calls the userinfo endpoint with the bearer token
|
||||
2. Caches successful responses to avoid repeated API calls
|
||||
3. Extracts username from the 'sub' or 'preferred_username' claim
|
||||
4. Optionally supports JWT validation for performance (future enhancement)
|
||||
This verifier supports both JWT and opaque tokens:
|
||||
1. For JWT tokens: Verifies signature with JWKS and extracts scopes from payload
|
||||
2. For opaque tokens: Falls back to userinfo endpoint validation
|
||||
3. Caches successful responses to avoid repeated API calls/verifications
|
||||
|
||||
The userinfo endpoint validates the token and returns user claims if valid,
|
||||
or returns HTTP 400/401 if the token is invalid or expired.
|
||||
JWT validation provides:
|
||||
- Faster validation (no HTTP call needed)
|
||||
- Direct scope extraction from token payload
|
||||
- Signature verification using JWKS
|
||||
|
||||
Userinfo fallback provides:
|
||||
- Support for opaque tokens
|
||||
- Backward compatibility
|
||||
- Additional validation layer
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nextcloud_host: str,
|
||||
userinfo_uri: str,
|
||||
jwks_uri: str | None = None,
|
||||
issuer: str | None = None,
|
||||
introspection_uri: str | None = None,
|
||||
client_id: str | None = None,
|
||||
client_secret: str | None = None,
|
||||
cache_ttl: int = 3600,
|
||||
):
|
||||
"""
|
||||
@@ -36,26 +49,52 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
Args:
|
||||
nextcloud_host: Base URL of the Nextcloud instance (e.g., https://cloud.example.com)
|
||||
userinfo_uri: Full URL to the userinfo endpoint
|
||||
jwks_uri: Full URL to the JWKS endpoint (for JWT verification)
|
||||
issuer: Expected issuer claim value (for JWT verification)
|
||||
introspection_uri: Full URL to the introspection endpoint (for opaque tokens)
|
||||
client_id: OAuth client ID (required for introspection)
|
||||
client_secret: OAuth client secret (required for introspection)
|
||||
cache_ttl: Time-to-live for cached tokens in seconds (default: 3600)
|
||||
"""
|
||||
self.nextcloud_host = nextcloud_host.rstrip("/")
|
||||
self.userinfo_uri = userinfo_uri
|
||||
self.jwks_uri = jwks_uri
|
||||
self.issuer = issuer
|
||||
self.introspection_uri = introspection_uri
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.cache_ttl = cache_ttl
|
||||
|
||||
# Cache: token -> (userinfo, expiry_timestamp)
|
||||
self._token_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
||||
|
||||
# HTTP client for userinfo requests
|
||||
# HTTP client for userinfo/introspection requests
|
||||
self._client = httpx.AsyncClient(timeout=10.0)
|
||||
|
||||
# PyJWKClient for JWT verification (lazy initialization)
|
||||
self._jwks_client: PyJWKClient | None = None
|
||||
if jwks_uri:
|
||||
logger.info(f"JWT verification enabled with JWKS URI: {jwks_uri}")
|
||||
self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True)
|
||||
|
||||
# Introspection support
|
||||
if introspection_uri and client_id and client_secret:
|
||||
logger.info(f"Token introspection enabled: {introspection_uri}")
|
||||
elif introspection_uri:
|
||||
logger.warning(
|
||||
"Introspection URI provided but missing client credentials - introspection disabled"
|
||||
)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Verify a bearer token by calling the userinfo endpoint.
|
||||
Verify a bearer token using JWT verification, introspection, or userinfo endpoint.
|
||||
|
||||
This method:
|
||||
1. Checks the cache first for recent validations
|
||||
2. Calls the userinfo endpoint if not cached
|
||||
3. Returns AccessToken with username stored in metadata
|
||||
2. Attempts JWT verification if JWKS is configured and token looks like JWT
|
||||
3. Falls back to introspection for opaque tokens (if configured)
|
||||
4. Falls back to userinfo endpoint as last resort
|
||||
5. Returns AccessToken with username and scopes
|
||||
|
||||
Args:
|
||||
token: The bearer token to verify
|
||||
@@ -69,13 +108,225 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
logger.debug("Token found in cache")
|
||||
return cached
|
||||
|
||||
# Validate via userinfo endpoint
|
||||
# Try JWT verification first if enabled and token looks like JWT
|
||||
is_jwt_format = self._is_jwt_format(token)
|
||||
logger.debug(
|
||||
f"Token format check: is_jwt_format={is_jwt_format}, _jwks_client={self._jwks_client is not None}"
|
||||
)
|
||||
if self._jwks_client and is_jwt_format:
|
||||
logger.debug("Attempting JWT verification...")
|
||||
jwt_result = self._verify_jwt(token)
|
||||
if jwt_result:
|
||||
logger.info("Token validated via JWT verification")
|
||||
return jwt_result
|
||||
else:
|
||||
logger.warning("JWT verification failed, will try other methods")
|
||||
|
||||
# For opaque tokens, try introspection if available
|
||||
if self.introspection_uri and self.client_id and self.client_secret:
|
||||
logger.debug("Attempting token introspection...")
|
||||
try:
|
||||
introspection_result = await self._verify_via_introspection(token)
|
||||
if introspection_result:
|
||||
logger.info("Token validated via introspection")
|
||||
return introspection_result
|
||||
except Exception as e:
|
||||
logger.warning(f"Introspection failed: {e}")
|
||||
|
||||
# Fall back to userinfo endpoint validation (last resort)
|
||||
logger.debug("Attempting userinfo endpoint validation...")
|
||||
try:
|
||||
return await self._verify_via_userinfo(token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Token verification failed: {e}")
|
||||
return None
|
||||
|
||||
def _is_jwt_format(self, token: str) -> bool:
|
||||
"""
|
||||
Check if token looks like a JWT (has 3 parts separated by dots).
|
||||
|
||||
Args:
|
||||
token: The token to check
|
||||
|
||||
Returns:
|
||||
True if token appears to be JWT format
|
||||
"""
|
||||
return "." in token and token.count(".") == 2
|
||||
|
||||
def _verify_jwt(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Verify JWT token with signature validation using JWKS.
|
||||
|
||||
Args:
|
||||
token: The JWT token to verify
|
||||
|
||||
Returns:
|
||||
AccessToken if valid, None if invalid
|
||||
"""
|
||||
try:
|
||||
# Get signing key from JWKS
|
||||
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
||||
|
||||
# Verify and decode JWT
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
issuer=self.issuer,
|
||||
options={
|
||||
"verify_signature": True,
|
||||
"verify_exp": True,
|
||||
"verify_iat": True,
|
||||
"verify_iss": True if self.issuer else False,
|
||||
"verify_aud": False, # Skip audience validation for Bearer tokens
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(f"JWT verified successfully for user: {payload.get('sub')}")
|
||||
logger.debug(f"Full JWT payload: {payload}")
|
||||
|
||||
# Extract username (sub claim)
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
logger.error("No 'sub' claim found in JWT payload")
|
||||
return None
|
||||
|
||||
# Extract scopes from scope claim (space-separated string)
|
||||
scope_string = payload.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
logger.debug(
|
||||
f"Extracted scopes from JWT - scope claim: '{scope_string}' -> scopes list: {scopes}"
|
||||
)
|
||||
|
||||
# Extract expiration
|
||||
exp = payload.get("exp")
|
||||
if not exp:
|
||||
logger.warning("No 'exp' claim in JWT, using default TTL")
|
||||
exp = int(time.time() + self.cache_ttl)
|
||||
|
||||
# Cache the result
|
||||
userinfo = {
|
||||
"sub": username,
|
||||
"scope": scope_string,
|
||||
**{k: v for k, v in payload.items() if k not in ["sub", "scope"]},
|
||||
}
|
||||
self._token_cache[token] = (userinfo, exp)
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=payload.get("client_id", ""),
|
||||
scopes=scopes,
|
||||
expires_at=exp,
|
||||
resource=username, # Store username in resource field (RFC 8707)
|
||||
)
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.info("JWT token has expired")
|
||||
return None
|
||||
except jwt.InvalidIssuerError as e:
|
||||
logger.warning(f"JWT issuer validation failed: {e}")
|
||||
return None
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"JWT validation failed: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during JWT verification: {e}")
|
||||
return None
|
||||
|
||||
async def _verify_via_introspection(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Validate token by calling the introspection endpoint (RFC 7662).
|
||||
|
||||
This method validates opaque tokens and retrieves their scopes.
|
||||
|
||||
Args:
|
||||
token: The bearer token to introspect
|
||||
|
||||
Returns:
|
||||
AccessToken if active, None if inactive or invalid
|
||||
"""
|
||||
try:
|
||||
# Introspection requires client authentication
|
||||
response = await self._client.post(
|
||||
self.introspection_uri,
|
||||
data={"token": token},
|
||||
auth=(self.client_id, self.client_secret),
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
introspection_data = response.json()
|
||||
|
||||
# Check if token is active
|
||||
if not introspection_data.get("active", False):
|
||||
logger.info("Token introspection returned inactive=false")
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"Token introspected successfully for user: {introspection_data.get('sub')}"
|
||||
)
|
||||
|
||||
# Extract username
|
||||
username = introspection_data.get("sub") or introspection_data.get(
|
||||
"username"
|
||||
)
|
||||
if not username:
|
||||
logger.error("No username found in introspection response")
|
||||
return None
|
||||
|
||||
# Extract scopes (space-separated string)
|
||||
scope_string = introspection_data.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
logger.debug(f"Extracted scopes from introspection: {scopes}")
|
||||
|
||||
# Extract expiration
|
||||
exp = introspection_data.get("exp")
|
||||
if exp:
|
||||
expiry = float(exp)
|
||||
else:
|
||||
logger.warning(
|
||||
"No 'exp' in introspection response, using default TTL"
|
||||
)
|
||||
expiry = time.time() + self.cache_ttl
|
||||
|
||||
# Cache the result
|
||||
cache_data = {
|
||||
"sub": username,
|
||||
"scope": scope_string,
|
||||
**{
|
||||
k: v
|
||||
for k, v in introspection_data.items()
|
||||
if k not in ["sub", "scope", "active"]
|
||||
},
|
||||
}
|
||||
self._token_cache[token] = (cache_data, expiry)
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=introspection_data.get("client_id", ""),
|
||||
scopes=scopes,
|
||||
expires_at=int(expiry),
|
||||
resource=username,
|
||||
)
|
||||
|
||||
elif response.status_code in (400, 401, 403):
|
||||
logger.info(f"Token introspection failed: HTTP {response.status_code}")
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unexpected response from introspection: {response.status_code}"
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Timeout while introspecting token")
|
||||
return None
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Network error while introspecting token: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during token introspection: {e}")
|
||||
return None
|
||||
|
||||
async def _verify_via_userinfo(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Validate token by calling the userinfo endpoint.
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Optional
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.calendar import (
|
||||
Calendar,
|
||||
@@ -18,6 +19,7 @@ logger = logging.getLogger(__name__)
|
||||
def configure_calendar_tools(mcp: FastMCP):
|
||||
# Calendar tools
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
||||
"""List all available calendars for the user"""
|
||||
client = get_client(ctx)
|
||||
@@ -27,6 +29,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_calendar_create_event(
|
||||
calendar_name: str,
|
||||
title: str,
|
||||
@@ -102,6 +105,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return await client.calendar.create_event(calendar_name, event_data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_calendar_list_events(
|
||||
calendar_name: str,
|
||||
ctx: Context,
|
||||
@@ -203,6 +207,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return events
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_calendar_get_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
@@ -214,6 +219,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return event_data
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_calendar_update_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
@@ -286,6 +292,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_calendar_delete_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
@@ -296,6 +303,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return await client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_calendar_create_meeting(
|
||||
title: str,
|
||||
date: str,
|
||||
@@ -361,6 +369,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return await client.calendar.create_event(calendar_name, event_data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_calendar_get_upcoming_events(
|
||||
ctx: Context,
|
||||
calendar_name: str = "", # Empty = all calendars
|
||||
@@ -410,6 +419,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return all_events[:limit]
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_calendar_find_availability(
|
||||
duration_minutes: int,
|
||||
ctx: Context,
|
||||
@@ -489,6 +499,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_calendar_bulk_operations(
|
||||
operation: str, # "update", "delete", "move"
|
||||
ctx: Context,
|
||||
@@ -737,6 +748,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_calendar_manage_calendar(
|
||||
action: str, # "create", "delete", "update", "list"
|
||||
ctx: Context,
|
||||
@@ -805,6 +817,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
# ============= Todo/Task Tools =============
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_calendar_list_todos(
|
||||
calendar_name: str,
|
||||
ctx: Context,
|
||||
@@ -849,6 +862,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_calendar_create_todo(
|
||||
calendar_name: str,
|
||||
summary: str,
|
||||
@@ -891,6 +905,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return await client.calendar.create_todo(calendar_name, todo_data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_calendar_update_todo(
|
||||
calendar_name: str,
|
||||
todo_uid: str,
|
||||
@@ -950,6 +965,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_calendar_delete_todo(
|
||||
calendar_name: str,
|
||||
todo_uid: str,
|
||||
@@ -969,6 +985,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return await client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_calendar_search_todos(
|
||||
ctx: Context,
|
||||
status: Optional[str] = None,
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -10,18 +11,21 @@ logger = logging.getLogger(__name__)
|
||||
def configure_contacts_tools(mcp: FastMCP):
|
||||
# Contacts tools
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_contacts_list_addressbooks(ctx: Context):
|
||||
"""List all addressbooks for the user."""
|
||||
client = get_client(ctx)
|
||||
return await client.contacts.list_addressbooks()
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
||||
"""List all contacts in the specified addressbook."""
|
||||
client = get_client(ctx)
|
||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_contacts_create_addressbook(
|
||||
ctx: Context, *, name: str, display_name: str
|
||||
):
|
||||
@@ -37,12 +41,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
||||
"""Delete an addressbook."""
|
||||
client = get_client(ctx)
|
||||
return await client.contacts.delete_addressbook(name=name)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_contacts_create_contact(
|
||||
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
|
||||
):
|
||||
@@ -59,12 +65,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||
"""Delete a contact."""
|
||||
client = get_client(ctx)
|
||||
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_contacts_update_contact(
|
||||
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
|
||||
):
|
||||
|
||||
@@ -5,6 +5,7 @@ from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.cookbook import (
|
||||
Category,
|
||||
@@ -70,6 +71,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
|
||||
"""Import a recipe from a URL using schema.org metadata.
|
||||
|
||||
@@ -126,6 +128,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
||||
"""Get all recipes in the database"""
|
||||
client = get_client(ctx)
|
||||
@@ -150,6 +153,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
|
||||
"""Get a specific recipe by its ID"""
|
||||
client = get_client(ctx)
|
||||
@@ -174,6 +178,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_cookbook_create_recipe(
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
@@ -252,6 +257,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_cookbook_update_recipe(
|
||||
recipe_id: int,
|
||||
name: str | None = None,
|
||||
@@ -340,6 +346,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_cookbook_delete_recipe(
|
||||
recipe_id: int, ctx: Context
|
||||
) -> DeleteRecipeResponse:
|
||||
@@ -374,6 +381,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_cookbook_search_recipes(
|
||||
query: str, ctx: Context
|
||||
) -> SearchRecipesResponse:
|
||||
@@ -409,6 +417,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
|
||||
"""Get all known categories.
|
||||
|
||||
@@ -435,6 +444,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_cookbook_get_recipes_in_category(
|
||||
category: str, ctx: Context
|
||||
) -> ListRecipesResponse:
|
||||
@@ -470,6 +480,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
||||
"""Get all known keywords/tags"""
|
||||
client = get_client(ctx)
|
||||
@@ -494,6 +505,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_cookbook_get_recipes_with_keywords(
|
||||
keywords: list[str], ctx: Context
|
||||
) -> ListRecipesResponse:
|
||||
@@ -527,6 +539,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_cookbook_set_config(
|
||||
folder: str | None = None,
|
||||
update_interval: int | None = None,
|
||||
@@ -569,6 +582,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
|
||||
"""Trigger a rescan of all recipes into the caching database.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Optional
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.deck import (
|
||||
CardOperationResponse,
|
||||
@@ -116,6 +117,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
# Read Tools (converted from resources)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
||||
"""Get all Nextcloud Deck boards"""
|
||||
client = get_client(ctx)
|
||||
@@ -123,6 +125,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return boards
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
||||
"""Get details of a specific Nextcloud Deck board"""
|
||||
client = get_client(ctx)
|
||||
@@ -130,6 +133,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return board
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
||||
"""Get all stacks in a Nextcloud Deck board"""
|
||||
client = get_client(ctx)
|
||||
@@ -137,6 +141,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return stacks
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
||||
"""Get details of a specific Nextcloud Deck stack"""
|
||||
client = get_client(ctx)
|
||||
@@ -144,6 +149,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return stack
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def deck_get_cards(
|
||||
ctx: Context, board_id: int, stack_id: int
|
||||
) -> list[DeckCard]:
|
||||
@@ -155,6 +161,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return []
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def deck_get_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> DeckCard:
|
||||
@@ -164,6 +171,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return card
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
||||
"""Get all labels in a Nextcloud Deck board"""
|
||||
client = get_client(ctx)
|
||||
@@ -171,6 +179,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return board.labels
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
||||
"""Get details of a specific Nextcloud Deck label"""
|
||||
client = get_client(ctx)
|
||||
@@ -180,6 +189,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
# Create/Update/Delete Tools
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_create_board(
|
||||
ctx: Context, title: str, color: str
|
||||
) -> CreateBoardResponse:
|
||||
@@ -196,6 +206,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
# Stack Tools
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_create_stack(
|
||||
ctx: Context, board_id: int, title: str, order: int
|
||||
) -> CreateStackResponse:
|
||||
@@ -211,6 +222,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_update_stack(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -236,6 +248,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_delete_stack(
|
||||
ctx: Context, board_id: int, stack_id: int
|
||||
) -> StackOperationResponse:
|
||||
@@ -256,6 +269,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
# Card Tools
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_create_card(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -289,6 +303,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_update_card(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -341,6 +356,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_delete_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> CardOperationResponse:
|
||||
@@ -362,6 +378,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_archive_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> CardOperationResponse:
|
||||
@@ -383,6 +400,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_unarchive_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> CardOperationResponse:
|
||||
@@ -404,6 +422,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_reorder_card(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -435,6 +454,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
# Label Tools
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_create_label(
|
||||
ctx: Context, board_id: int, title: str, color: str
|
||||
) -> CreateLabelResponse:
|
||||
@@ -450,6 +470,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_update_label(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -475,6 +496,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_delete_label(
|
||||
ctx: Context, board_id: int, label_id: int
|
||||
) -> LabelOperationResponse:
|
||||
@@ -495,6 +517,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
# Card-Label Assignment Tools
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_assign_label_to_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||
) -> CardOperationResponse:
|
||||
@@ -517,6 +540,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_remove_label_from_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||
) -> CardOperationResponse:
|
||||
@@ -540,6 +564,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
# Card-User Assignment Tools
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_assign_user_to_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||
) -> CardOperationResponse:
|
||||
@@ -562,6 +587,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def deck_unassign_user_from_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||
) -> CardOperationResponse:
|
||||
|
||||
@@ -5,6 +5,7 @@ from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.notes import (
|
||||
AppendContentResponse,
|
||||
@@ -84,10 +85,11 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_notes_create_note(
|
||||
title: str, content: str, category: str, ctx: Context
|
||||
) -> CreateNoteResponse:
|
||||
"""Create a new note"""
|
||||
"""Create a new note (requires nc:write scope)"""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.create_note(
|
||||
@@ -129,6 +131,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_notes_update_note(
|
||||
note_id: int,
|
||||
etag: str,
|
||||
@@ -137,7 +140,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
category: str | None,
|
||||
ctx: Context,
|
||||
) -> UpdateNoteResponse:
|
||||
"""Update an existing note's title, content, or category.
|
||||
"""Update an existing note's title, content, or category (requires nc:write scope).
|
||||
|
||||
REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes.
|
||||
Get the current ETag by first retrieving the note using nc_notes_get_note tool.
|
||||
@@ -193,6 +196,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_notes_append_content(
|
||||
note_id: int, content: str, ctx: Context
|
||||
) -> AppendContentResponse:
|
||||
@@ -242,8 +246,9 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||
"""Search notes by title or content, returning only id, title, and category."""
|
||||
"""Search notes by title or content, returning only id, title, and category (requires nc:read scope)."""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
search_results_raw = await client.notes_search_notes(query=query)
|
||||
@@ -287,8 +292,9 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
||||
"""Get a specific note by its ID"""
|
||||
"""Get a specific note by its ID (requires nc:read scope)"""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.get_note(note_id)
|
||||
@@ -315,6 +321,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_notes_get_attachment(
|
||||
note_id: int, attachment_filename: str, ctx: Context
|
||||
) -> dict[str, str]:
|
||||
@@ -360,6 +367,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
||||
"""Delete a note permanently"""
|
||||
logger.info("Deleting note %s", note_id)
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
|
||||
|
||||
@@ -15,6 +16,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
"""
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_share_create(
|
||||
path: str,
|
||||
share_with: str,
|
||||
@@ -53,6 +55,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
return json.dumps(share_data, indent=2)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_share_delete(share_id: int, ctx: Context) -> str:
|
||||
"""Delete a share by its ID.
|
||||
|
||||
@@ -71,6 +74,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_share_get(share_id: int, ctx: Context) -> str:
|
||||
"""Get information about a specific share.
|
||||
|
||||
@@ -88,6 +92,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
return json.dumps(share_data, indent=2)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_share_list(
|
||||
ctx: Context, path: str | None = None, shared_with_me: bool = False
|
||||
) -> str:
|
||||
@@ -108,6 +113,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
return json.dumps(shares, indent=2)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
|
||||
"""Update the permissions of an existing share.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -10,18 +11,21 @@ logger = logging.getLogger(__name__)
|
||||
def configure_tables_tools(mcp: FastMCP):
|
||||
# Tables tools
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_tables_list_tables(ctx: Context):
|
||||
"""List all tables available to the user"""
|
||||
client = get_client(ctx)
|
||||
return await client.tables.list_tables()
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||
"""Get the schema/structure of a specific table including columns and views"""
|
||||
client = get_client(ctx)
|
||||
return await client.tables.get_table_schema(table_id)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_tables_read_table(
|
||||
table_id: int,
|
||||
ctx: Context,
|
||||
@@ -33,6 +37,7 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
||||
"""Insert a new row into a table.
|
||||
|
||||
@@ -42,6 +47,7 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
return await client.tables.create_row(table_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
||||
"""Update an existing row in a table.
|
||||
|
||||
@@ -51,6 +57,7 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
return await client.tables.update_row(row_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||
"""Delete a row from a table"""
|
||||
client = get_client(ctx)
|
||||
|
||||
@@ -8,6 +8,7 @@ from nextcloud_mcp_server.utils.document_parser import (
|
||||
parse_document,
|
||||
)
|
||||
from nextcloud_mcp_server.config import is_unstructured_parsing_enabled
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models import FileInfo, SearchFilesResponse
|
||||
|
||||
@@ -17,6 +18,7 @@ logger = logging.getLogger(__name__)
|
||||
def configure_webdav_tools(mcp: FastMCP):
|
||||
# WebDAV file system tools
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_webdav_list_directory(ctx: Context, path: str = ""):
|
||||
"""List files and directories in the specified NextCloud path.
|
||||
|
||||
@@ -30,6 +32,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
return await client.webdav.list_directory(path)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_webdav_read_file(path: str, ctx: Context):
|
||||
"""Read the content of a file from NextCloud.
|
||||
|
||||
@@ -105,6 +108,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_webdav_write_file(
|
||||
path: str, content: str, ctx: Context, content_type: str | None = None
|
||||
):
|
||||
@@ -132,6 +136,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
return await client.webdav.write_file(path, content_bytes, content_type)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_webdav_create_directory(path: str, ctx: Context):
|
||||
"""Create a directory in NextCloud.
|
||||
|
||||
@@ -145,6 +150,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
return await client.webdav.create_directory(path)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
||||
"""Delete a file or directory in NextCloud.
|
||||
|
||||
@@ -158,6 +164,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
return await client.webdav.delete_resource(path)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_webdav_move_resource(
|
||||
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||
):
|
||||
@@ -177,6 +184,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_webdav_copy_resource(
|
||||
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||
):
|
||||
@@ -196,6 +204,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_webdav_search_files(
|
||||
ctx: Context,
|
||||
scope: str = "",
|
||||
@@ -311,6 +320,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_webdav_find_by_name(
|
||||
pattern: str, ctx: Context, scope: str = "", limit: int | None = None
|
||||
) -> SearchFilesResponse:
|
||||
@@ -337,6 +347,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_webdav_find_by_type(
|
||||
mime_type: str, ctx: Context, scope: str = "", limit: int | None = None
|
||||
) -> SearchFilesResponse:
|
||||
@@ -363,6 +374,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_webdav_list_favorites(
|
||||
ctx: Context, scope: str = "", limit: int | None = None
|
||||
) -> SearchFilesResponse:
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.17.1"
|
||||
version = "0.18.0"
|
||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||
authors = [
|
||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"pydantic>=2.11.4",
|
||||
"click>=8.1.8",
|
||||
"caldav",
|
||||
"pyjwt[crypto]>=2.8.0", # JWT validation with RSA support
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
@@ -40,7 +41,7 @@ Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHAN
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
anyio_mode = "auto"
|
||||
addopts = "-p no:asyncio" # Disable pytest-asyncio plugin, use only anyio
|
||||
addopts = "-p no:asyncio -x" # Disable pytest-asyncio plugin, use only anyio
|
||||
log_cli = 1
|
||||
log_cli_level = "ERROR"
|
||||
log_level = "ERROR"
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Script to automatically add @require_scopes decorators to MCP tools.
|
||||
|
||||
This script parses server module files and adds appropriate scope decorators
|
||||
based on the operation type (read vs write).
|
||||
|
||||
Usage:
|
||||
python scripts/add_scope_decorators.py [--dry-run] [--file FILE]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
# Operation patterns for classification
|
||||
READ_PATTERNS = [
|
||||
r".*_get_.*",
|
||||
r".*_get$",
|
||||
r".*_list_.*",
|
||||
r".*_list$",
|
||||
r".*_search_.*",
|
||||
r".*_search$",
|
||||
r".*_read_.*",
|
||||
r".*_read$",
|
||||
r".*_find_.*",
|
||||
r".*_find$",
|
||||
r".*_fetch_.*",
|
||||
r".*_fetch$",
|
||||
r".*_retrieve_.*",
|
||||
r".*_retrieve$",
|
||||
]
|
||||
|
||||
WRITE_PATTERNS = [
|
||||
r".*_create_.*",
|
||||
r".*_create$",
|
||||
r".*_update_.*",
|
||||
r".*_update$",
|
||||
r".*_delete_.*",
|
||||
r".*_delete$",
|
||||
r".*_append_.*",
|
||||
r".*_append$",
|
||||
r".*_modify_.*",
|
||||
r".*_modify$",
|
||||
r".*_set_.*",
|
||||
r".*_set$",
|
||||
r".*_add_.*",
|
||||
r".*_add$",
|
||||
r".*_remove_.*",
|
||||
r".*_remove$",
|
||||
r".*_edit_.*",
|
||||
r".*_edit$",
|
||||
r".*_move_.*",
|
||||
r".*_move$",
|
||||
r".*_copy_.*",
|
||||
r".*_copy$",
|
||||
r".*_upload_.*",
|
||||
r".*_upload$",
|
||||
r".*_download_.*",
|
||||
r".*_download$",
|
||||
r".*_share_.*",
|
||||
r".*_share$",
|
||||
r".*_unshare_.*",
|
||||
r".*_unshare$",
|
||||
r".*_bulk_.*", # Bulk operations are typically writes
|
||||
]
|
||||
|
||||
|
||||
def classify_operation(func_name: str) -> str | None:
|
||||
"""Classify a function as read or write operation.
|
||||
|
||||
Args:
|
||||
func_name: Function name to classify
|
||||
|
||||
Returns:
|
||||
"nc:read", "nc:write", or None if cannot classify
|
||||
"""
|
||||
# Check write patterns first (more specific)
|
||||
for pattern in WRITE_PATTERNS:
|
||||
if re.match(pattern, func_name):
|
||||
return "nc:write"
|
||||
|
||||
# Check read patterns
|
||||
for pattern in READ_PATTERNS:
|
||||
if re.match(pattern, func_name):
|
||||
return "nc:read"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def has_scope_decorator(decorators: List[ast.expr]) -> bool:
|
||||
"""Check if function already has @require_scopes decorator."""
|
||||
for decorator in decorators:
|
||||
if isinstance(decorator, ast.Call):
|
||||
if (
|
||||
isinstance(decorator.func, ast.Name)
|
||||
and decorator.func.id == "require_scopes"
|
||||
):
|
||||
return True
|
||||
elif isinstance(decorator, ast.Name) and decorator.name == "require_scopes":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_mcp_tool_decorator(decorators: List[ast.expr]) -> bool:
|
||||
"""Check if function has @mcp.tool() decorator."""
|
||||
for decorator in decorators:
|
||||
if isinstance(decorator, ast.Call):
|
||||
if isinstance(decorator.func, ast.Attribute):
|
||||
if decorator.func.attr == "tool":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_tools_needing_decorators(
|
||||
file_path: Path, verbose: bool = False
|
||||
) -> List[Tuple[str, int, str]]:
|
||||
"""Find all tools that need scope decorators.
|
||||
|
||||
Returns:
|
||||
List of (function_name, line_number, required_scope)
|
||||
"""
|
||||
with open(file_path) as f:
|
||||
content = f.read()
|
||||
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
except SyntaxError as e:
|
||||
print(f" ⚠️ Syntax error in {file_path}: {e}")
|
||||
return []
|
||||
|
||||
tools_to_update = []
|
||||
total_functions = 0
|
||||
mcp_tools = 0
|
||||
already_has_scope = 0
|
||||
cannot_classify = 0
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
total_functions += 1
|
||||
|
||||
if verbose and node.decorator_list:
|
||||
decorators_str = [
|
||||
ast.unparse(d) if hasattr(ast, "unparse") else str(d)
|
||||
for d in node.decorator_list
|
||||
]
|
||||
print(f" Function {node.name} has decorators: {decorators_str}")
|
||||
|
||||
# Check if it's an MCP tool
|
||||
if not has_mcp_tool_decorator(node.decorator_list):
|
||||
continue
|
||||
|
||||
mcp_tools += 1
|
||||
|
||||
# Check if it already has scope decorator
|
||||
if has_scope_decorator(node.decorator_list):
|
||||
already_has_scope += 1
|
||||
continue
|
||||
|
||||
# Classify operation
|
||||
scope = classify_operation(node.name)
|
||||
if scope:
|
||||
tools_to_update.append((node.name, node.lineno, scope))
|
||||
else:
|
||||
cannot_classify += 1
|
||||
if verbose:
|
||||
print(f" ⚠️ Cannot classify: {node.name}")
|
||||
|
||||
if verbose:
|
||||
print(
|
||||
f" Debug: total_functions={total_functions}, mcp_tools={mcp_tools}, already_has_scope={already_has_scope}, cannot_classify={cannot_classify}"
|
||||
)
|
||||
|
||||
return tools_to_update
|
||||
|
||||
|
||||
def add_decorator_to_file(
|
||||
file_path: Path, dry_run: bool = False, verbose: bool = False
|
||||
) -> int:
|
||||
"""Add @require_scopes decorators to tools in a file.
|
||||
|
||||
Returns:
|
||||
Number of decorators added
|
||||
"""
|
||||
tools = find_tools_needing_decorators(file_path, verbose=verbose)
|
||||
|
||||
if not tools:
|
||||
return 0
|
||||
|
||||
print(f"\n📝 {file_path.relative_to(Path.cwd())}")
|
||||
|
||||
with open(file_path) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Check if require_scopes is already imported
|
||||
has_import = False
|
||||
import_line_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if "from nextcloud_mcp_server.auth import" in line and "require_scopes" in line:
|
||||
has_import = True
|
||||
break
|
||||
elif "from nextcloud_mcp_server.auth import" in line:
|
||||
import_line_idx = i
|
||||
|
||||
# Add import if needed
|
||||
if not has_import:
|
||||
if import_line_idx is not None:
|
||||
# Add require_scopes to existing import
|
||||
old_line = lines[import_line_idx]
|
||||
if "(" in old_line:
|
||||
# Multi-line import
|
||||
print(
|
||||
" ⚠️ Multi-line import detected, please add manually: from nextcloud_mcp_server.auth import require_scopes"
|
||||
)
|
||||
else:
|
||||
# Single line import - add require_scopes
|
||||
lines[import_line_idx] = (
|
||||
old_line.rstrip().rstrip(")").rstrip() + ", require_scopes)\n"
|
||||
)
|
||||
print(" ✓ Added require_scopes to import")
|
||||
else:
|
||||
# No auth import exists, add new import
|
||||
# Find first import line
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("from nextcloud_mcp_server"):
|
||||
lines.insert(
|
||||
i, "from nextcloud_mcp_server.auth import require_scopes\n"
|
||||
)
|
||||
print(
|
||||
" ✓ Added import: from nextcloud_mcp_server.auth import require_scopes"
|
||||
)
|
||||
break
|
||||
|
||||
# Add decorators to tools (in reverse order to preserve line numbers)
|
||||
for func_name, line_num, scope in reversed(tools):
|
||||
# Find the @mcp.tool() decorator line
|
||||
for i in range(line_num - 1, max(0, line_num - 10), -1):
|
||||
if "@mcp.tool()" in lines[i]:
|
||||
# Get indentation from @mcp.tool() line
|
||||
indent = len(lines[i]) - len(lines[i].lstrip())
|
||||
decorator_line = " " * indent + f'@require_scopes("{scope}")\n'
|
||||
lines.insert(i + 1, decorator_line)
|
||||
print(f' ✓ {func_name}:{line_num} → @require_scopes("{scope}")')
|
||||
break
|
||||
|
||||
if not dry_run:
|
||||
with open(file_path, "w") as f:
|
||||
f.writelines(lines)
|
||||
print(" 💾 Saved changes")
|
||||
else:
|
||||
print(" 🔍 DRY RUN - no changes written")
|
||||
|
||||
return len(tools)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Add @require_scopes decorators to MCP tools"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be changed without modifying files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--file",
|
||||
type=Path,
|
||||
help="Process a single file instead of all server modules",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
"-v",
|
||||
action="store_true",
|
||||
help="Show debug information",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
server_dir = Path(__file__).parent.parent / "nextcloud_mcp_server" / "server"
|
||||
|
||||
if args.file:
|
||||
files = [args.file]
|
||||
else:
|
||||
files = sorted(server_dir.glob("*.py"))
|
||||
files = [f for f in files if f.name != "__init__.py"]
|
||||
|
||||
print("🔍 Scanning for tools needing scope decorators...")
|
||||
print(
|
||||
f" {'DRY RUN MODE - No changes will be made' if args.dry_run else 'LIVE MODE - Files will be modified'}"
|
||||
)
|
||||
|
||||
total_added = 0
|
||||
for file_path in files:
|
||||
added = add_decorator_to_file(
|
||||
file_path, dry_run=args.dry_run, verbose=args.verbose
|
||||
)
|
||||
total_added += added
|
||||
|
||||
print(f"\n{'📊 Summary (dry run)' if args.dry_run else '✅ Complete'}")
|
||||
print(f" Total decorators added: {total_added}")
|
||||
|
||||
if args.dry_run:
|
||||
print("\n💡 Run without --dry-run to apply changes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Simpler script to add @require_scopes decorators using regex.
|
||||
|
||||
This script uses regex patterns to find @mcp.tool() decorators and adds
|
||||
the appropriate @require_scopes decorator based on function name patterns.
|
||||
|
||||
Usage:
|
||||
python scripts/add_scope_decorators_simple.py [--dry-run]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Operation patterns for classification
|
||||
READ_KEYWORDS = [
|
||||
"get",
|
||||
"list",
|
||||
"search",
|
||||
"read",
|
||||
"find",
|
||||
"fetch",
|
||||
"retrieve",
|
||||
"upcoming",
|
||||
]
|
||||
WRITE_KEYWORDS = [
|
||||
"create",
|
||||
"update",
|
||||
"delete",
|
||||
"append",
|
||||
"modify",
|
||||
"set",
|
||||
"add",
|
||||
"remove",
|
||||
"edit",
|
||||
"move",
|
||||
"copy",
|
||||
"upload",
|
||||
"download",
|
||||
"share",
|
||||
"unshare",
|
||||
"bulk",
|
||||
"manage",
|
||||
"import",
|
||||
"reindex",
|
||||
"archive",
|
||||
"unarchive",
|
||||
"reorder",
|
||||
"assign",
|
||||
"unassign",
|
||||
"insert",
|
||||
"write",
|
||||
]
|
||||
|
||||
|
||||
def classify_function(func_name: str) -> str | None:
|
||||
"""Classify a function name as read or write operation."""
|
||||
func_lower = func_name.lower()
|
||||
|
||||
# Check write keywords first (more specific)
|
||||
for keyword in WRITE_KEYWORDS:
|
||||
if f"_{keyword}_" in func_lower or func_lower.endswith(f"_{keyword}"):
|
||||
return "nc:write"
|
||||
|
||||
# Check read keywords
|
||||
for keyword in READ_KEYWORDS:
|
||||
if f"_{keyword}_" in func_lower or func_lower.endswith(f"_{keyword}"):
|
||||
return "nc:read"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def process_file(file_path: Path, dry_run: bool = False) -> int:
|
||||
"""Process a single file to add @require_scopes decorators.
|
||||
|
||||
Returns:
|
||||
Number of decorators added
|
||||
"""
|
||||
with open(file_path) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Check if require_scopes is already imported
|
||||
has_import = False
|
||||
import_line_idx = None
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if "from nextcloud_mcp_server.auth import" in line:
|
||||
if "require_scopes" in line:
|
||||
has_import = True
|
||||
else:
|
||||
import_line_idx = i
|
||||
|
||||
modified = False
|
||||
decorators_added = 0
|
||||
|
||||
# Find all @mcp.tool() decorators
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
|
||||
# Look for @mcp.tool() decorator
|
||||
if re.match(r"\s*@mcp\.tool\(\)", line):
|
||||
# Check if next line already has @require_scopes
|
||||
if i + 1 < len(lines) and "@require_scopes" in lines[i + 1]:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Find the function definition (should be on next line or after other decorators)
|
||||
func_line_idx = i + 1
|
||||
while func_line_idx < len(lines) and not lines[
|
||||
func_line_idx
|
||||
].strip().startswith("async def"):
|
||||
func_line_idx += 1
|
||||
|
||||
if func_line_idx >= len(lines):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Extract function name
|
||||
func_match = re.match(r"\s*async def (\w+)\(", lines[func_line_idx])
|
||||
if not func_match:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
func_name = func_match.group(1)
|
||||
scope = classify_function(func_name)
|
||||
|
||||
if scope:
|
||||
# Get indentation from @mcp.tool() line
|
||||
indent = len(line) - len(line.lstrip())
|
||||
decorator_line = " " * indent + f'@require_scopes("{scope}")\n'
|
||||
|
||||
# Insert after @mcp.tool()
|
||||
lines.insert(i + 1, decorator_line)
|
||||
decorators_added += 1
|
||||
modified = True
|
||||
print(f' ✓ {func_name} → @require_scopes("{scope}")')
|
||||
else:
|
||||
print(f" ⚠️ Cannot classify: {func_name}")
|
||||
|
||||
i += 1
|
||||
|
||||
# Add import if needed and decorators were added
|
||||
if decorators_added > 0 and not has_import:
|
||||
if import_line_idx is not None:
|
||||
# Add to existing import
|
||||
old_line = lines[import_line_idx]
|
||||
if old_line.rstrip().endswith(")"):
|
||||
lines[import_line_idx] = old_line.rstrip()[:-1] + ", require_scopes)\n"
|
||||
else:
|
||||
lines[import_line_idx] = old_line.rstrip() + ", require_scopes\n"
|
||||
print(" ✓ Added require_scopes to existing import")
|
||||
modified = True
|
||||
else:
|
||||
# No auth import exists, add new import after last 'from nextcloud_mcp_server' import
|
||||
last_nc_import_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("from nextcloud_mcp_server"):
|
||||
last_nc_import_idx = i
|
||||
|
||||
if last_nc_import_idx is not None:
|
||||
lines.insert(
|
||||
last_nc_import_idx + 1,
|
||||
"from nextcloud_mcp_server.auth import require_scopes\n",
|
||||
)
|
||||
print(
|
||||
" ✓ Added new import: from nextcloud_mcp_server.auth import require_scopes"
|
||||
)
|
||||
modified = True
|
||||
else:
|
||||
print(" ⚠️ Could not find place to add require_scopes import")
|
||||
|
||||
# Write changes
|
||||
if modified and not dry_run:
|
||||
with open(file_path, "w") as f:
|
||||
f.writelines(lines)
|
||||
print(f" 💾 Saved changes to {file_path.name}")
|
||||
elif dry_run and decorators_added > 0:
|
||||
print(f" 🔍 DRY RUN - would add {decorators_added} decorators")
|
||||
|
||||
return decorators_added
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Add @require_scopes decorators to MCP tools"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be changed without modifying files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--file",
|
||||
type=Path,
|
||||
help="Process a single file instead of all server modules",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
server_dir = Path(__file__).parent.parent / "nextcloud_mcp_server" / "server"
|
||||
|
||||
if args.file:
|
||||
files = [args.file]
|
||||
else:
|
||||
files = sorted(server_dir.glob("*.py"))
|
||||
files = [f for f in files if f.name != "__init__.py"]
|
||||
|
||||
print("🔍 Scanning for tools needing scope decorators...")
|
||||
print(
|
||||
f" {'DRY RUN MODE - No changes will be made' if args.dry_run else 'LIVE MODE - Files will be modified'}"
|
||||
)
|
||||
|
||||
total_added = 0
|
||||
for file_path in files:
|
||||
file_path = file_path.resolve() # Convert to absolute path
|
||||
try:
|
||||
display_path = file_path.relative_to(Path.cwd())
|
||||
except ValueError:
|
||||
display_path = file_path.name
|
||||
print(f"\n📝 {display_path}")
|
||||
added = process_file(file_path, dry_run=args.dry_run)
|
||||
total_added += added
|
||||
|
||||
print(f"\n{'📊 Summary (dry run)' if args.dry_run else '✅ Complete'}")
|
||||
print(f" Total decorators added: {total_added}")
|
||||
|
||||
if args.dry_run and total_added > 0:
|
||||
print("\n💡 Run without --dry-run to apply changes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+846
-54
File diff suppressed because it is too large
Load Diff
@@ -145,7 +145,7 @@ uv run python -m tests.load.oauth_benchmark -u 2 -d 30 --verbose
|
||||
| `--users` | `-u` | 2 | Number of concurrent users (dynamically created) |
|
||||
| `--duration` | `-d` | 30.0 | Test duration in seconds |
|
||||
| `--warmup` | `-w` | 5.0 | Warmup period before metrics collection (seconds) |
|
||||
| `--url` | | `http://127.0.0.1:8001/mcp` | MCP OAuth server URL |
|
||||
| `--url` | | `http://localhost:8001/mcp` | MCP OAuth server URL |
|
||||
| `--output` | `-o` | None | JSON output file path |
|
||||
| `--workload` | | `mixed` | Workload type: mixed, sharing, collaboration, baseline |
|
||||
| `--user-prefix` | | `loadtest` | Prefix for dynamically created usernames |
|
||||
|
||||
@@ -414,7 +414,7 @@ async def show_progress(
|
||||
@click.option(
|
||||
"--url",
|
||||
"-u",
|
||||
default="http://127.0.0.1:8000/mcp",
|
||||
default="http://localhost:8000/mcp",
|
||||
show_default=True,
|
||||
help="MCP server URL",
|
||||
)
|
||||
@@ -463,7 +463,7 @@ def main(
|
||||
uv run python -m tests.load.benchmark -c 20 -d 60 --output results.json
|
||||
|
||||
# Test OAuth server on port 8001
|
||||
uv run python -m tests.load.benchmark --url http://127.0.0.1:8001/mcp
|
||||
uv run python -m tests.load.benchmark --url http://localhost:8001/mcp
|
||||
"""
|
||||
if verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
@@ -51,7 +51,7 @@ class OAuthCallbackServer:
|
||||
correlation, and stores them in a shared dictionary.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = 8081):
|
||||
def __init__(self, host: str = "localhost", port: int = 8081):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.auth_states: dict[str, str] = {}
|
||||
@@ -363,13 +363,13 @@ async def run_oauth_benchmark(
|
||||
try:
|
||||
# Get environment variables
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||
callback_url = "http://127.0.0.1:8081/callback"
|
||||
callback_url = "http://localhost:8081/callback"
|
||||
|
||||
# Step 1: Start OAuth callback server
|
||||
print("Step 1/6: Starting OAuth callback server...")
|
||||
callback_server = OAuthCallbackServer(host="127.0.0.1", port=8081)
|
||||
callback_server = OAuthCallbackServer(host="localhost", port=8081)
|
||||
callback_server.start()
|
||||
print("✓ Callback server listening on http://127.0.0.1:8081\n")
|
||||
print("✓ Callback server listening on http://localhost:8081\n")
|
||||
|
||||
# Step 2: Discover OIDC endpoints
|
||||
print("Step 2/6: Discovering OIDC endpoints...")
|
||||
@@ -634,7 +634,7 @@ async def run_oauth_benchmark(
|
||||
)
|
||||
@click.option(
|
||||
"--url",
|
||||
default="http://127.0.0.1:8001/mcp",
|
||||
default="http://localhost:8001/mcp",
|
||||
show_default=True,
|
||||
help="MCP OAuth server URL",
|
||||
)
|
||||
|
||||
@@ -138,7 +138,7 @@ class OAuthUserPool:
|
||||
return profile
|
||||
|
||||
async def create_user_session(
|
||||
self, username: str, mcp_url: str = "http://127.0.0.1:8001/mcp"
|
||||
self, username: str, mcp_url: str = "http://localhost:8001/mcp"
|
||||
) -> ClientSession:
|
||||
"""
|
||||
Create an MCP client session for a user.
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Test JWT token structure and scope support.
|
||||
|
||||
This test obtains a JWT token via OAuth and examines its structure.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def decode_jwt_without_verification(token: str) -> dict:
|
||||
"""
|
||||
Decode JWT token without signature verification (for inspection only).
|
||||
|
||||
Returns:
|
||||
Dict with header and payload
|
||||
"""
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}")
|
||||
|
||||
# Decode header
|
||||
header = json.loads(
|
||||
base64.urlsafe_b64decode(parts[0] + "=" * (4 - len(parts[0]) % 4))
|
||||
)
|
||||
|
||||
# Decode payload
|
||||
payload = json.loads(
|
||||
base64.urlsafe_b64decode(parts[1] + "=" * (4 - len(parts[1]) % 4))
|
||||
)
|
||||
|
||||
return {
|
||||
"header": header,
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_jwt_token_structure_with_custom_client():
|
||||
"""
|
||||
Test that we can create a JWT-enabled OAuth client and examine the token structure.
|
||||
|
||||
This test manually configures a JWT client and obtains a token.
|
||||
"""
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# This test requires manual setup of a JWT client
|
||||
# Skip if not configured
|
||||
client_id = os.getenv("NEXTCLOUD_JWT_CLIENT_ID")
|
||||
if not client_id:
|
||||
pytest.skip("NEXTCLOUD_JWT_CLIENT_ID not set - skipping JWT token test")
|
||||
|
||||
_client_secret = os.getenv("NEXTCLOUD_JWT_CLIENT_SECRET")
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||
|
||||
# Fetch discovery
|
||||
async with httpx.AsyncClient() as client:
|
||||
discovery_response = await client.get(
|
||||
f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
)
|
||||
discovery_response.raise_for_status()
|
||||
discovery = discovery_response.json()
|
||||
|
||||
_token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
# For this test, we'll use client credentials grant if supported
|
||||
# Otherwise, skip this test
|
||||
pytest.skip(
|
||||
"JWT token test requires OAuth flow - use manual testing script instead"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_opaque_token_vs_jwt_comparison():
|
||||
"""
|
||||
Compare opaque tokens vs JWT tokens to understand the differences.
|
||||
|
||||
This is a documentation test that explains the findings.
|
||||
"""
|
||||
# This test documents our findings about JWT vs opaque tokens
|
||||
# Based on manual testing with the test script
|
||||
|
||||
findings = {
|
||||
"oidc_app_capabilities": {
|
||||
"supports_jwt_tokens": True,
|
||||
"supports_opaque_tokens": True,
|
||||
"configuration_method": "per-client via token_type field",
|
||||
"jwt_standard": "RFC 9068 (OAuth 2.0 Access Token JWT Profile)",
|
||||
},
|
||||
"dynamic_registration": {
|
||||
"sets_allowed_scopes": False,
|
||||
"note": "Dynamic registration does NOT populate allowed_scopes from the scope parameter in registration request",
|
||||
"workaround": "Must use occ oidc:create with --allowed_scopes flag or manually update via web UI/API",
|
||||
},
|
||||
"jwt_token_structure": {
|
||||
"header": {
|
||||
"typ": "at+JWT", # RFC 9068 access token type
|
||||
"alg": "RS256", # Signature algorithm
|
||||
},
|
||||
"payload_claims": {
|
||||
"iss": "issuer URL",
|
||||
"sub": "user ID",
|
||||
"aud": "client ID",
|
||||
"exp": "expiration timestamp",
|
||||
"iat": "issued at timestamp",
|
||||
"scope": "space-separated scope string (THIS IS THE KEY!)",
|
||||
"client_id": "client identifier",
|
||||
"jti": "JWT ID",
|
||||
# Optional based on scopes:
|
||||
"roles": "if roles scope present",
|
||||
"groups": "if groups scope present",
|
||||
"email": "if email scope present",
|
||||
"name": "if profile scope present",
|
||||
},
|
||||
"scope_claim": {
|
||||
"format": "space-separated string",
|
||||
"example": "openid profile email nc:read nc:write",
|
||||
"extraction": "payload['scope'].split()",
|
||||
},
|
||||
},
|
||||
"scope_validation": {
|
||||
"oidc_app": {
|
||||
"validates": True,
|
||||
"method": "Intersects requested scopes with allowed_scopes per client",
|
||||
"location": "LoginRedirectorController.php:251-267",
|
||||
},
|
||||
"user_oidc_app": {
|
||||
"validates_scopes": False,
|
||||
"validates": ["token expiration", "issuer", "audience (optional)"],
|
||||
"limitation": "Does NOT extract or validate scopes from JWT",
|
||||
},
|
||||
},
|
||||
"token_size": {
|
||||
"opaque": "72 characters",
|
||||
"jwt": "~800-1200 characters (depends on claims)",
|
||||
"overhead": "JWT is 10-15x larger than opaque tokens",
|
||||
},
|
||||
"recommendation": {
|
||||
"for_mcp_server": "Use JWT tokens with self-validation",
|
||||
"reasoning": [
|
||||
"Can extract scopes directly from token payload",
|
||||
"No additional API call needed",
|
||||
"Standard approach (RFC 9068)",
|
||||
"Works with existing oidc app",
|
||||
],
|
||||
"alternative": "Implement introspection endpoint in oidc app (future work)",
|
||||
},
|
||||
}
|
||||
|
||||
# Print findings for documentation
|
||||
print("\n" + "=" * 80)
|
||||
print("JWT Token vs Opaque Token Findings")
|
||||
print("=" * 80)
|
||||
print(json.dumps(findings, indent=2))
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
# This test always passes - it's for documentation
|
||||
assert True, "Findings documented"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_scope_presence_in_jwt():
|
||||
"""
|
||||
Verify that custom scopes (nc:read, nc:write) are present in JWT tokens.
|
||||
|
||||
NOTE: This test documents the expected behavior based on manual testing.
|
||||
Actual implementation will be tested in integration tests after JWT validation is implemented.
|
||||
"""
|
||||
expected_behavior = {
|
||||
"client_configuration": {
|
||||
"allowed_scopes": "openid profile email nc:read nc:write",
|
||||
"token_type": "jwt",
|
||||
},
|
||||
"authorization_request": {
|
||||
"scope": "openid profile email nc:read nc:write",
|
||||
},
|
||||
"token_response": {
|
||||
"access_token": "JWT with scope claim",
|
||||
},
|
||||
"jwt_payload": {
|
||||
"scope": "openid profile email nc:read nc:write", # All requested scopes present if in allowed_scopes
|
||||
},
|
||||
"scope_filtering": {
|
||||
"description": "oidc app filters requested scopes against allowed_scopes",
|
||||
"example": {
|
||||
"requested": "openid profile nc:read nc:write nc:admin",
|
||||
"allowed": "openid profile email nc:read nc:write",
|
||||
"granted": "openid profile nc:read nc:write", # nc:admin filtered out, email not requested
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("Expected JWT Scope Behavior")
|
||||
print("=" * 80)
|
||||
print(json.dumps(expected_behavior, indent=2))
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
assert True, "Expected behavior documented"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run with: uv run pytest tests/server/test_jwt_tokens.py -v
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Integration tests for JWT OAuth authentication.
|
||||
|
||||
These tests verify:
|
||||
1. JWT token authentication works correctly
|
||||
2. JWT token verification via JWKS
|
||||
3. Scope information is properly extracted from JWT claims
|
||||
4. Dynamic tool filtering works with JWT tokens
|
||||
5. All MCP operations work with JWT authentication
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
async def test_jwt_mcp_server_connection(nc_mcp_oauth_jwt_client):
|
||||
"""Test connection to JWT OAuth-enabled MCP server."""
|
||||
result = await nc_mcp_oauth_jwt_client.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
logger.info(f"JWT OAuth MCP server has {len(result.tools)} tools available")
|
||||
|
||||
|
||||
async def test_jwt_token_authentication(nc_mcp_oauth_jwt_client):
|
||||
"""Test that JWT token authentication works."""
|
||||
# Execute a simple read operation
|
||||
result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully authenticated with JWT token and executed tool, got {len(response_data['results'])} notes."
|
||||
)
|
||||
|
||||
|
||||
async def test_jwt_tool_list_operations(nc_mcp_oauth_jwt_client):
|
||||
"""Test that list_tools works with JWT authentication."""
|
||||
result = await nc_mcp_oauth_jwt_client.list_tools()
|
||||
|
||||
# Verify we have tools
|
||||
assert len(result.tools) > 0
|
||||
|
||||
# Verify some expected tools exist
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
assert "nc_notes_get_note" in tool_names
|
||||
assert "nc_notes_create_note" in tool_names
|
||||
assert "nc_calendar_list_calendars" in tool_names
|
||||
assert "nc_webdav_list_directory" in tool_names
|
||||
|
||||
logger.info(f"JWT server provides {len(result.tools)} tools")
|
||||
|
||||
|
||||
async def test_jwt_read_operation(nc_mcp_oauth_jwt_client):
|
||||
"""Test read operation with JWT authentication."""
|
||||
# List calendars (read operation)
|
||||
result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_calendar_list_calendars", arguments={}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
assert "calendars" in response_data
|
||||
assert isinstance(response_data["calendars"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully executed read operation with JWT, got {len(response_data['calendars'])} calendars."
|
||||
)
|
||||
|
||||
|
||||
async def test_jwt_write_operation(nc_mcp_oauth_jwt_client):
|
||||
"""Test write operation with JWT authentication."""
|
||||
import uuid
|
||||
|
||||
# Create a note (write operation)
|
||||
note_title = f"JWT Test Note {uuid.uuid4().hex[:8]}"
|
||||
note_content = "This note was created during JWT authentication testing"
|
||||
|
||||
result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_create_note",
|
||||
arguments={
|
||||
"title": note_title,
|
||||
"content": note_content,
|
||||
"category": "Testing",
|
||||
},
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# Verify note was created
|
||||
assert "id" in response_data
|
||||
assert response_data["title"] == note_title
|
||||
|
||||
note_id = response_data["id"]
|
||||
logger.info(f"Successfully created note {note_id} with JWT authentication")
|
||||
|
||||
# Clean up: Delete the note
|
||||
delete_result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_delete_note", arguments={"note_id": note_id}
|
||||
)
|
||||
|
||||
assert delete_result.isError is False, f"Cleanup failed: {delete_result.content}"
|
||||
logger.info(f"Cleaned up test note {note_id}")
|
||||
|
||||
|
||||
async def test_jwt_multiple_operations(nc_mcp_oauth_jwt_client):
|
||||
"""Test multiple operations with same JWT token to verify token persistence."""
|
||||
# First operation: Search notes
|
||||
result1 = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
assert result1.isError is False
|
||||
|
||||
# Second operation: List calendars
|
||||
result2 = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_calendar_list_calendars", arguments={}
|
||||
)
|
||||
assert result2.isError is False
|
||||
|
||||
# Third operation: List directory
|
||||
result3 = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_webdav_list_directory", arguments={"path": "/"}
|
||||
)
|
||||
assert result3.isError is False
|
||||
|
||||
logger.info("Successfully executed multiple operations with JWT token")
|
||||
|
||||
|
||||
async def test_jwt_vs_opaque_token_compatibility(
|
||||
nc_mcp_oauth_client, nc_mcp_oauth_jwt_client
|
||||
):
|
||||
"""Verify that both opaque and JWT tokens provide same functionality."""
|
||||
# Execute same operation on both servers
|
||||
opaque_result = await nc_mcp_oauth_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
jwt_result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
# Both should succeed
|
||||
assert opaque_result.isError is False
|
||||
assert jwt_result.isError is False
|
||||
|
||||
# Both should have results
|
||||
opaque_data = json.loads(opaque_result.content[0].text)
|
||||
jwt_data = json.loads(jwt_result.content[0].text)
|
||||
|
||||
assert "results" in opaque_data
|
||||
assert "results" in jwt_data
|
||||
|
||||
# Results should be the same (same user, same notes)
|
||||
assert len(opaque_data["results"]) == len(jwt_data["results"])
|
||||
|
||||
logger.info(
|
||||
"Verified opaque and JWT tokens provide identical functionality: "
|
||||
f"{len(opaque_data['results'])} notes accessible from both servers"
|
||||
)
|
||||
|
||||
|
||||
async def test_jwt_error_handling(nc_mcp_oauth_jwt_client):
|
||||
"""Test error handling with JWT authentication."""
|
||||
# Try to get a non-existent note
|
||||
result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_get_note", arguments={"note_id": 999999}
|
||||
)
|
||||
|
||||
# Should get an error (note doesn't exist)
|
||||
assert result.isError is True
|
||||
logger.info("JWT server correctly handles errors for invalid operations")
|
||||
|
||||
|
||||
async def test_jwt_scope_enforcement(nc_mcp_oauth_jwt_client):
|
||||
"""Test that JWT server properly enforces scopes."""
|
||||
# This test assumes the JWT token has both nc:read and nc:write scopes
|
||||
# Both read and write operations should succeed
|
||||
|
||||
# Read operation
|
||||
read_result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
assert read_result.isError is False
|
||||
|
||||
# Write operation
|
||||
import uuid
|
||||
|
||||
note_title = f"Scope Test {uuid.uuid4().hex[:8]}"
|
||||
write_result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_create_note",
|
||||
arguments={
|
||||
"title": note_title,
|
||||
"content": "Testing scope enforcement",
|
||||
"category": "Testing",
|
||||
},
|
||||
)
|
||||
assert write_result.isError is False
|
||||
|
||||
# Clean up
|
||||
note_id = json.loads(write_result.content[0].text)["id"]
|
||||
await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_delete_note", arguments={"note_id": note_id}
|
||||
)
|
||||
|
||||
logger.info("JWT server properly allows operations based on token scopes")
|
||||
|
||||
|
||||
async def test_jwt_automation_worked(nc_mcp_oauth_jwt_client):
|
||||
"""Test that verifies the automated JWT client creation worked correctly.
|
||||
|
||||
This test confirms that:
|
||||
1. JWT client was auto-created during container initialization
|
||||
2. MCP server loaded credentials from auto-generated file
|
||||
3. JWT authentication flow works end-to-end
|
||||
4. Server uses JWT tokens (not opaque tokens)
|
||||
"""
|
||||
# If we can connect and execute tools, the automation worked
|
||||
result = await nc_mcp_oauth_jwt_client.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
# Execute a tool to verify full OAuth flow
|
||||
tool_result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
assert tool_result.isError is False
|
||||
|
||||
logger.info(
|
||||
"✅ JWT client automation successful! "
|
||||
"Auto-generated credentials working correctly."
|
||||
)
|
||||
@@ -0,0 +1,539 @@
|
||||
"""Integration tests for OAuth scope-based authorization and dynamic tool filtering.
|
||||
|
||||
These tests verify:
|
||||
1. Dynamic tool filtering based on user's token scopes
|
||||
2. Scope enforcement (403 responses for insufficient scopes)
|
||||
3. Protected Resource Metadata (PRM) endpoint
|
||||
4. WWW-Authenticate challenge headers
|
||||
5. BasicAuth bypass (all tools visible)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_prm_endpoint():
|
||||
"""Test that the Protected Resource Metadata endpoint returns correct data."""
|
||||
import httpx
|
||||
|
||||
# Test the PRM endpoint directly
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"http://localhost:8001/.well-known/oauth-protected-resource"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
prm_data = response.json()
|
||||
assert prm_data["resource"] == "http://localhost:8001"
|
||||
assert "nc:read" in prm_data["scopes_supported"]
|
||||
assert "nc:write" in prm_data["scopes_supported"]
|
||||
assert "http://localhost:8080" in prm_data["authorization_servers"]
|
||||
assert "header" in prm_data["bearer_methods_supported"]
|
||||
assert "RS256" in prm_data["resource_signing_alg_values_supported"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_basicauth_shows_all_tools(nc_mcp_client):
|
||||
"""Test that BasicAuth mode shows all tools (no filtering)."""
|
||||
# Note: Don't use 'async with' for session-scoped fixtures
|
||||
# The fixture itself manages the session lifecycle
|
||||
|
||||
# List all tools
|
||||
tools_response = await nc_mcp_client.list_tools()
|
||||
|
||||
# BasicAuth should see all tools
|
||||
tool_names = [tool.name for tool in tools_response.tools]
|
||||
|
||||
# Should see both read and write tools
|
||||
assert "nc_notes_get_note" in tool_names # read tool
|
||||
assert "nc_notes_create_note" in tool_names # write tool
|
||||
assert "nc_calendar_list_calendars" in tool_names # read tool
|
||||
assert "nc_calendar_create_event" in tool_names # write tool
|
||||
|
||||
# Should have all 90+ tools
|
||||
assert len(tool_names) >= 90
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only):
|
||||
"""Test that a token with only nc:read scope filters out write tools."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Connect with token that has only "nc:read" scope
|
||||
result = await nc_mcp_oauth_client_read_only.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"Read-only token sees {len(tool_names)} tools")
|
||||
|
||||
# Verify read tools are present
|
||||
expected_read_tools = [
|
||||
"nc_notes_get_note",
|
||||
"nc_notes_search_notes",
|
||||
"nc_calendar_list_calendars",
|
||||
"nc_calendar_get_event",
|
||||
"nc_webdav_list_directory",
|
||||
"nc_webdav_read_file",
|
||||
]
|
||||
|
||||
for tool in expected_read_tools:
|
||||
assert tool in tool_names, f"Expected read tool {tool} not found in tool list"
|
||||
|
||||
# Verify write tools are NOT present
|
||||
write_tools_should_be_filtered = [
|
||||
"nc_notes_create_note",
|
||||
"nc_notes_update_note",
|
||||
"nc_notes_delete_note",
|
||||
"nc_calendar_create_event",
|
||||
"nc_calendar_update_event",
|
||||
"nc_calendar_delete_event",
|
||||
"nc_webdav_write_file",
|
||||
"nc_webdav_create_directory",
|
||||
]
|
||||
|
||||
for tool in write_tools_should_be_filtered:
|
||||
assert tool not in tool_names, (
|
||||
f"Write tool {tool} should be filtered out but was found in tool list"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✅ Read-only token properly filters tools: {len(tool_names)} read tools visible, "
|
||||
f"write tools hidden"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_only):
|
||||
"""Test that a token with only nc:write scope filters out read tools."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Connect with token that has only "nc:write" scope
|
||||
result = await nc_mcp_oauth_client_write_only.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"Write-only token sees {len(tool_names)} tools")
|
||||
|
||||
# Verify write tools are present
|
||||
expected_write_tools = [
|
||||
"nc_notes_create_note",
|
||||
"nc_notes_update_note",
|
||||
"nc_notes_delete_note",
|
||||
"nc_calendar_create_event",
|
||||
"nc_calendar_update_event",
|
||||
"nc_calendar_delete_event",
|
||||
"nc_webdav_write_file",
|
||||
"nc_webdav_create_directory",
|
||||
]
|
||||
|
||||
for tool in expected_write_tools:
|
||||
assert tool in tool_names, f"Expected write tool {tool} not found in tool list"
|
||||
|
||||
# Verify read tools are NOT present (write-only scope)
|
||||
read_tools_should_be_filtered = [
|
||||
"nc_notes_get_note",
|
||||
"nc_notes_search_notes",
|
||||
"nc_calendar_list_calendars",
|
||||
"nc_calendar_get_event",
|
||||
"nc_webdav_list_directory",
|
||||
"nc_webdav_read_file",
|
||||
]
|
||||
|
||||
for tool in read_tools_should_be_filtered:
|
||||
assert tool not in tool_names, (
|
||||
f"Read tool {tool} should be filtered out but was found in tool list"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✅ Write-only token properly filters tools: {len(tool_names)} write tools visible, "
|
||||
f"read tools hidden"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_full_access_token_shows_all_tools(nc_mcp_oauth_client_full_access):
|
||||
"""Test that a token with both nc:read and nc:write scopes can see all tools."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Connect with token that has both "nc:read" and "nc:write" scopes
|
||||
result = await nc_mcp_oauth_client_full_access.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"Full access token sees {len(tool_names)} tools")
|
||||
|
||||
# Verify both read and write tools are present
|
||||
expected_read_tools = [
|
||||
"nc_notes_get_note",
|
||||
"nc_notes_search_notes",
|
||||
"nc_calendar_list_calendars",
|
||||
"nc_webdav_read_file",
|
||||
]
|
||||
|
||||
expected_write_tools = [
|
||||
"nc_notes_create_note",
|
||||
"nc_calendar_create_event",
|
||||
"nc_webdav_write_file",
|
||||
]
|
||||
|
||||
for tool in expected_read_tools:
|
||||
assert tool in tool_names, f"Expected read tool {tool} not found"
|
||||
|
||||
for tool in expected_write_tools:
|
||||
assert tool in tool_names, f"Expected write tool {tool} not found"
|
||||
|
||||
# Should have all 90+ tools (both read and write)
|
||||
assert len(tool_names) >= 90
|
||||
|
||||
logger.info(
|
||||
f"✅ Full access token sees all tools: {len(tool_names)} total (read + write)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_scope_helper_functions():
|
||||
"""Test the scope authorization helper functions."""
|
||||
from nextcloud_mcp_server.auth import get_required_scopes, has_required_scopes
|
||||
|
||||
# Create a mock function with scope requirements
|
||||
async def mock_read_tool():
|
||||
pass
|
||||
|
||||
async def mock_write_tool():
|
||||
pass
|
||||
|
||||
async def mock_no_scope_tool():
|
||||
pass
|
||||
|
||||
# Add scope metadata
|
||||
mock_read_tool._required_scopes = ["nc:read"] # type: ignore
|
||||
mock_write_tool._required_scopes = ["nc:write"] # type: ignore
|
||||
|
||||
# Test get_required_scopes
|
||||
assert get_required_scopes(mock_read_tool) == ["nc:read"]
|
||||
assert get_required_scopes(mock_write_tool) == ["nc:write"]
|
||||
assert get_required_scopes(mock_no_scope_tool) == []
|
||||
|
||||
# Test has_required_scopes
|
||||
read_only_scopes = {"nc:read"}
|
||||
full_scopes = {"nc:read", "nc:write"}
|
||||
no_scopes = set()
|
||||
|
||||
# User with only read scope
|
||||
assert has_required_scopes(mock_read_tool, read_only_scopes) is True
|
||||
assert has_required_scopes(mock_write_tool, read_only_scopes) is False
|
||||
assert has_required_scopes(mock_no_scope_tool, read_only_scopes) is True
|
||||
|
||||
# User with full scopes
|
||||
assert has_required_scopes(mock_read_tool, full_scopes) is True
|
||||
assert has_required_scopes(mock_write_tool, full_scopes) is True
|
||||
assert has_required_scopes(mock_no_scope_tool, full_scopes) is True
|
||||
|
||||
# User with no scopes
|
||||
assert has_required_scopes(mock_read_tool, no_scopes) is False
|
||||
assert has_required_scopes(mock_write_tool, no_scopes) is False
|
||||
assert has_required_scopes(mock_no_scope_tool, no_scopes) is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_scope_decorator_stores_metadata():
|
||||
"""Test that @require_scopes decorator properly stores metadata."""
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
|
||||
@require_scopes("nc:read", "nc:write")
|
||||
async def test_function():
|
||||
pass
|
||||
|
||||
# Check that metadata was stored
|
||||
assert hasattr(test_function, "_required_scopes")
|
||||
assert test_function._required_scopes == ["nc:read", "nc:write"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_tools_have_scope_decorators(nc_mcp_client):
|
||||
"""Test that MCP tools have scope requirements defined."""
|
||||
# Note: Don't use 'async with' for session-scoped fixtures
|
||||
# The fixture itself manages the session lifecycle
|
||||
|
||||
# We can at least verify that some expected tools exist
|
||||
tools_response = await nc_mcp_client.list_tools()
|
||||
tool_names = [tool.name for tool in tools_response.tools]
|
||||
|
||||
# Verify expected read tools exist
|
||||
expected_read_tools = [
|
||||
"nc_notes_get_note",
|
||||
"nc_notes_search_notes",
|
||||
"nc_calendar_list_calendars",
|
||||
"nc_calendar_get_event",
|
||||
"nc_contacts_list_contacts",
|
||||
"nc_webdav_list_directory",
|
||||
"nc_webdav_read_file",
|
||||
]
|
||||
|
||||
for tool in expected_read_tools:
|
||||
assert tool in tool_names, f"Expected read tool {tool} not found"
|
||||
|
||||
# Verify expected write tools exist
|
||||
expected_write_tools = [
|
||||
"nc_notes_create_note",
|
||||
"nc_notes_update_note",
|
||||
"nc_notes_delete_note",
|
||||
"nc_calendar_create_event",
|
||||
"nc_calendar_update_event",
|
||||
"nc_calendar_delete_event",
|
||||
"nc_contacts_create_contact",
|
||||
"nc_webdav_write_file",
|
||||
"nc_webdav_create_directory",
|
||||
]
|
||||
|
||||
for tool in expected_write_tools:
|
||||
assert tool in tool_names, f"Expected write tool {tool} not found"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_scope_classification():
|
||||
"""Test that our scope classification correctly identifies read vs write operations."""
|
||||
from scripts.add_scope_decorators_simple import classify_function
|
||||
|
||||
# Test read operations
|
||||
assert classify_function("nc_notes_get_note") == "nc:read"
|
||||
assert classify_function("nc_notes_search_notes") == "nc:read"
|
||||
assert classify_function("nc_calendar_list_events") == "nc:read"
|
||||
assert classify_function("nc_webdav_read_file") == "nc:read"
|
||||
assert classify_function("nc_calendar_find_availability") == "nc:read"
|
||||
assert classify_function("nc_calendar_get_upcoming_events") == "nc:read"
|
||||
|
||||
# Test write operations
|
||||
assert classify_function("nc_notes_create_note") == "nc:write"
|
||||
assert classify_function("nc_notes_update_note") == "nc:write"
|
||||
assert classify_function("nc_notes_delete_note") == "nc:write"
|
||||
assert classify_function("nc_notes_append_content") == "nc:write"
|
||||
assert classify_function("nc_calendar_create_event") == "nc:write"
|
||||
assert classify_function("nc_calendar_update_event") == "nc:write"
|
||||
assert classify_function("nc_calendar_manage_calendar") == "nc:write"
|
||||
assert classify_function("nc_webdav_write_file") == "nc:write"
|
||||
assert classify_function("nc_webdav_move_resource") == "nc:write"
|
||||
assert classify_function("nc_contacts_create_contact") == "nc:write"
|
||||
assert classify_function("nc_cookbook_import_recipe") == "nc:write"
|
||||
assert classify_function("nc_tables_insert_row") == "nc:write"
|
||||
assert classify_function("deck_archive_card") == "nc:write"
|
||||
assert classify_function("deck_assign_label_to_card") == "nc:write"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_all_tools_classified():
|
||||
"""Verify that all tools can be properly classified as read or write."""
|
||||
from scripts.add_scope_decorators_simple import classify_function
|
||||
|
||||
# List of all tool names (extracted from our implementation)
|
||||
all_tools = [
|
||||
# Calendar tools
|
||||
"nc_calendar_list_calendars",
|
||||
"nc_calendar_create_event",
|
||||
"nc_calendar_list_events",
|
||||
"nc_calendar_get_event",
|
||||
"nc_calendar_update_event",
|
||||
"nc_calendar_delete_event",
|
||||
"nc_calendar_create_meeting",
|
||||
"nc_calendar_get_upcoming_events",
|
||||
"nc_calendar_find_availability",
|
||||
"nc_calendar_bulk_operations",
|
||||
"nc_calendar_manage_calendar",
|
||||
"nc_calendar_list_todos",
|
||||
"nc_calendar_create_todo",
|
||||
"nc_calendar_update_todo",
|
||||
"nc_calendar_delete_todo",
|
||||
"nc_calendar_search_todos",
|
||||
# Notes tools
|
||||
"nc_notes_get_note",
|
||||
"nc_notes_search_notes",
|
||||
"nc_notes_create_note",
|
||||
"nc_notes_update_note",
|
||||
"nc_notes_append_content",
|
||||
"nc_notes_delete_note",
|
||||
"nc_notes_get_attachment",
|
||||
# Add more as needed...
|
||||
]
|
||||
|
||||
unclassified = []
|
||||
for tool_name in all_tools:
|
||||
scope = classify_function(tool_name)
|
||||
if scope is None:
|
||||
unclassified.append(tool_name)
|
||||
|
||||
# All tools should be classifiable
|
||||
assert len(unclassified) == 0, f"Unclassified tools: {unclassified}"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_scope_metadata_coverage(nc_mcp_client):
|
||||
"""Test that all tools have scope metadata defined (no undecorated tools)."""
|
||||
# This test would require access to the actual tool functions to check metadata
|
||||
# For now, we verify that the expected number of tools exists
|
||||
# Note: Don't use 'async with' for session-scoped fixtures
|
||||
|
||||
tools_response = await nc_mcp_client.list_tools()
|
||||
|
||||
# We applied decorators to 90 tools
|
||||
# In BasicAuth mode, all should be visible
|
||||
assert len(tools_response.tools) >= 90
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
||||
nc_mcp_oauth_client_no_custom_scopes,
|
||||
):
|
||||
"""
|
||||
Test that a JWT token with only OIDC default scopes (no nc:read or nc:write) returns 0 tools.
|
||||
|
||||
This tests the security behavior when a user declines to grant custom scopes during consent.
|
||||
Expected: JWT token has scopes=['openid', 'profile', 'email'] but no nc:read or nc:write.
|
||||
All tools require at least one custom scope, so they should all be filtered out.
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Connect with JWT token that has NO custom scopes (only openid, profile, email)
|
||||
result = await nc_mcp_oauth_client_no_custom_scopes.list_tools()
|
||||
assert result is not None
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(
|
||||
f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 0)"
|
||||
)
|
||||
|
||||
# All tools require nc:read or nc:write, so should be filtered out
|
||||
assert len(tool_names) == 0, (
|
||||
f"Expected 0 tools but got {len(tool_names)}: {tool_names[:10]}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ JWT token without custom scopes correctly returns 0 tools (all filtered out)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only):
|
||||
"""
|
||||
Test JWT with only nc:read scope consented.
|
||||
|
||||
Simulates user granting only read permission during OAuth consent.
|
||||
Expected: Should see read tools but not write tools.
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
result = await nc_mcp_oauth_client_read_only.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"JWT with nc:read consent sees {len(tool_names)} tools")
|
||||
|
||||
# Verify read tools are present
|
||||
read_tools = ["nc_notes_get_note", "nc_notes_search_notes", "nc_webdav_read_file"]
|
||||
for tool in read_tools:
|
||||
assert tool in tool_names, f"Expected read tool {tool} not found"
|
||||
|
||||
# Verify write tools are filtered out
|
||||
write_tools = [
|
||||
"nc_notes_create_note",
|
||||
"nc_notes_update_note",
|
||||
"nc_webdav_write_file",
|
||||
]
|
||||
for tool in write_tools:
|
||||
assert tool not in tool_names, f"Write tool {tool} should be filtered out"
|
||||
|
||||
logger.info(
|
||||
f"✅ JWT with nc:read consent: {len(tool_names)} read tools visible, write tools filtered"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only):
|
||||
"""
|
||||
Test JWT with only nc:write scope consented.
|
||||
|
||||
Simulates user granting only write permission during OAuth consent.
|
||||
Expected: Should see write tools but not read-only tools.
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
result = await nc_mcp_oauth_client_write_only.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"JWT with nc:write consent sees {len(tool_names)} tools")
|
||||
|
||||
# Verify write tools are present
|
||||
write_tools = [
|
||||
"nc_notes_create_note",
|
||||
"nc_notes_update_note",
|
||||
"nc_webdav_write_file",
|
||||
]
|
||||
for tool in write_tools:
|
||||
assert tool in tool_names, f"Expected write tool {tool} not found"
|
||||
|
||||
# Verify read-only tools are filtered out
|
||||
read_only_tools = ["nc_notes_get_note", "nc_notes_search_notes"]
|
||||
for tool in read_only_tools:
|
||||
assert tool not in tool_names, f"Read-only tool {tool} should be filtered out"
|
||||
|
||||
logger.info(
|
||||
f"✅ JWT with nc:write consent: {len(tool_names)} write tools visible, read-only tools filtered"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_jwt_consent_scenarios_full_access(nc_mcp_oauth_client_full_access):
|
||||
"""
|
||||
Test JWT with both nc:read and nc:write scopes consented.
|
||||
|
||||
Simulates user granting both permissions during OAuth consent.
|
||||
Expected: Should see all 90+ tools (both read and write).
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
result = await nc_mcp_oauth_client_full_access.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"JWT with full consent sees {len(tool_names)} tools")
|
||||
|
||||
# Verify both read and write tools are present
|
||||
read_tools = ["nc_notes_get_note", "nc_webdav_read_file"]
|
||||
write_tools = ["nc_notes_create_note", "nc_webdav_write_file"]
|
||||
|
||||
for tool in read_tools:
|
||||
assert tool in tool_names, f"Expected read tool {tool} not found"
|
||||
|
||||
for tool in write_tools:
|
||||
assert tool in tool_names, f"Expected write tool {tool} not found"
|
||||
|
||||
# Should have all tools
|
||||
assert len(tool_names) >= 90, f"Expected 90+ tools but got {len(tool_names)}"
|
||||
|
||||
logger.info(
|
||||
f"✅ JWT with full consent: {len(tool_names)} tools visible (all read + write)"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Tests for CLI options using Click's testing utilities."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from nextcloud_mcp_server.app import run
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner():
|
||||
"""Create a Click CLI runner."""
|
||||
return CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_env(monkeypatch):
|
||||
"""Clean environment variables before each test."""
|
||||
env_vars = [
|
||||
"NEXTCLOUD_HOST",
|
||||
"NEXTCLOUD_USERNAME",
|
||||
"NEXTCLOUD_PASSWORD",
|
||||
"NEXTCLOUD_OIDC_CLIENT_ID",
|
||||
"NEXTCLOUD_OIDC_CLIENT_SECRET",
|
||||
"NEXTCLOUD_OIDC_CLIENT_STORAGE",
|
||||
"NEXTCLOUD_OIDC_SCOPES",
|
||||
"NEXTCLOUD_OIDC_TOKEN_TYPE",
|
||||
"NEXTCLOUD_MCP_SERVER_URL",
|
||||
"NEXTCLOUD_PUBLIC_ISSUER_URL",
|
||||
]
|
||||
for var in env_vars:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
def test_help_message_displays_all_options(runner):
|
||||
"""Test that help message includes all new CLI options."""
|
||||
result = runner.invoke(run, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Check for new options
|
||||
assert "--nextcloud-host" in result.output
|
||||
assert "--nextcloud-username" in result.output
|
||||
assert "--nextcloud-password" in result.output
|
||||
assert "--oauth-scopes" in result.output
|
||||
assert "--oauth-token-type" in result.output
|
||||
assert "--public-issuer-url" in result.output
|
||||
|
||||
# Check for existing options
|
||||
assert "--oauth-client-id" in result.output
|
||||
assert "--oauth-client-secret" in result.output
|
||||
assert "--mcp-server-url" in result.output
|
||||
|
||||
|
||||
def test_token_type_accepts_valid_values(runner, clean_env):
|
||||
"""Test that --oauth-token-type accepts bearer and jwt (case insensitive)."""
|
||||
# Test lowercase bearer
|
||||
result = runner.invoke(run, ["--oauth-token-type", "bearer", "--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Test lowercase jwt
|
||||
result = runner.invoke(run, ["--oauth-token-type", "jwt", "--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Test uppercase (should work with case_sensitive=False)
|
||||
result = runner.invoke(run, ["--oauth-token-type", "Bearer", "--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = runner.invoke(run, ["--oauth-token-type", "JWT", "--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_token_type_rejects_invalid_values(runner, clean_env):
|
||||
"""Test that --oauth-token-type rejects invalid values."""
|
||||
result = runner.invoke(run, ["--oauth-token-type", "invalid"])
|
||||
assert result.exit_code != 0
|
||||
assert "Invalid value" in result.output
|
||||
|
||||
|
||||
def test_cli_options_set_environment_variables(runner, clean_env, monkeypatch):
|
||||
"""Test that CLI options set environment variables correctly."""
|
||||
# We need to mock the actual server startup to avoid connection errors
|
||||
# Store the env vars that get set
|
||||
captured_env = {}
|
||||
|
||||
def mock_get_app(*args, **kwargs):
|
||||
# Capture environment variables after they're set by CLI
|
||||
captured_env.update(
|
||||
{
|
||||
"NEXTCLOUD_HOST": os.environ.get("NEXTCLOUD_HOST"),
|
||||
"NEXTCLOUD_USERNAME": os.environ.get("NEXTCLOUD_USERNAME"),
|
||||
"NEXTCLOUD_PASSWORD": os.environ.get("NEXTCLOUD_PASSWORD"),
|
||||
"NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"),
|
||||
"NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get(
|
||||
"NEXTCLOUD_OIDC_TOKEN_TYPE"
|
||||
),
|
||||
"NEXTCLOUD_PUBLIC_ISSUER_URL": os.environ.get(
|
||||
"NEXTCLOUD_PUBLIC_ISSUER_URL"
|
||||
),
|
||||
"NEXTCLOUD_MCP_SERVER_URL": os.environ.get("NEXTCLOUD_MCP_SERVER_URL"),
|
||||
}
|
||||
)
|
||||
# Raise an exception to stop execution before uvicorn.run
|
||||
raise SystemExit(0)
|
||||
|
||||
# Patch get_app to capture env vars
|
||||
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
|
||||
|
||||
_ = runner.invoke(
|
||||
run,
|
||||
[
|
||||
"--nextcloud-host",
|
||||
"https://test.example.com",
|
||||
"--nextcloud-username",
|
||||
"testuser",
|
||||
"--nextcloud-password",
|
||||
"testpass",
|
||||
"--oauth-scopes",
|
||||
"openid nc:read",
|
||||
"--oauth-token-type",
|
||||
"jwt",
|
||||
"--public-issuer-url",
|
||||
"https://public.example.com",
|
||||
"--mcp-server-url",
|
||||
"http://test:8000",
|
||||
],
|
||||
)
|
||||
|
||||
# Verify environment variables were set
|
||||
assert captured_env["NEXTCLOUD_HOST"] == "https://test.example.com"
|
||||
assert captured_env["NEXTCLOUD_USERNAME"] == "testuser"
|
||||
assert captured_env["NEXTCLOUD_PASSWORD"] == "testpass"
|
||||
assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid nc:read"
|
||||
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "jwt"
|
||||
assert captured_env["NEXTCLOUD_PUBLIC_ISSUER_URL"] == "https://public.example.com"
|
||||
assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://test:8000"
|
||||
|
||||
|
||||
def test_cli_options_override_environment_variables(runner, monkeypatch):
|
||||
"""Test that CLI options override environment variables."""
|
||||
# Set environment variables
|
||||
monkeypatch.setenv("NEXTCLOUD_HOST", "https://from-env.example.com")
|
||||
monkeypatch.setenv("NEXTCLOUD_USERNAME", "envuser")
|
||||
monkeypatch.setenv("NEXTCLOUD_OIDC_SCOPES", "openid")
|
||||
monkeypatch.setenv("NEXTCLOUD_OIDC_TOKEN_TYPE", "bearer")
|
||||
|
||||
captured_env = {}
|
||||
|
||||
def mock_get_app(*args, **kwargs):
|
||||
captured_env.update(
|
||||
{
|
||||
"NEXTCLOUD_HOST": os.environ.get("NEXTCLOUD_HOST"),
|
||||
"NEXTCLOUD_USERNAME": os.environ.get("NEXTCLOUD_USERNAME"),
|
||||
"NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"),
|
||||
"NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get(
|
||||
"NEXTCLOUD_OIDC_TOKEN_TYPE"
|
||||
),
|
||||
}
|
||||
)
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
|
||||
|
||||
# Provide CLI options that should override env vars
|
||||
_ = runner.invoke(
|
||||
run,
|
||||
[
|
||||
"--nextcloud-host",
|
||||
"https://from-cli.example.com",
|
||||
"--nextcloud-username",
|
||||
"cliuser",
|
||||
"--oauth-scopes",
|
||||
"openid nc:write",
|
||||
"--oauth-token-type",
|
||||
"jwt",
|
||||
],
|
||||
)
|
||||
|
||||
# Verify CLI options overrode env vars
|
||||
assert captured_env["NEXTCLOUD_HOST"] == "https://from-cli.example.com"
|
||||
assert captured_env["NEXTCLOUD_USERNAME"] == "cliuser"
|
||||
assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid nc:write"
|
||||
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "jwt"
|
||||
|
||||
|
||||
def test_environment_variables_used_when_cli_not_provided(runner, monkeypatch):
|
||||
"""Test that environment variables are used when CLI options not provided."""
|
||||
# Set environment variables
|
||||
monkeypatch.setenv("NEXTCLOUD_HOST", "https://from-env.example.com")
|
||||
monkeypatch.setenv("NEXTCLOUD_USERNAME", "envuser")
|
||||
monkeypatch.setenv("NEXTCLOUD_PASSWORD", "envpass")
|
||||
monkeypatch.setenv("NEXTCLOUD_OIDC_SCOPES", "openid email")
|
||||
monkeypatch.setenv("NEXTCLOUD_OIDC_TOKEN_TYPE", "jwt")
|
||||
monkeypatch.setenv("NEXTCLOUD_PUBLIC_ISSUER_URL", "https://public-env.example.com")
|
||||
|
||||
captured_env = {}
|
||||
|
||||
def mock_get_app(*args, **kwargs):
|
||||
captured_env.update(
|
||||
{
|
||||
"NEXTCLOUD_HOST": os.environ.get("NEXTCLOUD_HOST"),
|
||||
"NEXTCLOUD_USERNAME": os.environ.get("NEXTCLOUD_USERNAME"),
|
||||
"NEXTCLOUD_PASSWORD": os.environ.get("NEXTCLOUD_PASSWORD"),
|
||||
"NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"),
|
||||
"NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get(
|
||||
"NEXTCLOUD_OIDC_TOKEN_TYPE"
|
||||
),
|
||||
"NEXTCLOUD_PUBLIC_ISSUER_URL": os.environ.get(
|
||||
"NEXTCLOUD_PUBLIC_ISSUER_URL"
|
||||
),
|
||||
}
|
||||
)
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
|
||||
|
||||
# Don't provide any CLI options - should use env vars
|
||||
_ = runner.invoke(run, [])
|
||||
|
||||
# Verify env vars were used
|
||||
assert captured_env["NEXTCLOUD_HOST"] == "https://from-env.example.com"
|
||||
assert captured_env["NEXTCLOUD_USERNAME"] == "envuser"
|
||||
assert captured_env["NEXTCLOUD_PASSWORD"] == "envpass"
|
||||
assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid email"
|
||||
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "jwt"
|
||||
assert (
|
||||
captured_env["NEXTCLOUD_PUBLIC_ISSUER_URL"] == "https://public-env.example.com"
|
||||
)
|
||||
|
||||
|
||||
def test_default_values(runner, clean_env, monkeypatch):
|
||||
"""Test that default values are used when neither CLI nor env vars provided."""
|
||||
captured_env = {}
|
||||
|
||||
def mock_get_app(*args, **kwargs):
|
||||
captured_env.update(
|
||||
{
|
||||
"NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"),
|
||||
"NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get(
|
||||
"NEXTCLOUD_OIDC_TOKEN_TYPE"
|
||||
),
|
||||
"NEXTCLOUD_MCP_SERVER_URL": os.environ.get("NEXTCLOUD_MCP_SERVER_URL"),
|
||||
"NEXTCLOUD_OIDC_CLIENT_STORAGE": os.environ.get(
|
||||
"NEXTCLOUD_OIDC_CLIENT_STORAGE"
|
||||
),
|
||||
}
|
||||
)
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
|
||||
|
||||
# Don't provide CLI options or env vars - should use defaults
|
||||
_ = runner.invoke(run, [])
|
||||
|
||||
# Verify default values
|
||||
assert (
|
||||
captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid profile email nc:read nc:write"
|
||||
)
|
||||
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "bearer"
|
||||
assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://localhost:8000"
|
||||
assert (
|
||||
captured_env["NEXTCLOUD_OIDC_CLIENT_STORAGE"] == ".nextcloud_oauth_client.json"
|
||||
)
|
||||
|
||||
|
||||
def test_oauth_token_type_case_normalization(runner, clean_env, monkeypatch):
|
||||
"""Test that token type is normalized correctly regardless of input case."""
|
||||
captured_env = {}
|
||||
|
||||
def mock_get_app(*args, **kwargs):
|
||||
captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] = os.environ.get(
|
||||
"NEXTCLOUD_OIDC_TOKEN_TYPE"
|
||||
)
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
|
||||
|
||||
# Test uppercase JWT
|
||||
runner.invoke(run, ["--oauth-token-type", "JWT"])
|
||||
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] in ["JWT", "jwt"]
|
||||
|
||||
# Test mixed case Bearer
|
||||
captured_env.clear()
|
||||
runner.invoke(run, ["--oauth-token-type", "Bearer"])
|
||||
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] in ["Bearer", "bearer"]
|
||||
+1
Submodule third_party/oidc added at f7f80b72d5
@@ -82,6 +82,76 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
@@ -291,6 +361,68 @@ toml = [
|
||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decli"
|
||||
version = "0.6.3"
|
||||
@@ -809,7 +941,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.17.1"
|
||||
version = "0.18.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "caldav" },
|
||||
@@ -819,6 +951,7 @@ dependencies = [
|
||||
{ name = "mcp", extra = ["cli"] },
|
||||
{ name = "pillow" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "pythonvcard4" },
|
||||
]
|
||||
|
||||
@@ -844,6 +977,7 @@ requires-dist = [
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.18,<1.19" },
|
||||
{ name = "pillow", specifier = ">=12.0.0,<12.1.0" },
|
||||
{ name = "pydantic", specifier = ">=2.11.4" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0" },
|
||||
{ name = "pythonvcard4", specifier = ">=0.2.0" },
|
||||
]
|
||||
|
||||
@@ -1035,6 +1169,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.3"
|
||||
@@ -1178,6 +1321,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.2"
|
||||
|
||||
Reference in New Issue
Block a user