Compare commits

...

3 Commits

Author SHA1 Message Date
Chris Coutinho 49f9cead69 docs: Update OAuth architecture 2025-10-25 21:54:30 +02:00
Chris Coutinho 415b1c901b docs: Parse available scopes from registered tools and update docs 2025-10-25 21:16:40 +02:00
Chris Coutinho 90b96a8afe docs: Remove old [skip ci] 2025-10-25 20:43:12 +02:00
10 changed files with 642 additions and 983 deletions
-250
View File
@@ -1,250 +0,0 @@
# DCR Client Deletion Investigation
## Summary
**RESOLVED** - As of 2025-10-24, Dynamic Client Registration (DCR) via RFC 7591 **and** RFC 7592 client deletion now work correctly in Nextcloud's OIDC server!
**Historical Note**: This document was originally created to investigate DCR deletion failures. The issue has been resolved by merging two feature branches (`feature/user-consent-complete` and `feature/dcr-jwt-scopes`) that implement RFC 7592 support.
## Resolution Summary (2025-10-24)
### What Now Works ✅
- **Client Registration** (RFC 7591): Successfully creates OAuth clients with custom scopes and token types
- **Registration Access Token**: ✅ Now included in registration response per RFC 7592
- **Registration Client URI**: ✅ Now included in registration response per RFC 7592
- **Client Deletion** (RFC 7592): ✅ Now works with Bearer token authentication
- **Token Acquisition**: Registered clients can obtain access tokens via authorization code flow
- **API Access**: Tokens work correctly for accessing Nextcloud APIs
### Test Evidence
The test `test_new_dcr_registration_includes_access_token` in `tests/server/oauth/test_dcr_new_implementation.py` confirms:
**Registration Response:**
```json
{
"client_id": "wynkPur15ibby0Ma2FUOMyv4JdmtxqlRepvGmERrE36RYmquuExma1srAgDG1rKZ",
"client_secret": "agaZU3WdffOy4o6TS4vZ...",
"registration_access_token": "uKycqheAzw2UMZUL58Ir...",
"registration_client_uri": "http://localhost:8080/apps/oidc/register/wynkPur15ibby0Ma2FUOMyv4JdmtxqlRepvGmERrE36RYmquuExma1srAgDG1rKZ",
...
}
```
**Deletion Test:**
- Endpoint: `DELETE /apps/oidc/register/{client_id}`
- Authentication: `Authorization: Bearer {registration_access_token}`
- Response: **204 No Content**
### Implementation Details
The resolution required:
1. Merging `feature/user-consent-complete` and `feature/dcr-jwt-scopes` branches
2. Adding missing classes to composer autoload files:
- `OCA\OIDCIdentityProvider\Db\RegistrationToken`
- `OCA\OIDCIdentityProvider\Db\RegistrationTokenMapper`
- `OCA\OIDCIdentityProvider\Service\RegistrationTokenService`
3. Fixing method calls in `DynamicRegistrationController.php`:
- Changed `findByClientId()` to `getByClientId()` for RedirectUriMapper
- Removed logout redirect URI deletion (not client-specific in schema)
4. Database migration applied automatically (`oc_oidc_reg_tokens` table created)
### Files Modified
- `third_party/oidc/composer/composer/autoload_classmap.php` - Added 3 new class mappings
- `third_party/oidc/composer/composer/autoload_static.php` - Added 3 new class mappings
- `third_party/oidc/lib/Controller/DynamicRegistrationController.php` - Fixed deletion logic
- `third_party/oidc/lib/Db/LogoutRedirectUriMapper.php` - Added `deleteByClientId()` method
## Technical Details
### Registration Response Analysis
When registering a client via POST to `/apps/oidc/register`, the response includes:
```json
{
"client_name": "DCR Lifecycle Test Client",
"client_id": "eVdV1obTHUhtQiBOLnDcOucZE3sQA6J7JgzsDFsnpgzLkWSNEPXHJbpSfjLUU5ot",
"client_secret": "iqNeH5inrdTPh6hYGOmvlML7SWqHPHpMZp9CQlNHNnKGf6VZ8pSeaSC1EBrDRmyd",
"redirect_uris": ["http://localhost:8081"],
"token_endpoint_auth_method": "client_secret_post",
"response_types": ["code"],
"grant_types": ["authorization_code"],
"id_token_signed_response_alg": "RS256",
"application_type": "web",
"client_id_issued_at": 1761286688,
"client_secret_expires_at": 1761290288,
"scope": "openid profile email notes:read",
"token_type": "Bearer"
}
```
**Missing:** `registration_access_token` and `registration_client_uri`
### Deletion Attempt Analysis
Attempting DELETE to `/apps/oidc/register/{client_id}` with various authentication methods:
#### Method 1: HTTP Basic Auth
- **Authentication**: HTTP Basic Auth with `client_id` as username, `client_secret` as password
- **Response**: 401 Unauthorized
- **Response Body**: `{"message":""}`
#### Method 2: Credentials in JSON Body
- **Authentication**: JSON body with `client_id` and `client_secret`
- **Response**: N/A (httpx.AsyncClient.delete() doesn't support `json` parameter)
#### Method 3: Credentials in Query Parameters
- **Authentication**: Query params `?client_id=...&client_secret=...`
- **Response**: 500 Internal Server Error (server-side exception when parsing query params)
#### Method 4: No Authentication (Baseline)
- **Authentication**: None
- **Response**: 401 Unauthorized
- **Response Body**: `{"error":"invalid_client","error_description":"Client authentication failed."}`
**Conclusion**: The 401 error occurs with HTTP Basic Auth (the standard RFC 7592 method). Query parameters cause a 500 error (not supported). No authentication returns 401 as expected.
### RFC 7592 Requirements (Not Met)
According to [RFC 7592 Section 3](https://www.rfc-editor.org/rfc/rfc7592.html#section-3), the registration endpoint MUST return:
1. **`registration_access_token`**: A token for subsequent management operations (read, update, delete)
2. **`registration_client_uri`**: The URI for managing this client
The client delete request should then use:
```http
DELETE /apps/oidc/register/{client_id}
Authorization: Bearer {registration_access_token}
```
## Root Cause Analysis
### Possible Causes
1. **Nextcloud OIDC Server Implementation Gap**
- The OIDC server (likely based on third-party library) may not fully implement RFC 7592
- Registration (RFC 7591) is implemented, but management operations (RFC 7592) are not
2. **Middleware Blocking**
- Nextcloud middleware may be blocking unauthenticated DELETE requests to `/apps/oidc/*`
- The 401 error suggests authentication is being checked but failing
3. **Missing Feature**
- Client deletion may simply not be implemented in the current OIDC app version
- The endpoint exists but returns 401 regardless of credentials
## Impact on Test Fixtures
### Current Fixture Behavior
The `shared_oauth_client_credentials` and `shared_jwt_oauth_client_credentials` fixtures in `tests/conftest.py` (lines 947-1112) attempt to clean up registered clients using:
```python
success = await delete_client(
nextcloud_url=nextcloud_host,
client_id=client_id,
client_secret=client_secret,
)
```
This cleanup **always fails** (returns `False`) due to the 401 error, but the failure is handled gracefully with a warning:
```python
except Exception as e:
logger.warning(
f"Error cleaning up shared OAuth client {client_id[:16]}...: {e}"
)
```
### Consequences
1. **OAuth Clients Accumulate**: Every test session registers 2 OAuth clients that are never deleted
2. **No Functional Impact**: Tests continue to work because:
- Clients have 1-hour expiration (`client_secret_expires_at`)
- New clients are registered for each session
- Old clients expire automatically
3. **Database Bloat**: Over time, the `oc_oauth2_clients` table may accumulate expired clients
## Recommendations
### Short Term (Current Approach)
1. **Keep Current Warning-Based Approach**: The fixtures already handle deletion failure gracefully
2. **Document Expected Behavior**: Add comments explaining that deletion is expected to fail
3. **Accept Client Accumulation**: Rely on automatic expiration (1 hour)
### Long Term (If DCR Deletion Needed)
1. **Check Nextcloud OIDC App Version**: Verify if newer versions support RFC 7592 deletion
2. **File Bug Report**: Report missing `registration_access_token` to Nextcloud OIDC project
3. **Alternative Cleanup**: Use Nextcloud admin API to delete OAuth clients directly
- Requires admin credentials
- Bypass OIDC app's DCR endpoint
- Example: `occ oauth:clients:delete {client_id}`
### Recommended Fixture Update
```python
@pytest.fixture(scope="session")
async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server):
"""
... existing docstring ...
Note:
Client deletion via RFC 7592 is not supported by Nextcloud OIDC server
(missing registration_access_token). Clients will expire after 1 hour
automatically. Manual cleanup via admin API may be needed in production.
"""
# ... registration code ...
yield (...)
# Cleanup: Attempt deletion (expected to fail due to RFC 7592 limitation)
try:
logger.info(f"Attempting cleanup of shared OAuth client: {client_id[:16]}...")
success = await delete_client(
nextcloud_url=nextcloud_host,
client_id=client_id,
client_secret=client_secret,
)
if success:
logger.info(f"✅ Successfully deleted client: {client_id[:16]}...")
else:
logger.warning(
f"⚠️ Client deletion not supported by Nextcloud OIDC server. "
f"Client {client_id[:16]}... will expire automatically in 1 hour."
)
except Exception as e:
logger.warning(
f"⚠️ Error during client cleanup (expected): {e}. "
f"Client will expire automatically."
)
```
## Test File Status
Created `tests/server/oauth/test_dcr_lifecycle.py` with 4 comprehensive tests:
1.`test_dcr_register_and_delete_lifecycle` - Documents full lifecycle (fails at deletion step as expected)
2.`test_dcr_delete_with_wrong_credentials` - Verifies authentication behavior
3.`test_dcr_delete_nonexistent_client` - Tests error handling
4.`test_dcr_deletion_is_idempotent` - Tests repeated deletion attempts
**All tests currently fail at the deletion step**, which is expected given the RFC 7592 limitation.
## Next Steps
1. **Update fixture comments** to document expected deletion failure
2. **Mark deletion tests as expected failures** using `@pytest.mark.xfail`
3. **Consider removing deletion tests** if they don't provide value (since deletion doesn't work)
4. **Investigate Nextcloud admin API** as alternative cleanup method for CI/CD environments
5. **Monitor Nextcloud OIDC app updates** for RFC 7592 support
## References
- [RFC 7591 - OAuth 2.0 Dynamic Client Registration Protocol](https://www.rfc-editor.org/rfc/rfc7591.html)
- [RFC 7592 - OAuth 2.0 Dynamic Client Registration Management Protocol](https://www.rfc-editor.org/rfc/rfc7592.html)
- Nextcloud OIDC App: Check `docker-compose.yml` for app location
- Test Evidence: `tests/server/oauth/test_dcr_lifecycle.py` line 254-256 (401 response details)
-288
View File
@@ -1,288 +0,0 @@
# Token Introspection Authorization Verification
**Date**: 2025-10-23
**Feature Branch**: `feature/opaque-introspection`
**Commit**: 52f417d - "Restrict introspection endpoint to audience/resource server"
## Summary
The OIDC app's token introspection endpoint (`/apps/oidc/introspect`) has been successfully verified to implement proper authorization controls. The implementation ensures that only authorized clients can introspect tokens, preventing unauthorized access to token information.
## Authorization Rules Implemented
The introspection endpoint implements a **two-factor authorization check** (IntrospectionController.php:193-238):
### 1. Client Must Be the Resource Server (Audience)
- **Rule**: `tokenResource === requestingClientId`
- **Purpose**: Allows resource servers to validate tokens intended for them
- **Example**: If a token has `resource=api.example.com`, then `api.example.com` can introspect it
### 2. OR Client Must Own the Token
- **Rule**: `tokenClient === requestingClientId`
- **Purpose**: Allows clients to introspect their own tokens
- **Example**: If client A issued a token, client A can introspect it
### 3. Unauthorized Requests Return `{active: false}`
- **Security**: RFC 7662 compliant - doesn't reveal token existence
- **Protection**: Prevents clients from discovering or validating tokens they don't own
## Client Authentication Required
All introspection requests **must** include client credentials (IntrospectionController.php:125-136):
- **Supported Methods**:
- HTTP Basic Authentication: `Authorization: Basic base64(client_id:client_secret)`
- POST body parameters: `client_id` and `client_secret`
- **Failed Authentication**: Returns `401 UNAUTHORIZED` with error response
## Test Coverage
### PHP Unit Tests (OIDC App)
**Location**: `third_party/oidc/tests/Unit/Controller/IntrospectionControllerTest.php`
**Coverage** (✅ All tests pass in CI):
1.**testInvalidClientCredentials** - Verifies 401 when credentials are missing
2.**testMissingTokenParameter** - Verifies 400 when token parameter is missing
3.**testTokenNotFound** - Verifies `{active: false}` for unknown tokens
4.**testExpiredToken** - Verifies `{active: false}` for expired tokens
5.**testValidTokenIntrospection** - Verifies client can introspect its own token
6.**testTokenIntrospectionAsResourceServer** - Verifies resource server can introspect token
7.**testTokenIntrospectionDeniedWrongAudience** - Verifies unauthorized client gets `{active: false}`
8.**testClientAuthenticationWithPostBody** - Verifies POST body authentication works
### Python Integration Tests (MCP Server)
**Location**: `tests/server/test_introspection_authorization.py`
**Test Results** (Run on 2025-10-23):
```
tests/server/test_introspection_authorization.py::test_introspection_requires_client_authentication PASSED
tests/server/test_introspection_authorization.py::test_client_cannot_introspect_other_clients_tokens SKIPPED
tests/server/test_introspection_authorization.py::test_introspection_with_resource_parameter SKIPPED
tests/server/test_introspection_authorization.py::test_introspection_returns_inactive_for_invalid_token PASSED
2 passed, 2 skipped in 73.43s
```
**Coverage**:
1.**test_introspection_requires_client_authentication** - PASSED
- Verifies 401 response when credentials are missing or invalid
- Confirms error responses are properly formatted
2.**test_introspection_returns_inactive_for_invalid_token** - PASSED
- Verifies `{active: false}` response for fake/unknown tokens
- Confirms no additional information is leaked
3. ⏭️ **test_client_cannot_introspect_other_clients_tokens** - SKIPPED
- Requires OAuth token acquisition via playwright (fixture setup)
- Core logic covered by PHP unit test `testTokenIntrospectionDeniedWrongAudience`
4. ⏭️ **test_introspection_with_resource_parameter** - SKIPPED
- Requires OAuth token acquisition with resource parameter
- Core logic covered by PHP unit test `testTokenIntrospectionAsResourceServer`
**Note**: The playwright-based tests are infrastructure for future end-to-end testing. The authorization logic is comprehensively verified by the passing PHP unit tests in CI.
## Security Guarantees
### ✅ Authentication Required
- All introspection requests must provide valid client credentials
- Invalid or missing credentials result in 401 UNAUTHORIZED
- Prevents anonymous token introspection
### ✅ Authorization Enforced
- Clients can only introspect:
1. Tokens they own (issued to them)
2. Tokens where they are the designated resource server
- Prevents cross-client token inspection
### ✅ Information Disclosure Prevention
- Unauthorized introspection returns `{active: false}`
- Same response as "token not found" (RFC 7662 Section 2.2)
- Prevents enumeration attacks
### ✅ Token Metadata Protection
- Token details (scopes, user, expiration) only revealed to authorized clients
- Protects user privacy and token information
## Implementation Details
### Token Resource Field
**Set During Token Generation** (TokenGenerationRequestListener.php:88-91):
```php
if (!isset($resource) || trim($resource)==='') {
$resource = (string)$this->appConfig->getAppValueString(
Application::APP_CONFIG_DEFAULT_RESOURCE_IDENTIFIER,
Application::DEFAULT_RESOURCE_IDENTIFIER
);
}
$accessToken->setResource(substr($resource, 0, 2000));
```
- The `resource` parameter can be specified in OAuth requests
- Falls back to default resource identifier from app config
- Stored in the `oc_oauth_access_tokens` table
### Authorization Check Logic
**IntrospectionController.php:193-238**:
```php
$tokenResource = $accessToken->getResource();
$requestingClientId = $client->getClientIdentifier();
$isAuthorized = false;
// Check if requesting client is the resource server
if (!empty($tokenResource) && $tokenResource === $requestingClientId) {
$isAuthorized = true;
$this->logger->info('Token introspection authorized: requesting client is token audience');
}
// OR check if requesting client owns the token
elseif ($tokenClient->getClientIdentifier() === $requestingClientId) {
$isAuthorized = true;
$this->logger->info('Token introspection authorized: requesting client owns the token');
}
if (!$isAuthorized) {
$this->logger->warning('Token introspection denied: requesting client not authorized');
return new JSONResponse(['active' => false]);
}
```
## Usage in MCP Server
The MCP server uses introspection for opaque token validation:
**Location**: `nextcloud_mcp_server/auth/token_verifier.py:236-335`
### Token Verification Flow
1. **JWT Verification** (if token is JWT format)
- Validates signature using JWKS
- Extracts scopes from JWT payload
- No introspection needed
2. **Introspection Fallback** (for opaque tokens)
- Calls introspection endpoint with client credentials
- Retrieves token metadata (user, scopes, expiration)
- Caches successful responses
3. **Userinfo Fallback** (if introspection unavailable)
- Validates token via userinfo endpoint
- Backward compatibility
### Introspection Request Example
```python
response = await self._client.post(
self.introspection_uri,
data={"token": token},
auth=(self.client_id, self.client_secret),
)
```
The MCP server authenticates as a specific OAuth client, which means:
- It can introspect tokens issued to it (as owner)
- It can introspect tokens where it is the resource server
- It cannot introspect tokens belonging to other clients
## Verification Results
### ✅ Client Authentication Verified
- Integration tests confirm 401 for missing/invalid credentials
- Error responses properly formatted
### ✅ Invalid Token Handling Verified
- Returns `{active: false}` for unknown tokens
- No information leakage
### ✅ Authorization Logic Verified
- PHP unit tests (passing in CI) cover all authorization scenarios:
- ✅ Client can introspect its own tokens
- ✅ Resource server can introspect tokens intended for it
- ✅ Unauthorized client cannot introspect other clients' tokens
### ✅ Opaque Token Support Verified
- Tokens have `resource` field set during generation
- Resource field is checked during introspection authorization
## Recommendations
### Production Deployment ✅
The introspection endpoint is **ready for production use** with proper security controls:
1. **Authentication**: Required for all requests
2. **Authorization**: Properly enforced based on token ownership and audience
3. **Privacy**: Token information protected from unauthorized access
4. **Compliance**: RFC 7662 compliant implementation
### Monitoring Recommendations
The implementation includes comprehensive logging:
```php
// Successful introspection
$this->logger->info('Token introspection successful', [
'requesting_client' => $client->getClientIdentifier(),
'token_owner_client' => $tokenClient->getClientIdentifier(),
'user_id' => $accessToken->getUserId(),
'scopes' => $accessToken->getScope(),
'token_resource' => $tokenResource
]);
// Denied introspection
$this->logger->warning('Token introspection denied: requesting client not authorized', [
'requesting_client' => $requestingClientId,
'token_resource' => $tokenResource,
'token_owner_client' => $tokenClient->getClientIdentifier()
]);
```
**Recommended Monitoring**:
- Track introspection denial rates
- Alert on unusual patterns (many denials from same client)
- Monitor for potential enumeration attempts
## Known Issues
### OAuth Session Management for New Clients
**Issue**: When creating brand-new OAuth clients and immediately using them, the OIDC app's consent screen session management has a bug where OAuth parameters are lost during the redirect flow:
1. `/apps/oidc/authorize?params...` → 303 redirect to login
2. After login → `/apps/oidc/redirect` (loads, 200 OK)
3. JavaScript redirects to `/apps/oidc/authorize` (NO params!) → Consent screen can't render
4. Flow times out
**Workaround**: Pre-authorized/shared OAuth clients work correctly (consent screen is skipped).
**Impact on Verification**: This is a **test infrastructure issue**, not an introspection authorization issue. The authorization logic is comprehensively verified by:
- PHP unit tests (8/8 passing in CI)
- Integration tests with pre-authorized clients
- Code review
## Conclusion
The introspection endpoint implementation has been thoroughly verified:
1.**Client authentication is required** - 401 for invalid/missing credentials
2.**Resource server authorization works** - Can introspect tokens with matching resource field
3.**Client ownership authorization works** - Can introspect own tokens
4.**Cross-client introspection blocked** - Returns `{active: false}` for unauthorized requests
5.**Opaque tokens properly supported** - Resource field populated and validated
The implementation follows RFC 7662 best practices and provides strong security guarantees against unauthorized token introspection.
**The OAuth session bug affects test infrastructure only, not the introspection endpoint security.**
---
**Verified By**: Claude Code
**Verification Method**: Code review + PHP unit test analysis (8/8 passing) + Integration tests
**Status**: ✅ VERIFIED - Ready for production
+11 -9
View File
@@ -186,18 +186,20 @@ The server exposes Nextcloud functionality through MCP tools (for actions) and r
The server provides 90+ tools across 8 Nextcloud apps. When using OAuth, tools are dynamically filtered based on your granted scopes.
For a complete list of all supported OAuth scopes and their descriptions, see [OAuth Scopes Documentation](docs/oauth-architecture.md#oauth-scopes).
#### Available Tool Categories
| App | Tools | Read Scope | Write Scope | Operations |
|-----|-------|-----------|-------------|------------|
| **Notes** | 7 | `mcp:notes:read` | `mcp:notes:write` | Create, read, update, delete, search notes |
| **Calendar** | 20+ | `mcp:calendar:read` | `mcp:calendar:write` | Events, todos (tasks), calendars, recurring events, attendees |
| **Contacts** | 8 | `mcp:contacts:read` | `mcp:contacts:write` | Create, read, update, delete contacts and address books |
| **Files (WebDAV)** | 12 | `mcp:files:read` | `mcp:files:write` | List, read, upload, delete, move files; **OCR/document processing** |
| **Deck** | 15 | `mcp:deck:read` | `mcp:deck:write` | Boards, stacks, cards, labels, assignments |
| **Cookbook** | 13 | `mcp:cookbook:read` | `mcp:cookbook:write` | Recipes, import from URLs, search, categories |
| **Tables** | 5 | `mcp:tables:read` | `mcp:tables:write` | Row operations on Nextcloud Tables |
| **Sharing** | 10+ | `mcp:sharing:read` | `mcp:sharing:write` | Create, manage, delete shares |
| **Notes** | 7 | `notes:read` | `notes:write` | Create, read, update, delete, search notes |
| **Calendar** | 20+ | `calendar:read` `todo:read` | `calendar:write` `todo:write` | Events, todos (tasks), calendars, recurring events, attendees |
| **Contacts** | 8 | `contacts:read` | `contacts:write` | Create, read, update, delete contacts and address books |
| **Files (WebDAV)** | 12 | `files:read` | `files:write` | List, read, upload, delete, move files; **OCR/document processing** |
| **Deck** | 15 | `deck:read` | `deck:write` | Boards, stacks, cards, labels, assignments |
| **Cookbook** | 13 | `cookbook:read` | `cookbook:write` | Recipes, import from URLs, search, categories |
| **Tables** | 5 | `tables:read` | `tables:write` | Row operations on Nextcloud Tables |
| **Sharing** | 10+ | `sharing:read` | `sharing:write` | Create, manage, delete shares |
#### Document Processing (Optional)
@@ -257,7 +259,7 @@ See [env.sample](env.sample) for complete configuration options.
- And 80+ more...
> [!TIP]
> **OAuth Scope Filtering**: When connecting via OAuth, MCP clients will only see tools for which you've granted access. For example, granting only `mcp:notes:read` and `mcp:notes:write` will show 7 Notes tools instead of all 90+ tools. See [OAuth Troubleshooting - Limited Scopes](docs/oauth-troubleshooting.md#limited-scopes---only-seeing-notes-tools) if you're only seeing a subset of tools.
> **OAuth Scope Filtering**: When connecting via OAuth, MCP clients will only see tools for which you've granted access. For example, granting only `notes:read` and `notes:write` will show 7 Notes tools instead of all 90+ tools. See [OAuth Scopes Documentation](docs/oauth-architecture.md#oauth-scopes) for the complete scope reference, or [OAuth Troubleshooting - Limited Scopes](docs/oauth-troubleshooting.md#limited-scopes---only-seeing-notes-tools) if you're only seeing a subset of tools.
>
> **Known Issue**: Claude Code and some other MCP clients may only request/grant Notes scopes during initial connection. Track progress at [#234](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/234).
-99
View File
@@ -1,99 +0,0 @@
# JWT Scope Truncation Fix - Summary
## Problem
When using JWT tokens with many scopes, the `scope` claim in the JWT payload was being truncated, causing only 32 out of 90 tools to be visible to the MCP client.
## Root Cause
Multiple hardcoded string length limits in the Nextcloud OIDC app code:
1. **Database schema**: `oc_oidc_access_tokens.scope` column was `VARCHAR(128)` - too small for 247-character scope string
2. **Code truncation in TokenGenerationRequestListener.php**: `substr($scopes, 0, 128)` on line 83
3. **Code truncation in LoginRedirectorController.php**: `substr($scope, 0, 128)` on line 437
4. **Client scope limits**: Multiple places truncating `allowed_scopes` to 255 characters
## Solution
Fixed all truncation points to support up to 512 characters:
### Database Migration (Version0015Date20251123100100.php)
```php
// Increase oidc_clients.allowed_scopes from 256 to 512
$table->changeColumn('allowed_scopes', [
'notnull' => false,
'length' => 512,
]);
// Increase oidc_access_tokens.scope from 128 to 512
$table->changeColumn('scope', [
'notnull' => true,
'length' => 512,
]);
```
### Code Changes
1. **TokenGenerationRequestListener.php** line 83: `128``512`
2. **LoginRedirectorController.php** line 437: `128``512`
3. **SettingsController.php** line 232: `255``511`
4. **DynamicRegistrationController.php** lines 182, 420: `255``511`
### Application Changes
1. **Added todo scopes** to default scope lists:
- `nextcloud_mcp_server/app.py`
- `tests/conftest.py` (DEFAULT_FULL_SCOPES, DEFAULT_READ_SCOPES, DEFAULT_WRITE_SCOPES)
2. **Skipped obsolete tests**:
- `test_scope_classification` - Script no longer exists
- `test_all_tools_classified` - Script no longer exists
## Verification
### Before Fix
- Scope length in database: **128 characters** (truncated)
- Tools visible: **32 out of 90** (35%)
- Missing scopes: `deck`, `tables`, `files`, `sharing`, partial `cookbook:write`
### After Fix
- Scope length in database: **247 characters** (full string)
- Tools visible: **90 out of 90** (100%)
- All scopes present and complete
### Test Results
```bash
$ uv run pytest tests/server/test_scope_authorization.py -v
===== 13 passed, 2 skipped in 22.11s =====
```
All scope authorization tests pass, including:
- ✅ Full access token shows all 90 tools
- ✅ Read-only token filters write tools
- ✅ Write-only token filters read tools
- ✅ JWT consent scenarios work correctly
- ✅ PRM endpoint lists all scopes
## Files Modified
### OIDC App (third_party/oidc/)
- `lib/Migration/Version0015Date20251123100100.php` - Database schema migration
- `lib/Listener/TokenGenerationRequestListener.php` - Token generation scope limit
- `lib/Controller/LoginRedirectorController.php` - OAuth flow scope limit
- `lib/Controller/SettingsController.php` - Client settings scope limit
- `lib/Controller/DynamicRegistrationController.php` - DCR scope limits
### MCP Server
- `nextcloud_mcp_server/app.py` - Added todo scopes to default scopes
- `tests/conftest.py` - Added todo scopes to all scope constants
- `tests/server/test_scope_authorization.py` - Skipped obsolete tests
## Impact
- ✅ All 90 MCP tools now accessible with full access token
- ✅ JWT tokens contain complete scope information
- ✅ No more scope truncation at any layer
- ✅ Database supports up to 512 characters (247 currently used, 265-char margin)
- ✅ Future-proof for adding more scopes
## Current Scope String
```
openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write
```
**Length**: 247 characters
**Capacity**: 512 characters
**Margin**: 265 characters (107% headroom)
-43
View File
@@ -1,43 +0,0 @@
# JWT Scope Truncation Issue
## Problem
When using JWT tokens with many scopes, the `scope` claim in the JWT payload gets truncated.
## Evidence
- **allowed_scopes** in `oc_oidc_clients`: 226 characters (ALL scopes present)
```
openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write
```
- **Scopes in JWT token**: Only partial scopes (truncated at ~70 characters)
```
openid email notes:read notes:write cookbook:wri contacts:read calendar:write profile cookbook:read calendar:read contacts:write
```
- **Missing scopes** in JWT:
- `cookbook:write` (appears as `cookbook:wri`)
- `deck:read`, `deck:write`
- `tables:read`, `tables:write`
- `files:read`, `files:write`
- `sharing:read`, `sharing:write`
## Root Cause
The Nextcloud OIDC app has a limitation when generating JWT tokens - the `scope` claim is being truncated, likely due to:
1. Database field size limit in JWT token generation code
2. JWT payload size optimization
3. Hardcoded string length limit
## Solution Options
1. **Increase JWT scope claim size limit** in OIDC app (preferred for your use case)
2. Use opaque tokens instead of JWT tokens (no truncation, but requires introspection)
3. Use scope groups/roles instead of individual scopes
4. Store scopes in a separate JWT claim array format
## Temporary Workaround
For testing, we adjusted the test expectations to match the actual number of tools available with truncated scopes (32 tools instead of 90+).
## Action Required
The OIDC app needs investigation to identify and fix the JWT scope truncation. Check:
- `lib/Controller/LoginController.php` - JWT generation code
- Database schema for JWT-related fields
- JWT library configuration for payload size limits
-155
View File
@@ -1,155 +0,0 @@
# Test Suite Reorganization Summary
## Completed: 2025-10-24
### Changes Implemented
#### 1. Added Test Layer Markers
**File**: `pyproject.toml`
Added four test markers to enable selective test execution:
- `@pytest.mark.unit` - Fast unit tests with mocked dependencies
- `@pytest.mark.integration` - Integration tests requiring Docker containers
- `@pytest.mark.oauth` - OAuth tests requiring Playwright (slowest)
- `@pytest.mark.smoke` - Critical path smoke tests
#### 2. Created Unit Test Suite
**Directory**: `tests/unit/`
Added fast unit tests (~5 seconds total):
- `test_scope_decorator.py` (5 tests) - Scope decorator metadata logic
- `test_response_models.py` (6 tests) - Pydantic model serialization
**Total**: 11 unit tests
#### 3. Reorganized OAuth Tests
**Directory**: `tests/server/oauth/`
Moved all OAuth-related tests to dedicated subdirectory:
- Created `test_oauth_core.py` - consolidated basic OAuth connectivity tests
- Moved 7 OAuth test files to `oauth/` subdirectory
- Fixed relative imports (`..conftest``...conftest`)
**Files**:
- `test_oauth_core.py` - Basic OAuth connectivity & JWT operations (8 tests)
- `test_scope_authorization.py` - Scope filtering & enforcement (16 tests)
- `test_introspection_authorization.py` - Token introspection auth (5 tests)
- `test_dcr_token_type.py` - Dynamic client registration (3 tests)
- `test_oauth_notes_permissions.py` - Notes app permissions (4 tests)
- `test_oauth_deck_permissions.py` - Deck app permissions (4 tests)
- `test_oauth_file_permissions.py` - Files app permissions (4 tests)
**Total**: ~48 OAuth tests
#### 4. Created Smoke Test Suite
**Directory**: `tests/smoke/`
Added critical path validation tests (~30-60 seconds):
- `test_smoke.py` (5 tests) - Essential functionality validation
- MCP connectivity
- Notes CRUD
- Calendar basic operations
- WebDAV basic operations
- OAuth connectivity
#### 5. Updated Documentation
**File**: `CLAUDE.md`
Added comprehensive test execution guide:
```bash
# Fast feedback (unit tests) - ~5 seconds
uv run pytest tests/unit/ -v
# Smoke tests - ~30-60 seconds
uv run pytest -m smoke -v
# Integration without OAuth - ~2-3 minutes
uv run pytest -m "integration and not oauth" -v
# Full suite - ~4-5 minutes
uv run pytest
# OAuth only - ~3 minutes
uv run pytest -m oauth -v
```
Added test structure diagram and marker documentation.
### Test Suite Metrics
**Before Reorganization**:
- ~235 tests, all integration
- No fast feedback loop
- All tests take ~5-7 minutes
- OAuth tests scattered across 9 files
**After Reorganization**:
- 234 tests total (11 unit + 5 smoke + ~218 integration)
- **Fast feedback**: unit tests in ~5 seconds
- **Quick validation**: smoke tests in ~30-60 seconds
- **Focused testing**: integration without OAuth in ~2-3 minutes
- **Full suite**: ~4-5 minutes
- OAuth tests consolidated in dedicated directory
### Feedback Time Improvements
| Test Type | Count | Time | Use Case |
|-----------|-------|------|----------|
| Unit only | 11 | ~5s | Logic changes, model updates |
| Smoke only | 5 | ~30-60s | Critical path validation |
| Integration (no OAuth) | ~172 | ~2-3min | API/MCP changes |
| OAuth only | 48 | ~3min | OAuth feature work |
| **Full suite** | **234** | **~4-5min** | **Pre-commit validation** |
### Key Benefits
1. **Fast Development Feedback**
- Unit tests run in 5 seconds vs. 5+ minutes
- Immediate validation for logic changes
2. **Efficient CI/CD**
- Can run unit tests on every commit
- Run smoke tests for pull requests
- Full suite for merge to main
3. **Better Organization**
- OAuth tests grouped together
- Clear test purpose from directory structure
- Easier to navigate and maintain
4. **Selective Execution**
- Skip slow OAuth tests during development
- Run only relevant test layer
- Faster iteration cycles
### Migration Notes
- **No breaking changes** to existing tests
- All tests continue to work as before
- Legacy commands still supported (`-m integration`, etc.)
- OAuth tests moved to subdirectory, imports updated
- Removed duplicate tests consolidated into `test_oauth_core.py`
### Next Steps (Optional Future Work)
1. **Further Consolidation**: Merge remaining OAuth permission tests
2. **More Unit Tests**: Add unit tests for client initialization, search logic
3. **Client/Server Deduplication**: Reduce overlap between client and server tests
4. **CI Pipeline**: Configure GitHub Actions to run test layers separately
5. **Performance**: Optimize fixtures to reduce setup time
### Commands Reference
```bash
# Development workflow
uv run pytest tests/unit/ -v # Check logic changes
uv run pytest -m smoke -v # Quick validation
uv run pytest -m "integration and not oauth" -v # Full validation without slow tests
# Before committing
uv run pytest # Run everything
# Working on OAuth features
uv run pytest tests/server/oauth/ -v # OAuth tests only
uv run pytest -m oauth --browser firefox --headed -v # Debug OAuth with visible browser
```
+542 -114
View File
@@ -8,166 +8,463 @@ The Nextcloud MCP Server acts as an **OAuth 2.0 Resource Server**, protecting ac
## Architecture Diagram
The complete OAuth flow includes server startup (with DCR), client discovery (with PRM), authorization (with PKCE), and API access phases:
```
═══════════════════════════════════════════════════════════════════════════════════
Phase 0: MCP Server Startup & Client Registration (DCR - RFC 7591)
═══════════════════════════════════════════════════════════════════════════════════
┌──────────────────┐ ┌─────────────────┐
│ MCP Server │ │ Nextcloud │
│ (Resource │ │ (OIDC Provider)│
│ Server) │ │ │
└────────┬─────────┘ └────────┬────────┘
│ │
│ 0a. OIDC Discovery │
├────────────────────────────────────>│
│ GET │
| /.well-known/openid-configuration │
│ │
│ 0b. Discovery response │
│<────────────────────────────────────┤
│ {issuer, endpoints, PKCE methods} │
│ │
│ 0c. Register OAuth client (DCR) │
├────────────────────────────────────>│
│ POST /apps/oidc/register │
│ {client_name, redirect_uris, │
│ scopes, token_type} │
│ │
│ 0d. Client credentials │
│<────────────────────────────────────┤
│ {client_id, client_secret} │
│ → Saved to .nextcloud_oauth_*.json │
│ │
│ ✓ Server ready for connections │
═══════════════════════════════════════════════════════════════════════════════════
Phase 1: Client Connection & Discovery (PRM - RFC 9728)
═══════════════════════════════════════════════════════════════════════════════════
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ │ │ │ │
│ MCP Client │ │ MCP Server │ │ Nextcloud
│ (Claude, │ │ (Resource │ │ Instance
│ etc.) │ │ Server) │ │ │
│ │ │ MCP Server │ │ Nextcloud
│ MCP Client │ │ (Resource │ │ Instance
│ (Claude) │ │ Server) │ │
│ │ │ │ │ │
└──────┬──────┘ └────────┬─────────┘ └────────┬────────┘
│ │ │
│ │
│ 1. Connect to MCP │ │
1a. Connect to MCP │ │
├─────────────────────────────────>│ │
│ │ │
2. Return auth settings │ │
│ (issuer_url, scopes) │ │
1b. Return auth settings │ │
│<─────────────────────────────────┤ │
│ {issuer_url, resource_url} │ │
│ │ │
│ │
│ 3. Start OAuth flow (with PKCE) │ │
├──────────────────────────────────┼────────────────────────────────────>│
│ │ /apps/oidc/authorize │
│ │ │
│ 4. User authenticates in browser│ │
│<─────────────────────────────────┼─────────────────────────────────────┤
│ │ │
│ 5. Authorization code (redirect)│ │
│<─────────────────────────────────┤ │
│ │ │
│ 6. Exchange code for token │ │
├──────────────────────────────────┼────────────────────────────────────>│
│ │ /apps/oidc/token │
│ │ │
│ 7. Access token │ │
│<─────────────────────────────────┼─────────────────────────────────────┤
│ │ │
│ │ │
│ 8. API request with Bearer token│ │
1c. PRM Discovery (RFC 9728) │ │
├─────────────────────────────────>│ │
Authorization: Bearer xxx │ │
GET /.well-known/oauth- │ │
│ protected-resource/mcp │ │
│ │ │
│ 9. Validate token via userinfo
│ ├────────────────────────────────────>│
│ │ /apps/oidc/userinfo │
│ │ │
│ │ 10. User info (token valid) │
│ │<────────────────────────────────────┤
│ │ │
│ │ 11. Nextcloud API request │
│ ├────────────────────────────────────>│
│ │ Authorization: Bearer xxx │
│ │ (Notes, Calendar, etc.) │
│ │ │
│ │ 12. API response │
│ │<────────────────────────────────────┤
│ │ │
│ 13. MCP tool response │ │
1d. PRM response (scopes!) │
│<─────────────────────────────────┤ │
│ {resource, scopes_supported, │ ← Dynamically discovered from │
│ authorization_servers} │ @require_scopes decorators │
│ │ │
═══════════════════════════════════════════════════════════════════════════════════
Phase 2: OAuth Authorization Flow (PKCE - RFC 7636)
═══════════════════════════════════════════════════════════════════════════════════
│ │ │
│ 2a. Generate PKCE challenge │ │
│ code_verifier = random(43-128) │ │
│ code_challenge = SHA256(verif.) │ │
│ │ │
│ 2b. Authorization request │ │
├──────────────────────────────────┼────────────────────────────────────>│
│ /apps/oidc/authorize? │ │
│ client_id=xxx │ │
│ &code_challenge=abc... │ │
│ &code_challenge_method=S256 │ │
│ &scope=openid notes:read ... │ │
│ │ │
│ 2c. User consent page │ │
│<─────────────────────────────────┼─────────────────────────────────────┤
│ (Browser: Select scopes) │ │
│ │ │
│ 2d. User grants scopes │ │
├──────────────────────────────────┼────────────────────────────────────>│
│ │ │
│ 2e. Authorization code redirect │ │
│<─────────────────────────────────┼─────────────────────────────────────┤
│ callback?code=xyz123 │ │
│ │ │
│ 2f. Exchange code for token │ │
├──────────────────────────────────┼────────────────────────────────────>│
│ POST /apps/oidc/token │ │
│ {code, code_verifier, │ ← Validates PKCE challenge │
│ client_id, client_secret} │ │
│ │ │
│ 2g. Access token (JWT/opaque) │ │
│<─────────────────────────────────┼─────────────────────────────────────┤
│ {access_token, token_type, │ │
│ scope: "openid notes:read...") │ ← User's granted scopes │
│ │ │
═══════════════════════════════════════════════════════════════════════════════════
Phase 3: MCP Tool Access (Scope-based Authorization)
═══════════════════════════════════════════════════════════════════════════════════
│ │ │
│ 3a. list_tools request │ │
├─────────────────────────────────>│ │
│ Authorization: Bearer <token> │ │
│ │ │
│ │ 3b. Validate token │
│ ├────────────────────────────────────>│
│ │ GET /apps/oidc/userinfo │
│ │ Authorization: Bearer <token> │
│ │ │
│ │ 3c. Token valid + scopes │
│ │<────────────────────────────────────┤
│ │ {sub, scopes, ...} │
│ │ ← Cached for 1 hour │
│ │ │
│ 3d. Filtered tool list │ │
│<─────────────────────────────────┤ ← Only tools matching user's │
│ [tools matching token scopes] │ token scopes (via @require_scopes)
│ │ │
│ 3e. Call tool │ │
├─────────────────────────────────>│ │
│ nc_notes_get_note(note_id=1) │ ← @require_scopes("notes:read") │
│ Authorization: Bearer <token> │ │
│ │ │
│ │ 3f. Scope check PASSED │
│ │ ✓ Token has notes:read │
│ │ │
│ │ 3g. Nextcloud API call │
│ ├────────────────────────────────────>│
│ │ GET /apps/notes/api/v1/notes/1 │
│ │ Authorization: Bearer <token> │
│ │ ← user_oidc validates Bearer token │
│ │ │
│ │ 3h. API response │
│ │<────────────────────────────────────┤
│ │ {id: 1, title: "Note", ...} │
│ │ │
│ 3i. MCP tool response │ │
│<─────────────────────────────────┤ │
│ {note data} │ │
│ │ │
═══════════════════════════════════════════════════════════════════════════════════
Insufficient Scope Example (Step-Up Authorization)
═══════════════════════════════════════════════════════════════════════════════════
│ 4a. Call write tool │ │
├─────────────────────────────────>│ │
│ nc_notes_create_note(...) │ ← @require_scopes("notes:write") │
│ Authorization: Bearer <token> │ │
│ │ │
│ │ 4b. Scope check FAILED │
│ │ ✗ Token only has notes:read │
│ │ │
│ 4c. 403 Insufficient Scope │ │
│<─────────────────────────────────┤ │
│ WWW-Authenticate: Bearer │ │
│ error="insufficient_scope", │ │
│ scope="notes:write", │ │
│ resource_metadata="..." │ │
│ │ │
│ → Client can re-authorize with │ │
│ additional scopes (Step-Up) │ │
│ │ │
```
## Components
### 1. MCP Client
- Any MCP-compatible client (Claude Desktop, Claude Code, custom clients)
### 1. MCP Client (e.g., Claude Desktop, Claude Code)
**Capabilities**:
- Discovers OAuth configuration via MCP server
- Queries PRM endpoint for supported scopes
- Initiates OAuth flow with PKCE (Proof Key for Code Exchange)
- Stores and sends access token with each request
- **Example**: Claude Desktop, Claude Code
- Handles scope-based tool filtering
- Supports step-up authorization (re-auth for additional scopes)
### 2. MCP Server (Resource Server)
- **Role**: OAuth 2.0 Resource Server
- **Location**: This Nextcloud MCP Server implementation
- **Responsibilities**:
- Validates Bearer tokens by calling Nextcloud's userinfo endpoint
- Caches validated tokens (default: 1 hour TTL)
- Creates authenticated Nextcloud client instances per-user
- Enforces PKCE requirements (S256 code challenge method)
- Exposes Nextcloud functionality via MCP tools
**Examples**: Claude Desktop, Claude Code, MCP Inspector, custom MCP clients
### 2. MCP Server (Resource Server - This Implementation)
**Role**: OAuth 2.0 Resource Server (RFC 6749)
**Responsibilities**:
#### Startup Phase
- **OIDC Discovery**: Queries `/.well-known/openid-configuration` for OAuth endpoints
- **PKCE Validation**: Verifies server advertises S256 code challenge method
- **Dynamic Client Registration (DCR)**: Automatically registers OAuth client via `/apps/oidc/register` (RFC 7591)
- Or loads pre-configured client credentials
- Saves credentials to `.nextcloud_oauth_client.json`
- **Tool Registration**: Loads all MCP tools with their `@require_scopes` decorators
#### Client Connection Phase
- **Auth Settings**: Returns OAuth issuer URL and resource URL
- **PRM Endpoint**: Exposes `/.well-known/oauth-protected-resource/mcp` (RFC 9728)
- Dynamically discovers scopes from all registered tools
- Returns `scopes_supported` list based on `@require_scopes` decorators
#### Request Processing Phase
- **Token Validation**: Validates Bearer tokens via Nextcloud userinfo endpoint
- Supports both JWT and opaque tokens
- Caches validation results (1-hour TTL)
- Extracts user identity and granted scopes
- **Scope Enforcement**:
- Filters `list_tools` based on user's token scopes
- Validates scopes before executing each tool
- Returns 403 with `WWW-Authenticate` header for insufficient scopes
- **Per-User Clients**: Creates authenticated `NextcloudClient` instance per user
- Uses Bearer token for all Nextcloud API requests
- User-specific permissions and audit trails
**Key Files**:
- [`app.py`](../nextcloud_mcp_server/app.py) - OAuth mode detection and configuration
- [`auth/token_verifier.py`](../nextcloud_mcp_server/auth/token_verifier.py) - Token validation logic
- [`app.py`](../nextcloud_mcp_server/app.py) - OAuth mode, DCR, PRM endpoint
- [`auth/token_verifier.py`](../nextcloud_mcp_server/auth/token_verifier.py) - Token validation (userinfo + introspection + JWT)
- [`auth/context_helper.py`](../nextcloud_mcp_server/auth/context_helper.py) - Per-user client creation
- [`auth/scope_authorization.py`](../nextcloud_mcp_server/auth/scope_authorization.py) - `@require_scopes` decorator, scope discovery
- [`auth/client_registration.py`](../nextcloud_mcp_server/auth/client_registration.py) - DCR implementation (RFC 7591)
### 3. Nextcloud OIDC Apps
#### a) `oidc` - OIDC Identity Provider
- **Role**: OAuth 2.0 Authorization Server
- **Location**: Nextcloud app (`apps/oidc`)
- **Endpoints**:
- `/.well-known/openid-configuration` - Discovery endpoint
- `/apps/oidc/authorize` - Authorization endpoint
- `/apps/oidc/token` - Token endpoint
- `/apps/oidc/userinfo` - User info endpoint (token validation)
- `/apps/oidc/jwks` - JSON Web Key Set
- `/apps/oidc/register` - Dynamic client registration
**Role**: OAuth 2.0 Authorization Server + OIDC Provider
**Location**: Nextcloud app (`apps/oidc`)
**Endpoints**:
- `/.well-known/openid-configuration` - OIDC Discovery (RFC 8414)
- `/apps/oidc/authorize` - Authorization endpoint (OAuth 2.0 + PKCE)
- `/apps/oidc/token` - Token endpoint (issues JWT or opaque tokens)
- `/apps/oidc/userinfo` - UserInfo endpoint (OIDC Core, used for token validation)
- `/apps/oidc/jwks` - JSON Web Key Set (for JWT signature verification)
- `/apps/oidc/register` - Dynamic Client Registration endpoint (RFC 7591)
- `/apps/oidc/introspect` - Token Introspection endpoint (RFC 7662, optional)
**Token Types**:
- **JWT tokens**: Self-contained tokens with embedded scopes, validated via JWKS or userinfo
- **Opaque tokens**: Random strings, validated via userinfo or introspection endpoint
**Configuration**:
```bash
# Enable dynamic client registration (optional)
# Settings → OIDC → "Allow dynamic client registration"
# Enable dynamic client registration (recommended for development)
# Nextcloud Admin → Settings → OIDC → "Allow dynamic client registration"
# Enable token introspection (optional, for opaque token validation)
# Nextcloud Admin → Settings → OIDC → "Enable token introspection"
```
#### b) `user_oidc` - OpenID Connect User Backend
- **Role**: Bearer token validation middleware
- **Location**: Nextcloud app (`apps/user_oidc`)
- **Responsibilities**:
- Validates Bearer tokens for Nextcloud API requests
- Creates user sessions from valid Bearer tokens
- Integrates with Nextcloud's authentication system
**Role**: Bearer token validation middleware for Nextcloud APIs
**Location**: Nextcloud app (`apps/user_oidc`)
**Responsibilities**:
- Intercepts Nextcloud API requests with `Authorization: Bearer` header
- Validates tokens against OIDC provider (`oidc` app)
- Creates authenticated user sessions
- Enforces user-specific permissions on API requests
**Configuration**:
```bash
# Enable Bearer token validation (required)
# Enable Bearer token validation (required for OAuth mode)
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
```
> [!IMPORTANT]
> The `user_oidc` app requires a patch to properly support Bearer token authentication for non-OCS endpoints. See [Upstream Status](oauth-upstream-status.md) for details.
> The `user_oidc` app requires a patch to properly support Bearer token authentication for non-OCS endpoints (like Notes API, Calendar API). See [Upstream Status](oauth-upstream-status.md) for patch details and PR status.
### 4. Nextcloud Instance
- **Role**: Resource Owner / API Provider
- **Provides**: Notes, Calendar, Contacts, Deck, Files, etc.
**Role**: Resource Owner + API Provider
**APIs Exposed**:
- **Notes API**: `/apps/notes/api/v1/` - Note CRUD operations
- **Calendar (CalDAV)**: `/remote.php/dav/calendars/` - Events and todos
- **Contacts (CardDAV)**: `/remote.php/dav/addressbooks/` - Contact management
- **Cookbook API**: `/apps/cookbook/api/v1/` - Recipe management
- **Deck API**: `/apps/deck/api/v1.0/` - Kanban boards
- **Tables API**: `/apps/tables/api/2/` - Table row operations
- **WebDAV (Files)**: `/remote.php/dav/files/` - File operations
- **Sharing API**: `/ocs/v2.php/apps/files_sharing/api/v1/` - Share management
## Authentication Flow
### Phase 1: OAuth Authorization (Steps 1-7)
The OAuth flow consists of four distinct phases (see diagram above for visual representation):
1. **Client Connects**: MCP client connects to MCP server
2. **Auth Settings**: MCP server returns OAuth settings:
```json
{
"issuer_url": "https://nextcloud.example.com",
"resource_server_url": "http://localhost:8000",
"required_scopes": ["openid", "profile"]
}
```
3. **OAuth Flow**: Client initiates OAuth flow with PKCE
- Generates `code_verifier` (random string)
- Calculates `code_challenge` = SHA256(code_verifier)
- Redirects user to `/apps/oidc/authorize` with `code_challenge`
4. **User Authentication**: User logs in to Nextcloud via browser
5. **Authorization Code**: Nextcloud redirects back with authorization code
6. **Token Exchange**: Client exchanges code for access token
- Sends `code` + `code_verifier` to `/apps/oidc/token`
- OIDC app validates PKCE challenge
7. **Access Token**: Client receives access token (JWT or opaque)
### Phase 0: MCP Server Startup (One-time Setup)
### Phase 2: API Access (Steps 8-13)
**Happens**: On MCP server first startup
8. **API Request**: Client sends MCP request with Bearer token
9. **Token Validation**: MCP server validates token:
- Checks cache (1-hour TTL by default)
- If not cached, calls `/apps/oidc/userinfo` with Bearer token
- Extracts username from `sub` or `preferred_username` claim
10. **User Info**: Nextcloud returns user info if token is valid
11. **Nextcloud API Call**: MCP server calls Nextcloud API on behalf of user
- Creates `NextcloudClient` instance with Bearer token
- User-specific permissions apply
12. **API Response**: Nextcloud returns data
13. **MCP Response**: MCP server returns formatted response to client
**Steps**:
1. **OIDC Discovery** (`GET /.well-known/openid-configuration`)
- MCP server queries Nextcloud for OAuth endpoints
- Validates PKCE support (requires `S256` code challenge method)
- Extracts endpoints: authorize, token, userinfo, jwks, register
2. **Dynamic Client Registration** (`POST /apps/oidc/register`)
- If no pre-configured client credentials exist
- MCP server registers itself as OAuth client (RFC 7591)
- Provides: client name, redirect URIs, requested scopes, token type
- Receives: `client_id`, `client_secret`
- Saves credentials to `.nextcloud_oauth_client.json`
3. **Tool Registration**
- All MCP tools loaded with their `@require_scopes` decorators
- Scope metadata stored for later discovery
**Result**: MCP server ready to accept client connections
### Phase 1: Client Discovery (Per MCP Client Connection)
**Happens**: When MCP client first connects
**Steps**:
1. **MCP Connection**
- Client connects to MCP server
- Server returns OAuth auth settings (issuer URL, resource URL)
2. **PRM Discovery** (`GET /.well-known/oauth-protected-resource/mcp`)
- Client queries Protected Resource Metadata endpoint (RFC 9728)
- Server **dynamically discovers** scopes from all registered tools
- Returns: resource URL, `scopes_supported` list, authorization servers
- Client now knows which scopes are available
**Result**: Client knows OAuth configuration and available scopes
### Phase 2: OAuth Authorization (PKCE Flow - RFC 7636)
**Happens**: User authorizes access
**Steps**:
1. **PKCE Challenge Generation** (Client-side)
- Generate `code_verifier`: random 43-128 character string
- Calculate `code_challenge`: `BASE64URL(SHA256(code_verifier))`
2. **Authorization Request** (`GET /apps/oidc/authorize`)
- Client redirects user to Nextcloud consent page
- Parameters:
- `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`)
- `redirect_uri`: MCP server callback URL
3. **User Consent**
- User authenticates to Nextcloud (if not already logged in)
- User reviews and approves/denies requested scopes
- Can select subset of requested scopes
4. **Authorization Code**
- Nextcloud redirects to `callback?code=xyz123`
- Code is bound to PKCE challenge
5. **Token Exchange** (`POST /apps/oidc/token`)
- Client sends:
- Authorization `code`
- `code_verifier` (proves possession of original challenge)
- `client_id` and `client_secret`
- Nextcloud validates PKCE challenge: `SHA256(code_verifier) == code_challenge`
- Nextcloud issues access token
6. **Access Token Response**
- Token type: JWT or opaque (configurable)
- Contains user's **granted scopes** (may be subset of requested)
- Client stores token for subsequent requests
**Result**: Client has valid access token with granted scopes
### Phase 3: MCP Tool Access (Scope-Based Authorization)
**Happens**: Every MCP tool invocation
**Steps**:
#### Tool Listing (`list_tools`)
1. **List Tools Request**
- Client sends `list_tools` with `Authorization: Bearer <token>`
2. **Token Validation**
- MCP server calls `/apps/oidc/userinfo` with Bearer token
- Nextcloud returns user info including **granted scopes**
- Result cached for 1 hour
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
4. **Filtered Tool List**
- Client receives only tools they can use
#### Tool Execution (e.g., `nc_notes_get_note`)
1. **Tool Call**
- Client invokes tool with `Authorization: Bearer <token>`
2. **Scope Validation**
- `@require_scopes` decorator extracts token scopes
- Verifies token contains required scope (e.g., `notes:read`)
- If missing → 403 with `WWW-Authenticate` header (step-up auth)
- If present → continues execution
3. **Nextcloud API Call**
- MCP server creates `NextcloudClient` with Bearer token
- Calls Nextcloud API (e.g., `GET /apps/notes/api/v1/notes/1`)
- `user_oidc` app validates Bearer token again
- Request executes as authenticated user
4. **Response**
- Nextcloud returns data
- MCP server formats response
- Returns to client
**Result**: User can only access tools and data they have permissions for
### Phase 4: Insufficient Scope Handling (Step-Up Authorization)
**Happens**: When user lacks required scopes
**Steps**:
1. **Tool Call with Insufficient Scopes**
- 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
3. **403 Response with Challenge**
- Returns `403 Forbidden`
- Includes `WWW-Authenticate` header:
```
Bearer error="insufficient_scope",
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`
**Result**: User can dynamically upgrade permissions without full re-authentication
## Token Validation
@@ -272,14 +569,145 @@ client = get_client_from_context(ctx)
- Protects against authorization code interception
### Scopes
- Required scopes: `openid`, `profile`
- Additional scopes inferred from userinfo response
- Base required scopes: `openid`, `profile`, `email`
- App-specific scopes control access to individual Nextcloud apps
- See [OAuth Scopes](#oauth-scopes) section for complete scope reference
### Token Validation
- Every MCP request validates Bearer token
- Cached for performance (1-hour default)
- Calls userinfo endpoint for validation
## OAuth Scopes
The Nextcloud MCP Server implements fine-grained OAuth scopes for each Nextcloud app integration. Scopes control which tools are visible and accessible to users based on their granted permissions.
### Scope-Based Access Control
When using OAuth authentication:
1. **Dynamic Discovery**: The server automatically discovers all required scopes from `@require_scopes` decorators on MCP tools
2. **Tool Filtering**: Tools are dynamically filtered based on the user's token scopes - users only see tools they have permission to use
3. **Per-Tool Enforcement**: Each tool validates required scopes before execution, returning a 403 error if insufficient scopes are present
### Supported Scopes
The server supports the following OAuth scopes, organized by Nextcloud app:
#### Base OIDC Scopes
- `openid` - OpenID Connect authentication (required)
- `profile` - Access to user profile information (required)
- `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
#### Calendar App
- `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
#### Contacts App
- `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
#### Deck App
- `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
#### Files (WebDAV)
- `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
### Scope Discovery
The MCP server provides scope discovery through two mechanisms:
#### 1. Protected Resource Metadata (PRM) Endpoint
```bash
# Query the PRM endpoint
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", ...],
"authorization_servers": ["https://nextcloud.example.com"],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"]
}
```
The `scopes_supported` field is **dynamically generated** from all registered MCP tools, ensuring it always reflects the actual available scopes.
#### 2. Scope Enforcement via Decorators
Tools are decorated with `@require_scopes()` to declare their required permissions:
```python
from nextcloud_mcp_server.auth import require_scopes
@mcp.tool()
@require_scopes("notes:read")
async def nc_notes_get_note(ctx: Context, note_id: int):
"""Get a specific note by ID"""
# Implementation
```
### Client Registration Scopes
During OAuth client registration (dynamic or manual), clients request a set of scopes that define the **maximum allowed** scopes for that client. The actual per-tool enforcement is handled separately via decorators.
**Environment Variable**:
```bash
NEXTCLOUD_OIDC_SCOPES="openid profile email notes:read notes:write calendar:read calendar:write ..."
```
**Default**: All supported scopes (recommended for development)
> **Note**: Client registration scopes define the maximum permissions. The MCP server's PRM endpoint dynamically advertises the actual supported scopes based on registered tools.
### Step-Up Authorization
The server supports OAuth step-up authorization (RFC 8693). If a user attempts to use a tool requiring scopes they don't have:
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="..."
```
3. Client can re-authorize with additional scopes
### Scope Validation
All scope enforcement happens at two levels:
1. **Tool Visibility**: During `list_tools` requests, only tools matching the user's token scopes are returned
2. **Execution Time**: When calling a tool, the `@require_scopes` decorator validates the token has necessary scopes
**Example**:
```python
# 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)
# Attempting to call a write tool returns 403 Forbidden
```
## Configuration
See [Configuration Guide](configuration.md) for all OAuth environment variables:
+22 -25
View File
@@ -18,6 +18,7 @@ from starlette.routing import Mount, Route
from nextcloud_mcp_server.auth import (
InsufficientScopeError,
NextcloudTokenVerifier,
discover_all_scopes,
get_access_token_scopes,
has_required_scopes,
is_jwt_token,
@@ -283,7 +284,15 @@ async def load_oauth_client_credentials(
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
# Get scopes from environment or use defaults
# Default: all app-specific read/write scopes
# Note: Client registration happens BEFORE tools are registered, so we can't
# dynamically discover scopes here. These scopes define the "maximum allowed"
# scopes for this OAuth client. The actual per-tool scope enforcement happens
# via @require_scopes decorators, and the PRM endpoint advertises the actual
# supported scopes dynamically.
#
# IMPORTANT: Keep this list in sync with all @require_scopes decorators
# when adding new apps, or set NEXTCLOUD_OIDC_SCOPES environment variable
# to override.
default_scopes = (
"openid profile email "
"notes:read notes:write "
@@ -644,7 +653,11 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
if oauth_enabled:
def oauth_protected_resource_metadata(request):
"""RFC 9728 Protected Resource Metadata endpoint."""
"""RFC 9728 Protected Resource Metadata endpoint.
Dynamically discovers supported scopes from registered MCP tools.
This ensures the advertised scopes always match the actual tool requirements.
"""
mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
)
@@ -658,30 +671,14 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
# Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set
public_issuer_url = os.getenv("NEXTCLOUD_HOST", "")
# Dynamically discover all scopes from registered tools
# This provides a single source of truth based on @require_scopes decorators
supported_scopes = discover_all_scopes(mcp)
return JSONResponse(
{
"resource": resource_url,
"scopes_supported": [
"openid",
"notes:read",
"notes:write",
"calendar:read",
"calendar:write",
"todo:read",
"todo:write",
"contacts:read",
"contacts:write",
"cookbook:read",
"cookbook:write",
"deck:read",
"deck:write",
"tables:read",
"tables:write",
"files:read",
"files:write",
"sharing:read",
"sharing:write",
],
"scopes_supported": supported_scopes,
"authorization_servers": [public_issuer_url],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"],
@@ -832,9 +829,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
@click.option(
"--oauth-scopes",
envvar="NEXTCLOUD_OIDC_SCOPES",
default="openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write",
default="openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write",
show_default=True,
help="OAuth scopes to request (can also use NEXTCLOUD_OIDC_SCOPES env var)",
help="OAuth scopes to request during client registration. These define the maximum allowed scopes for the client. Note: Actual supported scopes are discovered dynamically from MCP tools at runtime. (can also use NEXTCLOUD_OIDC_SCOPES env var)",
)
@click.option(
"--oauth-token-type",
+2
View File
@@ -7,6 +7,7 @@ from .scope_authorization import (
InsufficientScopeError,
ScopeAuthorizationError,
check_scopes,
discover_all_scopes,
get_access_token_scopes,
get_required_scopes,
has_required_scopes,
@@ -25,6 +26,7 @@ __all__ = [
"ScopeAuthorizationError",
"InsufficientScopeError",
"check_scopes",
"discover_all_scopes",
"get_access_token_scopes",
"get_required_scopes",
"has_required_scopes",
@@ -276,3 +276,68 @@ def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
# Check if user has all required scopes
return set(required).issubset(user_scopes)
def discover_all_scopes(mcp) -> list[str]:
"""
Dynamically discover all OAuth scopes required by registered MCP tools.
This function inspects all registered tools and extracts their required scopes
from the @require_scopes decorator metadata. It provides a single source of truth
for available scopes based on the actual tool implementations.
Args:
mcp: FastMCP instance with registered tools
Returns:
Sorted list of unique scope strings, including base OIDC scopes
Example:
```python
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("My Server")
@mcp.tool()
@require_scopes("notes:read")
async def get_notes():
pass
@mcp.tool()
@require_scopes("notes:write")
async def create_note():
pass
scopes = discover_all_scopes(mcp)
# Returns: ["notes:read", "notes:write", "openid", "profile", "email"]
```
Note:
- Base OIDC scopes (openid, profile, email) are always included
- Scopes are deduplicated and sorted alphabetically
- Only scopes from decorated tools are included
- Must be called after tools are registered
"""
# Start with base OIDC scopes that are always required
all_scopes = {"openid", "profile", "email"}
# Get all registered tools
try:
tools = mcp._tool_manager.list_tools()
except AttributeError:
logger.warning("FastMCP instance does not have _tool_manager attribute")
return sorted(all_scopes)
# Extract scopes from each tool
for tool in tools:
# Get the original function (tools have a .fn attribute)
func = getattr(tool, "fn", None)
if func is None:
continue
# Extract scopes using existing helper
tool_scopes = get_required_scopes(func)
all_scopes.update(tool_scopes)
# Return sorted list of unique scopes
return sorted(all_scopes)