Merge remote-tracking branch 'origin/master' into docs/adr-024-dynaconf-config-management

This commit is contained in:
Chris Coutinho
2026-04-07 14:17:57 +02:00
48 changed files with 733 additions and 525 deletions
+139
View File
@@ -0,0 +1,139 @@
# ADR-024: OAuth Scope Separator Migration (colon to dot)
**Status:** Accepted
**Date:** 2026-04-07
**Supersedes:** Scope naming conventions in ADR-004, ADR-009, ADR-011
## Context
The MCP server defines application-level OAuth scopes using the `resource:action`
pattern (e.g., `notes:read`, `calendar:write`). While the colon separator is
visually intuitive and used by some OAuth implementations, it causes
interoperability problems with many identity providers.
### IDP Compatibility Issues
Several widely-deployed identity providers reject or mishandle colons in OAuth
scope names:
- **Keycloak**: Accepts colons but requires special configuration for scope
mappers; colons can conflict with realm-qualified scope names
- **Auth0**: Permits colons but treats them as namespace delimiters in their
Resource Server API, leading to unexpected scope resolution behavior
- **Azure AD / Entra ID**: Uses colons internally for delegated permissions
(e.g., `User.Read`) and may reject custom scopes containing colons
- **AWS Cognito**: Restricts scope names to alphanumeric characters, hyphens,
periods, and underscores — colons are not allowed
- **Okta**: Custom scopes are restricted to `[a-zA-Z0-9._-]`; colons are
explicitly rejected
### RFC References
The OAuth 2.0 framework (RFC 6749, Section 3.3) defines scope values as:
> scope-token = 1*( %x21 / %x23-5B / %x5D-7E )
This technically permits the colon character (`%x3A`), so colons are
spec-compliant. However, the specification also notes:
> The authorization server MAY fully or partially ignore the scope requested by
> the client, based on the authorization server policy or the resource owner's
> instructions.
In practice, many authorization servers impose stricter character restrictions
than the RFC minimum. The dot separator (`.`) is universally accepted across all
major OAuth/OIDC implementations and is the de facto convention used by:
- Microsoft Identity Platform (`User.Read`, `Mail.Send`)
- Google OAuth (`https://www.googleapis.com/auth/calendar.readonly`)
- MCP specification examples in RFC 9728 (OAuth Protected Resource Metadata)
### RFC 9728 (OAuth Protected Resource Metadata)
RFC 9728 defines the Protected Resource Metadata endpoint used by this server
(`/.well-known/oauth-protected-resource`). While the RFC does not mandate a
specific scope naming convention, its examples and the broader OAuth ecosystem
favor dot-separated scopes for maximum interoperability.
## Decision
Replace the colon (`:`) separator with a dot (`.`) in all application-level
OAuth scope names:
| Before | After |
|--------|-------|
| `notes:read` | `notes.read` |
| `notes:write` | `notes.write` |
| `calendar:read` | `calendar.read` |
| `calendar:write` | `calendar.write` |
| `todo:read` | `todo.read` |
| `todo:write` | `todo.write` |
| `contacts:read` | `contacts.read` |
| `contacts:write` | `contacts.write` |
| `files:read` | `files.read` |
| `files:write` | `files.write` |
| `tables:read` | `tables.read` |
| `tables:write` | `tables.write` |
| `deck:read` | `deck.read` |
| `deck:write` | `deck.write` |
| `cookbook:read` | `cookbook.read` |
| `cookbook:write` | `cookbook.write` |
| `sharing:read` | `sharing.read` |
| `sharing:write` | `sharing.write` |
| `news:read` | `news.read` |
| `news:write` | `news.write` |
| `collectives:read` | `collectives.read` |
| `collectives:write` | `collectives.write` |
| `semantic:read` | `semantic.read` |
Standard OIDC scopes (`openid`, `profile`, `email`, `offline_access`) are
unchanged — they are defined by OIDC Core and do not use separators.
## Consequences
### Positive
- **Universal IDP compatibility**: Dot-separated scopes work with every major
identity provider without special configuration
- **Industry alignment**: Matches the naming convention used by Microsoft,
Google, and other major OAuth implementations
- **No logic changes**: The authorization system uses string comparison and
`startswith()` prefix matching — changing the separator character requires no
algorithmic changes
### Negative
- **Breaking change**: Existing OAuth clients, stored tokens, and IDP
configurations must be updated to use the new scope names
- **Migration required**: An Alembic database migration updates stored scope
strings in `app_passwords` and `login_flow_sessions` tables
### Migration
- **Database**: Alembic migration `004` handles `REPLACE(scopes, ':', '.')`
on stored scope JSON
- **Keycloak**: The realm export (`keycloak/realm-export.json`) has been updated;
existing Keycloak deployments must re-import or manually update scope
definitions
- **Nextcloud OIDC app**: The `astrolabe` OAuth client hook
(`26-configure-astrolabe-oauth.sh`) has been updated with new scope names
- **Existing MCP clients**: Must update their scope requests to use dot
separators; old colon-separated scope requests will be rejected
## Alternatives Considered
### Hyphen separator (`notes-read`)
Rejected: While universally compatible, hyphens are commonly used within scope
component names (e.g., hypothetical `file-share.read`), creating ambiguity about
which hyphen is the separator.
### Underscore separator (`notes_read`)
Rejected: Also universally compatible but less conventional in the OAuth
ecosystem. Dot is the dominant separator in industry practice.
### Keep colons with IDP-specific workarounds
Rejected: Requires per-IDP configuration, documentation, and ongoing maintenance.
The root cause is a poor separator choice, not an IDP deficiency.
+3 -3
View File
@@ -76,7 +76,7 @@ Both modes maintain the critical separation: **refresh tokens are never used for
- ✅ Generated **on-demand** during tool execution
-**Ephemeral** - used only for current operation
-**NOT stored** - discarded after use
-**Limited scopes** - only what tool needs (e.g., `notes:read` for search)
-**Limited scopes** - only what tool needs (e.g., `notes.read` for search)
-**Short-lived** - expires quickly (e.g., 5 minutes)
**Background Jobs (Offline Operations)**:
@@ -202,7 +202,7 @@ Tools use the same pattern regardless of token acquisition mode:
```python
@mcp.tool()
@require_scopes("notes:read") # Soft-scope enforced by MCP server, not Nextcloud
@require_scopes("notes.read") # Soft-scope enforced by MCP server, not Nextcloud
@require_provisioning
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
"""Search notes by title or content."""
@@ -333,7 +333,7 @@ When enabled, all MCP tool calls will use token exchange (RFC 8693) to obtain ep
## Nextcloud Scope Limitation
**IMPORTANT**: Nextcloud does not support OAuth scopes natively. Scopes like "notes:read" are **soft-scopes** enforced by the MCP server via `@require_scopes` decorator, not by the IdP or Nextcloud.
**IMPORTANT**: Nextcloud does not support OAuth scopes natively. Scopes like "notes.read" are **soft-scopes** enforced by the MCP server via `@require_scopes` decorator, not by the IdP or Nextcloud.
This means:
- Token exchange provides audit and delegation benefits, not scope restriction
+43 -43
View File
@@ -28,7 +28,7 @@ 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** - `mcp:notes:read` and `mcp:notes:write` for read/write access control
-**Custom Scopes** - `mcp:notes.read` and `mcp:notes.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 9728 endpoint for scope discovery
@@ -38,8 +38,8 @@ The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 90
| Scope | Description | Tool Count |
|-------|-------------|------------|
| `mcp:notes:read` | Read-only access to Nextcloud data | 36 tools |
| `mcp:notes:write` | Write access to create/modify/delete data | 54 tools |
| `mcp:notes.read` | Read-only access to Nextcloud data | 36 tools |
| `mcp:notes.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.
@@ -75,7 +75,7 @@ The Nextcloud OIDC app supports two token formats, configured per-client:
"aud": "client_id",
"exp": 1234567890,
"iat": 1234567890,
"scope": "openid profile email mcp:notes:read mcp:notes:write",
"scope": "openid profile email mcp:notes.read mcp:notes.write",
"client_id": "...",
"jti": "..."
}
@@ -116,8 +116,8 @@ The MCP server uses **coarse-grained scopes** for simplicity:
| Scope | Operations | Examples |
|-------|------------|----------|
| `mcp:notes:read` | Read-only access | Get notes, search files, list calendars, read contacts |
| `mcp:notes:write` | Write operations | Create notes, update events, delete files, modify contacts |
| `mcp:notes.read` | Read-only access | Get notes, search files, list calendars, read contacts |
| `mcp:notes.write` | Write operations | Create notes, update events, delete files, modify contacts |
### Standard OIDC Scopes
@@ -131,12 +131,12 @@ The MCP server uses **coarse-grained scopes** for simplicity:
**Full Access:**
```
openid profile email mcp:notes:read mcp:notes:write
openid profile email mcp:notes.read mcp:notes.write
```
**Read-Only:**
```
openid profile email mcp:notes:read
openid profile email mcp:notes.read
```
**No Custom Scopes (OIDC only):**
@@ -150,32 +150,32 @@ All 90 MCP tools are decorated with scope requirements:
```python
@mcp.tool()
@require_scopes("mcp:notes:read")
@require_scopes("mcp:notes.read")
async def nc_notes_get_note(note_id: int, ctx: Context):
"""Get a note by ID (requires mcp:notes:read scope)"""
"""Get a note by ID (requires mcp:notes.read scope)"""
...
@mcp.tool()
@require_scopes("mcp:notes:write")
@require_scopes("mcp:notes.write")
async def nc_notes_create_note(title: str, content: str, ctx: Context):
"""Create a note (requires mcp:notes:write scope)"""
"""Create a note (requires mcp:notes.write scope)"""
...
```
**Coverage:**
- ✅ 36 read tools decorated with `@require_scopes("mcp:notes:read")`
- ✅ 54 write tools decorated with `@require_scopes("mcp:notes:write")`
- ✅ 36 read tools decorated with `@require_scopes("mcp:notes.read")`
- ✅ 54 write tools decorated with `@require_scopes("mcp:notes.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. This applies to **both JWT and Bearer (opaque) tokens** in OAuth mode:
**Token with `mcp:notes:read` only:**
**Token with `mcp:notes.read` only:**
- `list_tools()` returns 36 read-only tools
- Write tools are hidden from the tool list
**Token with `mcp:notes:write` only:**
**Token with `mcp:notes.write` only:**
- `list_tools()` returns 54 write-only tools
- Read tools are hidden from the tool list
@@ -183,7 +183,7 @@ The MCP server implements **dynamic tool filtering** - users only see tools they
- `list_tools()` returns all 90 tools
**Token with no custom scopes:**
- `list_tools()` returns 0 tools (all require `mcp:notes:read` or `mcp:notes:write`)
- `list_tools()` returns 0 tools (all require `mcp:notes.read` or `mcp:notes.write`)
**BasicAuth mode:**
- `list_tools()` returns all 90 tools (no filtering)
@@ -197,7 +197,7 @@ When a tool is called without required scopes, the server returns a `403 Forbidd
```http
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="mcp:notes:write",
scope="mcp:notes.write",
resource_metadata="http://server/.well-known/oauth-protected-resource/mcp"
```
@@ -213,7 +213,7 @@ The server implements RFC 9728's Protected Resource Metadata endpoint:
```json
{
"resource": "http://localhost:8001/mcp",
"scopes_supported": ["mcp:notes:read", "mcp:notes:write"],
"scopes_supported": ["mcp:notes.read", "mcp:notes.write"],
"authorization_servers": ["http://localhost:8080"],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"]
@@ -250,7 +250,7 @@ mcp-oauth:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes.read mcp:notes.write
volumes:
- oauth-client-storage:/app/.oauth # Persist DCR credentials
```
@@ -286,7 +286,7 @@ mcp-oauth:
| `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_SCOPES` | Space-separated scopes to request | `"openid profile email mcp:notes:read mcp:notes:write"` |
| `NEXTCLOUD_OIDC_SCOPES` | Space-separated scopes to request | `"openid profile email mcp:notes.read mcp:notes.write"` |
| `NEXTCLOUD_OIDC_TOKEN_TYPE` | Token format: `"jwt"` or `"Bearer"` | `"Bearer"` |
### Dynamic Client Registration (DCR)
@@ -320,7 +320,7 @@ DCR automatically configures the client based on environment variables:
# 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 mcp:notes:read mcp:notes:write"
export NEXTCLOUD_OIDC_SCOPES="openid profile email mcp:notes.read mcp:notes.write"
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt # or "Bearer" for opaque tokens
```
@@ -362,7 +362,7 @@ Manual client creation is **optional** but may be preferred when:
```bash
docker compose exec app php occ oidc:create \
--token_type=jwt \
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
--allowed_scopes="openid profile email mcp:notes.read mcp:notes.write" \
"Nextcloud MCP Server" \
"http://localhost:8000/oauth/callback"
```
@@ -373,7 +373,7 @@ docker compose exec app php occ oidc:create \
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
"token_type": "jwt",
"allowed_scopes": "openid profile email mcp:notes:read mcp:notes:write"
"allowed_scopes": "openid profile email mcp:notes.read mcp:notes.write"
}
```
@@ -406,7 +406,7 @@ When credentials are provided via environment variables or storage file, **DCR i
│ │
│ JWT Access Token │
│ { │
│ "scope": "openid mcp:notes:read mcp:notes:write" │
│ "scope": "openid mcp:notes.read mcp:notes.write" │
│ ... │
│ } │
│ │
@@ -463,7 +463,7 @@ When credentials are provided via environment variables or storage file, **DCR i
**4. PRM Endpoint** (`nextcloud_mcp_server/app.py:503-532`)
- `GET /.well-known/oauth-protected-resource/mcp`
- Advertises `["mcp:notes:read", "mcp:notes:write"]`
- Advertises `["mcp:notes.read", "mcp:notes.write"]`
- RFC 9728 compliant
**5. Exception Handler** (`nextcloud_mcp_server/app.py:540-563`)
@@ -499,7 +499,7 @@ The `NextcloudTokenVerifier` implements a **cascading validation strategy** that
│ ├─ Authenticate with client credentials
│ ├─ Response contains:
│ │ • active: true/false
│ │ • scope: "openid mcp:notes:read mcp:notes:write"
│ │ • scope: "openid mcp:notes.read mcp:notes.write"
│ │ • sub, exp, iat, client_id
│ ├─ Extract scopes from response
│ └─ Success: Return AccessToken
@@ -553,7 +553,7 @@ uv run pytest tests/server/test_scope_authorization.py::test_jwt_with_no_custom_
```
**Scenario:** JWT token with only OIDC defaults (`openid profile email`)
**Expected:** 0 tools returned (all require `mcp:notes:read` or `mcp:notes:write`)
**Expected:** 0 tools returned (all require `mcp:notes.read` or `mcp:notes.write`)
**Verifies:** Security - users who decline custom scopes cannot access any MCP tools
#### 2. Read-Only Access (36 tools)
@@ -561,7 +561,7 @@ uv run pytest tests/server/test_scope_authorization.py::test_jwt_with_no_custom_
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_read_only -v
```
**Scenario:** JWT token with `mcp:notes:read` only
**Scenario:** JWT token with `mcp:notes.read` only
**Expected:** 36 read-only tools visible, write tools hidden
**Verifies:** Read tools accessible, write tools filtered out
@@ -570,7 +570,7 @@ uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenari
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_write_only -v
```
**Scenario:** JWT token with `mcp:notes:write` only
**Scenario:** JWT token with `mcp:notes.write` only
**Expected:** 54 write tools visible, read tools hidden
**Verifies:** Write tools accessible, read tools filtered out
@@ -579,21 +579,21 @@ uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenari
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_full_access -v
```
**Scenario:** JWT token with both `mcp:notes:read` and `mcp:notes:write`
**Scenario:** JWT token with both `mcp:notes.read` and `mcp:notes.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 `mcp:notes:read` only
- `write_only_oauth_client_credentials` - Client with `mcp:notes:write` only
- `read_only_oauth_client_credentials` - Client with `mcp:notes.read` only
- `write_only_oauth_client_credentials` - Client with `mcp:notes.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 `mcp:notes:read`
- `playwright_oauth_token_write_only` - Obtains token with `mcp:notes:write`
- `playwright_oauth_token_read_only` - Obtains token with `mcp:notes.read`
- `playwright_oauth_token_write_only` - Obtains token with `mcp:notes.write`
- `playwright_oauth_token_full_access` - Obtains token with both scopes
- `playwright_oauth_token_no_custom_scopes` - Obtains token with no custom scopes
@@ -681,14 +681,14 @@ docker compose exec app php occ oidc:list
# If empty, recreate client with --allowed_scopes
docker compose exec app php occ oidc:create \
--token_type=jwt \
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
--allowed_scopes="openid profile email mcp:notes.read mcp:notes.write" \
"Client Name" \
"http://callback/url"
```
### Issue: All Tools Visible Despite Read-Only Token
**Symptom:** User with `mcp:notes:read` token can see all 90 tools including write tools
**Symptom:** User with `mcp:notes.read` token can see all 90 tools including write tools
**Cause:** Server running in BasicAuth mode, not OAuth mode
@@ -716,7 +716,7 @@ DCR **now properly sets `allowed_scopes`** when the `scope` parameter is provide
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 mcp:notes:read mcp:notes:write")
# Should show your requested scopes (e.g., "openid profile email mcp:notes.read mcp:notes.write")
```
**If scopes are missing:**
@@ -812,7 +812,7 @@ mcp-oauth:
- 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 mcp:notes:read mcp:notes:write
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes.read mcp:notes.write
ports:
- "8001:8001"
```
@@ -846,22 +846,22 @@ mcp-oauth:
```bash
# Success
INFO JWT verified successfully for user: admin
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'mcp:notes:read', 'mcp:notes:write'}
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'mcp:notes.read', 'mcp:notes.write'}
# Failures
WARNING JWT issuer validation failed: Invalid issuer
WARNING Missing required scopes: mcp:notes:write
WARNING Missing required scopes: mcp:notes.write
```
### Known Limitations
1. **No Fine-Grained Scopes** - Only coarse `mcp:notes:read` and `mcp:notes:write` (not per-app scopes)
1. **No Fine-Grained Scopes** - Only coarse `mcp:notes.read` and `mcp:notes.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`)
- 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
+43 -43
View File
@@ -89,7 +89,7 @@ Phase 2: OAuth Authorization Flow (PKCE - RFC 7636)
│ client_id=xxx │ │
│ &code_challenge=abc... │ │
│ &code_challenge_method=S256 │ │
│ &scope=openid notes:read ... │ │
│ &scope=openid notes.read ... │ │
│ │ │
│ 2c. User consent page │ │
│<─────────────────────────────────┼─────────────────────────────────────┤
@@ -111,7 +111,7 @@ Phase 2: OAuth Authorization Flow (PKCE - RFC 7636)
│ 2g. Access token (JWT/opaque) │ │
│<─────────────────────────────────┼─────────────────────────────────────┤
│ {access_token, token_type, │ │
│ scope: "openid notes:read...") │ ← User's granted scopes │
│ scope: "openid notes.read...") │ ← User's granted scopes │
│ │ │
@@ -140,11 +140,11 @@ Phase 3: MCP Tool Access (Scope-based Authorization)
│ │ │
│ 3e. Call tool │ │
├─────────────────────────────────>│ │
│ nc_notes_get_note(note_id=1) │ ← @require_scopes("notes:read") │
│ nc_notes_get_note(note_id=1) │ ← @require_scopes("notes.read") │
│ Authorization: Bearer <token> │ │
│ │ │
│ │ 3f. Scope check PASSED │
│ │ ✓ Token has notes:read │
│ │ ✓ Token has notes.read │
│ │ │
│ │ 3g. Nextcloud API call │
│ ├────────────────────────────────────>│
@@ -168,17 +168,17 @@ Insufficient Scope Example (Step-Up Authorization)
│ 4a. Call write tool │ │
├─────────────────────────────────>│ │
│ nc_notes_create_note(...) │ ← @require_scopes("notes:write") │
│ nc_notes_create_note(...) │ ← @require_scopes("notes.write") │
│ Authorization: Bearer <token> │ │
│ │ │
│ │ 4b. Scope check FAILED │
│ │ ✗ Token only has notes:read │
│ │ ✗ Token only has notes.read │
│ │ │
│ 4c. 403 Insufficient Scope │ │
│<─────────────────────────────────┤ │
│ WWW-Authenticate: Bearer │ │
│ error="insufficient_scope", │ │
│ scope="notes:write", │ │
│ scope="notes.write", │ │
│ resource_metadata="..." │ │
│ │ │
│ → Client can re-authorize with │ │
@@ -364,7 +364,7 @@ The OAuth flow consists of four distinct phases (see diagram above for visual re
- `client_id`: OAuth client ID
- `code_challenge`: SHA256 hash of verifier
- `code_challenge_method`: `S256`
- `scope`: Requested scopes (e.g., `openid notes:read notes:write`)
- `scope`: Requested scopes (e.g., `openid notes.read notes.write`)
- `redirect_uri`: MCP server callback URL
3. **User Consent**
@@ -409,7 +409,7 @@ The OAuth flow consists of four distinct phases (see diagram above for visual re
3. **Dynamic Tool Filtering**
- Server compares token scopes with each tool's `@require_scopes`
- Only returns tools where user has all required scopes
- Example: Token with `notes:read` sees 4 read tools, not 3 write tools
- Example: Token with `notes.read` sees 4 read tools, not 3 write tools
4. **Filtered Tool List**
- Client receives only tools they can use
@@ -420,7 +420,7 @@ The OAuth flow consists of four distinct phases (see diagram above for visual re
2. **Scope Validation**
- `@require_scopes` decorator extracts token scopes
- Verifies token contains required scope (e.g., `notes:read`)
- Verifies token contains required scope (e.g., `notes.read`)
- If missing → 403 with `WWW-Authenticate` header (step-up auth)
- If present → continues execution
@@ -443,26 +443,26 @@ The OAuth flow consists of four distinct phases (see diagram above for visual re
**Steps**:
1. **Tool Call with Insufficient Scopes**
- User calls `nc_notes_create_note` (requires `notes:write`)
- But token only has `notes:read`
- User calls `nc_notes_create_note` (requires `notes.write`)
- But token only has `notes.read`
2. **Scope Validation Fails**
- `@require_scopes("notes:write")` decorator checks token
- Finds `notes:write` missing
- `@require_scopes("notes.write")` decorator checks token
- Finds `notes.write` missing
3. **403 Response with Challenge**
- Returns `403 Forbidden`
- Includes `WWW-Authenticate` header:
```
Bearer error="insufficient_scope",
scope="notes:write",
scope="notes.write",
resource_metadata="http://localhost:8000/.well-known/oauth-protected-resource/mcp"
```
4. **Client Re-Authorization** (Optional)
- Client can initiate new OAuth flow requesting additional scopes
- User re-consents with expanded permissions
- New token includes both `notes:read` and `notes:write`
- New token includes both `notes.read` and `notes.write`
**Result**: User can dynamically upgrade permissions without full re-authentication
@@ -599,46 +599,46 @@ The server supports the following OAuth scopes, organized by Nextcloud app:
- `email` - Access to user email address (required)
#### Notes App
- `notes:read` - Read notes, search notes, get note attachments
- `notes:write` - Create, update, append to, and delete notes
- `notes.read` - Read notes, search notes, get note attachments
- `notes.write` - Create, update, append to, and delete notes
#### Calendar App
- `calendar:read` - List calendars, read events, search events
- `calendar:write` - Create, update, and delete calendars and events
- `calendar.read` - List calendars, read events, search events
- `calendar.write` - Create, update, and delete calendars and events
#### Calendar Tasks (VTODO)
- `todo:read` - List and read CalDAV tasks
- `todo:write` - Create, update, and delete CalDAV tasks
- `todo.read` - List and read CalDAV tasks
- `todo.write` - Create, update, and delete CalDAV tasks
#### Contacts App
- `contacts:read` - List address books and read contacts (CardDAV)
- `contacts:write` - Create, update, and delete address books and contacts
- `contacts.read` - List address books and read contacts (CardDAV)
- `contacts.write` - Create, update, and delete address books and contacts
#### Cookbook App
- `cookbook:read` - Read recipes, search recipes
- `cookbook:write` - Create, update, and delete recipes
- `cookbook.read` - Read recipes, search recipes
- `cookbook.write` - Create, update, and delete recipes
#### Deck App
- `deck:read` - List boards, stacks, cards, and labels
- `deck:write` - Create, update, and delete boards, stacks, cards, and labels
- `deck.read` - List boards, stacks, cards, and labels
- `deck.write` - Create, update, and delete boards, stacks, cards, and labels
#### Tables App
- `tables:read` - List tables and read rows
- `tables:write` - Create, update, and delete rows in tables
- `tables.read` - List tables and read rows
- `tables.write` - Create, update, and delete rows in tables
#### Files (WebDAV)
- `files:read` - List files, read file contents, search files
- `files:write` - Upload, update, move, copy, and delete files
- `files.read` - List files, read file contents, search files
- `files.write` - Upload, update, move, copy, and delete files
#### Sharing
- `sharing:read` - List shares and read share information
- `sharing:write` - Create, update, and delete shares
- `sharing.read` - List shares and read share information
- `sharing.write` - Create, update, and delete shares
#### Semantic Search (Multi-App Vector Database)
- `semantic:read` - Query vector database, perform semantic search across all indexed Nextcloud apps (notes, calendar, deck, files, contacts)
- `semantic:write` - Enable/disable background vector synchronization, manage indexing settings
- `semantic.read` - Query vector database, perform semantic search across all indexed Nextcloud apps (notes, calendar, deck, files, contacts)
- `semantic.write` - Enable/disable background vector synchronization, manage indexing settings
> **Note**: Semantic search scopes provide access to the vector database that indexes content across **all** Nextcloud apps. Unlike app-specific scopes (e.g., `notes:read`), semantic scopes grant cross-app search capabilities powered by background vector synchronization (ADR-007).
> **Note**: Semantic search scopes provide access to the vector database that indexes content across **all** Nextcloud apps. Unlike app-specific scopes (e.g., `notes.read`), semantic scopes grant cross-app search capabilities powered by background vector synchronization (ADR-007).
### Scope Discovery
@@ -652,7 +652,7 @@ curl http://localhost:8000/.well-known/oauth-protected-resource/mcp
# Response includes dynamically discovered scopes
{
"resource": "http://localhost:8000/mcp",
"scopes_supported": ["openid", "profile", "email", "notes:read", ...],
"scopes_supported": ["openid", "profile", "email", "notes.read", ...],
"authorization_servers": ["https://nextcloud.example.com"],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"]
@@ -669,7 +669,7 @@ Tools are decorated with `@require_scopes()` to declare their required permissio
from nextcloud_mcp_server.auth import require_scopes
@mcp.tool()
@require_scopes("notes:read")
@require_scopes("notes.read")
async def nc_notes_get_note(ctx: Context, note_id: int):
"""Get a specific note by ID"""
# Implementation
@@ -681,7 +681,7 @@ During OAuth client registration (dynamic or manual), clients request a set of s
**Environment Variable**:
```bash
NEXTCLOUD_OIDC_SCOPES="openid profile email notes:read notes:write calendar:read calendar:write ..."
NEXTCLOUD_OIDC_SCOPES="openid profile email notes.read notes.write calendar.read calendar.write ..."
```
**Default**: All supported scopes (recommended for development)
@@ -695,7 +695,7 @@ The server supports OAuth step-up authorization (RFC 8693). If a user attempts t
1. Tool returns `403 Forbidden` with `InsufficientScopeError`
2. Response includes `WWW-Authenticate` header listing missing scopes:
```
WWW-Authenticate: Bearer error="insufficient_scope", scope="notes:write", resource_metadata="..."
WWW-Authenticate: Bearer error="insufficient_scope", scope="notes.write", resource_metadata="..."
```
3. Client can re-authorize with additional scopes
@@ -708,9 +708,9 @@ All scope enforcement happens at two levels:
**Example**:
```python
# User token has: ["openid", "profile", "email", "notes:read"]
# User token has: ["openid", "profile", "email", "notes.read"]
# They will see: 4 read-only notes tools
# They will NOT see: 3 write notes tools (notes:write required)
# They will NOT see: 3 write notes tools (notes.write required)
# Attempting to call a write tool returns 403 Forbidden
```
+10 -10
View File
@@ -412,7 +412,7 @@ http://localhost:8000/oauth/callback
**Symptoms**:
- MCP client (e.g., Claude Code) successfully connects via OAuth
- Only Notes tools are available (7 tools instead of 90+)
- Token scopes show only `mcp:notes:read` and `mcp:notes:write`
- Token scopes show only `mcp:notes.read` and `mcp:notes.write`
**Cause**: During the OAuth consent flow, the user only granted access to Notes scopes, or the client only requested those scopes.
@@ -449,7 +449,7 @@ When reconnecting, you'll see a consent screen listing all available scopes. Mak
```bash
# Update allowed scopes for an existing client
php occ oidc:update <client_id> \
--allowed-scopes "openid profile email mcp:notes:read mcp:notes:write mcp:calendar:read mcp:calendar:write mcp:contacts:read mcp:contacts:write mcp:cookbook:read mcp:cookbook:write mcp:deck:read mcp:deck:write mcp:tables:read mcp:tables:write mcp:files:read mcp:files:write mcp:sharing:read mcp:sharing:write"
--allowed-scopes "openid profile email mcp:notes.read mcp:notes.write mcp:calendar.read mcp:calendar.write mcp:contacts.read mcp:contacts.write mcp:cookbook.read mcp:cookbook.write mcp:deck.read mcp:deck.write mcp:tables.read mcp:tables.write mcp:files.read mcp:files.write mcp:sharing.read mcp:sharing.write"
# User will need to reconnect to get new token with updated scopes
```
@@ -463,14 +463,14 @@ curl http://localhost:8001/.well-known/oauth-protected-resource | jq '.scopes_su
# Should show all 16 scope categories:
# - openid
# - mcp:notes:read, mcp:notes:write
# - mcp:calendar:read, mcp:calendar:write
# - mcp:contacts:read, mcp:contacts:write
# - mcp:cookbook:read, mcp:cookbook:write
# - mcp:deck:read, mcp:deck:write
# - mcp:tables:read, mcp:tables:write
# - mcp:files:read, mcp:files:write
# - mcp:sharing:read, mcp:sharing:write
# - mcp:notes.read, mcp:notes.write
# - mcp:calendar.read, mcp:calendar.write
# - mcp:contacts.read, mcp:contacts.write
# - mcp:cookbook.read, mcp:cookbook.write
# - mcp:deck.read, mcp:deck.write
# - mcp:tables.read, mcp:tables.write
# - mcp:files.read, mcp:files.write
# - mcp:sharing.read, mcp:sharing.write
```
**Understanding Scope Filtering**:
+6 -6
View File
@@ -298,7 +298,7 @@ sequenceDiagram
participant NC as Nextcloud API
User->>MCP: nc_semantic_search("machine learning")
MCP->>MCP: Check OAuth scope<br/>(semantic:read)
MCP->>MCP: Check OAuth scope<br/>(semantic.read)
MCP->>Ollama: Generate query embedding
Ollama-->>MCP: Query vector (768-dim)
MCP->>Qdrant: Search similar vectors<br/>(filter: user_id=alice)
@@ -319,7 +319,7 @@ sequenceDiagram
### Dual-Phase Authorization
**Phase 1: OAuth Scope Check**
- Verify user has `semantic:read` scope
- Verify user has `semantic.read` scope
- Rejects unauthorized users immediately
**Phase 2: Per-Document Verification**
@@ -419,19 +419,19 @@ except Exception as e:
### OAuth Scopes
**`semantic:read`** - Search permission
**`semantic.read`** - Search permission
- Allows using `nc_semantic_search` and `nc_semantic_search_answer` tools
- Does NOT grant access to documents (verified via app APIs)
- Required for any semantic search operation
**`semantic:write`** - Sync control permission
**`semantic.write`** - Sync control permission
- Allows enabling/disabling background sync (`provision_vector_sync`, `deprovision_vector_sync`)
- Controls whether user's documents are indexed
- Currently not implemented in OAuth mode (BasicAuth only)
### Dual-Phase Authorization Pattern
**Phase 1: Scope Check** (semantic:read)
**Phase 1: Scope Check** (semantic.read)
- Verifies user authorized to search
- Prevents unauthorized vector database access
@@ -482,7 +482,7 @@ except Exception as e:
- Credentials stored in `.env` file (secure server access required)
**OAuth:**
- Client authenticates with `semantic:read` scope
- Client authenticates with `semantic.read` scope
- User must explicitly provision offline access (future: `provision_vector_sync` tool)
- Background sync only works for users who provisioned access
- More secure: tokens expire, user controls access
+3 -3
View File
@@ -190,7 +190,7 @@ docker compose logs -f mcp-oauth
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:8001/oauth/callback&scope=openid+profile+email+mcp:notes:read+mcp:notes:write&state=test123
http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:8001/oauth/callback&scope=openid+profile+email+mcp:notes.read+mcp:notes.write&state=test123
```
### 3. Expected Behavior
@@ -203,8 +203,8 @@ http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type
- ✓ Basic authentication (openid) - required, cannot deselect
- ✓ Profile information (profile)
- ✓ Email address (email)
- ✓ mcp:notes:read (custom scope, shown as-is)
- ✓ mcp:notes:write (custom scope, shown as-is)
- ✓ mcp:notes.read (custom scope, shown as-is)
- ✓ mcp:notes.write (custom scope, shown as-is)
- "Allow" and "Deny" buttons
3. User selects scopes and clicks "Allow"
4. Authorization proceeds with selected scopes