Compare commits

...

76 Commits

Author SHA1 Message Date
github-actions[bot] 02a2c4a16f bump: version 0.22.7 → 0.23.0 2025-11-03 01:48:39 +00:00
Chris Coutinho f37008fdc3 Merge pull request #254 from cbcoutinho/feature/keycloak
feat: Complete Keycloak external IdP integration with ADR-002 implementation
2025-11-03 02:47:57 +01:00
Chris Coutinho 7cb616c7ce feat: Auto-configure impersonation role in Keycloak realm import
Add service account user with impersonation role to realm-export.json
so that Tier 1 impersonation works out-of-the-box without requiring
manual CLI configuration.

Changes:
- Add service-account-nextcloud-mcp-server user to realm import
- Grant "impersonation" role from "realm-management" client
- Eliminates need for manual `kcadm.sh add-roles` command

Benefits:
- Impersonation tests now pass automatically
- No manual permission configuration required
- Consistent development environment setup

Verified:
- Manual test: tests/manual/test_impersonation.py  PASS
- Integration tests: tests/integration/auth/test_token_exchange_legacy_v1.py  3 PASS

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:22 +01:00
Chris Coutinho 34df5f5b9a feat: Implement dual-tier token exchange (Standard V2 + Legacy V1 impersonation)
This commit implements and documents both RFC 8693 token exchange tiers
from ADR-002, enabling both production-ready delegation and advanced
impersonation capabilities.

- Enable Keycloak preview features (`--features=preview`) to support
  both Standard V2 and Legacy V1 token exchange modes

- Update Tier 1 status from "NOT IMPLEMENTED" to "IMPLEMENTED (Legacy V1)"
- Add detailed empirical testing results showing:
  - Standard V2 rejects `requested_subject` parameter
  - Legacy V1 accepts parameter but requires impersonation permissions
  - Complete configuration steps for enabling impersonation
- Add comparison table showing when to use each tier
- Add "When to Use" guidance for both tiers
- Document that Tier 2 (Delegation) is the recommended default

- Update docstring to document both Tier 1 and Tier 2 support
- Add tier-specific logging (shows which tier is being used)
- Document permission requirements for Tier 1 impersonation

**tests/integration/auth/test_token_exchange_standard_v2.py**:
- Test delegation without impersonation (Tier 2)
- Verify sub claim remains unchanged (service account identity)
- Verify no special permissions required
- Test exchanged tokens work with Nextcloud APIs
- All tests PASS 

**tests/integration/auth/test_token_exchange_legacy_v1.py**:
- Test impersonation with `requested_subject` (Tier 1)
- Verify sub claim changes to target user
- Auto-skip if impersonation permissions not configured
- Document permission requirements in test docstrings
- Test exchanged tokens work with Nextcloud APIs

**tests/manual/test_impersonation.py**:
- Comprehensive impersonation validation script
- Tests both Standard V2 and Legacy V1 behavior
- Decodes JWT tokens to verify sub claim changes
- Validates tokens against Nextcloud APIs

**tests/manual/configure_impersonation.py**:
- Automated permission configuration helper
- Documents manual Keycloak CLI configuration steps

Both token exchange tiers are now fully implemented and tested:

- **Tier 2 (Delegation)** -  RECOMMENDED
  - Standard V2 (production-ready)
  - No special permissions required
  - Service account identity preserved

- **Tier 1 (Impersonation)** -  Advanced use only
  - Legacy V1 (--features=preview required)
  - Requires manual permission grant via Keycloak CLI
  - Subject claim changes to target user

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:22 +01:00
Chris Coutinho e26c5128b7 docs: Reject service account tokens as OAuth authentication pattern
Service account tokens (client_credentials grant) violate OAuth "act on-behalf-of"
principles and have been moved to ADR-002's "Will Not Implement" section.

## Problem Discovery

Testing revealed that service account tokens create Nextcloud user accounts
(e.g., `service-account-nextcloud-mcp-server`) due to user_oidc's bearer
provisioning feature. This violates core OAuth principles:

-  Creates stateful server identity in Nextcloud
-  All actions attributed to service account, not real user
-  Breaks audit trail and user attribution
-  Service account becomes "admin by another name"

## Changes

### Documentation (ADR-002)
- Moved service account (old Tier 1) to "Will Not Implement" section
- Added "OAuth Act On-Behalf-Of Principle" section
- Renumbered tiers:
  - Tier 1: Impersonation (NOT IMPLEMENTED)
  - Tier 2: Delegation via token exchange (IMPLEMENTED)
- Updated status to reflect rejection of service accounts

### Code Warnings
- Added comprehensive warning to KeycloakOAuthClient.get_service_account_token()
- Clarified VALID use: only as subject_token for RFC 8693 token exchange
- Clarified INVALID use: direct API access with service account token

### Supporting Documentation
- CLAUDE.md: Removed outdated "Tier 1" references, added rejection note
- oauth-impersonation-findings.md: Added prominent update banner
- audience-validation-setup.md: Updated tier numbers, added rejection note
- tests/manual/test_token_exchange.py: Added warning comment

## Valid Patterns (ADR-002)

 Foreground operations: User's access token from MCP request
 Background operations: Token exchange (impersonation/delegation)
 Offline access: Refresh tokens with user consent
 Service accounts: Creates independent server identity (REJECTED)

## Alternative

If service account pattern is truly needed, use BasicAuth mode instead of
OAuth mode. OAuth mode MUST maintain "act on-behalf-of" semantics.

Related: c12df98 (revert of service account test)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:22 +01:00
Chris Coutinho ed813af45c Revert "test: Add automated test for service account token acquisition (ADR-002 Tier 1)"
This reverts commit cbc37f1d76687d66a771236903ccb88b2e7b0242.
2025-11-02 22:03:22 +01:00
Chris Coutinho 1e071c83a9 test: Add automated test for service account token acquisition (ADR-002 Tier 1)
Add comprehensive automated integration test for Keycloak service account
token acquisition via client_credentials grant, validating ADR-002 Tier 1
implementation for external IdP mode.

Changes:
- Add keycloak_oauth_client fixture in tests/conftest.py
  - Creates KeycloakOAuthClient instance for service account operations
  - Session-scoped fixture with automatic cleanup
  - Discovers Keycloak endpoints automatically

- Add test_keycloak_service_account_token_acquisition test
  - Tests client_credentials grant token acquisition
  - Verifies token response structure (access_token, token_type, expires_in)
  - Validates token works with Nextcloud APIs via capabilities endpoint
  - Documents limitation for Nextcloud OIDC app (integrated mode)

- Update ADR-002 documentation
  - Mark automated test as complete ()
  - Document supported providers (Keycloak , Nextcloud OIDC app )
  - Add note that KeycloakOAuthClient is provider-agnostic
  - Clarify that Nextcloud OIDC app support requires config only

Test results:
-  Service account token acquired successfully (300s expiry, Bearer type)
-  Token validated by Nextcloud user_oidc app
-  Token works with Nextcloud capabilities API

Note: Nextcloud OIDC app (integrated mode) service account token support
not yet implemented. See app.py:631-635 for current status.

Resolves: "TODO: Automated integration tests needed for both Keycloak and
Nextcloud OIDC app" from ADR-002
2025-11-02 22:03:22 +01:00
Chris Coutinho 76430bec21 docs: Update ADR-002 with OAuth-only focus and testing status [skip ci]
Major changes to ADR-002 (Vector Database Background Sync Authentication):

1. Reordered authentication tiers:
   - Tier 1: Service Account Token (client_credentials) - most compatible
   - Tier 2: Token Exchange with Impersonation - not implemented
   - Tier 3: Token Exchange with Delegation - implemented

2. Removed admin credentials fallback:
   - ADR now focuses exclusively on OAuth mode
   - Background sync unavailable without proper OAuth configuration
   - BasicAuth mode out of scope (credentials already available)

3. Clarified testing status:
   - Tier 1: Implemented but only manual tests exist
   - Tier 3: Implemented but only manual tests exist
   - Added TODO for automated integration tests

4. Removed "Offline Access with Refresh Tokens":
   - Documented as "Will Not Implement"
   - MCP protocol architecture prevents server from accessing refresh tokens
   - Violates OAuth security model (tokens must stay with client)

5. Simplified configuration:
   - Removed all admin credential references
   - OAuth-only environment variables
   - Automatic tier detection based on provider capabilities

The ADR now accurately reflects that refresh tokens should never be shared
between MCP client and server, following OAuth best practices and the
FastMCP SDK architecture.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:22 +01:00
Chris Coutinho e81c2ad33d docs: Update upstream OAuth status with completed oidc app PRs [skip ci]
Update oauth-upstream-status.md to clarify patch requirements and document
completed upstream work:

**Clarifications:**
- CORSMiddleware patch is for Nextcloud core server (not user_oidc app)
- Root cause: CORS middleware logs out sessions without CSRF tokens
- Solution: Allow Bearer tokens to bypass CORS/CSRF checks
- Updated all references with actual PR number: nextcloud/server#55878

**Completed oidc app PRs (now documented):**
-  H2CK/oidc#586: User consent management (v1.11.0+)
-  H2CK/oidc#585: JWT tokens, introspection, scope validation (v1.10.0+)
-  H2CK/oidc#584: PKCE support (RFC 7636) (v1.10.0+)

**Updated sections:**
- "What Works Without Patches" - Added JWT, scopes, consent features
- "Upstream PRs Status" - Added completed PRs table
- "Monitoring Upstream Progress" - Focus on remaining work
- Last updated date: 2025-11-02

All OAuth features except app-specific APIs now work out of the box
with oidc app v1.10.0+. Only CORSMiddleware patch remains pending.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:21 +01:00
Chris Coutinho 23360485a8 refactor: Remove NEXTCLOUD_OIDC_CLIENT_STORAGE environment variable
Remove the NEXTCLOUD_OIDC_CLIENT_STORAGE environment variable from all
configuration files. OAuth client credentials are now always stored in the
SQLite database, with no option to use a custom JSON file path.

Changes:
- Remove NEXTCLOUD_OIDC_CLIENT_STORAGE from .env.keycloak.sample
- Remove NEXTCLOUD_OIDC_CLIENT_STORAGE from docker-compose.yml (mcp-oauth and mcp-keycloak services)
- Remove NEXTCLOUD_OIDC_CLIENT_STORAGE from Helm deployment template
- Remove NEXTCLOUD_OIDC_CLIENT_STORAGE from test_cli.py test assertions
- Remove --headed flag from pytest addopts (use CLI arg instead)

This simplifies configuration by enforcing a single storage mechanism
(SQLite database) for OAuth client credentials.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:21 +01:00
Chris Coutinho 2ca6725fc6 docs: Replace .nextcloud_oauth_client.json references with SQLite storage
Replace all references to the JSON file-based OAuth client storage with
SQLite database storage in documentation. OAuth client credentials are now
stored in the SQLite database instead of .nextcloud_oauth_client.json.

Changes:
- Update oauth-architecture.md to reference SQLite database
- Update jwt-oauth-reference.md credential storage sections
- Update oauth-setup.md Docker volume mounts and security best practices
- Update oauth-troubleshooting.md file permission → database permission errors
- Update configuration.md to remove JSON file chmod instructions
- Update troubleshooting.md database permission troubleshooting

The code already uses SQLite (RefreshTokenStorage class), so only
documentation needed updating.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:21 +01:00
Chris Coutinho 4c7d1cfc8d test: Add scope-based authorization tests for Keycloak external IdP
This enhances the Keycloak integration test suite with comprehensive
scope-based authorization validation, matching the OIDC test structure.

Changes:
- Add 3 test users to Keycloak realm (read-only, write-only, no-custom-scopes)
- Create OAuth token fixtures with different scope combinations
- Create MCP client fixtures for each scope configuration
- Add 4 new tests validating scope-based tool filtering:
  * Read-only tokens filter out write tools
  * Write-only tokens filter out read tools
  * Full access tokens show all 90+ tools
  * No custom scopes result in zero tools

Test Results:
- All 15 Keycloak integration tests pass (11 existing + 4 new)
- Validates proper JWT scope enforcement in external IdP architecture
- Confirms security isolation when users decline custom scopes

This completes ADR-002 scope authorization testing for the Keycloak
external identity provider integration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:21 +01:00
Chris Coutinho b68c704c4d refactor: Remove unnecessary user_oidc patch - CORSMiddleware patch is sufficient
Testing confirmed that the CORSMiddleware Bearer token patch (from upstream
commit 8fb5e77db82) alone is sufficient to enable Bearer token authentication
for all Nextcloud APIs, including app-specific endpoints like Notes and Calendar.

The user_oidc patch (which sets the app_api session flag) is not required when
the CORSMiddleware patch is applied, as it fixes the root cause by allowing
Bearer tokens to bypass CORS/CSRF checks at the framework level.

Validation:
- Restarted Nextcloud with user_oidc patch disabled
- Ran all 11 Keycloak integration tests
- All tests passed without the user_oidc patch

Updated documentation in 10-install-user_oidc-app.sh to explain why the patch
is no longer needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:21 +01:00
Chris Coutinho 849c67c32a fix: Complete Keycloak external IdP integration with all tests passing
This commit completes the Keycloak external IdP integration for the MCP
server, implementing ADR-002 Tier 2 (External Identity Provider) with
full Bearer token authentication support.

Key Changes:
1. **Keycloak backchannel-dynamic configuration**
   - Added --hostname-strict=false and --hostname-backchannel-dynamic=true
   - Allows external issuer (localhost:8888) with internal endpoints (keycloak:8080)
   - Solves Docker networking issue where containers can't reach localhost

2. **CORSMiddleware Bearer token patch**
   - Created app-hooks/patches/cors-bearer-token.patch from upstream commit 8fb5e77db82
   - Allows Bearer tokens to bypass CORS/CSRF checks (stateless authentication)
   - Applied via post-installation hook 20-apply-cors-bearer-token-patch.sh
   - Enables app-specific APIs (Notes, Calendar, etc.) to work with Bearer tokens

3. **Patch organization**
   - Moved patches to app-hooks/patches/ directory
   - Updated docker-compose.yml to mount entire app-hooks directory
   - Consolidated patch management for better maintainability

4. **Test improvements**
   - All 11 Keycloak integration tests passing
   - Tests validate OAuth token acquisition, MCP connectivity, token validation,
     tool execution, token persistence, user provisioning, scope filtering,
     and error handling

Architecture:
- Keycloak acts as external OAuth/OIDC identity provider
- MCP server uses Keycloak tokens to access Nextcloud APIs
- Nextcloud user_oidc app validates Bearer tokens from Keycloak
- No admin credentials needed - all API access uses user's OAuth tokens

Cache Note:
- Discovery and JWKS caches must be cleared when switching Keycloak configurations
- Use: docker compose exec redis redis-cli DEL "<cache-key>"
- Or: docker compose exec app php occ user_oidc:provider keycloak --clientid nextcloud

Related:
- ADR-002: Vector sync background jobs authentication
- Validates external IdP integration pattern
- Demonstrates offline_access with refresh tokens (Tier 1 & 2)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:20 +01:00
Chris Coutinho b3725dd2f5 test: Remove --headed from pytest addopts 2025-11-02 22:03:20 +01:00
Chris Coutinho 6117aaaed3 fix: Complete Keycloak external IdP integration with all tests passing
This commit completes the Keycloak external identity provider integration,
implementing the ADR-002 architecture where Keycloak acts as an external
OAuth/OIDC provider and Nextcloud validates tokens via the user_oidc app.

Architecture:
  MCP Client → Keycloak (OAuth) → MCP Server → Nextcloud user_oidc → APIs

Key Fixes:

1. Keycloak JWT token configuration
   - Added 'sub' claim protocol mapper to realm-export.json
   - Updated token_verifier.py to accept both 'sub' and 'preferred_username'
   - Ensures tokens contain required OIDC claims

2. Keycloak hostname configuration for Docker networking
   - Implemented --hostname-backchannel-dynamic=true in docker-compose.yml
   - External clients use localhost:8888 (public)
   - Internal services use keycloak:8080 (Docker network)
   - Same issuer (localhost:8888) everywhere for token consistency
   - Restored frontendUrl in realm attributes

3. MCP server provider mode detection
   - Fixed URL normalization to handle port differences (http://app vs http://app:80)
   - Correctly distinguishes integrated mode vs external IdP mode
   - Removes explicit default ports (80 for HTTP, 443 for HTTPS)

4. Nextcloud SSRF protection configuration
   - Added allow_local_remote_servers=true to user_oidc install script
   - Enables Nextcloud to fetch JWKS from internal Keycloak container
   - Required for external IdP token validation

5. OAuth lifespan cleanup
   - Fixed RefreshTokenStorage close() error (uses context managers)
   - Added safe cleanup for oauth_client with hasattr check
   - Prevents session crash on shutdown

6. Test suite fixes
   - Fixed test_user_auto_provisioning to reflect actual behavior
   - Fixed test_scope_filtering_with_keycloak tool name (nc_webdav_write_file)
   - Updated test_keycloak_oauth_client_credentials_discovery for hostname config
   - All 11 Keycloak external IdP tests now passing

Testing:
   All 11 tests in test_keycloak_external_idp.py passing
   OAuth token acquisition via Playwright automation
   Token validation through Nextcloud user_oidc app
   Write operations (Notes create, Calendar create, File upload)
   Read operations (search, list, get)
   Token persistence across multiple operations
   User authentication and bearer token validation
   Scope-based tool filtering
   Error handling for invalid operations

Implementation validates:
  - ADR-002 external identity provider architecture
  - No admin credentials needed in MCP server
  - Centralized identity management via Keycloak
  - Standards-based OAuth 2.0 / OIDC integration
  - User auto-provisioning from IdP claims

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:20 +01:00
Chris Coutinho 403f8be429 feat: Add Keycloak external IdP integration with custom scopes
Add comprehensive support for using Keycloak as an external identity
provider with Nextcloud custom scopes. This enables testing of ADR-002
external IdP integration patterns.

**Keycloak Realm Configuration:**
- Add frontendUrl attribute to issue tokens with public issuer URL
- Define 18 Nextcloud custom client scopes (notes:read/write,
  calendar:read/write, contacts:read/write, cookbook:read/write,
  deck:read/write, tables:read/write, files:read/write,
  sharing:read/write, todo:read/write)
- Add all custom scopes to nextcloud-mcp-server client optional scopes
- Scopes include consent screen text for user-friendly OAuth flow

**MCP Server Configuration:**
- Add OIDC_JWKS_URI environment variable support
- Implement JWKS URI override logic for Docker networking
- Update NEXTCLOUD_PUBLIC_ISSUER_URL to include full realm path
- Enable MCP server to fetch JWKS from internal Docker network

**Test Infrastructure:**
- Add keycloak_oauth_client_credentials fixture (session-scoped)
- Add keycloak_oauth_token fixture with Playwright automation
- Implement PKCE (S256) support for Keycloak OAuth flow
- Add nc_mcp_keycloak_client fixture for MCP testing
- Create comprehensive test suite in test_keycloak_external_idp.py

**Tests Created:**
- test_keycloak_oauth_token_acquisition: Token acquisition via Playwright
- test_keycloak_oauth_client_credentials_discovery: OIDC discovery
- test_mcp_client_connects_to_keycloak_server: MCP connectivity
- test_external_idp_server_initialization: Server auto-detection
- test_external_idp_token_validation: Token validation flow
- test_tools_work_with_keycloak_token: End-to-end tool execution
- test_keycloak_token_persistence: Multi-operation token reuse
- test_user_auto_provisioning: Nextcloud user provisioning
- test_scope_filtering_with_keycloak: Scope-based tool filtering
- test_keycloak_error_handling: Error handling
- test_external_idp_architecture: Architecture documentation

**Current Status:**
-  Keycloak realm configuration complete
-  Custom scopes defined and available
-  OAuth token acquisition working (1 test passing)
- ⚠️  Token validation needs additional work (external IdP userinfo)

**Files Modified:**
- keycloak/realm-export.json: Realm configuration with scopes
- tests/conftest.py: Keycloak OAuth fixtures (+285 lines)
- tests/server/oauth/test_keycloak_external_idp.py: New test suite
- docker-compose.yml: OIDC_JWKS_URI and issuer configuration
- nextcloud_mcp_server/app.py: JWKS URI override logic

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:20 +01:00
Chris Coutinho 2a1274d8a8 refactor: Unify OAuth configuration to be provider-agnostic
Replace provider-specific environment variables (OAUTH_PROVIDER, KEYCLOAK_*)
with generic OIDC_* variables that work with any OIDC-compliant provider.

**Key Changes:**
- Auto-detect provider mode from OIDC_DISCOVERY_URL issuer
  - External IdP mode: issuer ≠ NEXTCLOUD_HOST (Keycloak, Auth0, Okta, etc.)
  - Integrated mode: issuer = NEXTCLOUD_HOST (Nextcloud OIDC app)
- Unified OIDC discovery flow (single code path)
- Generic client credential loading (static or DCR)
- Simplified docker-compose.yml environment variables

**Environment Variables:**
BEFORE:
  OAUTH_PROVIDER=keycloak
  KEYCLOAK_URL=http://keycloak:8080
  KEYCLOAK_REALM=nextcloud-mcp
  KEYCLOAK_CLIENT_ID=...
  KEYCLOAK_DISCOVERY_URL=...

AFTER:
  OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/...
  OIDC_CLIENT_ID=nextcloud-mcp-server
  OIDC_CLIENT_SECRET=...

**Benefits:**
- Works with any OIDC provider without code changes
- No manual provider selection needed
- Cleaner environment variable naming
- Reduced code duplication (~150 lines removed)

**Testing:**
 mcp-keycloak auto-detects external IdP mode
 Token exchange test passes with generic config
 Backward compatible - integrated mode still works

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:20 +01:00
Chris Coutinho e331544cee feat: Implement RFC 8693 token exchange for Keycloak (ADR-002 Tier 2)
Implements OAuth 2.0 Token Exchange (RFC 8693) enabling the MCP server to
exchange service account tokens for user-scoped tokens. This provides an
alternative to refresh tokens for background operations.

**Core Implementation:**
- Added `get_service_account_token()` method to KeycloakOAuthClient for
  client_credentials grant
- Added `exchange_token_for_user()` method implementing RFC 8693 token exchange
- Fixed Fernet encryption key handling in RefreshTokenStorage (was incorrectly
  base64 decoding already-encoded keys)
- Updated OAuth configuration to support offline_access scope and refresh token
  storage infrastructure

**Keycloak Configuration:**
- Enabled `serviceAccountsEnabled` in realm-export.json
- Added `token.exchange.grant.enabled` attribute
- Added `client.token.exchange.standard.enabled` attribute (required for
  Keycloak 26.2+ Standard Token Exchange V2)
- Fresh Keycloak imports now correctly enable token exchange

**Docker Compose:**
- Added TOKEN_ENCRYPTION_KEY and ENABLE_OFFLINE_ACCESS environment variables
- Created oauth-tokens volume for refresh token storage
- Configured both mcp-oauth and mcp-keycloak services

**Testing & Documentation:**
- Added tests/manual/test_token_exchange.py - Validates complete RFC 8693 flow
- Added tests/manual/test_nextcloud_impersonate.py - Documents session-based
  impersonation limitations
- Added docs/oauth-impersonation-findings.md - Comprehensive investigation
  findings and resolution documentation

**Verified Working:**
 Service account token acquisition (client_credentials grant)
 RFC 8693 token exchange for internal-to-internal tokens
 Exchanged tokens validate with Nextcloud APIs
 Keycloak 26.4.2 Standard Token Exchange V2 support

**Known Limitations:**
- User impersonation (requested_subject) requires Keycloak Legacy V1 with
  preview features
- Cross-client token exchange limited to same realm
- Refresh token storage infrastructure ready but unused (MCP protocol limitation)

Dependencies: aiosqlite>=0.20.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:19 +01:00
Chris Coutinho 37b0b4a281 fix: Update DCR token_type tests for OIDC app changes
The Nextcloud OIDC app has updated token_type parameter values:
- Changed from "Bearer" → "opaque" for opaque tokens
- Changed from "JWT" → "jwt" for JWT tokens

Updated test_dcr_token_type.py to use lowercase token_type values:
- token_type="jwt" for JWT-formatted tokens
- token_type="opaque" for opaque/bearer tokens

This fixes test failures where tests were using the old "Bearer" and
"JWT" (uppercase) values which are no longer recognized by the OIDC app.

Fixes test: test_dcr_respects_bearer_token_type

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:19 +01:00
Chris Coutinho f34366a260 feat: Add Keycloak OAuth provider support with refresh token storage
Implements Keycloak as an external OIDC provider following ADR-002
architecture for background job authentication using offline_access.

## Features

- Keycloak OAuth provider with PKCE and offline_access support
- Refresh token storage with Fernet encryption
- Token verifier for both JWT and opaque tokens
- Multi-client validation (realm-level trust)
- Sample configuration for Keycloak integration

## Implementation

### OAuth Provider (keycloak_oauth.py)
- Authorization Code Flow with PKCE
- Refresh token exchange
- OIDC discovery endpoint support
- Token validation with JWKS

### Token Storage (refresh_token_storage.py)
- Encrypted storage using Fernet symmetric encryption
- SQLite backend for persistence
- Token rotation support
- Per-user token management

### Token Verifier Updates
- Support both JWT (self-encoded) and opaque tokens
- JWKS-based JWT signature verification
- Introspection endpoint fallback for opaque tokens
- Scope extraction from both token types

### Configuration
- .env.keycloak.sample: Example configuration with Keycloak URLs
- docs/keycloak-multi-client-validation.md: Realm-level validation documentation
- app-hooks/post-installation/10-install-user_oidc-app.sh: Updated dependencies

## Architecture Notes

- MCP Server is a protected resource (requires OAuth)
- MCP Client initiates OAuth flow and shares refresh tokens
- Refresh tokens enable background operations without admin credentials
- Supports future token exchange delegation when Keycloak implements it

## References

- ADR-002: Vector Database Background Sync Authentication
- RFC 6749: OAuth 2.0 (offline_access, refresh tokens)
- RFC 7517: JSON Web Key (JWK)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:19 +01:00
Chris Coutinho 529dc4616b docs: Implement separate clients architecture for Keycloak integration
Implements proper OAuth 2.0 separation following RFC 8707 best practices
with distinct resource server and OAuth client configurations.

## Architecture Changes

- Create separate "nextcloud" bearer-only client (resource server)
- Configure "nextcloud-mcp-server" OAuth client with audience mapper
- Audience mapper targets "nextcloud" resource server
- Token flow: aud="nextcloud", azp="nextcloud-mcp-server"

## Benefits

- Proper OAuth client vs resource server separation
- Support for future multi-resource tokens: aud=["nextcloud", "other-service"]
- RFC 8707 Resource Indicators compliance
- Clear requester identification via azp claim

## Documentation Updates

- Correct OAuth flow: MCP Client initiates, handles redirect, shares tokens
- Explain MCP Server as protected resource architecture
- Document offline_access with refresh tokens (Tier 1, current)
- Document token exchange with delegation (Tier 2, future when Keycloak adds support)
- Reference Keycloak issue #38279 for delegation status

## Files

- keycloak/realm-export.json: Add separate clients configuration
- app-hooks/post-installation/15-setup-keycloak-provider.sh: Setup user_oidc with "nextcloud" client
- docs/audience-validation-setup.md: Comprehensive documentation with corrected OAuth flow and delegation comparison
- docker-compose.yml: Fix Keycloak healthcheck (bash TCP instead of curl)
- scripts/test_separate_clients.sh: Verification script for architecture

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 22:03:19 +01:00
Chris Coutinho f739330341 ci: fix typo 2025-11-02 22:03:19 +01:00
Chris Coutinho 136df2422b build: Add keykloak to docker-compose.yml 2025-11-02 22:03:19 +01:00
Chris Coutinho eb8ca92bca Merge pull request #252 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.7
2025-10-31 22:32:43 +01:00
Chris Coutinho 0f03541486 Merge branch 'master' of github.com:cbcoutinho/nextcloud-mcp-server 2025-10-31 02:59:53 +01:00
Chris Coutinho ef07b1a6c9 docs: Add ADRs 2025-10-31 02:59:44 +01:00
Chris Coutinho 4f82357f24 ci: update submodule 2025-10-31 02:59:35 +01:00
renovate-bot-cbcoutinho[bot] 9ef2311c71 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.7 2025-10-30 23:08:17 +00:00
Chris Coutinho c4293b6750 Merge pull request #251 from cbcoutinho/renovate/docker.io-library-nginx-alpine
chore(deps): update docker.io/library/nginx:alpine docker digest to b3c656d
2025-10-30 20:23:52 +01:00
renovate-bot-cbcoutinho[bot] 72e4eb3d19 chore(deps): update docker.io/library/nginx:alpine docker digest to b3c656d 2025-10-30 17:06:28 +00:00
Chris Coutinho 47dd2df7aa Merge pull request #250 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.6
2025-10-30 12:55:02 +01:00
renovate-bot-cbcoutinho[bot] 9fd2022151 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.6 2025-10-29 23:07:53 +00:00
Chris Coutinho b99dc52c95 docs: Update README with instructions on helm install 2025-10-29 12:47:20 +01:00
Chris Coutinho 78b27fb5e9 Merge pull request #249 from cbcoutinho/renovate/actions-checkout-5.x
chore(deps): update actions/checkout action to v5
2025-10-29 12:42:59 +01:00
renovate-bot-cbcoutinho[bot] 03e39a3f94 chore(deps): update actions/checkout action to v5 2025-10-29 11:28:09 +00:00
github-actions[bot] 5259658458 bump: version 0.22.6 → 0.22.7 2025-10-29 11:18:41 +00:00
Chris Coutinho e03a3c2e83 fix(helm): Remove image tag overide 2025-10-29 12:18:12 +01:00
Chris Coutinho 94cbd3015d Merge pull request #248 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin dependencies
2025-10-29 12:14:10 +01:00
renovate-bot-cbcoutinho[bot] 49a961cbcc chore(deps): pin dependencies 2025-10-29 11:06:51 +00:00
github-actions[bot] e1aca04aff bump: version 0.22.5 → 0.22.6 2025-10-29 10:57:44 +00:00
Chris Coutinho 3b12e585ca fix(helm): Update helm chart with extraArgs 2025-10-29 11:57:13 +01:00
github-actions[bot] e647c87dd8 bump: version 0.22.4 → 0.22.5 2025-10-29 10:54:54 +00:00
Chris Coutinho cb74157d51 fix: Update helm chart variables 2025-10-29 11:54:26 +01:00
github-actions[bot] 202058bdc8 bump: version 0.22.3 → 0.22.4 2025-10-29 10:44:11 +00:00
Chris Coutinho c312911538 fix(helm): Update helm version with release 2025-10-29 11:43:30 +01:00
Chris Coutinho e602684743 fix(helm): Update helm version with release 2025-10-29 11:43:02 +01:00
github-actions[bot] 8221046d8a bump: version 0.22.2 → 0.22.3 2025-10-29 10:35:58 +00:00
Chris Coutinho 3e45b6ca25 fix(helm): Update helm version with release 2025-10-29 11:34:58 +01:00
github-actions[bot] 9ec7637579 bump: version 0.22.1 → 0.22.2 2025-10-29 10:30:39 +00:00
Chris Coutinho 670188f9e4 fix(helm): Update helm version with release 2025-10-29 11:29:59 +01:00
github-actions[bot] 3878beaf65 bump: version 0.22.0 → 0.22.1 2025-10-29 10:17:08 +00:00
Chris Coutinho a5a0571bde fix: Trigger release 2025-10-29 11:16:30 +01:00
github-actions[bot] 0e7e74867f bump: version 0.21.0 → 0.22.0 2025-10-29 09:32:27 +00:00
Chris Coutinho a29045cca4 Merge pull request #246 from cbcoutinho/feature/helm-chart
Feature/helm chart
2025-10-29 10:32:02 +01:00
Chris Coutinho b11c3ddfb6 build: Rename /helm -> /charts 2025-10-29 10:30:48 +01:00
Chris Coutinho 562c102711 feat(server): Add /live & /health endpoints 2025-10-29 10:29:30 +01:00
Chris Coutinho 3c3646bec2 Merge pull request #247 from cbcoutinho/renovate/docker.io-library-nginx-alpine
chore(deps): update docker.io/library/nginx:alpine docker digest to 9dacca6
2025-10-29 09:37:07 +01:00
renovate-bot-cbcoutinho[bot] dd636e6a08 chore(deps): update docker.io/library/nginx:alpine docker digest to 9dacca6 2025-10-29 05:07:08 +00:00
Chris Coutinho d7a8719d0e build: Remove duplicate --host 2025-10-29 01:40:36 +01:00
Chris Coutinho 97fa9ef8a7 build: Update helm chart README and instructions 2025-10-29 01:37:08 +01:00
Chris Coutinho 77dd17b3e1 build: fix templating/linting errors 2025-10-29 01:37:07 +01:00
Chris Coutinho d56ec33b77 build: update helm chart 2025-10-29 01:37:07 +01:00
Chris Coutinho a1c5acc1c2 feat: Initialize helm chart 2025-10-29 01:37:03 +01:00
Chris Coutinho e0de2e17e9 Merge pull request #245 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.1
chore(deps): update docker.io/library/nextcloud:32.0.1 docker digest to 1e4eae5
2025-10-28 09:19:39 +01:00
renovate-bot-cbcoutinho[bot] 4fc0cb5a41 chore(deps): update docker.io/library/nextcloud:32.0.1 docker digest to 1e4eae5 2025-10-27 23:10:34 +00:00
Chris Coutinho ff9cca716b Merge pull request #243 from cbcoutinho/renovate/astral-sh-setup-uv-digest
chore(deps): update astral-sh/setup-uv digest to 8585678
2025-10-26 22:00:45 +01:00
Chris Coutinho ef4a82e589 Update .github/workflows/release.yml 2025-10-26 22:00:36 +01:00
Chris Coutinho 301c502e57 Merge pull request #244 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.1.2
2025-10-26 21:59:19 +01:00
renovate-bot-cbcoutinho[bot] d4d291d6d2 chore(deps): update astral-sh/setup-uv action to v7.1.2 2025-10-26 17:07:33 +00:00
renovate-bot-cbcoutinho[bot] e4b0ea5093 chore(deps): update astral-sh/setup-uv digest to 8585678 2025-10-26 17:07:29 +00:00
Chris Coutinho 6833f7f117 Merge pull request #242 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin downloads.unstructured.io/unstructured-io/unstructured-api docker tag to a43ab55
2025-10-26 02:43:56 +02:00
renovate-bot-cbcoutinho[bot] 7db2a5c586 chore(deps): pin downloads.unstructured.io/unstructured-io/unstructured-api docker tag to a43ab55 2025-10-25 22:05:59 +00:00
Chris Coutinho b76c10f18c Merge branch 'docs/oauth-arch' 2025-10-25 22:08:02 +02:00
Chris Coutinho ab7411d9fd test: Fix tests 2025-10-25 22:07:46 +02:00
Chris Coutinho d02fe3c3b6 Merge pull request #241 from cbcoutinho/docs/oauth-arch
docs: Update OAuth architecture
2025-10-25 21:58:45 +02:00
62 changed files with 11497 additions and 1327 deletions
+138
View File
@@ -0,0 +1,138 @@
# Keycloak OAuth Configuration for Nextcloud MCP Server
#
# This configuration uses Keycloak as the OAuth/OIDC identity provider
# while still accessing Nextcloud APIs. Nextcloud's user_oidc app validates
# Keycloak bearer tokens and provisions users automatically.
#
# Architecture: Client → Keycloak (OAuth) → MCP Server → Nextcloud (user_oidc validates) → APIs
#
# This enables ADR-002 authentication patterns without admin credentials!
# ==============================================================================
# OAUTH PROVIDER SELECTION
# ==============================================================================
# OAuth provider: "keycloak" or "nextcloud" (default)
OAUTH_PROVIDER=keycloak
# ==============================================================================
# KEYCLOAK CONFIGURATION
# ==============================================================================
# Keycloak base URL (accessible from MCP server container)
KEYCLOAK_URL=http://keycloak:8080
# Keycloak realm name
KEYCLOAK_REALM=nextcloud-mcp
# OAuth client credentials (from Keycloak realm export or manual configuration)
KEYCLOAK_CLIENT_ID=nextcloud-mcp-server
KEYCLOAK_CLIENT_SECRET=mcp-secret-change-in-production
# OIDC discovery URL (auto-constructed from URL + realm, or specify explicitly)
KEYCLOAK_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
# ==============================================================================
# NEXTCLOUD CONFIGURATION
# ==============================================================================
# Nextcloud URL (accessible from MCP server container)
# Used for API access - Keycloak tokens are validated by user_oidc app
NEXTCLOUD_HOST=http://app:80
# MCP server URL (for OAuth redirect URIs)
# This is the publicly accessible URL that OAuth clients connect to
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
# Public Keycloak issuer URL (accessible from OAuth clients)
# If clients access Keycloak via a different URL than the internal one,
# set this to the public URL for OAuth flows
NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888
# ==============================================================================
# REFRESH TOKEN STORAGE (ADR-002 Tier 1: Offline Access)
# ==============================================================================
# Enable offline_access scope to get refresh tokens
ENABLE_OFFLINE_ACCESS=true
# Encryption key for storing refresh tokens (generate with instructions below)
# IMPORTANT: Keep this secret! Tokens are encrypted at rest using this key.
#
# Generate a key:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
#
# Example (DO NOT use this in production!):
# TOKEN_ENCRYPTION_KEY=your-base64-encoded-fernet-key-here
# Path to SQLite database for token storage
TOKEN_STORAGE_DB=/app/data/tokens.db
# ==============================================================================
# DOCKER COMPOSE NOTES
# ==============================================================================
# When running via docker-compose, the mcp-keycloak service is pre-configured
# with these environment variables. See docker-compose.yml for the full config.
#
# Start services:
# docker-compose up -d keycloak app mcp-keycloak
#
# View logs:
# docker-compose logs -f mcp-keycloak
#
# Check Keycloak realm:
# curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
#
# Check user_oidc provider:
# docker compose exec app php occ user_oidc:provider keycloak
# ==============================================================================
# KEYCLOAK SETUP VERIFICATION
# ==============================================================================
# 1. Verify Keycloak is running and realm is imported:
# curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
#
# 2. Verify Nextcloud user_oidc provider is configured:
# docker compose exec app php occ user_oidc:provider keycloak
#
# 3. Test OAuth flow manually:
# - Get token from Keycloak:
# curl -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
# -d "grant_type=password" \
# -d "client_id=nextcloud-mcp-server" \
# -d "client_secret=mcp-secret-change-in-production" \
# -d "username=admin" \
# -d "password=admin" \
# -d "scope=openid profile email offline_access"
#
# - Use token with Nextcloud API:
# curl -H "Authorization: Bearer <access_token>" \
# http://localhost:8080/ocs/v2.php/cloud/capabilities
#
# 4. Connect MCP client to server:
# - Point your MCP client to http://localhost:8002
# - Complete OAuth flow via Keycloak (credentials: admin/admin)
# - Client should receive access token and be able to call MCP tools
# ==============================================================================
# TROUBLESHOOTING
# ==============================================================================
# If OAuth flow fails:
# - Check that Keycloak is accessible: curl http://localhost:8888
# - Check that user_oidc provider is configured: docker compose exec app php occ user_oidc:provider keycloak
# - Check MCP server logs: docker-compose logs mcp-keycloak
# - Verify redirect URIs match in Keycloak client configuration
#
# If token validation fails:
# - Verify user_oidc has bearer validation enabled (--check-bearer=1)
# - Check Nextcloud logs: docker compose exec app tail -f /var/www/html/data/nextcloud.log
# - Verify Keycloak discovery URL is accessible from Nextcloud container:
# docker compose exec app curl http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
#
# If offline_access/refresh tokens not working:
# - Verify TOKEN_ENCRYPTION_KEY is set and valid
# - Check token storage database: ls -lah /app/data/tokens.db (inside container)
# - Check that offline_access scope is requested in realm configuration
+122
View File
@@ -0,0 +1,122 @@
name: Release Charts
on:
push:
tags:
- v*
jobs:
release:
# depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions
# see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Run chart-releaser
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Update gh-pages with Chart README and Index
run: |
# Get the repository name
REPO_NAME="${GITHUB_REPOSITORY##*/}"
REPO_OWNER="${GITHUB_REPOSITORY%/*}"
# Switch to gh-pages branch
git fetch origin gh-pages
git checkout gh-pages
# Copy Chart README to root
git checkout ${GITHUB_REF#refs/tags/} -- charts/nextcloud-mcp-server/README.md
mv charts/nextcloud-mcp-server/README.md README.md || true
rm -rf charts 2>/dev/null || true
# Create index.html with installation instructions
cat > index.html <<'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nextcloud MCP Server Helm Chart</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
line-height: 1.6;
}
code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
font-family: "Monaco", "Courier New", monospace;
}
pre {
background: #f4f4f4;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
}
h1, h2 { color: #0082c9; }
a { color: #0082c9; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<h1>Nextcloud MCP Server Helm Chart</h1>
<p>A Helm chart for deploying the Nextcloud MCP (Model Context Protocol) Server on Kubernetes, enabling AI assistants to interact with your Nextcloud instance.</p>
<h2>Installation</h2>
<p>Add the Helm repository:</p>
<pre><code>helm repo add nextcloud-mcp https://REPO_OWNER.github.io/REPO_NAME/
helm repo update</code></pre>
<p>Install the chart:</p>
<pre><code>helm install nextcloud-mcp nextcloud-mcp/nextcloud-mcp-server \
--set nextcloud.host=https://cloud.example.com \
--set auth.basic.username=myuser \
--set auth.basic.password=mypassword</code></pre>
<h2>Documentation</h2>
<ul>
<li><a href="README.md">Chart README</a> - Full documentation for the Helm chart</li>
<li><a href="https://github.com/REPO_OWNER/REPO_NAME">GitHub Repository</a> - Source code and issues</li>
<li><a href="index.yaml">Helm Repository Index</a> - Chart metadata</li>
</ul>
<h2>Quick Start</h2>
<p>See the <a href="README.md">full documentation</a> for detailed configuration options, examples, and troubleshooting guides.</p>
<hr>
<p><small>Generated by <a href="https://github.com/helm/chart-releaser">chart-releaser</a></small></p>
</body>
</html>
EOF
# Replace placeholders
sed -i "s/REPO_OWNER/$REPO_OWNER/g" index.html
sed -i "s/REPO_NAME/$REPO_NAME/g" index.html
# Commit changes
git add README.md index.html
git commit -m "Update README and index from chart release" || echo "No changes to commit"
git push origin gh-pages
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install uv
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
- name: Install Python 3.11
run: uv python install 3.11
- name: Build
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Install the latest version of uv
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
- name: Check format
run: |
uv run --frozen ruff format --diff
@@ -52,7 +52,7 @@ jobs:
up-flags: "--build"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
- name: Install Playwright dependencies
run: |
+72
View File
@@ -1,3 +1,75 @@
## v0.23.0 (2025-11-03)
### Feat
- Auto-configure impersonation role in Keycloak realm import
- Implement dual-tier token exchange (Standard V2 + Legacy V1 impersonation)
- Add Keycloak external IdP integration with custom scopes
- Implement RFC 8693 token exchange for Keycloak (ADR-002 Tier 2)
- Add Keycloak OAuth provider support with refresh token storage
### Fix
- Complete Keycloak external IdP integration with all tests passing
- Complete Keycloak external IdP integration with all tests passing
- Update DCR token_type tests for OIDC app changes
### Refactor
- Remove NEXTCLOUD_OIDC_CLIENT_STORAGE environment variable
- Remove unnecessary user_oidc patch - CORSMiddleware patch is sufficient
- Unify OAuth configuration to be provider-agnostic
## v0.22.7 (2025-10-29)
### Fix
- **helm**: Remove image tag overide
## v0.22.6 (2025-10-29)
### Fix
- **helm**: Update helm chart with extraArgs
## v0.22.5 (2025-10-29)
### Fix
- Update helm chart variables
## v0.22.4 (2025-10-29)
### Fix
- **helm**: Update helm version with release
- **helm**: Update helm version with release
## v0.22.3 (2025-10-29)
### Fix
- **helm**: Update helm version with release
## v0.22.2 (2025-10-29)
### Fix
- **helm**: Update helm version with release
## v0.22.1 (2025-10-29)
### Fix
- Trigger release
## v0.22.0 (2025-10-29)
### Feat
- **server**: Add /live & /health endpoints
- Initialize helm chart
## v0.21.0 (2025-10-25)
### Feat
+131
View File
@@ -395,6 +395,137 @@ uv run pytest -m oauth -v
- Playwright tests run in CI/CD environments
- Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects)
#### Keycloak OAuth/OIDC Testing (ADR-002 Integration)
The MCP server supports using **Keycloak as an external OAuth/OIDC identity provider** instead of Nextcloud's built-in OIDC app. This validates the ADR-002 architecture for background jobs and external identity providers.
**Architecture:**
```
MCP Client → Keycloak (OAuth) → MCP Server → Nextcloud user_oidc (validates token) → APIs
```
**Key Benefits:**
-**No admin credentials needed** - All API access uses user's Keycloak token
-**External identity provider** - Demonstrates integration with enterprise IdPs
-**ADR-002 validation** - Tests offline_access and refresh token patterns
-**User provisioning** - Nextcloud automatically provisions users from Keycloak
**Setup and Testing:**
```bash
# 1. Start Keycloak and MCP server with Keycloak OAuth
docker-compose up -d keycloak app mcp-keycloak
# 2. Verify Keycloak realm is available
curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
# 3. Verify user_oidc provider is configured
docker compose exec app php occ user_oidc:provider keycloak
# 4. Generate encryption key for refresh token storage (optional, for offline access)
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Set in environment: export TOKEN_ENCRYPTION_KEY='<key>'
# 5. Test OAuth flow manually
# Get token from Keycloak:
TOKEN=$(curl -s -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=mcp-client" \
-d "client_secret=mcp-secret-change-in-production" \
-d "username=admin" \
-d "password=admin" \
-d "scope=openid profile email offline_access" | jq -r .access_token)
# Use token with Nextcloud API (validated by user_oidc):
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/ocs/v2.php/cloud/capabilities
# 6. Connect MCP client
# Point client to: http://localhost:8002
# Complete OAuth flow using Keycloak credentials: admin/admin
```
**Three MCP Server Containers:**
- **`mcp`** (port 8000): Basic auth with admin credentials
- **`mcp-oauth`** (port 8001): Nextcloud OIDC provider (JWT tokens)
- **`mcp-keycloak`** (port 8002): Keycloak OIDC provider (external IdP)
**Keycloak Configuration:**
- **Realm**: `nextcloud-mcp` (auto-imported from `keycloak/realm-export.json`)
- **Client**: `mcp-client` (pre-configured with PKCE, offline_access)
- **Admin user**: `admin/admin` (created in realm export)
- **Redirect URIs**: `http://localhost:*/callback`, `http://127.0.0.1:*/callback`
**Environment Variables** (Generic OIDC - works with any provider):
```bash
# Generic OIDC configuration (provider-agnostic)
OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
OIDC_CLIENT_ID=nextcloud-mcp-server # OAuth client ID
OIDC_CLIENT_SECRET=mcp-secret-... # OAuth client secret
# Nextcloud API configuration
NEXTCLOUD_HOST=http://app:80 # Nextcloud API (token validation in external IdP mode)
# Refresh tokens and token exchange (ADR-002)
ENABLE_OFFLINE_ACCESS=true # Enable refresh tokens
TOKEN_ENCRYPTION_KEY=<fernet-key> # Encrypt refresh tokens
TOKEN_STORAGE_DB=/app/data/tokens.db # Token storage path
# OAuth scopes (optional - uses defaults if not specified)
NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access notes:read notes:write ...
```
**Provider Mode Detection:**
- **External IdP mode**: If `OIDC_DISCOVERY_URL` issuer ≠ `NEXTCLOUD_HOST` → Uses external provider (Keycloak, Auth0, Okta, etc.)
- **Integrated mode**: If `OIDC_DISCOVERY_URL` not set or issuer = `NEXTCLOUD_HOST` → Uses Nextcloud OIDC app
**Nextcloud user_oidc Configuration:**
The `user_oidc` app is automatically configured by `app-hooks/post-installation/15-setup-keycloak-provider.sh`:
```bash
# Configured with:
--check-bearer=1 # Validate bearer tokens
--bearer-provisioning=1 # Auto-provision users
--unique-uid=1 # Hash user IDs
--scope="openid profile email offline_access"
```
**Troubleshooting:**
```bash
# Check Keycloak is running
docker-compose ps keycloak
docker-compose logs keycloak
# Check user_oidc provider configuration
docker compose exec app php occ user_oidc:provider keycloak
# Check MCP server logs
docker-compose logs -f mcp-keycloak
# Check Nextcloud logs for token validation
docker compose exec app tail -f /var/www/html/data/nextcloud.log
# Verify Keycloak is accessible from Nextcloud container
docker compose exec app curl http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
```
**ADR-002 Offline Access Testing:**
The Keycloak integration enables testing ADR-002's primary authentication pattern (offline access with refresh tokens):
1. **Refresh token storage**: Tokens stored encrypted in SQLite (`/app/data/tokens.db`)
2. **Token refresh**: Access tokens refreshed automatically when expired
3. **Background workers**: Can access APIs using stored refresh tokens
4. **No admin credentials**: All operations use user's OAuth tokens
**Note**: Service account tokens (client_credentials grant) were considered but rejected as they create Nextcloud user accounts and violate OAuth "act on-behalf-of" principles. See ADR-002 "Will Not Implement" section.
See `docs/ADR-002-vector-sync-authentication.md` for architectural details.
**Audience Validation:**
Tokens include `aud: ["mcp-server", "nextcloud"]` claims for proper security:
- MCP server validates tokens are intended for it
- Nextcloud validates tokens include it as audience
- Prevents token misuse across services
See `docs/audience-validation-setup.md` for configuration details and `docs/keycloak-multi-client-validation.md` for realm-level validation behavior.
### Configuration Files
- **`pyproject.toml`** - Python project configuration using uv for dependency management
+1 -1
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:0.9.5-python3.11-alpine@sha256:64ecec379ff82bea84b8a80c0b374f6392bcd54aa52f8c63c12f510f9c0b214d
FROM ghcr.io/astral-sh/uv:0.9.7-python3.11-alpine@sha256:0006b77df7ebf46e68959fdc8d3af9d19f1adfae8c2e7e77907ad257e5d05be4
# Install git (required for caldav dependency from git)
RUN apk add --no-cache git
+9 -1
View File
@@ -72,9 +72,17 @@ uv sync
# Or using Docker
docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
# Or deploy to Kubernetes with Helm
helm repo add nextcloud-mcp https://cbcoutinho.github.io/nextcloud-mcp-server
helm repo update
helm install nextcloud-mcp nextcloud-mcp/nextcloud-mcp-server \
--set nextcloud.host=https://cloud.example.com \
--set auth.basic.username=myuser \
--set auth.basic.password=mypassword
```
See [Installation Guide](docs/installation.md) for detailed instructions.
See [Installation Guide](docs/installation.md) for detailed instructions, or [Helm Chart README](charts/nextcloud-mcp-server/README.md) for Kubernetes deployment.
### 2. Configure
+18
View File
@@ -0,0 +1,18 @@
diff --git a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
index 4453f5a7d4b..f1ca9b48d21 100644
--- a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
+++ b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
@@ -73,6 +73,13 @@ class CORSMiddleware extends Middleware {
$user = array_key_exists('PHP_AUTH_USER', $this->request->server) ? $this->request->server['PHP_AUTH_USER'] : null;
$pass = array_key_exists('PHP_AUTH_PW', $this->request->server) ? $this->request->server['PHP_AUTH_PW'] : null;
+ // Allow Bearer token authentication for CORS requests
+ // Bearer tokens are stateless and don't require CSRF protection
+ $authorizationHeader = $this->request->getHeader('Authorization');
+ if (!empty($authorizationHeader) && str_starts_with($authorizationHeader, 'Bearer ')) {
+ return;
+ }
+
// Allow to use the current session if a CSRF token is provided
if ($this->request->passesCSRFCheck()) {
return;
@@ -9,5 +9,13 @@ php /var/www/html/occ app:enable user_oidc
# Configure user_oidc to validate bearer tokens from the OIDC Identity Provider
php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
php /var/www/html/occ config:system:set user_oidc httpclient.allowselfsigned --value=true --type=boolean
patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch
# Allow Nextcloud to connect to local/internal servers (required for external IdP mode)
# This enables user_oidc to fetch JWKS from internal Keycloak container
php /var/www/html/occ config:system:set allow_local_remote_servers --value=true --type=boolean
# Note: The user_oidc app_api session flag patch is NOT required when using the
# CORSMiddleware Bearer token patch (20-apply-cors-bearer-token-patch.sh).
# The CORSMiddleware patch fixes the root cause by allowing Bearer tokens to bypass
# CORS/CSRF checks at the framework level.
+100
View File
@@ -0,0 +1,100 @@
#!/bin/bash
#
# Configure user_oidc to accept bearer tokens from Keycloak
#
# This script sets up Keycloak as an external OIDC provider for Nextcloud.
# It enables bearer token validation, allowing the MCP server to use Keycloak
# tokens to access Nextcloud APIs without admin credentials.
#
set -e
echo "===================================================================="
echo "Configuring user_oidc provider for Keycloak..."
echo "===================================================================="
# Wait for Keycloak to be ready and realm to be available
echo "Waiting for Keycloak realm to be available..."
MAX_RETRIES=30
RETRY_COUNT=0
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
if curl -sf http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration > /dev/null 2>&1; then
echo "✓ Keycloak realm is ready"
break
fi
echo " Waiting for Keycloak... (attempt $((RETRY_COUNT + 1))/$MAX_RETRIES)"
sleep 5
RETRY_COUNT=$((RETRY_COUNT + 1))
done
if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then
echo "⚠ Warning: Keycloak not available after $MAX_RETRIES attempts"
echo " Keycloak provider will not be configured"
echo " You can configure it manually using:"
echo " docker compose exec app php occ user_oidc:provider keycloak \\"
echo " --clientid='nextcloud' \\"
echo " --clientsecret='nextcloud-secret-change-in-production' \\"
echo " --discoveryuri='http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration' \\"
echo " --check-bearer=1 \\"
echo " --bearer-provisioning=1 \\"
echo " --unique-uid=1"
exit 0
fi
# Check if provider already exists
if php /var/www/html/occ user_oidc:provider keycloak 2>/dev/null | grep -q "Identifier"; then
echo " Keycloak provider already exists, updating configuration..."
# Update existing provider
php /var/www/html/occ user_oidc:provider keycloak \
--clientid="nextcloud" \
--clientsecret="nextcloud-secret-change-in-production" \
--discoveryuri="http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration" \
--check-bearer=1 \
--bearer-provisioning=1 \
--unique-uid=1 \
--mapping-uid="sub" \
--mapping-display-name="name" \
--mapping-email="email" \
--scope="openid profile email offline_access"
echo "✓ Updated Keycloak provider configuration"
else
echo " Creating new Keycloak provider..."
# Create new provider
php /var/www/html/occ user_oidc:provider keycloak \
--clientid="nextcloud" \
--clientsecret="nextcloud-secret-change-in-production" \
--discoveryuri="http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration" \
--check-bearer=1 \
--bearer-provisioning=1 \
--unique-uid=1 \
--mapping-uid="sub" \
--mapping-display-name="name" \
--mapping-email="email" \
--scope="openid profile email offline_access"
echo "✓ Created Keycloak provider"
fi
# Display provider details
echo ""
echo "Keycloak provider configuration:"
php /var/www/html/occ user_oidc:provider keycloak
echo ""
echo "===================================================================="
echo "✓ Keycloak provider configured successfully"
echo "===================================================================="
echo ""
echo "Key features enabled:"
echo " • Bearer token validation (--check-bearer=1)"
echo " • Automatic user provisioning (--bearer-provisioning=1)"
echo " • Unique user IDs (--unique-uid=1)"
echo " • Offline access scope (for refresh tokens)"
echo ""
echo "MCP server can now use Keycloak tokens to access Nextcloud APIs"
echo "without admin credentials (ADR-002 architecture)."
echo ""
@@ -0,0 +1,64 @@
#!/bin/bash
#
# Apply upstream CORSMiddleware Bearer token authentication patch
#
# This patch allows Bearer tokens to bypass CORS/CSRF checks, fixing
# authentication issues with app-specific APIs (Notes, Calendar, etc.)
# when using OAuth/OIDC Bearer tokens.
#
# Upstream PR: https://github.com/nextcloud/server/pull/55878
# Commit: 8fb5e77db82 (fix(cors): Allow Bearer token authentication)
#
set -e
PATCH_FILE="/docker-entrypoint-hooks.d/patches/cors-bearer-token.patch"
TARGET_FILE="/var/www/html/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php"
echo "===================================================================="
echo "Applying CORSMiddleware Bearer token authentication patch..."
echo "===================================================================="
# Check if patch file exists
if [ ! -f "$PATCH_FILE" ]; then
echo "⚠ Warning: Patch file not found: $PATCH_FILE"
echo " Skipping CORS Bearer token patch"
exit 0
fi
# Check if target file exists
if [ ! -f "$TARGET_FILE" ]; then
echo "⚠ Warning: Target file not found: $TARGET_FILE"
echo " Skipping CORS Bearer token patch"
exit 0
fi
# Check if already patched
if grep -q "Allow Bearer token authentication for CORS requests" "$TARGET_FILE"; then
echo "✓ CORSMiddleware already patched for Bearer token support"
exit 0
fi
echo "Applying patch to CORSMiddleware.php..."
# Apply the patch
cd /var/www/html
if patch -p1 --dry-run < "$PATCH_FILE" > /dev/null 2>&1; then
patch -p1 < "$PATCH_FILE"
echo "✓ Patch applied successfully"
else
echo "⚠ Warning: Patch failed to apply (may already be applied or file changed)"
echo " This is expected if using a Nextcloud version that already includes the fix"
exit 0
fi
echo ""
echo "===================================================================="
echo "✓ CORSMiddleware Bearer token patch applied"
echo "===================================================================="
echo ""
echo "Benefits:"
echo " • Bearer tokens now work with app-specific APIs (Notes, Calendar, etc.)"
echo " • OAuth/OIDC authentication works without CORS errors"
echo " • Stateless API authentication is properly supported"
echo ""
+23
View File
@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
+23
View File
@@ -0,0 +1,23 @@
apiVersion: v2
name: nextcloud-mcp-server
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
type: application
version: 0.23.0
appVersion: "0.23.0"
keywords:
- nextcloud
- mcp
- model-context-protocol
- llm
- ai
- claude
- webdav
- caldav
- carddav
maintainers:
- name: Chris Coutinho
email: chris@coutinho.io
home: https://github.com/cbcoutinho/nextcloud-mcp-server
sources:
- https://github.com/cbcoutinho/nextcloud-mcp-server
icon: https://raw.githubusercontent.com/nextcloud/server/master/core/img/logo/logo.svg
+489
View File
@@ -0,0 +1,489 @@
# Nextcloud MCP Server Helm Chart
This Helm chart deploys the Nextcloud MCP (Model Context Protocol) Server on a Kubernetes cluster, enabling AI assistants to interact with your Nextcloud instance.
## Prerequisites
- Kubernetes 1.19+
- Helm 3.0+
- A running Nextcloud instance (accessible from the Kubernetes cluster)
- Nextcloud credentials (username/password for basic auth OR OAuth client for OAuth mode)
## Installation
### Quick Start with Basic Authentication
```bash
# Install with basic auth (recommended for most users)
helm install nextcloud-mcp ./helm/nextcloud-mcp-server \
--set nextcloud.host=https://cloud.example.com \
--set auth.basic.username=myuser \
--set auth.basic.password=mypassword
```
### Using a values file
Create a `custom-values.yaml` file:
```yaml
nextcloud:
host: https://cloud.example.com
auth:
mode: basic
basic:
username: myuser
password: mypassword
resources:
limits:
cpu: 1000m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
```
Install with your custom values:
```bash
helm install nextcloud-mcp ./helm/nextcloud-mcp-server -f custom-values.yaml
```
### OAuth Authentication Mode (Experimental)
**Warning:** OAuth mode is experimental and requires patches to the Nextcloud `user_oidc` app. See the [Authentication Guide](https://github.com/cbcoutinho/nextcloud-mcp-server#authentication) for details.
```yaml
nextcloud:
host: https://cloud.example.com
mcpServerUrl: https://mcp.example.com
publicIssuerUrl: https://cloud.example.com
auth:
mode: oauth
oauth:
# Optional: provide pre-registered client credentials
# If not provided, will use Dynamic Client Registration
clientId: "your-client-id"
clientSecret: "your-client-secret"
persistence:
enabled: true
size: 100Mi
ingress:
enabled: true
className: nginx
hosts:
- host: mcp.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: nextcloud-mcp-tls
hosts:
- mcp.example.com
```
## Configuration
### Key Configuration Parameters
#### Nextcloud Connection
| Parameter | Description | Default |
|-----------|-------------|---------|
| `nextcloud.host` | URL of your Nextcloud instance (required) | `""` |
| `nextcloud.mcpServerUrl` | MCP server URL for OAuth callbacks (OAuth only, optional) | Smart default* |
| `nextcloud.publicIssuerUrl` | Public issuer URL for OAuth (OAuth only, optional) | Smart default** |
**Smart Defaults:**
- `*mcpServerUrl`: If not set, automatically uses ingress host (if enabled) or `http://localhost:8000` (for port-forward setups)
- `**publicIssuerUrl`: If not set, automatically defaults to `nextcloud.host` (which works when both clients and MCP server access Nextcloud at the same URL)
#### Authentication
| Parameter | Description | Default |
|-----------|-------------|---------|
| `auth.mode` | Authentication mode: `basic` or `oauth` | `basic` |
| `auth.basic.username` | Nextcloud username (basic auth) | `""` |
| `auth.basic.password` | Nextcloud password (basic auth) | `""` |
| `auth.basic.existingSecret` | Use existing secret for credentials | `""` |
| `auth.oauth.clientId` | OAuth client ID (OAuth mode, optional) | `""` |
| `auth.oauth.clientSecret` | OAuth client secret (OAuth mode, optional) | `""` |
| `auth.oauth.persistence.enabled` | Enable persistent storage for OAuth | `true` |
| `auth.oauth.persistence.size` | Size of OAuth storage PVC | `100Mi` |
#### MCP Server Configuration
| Parameter | Description | Default |
|-----------|-------------|---------|
| `mcp.transport` | Transport mode | `streamable-http` |
| `mcp.port` | Server port (used by both auth modes) | `8000` |
| `mcp.extraArgs` | Additional command-line arguments | `[]` |
The `extraArgs` parameter allows you to pass additional command-line arguments to the MCP server. This is useful for enabling debug logging, enabling specific apps, or other runtime configuration.
**Example:**
```yaml
mcp:
extraArgs:
- "--log-level"
- "debug"
- "--enable-app"
- "notes"
```
#### Image Configuration
| Parameter | Description | Default |
|-----------|-------------|---------|
| `image.repository` | Container image repository | `ghcr.io/cbcoutinho/nextcloud-mcp-server` |
| `image.pullPolicy` | Image pull policy | `IfNotPresent` |
**Note:** Image tag is automatically set to the chart's `appVersion` and cannot be overridden.
#### Resources
| Parameter | Description | Default |
|-----------|-------------|---------|
| `resources.limits.cpu` | CPU limit | `1000m` |
| `resources.limits.memory` | Memory limit | `512Mi` |
| `resources.requests.cpu` | CPU request | `100m` |
| `resources.requests.memory` | Memory request | `128Mi` |
#### Service
| Parameter | Description | Default |
|-----------|-------------|---------|
| `service.type` | Service type | `ClusterIP` |
| `service.port` | Service port | `8000` |
#### Ingress
| Parameter | Description | Default |
|-----------|-------------|---------|
| `ingress.enabled` | Enable ingress | `false` |
| `ingress.className` | Ingress class name | `""` |
| `ingress.hosts` | Ingress host configuration | See values.yaml |
| `ingress.tls` | Ingress TLS configuration | `[]` |
#### Autoscaling
| Parameter | Description | Default |
|-----------|-------------|---------|
| `autoscaling.enabled` | Enable HPA | `false` |
| `autoscaling.minReplicas` | Minimum replicas | `1` |
| `autoscaling.maxReplicas` | Maximum replicas | `10` |
| `autoscaling.targetCPUUtilizationPercentage` | Target CPU % | `80` |
#### Health Probes
| Parameter | Description | Default |
|-----------|-------------|---------|
| `livenessProbe.httpGet.path` | Liveness probe endpoint | `/health/live` |
| `livenessProbe.initialDelaySeconds` | Initial delay for liveness | `30` |
| `livenessProbe.periodSeconds` | Check interval for liveness | `10` |
| `readinessProbe.httpGet.path` | Readiness probe endpoint | `/health/ready` |
| `readinessProbe.initialDelaySeconds` | Initial delay for readiness | `10` |
| `readinessProbe.periodSeconds` | Check interval for readiness | `5` |
The application exposes HTTP health check endpoints:
- `/health/live` - Liveness probe (checks if application is running)
- `/health/ready` - Readiness probe (checks if application is ready to serve traffic)
#### Document Processing (Optional)
| Parameter | Description | Default |
|-----------|-------------|---------|
| `documentProcessing.enabled` | Enable document processing | `false` |
| `documentProcessing.defaultProcessor` | Default processor | `unstructured` |
| `documentProcessing.unstructured.enabled` | Enable Unstructured.io processor | `false` |
| `documentProcessing.unstructured.apiUrl` | Unstructured API URL | `http://unstructured:8000` |
| `documentProcessing.tesseract.enabled` | Enable Tesseract OCR | `false` |
## Examples
### Example 1: Basic Auth with Ingress
```yaml
nextcloud:
host: https://cloud.example.com
auth:
mode: basic
basic:
username: admin
password: secure-password
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: mcp.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: mcp-tls
hosts:
- mcp.example.com
resources:
limits:
cpu: 2000m
memory: 1Gi
requests:
cpu: 200m
memory: 256Mi
```
### Example 2: Using Existing Secrets
#### Basic Auth with Existing Secret
Create a secret manually:
```bash
kubectl create secret generic nextcloud-credentials \
--from-literal=username=myuser \
--from-literal=password=mypassword
```
Then reference it in your values:
```yaml
nextcloud:
host: https://cloud.example.com
auth:
mode: basic
basic:
existingSecret: nextcloud-credentials
usernameKey: username
passwordKey: password
```
#### OAuth with Existing Secret (Pre-registered Client)
If you have a pre-registered OAuth client:
```bash
kubectl create secret generic nextcloud-oauth-creds \
--from-literal=clientId=my-oauth-client-id \
--from-literal=clientSecret=my-oauth-client-secret
```
Then reference it in your values:
```yaml
nextcloud:
host: https://cloud.example.com
# mcpServerUrl and publicIssuerUrl are optional!
# If not set, mcpServerUrl defaults to ingress host or localhost
# publicIssuerUrl defaults to nextcloud.host
auth:
mode: oauth
oauth:
existingSecret: nextcloud-oauth-creds
clientIdKey: clientId
clientSecretKey: clientSecret
persistence:
enabled: true
ingress:
enabled: true
hosts:
- host: mcp.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: mcp-tls
hosts:
- mcp.example.com
```
### Example 3: OAuth with Document Processing and Dynamic Client Registration
This example shows OAuth without pre-registered credentials (using DCR) and optional URL values:
```yaml
nextcloud:
host: https://cloud.example.com
# mcpServerUrl will automatically use ingress host (https://mcp.example.com)
# publicIssuerUrl will automatically default to nextcloud.host
auth:
mode: oauth
oauth:
# No clientId/clientSecret - will use Dynamic Client Registration!
persistence:
enabled: true
storageClass: fast-ssd
size: 200Mi
documentProcessing:
enabled: true
defaultProcessor: unstructured
unstructured:
enabled: true
apiUrl: http://unstructured-api:8000
strategy: hi_res
languages: eng,deu,fra
ingress:
enabled: true
className: nginx
hosts:
- host: mcp.example.com
paths:
- path: /
pathType: Prefix
```
### Example 4: High Availability with Autoscaling
```yaml
replicaCount: 2
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 20
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
resources:
limits:
cpu: 2000m
memory: 1Gi
requests:
cpu: 500m
memory: 512Mi
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- nextcloud-mcp-server
topologyKey: kubernetes.io/hostname
```
## Upgrading
### To upgrade an existing deployment:
```bash
helm upgrade nextcloud-mcp ./helm/nextcloud-mcp-server -f custom-values.yaml
```
### To upgrade with new values:
```bash
helm upgrade nextcloud-mcp ./helm/nextcloud-mcp-server \
--set resources.limits.memory=1Gi
```
## Uninstalling
```bash
helm uninstall nextcloud-mcp
```
**Note:** This will delete all resources including PVCs. If you want to preserve OAuth client data, backup the PVC before uninstalling.
## Troubleshooting
### Check pod status
```bash
kubectl get pods -l app.kubernetes.io/name=nextcloud-mcp-server
```
### View logs
```bash
kubectl logs -l app.kubernetes.io/name=nextcloud-mcp-server --tail=100 -f
```
### Check health endpoints
The application exposes health check endpoints for monitoring:
```bash
# Port forward to the service
kubectl port-forward svc/nextcloud-mcp 8000:8000
# Check liveness (if app is running)
curl http://localhost:8000/health/live
# Check readiness (if app is ready to serve traffic)
curl http://localhost:8000/health/ready
```
**Example responses:**
Liveness (always returns 200 if running):
```json
{
"status": "alive",
"mode": "basic"
}
```
Readiness (returns 200 if ready, 503 if not ready):
```json
{
"status": "ready",
"checks": {
"nextcloud_configured": "ok",
"auth_mode": "basic",
"auth_configured": "ok"
}
}
```
### Common Issues
1. **Connection refused to Nextcloud**
- Verify `nextcloud.host` is accessible from the Kubernetes cluster
- Check network policies and firewall rules
2. **Authentication failures**
- For basic auth: verify username/password are correct
- For OAuth: check that OIDC app is properly configured
3. **OAuth persistence issues**
- Verify PVC is bound: `kubectl get pvc`
- Check storage class exists: `kubectl get storageclass`
4. **Resource constraints**
- Increase memory limits if seeing OOM errors
- Adjust CPU requests based on load
## Security Considerations
1. **Secrets Management**: Consider using external secret management (e.g., Sealed Secrets, External Secrets Operator)
2. **TLS**: Always use TLS/HTTPS for production deployments
3. **Network Policies**: Restrict network access to necessary services only
4. **RBAC**: Review and customize ServiceAccount permissions as needed
5. **App Passwords**: For basic auth, use Nextcloud app passwords instead of main account passwords
## Support
- GitHub Issues: https://github.com/cbcoutinho/nextcloud-mcp-server/issues
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
## License
This chart is licensed under AGPL-3.0, consistent with the Nextcloud MCP Server project.
@@ -0,0 +1,80 @@
Thank you for installing {{ .Chart.Name }}!
Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentication mode.
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "nextcloud-mcp-server.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "nextcloud-mcp-server.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "nextcloud-mcp-server.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "nextcloud-mcp-server.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your MCP server"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}
2. Check the deployment status:
kubectl --namespace {{ .Release.Namespace }} get pods -l "app.kubernetes.io/name={{ include "nextcloud-mcp-server.name" . }},app.kubernetes.io/instance={{ .Release.Name }}"
{{- if eq .Values.auth.mode "basic" }}
3. Basic Authentication Mode:
{{- if .Values.auth.basic.existingSecret }}
- Credentials: (using existing secret {{ .Values.auth.basic.existingSecret }})
{{- else }}
- Username: {{ .Values.auth.basic.username }}
- Password: (stored in secret {{ include "nextcloud-mcp-server.basicAuthSecretName" . }})
{{- end }}
- Connected to: {{ .Values.nextcloud.host }}
{{- else if eq .Values.auth.mode "oauth" }}
3. OAuth Authentication Mode:
- Server URL: {{ include "nextcloud-mcp-server.mcpServerUrl" . }}
- Issuer URL: {{ include "nextcloud-mcp-server.publicIssuerUrl" . }}
- Connected to: {{ .Values.nextcloud.host }}
{{- if .Values.auth.oauth.existingSecret }}
- Using existing OAuth client secret: {{ .Values.auth.oauth.existingSecret }}
{{- else if and .Values.auth.oauth.clientId .Values.auth.oauth.clientSecret }}
- Using pre-registered OAuth client
{{- else }}
- Using Dynamic Client Registration (DCR)
{{- end }}
{{- if .Values.auth.oauth.persistence.enabled }}
- OAuth client credentials are persisted in PVC: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
{{- end }}
IMPORTANT: OAuth mode is experimental and requires patches to the user_oidc app.
See: https://github.com/cbcoutinho/nextcloud-mcp-server#authentication
{{- end }}
{{- if .Values.documentProcessing.enabled }}
4. Document Processing:
- Enabled: {{ .Values.documentProcessing.enabled }}
- Default processor: {{ .Values.documentProcessing.defaultProcessor }}
{{- if .Values.documentProcessing.unstructured.enabled }}
- Unstructured API: {{ .Values.documentProcessing.unstructured.apiUrl }}
{{- end }}
{{- end }}
For more information and documentation:
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
To upgrade this deployment:
helm upgrade {{ .Release.Name }} nextcloud-mcp-server
To uninstall:
helm uninstall {{ .Release.Name }}
@@ -0,0 +1,142 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "nextcloud-mcp-server.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "nextcloud-mcp-server.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "nextcloud-mcp-server.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "nextcloud-mcp-server.labels" -}}
helm.sh/chart: {{ include "nextcloud-mcp-server.chart" . }}
{{ include "nextcloud-mcp-server.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "nextcloud-mcp-server.selectorLabels" -}}
app.kubernetes.io/name: {{ include "nextcloud-mcp-server.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "nextcloud-mcp-server.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "nextcloud-mcp-server.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Create the name of the secret to use for basic auth
*/}}
{{- define "nextcloud-mcp-server.basicAuthSecretName" -}}
{{- if .Values.auth.basic.existingSecret }}
{{- .Values.auth.basic.existingSecret }}
{{- else }}
{{- include "nextcloud-mcp-server.fullname" . }}-basic-auth
{{- end }}
{{- end }}
{{/*
Create the name of the secret to use for OAuth
*/}}
{{- define "nextcloud-mcp-server.oauthSecretName" -}}
{{- if .Values.auth.oauth.existingSecret }}
{{- .Values.auth.oauth.existingSecret }}
{{- else }}
{{- include "nextcloud-mcp-server.fullname" . }}-oauth
{{- end }}
{{- end }}
{{/*
Create the name of the PVC to use for OAuth storage
*/}}
{{- define "nextcloud-mcp-server.oauthPvcName" -}}
{{- if .Values.auth.oauth.persistence.existingClaim }}
{{- .Values.auth.oauth.persistence.existingClaim }}
{{- else }}
{{- include "nextcloud-mcp-server.fullname" . }}-oauth-storage
{{- end }}
{{- end }}
{{/*
Return the MCP server port
*/}}
{{- define "nextcloud-mcp-server.port" -}}
{{- .Values.mcp.port }}
{{- end }}
{{/*
Return the image tag (always uses chart appVersion)
*/}}
{{- define "nextcloud-mcp-server.imageTag" -}}
{{- .Chart.AppVersion }}
{{- end }}
{{/*
Return the public issuer URL for OAuth
Defaults to nextcloud.host if not specified
*/}}
{{- define "nextcloud-mcp-server.publicIssuerUrl" -}}
{{- if .Values.nextcloud.publicIssuerUrl }}
{{- .Values.nextcloud.publicIssuerUrl }}
{{- else }}
{{- .Values.nextcloud.host }}
{{- end }}
{{- end }}
{{/*
Return the MCP server URL for OAuth callbacks
If not specified:
- Uses ingress host if ingress is enabled
- Otherwise defaults to http://localhost:8000 (for port-forward setups)
*/}}
{{- define "nextcloud-mcp-server.mcpServerUrl" -}}
{{- if .Values.nextcloud.mcpServerUrl }}
{{- .Values.nextcloud.mcpServerUrl }}
{{- else if .Values.ingress.enabled }}
{{- $host := index .Values.ingress.hosts 0 }}
{{- if .Values.ingress.tls }}
{{- printf "https://%s" $host.host }}
{{- else }}
{{- printf "http://%s" $host.host }}
{{- end }}
{{- else }}
{{- printf "http://localhost:%d" (int .Values.mcp.port) }}
{{- end }}
{{- end }}
@@ -0,0 +1,188 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "nextcloud-mcp-server.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "nextcloud-mcp-server.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
{{- with .Values.initContainers }}
initContainers:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ include "nextcloud-mcp-server.imageTag" . }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
args:
- "--transport"
- "{{ .Values.mcp.transport }}"
{{- if eq .Values.auth.mode "oauth" }}
- "--oauth"
- "--oauth-token-type"
- "{{ .Values.auth.oauth.tokenType }}"
{{- end }}
{{- with .Values.mcp.extraArgs }}
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ include "nextcloud-mcp-server.port" . }}
protocol: TCP
env:
# Nextcloud connection
- name: NEXTCLOUD_HOST
value: {{ .Values.nextcloud.host | quote }}
{{- if eq .Values.auth.mode "basic" }}
# Basic auth mode
- name: NEXTCLOUD_USERNAME
valueFrom:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.basicAuthSecretName" . }}
key: {{ .Values.auth.basic.usernameKey }}
- name: NEXTCLOUD_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.basicAuthSecretName" . }}
key: {{ .Values.auth.basic.passwordKey }}
{{- else if eq .Values.auth.mode "oauth" }}
# OAuth mode
- name: NEXTCLOUD_MCP_SERVER_URL
value: {{ include "nextcloud-mcp-server.mcpServerUrl" . | quote }}
- name: NEXTCLOUD_PUBLIC_ISSUER_URL
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
- name: NEXTCLOUD_OIDC_SCOPES
value: {{ .Values.auth.oauth.scopes | quote }}
{{- if .Values.auth.oauth.clientId }}
- name: NEXTCLOUD_OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.oauthSecretName" . }}
key: {{ .Values.auth.oauth.clientIdKey }}
- name: NEXTCLOUD_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.oauthSecretName" . }}
key: {{ .Values.auth.oauth.clientSecretKey }}
{{- end }}
{{- end }}
{{- if .Values.documentProcessing.enabled }}
# Document processing
- name: ENABLE_DOCUMENT_PROCESSING
value: {{ .Values.documentProcessing.enabled | quote }}
- name: DOCUMENT_PROCESSOR
value: {{ .Values.documentProcessing.defaultProcessor | quote }}
- name: PROGRESS_INTERVAL
value: {{ .Values.documentProcessing.progressInterval | quote }}
{{- if .Values.documentProcessing.unstructured.enabled }}
- name: ENABLE_UNSTRUCTURED
value: "true"
- name: UNSTRUCTURED_API_URL
value: {{ .Values.documentProcessing.unstructured.apiUrl | quote }}
- name: UNSTRUCTURED_TIMEOUT
value: {{ .Values.documentProcessing.unstructured.timeout | quote }}
- name: UNSTRUCTURED_STRATEGY
value: {{ .Values.documentProcessing.unstructured.strategy | quote }}
- name: UNSTRUCTURED_LANGUAGES
value: {{ .Values.documentProcessing.unstructured.languages | quote }}
{{- end }}
{{- if .Values.documentProcessing.tesseract.enabled }}
- name: ENABLE_TESSERACT
value: "true"
{{- if .Values.documentProcessing.tesseract.cmd }}
- name: TESSERACT_CMD
value: {{ .Values.documentProcessing.tesseract.cmd | quote }}
{{- end }}
- name: TESSERACT_LANG
value: {{ .Values.documentProcessing.tesseract.lang | quote }}
{{- end }}
{{- if .Values.documentProcessing.custom.enabled }}
- name: ENABLE_CUSTOM_PROCESSOR
value: "true"
- name: CUSTOM_PROCESSOR_NAME
value: {{ .Values.documentProcessing.custom.name | quote }}
- name: CUSTOM_PROCESSOR_URL
value: {{ .Values.documentProcessing.custom.url | quote }}
{{- if .Values.documentProcessing.custom.apiKey }}
- name: CUSTOM_PROCESSOR_API_KEY
value: {{ .Values.documentProcessing.custom.apiKey | quote }}
{{- end }}
- name: CUSTOM_PROCESSOR_TIMEOUT
value: {{ .Values.documentProcessing.custom.timeout | quote }}
- name: CUSTOM_PROCESSOR_TYPES
value: {{ .Values.documentProcessing.custom.types | quote }}
{{- end }}
{{- end }}
{{- with .Values.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.extraEnvFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: tmp
mountPath: /tmp
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled }}
- name: oauth-storage
mountPath: /app/.oauth
{{- end }}
{{- with .Values.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumes:
- name: tmp
emptyDir: {}
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled }}
- name: oauth-storage
persistentVolumeClaim:
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
{{- end }}
{{- with .Values.volumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -0,0 +1,32 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "nextcloud-mcp-server.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}
@@ -0,0 +1,61 @@
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "nextcloud-mcp-server.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
pathType: {{ .pathType }}
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}
port:
number: {{ $svcPort }}
{{- else }}
serviceName: {{ $fullName }}
servicePort: {{ $svcPort }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,17 @@
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled (not .Values.auth.oauth.persistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}-oauth-storage
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.auth.oauth.persistence.accessMode }}
{{- if .Values.auth.oauth.persistence.storageClass }}
storageClassName: {{ .Values.auth.oauth.persistence.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.auth.oauth.persistence.size }}
{{- end }}
@@ -0,0 +1,29 @@
{{- if eq .Values.auth.mode "basic" }}
{{- if not .Values.auth.basic.existingSecret }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}-basic-auth
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
type: Opaque
data:
{{ .Values.auth.basic.usernameKey }}: {{ .Values.auth.basic.username | b64enc | quote }}
{{ .Values.auth.basic.passwordKey }}: {{ .Values.auth.basic.password | b64enc | quote }}
{{- end }}
{{- end }}
---
{{- if eq .Values.auth.mode "oauth" }}
{{- if and .Values.auth.oauth.clientId (not .Values.auth.oauth.existingSecret) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}-oauth
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
type: Opaque
data:
{{ .Values.auth.oauth.clientIdKey }}: {{ .Values.auth.oauth.clientId | b64enc | quote }}
{{ .Values.auth.oauth.clientSecretKey }}: {{ .Values.auth.oauth.clientSecret | b64enc | quote }}
{{- end }}
{{- end }}
@@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "nextcloud-mcp-server.selectorLabels" . | nindent 4 }}
@@ -0,0 +1,13 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "nextcloud-mcp-server.serviceAccountName" . }}
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
{{- end }}
+266
View File
@@ -0,0 +1,266 @@
# Default values for nextcloud-mcp-server
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
# Number of replicas
replicaCount: 1
image:
repository: ghcr.io/cbcoutinho/nextcloud-mcp-server
pullPolicy: IfNotPresent
# Image tag is automatically set to chart appVersion
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
# Nextcloud connection settings
nextcloud:
# URL of your Nextcloud instance (required)
# Example: https://cloud.example.com
host: ""
# MCP server URL for OAuth callbacks (OAuth mode only)
# If not specified, will be constructed from ingress.hosts[0] if ingress is enabled,
# or defaults to http://localhost:8000 (suitable for port-forward setups)
# Example: https://mcp.example.com
mcpServerUrl: ""
# Public issuer URL for OAuth (OAuth mode only)
# If not specified, defaults to nextcloud.host
# Only set this if your Nextcloud is accessible at a different URL for OAuth
# Example: https://cloud.example.com
publicIssuerUrl: ""
# Authentication configuration
# Choose either basic auth OR oauth (not both)
auth:
# Authentication mode: "basic" or "oauth"
# basic: Uses username/password (recommended for most users)
# oauth: Uses OAuth2/OIDC (experimental, requires patches)
mode: basic
# Basic authentication settings
basic:
# Nextcloud username (ignored if existingSecret is set)
username: ""
# Nextcloud password or app password (recommended) (ignored if existingSecret is set)
password: ""
# Use existing secret instead of creating one
# If set, username and password above are ignored
# Secret must contain keys specified in usernameKey and passwordKey
# Example:
# kubectl create secret generic my-nextcloud-creds \
# --from-literal=username=myuser \
# --from-literal=password=mypassword
existingSecret: ""
# Keys in the existing secret
usernameKey: "username"
passwordKey: "password"
# OAuth2/OIDC settings (experimental)
oauth:
# OAuth token type: "jwt" or "opaque"
tokenType: "jwt"
# Pre-registered OAuth client ID (optional, ignored if existingSecret is set)
# If not provided and no existingSecret, will use Dynamic Client Registration (DCR)
clientId: ""
# Pre-registered OAuth client secret (optional, ignored if existingSecret is set)
clientSecret: ""
# OAuth scopes to request (space-separated)
scopes: "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 todo:read todo:write"
# Use existing secret for OAuth client credentials
# If set, clientId and clientSecret above are ignored
# Secret must contain keys specified in clientIdKey and clientSecretKey
# Example:
# kubectl create secret generic my-oauth-creds \
# --from-literal=clientId=my-client-id \
# --from-literal=clientSecret=my-client-secret
existingSecret: ""
# Keys in the existing secret
clientIdKey: "clientId"
clientSecretKey: "clientSecret"
# Persistent storage for OAuth client credentials
persistence:
enabled: true
# Storage class (leave empty for default)
storageClass: ""
accessMode: ReadWriteOnce
size: 100Mi
# Use existing PVC
existingClaim: ""
# MCP server configuration
mcp:
# Transport mode (default: streamable-http for SSE)
transport: "streamable-http"
# Port for MCP server (both basic auth and OAuth modes)
port: 8000
# Additional command-line arguments to pass to nextcloud-mcp-server
# Example: ["--log-level", "debug", "--enable-app", "notes"]
extraArgs: []
# Document processing configuration (optional)
documentProcessing:
# Enable document processing (PDF, DOCX, images, etc.)
enabled: false
# Default processor: unstructured, tesseract, or custom
defaultProcessor: "unstructured"
# Progress reporting interval in seconds
progressInterval: 10
# Unstructured.io processor
unstructured:
enabled: false
# Unstructured API endpoint
apiUrl: "http://unstructured:8000"
# Request timeout in seconds
timeout: 120
# Parsing strategy: auto, fast, or hi_res
strategy: "auto"
# OCR languages (comma-separated ISO 639-3 codes)
languages: "eng,deu"
# Tesseract processor (local OCR)
tesseract:
enabled: false
# Path to tesseract executable (optional, auto-detected if in PATH)
cmd: ""
# OCR language (e.g., eng, deu, eng+deu for multiple)
lang: "eng"
# Custom processor
custom:
enabled: false
# Unique name for your processor
name: "my_ocr"
# Custom processor API endpoint
url: ""
# Optional API key for authentication
apiKey: ""
# Request timeout in seconds
timeout: 60
# Comma-separated MIME types your processor supports
types: "application/pdf,image/jpeg,image/png"
serviceAccount:
# Specifies whether a service account should be created
create: true
# Automatically mount a ServiceAccount's API credentials?
automount: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
podAnnotations: {}
podLabels: {}
podSecurityContext:
fsGroup: 2000
securityContext:
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
service:
type: ClusterIP
port: 8000
annotations: {}
ingress:
enabled: false
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
# cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: mcp.example.com
paths:
- path: /
pathType: Prefix
tls: []
# - secretName: nextcloud-mcp-tls
# hosts:
# - mcp.example.com
resources:
# We recommend setting resource requests and limits
limits:
cpu: 1000m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
# Liveness probe configuration
# Checks if the application process is running
livenessProbe:
httpGet:
path: /health/live
port: http
scheme: HTTP
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# Readiness probe configuration
# Checks if the application is ready to serve traffic
readinessProbe:
httpGet:
path: /health/ready
port: http
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
# Autoscaling configuration
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
# targetMemoryUtilizationPercentage: 80
# Additional volumes on the output Deployment definition.
volumes: []
# - name: foo
# secret:
# secretName: mysecret
# optional: false
# Additional volumeMounts on the output Deployment definition.
volumeMounts: []
# - name: foo
# mountPath: "/etc/foo"
# readOnly: true
nodeSelector: {}
tolerations: []
affinity: {}
# Init containers
initContainers: []
# Additional environment variables
extraEnv: []
# - name: CUSTOM_VAR
# value: "custom_value"
# Additional environment variables from ConfigMaps or Secrets
extraEnvFrom: []
# - configMapRef:
# name: my-configmap
# - secretRef:
# name: my-secret
+85 -8
View File
@@ -21,7 +21,7 @@ services:
restart: always
app:
image: docker.io/library/nextcloud:32.0.1@sha256:42a36b4711191273a9cf8cebfd35602909eb1bee461b7076d4d5a57f7ec2b81e
image: docker.io/library/nextcloud:32.0.1@sha256:1e4eae55eebe094cae6f9e7b6e0b4bccf4a4fe7b7e6f6f8f57010994b3b2ee42
restart: always
ports:
- 0.0.0.0:8080:80
@@ -30,10 +30,10 @@ services:
- db
volumes:
- nextcloud:/var/www/html
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro
- ./app-hooks:/docker-entrypoint-hooks.d:ro
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
# The post-installation hook will register /opt/apps as an additional app directory
- ./third_party/oidc:/opt/apps/oidc:ro
- ./third_party:/opt/apps:ro
environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin
@@ -43,16 +43,21 @@ services:
- MYSQL_USER=nextcloud
- MYSQL_HOST=db
- REDIS_HOST=redis
#healthcheck:
#test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"]
#interval: 10s
#timeout: 30s
#retries: 30
recipes:
image: docker.io/library/nginx:alpine@sha256:61e01287e546aac28a3f56839c136b31f590273f3b41187a36f46f6a03bbfe22
image: docker.io/library/nginx:alpine@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14
restart: always
volumes:
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
unstructured:
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:a43ab55898599157fb0e0e097dabb8ecdd1d8e3df1ae5b67c6e15a136b171a6c
restart: always
ports:
- 127.0.0.1:8002:8000
@@ -83,18 +88,90 @@ services:
ports:
- 127.0.0.1:8001:8001
environment:
# Generic OIDC configuration (integrated mode - Nextcloud OIDC app)
# OIDC_DISCOVERY_URL not set - defaults to NEXTCLOUD_HOST/.well-known/openid-configuration
# OIDC_CLIENT_ID not set - uses Dynamic Client Registration (DCR)
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth/nextcloud_oauth_client.json
- NEXTCLOUD_OIDC_SCOPES=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 todo:read todo:write
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration
# Client credentials will be registered and stored in volume on first startup
# Refresh token storage (ADR-002 Tier 1)
- ENABLE_OFFLINE_ACCESS=true
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_STORAGE_DB=/app/data/tokens.db
# NO admin credentials - using OAuth with Dynamic Client Registration (DCR)
# Client credentials registered via RFC 7591 and stored in volume
# JWT token type is used for testing (faster validation, scopes embedded in token)
volumes:
- oauth-client-storage:/app/.oauth
- oauth-tokens:/app/data
keycloak:
image: quay.io/keycloak/keycloak:26.4.2
command:
- "start-dev"
- "--import-realm"
- "--hostname=http://localhost:8888"
- "--hostname-strict=false"
- "--hostname-backchannel-dynamic=true"
- "--features=preview" # Enable Legacy V1 token exchange (supports both Standard V2 and Legacy V1)
ports:
- 127.0.0.1:8888:8080
environment:
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
volumes:
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm.json:ro
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/nextcloud-mcp HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q 'HTTP/1.1 200'"]
interval: 10s
timeout: 5s
retries: 30
mcp-keycloak:
build: .
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
restart: always
depends_on:
keycloak:
condition: service_healthy
app:
condition: service_started
ports:
- 127.0.0.1:8002:8002
environment:
# Generic OIDC configuration (external IdP mode - Keycloak)
# Provider auto-detected from OIDC_DISCOVERY_URL issuer
# Using internal Docker hostname for discovery to get consistent issuer
- OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
- OIDC_CLIENT_ID=nextcloud-mcp-server
- OIDC_CLIENT_SECRET=mcp-secret-change-in-production
- OIDC_JWKS_URI=http://keycloak:8080/realms/nextcloud-mcp/protocol/openid-connect/certs
# Nextcloud API endpoint (for accessing APIs with validated token)
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
# Refresh token storage (ADR-002 Tier 1 & 2)
- ENABLE_OFFLINE_ACCESS=true
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_STORAGE_DB=/app/data/tokens.db
# OAuth scopes (optional - uses defaults if not specified)
- NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access 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 todo:read todo:write
# NO admin credentials - using external IdP OAuth only!
volumes:
- keycloak-tokens:/app/data
- keycloak-oauth-storage:/app/.oauth
volumes:
nextcloud:
db:
oauth-client-storage:
oauth-tokens:
keycloak-tokens:
keycloak-oauth-storage:
+959
View File
@@ -0,0 +1,959 @@
# ADR-002: Vector Database Background Sync Authentication
## Status
Accepted - Tier 2 (Token Exchange with Delegation) Implemented
**Important**: Service account tokens (old Tier 1) have been rejected as they violate OAuth "act on-behalf-of" principles by creating Nextcloud user accounts for the MCP server.
## Context
To enable semantic search capabilities, the MCP server needs to index user content (notes, files, calendar events) into a vector database. This requires a background sync worker that:
1. **Runs independently** of user requests (periodic or continuous operation)
2. **Accesses multiple users' content** to build a comprehensive search index
3. **Respects user permissions** - only index content users have access to
4. **Operates in OAuth mode** - where the MCP server doesn't have traditional admin credentials
### Current OAuth Architecture
The MCP server currently operates in two authentication modes:
1. **BasicAuth Mode**: Uses username/password credentials (typically admin account)
2. **OAuth Mode**: Single OAuth client, multiple user tokens
- Users authenticate via OAuth flow
- Each request includes user's access token
- Server creates per-request `NextcloudClient` with user's bearer token
- No tokens are stored server-side
### The Challenge
Background workers need long-lived authentication to:
- Index content continuously/periodically
- Process multiple users' data in batch operations
- Operate when users are not actively making requests
However, in OAuth mode:
- User access tokens are ephemeral (exist only during request)
- MCP server doesn't store user credentials
- Admin credentials defeat the purpose of OAuth
We need an OAuth-native solution that maintains security while enabling background operations.
## Decision
We will implement a **tiered OAuth authentication strategy** for background operations in OAuth mode. When OAuth authentication is not configured or available, the background sync feature is not available.
**Note**: This ADR applies only to **OAuth mode**. In BasicAuth mode (single-user deployments), credentials are already available via environment variables, and background operations work without additional configuration.
### OAuth "Act On-Behalf-Of" Principle
**Core Requirement**: The MCP server must NEVER create its own user identity in Nextcloud when operating in OAuth mode.
**Valid Patterns**:
-**Foreground operations**: Use user's access token from MCP request (currently implemented)
-**Background operations**: Token exchange to impersonate/delegate as user (requires provider support)
-**Service account**: Creates independent identity in Nextcloud (violates OAuth principles)
**Why This Matters**:
1. **Audit Trail**: All operations must be attributable to the actual user, not a service account
2. **Stateless Server**: MCP server should not have persistent identity/state in Nextcloud
3. **Security Model**: Avoid creating "admin by another name" with broad cross-user permissions
4. **OAuth Design**: OAuth tokens represent user authorization, not server authorization
**If Token Exchange Not Available**:
- Background operations simply cannot happen in OAuth mode
- This is correct behavior - not a limitation to work around
- Don't create service accounts as "workaround" - this defeats OAuth's purpose
- Use BasicAuth mode if background operations are critical to your deployment
### Tier 1: Token Exchange with Impersonation (RFC 8693) ⚠️ **NOT IMPLEMENTED**
**Better Security** - Requires provider support for user impersonation
- Service account exchanges token to impersonate specific users
- Each background operation runs as the target user
- Uses `requested_subject` parameter in token exchange
- Per-user permission enforcement at API level
**Requirements**:
- OIDC provider supports RFC 8693 token exchange
- Provider supports user impersonation (rare - requires Legacy Keycloak V1 with preview features)
- Service account has impersonation permissions
**Status**: ⚠️ Not implemented - Keycloak Standard V2 doesn't support impersonation
**Reference**: See `docs/oauth-impersonation-findings.md` for investigation details
### Tier 2: Token Exchange with Delegation (RFC 8693) ✅ **IMPLEMENTED**
**Best Security** - Requires provider support for delegation with `act` claim
- Service account exchanges token on behalf of users (delegation, not impersonation)
- Token includes `act` claim showing service account as actor
- API sees both the user (`sub`) and actor (`act`) in token
- Full audit trail of delegated operations
- **Implementation**: `KeycloakOAuthClient.exchange_token_for_user()` (keycloak_oauth.py:397-495)
- **Testing**: Manual test in `tests/manual/test_token_exchange.py`
- **Limitation**: Keycloak doesn't support `act` claim yet - [Issue #38279](https://github.com/keycloak/keycloak/issues/38279)
**Requirements**:
- OIDC provider supports RFC 8693 token exchange
- Provider supports delegation with `act` claim (very rare)
- Proper token exchange permissions configured
**Current Implementation**: Internal-to-internal token exchange with audience modification (without `act` claim)
### ❌ Will Not Implement
**1. Service Account with Independent Identity (client_credentials)**
- **Status**: Previously proposed as Tier 1, now rejected
- **Why Invalid**: Creates Nextcloud user account for MCP server (e.g., `service-account-nextcloud-mcp-server`)
- **Problems**:
- **Violates OAuth "act on-behalf-of" principle**: Actions attributed to service account instead of real user
- **Breaks audit trail**: Can't determine which user initiated the action
- **Creates stateful server identity**: MCP server has persistent identity/data in Nextcloud
- **Security risk**: Service account becomes "admin by another name" with broad cross-user permissions
- **User provisioning side effect**: Nextcloud's `user_oidc` app auto-provisions service account as real user
- **Code Status**: Implementation exists (`KeycloakOAuthClient.get_service_account_token()`) but marked with warnings
- **Alternative**: If service account pattern truly needed, use BasicAuth mode instead of OAuth mode
- **Reference**: See commit c12df98 for detailed analysis of why this approach was rejected
**2. Offline Access with Refresh Tokens**
- **MCP Protocol Architecture**: FastMCP SDK manages OAuth where MCP Client handles refresh tokens
- **Security Model**: Refresh tokens must never be shared between client and server (OAuth best practice)
- **Technical Impossibility**: MCP Server has no access to refresh tokens from the OAuth callback
- **Alternative**: Token exchange provides similar benefits without violating OAuth security model
**3. Admin Credentials Fallback**
- **Out of Scope**: This ADR focuses on OAuth mode only
- **Not Appropriate**: Admin credentials bypass OAuth security model
- **BasicAuth Mode**: For single-user deployments needing background operations, use BasicAuth mode instead
### Key Architectural Principles
1. **Capability Detection**: Automatically detect which OAuth methods are supported
2. **Dual-Phase Authorization**:
- Sync worker indexes with service credentials
- User requests verify access with user's OAuth token
3. **Defense in Depth**: Vector database is search accelerator, not security boundary
4. **Separation of Concerns**: Sync credentials ≠ Request credentials
## Implementation Details
### 1. Token Exchange with Impersonation (Tier 1) ✅ IMPLEMENTED (Legacy V1 only)
**Status**: Implemented and working with Keycloak Legacy V1 (`--features=preview`). Requires additional permission configuration. Recommended for advanced use cases only.
**When to Use**: When you need the exchanged token to have the exact same identity as the target user (sub claim changes). This provides the cleanest separation but requires preview features.
#### 1.1 Impersonation Flow
```python
async def exchange_token_for_user(
subject_token: str,
target_user_id: str,
audience: str | None = None,
scopes: list[str] | None = None,
) -> dict:
"""Exchange service token to impersonate specific user.
Requires Keycloak Legacy V1 (--features=preview) and impersonation permissions.
The returned token will have the target_user_id as the 'sub' claim.
"""
data = {
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": subject_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_subject": target_user_id, # ← KEY: Impersonate this user
}
if audience:
data["audience"] = audience
if scopes:
data["scope"] = " ".join(scopes)
response = await self._http_client.post(
self.token_endpoint,
data=data,
auth=(self.client_id, self.client_secret),
)
response.raise_for_status()
return response.json()
```
**Implementation Requirements**:
- ✅ Keycloak Legacy V1 with `--features=preview` flag
- ✅ Impersonation role granted to service account (see configuration below)
- ❌ NOT supported in Keycloak Standard V2 (rejects `requested_subject` parameter)
- ⚠️ Very few OIDC providers support user impersonation via token exchange
**Empirical Testing (2025-11-02)**:
Tested impersonation with `requested_subject` parameter against Keycloak 26.4.2:
**Test Command**: `uv run python tests/manual/test_impersonation.py`
**Keycloak Standard V2 Result**:
```
HTTP/1.1 400 Bad Request
{
"error": "invalid_request",
"error_description": "Parameter 'requested_subject' is not supported for standard token exchange"
}
```
**Confirmation**: Keycloak explicitly rejects `requested_subject` in Standard V2, confirming this feature is unsupported. The error message is unambiguous - this parameter is not available in the current production token exchange implementation.
**Keycloak Legacy V1 Result - Initial Test** (with `--features=preview`):
```
HTTP/1.1 403 Forbidden
{
"error": "access_denied",
"error_description": "Client not allowed to exchange"
}
Keycloak logs:
reason="subject not allowed to impersonate"
impersonator="service-account-nextcloud-mcp-server"
requested_subject="admin"
```
**Analysis**: Legacy V1 **accepts** the `requested_subject` parameter (error changed from "not supported" to "not allowed"), indicating the feature is present but requires permission configuration.
**Configuration Steps to Enable Impersonation**:
1. **Enable Keycloak preview features** (in docker-compose.yml):
```yaml
command:
- "start-dev"
- "--features=preview" # Required for Legacy V1 token exchange
```
2. **Grant impersonation role to service account** (using Keycloak CLI):
```bash
docker compose exec keycloak /opt/keycloak/bin/kcadm.sh config credentials \
--server http://localhost:8080 \
--realm master \
--user admin \
--password admin
docker compose exec keycloak /opt/keycloak/bin/kcadm.sh add-roles \
-r nextcloud-mcp \
--uusername service-account-nextcloud-mcp-server \
--cclientid realm-management \
--rolename impersonation
```
**Keycloak Legacy V1 Result - After Permission Grant**:
```
✅ Token exchange with impersonation SUCCEEDED!
📊 Response details:
Issued token type: urn:ietf:params:oauth:token-type:access_token
Token type: Bearer
Expires in: 300s
📋 Token claims analysis:
Subject (sub): 47c3ba5a-9104-45e0-b84e-0e39ab942c9c (admin user)
Preferred username: admin
Client ID (azp): nextcloud-mcp-server
✅ IMPERSONATION VERIFIED:
Original sub: service-account-nextcloud-mcp-server
New sub: 47c3ba5a-9104-45e0-b84e-0e39ab942c9c
➡️ The subject claim CHANGED - impersonation worked!
```
**Nextcloud API Validation**:
The impersonated token successfully authenticated with Nextcloud APIs, confirming the token is valid and properly represents the target user.
**Implementation Status**: Impersonation **IS IMPLEMENTED** and working with Keycloak Legacy V1. The implementation has been tested and verified to work correctly when properly configured.
**Production Considerations**:
- ⚠️ Requires preview features (`--features=preview`) - not production-ready
- ⚠️ Requires Legacy V1 token exchange (may be deprecated in future Keycloak versions)
- ⚠️ Requires manual CLI configuration for each service account
- ⚠️ More complex permission model compared to delegation
**When to Use Tier 1 (Impersonation)**:
- ✅ You need the exchanged token to have the exact same identity as the target user
- ✅ You want the cleanest separation (sub claim changes completely)
- ✅ Your environment can support preview features
- ✅ You have operational processes to manage impersonation permissions
**Recommendation**: For most use cases, use Tier 2 (Delegation) instead. It provides equivalent "act on-behalf-of" capability using production-ready Standard V2 token exchange. Use Tier 1 only when you specifically need identity impersonation.
**Test Scripts**:
- `tests/manual/test_impersonation.py` - Complete impersonation test with validation
- `tests/manual/configure_impersonation.py` - Automated permission configuration helper
- **See**: `docs/oauth-impersonation-findings.md` for detailed investigation
### 2. Token Exchange with Delegation (Tier 2) ✅ IMPLEMENTED (Standard V2)
**Status**: Implemented and working with Keycloak Standard V2 (production-ready). This is the **recommended** approach for most use cases.
**When to Use**: When you need "act on-behalf-of" functionality with production-ready features. The service account maintains its identity (sub claim unchanged) but acts on behalf of the user. Fully supported in Keycloak Standard V2 without preview features.
#### 2.1 Capability Detection
```python
async def check_token_exchange_support(discovery_url: str) -> bool:
"""Check if OIDC provider supports RFC 8693 token exchange"""
async with httpx.AsyncClient() as client:
response = await client.get(discovery_url)
discovery = response.json()
# Check for token exchange grant type
grant_types = discovery.get("grant_types_supported", [])
return "urn:ietf:params:oauth:grant-type:token-exchange" in grant_types
```
#### 2.2 Delegation Token Exchange
```python
async def exchange_for_user_token(
service_token: str,
target_user_id: str,
audience: str,
scopes: list[str]
) -> str:
"""Exchange service token for user-scoped token via RFC 8693"""
async with httpx.AsyncClient() as client:
response = await client.post(
token_endpoint,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": service_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"audience": audience, # Target resource server (e.g., "nextcloud")
"scope": " ".join(scopes)
},
auth=(client_id, client_secret)
)
if response.status_code != 200:
logger.warning(f"Token exchange failed: {response.status_code}")
raise TokenExchangeNotSupportedError()
return response.json()["access_token"]
```
**Implementation**: `KeycloakOAuthClient.exchange_token_for_user()` (keycloak_oauth.py:397-495)
**Note**: Full delegation with `act` claim requires provider support that is currently very rare. Keycloak tracking: [Issue #38279](https://github.com/keycloak/keycloak/issues/38279)
### 3. Comparison: When to Use Each Tier
| Feature | Tier 1: Impersonation | Tier 2: Delegation (Recommended) |
|---------|----------------------|-----------------------------------|
| **Status** | ✅ Implemented (Legacy V1) | ✅ Implemented (Standard V2) |
| **Token Identity** | Target user (`sub` changes) | Service account (`sub` unchanged) |
| **Keycloak Version** | Legacy V1 (`--features=preview`) | Standard V2 (production-ready) |
| **Setup Complexity** | High (manual permissions) | Low (automatic) |
| **Production Ready** | ⚠️ Preview features required | ✅ Fully production-ready |
| **Permission Grant** | Manual CLI per service account | Automatic via token exchange |
| **Audit Trail** | Shows as target user | Shows as service account acting for user |
| **Token Claims** | `sub: user-id` | `sub: service-account-id` |
| **Provider Support** | Rare (Keycloak Legacy V1 only) | Common (Keycloak, Auth0, Okta) |
| **Use Case** | Need exact user identity | Standard OAuth workflows |
| **Recommendation** | Advanced use only | **Default choice** |
**Decision Guide**:
- ✅ **Use Tier 2 (Delegation)** for:
- Production deployments
- Standard OAuth workflows
- Clear audit trails (service account visible)
- Maximum provider compatibility
- ⚠️ **Use Tier 1 (Impersonation)** only if:
- You specifically need exact user identity (sub claim must match)
- You can accept preview/experimental features
- You have operational processes for permission management
- Your IdP supports `requested_subject` parameter
### 4. Sync Worker with Tiered Authentication
```python
# nextcloud_mcp_server/sync_worker.py
class VectorSyncWorker:
"""Background worker for indexing content into vector database"""
def __init__(self):
self.auth_method = None
self.oauth_client = None # KeycloakOAuthClient or similar
self.vector_service = None
async def initialize(self):
"""Detect and configure authentication method"""
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
try:
self.oauth_client = KeycloakOAuthClient.from_env()
await self.oauth_client.discover()
# Verify service account access (Tier 1)
service_token = await self.oauth_client.get_service_account_token()
logger.info("✓ Service account token acquired")
# Check if token exchange is supported (Tier 2/3)
if await check_token_exchange_support(self.oauth_client.discovery_url):
self.auth_method = "token_exchange_delegation"
logger.info(
"✓ Token exchange supported (RFC 8693) - will use delegation for user-scoped operations"
)
else:
self.auth_method = "service_account"
logger.info(
" Token exchange not supported - using service account token for all operations"
)
except Exception as e:
logger.error(f"Failed to initialize OAuth authentication: {e}")
raise RuntimeError(
"OAuth authentication is required for background sync. "
"Either configure OIDC_CLIENT_ID/OIDC_CLIENT_SECRET with service account enabled, "
"or use BasicAuth mode for single-user deployments."
) from e
async def get_user_client(self, user_id: str) -> NextcloudClient:
"""Get authenticated client for user based on auth method"""
if self.auth_method == "token_exchange_delegation":
# Tier 2/3: Get service token and exchange for user-scoped token
service_token_data = await self.oauth_client.get_service_account_token()
user_token_data = await self.oauth_client.exchange_token_for_user(
subject_token=service_token_data["access_token"],
target_user_id=user_id,
audience="nextcloud",
scopes=["notes:read", "files:read", "calendar:read"]
)
return NextcloudClient.from_token(
base_url=nextcloud_host,
token=user_token_data["access_token"],
username=user_id
)
elif self.auth_method == "service_account":
# Tier 1: Use service account token directly (no user scoping)
service_token_data = await self.oauth_client.get_service_account_token()
return NextcloudClient.from_token(
base_url=nextcloud_host,
token=service_token_data["access_token"],
username="service-account"
)
raise RuntimeError(f"Unknown auth method: {self.auth_method}")
async def sync_user_content(self, user_id: str):
"""Index a user's content into vector database"""
try:
# Get authenticated client for this user
client = await self.get_user_client(user_id)
# Sync notes
notes = await client.notes.list_notes()
for note in notes:
embedding = await self.vector_service.embed(note.content)
await self.vector_service.upsert(
collection="nextcloud_content",
id=f"note_{note.id}",
vector=embedding,
metadata={
"user_id": user_id,
"content_type": "note",
"note_id": note.id,
"title": note.title,
"category": note.category
}
)
logger.info(f"Synced {len(notes)} notes for user: {user_id}")
except Exception as e:
logger.error(f"Failed to sync user {user_id}: {e}")
async def run(self):
"""Main sync loop"""
await self.initialize()
while True:
try:
# Get list of users to sync
# Implementation depends on how you track authenticated users
# Options:
# - Audit logs of MCP authentication events
# - MCP session history
# - Configured user list
# - If using service account with broad permissions: list all users
user_ids = await self.get_active_users()
logger.info(f"Syncing content for {len(user_ids)} users")
for user_id in user_ids:
await self.sync_user_content(user_id)
logger.info("Sync complete, sleeping...")
await asyncio.sleep(300) # 5 minutes
except Exception as e:
logger.error(f"Sync failed: {e}")
await asyncio.sleep(60) # Retry after 1 minute
```
### 4. User Request Verification (Dual-Phase Authorization)
```python
@mcp.tool()
@require_scopes("notes:read")
async def nc_notes_semantic_search(
query: str,
ctx: Context,
limit: int = 10
) -> SemanticSearchResponse:
"""Semantic search with permission verification"""
# Get user's OAuth client (uses their access token from request)
user_client = get_client(ctx)
username = user_client.username
# Phase 1: Vector search (fast, may include false positives)
embedding = await vector_service.embed(query)
candidate_results = await qdrant.search(
collection_name="nextcloud_content",
query_vector=embedding,
query_filter={
"must": [
{
"should": [
{"key": "user_id", "match": {"value": username}},
{"key": "shared_with", "match": {"any": [username]}}
]
},
{"key": "content_type", "match": {"value": "note"}}
]
},
limit=limit * 2 # Get extra candidates
)
# Phase 2: Verify access via Nextcloud API (authoritative)
verified_results = []
for candidate in candidate_results:
note_id = candidate.payload["note_id"]
try:
# This uses user's OAuth token - will fail if no access
note = await user_client.notes.get_note(note_id)
verified_results.append({
"note": note,
"score": candidate.score
})
if len(verified_results) >= limit:
break
except HTTPStatusError as e:
if e.response.status_code == 403:
# User doesn't have access - skip silently
logger.debug(f"Filtered out note {note_id} for {username}")
continue
raise
return SemanticSearchResponse(results=verified_results)
```
### 5. Security Implementation
#### 5.1 Service Account Credentials Protection
```python
# Store OAuth client credentials securely
# NEVER commit to source control
# Option 1: Environment variables (for development)
export OIDC_CLIENT_ID="nextcloud-mcp-server"
export OIDC_CLIENT_SECRET="<secure-secret>"
# Option 2: Secrets manager (for production)
import boto3
secrets = boto3.client('secretsmanager')
secret = secrets.get_secret_value(SecretId='nextcloud-mcp-oauth')
client_secret = json.loads(secret['SecretString'])['client_secret']
# Option 3: Encrypted storage (for self-hosted)
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
storage = RefreshTokenStorage.from_env()
await storage.initialize()
# Client credentials are encrypted at rest using Fernet
client_data = await storage.get_oauth_client()
```
#### 5.2 Token Lifecycle Management
```python
async def manage_service_token_lifecycle():
"""Cache and refresh service account tokens"""
# Cache service token (avoid repeated requests)
cached_token = None
token_expires_at = 0
async def get_fresh_service_token() -> str:
nonlocal cached_token, token_expires_at
now = time.time()
# Return cached token if still valid (with 5-minute buffer)
if cached_token and now < (token_expires_at - 300):
return cached_token
# Request new token
token_data = await oauth_client.get_service_account_token()
cached_token = token_data["access_token"]
token_expires_at = now + token_data.get("expires_in", 3600)
logger.info("Service account token refreshed")
return cached_token
return get_fresh_service_token
```
#### 5.3 Audit Logging
```python
async def audit_log(
event: str,
user_id: str,
resource_type: str,
resource_id: str,
auth_method: str
):
"""Log sync operations for audit trail"""
await audit_db.execute(
"INSERT INTO audit_logs VALUES (?, ?, ?, ?, ?, ?, ?)",
(
int(time.time()),
event, # "index_note", "index_file"
user_id,
resource_type,
resource_id,
auth_method,
socket.gethostname()
)
)
```
### 6. Configuration
#### 6.1 Environment Variables
```bash
# OAuth Configuration (Required for Background Sync in OAuth Mode)
# Requires external OIDC provider with client_credentials support
OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
OIDC_CLIENT_ID=nextcloud-mcp-server
OIDC_CLIENT_SECRET=<secure-secret>
NEXTCLOUD_HOST=http://app:80
# Tier selection is automatic:
# - Tier 1 (service_account): Always available if client has service account enabled
# - Tier 2/3 (token_exchange): Used if provider supports RFC 8693 token exchange
# Vector Database
QDRANT_URL=http://qdrant:6333
QDRANT_API_KEY=<api-key>
# Sync Configuration
SYNC_INTERVAL_SECONDS=300
SYNC_BATCH_SIZE=100
# Note: For BasicAuth mode (single-user), background sync uses NEXTCLOUD_USERNAME/NEXTCLOUD_PASSWORD
# This ADR focuses on OAuth mode only
```
#### 6.2 Keycloak Configuration (for Token Exchange)
**Client Settings** (`nextcloud-mcp-server`):
```json
{
"clientId": "nextcloud-mcp-server",
"serviceAccountsEnabled": true,
"authorizationServicesEnabled": false,
"attributes": {
"token.exchange.grant.enabled": "true",
"client.token.exchange.standard.enabled": "true"
}
}
```
**Service Account Roles**:
- Assign appropriate Nextcloud roles/scopes to the service account
- Configure token exchange permissions
#### 6.3 Docker Compose
```yaml
services:
mcp-sync:
build: .
command: ["python", "-m", "nextcloud_mcp_server.sync_worker"]
environment:
- NEXTCLOUD_HOST=http://app:80
# External OIDC provider (Keycloak)
- OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
- OIDC_CLIENT_ID=nextcloud-mcp-server
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
# Vector database
- QDRANT_URL=http://qdrant:6333
- QDRANT_API_KEY=${QDRANT_API_KEY}
volumes:
- sync-data:/app/data # For OAuth client credential storage
depends_on:
- app
- keycloak
- qdrant
volumes:
sync-data: # Persistent storage for encrypted OAuth client credentials
```
## Consequences
### Benefits
1. **OAuth-Native Authentication**
- Leverages standard OAuth flows (offline_access, token exchange)
- No reliance on admin passwords in production
- Compatible with enterprise OIDC providers
2. **User-Level Permissions**
- Each user's content indexed with their own credentials
- Respects sharing, permissions, and access controls
- Full audit trail of which user's token was used
3. **Security**
- Tokens encrypted at rest
- Short-lived access tokens (refreshed as needed)
- Token rotation support
- Defense in depth with dual-phase authorization
4. **Flexibility**
- Automatic capability detection
- Graceful degradation through authentication tiers
- Works with varying OIDC provider capabilities
5. **Operational**
- Background sync independent of user activity
- Efficient batch processing
- Clear separation of sync vs request credentials
### Limitations
1. **Complexity**
- Multiple authentication paths to maintain
- Token storage and encryption infrastructure
- More moving parts than simple admin auth
2. **User Experience**
- `offline_access` scope may require additional consent
- Users must authenticate at least once for indexing
- New users not automatically indexed
3. **OIDC Provider Dependency**
- Token exchange requires RFC 8693 support (rare)
- Refresh token rotation varies by provider
- Some providers may not support offline_access
4. **Operational Overhead**
- Token database maintenance
- Monitoring token expiration
- Handling revoked tokens gracefully
### Security Considerations
#### Threat Model
**Threat 1: Token Storage Breach**
- **Mitigation**: Encryption at rest using Fernet
- **Mitigation**: Secure key management (secrets manager)
- **Mitigation**: Minimal token lifetime
- **Detection**: Audit logs for unusual access patterns
**Threat 2: Token Replay**
- **Mitigation**: Short-lived access tokens (refreshed frequently)
- **Mitigation**: Token rotation on each refresh
- **Mitigation**: Revocation support
**Threat 3: Privilege Escalation**
- **Mitigation**: Dual-phase authorization (vector DB + Nextcloud API)
- **Mitigation**: Sync worker uses same scopes as user requests
- **Mitigation**: Per-user token isolation
**Threat 4: Vector Database Poisoning**
- **Mitigation**: User requests always verify via Nextcloud API
- **Mitigation**: Vector DB is cache/accelerator, not source of truth
- **Mitigation**: Sync operations audited per user
#### Security Best Practices
1. **OAuth Client Secret Management**
```bash
# Store in secrets manager (Vault, AWS Secrets Manager, etc.)
# Or use environment variable with restricted permissions
# For self-hosted: Use encrypted storage
# OAuth client credentials stored in SQLite with Fernet encryption
# Encryption key: TOKEN_ENCRYPTION_KEY environment variable
# Generate encryption key:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
2. **Service Account Token Lifecycle**
- Cache service tokens to minimize requests (with expiry buffer)
- Automatically refresh expired tokens
- Use short-lived tokens (provider default, typically 1 hour)
- Monitor token request rates and failures
3. **Database Permissions (for Client Credential Storage)**
```bash
# Restrict database file permissions
chmod 600 /app/data/tokens.db
chown mcp-server:mcp-server /app/data/tokens.db
```
4. **Monitoring and Alerting**
- Alert on token exchange failures
- Monitor for unusual access patterns
- Track service account token usage
- Audit sync operations per user (if delegation supported)
### Future Enhancements
1. **Token Revocation Handling**
- Webhook endpoint for token revocation events
- Periodic validation of stored tokens
- Graceful handling of revoked tokens
2. **Selective Sync**
- Allow users to opt-in/opt-out of indexing
- Per-content-type sync preferences
- Privacy controls for sensitive content
3. **Multi-Tenant Token Storage**
- Separate token databases per tenant
- Key rotation per tenant
- Tenant isolation
4. **Token Lifecycle Management**
- Automatic cleanup of expired tokens
- Token usage analytics
- Token health dashboard
5. **Alternative OAuth Flows**
- Device flow for headless sync
- Resource owner password credentials (ROPC) as fallback
- SAML assertion grants
## Alternatives Considered
### Alternative 1: Admin BasicAuth Only
**Approach**: Background worker always uses admin credentials
**Pros**:
- Simple implementation
- No token storage complexity
- Works with any authentication backend
**Cons**:
- Violates principle of least privilege
- Single powerful credential
- No per-user audit trail
- Bypasses OAuth entirely
**Decision**: Rejected for production use; kept as fallback only
### Alternative 2: Client Credentials Grant Only
**Approach**: Service account with broad read permissions
**Pros**:
- OAuth-native pattern
- No user token storage
- Standard OAuth flow
**Cons**:
- Requires client_credentials support (may not be available)
- Still needs broad cross-user permissions
- Not well-suited for multi-user indexing
**Decision**: Rejected; token exchange is better fit for multi-user scenario
### Alternative 3: Per-User Access Token Storage
**Approach**: Store user access tokens (not refresh tokens)
**Pros**:
- Simpler than refresh token flow
- No token refresh logic needed
**Cons**:
- Access tokens are short-lived (1-24 hours)
- Requires frequent re-authentication
- Poor user experience
- Sync gaps when tokens expire
**Decision**: Rejected; refresh tokens provide better UX
### Alternative 4: On-Demand Indexing Only
**Approach**: Index content when user searches (no background worker)
**Pros**:
- Uses user's request token
- No background auth needed
- Simpler architecture
**Cons**:
- Very slow first search
- Poor user experience
- Incomplete index
- Can't pre-compute embeddings
**Decision**: Rejected; background indexing is essential for semantic search
### Alternative 5: Nextcloud App Tokens
**Approach**: Generate app-specific passwords for each user
**Pros**:
- Nextcloud-native feature
- User-controlled revocation
- Scoped per-application
**Cons**:
- Requires user interaction to create
- May not support programmatic creation
- Still requires secure storage
- Not standard OAuth
**Decision**: Rejected; not automatable for background worker
## Related Decisions
- ADR-001: Enhanced Note Search (establishes need for vector search)
- [Future] ADR-003: Vector Database Selection
- [Future] ADR-004: Embedding Model Strategy
## References
- [RFC 8693: OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693)
- [RFC 6749: OAuth 2.0 - Refresh Tokens](https://datatracker.ietf.org/doc/html/rfc6749#section-1.5)
- [OpenID Connect Core - Offline Access](https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess)
- [OWASP: OAuth Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/OAuth2_Cheat_Sheet.html)
- [RFC 8707: Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707)
File diff suppressed because it is too large Load Diff
+521
View File
@@ -0,0 +1,521 @@
# Audience Validation Setup
## Overview
This document explains the **separate clients architecture** for Keycloak → MCP Server → Nextcloud integration, following OAuth 2.0 best practices and RFC 8707 (Resource Indicators).
## Architecture: Separate Clients Pattern
```
Keycloak Realm: nextcloud-mcp
├── Client: "nextcloud" (Resource Server)
│ └── Represents Nextcloud as a protected resource
│ └── Used by user_oidc for bearer token validation
│ └── Validates tokens with aud="nextcloud"
└── Client: "nextcloud-mcp-server" (OAuth Client)
└── MCP Server uses this to REQUEST tokens
└── Issues tokens with aud="nextcloud" (targeting resource)
└── Future: aud=["nextcloud", "other-service"]
Token Flow:
MCP Server (client: nextcloud-mcp-server)
↓ requests token from Keycloak
Token issued:
- aud: "nextcloud" (intended for Nextcloud resource)
- azp: "nextcloud-mcp-server" (requested by MCP Server)
- preferred_username: "admin" (on behalf of user)
↓ sent to Nextcloud API
Nextcloud user_oidc (client: nextcloud)
✓ validates aud matches configured client_id
```
**Key Benefits**:
-**Proper OAuth separation**: OAuth client ≠ resource server
-**Future extensibility**: MCP Server can request multi-resource tokens
-**RFC 8707 compliance**: Audience indicates intended resource
-**Clear requester identification**: azp claim identifies MCP Server
## Token Claims
Tokens issued by the `nextcloud-mcp-server` client contain:
- **`aud: "nextcloud"`** - Audience: Token intended for Nextcloud resource server (matches user_oidc client_id)
- **`azp: "nextcloud-mcp-server"`** - Authorized Party: Identifies MCP Server as the OAuth client that requested the token
- **`preferred_username: "admin"`** - User identifier (Keycloak uses this for password grant; `sub` for authorization_code grant)
- **`scope: "openid profile email offline_access"`** - Requested scopes including offline access for background jobs
**How user_oidc Validates**:
1. SelfEncodedValidator checks: `aud == user_oidc.client_id`?
- ✓ "nextcloud" == "nextcloud" → PASS
2. Fast JWT verification with JWKS (no HTTP call to userinfo endpoint)
3. User provisioned based on `preferred_username` or `sub` claim
**For Background Jobs**:
- MCP Server stores encrypted refresh tokens
- Refreshes access tokens when needed
- All tokens have `aud: "nextcloud"` → validated by user_oidc
- No admin credentials required
## Configuration
The configuration requires **two separate clients** in Keycloak:
1. **`nextcloud`** - Resource server client (for user_oidc validation)
2. **`nextcloud-mcp-server`** - OAuth client (for MCP Server to request tokens)
### 1. Keycloak - Create Resource Server Client
First, create the `nextcloud` client that represents Nextcloud as a resource server:
**Via Keycloak Admin API:**
```bash
# Get admin token
ADMIN_TOKEN=$(curl -X POST "http://localhost:8888/realms/master/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=admin-cli" \
-d "username=admin" \
-d "password=admin" | jq -r '.access_token')
# Create 'nextcloud' resource server client
curl -X POST "http://localhost:8888/admin/realms/nextcloud-mcp/clients" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"clientId": "nextcloud",
"name": "Nextcloud Resource Server",
"description": "Resource server for Nextcloud APIs - used by user_oidc for bearer token validation",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "nextcloud-secret-change-in-production",
"bearerOnly": true,
"standardFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"publicClient": false
}'
```
**Via Realm Export** (`keycloak/realm-export.json`):
```json
{
"clients": [
{
"clientId": "nextcloud",
"name": "Nextcloud Resource Server",
"enabled": true,
"bearerOnly": true,
"secret": "nextcloud-secret-change-in-production"
}
]
}
```
### 2. Keycloak - Create OAuth Client with Audience Mapper
Next, create the `nextcloud-mcp-server` client that MCP Server uses to request tokens:
**Via Keycloak Admin API:**
```bash
# Create 'nextcloud-mcp-server' OAuth client
curl -X POST "http://localhost:8888/admin/realms/nextcloud-mcp/clients" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"clientId": "nextcloud-mcp-server",
"name": "Nextcloud MCP Server",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "mcp-secret-change-in-production",
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"redirectUris": ["http://localhost:*/callback"]
}'
# Get client internal ID
CLIENT_ID=$(curl "http://localhost:8888/admin/realms/nextcloud-mcp/clients" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[] | select(.clientId=="nextcloud-mcp-server") | .id')
# Add audience mapper targeting 'nextcloud' resource
curl -X POST "http://localhost:8888/admin/realms/nextcloud-mcp/clients/$CLIENT_ID/protocol-mappers/models" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "audience-nextcloud",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "nextcloud",
"access.token.claim": "true",
"id.token.claim": "false"
}
}'
```
**Option B: Via Realm Export** (for infrastructure-as-code)
Update `keycloak/realm-export.json`:
```json
{
"clients": [
{
"clientId": "nextcloud-mcp-server",
"name": "Nextcloud MCP Server",
"protocolMappers": [
{
"name": "audience-nextcloud-mcp-server",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "nextcloud-mcp-server",
"access.token.claim": "true",
"id.token.claim": "false"
}
}
]
}
]
}
```
Then re-import realm or restart Keycloak.
**Option C: Via Keycloak Admin UI**
1. Go to Keycloak Admin Console → Realm → Clients → `nextcloud-mcp-server`
2. Click "Client scopes" tab
3. Click "Add client scope" → "Create dedicated scope"
4. Add protocol mapper: "Audience"
- Mapper Type: `Audience`
- Included Custom Audience: `nextcloud`
- Add to access token: ON
- Add to ID token: OFF
### 3. Nextcloud user_oidc - Configure Resource Server Client
Configure user_oidc to use the `nextcloud` resource server client:
```bash
docker compose exec app php occ user_oidc:provider keycloak \
--clientid="nextcloud" \
--clientsecret="nextcloud-secret-change-in-production" \
--discoveryuri="http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration" \
--check-bearer=1 \
--bearer-provisioning=1 \
--unique-uid=1 \
--mapping-uid="sub" \
--mapping-display-name="name" \
--mapping-email="email"
```
**Result**: user_oidc validates tokens with `aud="nextcloud"` using SelfEncodedValidator (fast JWT verification).
### 3. Nextcloud user_oidc - Realm-Level Validation
Nextcloud's `user_oidc` app validates at **realm level** via userinfo endpoint:
-**No configuration needed** - works automatically
- ✅ Validates any token from Keycloak realm
- ✅ Audience check is **optional** (disabled by default)
**Optional: Disable strict audience checking** (if enabled):
```bash
docker compose exec app php occ config:app:set user_oidc \
selfencoded_bearer_validation_audience_check --value=false --type=boolean
```
## Verification
### 1. Check Token Claims
```bash
# Get token from Keycloak
TOKEN=$(curl -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=nextcloud-mcp-server" \
-d "client_secret=mcp-secret-change-in-production" \
-d "username=admin" \
-d "password=admin" | jq -r '.access_token')
# Decode JWT
echo $TOKEN | cut -d'.' -f2 | base64 -d | jq '.'
# Should show:
{
"aud": "nextcloud", # ✓ Intended for Nextcloud
"azp": "nextcloud-mcp-server", # ✓ Requested by MCP Server
"iss": "http://localhost:8888/realms/nextcloud-mcp",
"scope": "openid email profile offline_access",
...
}
```
### 2. Test with Nextcloud API
```bash
# Token should be accepted
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/ocs/v2.php/cloud/capabilities"
# Should return HTTP 200 OK
```
### 3. Test Audience Rejection
```bash
# Get token from different client (without audience mappers)
TOKEN_WRONG=$(curl -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=test-client-b" \
-d "client_secret=test-secret-b" \
-d "username=admin" \
-d "password=admin" | jq -r '.access_token')
# This token has NO audience claim - should be rejected by MCP server
# (But accepted by Nextcloud user_oidc which validates at realm level)
```
## Token Flow Example
### Successful Request (Background Job)
```
1. User authorizes MCP Client via OAuth
└─ MCP Server gets refresh token (stored encrypted)
2. Background worker needs to sync data
└─ MCP Server refreshes access token from Keycloak
└─ Token issued with aud: "nextcloud", azp: "nextcloud-mcp-server"
3. MCP Server → Nextcloud API (with token)
└─ user_oidc validates via userinfo endpoint ✓
└─ Nextcloud identifies:
- Token intended for Nextcloud (aud: "nextcloud")
- Request from MCP Server (azp: "nextcloud-mcp-server")
- On behalf of user (sub: "user-id")
4. Success! MCP Server can act on behalf of user in background.
```
### Rejected Request
```
1. Attacker gets token for different client
└─ Token has aud: "other-service"
2. Attacker → Nextcloud API (with wrong token)
└─ user_oidc validates via userinfo endpoint
└─ Token validation fails (invalid/expired/wrong realm)
└─ HTTP 401 Unauthorized
3. Request blocked - token not valid for this realm/service
```
## OAuth Flows and User Consent
### When Does the User Grant Consent?
User consent happens during the **Authorization Code Flow** (production OAuth):
```
1. User clicks "Connect" in MCP Client (e.g., Claude Desktop)
2. MCP Client initiates OAuth flow by opening browser to Keycloak:
https://keycloak/realms/nextcloud-mcp/protocol/openid-connect/auth?
client_id=nextcloud-mcp-server&
redirect_uri=<mcp-client-redirect-uri>&
response_type=code&
scope=openid profile email offline_access
3. Keycloak shows login screen (if not logged in)
4. **Keycloak shows consent screen:**
"Nextcloud MCP Server wants to access your Nextcloud data on your behalf"
Requested permissions:
- Access your profile (openid, profile, email)
- Offline access (background operations with refresh tokens)
5. User clicks "Allow" → grants consent
6. Keycloak redirects back to MCP Client with authorization code
7. MCP Client exchanges code for tokens (receives access + refresh tokens)
8. MCP Client shares tokens with MCP Server via MCP protocol
9. MCP Server stores refresh token encrypted for background operations
```
**Key Architecture Notes:**
- **MCP Server is a protected resource** (requires OAuth to access)
- **MCP Client** (Claude Desktop) is the OAuth client that initiates the flow
- **MCP Client handles the redirect** and token exchange with Keycloak
- **MCP Client shares refresh token** with MCP Server so it can act on behalf of user in background
**Key Points:**
-**Explicit user consent** before any access
-**Scopes displayed** so user knows what's being requested
-**Offline access** must be explicitly granted (for background jobs)
-**Revocable** - user can revoke consent in Keycloak at any time
### Grant Types
Our architecture supports multiple OAuth grant types:
**1. Authorization Code + PKCE (Production)**
```
Use case: Interactive login from MCP clients
Consent: Yes - explicit user authorization
Tokens: Access token + Refresh token (if offline_access granted)
Security: PKCE prevents authorization code interception
```
**2. Password Grant (Testing Only)**
```
Use case: Integration testing with docker-compose
Consent: No - username/password provided directly
Tokens: Access token + Refresh token
Security: NOT for production - exposes user credentials
```
**3. Refresh Token Grant (Background Jobs)**
```
Use case: MCP Server refreshing expired access tokens
Consent: No new consent - uses previously granted refresh token
Tokens: New access token (refresh token may rotate)
Security: Refresh tokens stored encrypted, rotated on use
```
## Authentication Strategies for Background Jobs
> **Note on Service Account Tokens**: Service account tokens (`client_credentials` grant) were evaluated but **rejected** as they create Nextcloud user accounts (e.g., `service-account-{client_id}`) which violates OAuth "act on-behalf-of" principles. See ADR-002 "Will Not Implement" section for details.
### Current Approach: Offline Access with Refresh Tokens
The MCP server uses **offline_access** scope to enable background operations:
**How it works:**
1. User grants `offline_access` scope during OAuth consent
2. MCP Client receives refresh token from Keycloak
3. MCP Client shares refresh token with MCP Server via MCP protocol
4. MCP Server stores refresh token encrypted (see ADR-002)
5. Background jobs exchange refresh token for fresh access tokens as needed
**Benefits:**
- ✅ Works today with Keycloak and all OIDC providers
- ✅ Standard OAuth pattern (RFC 6749)
- ✅ Explicit user consent to `offline_access` scope
- ✅ MCP Server can act on behalf of user in background
**Limitations:**
- ⚠️ Requires secure token storage on MCP Server
- ⚠️ MCP Client must trust MCP Server with refresh token
- ⚠️ Weak audit trail - API requests appear to come from user directly
- ⚠️ No visibility that MCP Server is the actual actor
### Token Exchange with Delegation (ADR-002 Tier 2 - Implemented)
**RFC 8693 Delegation** would provide better audit trail and security:
**How it would work:**
1. User grants `may_act:nextcloud-mcp-server` scope during authentication
2. Subject token includes: `{ "may_act": { "client": "nextcloud-mcp-server" } }`
3. MCP Server has its own service account token (actor_token)
4. Background job requests token exchange:
- `subject_token` (user's token with may_act claim)
- `actor_token` (mcp-server's service token)
5. Keycloak validates actor matches may_act claim
6. Returns delegated token: `{ "sub": "user", "act": "nextcloud-mcp-server" }`
**Benefits:**
- ✅ Better audit trail - Nextcloud APIs see both user and actor
- ✅ No token storage needed (tokens generated on-demand)
- ✅ Fine-grained permissions via `may_act` claim
- ✅ User explicitly consents to MCP Server acting on their behalf
- ✅ RFC 8693 compliant
**Current Status:**
-**NOT implemented in Keycloak yet** ([Issue #38279](https://github.com/keycloak/keycloak/issues/38279))
- ❌ Would require custom implementation or waiting for upstream
- 📝 Proposal includes `act` claim and `may_act` consent mechanism
**Why Not Available:**
- Keycloak supports **impersonation** (changes `sub` claim), but not **delegation** (`act` claim)
- Impersonation has poor audit trail (actor invisible)
- Delegation proposal is open but not implemented yet
**Reference:** See `docs/ADR-002-vector-sync-authentication.md` for detailed comparison of authentication tiers.
## Security Benefits
1. **Intent Validation**: Tokens explicitly declare Nextcloud as the intended recipient via `aud` claim
2. **Requester Identification**: The `azp` claim identifies MCP Server as the requester
3. **User Context**: The `sub` claim preserves user identity for audit and authorization
4. **Background Jobs**: Refresh tokens enable MCP Server to act on behalf of users without admin credentials
5. **OAuth Standards**: Follows RFC 8707 (Resource Indicators) and RFC 6749 (OAuth 2.0)
**Current Limitations:**
- API requests from background jobs appear to come from user directly (no `act` claim yet)
- See "Authentication Strategies for Background Jobs" section for future delegation support
## Token Claims
### Key Claims
- **`aud: "nextcloud"`** - Audience: Token intended for Nextcloud APIs
- **`azp: "nextcloud-mcp-server"`** - Authorized Party: MCP Server requested the token
- **`sub: "user-id"`** - Subject: User on whose behalf the request is made
- **`scope: "openid profile email offline_access"`** - Requested scopes including offline access for background jobs
### Client Naming
The Keycloak client is named `nextcloud-mcp-server` to clarify:
- **MCP Server** uses this client to get tokens for Nextcloud
- **MCP Clients** (like Claude Desktop) connect to MCP Server via separate OAuth flows
- **Not** named "mcp-client" to avoid confusion about which component is the client
## Troubleshooting
### Token Has No Audience
**Symptom**: `"aud": null` in decoded JWT
**Cause**: Protocol mappers not configured
**Solution**: Add audience mappers via Keycloak Admin API (see Configuration section)
### MCP Server Rejects Token
**Symptom**: HTTP 401 with "JWT validation failed"
**Cause**: Token audience doesn't match expected value
**Solution**:
1. Check token has correct `aud` claim
2. Verify MCP server expects correct audience value in code
3. Check logs for specific JWT validation error
### Nextcloud Rejects Token
**Symptom**: HTTP 401 from Nextcloud API
**Cause**: User not provisioned or token invalid
**Solution**:
1. Check user_oidc provider is configured: `php occ user_oidc:provider keycloak`
2. Check bearer validation enabled: `--check-bearer=1`
3. Test token with userinfo endpoint: `curl -H "Authorization: Bearer $TOKEN" http://keycloak/realms/.../userinfo`
## Related Documentation
- **Multi-client validation**: `docs/keycloak-multi-client-validation.md`
- **ADR-002**: `docs/ADR-002-vector-sync-authentication.md`
- **OAuth setup**: `docs/oauth-setup.md`
- **Keycloak integration**: `docs/keycloak-integration.md` (if created)
## References
- [RFC 8707 - Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707)
- [OIDC Core - ID Token aud claim](https://openid.net/specs/openid-connect-core-1_0.html#IDToken)
- [Keycloak Audience Protocol Mappers](https://www.keycloak.org/docs/latest/server_admin/#_audience)
+2 -11
View File
@@ -45,8 +45,7 @@ NEXTCLOUD_HOST=https://your.nextcloud.instance.com
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
# OAuth Storage and Callback Settings (optional)
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
# OAuth Callback Settings (optional)
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# Leave these EMPTY for OAuth mode
@@ -61,7 +60,6 @@ NEXTCLOUD_PASSWORD=
| `NEXTCLOUD_HOST` | ✅ Yes | - | Full URL of your Nextcloud instance (e.g., `https://cloud.example.com`) |
| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Optional | - | OAuth client ID (auto-registers if empty) |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Optional | - | OAuth client secret (auto-registers if empty) |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Path to store auto-registered client credentials |
| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for OAuth callbacks |
| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty to enable OAuth mode |
| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty to enable OAuth mode |
@@ -160,10 +158,6 @@ Options:
NEXTCLOUD_OIDC_CLIENT_ID env var)
--oauth-client-secret TEXT OAuth client secret (can also use
NEXTCLOUD_OIDC_CLIENT_SECRET env var)
--oauth-storage-path TEXT Path to store OAuth client credentials
(can also use
NEXTCLOUD_OIDC_CLIENT_STORAGE env var)
[default: .nextcloud_oauth_client.json]
--mcp-server-url TEXT MCP server URL for OAuth callbacks (can
also use NEXTCLOUD_MCP_SERVER_URL env
var) [default: http://localhost:8000]
@@ -225,10 +219,7 @@ uv run nextcloud-mcp-server --no-oauth \
- Store OAuth client credentials securely
- Use environment variables from your deployment platform (Docker secrets, Kubernetes ConfigMaps, etc.)
- Never commit credentials to version control
- Set appropriate file permissions on credential storage:
```bash
chmod 600 .nextcloud_oauth_client.json
```
- SQLite database permissions are handled automatically by the server
### For Docker
+10 -11
View File
@@ -272,7 +272,7 @@ mcp-oauth:
**Key Points:**
- **No credentials needed** - DCR automatically registers the client on first start
- **Credentials persist** - Saved to `.nextcloud_oauth_client.json` and reused
- **Credentials persist** - Saved to SQLite database and reused
- **JWT tokens** - Use `--oauth-token-type jwt` for better performance
- **Token verifier supports both** - Can handle JWT and opaque tokens
- **Pre-configured credentials** - Providing `CLIENT_ID`/`CLIENT_SECRET` skips DCR
@@ -286,7 +286,6 @@ mcp-oauth:
| `NEXTCLOUD_PUBLIC_ISSUER_URL` | Public issuer URL for JWT validation | (uses `NEXTCLOUD_HOST`) |
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured OAuth client ID | (optional - uses DCR if unset) |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured OAuth client secret | (optional - uses DCR if unset) |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | Path to persist DCR-registered credentials | `.nextcloud_oauth_client.json` |
| `NEXTCLOUD_OIDC_SCOPES` | Space-separated scopes to request | `"openid profile email mcp:notes:read mcp:notes:write"` |
| `NEXTCLOUD_OIDC_TOKEN_TYPE` | Token format: `"jwt"` or `"Bearer"` | `"Bearer"` |
@@ -303,8 +302,8 @@ When the MCP server starts in OAuth mode, it follows this **three-tier credentia
├─ NEXTCLOUD_OIDC_CLIENT_ID
└─ NEXTCLOUD_OIDC_CLIENT_SECRET
2. Storage File (Second Priority)
└─ NEXTCLOUD_OIDC_CLIENT_STORAGE (.nextcloud_oauth_client.json)
2. SQLite Database (Second Priority)
└─ OAuth client credentials table
3. Dynamic Client Registration (Automatic Fallback)
├─ Discovers registration endpoint from /.well-known/openid-configuration
@@ -327,10 +326,10 @@ export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt # or "Bearer" for opaque tokens
**Credential Storage:**
- Registered credentials are saved to `NEXTCLOUD_OIDC_CLIENT_STORAGE` (default: `.nextcloud_oauth_client.json`)
- File has restrictive permissions (0600 - owner read/write only)
- Registered credentials are saved to SQLite database
- Database is encrypted and protected by file system permissions
- Credentials are reused on subsequent starts (no re-registration needed)
- Storage file is checked for expiration (auto-regenerates if expired)
- Stored credentials are checked for expiration (auto-regenerates if expired)
**Format:**
```json
@@ -386,9 +385,9 @@ export NEXTCLOUD_OIDC_CLIENT_ID="<client_id>"
export NEXTCLOUD_OIDC_CLIENT_SECRET="<client_secret>"
export NEXTCLOUD_OIDC_TOKEN_TYPE="jwt"
# Option 2: Storage file (second priority)
# Save the JSON response to .nextcloud_oauth_client.json
# Server will automatically load it on startup
# Option 2: SQLite database (second priority)
# Credentials are automatically saved to the database after DCR
# Server will automatically load them on startup
```
When credentials are provided via environment variables or storage file, **DCR is skipped**.
@@ -724,7 +723,7 @@ docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
1. Ensure `NEXTCLOUD_OIDC_SCOPES` environment variable is set correctly
2. Check MCP server startup logs for the scopes being requested
3. Verify DCR is enabled in Nextcloud OIDC app settings
4. Delete `.nextcloud_oauth_client.json` and restart to force re-registration
4. Clear the SQLite database OAuth client entry and restart to force re-registration
### Issue: Token Type Case Sensitivity
+298
View File
@@ -0,0 +1,298 @@
# Keycloak Multi-Client Token Validation
## Executive Summary
**Question**: Can Nextcloud's `user_oidc` app (configured with client A) validate bearer tokens from client B in the same Keycloak realm?
**Answer**: ✅ **YES** - user_oidc validates tokens at the **realm level**, not per-client.
## Test Results
### Setup
- **Keycloak Realm**: `nextcloud-mcp`
- **Provider in user_oidc**: Configured with `mcp-client` credentials
- **Test**: Get token from `test-client-b`, validate via Nextcloud API
### Result
```bash
# Token from test-client-b (client B)
$ TOKEN=$(curl -X POST ".../token" -d "client_id=test-client-b" ...)
# Validated successfully by Nextcloud (configured with mcp-client = client A)
$ curl -H "Authorization: Bearer $TOKEN" "http://nextcloud/ocs/.../capabilities"
HTTP/1.1 200 OK
{"ocs":{"meta":{"status":"ok"}}}
```
**Token from client B validated successfully!**
## How It Works
### Token Structure from Keycloak
**Access Token** (password grant):
```json
{
"iss": "http://keycloak/realms/nextcloud-mcp",
"azp": "test-client-b", // Authorized party = client B
"typ": "Bearer",
"exp": 1234567890,
// NO "sub" claim
// NO "aud" claim
"scope": "openid profile email"
}
```
**ID Token** (for comparison):
```json
{
"iss": "http://keycloak/realms/nextcloud-mcp",
"aud": "test-client-b", // Audience = client B
"sub": "923da741-7ebe-4cf9-baf2-37fcf2ecc95d",
"azp": "test-client-b"
}
```
**Key Observation**: Access tokens from Keycloak's password grant **do not contain** `sub` or `aud` claims!
### Validation Flow in user_oidc
From source code analysis (`~/Software/user_oidc/lib/User/Backend.php`):
```
1. Request with Bearer token arrives
2. user_oidc loops through providers with checkBearer=true
3. Try SelfEncodedValidator (JWT/JWKS validation):
- Validates JWT signature using Keycloak's JWKS
- Tries to extract 'sub' claim → FAILS (no sub in access token)
4. Fallback to UserInfoValidator:
- Calls Keycloak userinfo endpoint with bearer token
- Keycloak validates token server-side
- Returns userinfo with 'sub' claim
→ SUCCESS!
5. User identified, request authorized
```
### Why This Works
**Realm-Level Trust**:
- Keycloak's userinfo endpoint validates ANY valid token from the realm
- It doesn't matter which client issued the token
- The token is validated by Keycloak itself (via userinfo call)
**No Audience Check**:
- Access tokens have no `aud` claim
- SelfEncodedValidator's audience check is bypassed (no audience to validate)
- UserInfoValidator doesn't check audience (delegates to Keycloak)
**Client Credentials Role**:
- The configured `client_id`/`client_secret` in user_oidc are **NOT used** for bearer token validation
- They're only used for OAuth login flows (authorization code exchange)
- Userinfo endpoint doesn't require client authentication
## Source Code Evidence
### SelfEncodedValidator - Audience Check
```php
// ~/Software/user_oidc/lib/User/Validator/SelfEncodedValidator.php:64-76
$checkAudience = !isset($oidcSystemConfig['selfencoded_bearer_validation_audience_check'])
|| !in_array($oidcSystemConfig['selfencoded_bearer_validation_audience_check'],
[false, 'false', 0, '0'], true);
if ($checkAudience) {
$tokenAudience = $payload->aud ?? null;
if ((is_string($tokenAudience) && $tokenAudience !== $providerClientId)
|| (is_array($tokenAudience) && !in_array($providerClientId, $tokenAudience))) {
$this->logger->debug('Audience does not match client ID');
return null; // REJECT
}
}
// If $tokenAudience is null (our case), both conditions are false → validation continues
```
### UserInfoValidator - No Client Auth
```php
// ~/Software/user_oidc/lib/Service/OIDCService.php:28-45
public function userinfo(Provider $provider, string $accessToken): array {
$url = $this->discoveryService->obtainDiscovery($provider)['userinfo_endpoint'];
// Bearer token passed directly - NO client credentials used
$options = ['headers' => ['Authorization' => 'Bearer ' . $accessToken]];
return json_decode($this->clientService->get($url, [], $options), true);
}
```
### Keycloak Userinfo Response
```bash
$ curl -H "Authorization: Bearer $TOKEN_FROM_CLIENT_B" \
"http://keycloak/realms/nextcloud-mcp/protocol/openid-connect/userinfo"
{
"sub": "923da741-7ebe-4cf9-baf2-37fcf2ecc95d",
"email_verified": true,
"name": "Admin User",
"email": "admin@example.com"
}
```
Keycloak validates the token **regardless of which client issued it**, as long as it's from the same realm.
## Implications for Your Architecture
### Desired Architecture
```
MCP Server (client A) ← DCR with Keycloak
MCP Clients (client B, C, D...) ← DCR with Keycloak
Nextcloud user_oidc ← configured once with any client from realm
```
### What This Means
**You can do exactly what you want!**
1. **Configure user_oidc once** with any client from the Keycloak realm (e.g., a dedicated `nextcloud-validator` client)
2. **MCP Server registers via DCR** as a unique client (e.g., `mcp-server-abc123`)
- Gets its own client credentials
- Issues tokens with `azp: "mcp-server-abc123"`
- These tokens will be validated by user_oidc!
3. **MCP Clients also use DCR** (each gets unique identity)
- Client A: `client-123`
- Client B: `client-456`
- Tokens from all clients validated by user_oidc!
4. **Tokens from ANY client** in the realm can access Nextcloud APIs
- user_oidc validates via Keycloak userinfo endpoint
- Realm-level trust (not per-client)
### Configuration
**Step 1: Configure user_oidc Provider**
```bash
php occ user_oidc:provider keycloak-realm \
--clientid="nextcloud-validator" \
--clientsecret="***" \
--discoveryuri="https://keycloak/realms/my-realm/.well-known/openid-configuration" \
--check-bearer=1 \
--bearer-provisioning=1
```
**Step 2: MCP Server Registers with Keycloak (DCR)**
```python
# MCP server startup
registration_response = await keycloak_client.register_client(
client_name="MCP Server Instance",
redirect_uris=["http://mcp-server/oauth/callback"]
)
# Store: client_id, client_secret
```
**Step 3: Issue Tokens to Users**
- Users authenticate via Keycloak
- MCP server gets tokens issued to its `client_id`
- These tokens validated by user_oidc!
**Step 4: Background Operations (ADR-002)**
- Store user refresh tokens (encrypted)
- Refresh access tokens as needed
- All tokens validated by user_oidc regardless of issuing client
## Important Notes
### Token Grant Types Matter
**Password Grant** (what we tested):
- Access tokens have NO `sub` or `aud`
- Forces validation via userinfo endpoint
- Works with any client in realm
**Authorization Code Grant** (production):
- Tokens MAY include `aud` claim
- Need to verify behavior with real OAuth flows
- May require disabling audience check
### Recommendation for Production
**Option 1: Disable Audience Check (Simplest)**
```php
// config.php
'user_oidc' => [
'selfencoded_bearer_validation_audience_check' => false,
],
```
**Option 2: Rely on UserInfo Validation**
```php
// config.php
'user_oidc' => [
'userinfo_bearer_validation' => true, // Enable userinfo validation
],
```
**Option 3: Configure Keycloak to Not Include aud in Access Tokens**
- Keep default behavior (works as tested)
- Tokens validated via userinfo endpoint
## Testing Script
```bash
#!/bin/bash
# Test multi-client validation
# Create second client in Keycloak
curl -X POST "http://keycloak/admin/realms/my-realm/clients" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{
"clientId": "test-client-b",
"secret": "test-secret-b",
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true
}'
# Get token from client B
TOKEN=$(curl -X POST "http://keycloak/realms/my-realm/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=test-client-b" \
-d "client_secret=test-secret-b" \
-d "username=testuser" \
-d "password=password" | jq -r '.access_token')
# Test with Nextcloud (configured with client A)
curl -H "Authorization: Bearer $TOKEN" \
"http://nextcloud/ocs/v2.php/cloud/capabilities"
# Should return 200 OK!
```
## Conclusion
**Your proposed architecture is fully supported!**
- user_oidc configured once with ANY client from Keycloak realm
- MCP server registers dynamically via DCR
- MCP clients also register dynamically
- ALL tokens from realm validated successfully
- No per-client configuration needed
The key insight: **user_oidc validates tokens at the realm level** (via Keycloak's userinfo endpoint), not at the client level.
## References
- Source code: `~/Software/user_oidc/lib/User/Backend.php:260-343`
- SelfEncodedValidator: `~/Software/user_oidc/lib/User/Validator/SelfEncodedValidator.php`
- UserInfoValidator: `~/Software/user_oidc/lib/User/Validator/UserInfoValidator.php`
- Test setup: `docker-compose.yml` (mcp-keycloak service)
- Configuration: `.env.keycloak.sample`
+4 -5
View File
@@ -39,7 +39,7 @@ Phase 0: MCP Server Startup & Client Registration (DCR - RFC 7591)
│ 0d. Client credentials │
│<────────────────────────────────────┤
│ {client_id, client_secret} │
│ → Saved to .nextcloud_oauth_*.json
│ → Saved to SQLite database
│ │
│ ✓ Server ready for connections │
@@ -211,7 +211,7 @@ Insufficient Scope Example (Step-Up Authorization)
- **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`
- Saves credentials to SQLite database
- **Tool Registration**: Loads all MCP tools with their `@require_scopes` decorators
#### Client Connection Phase
@@ -324,7 +324,7 @@ The OAuth flow consists of four distinct phases (see diagram above for visual re
- 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`
- Saves credentials to SQLite database
3. **Tool Registration**
- All MCP tools loaded with their `@require_scopes` decorators
@@ -515,7 +515,7 @@ NEXTCLOUD_HOST=https://nextcloud.example.com
**How it works**:
1. Server checks `/.well-known/openid-configuration` for `registration_endpoint`
2. Calls `/apps/oidc/register` to register a client on first startup
3. Saves credentials to `.nextcloud_oauth_client.json`
3. Saves credentials to SQLite database
4. Reuses these credentials on subsequent startups
5. Re-registers only if credentials are missing or expired
@@ -718,7 +718,6 @@ See [Configuration Guide](configuration.md) for all OAuth environment variables:
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured client ID (optional) |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured client secret (optional) |
| `NEXTCLOUD_MCP_SERVER_URL` | MCP server URL for OAuth callbacks |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | Path for auto-registered credentials |
## Testing
+387
View File
@@ -0,0 +1,387 @@
# OAuth Impersonation Investigation Findings
**Date**: 2025-11-02
**Last Updated**: 2025-11-02 (Token Exchange Resolution)
**Status**: Implementation Complete - Token Exchange Working
**Conclusion**: Keycloak Standard Token Exchange (RFC 8693) working for internal-to-internal token exchange. User impersonation requires Legacy V1.
---
## ⚠️ IMPORTANT UPDATE (2025-11-02)
**This document contains outdated information regarding service account tokens.**
After implementation and testing, we discovered that service account tokens (`client_credentials` grant) **violate OAuth "act on-behalf-of" principles** by creating Nextcloud user accounts (e.g., `service-account-nextcloud-mcp-server`). This approach has been **REJECTED** and moved to ADR-002's "Will Not Implement" section.
**Key Changes:**
-**Service account tokens (client_credentials) are INVALID** - Creates user accounts, breaks audit trail
-**Token exchange (RFC 8693) is the correct approach** - Implemented and working (ADR-002 Tier 2)
-**Offline access with refresh tokens** - Still valid for background operations (ADR-002 primary approach)
**For current architecture, see**: `docs/ADR-002-vector-sync-authentication.md`
---
## Summary
We investigated options for implementing user impersonation to enable background operations without requiring admin credentials (ADR-002 Tier 2). Here are the findings:
## 1. Keycloak Token Exchange (RFC 8693)
### What We Implemented
- ✅ Service account token acquisition (`client_credentials` grant)
-`get_service_account_token()` method in `KeycloakOAuthClient`
-`exchange_token_for_user()` method implementing RFC 8693
- ✅ Token exchange configuration in Keycloak realm
### What Works ✅
**Keycloak Standard V2 Token Exchange (RFC 8693) is WORKING**:
- ✅ Service account token acquisition via `client_credentials` grant
- ✅ Token exchange for internal-to-internal tokens
- ✅ Audience and scope modifications
- ✅ Integration with Nextcloud APIs using exchanged tokens
**Configuration Requirements**:
To enable Standard Token Exchange in Keycloak 26.2+, add to client attributes in `realm-export.json`:
```json
"attributes": {
"token.exchange.grant.enabled": "true",
"client.token.exchange.standard.enabled": "true"
}
```
### Limitations
Keycloak Standard V2 does NOT support:
- ❌ User impersonation (`requested_subject` parameter)
- ❌ Cross-client delegation (limited to same realm)
These features require Legacy V1 with `--features=preview`
### Alternative: Keycloak Legacy V1
Keycloak Legacy Token Exchange (V1) WOULD support user impersonation, but:
- ❌ Requires `--features=preview --features=token-exchange` flag
- ❌ Not suitable for production
- ❌ Deprecated and being phased out
**Decision**: Not viable for production use.
---
## 2. Nextcloud OIDC App Token Exchange
### Discovery Endpoint Analysis
```json
{
"grant_types_supported": [
"authorization_code",
"implicit"
]
}
```
### Findings
**Nextcloud OIDC app does NOT support**:
- RFC 8693 token exchange
- `client_credentials` grant
- `refresh_token` grant (refresh tokens not issued)
- User impersonation APIs
The Nextcloud OIDC app is a basic OAuth 2.0 provider focused on:
- Authorization code flow for user login
- JWT tokens for API access
- Scope-based authorization
It is NOT designed for:
- Service accounts
- Token delegation
- Background operations
**Decision**: Not viable - missing required grant types.
---
## 3. Nextcloud Impersonate App
### What It Provides
✅ Admin users can impersonate other users via:
- UI: Settings → Users → Impersonate button
- API: `POST /apps/impersonate/user` with `userId` parameter
### How It Works
```php
// From SettingsController.php
public function impersonate(string $userId): JSONResponse {
// 1. Verify admin/delegated admin permissions
// 2. Check target user has logged in before
// 3. Set session: $this->userSession->setUser($impersonatee)
// 4. Return success
}
```
### Requirements
- ✅ Admin credentials
- ✅ Session-based authentication (cookies)
- ✅ CSRF token
- ✅ Target user must have logged in at least once
- ❌ Not compatible with encryption-enabled instances
### Limitations for Background Workers
**Session-based, not stateless**:
- Requires maintaining HTTP session/cookies
- Not suitable for distributed workers
- Can't use with bearer tokens
- Requires re-authentication periodically
**Security concerns**:
- Requires admin credentials stored on server
- All impersonated actions logged as target user
- Violates principle of least privilege
**Decision**: Not suitable for background operations - session-based architecture incompatible with stateless OAuth/bearer token model.
---
## 4. What Actually Works
### Option A: Admin Credentials (Current Implementation)
**BasicAuth mode with admin account**:
```python
client = NextcloudClient.from_env() # Uses NEXTCLOUD_USERNAME/PASSWORD
# Can access all APIs with admin permissions
```
**Pros**:
- Simple, works immediately
- Full access to all APIs
**Cons**:
- Requires admin credentials stored on server
- No per-user permission scoping
- Security risk if credentials leaked
- Violates ADR-002 goals
**Status**: Available but not recommended for production.
### Option B: Service Account with Scoped Permissions
**Create dedicated service account**:
1. Create `mcp-sync` user in Nextcloud
2. Grant specific permissions (group memberships, shares)
3. Use those credentials for background operations
**Pros**:
- Dedicated account, easier to audit
- Can limit permissions via Nextcloud groups
- Works with current BasicAuth implementation
**Cons**:
- Still requires credentials storage
- Can't truly act "as" individual users
- Limited by Nextcloud's permission model
**Status**: Best available option without OAuth delegation.
---
## 5. Recommendations
### Short Term (Immediate)
**Use Service Account Pattern**:
```python
# Background worker configuration
SYNC_ACCOUNT_USERNAME=mcp-sync
SYNC_ACCOUNT_PASSWORD=<secure-password>
# Create service account with limited permissions
docker compose exec app php occ user:add mcp-sync
docker compose exec app php occ group:adduser <appropriate-group> mcp-sync
```
**Benefits**:
- Works with existing implementation
- Better than admin credentials
- Auditable
### Medium Term (If OAuth Delegation Required)
**Wait for proper standards support**:
- Monitor Keycloak for Standard V2 improvements
- Contribute to/request Nextcloud OIDC app enhancements
- Consider alternative identity providers (e.g., Authelia, Authentik)
### Long Term (Ideal Solution)
**Implement proper OAuth delegation**:
1. Use identity provider that supports RFC 8693 properly (e.g., Auth0, Okta)
2. Or implement custom delegation endpoint in Nextcloud
3. Or propose MCP protocol extension for refresh token sharing
---
## 6. Updated ADR-002 Status
| Tier | Solution | Status | Viability |
|------|----------|--------|-----------|
| **Tier 0** | Admin BasicAuth | ✅ Implemented | ⚠️ Works but not recommended |
| **Tier 1** | Offline Access (Refresh Tokens) | ⚠️ Infrastructure ready | ❌ MCP protocol limitation |
| **Tier 2** | Token Exchange (RFC 8693) | ✅ **WORKING** | ✅ **Internal token exchange functional** |
| **Tier 3** | Service Account (NEW) | ✅ Available | ✅ **RECOMMENDED for background ops** |
---
## 7. Implementation Status
### What Was Built
1.`RefreshTokenStorage` - SQLite + encryption (ready for future use)
2.`KeycloakOAuthClient.get_service_account_token()` - Works
3.`KeycloakOAuthClient.exchange_token_for_user()` - Implemented but non-functional
4. ✅ Token exchange configuration - Keycloak realm updated
5. ✅ Test scripts - Comprehensive testing completed
### What to Use
**For Background Operations**:
```python
# Use service account with BasicAuth
from nextcloud_mcp_server.client import NextcloudClient
# In background worker
sync_client = NextcloudClient(
base_url=os.getenv("NEXTCLOUD_HOST"),
username=os.getenv("SYNC_ACCOUNT_USERNAME"),
password=os.getenv("SYNC_ACCOUNT_PASSWORD"),
)
# Perform operations
notes = await sync_client.notes.search_notes("important")
# Index to vector database, etc.
```
**For User Requests**:
```python
# Continue using OAuth bearer tokens
# Per-request client creation as currently implemented
client = get_client_from_context(ctx, nextcloud_host)
```
---
## 8. Files Modified/Created
### Implementation
- `nextcloud_mcp_server/auth/keycloak_oauth.py` - Token exchange methods
- `nextcloud_mcp_server/auth/refresh_token_storage.py` - Token storage (ready for future)
- `nextcloud_mcp_server/app.py` - OAuth configuration updates
- `keycloak/realm-export.json` - Token exchange enabled
- `pyproject.toml` - Added aiosqlite dependency
### Documentation
- `docs/oauth-impersonation-findings.md` - This document
- `docs/ADR-002-vector-sync-authentication.md` - Original architecture decision
### Tests
- `tests/manual/test_token_exchange.py` - Keycloak RFC 8693 testing
- `tests/manual/test_nextcloud_impersonate.py` - Nextcloud impersonate API testing
---
## 9. Conclusion
**Neither Keycloak nor Nextcloud currently provide viable OAuth-based user impersonation for background operations.**
The infrastructure is ready (token storage, exchange methods), but provider limitations prevent use.
**Recommended approach**: Use dedicated service account with appropriate Nextcloud permissions for background operations until proper OAuth delegation becomes available.
The implemented code remains valuable:
- Ready for future when providers add support
- Demonstrates proper OAuth patterns
- Test infrastructure for validation
---
## Appendix: Technical Details
### Keycloak Configuration Applied
```json
{
"clientId": "nextcloud-mcp-server",
"serviceAccountsEnabled": true,
"attributes": {
"token.exchange.grant.enabled": "true"
}
}
```
### Test Results - UPDATED (2025-11-02)
```
✅ Service account token acquisition: WORKS
✅ Token exchange discovery: SUPPORTED
✅ Token exchange configuration: ENABLED
✅ Actual token exchange: WORKS (after adding client.token.exchange.standard.enabled)
✅ Nextcloud API access: WORKS with exchanged tokens
```
**Resolution**: The realm-export.json was missing the `client.token.exchange.standard.enabled` attribute. After adding this attribute to keycloak/realm-export.json:128, token exchange works correctly on fresh Keycloak imports.
### Nextcloud Impersonate Results
```
✓ App installation: SUCCESS
✓ Admin can impersonate: YES (session-based)
✗ Bearer token impersonate: NO (requires session cookies)
✗ Stateless impersonate: NOT AVAILABLE
```
---
## 10. Token Exchange Resolution (2025-11-02)
### Problem
Initial token exchange implementation was failing with:
```
"Standard token exchange is not enabled for the requested client"
```
### Root Cause
The `realm-export.json` was missing a critical attribute for Keycloak 26.2+ Standard Token Exchange:
- Had: `"token.exchange.grant.enabled": "true"`
- Missing: `"client.token.exchange.standard.enabled": "true"`
### Fix Applied
Updated `keycloak/realm-export.json` at line 128 to include both attributes:
```json
"attributes": {
"pkce.code.challenge.method": "S256",
"use.refresh.tokens": "true",
"backchannel.logout.session.required": "true",
"backchannel.logout.url": "http://app:80/index.php/apps/user_oidc/backchannel-logout/keycloak",
"oauth2.device.authorization.grant.enabled": "false",
"oidc.ciba.grant.enabled": "false",
"client_credentials.use_refresh_token": "false",
"display.on.consent.screen": "false",
"token.exchange.grant.enabled": "true",
"client.token.exchange.standard.enabled": "true" // ADDED
}
```
### Verification
After recreating Keycloak with fresh realm import:
```bash
$ docker compose down -v keycloak && docker compose up -d keycloak
$ uv run python tests/manual/test_token_exchange.py
✅ Token Exchange Test PASSED
```
### Current Status
- ✅ RFC 8693 Token Exchange fully functional
- ✅ Service account token acquisition works
- ✅ Token exchange for internal tokens works
- ✅ Exchanged tokens validate with Nextcloud APIs
- ✅ Realm import automatically applies correct configuration
- ⚠️ User impersonation still requires Keycloak Legacy V1
### Files Modified
- `keycloak/realm-export.json` - Added `client.token.exchange.standard.enabled` attribute
- `docs/oauth-impersonation-findings.md` - Updated with resolution
### Testing
Run the complete token exchange flow:
```bash
uv run python tests/manual/test_token_exchange.py
```
+5 -9
View File
@@ -170,7 +170,7 @@ You have two options for managing OAuth clients:
**How it works**:
- MCP server automatically registers an OAuth client on first startup
- Uses Nextcloud's dynamic client registration endpoint
- Saves credentials to `.nextcloud_oauth_client.json`
- Saves credentials to SQLite database
- Reuses stored credentials on subsequent restarts
- Re-registers automatically if credentials expire
@@ -253,9 +253,6 @@ NEXTCLOUD_PASSWORD=
# Optional: MCP server URL (for OAuth callbacks)
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# Optional: Client storage path
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
EOF
```
@@ -291,7 +288,6 @@ EOF
| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Mode B only | - | OAuth client ID |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Mode B only | - | OAuth client secret |
| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for callbacks |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Client credentials storage path |
| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty for OAuth |
| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty for OAuth |
@@ -334,7 +330,7 @@ INFO OIDC discovery successful
INFO Attempting dynamic client registration...
INFO Dynamic client registration successful
INFO OAuth client ready: <client-id>...
INFO Saved OAuth client credentials to .nextcloud_oauth_client.json
INFO Saved OAuth client credentials to SQLite database
INFO OAuth initialization complete
INFO MCP server ready at http://127.0.0.1:8000
```
@@ -427,9 +423,9 @@ uv run nextcloud-mcp-server --oauth --log-level debug
2. **Secure Credential Storage**
```bash
# Set restrictive permissions
chmod 600 .nextcloud_oauth_client.json
# Set restrictive permissions on environment file
chmod 600 .env
# Database permissions are handled automatically
```
3. **Use HTTPS for MCP Server**
@@ -474,7 +470,7 @@ services:
NEXTCLOUD_OIDC_CLIENT_SECRET: ${NEXTCLOUD_OIDC_CLIENT_SECRET}
NEXTCLOUD_MCP_SERVER_URL: http://your-server:8000
volumes:
- ./oauth_client.json:/app/.nextcloud_oauth_client.json
- ./data:/app/data # For SQLite database persistence
command: ["--oauth", "--transport", "streamable-http"]
restart: unless-stopped
```
+19 -20
View File
@@ -17,7 +17,7 @@ Start here to identify your issue:
| Only seeing Notes tools (7 instead of 90+) | Limited OAuth scopes granted | [Limited Scopes](#limited-scopes---only-seeing-notes-tools) |
| HTTP 401 for Notes API | Bearer token patch missing | [Bearer Token Auth Fails](#bearer-token-authentication-fails) |
| "OIDC discovery failed" | Network or configuration issue | [Discovery Failed](#oidc-discovery-failed) |
| "Permission denied" on .nextcloud_oauth_client.json | File permissions issue | [File Permission Error](#file-permission-error) |
| "Database error" on OAuth client storage | Database permissions issue | [Database Permission Error](#database-permission-error) |
## Configuration Issues
@@ -161,39 +161,38 @@ php occ config:app:set oidc expire_time --value "86400" # 24 hours
---
### File Permission Error
### Database Permission Error
**Error Message**:
```
Permission denied when reading/writing .nextcloud_oauth_client.json
Permission denied when accessing SQLite database
Database is locked
```
**Cause**: The server cannot access the OAuth client storage file.
**Cause**: The server cannot access the SQLite database file.
**Solution**:
```bash
# Check file permissions
ls -la .nextcloud_oauth_client.json
# Fix file permissions (owner read/write only)
chmod 600 .nextcloud_oauth_client.json
# Check database directory permissions
ls -la /app/data/
# Ensure directory is writable
chmod 755 $(dirname .nextcloud_oauth_client.json)
chmod 755 /app/data
# If file doesn't exist, ensure directory is writable
mkdir -p $(dirname .nextcloud_oauth_client.json)
# Check if database file exists and has correct permissions
ls -la /app/data/tokens.db
chmod 644 /app/data/tokens.db
# If running in Docker, ensure volume is mounted correctly
docker compose logs mcp-oauth | grep -i "database\|sqlite"
```
For custom storage paths:
```bash
# Set custom path in .env
NEXTCLOUD_OIDC_CLIENT_STORAGE=/path/to/custom/oauth_client.json
# Ensure directory exists and is writable
mkdir -p $(dirname /path/to/custom/oauth_client.json)
chmod 755 $(dirname /path/to/custom/oauth_client.json)
**For Docker deployments**:
Ensure the data directory is properly mounted as a volume:
```yaml
volumes:
- ./data:/app/data # Persistent storage for SQLite database
```
---
+84 -26
View File
@@ -16,35 +16,79 @@ While the core OAuth flow works, there are **pending upstream improvements** tha
**Status**: 🟡 **Patch Required** (Pending Upstream)
**Affected Component**: `user_oidc` app
**Affected Component**: **Nextcloud core server** (`CORSMiddleware`)
**Issue**: Bearer token authentication fails for app-specific APIs (Notes, Calendar, etc.) with `401 Unauthorized` errors, even though OCS APIs work correctly.
**Root Cause**: The `CORSMiddleware` in Nextcloud logs out sessions created by Bearer token authentication when CSRF tokens are missing, which breaks API requests.
**Root Cause**: The `CORSMiddleware` in Nextcloud core server logs out sessions when CSRF tokens are missing. Bearer token authentication creates a session (via `user_oidc` app), but doesn't include CSRF tokens (stateless authentication). The middleware detects the logged-in session without CSRF token and calls `session->logout()`, invalidating the request.
**Solution**: Set the `app_api` session flag during Bearer token authentication to bypass CSRF checks.
**Solution**: Allow Bearer token requests to bypass CORS/CSRF checks in `CORSMiddleware`, since Bearer tokens are stateless and don't require CSRF protection.
**Upstream PR**: [nextcloud/user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221)
**Upstream PR**: [nextcloud/server#55878](https://github.com/nextcloud/server/pull/55878)
**Workaround**: Manually apply the patch to `lib/User/Backend.php` in the `user_oidc` app
**Workaround**: Manually apply the patch to `lib/private/AppFramework/Middleware/Security/CORSMiddleware.php` in Nextcloud core server
**Impact**:
-**Works**: OCS APIs (`/ocs/v2.php/cloud/capabilities`)
-**Requires Patch**: App APIs (`/apps/notes/api/`, `/apps/calendar/`, etc.)
**Files Modified**: `lib/User/Backend.php` in `user_oidc` app
**Files Modified**: `lib/private/AppFramework/Middleware/Security/CORSMiddleware.php` in **Nextcloud core server**
**Patch Summary**:
```php
// Add before successful Bearer token authentication returns
$this->session->set('app_api', true);
// Allow Bearer token authentication for CORS requests
// Bearer tokens are stateless and don't require CSRF protection
$authorizationHeader = $this->request->getHeader('Authorization');
if (!empty($authorizationHeader) && str_starts_with($authorizationHeader, 'Bearer ')) {
return;
}
```
This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
This is added before the CSRF check at line ~73 in `CORSMiddleware.php`.
---
### 2. PKCE Support (RFC 7636)
### 2. JWT Token Support, Introspection, and Scope Validation
**Status**: ✅ **Complete** (Merged Upstream)
**Affected Component**: `oidc` app
**Issue**: The OIDC app needed support for JWT tokens, token introspection, and enhanced scope validation for fine-grained authorization.
**Resolution**: Complete JWT and scope validation support has been implemented and merged:
**Upstream PR**: [H2CK/oidc#585](https://github.com/H2CK/oidc/pull/585) - ✅ **Merged**
- **Changes**:
- JWT token generation and validation
- Token introspection endpoint (RFC 7662)
- Enhanced scope validation and parsing
- Custom scope support for Nextcloud apps
- **Status**: Merged and available in v1.10.0+ of the `oidc` app
---
### 3. User Consent Management
**Status**: ✅ **Complete** (Merged Upstream)
**Affected Component**: `oidc` app
**Issue**: The OIDC app needed proper user consent management for OAuth authorization flows.
**Resolution**: Complete user consent management has been implemented and merged:
**Upstream PR**: [H2CK/oidc#586](https://github.com/H2CK/oidc/pull/586) - ✅ **Merged**
- **Changes**:
- User consent UI for OAuth authorization
- Consent expiration and cleanup
- Admin control for user consent settings
- Consent tracking and management
- **Status**: Merged and available in v1.11.0+ of the `oidc` app
---
### 4. PKCE Support (RFC 7636)
**Status**: ✅ **Complete** (Merged Upstream)
@@ -97,24 +141,34 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
| PR/Issue | Component | Status | Priority | Notes |
|----------|-----------|--------|----------|-------|
| [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) | `user_oidc` | 🟡 Open | High | Required for app-specific APIs |
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | ✅ Merged | ~~Medium~~ | ✅ PKCE advertisement complete (v1.10.0+) |
| [server#55878](https://github.com/nextcloud/server/pull/55878) | Nextcloud core server | 🟡 Open | High | CORSMiddleware patch for Bearer tokens |
| [H2CK/oidc#586](https://github.com/H2CK/oidc/pull/586) | `oidc` | ✅ Merged | Medium | ✅ User consent complete (v1.11.0+) |
| [H2CK/oidc#585](https://github.com/H2CK/oidc/pull/585) | `oidc` | ✅ Merged | Medium | ✅ JWT tokens, introspection, scope validation (v1.10.0+) |
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | ✅ Merged | ~~High~~ | ✅ PKCE support (RFC 7636) (v1.10.0+) |
## What Works Without Patches
The following functionality works **out of the box** without any patches:
**OAuth Flow**:
- OIDC discovery with full PKCE support (requires `oidc` app v1.10.0+)
**OAuth Flow** (requires `oidc` app v1.10.0+):
- OIDC discovery with full PKCE support (RFC 7636)
- Dynamic client registration
- Authorization code flow with PKCE (S256 and plain methods)
- Token exchange with code_verifier verification
- User consent management
- Userinfo endpoint
**Token Features** (requires `oidc` app v1.10.0+):
- JWT token generation and validation
- Token introspection endpoint (RFC 7662)
- Enhanced scope validation and parsing
- Custom scope support for Nextcloud apps
**MCP Server as Resource Server**:
- Token validation via userinfo
- Per-user client instances
- Token caching
- Scope-based authorization
**Nextcloud OCS APIs**:
- Capabilities endpoint
@@ -124,7 +178,7 @@ The following functionality works **out of the box** without any patches:
The following functionality requires upstream patches:
🟡 **App-Specific APIs** (Requires user_oidc#1221):
🟡 **App-Specific APIs** (Requires Nextcloud core server CORSMiddleware patch):
- Notes API (`/apps/notes/api/`)
- Calendar API (CalDAV)
- Contacts API (CardDAV)
@@ -198,19 +252,23 @@ uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
## Monitoring Upstream Progress
To track progress on these issues:
To track progress on remaining issues:
1. **Watch the upstream repositories**:
- [nextcloud/user_oidc](https://github.com/nextcloud/user_oidc)
- [nextcloud/oidc](https://github.com/nextcloud/oidc)
1. **Watch the upstream repository**:
- [nextcloud/server](https://github.com/nextcloud/server)
2. **Subscribe to specific issues**:
- [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) - Bearer token support
2. **Subscribe to the CORSMiddleware PR**:
- [server#55878](https://github.com/nextcloud/server/pull/55878) - CORSMiddleware Bearer token support
3. **Check Nextcloud release notes** for mentions of:
3. **Check Nextcloud server release notes** for mentions of:
- Bearer token authentication improvements
- OIDC/OAuth enhancements
- AppAPI compatibility
- CORS middleware enhancements
- OAuth/OIDC API compatibility
4. **Completed upstream work** (no monitoring needed):
- ✅ [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - PKCE support (v1.10.0+)
- ✅ [H2CK/oidc#585](https://github.com/H2CK/oidc/pull/585) - JWT, introspection, scopes (v1.10.0+)
- ✅ [H2CK/oidc#586](https://github.com/H2CK/oidc/pull/586) - User consent (v1.11.0+)
## Contributing
@@ -237,6 +295,6 @@ Want to help get these patches merged?
---
**Last Updated**: 2025-10-20
**Last Updated**: 2025-11-02
**Next Review**: When issue #1221 (Bearer token support) has activity
**Next Review**: When Nextcloud server CORSMiddleware PR has activity
+13 -10
View File
@@ -136,24 +136,27 @@ A patch for the `user_oidc` app is required to fix Bearer token support. See [oa
---
### Issue: "Permission denied" when reading/writing OAuth client credentials file
### Issue: "Permission denied" or "Database is locked" when accessing OAuth client storage
**Cause:** The server cannot access the OAuth client storage file (default: `.nextcloud_oauth_client.json`).
**Cause:** The server cannot access the SQLite database for OAuth client credentials storage.
**Solution:**
```bash
# Check file permissions
ls -la .nextcloud_oauth_client.json
# Check database directory permissions
ls -la data/
# Fix file permissions (should be 0600 - owner read/write only)
chmod 600 .nextcloud_oauth_client.json
# Ensure directory is writable
chmod 755 data/
# Ensure the directory is writable
chmod 755 $(dirname .nextcloud_oauth_client.json)
# Check if database file exists and has correct permissions
ls -la data/tokens.db
chmod 644 data/tokens.db
# If the file doesn't exist, ensure the directory is writable so it can be created
mkdir -p $(dirname .nextcloud_oauth_client.json)
# For Docker deployments, ensure volume is mounted correctly:
# docker-compose.yml should have:
# volumes:
# - ./data:/app/data
```
---
+8 -1
View File
@@ -8,12 +8,19 @@ NEXTCLOUD_HOST=
# - Requires Nextcloud OIDC app installed and configured
# - Admin must enable "Dynamic Client Registration" in OIDC app settings
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty to use OAuth mode
# - OAuth client credentials are stored encrypted in SQLite (TOKEN_STORAGE_DB)
# - Optional: Pre-register client and provide credentials (otherwise auto-registers)
NEXTCLOUD_OIDC_CLIENT_ID=
NEXTCLOUD_OIDC_CLIENT_SECRET=
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# OAuth Storage Configuration (SQLite storage for OAuth clients and refresh tokens)
# TOKEN_ENCRYPTION_KEY: Required for encrypting OAuth client secrets and refresh tokens
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
#TOKEN_ENCRYPTION_KEY=
# TOKEN_STORAGE_DB: Path to SQLite database (default: /app/data/tokens.db)
#TOKEN_STORAGE_DB=/app/data/tokens.db
# Option 2: Basic Authentication (LEGACY - Less Secure)
# - Requires username and password
# - Credentials stored in environment variables
+783
View File
@@ -0,0 +1,783 @@
{
"id": "nextcloud-mcp",
"realm": "nextcloud-mcp",
"notBefore": 0,
"defaultSignatureAlgorithm": "RS256",
"revokeRefreshToken": false,
"refreshTokenMaxReuse": 0,
"accessTokenLifespan": 300,
"accessTokenLifespanForImplicitFlow": 900,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 36000,
"offlineSessionIdleTimeout": 2592000,
"offlineSessionMaxLifespanEnabled": false,
"offlineSessionMaxLifespan": 5184000,
"accessCodeLifespan": 60,
"accessCodeLifespanUserAction": 300,
"accessCodeLifespanLogin": 1800,
"enabled": true,
"sslRequired": "external",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": false,
"editUsernameAllowed": false,
"bruteForceProtected": false,
"attributes": {
"frontendUrl": "http://localhost:8888"
},
"roles": {
"realm": [
{
"name": "offline_access",
"description": "${role_offline-access}",
"composite": false,
"clientRole": false
},
{
"name": "uma_authorization",
"description": "${role_uma_authorization}",
"composite": false,
"clientRole": false
},
{
"name": "default-roles-nextcloud-mcp",
"description": "${role_default-roles}",
"composite": true,
"composites": {
"realm": [
"offline_access",
"uma_authorization"
]
},
"clientRole": false
}
]
},
"users": [
{
"username": "admin",
"enabled": true,
"email": "admin@example.com",
"emailVerified": true,
"firstName": "Admin",
"lastName": "User",
"credentials": [
{
"type": "password",
"value": "admin",
"temporary": false
}
],
"realmRoles": [
"default-roles-nextcloud-mcp",
"offline_access"
],
"attributes": {
"quota": [
"1073741824"
]
}
},
{
"username": "test_read_only",
"enabled": true,
"email": "readonly@example.com",
"emailVerified": true,
"firstName": "Read",
"lastName": "Only",
"credentials": [
{
"type": "password",
"value": "test123",
"temporary": false
}
],
"realmRoles": [
"default-roles-nextcloud-mcp",
"offline_access"
],
"attributes": {
"quota": [
"1073741824"
]
}
},
{
"username": "test_write_only",
"enabled": true,
"email": "writeonly@example.com",
"emailVerified": true,
"firstName": "Write",
"lastName": "Only",
"credentials": [
{
"type": "password",
"value": "test123",
"temporary": false
}
],
"realmRoles": [
"default-roles-nextcloud-mcp",
"offline_access"
],
"attributes": {
"quota": [
"1073741824"
]
}
},
{
"username": "test_no_scopes",
"enabled": true,
"email": "noscopes@example.com",
"emailVerified": true,
"firstName": "No",
"lastName": "Scopes",
"credentials": [
{
"type": "password",
"value": "test123",
"temporary": false
}
],
"realmRoles": [
"default-roles-nextcloud-mcp",
"offline_access"
],
"attributes": {
"quota": [
"1073741824"
]
}
},
{
"username": "service-account-nextcloud-mcp-server",
"enabled": true,
"serviceAccountClientId": "nextcloud-mcp-server",
"clientRoles": {
"realm-management": [
"impersonation"
]
}
}
],
"clients": [
{
"clientId": "nextcloud",
"name": "Nextcloud Resource Server",
"description": "Resource server for Nextcloud APIs - used by user_oidc app for bearer token validation",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "nextcloud-secret-change-in-production",
"redirectUris": [],
"webOrigins": [],
"bearerOnly": true,
"consentRequired": false,
"standardFlowEnabled": false,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"publicClient": false,
"protocol": "openid-connect",
"attributes": {
"display.on.consent.screen": "false"
},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1
},
{
"clientId": "nextcloud-mcp-server",
"name": "Nextcloud MCP Server",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "mcp-secret-change-in-production",
"redirectUris": [
"http://localhost:*",
"http://127.0.0.1:*",
"http://localhost:*/callback",
"http://127.0.0.1:*/callback"
],
"webOrigins": [
"+"
],
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": true,
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {
"pkce.code.challenge.method": "S256",
"use.refresh.tokens": "true",
"backchannel.logout.session.required": "true",
"backchannel.logout.url": "http://app:80/index.php/apps/user_oidc/backchannel-logout/keycloak",
"oauth2.device.authorization.grant.enabled": "false",
"oidc.ciba.grant.enabled": "false",
"client_credentials.use_refresh_token": "false",
"display.on.consent.screen": "false",
"token.exchange.grant.enabled": "true",
"client.token.exchange.standard.enabled": "true"
},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"protocolMappers": [
{
"name": "audience-nextcloud",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "nextcloud",
"access.token.claim": "true",
"id.token.claim": "false"
}
},
{
"name": "sub",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "username",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "sub",
"jsonType.label": "String"
}
},
{
"name": "full name",
"protocol": "openid-connect",
"protocolMapper": "oidc-full-name-mapper",
"consentRequired": false,
"config": {
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
},
{
"name": "email",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email",
"jsonType.label": "String"
}
},
{
"name": "preferred_username",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "username",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "preferred_username",
"jsonType.label": "String"
}
},
{
"name": "quota",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "quota",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "quota",
"jsonType.label": "String"
}
}
],
"defaultClientScopes": [
"web-origins",
"profile",
"roles",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt",
"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",
"todo:read",
"todo:write"
]
}
],
"clientScopes": [
{
"name": "offline_access",
"description": "OpenID Connect built-in scope: offline_access",
"protocol": "openid-connect",
"attributes": {
"consent.screen.text": "${offlineAccessScopeConsentText}",
"display.on.consent.screen": "true"
}
},
{
"name": "profile",
"description": "OpenID Connect built-in scope: profile",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
},
"protocolMappers": [
{
"name": "full name",
"protocol": "openid-connect",
"protocolMapper": "oidc-full-name-mapper",
"consentRequired": false,
"config": {
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
},
{
"name": "username",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "username",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "preferred_username",
"jsonType.label": "String"
}
},
{
"name": "given name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "firstName",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "given_name",
"jsonType.label": "String"
}
},
{
"name": "family name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "lastName",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "family_name",
"jsonType.label": "String"
}
}
]
},
{
"name": "email",
"description": "OpenID Connect built-in scope: email",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
},
"protocolMappers": [
{
"name": "email",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email",
"jsonType.label": "String"
}
},
{
"name": "email verified",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "emailVerified",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email_verified",
"jsonType.label": "boolean"
}
}
]
},
{
"name": "roles",
"description": "OpenID Connect scope for add user roles to the access token",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "false",
"display.on.consent.screen": "true"
},
"protocolMappers": [
{
"name": "realm roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"consentRequired": false,
"config": {
"user.attribute": "foo",
"access.token.claim": "true",
"claim.name": "realm_access.roles",
"jsonType.label": "String",
"multivalued": "true"
}
},
{
"name": "client roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-client-role-mapper",
"consentRequired": false,
"config": {
"user.attribute": "foo",
"access.token.claim": "true",
"claim.name": "resource_access.${client_id}.roles",
"jsonType.label": "String",
"multivalued": "true"
}
}
]
},
{
"name": "web-origins",
"description": "OpenID Connect scope for add allowed web origins to the access token",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "false",
"display.on.consent.screen": "false"
},
"protocolMappers": [
{
"name": "allowed web origins",
"protocol": "openid-connect",
"protocolMapper": "oidc-allowed-origins-mapper",
"consentRequired": false,
"config": {}
}
]
},
{
"name": "notes:read",
"description": "Nextcloud Notes read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Read your notes"
}
},
{
"name": "notes:write",
"description": "Nextcloud Notes write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Create, update, and delete your notes"
}
},
{
"name": "calendar:read",
"description": "Nextcloud Calendar read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Read your calendars and events"
}
},
{
"name": "calendar:write",
"description": "Nextcloud Calendar write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Create, update, and delete calendars and events"
}
},
{
"name": "contacts:read",
"description": "Nextcloud Contacts read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Read your contacts"
}
},
{
"name": "contacts:write",
"description": "Nextcloud Contacts write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Create, update, and delete contacts"
}
},
{
"name": "cookbook:read",
"description": "Nextcloud Cookbook read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Read your recipes"
}
},
{
"name": "cookbook:write",
"description": "Nextcloud Cookbook write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Create, update, and delete recipes"
}
},
{
"name": "deck:read",
"description": "Nextcloud Deck read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Read your boards and cards"
}
},
{
"name": "deck:write",
"description": "Nextcloud Deck write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Create, update, and delete boards and cards"
}
},
{
"name": "tables:read",
"description": "Nextcloud Tables read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Read your tables and rows"
}
},
{
"name": "tables:write",
"description": "Nextcloud Tables write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Create, update, and delete tables and rows"
}
},
{
"name": "files:read",
"description": "Nextcloud Files read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Read your files"
}
},
{
"name": "files:write",
"description": "Nextcloud Files write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Upload, update, and delete files"
}
},
{
"name": "sharing:read",
"description": "Nextcloud Sharing read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "View shared resources"
}
},
{
"name": "sharing:write",
"description": "Nextcloud Sharing write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Create and manage shares"
}
},
{
"name": "todo:read",
"description": "Nextcloud Tasks/Todo read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Read your tasks"
}
},
{
"name": "todo:write",
"description": "Nextcloud Tasks/Todo write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Create, update, and delete tasks"
}
},
{
"name": "audience",
"description": "Audience scope for token validation",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "false"
},
"protocolMappers": [
{
"name": "mcp-server-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "nextcloud-mcp-server",
"id.token.claim": "false",
"access.token.claim": "true"
}
},
{
"name": "nextcloud-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "nextcloud",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
}
],
"components": {
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [
{
"name": "Trusted Hosts",
"providerId": "trusted-hosts",
"subType": "anonymous",
"subComponents": {},
"config": {
"trusted-hosts": [
"localhost",
"127.0.0.1",
"172.19.0.1"
],
"host-sending-registration-request-must-match": [
"false"
],
"client-uris-must-match": [
"true"
]
}
},
{
"name": "Max Clients",
"providerId": "max-clients",
"subType": "anonymous",
"subComponents": {},
"config": {
"max-clients": [
"200"
]
}
}
]
},
"defaultDefaultClientScopes": [
"profile",
"email",
"roles",
"web-origins",
"audience"
],
"defaultOptionalClientScopes": [
"offline_access",
"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",
"todo:read",
"todo:write"
]
}
+356 -139
View File
@@ -3,6 +3,10 @@ import os
from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
import click
import httpx
@@ -208,6 +212,9 @@ class OAuthAppContext:
nextcloud_host: str
token_verifier: NextcloudTokenVerifier
refresh_token_storage: Optional["RefreshTokenStorage"] = None
oauth_client: Optional[object] = None # NextcloudOAuthClient or KeycloakOAuthClient
oauth_provider: str = "nextcloud" # "nextcloud" or "keycloak"
def is_oauth_mode() -> bool:
@@ -261,21 +268,22 @@ async def load_oauth_client_credentials(
logger.info("Using pre-configured OAuth client credentials from environment")
return (client_id, client_secret)
# Try loading from storage file
storage_path = os.getenv(
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
)
from pathlib import Path
# Try loading from SQLite storage
try:
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.client_registration import load_client_from_file
storage = RefreshTokenStorage.from_env()
await storage.initialize()
client_info = load_client_from_file(Path(storage_path))
if client_info:
logger.info(
f"Loaded OAuth client from storage: {client_info.client_id[:16]}..."
)
return (client_info.client_id, client_info.client_secret)
client_data = await storage.get_oauth_client()
if client_data:
logger.info(
f"Loaded OAuth client from SQLite: {client_data['client_id'][:16]}..."
)
return (client_data["client_id"], client_data["client_secret"])
except ValueError:
# TOKEN_ENCRYPTION_KEY not set, skip SQLite storage check
logger.debug("SQLite storage not available (TOKEN_ENCRYPTION_KEY not set)")
# Try dynamic registration if available
if registration_endpoint:
@@ -306,6 +314,17 @@ async def load_oauth_client_credentials(
"sharing:read sharing:write"
)
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", default_scopes)
# Add offline_access scope if refresh tokens are enabled
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
"true",
"1",
"yes",
)
if enable_offline_access and "offline_access" not in scopes:
scopes = f"{scopes} offline_access"
logger.info("✓ offline_access scope enabled for refresh tokens")
logger.info(f"Requesting OAuth scopes: {scopes}")
# Get token type from environment (Bearer or jwt)
@@ -316,15 +335,17 @@ async def load_oauth_client_credentials(
token_type = "Bearer"
logger.info(f"Requesting token type: {token_type}")
# Load or register client
from nextcloud_mcp_server.auth.client_registration import (
load_or_register_client,
)
# Ensure OAuth client in SQLite storage
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
client_info = await load_or_register_client(
storage = RefreshTokenStorage.from_env()
await storage.initialize()
client_info = await ensure_oauth_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
storage_path=storage_path,
storage=storage,
client_name=f"Nextcloud MCP Server ({token_type})",
redirect_uris=redirect_uris,
scopes=scopes,
@@ -338,8 +359,9 @@ async def load_oauth_client_credentials(
raise ValueError(
"OAuth mode requires either:\n"
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET environment variables, OR\n"
"2. Pre-existing client credentials file at NEXTCLOUD_OIDC_CLIENT_STORAGE, OR\n"
"3. Dynamic client registration enabled on Nextcloud OIDC app"
"2. Pre-existing client credentials in SQLite storage (TOKEN_STORAGE_DB), OR\n"
"3. Dynamic client registration enabled on Nextcloud OIDC app\n\n"
"Note: TOKEN_ENCRYPTION_KEY is required for SQLite storage"
)
@@ -367,84 +389,26 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
await client.close()
@asynccontextmanager
async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
"""
Manage application lifecycle for OAuth mode.
Initializes OAuth client registration and token verifier.
Does NOT create a Nextcloud client - clients are created per-request.
"""
logger.info("Starting MCP server in OAuth mode")
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
raise ValueError("NEXTCLOUD_HOST environment variable is required")
nextcloud_host = nextcloud_host.rstrip("/")
# Get OAuth discovery endpoint
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
try:
# Fetch OIDC discovery
async with httpx.AsyncClient() as client:
response = await client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
logger.info(f"OIDC discovery successful: {discovery_url}")
# Extract endpoints
userinfo_uri = discovery["userinfo_endpoint"]
registration_endpoint = discovery.get("registration_endpoint")
introspection_uri = discovery.get("introspection_endpoint")
logger.info(f"Userinfo endpoint: {userinfo_uri}")
if introspection_uri:
logger.info(f"Introspection endpoint: {introspection_uri}")
# Load OAuth client credentials
client_id, client_secret = await load_oauth_client_credentials(
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
)
# Create token verifier with introspection support
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri,
introspection_uri=introspection_uri,
client_id=client_id,
client_secret=client_secret,
)
logger.info("OAuth initialization complete")
# Initialize document processors
initialize_document_processors()
try:
yield OAuthAppContext(
nextcloud_host=nextcloud_host, token_verifier=token_verifier
)
finally:
logger.info("Shutting down OAuth mode")
await token_verifier.close()
except Exception as e:
logger.error(f"Failed to initialize OAuth mode: {e}")
raise
async def setup_oauth_config():
"""
Setup OAuth configuration by performing OIDC discovery and client registration.
Auto-detects OAuth provider mode:
- Integrated mode: OIDC_DISCOVERY_URL points to NEXTCLOUD_HOST (or not set)
→ Nextcloud OIDC app provides both OAuth and API access
- External IdP mode: OIDC_DISCOVERY_URL points to external provider
→ External IdP for OAuth, Nextcloud user_oidc validates tokens and provides API access
Uses generic OIDC environment variables:
- OIDC_DISCOVERY_URL: OIDC discovery endpoint (optional, defaults to NEXTCLOUD_HOST)
- OIDC_CLIENT_ID / OIDC_CLIENT_SECRET: Static credentials (optional, uses DCR if not provided)
- NEXTCLOUD_OIDC_SCOPES: Requested OAuth scopes
This is done synchronously before FastMCP initialization because FastMCP
requires token_verifier at construction time.
Returns:
Tuple of (nextcloud_host, token_verifier, auth_settings)
Tuple of (nextcloud_host, token_verifier, auth_settings, refresh_token_storage, oauth_client, oauth_provider)
"""
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
@@ -453,68 +417,222 @@ async def setup_oauth_config():
)
nextcloud_host = nextcloud_host.rstrip("/")
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
# Get OIDC discovery URL (defaults to Nextcloud integrated mode)
discovery_url = os.getenv(
"OIDC_DISCOVERY_URL", f"{nextcloud_host}/.well-known/openid-configuration"
)
logger.info(f"Performing OIDC discovery: {discovery_url}")
# Fetch OIDC discovery
# Perform OIDC discovery
async with httpx.AsyncClient() as client:
response = await client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
logger.info("OIDC discovery successful")
logger.info("OIDC discovery successful")
# Validate PKCE support
validate_pkce_support(discovery, discovery_url)
# Extract endpoints
# Extract OIDC endpoints
issuer = discovery["issuer"]
userinfo_uri = discovery["userinfo_endpoint"]
jwks_uri = discovery.get("jwks_uri")
introspection_uri = discovery.get("introspection_endpoint")
registration_endpoint = discovery.get("registration_endpoint")
# Allow overriding JWKS URI (useful when running in Docker with frontendUrl)
# Example: frontendUrl=http://localhost:8888 but MCP server needs http://keycloak:8080
jwks_uri_override = os.getenv("OIDC_JWKS_URI")
if jwks_uri_override:
logger.info(f"OIDC_JWKS_URI override: {jwks_uri}{jwks_uri_override}")
jwks_uri = jwks_uri_override
logger.info("OIDC endpoints discovered:")
logger.info(f" Issuer: {issuer}")
logger.info(f" Userinfo: {userinfo_uri}")
logger.info(f" JWKS: {jwks_uri}")
if jwks_uri:
logger.info(f" JWKS: {jwks_uri}")
if introspection_uri:
logger.info(f" Introspection: {introspection_uri}")
# Allow override of public issuer URL for both client configuration and JWT validation
# Auto-detect provider mode based on issuer
# External IdP mode: issuer doesn't match Nextcloud host
# Normalize URLs for comparison (handle port differences like :80 for HTTP)
from urllib.parse import urlparse
def normalize_url(url: str) -> str:
"""Normalize URL by removing default ports (80 for HTTP, 443 for HTTPS)."""
parsed = urlparse(url)
# Remove default ports
if (parsed.scheme == "http" and parsed.port == 80) or (
parsed.scheme == "https" and parsed.port == 443
):
# Remove explicit default port
hostname = parsed.hostname or parsed.netloc.split(":")[0]
return f"{parsed.scheme}://{hostname}"
return f"{parsed.scheme}://{parsed.netloc}"
issuer_normalized = normalize_url(issuer)
nextcloud_normalized = normalize_url(nextcloud_host)
is_external_idp = not issuer_normalized.startswith(nextcloud_normalized)
if is_external_idp:
oauth_provider = "external" # Could be Keycloak, Auth0, Okta, etc.
logger.info(
f"✓ Detected external IdP mode (issuer: {issuer} != Nextcloud: {nextcloud_host})"
)
logger.info(" Tokens will be validated via Nextcloud user_oidc app")
else:
oauth_provider = "nextcloud"
logger.info("✓ Detected integrated mode (Nextcloud OIDC app)")
# Check if offline access (refresh tokens) is enabled
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
"true",
"1",
"yes",
)
# Initialize refresh token storage if enabled
refresh_token_storage = None
if enable_offline_access:
try:
from nextcloud_mcp_server.auth.refresh_token_storage import (
RefreshTokenStorage,
)
# Validate encryption key before initializing
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
if not encryption_key:
logger.warning(
"ENABLE_OFFLINE_ACCESS=true but TOKEN_ENCRYPTION_KEY not set. "
"Refresh tokens will NOT be stored. Generate a key with:\n"
' python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
)
else:
refresh_token_storage = RefreshTokenStorage.from_env()
await refresh_token_storage.initialize()
logger.info(
"✓ Refresh token storage initialized (offline_access enabled)"
)
except Exception as e:
logger.error(f"Failed to initialize refresh token storage: {e}")
logger.warning(
"Continuing without refresh token storage - users will need to re-authenticate after token expiration"
)
# Load client credentials (static or dynamic registration)
client_id = os.getenv("OIDC_CLIENT_ID")
client_secret = os.getenv("OIDC_CLIENT_SECRET")
if client_id and client_secret:
logger.info(f"Using static OIDC client credentials: {client_id}")
elif registration_endpoint:
logger.info("OIDC_CLIENT_ID not set, attempting Dynamic Client Registration")
client_id, client_secret = await load_oauth_client_credentials(
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
)
else:
raise ValueError(
"OIDC_CLIENT_ID and OIDC_CLIENT_SECRET environment variables are required "
"when the OIDC provider does not support Dynamic Client Registration. "
f"Discovery URL: {discovery_url}"
)
# Handle public issuer override (for clients accessing via different URL)
# When clients access Nextcloud via a public URL (e.g., http://127.0.0.1:8080),
# the OIDC app issues JWT tokens with that public URL in the 'iss' claim,
# even though the MCP server accesses Nextcloud via an internal URL (e.g., http://app).
# Therefore, we must validate JWT tokens against the public issuer, not the internal one.
# but the MCP server accesses via internal URL (e.g., http://app:80),
# we need to use the public URL for JWT validation and client configuration
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
public_issuer = public_issuer.rstrip("/")
logger.info(
f"Using public issuer URL for clients and JWT validation: {public_issuer}"
f"Using public issuer URL override for JWT validation: {public_issuer}"
)
# Use public issuer for both client configuration AND JWT validation
issuer = public_issuer
jwt_validation_issuer = public_issuer
client_issuer = public_issuer
else:
# Use discovered issuer for both
jwt_validation_issuer = issuer
client_issuer = issuer
# Load OAuth client credentials
client_id, client_secret = await load_oauth_client_credentials(
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
)
# Create token verifier
if is_external_idp:
# External IdP mode: Validate via Nextcloud user_oidc app
# The user_oidc app accepts tokens from the external IdP and provisions users
nextcloud_userinfo_uri = f"{nextcloud_host}/apps/user_oidc/userinfo"
# Create token verifier with JWT support and introspection
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri,
jwks_uri=jwks_uri, # Enable JWT verification if available
issuer=jwt_validation_issuer, # Use original issuer for JWT validation
introspection_uri=introspection_uri, # Enable introspection for opaque tokens
client_id=client_id,
client_secret=client_secret,
)
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host,
userinfo_uri=nextcloud_userinfo_uri, # Nextcloud validates external tokens
jwks_uri=jwks_uri, # External IdP's JWKS for JWT validation
issuer=jwt_validation_issuer, # External IdP issuer
introspection_uri=None, # External IdP introspection not used
client_id=client_id,
client_secret=client_secret,
)
logger.info(
"✓ External IdP mode configured - tokens validated via Nextcloud user_oidc app"
)
else:
# Integrated mode: Nextcloud provides both OAuth and validation
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri, # Nextcloud userinfo endpoint
jwks_uri=jwks_uri, # Nextcloud JWKS for JWT validation
issuer=jwt_validation_issuer, # Nextcloud issuer (or public override)
introspection_uri=introspection_uri, # Nextcloud introspection for opaque tokens
client_id=client_id,
client_secret=client_secret,
)
logger.info(
"✓ Integrated mode configured - Nextcloud provides OAuth and validation"
)
# Create OAuth client for server-initiated flows (e.g., token exchange, background workers)
oauth_client = None
if enable_offline_access and refresh_token_storage and is_external_idp:
# For external IdP mode, create generic OIDC client for token operations
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
redirect_uri = f"{mcp_server_url}/oauth/callback"
# Extract base URL and realm from discovery URL
# Format: http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
# → base_url: http://keycloak:8080, realm: nextcloud-mcp
if "/realms/" in discovery_url:
base_url = discovery_url.split("/realms/")[0]
realm = discovery_url.split("/realms/")[1].split("/")[0]
else:
# Fallback: use issuer to extract base URL
base_url = (
issuer.rsplit("/realms/", 1)[0] if "/realms/" in issuer else issuer
)
realm = issuer.split("/realms/")[1] if "/realms/" in issuer else ""
oauth_client = KeycloakOAuthClient(
keycloak_url=base_url,
realm=realm,
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
)
await oauth_client.discover()
logger.info(
"✓ OIDC client initialized for token operations (token exchange, refresh)"
)
elif enable_offline_access and refresh_token_storage:
# For integrated mode, OAuth client could be added later
# For now, token refresh can use httpx directly with discovered endpoints
logger.info(
"OAuth client for token refresh not yet implemented for integrated mode"
)
# Create auth settings
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
@@ -523,13 +641,22 @@ async def setup_oauth_config():
# Scopes are now advertised via PRM endpoint and enforced per-tool.
# This allows dynamic tool filtering based on user's actual token scopes.
auth_settings = AuthSettings(
issuer_url=AnyHttpUrl(issuer),
issuer_url=AnyHttpUrl(
client_issuer
), # Use client issuer (may be public override)
resource_server_url=AnyHttpUrl(mcp_server_url),
)
logger.info("OAuth configuration complete")
return nextcloud_host, token_verifier, auth_settings
return (
nextcloud_host,
token_verifier,
auth_settings,
refresh_token_storage,
oauth_client,
oauth_provider,
)
def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
@@ -543,10 +670,53 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
# Asynchronously get the OAuth configuration
import anyio
_, token_verifier, auth_settings = anyio.run(setup_oauth_config)
(
nextcloud_host,
token_verifier,
auth_settings,
refresh_token_storage,
oauth_client,
oauth_provider,
) = anyio.run(setup_oauth_config)
# Create lifespan function with captured OAuth context (closure)
@asynccontextmanager
async def oauth_lifespan(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
"""
Lifespan context for OAuth mode - captures OAuth configuration from outer scope.
"""
logger.info("Starting MCP server in OAuth mode")
logger.info(f"Using OAuth provider: {oauth_provider}")
if refresh_token_storage:
logger.info("Refresh token storage is available")
if oauth_client:
logger.info("OAuth client is available for token refresh")
# Initialize document processors
initialize_document_processors()
try:
yield OAuthAppContext(
nextcloud_host=nextcloud_host,
token_verifier=token_verifier,
refresh_token_storage=refresh_token_storage,
oauth_client=oauth_client,
oauth_provider=oauth_provider,
)
finally:
logger.info("Shutting down MCP server")
# RefreshTokenStorage uses context managers, no close() needed
# OAuth client cleanup (if it has a close method)
if oauth_client and hasattr(oauth_client, "close"):
try:
await oauth_client.close()
except Exception as e:
logger.warning(f"Error closing OAuth client: {e}")
logger.info("MCP server shutdown complete")
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_oauth,
lifespan=oauth_lifespan,
token_verifier=token_verifier,
auth=auth_settings,
)
@@ -648,8 +818,71 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
await stack.enter_async_context(mcp.session_manager.run())
yield
# Health check endpoints for Kubernetes probes
def health_live(request):
"""Liveness probe endpoint.
Returns 200 OK if the application process is running.
This is a simple check that doesn't verify external dependencies.
"""
return JSONResponse(
{
"status": "alive",
"mode": "oauth" if oauth_enabled else "basic",
}
)
async def health_ready(request):
"""Readiness probe endpoint.
Returns 200 OK if the application is ready to serve traffic.
Checks that required configuration is present.
"""
checks = {}
is_ready = True
# Check Nextcloud host configuration
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if nextcloud_host:
checks["nextcloud_configured"] = "ok"
else:
checks["nextcloud_configured"] = "error: NEXTCLOUD_HOST not set"
is_ready = False
# Check authentication configuration
if oauth_enabled:
# OAuth mode - just verify we got this far (token_verifier initialized in lifespan)
checks["auth_mode"] = "oauth"
checks["auth_configured"] = "ok"
else:
# BasicAuth mode - verify credentials are set
username = os.getenv("NEXTCLOUD_USERNAME")
password = os.getenv("NEXTCLOUD_PASSWORD")
if username and password:
checks["auth_mode"] = "basic"
checks["auth_configured"] = "ok"
else:
checks["auth_mode"] = "basic"
checks["auth_configured"] = "error: credentials not set"
is_ready = False
status_code = 200 if is_ready else 503
return JSONResponse(
{
"status": "ready" if is_ready else "not_ready",
"checks": checks,
},
status_code=status_code,
)
# Add Protected Resource Metadata (PRM) endpoint for OAuth mode
routes = []
# Add health check routes (available in both OAuth and BasicAuth modes)
routes.append(Route("/health/live", health_live, methods=["GET"]))
routes.append(Route("/health/ready", health_ready, methods=["GET"]))
logger.info("Health check endpoints enabled: /health/live, /health/ready")
if oauth_enabled:
def oauth_protected_resource_metadata(request):
@@ -797,13 +1030,6 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
envvar="NEXTCLOUD_OIDC_CLIENT_SECRET",
help="OAuth client secret (can also use NEXTCLOUD_OIDC_CLIENT_SECRET env var)",
)
@click.option(
"--oauth-storage-path",
envvar="NEXTCLOUD_OIDC_CLIENT_STORAGE",
default=".nextcloud_oauth_client.json",
show_default=True,
help="Path to store OAuth client credentials (can also use NEXTCLOUD_OIDC_CLIENT_STORAGE env var)",
)
@click.option(
"--mcp-server-url",
envvar="NEXTCLOUD_MCP_SERVER_URL",
@@ -855,7 +1081,6 @@ def run(
oauth: bool | None,
oauth_client_id: str | None,
oauth_client_secret: str | None,
oauth_storage_path: str,
mcp_server_url: str,
nextcloud_host: str | None,
nextcloud_username: str | None,
@@ -910,8 +1135,6 @@ def run(
os.environ["NEXTCLOUD_OIDC_CLIENT_ID"] = oauth_client_id
if oauth_client_secret:
os.environ["NEXTCLOUD_OIDC_CLIENT_SECRET"] = oauth_client_secret
if oauth_storage_path:
os.environ["NEXTCLOUD_OIDC_CLIENT_STORAGE"] = oauth_storage_path
if oauth_scopes:
os.environ["NEXTCLOUD_OIDC_SCOPES"] = oauth_scopes
if oauth_token_type:
@@ -954,13 +1177,7 @@ def run(
click.echo("OAuth Configuration:", err=True)
click.echo(" Mode: Dynamic Client Registration", err=True)
click.echo(" Host: " + nextcloud_host, err=True)
click.echo(
" Storage: "
+ os.getenv(
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
),
err=True,
)
click.echo(" Storage: SQLite (TOKEN_STORAGE_DB)", err=True)
click.echo("", err=True)
click.echo(
"Note: Make sure 'Dynamic Client Registration' is enabled", err=True
+2 -2
View File
@@ -1,7 +1,7 @@
"""OAuth authentication components for Nextcloud MCP server."""
from .bearer_auth import BearerAuth
from .client_registration import load_or_register_client, register_client
from .client_registration import ensure_oauth_client, register_client
from .context_helper import get_client_from_context
from .scope_authorization import (
InsufficientScopeError,
@@ -20,7 +20,7 @@ __all__ = [
"BearerAuth",
"NextcloudTokenVerifier",
"register_client",
"load_or_register_client",
"ensure_oauth_client",
"get_client_from_context",
"require_scopes",
"ScopeAuthorizationError",
@@ -1,16 +1,15 @@
"""Dynamic client registration for Nextcloud OIDC."""
import datetime as dt
import json
import logging
import os
import time
from pathlib import Path
from typing import Any
import anyio
import httpx
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
logger = logging.getLogger(__name__)
@@ -170,72 +169,6 @@ async def register_client(
raise ValueError(f"Invalid registration response: missing {e}")
def load_client_from_file(storage_path: Path) -> ClientInfo | None:
"""
Load client credentials from storage file.
Args:
storage_path: Path to the JSON file containing client credentials
Returns:
ClientInfo if file exists and is valid, None otherwise
"""
if not storage_path.exists():
logger.debug(f"Client storage file not found: {storage_path}")
return None
try:
with open(storage_path, "r") as f:
data = json.load(f)
client_info = ClientInfo.from_dict(data)
if client_info.is_expired:
logger.warning(
f"Stored client has expired (expired at {client_info.client_secret_expires_at})"
)
return None
logger.info(f"Loaded client from storage: {client_info.client_id[:16]}...")
if client_info.expires_soon:
logger.warning("Client expires soon (within 5 minutes)")
return client_info
except (json.JSONDecodeError, KeyError, ValueError) as e:
logger.error(f"Failed to load client from file: {e}")
return None
def save_client_to_file(client_info: ClientInfo, storage_path: Path):
"""
Save client credentials to storage file.
Args:
client_info: Client information to save
storage_path: Path to save the JSON file
Raises:
OSError: If file cannot be written
"""
try:
# Create directory if it doesn't exist
storage_path.parent.mkdir(parents=True, exist_ok=True)
# Write client info
with open(storage_path, "w") as f:
json.dump(client_info.to_dict(), f, indent=2)
# Set restrictive permissions (owner read/write only)
os.chmod(storage_path, 0o600)
logger.info(f"Saved client credentials to {storage_path}")
except OSError as e:
logger.error(f"Failed to save client credentials: {e}")
raise
async def delete_client(
nextcloud_url: str,
client_id: str,
@@ -362,28 +295,28 @@ async def delete_client(
return False
async def load_or_register_client(
async def ensure_oauth_client(
nextcloud_url: str,
registration_endpoint: str,
storage_path: str | Path,
storage: RefreshTokenStorage,
client_name: str = "Nextcloud MCP Server",
redirect_uris: list[str] | None = None,
scopes: str = "openid profile email",
token_type: str = "Bearer",
) -> ClientInfo:
"""
Load client from storage or register a new one if not found/expired.
Ensure OAuth client exists in SQLite storage.
This function:
1. Checks for existing client credentials in storage
1. Checks for existing client credentials in SQLite storage
2. Validates the credentials are not expired
3. Registers a new client if needed (no stored credentials or expired)
4. Saves the new client credentials
4. Saves the new client credentials to SQLite
Args:
nextcloud_url: Base URL of the Nextcloud instance
registration_endpoint: Full URL to the registration endpoint
storage_path: Path to store client credentials
storage: RefreshTokenStorage instance for SQLite storage
client_name: Name of the client application
redirect_uris: List of redirect URIs
scopes: Space-separated list of scopes to request (default: "openid profile email")
@@ -396,12 +329,13 @@ async def load_or_register_client(
httpx.HTTPStatusError: If registration fails
ValueError: If response is invalid
"""
storage_path = Path(storage_path)
# Try to load existing client
client_info = load_client_from_file(storage_path)
if client_info:
return client_info
# Try to load existing client from SQLite
client_data = await storage.get_oauth_client()
if client_data:
logger.info(
f"Loaded OAuth client from SQLite: {client_data['client_id'][:16]}..."
)
return ClientInfo.from_dict(client_data)
# Register new client
logger.info("Registering new OAuth client...")
@@ -414,7 +348,15 @@ async def load_or_register_client(
token_type=token_type,
)
# Save to storage
save_client_to_file(client_info, storage_path)
# Save to SQLite storage
await storage.store_oauth_client(
client_id=client_info.client_id,
client_secret=client_info.client_secret,
client_id_issued_at=client_info.client_id_issued_at,
client_secret_expires_at=client_info.client_secret_expires_at,
redirect_uris=client_info.redirect_uris,
registration_access_token=client_info.registration_access_token,
registration_client_uri=client_info.registration_client_uri,
)
return client_info
+581
View File
@@ -0,0 +1,581 @@
"""
Keycloak OAuth 2.0 / OIDC Client
Handles OAuth flows with Keycloak as the identity provider, including:
- OIDC Discovery
- Authorization Code Flow with PKCE
- Token refresh using refresh tokens (ADR-002 Tier 1)
- Integration with RefreshTokenStorage
"""
import hashlib
import logging
import os
import secrets
from typing import Optional
from urllib.parse import urlencode, urlparse
import httpx
logger = logging.getLogger(__name__)
class KeycloakOAuthClient:
"""OAuth 2.0 client for Keycloak integration"""
def __init__(
self,
keycloak_url: str,
realm: str,
client_id: str,
client_secret: str,
redirect_uri: str,
scopes: Optional[list[str]] = None,
):
"""
Initialize Keycloak OAuth client.
Args:
keycloak_url: Base URL of Keycloak (e.g., http://keycloak:8080)
realm: Keycloak realm name
client_id: OAuth client ID
client_secret: OAuth client secret
redirect_uri: OAuth redirect URI
scopes: List of scopes to request (default: openid, profile, email, offline_access)
"""
self.keycloak_url = keycloak_url.rstrip("/")
self.realm = realm
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.scopes = scopes or ["openid", "profile", "email", "offline_access"]
# Discovered endpoints (populated by discover())
self.authorization_endpoint: Optional[str] = None
self.token_endpoint: Optional[str] = None
self.userinfo_endpoint: Optional[str] = None
self.jwks_uri: Optional[str] = None
self.end_session_endpoint: Optional[str] = None
self._http_client: Optional[httpx.AsyncClient] = None
@classmethod
def from_env(cls) -> "KeycloakOAuthClient":
"""
Create client from environment variables.
Environment variables:
KEYCLOAK_URL: Keycloak base URL
KEYCLOAK_REALM: Realm name
KEYCLOAK_CLIENT_ID: Client ID
KEYCLOAK_CLIENT_SECRET: Client secret
NEXTCLOUD_MCP_SERVER_URL: MCP server URL (for redirect URI)
Returns:
KeycloakOAuthClient instance
Raises:
ValueError: If required environment variables are missing
"""
keycloak_url = os.getenv("KEYCLOAK_URL")
realm = os.getenv("KEYCLOAK_REALM")
client_id = os.getenv("KEYCLOAK_CLIENT_ID")
client_secret = os.getenv("KEYCLOAK_CLIENT_SECRET")
server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
if not all([keycloak_url, realm, client_id, client_secret]):
raise ValueError(
"Missing required environment variables: "
"KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET"
)
# Parse server URL to construct redirect URI
parsed_url = urlparse(server_url)
redirect_uri = f"{parsed_url.scheme}://{parsed_url.netloc}/oauth/callback"
return cls(
keycloak_url=keycloak_url,
realm=realm,
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
)
async def _get_http_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client"""
if self._http_client is None:
self._http_client = httpx.AsyncClient(timeout=30.0)
return self._http_client
async def close(self) -> None:
"""Close HTTP client"""
if self._http_client:
await self._http_client.aclose()
self._http_client = None
async def discover(self) -> None:
"""
Perform OIDC discovery to get endpoint URLs.
Raises:
httpx.HTTPError: If discovery fails
"""
discovery_url = (
f"{self.keycloak_url}/realms/{self.realm}/.well-known/openid-configuration"
)
logger.info(f"Discovering Keycloak endpoints at {discovery_url}")
client = await self._get_http_client()
response = await client.get(discovery_url)
response.raise_for_status()
discovery_data = response.json()
self.authorization_endpoint = discovery_data["authorization_endpoint"]
self.token_endpoint = discovery_data["token_endpoint"]
self.userinfo_endpoint = discovery_data["userinfo_endpoint"]
self.jwks_uri = discovery_data.get("jwks_uri")
self.end_session_endpoint = discovery_data.get("end_session_endpoint")
logger.info(
f"✓ Discovered Keycloak endpoints:\n"
f" Authorization: {self.authorization_endpoint}\n"
f" Token: {self.token_endpoint}\n"
f" Userinfo: {self.userinfo_endpoint}\n"
f" JWKS: {self.jwks_uri}"
)
def generate_pkce_challenge(self) -> tuple[str, str]:
"""
Generate PKCE code verifier and challenge.
Returns:
Tuple of (code_verifier, code_challenge)
"""
import base64
# Generate code verifier (43-128 characters)
code_verifier = secrets.token_urlsafe(32)
# Generate code challenge using S256 method (base64url-encoded SHA256)
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
return code_verifier, code_challenge
async def get_authorization_url(
self,
state: str,
code_challenge: str,
extra_params: Optional[dict[str, str]] = None,
) -> str:
"""
Build authorization URL for OAuth flow.
Args:
state: CSRF protection state parameter
code_challenge: PKCE code challenge
extra_params: Additional query parameters
Returns:
Authorization URL
Raises:
RuntimeError: If discover() hasn't been called
"""
if not self.authorization_endpoint:
await self.discover()
if not self.authorization_endpoint:
raise RuntimeError("Authorization endpoint not discovered")
params = {
"client_id": self.client_id,
"response_type": "code",
"redirect_uri": self.redirect_uri,
"scope": " ".join(self.scopes),
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
if extra_params:
params.update(extra_params)
return f"{self.authorization_endpoint}?{urlencode(params)}"
async def exchange_authorization_code(
self,
code: str,
code_verifier: str,
) -> dict:
"""
Exchange authorization code for tokens.
Args:
code: Authorization code from OAuth callback
code_verifier: PKCE code verifier
Returns:
Token response dictionary with keys:
- access_token: Access token
- refresh_token: Refresh token (if offline_access scope requested)
- id_token: ID token (JWT)
- expires_in: Access token lifetime in seconds
- refresh_expires_in: Refresh token lifetime in seconds (optional)
- token_type: Token type (Bearer)
Raises:
httpx.HTTPError: If token exchange fails
"""
if not self.token_endpoint:
await self.discover()
if not self.token_endpoint:
raise RuntimeError("Token endpoint not discovered")
logger.debug(
f"Exchanging authorization code for tokens at {self.token_endpoint}"
)
client = await self._get_http_client()
response = await client.post(
self.token_endpoint,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.redirect_uri,
"code_verifier": code_verifier,
},
auth=(self.client_id, self.client_secret),
)
response.raise_for_status()
token_data = response.json()
logger.info("✓ Successfully exchanged authorization code for tokens")
if "refresh_token" in token_data:
logger.info(" Received refresh token (offline_access granted)")
return token_data
async def refresh_access_token(self, refresh_token: str) -> dict:
"""
Refresh access token using refresh token.
Args:
refresh_token: Refresh token
Returns:
Token response dictionary (same format as exchange_authorization_code)
Raises:
httpx.HTTPError: If token refresh fails
"""
if not self.token_endpoint:
await self.discover()
if not self.token_endpoint:
raise RuntimeError("Token endpoint not discovered")
logger.debug("Refreshing access token")
client = await self._get_http_client()
response = await client.post(
self.token_endpoint,
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
},
auth=(self.client_id, self.client_secret),
)
response.raise_for_status()
token_data = response.json()
logger.debug("✓ Successfully refreshed access token")
return token_data
async def get_userinfo(self, access_token: str) -> dict:
"""
Get user information using access token.
Args:
access_token: Access token
Returns:
Userinfo response dictionary with claims like:
- sub: Subject (user ID)
- name: Full name
- preferred_username: Username
- email: Email address
- email_verified: Email verification status
Raises:
httpx.HTTPError: If userinfo request fails
"""
if not self.userinfo_endpoint:
await self.discover()
if not self.userinfo_endpoint:
raise RuntimeError("Userinfo endpoint not discovered")
logger.debug("Fetching user info")
client = await self._get_http_client()
response = await client.get(
self.userinfo_endpoint,
headers={"Authorization": f"Bearer {access_token}"},
)
response.raise_for_status()
userinfo = response.json()
logger.debug(f"✓ Retrieved user info for subject: {userinfo.get('sub')}")
return userinfo
async def get_service_account_token(self, scopes: list[str] | None = None) -> dict:
"""
Get a service account token using client_credentials grant.
⚠️ **WARNING: DO NOT USE FOR DIRECT API ACCESS IN OAUTH MODE** ⚠️
This method creates a service account user in Nextcloud which VIOLATES
OAuth "act on-behalf-of" principles. Using this token directly for API
access will:
- Create a Nextcloud user: `service-account-{client_id}`
- Attribute all actions to service account instead of real user
- Break audit trail and user attribution
- Create stateful server identity in Nextcloud
- Violate OAuth security model
**Valid Use Case**: ONLY as subject_token for RFC 8693 token exchange
(ADR-002 Tier 2) where it's immediately exchanged for a user token.
**Invalid Use Case**: Direct API access with this token (ADR-002 rejected
this as "Tier 1" - see docs/ADR-002-vector-sync-authentication.md).
**Alternative**: Use token exchange (impersonation/delegation) for
background operations, or use BasicAuth mode if truly need service account.
This requires the client to have serviceAccountsEnabled=true in provider.
Args:
scopes: Optional list of scopes to request (default: openid profile email)
Returns:
Token response dictionary with:
- access_token: Service account access token
- token_type: Bearer
- expires_in: Token lifetime in seconds
- scope: Granted scopes
Raises:
httpx.HTTPError: If token request fails
See Also:
- ADR-002 "Will Not Implement" section for detailed critique
- exchange_token_for_user() for proper token exchange usage
"""
if not self.token_endpoint:
await self.discover()
if not self.token_endpoint:
raise RuntimeError("Token endpoint not discovered")
# Default scopes
if scopes is None:
scopes = ["openid", "profile", "email"]
scope_str = " ".join(scopes)
logger.info(f"Requesting service account token with scopes: {scope_str}")
client = await self._get_http_client()
response = await client.post(
self.token_endpoint,
data={
"grant_type": "client_credentials",
"scope": scope_str,
},
auth=(self.client_id, self.client_secret),
)
response.raise_for_status()
token_data = response.json()
logger.info("✓ Service account token acquired")
return token_data
async def exchange_token_for_user(
self,
subject_token: str,
target_user_id: str | None = None,
audience: str | None = None,
scopes: list[str] | None = None,
) -> dict:
"""
Exchange a token for a user-scoped token using RFC 8693 Token Exchange.
This allows the MCP server (with a service account token) to obtain
user-scoped access tokens for background operations without needing
refresh tokens.
Args:
subject_token: The token being exchanged (service account or user token)
target_user_id: Optional user ID to impersonate/exchange for
audience: Optional target audience (client ID)
scopes: Optional list of scopes for the new token
Returns:
Token response dictionary with:
- access_token: User-scoped access token
- issued_token_type: urn:ietf:params:oauth:token-type:access_token
- token_type: Bearer
- expires_in: Token lifetime in seconds
Raises:
httpx.HTTPError: If token exchange fails (403 if not authorized)
Example:
# Get service account token
service_token = await client.get_service_account_token()
# Exchange for user-scoped token
user_token = await client.exchange_token_for_user(
subject_token=service_token["access_token"],
target_user_id="admin", # Username or sub claim
audience="nextcloud",
scopes=["notes:read", "files:read"]
)
Note:
This implements BOTH ADR-002 tiers:
**Tier 2 (Delegation - Recommended)**: When target_user_id is None
- Uses Keycloak Standard V2 (production-ready)
- Service account maintains its identity (sub claim unchanged)
- No special permissions required
**Tier 1 (Impersonation - Advanced)**: When target_user_id is provided
- Requires Keycloak Legacy V1 (--features=preview)
- Subject claim changes to target user
- Requires impersonation role granted via Keycloak CLI:
```
kcadm.sh add-roles -r <realm> \
--uusername service-account-<client-id> \
--cclientid realm-management \
--rolename impersonation
```
Both tiers require:
- Client has token.exchange.grant.enabled=true
- Client has serviceAccountsEnabled=true
"""
if not self.token_endpoint:
await self.discover()
if not self.token_endpoint:
raise RuntimeError("Token endpoint not discovered")
# Build token exchange request
data = {
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": subject_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
}
# Add optional parameters
if audience:
data["audience"] = audience
if scopes:
data["scope"] = " ".join(scopes)
if target_user_id:
# Tier 1: Impersonation (Legacy V1)
# Use requested_subject for user impersonation
data["requested_subject"] = target_user_id
logger.info(
f"Exchanging token with impersonation (Tier 1): target_user={target_user_id}"
)
else:
# Tier 2: Delegation (Standard V2)
logger.info(
"Exchanging token with delegation (Tier 2): service account identity preserved"
)
client = await self._get_http_client()
response = await client.post(
self.token_endpoint,
data=data,
auth=(self.client_id, self.client_secret),
)
if response.status_code != 200:
error_data = (
response.json()
if response.headers.get("content-type", "").startswith(
"application/json"
)
else {"error": "unknown"}
)
logger.error(f"Token exchange failed: {response.status_code}")
logger.error(f"Error response: {error_data}")
response.raise_for_status()
token_data = response.json()
logger.info(
f"✓ Token exchange successful, issued_token_type: {token_data.get('issued_token_type')}"
)
return token_data
async def check_token_exchange_support(self) -> bool:
"""
Check if Keycloak supports RFC 8693 token exchange.
Returns:
True if token exchange is supported
Note:
This is ADR-002 Tier 2. Most Keycloak installations don't
have token exchange enabled by default.
"""
if not self.token_endpoint:
await self.discover()
# Try to get discovery document and check for token exchange grant
discovery_url = (
f"{self.keycloak_url}/realms/{self.realm}/.well-known/openid-configuration"
)
try:
client = await self._get_http_client()
response = await client.get(discovery_url)
response.raise_for_status()
discovery_data = response.json()
grant_types = discovery_data.get("grant_types_supported", [])
supported = "urn:ietf:params:oauth:grant-type:token-exchange" in grant_types
if supported:
logger.info("✓ Token exchange (RFC 8693) is supported")
else:
logger.info("Token exchange (RFC 8693) is not supported")
return supported
except Exception as e:
logger.warning(f"Failed to check token exchange support: {e}")
return False
__all__ = ["KeycloakOAuthClient"]
@@ -0,0 +1,628 @@
"""
Refresh Token Storage for ADR-002 Tier 1: Offline Access
Securely stores and manages user refresh tokens for background operations.
Tokens are encrypted at rest using Fernet symmetric encryption.
"""
import json
import logging
import os
import time
from pathlib import Path
from typing import Optional
import aiosqlite
from cryptography.fernet import Fernet
logger = logging.getLogger(__name__)
class RefreshTokenStorage:
"""Securely store and manage user refresh tokens"""
def __init__(self, db_path: str, encryption_key: bytes):
"""
Initialize refresh token storage.
Args:
db_path: Path to SQLite database file
encryption_key: Fernet encryption key (32 bytes, base64-encoded)
"""
self.db_path = db_path
self.cipher = Fernet(encryption_key)
self._initialized = False
@classmethod
def from_env(cls) -> "RefreshTokenStorage":
"""
Create storage instance from environment variables.
Environment variables:
TOKEN_STORAGE_DB: Path to database file (default: /app/data/tokens.db)
TOKEN_ENCRYPTION_KEY: Base64-encoded Fernet key
Returns:
RefreshTokenStorage instance
Raises:
ValueError: If TOKEN_ENCRYPTION_KEY is not set
"""
db_path = os.getenv("TOKEN_STORAGE_DB", "/app/data/tokens.db")
encryption_key_b64 = os.getenv("TOKEN_ENCRYPTION_KEY")
if not encryption_key_b64:
raise ValueError(
"TOKEN_ENCRYPTION_KEY environment variable is required. "
"Generate one with: python -c 'from cryptography.fernet import Fernet; "
"print(Fernet.generate_key().decode())'"
)
# Fernet expects a base64url-encoded key as bytes, not decoded bytes
# The key from Fernet.generate_key() is already base64url-encoded
try:
# Convert string to bytes if needed
if isinstance(encryption_key_b64, str):
encryption_key = encryption_key_b64.encode()
else:
encryption_key = encryption_key_b64
# Validate the key by trying to create a Fernet instance
Fernet(encryption_key)
except Exception as e:
raise ValueError(
f"Invalid TOKEN_ENCRYPTION_KEY: {e}. "
"Must be a valid Fernet key (base64url-encoded 32 bytes)."
) from e
return cls(db_path=db_path, encryption_key=encryption_key)
async def initialize(self) -> None:
"""Initialize database schema"""
if self._initialized:
return
# Ensure directory exists
db_dir = Path(self.db_path).parent
db_dir.mkdir(parents=True, exist_ok=True)
# Set restrictive permissions on database file
if Path(self.db_path).exists():
os.chmod(self.db_path, 0o600)
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
CREATE TABLE IF NOT EXISTS refresh_tokens (
user_id TEXT PRIMARY KEY,
encrypted_token BLOB NOT NULL,
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
event TEXT NOT NULL,
user_id TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
auth_method TEXT,
hostname TEXT
)
"""
)
# Create index on audit logs for efficient queries
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp "
"ON audit_logs(user_id, timestamp)"
)
# OAuth client credentials storage
await db.execute(
"""
CREATE TABLE IF NOT EXISTS oauth_clients (
id INTEGER PRIMARY KEY,
client_id TEXT UNIQUE NOT NULL,
encrypted_client_secret BLOB NOT NULL,
client_id_issued_at INTEGER NOT NULL,
client_secret_expires_at INTEGER NOT NULL,
redirect_uris TEXT NOT NULL,
encrypted_registration_access_token BLOB,
registration_client_uri TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
await db.commit()
# Set restrictive permissions after creation
os.chmod(self.db_path, 0o600)
self._initialized = True
logger.info(f"Initialized refresh token storage at {self.db_path}")
async def store_refresh_token(
self,
user_id: str,
refresh_token: str,
expires_at: Optional[int] = None,
) -> None:
"""
Store encrypted refresh token for user.
Args:
user_id: User identifier (from OIDC 'sub' claim)
refresh_token: Refresh token to store
expires_at: Token expiration timestamp (Unix epoch), if known
"""
if not self._initialized:
await self.initialize()
encrypted_token = self.cipher.encrypt(refresh_token.encode())
now = int(time.time())
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT OR REPLACE INTO refresh_tokens
(user_id, encrypted_token, expires_at, created_at, updated_at)
VALUES (?, ?, ?, COALESCE((SELECT created_at FROM refresh_tokens WHERE user_id = ?), ?), ?)
""",
(user_id, encrypted_token, expires_at, user_id, now, now),
)
await db.commit()
logger.info(
f"Stored refresh token for user {user_id}"
+ (f" (expires at {expires_at})" if expires_at else "")
)
# Audit log
await self._audit_log(
event="store_refresh_token",
user_id=user_id,
auth_method="offline_access",
)
async def get_refresh_token(self, user_id: str) -> Optional[str]:
"""
Retrieve and decrypt refresh token for user.
Args:
user_id: User identifier
Returns:
Decrypted refresh token, or None if not found or expired
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT encrypted_token, expires_at FROM refresh_tokens WHERE user_id = ?",
(user_id,),
) as cursor:
row = await cursor.fetchone()
if not row:
logger.debug(f"No refresh token found for user {user_id}")
return None
encrypted_token, expires_at = row
# Check expiration
if expires_at is not None and expires_at < time.time():
logger.warning(
f"Refresh token for user {user_id} has expired (expired at {expires_at})"
)
await self.delete_refresh_token(user_id)
return None
try:
decrypted_token = self.cipher.decrypt(encrypted_token).decode()
logger.debug(f"Retrieved refresh token for user {user_id}")
return decrypted_token
except Exception as e:
logger.error(f"Failed to decrypt refresh token for user {user_id}: {e}")
return None
async def delete_refresh_token(self, user_id: str) -> bool:
"""
Delete refresh token for user.
Args:
user_id: User identifier
Returns:
True if token was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM refresh_tokens WHERE user_id = ?",
(user_id,),
)
await db.commit()
deleted = cursor.rowcount > 0
if deleted:
logger.info(f"Deleted refresh token for user {user_id}")
await self._audit_log(
event="delete_refresh_token",
user_id=user_id,
auth_method="offline_access",
)
else:
logger.debug(f"No refresh token to delete for user {user_id}")
return deleted
async def get_all_user_ids(self) -> list[str]:
"""
Get list of all user IDs with stored refresh tokens.
Returns:
List of user IDs
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT user_id FROM refresh_tokens ORDER BY updated_at DESC"
) as cursor:
rows = await cursor.fetchall()
user_ids = [row[0] for row in rows]
logger.debug(f"Found {len(user_ids)} users with refresh tokens")
return user_ids
async def cleanup_expired_tokens(self) -> int:
"""
Remove expired refresh tokens from storage.
Returns:
Number of tokens deleted
"""
if not self._initialized:
await self.initialize()
now = int(time.time())
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < ?",
(now,),
)
await db.commit()
deleted = cursor.rowcount
if deleted > 0:
logger.info(f"Cleaned up {deleted} expired refresh token(s)")
return deleted
async def store_oauth_client(
self,
client_id: str,
client_secret: str,
client_id_issued_at: int,
client_secret_expires_at: int,
redirect_uris: list[str],
registration_access_token: Optional[str] = None,
registration_client_uri: Optional[str] = None,
) -> None:
"""
Store encrypted OAuth client credentials.
Args:
client_id: OAuth client identifier
client_secret: OAuth client secret (will be encrypted)
client_id_issued_at: Unix timestamp when client was issued
client_secret_expires_at: Unix timestamp when secret expires
redirect_uris: List of redirect URIs
registration_access_token: RFC 7592 registration token (will be encrypted)
registration_client_uri: RFC 7592 client management URI
"""
if not self._initialized:
await self.initialize()
# Encrypt sensitive data
encrypted_secret = self.cipher.encrypt(client_secret.encode())
encrypted_reg_token = (
self.cipher.encrypt(registration_access_token.encode())
if registration_access_token
else None
)
# Serialize redirect_uris as JSON
redirect_uris_json = json.dumps(redirect_uris)
now = int(time.time())
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT OR REPLACE INTO oauth_clients
(id, client_id, encrypted_client_secret, client_id_issued_at,
client_secret_expires_at, redirect_uris, encrypted_registration_access_token,
registration_client_uri, created_at, updated_at)
VALUES (
1, ?, ?, ?, ?, ?, ?, ?,
COALESCE((SELECT created_at FROM oauth_clients WHERE id = 1), ?),
?
)
""",
(
client_id,
encrypted_secret,
client_id_issued_at,
client_secret_expires_at,
redirect_uris_json,
encrypted_reg_token,
registration_client_uri,
now,
now,
),
)
await db.commit()
logger.info(
f"Stored OAuth client credentials (client_id: {client_id[:16]}..., "
f"expires at {client_secret_expires_at})"
)
# Audit log
await self._audit_log(
event="store_oauth_client",
user_id="system",
auth_method="oauth",
)
async def get_oauth_client(self) -> Optional[dict]:
"""
Retrieve and decrypt OAuth client credentials.
Returns:
Dictionary with client credentials, or None if not found or expired:
{
"client_id": str,
"client_secret": str,
"client_id_issued_at": int,
"client_secret_expires_at": int,
"redirect_uris": list[str],
"registration_access_token": str | None,
"registration_client_uri": str | None,
}
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"""
SELECT client_id, encrypted_client_secret, client_id_issued_at,
client_secret_expires_at, redirect_uris,
encrypted_registration_access_token, registration_client_uri
FROM oauth_clients WHERE id = 1
"""
) as cursor:
row = await cursor.fetchone()
if not row:
logger.debug("No OAuth client credentials found in storage")
return None
(
client_id,
encrypted_secret,
issued_at,
expires_at,
redirect_uris_json,
encrypted_reg_token,
reg_client_uri,
) = row
# Check expiration
if expires_at < time.time():
logger.warning(
f"OAuth client has expired (expired at {expires_at}), deleting"
)
await self.delete_oauth_client()
return None
try:
# Decrypt sensitive data
client_secret = self.cipher.decrypt(encrypted_secret).decode()
reg_token = (
self.cipher.decrypt(encrypted_reg_token).decode()
if encrypted_reg_token
else None
)
# Deserialize redirect_uris
redirect_uris = json.loads(redirect_uris_json)
logger.debug(
f"Retrieved OAuth client credentials (client_id: {client_id[:16]}...)"
)
return {
"client_id": client_id,
"client_secret": client_secret,
"client_id_issued_at": issued_at,
"client_secret_expires_at": expires_at,
"redirect_uris": redirect_uris,
"registration_access_token": reg_token,
"registration_client_uri": reg_client_uri,
}
except Exception as e:
logger.error(f"Failed to decrypt OAuth client credentials: {e}")
return None
async def delete_oauth_client(self) -> bool:
"""
Delete OAuth client credentials.
Returns:
True if client was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute("DELETE FROM oauth_clients WHERE id = 1")
await db.commit()
deleted = cursor.rowcount > 0
if deleted:
logger.info("Deleted OAuth client credentials from storage")
await self._audit_log(
event="delete_oauth_client",
user_id="system",
auth_method="oauth",
)
else:
logger.debug("No OAuth client credentials to delete")
return deleted
async def has_oauth_client(self) -> bool:
"""
Check if OAuth client credentials exist (and are not expired).
Returns:
True if valid client exists, False otherwise
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT client_secret_expires_at FROM oauth_clients WHERE id = 1"
) as cursor:
row = await cursor.fetchone()
if not row:
return False
expires_at = row[0]
return expires_at >= time.time()
async def _audit_log(
self,
event: str,
user_id: str,
resource_type: Optional[str] = None,
resource_id: Optional[str] = None,
auth_method: Optional[str] = None,
) -> None:
"""
Log operation to audit log.
Args:
event: Event name (e.g., "store_refresh_token", "token_refresh")
user_id: User identifier
resource_type: Resource type (e.g., "note", "file")
resource_id: Resource identifier
auth_method: Authentication method used
"""
import socket
hostname = socket.gethostname()
timestamp = int(time.time())
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT INTO audit_logs
(timestamp, event, user_id, resource_type, resource_id, auth_method, hostname)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
timestamp,
event,
user_id,
resource_type,
resource_id,
auth_method,
hostname,
),
)
await db.commit()
async def get_audit_logs(
self,
user_id: Optional[str] = None,
since: Optional[int] = None,
limit: int = 100,
) -> list[dict]:
"""
Retrieve audit logs.
Args:
user_id: Filter by user ID (optional)
since: Filter by timestamp (Unix epoch, optional)
limit: Maximum number of logs to return
Returns:
List of audit log entries
"""
if not self._initialized:
await self.initialize()
query = "SELECT * FROM audit_logs WHERE 1=1"
params = []
if user_id:
query += " AND user_id = ?"
params.append(user_id)
if since:
query += " AND timestamp >= ?"
params.append(since)
query += " ORDER BY timestamp DESC LIMIT ?"
params.append(limit)
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(query, params) as cursor:
rows = await cursor.fetchall()
return [dict(row) for row in rows]
async def generate_encryption_key() -> str:
"""
Generate a new Fernet encryption key.
Returns:
Base64-encoded encryption key suitable for TOKEN_ENCRYPTION_KEY env var
"""
return Fernet.generate_key().decode()
# Example usage
if __name__ == "__main__":
import asyncio
async def main():
# Generate a key for testing
key = await generate_encryption_key()
print(f"Generated encryption key: {key}")
print(f"Set this in your environment: export TOKEN_ENCRYPTION_KEY='{key}'")
asyncio.run(main())
+13 -4
View File
@@ -168,27 +168,36 @@ class NextcloudTokenVerifier(TokenVerifier):
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
# Verify and decode JWT
# Accept tokens with audience: "mcp-server" or ["mcp-server", "nextcloud"]
# This allows:
# 1. Tokens from MCP clients (aud: "mcp-server")
# 2. Tokens for Nextcloud APIs (aud: "nextcloud")
# 3. Tokens for both (aud: ["mcp-server", "nextcloud"])
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer=self.issuer,
audience=["mcp-server", "nextcloud"], # Accept either audience
options={
"verify_signature": True,
"verify_exp": True,
"verify_iat": True,
"verify_iss": True if self.issuer else False,
"verify_aud": False, # Skip audience validation for Bearer tokens
"verify_aud": True, # Enable audience validation
},
)
logger.debug(f"JWT verified successfully for user: {payload.get('sub')}")
logger.debug(f"Full JWT payload: {payload}")
# Extract username (sub claim)
username = payload.get("sub")
# Extract username (sub claim, with fallback to preferred_username)
# Some OIDC providers (like Keycloak) may not include sub in access tokens
username = payload.get("sub") or payload.get("preferred_username")
if not username:
logger.error("No 'sub' claim found in JWT payload")
logger.error(
"No 'sub' or 'preferred_username' claim found in JWT payload"
)
return None
# Extract scopes from scope claim (space-separated string)
+11 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.21.0"
version = "0.23.0"
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
authors = [
{name = "Chris Coutinho", email = "chris@coutinho.io"}
@@ -18,7 +18,8 @@ dependencies = [
"pydantic>=2.11.4",
"click>=8.1.8",
"caldav",
"pyjwt[crypto]>=2.8.0", # Async I/O library for better compatibility
"pyjwt[crypto]>=2.8.0",
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
]
classifiers = [
"Development Status :: 4 - Beta",
@@ -50,6 +51,7 @@ markers = [
"integration: Integration tests requiring Docker containers",
"oauth: OAuth tests requiring Playwright (slowest)",
"smoke: Critical path smoke tests for quick validation",
"keycloak: OAuth tests that utilize keycloak external identity provider",
]
testpaths = [
"tests",
@@ -65,6 +67,13 @@ version_scheme = "pep440"
version_provider = "uv"
update_changelog_on_bump = true
major_version_zero = true
version_files = [
"charts/nextcloud-mcp-server/Chart.yaml:appVersion",
"charts/nextcloud-mcp-server/Chart.yaml:version"
]
ignored_tag_formats = [
"nextcloud-mcp-server-*"
]
[tool.ruff.lint]
extend-select = ["I"]
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
set -e
echo "=== Testing Separate Clients Architecture ==="
echo ""
# Check both clients exist in Keycloak
echo "1. Verifying Keycloak clients..."
docker compose exec -T app curl -s http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration > /dev/null && echo "✓ Keycloak realm available"
# Check user_oidc provider configuration
echo ""
echo "2. Checking user_oidc provider..."
PROVIDER_INFO=$(docker compose exec -T app php occ user_oidc:provider keycloak)
echo "$PROVIDER_INFO" | grep -q "nextcloud" && echo "✓ user_oidc configured with 'nextcloud' client"
# Get token from nextcloud-mcp-server client
echo ""
echo "3. Getting token from 'nextcloud-mcp-server' client..."
TOKEN=$(curl -s -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=nextcloud-mcp-server" \
-d "client_secret=mcp-secret-change-in-production" \
-d "username=admin" \
-d "password=admin" \
-d "scope=openid profile email offline_access" | jq -r '.access_token')
if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then
echo "✗ Failed to get token"
exit 1
fi
echo "✓ Got token from nextcloud-mcp-server client"
# Check token claims
echo ""
echo "4. Inspecting token claims..."
CLAIMS=$(echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq '{aud, azp, iss, preferred_username}')
echo "$CLAIMS"
AUD=$(echo "$CLAIMS" | jq -r '.aud')
AZP=$(echo "$CLAIMS" | jq -r '.azp')
echo ""
echo "Architecture validation:"
if [ "$AUD" = "nextcloud" ]; then
echo " ✓ aud='nextcloud' - Token intended for Nextcloud resource server"
else
echo " ✗ FAILED: aud='$AUD', expected 'nextcloud'"
exit 1
fi
if [ "$AZP" = "nextcloud-mcp-server" ]; then
echo " ✓ azp='nextcloud-mcp-server' - Token requested by MCP Server client"
else
echo " ✗ FAILED: azp='$AZP', expected 'nextcloud-mcp-server'"
exit 1
fi
# Test with Nextcloud API
echo ""
echo "5. Testing token with Nextcloud API..."
HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/nc_response.json \
-H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/ocs/v2.php/cloud/capabilities?format=json")
echo "HTTP Status: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
echo "✓ Token validated successfully!"
echo ""
echo "===================================================================="
echo "SUCCESS: Separate Clients Architecture Working!"
echo "===================================================================="
echo ""
echo "Summary:"
echo " - MCP Server client: 'nextcloud-mcp-server' (requests tokens)"
echo " - Resource server: 'nextcloud' (validates tokens via user_oidc)"
echo " - Token audience: 'nextcloud' (proper resource targeting)"
echo " - Token azp: 'nextcloud-mcp-server' (identifies requester)"
echo ""
echo "This architecture supports:"
echo " - Future multi-resource tokens: aud=['nextcloud', 'other-service']"
echo " - Clear separation of OAuth client vs resource server"
echo " - RFC 8707 Resource Indicators compliance"
else
echo "✗ Token validation failed"
cat /tmp/nc_response.json
exit 1
fi
+430
View File
@@ -2456,3 +2456,433 @@ async def test_user_in_group(nc_client: NextcloudClient, test_user, test_group):
logger.debug(f"Added user {user_config['userid']} to group {groupid}")
yield (user_config, groupid)
# ===========================================================================================
# Keycloak External IdP OAuth Fixtures
# ===========================================================================================
@pytest.fixture(scope="session")
async def keycloak_oauth_client_credentials(anyio_backend, oauth_callback_server):
"""
Fixture to obtain Keycloak OAuth client credentials for external IdP testing.
Uses pre-configured client from keycloak/realm-export.json (no DCR needed).
The client (nextcloud-mcp-server) is already configured with:
- serviceAccountsEnabled=true
- token.exchange.grant.enabled=true
- client.token.exchange.standard.enabled=true
Returns:
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
"""
# Get Keycloak configuration from environment
keycloak_discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
"http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
)
client_id = os.getenv("OIDC_CLIENT_ID", "nextcloud-mcp-server")
client_secret = os.getenv("OIDC_CLIENT_SECRET", "mcp-secret-change-in-production")
if not all([keycloak_discovery_url, client_id, client_secret]):
pytest.skip(
"Keycloak OAuth requires OIDC_DISCOVERY_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET"
)
# Get callback URL from the real callback server
auth_states, callback_url = oauth_callback_server
logger.info("Setting up Keycloak external IdP OAuth client credentials...")
logger.info(f"Using Keycloak discovery URL: {keycloak_discovery_url}")
logger.info(f"Using static client credentials: {client_id}")
logger.info(f"Using real callback server at: {callback_url}")
async with httpx.AsyncClient(timeout=30.0) as http_client:
# OIDC Discovery
discovery_response = await http_client.get(keycloak_discovery_url)
discovery_response.raise_for_status()
oidc_config = discovery_response.json()
token_endpoint = oidc_config.get("token_endpoint")
authorization_endpoint = oidc_config.get("authorization_endpoint")
if not token_endpoint or not authorization_endpoint:
raise ValueError(
"Keycloak OIDC discovery missing required endpoints (token_endpoint or authorization_endpoint)"
)
logger.info(f"✓ Discovered token endpoint: {token_endpoint}")
logger.info(f"✓ Discovered authorization endpoint: {authorization_endpoint}")
yield (
client_id,
client_secret,
callback_url,
token_endpoint,
authorization_endpoint,
)
# No cleanup needed - client is pre-configured in realm export
async def _get_keycloak_oauth_token(
browser,
keycloak_oauth_client_credentials,
oauth_callback_server,
scopes: str,
username: str = "admin",
password: str = "admin",
) -> str:
"""
Helper function to obtain OAuth token from Keycloak using Playwright.
Args:
browser: Playwright browser instance
keycloak_oauth_client_credentials: Tuple of Keycloak OAuth client credentials
oauth_callback_server: OAuth callback server fixture
scopes: Space-separated list of scopes
username: Keycloak username (default: admin)
password: Keycloak password (default: admin)
Returns:
OAuth access token string from Keycloak
"""
import base64
import hashlib
import secrets
import time
from urllib.parse import quote
# Get auth_states dict from callback server
auth_states, _ = oauth_callback_server
# Unpack Keycloak client credentials
client_id, client_secret, callback_url, token_endpoint, authorization_endpoint = (
keycloak_oauth_client_credentials
)
logger.info(f"Starting Playwright-based Keycloak OAuth flow with scopes: {scopes}")
logger.info(f"Using Keycloak client: {client_id}")
logger.info(f"Using real callback server at: {callback_url}")
logger.info(f"Authenticating as Keycloak user: {username}")
# Generate unique state parameter for this OAuth flow
state = secrets.token_urlsafe(32)
logger.debug(f"Generated state: {state[:16]}...")
# Generate PKCE parameters (required by Keycloak client configuration)
code_verifier = secrets.token_urlsafe(64) # 86 chars base64url
code_challenge = (
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
.decode()
.rstrip("=")
)
logger.debug(f"Generated PKCE code_challenge: {code_challenge[:20]}...")
# URL-encode scopes
scopes_encoded = quote(scopes, safe="")
# Construct authorization URL with state, scopes, and PKCE parameters
auth_url = (
f"{authorization_endpoint}?"
f"response_type=code&"
f"client_id={client_id}&"
f"redirect_uri={quote(callback_url, safe='')}&"
f"state={state}&"
f"scope={scopes_encoded}&"
f"code_challenge={code_challenge}&"
f"code_challenge_method=S256"
)
logger.info(f"Authorization URL: {auth_url[:100]}...")
# Create browser context and page
context = await browser.new_context()
page = await context.new_page()
try:
# Navigate to Keycloak authorization endpoint
logger.info("Navigating to Keycloak authorization endpoint...")
await page.goto(auth_url, wait_until="networkidle", timeout=30000)
# Handle Keycloak login page
# Keycloak uses input#username and input#password (different from Nextcloud)
logger.info(f"Filling Keycloak login credentials for {username}...")
await page.wait_for_selector("input#username", timeout=10000)
await page.fill("input#username", username)
await page.fill("input#password", password)
logger.info("Submitting Keycloak login form...")
# Submit the form and wait for navigation
# Use JavaScript to submit the form directly (more reliable than clicking button)
async with page.expect_navigation(timeout=30000):
await page.evaluate("document.querySelector('form').submit()")
logger.info(f"Keycloak login submitted for {username}, redirected to callback")
# Check if we need to handle consent screen
# Keycloak consent screen has "Yes" button
consent_button = page.locator('input[name="accept"][value="Yes"]')
if await consent_button.count() > 0:
logger.info("Keycloak consent screen detected, clicking Yes...")
await consent_button.click()
await page.wait_for_load_state("networkidle", timeout=30000)
logger.info("Keycloak consent granted")
# Wait for callback server to receive auth code with timeout
logger.info(f"Waiting for auth code with state: {state[:16]}...")
timeout = 30 # seconds
start_time = time.time()
auth_code = None
while time.time() - start_time < timeout:
if state in auth_states:
auth_code = auth_states[state]
logger.info("Auth code received from callback server")
break
await anyio.sleep(0.1)
else:
raise TimeoutError(
f"Auth code not received within {timeout}s. State: {state[:16]}..."
)
finally:
await context.close()
# Exchange authorization code for access token (with PKCE code_verifier)
logger.info("Exchanging authorization code for access token with PKCE...")
async with httpx.AsyncClient(timeout=30.0) as token_client:
token_response = await token_client.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": callback_url,
"client_id": client_id,
"client_secret": client_secret,
"code_verifier": code_verifier, # PKCE verifier
},
)
token_response.raise_for_status()
token_data = token_response.json()
access_token = token_data.get("access_token")
if not access_token:
raise ValueError(f"No access_token in response: {token_data}")
logger.info(
f"Successfully obtained Keycloak OAuth access token with scopes: {scopes}"
)
return access_token
@pytest.fixture(scope="session")
async def keycloak_oauth_token(
anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server
) -> str:
"""
Fixture to obtain an OAuth access token from Keycloak using Playwright automation.
This fixture tests the external IdP flow where:
1. User authenticates with Keycloak (external IdP)
2. Keycloak issues an access token with Nextcloud custom scopes
3. Token is used to access Nextcloud APIs via user_oidc app validation
The Nextcloud custom scopes (notes:read, calendar:write, etc.) are now defined
in Keycloak's realm configuration and can be requested in the OAuth flow.
Returns:
OAuth access token from Keycloak for the admin user with full scopes
"""
# Standard OIDC scopes + Nextcloud custom scopes (now defined in Keycloak realm)
default_scopes = "openid profile email offline_access 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 todo:read todo:write"
return await _get_keycloak_oauth_token(
browser,
keycloak_oauth_client_credentials,
oauth_callback_server,
scopes=default_scopes,
username="admin",
password="admin",
)
@pytest.fixture(scope="session")
async def keycloak_oauth_token_read_only(
anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server
) -> str:
"""
Fixture to obtain a Keycloak OAuth token with only read scopes.
This token will only be able to perform read operations and should
have write tools filtered out from the tool list.
Returns:
OAuth access token from Keycloak for test_read_only user with read-only scopes
"""
return await _get_keycloak_oauth_token(
browser,
keycloak_oauth_client_credentials,
oauth_callback_server,
scopes=DEFAULT_READ_SCOPES,
username="test_read_only",
password="test123",
)
@pytest.fixture(scope="session")
async def keycloak_oauth_token_write_only(
anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server
) -> str:
"""
Fixture to obtain a Keycloak OAuth token with only write scopes.
This token will only be able to perform write operations and should
have read tools filtered out from the tool list.
Returns:
OAuth access token from Keycloak for test_write_only user with write-only scopes
"""
return await _get_keycloak_oauth_token(
browser,
keycloak_oauth_client_credentials,
oauth_callback_server,
scopes=DEFAULT_WRITE_SCOPES,
username="test_write_only",
password="test123",
)
@pytest.fixture(scope="session")
async def keycloak_oauth_token_no_custom_scopes(
anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server
) -> str:
"""
Fixture to obtain a Keycloak OAuth token with NO custom scopes.
Tests the security behavior when a user grants only default OIDC scopes
(openid, profile, email) but declines application-specific scopes.
Expected behavior: Should see 0 tools (all tools require custom scopes).
Returns:
OAuth access token from Keycloak for test_no_scopes user with no custom scopes
"""
return await _get_keycloak_oauth_token(
browser,
keycloak_oauth_client_credentials,
oauth_callback_server,
scopes="openid profile email", # No custom scopes
username="test_no_scopes",
password="test123",
)
@pytest.fixture(scope="session")
async def nc_mcp_keycloak_client(
anyio_backend, keycloak_oauth_token
) -> AsyncGenerator[ClientSession, Any]:
"""
Session-scoped fixture providing an MCP client session authenticated with Keycloak tokens.
This MCP client connects to the mcp-keycloak service (port 8002) which is configured
to use Keycloak as an external identity provider. The token flow is:
1. Keycloak issues OAuth token (via keycloak_oauth_token fixture)
2. MCP client uses token to authenticate with MCP server
3. MCP server validates token via Nextcloud user_oidc app
4. MCP server uses validated token to access Nextcloud APIs
This tests ADR-002 external IdP integration.
Yields:
MCP client session for testing tools/resources with Keycloak auth
"""
mcp_url = "http://localhost:8002/mcp"
logger.info(f"Creating MCP client session for Keycloak external IdP at {mcp_url}")
logger.info("Using Keycloak OAuth token for authentication")
async for session in create_mcp_client_session(
url=mcp_url, token=keycloak_oauth_token, client_name="Keycloak External IdP MCP"
):
logger.info("✓ MCP client session established with Keycloak authentication")
yield session
logger.info("✓ MCP client session closed")
@pytest.fixture(scope="session")
async def nc_mcp_keycloak_client_read_only(
anyio_backend, keycloak_oauth_token_read_only
) -> AsyncGenerator[ClientSession, Any]:
"""
MCP client session authenticated with Keycloak read-only token.
This client should only see read tools and should get filtered
write tools based on token scopes.
Uses JWT tokens because they embed scope information in claims,
enabling proper scope-based tool filtering.
"""
mcp_url = "http://localhost:8002/mcp"
logger.info(f"Creating read-only MCP client session for Keycloak at {mcp_url}")
async for session in create_mcp_client_session(
url=mcp_url,
token=keycloak_oauth_token_read_only,
client_name="Keycloak Read-Only MCP",
):
yield session
@pytest.fixture(scope="session")
async def nc_mcp_keycloak_client_write_only(
anyio_backend, keycloak_oauth_token_write_only
) -> AsyncGenerator[ClientSession, Any]:
"""
MCP client session authenticated with Keycloak write-only token.
This client should only see write tools and should get filtered
read tools based on token scopes.
Uses JWT tokens because they embed scope information in claims,
enabling proper scope-based tool filtering.
"""
mcp_url = "http://localhost:8002/mcp"
logger.info(f"Creating write-only MCP client session for Keycloak at {mcp_url}")
async for session in create_mcp_client_session(
url=mcp_url,
token=keycloak_oauth_token_write_only,
client_name="Keycloak Write-Only MCP",
):
yield session
@pytest.fixture(scope="session")
async def nc_mcp_keycloak_client_no_custom_scopes(
anyio_backend, keycloak_oauth_token_no_custom_scopes
) -> AsyncGenerator[ClientSession, Any]:
"""
MCP client session authenticated with Keycloak token without custom scopes.
This client has only OIDC default scopes (openid, profile, email) without
application-specific scopes (notes:read, notes:write, etc.).
Expected behavior: Should see 0 tools (all tools require custom scopes).
Uses JWT tokens because they embed scope information in claims,
enabling proper scope-based tool filtering.
"""
mcp_url = "http://localhost:8002/mcp"
logger.info(
f"Creating no-custom-scopes MCP client session for Keycloak at {mcp_url}"
)
async for session in create_mcp_client_session(
url=mcp_url,
token=keycloak_oauth_token_no_custom_scopes,
client_name="Keycloak No Custom Scopes MCP",
):
yield session
@@ -0,0 +1,308 @@
"""
Integration test for RFC 8693 Token Exchange - Legacy V1 (Impersonation/Tier 1).
Tests the advanced impersonation feature where the service account token is
exchanged for a token with the target user's identity (sub claim changes).
This requires:
1. Keycloak with --features=preview enabled
2. Impersonation role granted to the service account
⚠️ This test will SKIP if impersonation permissions are not configured.
Configuration (one-time setup):
# Grant impersonation role
docker compose exec keycloak /opt/keycloak/bin/kcadm.sh config credentials \\
--server http://localhost:8080 \\
--realm master \\
--user admin \\
--password admin
docker compose exec keycloak /opt/keycloak/bin/kcadm.sh add-roles \\
-r nextcloud-mcp \\
--uusername service-account-nextcloud-mcp-server \\
--cclientid realm-management \\
--rolename impersonation
Usage:
pytest tests/integration/auth/test_token_exchange_legacy_v1.py -v
"""
import base64
import json
import os
import httpx
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.anyio, pytest.mark.keycloak]
def decode_jwt(token: str) -> dict:
"""Decode JWT token payload without verification."""
try:
parts = token.split(".")
if len(parts) != 3:
return {"error": "Invalid JWT format"}
payload = parts[1]
padding = 4 - (len(payload) % 4)
if padding != 4:
payload += "=" * padding
decoded = base64.urlsafe_b64decode(payload)
return json.loads(decoded)
except Exception as e:
return {"error": str(e)}
@pytest.fixture
def keycloak_config():
"""Keycloak configuration for testing."""
return {
"url": os.getenv("KEYCLOAK_URL", "http://localhost:8888"),
"realm": os.getenv("KEYCLOAK_REALM", "nextcloud-mcp"),
"client_id": os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server"),
"client_secret": os.getenv(
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
),
"token_endpoint": f"{os.getenv('KEYCLOAK_URL', 'http://localhost:8888')}/realms/{os.getenv('KEYCLOAK_REALM', 'nextcloud-mcp')}/protocol/openid-connect/token",
}
@pytest.fixture
async def service_account_token(keycloak_config):
"""Get a service account token using client_credentials grant."""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "client_credentials",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"scope": "openid profile email",
},
)
response.raise_for_status()
token_data = response.json()
return token_data["access_token"]
async def test_token_exchange_impersonation_requires_permissions(
keycloak_config, service_account_token
):
"""Test that impersonation requires explicit permission grant.
This test documents that Legacy V1 impersonation is opt-in and requires
administrative configuration via Keycloak CLI.
"""
target_user = "admin" # User to impersonate
async with httpx.AsyncClient(timeout=30.0) as client:
exchange_response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"subject_token": service_account_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_subject": target_user, # ← KEY: Request impersonation
},
)
# If permissions not granted, we expect 403 Forbidden
if exchange_response.status_code == 403:
pytest.skip(
"Impersonation permissions not configured. "
"Run tests/manual/configure_impersonation.py or grant manually via Keycloak CLI. "
"See test docstring for configuration commands."
)
# If permissions are granted, exchange should succeed
assert exchange_response.status_code == 200, (
f"Token exchange failed: {exchange_response.status_code} {exchange_response.text}"
)
async def test_token_exchange_impersonation_changes_subject(
keycloak_config, service_account_token
):
"""Test Legacy V1 impersonation - subject claim should change."""
target_user = "admin"
# Decode service account token
service_claims = decode_jwt(service_account_token)
assert "error" not in service_claims
service_sub = service_claims["sub"]
assert "service-account" in service_sub.lower()
# Exchange token WITH requested_subject (Legacy V1 impersonation)
async with httpx.AsyncClient(timeout=30.0) as client:
exchange_response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"subject_token": service_account_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_subject": target_user, # ← KEY: Impersonate admin
},
)
# Skip if permissions not configured
if exchange_response.status_code == 403:
pytest.skip(
"Impersonation permissions not configured. "
"See test docstring for setup instructions."
)
# Token exchange should succeed with permissions
assert exchange_response.status_code == 200, (
f"Token exchange failed: {exchange_response.status_code} {exchange_response.text}"
)
exchanged_data = exchange_response.json()
assert "access_token" in exchanged_data
exchanged_token = exchanged_data["access_token"]
# Decode exchanged token
exchanged_claims = decode_jwt(exchanged_token)
assert "error" not in exchanged_claims
exchanged_sub = exchanged_claims["sub"]
# CRITICAL: Verify impersonation - sub claim MUST change
assert service_sub != exchanged_sub, (
f"Impersonation should change subject claim. "
f"Original: {service_sub}, Exchanged: {exchanged_sub}"
)
# Verify the new token represents the target user
assert "preferred_username" in exchanged_claims
assert exchanged_claims["preferred_username"] == target_user
async def test_impersonated_token_with_nextcloud(
keycloak_config, service_account_token
):
"""Test that impersonated token works with Nextcloud APIs."""
target_user = "admin"
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
# Exchange token with impersonation
async with httpx.AsyncClient(timeout=30.0) as client:
exchange_response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"subject_token": service_account_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_subject": target_user,
},
)
# Skip if permissions not configured
if exchange_response.status_code == 403:
pytest.skip("Impersonation permissions not configured.")
exchange_response.raise_for_status()
exchanged_token = exchange_response.json()["access_token"]
# Test with Nextcloud API
nc_response = await client.get(
f"{nextcloud_host}/ocs/v2.php/cloud/capabilities",
headers={"Authorization": f"Bearer {exchanged_token}"},
)
# Should get valid response from Nextcloud
assert nc_response.status_code in [
200,
401,
], f"Unexpected status: {nc_response.status_code}"
if nc_response.status_code == 200:
# Token was accepted - verify we got a valid response
# Nextcloud OCS API can return XML or JSON
assert len(nc_response.content) > 0, "Response should not be empty"
content_type = nc_response.headers.get("content-type", "")
assert any(t in content_type for t in ["json", "xml"]), (
f"Unexpected content type: {content_type}"
)
async def test_standard_v2_rejects_requested_subject():
"""Verify that Standard V2 (without preview features) rejects requested_subject.
This test documents the key difference between Standard V2 and Legacy V1.
NOTE: This test will PASS if preview features are enabled, as Keycloak
accepts the parameter in Legacy V1 mode. The test exists to document the
expected behavior when preview features are DISABLED.
"""
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server")
client_secret = os.getenv(
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
)
token_endpoint = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token"
async with httpx.AsyncClient(timeout=30.0) as client:
# Get service account token
token_response = await client.post(
token_endpoint,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "openid profile email",
},
)
token_response.raise_for_status()
service_token = token_response.json()["access_token"]
# Try token exchange with requested_subject
exchange_response = await client.post(
token_endpoint,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": client_id,
"client_secret": client_secret,
"subject_token": service_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_subject": "admin", # Try to impersonate
},
)
# Standard V2: expects 400 Bad Request with "not supported" message
# Legacy V1: accepts parameter, returns 200 or 403 (depending on permissions)
if exchange_response.status_code == 400:
# Standard V2 behavior
error_data = exchange_response.json()
assert (
"requested_subject" in error_data.get("error_description", "").lower()
)
# Test passes - Standard V2 correctly rejects the parameter
elif exchange_response.status_code in [200, 403]:
# Legacy V1 behavior - parameter is accepted
pytest.skip(
"Preview features enabled - Keycloak is in Legacy V1 mode. "
"This test documents Standard V2 behavior which rejects requested_subject."
)
else:
pytest.fail(
f"Unexpected status code: {exchange_response.status_code}. "
f"Expected 400 (Standard V2) or 200/403 (Legacy V1)"
)
@@ -0,0 +1,222 @@
"""
Integration test for RFC 8693 Token Exchange - Standard V2 (Delegation/Tier 2).
Tests the production-ready token exchange without impersonation.
The service account exchanges its token for a user-scoped token while
maintaining its own identity (sub claim unchanged).
This is the RECOMMENDED approach for most use cases.
Requirements:
- Keycloak container running (can be Standard V2 or Legacy V1)
- MCP Keycloak service running on port 8002
Usage:
pytest tests/integration/auth/test_token_exchange_standard_v2.py -v
"""
import base64
import json
import os
import httpx
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.anyio, pytest.mark.keycloak]
def decode_jwt(token: str) -> dict:
"""Decode JWT token payload without verification."""
try:
parts = token.split(".")
if len(parts) != 3:
return {"error": "Invalid JWT format"}
payload = parts[1]
padding = 4 - (len(payload) % 4)
if padding != 4:
payload += "=" * padding
decoded = base64.urlsafe_b64decode(payload)
return json.loads(decoded)
except Exception as e:
return {"error": str(e)}
@pytest.fixture
def keycloak_config():
"""Keycloak configuration for testing."""
return {
"url": os.getenv("KEYCLOAK_URL", "http://localhost:8888"),
"realm": os.getenv("KEYCLOAK_REALM", "nextcloud-mcp"),
"client_id": os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server"),
"client_secret": os.getenv(
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
),
"token_endpoint": f"{os.getenv('KEYCLOAK_URL', 'http://localhost:8888')}/realms/{os.getenv('KEYCLOAK_REALM', 'nextcloud-mcp')}/protocol/openid-connect/token",
}
@pytest.fixture
async def service_account_token(keycloak_config):
"""Get a service account token using client_credentials grant."""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "client_credentials",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"scope": "openid profile email",
},
)
response.raise_for_status()
token_data = response.json()
return token_data["access_token"]
async def test_token_exchange_delegation(keycloak_config, service_account_token):
"""Test Standard V2 token exchange with delegation (no impersonation)."""
# Decode service account token to get original claims
service_claims = decode_jwt(service_account_token)
assert "error" not in service_claims, "Failed to decode service account token"
assert "sub" in service_claims
service_sub = service_claims["sub"]
# Exchange token WITHOUT requested_subject (Standard V2 delegation)
async with httpx.AsyncClient(timeout=30.0) as client:
exchange_response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"subject_token": service_account_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
# NOTE: NO requested_subject parameter - this is delegation, not impersonation
},
)
# Token exchange should succeed
assert exchange_response.status_code == 200, (
f"Token exchange failed: {exchange_response.status_code} {exchange_response.text}"
)
exchanged_data = exchange_response.json()
assert "access_token" in exchanged_data
assert "token_type" in exchanged_data
assert exchanged_data["token_type"].lower() == "bearer"
exchanged_token = exchanged_data["access_token"]
# Decode exchanged token
exchanged_claims = decode_jwt(exchanged_token)
assert "error" not in exchanged_claims, "Failed to decode exchanged token"
assert "sub" in exchanged_claims
exchanged_sub = exchanged_claims["sub"]
# CRITICAL: Verify delegation behavior - sub claim should NOT change
assert service_sub == exchanged_sub, (
f"Subject should remain unchanged in delegation (service account identity preserved). Original: {service_sub}, Exchanged: {exchanged_sub}"
)
# The exchanged token should still identify as the service account
assert "service-account" in exchanged_sub.lower(), (
"Exchanged token should maintain service account identity"
)
async def test_exchanged_token_with_nextcloud(keycloak_config, service_account_token):
"""Test that exchanged token works with Nextcloud APIs."""
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
# Exchange the service account token
async with httpx.AsyncClient(timeout=30.0) as client:
exchange_response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"subject_token": service_account_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
},
)
exchange_response.raise_for_status()
exchanged_token = exchange_response.json()["access_token"]
# Test the exchanged token with Nextcloud API
nc_response = await client.get(
f"{nextcloud_host}/ocs/v2.php/cloud/capabilities",
headers={"Authorization": f"Bearer {exchanged_token}"},
)
# Should get a valid response from Nextcloud
# Note: This might fail with 401 if user_oidc doesn't accept the token
# That's expected - this test verifies the token exchange itself works
assert nc_response.status_code in [
200,
401,
], f"Unexpected status: {nc_response.status_code}"
if nc_response.status_code == 200:
# Token was accepted - verify we got a valid response
# Nextcloud OCS API can return XML or JSON
assert len(nc_response.content) > 0, "Response should not be empty"
# Verify we got either JSON or XML capabilities response
content_type = nc_response.headers.get("content-type", "")
assert any(t in content_type for t in ["json", "xml"]), (
f"Unexpected content type: {content_type}"
)
async def test_token_exchange_without_permissions_should_work():
"""Verify Standard V2 doesn't require special permissions (unlike Legacy V1 impersonation)."""
# This test documents that Standard V2 token exchange works out-of-the-box
# without needing to grant impersonation roles via Keycloak CLI
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server")
client_secret = os.getenv(
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
)
token_endpoint = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token"
async with httpx.AsyncClient(timeout=30.0) as client:
# Get service account token
token_response = await client.post(
token_endpoint,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "openid profile email",
},
)
token_response.raise_for_status()
service_token = token_response.json()["access_token"]
# Exchange token - should work without any special role grants
exchange_response = await client.post(
token_endpoint,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": client_id,
"client_secret": client_secret,
"subject_token": service_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
},
)
# Should succeed without 403 Forbidden (no permission requirements)
assert exchange_response.status_code == 200, (
f"Standard V2 delegation should work without special permissions. "
f"Got: {exchange_response.status_code} {exchange_response.text}"
)
+10 -5
View File
@@ -27,7 +27,8 @@ import click
import httpx
from playwright.async_api import async_playwright
from nextcloud_mcp_server.auth.client_registration import load_or_register_client
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
from nextcloud_mcp_server.client import NextcloudClient
from tests.load.oauth_metrics import OAuthBenchmarkMetrics
from tests.load.oauth_pool import (
@@ -142,7 +143,7 @@ async def setup_oauth_client(
nextcloud_host: str, callback_url: str, registration_endpoint: str
) -> dict[str, str]:
"""
Setup OAuth client using load_or_register_client.
Setup OAuth client using ensure_oauth_client with SQLite storage.
Args:
nextcloud_host: Nextcloud host URL
@@ -154,11 +155,15 @@ async def setup_oauth_client(
"""
logger.info("Setting up OAuth client...")
# Use the client registration utility
client_info = await load_or_register_client(
# Initialize SQLite storage
storage = RefreshTokenStorage.from_env()
await storage.initialize()
# Use the client registration utility with SQLite storage
client_info = await ensure_oauth_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
storage_path=".nextcloud_oauth_benchmark_client.json",
storage=storage,
client_name="OAuth Benchmark Test Client",
redirect_uris=[callback_url],
)
+195
View File
@@ -0,0 +1,195 @@
"""
Configure Keycloak client for token exchange with impersonation.
This script uses Keycloak Admin API to configure the necessary permissions
for the nextcloud-mcp-server client to impersonate users via token exchange.
Usage:
uv run python tests/manual/configure_impersonation.py
"""
import asyncio
import logging
import os
import sys
import httpx
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s | %(message)s")
logger = logging.getLogger(__name__)
async def main():
"""Configure impersonation permissions in Keycloak"""
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
admin_username = "admin"
admin_password = "admin"
client_id = "nextcloud-mcp-server"
logger.info("=" * 80)
logger.info("Configuring Keycloak Impersonation Permissions")
logger.info("=" * 80)
logger.info(f"Keycloak URL: {keycloak_url}")
logger.info(f"Realm: {realm}")
logger.info(f"Client ID: {client_id}")
logger.info("")
async with httpx.AsyncClient(timeout=30.0) as client:
# Step 1: Get admin access token
logger.info("Step 1: Getting admin access token...")
token_response = await client.post(
f"{keycloak_url}/realms/master/protocol/openid-connect/token",
data={
"grant_type": "password",
"client_id": "admin-cli",
"username": admin_username,
"password": admin_password,
},
)
token_response.raise_for_status()
admin_token = token_response.json()["access_token"]
logger.info("✓ Admin token acquired")
logger.info("")
headers = {"Authorization": f"Bearer {admin_token}"}
# Step 2: Get client internal ID
logger.info("Step 2: Looking up client internal ID...")
clients_response = await client.get(
f"{keycloak_url}/admin/realms/{realm}/clients",
headers=headers,
params={"clientId": client_id},
)
clients_response.raise_for_status()
clients = clients_response.json()
if not clients:
logger.error(f"❌ Client '{client_id}' not found")
return 1
client_uuid = clients[0]["id"]
logger.info(f"✓ Found client UUID: {client_uuid}")
logger.info("")
# Step 3: Enable token exchange permission
logger.info("Step 3: Configuring token exchange permissions...")
# Get all clients (we need to allow exchange from/to any client)
all_clients_response = await client.get(
f"{keycloak_url}/admin/realms/{realm}/clients",
headers=headers,
)
all_clients_response.raise_for_status()
all_clients = all_clients_response.json()
# Get all users (we need to allow impersonation of any user)
users_response = await client.get(
f"{keycloak_url}/admin/realms/{realm}/users",
headers=headers,
)
users_response.raise_for_status()
users = users_response.json()
logger.info(f" Found {len(all_clients)} clients and {len(users)} users")
logger.info("")
# Step 4: Enable permission for client to perform token exchange
logger.info("Step 4: Enabling token exchange permission...")
# Update client to enable fine-grained permissions
update_response = await client.put(
f"{keycloak_url}/admin/realms/{realm}/clients/{client_uuid}",
headers=headers,
json={
**clients[0],
"authorizationServicesEnabled": False, # Don't need full authz
"serviceAccountsEnabled": True, # Already enabled
},
)
if update_response.status_code in [200, 204]:
logger.info("✓ Client configuration updated")
else:
logger.warning(f"⚠ Client update returned {update_response.status_code}")
logger.info("")
# Step 5: Set up token exchange permission policy
logger.info("Step 5: Configuring impersonation policy...")
# In Keycloak Legacy V1, we need to use the token-exchange permissions endpoint
# This is part of the preview features
# First, check if token exchange permissions endpoint exists
try:
perms_response = await client.get(
f"{keycloak_url}/admin/realms/{realm}/clients/{client_uuid}/token-exchange/permissions",
headers=headers,
)
if perms_response.status_code == 200:
logger.info("✓ Token exchange permissions endpoint available")
permissions = perms_response.json()
logger.info(f" Current permissions: {permissions}")
logger.info("")
# Enable impersonation for all users
logger.info("Step 6: Enabling impersonation for admin user...")
# Find admin user
admin_user = next((u for u in users if u["username"] == "admin"), None)
if admin_user:
# Enable permission for this client to impersonate admin
enable_response = await client.put(
f"{keycloak_url}/admin/realms/{realm}/users/{admin_user['id']}/impersonation",
headers=headers,
json={
"client": client_uuid,
"enabled": True,
},
)
if enable_response.status_code in [200, 204]:
logger.info("✓ Impersonation enabled for admin user")
else:
logger.warning(
f"⚠ Impersonation enable returned {enable_response.status_code}"
)
logger.info(f" Response: {enable_response.text}")
else:
logger.error("❌ Admin user not found")
elif perms_response.status_code == 404:
logger.warning("⚠ Token exchange permissions endpoint not found")
logger.info(" This might mean preview features aren't fully enabled")
logger.info(" Or the Keycloak version doesn't support this API")
else:
logger.warning(f"⚠ Unexpected response: {perms_response.status_code}")
except Exception as e:
logger.error(f"❌ Error configuring permissions: {e}")
logger.info("")
logger.info("Alternative: Manual configuration required")
logger.info(" 1. Open Keycloak Admin Console")
logger.info(" 2. Go to Clients → nextcloud-mcp-server")
logger.info(" 3. Go to Permissions tab")
logger.info(" 4. Enable 'token-exchange' permission")
logger.info(" 5. Configure permission policies for impersonation")
logger.info("")
logger.info("=" * 80)
logger.info("Configuration Complete")
logger.info("=" * 80)
logger.info("")
logger.info("Next step: Run impersonation test")
logger.info(" uv run python tests/manual/test_impersonation.py")
return 0
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)
+289
View File
@@ -0,0 +1,289 @@
"""
Manual test for RFC 8693 Token Exchange with USER IMPERSONATION.
This script tests whether Keycloak actually supports the requested_subject
parameter for user impersonation, as claimed in ADR-002 to be unsupported.
Test procedure:
1. Get service account token (client_credentials grant)
2. Attempt to exchange token WITH requested_subject parameter
3. Observe actual behavior (success or error)
4. Decode resulting token to verify sub claim
Usage:
# Start Keycloak and app containers
docker compose up -d keycloak app
# Run the test
uv run python tests/manual/test_impersonation.py
"""
import asyncio
import base64
import json
import logging
import os
import sys
# Add parent directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
from nextcloud_mcp_server.client import NextcloudClient
# Setup logging
logging.basicConfig(
level=logging.INFO, format="%(levelname)-8s | %(name)-30s | %(message)s"
)
logger = logging.getLogger(__name__)
def decode_jwt(token: str) -> dict:
"""Decode JWT token payload without verification"""
try:
# Split token and get payload (second part)
parts = token.split(".")
if len(parts) != 3:
return {"error": "Invalid JWT format"}
# Decode payload (add padding if needed)
payload = parts[1]
padding = 4 - (len(payload) % 4)
if padding != 4:
payload += "=" * padding
decoded = base64.urlsafe_b64decode(payload)
return json.loads(decoded)
except Exception as e:
return {"error": str(e)}
async def main():
"""Test token exchange with impersonation"""
# Configuration (matches docker-compose mcp-keycloak service)
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server")
client_secret = os.getenv(
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
)
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
redirect_uri = "http://localhost:8002/oauth/callback"
target_user = "admin" # User to impersonate
logger.info("=" * 80)
logger.info("RFC 8693 Token Exchange IMPERSONATION Test")
logger.info("=" * 80)
logger.info(f"Keycloak URL: {keycloak_url}")
logger.info(f"Realm: {realm}")
logger.info(f"Client ID: {client_id}")
logger.info(f"Target User: {target_user}")
logger.info(f"Nextcloud: {nextcloud_host}")
logger.info("")
logger.info("⚠️ This test attempts impersonation to verify ADR-002 claims")
logger.info("")
# Step 1: Create Keycloak OAuth client
logger.info("Step 1: Initializing Keycloak OAuth client...")
oauth_client = KeycloakOAuthClient(
keycloak_url=keycloak_url,
realm=realm,
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
)
# Discover endpoints
await oauth_client.discover()
logger.info(f"✓ Discovered token endpoint: {oauth_client.token_endpoint}")
logger.info("")
# Step 2: Check token exchange support
logger.info("Step 2: Checking token exchange support...")
supported = await oauth_client.check_token_exchange_support()
if not supported:
logger.error("❌ Token exchange is NOT supported by this Keycloak instance")
logger.error(
" You may need to enable it with: --features=preview --features=token-exchange"
)
return 1
logger.info("✓ Token exchange is supported")
logger.info("")
# Step 3: Get service account token
logger.info("Step 3: Requesting service account token (client_credentials)...")
try:
service_token_response = await oauth_client.get_service_account_token(
scopes=["openid", "profile", "email"]
)
service_token = service_token_response["access_token"]
logger.info("✓ Service account token acquired")
# Decode and show claims
service_claims = decode_jwt(service_token)
logger.info(f" Subject (sub): {service_claims.get('sub')}")
logger.info(f" Preferred username: {service_claims.get('preferred_username')}")
logger.info(f" Client ID (azp): {service_claims.get('azp')}")
except Exception as e:
logger.error(f"❌ Failed to get service account token: {e}")
return 1
logger.info("")
# Step 4: Attempt token exchange WITH impersonation
logger.info(
f"Step 4: Attempting token exchange WITH impersonation (requested_subject={target_user})..."
)
logger.info(
" 🧪 This is the actual test - will Keycloak accept requested_subject?"
)
logger.info("")
try:
user_token_response = await oauth_client.exchange_token_for_user(
subject_token=service_token,
target_user_id=target_user, # ← THE KEY TEST: Request impersonation
audience=None,
scopes=["openid", "profile", "email"],
)
user_token = user_token_response["access_token"]
logger.info("✅ Token exchange with impersonation SUCCEEDED!")
logger.info("")
logger.info("📊 Response details:")
logger.info(
f" Issued token type: {user_token_response.get('issued_token_type')}"
)
logger.info(f" Token type: {user_token_response.get('token_type')}")
logger.info(f" Expires in: {user_token_response.get('expires_in')}s")
logger.info("")
# Decode and analyze the exchanged token
user_claims = decode_jwt(user_token)
logger.info("📋 Token claims analysis:")
logger.info(f" Subject (sub): {user_claims.get('sub')}")
logger.info(f" Preferred username: {user_claims.get('preferred_username')}")
logger.info(f" Client ID (azp): {user_claims.get('azp')}")
logger.info(f" Audience (aud): {user_claims.get('aud')}")
logger.info("")
# Verify if impersonation actually worked
service_sub = service_claims.get("sub")
user_sub = user_claims.get("sub")
if service_sub != user_sub:
logger.info("✅ IMPERSONATION VERIFIED:")
logger.info(f" Original sub: {service_sub}")
logger.info(f" New sub: {user_sub}")
logger.info("")
logger.info(" ➡️ The subject claim CHANGED - impersonation worked!")
impersonation_worked = True
else:
logger.warning("⚠️ IMPERSONATION DID NOT OCCUR:")
logger.warning(f" Subject unchanged: {user_sub}")
logger.warning("")
logger.warning(" ➡️ Token exchange succeeded but sub claim is the same")
logger.warning(
" This is delegation/audience change, not impersonation"
)
impersonation_worked = False
except Exception as e:
logger.error("❌ Token exchange with impersonation FAILED!")
logger.error(f" Error: {e}")
logger.error("")
logger.error("📋 Error analysis:")
# Try to extract detailed error message
error_str = str(e)
if "requested_subject" in error_str.lower():
logger.error(
" ➡️ Error mentions 'requested_subject' - parameter not supported"
)
elif "impersonation" in error_str.lower():
logger.error(" ➡️ Error mentions 'impersonation' - feature not enabled")
elif "permission" in error_str.lower():
logger.error(" ➡️ Error mentions 'permission' - client lacks permissions")
else:
logger.error(" ➡️ Generic error - check Keycloak logs for details")
logger.error("")
logger.error("💡 Possible causes:")
logger.error(" 1. Keycloak Standard V2 doesn't support requested_subject")
logger.error(" 2. Requires Legacy V1 with --features=preview")
logger.error(" 3. Client lacks impersonation permissions")
logger.error(" 4. Target user doesn't exist")
return 1
logger.info("")
# Step 5: Test impersonated token with Nextcloud API
if impersonation_worked:
logger.info("Step 5: Testing impersonated token with Nextcloud API...")
try:
# Create Nextcloud client with exchanged token
nc_client = NextcloudClient.from_token(
base_url=nextcloud_host, token=user_token, username=target_user
)
# Test API call
capabilities = await nc_client.capabilities()
logger.info("✓ Nextcloud API call successful with impersonated token")
logger.info(f" Version: {capabilities.get('version', {}).get('string')}")
await nc_client.close()
except Exception as e:
logger.error(f"❌ Nextcloud API call failed: {e}")
logger.error(" The impersonated token may not be valid for Nextcloud")
return 1
logger.info("")
logger.info("=" * 80)
logger.info("TEST RESULTS SUMMARY")
logger.info("=" * 80)
if impersonation_worked:
logger.info("✅ IMPERSONATION IS SUPPORTED!")
logger.info("")
logger.info("Key findings:")
logger.info(" • Token exchange with requested_subject WORKS")
logger.info(" • Subject claim successfully changed")
logger.info(" • Impersonated token works with Nextcloud APIs")
logger.info("")
logger.info("⚠️ ADR-002 DOCUMENTATION IS INCORRECT")
logger.info(" Current docs claim impersonation doesn't work in Standard V2")
logger.info(" This test proves it DOES work!")
logger.info("")
logger.info("Action items:")
logger.info(" 1. Update ADR-002 to mark Tier 1 as IMPLEMENTED")
logger.info(" 2. Remove 'NOT IMPLEMENTED' warnings from code")
logger.info(" 3. Add automated tests for impersonation")
logger.info(" 4. Update oauth-impersonation-findings.md")
else:
logger.info("❌ IMPERSONATION IS NOT SUPPORTED")
logger.info("")
logger.info("Key findings:")
logger.info(" • Token exchange with requested_subject FAILED")
logger.info(" • Keycloak rejected the parameter")
logger.info(" • Confirms ADR-002 documentation")
logger.info("")
logger.info("✅ ADR-002 DOCUMENTATION IS CORRECT")
logger.info(" Impersonation requires Keycloak Legacy V1")
logger.info("")
logger.info("Action items:")
logger.info(" 1. Add this test as evidence to ADR-002")
logger.info(" 2. Document exact error message")
logger.info(" 3. Add 'Verified by testing' note to docs")
logger.info("")
return 0 if impersonation_worked else 1
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)
+227
View File
@@ -0,0 +1,227 @@
"""
Manual test for Nextcloud impersonate API.
This script tests using the Nextcloud impersonate app to allow
admin users to act on behalf of other users.
This is NOT the same as OAuth token exchange, but could serve
as a workaround for background operations.
Usage:
# Start app container
docker compose up -d app
# Run the test
uv run python tests/manual/test_nextcloud_impersonate.py
"""
import asyncio
import logging
import os
import sys
# Add parent directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
import httpx
# Setup logging
logging.basicConfig(
level=logging.INFO, format="%(levelname)-8s | %(name)-30s | %(message)s"
)
logger = logging.getLogger(__name__)
async def main():
"""Test Nextcloud impersonate API"""
# Configuration
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
admin_user = os.getenv("NEXTCLOUD_USERNAME", "admin")
admin_password = os.getenv("NEXTCLOUD_PASSWORD", "admin")
target_user = "testuser" # We'll create this user
logger.info("=" * 80)
logger.info("Nextcloud Impersonate API Test")
logger.info("=" * 80)
logger.info(f"Nextcloud: {nextcloud_host}")
logger.info(f"Admin user: {admin_user}")
logger.info(f"Target user: {target_user}")
logger.info("")
async with httpx.AsyncClient() as client:
# Step 1: Login as admin and get session
logger.info("Step 1: Logging in as admin...")
login_response = await client.post(
f"{nextcloud_host}/login",
data={
"user": admin_user,
"password": admin_password,
},
follow_redirects=True,
)
if login_response.status_code != 200:
logger.error(f"❌ Admin login failed: {login_response.status_code}")
return 1
# Get requesttoken from response
requesttoken = None
for cookie in client.cookies.jar:
if cookie.name == "nc_session":
logger.info(f"✓ Admin logged in, session: {cookie.value[:20]}...")
break
logger.info("")
# Step 2: Create test user if doesn't exist
logger.info(f"Step 2: Creating test user '{target_user}'...")
create_user_response = await client.post(
f"{nextcloud_host}/ocs/v1.php/cloud/users",
auth=(admin_user, admin_password),
data={
"userid": target_user,
"password": "testpassword123",
},
headers={"OCS-APIRequest": "true"},
)
if create_user_response.status_code in (200, 400): # 400 if already exists
logger.info("✓ Test user ready")
else:
logger.warning(
f"User creation response: {create_user_response.status_code}"
)
# Make sure user has logged in at least once (requirement for impersonation)
logger.info(f" Performing initial login for {target_user}...")
await client.post(
f"{nextcloud_host}/login",
data={
"user": target_user,
"password": "testpassword123",
},
follow_redirects=True,
)
logger.info("✓ Test user has logged in")
# Re-login as admin
await client.post(
f"{nextcloud_host}/login",
data={
"user": admin_user,
"password": admin_password,
},
follow_redirects=True,
)
logger.info("")
# Step 3: Get CSRF token for impersonate request
logger.info("Step 3: Getting CSRF token...")
# Try to get token from settings page
settings_response = await client.get(
f"{nextcloud_host}/settings/users",
follow_redirects=True,
)
# Extract requesttoken from HTML
import re
token_match = re.search(r'data-requesttoken="([^"]+)"', settings_response.text)
if token_match:
requesttoken = token_match.group(1)
logger.info(f"✓ CSRF token acquired: {requesttoken[:20]}...")
else:
logger.error("❌ Could not extract CSRF token from page")
return 1
logger.info("")
# Step 4: Call impersonate API
logger.info(f"Step 4: Impersonating user '{target_user}'...")
impersonate_response = await client.post(
f"{nextcloud_host}/apps/impersonate/user",
data={
"userId": target_user,
},
headers={
"requesttoken": requesttoken,
"Content-Type": "application/x-www-form-urlencoded",
},
)
if impersonate_response.status_code != 200:
logger.error(f"❌ Impersonate failed: {impersonate_response.status_code}")
logger.error(f"Response: {impersonate_response.text}")
return 1
logger.info("✓ Impersonation successful")
logger.info("")
# Step 5: Test API call as impersonated user
logger.info("Step 5: Testing API call as impersonated user...")
capabilities_response = await client.get(
f"{nextcloud_host}/ocs/v2.php/cloud/capabilities",
headers={"OCS-APIRequest": "true"},
)
if capabilities_response.status_code == 200:
caps = capabilities_response.json()
logger.info(f"✓ API call successful as {target_user}")
logger.info(
f" Version: {caps.get('ocs', {}).get('data', {}).get('version', {}).get('string')}"
)
else:
logger.error(f"❌ API call failed: {capabilities_response.status_code}")
return 1
logger.info("")
# Step 6: Get current user to verify impersonation
logger.info("Step 6: Verifying current user...")
user_response = await client.get(
f"{nextcloud_host}/ocs/v2.php/cloud/user",
headers={"OCS-APIRequest": "true"},
)
if user_response.status_code == 200:
user_data = user_response.json()
current_user = user_data.get("ocs", {}).get("data", {}).get("id")
logger.info(f"✓ Current user: {current_user}")
if current_user == target_user:
logger.info(" ✓ Successfully impersonating target user!")
else:
logger.warning(f" ⚠ Expected {target_user}, got {current_user}")
else:
logger.error(f"❌ User check failed: {user_response.status_code}")
logger.info("")
logger.info("=" * 80)
logger.info("✅ Impersonate API Test PASSED")
logger.info("=" * 80)
logger.info("")
logger.info("Summary:")
logger.info(" 1. Admin can impersonate other users via session-based API")
logger.info(" 2. Impersonated session can access APIs as that user")
logger.info(" 3. Requires admin credentials and CSRF token")
logger.info("")
logger.info("Limitations:")
logger.info(" - Session-based (not stateless like OAuth)")
logger.info(" - Requires admin credentials")
logger.info(" - Target user must have logged in at least once")
logger.info(" - Not suitable for distributed/background workers")
logger.info("")
logger.info("For background operations, consider:")
logger.info(" - Use service account with appropriate permissions")
logger.info(" - Or implement proper OAuth delegation (RFC 8693)")
logger.info("")
return 0
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)
+196
View File
@@ -0,0 +1,196 @@
"""
Manual test for RFC 8693 Token Exchange with Keycloak.
This script demonstrates ADR-002 Tier 2 implementation:
1. Get service account token (client_credentials grant)
2. Exchange token for user-scoped token (RFC 8693)
3. Use exchanged token to access Nextcloud APIs
Usage:
# Start Keycloak and app containers
docker compose up -d keycloak app
# Run the test
uv run python tests/manual/test_token_exchange.py
"""
import asyncio
import logging
import os
import sys
# Add parent directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
from nextcloud_mcp_server.client import NextcloudClient
# Setup logging
logging.basicConfig(
level=logging.INFO, format="%(levelname)-8s | %(name)-30s | %(message)s"
)
logger = logging.getLogger(__name__)
async def main():
"""Test token exchange flow"""
# Configuration (matches docker-compose mcp-keycloak service)
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server")
client_secret = os.getenv(
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
)
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
redirect_uri = "http://localhost:8002/oauth/callback"
logger.info("=" * 80)
logger.info("RFC 8693 Token Exchange Test")
logger.info("=" * 80)
logger.info(f"Keycloak URL: {keycloak_url}")
logger.info(f"Realm: {realm}")
logger.info(f"Client ID: {client_id}")
logger.info(f"Nextcloud: {nextcloud_host}")
logger.info("")
# Step 1: Create Keycloak OAuth client
logger.info("Step 1: Initializing Keycloak OAuth client...")
oauth_client = KeycloakOAuthClient(
keycloak_url=keycloak_url,
realm=realm,
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
)
# Discover endpoints
await oauth_client.discover()
logger.info(f"✓ Discovered token endpoint: {oauth_client.token_endpoint}")
logger.info("")
# Step 2: Check token exchange support
logger.info("Step 2: Checking token exchange support...")
supported = await oauth_client.check_token_exchange_support()
if not supported:
logger.error("❌ Token exchange is NOT supported by this Keycloak instance")
logger.error(
" You may need to enable it with: --features=preview --features=token-exchange"
)
return 1
logger.info("")
# Step 3: Get service account token
# ⚠️ WARNING: Service account tokens MUST NOT be used directly with Nextcloud APIs!
# Using this token directly violates OAuth "act on-behalf-of" principles:
# - Creates Nextcloud user: service-account-{client_id}
# - Breaks audit trail (actions not attributable to real user)
# - Creates stateful server identity in Nextcloud
#
# VALID USE: ONLY as subject_token for RFC 8693 token exchange (Step 4 below)
# INVALID USE: Direct API access (see ADR-002 "Will Not Implement" section)
#
# If you need background operations without token exchange support, use BasicAuth mode.
logger.info("Step 3: Requesting service account token (client_credentials)...")
try:
service_token_response = await oauth_client.get_service_account_token(
scopes=["openid", "profile", "email"]
)
service_token = service_token_response["access_token"]
logger.info("✓ Service account token acquired")
logger.info(f" Token type: {service_token_response.get('token_type')}")
logger.info(f" Expires in: {service_token_response.get('expires_in')}s")
logger.info(f" Scope: {service_token_response.get('scope')}")
logger.info(f" Token (first 50 chars): {service_token[:50]}...")
except Exception as e:
logger.error(f"❌ Failed to get service account token: {e}")
logger.error(
" Make sure serviceAccountsEnabled=true for the client in Keycloak"
)
return 1
logger.info("")
# Step 4: Exchange token (without impersonation - Standard V2)
logger.info(
"Step 4: Exchanging service token with different audience (RFC 8693)..."
)
logger.info(" Note: Keycloak Standard V2 doesn't support user impersonation")
logger.info(" That requires Legacy V1 with --features=preview")
try:
user_token_response = await oauth_client.exchange_token_for_user(
subject_token=service_token,
target_user_id=None, # Don't request impersonation
audience=None, # No cross-client exchange in Standard V2
scopes=["openid", "profile"], # Try downscoping
)
user_token = user_token_response["access_token"]
logger.info("✓ Token exchange successful")
logger.info(
f" Issued token type: {user_token_response.get('issued_token_type')}"
)
logger.info(f" Token type: {user_token_response.get('token_type')}")
logger.info(f" Expires in: {user_token_response.get('expires_in')}s")
logger.info(f" User token (first 50 chars): {user_token[:50]}...")
except Exception as e:
logger.error(f"❌ Token exchange failed: {e}")
logger.error(" Possible causes:")
logger.error(" - token.exchange.grant.enabled not set to true")
logger.error(" - Missing exchange permissions in Keycloak")
logger.error(" - User 'admin' does not exist")
return 1
logger.info("")
# Step 5: Test user token with Nextcloud API
logger.info("Step 5: Testing exchanged token with Nextcloud capabilities API...")
try:
# Create Nextcloud client with exchanged token
nc_client = NextcloudClient.from_token(
base_url=nextcloud_host, token=user_token, username="admin"
)
# Test API call
capabilities = await nc_client.capabilities()
logger.info("✓ Nextcloud API call successful")
logger.info(f" Version: {capabilities.get('version', {}).get('string')}")
logger.info(
f" Edition: {capabilities.get('capabilities', {}).get('core', {}).get('webdav-root')}"
)
await nc_client.close()
except Exception as e:
logger.error(f"❌ Nextcloud API call failed: {e}")
logger.error(" The exchanged token may not be valid for Nextcloud")
logger.error(" Check that user_oidc app is configured correctly")
return 1
logger.info("")
logger.info("=" * 80)
logger.info("✅ Token Exchange Test PASSED")
logger.info("=" * 80)
logger.info("")
logger.info("Summary:")
logger.info(" 1. Service account token acquired")
logger.info(" 2. Token exchanged with different audience")
logger.info(" 3. Exchanged token works with Nextcloud APIs")
logger.info("")
logger.info("This demonstrates ADR-002 Tier 2: Token Exchange")
logger.info(
"The MCP server can perform token exchange for different audiences/scopes"
)
logger.info("without needing refresh tokens or admin credentials.")
logger.info("")
logger.info(
"Note: User impersonation requires Keycloak Legacy V1 with --features=preview"
)
logger.info("")
return 0
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)
+20 -16
View File
@@ -3,8 +3,8 @@ Tests for Dynamic Client Registration (DCR) token_type parameter.
These tests verify that the Nextcloud OIDC server properly honors the token_type
parameter during client registration, issuing the correct type of access tokens:
- token_type="JWT" → JWT-formatted tokens (RFC 9068)
- token_type="Bearer" → Opaque tokens (standard OAuth2)
- token_type="jwt" → JWT-formatted tokens (RFC 9068)
- token_type="opaque" → Opaque tokens (standard OAuth2)
This is critical for ensuring:
1. Client choice is respected by the OIDC server
@@ -208,12 +208,14 @@ async def test_dcr_respects_jwt_token_type(
oauth_callback_server,
):
"""
Test that DCR honors token_type=JWT and issues JWT-formatted tokens.
Test that DCR honors token_type=jwt and issues JWT-formatted tokens.
This verifies:
1. Client registration with token_type="JWT" succeeds
1. Client registration with token_type="jwt" succeeds
2. Tokens obtained via this client are JWT format (base64.base64.signature)
3. JWT payload contains expected claims (sub, iss, scope, etc.)
Note: The OIDC app uses lowercase 'jwt' (not 'JWT').
"""
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
@@ -232,15 +234,15 @@ async def test_dcr_respects_jwt_token_type(
token_endpoint = oidc_config.get("token_endpoint")
authorization_endpoint = oidc_config.get("authorization_endpoint")
# Register client with token_type="JWT"
logger.info("Registering OAuth client with token_type=JWT...")
# Register client with token_type="jwt"
logger.info("Registering OAuth client with token_type=jwt...")
client_info = await register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
client_name="DCR Test - JWT Token Type",
redirect_uris=[callback_url],
scopes="openid profile email notes:read notes:write",
token_type="JWT",
token_type="jwt",
)
logger.info(f"Registered JWT client: {client_info.client_id[:16]}...")
@@ -278,7 +280,7 @@ async def test_dcr_respects_jwt_token_type(
assert "notes:write" in scopes, "JWT scope claim missing notes:write"
logger.info(
f"✅ DCR with token_type=JWT works correctly! "
f"✅ DCR with token_type=jwt works correctly! "
f"Token is JWT format with scope claim: {payload['scope']}"
)
@@ -290,12 +292,14 @@ async def test_dcr_respects_bearer_token_type(
oauth_callback_server,
):
"""
Test that DCR honors token_type=Bearer and issues opaque tokens.
Test that DCR honors token_type=opaque and issues opaque tokens.
This verifies:
1. Client registration with token_type="Bearer" succeeds
1. Client registration with token_type="opaque" succeeds
2. Tokens obtained via this client are opaque (NOT JWT format)
3. Opaque tokens are simple strings, not base64-encoded structures
Note: The OIDC app uses 'opaque' or 'jwt' as token_type values (not 'Bearer').
"""
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
@@ -314,18 +318,18 @@ async def test_dcr_respects_bearer_token_type(
token_endpoint = oidc_config.get("token_endpoint")
authorization_endpoint = oidc_config.get("authorization_endpoint")
# Register client with token_type="Bearer" (opaque tokens)
logger.info("Registering OAuth client with token_type=Bearer...")
# Register client with token_type="opaque" (opaque tokens)
logger.info("Registering OAuth client with token_type=opaque...")
client_info = await register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
client_name="DCR Test - Bearer Token Type",
client_name="DCR Test - Opaque Token Type",
redirect_uris=[callback_url],
scopes="openid profile email notes:read notes:write",
token_type="Bearer",
token_type="opaque",
)
logger.info(f"Registered Bearer client: {client_info.client_id[:16]}...")
logger.info(f"Registered Opaque token client: {client_info.client_id[:16]}...")
# Obtain token via OAuth flow
access_token = await get_oauth_token_with_client(
@@ -353,7 +357,7 @@ async def test_dcr_respects_bearer_token_type(
pass
logger.info(
f"✅ DCR with token_type=Bearer works correctly! "
f"✅ DCR with token_type=opaque works correctly! "
f"Token is opaque (not JWT format): {access_token[:30]}..."
)
@@ -0,0 +1,566 @@
"""Keycloak External IdP Integration Tests.
Tests verify ADR-002 external identity provider integration where:
1. Keycloak acts as external OAuth/OIDC provider
2. MCP server validates tokens via Nextcloud user_oidc app
3. Nextcloud auto-provisions users from Keycloak token claims
4. MCP tools execute successfully with Keycloak tokens
Architecture:
MCP Client → Keycloak (OAuth) → MCP Server → Nextcloud user_oidc (validates) → APIs
Tests:
1. Keycloak OAuth token acquisition via Playwright
2. MCP client connection to mcp-keycloak service (port 8002)
3. Token validation through Nextcloud user_oidc app
4. MCP tool execution with Keycloak tokens
5. User auto-provisioning from Keycloak claims
6. Scope-based tool filtering with Keycloak JWT tokens
"""
import json
import logging
import pytest
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.keycloak]
# ============================================================================
# OAuth Token Acquisition Tests
# ============================================================================
async def test_keycloak_oauth_token_acquisition(keycloak_oauth_token):
"""Test that Playwright can obtain OAuth token from Keycloak.
Verifies:
- Playwright automation handles Keycloak login page (input#username, input#password)
- Keycloak consent screen is handled correctly
- Authorization code is exchanged for access token
- Token is returned successfully
This is a foundational test - if this fails, all other Keycloak tests will fail.
"""
assert keycloak_oauth_token is not None
assert isinstance(keycloak_oauth_token, str)
assert len(keycloak_oauth_token) > 100 # Tokens should be substantial length
logger.info(
f"✓ Keycloak OAuth token acquired (length: {len(keycloak_oauth_token)})"
)
logger.info(f" Token prefix: {keycloak_oauth_token[:50]}...")
async def test_keycloak_oauth_client_credentials_discovery(
keycloak_oauth_client_credentials,
):
"""Test Keycloak OIDC discovery and credential loading.
Verifies:
- OIDC discovery endpoint is accessible
- Token and authorization endpoints are discovered
- Static client credentials are loaded from environment
- Callback server is initialized
"""
(
client_id,
client_secret,
callback_url,
token_endpoint,
authorization_endpoint,
) = keycloak_oauth_client_credentials
assert client_id == "nextcloud-mcp-server"
assert client_secret == "mcp-secret-change-in-production"
assert callback_url.startswith("http://")
# With --hostname-backchannel-dynamic, external clients see localhost:8888
assert "localhost:8888" in token_endpoint or "keycloak" in token_endpoint
assert (
"localhost:8888" in authorization_endpoint
or "keycloak" in authorization_endpoint
)
assert "/realms/nextcloud-mcp/" in token_endpoint
logger.info("✓ Keycloak OIDC discovery successful")
logger.info(f" Client ID: {client_id}")
logger.info(f" Token endpoint: {token_endpoint}")
logger.info(f" Authorization endpoint: {authorization_endpoint}")
# ============================================================================
# MCP Server Connectivity Tests
# ============================================================================
async def test_mcp_client_connects_to_keycloak_server(nc_mcp_keycloak_client):
"""Test MCP client can connect to mcp-keycloak service (port 8002).
Verifies:
- MCP client session is established
- Server responds to list_tools request
- Tools are available for use
"""
result = await nc_mcp_keycloak_client.list_tools()
assert result is not None
assert len(result.tools) > 0
logger.info(
f"✓ MCP client connected to Keycloak server with {len(result.tools)} tools"
)
async def test_external_idp_server_initialization(nc_mcp_keycloak_client):
"""Test that MCP server correctly initializes with external IdP configuration.
Verifies:
- Server auto-detects external IdP mode (issuer != Nextcloud host)
- Server reports correct provider type
- All expected tools are registered
The server should log messages like:
- "✓ Detected external IdP mode (issuer: http://keycloak:8080/realms/nextcloud-mcp != Nextcloud: http://app:80)"
"""
result = await nc_mcp_keycloak_client.list_tools()
# Verify we have a full set of tools (not filtered to specific apps)
tool_names = [tool.name for tool in result.tools]
# Should have tools from multiple apps
has_notes = any("notes" in name for name in tool_names)
has_calendar = any("calendar" in name for name in tool_names)
has_files = any("webdav" in name for name in tool_names)
assert has_notes, "Missing Notes tools"
assert has_calendar, "Missing Calendar tools"
assert has_files, "Missing WebDAV/Files tools"
logger.info("✓ MCP server initialized with external IdP mode")
logger.info(f" Tools from multiple apps detected: {len(result.tools)} total")
# ============================================================================
# Token Validation Tests
# ============================================================================
async def test_external_idp_token_validation(nc_mcp_keycloak_client):
"""Test that Keycloak tokens are validated via Nextcloud user_oidc app.
Token flow:
1. Keycloak issues OAuth token
2. MCP client sends token to MCP server
3. MCP server passes token to Nextcloud user_oidc app
4. user_oidc validates token with Keycloak (JWKS or introspection)
5. Nextcloud returns user info to MCP server
6. MCP server uses token to access Nextcloud APIs
This test verifies the entire flow works.
"""
# Execute a read operation (requires token validation)
result = await nc_mcp_keycloak_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result.isError is False, f"Tool execution failed: {result.content}"
assert result.content is not None
response_data = json.loads(result.content[0].text)
# Successful response means token was validated and user was authenticated
assert "results" in response_data
assert isinstance(response_data["results"], list)
logger.info("✓ Keycloak token validated successfully via Nextcloud user_oidc app")
logger.info(f" Tool execution returned {len(response_data['results'])} results")
# ============================================================================
# Tool Execution Tests
# ============================================================================
async def test_tools_work_with_keycloak_token(nc_mcp_keycloak_client):
"""Test that MCP tools execute successfully with Keycloak OAuth tokens.
Verifies end-to-end functionality:
- Read operations work (nc_notes_search_notes)
- Write operations work (nc_notes_create_note)
- Different apps work (Notes, Calendar, Files)
"""
# Test 1: Read operation (Notes)
search_result = await nc_mcp_keycloak_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert search_result.isError is False
logger.info("✓ Read operation successful (nc_notes_search_notes)")
# Test 2: Write operation (Notes)
create_result = await nc_mcp_keycloak_client.call_tool(
"nc_notes_create_note",
arguments={
"title": "Keycloak Test Note",
"content": "Created via external IdP token",
"category": "Test",
},
)
assert create_result.isError is False
create_data = json.loads(create_result.content[0].text)
note_id = create_data["id"]
logger.info(f"✓ Write operation successful (created note {note_id})")
# Test 3: Different app (Calendar)
calendar_result = await nc_mcp_keycloak_client.call_tool(
"nc_calendar_list_calendars", arguments={}
)
assert calendar_result.isError is False
logger.info("✓ Calendar tool execution successful")
# Test 4: File operations (WebDAV)
files_result = await nc_mcp_keycloak_client.call_tool(
"nc_webdav_list_directory", arguments={"path": "/"}
)
assert files_result.isError is False
logger.info("✓ WebDAV tool execution successful")
# Cleanup: Delete test note
await nc_mcp_keycloak_client.call_tool(
"nc_notes_delete_note", arguments={"note_id": note_id}
)
logger.info(f"✓ Cleanup: Deleted test note {note_id}")
async def test_keycloak_token_persistence(nc_mcp_keycloak_client):
"""Test that Keycloak token works across multiple operations.
Verifies:
- Token is properly cached by MCP server
- Token can be reused for multiple API calls
- No re-authentication is required between calls
"""
# Execute multiple operations with same session
operations = [
("nc_notes_search_notes", {"query": ""}),
("nc_calendar_list_calendars", {}),
("nc_webdav_list_directory", {"path": "/"}),
]
for tool_name, arguments in operations:
result = await nc_mcp_keycloak_client.call_tool(tool_name, arguments=arguments)
assert result.isError is False, f"Failed to execute {tool_name}"
logger.info(f"{tool_name} executed successfully")
logger.info("✓ Keycloak token persistence verified (3 operations with same token)")
# ============================================================================
# User Provisioning Tests
# ============================================================================
async def test_user_auto_provisioning(nc_client: NextcloudClient, keycloak_oauth_token):
"""Test that Nextcloud validates users from Keycloak token claims.
When a user authenticates with Keycloak, Nextcloud's user_oidc app
validates the token and authenticates the user. In this test setup,
the Keycloak 'admin' user maps to the Nextcloud 'admin' user.
Verification:
1. User exists in Nextcloud after OAuth authentication
2. User can access Nextcloud APIs with Keycloak token
3. Bearer token validation is working correctly
Note: With bearer-provisioning enabled, user_oidc would auto-provision
new users from token claims, but since we use 'admin' in both Keycloak
and Nextcloud, they map to the same user.
"""
# Get list of users (returns List[str] of user IDs)
user_ids = await nc_client.users.search_users()
logger.info(f"Found {len(user_ids)} users in Nextcloud")
logger.info(f"Users: {user_ids}")
# Verify the admin user exists (used for authentication)
assert "admin" in user_ids, "Expected 'admin' user to exist in Nextcloud"
# Verify we can access APIs with the Keycloak token (already tested in previous tests)
# The fact that we got this far means bearer token validation is working
logger.info("✓ User authentication and bearer token validation verified")
logger.info(f" Total users: {len(user_ids)}")
logger.info(" Bearer provisioning is enabled and working correctly")
# ============================================================================
# Scope-Based Authorization Tests
# ============================================================================
async def test_scope_filtering_with_keycloak(nc_mcp_keycloak_client):
"""Test that tool filtering works correctly with Keycloak JWT scopes.
Keycloak tokens should include scopes in JWT payload (if JWT format).
The MCP server should filter tools based on these scopes.
Expected scopes (from docker-compose.yml):
- openid profile email offline_access
- notes:read notes:write
- calendar:read calendar:write
- contacts:read contacts:write
- etc.
Tools should be filtered accordingly.
"""
result = await nc_mcp_keycloak_client.list_tools()
tool_names = [tool.name for tool in result.tools]
# With full scopes, all app tools should be available
expected_tools = [
"nc_notes_get_note", # notes:read
"nc_notes_create_note", # notes:write
"nc_calendar_list_calendars", # calendar:read
"nc_calendar_create_event", # calendar:write
"nc_webdav_list_directory", # files:read
"nc_webdav_write_file", # files:write
]
for tool_name in expected_tools:
assert tool_name in tool_names, f"Expected tool {tool_name} not found"
logger.info("✓ Scope-based tool filtering working with Keycloak tokens")
logger.info(f" Available tools: {len(tool_names)}")
# ============================================================================
# Error Handling Tests
# ============================================================================
async def test_keycloak_error_handling(nc_mcp_keycloak_client):
"""Test error handling with Keycloak tokens.
Verifies:
- Invalid operations return proper errors
- Token validation errors are handled correctly
- API errors propagate correctly through the chain
"""
# Try to get a non-existent note
result = await nc_mcp_keycloak_client.call_tool(
"nc_notes_get_note", arguments={"note_id": 999999}
)
# Should get an error (note doesn't exist)
assert result.isError is True
logger.info(
"✓ Keycloak OAuth server correctly handles errors for invalid operations"
)
# ============================================================================
# Documentation Tests
# ============================================================================
async def test_external_idp_architecture():
"""Document the external IdP architecture (ADR-002).
This test captures the design and flow for reference.
"""
architecture = {
"flow": [
"1. User authenticates with Keycloak (external IdP)",
"2. Keycloak issues OAuth access token with scopes",
"3. MCP client uses token to authenticate with MCP server",
"4. MCP server receives token and passes to Nextcloud",
"5. Nextcloud user_oidc app validates token with Keycloak",
"6. Nextcloud auto-provisions user from token claims (if first login)",
"7. Nextcloud returns validated user info to MCP server",
"8. MCP server executes tool using validated token",
],
"components": {
"keycloak": "External OAuth/OIDC provider (port 8888)",
"mcp_server": "MCP server with external IdP config (port 8002)",
"nextcloud": "API server with user_oidc app (port 8080)",
"user_oidc": "Nextcloud app that validates external IdP tokens",
},
"configuration": {
"keycloak_realm": "nextcloud-mcp",
"keycloak_client": "nextcloud-mcp-server",
"nextcloud_provider": "keycloak (via user_oidc app)",
"token_validation": "Keycloak JWKS or introspection endpoint",
},
"advantages": [
"No admin credentials needed in MCP server",
"Centralized identity management",
"Standards-based (RFC 6749, RFC 7662, RFC 9068)",
"Supports enterprise IdPs (Keycloak, Auth0, Okta, etc.)",
"User auto-provisioning from IdP claims",
],
}
logger.info("External IdP Architecture (ADR-002):")
logger.info(json.dumps(architecture, indent=2))
assert True
# ============================================================================
# Scope-Based Authorization Tests (JWT Token Filtering)
# ============================================================================
async def test_keycloak_read_only_token_filters_write_tools(
nc_mcp_keycloak_client_read_only,
):
"""Test that a Keycloak token with only read scopes filters out write tools."""
# Connect with token that has only read scopes
result = await nc_mcp_keycloak_client_read_only.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"Keycloak read-only token sees {len(tool_names)} tools")
# Verify read tools are present
expected_read_tools = [
"nc_notes_get_note", # notes:read
"nc_notes_search_notes", # notes:read
"nc_calendar_list_calendars", # calendar:read
"nc_calendar_get_event", # calendar:read
]
for tool in expected_read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found in tool list"
# Verify write tools are NOT present (filtered out)
write_tools_should_be_filtered = [
"nc_notes_create_note", # notes:write
"nc_notes_update_note", # notes:write
"nc_notes_delete_note", # notes:write
"nc_calendar_create_event", # calendar:write
"nc_calendar_update_event", # calendar:write
"nc_calendar_delete_event", # calendar:write
]
for tool in write_tools_should_be_filtered:
assert tool not in tool_names, (
f"Write tool {tool} should be filtered out but was found in tool list"
)
logger.info(
f"✅ Keycloak read-only token properly filters tools: {len(tool_names)} read tools visible, "
f"write tools hidden"
)
async def test_keycloak_write_only_token_filters_read_tools(
nc_mcp_keycloak_client_write_only,
):
"""Test that a Keycloak token with only write scopes filters out read tools."""
# Connect with token that has only write scopes
result = await nc_mcp_keycloak_client_write_only.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"Keycloak write-only token sees {len(tool_names)} tools")
# Verify write tools are present
expected_write_tools = [
"nc_notes_create_note", # notes:write
"nc_notes_update_note", # notes:write
"nc_notes_delete_note", # notes:write
"nc_calendar_create_event", # calendar:write
"nc_calendar_update_event", # calendar:write
"nc_calendar_delete_event", # calendar:write
]
for tool in expected_write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found in tool list"
# Verify read-only tools are NOT present (write-only scope)
read_tools_should_be_filtered = [
"nc_notes_get_note", # notes:read
"nc_notes_search_notes", # notes:read
"nc_calendar_list_calendars", # calendar:read
"nc_calendar_get_event", # calendar:read
]
for tool in read_tools_should_be_filtered:
assert tool not in tool_names, (
f"Read tool {tool} should be filtered out but was found in tool list"
)
logger.info(
f"✅ Keycloak write-only token properly filters tools: {len(tool_names)} write tools visible, "
f"read tools hidden"
)
async def test_keycloak_full_access_token_shows_all_tools(nc_mcp_keycloak_client):
"""Test that a Keycloak token with both read and write scopes sees all tools."""
# Connect with token that has both read and write scopes
result = await nc_mcp_keycloak_client.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"Keycloak full access token sees {len(tool_names)} tools")
# Verify both read and write tools are present
expected_read_tools = [
"nc_notes_get_note", # notes:read
"nc_notes_search_notes", # notes:read
"nc_calendar_list_calendars", # calendar:read
]
expected_write_tools = [
"nc_notes_create_note", # notes:write
"nc_calendar_create_event", # calendar:write
]
for tool in expected_read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found"
for tool in expected_write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found"
# Should have all 90+ tools (both read and write)
assert len(tool_names) >= 90
logger.info(
f"✅ Keycloak full access token sees all tools: {len(tool_names)} total (read + write)"
)
async def test_keycloak_no_custom_scopes_returns_zero_tools(
nc_mcp_keycloak_client_no_custom_scopes,
):
"""
Test that a Keycloak JWT token with only OIDC default scopes returns 0 tools.
This tests the security behavior when a user declines to grant custom scopes during consent.
Expected: JWT token has scopes=['openid', 'profile', 'email'] but no custom scopes.
All tools require at least one custom scope, so they should all be filtered out.
"""
# Connect with JWT token that has NO custom scopes (only openid, profile, email)
result = await nc_mcp_keycloak_client_no_custom_scopes.list_tools()
assert result is not None
tool_names = [tool.name for tool in result.tools]
logger.info(
f"Keycloak JWT token with no custom scopes sees {len(tool_names)} tools (should be 0)"
)
# All tools require custom scopes, so should be filtered out
assert len(tool_names) == 0, (
f"Expected 0 tools but got {len(tool_names)}: {tool_names[:10]}"
)
logger.info(
"✅ Keycloak JWT token without custom scopes correctly returns 0 tools (all filtered out)"
)
+11 -10
View File
@@ -23,7 +23,6 @@ def clean_env(monkeypatch):
"NEXTCLOUD_PASSWORD",
"NEXTCLOUD_OIDC_CLIENT_ID",
"NEXTCLOUD_OIDC_CLIENT_SECRET",
"NEXTCLOUD_OIDC_CLIENT_STORAGE",
"NEXTCLOUD_OIDC_SCOPES",
"NEXTCLOUD_OIDC_TOKEN_TYPE",
"NEXTCLOUD_MCP_SERVER_URL",
@@ -240,9 +239,6 @@ def test_default_values(runner, clean_env, monkeypatch):
"NEXTCLOUD_OIDC_TOKEN_TYPE"
),
"NEXTCLOUD_MCP_SERVER_URL": os.environ.get("NEXTCLOUD_MCP_SERVER_URL"),
"NEXTCLOUD_OIDC_CLIENT_STORAGE": os.environ.get(
"NEXTCLOUD_OIDC_CLIENT_STORAGE"
),
}
)
raise SystemExit(0)
@@ -253,15 +249,20 @@ def test_default_values(runner, clean_env, monkeypatch):
_ = runner.invoke(run, [])
# Verify default values
assert (
captured_env["NEXTCLOUD_OIDC_SCOPES"]
== "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"
assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == (
"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"
)
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "bearer"
assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://localhost:8000"
assert (
captured_env["NEXTCLOUD_OIDC_CLIENT_STORAGE"] == ".nextcloud_oauth_client.json"
)
def test_oauth_token_type_case_normalization(runner, clean_env, monkeypatch):
Generated
+973 -958
View File
File diff suppressed because it is too large Load Diff