ci: [skip ci] Remove
This commit is contained in:
@@ -1,742 +0,0 @@
|
||||
# OAuth2/OIDC Implementation Plan for Nextcloud MCP Server
|
||||
|
||||
## Executive Summary
|
||||
Upgrade the Nextcloud MCP server to support OAuth2/OIDC authentication using Nextcloud's OIDC app as the Authorization Server, eliminating the need for baked-in credentials in server deployment.
|
||||
|
||||
**Status**: ✅ Research Complete - Implementation Ready
|
||||
|
||||
## Research Findings Summary
|
||||
|
||||
### ✅ Verified Nextcloud OIDC Capabilities
|
||||
- **Token Format**: Opaque tokens by default, **RFC 9068 JWT access tokens available** (must be enabled per-client)
|
||||
- **Discovery**: Full OpenID Connect discovery available at `/.well-known/openid-configuration`
|
||||
- **JWKS**: Available at `/apps/oidc/jwks` for JWT signature validation
|
||||
- **Dynamic Registration**: Supported via `/apps/oidc/register` (must be enabled by admin)
|
||||
- **Introspection**: ❌ NOT available - must use **userinfo endpoint** for token validation
|
||||
- **Userinfo**: Available at `/apps/oidc/userinfo` - validates token and returns user claims
|
||||
- **Scopes**: `openid`, `profile`, `email`, `roles`, `groups`
|
||||
- **User Claims**: `sub`, `preferred_username` (both contain Nextcloud username)
|
||||
|
||||
### 🔑 Key Implementation Decisions
|
||||
1. **Primary Token Validation**: Use **userinfo endpoint** (not introspection)
|
||||
2. **JWT Support**: Optional - enables local validation if client configured for RFC 9068
|
||||
3. **User Context**: Extract username from `sub` or `preferred_username` claim via userinfo
|
||||
4. **Dynamic Registration**: Primary deployment method (zero-config)
|
||||
5. **Token Lifetime**: Access tokens default to 3600s, clients default to 3600s (both configurable)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Server Role: Resource Server (RS) - RFC 9728
|
||||
The MCP server acts as a **Resource Server** that:
|
||||
- Validates OAuth tokens issued by Nextcloud OIDC app (Authorization Server)
|
||||
- Protects MCP tools/resources with OAuth authentication
|
||||
- Uses validated tokens to make Nextcloud API calls on behalf of authenticated users
|
||||
|
||||
### Authentication Flow
|
||||
```
|
||||
1. Client connects to MCP Server (RS)
|
||||
2. MCP Server provides RFC 9728 metadata pointing to Nextcloud OIDC (AS)
|
||||
3. Client performs OAuth flow with Nextcloud OIDC
|
||||
4. Client presents access token to MCP Server
|
||||
5. MCP Server validates token via userinfo endpoint (or JWT if configured)
|
||||
6. MCP Server extracts username from claims
|
||||
7. MCP Server uses token to call Nextcloud APIs with user context
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Dynamic Client Registration (PRIMARY APPROACH)
|
||||
**Use Nextcloud OIDC's Dynamic Client Registration for zero-config deployment**
|
||||
|
||||
**Benefits:**
|
||||
- No manual client setup required
|
||||
- MCP server auto-registers on first startup
|
||||
- Automatic credential generation
|
||||
- Self-healing if client expires
|
||||
- Better developer/deployment experience
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
# Startup sequence:
|
||||
1. Check for existing client credentials (file/env)
|
||||
2. If none found, POST to /apps/oidc/register
|
||||
3. Store client_id and client_secret persistently
|
||||
4. Use credentials for OAuth flow
|
||||
5. Auto re-register if client expires (3600s default)
|
||||
```
|
||||
|
||||
**Nextcloud OIDC Requirements:**
|
||||
- Admin must enable "Dynamic Client Registration" in OIDC app settings
|
||||
- Rate limiting via BruteForce protection
|
||||
- Max 100 dynamic clients per instance
|
||||
- Clients expire after 1 hour (configurable via occ)
|
||||
|
||||
### 2. Token Validation Strategy: Userinfo Endpoint (PRIMARY)
|
||||
|
||||
**✅ VERIFIED IMPLEMENTATION: Userinfo Endpoint Validation**
|
||||
|
||||
Nextcloud OIDC **does NOT provide** a token introspection endpoint. Token validation must use:
|
||||
|
||||
**Primary: Userinfo Endpoint Validation**
|
||||
- Call `/apps/oidc/userinfo` with Bearer token
|
||||
- Nextcloud validates token internally (checks expiration, client, etc.)
|
||||
- Returns user claims if valid: `sub`, `preferred_username`, `email`, `roles`, `groups`
|
||||
- HTTP 400/401 if token invalid
|
||||
- Cache results with TTL matching token expiration (3600s default)
|
||||
|
||||
**Implementation Pattern**:
|
||||
```python
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
# Call userinfo endpoint
|
||||
response = await client.get(
|
||||
f"{nextcloud_host}/apps/oidc/userinfo",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
claims = response.json()
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="", # Not available from userinfo
|
||||
scopes=["openid", "profile"], # From original request
|
||||
expires_at=calculate_expiry() # 3600s from now
|
||||
)
|
||||
return None # Invalid token
|
||||
```
|
||||
|
||||
**Optional: JWT Validation (Performance Optimization)**
|
||||
- Available if client configured with "JWT Access Tokens (RFC 9068)" enabled
|
||||
- Fetch JWKS from `/apps/oidc/jwks`
|
||||
- Validate JWT signatures locally (no network call)
|
||||
- Cache JWKS with refresh mechanism
|
||||
- Falls back to userinfo if JWT validation fails
|
||||
|
||||
**Trade-offs**:
|
||||
- Userinfo: Simpler, always works, network call per validation
|
||||
- JWT: Faster, no network call, requires per-client configuration
|
||||
|
||||
### 3. Dual-Mode Authentication (Backward Compatibility)
|
||||
Support both authentication modes:
|
||||
|
||||
**Mode 1: OAuth2/OIDC (NEW)**
|
||||
- Environment: `NEXTCLOUD_HOST` + optional `NEXTCLOUD_OIDC_CLIENT_ID/SECRET`
|
||||
- Auto-registers if no client credentials provided
|
||||
- Per-request client creation with bearer token
|
||||
|
||||
**Mode 2: Basic Auth (LEGACY)**
|
||||
- Environment: `NEXTCLOUD_HOST` + `NEXTCLOUD_USERNAME` + `NEXTCLOUD_PASSWORD`
|
||||
- Current implementation preserved
|
||||
- Single client in lifespan context
|
||||
|
||||
### 4. HTTP Client Architecture
|
||||
|
||||
**✅ REVISED: Context-aware Client Retrieval**
|
||||
|
||||
Instead of per-request client creation, use a helper that extracts user context:
|
||||
|
||||
```python
|
||||
# Helper function to get client from MCP context
|
||||
async def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
|
||||
"""Extract authenticated user context and create NextcloudClient."""
|
||||
# MCP SDK provides AccessToken from TokenVerifier
|
||||
access_token: AccessToken = ctx.request_context.session.access_token
|
||||
|
||||
# Extract username from cached userinfo claims
|
||||
# (stored during token verification)
|
||||
username = access_token.scopes[0] # Or from custom metadata
|
||||
|
||||
# Create client with bearer token
|
||||
return NextcloudClient.from_token(
|
||||
base_url=base_url,
|
||||
token=access_token.token,
|
||||
username=username
|
||||
)
|
||||
|
||||
# In tool implementations:
|
||||
@mcp.tool()
|
||||
async def nc_notes_create(title: str, content: str):
|
||||
ctx = mcp.get_context()
|
||||
|
||||
if oauth_mode:
|
||||
client = await get_client_from_context(ctx, nextcloud_host)
|
||||
else:
|
||||
# Legacy: use lifespan client
|
||||
client = ctx.request_context.lifespan_context.client
|
||||
|
||||
return await client.notes.create_note(title, content)
|
||||
```
|
||||
|
||||
**Key Pattern**:
|
||||
- Token verification caches userinfo claims
|
||||
- Helper retrieves username from cached data (no additional API call)
|
||||
- Client uses bearer token for Nextcloud API calls
|
||||
|
||||
### 5. User Context Extraction
|
||||
|
||||
**✅ VERIFIED: Userinfo Endpoint Response**
|
||||
|
||||
From Nextcloud OIDC userinfo endpoint response:
|
||||
- **Username**: `sub` AND `preferred_username` (both contain Nextcloud username)
|
||||
- **Scopes**: Determined by scopes requested during OAuth flow
|
||||
- **Groups/Roles**: Available via `roles` or `groups` scope
|
||||
- **Profile**: `name`, `email`, `picture`, etc. (if `profile` scope requested)
|
||||
|
||||
**Implementation**:
|
||||
```python
|
||||
# During token verification:
|
||||
userinfo = await fetch_userinfo(token)
|
||||
# {
|
||||
# "sub": "username",
|
||||
# "preferred_username": "username",
|
||||
# "email": "user@example.com",
|
||||
# "roles": ["group1", "group2"], # if 'roles' scope
|
||||
# "groups": ["group1", "group2"] # if 'groups' scope
|
||||
# }
|
||||
|
||||
username = userinfo["sub"] # or userinfo["preferred_username"]
|
||||
```
|
||||
|
||||
**Storage Strategy**:
|
||||
- Cache userinfo in AccessToken metadata
|
||||
- Use MCP SDK's built-in token caching
|
||||
- TTL matches access token expiration (3600s default)
|
||||
|
||||
## Implementation Components
|
||||
|
||||
### New Modules
|
||||
|
||||
#### 1. `nextcloud_mcp_server/auth/__init__.py`
|
||||
Exports: `NextcloudTokenVerifier`, `BearerAuth`, `register_client`
|
||||
|
||||
#### 2. `nextcloud_mcp_server/auth/token_verifier.py`
|
||||
```python
|
||||
class NextcloudTokenVerifier(TokenVerifier):
|
||||
"""
|
||||
Validates access tokens using Nextcloud OIDC userinfo endpoint.
|
||||
|
||||
Primary method: Userinfo endpoint validation (always works)
|
||||
Optional: JWT validation if client configured for RFC 9068
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nextcloud_host: str,
|
||||
userinfo_uri: str,
|
||||
jwks_uri: str | None = None,
|
||||
enable_jwt_validation: bool = False
|
||||
):
|
||||
self.nextcloud_host = nextcloud_host
|
||||
self.userinfo_uri = userinfo_uri
|
||||
self.jwks_uri = jwks_uri
|
||||
self.enable_jwt_validation = enable_jwt_validation
|
||||
|
||||
# Cache for validated tokens: token -> (userinfo, expiry)
|
||||
self._token_cache: dict[str, tuple[dict, float]] = {}
|
||||
|
||||
# JWKS cache (if JWT validation enabled)
|
||||
self._jwks: dict | None = None
|
||||
self._jwks_expires: float = 0
|
||||
|
||||
self._client = httpx.AsyncClient()
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Verify token using userinfo endpoint (primary) or JWT validation (optional).
|
||||
|
||||
Returns AccessToken with userinfo cached in metadata.
|
||||
"""
|
||||
# Check cache first
|
||||
if token in self._token_cache:
|
||||
userinfo, expiry = self._token_cache[token]
|
||||
if time.time() < expiry:
|
||||
return self._create_access_token(token, userinfo)
|
||||
|
||||
# Try JWT validation first if enabled
|
||||
if self.enable_jwt_validation and self.jwks_uri:
|
||||
access_token = await self._verify_jwt(token)
|
||||
if access_token:
|
||||
return access_token
|
||||
|
||||
# Fall back to (or use primary) userinfo validation
|
||||
return await self._verify_via_userinfo(token)
|
||||
|
||||
async def _verify_via_userinfo(self, token: str) -> AccessToken | None:
|
||||
"""Validate token by calling userinfo endpoint."""
|
||||
try:
|
||||
response = await self._client.get(
|
||||
self.userinfo_uri,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
userinfo = response.json()
|
||||
|
||||
# Cache for 3600s (default token lifetime)
|
||||
# TODO: Get actual expiry from token if JWT
|
||||
expiry = time.time() + 3600
|
||||
self._token_cache[token] = (userinfo, expiry)
|
||||
|
||||
return self._create_access_token(token, userinfo)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Userinfo validation failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
async def _verify_jwt(self, token: str) -> AccessToken | None:
|
||||
"""Validate JWT token locally using JWKS (optional optimization)."""
|
||||
try:
|
||||
# Fetch JWKS if not cached
|
||||
if not self._jwks or time.time() > self._jwks_expires:
|
||||
await self._refresh_jwks()
|
||||
|
||||
# Decode and validate JWT
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
self._jwks,
|
||||
algorithms=["RS256", "HS256"],
|
||||
issuer=self.nextcloud_host,
|
||||
options={"verify_aud": False} # Nextcloud may not include aud
|
||||
)
|
||||
|
||||
# Extract userinfo from JWT claims
|
||||
userinfo = {
|
||||
"sub": claims.get("sub"),
|
||||
"preferred_username": claims.get("preferred_username"),
|
||||
"email": claims.get("email"),
|
||||
"roles": claims.get("roles", []),
|
||||
"groups": claims.get("groups", [])
|
||||
}
|
||||
|
||||
# Cache
|
||||
expiry = claims.get("exp", time.time() + 3600)
|
||||
self._token_cache[token] = (userinfo, expiry)
|
||||
|
||||
return self._create_access_token(token, userinfo)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"JWT validation failed, falling back to userinfo: {e}")
|
||||
return None
|
||||
|
||||
def _create_access_token(self, token: str, userinfo: dict) -> AccessToken:
|
||||
"""Create AccessToken with userinfo in metadata."""
|
||||
username = userinfo.get("sub") or userinfo.get("preferred_username")
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="", # Not available from userinfo
|
||||
scopes=["openid", "profile", "email"], # TODO: Track actual scopes
|
||||
expires_at=int(time.time() + 3600), # TODO: Get from JWT exp claim
|
||||
# Store username in scopes[0] as workaround for MCP SDK limitation
|
||||
# Or use custom AccessToken subclass with username field
|
||||
)
|
||||
|
||||
async def _refresh_jwks(self):
|
||||
"""Fetch JWKS from Nextcloud OIDC."""
|
||||
response = await self._client.get(self.jwks_uri)
|
||||
response.raise_for_status()
|
||||
self._jwks = response.json()
|
||||
self._jwks_expires = time.time() + 3600 # Cache for 1 hour
|
||||
|
||||
async def close(self):
|
||||
"""Cleanup resources."""
|
||||
await self._client.aclose()
|
||||
```
|
||||
|
||||
#### 3. `nextcloud_mcp_server/auth/client_registration.py`
|
||||
```python
|
||||
async def register_client(
|
||||
nextcloud_url: str,
|
||||
client_name: str = "Nextcloud MCP Server",
|
||||
redirect_uris: list[str] = None
|
||||
) -> dict:
|
||||
"""Register MCP server as OAuth client with Nextcloud OIDC"""
|
||||
# POST to /apps/oidc/register
|
||||
# Return client_id, client_secret, expires_at
|
||||
|
||||
async def load_or_register_client(storage_path: str) -> dict:
|
||||
"""Load existing client or register new one"""
|
||||
# Check storage file
|
||||
# Validate expiration
|
||||
# Re-register if expired
|
||||
# Persist credentials
|
||||
```
|
||||
|
||||
#### 4. `nextcloud_mcp_server/auth/bearer_auth.py`
|
||||
```python
|
||||
class BearerAuth(httpx.Auth):
|
||||
"""Bearer token authentication for httpx"""
|
||||
|
||||
def __init__(self, token: str):
|
||||
self.token = token
|
||||
|
||||
def auth_flow(self, request):
|
||||
request.headers["Authorization"] = f"Bearer {self.token}"
|
||||
yield request
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
|
||||
#### 1. `nextcloud_mcp_server/app.py`
|
||||
```python
|
||||
# Add OAuth configuration
|
||||
from nextcloud_mcp_server.auth import NextcloudTokenVerifier, register_client
|
||||
|
||||
# In get_app():
|
||||
if oauth_enabled:
|
||||
# Load or register client
|
||||
client_info = await load_or_register_client(storage_path)
|
||||
|
||||
# Create token verifier
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
jwks_uri=f"{nextcloud_host}/apps/oidc/jwks",
|
||||
issuer=f"{nextcloud_host}"
|
||||
)
|
||||
|
||||
# Configure FastMCP with OAuth
|
||||
mcp = FastMCP(
|
||||
"Nextcloud MCP",
|
||||
token_verifier=token_verifier,
|
||||
auth=AuthSettings(
|
||||
issuer_url=nextcloud_host,
|
||||
resource_server_url=mcp_server_url,
|
||||
required_scopes=["openid", "profile"]
|
||||
),
|
||||
lifespan=app_lifespan_oauth # Don't create client in lifespan
|
||||
)
|
||||
else:
|
||||
# Legacy BasicAuth mode
|
||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
|
||||
```
|
||||
|
||||
#### 2. `nextcloud_mcp_server/client/__init__.py`
|
||||
```python
|
||||
class NextcloudClient:
|
||||
def __init__(self, base_url: str, username: str, auth: Auth | None = None):
|
||||
# Accept either BasicAuth or BearerAuth
|
||||
self._client = AsyncClient(base_url=base_url, auth=auth, ...)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
"""Legacy: Create from username/password env vars"""
|
||||
return cls(base_url, username, auth=BasicAuth(username, password))
|
||||
|
||||
@classmethod
|
||||
def from_token(cls, base_url: str, token: str, username: str):
|
||||
"""OAuth: Create from bearer token"""
|
||||
return cls(base_url, username, auth=BearerAuth(token))
|
||||
```
|
||||
|
||||
#### 3. `nextcloud_mcp_server/server/notes.py` (and other tool modules)
|
||||
```python
|
||||
from nextcloud_mcp_server.auth import get_client_from_context
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_create(title: str, content: str):
|
||||
ctx: Context = mcp.get_context()
|
||||
|
||||
# OAuth mode: Get client from request context
|
||||
if oauth_enabled:
|
||||
client = get_client_from_context(ctx)
|
||||
else:
|
||||
# Legacy mode: Use lifespan client
|
||||
client = ctx.request_context.lifespan_context.client
|
||||
|
||||
return await client.notes.create_note(...)
|
||||
```
|
||||
|
||||
#### 4. `nextcloud_mcp_server/config.py`
|
||||
```python
|
||||
class NextcloudConfig:
|
||||
# Common
|
||||
host: str
|
||||
|
||||
# OAuth mode
|
||||
oauth_enabled: bool = False
|
||||
oidc_client_id: str | None = None
|
||||
oidc_client_secret: str | None = None
|
||||
client_storage_path: str = ".nextcloud_oauth_client.json"
|
||||
mcp_server_url: str = "http://localhost:8000/mcp"
|
||||
required_scopes: list[str] = ["openid", "profile", "email"]
|
||||
|
||||
# Legacy mode
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
oauth_enabled = not (
|
||||
os.getenv("NEXTCLOUD_USERNAME") and
|
||||
os.getenv("NEXTCLOUD_PASSWORD")
|
||||
)
|
||||
return cls(oauth_enabled=oauth_enabled, ...)
|
||||
```
|
||||
|
||||
### Configuration Files
|
||||
|
||||
#### Updated `env.sample`
|
||||
```bash
|
||||
# Nextcloud Instance
|
||||
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||
|
||||
# ===== AUTHENTICATION MODE =====
|
||||
# Choose ONE of the following:
|
||||
|
||||
# Option 1: OAuth2/OIDC (RECOMMENDED)
|
||||
# - Requires Nextcloud OIDC app installed
|
||||
# - Enable "Dynamic Client Registration" in OIDC app settings
|
||||
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty
|
||||
# - Optional: Pre-register client and provide credentials
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=
|
||||
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
|
||||
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000/mcp
|
||||
|
||||
# Option 2: Basic Authentication (LEGACY - Will be deprecated)
|
||||
# - Requires username and password
|
||||
# - Less secure - credentials stored in environment
|
||||
# - Use only for backward compatibility
|
||||
NEXTCLOUD_USERNAME=
|
||||
NEXTCLOUD_PASSWORD=
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
### New Python Dependencies
|
||||
```toml
|
||||
# pyproject.toml additions:
|
||||
dependencies = [
|
||||
# ... existing ...
|
||||
"PyJWT[crypto]>=2.8.0", # JWT validation
|
||||
"cryptography>=41.0.0", # JWKS key handling (if not present)
|
||||
]
|
||||
```
|
||||
|
||||
## Nextcloud OIDC Setup
|
||||
|
||||
### Administrator Setup (One-time)
|
||||
1. Install Nextcloud OIDC app from App Store
|
||||
2. Navigate to Settings → OIDC
|
||||
3. Enable "Dynamic Client Registration"
|
||||
4. (Optional) Configure token expiration times via CLI:
|
||||
```bash
|
||||
php occ config:app:set oidc expire_time --value "3600"
|
||||
php occ config:app:set oidc refresh_expire_time --value "86400"
|
||||
```
|
||||
|
||||
### MCP Server Deployment (Zero-config)
|
||||
1. Set `NEXTCLOUD_HOST` environment variable
|
||||
2. Set `NEXTCLOUD_MCP_SERVER_URL` (if not localhost:8000)
|
||||
3. Start MCP server → Auto-registers on first run
|
||||
4. Client credentials stored in `.nextcloud_oauth_client.json`
|
||||
|
||||
### Alternative: Pre-registered Client
|
||||
```bash
|
||||
# Create client via CLI
|
||||
php occ oidc:create \
|
||||
--name="Nextcloud MCP Server" \
|
||||
--type=confidential \
|
||||
--redirect-uri="http://localhost:8000/oauth/callback"
|
||||
|
||||
# Set credentials in environment
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=<generated-id>
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=<generated-secret>
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Token validation with mocked JWKS
|
||||
- JWT claim extraction
|
||||
- Client registration flow
|
||||
- Bearer auth implementation
|
||||
|
||||
### Integration Tests
|
||||
- Dynamic client registration against test Nextcloud
|
||||
- OAuth flow end-to-end
|
||||
- Token-based API calls
|
||||
- Client expiration and re-registration
|
||||
- Dual-mode authentication (OAuth + BasicAuth)
|
||||
|
||||
### Test Fixtures
|
||||
```python
|
||||
# tests/conftest.py additions:
|
||||
@pytest.fixture
|
||||
def mock_oidc_server():
|
||||
"""Mock Nextcloud OIDC endpoints"""
|
||||
# Mock /apps/oidc/openid-configuration
|
||||
# Mock /apps/oidc/jwks
|
||||
# Mock /apps/oidc/register
|
||||
# Mock /apps/oidc/token
|
||||
|
||||
@pytest.fixture
|
||||
async def oauth_nc_client(mock_oidc_server):
|
||||
"""NextcloudClient with OAuth token"""
|
||||
token = generate_test_jwt()
|
||||
return NextcloudClient.from_token(base_url, token, "testuser")
|
||||
```
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Implementation (Week 1-2)
|
||||
- [ ] Implement token verifier with JWT validation
|
||||
- [ ] Implement dynamic client registration
|
||||
- [ ] Add BearerAuth for httpx
|
||||
- [ ] Modify NextcloudClient for dual-mode auth
|
||||
- [ ] Update app.py with OAuth configuration
|
||||
- [ ] Add configuration management
|
||||
|
||||
### Phase 2: Testing (Week 2-3)
|
||||
- [ ] Unit tests for all auth components
|
||||
- [ ] Integration tests with test Nextcloud instance
|
||||
- [ ] End-to-end OAuth flow testing
|
||||
- [ ] Backward compatibility testing
|
||||
|
||||
### Phase 3: Documentation (Week 3)
|
||||
- [ ] Update README.md with OAuth setup
|
||||
- [ ] Update CLAUDE.md with architecture changes
|
||||
- [ ] Add OAuth troubleshooting guide
|
||||
- [ ] Document OIDC app configuration
|
||||
- [ ] Add migration guide for existing deployments
|
||||
|
||||
### Phase 4: Deployment (Week 4)
|
||||
- [ ] Release with both modes supported
|
||||
- [ ] Monitor for issues
|
||||
- [ ] Deprecation notice for BasicAuth
|
||||
- [ ] Plan BasicAuth removal timeline (6+ months)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Token Security
|
||||
- Store client secrets securely (file permissions, secret managers)
|
||||
- Validate JWT signatures against trusted JWKS
|
||||
- Verify token claims (issuer, audience, expiration)
|
||||
- Implement token refresh logic
|
||||
- Rate limit token validation failures
|
||||
|
||||
### Client Registration Security
|
||||
- Nextcloud OIDC provides BruteForce protection
|
||||
- Dynamic clients limited to 100 per instance
|
||||
- Clients expire after 1 hour (configurable)
|
||||
- Admin must explicitly enable dynamic registration
|
||||
|
||||
### API Security
|
||||
- Bearer tokens used for Nextcloud API calls
|
||||
- Token scopes control access levels
|
||||
- User context preserved in all API operations
|
||||
- No credential storage in MCP server
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### JWT Validation Performance
|
||||
- JWKS caching with TTL (e.g., 1 hour)
|
||||
- Key rotation handling via JWKS refresh
|
||||
- Local validation (no network call per request)
|
||||
- Async validation to avoid blocking
|
||||
|
||||
### Client Creation
|
||||
- OAuth mode: Per-request client creation (lightweight)
|
||||
- BasicAuth mode: Single client in lifespan (current)
|
||||
- Connection pooling maintained in both modes
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Scope-based Authorization
|
||||
- Define custom Nextcloud scopes for MCP operations
|
||||
- Map MCP tools to required scopes
|
||||
- Fine-grained permission control
|
||||
|
||||
### Multi-tenant Support
|
||||
- Support multiple Nextcloud instances
|
||||
- Per-user client registration
|
||||
- Tenant isolation
|
||||
|
||||
### Token Introspection Fallback
|
||||
- Implement RFC 7662 introspection
|
||||
- Use if JWT validation fails
|
||||
- Support for opaque tokens
|
||||
|
||||
### Admin Controls
|
||||
- MCP server admin UI for OAuth config
|
||||
- Client credential rotation
|
||||
- Usage monitoring and logging
|
||||
|
||||
## Decisions Made (Post-Research)
|
||||
|
||||
1. **✅ Token Validation Method**: Userinfo endpoint (primary), JWT optional
|
||||
- Nextcloud OIDC does NOT provide introspection endpoint
|
||||
- Userinfo endpoint validates token AND returns user claims
|
||||
- JWT validation available as performance optimization if client configured
|
||||
|
||||
2. **✅ Client expiration handling**: Auto re-register with logging
|
||||
- Clients expire after 3600s by default
|
||||
- Check expiry on startup and periodically
|
||||
- Auto-register with backoff on failure
|
||||
|
||||
3. **✅ Scope requirements**: `["openid", "profile", "email"]`
|
||||
- Sufficient for basic user identification
|
||||
- Optional: Add `"roles"` or `"groups"` for group-based authorization
|
||||
|
||||
4. **✅ Token caching**: In-memory with 3600s TTL
|
||||
- Cache userinfo response (includes all needed claims)
|
||||
- Use token string as cache key
|
||||
- TTL matches default access token lifetime
|
||||
|
||||
5. **✅ Client storage**: JSON file with 0600 permissions
|
||||
- Default: `.nextcloud_oauth_client.json`
|
||||
- Configurable via env var
|
||||
- Contains: client_id, client_secret, issued_at
|
||||
|
||||
6. **✅ Username extraction**: From `sub` or `preferred_username` claim
|
||||
- Both contain Nextcloud username (verified)
|
||||
- Retrieved during token validation
|
||||
- Cached with token
|
||||
|
||||
7. **✅ BasicAuth deprecation**: 12 months after OAuth stable release
|
||||
- Phase 1: OAuth + BasicAuth (6 months)
|
||||
- Phase 2: OAuth only, deprecation warnings (6 months)
|
||||
- Phase 3: Remove BasicAuth
|
||||
|
||||
## Key Changes from Original Plan
|
||||
|
||||
### 1. Token Validation
|
||||
**Original**: JWT validation with JWKS (primary), introspection (fallback)
|
||||
**Updated**: Userinfo endpoint (primary), JWT validation (optional optimization)
|
||||
- Reason: Nextcloud OIDC has no introspection endpoint
|
||||
|
||||
### 2. User Context Extraction
|
||||
**Original**: Extract username from JWT claims
|
||||
**Updated**: Fetch from userinfo endpoint during validation
|
||||
- Reason: Opaque tokens by default, userinfo always works
|
||||
|
||||
### 3. Token Caching Strategy
|
||||
**Original**: MCP SDK handles all caching
|
||||
**Updated**: Custom cache in TokenVerifier for userinfo responses
|
||||
- Reason: Need to cache username separately from AccessToken
|
||||
|
||||
### 4. JWT Support
|
||||
**Original**: Required for all deployments
|
||||
**Updated**: Optional performance optimization
|
||||
- Reason: Requires per-client configuration in Nextcloud OIDC
|
||||
- Default: Opaque tokens validated via userinfo
|
||||
|
||||
## References
|
||||
|
||||
- [MCP Python SDK OAuth Documentation](https://github.com/modelcontextprotocol/python-sdk)
|
||||
- [MCP RFC 9728 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728.html)
|
||||
- [Nextcloud OIDC App Repository](https://github.com/H2CK/oidc)
|
||||
- [OpenID Connect Dynamic Client Registration](https://openid.net/specs/openid-connect-registration-1_0.html)
|
||||
- [RFC 9068 JWT Access Tokens](https://www.rfc-editor.org/rfc/rfc9068.html)
|
||||
- [MCP Simple Auth Example](~/Software/python-sdk/examples/servers/simple-auth/)
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ MCP server can authenticate via Nextcloud OIDC with zero manual client setup
|
||||
✅ Dynamic client registration works automatically on first run
|
||||
✅ JWT tokens validated locally without per-request network calls
|
||||
✅ Backward compatibility maintained with BasicAuth mode
|
||||
✅ All existing tests pass in both auth modes
|
||||
✅ Documentation complete for OAuth setup and migration
|
||||
✅ Security review passed (token handling, credential storage)
|
||||
✅ Performance benchmarks meet targets (< 10ms token validation overhead)
|
||||
@@ -1,121 +0,0 @@
|
||||
# OAuth Testing Setup
|
||||
|
||||
This document describes the automated OAuth testing infrastructure for the Nextcloud MCP server.
|
||||
|
||||
## Overview
|
||||
|
||||
We've created a comprehensive testing setup that includes:
|
||||
|
||||
1. **OIDC App Configuration** - Nextcloud OIDC app automatically installed and configured with dynamic client registration
|
||||
2. **Dual MCP Services** - Two MCP server instances running in Docker:
|
||||
- `mcp` (port 8000) - BasicAuth mode (username/password)
|
||||
- `mcp-oauth` (port 8001) - OAuth mode (dynamic client registration)
|
||||
3. **Test Fixtures** - Pytest fixtures for OAuth client testing
|
||||
4. **Integration Tests** - OAuth-specific integration tests
|
||||
|
||||
## Docker Compose Setup
|
||||
|
||||
The `docker-compose.yml` includes:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app: # Nextcloud with OIDC app enabled
|
||||
mcp: # BasicAuth MCP server (port 8000)
|
||||
mcp-oauth: # OAuth MCP server (port 8001)
|
||||
```
|
||||
|
||||
## OIDC Configuration
|
||||
|
||||
The OIDC app is configured automatically via `app-hooks/post-installation/install-oidc-app.sh`:
|
||||
|
||||
- **Dynamic Client Registration**: Enabled
|
||||
- **Config Key**: `dynamic_client_registration` (not `allow_dynamic_client_registration`)
|
||||
- **Registration Endpoint**: `http://localhost:8080/apps/oidc/register`
|
||||
|
||||
### Important: Config Key Fix
|
||||
|
||||
The correct OIDC config key is `dynamic_client_registration`. The initial implementation used `allow_dynamic_client_registration` which was incorrect and caused the registration endpoint to not appear in the OIDC discovery document.
|
||||
|
||||
## Test Fixtures
|
||||
|
||||
Located in `tests/conftest.py`:
|
||||
|
||||
### `oauth_token`
|
||||
Session-scoped fixture that obtains an OAuth access token.
|
||||
|
||||
**Current Limitation**: Nextcloud OIDC only supports `authorization_code` and `refresh_token` grant types, not the `password` grant type. This means we cannot automatically obtain tokens for testing without implementing a full browser-based OAuth flow.
|
||||
|
||||
### `nc_oauth_client`
|
||||
Session-scoped NextcloudClient configured with OAuth bearer token authentication.
|
||||
|
||||
**Status**: Implemented but currently skipped due to token acquisition limitation.
|
||||
|
||||
### `nc_mcp_oauth_client`
|
||||
Session-scoped MCP client that connects to the OAuth-enabled MCP server on port 8001.
|
||||
|
||||
**Status**: Implemented but marked as skip - requires full OAuth authorization flow implementation in MCP SDK.
|
||||
|
||||
## Current Test Status
|
||||
|
||||
### ✅ Working
|
||||
- OIDC app installation and configuration
|
||||
- Dynamic client registration
|
||||
- OAuth infrastructure (BearerAuth, TokenVerifier, client registration)
|
||||
- Docker compose dual-mode setup
|
||||
|
||||
### ⚠️ Limitations
|
||||
- **No automated token acquisition**: Nextcloud OIDC doesn't support the Resource Owner Password Credentials grant, which means we cannot programmatically get tokens for testing without browser interaction
|
||||
- **Manual testing only**: OAuth functionality must be tested manually using a browser-based OAuth flow
|
||||
- **MCP OAuth server untested**: The OAuth MCP server requires the full OAuth authorization flow to be implemented in the MCP Python SDK
|
||||
|
||||
## Manual Testing OAuth
|
||||
|
||||
To manually test OAuth functionality:
|
||||
|
||||
1. Start the docker-compose environment:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
2. The OAuth MCP server runs on port 8001 and will:
|
||||
- Automatically register a client via dynamic registration
|
||||
- Store client credentials in `/app/.oauth/` volume
|
||||
- Display OAuth configuration on startup
|
||||
|
||||
3. To test OAuth with a real client:
|
||||
- Use the authorization endpoint: `http://localhost:8080/apps/oidc/authorize`
|
||||
- Implement the authorization code flow
|
||||
- Exchange code for token at: `http://localhost:8080/apps/oidc/token`
|
||||
|
||||
## Future Work
|
||||
|
||||
To enable automated OAuth testing, one of these approaches is needed:
|
||||
|
||||
1. **Mock OIDC Server**: Create a test OIDC server that supports password grant
|
||||
2. **Browser Automation**: Use Selenium/Playwright to automate the OAuth flow
|
||||
3. **Test-Only Password Grant**: Patch Nextcloud OIDC to support password grant in test mode
|
||||
4. **Pre-generated Tokens**: Manually generate long-lived tokens and use them in tests
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests (OAuth tests will be skipped)
|
||||
uv run pytest tests/integration/test_oauth.py -v
|
||||
|
||||
# Run only the invalid token test (this one works)
|
||||
uv run pytest tests/integration/test_oauth.py::TestOAuthTokenValidation::test_invalid_token_fails -v
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `tests/conftest.py` - Added OAuth fixtures and token acquisition logic
|
||||
- `tests/integration/test_oauth.py` - OAuth-specific integration tests
|
||||
- `docker-compose.yml` - Added `mcp-oauth` service
|
||||
- `app-hooks/post-installation/install-oidc-app.sh` - OIDC installation and configuration
|
||||
- `nextcloud_mcp_server/client/__init__.py` - Added `from_token()` classmethod
|
||||
|
||||
## Notes
|
||||
|
||||
- The `from_token()` method was added to NextcloudClient to support OAuth authentication
|
||||
- All OAuth infrastructure is in place and functional
|
||||
- The main limitation is automated token acquisition for testing, not the OAuth implementation itself
|
||||
Reference in New Issue
Block a user