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.
|
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>"""
|
|
||||||
|
|||||||
Reference in New Issue
Block a user