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>
This commit is contained in:
Chris Coutinho
2026-05-03 23:50:59 +02:00
co-authored by Claude Opus 4.7
parent beef77d785
commit a0e484d95b
4 changed files with 457 additions and 73 deletions
+44 -24
View File
@@ -1,21 +1,23 @@
"""
Unit tests for the Management API /api/v1/apps endpoint.
These tests cover the regression where /api/v1/apps proxied to
/ocs/v1.php/cloud/apps — an admin-only and @PasswordConfirmationRequired
endpoint that always 401s for OAuth bearer tokens. The handler now uses
/ocs/v2.php/cloud/capabilities, which accepts the bearer and returns an
authenticated capability map keyed by app id.
The handler hits ``/ocs/v2.php/cloud/capabilities`` (not the legacy admin-only
``/ocs/v1.php/cloud/apps`` which was ``@PasswordConfirmationRequired``) and
authenticates via the user's stored Login Flow v2 app password using HTTP
Basic Auth. The OAuth bearer is **never** forwarded to Nextcloud (see
``docs/login-flow-v2.md`` and ADR-022).
"""
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from nextcloud_mcp_server.api.webhooks import get_installed_apps
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
pytestmark = pytest.mark.unit
@@ -33,8 +35,19 @@ def _patch_token_validation(mocker, user_id: str = "admin") -> None:
)
def _patch_outbound_client(mocker, response: MagicMock) -> AsyncMock:
"""Patch the outbound httpx client; return the mocked .get() AsyncMock."""
def _patch_basic_auth(
mocker, username: str = "admin", app_password: str = "stored-app-pwd"
) -> AsyncMock:
"""Patch get_basic_auth_for_user to return canned credentials."""
return mocker.patch(
"nextcloud_mcp_server.api.webhooks.get_basic_auth_for_user",
new=AsyncMock(return_value=(username, app_password)),
)
def _patch_outbound_client(mocker, response: MagicMock) -> MagicMock:
"""Patch the outbound httpx client; return the factory MagicMock so tests
can introspect the kwargs (esp. ``auth=``) it was called with."""
mock_get = AsyncMock(return_value=response)
mock_client = AsyncMock()
mock_client.get = mock_get
@@ -48,7 +61,6 @@ def _patch_outbound_client(mocker, response: MagicMock) -> AsyncMock:
mocker.patch(
"nextcloud_mcp_server.api.webhooks.nextcloud_httpx_client", mock_factory
)
# Return both so tests can assert on factory kwargs and call args
mock_factory.attach_mock(mock_get, "get")
return mock_factory
@@ -63,6 +75,7 @@ def _capabilities_response(capabilities: dict[str, dict]) -> MagicMock:
async def test_returns_sorted_capability_keys(mocker):
"""Happy path: handler hits OCS v2 capabilities and returns sorted app keys."""
_patch_token_validation(mocker)
_patch_basic_auth(mocker)
response = _capabilities_response(
{
"notes": {"api_version": "1.4"},
@@ -82,22 +95,24 @@ async def test_returns_sorted_capability_keys(mocker):
assert http_response.status_code == 200
assert http_response.json() == {"apps": ["core", "files", "notes", "tables"]}
# Regression check: outbound URL is OCS v2 capabilities, NOT v1 cloud/apps.
# /ocs/v1.php/cloud/apps was admin-only + @PasswordConfirmationRequired
# which always 401s for OAuth bearer tokens — that was the bug.
# Outbound URL is OCS v2 capabilities, NOT v1 cloud/apps.
factory.get.assert_awaited_once()
called_path = factory.get.call_args.args[0]
assert called_path == "/ocs/v2.php/cloud/capabilities"
assert "/cloud/apps" not in called_path
# Bearer token forwarded so authenticated capabilities are returned
factory_call_kwargs = factory.call_args.kwargs
assert factory_call_kwargs["headers"] == {"Authorization": "Bearer test-token"}
# Outbound auth is BasicAuth (NOT Bearer) — the OAuth token must not be
# forwarded to Nextcloud per ADR-022 / docs/login-flow-v2.md.
factory_kwargs = factory.call_args.kwargs
assert "headers" not in factory_kwargs or "Authorization" not in (
factory_kwargs.get("headers") or {}
)
assert isinstance(factory_kwargs["auth"], httpx.BasicAuth)
async def test_empty_capabilities_returns_empty_list(mocker):
"""Empty capabilities map → empty apps list, not an error."""
_patch_token_validation(mocker)
_patch_basic_auth(mocker)
response = _capabilities_response({})
_patch_outbound_client(mocker, response)
@@ -114,6 +129,7 @@ async def test_empty_capabilities_returns_empty_list(mocker):
async def test_ocs_error_returns_500_with_sanitized_message(mocker):
"""Non-200 from Nextcloud OCS surfaces as a generic 500 to the caller."""
_patch_token_validation(mocker)
_patch_basic_auth(mocker)
error_response = MagicMock()
error_response.status_code = 503
_patch_outbound_client(mocker, error_response)
@@ -135,6 +151,7 @@ async def test_ocs_error_returns_500_with_sanitized_message(mocker):
async def test_missing_nextcloud_host_returns_500(mocker):
"""Misconfigured oauth_context (no nextcloud_host) surfaces as 500."""
_patch_token_validation(mocker)
_patch_basic_auth(mocker)
response = _capabilities_response({})
_patch_outbound_client(mocker, response)
@@ -149,17 +166,20 @@ async def test_missing_nextcloud_host_returns_500(mocker):
assert http_response.status_code == 500
async def test_missing_authorization_returns_500(mocker):
"""When token validation passes but Authorization header is absent, the
handler can't construct the outbound bearer header — returns 500 with a
sanitized message."""
async def test_unprovisioned_user_returns_412(mocker):
"""Users without a stored app password get HTTP 412 so the client can
surface a 'complete Login Flow v2' UX rather than a generic 500."""
_patch_token_validation(mocker)
response = _capabilities_response({})
_patch_outbound_client(mocker, response)
mocker.patch(
"nextcloud_mcp_server.api.webhooks.get_basic_auth_for_user",
new=AsyncMock(side_effect=ProvisioningRequiredError("not provisioned")),
)
app = _build_test_app()
client = TestClient(app)
# No Authorization header
http_response = client.get("/api/v1/apps")
http_response = client.get(
"/api/v1/apps", headers={"Authorization": "Bearer test-token"}
)
assert http_response.status_code == 500
assert http_response.status_code == 412
assert http_response.json()["error"] == "Provisioning required"
+314
View File
@@ -0,0 +1,314 @@
"""Unit tests asserting the webhook API endpoints authenticate to Nextcloud
with an app-password BasicAuth credential — never by forwarding the inbound
OAuth bearer token.
Per ADR-022 / ``docs/login-flow-v2.md`` the data leg from MCP server to
Nextcloud always uses HTTP Basic Auth with a per-user app password obtained
via Login Flow v2. Forwarding OAuth bearers to Nextcloud was the obsolete
pre-ADR-022 pattern; it relies on upstream user_oidc patches that were never
merged and is incompatible with admin endpoints gated by
``@PasswordConfirmationRequired`` (e.g. ``webhook_listeners``).
"""
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from nextcloud_mcp_server.api.webhooks import (
create_webhook,
delete_webhook,
list_webhooks,
)
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
pytestmark = pytest.mark.unit
def _build_test_app() -> Starlette:
app = Starlette(
routes=[
Route("/api/v1/webhooks", list_webhooks, methods=["GET"]),
Route("/api/v1/webhooks", create_webhook, methods=["POST"]),
Route("/api/v1/webhooks/{webhook_id}", delete_webhook, methods=["DELETE"]),
]
)
app.state.oauth_context = {"config": {"nextcloud_host": "http://nc.test"}}
return app
def _patch_token_validation(mocker, user_id: str = "admin") -> None:
mocker.patch(
"nextcloud_mcp_server.api.webhooks.validate_token_and_get_user",
new=AsyncMock(return_value=(user_id, {"sub": user_id})),
)
def _patch_basic_auth(
mocker, username: str = "admin", app_password: str = "stored-app-pwd"
) -> AsyncMock:
return mocker.patch(
"nextcloud_mcp_server.api.webhooks.get_basic_auth_for_user",
new=AsyncMock(return_value=(username, app_password)),
)
def _patch_webhooks_client(mocker, **methods) -> MagicMock:
"""Replace WebhooksClient with a stub. ``methods`` maps method name →
return value for AsyncMock(side_effect/return_value)."""
instance = MagicMock()
for name, value in methods.items():
setattr(instance, name, AsyncMock(return_value=value))
cls = MagicMock(return_value=instance)
mocker.patch("nextcloud_mcp_server.api.webhooks.WebhooksClient", cls)
return cls
def _patch_outbound_client_factory(mocker) -> MagicMock:
"""Patch nextcloud_httpx_client so we can assert on the kwargs (esp.
``auth=``) the handler called it with."""
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
factory = MagicMock(return_value=mock_client)
mocker.patch("nextcloud_mcp_server.api.webhooks.nextcloud_httpx_client", factory)
return factory
def _assert_basic_auth_not_bearer(factory: MagicMock) -> None:
"""Outbound httpx kwargs must use ``auth=BasicAuth(...)`` and NOT a
Bearer ``Authorization`` header."""
factory.assert_called_once()
kwargs = factory.call_args.kwargs
assert isinstance(kwargs["auth"], httpx.BasicAuth), (
"Outbound NC request must use BasicAuth, not bearer-header forwarding"
)
headers = kwargs.get("headers") or {}
assert "Authorization" not in headers, (
"Outbound NC request must NOT carry an Authorization header — "
"the OAuth bearer must never be forwarded to Nextcloud"
)
# ---------------------------------------------------------------------------
# list_webhooks
# ---------------------------------------------------------------------------
async def test_list_webhooks_uses_basic_auth(mocker):
_patch_token_validation(mocker)
_patch_basic_auth(mocker, username="alice", app_password="alice-pwd")
factory = _patch_outbound_client_factory(mocker)
_patch_webhooks_client(
mocker, list_webhooks=[{"id": 1, "event": "test", "uri": "http://x"}]
)
client = TestClient(_build_test_app())
resp = client.get("/api/v1/webhooks", headers={"Authorization": "Bearer mcp-token"})
assert resp.status_code == 200
assert resp.json() == {"webhooks": [{"id": 1, "event": "test", "uri": "http://x"}]}
_assert_basic_auth_not_bearer(factory)
async def test_list_webhooks_returns_412_when_unprovisioned(mocker):
_patch_token_validation(mocker)
mocker.patch(
"nextcloud_mcp_server.api.webhooks.get_basic_auth_for_user",
new=AsyncMock(side_effect=ProvisioningRequiredError("not provisioned")),
)
client = TestClient(_build_test_app())
resp = client.get("/api/v1/webhooks", headers={"Authorization": "Bearer mcp-token"})
assert resp.status_code == 412
assert resp.json()["error"] == "Provisioning required"
# ---------------------------------------------------------------------------
# create_webhook
# ---------------------------------------------------------------------------
async def test_create_webhook_uses_basic_auth(mocker):
_patch_token_validation(mocker)
_patch_basic_auth(mocker, username="bob", app_password="bob-pwd")
factory = _patch_outbound_client_factory(mocker)
_patch_webhooks_client(
mocker,
create_webhook={"id": 42, "event": "OCP\\Events\\NodeCreated"},
)
mocker.patch(
"nextcloud_mcp_server.api.webhooks.webhook_auth_pair",
return_value=("none", None),
)
client = TestClient(_build_test_app())
resp = client.post(
"/api/v1/webhooks",
headers={"Authorization": "Bearer mcp-token"},
json={
"event": "OCP\\Events\\NodeCreated",
"uri": "http://mcp:8000/webhooks/nextcloud",
},
)
assert resp.status_code == 200
assert resp.json() == {"webhook": {"id": 42, "event": "OCP\\Events\\NodeCreated"}}
_assert_basic_auth_not_bearer(factory)
async def test_create_webhook_validates_required_fields(mocker):
_patch_token_validation(mocker)
_patch_basic_auth(mocker)
client = TestClient(_build_test_app())
resp = client.post(
"/api/v1/webhooks",
headers={"Authorization": "Bearer mcp-token"},
json={"event": "X"}, # missing uri
)
assert resp.status_code == 400
async def test_create_webhook_returns_412_when_unprovisioned(mocker):
_patch_token_validation(mocker)
mocker.patch(
"nextcloud_mcp_server.api.webhooks.get_basic_auth_for_user",
new=AsyncMock(side_effect=ProvisioningRequiredError("not provisioned")),
)
client = TestClient(_build_test_app())
resp = client.post(
"/api/v1/webhooks",
headers={"Authorization": "Bearer mcp-token"},
json={"event": "X", "uri": "http://x"},
)
assert resp.status_code == 412
assert resp.json()["error"] == "Provisioning required"
# ---------------------------------------------------------------------------
# delete_webhook
# ---------------------------------------------------------------------------
async def test_delete_webhook_uses_basic_auth(mocker):
_patch_token_validation(mocker)
_patch_basic_auth(mocker, username="carol", app_password="carol-pwd")
factory = _patch_outbound_client_factory(mocker)
_patch_webhooks_client(mocker, delete_webhook=None)
client = TestClient(_build_test_app())
resp = client.delete(
"/api/v1/webhooks/99", headers={"Authorization": "Bearer mcp-token"}
)
assert resp.status_code == 200
assert resp.json() == {"success": True, "message": "Webhook deleted"}
_assert_basic_auth_not_bearer(factory)
async def test_delete_webhook_rejects_non_integer_id(mocker):
_patch_token_validation(mocker)
_patch_basic_auth(mocker)
client = TestClient(_build_test_app())
resp = client.delete(
"/api/v1/webhooks/notanumber",
headers={"Authorization": "Bearer mcp-token"},
)
assert resp.status_code == 400
async def test_delete_webhook_returns_412_when_unprovisioned(mocker):
_patch_token_validation(mocker)
mocker.patch(
"nextcloud_mcp_server.api.webhooks.get_basic_auth_for_user",
new=AsyncMock(side_effect=ProvisioningRequiredError("not provisioned")),
)
client = TestClient(_build_test_app())
resp = client.delete(
"/api/v1/webhooks/99",
headers={"Authorization": "Bearer mcp-token"},
)
assert resp.status_code == 412
assert resp.json()["error"] == "Provisioning required"
# ---------------------------------------------------------------------------
# get_basic_auth_for_user helper
# ---------------------------------------------------------------------------
async def test_get_basic_auth_for_user_resolves_username_and_password(mocker):
"""Helper returns the stored Nextcloud username (not the OAuth user_id)
when one was recorded at provisioning time."""
from nextcloud_mcp_server.api._auth import get_basic_auth_for_user
storage = MagicMock()
storage.get_app_password_with_scopes = AsyncMock(
return_value={
"app_password": "encrypted-then-decrypted-pwd",
"scopes": ["notes.read"],
"username": "nc-username",
"created_at": "2026-01-01",
"updated_at": "2026-01-02",
}
)
mocker.patch(
"nextcloud_mcp_server.api._auth.get_shared_storage",
new=AsyncMock(return_value=storage),
)
username, password = await get_basic_auth_for_user("idp-user-id")
assert username == "nc-username"
assert password == "encrypted-then-decrypted-pwd"
async def test_get_basic_auth_for_user_falls_back_to_user_id(mocker):
"""When ``username`` is null in storage, the helper falls back to the
OAuth-issued user_id."""
from nextcloud_mcp_server.api._auth import get_basic_auth_for_user
storage = MagicMock()
storage.get_app_password_with_scopes = AsyncMock(
return_value={
"app_password": "pwd",
"scopes": None,
"username": None,
"created_at": "2026-01-01",
"updated_at": "2026-01-01",
}
)
mocker.patch(
"nextcloud_mcp_server.api._auth.get_shared_storage",
new=AsyncMock(return_value=storage),
)
username, _ = await get_basic_auth_for_user("admin")
assert username == "admin"
async def test_get_basic_auth_for_user_raises_when_unprovisioned(mocker):
from nextcloud_mcp_server.api._auth import get_basic_auth_for_user
storage = MagicMock()
storage.get_app_password_with_scopes = AsyncMock(return_value=None)
mocker.patch(
"nextcloud_mcp_server.api._auth.get_shared_storage",
new=AsyncMock(return_value=storage),
)
with pytest.raises(ProvisioningRequiredError):
await get_basic_auth_for_user("admin")