Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49f9cead69 | |||
| 415b1c901b | |||
| 90b96a8afe |
@@ -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)
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user