feat(server): Add support for custom OIDC scopes and permissions via JWTs
This commit is contained in:
@@ -25,6 +25,25 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- 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
|
- name: Run docker compose
|
||||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
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
|
- **`pyproject.toml`** - Python project configuration using uv for dependency management
|
||||||
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
|
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
|
||||||
- **`docker-compose.yml`** - Complete development environment with Nextcloud + database
|
- **`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..."
|
echo "Installing and configuring OIDC app for testing..."
|
||||||
|
|
||||||
# Enable the OIDC Identity Provider app
|
# Check if development OIDC app is mounted at /opt/apps/oidc
|
||||||
php /var/www/html/occ app:enable 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
|
# Configure OIDC Identity Provider with dynamic client registration enabled
|
||||||
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
|
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- nextcloud:/var/www/html
|
- nextcloud:/var/www/html
|
||||||
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro
|
- ./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:
|
environment:
|
||||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||||
- NEXTCLOUD_ADMIN_USER=admin
|
- NEXTCLOUD_ADMIN_USER=admin
|
||||||
|
|||||||
@@ -273,17 +273,24 @@ async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
|
|||||||
# Extract endpoints
|
# Extract endpoints
|
||||||
userinfo_uri = discovery["userinfo_endpoint"]
|
userinfo_uri = discovery["userinfo_endpoint"]
|
||||||
registration_endpoint = discovery.get("registration_endpoint")
|
registration_endpoint = discovery.get("registration_endpoint")
|
||||||
|
introspection_uri = discovery.get("introspection_endpoint")
|
||||||
|
|
||||||
logger.info(f"Userinfo endpoint: {userinfo_uri}")
|
logger.info(f"Userinfo endpoint: {userinfo_uri}")
|
||||||
|
if introspection_uri:
|
||||||
|
logger.info(f"Introspection endpoint: {introspection_uri}")
|
||||||
|
|
||||||
# Load OAuth client credentials
|
# Load OAuth client credentials
|
||||||
client_id, client_secret = await load_oauth_client_credentials(
|
client_id, client_secret = await load_oauth_client_credentials(
|
||||||
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
|
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create token verifier
|
# Create token verifier with introspection support
|
||||||
token_verifier = NextcloudTokenVerifier(
|
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")
|
logger.info("OAuth initialization complete")
|
||||||
@@ -337,12 +344,15 @@ async def setup_oauth_config():
|
|||||||
issuer = discovery["issuer"]
|
issuer = discovery["issuer"]
|
||||||
userinfo_uri = discovery["userinfo_endpoint"]
|
userinfo_uri = discovery["userinfo_endpoint"]
|
||||||
jwks_uri = discovery.get("jwks_uri")
|
jwks_uri = discovery.get("jwks_uri")
|
||||||
|
introspection_uri = discovery.get("introspection_endpoint")
|
||||||
registration_endpoint = discovery.get("registration_endpoint")
|
registration_endpoint = discovery.get("registration_endpoint")
|
||||||
|
|
||||||
logger.info("OIDC endpoints discovered:")
|
logger.info("OIDC endpoints discovered:")
|
||||||
logger.info(f" Issuer: {issuer}")
|
logger.info(f" Issuer: {issuer}")
|
||||||
logger.info(f" Userinfo: {userinfo_uri}")
|
logger.info(f" Userinfo: {userinfo_uri}")
|
||||||
logger.info(f" JWKS: {jwks_uri}")
|
logger.info(f" JWKS: {jwks_uri}")
|
||||||
|
if introspection_uri:
|
||||||
|
logger.info(f" Introspection: {introspection_uri}")
|
||||||
|
|
||||||
# Allow override of public issuer URL for both client configuration and JWT validation
|
# 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),
|
# 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
|
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(
|
token_verifier = NextcloudTokenVerifier(
|
||||||
nextcloud_host=nextcloud_host,
|
nextcloud_host=nextcloud_host,
|
||||||
userinfo_uri=userinfo_uri,
|
userinfo_uri=userinfo_uri,
|
||||||
jwks_uri=jwks_uri, # Enable JWT verification if available
|
jwks_uri=jwks_uri, # Enable JWT verification if available
|
||||||
issuer=jwt_validation_issuer, # Use original issuer for JWT validation
|
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
|
# Create auth settings
|
||||||
@@ -461,7 +474,8 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
all_tools = original_list_tools()
|
all_tools = original_list_tools()
|
||||||
|
|
||||||
# If OAuth mode and user has scopes, filter by them
|
# 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 = [
|
allowed_tools = [
|
||||||
tool
|
tool
|
||||||
for tool in all_tools
|
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(
|
mcp_server_url = os.getenv(
|
||||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
"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(
|
return JSONResponse(
|
||||||
{
|
{
|
||||||
"resource": mcp_server_url,
|
"resource": mcp_server_url,
|
||||||
"scopes_supported": ["nc:read", "nc:write"],
|
"scopes_supported": ["nc:read", "nc:write"],
|
||||||
"authorization_servers": [nextcloud_host],
|
"authorization_servers": [public_issuer_url],
|
||||||
"bearer_methods_supported": ["header"],
|
"bearer_methods_supported": ["header"],
|
||||||
"resource_signing_alg_values_supported": ["RS256"],
|
"resource_signing_alg_values_supported": ["RS256"],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ async def register_client(
|
|||||||
client_name: str = "Nextcloud MCP Server",
|
client_name: str = "Nextcloud MCP Server",
|
||||||
redirect_uris: list[str] | None = None,
|
redirect_uris: list[str] | None = None,
|
||||||
scopes: str = "openid profile email",
|
scopes: str = "openid profile email",
|
||||||
|
token_type: str = "Bearer",
|
||||||
) -> ClientInfo:
|
) -> ClientInfo:
|
||||||
"""
|
"""
|
||||||
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
|
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
|
client_name: Name of the client application
|
||||||
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
|
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
|
||||||
scopes: Space-separated list of scopes to request
|
scopes: Space-separated list of scopes to request
|
||||||
|
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ClientInfo with registration details
|
ClientInfo with registration details
|
||||||
@@ -96,6 +98,7 @@ async def register_client(
|
|||||||
"grant_types": ["authorization_code", "refresh_token"],
|
"grant_types": ["authorization_code", "refresh_token"],
|
||||||
"response_types": ["code"],
|
"response_types": ["code"],
|
||||||
"scope": scopes,
|
"scope": scopes,
|
||||||
|
"token_type": token_type,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
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",
|
client_name: str = "Nextcloud MCP Server",
|
||||||
redirect_uris: list[str] | None = None,
|
redirect_uris: list[str] | None = None,
|
||||||
scopes: str = "openid profile email",
|
scopes: str = "openid profile email",
|
||||||
|
token_type: str = "Bearer",
|
||||||
) -> ClientInfo:
|
) -> ClientInfo:
|
||||||
"""
|
"""
|
||||||
Load client from storage or register a new one if not found/expired.
|
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
|
client_name: Name of the client application
|
||||||
redirect_uris: List of redirect URIs
|
redirect_uris: List of redirect URIs
|
||||||
scopes: Space-separated list of scopes to request (default: "openid profile email")
|
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:
|
Returns:
|
||||||
ClientInfo with valid credentials
|
ClientInfo with valid credentials
|
||||||
@@ -256,6 +261,7 @@ async def load_or_register_client(
|
|||||||
client_name=client_name,
|
client_name=client_name,
|
||||||
redirect_uris=redirect_uris,
|
redirect_uris=redirect_uris,
|
||||||
scopes=scopes,
|
scopes=scopes,
|
||||||
|
token_type=token_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save to storage
|
# Save to storage
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ class NextcloudTokenVerifier(TokenVerifier):
|
|||||||
userinfo_uri: str,
|
userinfo_uri: str,
|
||||||
jwks_uri: str | None = None,
|
jwks_uri: str | None = None,
|
||||||
issuer: 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,
|
cache_ttl: int = 3600,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -48,18 +51,24 @@ class NextcloudTokenVerifier(TokenVerifier):
|
|||||||
userinfo_uri: Full URL to the userinfo endpoint
|
userinfo_uri: Full URL to the userinfo endpoint
|
||||||
jwks_uri: Full URL to the JWKS endpoint (for JWT verification)
|
jwks_uri: Full URL to the JWKS endpoint (for JWT verification)
|
||||||
issuer: Expected issuer claim value (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)
|
cache_ttl: Time-to-live for cached tokens in seconds (default: 3600)
|
||||||
"""
|
"""
|
||||||
self.nextcloud_host = nextcloud_host.rstrip("/")
|
self.nextcloud_host = nextcloud_host.rstrip("/")
|
||||||
self.userinfo_uri = userinfo_uri
|
self.userinfo_uri = userinfo_uri
|
||||||
self.jwks_uri = jwks_uri
|
self.jwks_uri = jwks_uri
|
||||||
self.issuer = issuer
|
self.issuer = issuer
|
||||||
|
self.introspection_uri = introspection_uri
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
self.cache_ttl = cache_ttl
|
self.cache_ttl = cache_ttl
|
||||||
|
|
||||||
# Cache: token -> (userinfo, expiry_timestamp)
|
# Cache: token -> (userinfo, expiry_timestamp)
|
||||||
self._token_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
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)
|
self._client = httpx.AsyncClient(timeout=10.0)
|
||||||
|
|
||||||
# PyJWKClient for JWT verification (lazy initialization)
|
# PyJWKClient for JWT verification (lazy initialization)
|
||||||
@@ -68,15 +77,24 @@ class NextcloudTokenVerifier(TokenVerifier):
|
|||||||
logger.info(f"JWT verification enabled with JWKS URI: {jwks_uri}")
|
logger.info(f"JWT verification enabled with JWKS URI: {jwks_uri}")
|
||||||
self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True)
|
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:
|
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:
|
This method:
|
||||||
1. Checks the cache first for recent validations
|
1. Checks the cache first for recent validations
|
||||||
2. Attempts JWT verification if JWKS is configured and token looks like JWT
|
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
|
3. Falls back to introspection for opaque tokens (if configured)
|
||||||
4. Returns AccessToken with username and scopes
|
4. Falls back to userinfo endpoint as last resort
|
||||||
|
5. Returns AccessToken with username and scopes
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
token: The bearer token to verify
|
token: The bearer token to verify
|
||||||
@@ -91,14 +109,31 @@ class NextcloudTokenVerifier(TokenVerifier):
|
|||||||
return cached
|
return cached
|
||||||
|
|
||||||
# Try JWT verification first if enabled and token looks like JWT
|
# 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...")
|
logger.debug("Attempting JWT verification...")
|
||||||
jwt_result = self._verify_jwt(token)
|
jwt_result = self._verify_jwt(token)
|
||||||
if jwt_result:
|
if jwt_result:
|
||||||
logger.info("Token validated via JWT verification")
|
logger.info("Token validated via JWT verification")
|
||||||
return jwt_result
|
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...")
|
logger.debug("Attempting userinfo endpoint validation...")
|
||||||
try:
|
try:
|
||||||
return await self._verify_via_userinfo(token)
|
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"JWT verified successfully for user: {payload.get('sub')}")
|
||||||
|
logger.debug(f"Full JWT payload: {payload}")
|
||||||
|
|
||||||
# Extract username (sub claim)
|
# Extract username (sub claim)
|
||||||
username = payload.get("sub")
|
username = payload.get("sub")
|
||||||
@@ -158,7 +194,9 @@ class NextcloudTokenVerifier(TokenVerifier):
|
|||||||
# Extract scopes from scope claim (space-separated string)
|
# Extract scopes from scope claim (space-separated string)
|
||||||
scope_string = payload.get("scope", "")
|
scope_string = payload.get("scope", "")
|
||||||
scopes = scope_string.split() if scope_string else []
|
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
|
# Extract expiration
|
||||||
exp = payload.get("exp")
|
exp = payload.get("exp")
|
||||||
@@ -195,6 +233,100 @@ class NextcloudTokenVerifier(TokenVerifier):
|
|||||||
logger.error(f"Unexpected error during JWT verification: {e}")
|
logger.error(f"Unexpected error during JWT verification: {e}")
|
||||||
return None
|
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:
|
async def _verify_via_userinfo(self, token: str) -> AccessToken | None:
|
||||||
"""
|
"""
|
||||||
Validate token by calling the userinfo endpoint.
|
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]
|
[tool.pytest.ini_options]
|
||||||
anyio_mode = "auto"
|
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 = 1
|
||||||
log_cli_level = "ERROR"
|
log_cli_level = "ERROR"
|
||||||
log_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)
|
# Create opaque token client with allowed_scopes (not JWT)
|
||||||
# This ensures the token has proper scopes even though they're not embedded
|
# 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(
|
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||||
callback_url=callback_url,
|
callback_url=callback_url,
|
||||||
client_name="Pytest - Shared Test Client (Opaque)",
|
client_name="Pytest - Shared Test Client (Opaque)",
|
||||||
allowed_scopes="openid profile email nc:read nc:write",
|
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]}...")
|
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)
|
# 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(
|
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||||
callback_url=callback_url,
|
callback_url=callback_url,
|
||||||
client_name="Pytest - Shared JWT Test Client",
|
client_name="Pytest - Shared JWT Test Client",
|
||||||
allowed_scopes="openid profile email nc:read nc:write",
|
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]}...")
|
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,
|
callback_url: str,
|
||||||
client_name: str,
|
client_name: str,
|
||||||
allowed_scopes: str,
|
allowed_scopes: str,
|
||||||
token_type: str = "jwt",
|
token_type: str = "JWT",
|
||||||
|
cache_file: str | None = None,
|
||||||
) -> tuple[str, str]:
|
) -> 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:
|
Args:
|
||||||
callback_url: OAuth callback URL
|
callback_url: OAuth callback URL
|
||||||
client_name: Name of the OAuth client
|
client_name: Name of the OAuth client
|
||||||
allowed_scopes: Space-separated list of allowed scopes
|
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:
|
Returns:
|
||||||
Tuple of (client_id, client_secret)
|
Tuple of (client_id, client_secret)
|
||||||
"""
|
"""
|
||||||
import json
|
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(
|
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
|
# Get Nextcloud host and registration endpoint
|
||||||
cmd = [
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
"docker",
|
if not nextcloud_host:
|
||||||
"compose",
|
raise ValueError("NEXTCLOUD_HOST environment variable not set")
|
||||||
"exec",
|
|
||||||
"-T",
|
|
||||||
"-u",
|
|
||||||
"www-data",
|
|
||||||
"app",
|
|
||||||
"php",
|
|
||||||
"/var/www/html/occ",
|
|
||||||
"oidc:create",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Add token_type flag for JWT clients
|
# Discover registration endpoint
|
||||||
if token_type == "jwt":
|
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||||
cmd.append("--token_type=jwt")
|
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
|
if not registration_endpoint:
|
||||||
cmd.extend(
|
raise ValueError("OIDC discovery missing registration_endpoint")
|
||||||
[
|
|
||||||
f"--allowed_scopes={allowed_scopes}",
|
# Register client using DCR
|
||||||
client_name,
|
client_info = await register_client(
|
||||||
callback_url,
|
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:
|
logger.info(
|
||||||
raise RuntimeError(
|
f"Created OAuth client via DCR: {client_id[:16]}... with scopes: {allowed_scopes}"
|
||||||
f"Failed to create OAuth client: {result.stderr}\nStdout: {result.stdout}"
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# Parse the JSON output from occ
|
# Save to cache if specified
|
||||||
try:
|
if cache_file:
|
||||||
client_data = json.loads(result.stdout)
|
cache_path = Path(cache_file)
|
||||||
client_id = client_data["client_id"]
|
try:
|
||||||
client_secret = client_data["client_secret"]
|
# Create parent directory if needed
|
||||||
logger.info(
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
f"Created OAuth client: {client_id[:16]}... with scopes: {allowed_scopes}"
|
|
||||||
)
|
# Save client data
|
||||||
return client_id, client_secret
|
with open(cache_path, "w") as f:
|
||||||
except (json.JSONDecodeError, KeyError) as e:
|
json.dump(
|
||||||
raise RuntimeError(
|
{
|
||||||
f"Failed to parse OAuth client response: {e}\nOutput: {result.stdout}"
|
"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")
|
@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")
|
token_endpoint = oidc_config.get("token_endpoint")
|
||||||
authorization_endpoint = oidc_config.get("authorization_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(
|
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||||
callback_url=callback_url,
|
callback_url=callback_url,
|
||||||
client_name="Test Client Read Only",
|
client_name="Test Client Read Only",
|
||||||
allowed_scopes="openid profile email nc:read",
|
allowed_scopes="openid profile email nc:read",
|
||||||
|
token_type="JWT", # JWT tokens for scope validation
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1131,11 +1175,12 @@ async def write_only_oauth_client_credentials(anyio_backend, oauth_callback_serv
|
|||||||
token_endpoint = oidc_config.get("token_endpoint")
|
token_endpoint = oidc_config.get("token_endpoint")
|
||||||
authorization_endpoint = oidc_config.get("authorization_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(
|
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||||
callback_url=callback_url,
|
callback_url=callback_url,
|
||||||
client_name="Test Client Write Only",
|
client_name="Test Client Write Only",
|
||||||
allowed_scopes="openid profile email nc:write",
|
allowed_scopes="openid profile email nc:write",
|
||||||
|
token_type="JWT", # JWT tokens for scope validation
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1170,11 +1215,12 @@ async def full_access_oauth_client_credentials(anyio_backend, oauth_callback_ser
|
|||||||
token_endpoint = oidc_config.get("token_endpoint")
|
token_endpoint = oidc_config.get("token_endpoint")
|
||||||
authorization_endpoint = oidc_config.get("authorization_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(
|
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||||
callback_url=callback_url,
|
callback_url=callback_url,
|
||||||
client_name="Test Client Full Access",
|
client_name="Test Client Full Access",
|
||||||
allowed_scopes="openid profile email nc:read nc:write",
|
allowed_scopes="openid profile email nc:read nc:write",
|
||||||
|
token_type="JWT", # JWT tokens for scope validation
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1284,24 +1330,11 @@ async def playwright_oauth_token(
|
|||||||
current_url = page.url
|
current_url = page.url
|
||||||
logger.info(f"After login, current URL: {current_url}")
|
logger.info(f"After login, current URL: {current_url}")
|
||||||
|
|
||||||
# Now we should be on the OAuth authorization/consent page or already redirected
|
# Handle consent screen if present
|
||||||
# Check if there's an authorize button to click
|
|
||||||
try:
|
try:
|
||||||
# Look for common authorization button patterns
|
await _handle_oauth_consent_screen(page, username)
|
||||||
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}")
|
|
||||||
except Exception as e:
|
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
|
# Wait for callback server to receive the auth code
|
||||||
# Browser will be redirected to localhost:8081 which will capture the 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(
|
async def _get_oauth_token_with_scopes(
|
||||||
browser,
|
browser,
|
||||||
shared_oauth_client_credentials,
|
shared_oauth_client_credentials,
|
||||||
@@ -1465,23 +1602,11 @@ async def _get_oauth_token_with_scopes(
|
|||||||
current_url = page.url
|
current_url = page.url
|
||||||
logger.info(f"After login, current URL: {current_url}")
|
logger.info(f"After login, current URL: {current_url}")
|
||||||
|
|
||||||
# Now we should be on the OAuth authorization/consent page or already redirected
|
# Handle consent screen if present
|
||||||
# Check if there's an authorize button to click
|
|
||||||
try:
|
try:
|
||||||
authorize_button = await page.query_selector(
|
await _handle_oauth_consent_screen(page, username)
|
||||||
'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"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
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
|
# Wait for callback server to receive the auth code
|
||||||
logger.info(f"Waiting for auth code with state: {state[:16]}...")
|
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)
|
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||||
current_url = page.url
|
current_url = page.url
|
||||||
|
|
||||||
# Handle OAuth consent if present
|
# Handle consent screen if present
|
||||||
try:
|
try:
|
||||||
authorize_button = await page.query_selector(
|
await _handle_oauth_consent_screen(page, username)
|
||||||
'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)
|
|
||||||
except Exception as e:
|
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
|
# Wait for callback server to receive the auth code
|
||||||
# Browser will be redirected to localhost:8081 which will capture the 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.integration
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_jwt_token_structure_with_custom_client():
|
async def test_jwt_token_structure_with_custom_client():
|
||||||
"""
|
"""
|
||||||
Test that we can create a JWT-enabled OAuth client and examine the token structure.
|
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.integration
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_opaque_token_vs_jwt_comparison():
|
async def test_opaque_token_vs_jwt_comparison():
|
||||||
"""
|
"""
|
||||||
Compare opaque tokens vs JWT tokens to understand the differences.
|
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.integration
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_scope_presence_in_jwt():
|
async def test_scope_presence_in_jwt():
|
||||||
"""
|
"""
|
||||||
Verify that custom scopes (nc:read, nc:write) are present in JWT tokens.
|
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 prm_data["resource"] == "http://127.0.0.1:8001"
|
||||||
assert "nc:read" in prm_data["scopes_supported"]
|
assert "nc:read" in prm_data["scopes_supported"]
|
||||||
assert "nc:write" 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 "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"]
|
||||||
|
|
||||||
|
|||||||
+1
Submodule third_party/oidc added at 536dab20b3
Reference in New Issue
Block a user