test: Update all test network hosts to respect iss claims from JWTs
This commit is contained in:
+4
-4
@@ -74,8 +74,8 @@ services:
|
|||||||
- 127.0.0.1:8001:8001
|
- 127.0.0.1:8001:8001
|
||||||
environment:
|
environment:
|
||||||
- NEXTCLOUD_HOST=http://app:80
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.0.1:8001
|
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://127.0.0.1:8080
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth/nextcloud_oauth_client.json
|
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth/nextcloud_oauth_client.json
|
||||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
||||||
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration
|
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration
|
||||||
@@ -93,8 +93,8 @@ services:
|
|||||||
- 127.0.0.1:8002:8002
|
- 127.0.0.1:8002:8002
|
||||||
environment:
|
environment:
|
||||||
- NEXTCLOUD_HOST=http://app:80
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.0.1:8002
|
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
|
||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://127.0.0.1:8080
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth-jwt/nextcloud_oauth_client.json
|
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth-jwt/nextcloud_oauth_client.json
|
||||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
||||||
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
||||||
|
|||||||
+107
-11
@@ -82,7 +82,7 @@ async def create_mcp_client_session(
|
|||||||
- Ensures proper cleanup without suppressing errors
|
- Ensures proper cleanup without suppressing errors
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: MCP server URL (e.g., "http://127.0.0.1:8000/mcp")
|
url: MCP server URL (e.g., "http://localhost:8000/mcp")
|
||||||
token: Optional OAuth access token for Bearer authentication
|
token: Optional OAuth access token for Bearer authentication
|
||||||
client_name: Client name for logging (e.g., "OAuth MCP (Playwright)")
|
client_name: Client name for logging (e.g., "OAuth MCP (Playwright)")
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ async def nc_mcp_client(anyio_backend) -> AsyncGenerator[ClientSession, Any]:
|
|||||||
Uses anyio pytest plugin for proper async fixture handling.
|
Uses anyio pytest plugin for proper async fixture handling.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://127.0.0.1:8000/mcp", client_name="Basic MCP"
|
url="http://localhost:8000/mcp", client_name="Basic MCP"
|
||||||
):
|
):
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ async def nc_mcp_oauth_client(
|
|||||||
Uses anyio pytest plugin for proper async fixture handling.
|
Uses anyio pytest plugin for proper async fixture handling.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://127.0.0.1:8001/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=playwright_oauth_token,
|
token=playwright_oauth_token,
|
||||||
client_name="OAuth MCP (Playwright)",
|
client_name="OAuth MCP (Playwright)",
|
||||||
):
|
):
|
||||||
@@ -202,7 +202,7 @@ async def nc_mcp_oauth_jwt_client(
|
|||||||
Uses anyio pytest plugin for proper async fixture handling.
|
Uses anyio pytest plugin for proper async fixture handling.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://127.0.0.1:8002/mcp",
|
url="http://localhost:8002/mcp",
|
||||||
token=playwright_oauth_token_jwt,
|
token=playwright_oauth_token_jwt,
|
||||||
client_name="OAuth JWT MCP (Playwright)",
|
client_name="OAuth JWT MCP (Playwright)",
|
||||||
):
|
):
|
||||||
@@ -225,7 +225,7 @@ async def nc_mcp_oauth_client_read_only(
|
|||||||
enabling proper scope-based filtering.
|
enabling proper scope-based filtering.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://127.0.0.1:8002/mcp",
|
url="http://localhost:8002/mcp",
|
||||||
token=playwright_oauth_token_read_only,
|
token=playwright_oauth_token_read_only,
|
||||||
client_name="OAuth JWT MCP Read-Only (Playwright)",
|
client_name="OAuth JWT MCP Read-Only (Playwright)",
|
||||||
):
|
):
|
||||||
@@ -248,7 +248,7 @@ async def nc_mcp_oauth_client_write_only(
|
|||||||
enabling proper scope-based filtering.
|
enabling proper scope-based filtering.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://127.0.0.1:8002/mcp",
|
url="http://localhost:8002/mcp",
|
||||||
token=playwright_oauth_token_write_only,
|
token=playwright_oauth_token_write_only,
|
||||||
client_name="OAuth JWT MCP Write-Only (Playwright)",
|
client_name="OAuth JWT MCP Write-Only (Playwright)",
|
||||||
):
|
):
|
||||||
@@ -270,13 +270,38 @@ async def nc_mcp_oauth_client_full_access(
|
|||||||
enabling proper scope-based filtering.
|
enabling proper scope-based filtering.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://127.0.0.1:8002/mcp",
|
url="http://localhost:8002/mcp",
|
||||||
token=playwright_oauth_token_full_access,
|
token=playwright_oauth_token_full_access,
|
||||||
client_name="OAuth JWT MCP Full Access (Playwright)",
|
client_name="OAuth JWT MCP Full Access (Playwright)",
|
||||||
):
|
):
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def nc_mcp_oauth_client_no_custom_scopes(
|
||||||
|
anyio_backend,
|
||||||
|
playwright_oauth_token_no_custom_scopes: str,
|
||||||
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
|
"""
|
||||||
|
Fixture to create an MCP client session with NO custom scopes.
|
||||||
|
Connects to the JWT OAuth-enabled MCP server on port 8002.
|
||||||
|
|
||||||
|
This client has only OIDC default scopes (openid, profile, email) without
|
||||||
|
application-specific scopes (nc:read, nc:write).
|
||||||
|
|
||||||
|
Expected behavior: Should see 0 tools (all tools require custom scopes).
|
||||||
|
|
||||||
|
Uses JWT MCP server because JWT tokens embed scope information in claims,
|
||||||
|
enabling proper scope-based filtering.
|
||||||
|
"""
|
||||||
|
async for session in create_mcp_client_session(
|
||||||
|
url="http://localhost:8002/mcp",
|
||||||
|
token=playwright_oauth_token_no_custom_scopes,
|
||||||
|
client_name="OAuth JWT MCP No Custom Scopes (Playwright)",
|
||||||
|
):
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def temporary_note(nc_client: NextcloudClient):
|
async def temporary_note(nc_client: NextcloudClient):
|
||||||
"""
|
"""
|
||||||
@@ -1232,6 +1257,51 @@ async def full_access_oauth_client_credentials(anyio_backend, oauth_callback_ser
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def no_custom_scopes_oauth_client_credentials(
|
||||||
|
anyio_backend, oauth_callback_server
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Fixture for OAuth client with NO custom scopes (only OIDC defaults).
|
||||||
|
|
||||||
|
Tests the security behavior when a user grants only the default OIDC scopes
|
||||||
|
(openid, profile, email) but declines custom application scopes (nc:read, nc:write).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
|
||||||
|
"""
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
if not nextcloud_host:
|
||||||
|
pytest.skip("No-custom-scopes OAuth client requires NEXTCLOUD_HOST")
|
||||||
|
|
||||||
|
auth_states, callback_url = oauth_callback_server
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
token_endpoint = oidc_config.get("token_endpoint")
|
||||||
|
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||||
|
|
||||||
|
# Create JWT client with NO custom scopes (only OIDC defaults)
|
||||||
|
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||||
|
callback_url=callback_url,
|
||||||
|
client_name="Test Client No Custom Scopes",
|
||||||
|
allowed_scopes="openid profile email", # No nc:read or nc:write
|
||||||
|
token_type="JWT", # JWT tokens for scope validation
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
callback_url,
|
||||||
|
token_endpoint,
|
||||||
|
authorization_endpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def playwright_oauth_token(
|
async def playwright_oauth_token(
|
||||||
anyio_backend, browser, shared_oauth_client_credentials, oauth_callback_server
|
anyio_backend, browser, shared_oauth_client_credentials, oauth_callback_server
|
||||||
@@ -1711,6 +1781,32 @@ async def playwright_oauth_token_full_access(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def playwright_oauth_token_no_custom_scopes(
|
||||||
|
anyio_backend,
|
||||||
|
browser,
|
||||||
|
no_custom_scopes_oauth_client_credentials,
|
||||||
|
oauth_callback_server,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Fixture to obtain an OAuth access token with NO custom scopes.
|
||||||
|
|
||||||
|
Tests the security behavior when a user grants only default OIDC scopes
|
||||||
|
(openid, profile, email) but declines application-specific scopes.
|
||||||
|
|
||||||
|
Expected: JWT token will contain only default scopes, and all MCP tools
|
||||||
|
should be filtered out since they all require nc:read or nc:write.
|
||||||
|
|
||||||
|
Uses a dedicated JWT OAuth client with allowed_scopes="openid profile email"
|
||||||
|
"""
|
||||||
|
return await _get_oauth_token_with_scopes(
|
||||||
|
browser,
|
||||||
|
no_custom_scopes_oauth_client_credentials,
|
||||||
|
oauth_callback_server,
|
||||||
|
scopes="openid profile email", # Only OIDC defaults, no custom scopes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def test_users_setup(anyio_backend, nc_client: NextcloudClient):
|
async def test_users_setup(anyio_backend, nc_client: NextcloudClient):
|
||||||
"""
|
"""
|
||||||
@@ -2047,7 +2143,7 @@ async def alice_mcp_client(
|
|||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client authenticated as alice (owner role)."""
|
"""MCP client authenticated as alice (owner role)."""
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://127.0.0.1:8001/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=alice_oauth_token,
|
token=alice_oauth_token,
|
||||||
client_name="Alice MCP",
|
client_name="Alice MCP",
|
||||||
):
|
):
|
||||||
@@ -2060,7 +2156,7 @@ async def bob_mcp_client(
|
|||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client authenticated as bob (viewer role)."""
|
"""MCP client authenticated as bob (viewer role)."""
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://127.0.0.1:8001/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=bob_oauth_token,
|
token=bob_oauth_token,
|
||||||
client_name="Bob MCP",
|
client_name="Bob MCP",
|
||||||
):
|
):
|
||||||
@@ -2074,7 +2170,7 @@ async def charlie_mcp_client(
|
|||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client authenticated as charlie (editor role, in 'editors' group)."""
|
"""MCP client authenticated as charlie (editor role, in 'editors' group)."""
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://127.0.0.1:8001/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=charlie_oauth_token,
|
token=charlie_oauth_token,
|
||||||
client_name="Charlie MCP",
|
client_name="Charlie MCP",
|
||||||
):
|
):
|
||||||
@@ -2088,7 +2184,7 @@ async def diana_mcp_client(
|
|||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client authenticated as diana (no-access role)."""
|
"""MCP client authenticated as diana (no-access role)."""
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://127.0.0.1:8001/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=diana_oauth_token,
|
token=diana_oauth_token,
|
||||||
client_name="Diana MCP",
|
client_name="Diana MCP",
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ uv run python -m tests.load.oauth_benchmark -u 2 -d 30 --verbose
|
|||||||
| `--users` | `-u` | 2 | Number of concurrent users (dynamically created) |
|
| `--users` | `-u` | 2 | Number of concurrent users (dynamically created) |
|
||||||
| `--duration` | `-d` | 30.0 | Test duration in seconds |
|
| `--duration` | `-d` | 30.0 | Test duration in seconds |
|
||||||
| `--warmup` | `-w` | 5.0 | Warmup period before metrics collection (seconds) |
|
| `--warmup` | `-w` | 5.0 | Warmup period before metrics collection (seconds) |
|
||||||
| `--url` | | `http://127.0.0.1:8001/mcp` | MCP OAuth server URL |
|
| `--url` | | `http://localhost:8001/mcp` | MCP OAuth server URL |
|
||||||
| `--output` | `-o` | None | JSON output file path |
|
| `--output` | `-o` | None | JSON output file path |
|
||||||
| `--workload` | | `mixed` | Workload type: mixed, sharing, collaboration, baseline |
|
| `--workload` | | `mixed` | Workload type: mixed, sharing, collaboration, baseline |
|
||||||
| `--user-prefix` | | `loadtest` | Prefix for dynamically created usernames |
|
| `--user-prefix` | | `loadtest` | Prefix for dynamically created usernames |
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ async def show_progress(
|
|||||||
@click.option(
|
@click.option(
|
||||||
"--url",
|
"--url",
|
||||||
"-u",
|
"-u",
|
||||||
default="http://127.0.0.1:8000/mcp",
|
default="http://localhost:8000/mcp",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
help="MCP server URL",
|
help="MCP server URL",
|
||||||
)
|
)
|
||||||
@@ -463,7 +463,7 @@ def main(
|
|||||||
uv run python -m tests.load.benchmark -c 20 -d 60 --output results.json
|
uv run python -m tests.load.benchmark -c 20 -d 60 --output results.json
|
||||||
|
|
||||||
# Test OAuth server on port 8001
|
# Test OAuth server on port 8001
|
||||||
uv run python -m tests.load.benchmark --url http://127.0.0.1:8001/mcp
|
uv run python -m tests.load.benchmark --url http://localhost:8001/mcp
|
||||||
"""
|
"""
|
||||||
if verbose:
|
if verbose:
|
||||||
logging.getLogger().setLevel(logging.DEBUG)
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class OAuthCallbackServer:
|
|||||||
correlation, and stores them in a shared dictionary.
|
correlation, and stores them in a shared dictionary.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, host: str = "127.0.0.1", port: int = 8081):
|
def __init__(self, host: str = "localhost", port: int = 8081):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.auth_states: dict[str, str] = {}
|
self.auth_states: dict[str, str] = {}
|
||||||
@@ -363,13 +363,13 @@ async def run_oauth_benchmark(
|
|||||||
try:
|
try:
|
||||||
# Get environment variables
|
# Get environment variables
|
||||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||||
callback_url = "http://127.0.0.1:8081/callback"
|
callback_url = "http://localhost:8081/callback"
|
||||||
|
|
||||||
# Step 1: Start OAuth callback server
|
# Step 1: Start OAuth callback server
|
||||||
print("Step 1/6: Starting OAuth callback server...")
|
print("Step 1/6: Starting OAuth callback server...")
|
||||||
callback_server = OAuthCallbackServer(host="127.0.0.1", port=8081)
|
callback_server = OAuthCallbackServer(host="localhost", port=8081)
|
||||||
callback_server.start()
|
callback_server.start()
|
||||||
print("✓ Callback server listening on http://127.0.0.1:8081\n")
|
print("✓ Callback server listening on http://localhost:8081\n")
|
||||||
|
|
||||||
# Step 2: Discover OIDC endpoints
|
# Step 2: Discover OIDC endpoints
|
||||||
print("Step 2/6: Discovering OIDC endpoints...")
|
print("Step 2/6: Discovering OIDC endpoints...")
|
||||||
@@ -634,7 +634,7 @@ async def run_oauth_benchmark(
|
|||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--url",
|
"--url",
|
||||||
default="http://127.0.0.1:8001/mcp",
|
default="http://localhost:8001/mcp",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
help="MCP OAuth server URL",
|
help="MCP OAuth server URL",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ class OAuthUserPool:
|
|||||||
return profile
|
return profile
|
||||||
|
|
||||||
async def create_user_session(
|
async def create_user_session(
|
||||||
self, username: str, mcp_url: str = "http://127.0.0.1:8001/mcp"
|
self, username: str, mcp_url: str = "http://localhost:8001/mcp"
|
||||||
) -> ClientSession:
|
) -> ClientSession:
|
||||||
"""
|
"""
|
||||||
Create an MCP client session for a user.
|
Create an MCP client session for a user.
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ async def test_jwt_token_structure_with_custom_client():
|
|||||||
pytest.skip("NEXTCLOUD_JWT_CLIENT_ID not set - skipping JWT token test")
|
pytest.skip("NEXTCLOUD_JWT_CLIENT_ID not set - skipping JWT token test")
|
||||||
|
|
||||||
_client_secret = os.getenv("NEXTCLOUD_JWT_CLIENT_SECRET")
|
_client_secret = os.getenv("NEXTCLOUD_JWT_CLIENT_SECRET")
|
||||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://127.0.0.1:8080")
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||||
|
|
||||||
# Fetch discovery
|
# Fetch discovery
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
|
|||||||
@@ -19,15 +19,15 @@ async def test_prm_endpoint():
|
|||||||
# Test the PRM endpoint directly
|
# Test the PRM endpoint directly
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"http://127.0.0.1:8001/.well-known/oauth-protected-resource"
|
"http://localhost:8001/.well-known/oauth-protected-resource"
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
prm_data = response.json()
|
prm_data = response.json()
|
||||||
assert prm_data["resource"] == "http://127.0.0.1:8001"
|
assert prm_data["resource"] == "http://localhost: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://127.0.0.1:8080" in prm_data["authorization_servers"]
|
assert "http://localhost: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"]
|
||||||
|
|
||||||
@@ -388,5 +388,152 @@ async def test_scope_metadata_coverage(nc_mcp_client):
|
|||||||
assert len(tools_response.tools) >= 90
|
assert len(tools_response.tools) >= 90
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
||||||
|
nc_mcp_oauth_client_no_custom_scopes,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test that a JWT token with only OIDC default scopes (no nc:read or nc:write) returns 0 tools.
|
||||||
|
|
||||||
|
This tests the security behavior when a user declines to grant custom scopes during consent.
|
||||||
|
Expected: JWT token has scopes=['openid', 'profile', 'email'] but no nc:read or nc:write.
|
||||||
|
All tools require at least one custom scope, so they should all be filtered out.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Connect with JWT token that has NO custom scopes (only openid, profile, email)
|
||||||
|
result = await nc_mcp_oauth_client_no_custom_scopes.list_tools()
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
tool_names = [tool.name for tool in result.tools]
|
||||||
|
logger.info(
|
||||||
|
f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 0)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# All tools require nc:read or nc:write, so should be filtered out
|
||||||
|
assert len(tool_names) == 0, (
|
||||||
|
f"Expected 0 tools but got {len(tool_names)}: {tool_names[:10]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"✅ JWT token without custom scopes correctly returns 0 tools (all filtered out)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only):
|
||||||
|
"""
|
||||||
|
Test JWT with only nc:read scope consented.
|
||||||
|
|
||||||
|
Simulates user granting only read permission during OAuth consent.
|
||||||
|
Expected: Should see read tools but not write tools.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
result = await nc_mcp_oauth_client_read_only.list_tools()
|
||||||
|
assert result is not None
|
||||||
|
assert len(result.tools) > 0
|
||||||
|
|
||||||
|
tool_names = [tool.name for tool in result.tools]
|
||||||
|
logger.info(f"JWT with nc:read consent sees {len(tool_names)} tools")
|
||||||
|
|
||||||
|
# Verify read tools are present
|
||||||
|
read_tools = ["nc_notes_get_note", "nc_notes_search_notes", "nc_webdav_read_file"]
|
||||||
|
for tool in read_tools:
|
||||||
|
assert tool in tool_names, f"Expected read tool {tool} not found"
|
||||||
|
|
||||||
|
# Verify write tools are filtered out
|
||||||
|
write_tools = [
|
||||||
|
"nc_notes_create_note",
|
||||||
|
"nc_notes_update_note",
|
||||||
|
"nc_webdav_write_file",
|
||||||
|
]
|
||||||
|
for tool in write_tools:
|
||||||
|
assert tool not in tool_names, f"Write tool {tool} should be filtered out"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✅ JWT with nc:read consent: {len(tool_names)} read tools visible, write tools filtered"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only):
|
||||||
|
"""
|
||||||
|
Test JWT with only nc:write scope consented.
|
||||||
|
|
||||||
|
Simulates user granting only write permission during OAuth consent.
|
||||||
|
Expected: Should see write tools but not read-only tools.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
result = await nc_mcp_oauth_client_write_only.list_tools()
|
||||||
|
assert result is not None
|
||||||
|
assert len(result.tools) > 0
|
||||||
|
|
||||||
|
tool_names = [tool.name for tool in result.tools]
|
||||||
|
logger.info(f"JWT with nc:write consent sees {len(tool_names)} tools")
|
||||||
|
|
||||||
|
# Verify write tools are present
|
||||||
|
write_tools = [
|
||||||
|
"nc_notes_create_note",
|
||||||
|
"nc_notes_update_note",
|
||||||
|
"nc_webdav_write_file",
|
||||||
|
]
|
||||||
|
for tool in write_tools:
|
||||||
|
assert tool in tool_names, f"Expected write tool {tool} not found"
|
||||||
|
|
||||||
|
# Verify read-only tools are filtered out
|
||||||
|
read_only_tools = ["nc_notes_get_note", "nc_notes_search_notes"]
|
||||||
|
for tool in read_only_tools:
|
||||||
|
assert tool not in tool_names, f"Read-only tool {tool} should be filtered out"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✅ JWT with nc:write consent: {len(tool_names)} write tools visible, read-only tools filtered"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_jwt_consent_scenarios_full_access(nc_mcp_oauth_client_full_access):
|
||||||
|
"""
|
||||||
|
Test JWT with both nc:read and nc:write scopes consented.
|
||||||
|
|
||||||
|
Simulates user granting both permissions during OAuth consent.
|
||||||
|
Expected: Should see all 90+ tools (both read and write).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
result = await nc_mcp_oauth_client_full_access.list_tools()
|
||||||
|
assert result is not None
|
||||||
|
assert len(result.tools) > 0
|
||||||
|
|
||||||
|
tool_names = [tool.name for tool in result.tools]
|
||||||
|
logger.info(f"JWT with full consent sees {len(tool_names)} tools")
|
||||||
|
|
||||||
|
# Verify both read and write tools are present
|
||||||
|
read_tools = ["nc_notes_get_note", "nc_webdav_read_file"]
|
||||||
|
write_tools = ["nc_notes_create_note", "nc_webdav_write_file"]
|
||||||
|
|
||||||
|
for tool in read_tools:
|
||||||
|
assert tool in tool_names, f"Expected read tool {tool} not found"
|
||||||
|
|
||||||
|
for tool in write_tools:
|
||||||
|
assert tool in tool_names, f"Expected write tool {tool} not found"
|
||||||
|
|
||||||
|
# Should have all tools
|
||||||
|
assert len(tool_names) >= 90, f"Expected 90+ tools but got {len(tool_names)}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✅ JWT with full consent: {len(tool_names)} tools visible (all read + write)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|||||||
Reference in New Issue
Block a user