Skip to content

fix(login-flow): resolve Login Flow v2 login_url to Nextcloud in external-IdP mode#993

Merged
cbcoutinho merged 7 commits into
masterfrom
test/keycloak-divergent-principal-980
Jul 1, 2026
Merged

fix(login-flow): resolve Login Flow v2 login_url to Nextcloud in external-IdP mode#993
cbcoutinho merged 7 commits into
masterfrom
test/keycloak-divergent-principal-980

Conversation

@cbcoutinho

@cbcoutinho cbcoutinho commented Jul 1, 2026

Copy link
Copy Markdown
Owner

What

Fixes a Login Flow v2 bug in external-IdP (Keycloak/OIDC) deployments and adds the missing end-to-end Keycloak Login Flow v2 WebDAV coverage that surfaced it. Started as an attempt to reproduce #980 in the keycloak lane; that investigation found the reproduction isn't feasible on the CI Nextcloud versions (see below) but uncovered two real bugs on the way.

Bug fixed: Login Flow v2 login_url rewritten to the IdP origin

mcp-keycloak rewrote the Login Flow v2 login_url origin to NEXTCLOUD_PUBLIC_ISSUER_URL. That setting is overloaded — it's the OAuth issuer for JWT iss validation and was reused as the browser-reachable Nextcloud URL for the Login Flow redirect. When Nextcloud is its own IdP these coincide; with an external IdP the issuer is the IdP, so the browser-facing login URL was rewritten onto the IdP origin (http://localhost:8888/login/v2/flow/...) — which has no /login/v2 endpoint and 404s. This breaks Login Flow v2 for any external-IdP deployment.

Fix: a dedicated NEXTCLOUD_PUBLIC_URL (browser-reachable Nextcloud base URL), resolved via a new Settings.nextcloud_browser_url property with a backward-compatible fallback chain (nextcloud_public_urlnextcloud_public_issuer_urlnextcloud_host). Login Flow v2 / elicitation rewrites use it; OAuth issuer / JWT validation stays on nextcloud_public_issuer_url. Wired for mcp-keycloak in docker-compose.yml. Existing login-flow deployments that set only NEXTCLOUD_PUBLIC_ISSUER_URL are unchanged.

Also fixed: keycloak OAuth-leg fixture

The OAuth-leg fixture requested talk.* scopes not registered on the Keycloak client → invalid_scope → Keycloak error page → Playwright #username timeout. Replaced the flaky browser auth-code flow with a direct-grant (ROPC) token using realm-supported scopes (the OAuth-leg identity is irrelevant — the Login Flow v2 app password authenticates DAV). The divergent user + provisioned session are now session-scoped (one live user, one browser login) since the app-password store is keyed by the shared Keycloak admin identity.

Tests

tests/server/keycloak/test_keycloak_login_flow_webdav.py — end-to-end Keycloak Login Flow v2 WebDAV round-trip (create/write/read/list/delete). Fills the keycloak-lane coverage gap (previously only DCR/authorize) and guards the NEXTCLOUD_PUBLIC_URL fix — without it the session can't provision. Plus a Settings.nextcloud_browser_url unit test and a NEXTCLOUD_PUBLIC_URL-preference elicitation test. Docs updated in configuration.md and login-flow-v2.md.

Relationship to #980 (why this is not a live reproduction)

The original goal was to reproduce #980's divergent-principal DAV bug via keycloak login-by-email. That doesn't reproduce on NC32/33, verified against a live stack:

So the keycloak tests are kept as genuine end-to-end coverage (they caught both bugs above), not as a #980 reproduction.

Validation

  • uv run ruff check / ruff format --check / ty check — clean
  • uv run pytest tests/unit/ — 1927 passed, 1 skipped
  • Keycloak integration lane (NC32, local) — 2 passed (WebDAV round-trip GREEN)

This PR was generated with the help of AI, and reviewed by a Human

…n Flow v2

Adds the missing Login Flow v2 app-password integration tests for the
mcp-keycloak service (port 8002). The keycloak lane previously only covered
DCR/authorize; it never provisioned an app password or exercised a DAV path.

The new fixtures obtain a Keycloak OAuth token (browser auth-code + PKCE) and
complete Nextcloud Login Flow v2, logging in as a *local* Nextcloud user via its
EMAIL. Nextcloud keys the app password on the loginName (the email), which
differs from the canonical UID, so context.py builds DAV paths from the email
(/remote.php/dav/files/<email>/) instead of the real home dir.

Without PR #980 the WebDAV cycle targets the wrong home and fails (RED); with
the current-user-principal discovery fix it resolves the UID and passes (GREEN).
This is the keycloak-service counterpart of the existing login_flow WebDAV test.

Card: Deck #489 (board 12).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown

Claude finished @cbcoutinho's task in 3m 40s —— View job


Code Review

  • Gather context (PR body, comments, changed files)
  • Read changed files in detail
  • Check diff against merge-base (cd74ce9; origin/master...HEAD had no common ancestor in this shallow checkout)
  • Review code quality, bugs, security, test coverage
  • Post final review

Note: couldn't execute uv run ruff/ty/pytest locally in this sandboxed run (no tool-execution permission for non-git commands), so this is a static read-through plus verification against the codebase's existing conventions. CI (Tests, Pact contract tests) was still in progress at review time — worth confirming green before merge.

Summary

This is a well-scoped, well-documented fix. The core bug (NEXTCLOUD_PUBLIC_ISSUER_URL overloaded as both OAuth issuer and browser-facing Nextcloud URL) is real, and the fix — a dedicated nextcloud_public_url / nextcloud_browser_url property with a backward-compatible fallback chain — is applied consistently everywhere the old nextcloud_public_issuer_url was used for browser redirects (elicitation.py, provision_routes.py ×2, auth_tools.py ×3, userinfo_routes.py), per config.py:1027-1045. Verified no other browser-facing call site of the old field was missed (grep -rn "public_host" and nextcloud_public_issuer_url usages all check out).

Things verified while reading (no issues found)

  • Settings.nextcloud_browser_url fallback chain (nextcloud_public_url → nextcloud_public_issuer_url → nextcloud_host) matches the PR description and the new TestNextcloudBrowserUrl unit tests (tests/unit/test_config.py).
  • The double rewrite_url_origin call (once inside LoginFlowV2Client.initiate() via public_host, again in provision_routes.py:269 against nextcloud_browser_url) is idempotent — rewriting an already-rewritten URL to the same origin is a no-op — and pre-existing behavior, not introduced by this PR.
  • browser fixture dependency on a session-scoped fixture (nc_mcp_keycloak_email_client, divergent_email_user) mirrors the same pattern already used in tests/server/login_flow/conftest.py, so no pytest scope-mismatch risk (pytest-playwright's browser fixture is session-scoped by default).
  • nc_client.users.create_user/delete_user signatures match the new divergent_email_user fixture's usage.
  • pytest.mark.keycloak is a registered marker (pyproject.toml), and PLC0415/G004 ruff rules aren't violated by the new test files (no f-string logging calls found).
  • Settings() construction with no args (used in test_none_when_nothing_set) is an established pattern elsewhere in test_config.py, so it won't hit unrelated __post_init__ validation.
  • The two SonarCloud findings raised in the automated review (S5443, S5332) were already addressed in the follow-up commits 59f2aa7/d2eab9d.

Minor observations (non-blocking)

  • tests/server/keycloak/conftest.py cross-imports _rewrite_login_flow_url from tests.server.login_flow.conftest (a private, underscore-prefixed helper). Already flagged and consciously deferred by the author in the PR thread as out-of-scope — agree it's reasonable to leave for a future shared-test-support refactor rather than block this PR on it.
  • The extensive docstrings in conftest.py/test_keycloak_login_flow_webdav.py explaining the fix(client): resolve DAV paths via current-user-principal discovery #980 non-reproduction are long, but given how easy it'd be for someone to later assume this is a fix(client): resolve DAV paths via current-user-principal discovery #980 regression test, the extra context earns its keep here.

Test coverage

The new test_keycloak_login_flow_webdav.py end-to-end round-trip (create/write/read/list/delete) plus the nextcloud_browser_url unit tests and the NEXTCLOUD_PUBLIC_URL-preference elicitation test give solid regression coverage for the actual bug fixed — without the fix, the Keycloak Login Flow v2 session fixture cannot provision, so these tests would fail/hang rather than silently pass.

No blocking issues found. This looks ready to merge once CI is green.

cbcoutinho and others added 2 commits July 1, 2026 21:19
…rnal-IdP mode

The Login Flow v2 login_url origin was rewritten to
`NEXTCLOUD_PUBLIC_ISSUER_URL`, which doubles as the OAuth issuer for JWT `iss`
validation. In single-IdP (login-flow) mode the issuer IS Nextcloud, so this
worked. In external-IdP mode (e.g. Keycloak) the issuer is the IdP, so the
browser-facing login URL was rewritten onto the IdP origin (e.g.
`http://localhost:8888/login/v2/flow/...`) — which has no `/login/v2` endpoint
and 404s, breaking Login Flow v2 for every external-IdP deployment.

Add a dedicated `NEXTCLOUD_PUBLIC_URL` (browser-reachable Nextcloud base URL)
resolved via a new `Settings.nextcloud_browser_url` property with a
backward-compatible fallback chain (`nextcloud_public_url` →
`nextcloud_public_issuer_url` → `nextcloud_host`). Login Flow v2 / elicitation
rewrites now use it; OAuth issuer / JWT validation stays on
`nextcloud_public_issuer_url`. Wire `NEXTCLOUD_PUBLIC_URL=http://localhost:8080`
for the `mcp-keycloak` compose service.

Existing login-flow deployments that set only `NEXTCLOUD_PUBLIC_ISSUER_URL` are
unchanged (the fallback resolves to the same value).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The keycloak lane's Login Flow v2 tests never actually ran the reproduction
they claimed: the OAuth-leg fixture requested `talk.*` scopes not registered on
the Keycloak client (invalid_scope → Playwright #username timeout), and the
Login Flow leg 404'd on the Keycloak-origin login_url (fixed separately via
NEXTCLOUD_PUBLIC_URL).

Rework:
- OAuth leg now uses a Keycloak direct-grant (ROPC) with realm-supported scopes
  instead of the flaky browser auth-code flow — its identity is irrelevant to
  the reproduction (the Login Flow v2 app password authenticates DAV).
- Session-scope the divergent user + provisioned client so a single live user
  and one browser login serve the lane (the app-password store is keyed by the
  shared Keycloak `admin` identity, so per-test users would be deleted while
  their app password is still cached).
- Reframe as end-to-end Keycloak Login Flow v2 WebDAV coverage. These do NOT
  reproduce #980's wrong-path bug on NC32/33: Nextcloud resolves
  `/remote.php/dav/files/<email>/` to the real home, so the round-trip succeeds
  with or without the client-side principal-discovery fix. #980's failure mode
  needs a backend (e.g. LDAP) where the loginName is not a valid files-path
  alias; it stays covered by #980's own mocked unit tests. The tests remain
  valuable: they fill the missing keycloak Login Flow coverage and guard the
  NEXTCLOUD_PUBLIC_URL fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cbcoutinho cbcoutinho changed the title test(keycloak): reproduce divergent-principal DAV bug (#980) via Login Flow v2 fix(login-flow): resolve Login Flow v2 login_url to Nextcloud in external-IdP mode Jul 1, 2026
@cbcoutinho cbcoutinho marked this pull request as ready for review July 1, 2026 20:23
Round-1 review finding: userinfo_routes.py built browser-facing Nextcloud app
links (viz tab) from `nextcloud_public_issuer_url or nextcloud_host`, the same
pattern migrated elsewhere in this PR. In external-IdP mode the issuer URL is
the IdP, so those links would point at the IdP origin instead of Nextcloud.
Switch to `settings.nextcloud_browser_url` for parity with the elicitation /
provision / auth_tools call sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cbcoutinho

Copy link
Copy Markdown
Owner Author

Round-1 findings addressed in 8061a38e:

  • 🟡 userinfo_routes.py missed the same browser-URL migration: Good catch — fixed. nextcloud_host_for_links (viz-tab app links) now uses settings.nextcloud_browser_url for parity with elicitation.py / provision_routes.py / auth_tools.py. It was indeed a browser-facing link, not issuer validation.
  • 🟡 PR description stale: Already addressed before this round — the review ran against the pre-rewrite metadata. The title is now fix(login-flow): resolve Login Flow v2 login_url to Nextcloud in external-IdP mode and the body drops the "RED→GREEN reproduction" framing and explicitly documents that this is not a live fix(client): resolve DAV paths via current-user-principal discovery #980 reproduction on NC32/33 (with the LDAP-backend explanation). This re-review round should see the updated body.

Left as-is (with reasons):

  • 🟢 SonarQube "Security Rating D" (hardcoded Keycloak secret/user/pass in conftest.py): Deferred — these are dev-only constants that mirror values already committed in keycloak/realm-export.json and docker-compose.yml (not real credentials), matching the repo's existing convention for the local dev stack. Sonar flags each new literal regardless of duplication; false positive.
  • 🟢 Cross-package import of _rewrite_login_flow_url: Deferred — intentional reuse to avoid duplicating the rewrite logic; promoting it to a shared non-underscored test-support module is a broader refactor out of scope for this PR.

ruff / ruff format / ty clean; userinfo + config + elicitation unit tests pass. CI keycloak integration lanes (nc32/nc33) are green on the prior push.

Round-2 nit: the module-level comment above ASTROLABE_SETTINGS_PATH still
referenced nextcloud_public_issuer_url / nextcloud_host; the code (and the
_astrolabe_settings_url docstring just below) now uses nextcloud_browser_url.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cbcoutinho

Copy link
Copy Markdown
Owner Author

Round-2 nit addressed in 2a190730: updated the stale module-level comment above ASTROLABE_SETTINGS_PATH in elicitation.py to reference nextcloud_browser_url (the _astrolabe_settings_url docstring just below already did). Thanks for the thorough review — with "No blocking issues found" and the two deferrals agreed, this is merge-ready pending CI.

cbcoutinho and others added 2 commits July 1, 2026 22:34
SonarCloud's quality gate flagged the hardcoded Keycloak client secret,
bootstrap password, and ephemeral test-user password in conftest.py
(Security Rating D on new code, rule S2068). These are dev-only values that
mirror keycloak/realm-export.json + docker-compose.yml (not real secrets).
Add the repo's established `# NOSONAR(S2068)` markers, matching the pattern in
tests/server/login_flow/test_app_password_loginname_mismatch.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Security Rating D gate was driven by the actual issues (not the credential
literals the reviewer speculated about):
- S5443 (CRITICAL) conftest.py — hardcoded /tmp screenshot path. Kept (CI
  uploads /tmp/*.png as debug artifacts) but hoisted to its own line with a
  # NOSONAR(S5443) marker so the suppression anchors to the flagged literal.
- S5332 (MINOR) test_config.py / test_elicitation.py — http:// URLs in the new
  nextcloud_browser_url fallback-chain assertions. Switched to https:// (the
  scheme is irrelevant to what those unit tests assert).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Jul 1, 2026

Copy link
Copy Markdown

@cbcoutinho cbcoutinho merged commit a286709 into master Jul 1, 2026
18 checks passed
cbcoutinho added a commit to dismantl/nextcloud-mcp-server that referenced this pull request Jul 1, 2026
…ia OpenLDAP

Adds an `ldap` docker-compose profile + integration lane that provides the
live RED→GREEN reproduction of cbcoutinho#980 (DAV paths built from the loginName
instead of the canonical Nextcloud UID) that the Keycloak lane (cbcoutinho#993) could
not: login-by-email resolves to the real home (email is a path alias) and
user_oidc hardcodes loginName == UID.

- `openldap` service (vegardit/openldap:2.6.10) seeds user `alice` from
  ldap/bootstrap.ldif. Nextcloud's user_ldap maps her to a UID derived from
  the LDAP entryUUID, so `loginName (alice) != UID` AND
  `/remote.php/dav/files/alice/` does not resolve to her real home.
- user_ldap is configured by an app-hook
  (app-hooks/post-installation/15-setup-ldap-backend.sh), gated on the
  openldap service so it is a no-op for every other lane — mirroring
  15-setup-keycloak-provider.sh.
- tests/server/ldap drives the multi-user BasicAuth MCP service (port 8003)
  as `alice`; the round-trip is xfail(strict=True): it fails on master (bug
  present) and xpasses once cbcoutinho#980's BaseNextcloudClient._ensure_principal_id
  discovery lands, at which point the marker should be dropped.
- CI matrix gains an `ldap` lane (reuses multi-user-basic + `ldap` profile,
  no browser); docs added to CLAUDE.md.

Verified locally: fresh-install hook auto-configures user_ldap; test xfails on
master and xpasses with cbcoutinho#980 cherry-picked.

Relates to cbcoutinho#980. Tracked on Deck card cbcoutinho#490.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant