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:
Chris Coutinho
2025-11-03 22:16:49 +01:00
parent 95b73019ab
commit c2dcb06fe1
8 changed files with 1599 additions and 29 deletions
View File
+333
View File
@@ -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")