Files
mcp-nextcloud/nextcloud_mcp_server/api/_auth.py
T
Chris CoutinhoandClaude Opus 4.7 a0e484d95b fix(webhooks): use app-password basic auth for NC API calls
The webhook API endpoints in api/webhooks.py forwarded the inbound MCP
OAuth bearer token directly to Nextcloud as the Authorization header.
Per ADR-022 / docs/login-flow-v2.md the data leg from MCP server to
Nextcloud must use HTTP Basic Auth with the user's stored Login Flow v2
app password — bearer-forwarding requires upstream user_oidc patches that
were never merged and is incompatible with admin endpoints gated by
@PasswordConfirmationRequired (e.g. webhook_listeners/api/v1/webhooks,
which 401s).

PR #760 papered over the symptom for /api/v1/apps by switching to the
permissive /cloud/capabilities endpoint, but the same architectural
mistake remained on list_webhooks / create_webhook / delete_webhook,
which still 500'd on the astrolabe admin UI's preset page.

Changes:
- New helper api/_auth.py:get_basic_auth_for_user(user_id) reads the
  user's app password from encrypted storage and returns
  (username, app_password). Mirrors context.py:_get_client_from_login_flow
  but is callable from Starlette routes (no MCP Context required).
- All four endpoints in api/webhooks.py now use httpx.BasicAuth instead
  of forwarding the OAuth bearer; ProvisioningRequiredError is mapped to
  HTTP 412 so callers can render a "complete provisioning" CTA rather
  than receiving an opaque 500.
- Outbound NC requests now identify the user by the username recorded at
  Login Flow v2 provisioning time (which may differ from the IdP-issued
  user_id) — flowed into WebhooksClient and used for logging.

Tests:
- tests/unit/test_management_apps_endpoint.py: assertions updated to
  verify outbound NC request uses BasicAuth and carries no Authorization
  header. Replaced "missing-Authorization → 500" test with a
  ProvisioningRequiredError → 412 case.
- tests/unit/test_webhooks_api_auth.py (new): cross-endpoint coverage
  for list_webhooks, create_webhook, delete_webhook and the new helper —
  including 412 symmetry for all four endpoints.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 23:50:59 +02:00

49 lines
2.1 KiB
Python

"""Credential resolution helpers for non-MCP-Context API endpoints.
Starlette routes (e.g. ``api/webhooks.py``) authenticate the inbound request
via OAuth bearer validation, then need to make Nextcloud API calls on behalf
of that user. Per ADR-022 / ``docs/login-flow-v2.md`` the data leg to
Nextcloud always uses **HTTP Basic Auth with the user's app password**, never
the OAuth token. This module resolves that credential pair.
The MCP tool path uses ``context.get_client(ctx)``; this is the equivalent
helper for callers that have a validated ``user_id`` but no MCP ``Context``.
"""
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
from nextcloud_mcp_server.auth.storage import get_shared_storage
async def get_basic_auth_for_user(user_id: str) -> tuple[str, str]:
"""Resolve ``(username, app_password)`` for an OAuth-validated user.
Reads the per-user app password provisioned via Login Flow v2 from
encrypted SQLite storage. The username returned is the actual Nextcloud
username recorded at provisioning time (which may differ from the IdP
user-id when an external IdP is configured).
Args:
user_id: MCP user identifier extracted from a validated OAuth token.
Returns:
Tuple ``(username, app_password)`` ready to pass to
``httpx.BasicAuth``.
Raises:
ProvisioningRequiredError: No app password is stored for ``user_id``.
The caller should surface this to the client so the user can
complete Login Flow v2 (typically via ``nc_auth_provision_access``
or the Astrolabe settings page).
"""
storage = await get_shared_storage()
app_data = await storage.get_app_password_with_scopes(user_id)
if not app_data:
raise ProvisioningRequiredError(
f"No Nextcloud app password provisioned for user {user_id!r}. "
"Complete Login Flow v2 (nc_auth_provision_access) before "
"calling this endpoint."
)
username = app_data.get("username") or user_id
return username, app_data["app_password"]