Two fixes surfaced while testing Login Flow v2 provisioning behind a split internal/external host (Docker: server↔Nextcloud over http://app, browser over http://localhost:8080): 1. login_url pointed at the internal host. Nextcloud builds the login URL from the request host, so the browser-facing URL came back as http://app/login/v2/flow/... — unreachable from the user's browser. The poll endpoint was already rewritten to the internal host (correct, the server polls it); now LoginFlowV2Client also rewrites the login_url origin to settings.nextcloud_public_issuer_url when set (passed at all 5 construction sites). When unset, behaviour is unchanged. 2. The app-password format guard rejected raw session tokens. core/ getapppassword returns a long alphanumeric token, not the dashed 25-char Security-settings format, so the dashed-only regex 400'd the one-click opt-in handoff. Relax APP_PASSWORD_PATTERN to `^[a-zA-Z0-9-]{20,256}$`; the authoritative validation is still the BasicAuth check against Nextcloud. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
225 lines
8.5 KiB
Python
225 lines
8.5 KiB
Python
"""Nextcloud Login Flow v2 HTTP client.
|
|
|
|
Implements the Nextcloud Login Flow v2 protocol for obtaining app passwords.
|
|
See: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
|
|
|
The flow has two steps:
|
|
1. Initiate: POST /index.php/login/v2 → returns login URL + poll endpoint/token
|
|
2. Poll: POST to poll endpoint with token → returns server URL, loginName, appPassword
|
|
"""
|
|
|
|
import logging
|
|
import ssl
|
|
from urllib.parse import urlparse, urlunparse
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from nextcloud_mcp_server.http import nextcloud_httpx_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def rewrite_url_origin(url: str, target_host: str) -> str:
|
|
"""Rewrite a URL's scheme+host+port to match target_host.
|
|
|
|
Preserves the path, params, query, and fragment from the original URL.
|
|
Useful for rewriting internal Docker hostnames to public-facing URLs.
|
|
"""
|
|
parsed_url = urlparse(url)
|
|
parsed_host = urlparse(target_host)
|
|
return urlunparse(
|
|
(
|
|
parsed_host.scheme,
|
|
parsed_host.netloc,
|
|
parsed_url.path,
|
|
parsed_url.params,
|
|
parsed_url.query,
|
|
parsed_url.fragment,
|
|
)
|
|
)
|
|
|
|
|
|
class LoginFlowInitResponse(BaseModel):
|
|
"""Response from initiating Login Flow v2."""
|
|
|
|
login_url: str = Field(description="URL to present to the user for browser login")
|
|
poll_endpoint: str = Field(description="URL to poll for flow completion")
|
|
poll_token: str = Field(description="Token to use when polling")
|
|
|
|
|
|
class LoginFlowPollResult(BaseModel):
|
|
"""Result of polling Login Flow v2."""
|
|
|
|
status: str = Field(description="Flow status: 'pending', 'completed', or 'expired'")
|
|
server: str | None = Field(None, description="Nextcloud server URL (on completion)")
|
|
login_name: str | None = Field(
|
|
None, description="Nextcloud login name (on completion)"
|
|
)
|
|
app_password: str | None = Field(
|
|
None, description="Generated app password (on completion)"
|
|
)
|
|
|
|
|
|
class LoginFlowV2Client:
|
|
"""HTTP client for Nextcloud Login Flow v2.
|
|
|
|
This client handles the two-step Login Flow v2 process:
|
|
1. Initiate a flow to get a login URL for the user
|
|
2. Poll for completion to receive the app password
|
|
|
|
Args:
|
|
nextcloud_host: Base URL of the Nextcloud instance, reachable by this
|
|
server (may be an internal/Docker hostname, e.g. http://app:80).
|
|
verify_ssl: SSL verification setting (True, False, or SSLContext)
|
|
public_host: Externally-reachable Nextcloud base URL for the
|
|
browser-facing login URL (e.g. https://cloud.example.com). When the
|
|
server talks to Nextcloud over an internal hostname, Nextcloud
|
|
builds the login URL with that internal host — unusable in the
|
|
user's browser. If set, the login URL's origin is rewritten to this
|
|
public host. When None, the login URL is returned unchanged
|
|
(correct when nextcloud_host is already the public URL).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
nextcloud_host: str,
|
|
verify_ssl: bool | ssl.SSLContext = True,
|
|
public_host: str | None = None,
|
|
):
|
|
self.nextcloud_host = nextcloud_host.rstrip("/")
|
|
self.verify_ssl = verify_ssl
|
|
self.public_host = public_host.rstrip("/") if public_host else None
|
|
|
|
async def initiate(
|
|
self, user_agent: str = "nextcloud-mcp-server"
|
|
) -> LoginFlowInitResponse:
|
|
"""Initiate Login Flow v2 by sending an HTTP POST to the Nextcloud instance.
|
|
|
|
Makes an outbound HTTP request to POST /index.php/login/v2 on the
|
|
configured Nextcloud server to start a new login flow.
|
|
|
|
Args:
|
|
user_agent: User-Agent string for the app password name
|
|
|
|
Returns:
|
|
LoginFlowInitResponse with login URL and poll credentials
|
|
|
|
Raises:
|
|
httpx.HTTPStatusError: If the Nextcloud server returns an error
|
|
"""
|
|
url = f"{self.nextcloud_host}/index.php/login/v2"
|
|
|
|
async with nextcloud_httpx_client(
|
|
verify=self.verify_ssl, timeout=15.0
|
|
) as client:
|
|
response = await client.post(
|
|
url,
|
|
headers={"User-Agent": user_agent},
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
poll_data = data.get("poll", {})
|
|
|
|
try:
|
|
raw_poll_endpoint = poll_data["endpoint"]
|
|
# Nextcloud returns URLs using its internal hostname (e.g.
|
|
# http://localhost/login/v2/poll) which may be unreachable from
|
|
# this process. Rewrite the poll endpoint to use nextcloud_host
|
|
# so server-side polling works across Docker networks.
|
|
poll_endpoint = self._rewrite_to_nextcloud_host(raw_poll_endpoint)
|
|
|
|
# The login URL is opened in the *user's browser*, so it must use
|
|
# the externally-reachable host. Nextcloud builds it from the
|
|
# request host (our internal nextcloud_host), so rewrite it to the
|
|
# public host when one is configured (internal != external).
|
|
login_url = data["login"]
|
|
if self.public_host:
|
|
rewritten = rewrite_url_origin(login_url, self.public_host)
|
|
if rewritten != login_url:
|
|
logger.debug(
|
|
"Rewrote Login Flow v2 login_url to public host: %s → %s",
|
|
login_url,
|
|
rewritten,
|
|
)
|
|
login_url = rewritten
|
|
|
|
result = LoginFlowInitResponse(
|
|
login_url=login_url,
|
|
poll_endpoint=poll_endpoint,
|
|
poll_token=poll_data["token"],
|
|
)
|
|
except KeyError as e:
|
|
raise ValueError(
|
|
f"Malformed Login Flow v2 initiate response from Nextcloud (missing key: {e})"
|
|
) from e
|
|
|
|
logger.info("Login Flow v2 initiated: login_url=%s...", result.login_url[:60])
|
|
return result
|
|
|
|
def _rewrite_to_nextcloud_host(self, url: str) -> str:
|
|
"""Rewrite a URL's origin to use self.nextcloud_host.
|
|
|
|
Nextcloud may return URLs with its internal hostname (e.g.
|
|
http://localhost) which differs from the configured NEXTCLOUD_HOST
|
|
(e.g. http://app:80). This replaces the scheme+host+port while
|
|
preserving the path and query.
|
|
"""
|
|
result = rewrite_url_origin(url, self.nextcloud_host)
|
|
if result != url:
|
|
logger.debug("Rewrote Login Flow v2 URL: %s → %s", url, result)
|
|
return result
|
|
|
|
async def poll(self, poll_endpoint: str, poll_token: str) -> LoginFlowPollResult:
|
|
"""Poll for Login Flow v2 completion by sending an HTTP POST to the Nextcloud instance.
|
|
|
|
Makes an outbound HTTP request to the poll endpoint provided by the
|
|
initiate response. Nextcloud returns:
|
|
- 200 with credentials when the user completes login
|
|
- 404 when still pending
|
|
- Other errors for expired/invalid flows
|
|
|
|
Args:
|
|
poll_endpoint: URL to poll (from initiate response)
|
|
poll_token: Token for polling (from initiate response)
|
|
|
|
Returns:
|
|
LoginFlowPollResult with status and optional credentials
|
|
"""
|
|
async with nextcloud_httpx_client(
|
|
verify=self.verify_ssl, timeout=10.0
|
|
) as client:
|
|
response = await client.post(
|
|
poll_endpoint,
|
|
data={"token": poll_token},
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
logger.info(
|
|
"Login Flow v2 completed: server=%s, loginName=%s",
|
|
data.get("server"),
|
|
data.get("loginName"),
|
|
)
|
|
try:
|
|
return LoginFlowPollResult(
|
|
status="completed",
|
|
server=data["server"],
|
|
login_name=data["loginName"],
|
|
app_password=data["appPassword"],
|
|
)
|
|
except KeyError as e:
|
|
raise ValueError(
|
|
f"Malformed Login Flow v2 poll response from Nextcloud (missing key: {e})"
|
|
) from e
|
|
|
|
if response.status_code == 404:
|
|
logger.debug("Login Flow v2 still pending")
|
|
return LoginFlowPollResult(status="pending")
|
|
|
|
# Any other status indicates the flow has expired or is invalid
|
|
logger.warning(
|
|
"Login Flow v2 poll returned unexpected status: %s", response.status_code
|
|
)
|
|
return LoginFlowPollResult(status="expired")
|