Skip to content

fix(client): resolve DAV paths via current-user-principal discovery#980

Merged
cbcoutinho merged 3 commits into
cbcoutinho:masterfrom
dismantl:codex/dav-principal-discovery
Jul 1, 2026
Merged

fix(client): resolve DAV paths via current-user-principal discovery#980
cbcoutinho merged 3 commits into
cbcoutinho:masterfrom
dismantl:codex/dav-principal-discovery

Conversation

@dismantl

Copy link
Copy Markdown
Contributor

I use external LDAP authentication for my Nextcloud instance, and nextcloud-mcp-server wouldn't work for my user account because its loginName and UID differ. This PR fixes that, and I deployed it to my Nextcloud 34 instance and it successfully solved the problem for me.

Problem

nextcloud-mcp-server builds every DAV path from the configured username, assuming that segment equals the user's canonical Nextcloud UID:

  • WebDAV: /remote.php/dav/files/<username>/
  • CardDAV: /remote.php/dav/addressbooks/users/<username>/
  • CalDAV: /remote.php/dav/calendars/<username>/

That assumption holds on simple/local installs where loginName == UID, but breaks on LDAP and external-OIDC deployments where they can diverge. For example:

  • Login Flow v2 returns loginName = alice
  • OCS /cloud/user reports id = alice_1234 (LDAP-suffixed canonical UID)
  • App-password BasicAuth succeeds, authenticating as alice
  • DAV paths are built from alice, missing the real data at /files/alice_1234/

If a separate local user named alice exists, PROPFIND /files/alice/ can return 207 with empty or stale content. That creates silent wrong-path behavior rather than a clean 404. All three DAV clients are affected: WebDAV files, CardDAV contacts, and CalDAV calendars/tasks.

Fix

WebDAV + CardDAV (BaseNextcloudClient)

Add _ensure_principal_id() to client/base.py: a lazy PROPFIND /remote.php/dav/ requesting <d:current-user-principal/> (RFC 5397). On success, the last href segment is unquote()-decoded and cached as _principal_id. On failure (transport error, parse error, missing/empty href), a warning is logged and the method returns without caching, so the next call can retry.

_get_webdav_base_path() and _get_carddav_base_path() now call _principal_or_username() (discovered id or self.username fallback). Public DAV methods in webdav.py and contacts.py call await self._ensure_principal_id() before building paths.

CalDAV (CalendarClient)

CalendarClient uses the caldav library rather than raw PROPFIND. A new _ensure_calendar_home() method discovers the calendar home via principal().get_property(CalendarHomeSet) (RFC 4791, preferred), falls back to the calendar_home_set attribute, and falls back further to deriving the principal id from the principal URL. The result is cached in _calendar_home_url. Calendar and task methods that construct URLs guard with await self._ensure_calendar_home().

Safety net

The existing self.username remains the fallback at every step. When UID == loginName, discovery returns the same segment and paths are unchanged. If discovery is unavailable, existing username-based behavior is preserved.

Tests

The divergent-principal case cannot be reached in the integration suite because local Nextcloud users always have matching UID and loginName. New mocked unit tests in tests/unit/client/test_dav_principal_discovery.py cover:

  • WebDAV / CardDAV divergent principal: discovery succeeds -> paths use discovered UID, not configured username
  • Equal principal regression guard: UID == username -> paths unchanged
  • Discovery failure -> fallback: transport error -> self.username path, no exception raised
  • No-href / empty-href response: warning logged, no caching, fallback, retry on next call
  • Caching: _ensure_principal_id() issues at most one PROPFIND per client instance
  • URL decoding: percent-encoded principal hrefs (for example alice%40example.com) are decoded correctly
  • CalDAV home-set preference: CalendarHomeSet discovery is preferred over deriving from the principal URL
  • CalDAV async-safe lookup: get_property(CalendarHomeSet) is awaited correctly whether the caldav version returns a coroutine or a plain value
  • CalDAV create/delete/events: all use the discovered home URL
  • CalDAV discovery retry: a failed discovery does not permanently cache the fallback; the next call retries

Existing path-helper unit tests that build clients and call public methods are updated to pre-seed _principal_discovered = True, preserving existing _make_request call-order assertions.

The integration suite serves as the regression guard for the equal-case local-user behavior.

Relationship to PR #638

PR #638 introduced the same principal-discovery idea but bundles additional independent changes: CardDAV server-side query/limit, RU->LAT transliteration, resilient vCard BDAY parsing, and OCS sharing path normalization. This PR extracts only the DAV path identity fix as a standalone, focused change.

It also corrects a bug present in #638's implementation: unquote() was not applied to the extracted principal id, producing wrong paths for UIDs containing percent-encoded characters, such as OIDC sub-claims with @ or +.

The other changes from #638 are out of scope here and can be proposed as separate PRs.

Out of scope

  • CardDAV server-side query/limit, transliteration, vCard BDAY parsing, and OCS sharing normalization
  • Login Flow identity-layer storage, such as persisting canonical UID separately from loginName in context.py or the app-password store

The discovery approach fixes the DAV-path issue at the client layer for all deployment modes without a schema migration.

Validation

  • Pre-push review completed
  • uv run ruff check
  • uv run ruff format --check
  • uv run ty check -- nextcloud_mcp_server
  • uv run pytest tests/unit/ -q -> 1908 passed, 1 skipped, 2 warnings
  • Integration lane (NC32 single-user) -> 257 passed, 20 skipped, 2201 deselected, 1 xfailed
  • Integration lane (NC33 single-user) -> 257 passed, 20 skipped, 2201 deselected, 1 xfailed
  • Integration lane (NC32 multi-user-basic) -> 9 passed, 1 skipped, 2479 deselected, 4 warnings

Known xfail: tests/integration/test_mail_greenmail.py::test_send_message_via_outbox, a Mail/GreenMail outbox environment limitation unrelated to DAV principal discovery.

@CLAassistant

CLAassistant commented Jun 29, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

cbcoutinho added a commit that referenced this pull request Jul 1, 2026
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 added a commit that referenced this pull request Jul 1, 2026
test(ldap): reproduce GH #980 divergent-principal DAV bug via OpenLDAP
dismantl and others added 2 commits July 2, 2026 00:48
- discover and cache DAV principal ids for WebDAV and CardDAV paths

- resolve CalDAV calendar homes through async-safe principal lookup

- cover divergent principal, fallback, retry, caching, and encoded href cases
… path

Rebased onto master (which now carries the OpenLDAP cbcoutinho#980 reproduction lane from
cbcoutinho#997). With this PR's BaseNextcloudClient._ensure_principal_id fix present, the
divergent-principal WebDAV round-trip lands in alice's real home, so the test
passes — remove the xfail(strict=True) marker and reword the docstring from
"reproduction (xfails until the fix)" to a passing regression guard.

Verified locally: `pytest -m ldap` → 1 passed with the fix (was 1 xfailed on
master without it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cbcoutinho cbcoutinho force-pushed the codex/dav-principal-discovery branch from e4d2ea2 to 462549e Compare July 1, 2026 22:53
cbcoutinho added a commit to dismantl/nextcloud-mcp-server that referenced this pull request Jul 1, 2026
… via Login 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 cbcoutinho#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 cbcoutinho#489 (board 12).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
…sword

The quality gate fails with "C Security Rating on New Code" from three
python:S2068 findings (hardcoded "app-pw" passed to CalendarClient in the DAV
principal-discovery unit tests). Hoist the dev-only literal to a single
`_APP_PW` constant annotated `# NOSONAR(S2068)` so the suppression anchors to
the literal and ruff won't wrap it, matching the repo's convention for
test-only credentials.

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

sonarqubecloud Bot commented Jul 1, 2026

Copy link
Copy Markdown

@cbcoutinho

Copy link
Copy Markdown
Owner

Thanks for your contribution! I was able to reproduce the issue in a separate PR, and then once that was merged rebased your work onto the latest master - your fix resolves the issue and is safe to merge. Regressions will be handled by the integration tests in the future.

@cbcoutinho cbcoutinho merged commit 7ef8052 into cbcoutinho:master Jul 1, 2026
19 of 20 checks passed
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.

3 participants