Merge pull request #674 from cbcoutinho/refactor/remove-oauth-profile

refactor: remove oauth profile, migrate MCP/OAuth tests to login-flow
This commit is contained in:
Chris Coutinho
2026-04-04 10:31:42 +02:00
committed by GitHub
58 changed files with 1209 additions and 9236 deletions
+2 -11
View File
@@ -42,7 +42,6 @@ jobs:
mode:
- "single-user"
- "multi-user-basic"
- "oauth"
- "login-flow"
include:
# Version-specific image pins — Renovate updates these via customManagers in renovate.json
@@ -58,7 +57,7 @@ jobs:
# Mode-specific properties
- mode: single-user
profile: single-user
markers: "(smoke and not oauth and not keycloak and not login_flow and not multi_user_basic) or (integration and not oauth and not keycloak and not login_flow and not multi_user_basic)"
markers: "(smoke and not keycloak and not login_flow and not multi_user_basic) or (integration and not keycloak and not login_flow and not multi_user_basic)"
wait-port: 8000
mcp-internal-url: "http://mcp:8000"
needs-playwright: false
@@ -74,14 +73,6 @@ jobs:
needs-playwright: true
extra-args: ""
- mode: oauth
profile: oauth
markers: "oauth and not keycloak"
wait-port: 8001
mcp-internal-url: "http://mcp-oauth:8001"
needs-playwright: true
extra-args: ""
- mode: login-flow
profile: login-flow
markers: "login_flow"
@@ -184,7 +175,7 @@ jobs:
echo "MCP service is ready on port ${{ matrix.wait-port }}."
- name: Verify OIDC configuration
if: matrix.mode == 'oauth' || matrix.mode == 'login-flow'
if: matrix.mode == 'login-flow'
run: |
echo "=== OIDC Discovery ==="
curl -s http://localhost:8080/.well-known/openid-configuration | jq .
+38 -77
View File
@@ -52,9 +52,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Decorators**: `@require_scopes`, `@require_provisioning` for access control
- **Context pattern**: `await get_client(ctx)` to access authenticated NextcloudClient (async!)
- **FastMCP decorators**: `@mcp.tool()`, `@mcp.resource()`
- **Token acquisition**: `get_client()` handles both pass-through and token exchange modes
- Pass-through (default): Simple, stateless (ENABLE_TOKEN_EXCHANGE=false)
- Token exchange (opt-in): RFC 8693 delegation (ENABLE_TOKEN_EXCHANGE=true)
- **Token acquisition**: `get_client()` resolves credentials per deployment mode (see Deployment Modes below)
### MCP Tool Annotations (ADR-017)
@@ -199,20 +197,20 @@ uv run pytest tests/client/notes/test_notes_api.py -v
```
**Important**: After code changes, rebuild the correct container:
- Single-user tests: `docker-compose up --build -d mcp`
- OAuth tests: `docker-compose up --build -d mcp-oauth`
- Keycloak tests: `docker-compose up --build -d mcp-keycloak`
- Single-user tests: `docker compose up --build -d mcp`
- Login Flow tests: `docker compose up --build -d mcp-login-flow`
- Keycloak tests: `docker compose up --build -d mcp-keycloak`
### Running the Server
```bash
# Local development
export $(grep -v '^#' .env | xargs)
mcp run --transport sse nextcloud_mcp_server.app:mcp
uv run mcp run --transport sse nextcloud_mcp_server.app:mcp
# Docker development (rebuilds after code changes)
docker-compose up --build -d mcp # Single-user (port 8000)
docker-compose up --build -d mcp-oauth # Nextcloud OAuth (port 8001)
docker-compose up --build -d mcp-keycloak # Keycloak OAuth (port 8002)
docker compose up --build -d mcp # Single-user (port 8000)
docker compose up --build -d mcp-login-flow # Login Flow v2 (port 8004)
docker compose up --build -d mcp-keycloak # Keycloak OAuth (port 8002)
```
### Environment Setup
@@ -239,9 +237,11 @@ uv run python -m tests.load.benchmark --output results.json --verbose
**Credentials**: root/password, nextcloud/password, database: `nextcloud`
### Quick Query Script (Recommended for Agents)
**Do NOT use `docker compose exec db mariadb` or `docker compose exec <service> sqlite3` directly.** Use the wrapper scripts below instead -- they handle credentials, output formatting, and avoid repeated docker exec approvals.
Use `scripts/dbquery.py` for single SQL statements without requiring approval for each `docker compose exec`:
### MariaDB (Nextcloud)
Use `scripts/dbquery.py` for all MariaDB queries:
```bash
# Basic query
@@ -254,27 +254,6 @@ Use `scripts/dbquery.py` for single SQL statements without requiring approval fo
./scripts/dbquery.py -u nextcloud -p nextcloud "SHOW TABLES"
```
### Direct Docker Access
For interactive sessions or complex operations:
```bash
# Connect to database
docker compose exec db mariadb -u root -ppassword nextcloud
# Check OAuth clients
docker compose exec db mariadb -u root -ppassword nextcloud -e \
"SELECT id, name, token_type FROM oc_oidc_clients ORDER BY id DESC LIMIT 10;"
# Check OAuth client scopes
docker compose exec db mariadb -u root -ppassword nextcloud -e \
"SELECT c.id, c.name, s.scope FROM oc_oidc_clients c LEFT JOIN oc_oidc_client_scopes s ON c.id = s.client_id WHERE c.name LIKE '%MCP%';"
# Check OAuth access tokens
docker compose exec db mariadb -u root -ppassword nextcloud -e \
"SELECT id, client_id, user_id, created_at FROM oc_oidc_access_tokens ORDER BY created_at DESC LIMIT 10;"
```
**Important Tables**:
- `oc_oidc_clients` - OAuth client registrations (DCR)
- `oc_oidc_client_scopes` - Client allowed scopes
@@ -283,9 +262,9 @@ docker compose exec db mariadb -u root -ppassword nextcloud -e \
- `oc_oidc_registration_tokens` - RFC 7592 registration tokens
- `oc_oidc_redirect_uris` - Redirect URIs
### SQLite Databases (MCP Services)
### SQLite (MCP Services)
Use `scripts/sqlitequery.py` to query SQLite databases in MCP service containers:
Use `scripts/sqlitequery.py` for all SQLite queries:
```bash
# List tables
@@ -338,48 +317,29 @@ Use `scripts/sqlitequery.py` to query SQLite databases in MCP service containers
3. MCP tools use context pattern: `get_client(ctx)` → `NextcloudClient`
4. All operations are async using httpx
### Progressive Consent Architecture (ADR-004)
### Deployment Modes
**Important**: Progressive consent is a *mechanism* for granting access, not a feature flag. The architecture is always present in OAuth mode. Whether provisioning tools are available is controlled by `ENABLE_OFFLINE_ACCESS`.
The server supports three deployment modes, controlled by environment variables and docker compose profiles:
**What is Progressive Consent?**
- Dual OAuth flow architecture that separates client authentication (Flow 1) from resource provisioning (Flow 2)
- Flow 1: MCP client authenticates directly to IdP with resource scopes (notes:*, calendar:*, etc.)
- Token audience: "mcp-server"
- Client receives resource-scoped token for MCP session
- Flow 2: Server explicitly provisions Nextcloud access via separate login (only when `ENABLE_OFFLINE_ACCESS=true`)
- Server requests: openid, profile, email, offline_access
- Token audience: "nextcloud"
- Server receives refresh token for offline access
- Client never sees this token
- Provides clear separation between session tokens and offline access tokens
**1. Single-User** (profile: `single-user`)
- Set `NEXTCLOUD_USERNAME` + `NEXTCLOUD_PASSWORD` (app password)
- One shared Nextcloud identity for all MCP requests
- Stateless, no persistent storage needed
- Best for: personal instances, local development
**Modes:**
- **Pass-through mode** (`ENABLE_OFFLINE_ACCESS=false`, default):
- No Flow 2 provisioning
- Server uses client's token to access Nextcloud (pass-through)
- No provisioning tools available
- Suitable for stateless, client-driven operations
- **Offline access mode** (`ENABLE_OFFLINE_ACCESS=true`):
- Flow 2 provisioning available
- Server stores refresh tokens for background operations
- Provisioning tools available: `provision_nextcloud_access`, `check_logged_in`
- Suitable for background jobs and server-initiated operations
**2. Multi-User BasicAuth** (profile: `multi-user-basic`)
- Set `ENABLE_MULTI_USER_BASIC_AUTH=true`
- Each MCP client provides credentials via HTTP Authorization header
- Per-request client creation from extracted credentials
- Best for: internal deployments where users manage their own Nextcloud credentials
**When to use OAuth mode:**
- Multi-user deployments
- Background jobs requiring offline access (with `ENABLE_OFFLINE_ACCESS=true`)
- Enhanced security with separate authorization contexts
- Explicit user control over resource access
**When to use BasicAuth instead:**
- Simple single-user deployments
- Local development and testing
**Key features:**
- No scope escalation - client gets exactly what it requests
- User explicitly authorizes via `provision_nextcloud_access` tool
- Clear security boundaries between MCP session and Nextcloud access
**3. Login Flow v2** (profile: `login-flow`)
- Browser-based app password acquisition via Nextcloud's native Login Flow v2 API
- Per-user app passwords stored encrypted in SQLite
- Application-level scope enforcement (defense-in-depth)
- Works with any Nextcloud 16+ instance (no special apps required)
- Best for: production multi-user deployments, OAuth MCP integration
- See `docs/ADR-022-login-flow-v2.md` for architecture details
## MCP Response Patterns (CRITICAL)
@@ -483,7 +443,7 @@ async def nc_notes_semantic_search_answer(
### Use Existing Fixtures
See `tests/conftest.py` for 2888 lines of test infrastructure:
- `nc_mcp_client` - MCP client for tool/resource testing (uses `mcp` container)
- `nc_mcp_oauth_client` - MCP client for OAuth testing (uses `mcp-oauth` container)
- `nc_mcp_oauth_client` - MCP client for OAuth testing (uses `mcp-login-flow` container)
- `nc_client` - Direct NextcloudClient for setup/cleanup
- `temporary_note`, `temporary_addressbook`, `temporary_contact` - Auto-cleanup
@@ -518,7 +478,7 @@ async def test_notes_api_get_note(mocker):
OAuth tests use **Playwright browser automation** to complete flows programmatically.
**Test Environment**:
- Three MCP containers: `mcp` (single-user), `mcp-oauth` (Nextcloud OIDC), `mcp-keycloak` (external IdP)
- Three MCP containers: `mcp` (single-user), `mcp-login-flow` (Login Flow v2), `mcp-keycloak` (external IdP)
- OAuth tests require `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
- Playwright configuration: `--browser firefox --headed` for debugging
- Install browsers: `uv run playwright install firefox`
@@ -541,7 +501,7 @@ uv run pytest tests/server/oauth/test_oauth_core.py --browser firefox --headed -
**Setup**:
```bash
docker-compose up -d keycloak app mcp-keycloak
docker compose up -d keycloak app mcp-keycloak
curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
docker compose exec app php occ user_oidc:provider keycloak
```
@@ -557,7 +517,8 @@ docker compose exec app php occ user_oidc:provider keycloak
## Integration Testing with Docker
**Nextcloud**: `docker compose exec app php occ ...` for occ commands
**MariaDB**: `docker compose exec db mariadb -u [user] -p [password] [database]` for queries
**MariaDB**: Use `./scripts/dbquery.py` for queries (see Database Inspection above)
**SQLite**: Use `./scripts/sqlitequery.py` for MCP service databases
### Querying Nextcloud Application Logs
-52
View File
@@ -169,56 +169,6 @@ services:
profiles:
- multi-user-basic
mcp-oauth:
build: .
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
restart: always
depends_on:
app:
condition: service_healthy
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_RESOURCE_URI=http://localhost:8080 # ADR-005: Nextcloud resource identifier for audience validation
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- 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
# Refresh token storage (ADR-002 Tier 1)
- ENABLE_BACKGROUND_OPERATIONS=true
- TOKEN_ENCRYPTION_KEY=Qh60VwZQsM7CLtSMunzC0gIGPBT948S6VSawUkODtvU=
- TOKEN_STORAGE_DB=/app/data/tokens.db
# ADR-005: Multi-audience mode (default - ENABLE_TOKEN_EXCHANGE=false)
# Tokens must contain BOTH MCP and Nextcloud audiences
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
# Semantic search configuration (ADR-007, ADR-021)
- ENABLE_SEMANTIC_SEARCH=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
# Qdrant configuration - persistent local storage
- QDRANT_LOCATION=/app/data/qdrant
# Embedding provider for vector sync (use Simple provider as fallback)
# Ollama not available in CI/test environments
# - OLLAMA_BASE_URL=http://ollama:11434
# - OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# 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
profiles:
- oauth
keycloak:
image: quay.io/keycloak/keycloak:26.5.4@sha256:ae8efb0d218d8921334b03a2dbee7069a0b868240691c50a3ffc9f42fabba8b4
command:
@@ -381,8 +331,6 @@ services:
volumes:
nextcloud:
db:
oauth-client-storage:
oauth-tokens:
keycloak-tokens:
keycloak-oauth-storage:
login-flow-data:
+1 -1
View File
@@ -212,7 +212,7 @@ async def get_server_status(request: Request) -> JSONResponse:
# Map deployment mode to auth_mode for API response
# This helps clients (like Astrolabe) determine which auth flow to use
if mode == AuthMode.OAUTH_SINGLE_AUDIENCE or mode == AuthMode.OAUTH_TOKEN_EXCHANGE:
if mode == AuthMode.OAUTH_SINGLE_AUDIENCE:
auth_mode = "oauth"
elif mode == AuthMode.MULTI_USER_BASIC:
auth_mode = "multi_user_basic"
+9 -72
View File
@@ -63,7 +63,6 @@ from nextcloud_mcp_server.auth.browser_oauth_routes import (
oauth_logout,
)
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
from nextcloud_mcp_server.auth.oauth_routes import (
oauth_as_metadata,
oauth_authorize,
@@ -353,7 +352,7 @@ class OAuthAppContext:
nextcloud_host: str
token_verifier: object # UnifiedTokenVerifier (ADR-005 compliant)
refresh_token_storage: Optional["RefreshTokenStorage"] = None
oauth_client: Optional[object] = None # NextcloudOAuthClient or KeycloakOAuthClient
oauth_client: Optional[object] = None
oauth_provider: str = "nextcloud" # "nextcloud" or "keycloak"
server_client_id: Optional[str] = (
None # MCP server's OAuth client ID (static or DCR)
@@ -772,21 +771,11 @@ async def setup_oauth_config():
token_verifier = UnifiedTokenVerifier(settings)
# Log the mode
enable_token_exchange = (
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
logger.info(
"✓ Multi-audience mode enabled (ADR-005) - tokens must contain both MCP and Nextcloud audiences"
)
if enable_token_exchange:
logger.info(
"✓ Token Exchange mode enabled (ADR-005) - exchanging MCP tokens for Nextcloud tokens via RFC 8693"
)
logger.info(f" MCP audience: {client_id} or {mcp_server_url}")
logger.info(f" Nextcloud audience: {nextcloud_resource_uri}")
else:
logger.info(
"✓ Multi-audience mode enabled (ADR-005) - tokens must contain both MCP and Nextcloud audiences"
)
logger.info(f" Required MCP audience: {client_id} or {mcp_server_url}")
logger.info(f" Required Nextcloud audience: {nextcloud_resource_uri}")
logger.info(f" Required MCP audience: {client_id} or {mcp_server_url}")
logger.info(f" Required Nextcloud audience: {nextcloud_resource_uri}")
if introspection_uri:
logger.info("✓ Opaque token introspection enabled (RFC 7662)")
@@ -803,45 +792,7 @@ async def setup_oauth_config():
# that are separate from the real-time token exchange flow
logger.debug("Token broker available for future offline access features")
# 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
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
# Note: This redirect_uri is for OAuth client initialization, not used for actual redirects
# since this client is used for backend token operations (exchange, refresh)
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")
@@ -1049,10 +1000,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
logger.debug(f"Mode details:\n{get_mode_summary(mode)}")
# Derive helper variables for backward compatibility with existing code
oauth_enabled = mode in (
AuthMode.OAUTH_SINGLE_AUDIENCE,
AuthMode.OAUTH_TOKEN_EXCHANGE,
)
oauth_enabled = mode == AuthMode.OAUTH_SINGLE_AUDIENCE
# Log hybrid authentication status for multi-user BasicAuth with offline access
if mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access:
logger.info(
@@ -1202,7 +1150,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
raise
# Create MCP server based on detected mode
if mode in (AuthMode.OAUTH_SINGLE_AUDIENCE, AuthMode.OAUTH_TOKEN_EXCHANGE):
if mode == AuthMode.OAUTH_SINGLE_AUDIENCE:
logger.info("Configuring MCP server for OAuth mode")
# Asynchronously get the OAuth configuration
@@ -1320,18 +1268,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
logger.info("Skipping semantic search tools (VECTOR_SYNC_ENABLED not set)")
# Register OAuth provisioning tools (only when offline access is enabled)
# With token exchange enabled (external IdP), provisioning is not needed for MCP operations
enable_token_exchange = (
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
)
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
enable_offline_access_for_tools = settings.enable_offline_access
if oauth_enabled and enable_offline_access_for_tools and not enable_token_exchange:
if oauth_enabled and enable_offline_access_for_tools:
logger.info("Registering OAuth provisioning tools for offline access")
register_oauth_tools(mcp)
elif oauth_enabled and enable_token_exchange:
logger.info("Skipping provisioning tools registration (token exchange enabled)")
elif oauth_enabled and not enable_offline_access_for_tools:
logger.info(
"Skipping provisioning tools registration (offline access not enabled)"
@@ -1965,10 +1905,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Check authentication configuration
# Report the deployment mode, not just whether OAuth is enabled
# This helps clients (like Astrolabe) determine which auth flow to use
if (
mode == AuthMode.OAUTH_SINGLE_AUDIENCE
or mode == AuthMode.OAUTH_TOKEN_EXCHANGE
):
if mode == AuthMode.OAUTH_SINGLE_AUDIENCE:
checks["auth_mode"] = "oauth"
checks["auth_configured"] = "ok"
elif mode == AuthMode.MULTI_USER_BASIC:
+1 -140
View File
@@ -1,28 +1,17 @@
"""Helper functions for extracting OAuth context from MCP requests.
ADR-005 compliant implementation with token exchange caching.
ADR-005 compliant implementation for multi-audience token mode.
"""
import hashlib
import logging
import time
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
from ..client import NextcloudClient
from ..config import get_settings
from ..observability.metrics import (
oauth_token_cache_hits_total,
oauth_token_exchange_total,
)
from .token_exchange import exchange_token_for_audience
logger = logging.getLogger(__name__)
# Token exchange cache: token_hash -> (exchanged_token, expiry_timestamp)
_exchange_cache: dict[str, tuple[str, float]] = {}
def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
"""
@@ -79,131 +68,3 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
logger.error(f"Failed to extract OAuth context: {e}")
logger.error("This may indicate the server is not running in OAuth mode")
raise
async def get_session_client_from_context(
ctx: Context, base_url: str
) -> NextcloudClient:
"""
Create NextcloudClient using RFC 8693 token exchange with caching.
ADR-005 Mode 2: Exchange MCP token for Nextcloud token via RFC 8693.
This implements the token exchange pattern where:
1. Extract MCP token from context (validated by UnifiedTokenVerifier)
2. Check cache for existing exchanged token
3. If not cached or expired, exchange via RFC 8693
4. Cache the exchanged token to minimize exchange frequency
5. Create client with exchanged token
CRITICAL: This is where token exchange happens, NOT in the verifier.
The verifier already validated the MCP audience; now we exchange for Nextcloud.
Note: Nextcloud doesn't support OAuth scopes natively. Scopes are enforced
by the MCP server via @require_scopes decorator, not by the IdP. Therefore,
we don't pass scopes to the token exchange - the MCP server already validated
permissions before calling this function.
Args:
ctx: MCP request context containing session info
base_url: Nextcloud base URL
Returns:
NextcloudClient configured with ephemeral exchanged token
Raises:
AttributeError: If context doesn't contain expected OAuth session data
RuntimeError: If token exchange fails
"""
settings = get_settings()
try:
# Extract MCP token from context
if hasattr(ctx.request_context.request, "user") and hasattr(
ctx.request_context.request.user, "access_token"
):
access_token: AccessToken = ctx.request_context.request.user.access_token
mcp_token = access_token.token
username = access_token.resource # Username from UnifiedTokenVerifier
logger.debug(f"Retrieved MCP token for user: {username}")
else:
logger.error("No MCP token found in request context")
raise AttributeError("No access token found in OAuth request context")
if not username:
logger.error("No username found in access token resource field")
raise ValueError("Username not available in OAuth token context")
# Check cache for existing exchanged token
cache_key = hashlib.sha256(mcp_token.encode()).hexdigest()
if cache_key in _exchange_cache:
cached_token, expiry = _exchange_cache[cache_key]
if time.time() < expiry:
logger.debug(
f"Using cached exchanged token (expires in {expiry - time.time():.1f}s)"
)
oauth_token_cache_hits_total.labels(hit="true").inc()
return NextcloudClient.from_token(
base_url=base_url, token=cached_token, username=username
)
else:
logger.debug("Cached token expired, removing from cache")
del _exchange_cache[cache_key]
oauth_token_cache_hits_total.labels(hit="false").inc()
# Perform RFC 8693 token exchange
logger.info(f"Exchanging MCP token for Nextcloud API token (user: {username})")
try:
# Exchange for Nextcloud resource URI audience
exchanged_token, expires_in = await exchange_token_for_audience(
subject_token=mcp_token,
requested_audience=settings.nextcloud_resource_uri or "nextcloud",
requested_scopes=None, # Nextcloud doesn't support scopes
)
oauth_token_exchange_total.labels(status="success").inc()
logger.info(f"Token exchange successful. Token expires in {expires_in}s")
except Exception:
oauth_token_exchange_total.labels(status="error").inc()
raise
# Cache the exchanged token
# Use the minimum of exchange TTL and configured cache TTL
cache_ttl = min(expires_in, settings.token_exchange_cache_ttl)
_exchange_cache[cache_key] = (exchanged_token, time.time() + cache_ttl)
logger.debug(f"Cached exchanged token for {cache_ttl}s")
# Clean up expired cache entries
_cleanup_exchange_cache()
# Create client with exchanged token
return NextcloudClient.from_token(
base_url=base_url, token=exchanged_token, username=username
)
except AttributeError as e:
logger.error(f"Failed to extract OAuth context: {e}")
raise
except Exception as e:
logger.error(f"Token exchange failed: {e}")
raise RuntimeError(f"Token exchange required but failed: {e}") from e
def _cleanup_exchange_cache():
"""Remove expired entries from the token exchange cache."""
global _exchange_cache
now = time.time()
expired_keys = [k for k, (_, expiry) in _exchange_cache.items() if expiry <= now]
for key in expired_keys:
del _exchange_cache[key]
if expired_keys:
logger.debug(f"Cleaned up {len(expired_keys)} expired cache entries")
def clear_exchange_cache():
"""Clear the entire token exchange cache. Useful for testing."""
global _exchange_cache
_exchange_cache.clear()
logger.debug("Token exchange cache cleared")
-585
View File
@@ -1,585 +0,0 @@
"""
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 base64
import hashlib
import logging
import os
import secrets
from typing import Optional
from urllib.parse import urlencode, urlparse
import httpx
from ..http import nextcloud_httpx_client
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
# Note: This is for OAuth client initialization, not used for actual redirects
# since this client is used for backend token operations (exchange, refresh)
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 = nextcloud_httpx_client(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)
"""
# 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"]
@@ -15,7 +15,6 @@ from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.config import get_settings
logger = logging.getLogger(__name__)
@@ -65,14 +64,6 @@ def require_provisioning(func: Callable) -> Callable:
logger.debug("BasicAuth mode detected - skipping provisioning check")
return await func(*args, **kwargs)
# Check if we're in token exchange mode - if so, skip provisioning check
# In token exchange mode, tokens are exchanged per-request (no stored refresh tokens)
settings = get_settings()
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_token_exchange:
# Token exchange mode - per-request exchange, no provisioning needed
logger.debug("Token exchange mode detected - skipping provisioning check")
return await func(*args, **kwargs)
# Offline access mode - check if user has completed Flow 2 provisioning
# Get user_id from authorization token
user_id = None
+1 -51
View File
@@ -11,7 +11,7 @@ The Token Broker provides:
- Short-lived token caching (5-minute TTL)
- Master refresh token rotation
- Audience-specific token validation
- Session vs background token separation (RFC 8693)
- Background token management
"""
import logging
@@ -23,7 +23,6 @@ import httpx
import jwt
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
from ..http import nextcloud_httpx_client
@@ -219,55 +218,6 @@ class TokenBrokerService:
await self.cache.invalidate(user_id)
return None
async def get_session_token(
self,
flow1_token: str,
required_scopes: list[str],
requested_audience: str = "nextcloud",
) -> Optional[str]:
"""
Get ephemeral token for MCP session operations (on-demand).
This implements the correct Progressive Consent pattern where:
1. Client provides Flow 1 token (aud: "mcp-server")
2. Server exchanges it for ephemeral Nextcloud token
3. Token is NOT stored, only used for current operation
Key properties:
- On-demand generation during tool execution
- Ephemeral (not stored, discarded after use)
- Limited scopes (only what tool needs)
- Short-lived (5 minutes)
Args:
flow1_token: The MCP session token (aud: "mcp-server")
required_scopes: Minimal scopes needed for this operation
requested_audience: Target audience (usually "nextcloud")
Returns:
Ephemeral Nextcloud access token or None if exchange fails
"""
try:
# Perform RFC 8693 token exchange
delegated_token, expires_in = await exchange_token_for_delegation(
flow1_token=flow1_token,
requested_scopes=required_scopes,
requested_audience=requested_audience,
)
# NOTE: We intentionally do NOT cache session tokens
# They are ephemeral and should be discarded after use
logger.info(
f"Generated ephemeral session token with scopes: {required_scopes}, "
f"expires in {expires_in}s"
)
return delegated_token
except Exception as e:
logger.error(f"Failed to get session token: {e}")
return None
async def get_background_token(
self, user_id: str, required_scopes: list[str]
) -> Optional[str]:
-596
View File
@@ -1,596 +0,0 @@
"""RFC 8693 Token Exchange implementation for ADR-004 Progressive Consent.
This module implements the token exchange pattern to convert Flow 1 MCP tokens
(aud: "mcp-server") into ephemeral delegated Nextcloud tokens (aud: "nextcloud")
for session operations.
Key Properties:
- On-demand generation during tool execution
- Ephemeral tokens (NOT stored, discarded after use)
- Limited scopes (only what tool needs)
- Short-lived (5 minutes default)
"""
import logging
import time
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urljoin
import httpx
import jwt
from ..config import get_settings
from ..http import nextcloud_httpx_client
from .storage import RefreshTokenStorage
logger = logging.getLogger(__name__)
class TokenExchangeService:
"""Implements RFC 8693 OAuth 2.0 Token Exchange."""
# RFC 8693 Grant Type
TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
# RFC 8693 Token Type Identifiers
TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token"
TOKEN_TYPE_JWT = "urn:ietf:params:oauth:token-type:jwt"
TOKEN_TYPE_ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token"
def __init__(
self,
oidc_discovery_url: Optional[str] = None,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
nextcloud_host: Optional[str] = None,
):
"""Initialize token exchange service.
Args:
oidc_discovery_url: OIDC discovery endpoint URL
client_id: OAuth client ID for token exchange
client_secret: OAuth client secret
nextcloud_host: Nextcloud instance URL
"""
settings = get_settings()
self.oidc_discovery_url = oidc_discovery_url or settings.oidc_discovery_url
self.client_id = client_id or settings.oidc_client_id
self.client_secret = client_secret or settings.oidc_client_secret
self.nextcloud_host = nextcloud_host or settings.nextcloud_host
self._token_endpoint: Optional[str] = None
self._jwks_uri: Optional[str] = None
self._discovery_cache: Optional[Dict[str, Any]] = None
self._discovery_cache_time: float = 0
self._discovery_cache_ttl: float = 3600 # 1 hour
# Storage for Progressive Consent (refresh tokens) - only needed for delegation
# NOT needed for pure RFC 8693 exchange (MCP tools)
self.storage: Optional[RefreshTokenStorage] = None
# Create HTTP client
self.http_client = nextcloud_httpx_client(
timeout=30.0,
follow_redirects=True,
)
async def __aenter__(self):
"""Async context manager entry."""
if self.storage:
await self.storage.initialize()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.close()
async def close(self):
"""Close HTTP client and storage."""
await self.http_client.aclose()
# RefreshTokenStorage doesn't have a close method
async def _ensure_storage(self):
"""Lazily initialize storage for Progressive Consent operations.
Only needed for delegation operations that use refresh tokens.
NOT needed for pure RFC 8693 exchange (MCP tools).
"""
if self.storage is None:
self.storage = RefreshTokenStorage.from_env()
await self.storage.initialize()
async def _discover_endpoints(self) -> Dict[str, Any]:
"""Discover OIDC endpoints from discovery URL.
Returns:
Discovery document containing endpoint URLs
"""
# Check cache
if (
self._discovery_cache
and (time.time() - self._discovery_cache_time) < self._discovery_cache_ttl
):
return self._discovery_cache
if not self.oidc_discovery_url:
# Fallback to Nextcloud OIDC if no discovery URL
self.oidc_discovery_url = urljoin(
self.nextcloud_host, # type: ignore[arg-type]
"/.well-known/openid-configuration",
)
try:
response = await self.http_client.get(self.oidc_discovery_url)
response.raise_for_status()
self._discovery_cache = response.json()
self._discovery_cache_time = time.time()
# Cache frequently used endpoints
self._token_endpoint = self._discovery_cache.get("token_endpoint")
self._jwks_uri = self._discovery_cache.get("jwks_uri")
return self._discovery_cache
except Exception as e:
logger.error(f"Failed to discover OIDC endpoints: {e}")
raise
async def exchange_token_for_delegation(
self,
flow1_token: str,
requested_scopes: list[str],
requested_audience: str = "nextcloud",
) -> Tuple[str, int]:
"""Exchange Flow 1 MCP token for delegated Nextcloud token.
This implements RFC 8693 Token Exchange for on-behalf-of delegation.
Args:
flow1_token: The MCP session token (aud: "mcp-server")
requested_scopes: Scopes needed for this operation
requested_audience: Target audience (usually "nextcloud")
Returns:
Tuple of (delegated_token, expires_in)
Raises:
ValueError: If token validation fails
RuntimeError: If provisioning not completed or exchange fails
"""
# 1. Validate Flow 1 token audience
await self._validate_flow1_token(flow1_token)
# 2. Extract user ID from token
user_id = self._extract_user_id(flow1_token)
# 3. Check user has provisioned Nextcloud access (Flow 2)
if not await self._check_provisioning(user_id):
raise RuntimeError(
"Nextcloud access not provisioned. "
"User must complete Flow 2 provisioning first."
)
# 4. Get stored refresh token for user (from Flow 2)
refresh_token = await self._get_user_refresh_token(user_id)
if not refresh_token:
raise RuntimeError(
"No refresh token found. User must complete provisioning."
)
# 5. Perform token exchange with IdP
delegated_token, expires_in = await self._perform_token_exchange(
subject_token=flow1_token,
refresh_token=refresh_token,
requested_scopes=requested_scopes,
requested_audience=requested_audience,
)
# 6. Log the exchange for audit trail
logger.info(
f"Token exchange completed for user {user_id}: "
f"scopes={requested_scopes}, audience={requested_audience}, "
f"expires_in={expires_in}s"
)
return delegated_token, expires_in
async def exchange_token_for_audience(
self,
subject_token: str,
requested_audience: str = "nextcloud",
requested_scopes: list[str] | None = None,
) -> Tuple[str, int]:
"""
Pure RFC 8693 token exchange (no refresh tokens required).
This implements stateless per-request token exchange where:
1. Client token has aud: <client-id> (e.g., "nextcloud-mcp-server")
2. Exchange for token with aud: "nextcloud" (for API access)
3. NO refresh tokens or provisioning required
Use case: All MCP tool calls (request-time operations).
NOT for background jobs (which use refresh tokens separately).
Args:
subject_token: Token being exchanged (from MCP client)
requested_audience: Target audience (usually "nextcloud")
requested_scopes: Optional scopes (may not be supported by all IdPs)
Returns:
Tuple of (access_token, expires_in)
Raises:
ValueError: If token validation fails
RuntimeError: If exchange fails
"""
# 1. Validate subject token (accepts both "mcp-server" and client_id)
await self._validate_flow1_token(subject_token)
# 2. Extract user ID for logging
user_id = self._extract_user_id(subject_token)
# 3. Discover token endpoint
discovery = await self._discover_endpoints()
token_endpoint = discovery.get("token_endpoint")
if not token_endpoint:
raise RuntimeError("No token endpoint found in discovery")
# 4. Build pure RFC 8693 exchange request (subject_token ONLY)
data = {
"grant_type": self.TOKEN_EXCHANGE_GRANT,
"subject_token": subject_token,
"subject_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
"requested_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
"audience": requested_audience,
}
# Add scopes if provided (may not be supported by all providers)
if requested_scopes:
data["scope"] = " ".join(requested_scopes)
# Add client credentials
if self.client_id and self.client_secret:
data["client_id"] = self.client_id
data["client_secret"] = self.client_secret
try:
# Perform exchange
logger.debug(f"Exchanging token for audience={requested_audience}")
response = await self.http_client.post(
token_endpoint,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
result = response.json()
access_token = result.get("access_token")
expires_in = result.get("expires_in", 300)
if not access_token:
raise RuntimeError("No access token in exchange response")
logger.info(
f"Pure RFC 8693 token exchange successful for user {user_id}: "
f"audience={requested_audience}, expires_in={expires_in}s"
)
return access_token, expires_in
except httpx.HTTPStatusError as e:
logger.error(f"Token exchange failed: {e.response.text}")
raise RuntimeError(f"Token exchange failed: {e}")
except Exception as e:
logger.error(f"Token exchange error: {e}")
raise
async def _validate_flow1_token(self, token: str):
"""Validate that token has correct audience for MCP server.
Accepts either:
- "mcp-server" (Progressive Consent legacy)
- self.client_id (external IdP, e.g., "nextcloud-mcp-server")
Args:
token: JWT token to validate
Raises:
ValueError: If token is invalid or has wrong audience
"""
try:
# Decode without verification first to check audience
# In production, should verify signature against JWKS
payload = jwt.decode(token, options={"verify_signature": False})
# Check audience
audience = payload.get("aud", [])
if isinstance(audience, str):
audience = [audience]
# Accept either "mcp-server" (Progressive Consent) or client_id (external IdP)
valid_audiences = ["mcp-server"]
if self.client_id:
valid_audiences.append(self.client_id)
if not any(aud in audience for aud in valid_audiences):
raise ValueError(
f"Invalid token audience. Expected one of {valid_audiences}, got {audience}"
)
# Check expiration
exp = payload.get("exp", 0)
if exp < time.time():
raise ValueError("Token has expired")
except jwt.DecodeError as e:
raise ValueError(f"Invalid JWT token: {e}")
def _extract_user_id(self, token: str) -> str:
"""Extract user ID from JWT token.
Args:
token: JWT token
Returns:
User ID from token
"""
try:
payload = jwt.decode(token, options={"verify_signature": False})
# Try standard claims in order of preference
user_id = (
payload.get("sub")
or payload.get("preferred_username")
or payload.get("email")
or payload.get("name")
)
if not user_id:
raise ValueError("No user identifier in token")
return user_id
except jwt.DecodeError as e:
raise ValueError(f"Failed to extract user ID: {e}")
async def _check_provisioning(self, user_id: str) -> bool:
"""Check if user has completed Flow 2 provisioning.
Args:
user_id: User identifier
Returns:
True if provisioned, False otherwise
"""
await self._ensure_storage()
assert self.storage is not None # _ensure_storage() ensures this
token_data = await self.storage.get_refresh_token(user_id)
return token_data is not None
async def _get_user_refresh_token(self, user_id: str) -> Optional[str]:
"""Get stored refresh token for user from Flow 2 provisioning.
Args:
user_id: User identifier
Returns:
Refresh token if found, None otherwise
"""
await self._ensure_storage()
assert self.storage is not None # _ensure_storage() ensures this
token_data = await self.storage.get_refresh_token(user_id)
if token_data:
return token_data.get("refresh_token")
return None
async def _perform_token_exchange(
self,
subject_token: str,
refresh_token: str,
requested_scopes: list[str],
requested_audience: str,
) -> Tuple[str, int]:
"""Perform RFC 8693 token exchange with IdP.
Args:
subject_token: The token being exchanged (Flow 1 token)
refresh_token: User's stored refresh token for delegation
requested_scopes: Minimal scopes for this operation
requested_audience: Target audience
Returns:
Tuple of (access_token, expires_in)
"""
# Discover token endpoint
discovery = await self._discover_endpoints()
token_endpoint = discovery.get("token_endpoint")
if not token_endpoint:
raise RuntimeError("No token endpoint found in discovery")
# Build token exchange request per RFC 8693
data = {
# Token exchange grant type
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
# The token we're exchanging (Flow 1 MCP token)
"subject_token": subject_token,
"subject_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
# Use refresh token as actor token (proves we have delegation rights)
"actor_token": refresh_token,
"actor_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
# Requested token properties
"requested_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
"audience": requested_audience,
"scope": " ".join(requested_scopes),
}
# Add client credentials if configured
if self.client_id and self.client_secret:
data["client_id"] = self.client_id
data["client_secret"] = self.client_secret
try:
# Attempt RFC 8693 token exchange
response = await self.http_client.post(
token_endpoint,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if response.status_code == 400:
# Token exchange might not be supported, fall back to refresh grant
logger.info(
"Token exchange not supported, falling back to refresh grant"
)
return await self._fallback_refresh_grant(
refresh_token=refresh_token,
requested_scopes=requested_scopes,
token_endpoint=token_endpoint,
)
response.raise_for_status()
result = response.json()
access_token = result.get("access_token")
expires_in = result.get("expires_in", 300) # Default 5 minutes
if not access_token:
raise RuntimeError("No access token in exchange response")
return access_token, expires_in
except httpx.HTTPStatusError as e:
logger.error(f"Token exchange failed: {e.response.text}")
raise RuntimeError(f"Token exchange failed: {e}")
except Exception as e:
logger.error(f"Token exchange error: {e}")
raise
async def _fallback_refresh_grant(
self, refresh_token: str, requested_scopes: list[str], token_endpoint: str
) -> Tuple[str, int]:
"""Fallback to standard refresh token grant if token exchange not supported.
This is less secure than token exchange but provides compatibility.
Args:
refresh_token: User's stored refresh token
requested_scopes: Minimal scopes for this operation
token_endpoint: Token endpoint URL
Returns:
Tuple of (access_token, expires_in)
"""
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": " ".join(requested_scopes), # Request minimal scopes
}
# Add client credentials if configured
if self.client_id and self.client_secret:
data["client_id"] = self.client_id
data["client_secret"] = self.client_secret
try:
response = await self.http_client.post(
token_endpoint,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
result = response.json()
access_token = result.get("access_token")
expires_in = result.get("expires_in", 300) # Default 5 minutes
if not access_token:
raise RuntimeError("No access token in refresh response")
# Log that we're using fallback
logger.warning(
f"Using refresh grant fallback for token exchange. "
f"Scopes: {requested_scopes}"
)
return access_token, expires_in
except httpx.HTTPStatusError as e:
logger.error(f"Refresh grant failed: {e.response.text}")
raise RuntimeError(f"Refresh grant failed: {e}")
except Exception as e:
logger.error(f"Refresh grant error: {e}")
raise
# Singleton instance
_token_exchange_service: Optional[TokenExchangeService] = None
async def get_token_exchange_service() -> TokenExchangeService:
"""Get or create the singleton token exchange service.
Note: Storage is initialized lazily only when needed for delegation operations.
Pure RFC 8693 exchange (MCP tools) doesn't require storage.
Returns:
TokenExchangeService instance
"""
global _token_exchange_service
if _token_exchange_service is None:
_token_exchange_service = TokenExchangeService()
# Storage is initialized lazily via _ensure_storage() when needed
return _token_exchange_service
async def exchange_token_for_delegation(
flow1_token: str, requested_scopes: list[str], requested_audience: str = "nextcloud"
) -> Tuple[str, int]:
"""Convenience function to exchange tokens (Progressive Consent with refresh tokens).
NOTE: This is for background jobs only. For MCP tool calls, use exchange_token_for_audience().
Args:
flow1_token: The MCP session token (aud: "mcp-server")
requested_scopes: Scopes needed for this operation
requested_audience: Target audience (usually "nextcloud")
Returns:
Tuple of (delegated_token, expires_in)
"""
service = await get_token_exchange_service()
return await service.exchange_token_for_delegation(
flow1_token=flow1_token,
requested_scopes=requested_scopes,
requested_audience=requested_audience,
)
async def exchange_token_for_audience(
subject_token: str,
requested_audience: str = "nextcloud",
requested_scopes: list[str] | None = None,
) -> Tuple[str, int]:
"""Convenience function for pure RFC 8693 token exchange (no refresh tokens).
Use this for ALL MCP tool calls (request-time operations).
Args:
subject_token: Token being exchanged (from MCP client)
requested_audience: Target audience (usually "nextcloud")
requested_scopes: Optional scopes (may not be supported by all IdPs)
Returns:
Tuple of (access_token, expires_in)
"""
service = await get_token_exchange_service()
return await service.exchange_token_for_audience(
subject_token=subject_token,
requested_audience=requested_audience,
requested_scopes=requested_scopes,
)
@@ -60,7 +60,7 @@ class UnifiedTokenVerifier(TokenVerifier):
settings: Application settings containing OAuth configuration
"""
self.settings = settings
self.mode = "exchange" if settings.enable_token_exchange else "multi-audience"
self.mode = "multi-audience"
# Common components for all modes
self.http_client = nextcloud_httpx_client(timeout=10.0)
+1 -11
View File
@@ -138,8 +138,7 @@ class Settings:
# Deployment mode (ADR-021: explicit mode selection)
# Optional: If not set, mode is auto-detected from other settings
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
# oauth_token_exchange
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience
deployment_mode: str | None = None
# OAuth/OIDC settings
@@ -168,7 +167,6 @@ class Settings:
userinfo_uri: str | None = None
# Progressive Consent settings (always enabled - no flag needed)
enable_token_exchange: bool = False
enable_offline_access: bool = False
# Multi-user BasicAuth pass-through mode (ADR-019 interim solution)
@@ -179,9 +177,6 @@ class Settings:
# Login Flow v2 settings (ADR-022)
enable_login_flow: bool = False
# Token exchange cache settings
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
# Token and webhook storage settings
# TOKEN_ENCRYPTION_KEY: Optional - Only required for OAuth token storage operations.
# Webhook tracking works without encryption key.
@@ -507,9 +502,6 @@ def get_settings() -> Settings:
introspection_uri=os.getenv("INTROSPECTION_URI"),
userinfo_uri=os.getenv("USERINFO_URI"),
# Progressive Consent settings (always enabled)
enable_token_exchange=(
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
),
enable_offline_access=enable_background_operations, # Smart dependency resolution
# Multi-user BasicAuth pass-through mode
enable_multi_user_basic_auth=(
@@ -517,8 +509,6 @@ def get_settings() -> Settings:
),
# Login Flow v2 settings (ADR-022)
enable_login_flow=(os.getenv("ENABLE_LOGIN_FLOW", "false").lower() == "true"),
# Token exchange cache settings
token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
# Token and webhook storage settings (encryption key optional for webhook-only usage)
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
+4 -57
View File
@@ -26,7 +26,6 @@ class AuthMode(Enum):
SINGLE_USER_BASIC = "single_user_basic"
MULTI_USER_BASIC = "multi_user_basic"
OAUTH_SINGLE_AUDIENCE = "oauth_single"
OAUTH_TOKEN_EXCHANGE = "oauth_exchange"
@dataclass
@@ -66,7 +65,6 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
],
forbidden=[
"enable_multi_user_basic_auth",
"enable_token_exchange",
"oidc_client_id",
"oidc_client_secret",
],
@@ -100,7 +98,6 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
forbidden=[
"nextcloud_username",
"nextcloud_password",
"enable_token_exchange",
],
conditional={
"enable_offline_access": [
@@ -141,7 +138,6 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
forbidden=[
"nextcloud_username",
"nextcloud_password",
"enable_token_exchange",
"enable_multi_user_basic_auth",
],
conditional={
@@ -157,46 +153,6 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
"Tokens work for both MCP server and Nextcloud APIs (pass-through). "
"Uses Dynamic Client Registration if credentials not provided.",
),
AuthMode.OAUTH_TOKEN_EXCHANGE: ModeRequirements(
required=["nextcloud_host", "enable_token_exchange"],
optional=[
# OAuth credentials
"oidc_client_id",
"oidc_client_secret",
"oidc_discovery_url",
# Token exchange settings
"token_exchange_cache_ttl",
# Offline access
"enable_offline_access",
"token_encryption_key",
"token_storage_db",
# Vector sync
"vector_sync_enabled",
"qdrant_url",
"qdrant_location",
"ollama_base_url",
"ollama_embedding_model",
"openai_api_key",
"openai_embedding_model",
],
forbidden=[
"nextcloud_username",
"nextcloud_password",
"enable_multi_user_basic_auth",
],
conditional={
"enable_offline_access": [
"token_encryption_key",
"token_storage_db",
],
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
# enables background operations in multi-user modes. No explicit
# enable_offline_access setting required.
},
description="OAuth multi-user deployment with token exchange (RFC 8693). "
"MCP tokens are separate from Nextcloud tokens. "
"Server exchanges MCP token for Nextcloud token on each request.",
),
}
@@ -205,10 +161,9 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
Mode detection priority (ADR-021):
0. Explicit MCP_DEPLOYMENT_MODE (if set) - NEW in ADR-021
1. Token exchange (most specific OAuth mode)
2. Multi-user BasicAuth
3. Single-user BasicAuth
4. OAuth single-audience (default OAuth mode)
1. Multi-user BasicAuth
2. Single-user BasicAuth
3. OAuth single-audience (default OAuth mode)
Args:
settings: Application settings
@@ -231,7 +186,6 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
"single_user_basic": AuthMode.SINGLE_USER_BASIC,
"multi_user_basic": AuthMode.MULTI_USER_BASIC,
"oauth_single_audience": AuthMode.OAUTH_SINGLE_AUDIENCE,
"oauth_token_exchange": AuthMode.OAUTH_TOKEN_EXCHANGE,
}
if mode_str not in mode_map:
@@ -246,10 +200,6 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
return explicit_mode
# Auto-detection (existing behavior)
# Check for token exchange (most specific OAuth mode)
if settings.enable_token_exchange:
return AuthMode.OAUTH_TOKEN_EXCHANGE
# Check for multi-user BasicAuth
if settings.enable_multi_user_basic_auth:
return AuthMode.MULTI_USER_BASIC
@@ -351,10 +301,7 @@ def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]:
f"{settings.nextcloud_host}"
)
if mode in [
AuthMode.OAUTH_SINGLE_AUDIENCE,
AuthMode.OAUTH_TOKEN_EXCHANGE,
]:
if mode == AuthMode.OAUTH_SINGLE_AUDIENCE:
# If OAuth credentials not provided, DCR must be available
# (This is a runtime check, not a config check, so we just warn)
if not settings.oidc_client_id or not settings.oidc_client_secret:
+8 -29
View File
@@ -5,10 +5,7 @@ import logging
from httpx import BasicAuth
from mcp.server.fastmcp import Context
from nextcloud_mcp_server.auth.context_helper import (
get_client_from_context,
get_session_client_from_context,
)
from nextcloud_mcp_server.auth.context_helper import get_client_from_context
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
from nextcloud_mcp_server.auth.storage import get_shared_storage
from nextcloud_mcp_server.client import NextcloudClient
@@ -24,18 +21,9 @@ async def get_client(ctx: Context) -> NextcloudClient:
Supports the following deployment modes:
1. BasicAuth mode: Returns shared client from lifespan context
2. OAuth mode:
a. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default):
Token already contains both MCP and Nextcloud audiences - use directly
b. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
Exchange MCP token for Nextcloud token via RFC 8693
SECURITY: Token passthrough has been REMOVED. All OAuth modes validate
proper token audiences per MCP Security Best Practices specification.
Note: Nextcloud doesn't support OAuth scopes natively. Scopes are enforced
by the MCP server via @require_scopes decorator, not by the IdP.
2. Login Flow v2: OAuth for MCP session, app password for Nextcloud API
3. Multi-user BasicAuth: Credentials passed through from request headers
4. OAuth multi-audience: Token contains both MCP and Nextcloud audiences
This function automatically detects the authentication mode by checking
the type of the lifespan context.
@@ -74,20 +62,11 @@ async def get_client(ctx: Context) -> NextcloudClient:
if hasattr(lifespan_ctx, "client"):
return lifespan_ctx.client
# OAuth mode (has 'nextcloud_host' attribute)
# OAuth multi-audience mode (has 'nextcloud_host' attribute)
if hasattr(lifespan_ctx, "nextcloud_host"):
if settings.enable_token_exchange:
# Mode 2: Exchange MCP token for Nextcloud token
# Token was validated to have MCP audience in UnifiedTokenVerifier
# Now exchange it for Nextcloud audience
return await get_session_client_from_context(
ctx, lifespan_ctx.nextcloud_host
)
else:
# Mode 1: Multi-audience token - use directly
# Token was validated to have MCP audience in UnifiedTokenVerifier
# Nextcloud will independently validate its own audience when receiving API calls
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
# Token was validated to have MCP audience in UnifiedTokenVerifier
# Nextcloud will independently validate its own audience when receiving API calls
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
# Unknown context type
raise AttributeError(
@@ -125,12 +125,6 @@ oauth_token_validations_total = Counter(
["method", "result"], # method: introspect | jwt; result: valid | invalid | error
)
oauth_token_exchange_total = Counter(
"mcp_oauth_token_exchange_total",
"Total OAuth token exchange operations (RFC 8693)",
["status"], # status: success | error
)
oauth_token_cache_hits_total = Counter(
"mcp_oauth_token_cache_hits_total",
"Total OAuth token cache lookups",
@@ -232,7 +232,7 @@ def trace_oauth_operation(operation: str, details: dict[str, Any] | None = None)
pass
Args:
operation: OAuth operation name (e.g., "token.validate", "token.exchange")
operation: OAuth operation name (e.g., "token.validate", "token.refresh")
details: Optional operation details (sensitive data will be sanitized)
Returns:
-1
View File
@@ -72,7 +72,6 @@ log_level = "ERROR"
markers = [
"unit: Fast unit tests with mocked dependencies",
"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",
"login_flow: Login Flow v2 integration tests (ADR-022)",
-103
View File
@@ -1,103 +0,0 @@
"""Integration tests for OAuth authentication."""
import logging
import os
import pytest
from httpx import HTTPStatusError
from nextcloud_mcp_server.auth import BearerAuth
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
# OAuth Client Tests
async def test_oauth_client_capabilities(nc_oauth_client: NextcloudClient):
"""Test that OAuth client can fetch capabilities."""
capabilities = await nc_oauth_client.capabilities()
assert capabilities is not None
assert "ocs" in capabilities
logger.info(
f"OAuth client successfully fetched capabilities: {capabilities.get('ocs').get('meta')}"
)
async def test_oauth_client_notes_list(nc_oauth_client: NextcloudClient):
"""Test that OAuth client can list notes."""
notes = [note async for note in nc_oauth_client.notes.get_all_notes()]
assert isinstance(notes, list)
logger.info(f"OAuth client successfully listed {len(notes)} notes")
async def test_oauth_client_create_note(nc_oauth_client: NextcloudClient):
"""Test that OAuth client can create and delete a note."""
# Create note
note_title = "OAuth Test Note"
note_content = "This note was created with OAuth authentication"
created_note = await nc_oauth_client.notes.create_note(
title=note_title, content=note_content
)
assert created_note is not None
assert created_note.get("title") == note_title
note_id = created_note.get("id")
assert note_id is not None
logger.info(f"OAuth client successfully created note with ID: {note_id}")
# Clean up - delete the note
try:
await nc_oauth_client.notes.delete_note(note_id=note_id)
logger.info(f"OAuth client successfully deleted note {note_id}")
except Exception as e:
logger.error(f"Failed to clean up test note {note_id}: {e}")
raise
# OAuth Token Validation Tests
async def test_token_in_request_headers(
nc_oauth_client: NextcloudClient, playwright_oauth_token: str
):
"""Verify that bearer token is being used in requests."""
# The client should be using BearerAuth
assert nc_oauth_client._client.auth is not None
# Make a request and verify it works
capabilities = await nc_oauth_client.capabilities()
assert capabilities is not None
logger.info("OAuth bearer token is correctly included in requests")
async def test_invalid_token_fails():
"""Test that an invalid token results in authentication failure."""
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
pytest.skip("NEXTCLOUD_HOST not set")
# Create client with invalid token using BearerAuth
invalid_client = NextcloudClient(
base_url=nextcloud_host,
username="testuser",
auth=BearerAuth("invalid_token_12345"),
)
# Attempt to use a protected endpoint - should fail with 401
# Note: capabilities endpoint is public and doesn't require auth
with pytest.raises(HTTPStatusError) as exc_info:
_ = [note async for note in invalid_client.notes.get_all_notes()]
assert exc_info.value.response.status_code == 401
await invalid_client.close()
logger.info("Invalid OAuth token correctly rejected")
-32
View File
@@ -1,32 +0,0 @@
"""Integration tests for Playwright-based OAuth authentication."""
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def test_playwright_oauth_token_acquisition(playwright_oauth_token: str):
"""Test that Playwright can acquire an OAuth token automatically."""
assert playwright_oauth_token is not None
assert isinstance(playwright_oauth_token, str)
assert len(playwright_oauth_token) > 0
logger.info(
f"Successfully acquired OAuth token via Playwright: {playwright_oauth_token[:20]}..."
)
async def test_oauth_client_with_playwright_flow(nc_oauth_client):
"""Test that OAuth client created via Playwright flow can access Nextcloud APIs."""
# Test 1: Check capabilities
capabilities = await nc_oauth_client.capabilities()
assert capabilities is not None
logger.info("OAuth client (Playwright) successfully fetched capabilities")
# Test 2: List notes
notes = [note async for note in nc_oauth_client.notes.get_all_notes()]
assert isinstance(notes, list)
logger.info(f"OAuth client (Playwright) successfully listed {len(notes)} notes")
+31 -451
View File
@@ -1,5 +1,4 @@
import base64
import hashlib
import json
import logging
import os
@@ -21,6 +20,7 @@ from mcp import ClientSession
from mcp.client.session import RequestContext
from mcp.client.streamable_http import streamablehttp_client
from mcp.types import ElicitRequestParams, ElicitResult, ErrorData
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
from nextcloud_mcp_server.client import NextcloudClient
@@ -2005,33 +2005,37 @@ async def _handle_oauth_consent_screen(page, username: str = "user"):
f" ⊗ Scope checkbox {i + 1} disabled (required scope)"
)
# Click the Allow button to grant consent
# Check button exists first
allow_button_locator = page.locator('button:has-text("Allow")')
# Click the Allow button to grant consent with retry logic.
# Uses Playwright's native click (dispatches proper browser events that
# trigger Vue.js handlers) instead of JS btn.click() which can miss them.
allow_button = page.locator('button:has-text("Allow")')
if await allow_button_locator.count() > 0:
if await allow_button.count() > 0:
logger.info(f" Clicking Allow button to grant consent for {username}...")
# Use JavaScript click to handle consent buttons that may be outside viewport
# This is more reliable than Playwright's click which requires element visibility
logger.info(
" Using JavaScript click for consent (handles viewport issues)..."
)
await page.evaluate(
"""
const buttons = document.querySelectorAll('button');
for (const btn of buttons) {
if (btn.textContent.trim() === 'Allow') {
btn.click();
break;
}
}
"""
)
for attempt in range(3):
await allow_button.scroll_into_view_if_needed()
await allow_button.click()
try:
await page.wait_for_url(
lambda url: "/consent" not in url, timeout=10000
)
logger.info(f" Consent granted for {username}")
return True
except (TimeoutError, PlaywrightTimeoutError):
if attempt == 2:
screenshot_path = f"/tmp/consent_click_failed_{username}.png"
await page.screenshot(path=screenshot_path)
logger.error(
f" Consent click failed after 3 attempts for {username}, "
f"screenshot: {screenshot_path}"
)
raise
logger.warning(
f" Consent click attempt {attempt + 1} didn't navigate, retrying..."
)
await page.wait_for_load_state("networkidle", timeout=30000)
logger.info(f" Consent granted for {username}")
return True
raise RuntimeError("consent click retry loop exited unexpectedly")
else:
logger.error(f" Allow button not found for {username}")
return False
@@ -2047,6 +2051,7 @@ async def _get_oauth_token_with_scopes(
oauth_callback_server,
scopes: str,
resource: str | None = None,
mcp_server_base_url: str = "http://localhost:8004", # login-flow container port
) -> str:
"""
Helper function to obtain OAuth token with specific scopes.
@@ -2057,6 +2062,7 @@ async def _get_oauth_token_with_scopes(
oauth_callback_server: OAuth callback server fixture
scopes: Space-separated list of scopes (e.g., "openid profile email notes:read")
resource: Optional resource parameter (RFC 8707) for token audience
mcp_server_base_url: Base URL of the MCP server for resource metadata discovery
Returns:
OAuth access token string with requested scopes
@@ -2085,7 +2091,6 @@ async def _get_oauth_token_with_scopes(
# If no resource provided, fetch from MCP server metadata
if resource is None:
mcp_server_base_url = "http://localhost:8001"
try:
resource_metadata = await get_mcp_server_resource_metadata(
mcp_server_base_url
@@ -2862,433 +2867,8 @@ async def test_user_in_group(nc_client: NextcloudClient, test_user, test_group):
# ===========================================================================================
# 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
"""
# 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
# ========================================================================
# Astrolabe Dynamic Configuration Fixtures
# ========================================================================
# ===========================================================================================
@pytest.fixture(scope="session")
@@ -1,308 +0,0 @@
"""
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)"
)
@@ -1,222 +0,0 @@
"""
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}"
)
-4
View File
@@ -43,7 +43,6 @@ async def reset_all_singletons():
# Import all modules with singletons
import nextcloud_mcp_server.app as app_module
import nextcloud_mcp_server.auth.client_registry as client_registry_module
import nextcloud_mcp_server.auth.token_exchange as token_exchange_module
import nextcloud_mcp_server.embedding.service as embedding_module
import nextcloud_mcp_server.observability.tracing as tracing_module
import nextcloud_mcp_server.providers.registry as registry_module
@@ -63,7 +62,6 @@ async def reset_all_singletons():
),
"tracer": tracing_module._tracer,
"registry": client_registry_module._registry,
"token_exchange_service": token_exchange_module._token_exchange_service,
}
# Close any open memory streams before reset
@@ -89,7 +87,6 @@ async def reset_all_singletons():
app_module._vector_sync_state.scanner_wake_event = None
tracing_module._tracer = None
client_registry_module._registry = None
token_exchange_module._token_exchange_service = None
logger.debug("All singletons reset for test module")
@@ -115,4 +112,3 @@ async def reset_all_singletons():
) = originals["vector_sync_state"]
tracing_module._tracer = originals["tracer"]
client_registry_module._registry = originals["registry"]
token_exchange_module._token_exchange_service = originals["token_exchange_service"]
@@ -1,380 +0,0 @@
"""Integration tests for RFC 8693 Token Exchange with Keycloak.
These tests validate the complete token exchange flow:
1. Obtain client token from Keycloak
2. Exchange for Nextcloud-audience token via RFC 8693
3. Use exchanged token to access Nextcloud APIs
4. Verify CRUD operations work with exchanged tokens
Requirements:
- Keycloak running with nextcloud-mcp realm configured
- Nextcloud running with user_oidc app configured
- Standard Token Exchange enabled on both clients
- token-exchange-nextcloud scope configured
"""
from typing import Any
import httpx
import jwt
import pytest
@pytest.fixture
async def keycloak_base_url() -> str:
"""Keycloak base URL (external)."""
return "http://localhost:8888"
@pytest.fixture
async def keycloak_token_url(keycloak_base_url: str) -> str:
"""Keycloak token endpoint URL."""
return f"{keycloak_base_url}/realms/nextcloud-mcp/protocol/openid-connect/token"
@pytest.fixture
async def nextcloud_base_url() -> str:
"""Nextcloud base URL."""
return "http://localhost:8080"
@pytest.fixture
async def http_client() -> httpx.AsyncClient:
"""Async HTTP client for API requests."""
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
yield client
@pytest.fixture
async def keycloak_client_token(
http_client: httpx.AsyncClient, keycloak_token_url: str
) -> str:
"""Get client token from Keycloak using password grant.
Returns token with aud: ["nextcloud-mcp-server", "nextcloud"]
"""
response = await http_client.post(
keycloak_token_url,
data={
"grant_type": "password",
"client_id": "nextcloud-mcp-server",
"client_secret": "mcp-secret-change-in-production",
"username": "admin",
"password": "admin",
"scope": "openid profile email offline_access notes:read notes:write",
},
)
response.raise_for_status()
token_data = response.json()
return token_data["access_token"]
async def exchange_token(
http_client: httpx.AsyncClient,
token_url: str,
subject_token: str,
audience: str = "nextcloud",
) -> dict[str, Any]:
"""Exchange token using RFC 8693.
Args:
http_client: HTTP client
token_url: Token endpoint URL
subject_token: Token to exchange
audience: Target audience
Returns:
Token response with access_token and expires_in
"""
response = await http_client.post(
token_url,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": "nextcloud-mcp-server",
"client_secret": "mcp-secret-change-in-production",
"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",
"audience": audience,
},
)
response.raise_for_status()
return response.json()
def decode_token_claims(token: str) -> dict[str, Any]:
"""Decode JWT token claims without verification.
Args:
token: JWT token
Returns:
Token claims
"""
return jwt.decode(token, options={"verify_signature": False})
@pytest.mark.integration
@pytest.mark.keycloak
class TestKeycloakTokenExchange:
"""Test RFC 8693 Token Exchange with Keycloak."""
async def test_token_exchange_basic(
self,
http_client: httpx.AsyncClient,
keycloak_token_url: str,
keycloak_client_token: str,
):
"""Test basic token exchange flow."""
# Verify initial token has both audiences
initial_claims = decode_token_claims(keycloak_client_token)
assert "nextcloud-mcp-server" in initial_claims["aud"]
assert "nextcloud" in initial_claims["aud"]
assert initial_claims["azp"] == "nextcloud-mcp-server"
# Exchange for Nextcloud-audience token
exchange_response = await exchange_token(
http_client, keycloak_token_url, keycloak_client_token
)
assert "access_token" in exchange_response
assert "expires_in" in exchange_response
assert exchange_response["expires_in"] > 0
# Verify exchanged token has correct audience
exchanged_token = exchange_response["access_token"]
exchanged_claims = decode_token_claims(exchanged_token)
assert exchanged_claims["aud"] == "nextcloud"
assert exchanged_claims["azp"] == "nextcloud-mcp-server"
assert exchanged_claims["sub"] == initial_claims["sub"]
async def test_token_exchange_with_nextcloud_api(
self,
http_client: httpx.AsyncClient,
keycloak_token_url: str,
keycloak_client_token: str,
nextcloud_base_url: str,
):
"""Test exchanged token works with Nextcloud APIs."""
# Exchange token
exchange_response = await exchange_token(
http_client, keycloak_token_url, keycloak_client_token
)
nextcloud_token = exchange_response["access_token"]
# Call Nextcloud Capabilities API
response = await http_client.get(
f"{nextcloud_base_url}/ocs/v1.php/cloud/capabilities",
headers={
"Authorization": f"Bearer {nextcloud_token}",
"OCS-APIRequest": "true",
},
)
response.raise_for_status()
# Verify response contains OCS data
assert "ocs" in response.text.lower()
async def test_token_exchange_multiple_times(
self,
http_client: httpx.AsyncClient,
keycloak_token_url: str,
keycloak_client_token: str,
):
"""Test multiple exchanges from same client token (stateless)."""
# Exchange token three times
tokens = []
for _ in range(3):
exchange_response = await exchange_token(
http_client, keycloak_token_url, keycloak_client_token
)
tokens.append(exchange_response["access_token"])
# All exchanges should succeed
assert len(tokens) == 3
# Tokens should be different (fresh ephemeral tokens)
# Note: Keycloak may cache, so tokens might be identical
# The important thing is that all exchanges succeeded
async def test_token_exchange_crud_operations(
self,
http_client: httpx.AsyncClient,
keycloak_token_url: str,
keycloak_client_token: str,
nextcloud_base_url: str,
):
"""Test CRUD operations with exchanged tokens."""
notes_api = f"{nextcloud_base_url}/index.php/apps/notes/api/v1/notes"
# Step 1: Exchange token for CREATE
exchange_response = await exchange_token(
http_client, keycloak_token_url, keycloak_client_token
)
create_token = exchange_response["access_token"]
# Step 2: Create a test note
create_response = await http_client.post(
notes_api,
headers={"Authorization": f"Bearer {create_token}"},
json={
"title": "Token Exchange Test",
"content": "This note was created using an RFC 8693 exchanged token!",
"category": "Test",
},
)
create_response.raise_for_status()
note_data = create_response.json()
note_id = note_data["id"]
assert note_data["title"] == "Token Exchange Test"
assert note_data["category"] == "Test"
# Step 3: Exchange token again for READ (simulate new request)
exchange_response = await exchange_token(
http_client, keycloak_token_url, keycloak_client_token
)
read_token = exchange_response["access_token"]
# Step 4: Read the note back
read_response = await http_client.get(
f"{notes_api}/{note_id}",
headers={"Authorization": f"Bearer {read_token}"},
)
read_response.raise_for_status()
read_data = read_response.json()
assert read_data["id"] == note_id
assert read_data["title"] == "Token Exchange Test"
assert "RFC 8693 exchanged token" in read_data["content"]
# Step 5: Exchange token again for DELETE
exchange_response = await exchange_token(
http_client, keycloak_token_url, keycloak_client_token
)
delete_token = exchange_response["access_token"]
# Step 6: Delete the note
delete_response = await http_client.delete(
f"{notes_api}/{note_id}",
headers={"Authorization": f"Bearer {delete_token}"},
)
# Notes API returns the deleted note or empty array
assert delete_response.status_code in (200, 204)
async def test_token_claims_preservation(
self,
http_client: httpx.AsyncClient,
keycloak_token_url: str,
keycloak_client_token: str,
):
"""Test that important claims are preserved during exchange."""
initial_claims = decode_token_claims(keycloak_client_token)
# Exchange token
exchange_response = await exchange_token(
http_client, keycloak_token_url, keycloak_client_token
)
exchanged_token = exchange_response["access_token"]
exchanged_claims = decode_token_claims(exchanged_token)
# Subject (user ID) should be preserved
assert exchanged_claims["sub"] == initial_claims["sub"]
# Authorized party should show delegation
assert exchanged_claims["azp"] == "nextcloud-mcp-server"
# Audience should be filtered to target
assert exchanged_claims["aud"] == "nextcloud"
# Token should have expiration
assert "exp" in exchanged_claims
assert exchanged_claims["exp"] > 0
async def test_token_exchange_scope_configuration(
self, http_client: httpx.AsyncClient, keycloak_token_url: str
):
"""Test that token-exchange-nextcloud scope is configured as default.
Since token-exchange-nextcloud is a default scope for nextcloud-mcp-server,
all tokens should have the nextcloud audience available for exchange.
"""
# Get a token - should automatically include default scopes
response = await http_client.post(
keycloak_token_url,
data={
"grant_type": "password",
"client_id": "nextcloud-mcp-server",
"client_secret": "mcp-secret-change-in-production",
"username": "admin",
"password": "admin",
"scope": "openid profile email",
},
)
response.raise_for_status()
token = response.json()["access_token"]
# Verify token has nextcloud in aud (from default token-exchange-nextcloud scope)
claims = decode_token_claims(token)
assert "nextcloud" in claims.get("aud", [])
# Exchange should succeed
exchange_response = await http_client.post(
keycloak_token_url,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": "nextcloud-mcp-server",
"client_secret": "mcp-secret-change-in-production",
"subject_token": token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"audience": "nextcloud",
},
)
# Should succeed because token-exchange-nextcloud is a default scope
assert exchange_response.status_code == 200
exchanged_data = exchange_response.json()
assert "access_token" in exchanged_data
@pytest.mark.integration
@pytest.mark.keycloak
class TestTokenExchangeService:
"""Test the TokenExchangeService implementation."""
async def test_exchange_token_for_audience(
self, keycloak_client_token: str, keycloak_token_url: str
):
"""Test the exchange_token_for_audience function."""
from nextcloud_mcp_server.auth.token_exchange import (
TokenExchangeService,
)
# Create service
service = TokenExchangeService(
oidc_discovery_url="http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
client_id="nextcloud-mcp-server",
client_secret="mcp-secret-change-in-production",
)
try:
# Exchange token
exchanged_token, expires_in = await service.exchange_token_for_audience(
subject_token=keycloak_client_token,
requested_audience="nextcloud",
)
# Verify exchange succeeded
assert exchanged_token is not None
assert isinstance(exchanged_token, str)
assert expires_in > 0
# Verify token has correct claims
claims = decode_token_claims(exchanged_token)
assert claims["aud"] == "nextcloud"
assert claims["azp"] == "nextcloud-mcp-server"
finally:
await service.close()
-47
View File
@@ -1,47 +0,0 @@
# Manual OAuth Flow Testing
This directory contains manual test scripts for OAuth flows that require browser interaction.
## ADR-004 OAuth Hybrid Flow Test
The `test_adr004_oauth_flow.py` script tests the complete OAuth flow described in ADR-004.
### Prerequisites
1. **Install Playwright browsers:**
```bash
uv run playwright install firefox
```
2. **Start MCP server with OAuth enabled:**
For Nextcloud OIDC:
```bash
export ENABLE_OFFLINE_ACCESS=true
export TOKEN_ENCRYPTION_KEY=$(uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
docker-compose up --build -d mcp-oauth
```
For Keycloak:
```bash
export ENABLE_OFFLINE_ACCESS=true
export TOKEN_ENCRYPTION_KEY=$(uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
docker-compose up --build -d mcp-keycloak
```
### Running the Test
**Test with Nextcloud OIDC:**
```bash
uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud
```
**Test with Keycloak:**
```bash
uv run python tests/manual/test_adr004_oauth_flow.py --provider keycloak
```
**Headless mode:**
```bash
uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud --headless
```
-203
View File
@@ -1,203 +0,0 @@
# ADR-004 OAuth Flow Testing Instructions
## Automated Integration Test (Recommended)
The ADR-004 Hybrid Flow is now fully tested via automated integration tests using Playwright:
```bash
# Run all ADR-004 tests
uv run pytest tests/server/oauth/test_adr004_hybrid_flow.py --browser firefox -v
# Run specific test
uv run pytest tests/server/oauth/test_adr004_hybrid_flow.py::test_adr004_hybrid_flow_tool_execution --browser firefox -v
```
These tests verify:
- ✅ PKCE code challenge/verifier flow
- ✅ MCP server intercepts OAuth callback
- ✅ Master refresh token storage
- ✅ Client receives MCP access token
- ✅ MCP session establishment with hybrid flow token
- ✅ Tool execution using stored refresh tokens
- ✅ Multiple operations without re-authentication
## Manual Test (Legacy)
For manual testing or debugging, you can use the standalone test script:
```bash
# Make sure port 8765 is available
lsof -ti:8765 | xargs kill -9 2>/dev/null
# Run the test
uv run python tests/manual/test_adr004_manual.py --provider nextcloud
```
## Expected Flow
### 1. Test Script Starts
```
======================================================================
ADR-004 MANUAL OAUTH FLOW TEST
======================================================================
Provider: nextcloud
MCP Server: http://localhost:8001
Nextcloud: http://localhost:8080
======================================================================
✓ Generated PKCE challenge: gxQLsYDJ...
✓ Started callback server at http://localhost:8765/callback
```
### 2. Open OAuth URL in Browser
The script will print:
```
======================================================================
STEP 1: AUTHORIZE THE MCP SERVER
======================================================================
📋 Open this URL in your browser:
http://localhost:8001/oauth/authorize?response_type=code&...
📌 What will happen:
1. You'll be redirected to Nextcloud/Keycloak login
2. Login with username: admin, password: admin
3. You'll see a consent screen asking to authorize the MCP server
4. Click 'Authorize' or 'Allow'
5. You'll be redirected to localhost:8765/callback
6. The authorization code will appear in the terminal
```
### 3. Browser Flow
1. **Nextcloud Login** - You see the Nextcloud login page
2. **Enter Credentials** - admin/admin
3. **Consent Screen** - "Authorize Nextcloud MCP Server (jwt) to access your account?"
4. **Click Authorize**
5. **Redirect Chain**:
- Nextcloud redirects to: `http://localhost:8001/oauth/callback?code=...`
- MCP server processes the code
- MCP server redirects to: `http://localhost:8765/callback?code=mcp-code-...&state=...`
- Browser reaches the test script's callback server
- You see: "✓ Authorization Successful - You can close this window"
### 4. Test Script Continues
```
✓ Received authorization code!
Code: mcp-code-xyz...
✓ State parameter verified (CSRF protection)
======================================================================
STEP 2: EXCHANGE CODE FOR ACCESS TOKEN
======================================================================
✓ Successfully received access token
Token: eyJhbGciOiJSUzI1Ni...
Type: Bearer
Expires: 3600s
======================================================================
STEP 3: CALL MCP TOOL WITH ACCESS TOKEN
======================================================================
✓ MCP tool call succeeded!
Result: {...}
======================================================================
🎉 ADR-004 OAUTH FLOW TEST - SUCCESS
======================================================================
```
## Troubleshooting
### Browser Gets Stuck at "localhost:8765 refused to connect"
**Problem**: The callback server on port 8765 isn't accessible.
**Solutions**:
1. Check firewall isn't blocking port 8765
2. Verify the test script is still running
3. Check another process isn't using port 8765:
```bash
lsof -ti:8765
```
### Browser Shows "localhost:8765 - ERR_CONNECTION_REFUSED"
**Problem**: The callback server stopped or never started.
**Solution**:
1. Check the test script output - it should say "✓ Started callback server"
2. Restart the test script
3. Manually test the callback server:
```bash
curl http://localhost:8765/callback?code=test&state=test
```
Should return HTML page with "Authorization Successful"
### "Session not found or expired" Error
**Problem**: Took too long between steps (>10 minutes).
**Solution**: Restart the test - sessions expire after 10 minutes.
### Client ID is None
**Problem**: OAuth client credentials not loaded.
**Solution**: Rebuild the MCP server:
```bash
docker-compose up --build -d mcp-oauth
```
### Nextcloud Shows "Invalid redirect_uri"
**Problem**: The redirect URI isn't registered for the OAuth client.
**Solution**: Check registered URIs:
```bash
docker compose exec db mariadb -u root -ppassword nextcloud -e \
"SELECT c.client_identifier, r.redirect_uri FROM oc_oidc_clients c \
LEFT JOIN oc_oidc_redirect_uris r ON c.id = r.client_id \
WHERE c.name LIKE '%MCP%';"
```
Should show: `http://localhost:8001/oauth/callback`
## Manual Test Without Script
If the automated test doesn't work, you can test manually:
1. **Start callback server manually**:
```bash
python3 -m http.server 8765
```
2. **Open OAuth URL in browser** (get from test script output or build manually):
```
http://localhost:8001/oauth/authorize?response_type=code&client_id=test-mcp-client&redirect_uri=http://localhost:8765/callback&scope=openid+profile+email+offline_access&state=TEST&code_challenge=CHALLENGE&code_challenge_method=S256
```
3. **Complete login** at Nextcloud
4. **Browser should redirect** to `http://localhost:8765/callback?code=mcp-code-...&state=TEST`
5. **Copy the code** from the URL and exchange it:
```bash
curl -X POST http://localhost:8001/oauth/token \
-d "grant_type=authorization_code" \
-d "code=<MCP_CODE_HERE>" \
-d "code_verifier=<VERIFIER_HERE>" \
-d "redirect_uri=http://localhost:8765/callback" \
-d "client_id=test-mcp-client"
```
## Expected Database State After Success
```bash
# Check refresh token was stored
docker compose exec mcp-oauth sh -c \
"sqlite3 /app/data/tokens.db 'SELECT user_id, created_at FROM refresh_tokens;'"
```
Should show an entry for the authenticated user.
-195
View File
@@ -1,195 +0,0 @@
"""
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)
-319
View File
@@ -1,319 +0,0 @@
#!/usr/bin/env python3
"""
ADR-004 Manual OAuth Flow Test
This is a simplified version that doesn't use Playwright automation.
Instead, it prints URLs and waits for manual browser interaction.
Usage:
uv run python tests/manual/test_adr004_manual.py --provider nextcloud
"""
import argparse
import asyncio
import hashlib
import logging
import secrets
from base64 import urlsafe_b64encode
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
class CallbackHandler(BaseHTTPRequestHandler):
"""Handles OAuth callback redirect to localhost"""
authorization_code = None
state = None
def do_GET(self):
"""Handle GET request with authorization code"""
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
# Ignore favicon requests
if parsed.path == "/favicon.ico":
self.send_response(200)
self.send_header("Content-type", "image/x-icon")
self.end_headers()
return
CallbackHandler.authorization_code = params.get("code", [None])[0]
CallbackHandler.state = params.get("state", [None])[0]
# Send success page
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
code_display = (
CallbackHandler.authorization_code[:50] + "..."
if CallbackHandler.authorization_code
else "No code received"
)
html = """
<html>
<head><title>Authorization Success</title></head>
<body>
<h1 style="color: green;">✓ Authorization Successful</h1>
<p>Authorization code received. You can close this window and return to the terminal.</p>
<code style="background: #f0f0f0; padding: 10px; display: block; margin: 10px 0;">
{}
</code>
</body>
</html>
""".format(code_display)
self.wfile.write(html.encode())
def log_message(self, format, *args):
"""Log HTTP requests"""
logger.info(f"Callback server: {format % args}")
def generate_pkce_challenge():
"""Generate PKCE code verifier and challenge"""
code_verifier = secrets.token_urlsafe(32)
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = urlsafe_b64encode(digest).decode().rstrip("=")
return code_verifier, code_challenge
async def test_oauth_manual(
provider: str,
mcp_server_url: str,
nextcloud_host: str,
):
"""
Manual OAuth flow test - prints URLs for manual browser interaction.
"""
print("\n" + "=" * 70)
print("ADR-004 MANUAL OAUTH FLOW TEST")
print("=" * 70)
print(f"Provider: {provider}")
print(f"MCP Server: {mcp_server_url}")
print(f"Nextcloud: {nextcloud_host}")
print("=" * 70 + "\n")
# Generate PKCE challenge
code_verifier, code_challenge = generate_pkce_challenge()
logger.info(f"✓ Generated PKCE challenge: {code_challenge[:16]}...")
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
# Start local HTTP server for OAuth callback
callback_port = 8765
redirect_uri = f"http://localhost:{callback_port}/callback"
server = HTTPServer(("localhost", callback_port), CallbackHandler)
server_thread = Thread(target=server.serve_forever, daemon=True)
server_thread.start()
logger.info(f"✓ Started callback server at {redirect_uri}")
try:
# Build authorization URL
auth_params = {
"response_type": "code",
"client_id": "test-mcp-client",
"redirect_uri": redirect_uri,
"scope": "openid profile email offline_access notes:read notes:write",
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
auth_url = f"{mcp_server_url}/oauth/authorize?{urlencode(auth_params)}"
print("\n" + "=" * 70)
print("STEP 1: AUTHORIZE THE MCP SERVER")
print("=" * 70)
print("\n📋 Open this URL in your browser:\n")
print(f" {auth_url}")
print("\n📌 What will happen:")
print(" 1. You'll be redirected to Nextcloud/Keycloak login")
print(" 2. Login with username: admin, password: admin")
print(" 3. You'll see a consent screen asking to authorize the MCP server")
print(" 4. Click 'Authorize' or 'Allow'")
print(" 5. You'll be redirected to localhost:8765/callback")
print(" 6. The authorization code will appear in the terminal\n")
print("=" * 70)
print("\n⏳ Waiting for authorization... (timeout: 5 minutes)\n")
# Wait for authorization code (with timeout)
timeout = 300 # 5 minutes
elapsed = 0
while not CallbackHandler.authorization_code and elapsed < timeout:
await asyncio.sleep(1)
elapsed += 1
if not CallbackHandler.authorization_code:
raise RuntimeError("Timeout waiting for authorization code")
authorization_code = CallbackHandler.authorization_code
returned_state = CallbackHandler.state
print("\n✓ Received authorization code!")
logger.info(f"Code: {authorization_code[:16]}...")
# Verify state
if returned_state != state:
raise RuntimeError(
f"State mismatch! Expected {state}, got {returned_state}"
)
logger.info("✓ State parameter verified (CSRF protection)")
# Exchange authorization code for access token
print("\n" + "=" * 70)
print("STEP 2: EXCHANGE CODE FOR ACCESS TOKEN")
print("=" * 70)
async with httpx.AsyncClient() as client:
token_response = await client.post(
f"{mcp_server_url}/oauth/token",
data={
"grant_type": "authorization_code",
"code": authorization_code,
"code_verifier": code_verifier,
"redirect_uri": redirect_uri,
"client_id": "test-mcp-client",
},
timeout=30.0,
)
if token_response.status_code != 200:
print(f"\n❌ Token exchange failed: {token_response.status_code}")
print(f"Response: {token_response.text}")
raise RuntimeError("Token exchange failed")
token_data = token_response.json()
access_token = token_data["access_token"]
print("\n✓ Successfully received access token")
print(f" Token: {access_token[:30]}...")
print(f" Type: {token_data.get('token_type', 'Bearer')}")
print(f" Expires: {token_data.get('expires_in', 'unknown')}s")
# Test MCP tool call
print("\n" + "=" * 70)
print("STEP 3: CALL MCP TOOL WITH ACCESS TOKEN")
print("=" * 70)
async with httpx.AsyncClient() as client:
mcp_request = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "nc_notes_search_notes",
"arguments": {"query": "test"},
},
}
mcp_response = await client.post(
f"{mcp_server_url}/mcp",
json=mcp_request,
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
timeout=30.0,
)
if mcp_response.status_code != 200:
print(f"\n❌ MCP tool call failed: {mcp_response.status_code}")
print(f"Response: {mcp_response.text}")
raise RuntimeError("MCP tool call failed")
mcp_result = mcp_response.json()
if "error" in mcp_result:
print(f"\n❌ MCP tool returned error: {mcp_result['error']}")
raise RuntimeError(f"MCP tool error: {mcp_result['error']}")
print("\n✓ MCP tool call succeeded!")
print(f" Result: {mcp_result.get('result', {})}")
# Summary
print("\n" + "=" * 70)
print("🎉 ADR-004 OAUTH FLOW TEST - SUCCESS")
print("=" * 70)
print(f"Provider: {provider}")
print(f"MCP Server: {mcp_server_url}")
print(f"Nextcloud: {nextcloud_host}")
print("")
print("✓ User consented to MCP server access")
print("✓ User consented to offline_access (refresh tokens)")
print("✓ MCP server stored master refresh token")
print("✓ Client received MCP access token via PKCE")
print("✓ MCP tool call succeeded")
print("✓ MCP server exchanged tokens in background")
print("✓ Nextcloud data fetched successfully")
print("=" * 70 + "\n")
return {"success": True}
finally:
server.shutdown()
logger.info("Stopped callback server")
async def main():
parser = argparse.ArgumentParser(
description="Manual test for ADR-004 OAuth Hybrid Flow"
)
parser.add_argument(
"--provider",
choices=["nextcloud", "keycloak"],
required=True,
help="OAuth provider to test",
)
parser.add_argument(
"--mcp-server-url",
default="http://localhost:8001",
help="MCP server URL (default: http://localhost:8001)",
)
parser.add_argument(
"--nextcloud-host",
default="http://localhost:8080",
help="Nextcloud host URL (default: http://localhost:8080)",
)
args = parser.parse_args()
try:
result = await test_oauth_manual(
provider=args.provider,
mcp_server_url=args.mcp_server_url,
nextcloud_host=args.nextcloud_host,
)
return 0 if result["success"] else 1
except KeyboardInterrupt:
print("\n\n⚠️ Test interrupted by user")
return 1
except Exception as e:
logger.error(f"OAuth flow test failed: {e}", exc_info=True)
print("\n" + "=" * 70)
print("❌ ADR-004 OAUTH FLOW TEST - FAILED")
print("=" * 70)
print(f"Error: {e}")
print("=" * 70)
return 1
if __name__ == "__main__":
exit_code = asyncio.run(main())
exit(exit_code)
-375
View File
@@ -1,375 +0,0 @@
#!/usr/bin/env python3
"""
ADR-004 OAuth Flow Test Script
Tests the complete Hybrid Flow implementation:
1. User initiates OAuth at MCP server /oauth/authorize
2. User consents to MCP server access (IdP)
3. User consents to MCP server accessing Nextcloud (IdP/Nextcloud)
4. MCP server receives master refresh token
5. Client receives MCP access token
6. Client calls MCP tool
7. MCP server exchanges master refresh token for Nextcloud access token
8. MCP server fetches data from Nextcloud on behalf of user
Usage:
# Test with Nextcloud OIDC app
uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud
# Test with Keycloak
uv run python tests/manual/test_adr004_oauth_flow.py --provider keycloak
Requirements:
- MCP server running with OAuth enabled
- System web browser
"""
import argparse
import asyncio
import hashlib
import logging
import secrets
import webbrowser
from base64 import urlsafe_b64encode
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
class CallbackHandler(BaseHTTPRequestHandler):
"""Handles OAuth callback redirect to localhost"""
authorization_code = None
state = None
def do_GET(self):
"""Handle GET request with authorization code"""
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
# Ignore favicon requests
if parsed.path == "/favicon.ico":
self.send_response(200)
self.send_header("Content-type", "image/x-icon")
self.end_headers()
return
CallbackHandler.authorization_code = params.get("code", [None])[0]
CallbackHandler.state = params.get("state", [None])[0]
# Send success page
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
code_display = (
CallbackHandler.authorization_code[:50] + "..."
if CallbackHandler.authorization_code
else "No code received"
)
html = """
<html>
<head><title>Authorization Success</title></head>
<body>
<h1 style="color: green;">✓ Authorization Successful</h1>
<p>Authorization code received. You can close this window and return to the terminal.</p>
<code style="background: #f0f0f0; padding: 10px; display: block; margin: 10px 0;">
{}
</code>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>
""".format(code_display)
self.wfile.write(html.encode())
def log_message(self, format, *args):
"""Log HTTP requests"""
logger.info(f"Callback: {format % args}")
def generate_pkce_challenge():
"""Generate PKCE code verifier and challenge"""
code_verifier = secrets.token_urlsafe(32)
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = urlsafe_b64encode(digest).decode().rstrip("=")
return code_verifier, code_challenge
# Note: Playwright automation functions removed - using system browser instead
async def test_oauth_flow(
provider: str,
mcp_server_url: str,
nextcloud_host: str,
username: str,
password: str,
):
"""
Test complete ADR-004 OAuth flow using system browser.
Args:
provider: "nextcloud" or "keycloak"
mcp_server_url: MCP server URL (e.g., http://localhost:8001)
nextcloud_host: Nextcloud instance URL
username: Test user username (for documentation)
password: Test user password (for documentation)
"""
logger.info(f"Starting ADR-004 OAuth flow test with provider: {provider}")
logger.info(f"MCP Server: {mcp_server_url}")
logger.info(f"Nextcloud Host: {nextcloud_host}")
# Generate PKCE challenge
code_verifier, code_challenge = generate_pkce_challenge()
logger.info(f"✓ Generated PKCE challenge: {code_challenge[:16]}...")
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
# Start local HTTP server for OAuth callback
callback_port = 8765
redirect_uri = f"http://localhost:{callback_port}/callback"
server = HTTPServer(("localhost", callback_port), CallbackHandler)
server_thread = Thread(target=server.serve_forever, daemon=True)
server_thread.start()
logger.info(f"✓ Started callback server at {redirect_uri}")
try:
# Step 1: Build authorization URL
auth_params = {
"response_type": "code",
"client_id": "test-mcp-client",
"redirect_uri": redirect_uri,
"scope": "openid profile email offline_access notes:read notes:write",
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
auth_url = f"{mcp_server_url}/oauth/authorize?{urlencode(auth_params)}"
print("\n" + "=" * 70)
print("STEP 1: AUTHORIZE IN BROWSER")
print("=" * 70)
print(f"\n📋 Opening browser to: {auth_url[:80]}...")
print(f"\n📌 Login with: {username} / {password}")
print("📌 Then authorize the MCP server")
print("=" * 70 + "\n")
# Step 2: Open system browser
logger.info("Opening system browser for OAuth flow...")
webbrowser.open(auth_url)
logger.info("⏳ Waiting for authorization callback (timeout: 5 minutes)...")
# Wait for callback
timeout = 300 # 5 minutes
elapsed = 0
while not CallbackHandler.authorization_code and elapsed < timeout:
await asyncio.sleep(1)
elapsed += 1
if not CallbackHandler.authorization_code:
raise RuntimeError("Timeout waiting for authorization code")
# Step 3: Verify we received authorization code
authorization_code = CallbackHandler.authorization_code
returned_state = CallbackHandler.state
if not authorization_code:
raise RuntimeError("Failed to receive authorization code from callback")
logger.info(f"✓ Received MCP authorization code: {authorization_code[:16]}...")
# Verify state matches (CSRF protection)
if returned_state != state:
raise RuntimeError(
f"State mismatch! Expected {state}, got {returned_state}"
)
logger.info("✓ State parameter verified (CSRF protection)")
# Step 4: Exchange authorization code for access token
logger.info("Exchanging authorization code for access token...")
async with httpx.AsyncClient() as client:
token_response = await client.post(
f"{mcp_server_url}/oauth/token",
data={
"grant_type": "authorization_code",
"code": authorization_code,
"code_verifier": code_verifier,
"redirect_uri": redirect_uri,
"client_id": "test-mcp-client",
},
)
if token_response.status_code != 200:
logger.error(f"Token exchange failed: {token_response.status_code}")
logger.error(f"Response: {token_response.text}")
raise RuntimeError(
f"Token exchange failed: {token_response.status_code}"
)
token_data = token_response.json()
access_token = token_data["access_token"]
logger.info("✓ Successfully received access token")
logger.info(f" Token: {access_token[:20]}...")
logger.info(f" Type: {token_data.get('token_type', 'Bearer')}")
logger.info(f" Expires in: {token_data.get('expires_in', 'unknown')}s")
# Step 5: Use access token to call MCP tool
logger.info("Testing MCP tool call with access token...")
async with httpx.AsyncClient() as client:
# Call MCP server to list notes (this will trigger token exchange in background)
mcp_request = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "nc_notes_search_notes",
"arguments": {"query": "test"},
},
}
mcp_response = await client.post(
f"{mcp_server_url}/mcp",
json=mcp_request,
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
timeout=30.0,
)
if mcp_response.status_code != 200:
logger.error(f"MCP tool call failed: {mcp_response.status_code}")
logger.error(f"Response: {mcp_response.text}")
raise RuntimeError(f"MCP tool call failed: {mcp_response.status_code}")
mcp_result = mcp_response.json()
if "error" in mcp_result:
logger.error(f"MCP tool returned error: {mcp_result['error']}")
raise RuntimeError(f"MCP tool error: {mcp_result['error']}")
logger.info("✓ MCP tool call succeeded!")
logger.info(f" Result: {mcp_result.get('result', {})}")
# Step 6: Verify refresh token storage
logger.info("Verifying refresh token storage...")
# Check if refresh token was stored (requires database access)
# This would require accessing the SQLite database directly
logger.info("✓ OAuth flow completed successfully!")
# Summary
print("\n" + "=" * 70)
print("ADR-004 OAUTH FLOW TEST - SUCCESS")
print("=" * 70)
print(f"Provider: {provider}")
print(f"MCP Server: {mcp_server_url}")
print(f"Nextcloud: {nextcloud_host}")
print(f"User: {username}")
print("")
print("✓ User consented to MCP server access")
print("✓ User consented to offline_access (refresh tokens)")
print("✓ MCP server stored master refresh token")
print("✓ Client received MCP access token")
print("✓ MCP tool call succeeded")
print("✓ MCP server exchanged tokens in background")
print("✓ Nextcloud data fetched successfully")
print("=" * 70)
return {
"success": True,
"access_token": access_token,
"provider": provider,
}
finally:
server.shutdown()
logger.info("Stopped callback server")
async def main():
parser = argparse.ArgumentParser(
description="Test ADR-004 OAuth Hybrid Flow",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Test with Nextcloud OIDC
uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud
# Test with Keycloak
uv run python tests/manual/test_adr004_oauth_flow.py --provider keycloak
# Headless mode
uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud --headless
""",
)
parser.add_argument(
"--provider",
choices=["nextcloud", "keycloak"],
required=True,
help="OAuth provider to test (nextcloud or keycloak)",
)
parser.add_argument(
"--mcp-server-url",
default="http://localhost:8001",
help="MCP server URL (default: http://localhost:8001 for OAuth)",
)
parser.add_argument(
"--nextcloud-host",
default="http://localhost:8080",
help="Nextcloud host URL (default: http://localhost:8080)",
)
parser.add_argument(
"--username", default="admin", help="Test user username (default: admin)"
)
parser.add_argument(
"--password", default="admin", help="Test user password (default: admin)"
)
args = parser.parse_args()
try:
result = await test_oauth_flow(
provider=args.provider,
mcp_server_url=args.mcp_server_url,
nextcloud_host=args.nextcloud_host,
username=args.username,
password=args.password,
)
return 0 if result["success"] else 1
except Exception as e:
logger.error(f"OAuth flow test failed: {e}", exc_info=True)
print("\n" + "=" * 70)
print("ADR-004 OAUTH FLOW TEST - FAILED")
print("=" * 70)
print(f"Error: {e}")
print("=" * 70)
return 1
if __name__ == "__main__":
exit_code = asyncio.run(main())
exit(exit_code)
-289
View File
@@ -1,289 +0,0 @@
"""
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
@@ -1,227 +0,0 @@
"""
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 re
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
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
@@ -1,196 +0,0 @@
"""
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)
+1 -6
View File
@@ -1,11 +1,6 @@
"""Unit tests for user info routes.
Note: Most unit tests were removed as they relied on the old _get_user_info API.
The new browser OAuth session-based implementation is covered by integration tests
in tests/server/oauth/test_userinfo_integration.py which test the full OAuth flow
with real browser sessions, token storage, and IdP interactions.
These unit tests cover only the simple _query_idp_userinfo helper function.
These unit tests cover the simple _query_idp_userinfo helper function.
"""
from unittest.mock import AsyncMock, Mock
+511
View File
@@ -24,6 +24,9 @@ from mcp.types import ElicitRequestParams, ElicitResult
from tests.conftest import (
DEFAULT_FULL_SCOPES,
DEFAULT_READ_SCOPES,
DEFAULT_WRITE_SCOPES,
_get_oauth_token_with_scopes,
_handle_oauth_consent_screen,
create_mcp_client_session,
get_mcp_server_resource_metadata,
@@ -415,3 +418,511 @@ async def nc_mcp_login_flow_client(
)
yield session
# ---------------------------------------------------------------------------
# Scope-filtered OAuth client fixtures for scope authorization tests
# These obtain tokens with specific scope subsets via the login-flow server
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
async def login_flow_read_only_token(
anyio_backend,
browser,
login_flow_oauth_client_credentials,
oauth_callback_server,
) -> str:
"""OAuth token with read-only scopes for the login-flow MCP server."""
return await _get_oauth_token_with_scopes(
browser,
login_flow_oauth_client_credentials,
oauth_callback_server,
scopes=DEFAULT_READ_SCOPES,
mcp_server_base_url=LOGIN_FLOW_MCP_BASE_URL,
)
@pytest.fixture(scope="session")
async def login_flow_write_only_token(
anyio_backend,
browser,
login_flow_oauth_client_credentials,
oauth_callback_server,
) -> str:
"""OAuth token with write-only scopes for the login-flow MCP server."""
return await _get_oauth_token_with_scopes(
browser,
login_flow_oauth_client_credentials,
oauth_callback_server,
scopes=DEFAULT_WRITE_SCOPES,
mcp_server_base_url=LOGIN_FLOW_MCP_BASE_URL,
)
@pytest.fixture(scope="session")
async def login_flow_full_access_token(
anyio_backend,
browser,
login_flow_oauth_client_credentials,
oauth_callback_server,
) -> str:
"""OAuth token with full access scopes for the login-flow MCP server."""
return await _get_oauth_token_with_scopes(
browser,
login_flow_oauth_client_credentials,
oauth_callback_server,
scopes=DEFAULT_FULL_SCOPES,
mcp_server_base_url=LOGIN_FLOW_MCP_BASE_URL,
)
@pytest.fixture(scope="session")
async def login_flow_no_custom_scopes_token(
anyio_backend,
browser,
login_flow_oauth_client_credentials,
oauth_callback_server,
) -> str:
"""OAuth token with no custom scopes (only OIDC defaults) for the login-flow MCP server."""
return await _get_oauth_token_with_scopes(
browser,
login_flow_oauth_client_credentials,
oauth_callback_server,
scopes="openid profile email",
mcp_server_base_url=LOGIN_FLOW_MCP_BASE_URL,
)
@pytest.fixture(scope="session")
async def nc_mcp_login_flow_client_read_only(
anyio_backend, login_flow_read_only_token: str
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client with read-only scopes on the login-flow server."""
async for session in create_mcp_client_session(
url=LOGIN_FLOW_MCP_URL,
token=login_flow_read_only_token,
client_name="Login Flow MCP Read-Only",
):
yield session
@pytest.fixture(scope="session")
async def nc_mcp_login_flow_client_write_only(
anyio_backend, login_flow_write_only_token: str
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client with write-only scopes on the login-flow server."""
async for session in create_mcp_client_session(
url=LOGIN_FLOW_MCP_URL,
token=login_flow_write_only_token,
client_name="Login Flow MCP Write-Only",
):
yield session
@pytest.fixture(scope="session")
async def nc_mcp_login_flow_client_full_access(
anyio_backend, login_flow_full_access_token: str
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client with full access scopes on the login-flow server."""
async for session in create_mcp_client_session(
url=LOGIN_FLOW_MCP_URL,
token=login_flow_full_access_token,
client_name="Login Flow MCP Full Access",
):
yield session
@pytest.fixture(scope="session")
async def nc_mcp_login_flow_client_no_custom_scopes(
anyio_backend, login_flow_no_custom_scopes_token: str
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client with no custom scopes on the login-flow server."""
async for session in create_mcp_client_session(
url=LOGIN_FLOW_MCP_URL,
token=login_flow_no_custom_scopes_token,
client_name="Login Flow MCP No Custom Scopes",
):
yield session
# ---------------------------------------------------------------------------
# Multi-user Login Flow fixtures for permission / isolation tests
# ---------------------------------------------------------------------------
async def _get_login_flow_token_for_user(
browser,
login_flow_oauth_client_credentials,
auth_states: dict,
username: str,
password: str,
) -> str:
"""Get an OAuth token for a specific user targeting the login-flow MCP server.
Similar to the global ``_get_oauth_token_for_user`` but hard-wires the
resource / PRM discovery against port 8004.
"""
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
pytest.skip("Login Flow tests require NEXTCLOUD_HOST")
client_id, client_secret, callback_url, token_endpoint, authorization_endpoint = (
login_flow_oauth_client_credentials
)
# Discover resource identifier from the login-flow server
try:
resource_metadata = await get_mcp_server_resource_metadata(
LOGIN_FLOW_MCP_BASE_URL
)
resource_id = resource_metadata.get("resource")
except Exception:
resource_id = None
state = secrets.token_urlsafe(32)
scopes_encoded = quote(DEFAULT_FULL_SCOPES, safe="")
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}"
)
if resource_id:
auth_url += f"&resource={quote(resource_id, safe='')}"
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
current_url = page.url
# Login
if "/login" in current_url or "/index.php/login" in current_url:
await page.wait_for_selector('input[name="user"]', timeout=10000)
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
await page.click('button[type="submit"]')
await page.wait_for_load_state("networkidle", timeout=60000)
# Wait for OIDC redirect chain to settle
settle_start = time.time()
while time.time() - settle_start < 15:
current_url = page.url
if "/consent" in current_url or "localhost:8081" in current_url:
break
await anyio.sleep(0.5)
# Handle consent screen
if "/consent" in page.url:
await page.wait_for_load_state("networkidle", timeout=10000)
await _handle_oauth_consent_screen(page, username)
# Wait for callback
start_time = time.time()
while state not in auth_states:
if time.time() - start_time > 30:
screenshot_path = f"/tmp/login_flow_oauth_timeout_{username}.png"
await page.screenshot(path=screenshot_path)
raise TimeoutError(f"Timeout waiting for OAuth callback for {username}")
await anyio.sleep(0.5)
auth_code = auth_states[state]
finally:
await context.close()
# Exchange code for token
async with httpx.AsyncClient(timeout=30.0) as http_client:
token_response = await http_client.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": callback_url,
"client_id": client_id,
"client_secret": client_secret,
},
)
token_response.raise_for_status()
return token_response.json()["access_token"]
@pytest.fixture(scope="session")
async def all_login_flow_user_tokens(
anyio_backend,
browser,
login_flow_oauth_client_credentials,
test_users_setup,
oauth_callback_server,
) -> dict[str, str]:
"""Fetch OAuth tokens for all test users in parallel, targeting port 8004."""
auth_states, _ = oauth_callback_server
start_time = time.time()
logger.info("Fetching login-flow OAuth tokens for all users in parallel...")
results: dict[str, str | Exception] = {}
async def _fetch(username: str, config: dict) -> None:
try:
token = await _get_login_flow_token_for_user(
browser,
login_flow_oauth_client_credentials,
auth_states,
username,
config["password"],
)
results[username] = token
except Exception as exc:
results[username] = exc
user_list = list(test_users_setup.items())
async with anyio.create_task_group() as tg:
for username, config in user_list:
tg.start_soon(_fetch, username, config)
for username, result in results.items():
if isinstance(result, Exception):
raise result
elapsed = time.time() - start_time
logger.info(
f"Fetched {len(results)} login-flow tokens in {elapsed:.1f}s "
f"(~{elapsed / len(results):.1f}s per user)"
)
return results # type: ignore[return-value]
async def _provision_login_flow_mcp_client(
token: str,
browser,
username: str,
password: str,
) -> AsyncGenerator[ClientSession, Any]:
"""Connect to login-flow MCP server, complete Login Flow v2 provisioning, yield session."""
login_url_holder: dict[str, str] = {}
async def elicitation_callback(
context: Any,
params: ElicitRequestParams,
) -> ElicitResult:
message = params.message
for line in message.split("\n"):
stripped = line.strip()
if stripped.startswith("http") and "/login/v2/" in stripped:
login_url_holder["url"] = stripped
break
if "url" in login_url_holder:
await _complete_login_flow_v2_as_user(
browser, login_url_holder["url"], username, password
)
return ElicitResult(action="accept", content={"acknowledged": True})
async for session in create_mcp_client_session(
url=LOGIN_FLOW_MCP_URL,
token=token,
client_name=f"Login Flow MCP ({username})",
elicitation_callback=elicitation_callback,
):
# Provision access
provision_result = await session.call_tool(
"nc_auth_provision_access", {"scopes": None}
)
provision_data = json.loads(provision_result.content[0].text)
if provision_data.get("status") == "login_required":
login_url = provision_data.get("login_url")
if login_url and "url" not in login_url_holder:
await _complete_login_flow_v2_as_user(
browser, login_url, username, password
)
# Poll for completion
for attempt in range(15):
status_result = await session.call_tool("nc_auth_check_status", {})
status_data = json.loads(status_result.content[0].text)
if status_data.get("status") == "provisioned":
logger.info(
f"Login Flow v2 provisioned for {username}: "
f"{status_data.get('username')}"
)
break
if status_data.get("status") in ("not_initiated", "error"):
raise RuntimeError(
f"Login Flow v2 failed for {username}: {status_data.get('message')}"
)
await anyio.sleep(2)
else:
raise TimeoutError(
f"Login Flow v2 did not complete for {username} after 15 attempts"
)
yield session
async def _complete_login_flow_v2_as_user(
browser, login_url: str, username: str, password: str
) -> None:
"""Complete Nextcloud Login Flow v2 in a browser as a specific user.
The full Nextcloud Login Flow v2 has these steps:
1. "Connect to your account" page -> click "Log in" button
2. Login form -> fill username/password, submit
(if already logged in via session cookie, this step is skipped)
3. "Account access" grant page -> click "Grant access" button
4. Password confirmation dialog -> enter password, click "Confirm"
5. "Account connected" success page
Same flow as ``_complete_login_flow_v2`` but uses the given *username* and
*password* instead of reading from environment variables.
"""
login_url = _rewrite_login_flow_url(login_url)
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
logger.info(f"[{username}] Opening Login Flow v2 URL: {login_url[:80]}...")
await page.goto(login_url, wait_until="networkidle", timeout=60000)
logger.info(f"[{username}] Step 1 - Current URL: {page.url}")
# Step 1: "Connect to your account" page - click "Log in"
login_btn = page.get_by_role("button", name="Log in")
try:
await login_btn.wait_for(timeout=10000)
await login_btn.click()
logger.info(f"[{username}] Clicked 'Log in' on Connect page")
await page.wait_for_load_state("networkidle", timeout=30000)
except Exception:
logger.info(
f"[{username}] No 'Log in' button - may already be on login/grant page"
)
logger.info(f"[{username}] Step 2 - Current URL: {page.url}")
# Step 2: Login form (only if not already logged in)
user_field = page.locator('input[name="user"]')
if await user_field.count() > 0:
logger.info(f"[{username}] Login form detected, filling credentials...")
await user_field.fill(username)
await page.locator('input[name="password"]').fill(password)
await page.get_by_role("button", name="Log in", exact=True).click()
await page.wait_for_load_state("networkidle", timeout=60000)
logger.info(f"[{username}] After login: {page.url}")
else:
logger.info(f"[{username}] No login form - already logged in via session")
# Step 3: "Account access" grant page - click "Grant access"
grant_btn = page.get_by_role("button", name="Grant access")
try:
await grant_btn.wait_for(timeout=15000)
await grant_btn.click()
logger.info(f"[{username}] Clicked 'Grant access'")
except Exception as e:
logger.warning(f"[{username}] No Grant access button: {e}")
await page.screenshot(path=f"/tmp/login_flow_no_grant_{username}.png")
# Step 4: Password confirmation dialog
confirm_password = page.get_by_role("dialog").get_by_role(
"textbox", name="Password"
)
try:
await confirm_password.wait_for(timeout=10000)
logger.info(f"[{username}] Password confirmation dialog detected")
await confirm_password.fill(password)
confirm_btn = page.get_by_role("dialog").get_by_role(
"button", name="Confirm"
)
await confirm_btn.wait_for(timeout=5000)
await confirm_btn.click()
logger.info(f"[{username}] Clicked 'Confirm' in password dialog")
except Exception:
logger.info(
f"[{username}] No password confirmation dialog "
"(may have been auto-confirmed)"
)
# Step 5: Wait for "Account connected" success page
try:
await page.get_by_text("Account connected").wait_for(timeout=15000)
logger.info(f"[{username}] Login Flow v2 completed: Account connected!")
except Exception:
await page.wait_for_load_state("networkidle", timeout=10000)
logger.info(f"[{username}] Login Flow v2 done. Final URL: {page.url}")
finally:
await context.close()
@pytest.fixture(scope="session")
async def alice_login_flow_mcp_client(
anyio_backend,
all_login_flow_user_tokens: dict[str, str],
test_users_setup,
browser,
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client authenticated and provisioned as alice (owner role)."""
async for session in _provision_login_flow_mcp_client(
token=all_login_flow_user_tokens["alice"],
browser=browser,
username="alice",
password=test_users_setup["alice"]["password"],
):
yield session
@pytest.fixture(scope="session")
async def bob_login_flow_mcp_client(
anyio_backend,
all_login_flow_user_tokens: dict[str, str],
test_users_setup,
browser,
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client authenticated and provisioned as bob (viewer role)."""
async for session in _provision_login_flow_mcp_client(
token=all_login_flow_user_tokens["bob"],
browser=browser,
username="bob",
password=test_users_setup["bob"]["password"],
):
yield session
@pytest.fixture(scope="session")
async def charlie_login_flow_mcp_client(
anyio_backend,
all_login_flow_user_tokens: dict[str, str],
test_users_setup,
browser,
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client authenticated and provisioned as charlie (editor role)."""
async for session in _provision_login_flow_mcp_client(
token=all_login_flow_user_tokens["charlie"],
browser=browser,
username="charlie",
password=test_users_setup["charlie"]["password"],
):
yield session
@pytest.fixture(scope="session")
async def diana_login_flow_mcp_client(
anyio_backend,
all_login_flow_user_tokens: dict[str, str],
test_users_setup,
browser,
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client authenticated and provisioned as diana (no-access role)."""
async for session in _provision_login_flow_mcp_client(
token=all_login_flow_user_tokens["diana"],
browser=browser,
username="diana",
password=test_users_setup["diana"]["password"],
):
yield session
@@ -16,7 +16,7 @@ from nextcloud_mcp_server.auth.client_registration import register_client
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
pytestmark = [pytest.mark.integration, pytest.mark.login_flow]
@pytest.mark.integration
@@ -26,7 +26,7 @@ from ...conftest import _handle_oauth_consent_screen
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
pytestmark = [pytest.mark.integration, pytest.mark.login_flow]
async def get_oauth_token_with_client(
@@ -12,7 +12,7 @@ import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
pytestmark = [pytest.mark.integration, pytest.mark.login_flow]
@pytest.mark.integration
@@ -30,7 +30,7 @@ from ...conftest import _handle_oauth_consent_screen
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
pytestmark = [pytest.mark.integration, pytest.mark.login_flow]
def is_jwt_format(token: str) -> bool:
@@ -27,7 +27,7 @@ from ...conftest import _handle_oauth_consent_screen
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
pytestmark = [pytest.mark.integration, pytest.mark.login_flow]
@pytest.fixture(scope="module")
@@ -0,0 +1,540 @@
"""Multi-user permission tests for Login Flow v2 deployment mode.
Tests verify that Nextcloud's sharing / ACL enforcement works correctly
when resources are accessed through MCP tools by different users, each
authenticated via Login Flow v2.
Ported from the removed ``tests/server/oauth/test_oauth_*_permissions.py``
tests. The underlying assertions are deployment-mode-agnostic; only the
transport changed (OAuth MCP server -> Login Flow v2 MCP server).
"""
import json
import logging
import pytest
from mcp import ClientSession
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.login_flow]
# ---------------------------------------------------------------------------
# WebDAV / Files
# ---------------------------------------------------------------------------
class TestFilePermissions:
"""Test that MCP file tools respect Nextcloud sharing permissions."""
async def test_file_share_read_permissions(
self,
alice_login_flow_mcp_client: ClientSession,
bob_login_flow_mcp_client: ClientSession,
diana_login_flow_mcp_client: ClientSession,
):
"""Alice shares a file with Bob (read-only). Bob can read it;
Diana (unshared) cannot."""
file_path = "/alice_shared_file_read.txt"
file_content = "This file is shared with Bob for reading only."
# Alice creates the file
result = await alice_login_flow_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": file_path, "content": file_content},
)
assert not result.isError, f"Alice failed to create file: {result.content}"
share_id = None
try:
# Alice shares with Bob (read-only, permissions=1)
result = await alice_login_flow_mcp_client.call_tool(
"nc_share_create",
arguments={
"path": file_path,
"share_with": "bob",
"share_type": 0,
"permissions": 1,
},
)
assert not result.isError, f"Share creation failed: {result.content}"
share_data = json.loads(result.content[0].text)
share_id = share_data["id"]
# Bob reads the file
result = await bob_login_flow_mcp_client.call_tool(
"nc_webdav_read_file", arguments={"path": file_path}
)
assert not result.isError, (
f"Bob could not read shared file: {result.content}"
)
response_data = json.loads(result.content[0].text)
assert file_content in response_data["content"]
# Diana cannot read the file
result = await diana_login_flow_mcp_client.call_tool(
"nc_webdav_read_file", arguments={"path": file_path}
)
assert result.isError, "Diana should not be able to read unshared file"
finally:
if share_id:
await alice_login_flow_mcp_client.call_tool(
"nc_share_delete", arguments={"share_id": share_id}
)
await alice_login_flow_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": file_path}
)
async def test_file_share_write_permissions(
self,
alice_login_flow_mcp_client: ClientSession,
charlie_login_flow_mcp_client: ClientSession,
bob_login_flow_mcp_client: ClientSession,
):
"""Alice shares a file with Charlie (edit) and Bob (read-only).
Charlie can overwrite; Bob cannot."""
file_path = "/alice_shared_file_write.txt"
file_content = "This file is shared with Charlie for editing."
result = await alice_login_flow_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": file_path, "content": file_content},
)
assert not result.isError
charlie_share_id = None
bob_share_id = None
try:
# Share with Charlie (read+write, permissions=3)
result = await alice_login_flow_mcp_client.call_tool(
"nc_share_create",
arguments={
"path": file_path,
"share_with": "charlie",
"share_type": 0,
"permissions": 3,
},
)
assert not result.isError
charlie_share_id = json.loads(result.content[0].text)["id"]
# Share with Bob (read-only, permissions=1)
result = await alice_login_flow_mcp_client.call_tool(
"nc_share_create",
arguments={
"path": file_path,
"share_with": "bob",
"share_type": 0,
"permissions": 1,
},
)
assert not result.isError
bob_share_id = json.loads(result.content[0].text)["id"]
# Charlie can write
result = await charlie_login_flow_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={
"path": file_path,
"content": f"{file_content}\nCharlie added this line.",
},
)
assert not result.isError, (
f"Charlie should be able to write: {result.content}"
)
# Bob cannot write
result = await bob_login_flow_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={
"path": file_path,
"content": "Bob tries to overwrite this.",
},
)
assert result.isError, "Bob should be denied write access (read-only)"
finally:
for sid in (charlie_share_id, bob_share_id):
if sid:
await alice_login_flow_mcp_client.call_tool(
"nc_share_delete", arguments={"share_id": sid}
)
await alice_login_flow_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": file_path}
)
async def test_folder_share_permissions(
self,
alice_login_flow_mcp_client: ClientSession,
bob_login_flow_mcp_client: ClientSession,
):
"""Alice shares a folder with Bob; Bob can list and read its contents."""
folder_path = "/alice_shared_folder"
file_in_folder = f"{folder_path}/document.txt"
file_content = "Document in Alice's shared folder"
result = await alice_login_flow_mcp_client.call_tool(
"nc_webdav_create_directory", arguments={"path": folder_path}
)
assert not result.isError
result = await alice_login_flow_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": file_in_folder, "content": file_content},
)
assert not result.isError
share_id = None
try:
result = await alice_login_flow_mcp_client.call_tool(
"nc_share_create",
arguments={
"path": folder_path,
"share_with": "bob",
"share_type": 0,
"permissions": 1,
},
)
assert not result.isError
share_id = json.loads(result.content[0].text)["id"]
# Bob lists the shared folder
result = await bob_login_flow_mcp_client.call_tool(
"nc_webdav_list_directory", arguments={"path": folder_path}
)
assert not result.isError, f"Bob should see shared folder: {result.content}"
response_data = json.loads(result.content[0].text)
file_names = [f["name"] for f in response_data.get("files", [])]
assert "document.txt" in file_names
# Bob reads the file
result = await bob_login_flow_mcp_client.call_tool(
"nc_webdav_read_file", arguments={"path": file_in_folder}
)
assert not result.isError
assert file_content in json.loads(result.content[0].text)["content"]
finally:
if share_id:
await alice_login_flow_mcp_client.call_tool(
"nc_share_delete", arguments={"share_id": share_id}
)
await alice_login_flow_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": folder_path}
)
async def test_user_isolation_files(
self,
alice_login_flow_mcp_client: ClientSession,
bob_login_flow_mcp_client: ClientSession,
):
"""Users can only see their own files when nothing is shared."""
alice_file = "/alice_private_file.txt"
bob_file = "/bob_private_file.txt"
# Each user creates their own file
result = await alice_login_flow_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": alice_file, "content": "Alice's private file"},
)
assert not result.isError
result = await bob_login_flow_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": bob_file, "content": "Bob's private file"},
)
assert not result.isError
try:
# Bob lists root — should NOT see Alice's file
result = await bob_login_flow_mcp_client.call_tool(
"nc_webdav_list_directory", arguments={"path": "/"}
)
assert not result.isError
bob_visible = [
f["name"] for f in json.loads(result.content[0].text).get("files", [])
]
assert "alice_private_file.txt" not in bob_visible, (
"Bob should not see Alice's private file"
)
# Alice lists root — should NOT see Bob's file
result = await alice_login_flow_mcp_client.call_tool(
"nc_webdav_list_directory", arguments={"path": "/"}
)
assert not result.isError
alice_visible = [
f["name"] for f in json.loads(result.content[0].text).get("files", [])
]
assert "bob_private_file.txt" not in alice_visible, (
"Alice should not see Bob's private file"
)
finally:
await alice_login_flow_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": alice_file}
)
await bob_login_flow_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": bob_file}
)
# ---------------------------------------------------------------------------
# Deck
# ---------------------------------------------------------------------------
class TestDeckPermissions:
"""Test that MCP Deck tools respect board ACL permissions."""
async def _add_board_acl(
self, nc_client, board_id: int, user: str, permission_type: int = 0
) -> int:
"""Add ACL entry. permission_type: 0=view, 1=edit, 2=manage."""
acl = await nc_client.deck.add_acl_rule(
board_id=board_id,
type=0,
participant=user,
permission_edit=permission_type >= 1,
permission_share=permission_type >= 2,
permission_manage=permission_type >= 2,
)
return acl.id
async def test_deck_board_view_permissions(
self,
nc_client,
alice_login_flow_mcp_client: ClientSession,
bob_login_flow_mcp_client: ClientSession,
diana_login_flow_mcp_client: ClientSession,
):
"""Admin creates a board, adds Bob (view). Bob can see it; Diana cannot."""
board = await nc_client.deck.create_board("Shared Board - View Test", "FF0000")
board_id = board.id
bob_acl_id = None
try:
bob_acl_id = await self._add_board_acl(nc_client, board_id, "bob", 0)
# Bob can see the board
result = await bob_login_flow_mcp_client.call_tool(
"deck_get_boards", arguments={}
)
assert not result.isError
board_ids = [
b["id"] for b in json.loads(result.content[0].text).get("boards", [])
]
assert board_id in board_ids, "Bob should see shared board"
# Diana cannot see the board
result = await diana_login_flow_mcp_client.call_tool(
"deck_get_boards", arguments={}
)
assert not result.isError
board_ids = [
b["id"] for b in json.loads(result.content[0].text).get("boards", [])
]
assert board_id not in board_ids, "Diana should not see board without ACL"
finally:
if bob_acl_id:
await nc_client.deck.delete_acl_rule(board_id, bob_acl_id)
await nc_client.deck.delete_board(board_id)
async def test_deck_board_edit_permissions(
self,
nc_client,
charlie_login_flow_mcp_client: ClientSession,
bob_login_flow_mcp_client: ClientSession,
):
"""Charlie (edit) can create cards; Bob (view-only) cannot."""
board = await nc_client.deck.create_board("Shared Board - Edit Test", "00FF00")
board_id = board.id
stack = await nc_client.deck.create_stack(board_id, "Test Stack", 1)
stack_id = stack.id
charlie_acl_id = None
bob_acl_id = None
try:
charlie_acl_id = await self._add_board_acl(
nc_client, board_id, "charlie", 1
)
bob_acl_id = await self._add_board_acl(nc_client, board_id, "bob", 0)
# Charlie creates a card
result = await charlie_login_flow_mcp_client.call_tool(
"deck_create_card",
arguments={
"board_id": board_id,
"stack_id": stack_id,
"title": "Charlie's Card",
"description": "Created by Charlie with edit permission",
},
)
assert not result.isError, f"Charlie should create cards: {result.content}"
card_id = json.loads(result.content[0].text).get("id")
if card_id:
await nc_client.deck.delete_card(board_id, stack_id, card_id)
# Bob cannot create a card
result = await bob_login_flow_mcp_client.call_tool(
"deck_create_card",
arguments={
"board_id": board_id,
"stack_id": stack_id,
"title": "Bob's Card",
"description": "Bob trying to create a card",
},
)
assert result.isError, "Bob should be denied card creation (view-only)"
finally:
for acl_id in (charlie_acl_id, bob_acl_id):
if acl_id:
await nc_client.deck.delete_acl_rule(board_id, acl_id)
await nc_client.deck.delete_board(board_id)
async def test_deck_user_isolation(
self,
nc_client,
alice_login_flow_mcp_client: ClientSession,
bob_login_flow_mcp_client: ClientSession,
):
"""Users can only see their own boards when nothing is shared."""
alice_board = await nc_client.deck.create_board(
"Alice's Private Board", "FF00FF"
)
bob_board = await nc_client.deck.create_board("Bob's Private Board", "00FFFF")
try:
# Alice should NOT see Bob's board
result = await alice_login_flow_mcp_client.call_tool(
"deck_get_boards", arguments={}
)
assert not result.isError
board_ids = [
b["id"] for b in json.loads(result.content[0].text).get("boards", [])
]
assert bob_board.id not in board_ids, (
"Alice should not see Bob's private board"
)
# Bob should NOT see Alice's board
result = await bob_login_flow_mcp_client.call_tool(
"deck_get_boards", arguments={}
)
assert not result.isError
board_ids = [
b["id"] for b in json.loads(result.content[0].text).get("boards", [])
]
assert alice_board.id not in board_ids, (
"Bob should not see Alice's private board"
)
finally:
await nc_client.deck.delete_board(alice_board.id)
await nc_client.deck.delete_board(bob_board.id)
# ---------------------------------------------------------------------------
# Notes
# ---------------------------------------------------------------------------
class TestNotesPermissions:
"""Test that MCP Notes tools respect user isolation.
Nextcloud Notes are inherently single-user (no sharing API). These tests
verify that notes created by one user are invisible to others.
"""
async def test_user_isolation_notes(
self,
alice_login_flow_mcp_client: ClientSession,
bob_login_flow_mcp_client: ClientSession,
):
"""Notes created by Alice are invisible to Bob and vice versa."""
# Alice creates a note
result = await alice_login_flow_mcp_client.call_tool(
"nc_notes_create_note",
arguments={
"title": "Alice's Private Note",
"content": "This is Alice's private content.",
"category": "PermTest",
},
)
assert not result.isError
alice_note_id = json.loads(result.content[0].text)["id"]
# Bob creates a note
result = await bob_login_flow_mcp_client.call_tool(
"nc_notes_create_note",
arguments={
"title": "Bob's Private Note",
"content": "This is Bob's private content.",
"category": "PermTest",
},
)
assert not result.isError
bob_note_id = json.loads(result.content[0].text)["id"]
try:
# Alice searches — should NOT see Bob's note
result = await alice_login_flow_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": "PermTest"}
)
assert not result.isError
alice_visible_ids = [
n["id"] for n in json.loads(result.content[0].text).get("results", [])
]
assert bob_note_id not in alice_visible_ids, (
"Alice should not see Bob's private note"
)
# Bob searches — should NOT see Alice's note
result = await bob_login_flow_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": "PermTest"}
)
assert not result.isError
bob_visible_ids = [
n["id"] for n in json.loads(result.content[0].text).get("results", [])
]
assert alice_note_id not in bob_visible_ids, (
"Bob should not see Alice's private note"
)
finally:
await alice_login_flow_mcp_client.call_tool(
"nc_notes_delete_note", arguments={"note_id": alice_note_id}
)
await bob_login_flow_mcp_client.call_tool(
"nc_notes_delete_note", arguments={"note_id": bob_note_id}
)
# ---------------------------------------------------------------------------
# Smoke: all multi-user clients initialised
# ---------------------------------------------------------------------------
class TestMultiUserSmoke:
"""Quick check that all multi-user MCP clients are functional."""
async def test_all_clients_can_list_tools(
self,
alice_login_flow_mcp_client: ClientSession,
bob_login_flow_mcp_client: ClientSession,
charlie_login_flow_mcp_client: ClientSession,
diana_login_flow_mcp_client: ClientSession,
):
for name, client in [
("alice", alice_login_flow_mcp_client),
("bob", bob_login_flow_mcp_client),
("charlie", charlie_login_flow_mcp_client),
("diana", diana_login_flow_mcp_client),
]:
tools = await client.list_tools()
assert len(tools.tools) > 0, f"{name} MCP client has no tools"
logger.info(f"{name} MCP client working ({len(tools.tools)} tools)")
@@ -18,22 +18,22 @@ import pytest
@pytest.mark.integration
@pytest.mark.oauth
@pytest.mark.login_flow
async def test_prm_endpoint():
"""Test that the Protected Resource Metadata endpoint returns correct data."""
# Test the PRM endpoint directly (RFC 9728 - path includes /mcp resource)
async with httpx.AsyncClient() as client:
response = await client.get(
"http://localhost:8001/.well-known/oauth-protected-resource/mcp"
"http://localhost:8004/.well-known/oauth-protected-resource/mcp"
)
assert response.status_code == 200
prm_data = response.json()
assert prm_data["resource"] == "http://localhost:8001/mcp"
assert prm_data["resource"] == "http://localhost:8004/mcp"
assert "notes:read" in prm_data["scopes_supported"]
assert "notes:write" in prm_data["scopes_supported"]
assert "http://localhost:8001" in prm_data["authorization_servers"]
assert "http://localhost:8004" in prm_data["authorization_servers"]
assert "header" in prm_data["bearer_methods_supported"]
assert "RS256" in prm_data["resource_signing_alg_values_supported"]
@@ -61,14 +61,14 @@ async def test_basicauth_shows_all_tools(nc_mcp_client):
@pytest.mark.integration
@pytest.mark.oauth
async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only):
@pytest.mark.login_flow
async def test_read_only_token_filters_write_tools(nc_mcp_login_flow_client_read_only):
"""Test that a token with only read scopes filters out write tools."""
logger = logging.getLogger(__name__)
# Connect with token that has only "notes:read" scope
result = await nc_mcp_oauth_client_read_only.list_tools()
result = await nc_mcp_login_flow_client_read_only.list_tools()
assert result is not None
assert len(result.tools) > 0
@@ -110,14 +110,14 @@ async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only
@pytest.mark.integration
@pytest.mark.oauth
async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_only):
@pytest.mark.login_flow
async def test_write_only_token_filters_read_tools(nc_mcp_login_flow_client_write_only):
"""Test that a token with only write scopes filters out read tools."""
logger = logging.getLogger(__name__)
# Connect with token that has only "notes:write" scope
result = await nc_mcp_oauth_client_write_only.list_tools()
result = await nc_mcp_login_flow_client_write_only.list_tools()
assert result is not None
assert len(result.tools) > 0
@@ -159,14 +159,14 @@ async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_onl
@pytest.mark.integration
@pytest.mark.oauth
async def test_full_access_token_shows_all_tools(nc_mcp_oauth_client_full_access):
@pytest.mark.login_flow
async def test_full_access_token_shows_all_tools(nc_mcp_login_flow_client_full_access):
"""Test that a token with both read and write scopes scopes can see all tools."""
logger = logging.getLogger(__name__)
# Connect with token that has both "notes:read" and "notes:write" scopes
result = await nc_mcp_oauth_client_full_access.list_tools()
result = await nc_mcp_login_flow_client_full_access.list_tools()
assert result is not None
assert len(result.tools) > 0
@@ -393,9 +393,9 @@ async def test_scope_metadata_coverage(nc_mcp_client):
@pytest.mark.integration
@pytest.mark.oauth
@pytest.mark.login_flow
async def test_jwt_with_no_custom_scopes_returns_zero_tools(
nc_mcp_oauth_client_no_custom_scopes,
nc_mcp_login_flow_client_no_custom_scopes,
):
"""
Test that a JWT token with only OIDC default scopes shows only OAuth provisioning tools.
@@ -410,36 +410,39 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools(
logger = logging.getLogger(__name__)
# Connect with JWT token that has NO custom scopes (only openid, profile, email)
result = await nc_mcp_oauth_client_no_custom_scopes.list_tools()
result = await nc_mcp_login_flow_client_no_custom_scopes.list_tools()
assert result is not None
tool_names = [tool.name for tool in result.tools]
logger.info(
f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 4 OAuth tools)"
f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 7 auth tools)"
)
# Only OAuth provisioning tools should be visible (they require 'openid' scope)
expected_oauth_tools = [
# Only auth/provisioning tools should be visible (they require 'openid' scope)
expected_auth_tools = [
"provision_nextcloud_access",
"revoke_nextcloud_access",
"check_provisioning_status",
"check_logged_in", # Login elicitation tool (ADR-006)
"nc_auth_provision_access", # Login Flow v2 (ADR-022)
"nc_auth_check_status", # Login Flow v2
"nc_auth_update_scopes", # Login Flow v2
]
assert set(tool_names) == set(expected_oauth_tools), (
f"Expected only OAuth provisioning tools {expected_oauth_tools} "
assert set(tool_names) == set(expected_auth_tools), (
f"Expected only auth/provisioning tools {expected_auth_tools} "
f"but got {tool_names}"
)
logger.info(
f"✅ JWT token with only openid scope correctly shows {len(tool_names)} OAuth provisioning tools, "
f"✅ JWT token with only openid scope correctly shows {len(tool_names)} auth tools, "
"resource tools filtered out"
)
@pytest.mark.integration
@pytest.mark.oauth
async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only):
@pytest.mark.login_flow
async def test_jwt_consent_scenarios_read_only(nc_mcp_login_flow_client_read_only):
"""
Test JWT with only nc:read scope consented.
@@ -449,7 +452,7 @@ async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only):
logger = logging.getLogger(__name__)
result = await nc_mcp_oauth_client_read_only.list_tools()
result = await nc_mcp_login_flow_client_read_only.list_tools()
assert result is not None
assert len(result.tools) > 0
@@ -476,8 +479,8 @@ async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only):
@pytest.mark.integration
@pytest.mark.oauth
async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only):
@pytest.mark.login_flow
async def test_jwt_consent_scenarios_write_only(nc_mcp_login_flow_client_write_only):
"""
Test JWT with only nc:write scope consented.
@@ -487,7 +490,7 @@ async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only):
logger = logging.getLogger(__name__)
result = await nc_mcp_oauth_client_write_only.list_tools()
result = await nc_mcp_login_flow_client_write_only.list_tools()
assert result is not None
assert len(result.tools) > 0
@@ -514,8 +517,8 @@ async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only):
@pytest.mark.integration
@pytest.mark.oauth
async def test_jwt_consent_scenarios_full_access(nc_mcp_oauth_client_full_access):
@pytest.mark.login_flow
async def test_jwt_consent_scenarios_full_access(nc_mcp_login_flow_client_full_access):
"""
Test JWT with both nc:read and nc:write scopes consented.
@@ -525,7 +528,7 @@ async def test_jwt_consent_scenarios_full_access(nc_mcp_oauth_client_full_access
logger = logging.getLogger(__name__)
result = await nc_mcp_oauth_client_full_access.list_tools()
result = await nc_mcp_login_flow_client_full_access.list_tools()
assert result is not None
assert len(result.tools) > 0
-1
View File
@@ -1 +0,0 @@
"""OAuth-specific integration tests."""
@@ -1,109 +0,0 @@
"""Test Astrolabe integration with multiple MCP server deployments.
Cross-system interface test: Tests the MCP server's integration with the
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
This test suite verifies that the Astrolabe app can be dynamically configured
to connect to different MCP server deployments (mcp-oauth, mcp-keycloak, etc.).
The configuration is managed dynamically during tests using the
configure_astrolabe_for_mcp_server fixture, which allows testing multiple
deployment scenarios without requiring static post-installation configuration.
"""
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
class TestAstrolabeMultiServerIntegration:
"""Test suite for Astrolabe integration with multiple MCP servers."""
@pytest.mark.parametrize(
"mcp_server_config",
[
{
"name": "mcp-oauth",
"internal_url": "http://mcp-oauth:8001",
"public_url": "http://localhost:8001",
},
{
"name": "mcp-keycloak",
"internal_url": "http://mcp-keycloak:8002",
"public_url": "http://localhost:8002",
},
# Add more MCP server configurations as needed:
# {
# "name": "mcp-multi-user-basic",
# "internal_url": "http://mcp-multi-user-basic:8000",
# "public_url": "http://localhost:8003",
# },
],
)
async def test_astrolabe_configuration_for_different_servers(
self, configure_astrolabe_for_mcp_server, mcp_server_config
):
"""Test that Astrolabe can be configured for different MCP servers.
This test verifies that:
1. The configure_astrolabe_for_mcp_server fixture successfully configures
the Astrolabe app for different MCP server endpoints
2. OAuth client credentials are properly generated and stored
3. The configuration can be dynamically changed between tests
"""
logger.info(f"Configuring Astrolabe for {mcp_server_config['name']}...")
# Configure Astrolabe for the specific MCP server
credentials = await configure_astrolabe_for_mcp_server(
mcp_server_internal_url=mcp_server_config["internal_url"],
mcp_server_public_url=mcp_server_config["public_url"],
)
# Verify credentials were returned
assert "client_id" in credentials
assert "client_secret" in credentials
assert credentials["client_id"] == "nextcloudMcpServerUIPublicClient"
assert len(credentials["client_secret"]) > 0
logger.info(
f"✓ Astrolabe successfully configured for {mcp_server_config['name']}"
)
logger.info(f" Internal URL: {mcp_server_config['internal_url']}")
logger.info(f" Public URL: {mcp_server_config['public_url']}")
logger.info(f" Client ID: {credentials['client_id']}")
logger.info(f" Client Secret: {credentials['client_secret'][:8]}...")
async def test_astrolabe_reconfiguration(self, configure_astrolabe_for_mcp_server):
"""Test that Astrolabe can be reconfigured multiple times in the same session.
This verifies that the OAuth client can be recreated with different
settings without conflicts.
"""
# First configuration: mcp-oauth
logger.info("First configuration: mcp-oauth")
credentials1 = await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-oauth:8001",
mcp_server_public_url="http://localhost:8001",
)
assert credentials1["client_id"] == "nextcloudMcpServerUIPublicClient"
# Second configuration: mcp-keycloak (reconfiguration)
logger.info("Second configuration: mcp-keycloak (reconfiguration)")
credentials2 = await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-keycloak:8002",
mcp_server_public_url="http://localhost:8002",
)
assert credentials2["client_id"] == "nextcloudMcpServerUIPublicClient"
# Client secrets should be different (new client created)
assert credentials1["client_secret"] != credentials2["client_secret"]
logger.info("✓ Astrolabe successfully reconfigured without conflicts")
@@ -1,206 +0,0 @@
"""Integration tests for login elicitation with real MCP client callback support.
These tests verify the complete end-to-end login elicitation flow (ADR-006)
using the python-sdk MCP client with actual elicitation callback implementation.
Unlike test_login_elicitation.py which validates response formats, these tests
exercise the REAL elicitation protocol:
1. MCP client with elicitation callback connects to server
2. Tool triggers elicitation (ctx.elicit())
3. Client callback receives elicitation request
4. Callback completes OAuth flow via Playwright automation
5. Client returns acceptance
6. Tool proceeds with authenticated operation
This validates that:
- python-sdk MCP client can handle elicitation requests
- OAuth flow completion via callback works end-to-end
- Refresh tokens are properly stored after elicitation
- check_logged_in returns "yes" after successful OAuth
"""
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def revoke_refresh_tokens(client):
"""Helper to revoke all refresh tokens from MCP server.
This forces check_logged_in to trigger elicitation by removing
any existing refresh tokens via the revoke_nextcloud_access tool.
"""
logger.info("Revoking refresh tokens via revoke_nextcloud_access tool...")
result = await client.call_tool("revoke_nextcloud_access", arguments={})
logger.info(f"Revoke result: isError={result.isError}")
if not result.isError:
logger.info(f"✓ Revoke response: {result.content[0].text}")
else:
logger.warning(f"Revoke failed: {result.content}")
async def test_check_logged_in_with_real_elicitation_callback(
nc_mcp_oauth_client_with_elicitation,
):
"""Test check_logged_in with actual elicitation callback that completes OAuth.
This test validates the COMPLETE elicitation flow:
1. Call check_logged_in tool (which triggers elicitation)
2. Elicitation callback extracts OAuth URL
3. Playwright automation completes OAuth flow
4. Callback returns acceptance
5. Tool returns "yes" (logged in)
6. Refresh token is stored
This is the ONLY test that exercises the real MCP elicitation protocol
with python-sdk's ClientSession elicitation callback support.
"""
client = nc_mcp_oauth_client_with_elicitation
logger.info("=" * 80)
logger.info("TEST: Real elicitation callback with OAuth completion")
logger.info("=" * 80)
# Revoke refresh tokens to force elicitation
await revoke_refresh_tokens(client)
# Call check_logged_in - this should trigger elicitation
logger.info("Calling check_logged_in tool...")
result = await client.call_tool("check_logged_in", arguments={})
logger.info("Tool execution completed")
logger.info(f" Is error: {result.isError}")
if result.content:
response_text = result.content[0].text
logger.info(f" Response: {response_text}")
else:
logger.warning(" No content in response")
# Validate tool execution succeeded
assert result.isError is False, f"Tool execution failed: {result.content}"
assert result.content is not None, "No content in tool response"
response_text = result.content[0].text.lower()
# Validate elicitation was triggered
elicitation_count = client.elicitation_triggered["count"]
logger.info(f"✓ Elicitation triggered {elicitation_count} time(s)")
assert elicitation_count >= 1, (
"Elicitation callback should have been invoked at least once"
)
# Validate OAuth completed successfully and tool returned "yes"
assert "yes" in response_text, (
f"Expected 'yes' after successful OAuth via elicitation, got: {response_text}"
)
logger.info("✅ Test passed: Real elicitation callback completed OAuth flow")
logger.info("=" * 80)
async def test_elicitation_callback_url_extraction(
nc_mcp_oauth_client_with_elicitation,
):
"""Test that elicitation callback correctly extracts OAuth URL.
This validates the URL extraction logic in the callback by examining
the elicitation message format returned by check_logged_in.
"""
client = nc_mcp_oauth_client_with_elicitation
logger.info("Testing OAuth URL extraction from elicitation message...")
# Revoke refresh tokens to force elicitation
await revoke_refresh_tokens(client)
# Call check_logged_in to trigger elicitation
result = await client.call_tool("check_logged_in", arguments={})
# Should succeed (callback extracts URL and completes OAuth)
assert result.isError is False
assert "yes" in result.content[0].text.lower()
# Elicitation should have been triggered
assert client.elicitation_triggered["count"] >= 1
logger.info("✓ URL extraction and OAuth completion successful")
async def test_elicitation_stores_refresh_token(
nc_mcp_oauth_client_with_elicitation,
):
"""Test that refresh token is stored after elicitation completes.
Validates that after successful OAuth via elicitation:
1. check_logged_in returns "yes"
2. check_provisioning_status shows is_provisioned=true
"""
client = nc_mcp_oauth_client_with_elicitation
logger.info("Testing refresh token storage after elicitation...")
# Revoke refresh tokens to force elicitation
await revoke_refresh_tokens(client)
# Complete OAuth via elicitation
result = await client.call_tool("check_logged_in", arguments={})
assert result.isError is False
assert "yes" in result.content[0].text.lower()
# Verify refresh token was stored
logger.info("Checking provisioning status...")
status_result = await client.call_tool("check_provisioning_status", arguments={})
assert status_result.isError is False
status_text = status_result.content[0].text.lower()
# Server should report provisioning complete
assert "is_provisioned" in status_text or "offline" in status_text, (
f"Expected provisioning status, got: {status_text}"
)
logger.info("✓ Refresh token stored successfully after elicitation")
async def test_second_check_logged_in_does_not_elicit(
nc_mcp_oauth_client_with_elicitation,
):
"""Test that second call to check_logged_in does not trigger elicitation.
After successful OAuth via elicitation:
- First call: triggers elicitation, completes OAuth, returns "yes"
- Second call: no elicitation (already logged in), returns "yes"
"""
client = nc_mcp_oauth_client_with_elicitation
logger.info("Testing that already-logged-in users don't get elicited...")
# First call: triggers elicitation
result1 = await client.call_tool("check_logged_in", arguments={})
assert result1.isError is False
assert "yes" in result1.content[0].text.lower()
elicitation_count_after_first = client.elicitation_triggered["count"]
logger.info(f"After first call: {elicitation_count_after_first} elicitations")
# Second call: should NOT trigger elicitation (already logged in)
result2 = await client.call_tool("check_logged_in", arguments={})
assert result2.isError is False
assert "yes" in result2.content[0].text.lower()
elicitation_count_after_second = client.elicitation_triggered["count"]
logger.info(f"After second call: {elicitation_count_after_second} elicitations")
# Elicitation count should be the same (no new elicitation)
assert elicitation_count_after_second == elicitation_count_after_first, (
"Second check_logged_in should not trigger elicitation "
"(user is already logged in)"
)
logger.info("✓ Already-logged-in users don't get redundant elicitations")
-630
View File
@@ -1,630 +0,0 @@
"""
Tests for Dynamic Client Registration (DCR) with Keycloak external IdP.
These tests verify that DCR (RFC 7591) and client deletion (RFC 7592)
work correctly with Keycloak as an external identity provider:
1. Client registration via Keycloak's DCR endpoint
2. Token acquisition with dynamically registered client
3. MCP tool execution with Keycloak-issued tokens
4. Client deletion via RFC 7592
5. Error handling for DCR operations
This validates ADR-002 external IdP integration where clients are
dynamically provisioned rather than pre-configured.
Architecture:
MCP Client → Keycloak DCR → Keycloak OAuth → MCP Server → Nextcloud APIs
"""
import json
import logging
import os
import secrets
import time
from urllib.parse import quote
import anyio
import httpx
import pytest
from nextcloud_mcp_server.auth.client_registration import delete_client, register_client
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.keycloak]
# ============================================================================
# Helper Functions
# ============================================================================
async def handle_keycloak_login(page, username: str, password: str):
"""
Handle Keycloak login page.
Keycloak uses:
- input#username for username field
- input#password for password field
- Form submission via JavaScript (more reliable than clicking button)
"""
logger.info(f"Handling Keycloak login for user: {username}")
logger.info(f"Current URL before login: {page.url}")
# Wait for username field and fill it
await page.wait_for_selector("input#username", timeout=10000)
await page.fill("input#username", username)
# Fill password field
await page.wait_for_selector("input#password", timeout=10000)
await page.fill("input#password", password)
# Submit form using JavaScript (more reliable than clicking button)
logger.info("Submitting Keycloak login form...")
async with page.expect_navigation(timeout=60000):
await page.evaluate("document.querySelector('form').submit()")
logger.info(f"✓ Keycloak login completed, redirected to: {page.url}")
async def handle_keycloak_consent(page, client_name: str):
"""
Handle Keycloak OAuth consent screen.
Keycloak consent screen has:
- Checkbox inputs for each scope
- Button with name="accept" to grant consent
- Button with name="cancel" to deny consent
"""
logger.info(f"Handling Keycloak consent for client: {client_name}")
try:
# Wait for consent screen (button with name="accept")
await page.wait_for_selector('button[name="accept"]', timeout=5000)
# Click accept button and wait for navigation
async with page.expect_navigation(timeout=60000):
await page.click('button[name="accept"]')
logger.info("✓ Keycloak consent granted")
except Exception as e:
# Consent screen might not appear if already consented
logger.debug(f"No consent screen or already authorized: {e}")
async def get_keycloak_oauth_token_with_client(
browser,
client_id: str,
client_secret: str,
token_endpoint: str,
authorization_endpoint: str,
callback_url: str,
auth_states: dict,
scopes: str = "openid profile email notes:read notes:write",
username: str = "admin",
password: str = "admin",
) -> str:
"""
Obtain OAuth access token from Keycloak using dynamically registered client.
Args:
browser: Playwright browser instance
client_id: OAuth client ID (from DCR registration)
client_secret: OAuth client secret (from DCR registration)
token_endpoint: Keycloak token endpoint URL
authorization_endpoint: Keycloak authorization endpoint URL
callback_url: Callback URL for OAuth redirect
auth_states: Dict for storing auth codes (from callback server)
scopes: Space-separated list of scopes to request
username: Keycloak username (default: admin)
password: Keycloak password (default: admin)
Returns:
Access token string
"""
# Generate unique state parameter
state = secrets.token_urlsafe(32)
# URL-encode scopes
scopes_encoded = quote(scopes, safe="")
# Construct authorization URL
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}"
)
logger.info("Starting OAuth flow with Keycloak...")
logger.info(f"Authorization URL: {auth_url[:100]}...")
# Browser automation
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
current_url = page.url
logger.info(f"Current URL after navigation: {current_url[:100]}...")
# Check if we're on Keycloak login page
if "/realms/" in current_url and "/protocol/openid-connect/auth" in current_url:
# We're on the Keycloak authorization page, might need to login
try:
# Check if login form is present
await page.wait_for_selector("input#username", timeout=3000)
await handle_keycloak_login(page, username, password)
except Exception as e:
logger.debug(f"No login form found, might already be logged in: {e}")
# Handle consent screen if present
await handle_keycloak_consent(page, "DCR Test Client")
# Wait for callback
logger.info("Waiting for OAuth callback...")
timeout_seconds = 30
start_time = time.time()
while state not in auth_states:
if time.time() - start_time > timeout_seconds:
raise TimeoutError(
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
)
await anyio.sleep(0.5)
auth_code = auth_states[state]
logger.info(f"Got auth code: {auth_code[:20]}...")
finally:
await context.close()
# Exchange code for token
logger.info("Exchanging authorization code for access token...")
async with httpx.AsyncClient(timeout=30.0) as http_client:
token_response = await http_client.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": callback_url,
"client_id": client_id,
"client_secret": client_secret,
},
)
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("Successfully obtained access token from Keycloak")
return access_token
# ============================================================================
# DCR Registration Tests
# ============================================================================
@pytest.mark.integration
async def test_keycloak_dcr_registration(anyio_backend, oauth_callback_server):
"""
Test that DCR registration works with Keycloak.
Verifies:
- Keycloak's DCR endpoint is discoverable via OIDC discovery
- Client registration succeeds (RFC 7591)
- Registration response includes client_id, client_secret
- Registration response includes RFC 7592 fields (registration_access_token, registration_client_uri)
"""
keycloak_discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
"http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
)
auth_states, callback_url = oauth_callback_server
# OIDC Discovery
logger.info("Discovering Keycloak OIDC endpoints...")
async with httpx.AsyncClient(timeout=30.0) as client:
discovery_response = await client.get(keycloak_discovery_url)
discovery_response.raise_for_status()
oidc_config = discovery_response.json()
registration_endpoint = oidc_config.get("registration_endpoint")
if not registration_endpoint:
pytest.skip(
"Keycloak DCR not enabled (no registration_endpoint in discovery)"
)
logger.info(f"✓ Found registration endpoint: {registration_endpoint}")
# Register client
logger.info("Registering OAuth client via Keycloak DCR...")
client_info = await register_client(
nextcloud_url=keycloak_discovery_url.replace(
"/.well-known/openid-configuration", ""
),
registration_endpoint=registration_endpoint,
client_name="Keycloak DCR Test Client",
redirect_uris=[callback_url],
scopes="openid profile email notes:read notes:write",
token_type=None, # Keycloak doesn't support token_type field
)
assert client_info.client_id, "Registration should return client_id"
assert client_info.client_secret, "Registration should return client_secret"
logger.info(f"✓ Client registered: {client_info.client_id[:16]}...")
# Verify RFC 7592 fields are present
assert client_info.registration_access_token, (
"Keycloak should return registration_access_token for RFC 7592 deletion"
)
assert client_info.registration_client_uri, (
"Keycloak should return registration_client_uri for RFC 7592 operations"
)
logger.info("✓ RFC 7592 fields present in registration response")
# Cleanup: Delete the client
logger.info("Cleaning up: deleting test client...")
keycloak_host = keycloak_discovery_url.replace(
"/.well-known/openid-configuration", ""
)
success = await delete_client(
nextcloud_url=keycloak_host,
client_id=client_info.client_id,
registration_access_token=client_info.registration_access_token,
client_secret=client_info.client_secret,
registration_client_uri=client_info.registration_client_uri,
)
assert success, "Cleanup deletion should succeed"
logger.info("✓ Test client deleted successfully")
# ============================================================================
# Complete DCR Lifecycle Tests
# ============================================================================
@pytest.mark.integration
async def test_keycloak_dcr_complete_lifecycle(
anyio_backend,
browser,
oauth_callback_server,
nc_mcp_keycloak_client,
):
"""
Test the complete DCR lifecycle with Keycloak:
1. Register client via DCR (RFC 7591)
2. Obtain OAuth token with registered client
3. Use token to access MCP tools
4. Delete client via RFC 7592
This is the end-to-end test that validates DCR works for external IdPs.
"""
keycloak_discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
"http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
)
auth_states, callback_url = oauth_callback_server
# Step 1: OIDC Discovery
logger.info("Step 1: Discovering Keycloak OIDC endpoints...")
async with httpx.AsyncClient(timeout=30.0) as client:
discovery_response = await client.get(keycloak_discovery_url)
discovery_response.raise_for_status()
oidc_config = discovery_response.json()
registration_endpoint = oidc_config.get("registration_endpoint")
token_endpoint = oidc_config.get("token_endpoint")
authorization_endpoint = oidc_config.get("authorization_endpoint")
if not registration_endpoint:
pytest.skip(
"Keycloak DCR not enabled (no registration_endpoint in discovery)"
)
logger.info(f"✓ Registration endpoint: {registration_endpoint}")
logger.info(f"✓ Token endpoint: {token_endpoint}")
logger.info(f"✓ Authorization endpoint: {authorization_endpoint}")
# Step 2: Register client
logger.info("Step 2: Registering OAuth client via Keycloak DCR...")
keycloak_host = keycloak_discovery_url.replace(
"/.well-known/openid-configuration", ""
)
client_info = await register_client(
nextcloud_url=keycloak_host,
registration_endpoint=registration_endpoint,
client_name="Keycloak DCR Lifecycle Test",
redirect_uris=[callback_url],
scopes="openid profile email notes:read notes:write calendar:read",
token_type=None, # Keycloak doesn't support token_type field
)
logger.info(f"✓ Client registered: {client_info.client_id[:16]}...")
logger.info(f" Client secret: {client_info.client_secret[:16]}...")
logger.info(
f" Registration token: {client_info.registration_access_token[:16]}..."
)
# Step 3: Obtain OAuth token
logger.info("Step 3: Obtaining OAuth token with registered client...")
access_token = await get_keycloak_oauth_token_with_client(
browser=browser,
client_id=client_info.client_id,
client_secret=client_info.client_secret,
token_endpoint=token_endpoint,
authorization_endpoint=authorization_endpoint,
callback_url=callback_url,
auth_states=auth_states,
scopes="openid profile email notes:read notes:write calendar:read",
username="admin",
password="admin",
)
assert access_token, "Failed to obtain access token"
logger.info(f"✓ Access token obtained: {access_token[:30]}...")
# Step 4: Verify token works with MCP server (optional - requires MCP client setup)
# This step is optional since we already have nc_mcp_keycloak_client fixture
# that uses the pre-configured client. For a full test, you'd create a new
# MCP client with the dynamically registered client, but that's complex.
logger.info("✓ Token can be used with MCP server (verified in other tests)")
# Step 5: Delete client
logger.info("Step 4: Deleting OAuth client via RFC 7592...")
success = await delete_client(
nextcloud_url=keycloak_host,
client_id=client_info.client_id,
registration_access_token=client_info.registration_access_token,
client_secret=client_info.client_secret,
registration_client_uri=client_info.registration_client_uri,
)
assert success, "Client deletion should succeed"
logger.info(f"✓ Client deleted successfully: {client_info.client_id[:16]}...")
# Step 6: Verify deleted client cannot be used
logger.info("Step 5: Verifying deleted client cannot obtain new tokens...")
async with httpx.AsyncClient(timeout=30.0) as http_client:
try:
# Try to use client credentials grant (should fail)
token_response = await http_client.post(
token_endpoint,
data={
"grant_type": "client_credentials",
"client_id": client_info.client_id,
"client_secret": client_info.client_secret,
},
)
# Accept 400 or 401 as valid rejection
if token_response.status_code in [400, 401]:
logger.info(
f"✓ Deleted client correctly rejected ({token_response.status_code})"
)
else:
pytest.fail(
f"Deleted client should not be able to obtain tokens, "
f"but got status {token_response.status_code}"
)
except httpx.HTTPStatusError as e:
if e.response.status_code in [400, 401]:
logger.info("✓ Deleted client correctly rejected")
else:
raise
logger.info("✅ Complete Keycloak DCR lifecycle test passed!")
# ============================================================================
# Error Handling Tests
# ============================================================================
@pytest.mark.integration
async def test_keycloak_dcr_delete_with_wrong_token(
anyio_backend,
oauth_callback_server,
):
"""
Test that deletion fails with wrong registration_access_token.
Verifies:
1. Client registration succeeds
2. Deletion with wrong registration_access_token fails
3. Deletion with correct registration_access_token succeeds
"""
keycloak_discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
"http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
)
auth_states, callback_url = oauth_callback_server
# OIDC Discovery
async with httpx.AsyncClient(timeout=30.0) as client:
discovery_response = await client.get(keycloak_discovery_url)
discovery_response.raise_for_status()
oidc_config = discovery_response.json()
registration_endpoint = oidc_config.get("registration_endpoint")
if not registration_endpoint:
pytest.skip("Keycloak DCR not enabled")
# Register client
logger.info("Registering OAuth client for wrong token test...")
keycloak_host = keycloak_discovery_url.replace(
"/.well-known/openid-configuration", ""
)
client_info = await register_client(
nextcloud_url=keycloak_host,
registration_endpoint=registration_endpoint,
client_name="Keycloak DCR Wrong Token Test",
redirect_uris=[callback_url],
scopes="openid profile email",
token_type=None, # Keycloak doesn't support token_type field
)
logger.info(f"Client registered: {client_info.client_id[:16]}...")
# Try to delete with wrong registration_access_token
logger.info("Attempting deletion with wrong registration_access_token...")
wrong_token = "wrong_token_" + secrets.token_urlsafe(32)
success = await delete_client(
nextcloud_url=keycloak_host,
client_id=client_info.client_id,
registration_access_token=wrong_token,
client_secret=client_info.client_secret,
registration_client_uri=client_info.registration_client_uri,
)
assert not success, "Deletion with wrong token should fail"
logger.info("✓ Deletion correctly failed with wrong token")
# Clean up: Delete with correct token
logger.info("Cleaning up: deleting with correct registration_access_token...")
success = await delete_client(
nextcloud_url=keycloak_host,
client_id=client_info.client_id,
registration_access_token=client_info.registration_access_token,
client_secret=client_info.client_secret,
registration_client_uri=client_info.registration_client_uri,
)
assert success, "Deletion with correct token should succeed"
logger.info("✓ Cleanup successful")
@pytest.mark.integration
async def test_keycloak_dcr_deletion_is_idempotent(
anyio_backend,
oauth_callback_server,
):
"""
Test that deleting the same client twice fails gracefully on second attempt.
Verifies:
1. First deletion succeeds
2. Second deletion fails gracefully (no exception, returns False)
"""
keycloak_discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
"http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
)
auth_states, callback_url = oauth_callback_server
# OIDC Discovery
async with httpx.AsyncClient(timeout=30.0) as client:
discovery_response = await client.get(keycloak_discovery_url)
discovery_response.raise_for_status()
oidc_config = discovery_response.json()
registration_endpoint = oidc_config.get("registration_endpoint")
if not registration_endpoint:
pytest.skip("Keycloak DCR not enabled")
# Register client
logger.info("Registering OAuth client for idempotency test...")
keycloak_host = keycloak_discovery_url.replace(
"/.well-known/openid-configuration", ""
)
client_info = await register_client(
nextcloud_url=keycloak_host,
registration_endpoint=registration_endpoint,
client_name="Keycloak DCR Idempotency Test",
redirect_uris=[callback_url],
scopes="openid profile email",
token_type=None, # Keycloak doesn't support token_type field
)
logger.info(f"Client registered: {client_info.client_id[:16]}...")
# First deletion
logger.info("First deletion attempt...")
success = await delete_client(
nextcloud_url=keycloak_host,
client_id=client_info.client_id,
registration_access_token=client_info.registration_access_token,
client_secret=client_info.client_secret,
registration_client_uri=client_info.registration_client_uri,
)
assert success, "First deletion should succeed"
logger.info("✓ First deletion succeeded")
# Second deletion (should fail gracefully)
logger.info("Second deletion attempt (should fail)...")
success = await delete_client(
nextcloud_url=keycloak_host,
client_id=client_info.client_id,
registration_access_token=client_info.registration_access_token,
client_secret=client_info.client_secret,
registration_client_uri=client_info.registration_client_uri,
)
assert not success, "Second deletion should fail (client already deleted)"
logger.info("✓ Second deletion correctly failed (client already deleted)")
# ============================================================================
# Documentation Tests
# ============================================================================
async def test_keycloak_dcr_architecture():
"""
Document the Keycloak DCR architecture for reference.
This test captures the design and flow for DCR with external IdPs.
"""
architecture = {
"flow": [
"1. MCP client discovers Keycloak OIDC endpoints via .well-known/openid-configuration",
"2. MCP client registers via Keycloak DCR endpoint (RFC 7591)",
"3. Keycloak returns client_id, client_secret, registration_access_token",
"4. MCP client uses credentials to obtain OAuth token",
"5. MCP client uses token to authenticate with MCP server",
"6. MCP server validates token via Nextcloud user_oidc app",
"7. When done, MCP client deletes registration via RFC 7592",
],
"components": {
"keycloak_dcr": "Dynamic Client Registration endpoint (RFC 7591)",
"keycloak_oauth": "OAuth/OIDC provider for authentication",
"mcp_server": "MCP server with external IdP config",
"nextcloud": "API server with user_oidc app for token validation",
},
"advantages": [
"No manual client pre-configuration required",
"Clients can self-register and self-cleanup",
"Standards-based (RFC 7591, RFC 7592)",
"Works with any compliant OIDC provider",
"Supports dynamic callback URL registration",
],
"security": [
"Registration tokens protect client management operations",
"Clients can only delete themselves (not others)",
"Token validation ensures only authorized access",
"Automatic cleanup prevents client sprawl",
],
}
logger.info("Keycloak DCR Architecture:")
logger.info(json.dumps(architecture, indent=2))
assert True
@@ -1,566 +0,0 @@
"""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)"
)
@@ -1,246 +0,0 @@
"""Integration tests for login elicitation flow (ADR-006 Interim Implementation).
Tests verify:
1. check_logged_in tool with elicitation for unauthenticated users
2. Elicitation contains login URL in message
3. User can complete login via OAuth
4. After login, check_logged_in returns "yes"
5. Already-authenticated users get immediate "yes" response
6. Elicitation decline/cancel handling
"""
import logging
import re
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def test_check_logged_in_elicitation_flow(
nc_mcp_oauth_client, browser, oauth_callback_server
):
"""Test that check_logged_in elicits login for unauthenticated user.
This test validates the complete elicitation flow:
1. Call check_logged_in on authenticated client (already has refresh token)
2. Verify tool returns "yes" without elicitation
3. Extract and validate the elicitation URL format from response
4. Verify refresh token exists after successful OAuth flow
Note: Actual elicitation handling requires MCP protocol support in the test client.
This test validates the response format and token storage.
"""
# Call check_logged_in tool on authenticated client
logger.info("Calling check_logged_in on authenticated client")
result = await nc_mcp_oauth_client.call_tool("check_logged_in", arguments={})
assert result.isError is False, f"Tool execution failed: {result.content}"
assert result.content is not None
response_text = result.content[0].text
logger.info(f"check_logged_in response: {response_text}")
# Since nc_mcp_oauth_client fixture already completes OAuth during setup,
# the user should already be provisioned and we expect "yes"
# For unauthenticated users, the response would contain an elicitation URL
# Note: Test framework may return "elicitation not supported" if MCP elicitation is unavailable
assert (
"yes" in response_text.lower()
or "http" in response_text.lower()
or "elicitation not supported" in response_text.lower()
), f"Unexpected response: {response_text}"
# If response contains a URL (elicitation case), validate its format
if "http" in response_text:
url_pattern = r"https?://[^\s]+"
urls = re.findall(url_pattern, response_text)
assert len(urls) > 0, "Expected elicitation URL in response"
login_url = urls[0]
logger.info(f"Elicitation URL: {login_url}")
# Validate URL points to MCP server's Flow 2 endpoint
assert "/oauth/authorize-nextcloud" in login_url, (
f"Expected URL to point to MCP server Flow 2 endpoint, got: {login_url}"
)
# Validate URL contains state parameter
assert "state=" in login_url, "Expected state parameter in elicitation URL"
elif "elicitation not supported" in response_text.lower():
logger.info(
"✓ Test client doesn't support elicitation - this is expected in test environment"
)
async def test_check_logged_in_already_authenticated(nc_mcp_oauth_client):
"""Test that check_logged_in returns 'yes' for authenticated user.
This test verifies that if the user has already completed Flow 2
(resource provisioning), the tool immediately returns "yes" without
elicitation.
"""
logger.info("Calling check_logged_in on authenticated client")
# Since we're using the nc_mcp_oauth_client fixture which completes
# OAuth during setup, the user should already be provisioned
result = await nc_mcp_oauth_client.call_tool("check_logged_in", arguments={})
assert result.isError is False, f"Tool execution failed: {result.content}"
assert result.content is not None
response_text = result.content[0].text
logger.info(f"Response: {response_text}")
# Check for valid responses:
# - "yes" (already logged in)
# - "not enabled" (offline access not enabled)
# - "not configured" (MCP_SERVER_CLIENT_ID not set)
# - "elicitation not supported" (test environment limitation)
assert (
"yes" in response_text.lower()
or "not enabled" in response_text.lower()
or "not configured" in response_text.lower()
or "elicitation not supported" in response_text.lower()
)
async def test_check_logged_in_url_format(nc_mcp_oauth_client):
"""Test that login URL (when needed) follows correct OAuth format.
This test verifies that if the tool needs to provide a login URL,
the URL contains the correct OAuth parameters for Flow 2.
"""
# Call the tool
result = await nc_mcp_oauth_client.call_tool("check_logged_in", arguments={})
assert result.isError is False, f"Tool execution failed: {result.content}"
assert result.content is not None
response_text = result.content[0].text
logger.info(f"Response: {response_text}")
# If response contains a URL, validate it
url_pattern = r"https?://[^\s]+"
urls = re.findall(url_pattern, response_text)
if urls:
login_url = urls[0]
logger.info(f"Found login URL: {login_url}")
# Validate OAuth parameters
assert "response_type=code" in login_url
assert "client_id=" in login_url
assert "redirect_uri=" in login_url
assert "scope=" in login_url
assert "state=" in login_url
assert "openid" in login_url # Should request openid scope
# Validate callback URL (unified endpoint without query params)
# Note: redirect_uri should be /oauth/callback (no query params)
# Flow type is determined by session lookup, not URL params
assert (
"/oauth/callback" in login_url
or "callback-nextcloud" in login_url # Legacy support
or "authorize-nextcloud" in login_url
)
async def test_check_logged_in_with_user_id(nc_mcp_oauth_client):
"""Test that check_logged_in accepts optional user_id parameter.
This verifies the tool can be called with an explicit user_id.
"""
result = await nc_mcp_oauth_client.call_tool(
"check_logged_in", arguments={"user_id": "testuser"}
)
assert result.isError is False, f"Tool execution failed: {result.content}"
assert result.content is not None
response_text = result.content[0].text
logger.info(f"Response with user_id: {response_text}")
# Should get some response (either yes or not logged in)
assert len(response_text) > 0
async def test_check_logged_in_tool_metadata(nc_mcp_oauth_client):
"""Test that check_logged_in tool has correct metadata."""
tools = await nc_mcp_oauth_client.list_tools()
assert tools is not None
# Find the check_logged_in tool
check_logged_in_tool = None
for tool in tools.tools:
if tool.name == "check_logged_in":
check_logged_in_tool = tool
break
assert check_logged_in_tool is not None, "check_logged_in tool not found"
logger.info(f"Tool: {check_logged_in_tool.name}")
logger.info(f"Description: {check_logged_in_tool.description}")
# Verify description mentions login
assert "login" in check_logged_in_tool.description.lower()
# Tool should have openid scope requirement
# (This would need to be verified via tool schema if exposed)
async def test_elicitation_url_and_refresh_token_flow(nc_mcp_oauth_client):
"""Test that MCP server validates refresh tokens after OAuth completion.
This test validates the server's refresh token handling through its API:
1. Call check_provisioning_status to verify server-side token validation
2. Server responses indicate token state:
- is_provisioned=True: Server has valid refresh token
- is_provisioned=False: No token or invalid token
- Error response: Token validation failed
The test does NOT directly access refresh token storage - it relies on
the MCP server to validate tokens internally and report status via API.
"""
logger.info("Testing server-side refresh token validation via API")
# Call check_provisioning_status - the server will internally:
# 1. Check if refresh token exists for the user
# 2. Validate the refresh token is not expired
# 3. Return provisioning status
result = await nc_mcp_oauth_client.call_tool(
"check_provisioning_status", arguments={}
)
assert result.isError is False, f"Tool execution failed: {result.content}"
assert result.content is not None
response_text = result.content[0].text
logger.info(f"Provisioning status response: {response_text}")
# Parse the response to validate server's token validation
# Expected responses:
# 1. "is_provisioned: true" - server validated token successfully
# 2. "is_provisioned: false" - no token or invalid token
# 3. Error message - token validation failed
if "is_provisioned" in response_text.lower():
if "true" in response_text.lower():
logger.info("✓ Server validated refresh token: is_provisioned=True")
logger.info(" This confirms the server has a valid refresh token stored")
else:
logger.info("Server reports: is_provisioned=False (no valid token)")
elif "error" in response_text.lower():
logger.warning(
f"Server returned error during token validation: {response_text}"
)
else:
logger.info(f"Server response: {response_text}")
# The key validation: Server must return a valid response
# (not an error), proving it can check its own refresh token state
assert (
"is_provisioned" in response_text.lower() or "offline" in response_text.lower()
), f"Expected provisioning status response from server, got: {response_text}"
logger.info("✓ Server successfully validated refresh token state via API")
@@ -1,92 +0,0 @@
"""Debug test to capture what's on the NC PHP app settings page."""
import logging
import os
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server):
"""Capture what's actually rendered on the personal settings page."""
# Configure Astrolabe for mcp-oauth server
await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-oauth:8001",
mcp_server_public_url="http://localhost:8001",
)
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
username = os.getenv("NEXTCLOUD_USERNAME", "admin")
password = os.getenv("NEXTCLOUD_PASSWORD", "admin")
context = await browser.new_context()
page = await context.new_page()
try:
# Login
logger.info(f"Logging in to {nextcloud_host} as {username}...")
await page.goto(f"{nextcloud_host}/login")
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
await page.click('button[type="submit"]')
await page.wait_for_url(f"{nextcloud_host}/apps/dashboard/", timeout=10000)
logger.info("✓ Logged in")
# Navigate to settings
logger.info("Navigating to personal MCP settings...")
await page.goto(f"{nextcloud_host}/settings/user/astrolabe")
await page.wait_for_load_state("networkidle")
# Capture page content
page_content = await page.content()
# Save screenshot
screenshot_path = "/tmp/nc-php-app-settings-debug.png"
await page.screenshot(path=screenshot_path, full_page=True)
logger.info(f"Screenshot saved to: {screenshot_path}")
# Log what we found
logger.info(f"Page URL: {page.url}")
logger.info(f"Page title: {await page.title()}")
# Check for key strings (Vue 3 UI)
checks = [
"Enable Semantic Search", # oauth-required.php authorization button
"Service Status", # personal.php when authorized
"Background Sync Access", # personal.php when authorized
"What happens next?", # oauth-required.php steps
"Astrolabe", # Header
]
for check in checks:
found = check in page_content
logger.info(f" '{check}': {'FOUND' if found else 'NOT FOUND'}")
# Print first 500 chars of body
body = await page.locator("body").text_content()
logger.info(f"Body text (first 500 chars): {body[:500] if body else 'NO BODY'}")
# Try to find links
links = await page.locator("a").all_text_contents()
logger.info(f"Found {len(links)} links on page")
for i, link_text in enumerate(links[:10]):
logger.info(f" Link {i}: {link_text}")
# Check the Enable Semantic Search button href
try:
btn = page.locator('a:has-text("Enable Semantic Search")')
if await btn.count() > 0:
href = await btn.get_attribute("href")
logger.info(f"Enable Semantic Search button href: {href}")
except Exception as e:
logger.warning(f"Could not get button href: {e}")
# Check for error messages
if "error" in page_content.lower():
logger.warning("Page contains 'error' keyword")
finally:
await context.close()
-414
View File
@@ -1,414 +0,0 @@
"""Test OAuth authorization flow for Nextcloud PHP app (astrolabe).
Tests the complete PKCE OAuth flow from the NC PHP app perspective:
1. User navigates to personal settings
2. Clicks "Authorize Access" button
3. Completes OAuth authorization via Nextcloud OIDC app
4. Token is stored encrypted in Nextcloud database
5. App can use token to call MCP management API
This tests the architecture from ADR-018 where the NC PHP app uses
OAuth PKCE (public client) to obtain tokens from Nextcloud's OIDC app.
"""
import logging
import os
import httpx
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
@pytest.fixture(scope="module")
def nextcloud_credentials():
"""Get Nextcloud credentials from environment."""
return {
"host": os.getenv("NEXTCLOUD_HOST", "http://localhost:8080"),
"username": os.getenv("NEXTCLOUD_USERNAME", "admin"),
"password": os.getenv("NEXTCLOUD_PASSWORD", "admin"),
}
@pytest.fixture(scope="module")
async def nc_admin_http_client(nextcloud_credentials):
"""HTTP client authenticated as admin user for NC API calls."""
async with httpx.AsyncClient(
base_url=nextcloud_credentials["host"],
auth=(nextcloud_credentials["username"], nextcloud_credentials["password"]),
timeout=30.0,
) as client:
yield client
@pytest.fixture(scope="module")
async def configure_astrolabe_for_tests(configure_astrolabe_for_mcp_server):
"""Configure Astrolabe to connect to mcp-oauth server before running tests.
This module-scoped fixture ensures Astrolabe is properly configured
for the mcp-oauth server (http://localhost:8001) before any tests run.
"""
logger.info("Configuring Astrolabe for mcp-oauth server...")
await configure_astrolabe_for_mcp_server(
mcp_server_internal_url="http://mcp-oauth:8001",
mcp_server_public_url="http://localhost:8001",
)
logger.info("✓ Astrolabe configured for mcp-oauth server")
@pytest.fixture(scope="module")
async def authorized_nc_session(
browser, nextcloud_credentials, configure_astrolabe_for_tests
):
"""Module-scoped fixture that logs in and authorizes the NC PHP app once.
This fixture:
1. Configures Astrolabe for mcp-oauth server (via configure_astrolabe_for_tests)
2. Creates a browser context
3. Logs in to Nextcloud
4. Authorizes the MCP Server UI app (if not already authorized)
5. Returns the page for use in all tests
The authorization is done once and reused for all tests in this module.
"""
host = nextcloud_credentials["host"]
username = nextcloud_credentials["username"]
password = nextcloud_credentials["password"]
logger.info("Setting up module-scoped authorized NC session...")
# Create browser context that persists for module duration
context = await browser.new_context()
page = await context.new_page()
# Enable console message logging
page.on(
"console", lambda msg: logger.debug(f"Browser console [{msg.type}]: {msg.text}")
)
page.on("pageerror", lambda err: logger.error(f"Browser page error: {err}"))
try:
# Step 1: Login to Nextcloud
logger.info(f"Logging in to Nextcloud as {username}...")
await page.goto(f"{host}/login")
# Fill login form
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
await page.click('button[type="submit"]')
# Wait for login to complete (dashboard loads)
await page.wait_for_url(f"{host}/apps/dashboard/", timeout=10000)
logger.info("✓ Logged in successfully")
# Step 2: Navigate to personal MCP settings
logger.info("Navigating to personal MCP settings...")
await page.goto(f"{host}/settings/user/astrolabe")
await page.wait_for_load_state("networkidle")
page_content = await page.content()
# Step 3: Check if authorization is needed
# Vue 3 UI shows "Enable Semantic Search" when not authorized
if (
"Enable Semantic Search" in page_content
or "What happens next?" in page_content
):
logger.info("User not authorized yet - initiating OAuth flow...")
# Click "Enable Semantic Search" button (Vue 3 template text)
authorize_selectors = [
'a:has-text("Enable Semantic Search")',
'button:has-text("Enable Semantic Search")',
'a:has-text("Sign In Again")',
"a.button.primary",
'[href*="oauth/login"]',
]
clicked = False
for selector in authorize_selectors:
try:
await page.click(selector, timeout=2000)
clicked = True
logger.info(f"✓ Clicked authorize button (selector: {selector})")
break
except Exception:
continue
if not clicked:
screenshot_path = "/tmp/nc-php-app-settings.png"
await page.screenshot(path=screenshot_path)
pytest.fail(
f"Could not find authorize button. Screenshot: {screenshot_path}"
)
# Wait for page to load after clicking
await page.wait_for_load_state("networkidle", timeout=10000)
current_url = page.url
logger.info(f"After clicking authorize, current URL: {current_url}")
# Take screenshot for debugging
await page.screenshot(path="/tmp/nc-php-app-after-authorize-click.png")
logger.info("Screenshot saved to /tmp/nc-php-app-after-authorize-click.png")
# Handle OAuth consent if needed
if (
"/apps/oidc/authorize" in current_url
or "/apps/oidc/consent" in current_url
):
logger.info("On OIDC authorization page - granting consent...")
consent_selectors = [
'button:has-text("Allow")',
'button:has-text("Authorize")',
'input[type="submit"][value="Allow"]',
'button[type="submit"]',
]
for selector in consent_selectors:
try:
await page.click(selector, timeout=2000)
logger.info(f"✓ Clicked consent button (selector: {selector})")
break
except Exception:
continue
# Wait for redirect back to settings
await page.wait_for_url(f"{host}/settings/user/astrolabe", timeout=15000)
await page.wait_for_load_state("networkidle")
logger.info("✓ OAuth authorization completed")
else:
logger.info("User already authorized")
# Return the page and context info for tests
yield {
"page": page,
"context": context,
"host": host,
"username": username,
}
finally:
# Cleanup at module end
logger.info("Closing authorized NC session...")
await context.close()
class TestNcPhpAppOAuth:
"""Test suite for NC PHP app OAuth integration."""
async def test_authorization_completed(self, authorized_nc_session):
"""Verify OAuth authorization was successful.
This test verifies the settings page shows the user is connected
after the module-scoped authorization fixture runs.
"""
page = authorized_nc_session["page"]
host = authorized_nc_session["host"]
# Navigate to settings (may already be there)
await page.goto(f"{host}/settings/user/astrolabe")
await page.wait_for_load_state("networkidle")
page_content = await page.content()
# Look for indicators that authorization succeeded (Vue 3 personal.php template)
# These must be unique to the authorized state (not found in oauth-required.php)
success_indicators = [
"Service Status",
"Background Sync Access",
"Manage Connection",
"Revoke Access",
"Service URL",
]
found_indicators = [ind for ind in success_indicators if ind in page_content]
has_success_indicator = len(found_indicators) > 0
# Always take screenshot for debugging
screenshot_path = "/tmp/nc-php-app-auth-check.png"
await page.screenshot(path=screenshot_path)
logger.info(f"Authorization check screenshot: {screenshot_path}")
logger.info(f"Found success indicators: {found_indicators}")
if not has_success_indicator:
logger.error("Authorization check failed.")
assert has_success_indicator, "Settings page should show user is authorized"
logger.info("✓ Authorization verification passed")
async def test_token_storage_and_retrieval(self, authorized_nc_session):
"""Test that tokens are properly stored and can be retrieved.
Verifies the settings page displays session information,
indicating the token was stored and retrieved successfully.
"""
page = authorized_nc_session["page"]
host = authorized_nc_session["host"]
await page.goto(f"{host}/settings/user/astrolabe")
await page.wait_for_load_state("networkidle")
page_content = await page.content()
# Debug: take screenshot and log content excerpt
screenshot_path = "/tmp/nc-php-app-token-test.png"
await page.screenshot(path=screenshot_path)
logger.info(f"Screenshot saved: {screenshot_path}")
logger.info(f"Page content excerpt: {page_content[:1000]}")
# Verify session information is visible (Vue 3 personal.php template)
session_indicators = [
"Service Status",
"Service URL",
"Version",
"Background Sync Access",
]
found_indicators = [ind for ind in session_indicators if ind in page_content]
assert len(found_indicators) >= 2, (
f"Expected session info on page. Found: {found_indicators}. Check {screenshot_path}"
)
logger.info(f"✓ Token retrieval verified - found: {found_indicators}")
async def test_management_api_access(
self, authorized_nc_session, nc_admin_http_client
):
"""Test that the NC PHP app can access MCP server management API.
Verifies the settings page successfully fetched data from the
MCP server's management API endpoints.
"""
page = authorized_nc_session["page"]
host = authorized_nc_session["host"]
# Check personal settings page shows server status
await page.goto(f"{host}/settings/user/astrolabe")
await page.wait_for_load_state("networkidle")
page_content = await page.content()
# Look for data that comes from management API or template structure (Vue 3)
api_indicators = [
"Service Status", # Section header
"Service URL", # Server info from API
"Version", # Server version from management API
"Semantic Search", # Vector sync status
]
found_api_data = [ind for ind in api_indicators if ind in page_content]
assert len(found_api_data) >= 1, (
f"Expected management API data on page. Found: {found_api_data}"
)
logger.info(f"✓ Management API access verified - found: {found_api_data}")
async def test_admin_settings_page(self, authorized_nc_session):
"""Test that admin settings page loads and displays server info.
The admin page should show server status from the management API.
"""
page = authorized_nc_session["page"]
host = authorized_nc_session["host"]
await page.goto(f"{host}/settings/admin/astrolabe")
await page.wait_for_load_state("networkidle")
page_content = await page.content()
# Admin page should show server status (Vue 3 AdminSettings.vue)
admin_indicators = [
"Astrolabe",
"Service Status",
"Version",
"Semantic Search",
]
found_indicators = [ind for ind in admin_indicators if ind in page_content]
# Admin page should at least show the Astrolabe header or Service Status
assert "Astrolabe" in page_content or "Service Status" in page_content, (
"Admin settings page should show Astrolabe section"
)
logger.info(f"✓ Admin settings page verified - found: {found_indicators}")
class TestNcPhpAppDisconnect:
"""Test suite for NC PHP app disconnect functionality.
Note: These tests are run separately and may modify the authorization state.
They should run after the main OAuth tests.
"""
@pytest.mark.skip(reason="Disconnect test modifies state - run manually if needed")
async def test_disconnect_flow(self, browser, nextcloud_credentials):
"""Test that users can disconnect (revoke) their authorization.
This test:
1. Logs in fresh (separate from authorized_nc_session)
2. Verifies user is authorized
3. Clicks "Disconnect" button
4. Verifies user is no longer authorized
Skipped by default as it modifies authorization state.
"""
host = nextcloud_credentials["host"]
username = nextcloud_credentials["username"]
password = nextcloud_credentials["password"]
context = await browser.new_context()
page = await context.new_page()
try:
# Login
await page.goto(f"{host}/login")
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
await page.click('button[type="submit"]')
await page.wait_for_url(f"{host}/apps/dashboard/", timeout=10000)
# Navigate to personal settings
await page.goto(f"{host}/settings/user/astrolabe")
await page.wait_for_load_state("networkidle")
page_content = await page.content()
# Check if user is authorized (Vue 3 personal.php shows Disconnect/Revoke when authorized)
if "Disconnect" not in page_content and "Revoke Access" not in page_content:
pytest.skip("User not authorized - cannot test disconnect")
# Click disconnect button
disconnect_selectors = [
'button:has-text("Disconnect")',
'form[action*="disconnect"] button',
"#mcp-disconnect-button",
]
for selector in disconnect_selectors:
try:
# Handle confirmation dialog
page.on("dialog", lambda dialog: dialog.accept())
await page.click(selector, timeout=2000)
logger.info(f"✓ Clicked disconnect button (selector: {selector})")
break
except Exception:
continue
# Wait for page reload
await page.wait_for_load_state("networkidle")
# Verify we're back to "Enable Semantic Search" state (Vue 3 oauth-required.php)
page_content = await page.content()
assert "Enable Semantic Search" in page_content, (
"Settings page should show 'Enable Semantic Search' after disconnect"
)
logger.info("✓ Disconnect flow test passed")
finally:
await context.close()
-262
View File
@@ -1,262 +0,0 @@
"""Core OAuth integration tests.
Consolidated from:
- test_mcp_oauth.py: Basic OAuth connectivity
- test_mcp_oauth_jwt.py: JWT-specific operations
- test_jwt_tokens.py: JWT token structure validation
Tests verify:
1. OAuth server connectivity and tool listing
2. Tool execution with OAuth tokens
3. JWT token structure and claims
4. Multiple operations with same token (persistence)
5. Error handling with OAuth
"""
import base64
import json
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
def decode_jwt_without_verification(token: str) -> dict:
"""Decode JWT token without signature verification (for inspection only).
Returns:
Dict with header and payload
"""
parts = token.split(".")
if len(parts) != 3:
raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}")
# Decode header
header = json.loads(
base64.urlsafe_b64decode(parts[0] + "=" * (4 - len(parts[0]) % 4))
)
# Decode payload
payload = json.loads(
base64.urlsafe_b64decode(parts[1] + "=" * (4 - len(parts[1]) % 4))
)
return {
"header": header,
"payload": payload,
}
# ============================================================================
# Basic OAuth Connectivity Tests
# ============================================================================
async def test_mcp_oauth_server_connection(nc_mcp_oauth_client):
"""Test connection to OAuth-enabled MCP server."""
result = await nc_mcp_oauth_client.list_tools()
assert result is not None
assert len(result.tools) > 0
logger.info(f"OAuth MCP server has {len(result.tools)} tools available")
async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client):
"""Test executing a tool on the OAuth-enabled MCP server."""
# Example: Execute the 'nc_notes_search_notes' tool
result = await nc_mcp_oauth_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)
# The search response should have a 'results' field containing the list
assert "results" in response_data
assert isinstance(response_data["results"], list)
logger.info(
f"Successfully executed 'nc_notes_search_notes' tool on OAuth MCP server and got {len(response_data['results'])} notes."
)
async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client):
"""Test that MCP OAuth client via Playwright can execute tools."""
# Test: Execute the 'nc_notes_search_notes' tool
result = await nc_mcp_oauth_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)
# The search response should have a 'results' field containing the list
assert "results" in response_data
assert isinstance(response_data["results"], list)
logger.info(
f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes."
)
# ============================================================================
# JWT-Specific Tests
# ============================================================================
async def test_jwt_tool_list_operations(nc_mcp_oauth_jwt_client):
"""Test that list_tools works with JWT authentication and returns expected tools.
This test verifies that tools are properly filtered based on per-app scopes:
- notes:read/write → Notes app tools
- calendar:read/write → Calendar app tools
- files:read/write → WebDAV/Files app tools
- etc.
"""
result = await nc_mcp_oauth_jwt_client.list_tools()
# Verify we have tools
assert len(result.tools) > 0
# Verify expected tools exist based on configured scopes
tool_names = [tool.name for tool in result.tools]
# Notes tools (require notes:read and notes:write)
assert "nc_notes_get_note" in tool_names, "Missing nc_notes_get_note (notes:read)"
assert "nc_notes_create_note" in tool_names, (
"Missing nc_notes_create_note (notes:write)"
)
# Calendar tools (require calendar:read and calendar:write)
assert "nc_calendar_list_calendars" in tool_names, (
"Missing nc_calendar_list_calendars (calendar:read)"
)
assert "nc_calendar_create_event" in tool_names, (
"Missing nc_calendar_create_event (calendar:write)"
)
# Verify we have a reasonable number of tools for the configured scopes
# With notes + calendar scopes, expect ~20-30 tools
assert len(tool_names) >= 20, (
f"Expected at least 20 tools with notes+calendar scopes, got {len(tool_names)}"
)
logger.info(
f"JWT OAuth server provides {len(result.tools)} tools with configured per-app scopes"
)
async def test_jwt_multiple_operations(nc_mcp_oauth_jwt_client):
"""Test multiple operations with same JWT token to verify token persistence.
JWT tokens should work across multiple tool calls without re-authentication,
demonstrating that the token is properly cached and reused.
"""
# First operation: Search notes
result1 = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result1.isError is False
# Second operation: List calendars
result2 = await nc_mcp_oauth_jwt_client.call_tool(
"nc_calendar_list_calendars", arguments={}
)
assert result2.isError is False
# Third operation: List directory
result3 = await nc_mcp_oauth_jwt_client.call_tool(
"nc_webdav_list_directory", arguments={"path": "/"}
)
assert result3.isError is False
logger.info(
"Successfully executed 3 different operations with same JWT token (token persistence verified)"
)
async def test_jwt_error_handling(nc_mcp_oauth_jwt_client):
"""Test error handling with JWT authentication.
Verifies that invalid operations return proper errors even with valid JWT tokens.
"""
# Try to get a non-existent note
result = await nc_mcp_oauth_jwt_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("JWT OAuth server correctly handles errors for invalid operations")
# ============================================================================
# JWT Token Structure Tests
# ============================================================================
async def test_jwt_tokens_embed_scopes_in_payload():
"""Document that JWT tokens embed scopes in the payload (RFC 9068).
This test documents expected JWT structure based on manual testing.
"""
expected_structure = {
"header": {
"typ": "at+JWT", # RFC 9068 access token type
"alg": "RS256", # Signature algorithm
},
"payload_claims": {
"iss": "issuer URL",
"sub": "user ID",
"aud": "client ID",
"exp": "expiration timestamp",
"iat": "issued at timestamp",
"scope": "space-separated scope string (e.g., 'notes:read notes:write')",
"client_id": "client identifier",
"jti": "JWT ID",
},
"scope_claim": {
"format": "space-separated string",
"example": "openid profile email notes:read notes:write",
"extraction": "payload['scope'].split()",
},
}
logger.info("JWT token structure (RFC 9068):")
logger.info(json.dumps(expected_structure, indent=2))
# This test documents expected behavior
assert True
async def test_opaque_token_vs_jwt_comparison():
"""Document differences between opaque tokens and JWT tokens.
This test captures our findings about the two token types.
"""
findings = {
"jwt_advantages": [
"Scopes embedded in payload - no introspection needed",
"Self-contained - can validate with JWKS",
"Standard approach (RFC 9068)",
],
"jwt_disadvantages": [
"10-15x larger than opaque tokens (~800-1200 chars vs 72)",
"Cannot be easily revoked (until expiration)",
],
"token_sizes": {
"opaque": "72 characters",
"jwt": "~800-1200 characters",
},
"recommendation": "Use JWT for MCP server (scopes available without introspection)",
}
logger.info("JWT vs Opaque token comparison:")
logger.info(json.dumps(findings, indent=2))
assert True
@@ -1,350 +0,0 @@
"""
Multi-user OAuth tests for Nextcloud Deck board permissions.
Tests verify that the MCP server respects Nextcloud Deck board ACL permissions
when accessed via OAuth authentication with different users.
"""
import json
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def add_board_acl(nc_client, board_id: int, user: str, permission_type: int = 0):
"""
Helper to add ACL entry to a Deck board.
Args:
nc_client: Admin NextcloudClient
board_id: Board ID
user: Username to grant access
permission_type: 0=view, 1=edit, 2=manage
Returns:
ACL entry ID
"""
acl = await nc_client.deck.add_acl_rule(
board_id=board_id,
type=0, # 0 = user, 1 = group
participant=user,
permission_edit=permission_type >= 1,
permission_share=permission_type >= 2,
permission_manage=permission_type >= 2,
)
logger.info(f"Added ACL for board {board_id}: {user} (type={permission_type})")
return acl.id
async def delete_board_acl(nc_client, board_id: int, acl_id: int):
"""Helper to delete a board ACL entry."""
await nc_client.deck.delete_acl_rule(board_id, acl_id)
logger.info(f"Deleted ACL {acl_id} from board {board_id}")
async def test_deck_board_view_permissions(
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
):
"""
Test that Deck boards respect view permissions.
Scenario:
1. Admin creates a board as alice
2. Admin adds bob to board with view-only permissions
3. Bob can view the board via MCP tools
4. Diana cannot access the board (no ACL entry)
"""
# Create a board as alice
logger.info("Creating Deck board as alice...")
board = await nc_client.deck.create_board(
"Alice's Shared Board - View Test", "FF0000"
)
board_id = board.id
bob_acl_id = None
try:
# Add bob to board with view-only permission
logger.info("Adding bob to board with view permission...")
bob_acl_id = await add_board_acl(nc_client, board_id, "bob", permission_type=0)
# Test: Bob can view the board via MCP
logger.info("Bob attempting to list boards via MCP...")
result = await bob_mcp_client.call_tool("deck_get_boards", arguments={})
if not result.isError:
response_data = json.loads(result.content[0].text)
# Response is a ListBoardsResponse with a "boards" field
board_list = response_data.get("boards", [])
board_ids = [b["id"] for b in board_list]
logger.info(f"Bob can see {len(board_list)} boards: {board_ids}")
# Bob should see the shared board
if board_id in board_ids:
logger.info(f"Bob can see shared board {board_id}")
else:
logger.warning(f"Bob cannot see shared board {board_id}")
else:
logger.warning(f"Bob could not list boards: {result.content}")
# Test: Diana cannot see the board
logger.info("Diana attempting to list boards via MCP...")
result = await diana_mcp_client.call_tool("deck_get_boards", arguments={})
if not result.isError:
response_data = json.loads(result.content[0].text)
# Response is a ListBoardsResponse with a "boards" field
board_list = response_data.get("boards", [])
board_ids = [b["id"] for b in board_list]
logger.info(f"Diana can see {len(board_list)} boards")
# Diana should NOT see the board
assert board_id not in board_ids, "Diana should not see board without ACL"
logger.info("Diana correctly cannot see board without ACL")
else:
logger.warning(f"Diana could not list boards: {result.content}")
finally:
# Cleanup
if bob_acl_id:
await delete_board_acl(nc_client, board_id, bob_acl_id)
logger.info(f"Deleting board {board_id}")
await nc_client.deck.delete_board(board_id)
async def test_deck_board_edit_permissions(
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
):
"""
Test that Deck boards respect edit permissions.
Scenario:
1. Admin creates a board as alice with a stack
2. Admin adds charlie with edit permission
3. Admin adds bob with view-only permission
4. Charlie can create cards via MCP tools
5. Bob cannot create cards
"""
# Create a board as alice
logger.info("Creating Deck board as alice...")
board = await nc_client.deck.create_board(
"Alice's Shared Board - Edit Test", "00FF00"
)
board_id = board.id
# Create a stack in the board
logger.info("Creating stack in board...")
stack = await nc_client.deck.create_stack(board_id, "Test Stack", 1)
stack_id = stack.id
charlie_acl_id = None
bob_acl_id = None
try:
# Add charlie with edit permission
logger.info("Adding charlie to board with edit permission...")
charlie_acl_id = await add_board_acl(
nc_client, board_id, "charlie", permission_type=1
)
# Add bob with view-only permission
logger.info("Adding bob to board with view permission...")
bob_acl_id = await add_board_acl(nc_client, board_id, "bob", permission_type=0)
# Test: Charlie can create a card
logger.info("Charlie attempting to create card via MCP...")
result = await charlie_mcp_client.call_tool(
"deck_create_card",
arguments={
"board_id": board_id,
"stack_id": stack_id,
"title": "Charlie's Card",
"description": "Created by Charlie with edit permission",
},
)
if not result.isError:
response_data = json.loads(result.content[0].text)
card_id = response_data.get("id")
logger.info(f"Charlie successfully created card {card_id}")
# Cleanup the card
await nc_client.deck.delete_card(board_id, stack_id, card_id)
else:
logger.warning(f"Charlie could not create card: {result.content}")
# Test: Bob attempts to create a card (should fail)
logger.info("Bob attempting to create card via MCP...")
result = await bob_mcp_client.call_tool(
"deck_create_card",
arguments={
"board_id": board_id,
"stack_id": stack_id,
"title": "Bob's Card",
"description": "Bob trying to create a card",
},
)
if result.isError:
logger.info("Bob correctly denied card creation (view-only)")
else:
logger.warning("Bob unexpectedly succeeded in creating card")
# Cleanup if bob somehow created a card
response_data = json.loads(result.content[0].text)
if "id" in response_data:
await nc_client.deck.delete_card(
board_id, stack_id, response_data["id"]
)
finally:
# Cleanup
if charlie_acl_id:
await delete_board_acl(nc_client, board_id, charlie_acl_id)
if bob_acl_id:
await delete_board_acl(nc_client, board_id, bob_acl_id)
logger.info(f"Deleting board {board_id}")
await nc_client.deck.delete_board(board_id)
async def test_deck_board_manage_permissions(
nc_client, alice_mcp_client, charlie_mcp_client
):
"""
Test that Deck boards respect manage permissions.
Scenario:
1. Admin creates a board as alice
2. Admin adds charlie with manage permission
3. Charlie can create stacks and modify board settings
"""
# Create a board as alice
logger.info("Creating Deck board as alice...")
board = await nc_client.deck.create_board(
"Alice's Shared Board - Manage Test", "0000FF"
)
board_id = board.id
charlie_acl_id = None
try:
# Add charlie with manage permission
logger.info("Adding charlie to board with manage permission...")
charlie_acl_id = await add_board_acl(
nc_client, board_id, "charlie", permission_type=2
)
# Test: Charlie can create a stack
logger.info("Charlie attempting to create stack via MCP...")
result = await charlie_mcp_client.call_tool(
"deck_create_stack",
arguments={"board_id": board_id, "title": "Charlie's Stack", "order": 1},
)
if not result.isError:
response_data = json.loads(result.content[0].text)
stack_id = response_data.get("id")
logger.info(f"Charlie successfully created stack {stack_id}")
# Cleanup the stack
await nc_client.deck.delete_stack(board_id, stack_id)
else:
logger.warning(f"Charlie could not create stack: {result.content}")
# Test: Charlie can delete a stack (manage permission)
logger.info("Charlie attempting to delete stack via MCP...")
# First create a temporary stack to delete
temp_stack = await nc_client.deck.create_stack(
board_id, "Temp Stack for Deletion", 99
)
result = await charlie_mcp_client.call_tool(
"deck_delete_stack",
arguments={"board_id": board_id, "stack_id": temp_stack.id},
)
if not result.isError:
logger.info("Charlie successfully deleted stack")
else:
logger.warning(f"Charlie could not delete stack: {result.content}")
# Cleanup if deletion via MCP failed
try:
await nc_client.deck.delete_stack(board_id, temp_stack.id)
except Exception:
pass
finally:
# Cleanup
if charlie_acl_id:
await delete_board_acl(nc_client, board_id, charlie_acl_id)
logger.info(f"Deleting board {board_id}")
await nc_client.deck.delete_board(board_id)
async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
"""
Test that users can only see their own boards when not shared.
Scenario:
1. Admin creates a board as alice (not shared)
2. Admin creates a board as bob (not shared)
3. Alice can only see her own board
4. Bob can only see his own board
"""
# Create alice's board
logger.info("Creating alice's private board...")
alice_board = await nc_client.deck.create_board("Alice's Private Board", "FF00FF")
alice_board_id = alice_board.id
# Create bob's board
logger.info("Creating bob's private board...")
bob_board = await nc_client.deck.create_board("Bob's Private Board", "00FFFF")
bob_board_id = bob_board.id
try:
# Test: Alice lists boards
logger.info("Alice listing boards via MCP...")
result = await alice_mcp_client.call_tool("deck_get_boards", arguments={})
if not result.isError:
response_data = json.loads(result.content[0].text)
# Response is a ListBoardsResponse with a "boards" field
board_list = response_data.get("boards", [])
board_ids = [b["id"] for b in board_list]
logger.info(f"Alice can see boards: {board_ids}")
# Alice should NOT see Bob's board
assert bob_board_id not in board_ids, (
"Alice should not see Bob's private board"
)
else:
logger.warning(f"Alice could not list boards: {result.content}")
# Test: Bob lists boards
logger.info("Bob listing boards via MCP...")
result = await bob_mcp_client.call_tool("deck_get_boards", arguments={})
if not result.isError:
response_data = json.loads(result.content[0].text)
# Response is a ListBoardsResponse with a "boards" field
board_list = response_data.get("boards", [])
board_ids = [b["id"] for b in board_list]
logger.info(f"Bob can see boards: {board_ids}")
# Bob should NOT see Alice's board
assert alice_board_id not in board_ids, (
"Bob should not see Alice's private board"
)
else:
logger.warning(f"Bob could not list boards: {result.content}")
logger.info("User isolation test passed: users can only see their own boards")
finally:
# Cleanup
logger.info("Cleaning up test boards...")
await nc_client.deck.delete_board(alice_board_id)
await nc_client.deck.delete_board(bob_board_id)
@@ -1,421 +0,0 @@
"""
Multi-user OAuth tests for Nextcloud WebDAV file permissions.
Tests verify that the MCP server respects Nextcloud file sharing permissions
when accessed via OAuth authentication with different users.
All operations (file creation, sharing, access) are performed through MCP tools
to ensure the MCP server properly supports multi-user scenarios.
"""
import json
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def test_file_share_read_permissions(
alice_mcp_client, bob_mcp_client, diana_mcp_client
):
"""
Test that shared files respect read permissions.
Scenario:
1. Alice creates a file via MCP
2. Alice shares the file with Bob (read-only) via MCP
3. Bob can read the file via MCP tools
4. Diana cannot access the file (no share)
"""
file_path = "/alice_shared_file_read.txt"
file_content = "This file is shared with Bob for reading only."
# Alice creates a file
logger.info(f"Alice creating file: {file_path}")
result = await alice_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": file_path, "content": file_content},
)
assert not result.isError, f"Alice failed to create file: {result.content}"
share_id = None
try:
# Alice shares the file with bob (read-only, permissions=1)
logger.info("Alice sharing file with bob (read-only)...")
result = await alice_mcp_client.call_tool(
"nc_share_create",
arguments={
"path": file_path,
"share_with": "bob",
"share_type": 0,
"permissions": 1,
},
)
assert not result.isError, f"Alice failed to create share: {result.content}"
share_data = json.loads(result.content[0].text)
share_id = share_data["id"]
logger.info(f"Created share {share_id}")
# Test: Bob reads the file via MCP
logger.info("Bob attempting to read file via MCP...")
result = await bob_mcp_client.call_tool(
"nc_webdav_read_file", arguments={"path": file_path}
)
# Bob should be able to read the shared file
if not result.isError:
response_data = json.loads(result.content[0].text)
logger.info(
f"Bob successfully read file: {response_data.get('content', '')[:50]}..."
)
assert "content" in response_data
assert file_content in response_data["content"]
else:
logger.warning(f"Bob could not read file: {result.content}")
# This might fail if the share path is different for bob
# Test: Diana attempts to read the file
logger.info("Diana attempting to read file via MCP...")
result = await diana_mcp_client.call_tool(
"nc_webdav_read_file", arguments={"path": file_path}
)
# Diana should NOT be able to read (no share)
if result.isError:
logger.info("Diana correctly denied access to unshared file")
else:
logger.warning("Diana unexpectedly could read unshared file")
finally:
# Cleanup - Alice deletes the share and file
if share_id:
logger.info(f"Alice deleting share {share_id}")
await alice_mcp_client.call_tool(
"nc_share_delete", arguments={"share_id": share_id}
)
logger.info(f"Alice deleting file {file_path}")
await alice_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": file_path}
)
async def test_file_share_write_permissions(
alice_mcp_client, charlie_mcp_client, bob_mcp_client
):
"""
Test that shared files respect write permissions.
Scenario:
1. Alice creates a file via MCP
2. Alice shares the file with Charlie (edit permission) via MCP
3. Alice shares the file with Bob (read-only) via MCP
4. Charlie can edit the file via MCP tools
5. Bob cannot edit the file
"""
file_path = "/alice_shared_file_write.txt"
file_content = "This file is shared with Charlie for editing."
logger.info(f"Alice creating file: {file_path}")
result = await alice_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": file_path, "content": file_content},
)
assert not result.isError, f"Alice failed to create file: {result.content}"
charlie_share_id = None
bob_share_id = None
try:
# Alice shares with Charlie (read+write, permissions=3)
logger.info("Alice sharing file with Charlie (edit permission)...")
result = await alice_mcp_client.call_tool(
"nc_share_create",
arguments={
"path": file_path,
"share_with": "charlie",
"share_type": 0,
"permissions": 3,
},
)
assert not result.isError, (
f"Alice failed to share with Charlie: {result.content}"
)
charlie_share_data = json.loads(result.content[0].text)
charlie_share_id = charlie_share_data["id"]
logger.info(f"Created share {charlie_share_id} for Charlie")
# Alice shares with Bob (read-only, permissions=1)
logger.info("Alice sharing file with Bob (read-only)...")
result = await alice_mcp_client.call_tool(
"nc_share_create",
arguments={
"path": file_path,
"share_with": "bob",
"share_type": 0,
"permissions": 1,
},
)
assert not result.isError, f"Alice failed to share with Bob: {result.content}"
bob_share_data = json.loads(result.content[0].text)
bob_share_id = bob_share_data["id"]
logger.info(f"Created share {bob_share_id} for Bob")
# Test: Charlie can write to the file
logger.info("Charlie attempting to write to file via MCP...")
updated_content = f"{file_content}\nCharlie added this line."
result = await charlie_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": file_path, "content": updated_content},
)
if not result.isError:
logger.info("Charlie successfully wrote to file")
else:
logger.warning(f"Charlie could not write to file: {result.content}")
# Test: Bob attempts to write (should fail)
logger.info("Bob attempting to write to file via MCP...")
result = await bob_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": file_path, "content": "Bob tries to overwrite this."},
)
# Bob should be denied
if result.isError:
logger.info("Bob correctly denied write access")
else:
logger.warning("Bob unexpectedly succeeded in writing (permissions issue?)")
finally:
# Cleanup - Alice deletes shares and file
if charlie_share_id:
logger.info(f"Alice deleting Charlie's share {charlie_share_id}")
await alice_mcp_client.call_tool(
"nc_share_delete", arguments={"share_id": charlie_share_id}
)
if bob_share_id:
logger.info(f"Alice deleting Bob's share {bob_share_id}")
await alice_mcp_client.call_tool(
"nc_share_delete", arguments={"share_id": bob_share_id}
)
logger.info(f"Alice deleting file {file_path}")
await alice_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": file_path}
)
async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
"""
Test that file listing respects share permissions.
Scenario:
1. Alice creates her private file via MCP
2. Bob creates his private file via MCP
3. Alice creates a file and shares it with Bob via MCP
4. Alice can list her own files + shared files
5. Bob can list his own files + shared files from Alice
"""
alice_file = "/alice_private_file.txt"
bob_file = "/bob_private_file.txt"
shared_file = "/alice_shared_with_bob.txt"
# Alice creates her private file
logger.info(f"Alice creating private file: {alice_file}")
result = await alice_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": alice_file, "content": "Alice's private file"},
)
assert not result.isError, f"Alice failed to create file: {result.content}"
# Bob creates his private file
logger.info(f"Bob creating private file: {bob_file}")
result = await bob_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": bob_file, "content": "Bob's private file"},
)
assert not result.isError, f"Bob failed to create file: {result.content}"
# Alice creates a shared file
logger.info(f"Alice creating shared file: {shared_file}")
result = await alice_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": shared_file, "content": "Shared file content"},
)
assert not result.isError, f"Alice failed to create shared file: {result.content}"
share_id = None
try:
# Alice shares the file with Bob
logger.info("Alice sharing file with Bob...")
result = await alice_mcp_client.call_tool(
"nc_share_create",
arguments={
"path": shared_file,
"share_with": "bob",
"share_type": 0,
"permissions": 1,
},
)
assert not result.isError, f"Alice failed to create share: {result.content}"
share_data = json.loads(result.content[0].text)
share_id = share_data["id"]
# Test: Alice lists files in root
logger.info("Alice listing files via MCP...")
result = await alice_mcp_client.call_tool(
"nc_webdav_list_directory", arguments={"path": "/"}
)
if not result.isError:
response_data = json.loads(result.content[0].text)
# Extract files from DirectoryListing response
files = response_data.get("files", [])
file_names = [f["name"] for f in files]
logger.info(f"Alice can see files: {file_names}")
# Alice should see her own files
# Note: Exact assertions depend on test isolation
else:
logger.warning(f"Alice could not list files: {result.content}")
# Test: Bob lists files in root
logger.info("Bob listing files via MCP...")
result = await bob_mcp_client.call_tool(
"nc_webdav_list_directory", arguments={"path": "/"}
)
if not result.isError:
response_data = json.loads(result.content[0].text)
# Extract files from DirectoryListing response
files = response_data.get("files", [])
file_names = [f["name"] for f in files]
logger.info(f"Bob can see files: {file_names}")
# Bob should see his own file, but not Alice's private file
# Bob may see shared files in his shared folder or via different path
else:
logger.warning(f"Bob could not list files: {result.content}")
finally:
# Cleanup
if share_id:
logger.info(f"Alice deleting share {share_id}")
await alice_mcp_client.call_tool(
"nc_share_delete", arguments={"share_id": share_id}
)
logger.info("Cleaning up Alice's files...")
await alice_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": alice_file}
)
await alice_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": shared_file}
)
logger.info("Cleaning up Bob's files...")
await bob_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": bob_file}
)
async def test_folder_share_permissions(alice_mcp_client, bob_mcp_client):
"""
Test that folder sharing works correctly.
Scenario:
1. Alice creates a folder via MCP
2. Alice creates files in the folder via MCP
3. Alice shares the folder with Bob via MCP
4. Bob can access files in the shared folder via MCP
"""
folder_path = "/alice_shared_folder"
file_in_folder = f"{folder_path}/document.txt"
file_content = "This is a document in Alice's shared folder"
# Alice creates folder
logger.info(f"Alice creating folder: {folder_path}")
result = await alice_mcp_client.call_tool(
"nc_webdav_create_directory", arguments={"path": folder_path}
)
assert not result.isError, f"Alice failed to create folder: {result.content}"
# Alice creates file in folder
logger.info(f"Alice creating file in folder: {file_in_folder}")
result = await alice_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": file_in_folder, "content": file_content},
)
assert not result.isError, f"Alice failed to create file: {result.content}"
share_id = None
try:
# Alice shares the folder with Bob
logger.info("Alice sharing folder with Bob...")
result = await alice_mcp_client.call_tool(
"nc_share_create",
arguments={
"path": folder_path,
"share_with": "bob",
"share_type": 0,
"permissions": 1,
},
)
assert not result.isError, f"Alice failed to create share: {result.content}"
share_data = json.loads(result.content[0].text)
share_id = share_data["id"]
logger.info(f"Created folder share {share_id}")
# Test: Bob lists the shared folder
logger.info("Bob attempting to list shared folder via MCP...")
result = await bob_mcp_client.call_tool(
"nc_webdav_list_directory", arguments={"path": folder_path}
)
if not result.isError:
response_data = json.loads(result.content[0].text)
# Extract files from DirectoryListing response
files = response_data.get("files", [])
logger.info(f"Bob can see {len(files)} files in shared folder")
# Bob should see the file in the shared folder
file_names = [f["name"] for f in files]
assert "document.txt" in file_names, (
"Bob should see the file in shared folder"
)
else:
logger.warning(f"Bob could not list shared folder: {result.content}")
# Test: Bob reads the file in the shared folder
logger.info("Bob attempting to read file in shared folder via MCP...")
result = await bob_mcp_client.call_tool(
"nc_webdav_read_file", arguments={"path": file_in_folder}
)
if not result.isError:
response_data = json.loads(result.content[0].text)
logger.info("Bob successfully read file in shared folder")
assert "content" in response_data
assert file_content in response_data["content"]
else:
logger.warning(
f"Bob could not read file in shared folder: {result.content}"
)
finally:
# Cleanup - Alice deletes the share and folder
if share_id:
logger.info(f"Alice deleting share {share_id}")
await alice_mcp_client.call_tool(
"nc_share_delete", arguments={"share_id": share_id}
)
logger.info("Alice cleaning up test folder...")
await alice_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": folder_path}
)
@@ -1,256 +0,0 @@
"""
Multi-user OAuth tests for Nextcloud Notes permissions.
Tests verify that the MCP server respects Nextcloud Notes sharing permissions
when accessed via OAuth authentication with different users.
"""
import json
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def test_notes_share_read_permissions(
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
):
"""
Test that shared notes respect read permissions.
Scenario:
1. Admin creates a note as alice
2. Admin shares the note with bob (read-only)
3. Bob can read the note via MCP tools
4. Diana cannot access the note (no share)
"""
# Create a note as alice (using admin client to set up data)
note_title = "Alice's Shared Note - Read Test"
note_content = "This note is shared with Bob for reading only."
note_category = "SharedNotes"
logger.info("Creating note as alice...")
created_note = await nc_client.notes.create_note(
title=note_title, content=note_content, category=note_category
)
note_id = created_note.get("id")
try:
# TODO: Share the note with bob (read-only)
# Note: Nextcloud Notes API doesn't have direct sharing endpoints
# Sharing is typically done at the folder level via WebDAV
# For now, this test documents the expected behavior
# Test: Bob searches for notes via MCP
logger.info("Bob searching for notes via MCP...")
result = await bob_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": "Alice's Shared"}
)
assert result.isError is False, f"Bob's search failed: {result.content}"
response_data = json.loads(result.content[0].text)
# Bob should see the shared note in search results
# (assuming proper share setup)
assert "results" in response_data
logger.info(f"Bob found {len(response_data['results'])} notes")
# Test: Diana searches for the same note
logger.info("Diana searching for notes via MCP...")
result = await diana_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": "Alice's Shared"}
)
assert result.isError is False
response_data = json.loads(result.content[0].text)
# Diana should NOT see the note (no share)
assert "results" in response_data
shared_note_ids = [
n["id"] for n in response_data["results"] if n["id"] == note_id
]
assert len(shared_note_ids) == 0, "Diana should not see unshared note"
logger.info("Diana correctly cannot see unshared note")
finally:
# Cleanup
logger.info(f"Cleaning up note {note_id}")
await nc_client.notes.delete_note(note_id)
async def test_notes_share_write_permissions(
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
):
"""
Test that shared notes respect write permissions.
Scenario:
1. Admin creates a note as alice
2. Admin shares the note with charlie (edit permission)
3. Admin shares the note with bob (read-only)
4. Charlie can edit the note via MCP tools
5. Bob cannot edit the note
"""
# Create a note as alice
note_title = "Alice's Shared Note - Write Test"
note_content = "This note is shared with Charlie for editing."
note_category = "SharedNotes"
logger.info("Creating note as alice...")
created_note = await nc_client.notes.create_note(
title=note_title, content=note_content, category=note_category
)
note_id = created_note.get("id")
try:
# TODO: Share the note with charlie (edit permission) and bob (read-only)
# Note: Nextcloud Notes sharing is folder-based
# Test: Charlie can append content to the note
logger.info("Charlie attempting to append content via MCP...")
result = await charlie_mcp_client.call_tool(
"nc_notes_append_content",
arguments={
"note_id": note_id,
"content": "\n\nCharlie added this content.",
},
)
# If sharing is properly configured, Charlie should succeed
# Without proper sharing setup, this will fail
logger.info(f"Charlie's append result: isError={result.isError}")
if not result.isError:
logger.info("Charlie successfully appended content (shares configured)")
else:
logger.warning("Charlie could not append (shares not yet configured)")
# Test: Bob attempts to append content (should fail)
logger.info("Bob attempting to append content via MCP...")
result = await bob_mcp_client.call_tool(
"nc_notes_append_content",
arguments={"note_id": note_id, "content": "\n\nBob tried to add this."},
)
# Bob should fail (read-only access)
logger.info(f"Bob's append result: isError={result.isError}")
if result.isError:
logger.info("Bob correctly denied write access")
else:
logger.warning("Bob unexpectedly succeeded (permissions issue?)")
finally:
# Cleanup
logger.info(f"Cleaning up note {note_id}")
await nc_client.notes.delete_note(note_id)
async def test_user_isolation_notes(nc_client, alice_mcp_client, bob_mcp_client):
"""
Test that users can only see their own notes when not shared.
Scenario:
1. Admin creates a note as alice (not shared)
2. Admin creates a note as bob (not shared)
3. Alice can only see her own note
4. Bob can only see his own note
"""
# Create alice's note
logger.info("Creating alice's private note...")
alice_note = await nc_client.notes.create_note(
title="Alice's Private Note",
content="This is Alice's private content.",
category="AlicePrivate",
)
alice_note_id = alice_note.get("id")
# Create bob's note
logger.info("Creating bob's private note...")
bob_note = await nc_client.notes.create_note(
title="Bob's Private Note",
content="This is Bob's private content.",
category="BobPrivate",
)
bob_note_id = bob_note.get("id")
try:
# Test: Alice searches all notes
logger.info("Alice searching all notes via MCP...")
result = await alice_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result.isError is False
response_data = json.loads(result.content[0].text)
alice_notes = response_data.get("results", [])
alice_note_ids = [n["id"] for n in alice_notes]
logger.info(f"Alice can see {len(alice_notes)} notes")
# Alice should NOT see Bob's note
assert bob_note_id not in alice_note_ids, (
"Alice should not see Bob's private note"
)
# Test: Bob searches all notes
logger.info("Bob searching all notes via MCP...")
result = await bob_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result.isError is False
response_data = json.loads(result.content[0].text)
bob_notes = response_data.get("results", [])
bob_note_ids = [n["id"] for n in bob_notes]
logger.info(f"Bob can see {len(bob_notes)} notes")
# Bob should NOT see Alice's note
assert alice_note_id not in bob_note_ids, (
"Bob should not see Alice's private note"
)
logger.info("User isolation test passed: users can only see their own notes")
finally:
# Cleanup
logger.info("Cleaning up test notes...")
await nc_client.notes.delete_note(alice_note_id)
await nc_client.notes.delete_note(bob_note_id)
async def test_oauth_mcp_clients_initialized(
alice_mcp_client, bob_mcp_client, charlie_mcp_client, diana_mcp_client
):
"""
Smoke test to verify all OAuth MCP clients are properly initialized.
"""
logger.info("Testing alice_mcp_client initialization...")
result = await alice_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result.isError is False, f"Alice MCP client failed: {result.content}"
logger.info("Alice MCP client working")
logger.info("Testing bob_mcp_client initialization...")
result = await bob_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result.isError is False, f"Bob MCP client failed: {result.content}"
logger.info("Bob MCP client working")
logger.info("Testing charlie_mcp_client initialization...")
result = await charlie_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result.isError is False, f"Charlie MCP client failed: {result.content}"
logger.info("Charlie MCP client working")
logger.info("Testing diana_mcp_client initialization...")
result = await diana_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result.isError is False, f"Diana MCP client failed: {result.content}"
logger.info("Diana MCP client working")
logger.info("All OAuth MCP clients successfully initialized!")
-436
View File
@@ -1,436 +0,0 @@
"""Unit tests for RFC 8693 Token Exchange (ADR-004).
Tests the critical token exchange pattern that separates:
- Session tokens (ephemeral, on-demand)
- Background tokens (stored refresh tokens)
"""
import os
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch
import jwt
import pytest
from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
from nextcloud_mcp_server.auth.token_exchange import TokenExchangeService
pytestmark = pytest.mark.unit
@pytest.fixture
async def token_storage():
"""Create test token storage."""
# Generate valid Fernet key
encryption_key = Fernet.generate_key()
# Create temporary database file
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
db_path = tmp.name
storage = RefreshTokenStorage(db_path=db_path, encryption_key=encryption_key)
await storage.initialize()
# Expose encryption key for tests that need to manually encrypt/decrypt
storage._test_encryption_key = encryption_key
yield storage
# Cleanup
if os.path.exists(db_path):
os.unlink(db_path)
@pytest.fixture
async def token_exchange_service(token_storage):
"""Create test token exchange service."""
service = TokenExchangeService(
oidc_discovery_url="http://test-idp/.well-known/openid-configuration",
client_id="test-client",
client_secret="test-secret",
nextcloud_host="http://test-nextcloud",
)
service.storage = token_storage
yield service
await service.http_client.aclose()
@pytest.fixture
async def token_broker(token_storage):
"""Create test token broker service."""
broker = TokenBrokerService(
storage=token_storage,
oidc_discovery_url="http://test-idp/.well-known/openid-configuration",
nextcloud_host="http://test-nextcloud",
client_id="test-client",
client_secret="test-secret",
cache_ttl=300,
cache_early_refresh=30,
)
yield broker
await broker.close()
def create_test_jwt(
user_id: str = "testuser", audience: str = "mcp-server", expires_in: int = 3600
) -> str:
"""Create a test JWT token."""
import time
payload = {
"sub": user_id,
"aud": audience,
"exp": int(time.time()) + expires_in,
"iat": int(time.time()),
"iss": "http://test-idp",
}
# For testing, we don't sign the token (uses 'none' algorithm)
# In production, tokens would be properly signed
return jwt.encode(payload, "", algorithm="none")
class TestTokenExchange:
"""Test RFC 8693 token exchange implementation."""
async def test_validate_flow1_token_success(self, token_exchange_service):
"""Test validation of Flow 1 token with correct audience."""
# Create token with correct audience
flow1_token = create_test_jwt(audience="mcp-server")
# Should not raise an exception
await token_exchange_service._validate_flow1_token(flow1_token)
async def test_validate_flow1_token_wrong_audience(self, token_exchange_service):
"""Test validation fails with wrong audience."""
# Create token with wrong audience
flow1_token = create_test_jwt(audience="nextcloud")
with pytest.raises(ValueError, match="Invalid token audience"):
await token_exchange_service._validate_flow1_token(flow1_token)
async def test_validate_flow1_token_expired(self, token_exchange_service):
"""Test validation fails with expired token."""
# Create expired token
flow1_token = create_test_jwt(audience="mcp-server", expires_in=-3600)
with pytest.raises(ValueError, match="Token has expired"):
await token_exchange_service._validate_flow1_token(flow1_token)
async def test_extract_user_id(self, token_exchange_service):
"""Test extraction of user ID from token."""
flow1_token = create_test_jwt(user_id="alice")
user_id = token_exchange_service._extract_user_id(flow1_token)
assert user_id == "alice"
async def test_check_provisioning_not_provisioned(self, token_exchange_service):
"""Test provisioning check when user not provisioned."""
result = await token_exchange_service._check_provisioning("unknown_user")
assert result is False
async def test_check_provisioning_is_provisioned(
self, token_exchange_service, token_storage
):
"""Test provisioning check when user is provisioned."""
# Store a refresh token for user
await token_storage.store_refresh_token(
user_id="alice", refresh_token="encrypted_refresh_token", flow_type="flow2"
)
result = await token_exchange_service._check_provisioning("alice")
assert result is True
async def test_exchange_token_not_provisioned(self, token_exchange_service):
"""Test token exchange fails when user not provisioned."""
flow1_token = create_test_jwt(user_id="unprovisioneduser")
with pytest.raises(RuntimeError, match="Nextcloud access not provisioned"):
await token_exchange_service.exchange_token_for_delegation(
flow1_token=flow1_token,
requested_scopes=["notes:read"],
requested_audience="nextcloud",
)
async def test_exchange_token_with_fallback(
self, token_exchange_service, token_storage
):
"""Test token exchange with refresh grant fallback."""
# Store a refresh token for user
await token_storage.store_refresh_token(
user_id="alice", refresh_token="test_refresh_token", flow_type="flow2"
)
# Create Flow 1 token
flow1_token = create_test_jwt(user_id="alice", audience="mcp-server")
# Mock HTTP client for token endpoint
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "delegated_token_12345",
"token_type": "Bearer",
"expires_in": 300, # 5 minutes
}
with patch.object(
token_exchange_service.http_client, "post", return_value=mock_response
):
# Mock discovery endpoint
with patch.object(
token_exchange_service,
"_discover_endpoints",
return_value={"token_endpoint": "http://test-idp/token"},
):
# Perform exchange
(
token,
expires_in,
) = await token_exchange_service.exchange_token_for_delegation(
flow1_token=flow1_token,
requested_scopes=["notes:read"],
requested_audience="nextcloud",
)
assert token == "delegated_token_12345"
assert expires_in == 300
class TestTokenBroker:
"""Test Token Broker session/background separation."""
async def test_get_session_token(self, token_broker, token_storage):
"""Test getting ephemeral session token via exchange."""
# Store refresh token for user
await token_storage.store_refresh_token(
user_id="alice", refresh_token="test_refresh_token", flow_type="flow2"
)
# Create Flow 1 token
flow1_token = create_test_jwt(user_id="alice", audience="mcp-server")
# Mock token exchange
with patch(
"nextcloud_mcp_server.auth.token_broker.exchange_token_for_delegation",
return_value=("ephemeral_token_xyz", 300),
):
token = await token_broker.get_session_token(
flow1_token=flow1_token,
required_scopes=["notes:read"],
requested_audience="nextcloud",
)
assert token == "ephemeral_token_xyz"
# Verify token is NOT cached (ephemeral)
cached = await token_broker.cache.get("alice")
assert cached is None # Should not be in cache
async def test_get_background_token(self, token_broker, token_storage):
"""Test getting background token with stored refresh."""
# Store encrypted refresh token for user
from cryptography.fernet import Fernet
# Use the same encryption key as token_storage/token_broker
fernet = Fernet(token_storage._test_encryption_key)
encrypted_token = fernet.encrypt(b"background_refresh_token").decode()
await token_storage.store_refresh_token(
user_id="alice", refresh_token=encrypted_token, flow_type="flow2"
)
# Mock OIDC config and token response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "background_token_abc",
"token_type": "Bearer",
"expires_in": 3600, # 1 hour
}
with patch.object(
token_broker,
"_get_oidc_config",
return_value={"token_endpoint": "http://test/token"},
):
with patch.object(token_broker, "_get_http_client") as mock_client:
mock_client.return_value.post = AsyncMock(return_value=mock_response)
# Mock audience validation
with patch.object(
token_broker, "_validate_token_audience", return_value=None
):
token = await token_broker.get_background_token(
user_id="alice", required_scopes=["notes:sync", "files:sync"]
)
assert token == "background_token_abc"
# Verify token IS cached (background tokens can be cached)
cache_key = "alice:background:files:sync,notes:sync"
cached = await token_broker.cache.get(cache_key)
assert cached == "background_token_abc"
async def test_session_background_separation(self, token_broker, token_storage):
"""Test that session and background tokens are kept separate."""
# Store refresh token
from cryptography.fernet import Fernet
# Use the same encryption key as token_storage/token_broker
fernet = Fernet(token_storage._test_encryption_key)
encrypted_token = fernet.encrypt(b"master_refresh_token").decode()
await token_storage.store_refresh_token(
user_id="alice", refresh_token=encrypted_token, flow_type="flow2"
)
flow1_token = create_test_jwt(user_id="alice", audience="mcp-server")
# Mock different tokens for session vs background
session_token = "ephemeral_session_123"
background_token = "cached_background_456"
# Get session token
with patch(
"nextcloud_mcp_server.auth.token_broker.exchange_token_for_delegation",
return_value=(session_token, 300),
):
session_result = await token_broker.get_session_token(
flow1_token=flow1_token, required_scopes=["notes:read"]
)
assert session_result == session_token
# Get background token
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": background_token,
"expires_in": 3600,
}
with patch.object(
token_broker,
"_get_oidc_config",
return_value={"token_endpoint": "http://test/token"},
):
with patch.object(token_broker, "_get_http_client") as mock_client:
mock_client.return_value.post = AsyncMock(return_value=mock_response)
with patch.object(
token_broker, "_validate_token_audience", return_value=None
):
background_result = await token_broker.get_background_token(
user_id="alice", required_scopes=["notes:sync"]
)
assert background_result == background_token
# Verify they are different tokens
assert session_result != background_result
# Verify session token not cached
assert await token_broker.cache.get("alice") is None
# Verify background token IS cached
cache_key = "alice:background:notes:sync"
assert await token_broker.cache.get(cache_key) == background_token
class TestScopeDownscoping:
"""Test that tokens request only necessary scopes."""
async def test_session_token_minimal_scopes(
self, token_exchange_service, token_storage
):
"""Test session tokens request minimal scopes."""
# Store refresh token
await token_storage.store_refresh_token(
user_id="alice", refresh_token="test_refresh_token", flow_type="flow2"
)
flow1_token = create_test_jwt(user_id="alice", audience="mcp-server")
# Track what scopes are requested
requested_scopes = None
async def mock_post(url, data, headers=None):
nonlocal requested_scopes
requested_scopes = data.get("scope", "").split()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "scoped_token",
"expires_in": 300,
}
return mock_response
with patch.object(
token_exchange_service.http_client, "post", side_effect=mock_post
):
with patch.object(
token_exchange_service,
"_discover_endpoints",
return_value={"token_endpoint": "http://test/token"},
):
await token_exchange_service.exchange_token_for_delegation(
flow1_token=flow1_token,
requested_scopes=["notes:read"], # Only read scope
requested_audience="nextcloud",
)
# Verify only requested scope was included
assert "notes:read" in requested_scopes
assert "notes:write" not in requested_scopes
assert "calendar:write" not in requested_scopes
async def test_background_token_different_scopes(self, token_broker, token_storage):
"""Test background tokens can request different scopes than session."""
from cryptography.fernet import Fernet
# Use the same encryption key as token_storage/token_broker
fernet = Fernet(token_storage._test_encryption_key)
encrypted_token = fernet.encrypt(b"refresh_token").decode()
await token_storage.store_refresh_token(
user_id="alice", refresh_token=encrypted_token, flow_type="flow2"
)
# Track requested scopes
requested_scopes = None
async def mock_post(url, data, headers=None):
nonlocal requested_scopes
requested_scopes = data.get("scope", "").split()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "background_sync_token",
"expires_in": 3600,
}
return mock_response
with patch.object(
token_broker,
"_get_oidc_config",
return_value={"token_endpoint": "http://test/token"},
):
with patch.object(token_broker, "_get_http_client") as mock_client:
mock_client.return_value.post = mock_post
with patch.object(
token_broker, "_validate_token_audience", return_value=None
):
await token_broker.get_background_token(
user_id="alice",
required_scopes=["notes:sync", "files:sync", "calendar:sync"],
)
# Verify sync scopes were requested
assert "notes:sync" in requested_scopes
assert "files:sync" in requested_scopes
assert "calendar:sync" in requested_scopes
# Basic OIDC scopes should also be included
assert "openid" in requested_scopes
assert "profile" in requested_scopes
-16
View File
@@ -102,19 +102,3 @@ async def test_webdav_basic_smoke(nc_mcp_client):
data = json.loads(result.content[0].text)
assert "files" in data
assert isinstance(data["files"], list)
@pytest.mark.oauth
async def test_oauth_connectivity_smoke(nc_mcp_oauth_client):
"""Smoke test: Verify OAuth authentication works."""
# List tools with OAuth
result = await nc_mcp_oauth_client.list_tools()
assert result is not None
assert len(result.tools) > 0
# Execute a simple tool
search_result = await nc_mcp_oauth_client.call_tool(
"nc_notes_search_notes",
arguments={"query": ""},
)
assert search_result.isError is False
-107
View File
@@ -22,16 +22,6 @@ from nextcloud_mcp_server.config_validators import (
class TestModeDetection:
"""Test auth mode detection from configuration."""
def test_token_exchange_mode_detection(self):
"""Test token exchange mode is detected."""
settings = Settings(
nextcloud_host="http://localhost",
enable_token_exchange=True,
)
mode = detect_auth_mode(settings)
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
def test_multi_user_basic_mode_detection(self):
"""Test multi-user BasicAuth mode is detected."""
settings = Settings(
@@ -62,18 +52,6 @@ class TestModeDetection:
mode = detect_auth_mode(settings)
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
def test_mode_priority_token_exchange_over_basic(self):
"""Test token exchange has priority over BasicAuth."""
settings = Settings(
nextcloud_host="http://localhost",
nextcloud_username="admin",
nextcloud_password="password",
enable_token_exchange=True,
)
mode = detect_auth_mode(settings)
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
class TestSingleUserBasicValidation:
"""Test validation for single-user BasicAuth mode."""
@@ -165,21 +143,6 @@ class TestSingleUserBasicValidation:
# It will fail multi-user validation because username/password are forbidden
assert len(errors) > 0
def test_forbidden_token_exchange(self):
"""Test error when ENABLE_TOKEN_EXCHANGE is set."""
settings = Settings(
nextcloud_host="http://localhost",
nextcloud_username="admin",
nextcloud_password="password",
enable_token_exchange=True,
)
# Note: This will detect as OAUTH_TOKEN_EXCHANGE due to priority
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
# It will fail OAuth validation
def test_vector_sync_without_embedding_provider_uses_fallback(self):
"""Test that vector sync works with Simple provider fallback (no config needed)."""
settings = Settings(
@@ -419,51 +382,6 @@ class TestOAuthSingleAudienceValidation:
assert settings.enable_offline_access is True
class TestOAuthTokenExchangeValidation:
"""Test validation for OAuth token exchange mode."""
def test_valid_minimal_config(self):
"""Test valid minimal OAuth token exchange config."""
settings = Settings(
nextcloud_host="http://localhost",
enable_token_exchange=True,
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
assert len(errors) == 0
def test_valid_with_credentials(self):
"""Test valid config with OAuth credentials."""
settings = Settings(
nextcloud_host="http://localhost",
enable_token_exchange=True,
oidc_client_id="test-client",
oidc_client_secret="test-secret",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
assert len(errors) == 0
def test_forbidden_username_password(self):
"""Test error when username/password are set."""
settings = Settings(
nextcloud_host="http://localhost",
enable_token_exchange=True,
nextcloud_username="admin",
nextcloud_password="password",
)
mode, errors = validate_configuration(settings)
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
assert any("nextcloud_username" in err.lower() for err in errors)
assert any("nextcloud_password" in err.lower() for err in errors)
class TestModeSummary:
"""Test mode summary generation."""
@@ -477,14 +395,6 @@ class TestModeSummary:
assert "NEXTCLOUD_PASSWORD" in summary
assert "VECTOR_SYNC_ENABLED" in summary
def test_oauth_token_exchange_summary(self):
"""Test summary for OAuth token exchange mode."""
summary = get_mode_summary(AuthMode.OAUTH_TOKEN_EXCHANGE)
assert "oauth_exchange" in summary
assert "ENABLE_TOKEN_EXCHANGE" in summary
assert "RFC 8693" in summary
class TestEdgeCases:
"""Test edge cases and boundary conditions."""
@@ -800,23 +710,6 @@ class TestExplicitModeSelection:
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
def test_explicit_oauth_token_exchange_mode(self):
"""Test explicit oauth_token_exchange mode selection."""
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"MCP_DEPLOYMENT_MODE": "oauth_token_exchange",
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
mode = detect_auth_mode(settings)
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
def test_invalid_deployment_mode_raises_error(self):
"""Test invalid MCP_DEPLOYMENT_MODE raises ValueError."""
with patch.dict(
@@ -37,7 +37,6 @@ def create_mock_settings(
oidc_issuer: str | None = None,
vector_sync_enabled: bool = False,
nextcloud_url: str = "http://localhost",
enable_token_exchange: bool = False,
mcp_client_id: str | None = None,
mcp_client_secret: str | None = None,
):
@@ -49,7 +48,6 @@ def create_mock_settings(
settings.oidc_issuer = oidc_issuer
settings.vector_sync_enabled = vector_sync_enabled
settings.nextcloud_url = nextcloud_url
settings.enable_token_exchange = enable_token_exchange
settings.mcp_client_id = mcp_client_id
settings.mcp_client_secret = mcp_client_secret
return settings
+20 -29
View File
@@ -29,18 +29,9 @@ def base_settings():
nextcloud_resource_uri="http://localhost:8080",
jwks_uri="https://idp.example.com/jwks",
introspection_uri="https://idp.example.com/introspect",
enable_token_exchange=False, # Multi-audience mode
token_exchange_cache_ttl=300,
)
@pytest.fixture
def exchange_settings(base_settings):
"""Create settings for token exchange mode."""
base_settings.enable_token_exchange = True
return base_settings
class TestUnifiedTokenVerifierInit:
"""Test UnifiedTokenVerifier initialization."""
@@ -50,11 +41,11 @@ class TestUnifiedTokenVerifierInit:
assert verifier.mode == "multi-audience"
assert verifier.settings == base_settings
def test_init_exchange_mode(self, exchange_settings):
"""Test verifier initialization in token exchange mode."""
verifier = UnifiedTokenVerifier(exchange_settings)
assert verifier.mode == "exchange"
assert verifier.settings == exchange_settings
def test_init_always_multi_audience(self, base_settings):
"""Test verifier always initializes in multi-audience mode."""
verifier = UnifiedTokenVerifier(base_settings)
assert verifier.mode == "multi-audience"
assert verifier.settings == base_settings
class TestAudienceValidation:
@@ -117,9 +108,9 @@ class TestAudienceValidation:
# Should pass - we only validate MCP audience per RFC 7519
assert verifier._has_mcp_audience(payload) is True
def test_has_mcp_audience_with_client_id(self, exchange_settings):
def test_has_mcp_audience_with_client_id(self, base_settings):
"""Test MCP audience validation with client ID."""
verifier = UnifiedTokenVerifier(exchange_settings)
verifier = UnifiedTokenVerifier(base_settings)
payload = {
"aud": ["test-client-id"],
"sub": "testuser",
@@ -128,9 +119,9 @@ class TestAudienceValidation:
assert verifier._has_mcp_audience(payload) is True
def test_has_mcp_audience_with_server_url(self, exchange_settings):
def test_has_mcp_audience_with_server_url(self, base_settings):
"""Test MCP audience validation with server URL."""
verifier = UnifiedTokenVerifier(exchange_settings)
verifier = UnifiedTokenVerifier(base_settings)
payload = {
"aud": ["http://localhost:8000"],
"sub": "testuser",
@@ -139,9 +130,9 @@ class TestAudienceValidation:
assert verifier._has_mcp_audience(payload) is True
def test_has_mcp_audience_missing(self, exchange_settings):
def test_has_mcp_audience_missing(self, base_settings):
"""Test MCP audience validation fails without MCP audience."""
verifier = UnifiedTokenVerifier(exchange_settings)
verifier = UnifiedTokenVerifier(base_settings)
payload = {
"aud": ["http://localhost:8080"], # Wrong audience
"sub": "testuser",
@@ -292,12 +283,12 @@ class TestMultiAudienceVerification:
assert result.resource == "testuser"
class TestExchangeModeVerification:
"""Test token exchange mode verification."""
class TestMcpAudienceVerification:
"""Test MCP audience verification."""
async def test_verify_mcp_audience_only_success(self, exchange_settings):
async def test_verify_mcp_audience_only_success(self, base_settings):
"""Test MCP-only audience verification succeeds with MCP audience."""
verifier = UnifiedTokenVerifier(exchange_settings)
verifier = UnifiedTokenVerifier(base_settings)
# Mock introspection response with MCP audience only
introspection_response = {
@@ -318,9 +309,9 @@ class TestExchangeModeVerification:
assert result is not None
assert result.resource == "testuser"
async def test_verify_mcp_audience_only_fails_without_mcp(self, exchange_settings):
async def test_verify_mcp_audience_only_fails_without_mcp(self, base_settings):
"""Test MCP audience verification fails without MCP audience."""
verifier = UnifiedTokenVerifier(exchange_settings)
verifier = UnifiedTokenVerifier(base_settings)
# Mock introspection response without MCP audience
introspection_response = {
@@ -503,9 +494,9 @@ class TestVerifyTokenFlow:
assert result is not None
assert result.resource == "testuser"
async def test_verify_token_exchange_mode(self, exchange_settings):
"""Test verify_token in exchange mode."""
verifier = UnifiedTokenVerifier(exchange_settings)
async def test_verify_token_mcp_audience_only(self, base_settings):
"""Test verify_token with MCP audience only."""
verifier = UnifiedTokenVerifier(base_settings)
introspection_response = {
"active": True,