refactor: use redirect-based Login Flow v2 provision instead of popup

Replace the popup-based approach with a direct redirect to Nextcloud's
login page. This is more compatible with Playwright E2E tests and
simpler for users. The background polling task still runs server-side
to store the app password when the user grants access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-03-29 22:17:54 +02:00
co-authored by Claude Opus 4.6
parent 081ecbe401
commit 1a51f5bbf5
+25 -208
View File
@@ -6,21 +6,22 @@ chain OAuth (bearer token) with Login Flow v2 (app password) in a single
user interaction. user interaction.
Flow: Flow:
1. GET /app/provision?redirect_uri=... → Initiates LFv2, renders polling page 1. GET /app/provision?redirect_uri=... → Initiates LFv2, redirects to NC login
2. The page opens Nextcloud's login URL in a popup window 2. User clicks "Grant access" on Nextcloud's login page
3. User clicks "Grant access" in the popup 3. MCP server background task polls and stores app password
4. Page polls GET /app/provision/status?id=... for completion 4. GET /app/provision/status?id=... → Returns completion status (JSON)
5. On success, redirects to redirect_uri 5. User returns to Astrolabe settings (via redirect_uri or navigation)
""" """
import asyncio import asyncio
import logging import logging
import os
import secrets import secrets
import time import time
from urllib.parse import urlparse from urllib.parse import urlparse
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
from nextcloud_mcp_server.auth.login_flow import LoginFlowV2Client from nextcloud_mcp_server.auth.login_flow import LoginFlowV2Client
from nextcloud_mcp_server.auth.storage import get_shared_storage from nextcloud_mcp_server.auth.storage import get_shared_storage
@@ -129,13 +130,15 @@ async def _poll_and_store(provision_id: str) -> None:
) )
async def provision_page(request: Request) -> HTMLResponse: async def provision_page(request: Request) -> RedirectResponse | HTMLResponse:
"""Render the Login Flow v2 provisioning page. """Initiate Login Flow v2 and redirect to Nextcloud's login page.
GET /app/provision?redirect_uri=...&user_id=... GET /app/provision?redirect_uri=...&user_id=...
Initiates Login Flow v2, starts background polling, and returns an HTML Initiates Login Flow v2, starts background polling, and redirects the
page that opens Nextcloud's login URL in a popup and polls for completion. browser to Nextcloud's login/grant page. After the user grants access,
the background task stores the app password. The user then navigates
back to the redirect_uri (Astrolabe settings).
""" """
_cleanup_expired_sessions() _cleanup_expired_sessions()
@@ -148,16 +151,13 @@ async def provision_page(request: Request) -> HTMLResponse:
status_code=400, status_code=400,
) )
# Check if user already has an app password # Check if user already has an app password — skip straight to redirect
if user_id: if user_id:
storage = await get_shared_storage() storage = await get_shared_storage()
existing = await storage.get_app_password_with_scopes(user_id) existing = await storage.get_app_password_with_scopes(user_id)
if existing: if existing:
logger.info(f"User {user_id} already has app password, skipping provision") logger.info(f"User {user_id} already has app password, skipping provision")
return HTMLResponse( return RedirectResponse(redirect_uri)
content=_render_redirect(redirect_uri),
status_code=200,
)
# Initiate Login Flow v2 # Initiate Login Flow v2
settings = get_settings() settings = get_settings()
@@ -201,16 +201,18 @@ async def provision_page(request: Request) -> HTMLResponse:
logger.info( logger.info(
f"Login Flow v2 web provision initiated (provision_id={provision_id}, " f"Login Flow v2 web provision initiated (provision_id={provision_id}, "
f"user_id={user_id or 'unknown'})" f"user_id={user_id or 'unknown'}), redirecting to NC login"
) )
return HTMLResponse( # Redirect to Nextcloud's Login Flow v2 login page.
content=_render_provision_page( # The login_url may use the internal Docker URL (http://app:80/...).
provision_id=provision_id, # Replace with the public Nextcloud URL for the browser.
login_url=init_response.login_url, login_url = init_response.login_url
redirect_uri=redirect_uri, public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL", "")
) if public_issuer and nextcloud_host and nextcloud_host in login_url:
) login_url = login_url.replace(nextcloud_host, public_issuer.rstrip("/"))
return RedirectResponse(login_url)
async def provision_status(request: Request) -> JSONResponse: async def provision_status(request: Request) -> JSONResponse:
@@ -244,175 +246,6 @@ async def provision_status(request: Request) -> JSONResponse:
# ── HTML rendering helpers ──────────────────────────────────────────────── # ── HTML rendering helpers ────────────────────────────────────────────────
def _render_provision_page(provision_id: str, login_url: str, redirect_uri: str) -> str:
"""Render the provisioning page HTML."""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connecting to Nextcloud - Astrolabe</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Open Sans', sans-serif;
background: #f5f5f5;
color: #222;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}}
.card {{
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 2.5rem;
max-width: 480px;
width: 90%;
text-align: center;
}}
h1 {{
font-size: 1.4rem;
margin-bottom: 1rem;
color: #00679e;
}}
.status {{
margin: 1.5rem 0;
padding: 1rem;
border-radius: 8px;
background: #e5eff5;
}}
.status.error {{
background: #fde8e8;
color: #c62828;
}}
.status.success {{
background: #e8f5e9;
color: #2e7d32;
}}
.spinner {{
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #e5eff5;
border-top-color: #00679e;
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: middle;
margin-right: 8px;
}}
@keyframes spin {{
to {{ transform: rotate(360deg); }}
}}
.btn {{
display: inline-block;
padding: 10px 24px;
background: #00679e;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
text-decoration: none;
margin-top: 1rem;
}}
.btn:hover {{ background: #005580; }}
.btn:disabled {{
background: #ccc;
cursor: not-allowed;
}}
.help {{
margin-top: 1.5rem;
font-size: 0.85rem;
color: #6b6b6b;
}}
#popup-blocked {{
display: none;
margin-top: 1rem;
}}
</style>
</head>
<body>
<div class="card">
<h1>Connect to Nextcloud</h1>
<p>Grant Astrolabe access to your Nextcloud account for background sync.</p>
<div id="status" class="status">
<span class="spinner"></span>
Waiting for authorization...
</div>
<div id="popup-blocked">
<p>Could not open the login window automatically.</p>
<a class="btn" href="{login_url}" target="_blank" rel="noopener"
id="manual-open-btn">Open Login Page</a>
</div>
<div id="grant-hint" class="help">
A popup window should open. Click <strong>"Grant access"</strong> in the
Nextcloud window to continue.
</div>
</div>
<script>
(function() {{
const provisionId = "{provision_id}";
const redirectUri = "{redirect_uri}";
const loginUrl = "{login_url}";
let popup = null;
let pollInterval = null;
// Try to open popup
try {{
popup = window.open(loginUrl, "nextcloud_login",
"width=600,height=700,scrollbars=yes,resizable=yes");
}} catch(e) {{
// Popup blocked
}}
if (!popup || popup.closed) {{
document.getElementById("popup-blocked").style.display = "block";
document.getElementById("grant-hint").textContent =
"After granting access, this page will update automatically.";
}}
// Poll for completion
function checkStatus() {{
fetch("/app/provision/status?id=" + encodeURIComponent(provisionId))
.then(r => r.json())
.then(data => {{
if (data.status === "completed") {{
clearInterval(pollInterval);
if (popup && !popup.closed) popup.close();
const el = document.getElementById("status");
el.className = "status success";
el.innerHTML = "✓ Connected as <strong>" +
(data.username || "user") + "</strong>. Redirecting...";
setTimeout(() => {{ window.location.href = redirectUri; }}, 1500);
}} else if (data.status === "expired" || data.status === "not_found") {{
clearInterval(pollInterval);
if (popup && !popup.closed) popup.close();
const el = document.getElementById("status");
el.className = "status error";
el.innerHTML = "Authorization expired. Please try again.";
}}
}})
.catch(() => {{
// Network error, keep polling
}});
}}
pollInterval = setInterval(checkStatus, 2000);
// Also check immediately after a short delay
setTimeout(checkStatus, 1000);
}})();
</script>
</body>
</html>"""
def _render_error(message: str) -> str: def _render_error(message: str) -> str:
"""Render an error page.""" """Render an error page."""
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html>
@@ -448,19 +281,3 @@ def _render_error(message: str) -> str:
</div> </div>
</body> </body>
</html>""" </html>"""
def _render_redirect(redirect_uri: str) -> str:
"""Render a page that immediately redirects (for already-provisioned users)."""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url={redirect_uri}">
<title>Redirecting...</title>
</head>
<body>
<p>Already connected. Redirecting...</p>
<script>window.location.href = "{redirect_uri}";</script>
</body>
</html>"""