feat(server): Add support for custom OIDC scopes and permissions via JWTs

This commit is contained in:
Chris Coutinho
2025-10-23 08:37:36 +02:00
parent 3ebc468a09
commit e48f5f3f30
13 changed files with 447 additions and 110 deletions
+19
View File
@@ -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
+6
View File
@@ -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
+12
View File
@@ -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'
+3
View File
@@ -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
+25 -6
View File
@@ -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
+139 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
-3
View File
@@ -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.
+1 -1
View File
@@ -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"]
Vendored Submodule
+1
Submodule third_party/oidc added at 536dab20b3