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
@@ -66,16 +66,16 @@ def _origin_matches_self(request: Request, oauth_ctx: dict) -> bool:
cfg = oauth_ctx.get("config") or oauth_ctx cfg = oauth_ctx.get("config") or oauth_ctx
mcp_server_url = cfg.get("mcp_server_url") mcp_server_url = cfg.get("mcp_server_url")
if not mcp_server_url: if not mcp_server_url:
# Mis-configured deployment — fail open rather than break logout, but # Fail closed (PR #758 round-3 finding 2): a future code path that
# log loudly so the operator can see this is happening (PR #758 # leaves ``mcp_server_url`` unset would otherwise silently disable
# finding 3). Other OAuth code paths require ``mcp_server_url`` and # CSRF protection on /oauth/logout. Blocking the logout is
# KeyError if it's absent, so this branch should never fire in a # recoverable — the user just re-logs-in once the misconfiguration
# correctly configured deployment. # is fixed — and the error log makes the cause monitorable.
logger.warning( logger.error(
"CSRF check bypassed on /oauth/logout: mcp_server_url not " "CSRF check failed on /oauth/logout: mcp_server_url not "
"configured in oauth_context — set NEXTCLOUD_MCP_SERVER_URL" "configured in oauth_context — set NEXTCLOUD_MCP_SERVER_URL"
) )
return True return False
expected = _normalise_origin(mcp_server_url) expected = _normalise_origin(mcp_server_url)
raw = request.headers.get("origin") or request.headers.get("referer") raw = request.headers.get("origin") or request.headers.get("referer")
@@ -434,14 +434,19 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
token_data = response.json() token_data = response.json()
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
# Correlation IDs let the user reference a specific failure in the
# server logs without us having to reflect raw exception/IdP text
# back into the HTML page (PR #758 round-3 nit 6).
correlation_id = secrets.token_hex(8)
error_body = ( error_body = (
e.response.text if hasattr(e.response, "text") else str(e.response.content) e.response.text if hasattr(e.response, "text") else str(e.response.content)
) )
logger.error( logger.error(
"Token exchange failed: HTTP %s - %s", e.response.status_code, error_body "Token exchange failed (correlation_id=%s): HTTP %s - %s",
correlation_id,
e.response.status_code,
error_body,
) )
# html_escape: error_body originates from the IdP and could contain
# markup that would be reflected into the failure page otherwise.
return HTMLResponse( return HTMLResponse(
f""" f"""
<!DOCTYPE html> <!DOCTYPE html>
@@ -449,15 +454,17 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
<head><title>Login Failed</title></head> <head><title>Login Failed</title></head>
<body> <body>
<h1>Login Failed</h1> <h1>Login Failed</h1>
<p>Failed to exchange authorization code for tokens</p> <p>An internal error occurred while exchanging the authorization code.</p>
<p>HTTP {e.response.status_code}: {html_escape(error_body)}</p> <p>Correlation ID: <code>{html_escape(correlation_id)}</code></p>
<p>Please try again, or contact your administrator if the problem persists.</p>
</body> </body>
</html> </html>
""", """,
status_code=500, status_code=500,
) )
except Exception as e: except Exception as e:
logger.error("Token exchange failed: %s", e) correlation_id = secrets.token_hex(8)
logger.error("Token exchange failed (correlation_id=%s): %s", correlation_id, e)
return HTMLResponse( return HTMLResponse(
f""" f"""
<!DOCTYPE html> <!DOCTYPE html>
@@ -465,8 +472,9 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
<head><title>Login Failed</title></head> <head><title>Login Failed</title></head>
<body> <body>
<h1>Login Failed</h1> <h1>Login Failed</h1>
<p>Failed to exchange authorization code for tokens</p> <p>An internal error occurred while exchanging the authorization code.</p>
<p>Error: {html_escape(str(e))}</p> <p>Correlation ID: <code>{html_escape(correlation_id)}</code></p>
<p>Please try again, or contact your administrator if the problem persists.</p>
</body> </body>
</html> </html>
""", """,
@@ -508,13 +516,19 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
expected_nonce=nonce, expected_nonce=nonce,
) )
except IdTokenVerificationError as e: except IdTokenVerificationError as e:
logger.error("ID token verification failed: %s", e) # Same correlation-ID pattern as token-exchange failures
# html_escape: defense-in-depth. The exception text is currently # (PR #758 round-3 nit 6) — log the detail server-side and only
# server-constructed, but escape on the success path too so any # show a generic message + correlation ID in the browser.
# future error wrapping that includes IdP response text can't correlation_id = secrets.token_hex(8)
# smuggle markup into the login-failure page. logger.error(
"ID token verification failed (correlation_id=%s): %s",
correlation_id,
e,
)
return HTMLResponse( return HTMLResponse(
f"<h1>Login Failed</h1><p>ID token failed verification: {html_escape(str(e))}</p>", f"<h1>Login Failed</h1>"
f"<p>The ID token failed verification.</p>"
f"<p>Correlation ID: <code>{html_escape(correlation_id)}</code></p>",
status_code=400, status_code=400,
) )
+16 -2
View File
@@ -465,7 +465,12 @@ async def oauth_authorize_nextcloud(
digest = hashlib.sha256(code_verifier.encode()).digest() digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = urlsafe_b64encode(digest).decode().rstrip("=") code_challenge = urlsafe_b64encode(digest).decode().rstrip("=")
# Store code_verifier in session for retrieval during callback # OIDC nonce binds the IdP-returned ID token to THIS auth request
# (PR #758 round-3 finding 1). Browser flow + AS proxy already do
# this; Flow 2 is the third path and was missing it.
nonce = secrets.token_urlsafe(32)
# Store code_verifier + nonce in session for retrieval during callback
storage = oauth_ctx["storage"] storage = oauth_ctx["storage"]
await storage.store_oauth_session( await storage.store_oauth_session(
session_id=state, session_id=state,
@@ -475,6 +480,7 @@ async def oauth_authorize_nextcloud(
code_challenge=code_challenge, code_challenge=code_challenge,
code_challenge_method="S256", code_challenge_method="S256",
mcp_authorization_code=code_verifier, # Store code_verifier here temporarily mcp_authorization_code=code_verifier, # Store code_verifier here temporarily
nonce=nonce,
flow_type="flow2", flow_type="flow2",
ttl_seconds=600, # 10 minutes ttl_seconds=600, # 10 minutes
) )
@@ -512,6 +518,7 @@ async def oauth_authorize_nextcloud(
"response_type": "code", "response_type": "code",
"scope": scopes, "scope": scopes,
"state": state, "state": state,
"nonce": nonce,
"code_challenge": code_challenge, "code_challenge": code_challenge,
"code_challenge_method": "S256", "code_challenge_method": "S256",
"prompt": "consent", # Force consent to show resource access "prompt": "consent", # Force consent to show resource access
@@ -572,12 +579,15 @@ async def oauth_callback_nextcloud(request: Request):
storage: RefreshTokenStorage = oauth_ctx["storage"] storage: RefreshTokenStorage = oauth_ctx["storage"]
oauth_config = oauth_ctx["config"] oauth_config = oauth_ctx["config"]
# Retrieve code_verifier from session storage (PKCE required by Nextcloud OIDC) # Retrieve code_verifier + nonce from session storage (PKCE + OIDC
# nonce binding both required for Flow 2 — round-3 finding 1).
code_verifier = "" code_verifier = ""
nonce: str | None = None
oauth_session = await storage.get_oauth_session(state) oauth_session = await storage.get_oauth_session(state)
if oauth_session: if oauth_session:
# code_verifier was stored in mcp_authorization_code field # code_verifier was stored in mcp_authorization_code field
code_verifier = oauth_session.get("mcp_authorization_code", "") code_verifier = oauth_session.get("mcp_authorization_code", "")
nonce = oauth_session.get("nonce")
logger.info( logger.info(
f"Retrieved code_verifier for Flow 2 callback (state={state[:16]}...)" f"Retrieved code_verifier for Flow 2 callback (state={state[:16]}...)"
) )
@@ -636,12 +646,16 @@ async def oauth_callback_nextcloud(request: Request):
id_token = token_data.get("id_token") id_token = token_data.get("id_token")
# Verify ID token signature + claims (issue #626 finding 1). # Verify ID token signature + claims (issue #626 finding 1).
# ``expected_nonce`` is the per-request nonce stored on the
# oauth_session row (PR #758 round-3 finding 1); falsy → skip nonce
# check for sessions written before the column existed.
logger.info("oauth_callback_nextcloud: Verifying ID token") logger.info("oauth_callback_nextcloud: Verifying ID token")
try: try:
userinfo = await verify_id_token( userinfo = await verify_id_token(
id_token, id_token,
discovery_url=discovery_url, discovery_url=discovery_url,
expected_audience=mcp_server_client_id, expected_audience=mcp_server_client_id,
expected_nonce=nonce or None,
) )
except IdTokenVerificationError as e: except IdTokenVerificationError as e:
logger.error("ID token verification failed: %s", e) logger.error("ID token verification failed: %s", e)
+26
View File
@@ -1178,6 +1178,16 @@ class RefreshTokenStorage:
ttl_seconds, ttl_seconds,
) )
# Audit log to match the pattern used by the other security-relevant
# storage operations (PR #758 round-3 nit 5). Browser session
# establishment is a security-relevant event.
await self._audit_log(
event="create_browser_session",
user_id=user_id,
resource_type="browser_session",
resource_id=session_id[:8],
)
async def get_browser_session_user(self, session_id: str) -> str | None: async def get_browser_session_user(self, session_id: str) -> str | None:
"""Look up the user_id bound to a browser session_id, or None. """Look up the user_id bound to a browser session_id, or None.
@@ -1210,7 +1220,16 @@ class RefreshTokenStorage:
if not self._initialized: if not self._initialized:
await self.initialize() await self.initialize()
# SELECT the row before DELETE so we can attribute the audit log
# entry to the right user (PR #758 round-3 nit 5).
async with aiosqlite.connect(self.db_path) as db: async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT user_id FROM browser_sessions WHERE session_id = ?",
(session_id,),
) as cursor:
row = await cursor.fetchone()
user_id = row[0] if row else None
cursor = await db.execute( cursor = await db.execute(
"DELETE FROM browser_sessions WHERE session_id = ?", (session_id,) "DELETE FROM browser_sessions WHERE session_id = ?", (session_id,)
) )
@@ -1219,6 +1238,13 @@ class RefreshTokenStorage:
if deleted: if deleted:
logger.debug("Deleted browser session %s", session_id[:8]) logger.debug("Deleted browser session %s", session_id[:8])
if user_id:
await self._audit_log(
event="delete_browser_session",
user_id=user_id,
resource_type="browser_session",
resource_id=session_id[:8],
)
return deleted return deleted
async def cleanup_expired_browser_sessions(self) -> int: async def cleanup_expired_browser_sessions(self) -> int:
+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. 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: Checks for both credential types:
1. App password from Astrolabe (works today) 1. App password from Astrolabe (works today)
2. OAuth refresh token from storage (for future) 2. OAuth refresh token from storage (for future)
@@ -200,9 +205,9 @@ def generate_oauth_url_for_flow2(
return f"{auth_endpoint}?{urlencode(params)}" 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 Returns URL to Astrolabe settings page where users can provision background
sync access using either: sync access using either:
@@ -219,7 +224,7 @@ async def provision_nextcloud_access(ctx: Context, user_id: str) -> Provisioning
""" """
try: try:
# Check if already provisioned # Check if already provisioned
status = await get_provisioning_status(ctx, user_id) status = await _get_provisioning_status(ctx, user_id)
if status.is_provisioned: if status.is_provisioned:
return ProvisioningResult( return ProvisioningResult(
success=True, 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 This tool removes the stored refresh token and revokes access
that was granted via Flow 2. that was granted via Flow 2.
@@ -285,7 +290,7 @@ async def revoke_nextcloud_access(ctx: Context, user_id: str) -> RevocationResul
""" """
try: try:
# Check current status # Check current status
status = await get_provisioning_status(ctx, user_id) status = await _get_provisioning_status(ctx, user_id)
if not status.is_provisioned: if not status.is_provisioned:
return RevocationResult( return RevocationResult(
success=True, 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 This tool allows users to check whether they have provisioned
Nextcloud access and see details about their current authorization. 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: Returns:
ProvisioningStatus with current state 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) 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 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 # ends up in log aggregation on every check_logged_in call, which is
# noise in a hosted multi-tenant deployment. # noise in a hosted multi-tenant deployment.
logger.debug("Checking provisioning status for user_id=%s", user_id) 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( logger.debug(
" Provisioning status for %s: is_provisioned=%s", " Provisioning status for %s: is_provisioned=%s",
user_id, user_id,
@@ -568,7 +573,7 @@ def register_oauth_tools(mcp):
@require_scopes("openid") @require_scopes("openid")
async def tool_provision_access(ctx: Context) -> ProvisioningResult: async def tool_provision_access(ctx: Context) -> ProvisioningResult:
user_id = await extract_user_id_from_token(ctx) 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( @mcp.tool(
name="revoke_nextcloud_access", name="revoke_nextcloud_access",
@@ -583,7 +588,7 @@ def register_oauth_tools(mcp):
@require_scopes("openid") @require_scopes("openid")
async def tool_revoke_access(ctx: Context) -> RevocationResult: async def tool_revoke_access(ctx: Context) -> RevocationResult:
user_id = await extract_user_id_from_token(ctx) 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( @mcp.tool(
name="check_provisioning_status", name="check_provisioning_status",
@@ -597,7 +602,7 @@ def register_oauth_tools(mcp):
@require_scopes("openid") @require_scopes("openid")
async def tool_check_status(ctx: Context) -> ProvisioningStatus: async def tool_check_status(ctx: Context) -> ProvisioningStatus:
user_id = await extract_user_id_from_token(ctx) 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( @mcp.tool(
name="check_logged_in", name="check_logged_in",
@@ -614,4 +619,4 @@ def register_oauth_tools(mcp):
@require_scopes("openid") @require_scopes("openid")
async def tool_check_logged_in(ctx: Context) -> str: async def tool_check_logged_in(ctx: Context) -> str:
user_id = await extract_user_id_from_token(ctx) 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)
+14 -3
View File
@@ -70,8 +70,14 @@ async def test_callback_escapes_error_query_params(storage):
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in body assert "&lt;script&gt;alert(1)&lt;/script&gt;" in body
async def test_callback_escapes_idp_http_error_body(storage): async def test_callback_does_not_reflect_idp_http_error_body(storage):
"""IdP-returned HTTPError body must be HTML-escaped before reflection.""" """IdP-returned HTTPError body must not appear in the user-visible HTML.
Updated for PR #758 round-3 nit 6: the callback now logs the IdP
response server-side and shows the user only a generic message + a
correlation ID, eliminating reflection of attacker-controllable text
into the error page entirely.
"""
discovery = {"token_endpoint": "http://idp.example/token"} discovery = {"token_endpoint": "http://idp.example/token"}
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
@@ -135,5 +141,10 @@ async def test_callback_escapes_idp_http_error_body(storage):
body = response.body.decode() body = response.body.decode()
assert response.status_code == 500 assert response.status_code == 500
# Strict: neither the raw payload nor an HTML-escaped form of the
# IdP body should appear — the page must show only the generic
# message + correlation ID.
assert XSS_PAYLOAD not in body assert XSS_PAYLOAD not in body
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in body assert "&lt;script&gt;alert(1)&lt;/script&gt;" not in body
assert "An internal error occurred" in body
assert "Correlation ID" in body
+49 -5
View File
@@ -92,7 +92,13 @@ async def test_logout_deletes_refresh_token_and_session(storage):
request = _build_request( request = _build_request(
cookie="sid-1", cookie="sid-1",
oauth_context={"storage": storage, "config": {"discovery_url": None}}, oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
) )
with patch( with patch(
@@ -118,7 +124,10 @@ async def test_logout_calls_revocation_when_refresh_token_present(storage):
cookie="sid-2", cookie="sid-2",
oauth_context={ oauth_context={
"storage": storage, "storage": storage,
"config": {"discovery_url": "http://idp/.well-known"}, "config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": "http://idp/.well-known",
},
}, },
) )
@@ -138,7 +147,13 @@ async def test_logout_no_session_cookie_returns_302(storage):
"""Without a cookie, logout still 302s and doesn't touch storage.""" """Without a cookie, logout still 302s and doesn't touch storage."""
request = _build_request( request = _build_request(
cookie=None, cookie=None,
oauth_context={"storage": storage, "config": {"discovery_url": None}}, oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
) )
response = await oauth_logout(request) response = await oauth_logout(request)
assert response.status_code == 302 assert response.status_code == 302
@@ -157,7 +172,10 @@ async def test_logout_swallows_storage_errors(storage):
cookie="sid-3", cookie="sid-3",
oauth_context={ oauth_context={
"storage": broken_storage, "storage": broken_storage,
"config": {"discovery_url": None}, "config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
}, },
) )
response = await oauth_logout(request) response = await oauth_logout(request)
@@ -295,6 +313,26 @@ async def test_logout_allows_referer_when_origin_missing(storage):
assert response.status_code == 302 assert response.status_code == 302
async def test_logout_blocked_when_mcp_server_url_missing(storage):
"""Fail-closed CSRF (PR #758 round-3 finding 2): missing ``mcp_server_url``
in oauth_ctx must reject the logout, not allow it.
A future code path that leaves ``mcp_server_url`` unset would
otherwise silently disable CSRF protection. Blocking is recoverable.
"""
await storage.create_browser_session(session_id="sid-MM", user_id="alice")
request = _build_request(
cookie="sid-MM",
oauth_context={"storage": storage, "config": {"discovery_url": None}},
)
response = await oauth_logout(request)
assert response.status_code == 403
# Session must NOT have been deleted.
assert await storage.get_browser_session_user("sid-MM") == "alice"
async def test_logout_handles_session_with_no_refresh_token(storage): async def test_logout_handles_session_with_no_refresh_token(storage):
"""Cookie + session row exist but refresh token already gone — logout is idempotent.""" """Cookie + session row exist but refresh token already gone — logout is idempotent."""
await storage.create_browser_session(session_id="sid-4", user_id="dave") await storage.create_browser_session(session_id="sid-4", user_id="dave")
@@ -302,7 +340,13 @@ async def test_logout_handles_session_with_no_refresh_token(storage):
revoke = AsyncMock() revoke = AsyncMock()
request = _build_request( request = _build_request(
cookie="sid-4", cookie="sid-4",
oauth_context={"storage": storage, "config": {"discovery_url": None}}, oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
) )
with patch( with patch(
"nextcloud_mcp_server.auth.browser_oauth_routes._revoke_refresh_token_at_idp", "nextcloud_mcp_server.auth.browser_oauth_routes._revoke_refresh_token_at_idp",