25 KiB
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/jwksfor 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
- Primary Token Validation: Use userinfo endpoint (not introspection)
- JWT Support: Optional - enables local validation if client configured for RFC 9068
- User Context: Extract username from
suborpreferred_usernameclaim via userinfo - Dynamic Registration: Primary deployment method (zero-config)
- 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:
# 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/userinfowith 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:
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+ optionalNEXTCLOUD_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:
# 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:
subANDpreferred_username(both contain Nextcloud username) - Scopes: Determined by scopes requested during OAuth flow
- Groups/Roles: Available via
rolesorgroupsscope - Profile:
name,email,picture, etc. (ifprofilescope requested)
Implementation:
# 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
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
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
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
# 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
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)
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
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
# 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
# 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)
- Install Nextcloud OIDC app from App Store
- Navigate to Settings → OIDC
- Enable "Dynamic Client Registration"
- (Optional) Configure token expiration times via CLI:
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)
- Set
NEXTCLOUD_HOSTenvironment variable - Set
NEXTCLOUD_MCP_SERVER_URL(if not localhost:8000) - Start MCP server → Auto-registers on first run
- Client credentials stored in
.nextcloud_oauth_client.json
Alternative: Pre-registered Client
# 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
# 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)
-
✅ 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
-
✅ 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
-
✅ Scope requirements:
["openid", "profile", "email"]- Sufficient for basic user identification
- Optional: Add
"roles"or"groups"for group-based authorization
-
✅ 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
-
✅ Client storage: JSON file with 0600 permissions
- Default:
.nextcloud_oauth_client.json - Configurable via env var
- Contains: client_id, client_secret, issued_at
- Default:
-
✅ Username extraction: From
suborpreferred_usernameclaim- Both contain Nextcloud username (verified)
- Retrieved during token validation
- Cached with token
-
✅ 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
- MCP RFC 9728 Protected Resource Metadata
- Nextcloud OIDC App Repository
- OpenID Connect Dynamic Client Registration
- RFC 9068 JWT Access Tokens
- MCP Simple Auth Example
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)