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.
Flow:
1. GET /app/provision?redirect_uri=... → Initiates LFv2, renders polling page
2. The page opens Nextcloud's login URL in a popup window
3. User clicks "Grant access" in the popup
4. Page polls GET /app/provision/status?id=... for completion
5. On success, redirects to redirect_uri
1. GET /app/provision?redirect_uri=... → Initiates LFv2, redirects to NC login
2. User clicks "Grant access" on Nextcloud's login page
3. MCP server background task polls and stores app password
4. GET /app/provision/status?id=... → Returns completion status (JSON)
5. User returns to Astrolabe settings (via redirect_uri or navigation)
"""
import asyncio
import logging
import os
import secrets
import time
from urllib.parse import urlparse
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.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:
"""Render the Login Flow v2 provisioning page.
async def provision_page(request: Request) -> RedirectResponse | HTMLResponse:
"""Initiate Login Flow v2 and redirect to Nextcloud's login page.
GET /app/provision?redirect_uri=...&user_id=...
Initiates Login Flow v2, starts background polling, and returns an HTML
page that opens Nextcloud's login URL in a popup and polls for completion.
Initiates Login Flow v2, starts background polling, and redirects the
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()
@@ -148,16 +151,13 @@ async def provision_page(request: Request) -> HTMLResponse:
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:
storage = await get_shared_storage()
existing = await storage.get_app_password_with_scopes(user_id)
if existing:
logger.info(f"User {user_id} already has app password, skipping provision")
return HTMLResponse(
content=_render_redirect(redirect_uri),
status_code=200,
)
return RedirectResponse(redirect_uri)
# Initiate Login Flow v2
settings = get_settings()
@@ -201,16 +201,18 @@ async def provision_page(request: Request) -> HTMLResponse:
logger.info(
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(
content=_render_provision_page(
provision_id=provision_id,
login_url=init_response.login_url,
redirect_uri=redirect_uri,
)
)
# Redirect to Nextcloud's Login Flow v2 login page.
# The login_url may use the internal Docker URL (http://app:80/...).
# Replace with the public Nextcloud URL for the browser.
login_url = init_response.login_url
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:
@@ -244,175 +246,6 @@ async def provision_status(request: Request) -> JSONResponse:
# ── 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:
"""Render an error page."""
return f"""<!DOCTYPE html>
@@ -448,19 +281,3 @@ def _render_error(message: str) -> str:
</div>
</body>
</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>"""