Merge remote-tracking branch 'origin/master' into docs/adr-024-dynaconf-config-management
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user