fix(gateway): coalesce concurrent native OAuth refresh requests - #71548
fix(gateway): coalesce concurrent native OAuth refresh requests#71548Doud-FR wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens the dashboard gateway’s native OAuth refresh endpoint against concurrent refresh-token rotation storms by introducing a per-token “single-flight” refresh path with a short-lived replay cache and running synchronous provider refreshes off the ASGI event loop.
Changes:
- Added an in-process replay cache + per-token locks to coalesce overlapping
/auth/native/refreshcalls that present the same old rotating refresh token. - Executed provider refresh operations in a threadpool to avoid blocking the ASGI event loop.
- Added regression tests covering sequential retries, concurrent refresh coalescing, negative-cache behavior for definitively rejected tokens, and lock lifecycle races.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
hermes_cli/dashboard_auth/routes.py |
Implements single-flight refresh coalescing + bounded replay/negative caching and moves provider refresh work into a threadpool. |
tests/hermes_cli/test_dashboard_auth_native_flow.py |
Adds test coverage for refresh coalescing, retries, negative caching, and lock lifecycle behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
949d780 to
a291f8d
Compare
a291f8d to
1e2a1c3
Compare
teknium1
left a comment
There was a problem hiding this comment.
Thanks for addressing a real native-refresh race. Current main still calls provider.refresh_session() independently in hermes_cli/dashboard_auth/routes.py:915-932, so the single-flight direction is needed.
Problems
hermes_cli/dashboard_auth/routes.py:134derives the replay key withoutprovider_hint, while the existing refresh contract makes that hint order providers (hermes_cli/dashboard_auth/middleware.py:547-565; seetests/hermes_cli/test_dashboard_auth_401_reauth.py:211-224). A cache hit can therefore return a session chosen under a different provider ordering. Include the hint in the key, or preserve equivalent ordering before returning cached results, and cover two providers sharing an opaque token value.hermes_cli/dashboard_auth/routes.py:76and:128useTuple, but the import remainsfrom typing import Any, Deque, Dict. Please importTupleor use built-intuple[...]annotations.
This is an automated hermes-sweeper review.
1e2a1c3 to
8272606
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
hermes_cli/dashboard_auth/routes.py:140
- The
_refresh_native_session_syncdocstring doesn’t match the actual return semantics: the function can return(None, None)for a definitive rejection (including negative-cache hits), and(None, <provider>)only when at least one provider was unreachable. Clarifying this helps callers/tests interpretunreachablecorrectly.
"""Single-flight native refresh for one old rotating refresh token.
Returns ``(session, None)`` on success/cache hit, or
``(None, unavailable_provider)`` when no provider refreshed it.
"""
|
Follow-up: I also addressed Copilot's suppressed low-confidence observation in The
This is documentation-only and does not change runtime behavior. Validation:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
tests/hermes_cli/test_dashboard_auth_native_flow.py:695
- This test still uses a fixed sleep to "give time" before asserting
refresh_calls == 2. The assertion is already deterministic here becausesecond_enteredguarantees the second provider call has started andrelease_secondis not set yet, so the third call cannot advance. Removing the sleep avoids unnecessary timing sensitivity in CI.
third = pool.submit(auth_routes._refresh_native_session_sync, *args)
assert wait_for_lock_users(2) is original_lock
time.sleep(0.05)
assert provider.refresh_calls == 2
hermes_cli/dashboard_auth/routes.py:145
provider_hintis incorporated into the replay-cache key without validation. Sinceprovidercomes from the request body, a buggy/malicious client can bypass coalescing/negative-caching by varying an unknownprovidervalue while reusing the same old refresh token (undermining the retry-storm mitigation). Normalizeprovider_hintto "" unless it matches a registered session provider before derivingcache_key.
cache_key = _native_refresh_cache_key(refresh_token, provider_hint, client_ip)
d29ddb6 to
fc52597
Compare
6de7c7c to
ae348bb
Compare
ae348bb to
6f25340
Compare
a8ccddd to
f358a4a
Compare
fix(gateway): coalesce concurrent native OAuth refresh requests
|
|
Field confirmation for this one, in case it helps it get looked at: I hit exactly the failure your root-cause section describes, on a self-hosted Authelia 4.39.20 with rotating refresh tokens and reuse detection. After a wake, four requests left for Two things you may want to link, since I could not find either reference in the PR:
I can reproduce this on demand by letting the machine sleep past the access-token lifetime, so if it would help to have this branch tested against a rotating-RT provider with reuse detection, I am happy to run it and report back. |
f358a4a to
0166a0c
Compare
0166a0c to
ac9327e
Compare
Appreciate the field confirmation on Authelia 4.39.20 — a real rotating-refresh-token + reuse-detection reproduction is exactly the evidence the coalescing fix targets. No comments from the review side. |
Summary
Root cause
Hermes Desktop can issue multiple overlapping requests to
/auth/native/refreshwhile each caller still holds the same old refresh token.With rotating refresh tokens:
Fix
The gateway now derives a SHA-256 cache key from the client IP and old refresh token, without storing the raw token.
Requests sharing that key use a per-token single-flight lock:
Session;Provider refresh calls are synchronous and may perform network I/O, so they are executed with
run_in_threadpool()instead of blocking the ASGI event loop.Validation
Native OAuth flow
Command:
uv run python -m pytest -q tests/hermes_cli/test_dashboard_auth_native_flow.pyResult: 25 passed, 5 warnings
Related authentication suites
Command:
uv run python -m pytest -q tests/hermes_cli/test_dashboard_auth_401_reauth.py tests/hermes_cli/test_dashboard_auth_middleware.py tests/hermes_cli/test_dashboard_auth_native_flow.pyResult: 109 passed, 5 warnings
Lint
Command:
uv run ruff check hermes_cli/dashboard_auth/routes.py tests/hermes_cli/test_dashboard_auth_native_flow.pyResult: All checks passed
Pre-existing test-order issue
Running
test_dashboard_auth_gate.pybeforetest_dashboard_auth_password_login.pyproduces three password-login failures.The exact same failures were reproduced in a clean detached worktree at
origin/main, so they are pre-existing and unrelated to this change.