Files
mcp-nextcloud/nextcloud_mcp_server/auth/login_flow.py
T
Chris CoutinhoandClaude Opus 4.8 ae54956f27 fix(auth): login-flow provisioning — public login_url + session app passwords
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>
2026-05-28 23:06:11 +02:00

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")