fix(auth): authenticate stored app passwords with loginName, not UID

Nextcloud authenticates app passwords against the *loginName*, which differs
from the UID for OIDC-provisioned users (e.g. user_oidc makes the UID the
display name: UID "Ada Lovelace", loginName "ada@example.com"). The runtime
consumers of stored app passwords bound the UID as the BasicAuth username, so
every Notes/Files/Shares/CalDAV call returned HTTP 401.

PR #818 fixed only the provisioning endpoint; the consuming paths were missed.
Observed on a login_flow tenant (NC's own OIDC app as IdP): the background-sync
scan loop never started ("Credential validation failed ... HTTP 401") and
semantic search returned 0 results because the ACL shared_with_me lookup 401'd
and degraded to a self-only owner filter.

Root cause: NextcloudClient / CalendarClient conflated two identities — the
DAV/URL path identity (the user_id the whole system keys on = NC UID) and the
auth-credential username (the loginName). Decouple them:

- Thread a keyword-only auth_username through NextcloudClient -> CalendarClient
  (defaults to username, so single-user / OAuth where UID == loginName is
  unchanged).
- get_user_client_basic_auth (background sync + the /api/v1/vector-viz/search
  endpoint) authenticates as the stored loginName, UID for paths.
- _get_client_from_login_flow (the get_client(ctx) MCP-tool path) does the same.
- cleanup_invalid_app_passwords validates with the loginName, so it no longer
  401s and wrongly deletes a valid OIDC user's password.

The loginName is already persisted in app_passwords.username and returned by
get_app_password_with_scopes. Adds unit tests covering the UID != loginName
split for both client builders, the calendar credential/path split, and the
cleanup validation. Also genericises the example user in the #818 comment/test
(real name/email -> Ada Lovelace / ada@example.com).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-31 21:34:05 +02:00
co-authored by Claude Opus 4.8
parent e7512d210c
commit 52da297ded
9 changed files with 340 additions and 25 deletions
+13 -5
View File
@@ -2130,14 +2130,22 @@ class RefreshTokenStorage:
removed: list[str] = []
async def _validate_user(user_id: str) -> None:
app_password = await self.get_app_password(user_id)
if not app_password:
return
try:
app_data = await self.get_app_password_with_scopes(user_id)
if not app_data:
return
app_password = app_data["app_password"]
# Authenticate as the stored loginName, not the UID: Nextcloud
# keys app-password auth on the loginName, which differs from
# the UID for OIDC-provisioned users. Using the UID here would
# 401 a *valid* password and wrongly delete it. Falls back to
# the UID for legacy rows without a stored loginName.
login_name = app_data.get("username") or user_id
async with httpx.AsyncClient(
base_url=nextcloud_host,
auth=httpx.BasicAuth(user_id, app_password),
auth=httpx.BasicAuth(login_name, app_password),
timeout=10.0,
) as client:
response = await client.get(