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>
This commit is contained in:
Chris Coutinho
2026-05-28 23:06:11 +02:00
co-authored by Claude Opus 4.8
parent bf35200bab
commit ae54956f27
6 changed files with 93 additions and 5 deletions
+37
View File
@@ -151,6 +151,43 @@ async def test_poll_expired(flow_client):
assert result.app_password is None
async def test_initiate_rewrites_login_url_to_public_host():
"""When server↔Nextcloud uses an internal host, the browser-facing login
URL must be rewritten to the configured public host; the poll endpoint
stays on the internal host for server-side polling."""
client = LoginFlowV2Client(
nextcloud_host="http://app:80", # internal Docker host
verify_ssl=False,
public_host="http://localhost:8080", # browser-reachable
)
mock_response = _mock_response(
200,
{
# Nextcloud builds these from the request (internal) host.
"login": "http://app/login/v2/flow/tok123",
"poll": {
"endpoint": "http://app/login/v2/poll",
"token": "secret-poll-token",
},
},
)
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch(
"nextcloud_mcp_server.auth.login_flow.nextcloud_httpx_client",
return_value=mock_client,
):
result = await client.initiate()
# Browser-facing URL uses the public host...
assert result.login_url == "http://localhost:8080/login/v2/flow/tok123"
# ...while the poll endpoint stays on the internal host (server polls it).
assert result.poll_endpoint == "http://app:80/login/v2/poll"
async def test_initiate_with_custom_user_agent(flow_client):
"""Test that custom user agent is passed in the request."""
mock_response = _mock_response(
@@ -180,6 +180,25 @@ async def test_provision_app_password_invalid_format():
assert "Invalid app password format" in response.json()["error"]
def test_app_password_pattern_accepts_dashed_and_raw_tokens():
"""The format guard accepts both the dashed Security-settings format and
the raw token from the one-click ``core/getapppassword`` flow, and still
rejects short / illegal-character input."""
from nextcloud_mcp_server.api.passwords import APP_PASSWORD_PATTERN
# Dashed format a user copies from Security settings.
assert APP_PASSWORD_PATTERN.match("abcde-ABCDE-12345-fghij-67890")
# Raw 72-char token returned by core/getapppassword (one-click opt-in).
assert APP_PASSWORD_PATTERN.match(
"kZmgLDQnqQHUAxhRq4d2VssBfjsI0PaHbL4JySWtwJkzVgAf34c0sZshEjZjuj1PLbwrf83q"
)
# Still rejects obviously-bad input.
assert not APP_PASSWORD_PATTERN.match("short")
assert not APP_PASSWORD_PATTERN.match("invalid-password") # < 20 chars
assert not APP_PASSWORD_PATTERN.match("has spaces not allowed in this token")
assert not APP_PASSWORD_PATTERN.match("contains/slash/" + "a" * 20)
async def test_provision_app_password_success(temp_storage, mocker):
"""Test successful app password provisioning."""
# Mock settings (imported locally in the function)