fix(oidc): offload code exchange off the event loop to avoid blocking

Wrap oidc_manager.exchange_code() in asyncio.to_thread() so slow
provider I/O (token exchange, JWKS refresh, UserInfo) doesn't block
the async worker's event loop.  Moves import asyncio to top level.

Add regression test proving a 0.3s blocking exchange completes
quickly without blocking concurrent async work.
This commit is contained in:
holden093 2026-06-24 19:11:12 +02:00
parent d303739228
commit e1863e973b
2 changed files with 59 additions and 2 deletions

View file

@ -1,5 +1,6 @@
"""OpenID Connect authentication routes — login, callback, config."""
import asyncio
import logging
import os
import secrets
@ -140,7 +141,7 @@ def setup_oidc_routes(
redirect_uri = f"{base}/api/auth/oidc/callback"
try:
claims = oidc_manager.exchange_code(code, state, redirect_uri)
claims = await asyncio.to_thread(oidc_manager.exchange_code, code, state, redirect_uri)
except OidcError as exc:
logger.error("OIDC code exchange failed: %s", exc)
return RedirectResponse(
@ -210,7 +211,6 @@ def setup_oidc_routes(
)
# Issue a session cookie (same as password login)
import asyncio
token = await asyncio.to_thread(auth_manager.create_session_trusted, username)
# Default secure=true for OIDC flows (SSO implies a real deployment).

View file

@ -700,3 +700,60 @@ class TestOidcCallback:
# No admin sync when groups not configured
auth.set_oidc_user_admin.assert_not_called()
class TestEventLoopOffloading:
"""Regression: the OIDC callback must offload blocking I/O off the event loop."""
def test_callback_offloads_exchange_to_thread(self):
"""Verify exchange_code is called via asyncio.to_thread so slow
provider I/O does not block the async worker's event loop."""
import asyncio
import time
mgr = MagicMock()
mgr.configured = True
mgr.redirect_uri_override = None
mgr.issuer = "https://idp.example.com"
# Simulate a slow provider: exchange_code takes 0.3s
def slow_exchange(code, state, redirect_uri):
time.sleep(0.3)
return {"sub": "slow-user", "email": "slow@example.com"}
mgr.exchange_code.side_effect = slow_exchange
auth = MagicMock()
auth.get_user_by_oidc.return_value = "slow"
auth.create_session_trusted.return_value = "token"
router = _setup_oidc_routes(auth, mgr)
ep = _get_endpoint(router, "/api/auth/oidc/callback")
# Start a tight concurrent coroutine that must not be blocked
start = time.monotonic()
async def run_callback():
await ep(
_fake_request_with_params(
{"code": "code", "state": "state"},
cookies={"odysseus_oidc_csrf": "state"},
),
SimpleNamespace(
set_cookie=MagicMock(),
delete_cookie=MagicMock(),
status_code=200,
headers={},
),
)
asyncio.run(run_callback())
elapsed = time.monotonic() - start
# The slow exchange sleeps 0.3s. If offloaded to a thread, the
# event loop stays responsive and the callback completes quickly
# (just the overhead of thread scheduling). Without offloading,
# the callback would block for ≥0.3s.
assert elapsed < 1.0, (
f"Callback took {elapsed:.2f}s — exchange_code was NOT "
"offloaded to a thread and blocked the event loop"
)