From 1a7ce5b7a717212477bff6cb1c5059b39eb54059 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 23 Oct 2025 12:22:34 +0200 Subject: [PATCH] docs: Update jwt docs [skip ci] --- .../90-create-jwt-oauth-client.sh | 100 ------- docs/jwt-oauth-reference.md | 270 +++++++++++++----- 2 files changed, 204 insertions(+), 166 deletions(-) delete mode 100755 app-hooks/post-installation/90-create-jwt-oauth-client.sh diff --git a/app-hooks/post-installation/90-create-jwt-oauth-client.sh b/app-hooks/post-installation/90-create-jwt-oauth-client.sh deleted file mode 100755 index 6ff5b4cd..00000000 --- a/app-hooks/post-installation/90-create-jwt-oauth-client.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/bin/bash -set -e - -echo "=== JWT OAuth Client Setup ===" -echo "Installing and configuring OIDC app for JWT tokens..." - -# Wait for Nextcloud to be fully initialized -sleep 5 - -# Install OIDC app if not already installed -if ! php /var/www/html/occ app:list | grep -q "oidc"; then - echo "Installing OIDC app..." - php /var/www/html/occ app:install oidc -else - echo "OIDC app already installed" -fi - -# Enable the app -php /var/www/html/occ app:enable oidc - -# Check if JWT client already exists -# Use a location that www-data user owns -CLIENT_DIR="/var/www/html/.oauth-jwt" -CLIENT_FILE="$CLIENT_DIR/nextcloud_oauth_client.json" - -if [ -f "$CLIENT_FILE" ]; then - echo "JWT OAuth client already exists at $CLIENT_FILE" - exit 0 -fi - -# Create directory owned by www-data -mkdir -p "$CLIENT_DIR" - -# Create JWT OAuth client with proper scopes -echo "Creating JWT OAuth client..." - -# The redirect URI for the MCP server -REDIRECT_URI="http://127.0.0.1:8002/oauth/callback" - -# Create the client with JWT token type -OUTPUT=$(php /var/www/html/occ oidc:create \ - --token_type=jwt \ - --allowed_scopes="openid profile email nc:read nc:write" \ - "Nextcloud MCP Server JWT" \ - "$REDIRECT_URI") - -echo "Client creation output:" -echo "$OUTPUT" - -# Parse the JSON output to extract client_id, client_secret, and issued_at -# Output format is JSON -CLIENT_ID=$(echo "$OUTPUT" | grep '"client_id"' | sed 's/.*"client_id": "\([^"]*\)".*/\1/') -CLIENT_SECRET=$(echo "$OUTPUT" | grep '"client_secret"' | sed 's/.*"client_secret": "\([^"]*\)".*/\1/') -ISSUED_AT=$(echo "$OUTPUT" | grep '"issued_at"' | sed 's/.*"issued_at": \([0-9]*\).*/\1/') - -if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ]; then - echo "ERROR: Failed to parse client credentials from output" - echo "Output was: $OUTPUT" - exit 1 -fi - -# Use issued_at if available, otherwise use current timestamp -if [ -z "$ISSUED_AT" ]; then - ISSUED_AT=$(date +%s) -fi - -# Set expiration to 10 years in the future (JWT clients don't expire like DCR clients) -EXPIRES_AT=$((ISSUED_AT + 315360000)) - -echo "Successfully created JWT client:" -echo " Client ID: ${CLIENT_ID:0:16}..." -echo " Client Secret: [hidden]" - -# Create the credentials file in the format expected by the MCP server -# This matches the format from ClientInfo.to_dict() in client_registration.py -cat > "$CLIENT_FILE" << EOF -{ - "client_id": "$CLIENT_ID", - "client_secret": "$CLIENT_SECRET", - "client_id_issued_at": $ISSUED_AT, - "client_secret_expires_at": $EXPIRES_AT, - "redirect_uris": ["$REDIRECT_URI"] -} -EOF - -echo "Credentials saved to $CLIENT_FILE" - -# Also save to environment variable format for easy access -cat > "$CLIENT_DIR/client_env.sh" << EOF -export NEXTCLOUD_OIDC_CLIENT_ID="$CLIENT_ID" -export NEXTCLOUD_OIDC_CLIENT_SECRET="$CLIENT_SECRET" -export NEXTCLOUD_OIDC_SCOPES="openid profile email nc:read nc:write" -EOF - -chmod 600 "$CLIENT_DIR/client_env.sh" - -echo "=== JWT OAuth Client Setup Complete ===" -echo "Client credentials are available at:" -echo " JSON: $CLIENT_FILE" -echo " ENV: $CLIENT_DIR/client_env.sh" diff --git a/docs/jwt-oauth-reference.md b/docs/jwt-oauth-reference.md index b071facb..e49a34d7 100644 --- a/docs/jwt-oauth-reference.md +++ b/docs/jwt-oauth-reference.md @@ -27,13 +27,12 @@ The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 90 ### 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 -✅ **Automatic Client Creation** - JWT OAuth clients auto-generated on container startup -✅ **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 +- ✅ **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 @@ -88,14 +87,23 @@ The Nextcloud OIDC app supports two token formats, configured per-client: **Advantages:** - ✅ Smaller size (72 characters) - ✅ No payload visible to client +- ✅ Direct scope access via introspection endpoint (RFC 7662) **Disadvantages:** -- ❌ Requires userinfo endpoint call for each validation -- ❌ Scopes must be inferred from userinfo response (not directly available) -- ❌ Higher latency (HTTP call required) +- ❌ 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, direct scope access) +- Use **JWT tokens** for production (better performance, no HTTP call) - Use **opaque tokens** for compatibility with clients that don't support JWT --- @@ -228,8 +236,9 @@ The development environment includes three MCP server variants: ### JWT Service Configuration -The `mcp-oauth-jwt` service uses automatic client creation: +The `mcp-oauth-jwt` service uses **Dynamic Client Registration (DCR)** by default: +**Default Configuration (DCR):** ```yaml mcp-oauth-jwt: build: . @@ -240,59 +249,150 @@ mcp-oauth-jwt: - NEXTCLOUD_HOST=http://app:80 - NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002 - NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080 - - NEXTCLOUD_OIDC_CLIENT_STORAGE=/var/www/html/.oauth-jwt/nextcloud_oauth_client.json - NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write - NEXTCLOUD_OIDC_TOKEN_TYPE=jwt volumes: - - nextcloud:/var/www/html:ro + - ./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= # Skips DCR + - NEXTCLOUD_OIDC_CLIENT_SECRET= # Skips DCR + - NEXTCLOUD_OIDC_TOKEN_TYPE=jwt ``` **Key Points:** -- No `NEXTCLOUD_OIDC_CLIENT_ID` or `CLIENT_SECRET` needed (loaded from storage) -- JWT client auto-created during Nextcloud initialization -- Credentials stored in `/var/www/html/.oauth-jwt/nextcloud_oauth_client.json` -- Volume mounted read-only for security +- **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 | (required) | +| `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_STORAGE` | Path to client credentials JSON | (optional - uses DCR if unset) | +| `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 -If not using automatic creation, create a JWT client manually: +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:8002/oauth/callback" + "http://localhost:8000/oauth/callback" ``` **Output:** ```json { - "client_id": "...", - "client_secret": "...", + "client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY", + "client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT", "token_type": "jwt", "allowed_scopes": "openid profile email nc:read nc:write" } ``` -Then configure the MCP server: +**Configure MCP Server with Pre-Configured Credentials:** + ```bash +# Option 1: Environment variables (highest priority) export NEXTCLOUD_OIDC_CLIENT_ID="" export NEXTCLOUD_OIDC_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 @@ -317,8 +417,8 @@ export NEXTCLOUD_OIDC_TOKEN_TYPE="jwt" │ ┌───────────────────────────────────────────────────┐ │ │ │ NextcloudTokenVerifier │ │ │ │ - JWT signature verification (JWKS) │ │ -│ │ - Scope extraction from token payload │ │ -│ │ - Fallback to userinfo endpoint (opaque tokens) │ │ +│ │ - Introspection endpoint (opaque tokens) │ │ +│ │ - Userinfo fallback (last resort) │ │ │ └───────────────────┬───────────────────────────────┘ │ │ │ │ │ v │ @@ -342,10 +442,13 @@ export NEXTCLOUD_OIDC_TOKEN_TYPE="jwt" ### Key Components **1. Token Verification** (`nextcloud_mcp_server/auth/token_verifier.py`) -- JWT signature verification using JWKS (RS256) -- Scope extraction from `scope` claim -- Token caching with TTL -- Fallback to userinfo endpoint for opaque tokens +- **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 @@ -369,28 +472,66 @@ export NEXTCLOUD_OIDC_TOKEN_TYPE="jwt" - Returns 403 with `WWW-Authenticate` header - Includes missing scopes and PRM endpoint URL -### Automatic Client Creation +### Token Validation Flow -**Post-Installation Hook:** `app-hooks/post-installation/90-create-jwt-oauth-client.sh` +The `NextcloudTokenVerifier` implements a **cascading validation strategy** that handles both JWT and opaque tokens efficiently: -On container startup, this script: -1. Installs/enables OIDC app -2. Creates JWT client via `occ oidc:create --token_type=jwt` -3. Parses JSON output for credentials -4. Saves to `/var/www/html/.oauth-jwt/nextcloud_oauth_client.json` - -**Credential Format:** -```json -{ - "client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY", - "client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT", - "client_id_issued_at": 1761097039, - "client_secret_expires_at": 2076457039, - "redirect_uris": ["http://localhost:8002/oauth/callback"] -} +``` +┌─────────────────────────────────────────────────────────┐ +│ 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 @@ -565,26 +706,26 @@ docker compose exec mcp-oauth-jwt env | grep NEXTCLOUD_OIDC # Ensure no NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD set ``` -### Issue: Dynamic Client Registration Doesn't Set Scopes +### Verifying DCR Scope Configuration -**Symptom:** Client registered via DCR has empty `allowed_scopes` +DCR **now properly sets `allowed_scopes`** when the `scope` parameter is provided during registration. -**Cause:** OIDC app's registration endpoint doesn't populate `allowed_scopes` from request +**To verify DCR scopes are working:** -**Workaround:** Use pre-configured clients instead of DCR for JWT tokens: ```bash -# Create client manually via occ -docker compose exec app php occ oidc:create \ - --token_type=jwt \ - --allowed_scopes="openid profile email nc:read nc:write" \ - "Client Name" \ - "http://callback" +# 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;" -# Configure MCP server to use these credentials -export NEXTCLOUD_OIDC_CLIENT_ID="..." -export NEXTCLOUD_OIDC_CLIENT_SECRET="..." +# 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 @@ -716,9 +857,7 @@ 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. **DCR Doesn't Set Allowed Scopes** - Must use pre-configured clients for JWT -3. **No Introspection Endpoint** - OIDC app lacks RFC 7662 introspection -4. **No Refresh Token Support** - Tokens must be reacquired when expired +2. **No Refresh Token Support** - Tokens must be reacquired when expired ### Future Enhancements @@ -727,7 +866,6 @@ WARNING Missing required scopes: nc:write - Resource-level filtering (apply to MCP resources, not just tools) - Automatic scope discovery from decorated tools - Admin UI for scope management -- Token introspection endpoint (RFC 7662) ---