fix(auth): address PR #758 round-3 review

- Flow 2 (oauth_authorize_nextcloud) now generates a nonce, stores it on
  the oauth_session row, forwards it to the IdP, and verifies it via
  expected_nonce in oauth_callback_nextcloud — closes the last replay-
  protection gap (round-3 finding 1).
- _origin_matches_self fails closed when mcp_server_url is missing
  instead of allowing the logout, and the diagnostic log is promoted
  from warning to error so the misconfiguration is monitorable
  (round-3 finding 2). New regression test pins the new behaviour.
- The five user_id-accepting helpers in oauth_tools.py (get_provisioning_status,
  provision_nextcloud_access, revoke_nextcloud_access, check_provisioning_status,
  check_logged_in) are renamed with leading underscores to make the
  trust boundary structural rather than documentary
  (round-3 finding 3).
- create_browser_session and delete_browser_session now emit audit_log
  rows so session establishment / teardown match the pattern used by
  the rest of the security-relevant storage operations
  (round-3 nit 5). delete_browser_session selects user_id before delete
  so the audit row is attributable.
- oauth_login_callback no longer reflects raw IdP-error text or
  exception strings into the HTML failure page; users see a generic
  "internal error occurred" message + a correlation ID, with the
  detail logged server-side keyed by the same ID (round-3 nit 6).
  The XSS regression test is updated to pin the stricter contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-02 22:40:06 +02:00
co-authored by Claude Opus 4.7
parent c33d52ea91
commit 9d0e7dcebe
6 changed files with 163 additions and 49 deletions
+22 -17
View File
@@ -77,10 +77,15 @@ class LoginConfirmation(BaseModel):
)
async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningStatus:
async def _get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningStatus:
"""
Check the provisioning status for Nextcloud access.
Internal helper — leading underscore signals that ``user_id`` is a
trusted identity claim that callers MUST derive from the verified
access token. The MCP tool wrappers in ``register_oauth_tools`` are
the only legitimate callers (PR #758 round-3 finding 3).
Checks for both credential types:
1. App password from Astrolabe (works today)
2. OAuth refresh token from storage (for future)
@@ -200,9 +205,9 @@ def generate_oauth_url_for_flow2(
return f"{auth_endpoint}?{urlencode(params)}"
async def provision_nextcloud_access(ctx: Context, user_id: str) -> ProvisioningResult:
async def _provision_nextcloud_access(ctx: Context, user_id: str) -> ProvisioningResult:
"""
MCP Tool: Provision offline access to Nextcloud resources.
Internal helper for the ``provision_nextcloud_access`` MCP tool.
Returns URL to Astrolabe settings page where users can provision background
sync access using either:
@@ -219,7 +224,7 @@ async def provision_nextcloud_access(ctx: Context, user_id: str) -> Provisioning
"""
try:
# Check if already provisioned
status = await get_provisioning_status(ctx, user_id)
status = await _get_provisioning_status(ctx, user_id)
if status.is_provisioned:
return ProvisioningResult(
success=True,
@@ -268,9 +273,9 @@ async def provision_nextcloud_access(ctx: Context, user_id: str) -> Provisioning
)
async def revoke_nextcloud_access(ctx: Context, user_id: str) -> RevocationResult:
async def _revoke_nextcloud_access(ctx: Context, user_id: str) -> RevocationResult:
"""
MCP Tool: Revoke offline access to Nextcloud resources.
Internal helper for the ``revoke_nextcloud_access`` MCP tool.
This tool removes the stored refresh token and revokes access
that was granted via Flow 2.
@@ -285,7 +290,7 @@ async def revoke_nextcloud_access(ctx: Context, user_id: str) -> RevocationResul
"""
try:
# Check current status
status = await get_provisioning_status(ctx, user_id)
status = await _get_provisioning_status(ctx, user_id)
if not status.is_provisioned:
return RevocationResult(
success=True,
@@ -339,9 +344,9 @@ async def revoke_nextcloud_access(ctx: Context, user_id: str) -> RevocationResul
)
async def check_provisioning_status(ctx: Context, user_id: str) -> ProvisioningStatus:
async def _check_provisioning_status(ctx: Context, user_id: str) -> ProvisioningStatus:
"""
MCP Tool: Check the current provisioning status.
Internal helper for the ``check_provisioning_status`` MCP tool.
This tool allows users to check whether they have provisioned
Nextcloud access and see details about their current authorization.
@@ -354,12 +359,12 @@ async def check_provisioning_status(ctx: Context, user_id: str) -> ProvisioningS
Returns:
ProvisioningStatus with current state
"""
return await get_provisioning_status(ctx, user_id)
return await _get_provisioning_status(ctx, user_id)
async def check_logged_in(ctx: Context, user_id: str) -> str:
async def _check_logged_in(ctx: Context, user_id: str) -> str:
"""
MCP Tool: Check if user is logged in and elicit login if needed.
Internal helper for the ``check_logged_in`` MCP tool.
This tool checks whether the user has completed Flow 2 (resource provisioning)
to grant offline access to Nextcloud. If not logged in, it uses MCP elicitation
@@ -378,7 +383,7 @@ async def check_logged_in(ctx: Context, user_id: str) -> str:
# ends up in log aggregation on every check_logged_in call, which is
# noise in a hosted multi-tenant deployment.
logger.debug("Checking provisioning status for user_id=%s", user_id)
status = await get_provisioning_status(ctx, user_id)
status = await _get_provisioning_status(ctx, user_id)
logger.debug(
" Provisioning status for %s: is_provisioned=%s",
user_id,
@@ -568,7 +573,7 @@ def register_oauth_tools(mcp):
@require_scopes("openid")
async def tool_provision_access(ctx: Context) -> ProvisioningResult:
user_id = await extract_user_id_from_token(ctx)
return await provision_nextcloud_access(ctx, user_id)
return await _provision_nextcloud_access(ctx, user_id)
@mcp.tool(
name="revoke_nextcloud_access",
@@ -583,7 +588,7 @@ def register_oauth_tools(mcp):
@require_scopes("openid")
async def tool_revoke_access(ctx: Context) -> RevocationResult:
user_id = await extract_user_id_from_token(ctx)
return await revoke_nextcloud_access(ctx, user_id)
return await _revoke_nextcloud_access(ctx, user_id)
@mcp.tool(
name="check_provisioning_status",
@@ -597,7 +602,7 @@ def register_oauth_tools(mcp):
@require_scopes("openid")
async def tool_check_status(ctx: Context) -> ProvisioningStatus:
user_id = await extract_user_id_from_token(ctx)
return await check_provisioning_status(ctx, user_id)
return await _check_provisioning_status(ctx, user_id)
@mcp.tool(
name="check_logged_in",
@@ -614,4 +619,4 @@ def register_oauth_tools(mcp):
@require_scopes("openid")
async def tool_check_logged_in(ctx: Context) -> str:
user_id = await extract_user_id_from_token(ctx)
return await check_logged_in(ctx, user_id)
return await _check_logged_in(ctx, user_id)