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:
co-authored by
Claude Opus 4.7
parent
beef77d785
commit
a0e484d95b
@@ -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")
|
||||
Reference in New Issue
Block a user