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
+1 -1
View File
@@ -241,7 +241,7 @@ async def provision_app_password(request: Request) -> JSONResponse:
# Parse optional scopes and the Nextcloud loginName from the request body
# up front. Nextcloud authenticates app passwords against the *loginName*,
# which can differ from the UID — e.g. OIDC-provisioned users whose UID is
# their display name (UID "Chris Coutinho", loginName "chris@coutinho.io").
# their display name (UID "Ada Lovelace", loginName "ada@example.com").
# Use the loginName for the BasicAuth validation below, falling back to the
# path user_id for legacy callers that don't send one (where UID ==
# loginName).
+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(
+16 -1
View File
@@ -102,10 +102,21 @@ class NextcloudClient:
username: str,
auth: Auth | None = None,
*,
auth_username: str | None = None,
password: str | None = None,
token: str | None = None,
):
# ``username`` is the Nextcloud UID — it drives DAV/API path
# construction (e.g. ``/remote.php/dav/files/<uid>/``). ``auth_username``
# is the credential identity Nextcloud authenticates the app password
# against (the loginName), which differs from the UID for
# OIDC-provisioned users. Defaults to ``username`` so single-user and
# OAuth modes (where UID == loginName) are unchanged. Callers pass the
# matching ``auth=BasicAuth(auth_username, ...)`` for the httpx leg;
# ``auth_username`` is threaded to the CalDAV client, which builds its
# own auth object from the raw credential.
self.username = username
auth_username = auth_username or username
self._client = AsyncClient(
base_url=base_url,
auth=auth,
@@ -122,7 +133,11 @@ class NextcloudClient:
# its preferred backend in v3.x) builds a backend-compatible auth object
# itself — passing httpx.BasicAuth here breaks under niquests (#731).
self.calendar = CalendarClient(
base_url, username, password=password, token=token
base_url,
username,
auth_username=auth_username,
password=password,
token=token,
)
self.contacts = ContactsClient(self._client, username)
self.cookbook = CookbookClient(self._client, username)
+11 -2
View File
@@ -42,6 +42,7 @@ class CalendarClient:
base_url: str,
username: str,
*,
auth_username: str | None = None,
password: str | None = None,
token: str | None = None,
):
@@ -55,7 +56,10 @@ class CalendarClient:
Args:
base_url: Nextcloud base URL
username: Nextcloud username
username: Nextcloud username (UID) — used for DAV path construction
auth_username: Credential identity (loginName) the app password
authenticates against; defaults to ``username``. Differs from
the UID for OIDC-provisioned users.
password: App password / login password — selects ``auth_type="basic"``
token: OAuth bearer token — selects ``auth_type="bearer"``
@@ -64,6 +68,11 @@ class CalendarClient:
"""
self.username = username
self.base_url = base_url
# The UID (``username``) drives DAV path construction; the loginName
# (``auth_username``) is the credential the app password authenticates
# against. They differ for OIDC-provisioned users. Defaults to the UID
# so existing single-user / OAuth callers are unchanged.
auth_username = auth_username or username
auth_kwargs: dict[str, Any] = {}
if password is not None:
@@ -74,7 +83,7 @@ class CalendarClient:
# AsyncDAVClient needs the full base URL for proper URL construction
self._dav_client = AsyncDAVClient(
url=f"{base_url}/remote.php/dav/",
username=username,
username=auth_username,
ssl_verify_cert=get_nextcloud_ssl_verify(), # type: ignore[arg-type] # caldav types say bool|str but passes through to niquests which accepts SSLContext
**auth_kwargs,
)
+19 -5
View File
@@ -191,13 +191,27 @@ async def _get_client_from_login_flow(
"Call nc_auth_provision_access to complete Login Flow."
)
username = app_data.get("username") or user_id
# Authenticate with the stored Nextcloud loginName, but build DAV/API paths
# with ``user_id`` — the identity the rest of the system is keyed on (the
# app-password store, vector payloads, scopes, and the background-sync
# ``get_user_client_basic_auth`` all use it). For OIDC-provisioned users the
# loginName (e.g. an email) differs from this identity, and Nextcloud
# authenticates app passwords against the loginName. Falls back to
# ``user_id`` for legacy rows stored without a loginName.
login_name = app_data.get("username") or user_id
app_password = app_data["app_password"]
logger.debug("Creating Login Flow v2 client for %s as %s", nextcloud_host, username)
logger.debug(
"Creating Login Flow v2 client for %s (id=%s, login=%s)",
nextcloud_host,
user_id,
login_name,
)
return NextcloudClient(
base_url=nextcloud_host,
username=username,
auth=BasicAuth(username, app_data["app_password"]),
password=app_data["app_password"],
username=user_id,
auth_username=login_name,
auth=BasicAuth(login_name, app_password),
password=app_password,
)
+12 -4
View File
@@ -114,20 +114,28 @@ async def get_user_client_basic_auth(
if storage is None:
storage = await _get_initialized_basic_auth_storage()
# Retrieve app password from local storage
app_password = await storage.get_app_password(user_id)
# Retrieve app password (and the stored Nextcloud loginName) from local
# storage. Nextcloud authenticates app passwords against the loginName,
# which differs from the UID for OIDC-provisioned users; authenticate as
# the loginName while keeping the UID for DAV/API path construction. Falls
# back to the UID for legacy rows stored without a loginName.
app_data = await storage.get_app_password_with_scopes(user_id)
if not app_password:
if not app_data:
raise NotProvisionedError(
f"User {user_id} has not provisioned an app password. "
f"User must configure background sync in Astrolabe personal settings."
)
app_password = app_data["app_password"]
login_name = app_data.get("username") or user_id
logger.info("Using app password for background sync: %s", user_id)
return NextcloudClient(
base_url=nextcloud_host,
username=user_id,
auth=BasicAuth(user_id, app_password),
auth_username=login_name,
auth=BasicAuth(login_name, app_password),
password=app_password,
)