fix(auth): validate opaque access tokens via userinfo fallback

The management API (used by the Astrolabe PHP app for /api/v1/apps and
/api/v1/webhooks) only accepted JWT access tokens. Opaque tokens were
sent to Nextcloud's oidc introspection endpoint, which returns
`active: false` for tokens minted for a *different* OIDC client (e.g.
Astrolabe) even when they are live — so every call 401'd. This surfaced
on the nx101294 tenant: webhook setup failed and the webhook-preset UI
(including the Files preset) showed empty, because getWebhookPresets
errors out before its `files`-always-available filter runs.

Add a userinfo-endpoint fallback in UnifiedTokenVerifier: when
introspection reports an opaque token inactive, validate it against the
discovered userinfo_endpoint (a 200 with a `sub` proves a live bearer
regardless of issuing client). userinfo returns no client_id/scope, so
such tokens are stamped `_auth_via_userinfo` and the ALLOWED_MGMT_CLIENT
allowlist is relaxed for that path only — authorization is still
enforced per-user (token sub == requested resource owner) by every
management endpoint. JWT and introspection paths are unchanged and still
enforce the allowlist.

Also bumps the astrolabe submodule to 0.29.0 (the deployed version that
exhibits the issue).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-17 18:53:47 +02:00
co-authored by Claude Opus 4.8
parent d969526613
commit 0294a99cd4
3 changed files with 198 additions and 3 deletions
+103
View File
@@ -616,3 +616,106 @@ class TestManagementApiAllowlist:
result = await verifier.verify_token_for_management_api(token)
assert result is None
class TestUserinfoFallback:
"""Opaque tokens that introspection reports inactive fall back to userinfo.
Covers the nx101294 case: the Astrolabe OIDC client issues opaque access
tokens that Nextcloud's oidc app introspection reports active=false
cross-client. The userinfo endpoint validates them regardless of client.
"""
@pytest.fixture
def userinfo_settings(self, base_settings):
base_settings.userinfo_uri = "https://idp.example.com/userinfo"
return base_settings
async def test_validate_via_userinfo_success(self, userinfo_settings):
verifier = UnifiedTokenVerifier(userinfo_settings)
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"sub": "testuser"}
with patch.object(
verifier.http_client, "get", AsyncMock(return_value=mock_resp)
):
result = await verifier._validate_via_userinfo("opaque-token")
assert result is not None
assert result["sub"] == "testuser"
assert result["_auth_via_userinfo"] is True
async def test_validate_via_userinfo_non_200(self, userinfo_settings):
verifier = UnifiedTokenVerifier(userinfo_settings)
mock_resp = MagicMock()
mock_resp.status_code = 401
with patch.object(
verifier.http_client, "get", AsyncMock(return_value=mock_resp)
):
result = await verifier._validate_via_userinfo("opaque-token")
assert result is None
async def test_validate_via_userinfo_missing_sub(self, userinfo_settings):
verifier = UnifiedTokenVerifier(userinfo_settings)
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"name": "no sub claim"}
with patch.object(
verifier.http_client, "get", AsyncMock(return_value=mock_resp)
):
result = await verifier._validate_via_userinfo("opaque-token")
assert result is None
async def test_validate_via_userinfo_not_configured(self, base_settings):
base_settings.userinfo_uri = None
verifier = UnifiedTokenVerifier(base_settings)
result = await verifier._validate_via_userinfo("opaque-token")
assert result is None
async def test_mgmt_opaque_userinfo_fallback_accepted_despite_allowlist(
self, monkeypatch, userinfo_settings
):
"""Introspection inactive -> userinfo validates -> accepted even though
no client_id matches the allowlist (per-user authorization applies)."""
monkeypatch.setenv("ALLOWED_MGMT_CLIENT", "astrolabe")
verifier = UnifiedTokenVerifier(userinfo_settings)
with (
patch.object(verifier, "_introspect_token", AsyncMock(return_value=None)),
patch.object(
verifier,
"_validate_via_userinfo",
AsyncMock(return_value={"sub": "testuser", "_auth_via_userinfo": True}),
),
):
result = await verifier.verify_token_for_management_api("opaque-token-123")
assert result is not None
assert result.resource == "testuser"
assert result.client_id == "" # userinfo provides no client_id
async def test_mgmt_userinfo_not_called_when_introspection_succeeds(
self, monkeypatch, userinfo_settings
):
monkeypatch.setenv("ALLOWED_MGMT_CLIENT", "astrolabe")
verifier = UnifiedTokenVerifier(userinfo_settings)
introspection_payload = {
"sub": "testuser",
"client_id": "astrolabe",
"scope": "openid",
"exp": int(time.time() + 3600),
}
userinfo_mock = AsyncMock(return_value={"sub": "x", "_auth_via_userinfo": True})
with (
patch.object(
verifier,
"_introspect_token",
AsyncMock(return_value=introspection_payload),
),
patch.object(verifier, "_validate_via_userinfo", userinfo_mock),
):
result = await verifier.verify_token_for_management_api("opaque-token-123")
assert result is not None
assert result.client_id == "astrolabe"
userinfo_mock.assert_not_called()