fix(webhooks): use OCS v2 capabilities for /api/v1/apps

The Astrolabe webhooks UI hits /api/v1/apps on the MCP server, which
forwarded the OAuth bearer token to /ocs/v1.php/cloud/apps?filter=enabled.
That OCS endpoint is admin-only AND @PasswordConfirmationRequired —
neither requirement is satisfiable via an OAuth bearer token, so even an
admin user's token returns a silent 401 (no entry in nextcloud.log).

Switch to /ocs/v2.php/cloud/capabilities, which has no admin or password-
confirmation gate, accepts the existing bearer token, and returns a
capabilities map keyed by app id (notes, files, tables, forms, etc.).
This is sufficient for the webhook presets UI to gate available presets
against the running Nextcloud instance's enabled apps.

Bearer is preserved on the outbound call because anonymous capabilities
omits notes/tables/forms — only authenticated capabilities exposes them.

Tests:
- New unit test covers the regression (asserts /ocs/v2.php/cloud/capabilities
  is hit, NOT /cloud/apps), response parsing, sanitized error messages,
  and missing-config paths.
- New integration test under tests/server/login_flow/ drives a real
  OAuth flow against mcp-login-flow with a static OIDC client
  (nextcloudMcpServerUIPublicClient) and asserts /api/v1/apps returns 200
  with core/files in the response.

docker-compose.yml: aligns mcp-login-flow's ALLOWED_MGMT_CLIENT with
mcp-multi-user-basic so the same static-client test fixture works for both.

Follow-up to homelab-argocd #1608, which set ALLOWED_MGMT_CLIENT in
production but didn't unblock the webhooks flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-03 22:26:35 +02:00
co-authored by Claude Opus 4.7
parent e2b9258dec
commit 148ab8c117
5 changed files with 433 additions and 11 deletions
+197
View File
@@ -933,3 +933,200 @@ async def diana_login_flow_mcp_client(
password=test_users_setup["diana"]["password"],
):
yield session
# Static OIDC client used by the management API integration tests.
# Matches the value `mcp-login-flow` and `mcp-multi-user-basic` allowlist
# (`ALLOWED_MGMT_CLIENT=nextcloudMcpServerUIPublicClient`) so tokens issued
# to it pass the management API allowlist check.
STATIC_MGMT_CLIENT_ID = "nextcloudMcpServerUIPublicClient"
@pytest.fixture(scope="session")
async def login_flow_static_client_credentials(anyio_backend, oauth_callback_server):
"""Pre-create the static OIDC client `nextcloudMcpServerUIPublicClient`
via `occ oidc:create` with the test's OAuth callback URL.
The static client_id is allowlisted on `mcp-login-flow` (and
`mcp-multi-user-basic`) via `ALLOWED_MGMT_CLIENT`, so tokens it issues
pass the management API allowlist check. Uses a confidential JWT-token
client to match production Astrolabe configuration.
Yields: (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
"""
import json
import subprocess
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
pytest.skip("Static client tests require NEXTCLOUD_HOST")
auth_states, callback_url = oauth_callback_server
client_id = STATIC_MGMT_CLIENT_ID
# Idempotent: remove if a previous session left one behind
subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"app",
"php",
"/var/www/html/occ",
"oidc:remove",
client_id,
],
check=False,
capture_output=True,
)
logger.info(f"Creating static OIDC client {client_id} with callback {callback_url}")
result = subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"app",
"php",
"/var/www/html/occ",
"oidc:create",
"Login Flow Static Client (test)",
callback_url,
"--client_id",
client_id,
"--type",
"confidential",
"--flow",
"code",
"--token_type",
"jwt",
"--resource_url",
LOGIN_FLOW_MCP_BASE_URL,
"--allowed_scopes",
DEFAULT_FULL_SCOPES,
],
check=True,
capture_output=True,
text=True,
)
try:
client_output = json.loads(result.stdout.strip())
except json.JSONDecodeError as e:
raise RuntimeError(
f"occ oidc:create returned non-JSON output: {result.stdout[:200]!r}"
) from e
client_secret = client_output.get("client_secret")
if not client_secret:
raise ValueError("occ oidc:create did not return client_secret in JSON output")
async with httpx.AsyncClient(timeout=30.0) as http_client:
discovery_response = await http_client.get(
f"{nextcloud_host}/.well-known/openid-configuration"
)
discovery_response.raise_for_status()
oidc_config = discovery_response.json()
yield (
client_id,
client_secret,
callback_url,
oidc_config["token_endpoint"],
oidc_config["authorization_endpoint"],
)
subprocess.run(
[
"docker",
"compose",
"exec",
"-T",
"app",
"php",
"/var/www/html/occ",
"oidc:remove",
client_id,
],
check=False,
capture_output=True,
)
@pytest.fixture(scope="session")
async def login_flow_static_client_token(
anyio_backend,
browser,
login_flow_static_client_credentials,
oauth_callback_server,
) -> str:
"""Drive the OAuth auth-code flow using the static OIDC client and
return the raw access_token string.
Mirrors `login_flow_oauth_token` but feeds it static credentials instead
of a DCR-generated client. Required for hitting management API
endpoints which gate on `ALLOWED_MGMT_CLIENT`.
"""
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
username = os.getenv("NEXTCLOUD_USERNAME")
password = os.getenv("NEXTCLOUD_PASSWORD")
if not all([nextcloud_host, username, password]):
pytest.skip(
"Static client OAuth requires NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD"
)
auth_states, _ = oauth_callback_server
client_id, client_secret, callback_url, token_endpoint, authorization_endpoint = (
login_flow_static_client_credentials
)
state = secrets.token_urlsafe(32)
scopes_encoded = quote(DEFAULT_FULL_SCOPES, safe="")
auth_url = (
f"{authorization_endpoint}?"
f"response_type=code&"
f"client_id={client_id}&"
f"redirect_uri={quote(callback_url, safe='')}&"
f"state={state}&"
f"scope={scopes_encoded}"
)
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
if "/login" in page.url or "/index.php/login" in page.url:
await page.wait_for_selector('input[name="user"]', timeout=10000)
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
await page.click('button[type="submit"]')
await page.wait_for_load_state("networkidle", timeout=60000)
try:
await _handle_oauth_consent_screen(page, username)
except Exception:
pass
start = time.time()
while state not in auth_states:
if time.time() - start > 30:
raise TimeoutError("Timeout waiting for OAuth callback")
await anyio.sleep(0.5)
auth_code = auth_states[state]
finally:
await context.close()
async with httpx.AsyncClient(timeout=30.0) as http_client:
token_response = await http_client.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": callback_url,
"client_id": client_id,
"client_secret": client_secret,
},
)
token_response.raise_for_status()
token_data = token_response.json()
return token_data["access_token"]
@@ -0,0 +1,52 @@
"""Integration tests for the management API on the login-flow MCP server.
These tests drive a real OAuth flow against Nextcloud's `oidc` app using the
static `nextcloudMcpServerUIPublicClient` client (which is allowlisted on the
`mcp-login-flow` container via `ALLOWED_MGMT_CLIENT`), then hit the
management API endpoints with the resulting bearer token.
Regression coverage for the bug where /api/v1/apps proxied to OCS v1
/cloud/apps and always 401'd. The handler now uses /ocs/v2.php/cloud/capabilities,
which is reachable for OAuth bearer tokens.
"""
import httpx
import pytest
LOGIN_FLOW_API_BASE_URL = "http://localhost:8004"
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def test_get_installed_apps_returns_capability_keys(
login_flow_static_client_token: str,
):
"""GET /api/v1/apps returns 200 with a list of enabled-app capability keys."""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"{LOGIN_FLOW_API_BASE_URL}/api/v1/apps",
headers={"Authorization": f"Bearer {login_flow_static_client_token}"},
)
assert response.status_code == 200, (
f"/api/v1/apps returned {response.status_code}: {response.text}"
)
data = response.json()
assert "apps" in data
assert isinstance(data["apps"], list)
# Anonymous capabilities always exposes core; authenticated also exposes
# files. Both should be present whether or not the oidc app's
# BearerAuthMiddleware ran for this OCS route.
apps = data["apps"]
assert "core" in apps, f"expected 'core' in apps, got {apps}"
assert "files" in apps, f"expected 'files' in apps, got {apps}"
async def test_get_installed_apps_requires_bearer_token():
"""No Authorization header → 401 (handler's token validator rejects it)."""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(f"{LOGIN_FLOW_API_BASE_URL}/api/v1/apps")
assert response.status_code == 401