feat: add browser-based user info page with separate OAuth flow
Implements /user and /user/page endpoints for displaying authenticated user information in both BasicAuth and OAuth modes. Key Features: - Separate browser OAuth flow (/oauth/login, /oauth/login-callback, /oauth/logout) - Session-based authentication using signed cookies - Token refresh for persistent sessions - HTML and JSON user info endpoints - IdP profile information retrieval Architecture: - BasicAuth mode: Always authenticated as configured user - OAuth mode: Browser-based authorization code flow with refresh tokens - Session stored in SQLite with encrypted refresh tokens - Server-side token refresh using internal Docker hostnames OAuth Flow: - /oauth/login: Initiates browser OAuth flow - /oauth/login-callback: Handles IdP callback and stores refresh token - /oauth/logout: Clears session cookie - /user: JSON API endpoint (requires authentication) - /user/page: HTML page endpoint (requires authentication) DCR Scopes Fix: - MCP server DCR now only requests basic OIDC scopes (openid profile email offline_access) - Nextcloud app scopes (notes:read, etc.) are for MCP clients, not the server itself - PRM endpoint dynamically advertises supported scopes from tool decorators Files: - nextcloud_mcp_server/auth/browser_oauth_routes.py: Browser OAuth flow handlers - nextcloud_mcp_server/auth/session_backend.py: Starlette session authentication - nextcloud_mcp_server/auth/userinfo_routes.py: User info endpoints with token refresh - tests/server/auth/test_userinfo_routes.py: Unit tests - tests/server/oauth/test_userinfo_integration.py: OAuth integration tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
"""Unit tests for user info routes."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
_get_user_context,
|
||||
_query_idp_userinfo,
|
||||
user_info_html,
|
||||
user_info_json,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_idp_userinfo_success(mocker):
|
||||
"""Test successful IdP userinfo query."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"sub": "alice",
|
||||
"email": "alice@example.com",
|
||||
"name": "Alice Smith",
|
||||
}
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_response
|
||||
|
||||
mocker.patch("httpx.AsyncClient", return_value=mock_client)
|
||||
|
||||
result = await _query_idp_userinfo("test_token", "https://example.com/userinfo")
|
||||
|
||||
assert result == {
|
||||
"sub": "alice",
|
||||
"email": "alice@example.com",
|
||||
"name": "Alice Smith",
|
||||
}
|
||||
mock_client.get.assert_called_once_with(
|
||||
"https://example.com/userinfo",
|
||||
headers={"Authorization": "Bearer test_token"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_idp_userinfo_failure(mocker):
|
||||
"""Test IdP userinfo query failure handling."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = Exception("Network error")
|
||||
|
||||
mocker.patch("httpx.AsyncClient", return_value=mock_client)
|
||||
|
||||
result = await _query_idp_userinfo("test_token", "https://example.com/userinfo")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_context_basic_auth(monkeypatch):
|
||||
"""Test get_user_context in BasicAuth mode."""
|
||||
monkeypatch.setenv("NEXTCLOUD_USERNAME", "testuser")
|
||||
monkeypatch.setenv("NEXTCLOUD_HOST", "https://cloud.example.com")
|
||||
|
||||
mock_request = Mock()
|
||||
oauth_ctx = None # BasicAuth mode
|
||||
|
||||
result = await _get_user_context(mock_request, oauth_ctx)
|
||||
|
||||
assert result["username"] == "testuser"
|
||||
assert result["auth_mode"] == "basic"
|
||||
assert result["nextcloud_host"] == "https://cloud.example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_context_oauth_no_token():
|
||||
"""Test get_user_context in OAuth mode without token."""
|
||||
mock_request = Mock()
|
||||
mock_request.user = Mock(spec=[]) # No access_token attribute
|
||||
oauth_ctx = {"token_verifier": Mock()}
|
||||
|
||||
result = await _get_user_context(mock_request, oauth_ctx)
|
||||
|
||||
assert "error" in result
|
||||
assert result["error"] == "Not authenticated"
|
||||
assert result["auth_mode"] == "oauth"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_context_oauth_with_token_no_idp_query(mocker):
|
||||
"""Test get_user_context in OAuth mode with token but no IdP query."""
|
||||
mock_access_token = Mock()
|
||||
mock_access_token.resource = "alice"
|
||||
mock_access_token.client_id = "mcp_client_123"
|
||||
mock_access_token.scopes = ["notes:read", "calendar:write"]
|
||||
mock_access_token.expires_at = 1730678400
|
||||
mock_access_token.token = "test_token"
|
||||
|
||||
mock_request = Mock()
|
||||
mock_request.user = Mock()
|
||||
mock_request.user.access_token = mock_access_token
|
||||
|
||||
# OAuth context without token_verifier
|
||||
oauth_ctx = {}
|
||||
|
||||
result = await _get_user_context(mock_request, oauth_ctx)
|
||||
|
||||
assert result["username"] == "alice"
|
||||
assert result["auth_mode"] == "oauth"
|
||||
assert result["client_id"] == "mcp_client_123"
|
||||
assert result["scopes"] == ["notes:read", "calendar:write"]
|
||||
assert result["token_expires_at"] == 1730678400
|
||||
assert "idp_profile" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_context_oauth_with_idp_query_success(mocker):
|
||||
"""Test get_user_context in OAuth mode with successful IdP query."""
|
||||
mock_access_token = Mock()
|
||||
mock_access_token.resource = "alice"
|
||||
mock_access_token.client_id = "mcp_client_123"
|
||||
mock_access_token.scopes = ["notes:read"]
|
||||
mock_access_token.expires_at = 1730678400
|
||||
mock_access_token.token = "test_token"
|
||||
|
||||
mock_request = Mock()
|
||||
mock_request.user = Mock()
|
||||
mock_request.user.access_token = mock_access_token
|
||||
|
||||
mock_token_verifier = Mock()
|
||||
mock_token_verifier.userinfo_uri = "https://example.com/userinfo"
|
||||
oauth_ctx = {"token_verifier": mock_token_verifier}
|
||||
|
||||
# Mock IdP response
|
||||
idp_profile = {
|
||||
"sub": "alice",
|
||||
"email": "alice@example.com",
|
||||
"name": "Alice Smith",
|
||||
}
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.auth.userinfo_routes._query_idp_userinfo",
|
||||
return_value=idp_profile,
|
||||
)
|
||||
|
||||
result = await _get_user_context(mock_request, oauth_ctx)
|
||||
|
||||
assert result["username"] == "alice"
|
||||
assert result["auth_mode"] == "oauth"
|
||||
assert result["idp_profile"] == idp_profile
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_context_oauth_with_idp_query_failure(mocker):
|
||||
"""Test get_user_context in OAuth mode with failed IdP query."""
|
||||
mock_access_token = Mock()
|
||||
mock_access_token.resource = "alice"
|
||||
mock_access_token.client_id = "mcp_client_123"
|
||||
mock_access_token.scopes = ["notes:read"]
|
||||
mock_access_token.expires_at = 1730678400
|
||||
mock_access_token.token = "test_token"
|
||||
|
||||
mock_request = Mock()
|
||||
mock_request.user = Mock()
|
||||
mock_request.user.access_token = mock_access_token
|
||||
|
||||
mock_token_verifier = Mock()
|
||||
mock_token_verifier.userinfo_uri = "https://example.com/userinfo"
|
||||
oauth_ctx = {"token_verifier": mock_token_verifier}
|
||||
|
||||
# Mock IdP failure
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.auth.userinfo_routes._query_idp_userinfo",
|
||||
return_value=None,
|
||||
)
|
||||
|
||||
result = await _get_user_context(mock_request, oauth_ctx)
|
||||
|
||||
assert result["username"] == "alice"
|
||||
assert result["auth_mode"] == "oauth"
|
||||
assert "idp_profile_error" in result
|
||||
assert result["idp_profile_error"] == "Failed to retrieve profile from IdP"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_json_basic_auth(mocker, monkeypatch):
|
||||
"""Test user_info_json endpoint in BasicAuth mode."""
|
||||
monkeypatch.setenv("NEXTCLOUD_USERNAME", "admin")
|
||||
monkeypatch.setenv("NEXTCLOUD_HOST", "https://cloud.example.com")
|
||||
|
||||
mock_request = Mock()
|
||||
mock_request.app = Mock()
|
||||
mock_request.app.state = Mock()
|
||||
mock_request.app.state.oauth_context = None
|
||||
|
||||
response = await user_info_json(mock_request)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.body.decode()
|
||||
assert "admin" in body
|
||||
assert "basic" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_json_oauth_unauthenticated(mocker):
|
||||
"""Test user_info_json endpoint in OAuth mode without authentication."""
|
||||
mock_request = Mock()
|
||||
mock_request.app = Mock()
|
||||
mock_request.app.state = Mock()
|
||||
mock_request.app.state.oauth_context = {"token_verifier": Mock()}
|
||||
mock_request.user = Mock(spec=[]) # No access_token
|
||||
|
||||
response = await user_info_json(mock_request)
|
||||
|
||||
assert response.status_code == 401
|
||||
body = response.body.decode()
|
||||
assert "error" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_json_oauth_authenticated(mocker):
|
||||
"""Test user_info_json endpoint in OAuth mode with authentication."""
|
||||
mock_access_token = Mock()
|
||||
mock_access_token.resource = "alice"
|
||||
mock_access_token.client_id = "mcp_client_123"
|
||||
mock_access_token.scopes = ["notes:read", "calendar:write"]
|
||||
mock_access_token.expires_at = 1730678400
|
||||
mock_access_token.token = "test_token"
|
||||
|
||||
mock_request = Mock()
|
||||
mock_request.app = Mock()
|
||||
mock_request.app.state = Mock()
|
||||
mock_request.app.state.oauth_context = {"token_verifier": Mock()}
|
||||
mock_request.user = Mock()
|
||||
mock_request.user.access_token = mock_access_token
|
||||
|
||||
response = await user_info_json(mock_request)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.body.decode()
|
||||
assert "alice" in body
|
||||
assert "oauth" in body
|
||||
assert "mcp_client_123" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_html_basic_auth(mocker, monkeypatch):
|
||||
"""Test user_info_html endpoint in BasicAuth mode."""
|
||||
monkeypatch.setenv("NEXTCLOUD_USERNAME", "admin")
|
||||
monkeypatch.setenv("NEXTCLOUD_HOST", "https://cloud.example.com")
|
||||
|
||||
mock_request = Mock()
|
||||
mock_request.app = Mock()
|
||||
mock_request.app.state = Mock()
|
||||
mock_request.app.state.oauth_context = None
|
||||
|
||||
response = await user_info_html(mock_request)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.body.decode()
|
||||
assert "<!DOCTYPE html>" in body
|
||||
assert "admin" in body
|
||||
assert "basic" in body.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_html_oauth_unauthenticated(mocker):
|
||||
"""Test user_info_html endpoint in OAuth mode without authentication."""
|
||||
mock_request = Mock()
|
||||
mock_request.app = Mock()
|
||||
mock_request.app.state = Mock()
|
||||
mock_request.app.state.oauth_context = {"token_verifier": Mock()}
|
||||
mock_request.user = Mock(spec=[]) # No access_token
|
||||
|
||||
response = await user_info_html(mock_request)
|
||||
|
||||
assert response.status_code == 401
|
||||
body = response.body.decode()
|
||||
assert "<!DOCTYPE html>" in body
|
||||
assert "Authentication Required" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_html_oauth_authenticated(mocker):
|
||||
"""Test user_info_html endpoint in OAuth mode with authentication."""
|
||||
mock_access_token = Mock()
|
||||
mock_access_token.resource = "bob"
|
||||
mock_access_token.client_id = "mcp_client_456"
|
||||
mock_access_token.scopes = ["notes:write"]
|
||||
mock_access_token.expires_at = 1730678400
|
||||
mock_access_token.token = "test_token"
|
||||
|
||||
mock_request = Mock()
|
||||
mock_request.app = Mock()
|
||||
mock_request.app.state = Mock()
|
||||
mock_request.app.state.oauth_context = {"token_verifier": Mock()}
|
||||
mock_request.user = Mock()
|
||||
mock_request.user.access_token = mock_access_token
|
||||
|
||||
response = await user_info_html(mock_request)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.body.decode()
|
||||
assert "<!DOCTYPE html>" in body
|
||||
assert "bob" in body
|
||||
assert "oauth" in body.lower()
|
||||
assert "mcp_client_456" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_html_with_scopes(mocker):
|
||||
"""Test user_info_html displays scopes correctly."""
|
||||
mock_access_token = Mock()
|
||||
mock_access_token.resource = "charlie"
|
||||
mock_access_token.client_id = "mcp_client_789"
|
||||
mock_access_token.scopes = ["notes:read", "notes:write", "calendar:read"]
|
||||
mock_access_token.expires_at = 1730678400
|
||||
mock_access_token.token = "test_token"
|
||||
|
||||
mock_request = Mock()
|
||||
mock_request.app = Mock()
|
||||
mock_request.app.state = Mock()
|
||||
mock_request.app.state.oauth_context = {"token_verifier": Mock()}
|
||||
mock_request.user = Mock()
|
||||
mock_request.user.access_token = mock_access_token
|
||||
|
||||
response = await user_info_html(mock_request)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.body.decode()
|
||||
assert "notes:read" in body
|
||||
assert "notes:write" in body
|
||||
assert "calendar:read" in body
|
||||
assert "<h2>Scopes</h2>" in body
|
||||
@@ -0,0 +1,307 @@
|
||||
"""OAuth integration tests for user info routes.
|
||||
|
||||
Tests verify:
|
||||
1. /user endpoint returns correct user info in OAuth mode
|
||||
2. /user/page endpoint renders HTML correctly in OAuth mode
|
||||
3. Endpoints return 401 when not authenticated
|
||||
4. Integration with Nextcloud OIDC and Keycloak IdP
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def get_user_info_json(access_token: str, port: int = 8001) -> dict:
|
||||
"""Call /user endpoint with OAuth token.
|
||||
|
||||
Args:
|
||||
access_token: OAuth access token
|
||||
port: MCP server port (8001 for mcp-oauth, 8002 for mcp-keycloak)
|
||||
|
||||
Returns:
|
||||
JSON response data
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"http://localhost:{port}/user",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
async def get_user_info_html(access_token: str, port: int = 8001) -> str:
|
||||
"""Call /user/page endpoint with OAuth token.
|
||||
|
||||
Args:
|
||||
access_token: OAuth access token
|
||||
port: MCP server port (8001 for mcp-oauth, 8002 for mcp-keycloak)
|
||||
|
||||
Returns:
|
||||
HTML response text
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"http://localhost:{port}/user/page",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Nextcloud OAuth Tests (mcp-oauth on port 8001)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_user_info_json_with_nextcloud_oauth(playwright_oauth_token):
|
||||
"""Test /user endpoint with Nextcloud OAuth token."""
|
||||
user_info = await get_user_info_json(playwright_oauth_token, port=8001)
|
||||
|
||||
# Verify response structure
|
||||
assert "username" in user_info
|
||||
assert "auth_mode" in user_info
|
||||
assert user_info["auth_mode"] == "oauth"
|
||||
|
||||
# Verify OAuth-specific fields
|
||||
assert "client_id" in user_info
|
||||
assert "scopes" in user_info
|
||||
assert "token_expires_at" in user_info
|
||||
assert isinstance(user_info["scopes"], list)
|
||||
|
||||
# Verify username matches environment
|
||||
expected_username = os.getenv("NEXTCLOUD_USERNAME", "admin")
|
||||
assert user_info["username"] == expected_username
|
||||
|
||||
logger.info(f"User info JSON: {json.dumps(user_info, indent=2)}")
|
||||
|
||||
|
||||
async def test_user_info_html_with_nextcloud_oauth(playwright_oauth_token):
|
||||
"""Test /user/page endpoint with Nextcloud OAuth token."""
|
||||
html = await get_user_info_html(playwright_oauth_token, port=8001)
|
||||
|
||||
# Verify HTML structure
|
||||
assert "<!DOCTYPE html>" in html
|
||||
assert "Nextcloud MCP Server - User Info" in html
|
||||
assert "oauth" in html.lower()
|
||||
|
||||
# Verify username is displayed
|
||||
expected_username = os.getenv("NEXTCLOUD_USERNAME", "admin")
|
||||
assert expected_username in html
|
||||
|
||||
# Verify OAuth-specific content
|
||||
assert "Client ID" in html
|
||||
assert "Scopes" in html
|
||||
assert "Token Expires At" in html
|
||||
|
||||
logger.info(f"User info HTML page rendered successfully ({len(html)} chars)")
|
||||
|
||||
|
||||
async def test_user_info_json_unauthenticated():
|
||||
"""Test /user endpoint without authentication returns 401."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get("http://localhost:8001/user")
|
||||
|
||||
# Should return 401 without authentication
|
||||
assert response.status_code == 401
|
||||
|
||||
# Verify error message
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert data["error"] == "Not authenticated"
|
||||
|
||||
logger.info("Unauthenticated request correctly returned 401")
|
||||
|
||||
|
||||
async def test_user_info_html_unauthenticated():
|
||||
"""Test /user/page endpoint without authentication returns 401 HTML."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get("http://localhost:8001/user/page")
|
||||
|
||||
# Should return 401 without authentication
|
||||
assert response.status_code == 401
|
||||
|
||||
# Verify HTML error page
|
||||
html = response.text
|
||||
assert "<!DOCTYPE html>" in html
|
||||
assert "Authentication Required" in html
|
||||
assert "You must be authenticated to view this page" in html
|
||||
|
||||
logger.info("Unauthenticated HTML request correctly returned 401 page")
|
||||
|
||||
|
||||
async def test_user_info_with_alice_token(alice_oauth_token):
|
||||
"""Test /user endpoint with alice's OAuth token."""
|
||||
user_info = await get_user_info_json(alice_oauth_token, port=8001)
|
||||
|
||||
# Verify alice's user info
|
||||
assert user_info["username"] == "alice"
|
||||
assert user_info["auth_mode"] == "oauth"
|
||||
assert isinstance(user_info["scopes"], list)
|
||||
assert len(user_info["scopes"]) > 0
|
||||
|
||||
logger.info(
|
||||
f"Alice's user info: username={user_info['username']}, scopes={user_info['scopes']}"
|
||||
)
|
||||
|
||||
|
||||
async def test_user_info_with_bob_token(bob_oauth_token):
|
||||
"""Test /user endpoint with bob's OAuth token."""
|
||||
user_info = await get_user_info_json(bob_oauth_token, port=8001)
|
||||
|
||||
# Verify bob's user info
|
||||
assert user_info["username"] == "bob"
|
||||
assert user_info["auth_mode"] == "oauth"
|
||||
|
||||
logger.info(f"Bob's user info: username={user_info['username']}")
|
||||
|
||||
|
||||
async def test_user_info_scopes_reflect_token(playwright_oauth_token_read_only):
|
||||
"""Test that /user endpoint reflects token's scopes."""
|
||||
user_info = await get_user_info_json(playwright_oauth_token_read_only, port=8001)
|
||||
|
||||
# Verify scopes are present and reflect read-only access
|
||||
assert "scopes" in user_info
|
||||
scopes = user_info["scopes"]
|
||||
assert isinstance(scopes, list)
|
||||
|
||||
# Read-only token should have read scopes but not write scopes
|
||||
# Note: Actual scope names depend on configuration
|
||||
logger.info(f"Read-only token scopes: {scopes}")
|
||||
|
||||
|
||||
async def test_user_info_idp_profile_included(playwright_oauth_token):
|
||||
"""Test that /user endpoint includes IdP profile when available."""
|
||||
user_info = await get_user_info_json(playwright_oauth_token, port=8001)
|
||||
|
||||
# Should have either idp_profile or idp_profile_error
|
||||
has_profile = "idp_profile" in user_info
|
||||
has_error = "idp_profile_error" in user_info
|
||||
|
||||
assert has_profile or has_error, "Should have IdP profile data or error"
|
||||
|
||||
if has_profile:
|
||||
idp_profile = user_info["idp_profile"]
|
||||
assert isinstance(idp_profile, dict)
|
||||
# Common OIDC claims
|
||||
assert "sub" in idp_profile, "IdP profile should include 'sub' claim"
|
||||
logger.info(f"IdP profile included: {json.dumps(idp_profile, indent=2)}")
|
||||
else:
|
||||
logger.warning(f"IdP profile query failed: {user_info['idp_profile_error']}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Keycloak OAuth Tests (mcp-keycloak on port 8002)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.keycloak
|
||||
async def test_user_info_json_with_keycloak_oauth(keycloak_oauth_token):
|
||||
"""Test /user endpoint with Keycloak OAuth token."""
|
||||
user_info = await get_user_info_json(keycloak_oauth_token, port=8002)
|
||||
|
||||
# Verify response structure
|
||||
assert "username" in user_info
|
||||
assert "auth_mode" in user_info
|
||||
assert user_info["auth_mode"] == "oauth"
|
||||
|
||||
# Verify Keycloak username (default admin user)
|
||||
assert user_info["username"] == "admin"
|
||||
|
||||
# Verify OAuth-specific fields
|
||||
assert "client_id" in user_info
|
||||
assert "scopes" in user_info
|
||||
assert isinstance(user_info["scopes"], list)
|
||||
|
||||
logger.info(f"Keycloak user info JSON: {json.dumps(user_info, indent=2)}")
|
||||
|
||||
|
||||
@pytest.mark.keycloak
|
||||
async def test_user_info_html_with_keycloak_oauth(keycloak_oauth_token):
|
||||
"""Test /user/page endpoint with Keycloak OAuth token."""
|
||||
html = await get_user_info_html(keycloak_oauth_token, port=8002)
|
||||
|
||||
# Verify HTML structure
|
||||
assert "<!DOCTYPE html>" in html
|
||||
assert "Nextcloud MCP Server - User Info" in html
|
||||
|
||||
# Verify Keycloak username is displayed
|
||||
assert "admin" in html
|
||||
|
||||
logger.info(
|
||||
f"Keycloak user info HTML page rendered successfully ({len(html)} chars)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.keycloak
|
||||
async def test_keycloak_user_info_idp_profile(keycloak_oauth_token):
|
||||
"""Test that Keycloak IdP profile includes extended claims."""
|
||||
user_info = await get_user_info_json(keycloak_oauth_token, port=8002)
|
||||
|
||||
# Keycloak should provide IdP profile with extended claims
|
||||
if "idp_profile" in user_info:
|
||||
idp_profile = user_info["idp_profile"]
|
||||
|
||||
# Standard OIDC claims
|
||||
assert "sub" in idp_profile
|
||||
|
||||
# Keycloak-specific claims (may vary by configuration)
|
||||
# Common claims: email, preferred_username, name, groups, roles
|
||||
logger.info(f"Keycloak IdP profile: {json.dumps(idp_profile, indent=2)}")
|
||||
|
||||
# Verify at least one identity claim exists
|
||||
identity_claims = ["email", "preferred_username", "name", "sub"]
|
||||
has_identity = any(claim in idp_profile for claim in identity_claims)
|
||||
assert has_identity, (
|
||||
f"IdP profile should include at least one identity claim: {identity_claims}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.keycloak
|
||||
async def test_keycloak_user_info_unauthenticated():
|
||||
"""Test /user endpoint on Keycloak server without authentication."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get("http://localhost:8002/user")
|
||||
|
||||
# Should return 401
|
||||
assert response.status_code == 401
|
||||
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
|
||||
logger.info("Keycloak server correctly returned 401 for unauthenticated request")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Cross-Mode Comparison Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_user_info_consistency_across_users(alice_oauth_token, bob_oauth_token):
|
||||
"""Test that user info structure is consistent across different users."""
|
||||
alice_info = await get_user_info_json(alice_oauth_token, port=8001)
|
||||
bob_info = await get_user_info_json(bob_oauth_token, port=8001)
|
||||
|
||||
# Both should have same structure
|
||||
assert set(alice_info.keys()) == set(bob_info.keys()), (
|
||||
"User info structure should be consistent across users"
|
||||
)
|
||||
|
||||
# But different usernames
|
||||
assert alice_info["username"] == "alice"
|
||||
assert bob_info["username"] == "bob"
|
||||
|
||||
logger.info("User info structure is consistent across users")
|
||||
Reference in New Issue
Block a user