refactor: change OAuth scope separator from colon to dot for IDP compatibility

Many identity providers (AWS Cognito, Okta, Azure AD) reject or mishandle
colons in OAuth scope names. This migrates all custom scopes from
`resource:action` to `resource.action` format (e.g., `notes:read` →
`notes.read`), which is universally accepted and aligns with industry
conventions (Microsoft, Google).

Includes Alembic migration 004 for stored scope strings and ADR-024
documenting the rationale and RFC references.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-04-07 10:07:02 +02:00
co-authored by Claude Opus 4.6
parent 899b9c7191
commit 29fd0486c9
44 changed files with 724 additions and 520 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