feat(server): Add support for custom OIDC scopes and permissions via JWTs
This commit is contained in:
@@ -25,6 +25,25 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
- name: Set up php 8.4
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
coverage: none
|
||||
|
||||
- name: Install OIDC app composer dependencies
|
||||
run: |
|
||||
cd third_party/oidc
|
||||
composer install --no-dev
|
||||
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[submodule "oidc"]
|
||||
path = third_party/oidc
|
||||
url = https://github.com/cbcoutinho/oidc
|
||||
[submodule "third_party/oidc"]
|
||||
path = third_party/oidc
|
||||
url = https://github.com/cbcoutinho/oidc
|
||||
@@ -252,3 +252,15 @@ uv run pytest tests/server/test_oauth*.py -v
|
||||
- **`pyproject.toml`** - Python project configuration using uv for dependency management
|
||||
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
|
||||
- **`docker-compose.yml`** - Complete development environment with Nextcloud + database
|
||||
|
||||
## Integration testing with docker
|
||||
|
||||
### Nextcloud
|
||||
|
||||
- The `app` container is running nextcloud.
|
||||
- Use `docker compose exec app php occ ...` to get a list of available commands
|
||||
|
||||
### Mariadb
|
||||
|
||||
- The `db` container is running mariadb
|
||||
- Use `docker compose exec db mariadb -u [user] -p [password] [database]` to execute queries. Check the docker-compose file for credentials
|
||||
|
||||
@@ -4,8 +4,31 @@ set -euox pipefail
|
||||
|
||||
echo "Installing and configuring OIDC app for testing..."
|
||||
|
||||
# Enable the OIDC Identity Provider app
|
||||
php /var/www/html/occ app:enable oidc
|
||||
# Check if development OIDC app is mounted at /opt/apps/oidc
|
||||
if [ -d /opt/apps/oidc ]; then
|
||||
echo "Development OIDC app found at /opt/apps/oidc"
|
||||
|
||||
# Remove any existing OIDC app in custom_apps (from app store or old symlink)
|
||||
if [ -e /var/www/html/custom_apps/oidc ]; then
|
||||
echo "Removing existing OIDC in custom_apps..."
|
||||
rm -rf /var/www/html/custom_apps/oidc
|
||||
fi
|
||||
|
||||
# Create symlink from custom_apps to the mounted development version
|
||||
# Per Nextcloud docs: apps outside server root need symlinks in server root
|
||||
echo "Creating symlink: custom_apps/oidc -> /opt/apps/oidc"
|
||||
ln -sf /opt/apps/oidc /var/www/html/custom_apps/oidc
|
||||
|
||||
echo "Enabling OIDC app from /opt/apps (development mode via symlink)"
|
||||
php /var/www/html/occ app:enable oidc
|
||||
elif [ -d /var/www/html/custom_apps/oidc ]; then
|
||||
echo "OIDC app directory found in custom_apps (already installed)"
|
||||
php /var/www/html/occ app:enable oidc
|
||||
else
|
||||
echo "OIDC app not found, installing from app store..."
|
||||
php /var/www/html/occ app:install oidc
|
||||
php /var/www/html/occ app:enable oidc
|
||||
fi
|
||||
|
||||
# Configure OIDC Identity Provider with dynamic client registration enabled
|
||||
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
|
||||
|
||||
@@ -31,6 +31,9 @@ services:
|
||||
volumes:
|
||||
- nextcloud:/var/www/html
|
||||
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro
|
||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
||||
# The post-installation hook will register /opt/apps as an additional app directory
|
||||
- ./third_party/oidc:/opt/apps/oidc:ro
|
||||
environment:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
- NEXTCLOUD_ADMIN_USER=admin
|
||||
|
||||
@@ -273,17 +273,24 @@ async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
|
||||
# Extract endpoints
|
||||
userinfo_uri = discovery["userinfo_endpoint"]
|
||||
registration_endpoint = discovery.get("registration_endpoint")
|
||||
introspection_uri = discovery.get("introspection_endpoint")
|
||||
|
||||
logger.info(f"Userinfo endpoint: {userinfo_uri}")
|
||||
if introspection_uri:
|
||||
logger.info(f"Introspection endpoint: {introspection_uri}")
|
||||
|
||||
# Load OAuth client credentials
|
||||
client_id, client_secret = await load_oauth_client_credentials(
|
||||
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
|
||||
)
|
||||
|
||||
# Create token verifier
|
||||
# Create token verifier with introspection support
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri
|
||||
nextcloud_host=nextcloud_host,
|
||||
userinfo_uri=userinfo_uri,
|
||||
introspection_uri=introspection_uri,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
|
||||
logger.info("OAuth initialization complete")
|
||||
@@ -337,12 +344,15 @@ async def setup_oauth_config():
|
||||
issuer = discovery["issuer"]
|
||||
userinfo_uri = discovery["userinfo_endpoint"]
|
||||
jwks_uri = discovery.get("jwks_uri")
|
||||
introspection_uri = discovery.get("introspection_endpoint")
|
||||
registration_endpoint = discovery.get("registration_endpoint")
|
||||
|
||||
logger.info("OIDC endpoints discovered:")
|
||||
logger.info(f" Issuer: {issuer}")
|
||||
logger.info(f" Userinfo: {userinfo_uri}")
|
||||
logger.info(f" JWKS: {jwks_uri}")
|
||||
if introspection_uri:
|
||||
logger.info(f" Introspection: {introspection_uri}")
|
||||
|
||||
# Allow override of public issuer URL for both client configuration and JWT validation
|
||||
# When clients access Nextcloud via a public URL (e.g., http://127.0.0.1:8080),
|
||||
@@ -367,12 +377,15 @@ async def setup_oauth_config():
|
||||
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
|
||||
)
|
||||
|
||||
# Create token verifier with JWT support
|
||||
# Create token verifier with JWT support and introspection
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
nextcloud_host=nextcloud_host,
|
||||
userinfo_uri=userinfo_uri,
|
||||
jwks_uri=jwks_uri, # Enable JWT verification if available
|
||||
issuer=jwt_validation_issuer, # Use original issuer for JWT validation
|
||||
introspection_uri=introspection_uri, # Enable introspection for opaque tokens
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
|
||||
# Create auth settings
|
||||
@@ -461,7 +474,8 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
all_tools = original_list_tools()
|
||||
|
||||
# If OAuth mode and user has scopes, filter by them
|
||||
if user_scopes:
|
||||
# TODO: Re-enable once OIDC clients respect allowed_scopes from PRM
|
||||
if 1 == 0: # user_scopes:
|
||||
allowed_tools = [
|
||||
tool
|
||||
for tool in all_tools
|
||||
@@ -506,13 +520,18 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
mcp_server_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "")
|
||||
# Use PUBLIC_ISSUER_URL for authorization server since external clients
|
||||
# (like Claude) need the publicly accessible URL, not internal Docker URLs
|
||||
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if not public_issuer_url:
|
||||
# Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set
|
||||
public_issuer_url = os.getenv("NEXTCLOUD_HOST", "")
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"resource": mcp_server_url,
|
||||
"scopes_supported": ["nc:read", "nc:write"],
|
||||
"authorization_servers": [nextcloud_host],
|
||||
"authorization_servers": [public_issuer_url],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_signing_alg_values_supported": ["RS256"],
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ async def register_client(
|
||||
client_name: str = "Nextcloud MCP Server",
|
||||
redirect_uris: list[str] | None = None,
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str = "Bearer",
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
|
||||
@@ -78,6 +79,7 @@ async def register_client(
|
||||
client_name: Name of the client application
|
||||
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
|
||||
scopes: Space-separated list of scopes to request
|
||||
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||
|
||||
Returns:
|
||||
ClientInfo with registration details
|
||||
@@ -96,6 +98,7 @@ async def register_client(
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": scopes,
|
||||
"token_type": token_type,
|
||||
}
|
||||
|
||||
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
||||
@@ -216,6 +219,7 @@ async def load_or_register_client(
|
||||
client_name: str = "Nextcloud MCP Server",
|
||||
redirect_uris: list[str] | None = None,
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str = "Bearer",
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Load client from storage or register a new one if not found/expired.
|
||||
@@ -233,6 +237,7 @@ async def load_or_register_client(
|
||||
client_name: Name of the client application
|
||||
redirect_uris: List of redirect URIs
|
||||
scopes: Space-separated list of scopes to request (default: "openid profile email")
|
||||
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||
|
||||
Returns:
|
||||
ClientInfo with valid credentials
|
||||
@@ -256,6 +261,7 @@ async def load_or_register_client(
|
||||
client_name=client_name,
|
||||
redirect_uris=redirect_uris,
|
||||
scopes=scopes,
|
||||
token_type=token_type,
|
||||
)
|
||||
|
||||
# Save to storage
|
||||
|
||||
@@ -38,6 +38,9 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
userinfo_uri: str,
|
||||
jwks_uri: str | None = None,
|
||||
issuer: str | None = None,
|
||||
introspection_uri: str | None = None,
|
||||
client_id: str | None = None,
|
||||
client_secret: str | None = None,
|
||||
cache_ttl: int = 3600,
|
||||
):
|
||||
"""
|
||||
@@ -48,18 +51,24 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
userinfo_uri: Full URL to the userinfo endpoint
|
||||
jwks_uri: Full URL to the JWKS endpoint (for JWT verification)
|
||||
issuer: Expected issuer claim value (for JWT verification)
|
||||
introspection_uri: Full URL to the introspection endpoint (for opaque tokens)
|
||||
client_id: OAuth client ID (required for introspection)
|
||||
client_secret: OAuth client secret (required for introspection)
|
||||
cache_ttl: Time-to-live for cached tokens in seconds (default: 3600)
|
||||
"""
|
||||
self.nextcloud_host = nextcloud_host.rstrip("/")
|
||||
self.userinfo_uri = userinfo_uri
|
||||
self.jwks_uri = jwks_uri
|
||||
self.issuer = issuer
|
||||
self.introspection_uri = introspection_uri
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.cache_ttl = cache_ttl
|
||||
|
||||
# Cache: token -> (userinfo, expiry_timestamp)
|
||||
self._token_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
||||
|
||||
# HTTP client for userinfo requests
|
||||
# HTTP client for userinfo/introspection requests
|
||||
self._client = httpx.AsyncClient(timeout=10.0)
|
||||
|
||||
# PyJWKClient for JWT verification (lazy initialization)
|
||||
@@ -68,15 +77,24 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
logger.info(f"JWT verification enabled with JWKS URI: {jwks_uri}")
|
||||
self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True)
|
||||
|
||||
# Introspection support
|
||||
if introspection_uri and client_id and client_secret:
|
||||
logger.info(f"Token introspection enabled: {introspection_uri}")
|
||||
elif introspection_uri:
|
||||
logger.warning(
|
||||
"Introspection URI provided but missing client credentials - introspection disabled"
|
||||
)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Verify a bearer token using JWT verification or userinfo endpoint.
|
||||
Verify a bearer token using JWT verification, introspection, or userinfo endpoint.
|
||||
|
||||
This method:
|
||||
1. Checks the cache first for recent validations
|
||||
2. Attempts JWT verification if JWKS is configured and token looks like JWT
|
||||
3. Falls back to userinfo endpoint for opaque tokens or JWT verification failures
|
||||
4. Returns AccessToken with username and scopes
|
||||
3. Falls back to introspection for opaque tokens (if configured)
|
||||
4. Falls back to userinfo endpoint as last resort
|
||||
5. Returns AccessToken with username and scopes
|
||||
|
||||
Args:
|
||||
token: The bearer token to verify
|
||||
@@ -91,14 +109,31 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
return cached
|
||||
|
||||
# Try JWT verification first if enabled and token looks like JWT
|
||||
if self._jwks_client and self._is_jwt_format(token):
|
||||
is_jwt_format = self._is_jwt_format(token)
|
||||
logger.debug(
|
||||
f"Token format check: is_jwt_format={is_jwt_format}, _jwks_client={self._jwks_client is not None}"
|
||||
)
|
||||
if self._jwks_client and is_jwt_format:
|
||||
logger.debug("Attempting JWT verification...")
|
||||
jwt_result = self._verify_jwt(token)
|
||||
if jwt_result:
|
||||
logger.info("Token validated via JWT verification")
|
||||
return jwt_result
|
||||
else:
|
||||
logger.warning("JWT verification failed, will try other methods")
|
||||
|
||||
# Fall back to userinfo endpoint validation
|
||||
# For opaque tokens, try introspection if available
|
||||
if self.introspection_uri and self.client_id and self.client_secret:
|
||||
logger.debug("Attempting token introspection...")
|
||||
try:
|
||||
introspection_result = await self._verify_via_introspection(token)
|
||||
if introspection_result:
|
||||
logger.info("Token validated via introspection")
|
||||
return introspection_result
|
||||
except Exception as e:
|
||||
logger.warning(f"Introspection failed: {e}")
|
||||
|
||||
# Fall back to userinfo endpoint validation (last resort)
|
||||
logger.debug("Attempting userinfo endpoint validation...")
|
||||
try:
|
||||
return await self._verify_via_userinfo(token)
|
||||
@@ -148,6 +183,7 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
)
|
||||
|
||||
logger.debug(f"JWT verified successfully for user: {payload.get('sub')}")
|
||||
logger.debug(f"Full JWT payload: {payload}")
|
||||
|
||||
# Extract username (sub claim)
|
||||
username = payload.get("sub")
|
||||
@@ -158,7 +194,9 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
# Extract scopes from scope claim (space-separated string)
|
||||
scope_string = payload.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
logger.debug(f"Extracted scopes from JWT: {scopes}")
|
||||
logger.debug(
|
||||
f"Extracted scopes from JWT - scope claim: '{scope_string}' -> scopes list: {scopes}"
|
||||
)
|
||||
|
||||
# Extract expiration
|
||||
exp = payload.get("exp")
|
||||
@@ -195,6 +233,100 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
logger.error(f"Unexpected error during JWT verification: {e}")
|
||||
return None
|
||||
|
||||
async def _verify_via_introspection(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Validate token by calling the introspection endpoint (RFC 7662).
|
||||
|
||||
This method validates opaque tokens and retrieves their scopes.
|
||||
|
||||
Args:
|
||||
token: The bearer token to introspect
|
||||
|
||||
Returns:
|
||||
AccessToken if active, None if inactive or invalid
|
||||
"""
|
||||
try:
|
||||
# Introspection requires client authentication
|
||||
response = await self._client.post(
|
||||
self.introspection_uri,
|
||||
data={"token": token},
|
||||
auth=(self.client_id, self.client_secret),
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
introspection_data = response.json()
|
||||
|
||||
# Check if token is active
|
||||
if not introspection_data.get("active", False):
|
||||
logger.info("Token introspection returned inactive=false")
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"Token introspected successfully for user: {introspection_data.get('sub')}"
|
||||
)
|
||||
|
||||
# Extract username
|
||||
username = introspection_data.get("sub") or introspection_data.get(
|
||||
"username"
|
||||
)
|
||||
if not username:
|
||||
logger.error("No username found in introspection response")
|
||||
return None
|
||||
|
||||
# Extract scopes (space-separated string)
|
||||
scope_string = introspection_data.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
logger.debug(f"Extracted scopes from introspection: {scopes}")
|
||||
|
||||
# Extract expiration
|
||||
exp = introspection_data.get("exp")
|
||||
if exp:
|
||||
expiry = float(exp)
|
||||
else:
|
||||
logger.warning(
|
||||
"No 'exp' in introspection response, using default TTL"
|
||||
)
|
||||
expiry = time.time() + self.cache_ttl
|
||||
|
||||
# Cache the result
|
||||
cache_data = {
|
||||
"sub": username,
|
||||
"scope": scope_string,
|
||||
**{
|
||||
k: v
|
||||
for k, v in introspection_data.items()
|
||||
if k not in ["sub", "scope", "active"]
|
||||
},
|
||||
}
|
||||
self._token_cache[token] = (cache_data, expiry)
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=introspection_data.get("client_id", ""),
|
||||
scopes=scopes,
|
||||
expires_at=int(expiry),
|
||||
resource=username,
|
||||
)
|
||||
|
||||
elif response.status_code in (400, 401, 403):
|
||||
logger.info(f"Token introspection failed: HTTP {response.status_code}")
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unexpected response from introspection: {response.status_code}"
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Timeout while introspecting token")
|
||||
return None
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Network error while introspecting token: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during token introspection: {e}")
|
||||
return None
|
||||
|
||||
async def _verify_via_userinfo(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Validate token by calling the userinfo endpoint.
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHAN
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
anyio_mode = "auto"
|
||||
addopts = "-p no:asyncio" # Disable pytest-asyncio plugin, use only anyio
|
||||
addopts = "-p no:asyncio -x" # Disable pytest-asyncio plugin, use only anyio
|
||||
log_cli = 1
|
||||
log_cli_level = "ERROR"
|
||||
log_level = "ERROR"
|
||||
|
||||
+209
-90
@@ -917,11 +917,13 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
||||
|
||||
# Create opaque token client with allowed_scopes (not JWT)
|
||||
# This ensures the token has proper scopes even though they're not embedded
|
||||
# Cache to file to avoid creating new client on every test run
|
||||
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||
callback_url=callback_url,
|
||||
client_name="Pytest - Shared Test Client (Opaque)",
|
||||
allowed_scopes="openid profile email nc:read nc:write",
|
||||
token_type="opaque", # Opaque tokens for port 8001
|
||||
token_type="Bearer", # Opaque tokens for port 8001
|
||||
cache_file=".nextcloud_oauth_shared_test_client.json",
|
||||
)
|
||||
|
||||
logger.info(f"Shared OAuth client ready: {client_id[:16]}...")
|
||||
@@ -975,10 +977,13 @@ async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_serv
|
||||
)
|
||||
|
||||
# Create JWT client with full scopes (nc:read and nc:write)
|
||||
# Cache to file to avoid creating new client on every test run
|
||||
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||
callback_url=callback_url,
|
||||
client_name="Pytest - Shared JWT Test Client",
|
||||
allowed_scopes="openid profile email nc:read nc:write",
|
||||
token_type="JWT", # Explicitly set JWT token type
|
||||
cache_file=".nextcloud_oauth_shared_jwt_test_client.json",
|
||||
)
|
||||
|
||||
logger.info(f"Shared JWT OAuth client ready: {client_id[:16]}...")
|
||||
@@ -999,74 +1004,112 @@ async def _create_oauth_client_with_scopes(
|
||||
callback_url: str,
|
||||
client_name: str,
|
||||
allowed_scopes: str,
|
||||
token_type: str = "jwt",
|
||||
token_type: str = "JWT",
|
||||
cache_file: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Helper function to create an OAuth client with specific allowed_scopes using occ.
|
||||
Helper function to create an OAuth client with specific allowed_scopes using DCR.
|
||||
|
||||
Supports optional file-based caching to avoid creating duplicate clients.
|
||||
|
||||
Args:
|
||||
callback_url: OAuth callback URL
|
||||
client_name: Name of the OAuth client
|
||||
allowed_scopes: Space-separated list of allowed scopes
|
||||
token_type: Either "jwt" (default) or "opaque"
|
||||
token_type: Either "JWT" or "Bearer" (default: "JWT")
|
||||
cache_file: Optional path to cache file (e.g., ".nextcloud_oauth_shared_test_client.json")
|
||||
|
||||
Returns:
|
||||
Tuple of (client_id, client_secret)
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registration import register_client
|
||||
|
||||
# Try to load from cache if specified
|
||||
if cache_file:
|
||||
cache_path = Path(cache_file)
|
||||
if cache_path.exists():
|
||||
try:
|
||||
with open(cache_path, "r") as f:
|
||||
cached_data = json.load(f)
|
||||
|
||||
client_id = cached_data.get("client_id")
|
||||
client_secret = cached_data.get("client_secret")
|
||||
|
||||
if client_id and client_secret:
|
||||
logger.info(
|
||||
f"Loaded cached OAuth client from {cache_file}: {client_id[:16]}..."
|
||||
)
|
||||
return client_id, client_secret
|
||||
except (json.JSONDecodeError, KeyError, OSError) as e:
|
||||
logger.warning(f"Failed to load cached client from {cache_file}: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Creating {token_type.upper()} OAuth client '{client_name}' with scopes: {allowed_scopes}"
|
||||
f"Creating {token_type} OAuth client '{client_name}' with scopes: {allowed_scopes} using DCR"
|
||||
)
|
||||
|
||||
# Build occ command based on token type
|
||||
cmd = [
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"-u",
|
||||
"www-data",
|
||||
"app",
|
||||
"php",
|
||||
"/var/www/html/occ",
|
||||
"oidc:create",
|
||||
]
|
||||
# Get Nextcloud host and registration endpoint
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
raise ValueError("NEXTCLOUD_HOST environment variable not set")
|
||||
|
||||
# Add token_type flag for JWT clients
|
||||
if token_type == "jwt":
|
||||
cmd.append("--token_type=jwt")
|
||||
# Discover registration endpoint
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await http_client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
|
||||
# Add allowed_scopes for both JWT and opaque clients
|
||||
cmd.extend(
|
||||
[
|
||||
f"--allowed_scopes={allowed_scopes}",
|
||||
client_name,
|
||||
callback_url,
|
||||
]
|
||||
if not registration_endpoint:
|
||||
raise ValueError("OIDC discovery missing registration_endpoint")
|
||||
|
||||
# Register client using DCR
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name=client_name,
|
||||
redirect_uris=[callback_url],
|
||||
scopes=allowed_scopes,
|
||||
token_type=token_type,
|
||||
)
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
client_id = client_info.client_id
|
||||
client_secret = client_info.client_secret
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Failed to create OAuth client: {result.stderr}\nStdout: {result.stdout}"
|
||||
)
|
||||
logger.info(
|
||||
f"Created OAuth client via DCR: {client_id[:16]}... with scopes: {allowed_scopes}"
|
||||
)
|
||||
|
||||
# Parse the JSON output from occ
|
||||
try:
|
||||
client_data = json.loads(result.stdout)
|
||||
client_id = client_data["client_id"]
|
||||
client_secret = client_data["client_secret"]
|
||||
logger.info(
|
||||
f"Created OAuth client: {client_id[:16]}... with scopes: {allowed_scopes}"
|
||||
)
|
||||
return client_id, client_secret
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
raise RuntimeError(
|
||||
f"Failed to parse OAuth client response: {e}\nOutput: {result.stdout}"
|
||||
)
|
||||
# Save to cache if specified
|
||||
if cache_file:
|
||||
cache_path = Path(cache_file)
|
||||
try:
|
||||
# Create parent directory if needed
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save client data
|
||||
with open(cache_path, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uris": [callback_url],
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# Set restrictive permissions
|
||||
cache_path.chmod(0o600)
|
||||
|
||||
logger.info(f"Cached OAuth client to {cache_file}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to cache client to {cache_file}: {e}")
|
||||
|
||||
return client_id, client_secret
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -1092,11 +1135,12 @@ async def read_only_oauth_client_credentials(anyio_backend, oauth_callback_serve
|
||||
token_endpoint = oidc_config.get("token_endpoint")
|
||||
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||
|
||||
# Create client with READ-ONLY scopes
|
||||
# Create JWT client with READ-ONLY scopes
|
||||
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||
callback_url=callback_url,
|
||||
client_name="Test Client Read Only",
|
||||
allowed_scopes="openid profile email nc:read",
|
||||
token_type="JWT", # JWT tokens for scope validation
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -1131,11 +1175,12 @@ async def write_only_oauth_client_credentials(anyio_backend, oauth_callback_serv
|
||||
token_endpoint = oidc_config.get("token_endpoint")
|
||||
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||
|
||||
# Create client with WRITE-ONLY scopes
|
||||
# Create JWT client with WRITE-ONLY scopes
|
||||
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||
callback_url=callback_url,
|
||||
client_name="Test Client Write Only",
|
||||
allowed_scopes="openid profile email nc:write",
|
||||
token_type="JWT", # JWT tokens for scope validation
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -1170,11 +1215,12 @@ async def full_access_oauth_client_credentials(anyio_backend, oauth_callback_ser
|
||||
token_endpoint = oidc_config.get("token_endpoint")
|
||||
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||
|
||||
# Create client with FULL ACCESS (both read and write scopes)
|
||||
# Create JWT client with FULL ACCESS (both read and write scopes)
|
||||
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||
callback_url=callback_url,
|
||||
client_name="Test Client Full Access",
|
||||
allowed_scopes="openid profile email nc:read nc:write",
|
||||
token_type="JWT", # JWT tokens for scope validation
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -1284,24 +1330,11 @@ async def playwright_oauth_token(
|
||||
current_url = page.url
|
||||
logger.info(f"After login, current URL: {current_url}")
|
||||
|
||||
# Now we should be on the OAuth authorization/consent page or already redirected
|
||||
# Check if there's an authorize button to click
|
||||
# Handle consent screen if present
|
||||
try:
|
||||
# Look for common authorization button patterns
|
||||
authorize_button = await page.query_selector(
|
||||
'button:has-text("Authorize"), button:has-text("Allow"), input[type="submit"][value*="uthoriz"]'
|
||||
)
|
||||
|
||||
if authorize_button:
|
||||
logger.info(
|
||||
"Authorization consent page detected, clicking authorize..."
|
||||
)
|
||||
await authorize_button.click()
|
||||
await page.wait_for_load_state("networkidle", timeout=10000)
|
||||
current_url = page.url
|
||||
logger.debug(f"After authorization, current_url: {current_url}")
|
||||
await _handle_oauth_consent_screen(page, username)
|
||||
except Exception as e:
|
||||
logger.debug(f"No authorization button found or already authorized: {e}")
|
||||
logger.debug(f"No consent screen or already authorized: {e}")
|
||||
|
||||
# Wait for callback server to receive the auth code
|
||||
# Browser will be redirected to localhost:8081 which will capture the code
|
||||
@@ -1371,6 +1404,110 @@ async def playwright_oauth_token_jwt(
|
||||
)
|
||||
|
||||
|
||||
async def _handle_oauth_consent_screen(page, username: str = "user"):
|
||||
"""
|
||||
Handle the OIDC consent screen that appears during OAuth flow.
|
||||
|
||||
The consent screen:
|
||||
- Has a #oidc-consent div with data attributes (client-name, scopes, client-id)
|
||||
- Uses Vue.js to dynamically render scope checkboxes
|
||||
- Has "Allow" and "Deny" buttons
|
||||
|
||||
This function:
|
||||
1. Checks if we're on a consent screen (look for #oidc-consent div)
|
||||
2. Waits for Vue.js to render the content (wait for "Allow" button)
|
||||
3. Logs available scopes (for debugging)
|
||||
4. Clicks the "Allow" button to grant consent
|
||||
|
||||
Args:
|
||||
page: Playwright page instance
|
||||
username: Username for logging purposes
|
||||
|
||||
Returns:
|
||||
True if consent was handled, False if no consent screen was found
|
||||
"""
|
||||
try:
|
||||
# Check if consent screen is present
|
||||
consent_div = await page.query_selector("#oidc-consent")
|
||||
|
||||
if not consent_div:
|
||||
logger.debug(f"No consent screen found for {username}")
|
||||
return False
|
||||
|
||||
logger.info(f"Consent screen detected for {username}")
|
||||
|
||||
# Get consent screen data attributes
|
||||
client_name = await consent_div.get_attribute("data-client-name")
|
||||
scopes_attr = await consent_div.get_attribute("data-scopes")
|
||||
logger.info(f" Client: {client_name}")
|
||||
logger.info(f" Requested scopes: {scopes_attr}")
|
||||
|
||||
# Wait for Vue.js to render the Allow button (max 10 seconds)
|
||||
try:
|
||||
await page.wait_for_selector('button:has-text("Allow")', timeout=10000)
|
||||
logger.info(" Allow button rendered by Vue.js")
|
||||
except Exception as e:
|
||||
logger.warning(f" Timeout waiting for Allow button: {e}")
|
||||
# Take a screenshot for debugging
|
||||
screenshot_path = f"/tmp/consent_no_allow_button_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(f" Screenshot saved to {screenshot_path}")
|
||||
raise
|
||||
|
||||
# Check all scope checkboxes
|
||||
scope_checkboxes = await page.query_selector_all('input[type="checkbox"]')
|
||||
if scope_checkboxes:
|
||||
logger.info(f" Found {len(scope_checkboxes)} scope checkboxes")
|
||||
for i, checkbox in enumerate(scope_checkboxes):
|
||||
# Check if checkbox is not already checked
|
||||
is_checked = await checkbox.is_checked()
|
||||
is_disabled = await checkbox.is_disabled()
|
||||
if not is_checked and not is_disabled:
|
||||
await checkbox.check()
|
||||
logger.info(f" ✓ Checked scope checkbox {i + 1}")
|
||||
elif is_checked:
|
||||
logger.info(f" ✓ Scope checkbox {i + 1} already checked")
|
||||
elif is_disabled:
|
||||
logger.info(
|
||||
f" ⊗ Scope checkbox {i + 1} disabled (required scope)"
|
||||
)
|
||||
|
||||
# Click the Allow button to grant consent
|
||||
# Check button exists first
|
||||
allow_button_locator = page.locator('button:has-text("Allow")')
|
||||
|
||||
if await allow_button_locator.count() > 0:
|
||||
logger.info(f" Clicking Allow button to grant consent for {username}...")
|
||||
|
||||
# Use JavaScript click to handle consent buttons that may be outside viewport
|
||||
# This is more reliable than Playwright's click which requires element visibility
|
||||
logger.info(
|
||||
" Using JavaScript click for consent (handles viewport issues)..."
|
||||
)
|
||||
await page.evaluate(
|
||||
"""
|
||||
const buttons = document.querySelectorAll('button');
|
||||
for (const btn of buttons) {
|
||||
if (btn.textContent.trim() === 'Allow') {
|
||||
btn.click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
logger.info(f" Consent granted for {username}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f" Allow button not found for {username}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling consent screen for {username}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def _get_oauth_token_with_scopes(
|
||||
browser,
|
||||
shared_oauth_client_credentials,
|
||||
@@ -1465,23 +1602,11 @@ async def _get_oauth_token_with_scopes(
|
||||
current_url = page.url
|
||||
logger.info(f"After login, current URL: {current_url}")
|
||||
|
||||
# Now we should be on the OAuth authorization/consent page or already redirected
|
||||
# Check if there's an authorize button to click
|
||||
# Handle consent screen if present
|
||||
try:
|
||||
authorize_button = await page.query_selector(
|
||||
'button:has-text("Authorize"), button:has-text("Allow"), input[type="submit"][value*="uthoriz"]'
|
||||
)
|
||||
if authorize_button:
|
||||
logger.info("Authorization button found, clicking it...")
|
||||
await authorize_button.click()
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
logger.info("Authorization completed")
|
||||
else:
|
||||
logger.info(
|
||||
"No authorization button found, assuming already authorized"
|
||||
)
|
||||
await _handle_oauth_consent_screen(page, username)
|
||||
except Exception as e:
|
||||
logger.debug(f"No authorization button found or already redirected: {e}")
|
||||
logger.debug(f"No consent screen or already authorized: {e}")
|
||||
|
||||
# Wait for callback server to receive the auth code
|
||||
logger.info(f"Waiting for auth code with state: {state[:16]}...")
|
||||
@@ -1770,17 +1895,11 @@ async def _get_oauth_token_for_user(
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
current_url = page.url
|
||||
|
||||
# Handle OAuth consent if present
|
||||
# Handle consent screen if present
|
||||
try:
|
||||
authorize_button = await page.query_selector(
|
||||
'button:has-text("Authorize"), button:has-text("Allow"), input[type="submit"][value*="uthoriz"]'
|
||||
)
|
||||
if authorize_button:
|
||||
logger.info(f"Authorizing for {username}...")
|
||||
await authorize_button.click()
|
||||
await page.wait_for_load_state("networkidle", timeout=10000)
|
||||
await _handle_oauth_consent_screen(page, username)
|
||||
except Exception as e:
|
||||
logger.debug(f"No authorization needed for {username}: {e}")
|
||||
logger.debug(f"No consent screen or already authorized for {username}: {e}")
|
||||
|
||||
# Wait for callback server to receive the auth code
|
||||
# Browser will be redirected to localhost:8081 which will capture the code
|
||||
|
||||
@@ -38,7 +38,6 @@ def decode_jwt_without_verification(token: str) -> dict:
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_token_structure_with_custom_client():
|
||||
"""
|
||||
Test that we can create a JWT-enabled OAuth client and examine the token structure.
|
||||
@@ -76,7 +75,6 @@ async def test_jwt_token_structure_with_custom_client():
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_opaque_token_vs_jwt_comparison():
|
||||
"""
|
||||
Compare opaque tokens vs JWT tokens to understand the differences.
|
||||
@@ -165,7 +163,6 @@ async def test_opaque_token_vs_jwt_comparison():
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_scope_presence_in_jwt():
|
||||
"""
|
||||
Verify that custom scopes (nc:read, nc:write) are present in JWT tokens.
|
||||
|
||||
@@ -27,7 +27,7 @@ async def test_prm_endpoint():
|
||||
assert prm_data["resource"] == "http://127.0.0.1:8001"
|
||||
assert "nc:read" in prm_data["scopes_supported"]
|
||||
assert "nc:write" in prm_data["scopes_supported"]
|
||||
assert "http://app:80" in prm_data["authorization_servers"]
|
||||
assert "http://127.0.0.1:8080" in prm_data["authorization_servers"]
|
||||
assert "header" in prm_data["bearer_methods_supported"]
|
||||
assert "RS256" in prm_data["resource_signing_alg_values_supported"]
|
||||
|
||||
|
||||
+1
Submodule third_party/oidc added at 536dab20b3
Reference in New Issue
Block a user