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:
co-authored by
Claude Opus 4.6
parent
081ecbe401
commit
1a51f5bbf5
@@ -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>"""
|
||||
|
||||
Reference in New Issue
Block a user