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:
@@ -42,7 +42,6 @@ jobs:
|
|||||||
mode:
|
mode:
|
||||||
- "single-user"
|
- "single-user"
|
||||||
- "multi-user-basic"
|
- "multi-user-basic"
|
||||||
- "oauth"
|
|
||||||
- "login-flow"
|
- "login-flow"
|
||||||
include:
|
include:
|
||||||
# Version-specific image pins — Renovate updates these via customManagers in renovate.json
|
# Version-specific image pins — Renovate updates these via customManagers in renovate.json
|
||||||
@@ -58,7 +57,7 @@ jobs:
|
|||||||
# Mode-specific properties
|
# Mode-specific properties
|
||||||
- mode: single-user
|
- mode: single-user
|
||||||
profile: 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
|
wait-port: 8000
|
||||||
mcp-internal-url: "http://mcp:8000"
|
mcp-internal-url: "http://mcp:8000"
|
||||||
needs-playwright: false
|
needs-playwright: false
|
||||||
@@ -74,14 +73,6 @@ jobs:
|
|||||||
needs-playwright: true
|
needs-playwright: true
|
||||||
extra-args: ""
|
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
|
- mode: login-flow
|
||||||
profile: login-flow
|
profile: login-flow
|
||||||
markers: "login_flow"
|
markers: "login_flow"
|
||||||
@@ -184,7 +175,7 @@ jobs:
|
|||||||
echo "MCP service is ready on port ${{ matrix.wait-port }}."
|
echo "MCP service is ready on port ${{ matrix.wait-port }}."
|
||||||
|
|
||||||
- name: Verify OIDC configuration
|
- name: Verify OIDC configuration
|
||||||
if: matrix.mode == 'oauth' || matrix.mode == 'login-flow'
|
if: matrix.mode == 'login-flow'
|
||||||
run: |
|
run: |
|
||||||
echo "=== OIDC Discovery ==="
|
echo "=== OIDC Discovery ==="
|
||||||
curl -s http://localhost:8080/.well-known/openid-configuration | jq .
|
curl -s http://localhost:8080/.well-known/openid-configuration | jq .
|
||||||
|
|||||||
@@ -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
|
- **Decorators**: `@require_scopes`, `@require_provisioning` for access control
|
||||||
- **Context pattern**: `await get_client(ctx)` to access authenticated NextcloudClient (async!)
|
- **Context pattern**: `await get_client(ctx)` to access authenticated NextcloudClient (async!)
|
||||||
- **FastMCP decorators**: `@mcp.tool()`, `@mcp.resource()`
|
- **FastMCP decorators**: `@mcp.tool()`, `@mcp.resource()`
|
||||||
- **Token acquisition**: `get_client()` handles both pass-through and token exchange modes
|
- **Token acquisition**: `get_client()` resolves credentials per deployment mode (see Deployment Modes below)
|
||||||
- Pass-through (default): Simple, stateless (ENABLE_TOKEN_EXCHANGE=false)
|
|
||||||
- Token exchange (opt-in): RFC 8693 delegation (ENABLE_TOKEN_EXCHANGE=true)
|
|
||||||
|
|
||||||
### MCP Tool Annotations (ADR-017)
|
### 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:
|
**Important**: After code changes, rebuild the correct container:
|
||||||
- Single-user tests: `docker-compose up --build -d mcp`
|
- Single-user tests: `docker compose up --build -d mcp`
|
||||||
- OAuth tests: `docker-compose up --build -d mcp-oauth`
|
- Login Flow tests: `docker compose up --build -d mcp-login-flow`
|
||||||
- Keycloak tests: `docker-compose up --build -d mcp-keycloak`
|
- Keycloak tests: `docker compose up --build -d mcp-keycloak`
|
||||||
|
|
||||||
### Running the Server
|
### Running the Server
|
||||||
```bash
|
```bash
|
||||||
# Local development
|
# Local development
|
||||||
export $(grep -v '^#' .env | xargs)
|
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 development (rebuilds after code changes)
|
||||||
docker-compose up --build -d mcp # Single-user (port 8000)
|
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-login-flow # Login Flow v2 (port 8004)
|
||||||
docker-compose up --build -d mcp-keycloak # Keycloak OAuth (port 8002)
|
docker compose up --build -d mcp-keycloak # Keycloak OAuth (port 8002)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Setup
|
### Environment Setup
|
||||||
@@ -239,9 +237,11 @@ uv run python -m tests.load.benchmark --output results.json --verbose
|
|||||||
|
|
||||||
**Credentials**: root/password, nextcloud/password, database: `nextcloud`
|
**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
|
```bash
|
||||||
# Basic query
|
# 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"
|
./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**:
|
**Important Tables**:
|
||||||
- `oc_oidc_clients` - OAuth client registrations (DCR)
|
- `oc_oidc_clients` - OAuth client registrations (DCR)
|
||||||
- `oc_oidc_client_scopes` - Client allowed scopes
|
- `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_registration_tokens` - RFC 7592 registration tokens
|
||||||
- `oc_oidc_redirect_uris` - Redirect URIs
|
- `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
|
```bash
|
||||||
# List tables
|
# 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`
|
3. MCP tools use context pattern: `get_client(ctx)` → `NextcloudClient`
|
||||||
4. All operations are async using httpx
|
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?**
|
**1. Single-User** (profile: `single-user`)
|
||||||
- Dual OAuth flow architecture that separates client authentication (Flow 1) from resource provisioning (Flow 2)
|
- Set `NEXTCLOUD_USERNAME` + `NEXTCLOUD_PASSWORD` (app password)
|
||||||
- Flow 1: MCP client authenticates directly to IdP with resource scopes (notes:*, calendar:*, etc.)
|
- One shared Nextcloud identity for all MCP requests
|
||||||
- Token audience: "mcp-server"
|
- Stateless, no persistent storage needed
|
||||||
- Client receives resource-scoped token for MCP session
|
- Best for: personal instances, local development
|
||||||
- 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
|
|
||||||
|
|
||||||
**Modes:**
|
**2. Multi-User BasicAuth** (profile: `multi-user-basic`)
|
||||||
- **Pass-through mode** (`ENABLE_OFFLINE_ACCESS=false`, default):
|
- Set `ENABLE_MULTI_USER_BASIC_AUTH=true`
|
||||||
- No Flow 2 provisioning
|
- Each MCP client provides credentials via HTTP Authorization header
|
||||||
- Server uses client's token to access Nextcloud (pass-through)
|
- Per-request client creation from extracted credentials
|
||||||
- No provisioning tools available
|
- Best for: internal deployments where users manage their own Nextcloud credentials
|
||||||
- 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
|
|
||||||
|
|
||||||
**When to use OAuth mode:**
|
**3. Login Flow v2** (profile: `login-flow`)
|
||||||
- Multi-user deployments
|
- Browser-based app password acquisition via Nextcloud's native Login Flow v2 API
|
||||||
- Background jobs requiring offline access (with `ENABLE_OFFLINE_ACCESS=true`)
|
- Per-user app passwords stored encrypted in SQLite
|
||||||
- Enhanced security with separate authorization contexts
|
- Application-level scope enforcement (defense-in-depth)
|
||||||
- Explicit user control over resource access
|
- Works with any Nextcloud 16+ instance (no special apps required)
|
||||||
|
- Best for: production multi-user deployments, OAuth MCP integration
|
||||||
**When to use BasicAuth instead:**
|
- See `docs/ADR-022-login-flow-v2.md` for architecture details
|
||||||
- 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
|
|
||||||
|
|
||||||
## MCP Response Patterns (CRITICAL)
|
## MCP Response Patterns (CRITICAL)
|
||||||
|
|
||||||
@@ -483,7 +443,7 @@ async def nc_notes_semantic_search_answer(
|
|||||||
### Use Existing Fixtures
|
### Use Existing Fixtures
|
||||||
See `tests/conftest.py` for 2888 lines of test infrastructure:
|
See `tests/conftest.py` for 2888 lines of test infrastructure:
|
||||||
- `nc_mcp_client` - MCP client for tool/resource testing (uses `mcp` container)
|
- `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
|
- `nc_client` - Direct NextcloudClient for setup/cleanup
|
||||||
- `temporary_note`, `temporary_addressbook`, `temporary_contact` - Auto-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.
|
OAuth tests use **Playwright browser automation** to complete flows programmatically.
|
||||||
|
|
||||||
**Test Environment**:
|
**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
|
- OAuth tests require `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
|
||||||
- Playwright configuration: `--browser firefox --headed` for debugging
|
- Playwright configuration: `--browser firefox --headed` for debugging
|
||||||
- Install browsers: `uv run playwright install firefox`
|
- 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**:
|
**Setup**:
|
||||||
```bash
|
```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
|
curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||||
docker compose exec app php occ user_oidc:provider keycloak
|
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
|
## Integration Testing with Docker
|
||||||
|
|
||||||
**Nextcloud**: `docker compose exec app php occ ...` for occ commands
|
**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
|
### Querying Nextcloud Application Logs
|
||||||
|
|
||||||
|
|||||||
@@ -169,56 +169,6 @@ services:
|
|||||||
profiles:
|
profiles:
|
||||||
- multi-user-basic
|
- 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:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:26.5.4@sha256:ae8efb0d218d8921334b03a2dbee7069a0b868240691c50a3ffc9f42fabba8b4
|
image: quay.io/keycloak/keycloak:26.5.4@sha256:ae8efb0d218d8921334b03a2dbee7069a0b868240691c50a3ffc9f42fabba8b4
|
||||||
command:
|
command:
|
||||||
@@ -381,8 +331,6 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
nextcloud:
|
nextcloud:
|
||||||
db:
|
db:
|
||||||
oauth-client-storage:
|
|
||||||
oauth-tokens:
|
|
||||||
keycloak-tokens:
|
keycloak-tokens:
|
||||||
keycloak-oauth-storage:
|
keycloak-oauth-storage:
|
||||||
login-flow-data:
|
login-flow-data:
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ async def get_server_status(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
# Map deployment mode to auth_mode for API response
|
# Map deployment mode to auth_mode for API response
|
||||||
# This helps clients (like Astrolabe) determine which auth flow to use
|
# 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"
|
auth_mode = "oauth"
|
||||||
elif mode == AuthMode.MULTI_USER_BASIC:
|
elif mode == AuthMode.MULTI_USER_BASIC:
|
||||||
auth_mode = "multi_user_basic"
|
auth_mode = "multi_user_basic"
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
|||||||
oauth_logout,
|
oauth_logout,
|
||||||
)
|
)
|
||||||
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
|
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 (
|
from nextcloud_mcp_server.auth.oauth_routes import (
|
||||||
oauth_as_metadata,
|
oauth_as_metadata,
|
||||||
oauth_authorize,
|
oauth_authorize,
|
||||||
@@ -353,7 +352,7 @@ class OAuthAppContext:
|
|||||||
nextcloud_host: str
|
nextcloud_host: str
|
||||||
token_verifier: object # UnifiedTokenVerifier (ADR-005 compliant)
|
token_verifier: object # UnifiedTokenVerifier (ADR-005 compliant)
|
||||||
refresh_token_storage: Optional["RefreshTokenStorage"] = None
|
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"
|
oauth_provider: str = "nextcloud" # "nextcloud" or "keycloak"
|
||||||
server_client_id: Optional[str] = (
|
server_client_id: Optional[str] = (
|
||||||
None # MCP server's OAuth client ID (static or DCR)
|
None # MCP server's OAuth client ID (static or DCR)
|
||||||
@@ -772,16 +771,6 @@ async def setup_oauth_config():
|
|||||||
token_verifier = UnifiedTokenVerifier(settings)
|
token_verifier = UnifiedTokenVerifier(settings)
|
||||||
|
|
||||||
# Log the mode
|
# Log the mode
|
||||||
enable_token_exchange = (
|
|
||||||
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
|
|
||||||
)
|
|
||||||
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(
|
logger.info(
|
||||||
"✓ Multi-audience mode enabled (ADR-005) - tokens must contain both MCP and Nextcloud audiences"
|
"✓ Multi-audience mode enabled (ADR-005) - tokens must contain both MCP and Nextcloud audiences"
|
||||||
)
|
)
|
||||||
@@ -803,45 +792,7 @@ async def setup_oauth_config():
|
|||||||
# that are separate from the real-time token exchange flow
|
# that are separate from the real-time token exchange flow
|
||||||
logger.debug("Token broker available for future offline access features")
|
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
|
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
|
# Create auth settings
|
||||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
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)}")
|
logger.debug(f"Mode details:\n{get_mode_summary(mode)}")
|
||||||
|
|
||||||
# Derive helper variables for backward compatibility with existing code
|
# Derive helper variables for backward compatibility with existing code
|
||||||
oauth_enabled = mode in (
|
oauth_enabled = mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
||||||
AuthMode.OAUTH_SINGLE_AUDIENCE,
|
|
||||||
AuthMode.OAUTH_TOKEN_EXCHANGE,
|
|
||||||
)
|
|
||||||
# Log hybrid authentication status for multi-user BasicAuth with offline access
|
# Log hybrid authentication status for multi-user BasicAuth with offline access
|
||||||
if mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access:
|
if mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1202,7 +1150,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
# Create MCP server based on detected mode
|
# 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")
|
logger.info("Configuring MCP server for OAuth mode")
|
||||||
# Asynchronously get the OAuth configuration
|
# 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)")
|
logger.info("Skipping semantic search tools (VECTOR_SYNC_ENABLED not set)")
|
||||||
|
|
||||||
# Register OAuth provisioning tools (only when offline access is enabled)
|
# 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
|
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")
|
logger.info("Registering OAuth provisioning tools for offline access")
|
||||||
register_oauth_tools(mcp)
|
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:
|
elif oauth_enabled and not enable_offline_access_for_tools:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Skipping provisioning tools registration (offline access not enabled)"
|
"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
|
# Check authentication configuration
|
||||||
# Report the deployment mode, not just whether OAuth is enabled
|
# Report the deployment mode, not just whether OAuth is enabled
|
||||||
# This helps clients (like Astrolabe) determine which auth flow to use
|
# This helps clients (like Astrolabe) determine which auth flow to use
|
||||||
if (
|
if mode == AuthMode.OAUTH_SINGLE_AUDIENCE:
|
||||||
mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
|
||||||
or mode == AuthMode.OAUTH_TOKEN_EXCHANGE
|
|
||||||
):
|
|
||||||
checks["auth_mode"] = "oauth"
|
checks["auth_mode"] = "oauth"
|
||||||
checks["auth_configured"] = "ok"
|
checks["auth_configured"] = "ok"
|
||||||
elif mode == AuthMode.MULTI_USER_BASIC:
|
elif mode == AuthMode.MULTI_USER_BASIC:
|
||||||
|
|||||||
@@ -1,28 +1,17 @@
|
|||||||
"""Helper functions for extracting OAuth context from MCP requests.
|
"""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 logging
|
||||||
import time
|
|
||||||
|
|
||||||
from mcp.server.auth.provider import AccessToken
|
from mcp.server.auth.provider import AccessToken
|
||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
|
|
||||||
from ..client import NextcloudClient
|
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__)
|
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:
|
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(f"Failed to extract OAuth context: {e}")
|
||||||
logger.error("This may indicate the server is not running in OAuth mode")
|
logger.error("This may indicate the server is not running in OAuth mode")
|
||||||
raise
|
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")
|
|
||||||
|
|||||||
@@ -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 mcp.types import ErrorData
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -65,14 +64,6 @@ def require_provisioning(func: Callable) -> Callable:
|
|||||||
logger.debug("BasicAuth mode detected - skipping provisioning check")
|
logger.debug("BasicAuth mode detected - skipping provisioning check")
|
||||||
return await func(*args, **kwargs)
|
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
|
# Offline access mode - check if user has completed Flow 2 provisioning
|
||||||
# Get user_id from authorization token
|
# Get user_id from authorization token
|
||||||
user_id = None
|
user_id = None
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ The Token Broker provides:
|
|||||||
- Short-lived token caching (5-minute TTL)
|
- Short-lived token caching (5-minute TTL)
|
||||||
- Master refresh token rotation
|
- Master refresh token rotation
|
||||||
- Audience-specific token validation
|
- Audience-specific token validation
|
||||||
- Session vs background token separation (RFC 8693)
|
- Background token management
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -23,7 +23,6 @@ import httpx
|
|||||||
import jwt
|
import jwt
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
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
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
@@ -219,55 +218,6 @@ class TokenBrokerService:
|
|||||||
await self.cache.invalidate(user_id)
|
await self.cache.invalidate(user_id)
|
||||||
return None
|
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(
|
async def get_background_token(
|
||||||
self, user_id: str, required_scopes: list[str]
|
self, user_id: str, required_scopes: list[str]
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
|
|||||||
@@ -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
|
settings: Application settings containing OAuth configuration
|
||||||
"""
|
"""
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.mode = "exchange" if settings.enable_token_exchange else "multi-audience"
|
self.mode = "multi-audience"
|
||||||
|
|
||||||
# Common components for all modes
|
# Common components for all modes
|
||||||
self.http_client = nextcloud_httpx_client(timeout=10.0)
|
self.http_client = nextcloud_httpx_client(timeout=10.0)
|
||||||
|
|||||||
@@ -138,8 +138,7 @@ class Settings:
|
|||||||
|
|
||||||
# Deployment mode (ADR-021: explicit mode selection)
|
# Deployment mode (ADR-021: explicit mode selection)
|
||||||
# Optional: If not set, mode is auto-detected from other settings
|
# Optional: If not set, mode is auto-detected from other settings
|
||||||
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
|
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience
|
||||||
# oauth_token_exchange
|
|
||||||
deployment_mode: str | None = None
|
deployment_mode: str | None = None
|
||||||
|
|
||||||
# OAuth/OIDC settings
|
# OAuth/OIDC settings
|
||||||
@@ -168,7 +167,6 @@ class Settings:
|
|||||||
userinfo_uri: str | None = None
|
userinfo_uri: str | None = None
|
||||||
|
|
||||||
# Progressive Consent settings (always enabled - no flag needed)
|
# Progressive Consent settings (always enabled - no flag needed)
|
||||||
enable_token_exchange: bool = False
|
|
||||||
enable_offline_access: bool = False
|
enable_offline_access: bool = False
|
||||||
|
|
||||||
# Multi-user BasicAuth pass-through mode (ADR-019 interim solution)
|
# Multi-user BasicAuth pass-through mode (ADR-019 interim solution)
|
||||||
@@ -179,9 +177,6 @@ class Settings:
|
|||||||
# Login Flow v2 settings (ADR-022)
|
# Login Flow v2 settings (ADR-022)
|
||||||
enable_login_flow: bool = False
|
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 and webhook storage settings
|
||||||
# TOKEN_ENCRYPTION_KEY: Optional - Only required for OAuth token storage operations.
|
# TOKEN_ENCRYPTION_KEY: Optional - Only required for OAuth token storage operations.
|
||||||
# Webhook tracking works without encryption key.
|
# Webhook tracking works without encryption key.
|
||||||
@@ -507,9 +502,6 @@ def get_settings() -> Settings:
|
|||||||
introspection_uri=os.getenv("INTROSPECTION_URI"),
|
introspection_uri=os.getenv("INTROSPECTION_URI"),
|
||||||
userinfo_uri=os.getenv("USERINFO_URI"),
|
userinfo_uri=os.getenv("USERINFO_URI"),
|
||||||
# Progressive Consent settings (always enabled)
|
# 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
|
enable_offline_access=enable_background_operations, # Smart dependency resolution
|
||||||
# Multi-user BasicAuth pass-through mode
|
# Multi-user BasicAuth pass-through mode
|
||||||
enable_multi_user_basic_auth=(
|
enable_multi_user_basic_auth=(
|
||||||
@@ -517,8 +509,6 @@ def get_settings() -> Settings:
|
|||||||
),
|
),
|
||||||
# Login Flow v2 settings (ADR-022)
|
# Login Flow v2 settings (ADR-022)
|
||||||
enable_login_flow=(os.getenv("ENABLE_LOGIN_FLOW", "false").lower() == "true"),
|
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 and webhook storage settings (encryption key optional for webhook-only usage)
|
||||||
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
|
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
|
||||||
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
|
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ class AuthMode(Enum):
|
|||||||
SINGLE_USER_BASIC = "single_user_basic"
|
SINGLE_USER_BASIC = "single_user_basic"
|
||||||
MULTI_USER_BASIC = "multi_user_basic"
|
MULTI_USER_BASIC = "multi_user_basic"
|
||||||
OAUTH_SINGLE_AUDIENCE = "oauth_single"
|
OAUTH_SINGLE_AUDIENCE = "oauth_single"
|
||||||
OAUTH_TOKEN_EXCHANGE = "oauth_exchange"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -66,7 +65,6 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
|
|||||||
],
|
],
|
||||||
forbidden=[
|
forbidden=[
|
||||||
"enable_multi_user_basic_auth",
|
"enable_multi_user_basic_auth",
|
||||||
"enable_token_exchange",
|
|
||||||
"oidc_client_id",
|
"oidc_client_id",
|
||||||
"oidc_client_secret",
|
"oidc_client_secret",
|
||||||
],
|
],
|
||||||
@@ -100,7 +98,6 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
|
|||||||
forbidden=[
|
forbidden=[
|
||||||
"nextcloud_username",
|
"nextcloud_username",
|
||||||
"nextcloud_password",
|
"nextcloud_password",
|
||||||
"enable_token_exchange",
|
|
||||||
],
|
],
|
||||||
conditional={
|
conditional={
|
||||||
"enable_offline_access": [
|
"enable_offline_access": [
|
||||||
@@ -141,7 +138,6 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
|
|||||||
forbidden=[
|
forbidden=[
|
||||||
"nextcloud_username",
|
"nextcloud_username",
|
||||||
"nextcloud_password",
|
"nextcloud_password",
|
||||||
"enable_token_exchange",
|
|
||||||
"enable_multi_user_basic_auth",
|
"enable_multi_user_basic_auth",
|
||||||
],
|
],
|
||||||
conditional={
|
conditional={
|
||||||
@@ -157,46 +153,6 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
|
|||||||
"Tokens work for both MCP server and Nextcloud APIs (pass-through). "
|
"Tokens work for both MCP server and Nextcloud APIs (pass-through). "
|
||||||
"Uses Dynamic Client Registration if credentials not provided.",
|
"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):
|
Mode detection priority (ADR-021):
|
||||||
0. Explicit MCP_DEPLOYMENT_MODE (if set) - NEW in ADR-021
|
0. Explicit MCP_DEPLOYMENT_MODE (if set) - NEW in ADR-021
|
||||||
1. Token exchange (most specific OAuth mode)
|
1. Multi-user BasicAuth
|
||||||
2. Multi-user BasicAuth
|
2. Single-user BasicAuth
|
||||||
3. Single-user BasicAuth
|
3. OAuth single-audience (default OAuth mode)
|
||||||
4. OAuth single-audience (default OAuth mode)
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
settings: Application settings
|
settings: Application settings
|
||||||
@@ -231,7 +186,6 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
|
|||||||
"single_user_basic": AuthMode.SINGLE_USER_BASIC,
|
"single_user_basic": AuthMode.SINGLE_USER_BASIC,
|
||||||
"multi_user_basic": AuthMode.MULTI_USER_BASIC,
|
"multi_user_basic": AuthMode.MULTI_USER_BASIC,
|
||||||
"oauth_single_audience": AuthMode.OAUTH_SINGLE_AUDIENCE,
|
"oauth_single_audience": AuthMode.OAUTH_SINGLE_AUDIENCE,
|
||||||
"oauth_token_exchange": AuthMode.OAUTH_TOKEN_EXCHANGE,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if mode_str not in mode_map:
|
if mode_str not in mode_map:
|
||||||
@@ -246,10 +200,6 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
|
|||||||
return explicit_mode
|
return explicit_mode
|
||||||
|
|
||||||
# Auto-detection (existing behavior)
|
# 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
|
# Check for multi-user BasicAuth
|
||||||
if settings.enable_multi_user_basic_auth:
|
if settings.enable_multi_user_basic_auth:
|
||||||
return AuthMode.MULTI_USER_BASIC
|
return AuthMode.MULTI_USER_BASIC
|
||||||
@@ -351,10 +301,7 @@ def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]:
|
|||||||
f"{settings.nextcloud_host}"
|
f"{settings.nextcloud_host}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if mode in [
|
if mode == AuthMode.OAUTH_SINGLE_AUDIENCE:
|
||||||
AuthMode.OAUTH_SINGLE_AUDIENCE,
|
|
||||||
AuthMode.OAUTH_TOKEN_EXCHANGE,
|
|
||||||
]:
|
|
||||||
# If OAuth credentials not provided, DCR must be available
|
# If OAuth credentials not provided, DCR must be available
|
||||||
# (This is a runtime check, not a config check, so we just warn)
|
# (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:
|
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import logging
|
|||||||
from httpx import BasicAuth
|
from httpx import BasicAuth
|
||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.context_helper import (
|
from nextcloud_mcp_server.auth.context_helper import get_client_from_context
|
||||||
get_client_from_context,
|
|
||||||
get_session_client_from_context,
|
|
||||||
)
|
|
||||||
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
|
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
|
||||||
from nextcloud_mcp_server.auth.storage import get_shared_storage
|
from nextcloud_mcp_server.auth.storage import get_shared_storage
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
@@ -24,18 +21,9 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
|||||||
Supports the following deployment modes:
|
Supports the following deployment modes:
|
||||||
|
|
||||||
1. BasicAuth mode: Returns shared client from lifespan context
|
1. BasicAuth mode: Returns shared client from lifespan context
|
||||||
|
2. Login Flow v2: OAuth for MCP session, app password for Nextcloud API
|
||||||
2. OAuth mode:
|
3. Multi-user BasicAuth: Credentials passed through from request headers
|
||||||
a. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
4. OAuth multi-audience: Token contains both MCP and Nextcloud audiences
|
||||||
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.
|
|
||||||
|
|
||||||
This function automatically detects the authentication mode by checking
|
This function automatically detects the authentication mode by checking
|
||||||
the type of the lifespan context.
|
the type of the lifespan context.
|
||||||
@@ -74,17 +62,8 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
|||||||
if hasattr(lifespan_ctx, "client"):
|
if hasattr(lifespan_ctx, "client"):
|
||||||
return 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 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
|
# Token was validated to have MCP audience in UnifiedTokenVerifier
|
||||||
# Nextcloud will independently validate its own audience when receiving API calls
|
# Nextcloud will independently validate its own audience when receiving API calls
|
||||||
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
|
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
|
||||||
|
|||||||
@@ -125,12 +125,6 @@ oauth_token_validations_total = Counter(
|
|||||||
["method", "result"], # method: introspect | jwt; result: valid | invalid | error
|
["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(
|
oauth_token_cache_hits_total = Counter(
|
||||||
"mcp_oauth_token_cache_hits_total",
|
"mcp_oauth_token_cache_hits_total",
|
||||||
"Total OAuth token cache lookups",
|
"Total OAuth token cache lookups",
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ def trace_oauth_operation(operation: str, details: dict[str, Any] | None = None)
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
Args:
|
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)
|
details: Optional operation details (sensitive data will be sanitized)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ log_level = "ERROR"
|
|||||||
markers = [
|
markers = [
|
||||||
"unit: Fast unit tests with mocked dependencies",
|
"unit: Fast unit tests with mocked dependencies",
|
||||||
"integration: Integration tests requiring Docker containers",
|
"integration: Integration tests requiring Docker containers",
|
||||||
"oauth: OAuth tests requiring Playwright (slowest)",
|
|
||||||
"smoke: Critical path smoke tests for quick validation",
|
"smoke: Critical path smoke tests for quick validation",
|
||||||
"keycloak: OAuth tests that utilize keycloak external identity provider",
|
"keycloak: OAuth tests that utilize keycloak external identity provider",
|
||||||
"login_flow: Login Flow v2 integration tests (ADR-022)",
|
"login_flow: Login Flow v2 integration tests (ADR-022)",
|
||||||
|
|||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
+29
-449
@@ -1,5 +1,4 @@
|
|||||||
import base64
|
import base64
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -21,6 +20,7 @@ from mcp import ClientSession
|
|||||||
from mcp.client.session import RequestContext
|
from mcp.client.session import RequestContext
|
||||||
from mcp.client.streamable_http import streamablehttp_client
|
from mcp.client.streamable_http import streamablehttp_client
|
||||||
from mcp.types import ElicitRequestParams, ElicitResult, ErrorData
|
from mcp.types import ElicitRequestParams, ElicitResult, ErrorData
|
||||||
|
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
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)"
|
f" ⊗ Scope checkbox {i + 1} disabled (required scope)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Click the Allow button to grant consent
|
# Click the Allow button to grant consent with retry logic.
|
||||||
# Check button exists first
|
# Uses Playwright's native click (dispatches proper browser events that
|
||||||
allow_button_locator = page.locator('button:has-text("Allow")')
|
# 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}...")
|
logger.info(f" Clicking Allow button to grant consent for {username}...")
|
||||||
|
|
||||||
# Use JavaScript click to handle consent buttons that may be outside viewport
|
for attempt in range(3):
|
||||||
# This is more reliable than Playwright's click which requires element visibility
|
await allow_button.scroll_into_view_if_needed()
|
||||||
logger.info(
|
await allow_button.click()
|
||||||
" Using JavaScript click for consent (handles viewport issues)..."
|
try:
|
||||||
|
await page.wait_for_url(
|
||||||
|
lambda url: "/consent" not in url, timeout=10000
|
||||||
)
|
)
|
||||||
await page.evaluate(
|
|
||||||
"""
|
|
||||||
const buttons = document.querySelectorAll('button');
|
|
||||||
for (const btn of buttons) {
|
|
||||||
if (btn.textContent.trim() === 'Allow') {
|
|
||||||
btn.click();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
|
||||||
logger.info(f" Consent granted for {username}")
|
logger.info(f" Consent granted for {username}")
|
||||||
return True
|
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..."
|
||||||
|
)
|
||||||
|
|
||||||
|
raise RuntimeError("consent click retry loop exited unexpectedly")
|
||||||
else:
|
else:
|
||||||
logger.error(f" Allow button not found for {username}")
|
logger.error(f" Allow button not found for {username}")
|
||||||
return False
|
return False
|
||||||
@@ -2047,6 +2051,7 @@ async def _get_oauth_token_with_scopes(
|
|||||||
oauth_callback_server,
|
oauth_callback_server,
|
||||||
scopes: str,
|
scopes: str,
|
||||||
resource: str | None = None,
|
resource: str | None = None,
|
||||||
|
mcp_server_base_url: str = "http://localhost:8004", # login-flow container port
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Helper function to obtain OAuth token with specific scopes.
|
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
|
oauth_callback_server: OAuth callback server fixture
|
||||||
scopes: Space-separated list of scopes (e.g., "openid profile email notes:read")
|
scopes: Space-separated list of scopes (e.g., "openid profile email notes:read")
|
||||||
resource: Optional resource parameter (RFC 8707) for token audience
|
resource: Optional resource parameter (RFC 8707) for token audience
|
||||||
|
mcp_server_base_url: Base URL of the MCP server for resource metadata discovery
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OAuth access token string with requested scopes
|
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 no resource provided, fetch from MCP server metadata
|
||||||
if resource is None:
|
if resource is None:
|
||||||
mcp_server_base_url = "http://localhost:8001"
|
|
||||||
try:
|
try:
|
||||||
resource_metadata = await get_mcp_server_resource_metadata(
|
resource_metadata = await get_mcp_server_resource_metadata(
|
||||||
mcp_server_base_url
|
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
|
# Astrolabe Dynamic Configuration Fixtures
|
||||||
# ========================================================================
|
# ===========================================================================================
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@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}"
|
|
||||||
)
|
|
||||||
@@ -43,7 +43,6 @@ async def reset_all_singletons():
|
|||||||
# Import all modules with singletons
|
# Import all modules with singletons
|
||||||
import nextcloud_mcp_server.app as app_module
|
import nextcloud_mcp_server.app as app_module
|
||||||
import nextcloud_mcp_server.auth.client_registry as client_registry_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.embedding.service as embedding_module
|
||||||
import nextcloud_mcp_server.observability.tracing as tracing_module
|
import nextcloud_mcp_server.observability.tracing as tracing_module
|
||||||
import nextcloud_mcp_server.providers.registry as registry_module
|
import nextcloud_mcp_server.providers.registry as registry_module
|
||||||
@@ -63,7 +62,6 @@ async def reset_all_singletons():
|
|||||||
),
|
),
|
||||||
"tracer": tracing_module._tracer,
|
"tracer": tracing_module._tracer,
|
||||||
"registry": client_registry_module._registry,
|
"registry": client_registry_module._registry,
|
||||||
"token_exchange_service": token_exchange_module._token_exchange_service,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Close any open memory streams before reset
|
# 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
|
app_module._vector_sync_state.scanner_wake_event = None
|
||||||
tracing_module._tracer = None
|
tracing_module._tracer = None
|
||||||
client_registry_module._registry = None
|
client_registry_module._registry = None
|
||||||
token_exchange_module._token_exchange_service = None
|
|
||||||
|
|
||||||
logger.debug("All singletons reset for test module")
|
logger.debug("All singletons reset for test module")
|
||||||
|
|
||||||
@@ -115,4 +112,3 @@ async def reset_all_singletons():
|
|||||||
) = originals["vector_sync_state"]
|
) = originals["vector_sync_state"]
|
||||||
tracing_module._tracer = originals["tracer"]
|
tracing_module._tracer = originals["tracer"]
|
||||||
client_registry_module._registry = originals["registry"]
|
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()
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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.
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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,11 +1,6 @@
|
|||||||
"""Unit tests for user info routes.
|
"""Unit tests for user info routes.
|
||||||
|
|
||||||
Note: Most unit tests were removed as they relied on the old _get_user_info API.
|
These unit tests cover the simple _query_idp_userinfo helper function.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ from mcp.types import ElicitRequestParams, ElicitResult
|
|||||||
|
|
||||||
from tests.conftest import (
|
from tests.conftest import (
|
||||||
DEFAULT_FULL_SCOPES,
|
DEFAULT_FULL_SCOPES,
|
||||||
|
DEFAULT_READ_SCOPES,
|
||||||
|
DEFAULT_WRITE_SCOPES,
|
||||||
|
_get_oauth_token_with_scopes,
|
||||||
_handle_oauth_consent_screen,
|
_handle_oauth_consent_screen,
|
||||||
create_mcp_client_session,
|
create_mcp_client_session,
|
||||||
get_mcp_server_resource_metadata,
|
get_mcp_server_resource_metadata,
|
||||||
@@ -415,3 +418,511 @@ async def nc_mcp_login_flow_client(
|
|||||||
)
|
)
|
||||||
|
|
||||||
yield session
|
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
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ from nextcloud_mcp_server.auth.client_registration import register_client
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
pytestmark = [pytest.mark.integration, pytest.mark.login_flow]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
+1
-1
@@ -26,7 +26,7 @@ from ...conftest import _handle_oauth_consent_screen
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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(
|
async def get_oauth_token_with_client(
|
||||||
+1
-1
@@ -12,7 +12,7 @@ import pytest
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
pytestmark = [pytest.mark.integration, pytest.mark.login_flow]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
+1
-1
@@ -30,7 +30,7 @@ from ...conftest import _handle_oauth_consent_screen
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def is_jwt_format(token: str) -> bool:
|
||||||
+1
-1
@@ -27,7 +27,7 @@ from ...conftest import _handle_oauth_consent_screen
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
pytestmark = [pytest.mark.integration, pytest.mark.login_flow]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@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)")
|
||||||
+34
-31
@@ -18,22 +18,22 @@ import pytest
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.oauth
|
@pytest.mark.login_flow
|
||||||
async def test_prm_endpoint():
|
async def test_prm_endpoint():
|
||||||
"""Test that the Protected Resource Metadata endpoint returns correct data."""
|
"""Test that the Protected Resource Metadata endpoint returns correct data."""
|
||||||
|
|
||||||
# Test the PRM endpoint directly (RFC 9728 - path includes /mcp resource)
|
# Test the PRM endpoint directly (RFC 9728 - path includes /mcp resource)
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.get(
|
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
|
assert response.status_code == 200
|
||||||
|
|
||||||
prm_data = response.json()
|
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:read" in prm_data["scopes_supported"]
|
||||||
assert "notes:write" 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 "header" in prm_data["bearer_methods_supported"]
|
||||||
assert "RS256" in prm_data["resource_signing_alg_values_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.integration
|
||||||
@pytest.mark.oauth
|
@pytest.mark.login_flow
|
||||||
async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only):
|
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."""
|
"""Test that a token with only read scopes filters out write tools."""
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Connect with token that has only "notes:read" scope
|
# 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 result is not None
|
||||||
assert len(result.tools) > 0
|
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.integration
|
||||||
@pytest.mark.oauth
|
@pytest.mark.login_flow
|
||||||
async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_only):
|
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."""
|
"""Test that a token with only write scopes filters out read tools."""
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Connect with token that has only "notes:write" scope
|
# 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 result is not None
|
||||||
assert len(result.tools) > 0
|
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.integration
|
||||||
@pytest.mark.oauth
|
@pytest.mark.login_flow
|
||||||
async def test_full_access_token_shows_all_tools(nc_mcp_oauth_client_full_access):
|
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."""
|
"""Test that a token with both read and write scopes scopes can see all tools."""
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Connect with token that has both "notes:read" and "notes:write" scopes
|
# 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 result is not None
|
||||||
assert len(result.tools) > 0
|
assert len(result.tools) > 0
|
||||||
|
|
||||||
@@ -393,9 +393,9 @@ async def test_scope_metadata_coverage(nc_mcp_client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.oauth
|
@pytest.mark.login_flow
|
||||||
async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
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.
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Connect with JWT token that has NO custom scopes (only openid, profile, email)
|
# 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
|
assert result is not None
|
||||||
|
|
||||||
tool_names = [tool.name for tool in result.tools]
|
tool_names = [tool.name for tool in result.tools]
|
||||||
logger.info(
|
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)
|
# Only auth/provisioning tools should be visible (they require 'openid' scope)
|
||||||
expected_oauth_tools = [
|
expected_auth_tools = [
|
||||||
"provision_nextcloud_access",
|
"provision_nextcloud_access",
|
||||||
"revoke_nextcloud_access",
|
"revoke_nextcloud_access",
|
||||||
"check_provisioning_status",
|
"check_provisioning_status",
|
||||||
"check_logged_in", # Login elicitation tool (ADR-006)
|
"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), (
|
assert set(tool_names) == set(expected_auth_tools), (
|
||||||
f"Expected only OAuth provisioning tools {expected_oauth_tools} "
|
f"Expected only auth/provisioning tools {expected_auth_tools} "
|
||||||
f"but got {tool_names}"
|
f"but got {tool_names}"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
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"
|
"resource tools filtered out"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.oauth
|
@pytest.mark.login_flow
|
||||||
async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only):
|
async def test_jwt_consent_scenarios_read_only(nc_mcp_login_flow_client_read_only):
|
||||||
"""
|
"""
|
||||||
Test JWT with only nc:read scope consented.
|
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__)
|
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 result is not None
|
||||||
assert len(result.tools) > 0
|
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.integration
|
||||||
@pytest.mark.oauth
|
@pytest.mark.login_flow
|
||||||
async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only):
|
async def test_jwt_consent_scenarios_write_only(nc_mcp_login_flow_client_write_only):
|
||||||
"""
|
"""
|
||||||
Test JWT with only nc:write scope consented.
|
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__)
|
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 result is not None
|
||||||
assert len(result.tools) > 0
|
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.integration
|
||||||
@pytest.mark.oauth
|
@pytest.mark.login_flow
|
||||||
async def test_jwt_consent_scenarios_full_access(nc_mcp_oauth_client_full_access):
|
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.
|
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__)
|
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 result is not None
|
||||||
assert len(result.tools) > 0
|
assert len(result.tools) > 0
|
||||||
|
|
||||||
@@ -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")
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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!")
|
|
||||||
@@ -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
|
|
||||||
@@ -102,19 +102,3 @@ async def test_webdav_basic_smoke(nc_mcp_client):
|
|||||||
data = json.loads(result.content[0].text)
|
data = json.loads(result.content[0].text)
|
||||||
assert "files" in data
|
assert "files" in data
|
||||||
assert isinstance(data["files"], list)
|
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
|
|
||||||
|
|||||||
@@ -22,16 +22,6 @@ from nextcloud_mcp_server.config_validators import (
|
|||||||
class TestModeDetection:
|
class TestModeDetection:
|
||||||
"""Test auth mode detection from configuration."""
|
"""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):
|
def test_multi_user_basic_mode_detection(self):
|
||||||
"""Test multi-user BasicAuth mode is detected."""
|
"""Test multi-user BasicAuth mode is detected."""
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
@@ -62,18 +52,6 @@ class TestModeDetection:
|
|||||||
mode = detect_auth_mode(settings)
|
mode = detect_auth_mode(settings)
|
||||||
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
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:
|
class TestSingleUserBasicValidation:
|
||||||
"""Test validation for single-user BasicAuth mode."""
|
"""Test validation for single-user BasicAuth mode."""
|
||||||
@@ -165,21 +143,6 @@ class TestSingleUserBasicValidation:
|
|||||||
# It will fail multi-user validation because username/password are forbidden
|
# It will fail multi-user validation because username/password are forbidden
|
||||||
assert len(errors) > 0
|
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):
|
def test_vector_sync_without_embedding_provider_uses_fallback(self):
|
||||||
"""Test that vector sync works with Simple provider fallback (no config needed)."""
|
"""Test that vector sync works with Simple provider fallback (no config needed)."""
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
@@ -419,51 +382,6 @@ class TestOAuthSingleAudienceValidation:
|
|||||||
assert settings.enable_offline_access is True
|
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:
|
class TestModeSummary:
|
||||||
"""Test mode summary generation."""
|
"""Test mode summary generation."""
|
||||||
|
|
||||||
@@ -477,14 +395,6 @@ class TestModeSummary:
|
|||||||
assert "NEXTCLOUD_PASSWORD" in summary
|
assert "NEXTCLOUD_PASSWORD" in summary
|
||||||
assert "VECTOR_SYNC_ENABLED" 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:
|
class TestEdgeCases:
|
||||||
"""Test edge cases and boundary conditions."""
|
"""Test edge cases and boundary conditions."""
|
||||||
@@ -800,23 +710,6 @@ class TestExplicitModeSelection:
|
|||||||
|
|
||||||
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
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):
|
def test_invalid_deployment_mode_raises_error(self):
|
||||||
"""Test invalid MCP_DEPLOYMENT_MODE raises ValueError."""
|
"""Test invalid MCP_DEPLOYMENT_MODE raises ValueError."""
|
||||||
with patch.dict(
|
with patch.dict(
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ def create_mock_settings(
|
|||||||
oidc_issuer: str | None = None,
|
oidc_issuer: str | None = None,
|
||||||
vector_sync_enabled: bool = False,
|
vector_sync_enabled: bool = False,
|
||||||
nextcloud_url: str = "http://localhost",
|
nextcloud_url: str = "http://localhost",
|
||||||
enable_token_exchange: bool = False,
|
|
||||||
mcp_client_id: str | None = None,
|
mcp_client_id: str | None = None,
|
||||||
mcp_client_secret: str | None = None,
|
mcp_client_secret: str | None = None,
|
||||||
):
|
):
|
||||||
@@ -49,7 +48,6 @@ def create_mock_settings(
|
|||||||
settings.oidc_issuer = oidc_issuer
|
settings.oidc_issuer = oidc_issuer
|
||||||
settings.vector_sync_enabled = vector_sync_enabled
|
settings.vector_sync_enabled = vector_sync_enabled
|
||||||
settings.nextcloud_url = nextcloud_url
|
settings.nextcloud_url = nextcloud_url
|
||||||
settings.enable_token_exchange = enable_token_exchange
|
|
||||||
settings.mcp_client_id = mcp_client_id
|
settings.mcp_client_id = mcp_client_id
|
||||||
settings.mcp_client_secret = mcp_client_secret
|
settings.mcp_client_secret = mcp_client_secret
|
||||||
return settings
|
return settings
|
||||||
|
|||||||
@@ -29,18 +29,9 @@ def base_settings():
|
|||||||
nextcloud_resource_uri="http://localhost:8080",
|
nextcloud_resource_uri="http://localhost:8080",
|
||||||
jwks_uri="https://idp.example.com/jwks",
|
jwks_uri="https://idp.example.com/jwks",
|
||||||
introspection_uri="https://idp.example.com/introspect",
|
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:
|
class TestUnifiedTokenVerifierInit:
|
||||||
"""Test UnifiedTokenVerifier initialization."""
|
"""Test UnifiedTokenVerifier initialization."""
|
||||||
|
|
||||||
@@ -50,11 +41,11 @@ class TestUnifiedTokenVerifierInit:
|
|||||||
assert verifier.mode == "multi-audience"
|
assert verifier.mode == "multi-audience"
|
||||||
assert verifier.settings == base_settings
|
assert verifier.settings == base_settings
|
||||||
|
|
||||||
def test_init_exchange_mode(self, exchange_settings):
|
def test_init_always_multi_audience(self, base_settings):
|
||||||
"""Test verifier initialization in token exchange mode."""
|
"""Test verifier always initializes in multi-audience mode."""
|
||||||
verifier = UnifiedTokenVerifier(exchange_settings)
|
verifier = UnifiedTokenVerifier(base_settings)
|
||||||
assert verifier.mode == "exchange"
|
assert verifier.mode == "multi-audience"
|
||||||
assert verifier.settings == exchange_settings
|
assert verifier.settings == base_settings
|
||||||
|
|
||||||
|
|
||||||
class TestAudienceValidation:
|
class TestAudienceValidation:
|
||||||
@@ -117,9 +108,9 @@ class TestAudienceValidation:
|
|||||||
# Should pass - we only validate MCP audience per RFC 7519
|
# Should pass - we only validate MCP audience per RFC 7519
|
||||||
assert verifier._has_mcp_audience(payload) is True
|
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."""
|
"""Test MCP audience validation with client ID."""
|
||||||
verifier = UnifiedTokenVerifier(exchange_settings)
|
verifier = UnifiedTokenVerifier(base_settings)
|
||||||
payload = {
|
payload = {
|
||||||
"aud": ["test-client-id"],
|
"aud": ["test-client-id"],
|
||||||
"sub": "testuser",
|
"sub": "testuser",
|
||||||
@@ -128,9 +119,9 @@ class TestAudienceValidation:
|
|||||||
|
|
||||||
assert verifier._has_mcp_audience(payload) is True
|
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."""
|
"""Test MCP audience validation with server URL."""
|
||||||
verifier = UnifiedTokenVerifier(exchange_settings)
|
verifier = UnifiedTokenVerifier(base_settings)
|
||||||
payload = {
|
payload = {
|
||||||
"aud": ["http://localhost:8000"],
|
"aud": ["http://localhost:8000"],
|
||||||
"sub": "testuser",
|
"sub": "testuser",
|
||||||
@@ -139,9 +130,9 @@ class TestAudienceValidation:
|
|||||||
|
|
||||||
assert verifier._has_mcp_audience(payload) is True
|
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."""
|
"""Test MCP audience validation fails without MCP audience."""
|
||||||
verifier = UnifiedTokenVerifier(exchange_settings)
|
verifier = UnifiedTokenVerifier(base_settings)
|
||||||
payload = {
|
payload = {
|
||||||
"aud": ["http://localhost:8080"], # Wrong audience
|
"aud": ["http://localhost:8080"], # Wrong audience
|
||||||
"sub": "testuser",
|
"sub": "testuser",
|
||||||
@@ -292,12 +283,12 @@ class TestMultiAudienceVerification:
|
|||||||
assert result.resource == "testuser"
|
assert result.resource == "testuser"
|
||||||
|
|
||||||
|
|
||||||
class TestExchangeModeVerification:
|
class TestMcpAudienceVerification:
|
||||||
"""Test token exchange mode verification."""
|
"""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."""
|
"""Test MCP-only audience verification succeeds with MCP audience."""
|
||||||
verifier = UnifiedTokenVerifier(exchange_settings)
|
verifier = UnifiedTokenVerifier(base_settings)
|
||||||
|
|
||||||
# Mock introspection response with MCP audience only
|
# Mock introspection response with MCP audience only
|
||||||
introspection_response = {
|
introspection_response = {
|
||||||
@@ -318,9 +309,9 @@ class TestExchangeModeVerification:
|
|||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.resource == "testuser"
|
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."""
|
"""Test MCP audience verification fails without MCP audience."""
|
||||||
verifier = UnifiedTokenVerifier(exchange_settings)
|
verifier = UnifiedTokenVerifier(base_settings)
|
||||||
|
|
||||||
# Mock introspection response without MCP audience
|
# Mock introspection response without MCP audience
|
||||||
introspection_response = {
|
introspection_response = {
|
||||||
@@ -503,9 +494,9 @@ class TestVerifyTokenFlow:
|
|||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.resource == "testuser"
|
assert result.resource == "testuser"
|
||||||
|
|
||||||
async def test_verify_token_exchange_mode(self, exchange_settings):
|
async def test_verify_token_mcp_audience_only(self, base_settings):
|
||||||
"""Test verify_token in exchange mode."""
|
"""Test verify_token with MCP audience only."""
|
||||||
verifier = UnifiedTokenVerifier(exchange_settings)
|
verifier = UnifiedTokenVerifier(base_settings)
|
||||||
|
|
||||||
introspection_response = {
|
introspection_response = {
|
||||||
"active": True,
|
"active": True,
|
||||||
|
|||||||
Reference in New Issue
Block a user