Skip to content

fix(gateway): coalesce concurrent native OAuth refresh requests - #71548

Open
Doud-FR wants to merge 4 commits into
NousResearch:mainfrom
Doud-FR:fix/gateway-native-refresh-singleflight
Open

fix(gateway): coalesce concurrent native OAuth refresh requests#71548
Doud-FR wants to merge 4 commits into
NousResearch:mainfrom
Doud-FR:fix/gateway-native-refresh-singleflight

Conversation

@Doud-FR

@Doud-FR Doud-FR commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Coalesce overlapping native OAuth refresh requests that present the same old rotating refresh token.
  • Reuse the first successfully rotated session for concurrent requests and short-lived retries.
  • Add a short negative cache to absorb retry storms for definitively rejected refresh tokens.
  • Run synchronous identity-provider refresh operations outside the ASGI event loop.
  • Bound and garbage-collect the in-process replay cache and per-token locks.
  • Add regression tests for sequential retries, concurrent refreshes, rejected-token storms, and lock lifecycle races.

Root cause

Hermes Desktop can issue multiple overlapping requests to /auth/native/refresh while each caller still holds the same old refresh token.

With rotating refresh tokens:

  1. The first request successfully rotates the token.
  2. Concurrent requests replay the now-consumed old token.
  3. The identity provider rejects those requests.
  4. The desktop retries and can create a refresh storm, eventually receiving HTTP 429 responses.

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:

  • one request reaches the identity provider;
  • concurrent requests reuse the resulting rotated Session;
  • successful results are cached for 30 seconds;
  • definitive rejections are cached for 5 seconds;
  • transient provider outages are not cached;
  • cache entries are limited to 256 and garbage-collected;
  • active and waiting lock references are counted to prevent a second lock from being created for the same token.

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.py

Result: 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.py

Result: 109 passed, 5 warnings

Lint

Command:

uv run ruff check hermes_cli/dashboard_auth/routes.py tests/hermes_cli/test_dashboard_auth_native_flow.py

Result: All checks passed

Pre-existing test-order issue

Running test_dashboard_auth_gate.py before test_dashboard_auth_password_login.py produces 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.

Copilot AI review requested due to automatic review settings July 25, 2026 19:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/refresh calls 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.

Comment thread hermes_cli/dashboard_auth/routes.py Outdated
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard area/auth Authentication, OAuth, credential pools sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Jul 25, 2026
@Doud-FR
Doud-FR force-pushed the fix/gateway-native-refresh-singleflight branch from 949d780 to a291f8d Compare July 25, 2026 20:00
@Doud-FR
Doud-FR requested a review from Copilot July 25, 2026 20:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment thread hermes_cli/dashboard_auth/routes.py
Comment thread tests/hermes_cli/test_dashboard_auth_native_flow.py
@Doud-FR
Doud-FR force-pushed the fix/gateway-native-refresh-singleflight branch from a291f8d to 1e2a1c3 Compare July 25, 2026 20:17
@Doud-FR
Doud-FR requested a review from Copilot July 25, 2026 20:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:134 derives the replay key without provider_hint, while the existing refresh contract makes that hint order providers (hermes_cli/dashboard_auth/middleware.py:547-565; see tests/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:76 and :128 use Tuple, but the import remains from typing import Any, Deque, Dict. Please import Tuple or use built-in tuple[...] annotations.

This is an automated hermes-sweeper review.

Comment thread hermes_cli/dashboard_auth/routes.py Outdated
Comment thread hermes_cli/dashboard_auth/routes.py
@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 30, 2026
@Doud-FR
Doud-FR force-pushed the fix/gateway-native-refresh-singleflight branch from 1e2a1c3 to 8272606 Compare July 30, 2026 21:34
@Doud-FR
Doud-FR requested a review from Copilot July 30, 2026 21:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_sync docstring 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 interpret unreachable correctly.
    """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.
    """

@Doud-FR

Doud-FR commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: I also addressed Copilot's suppressed low-confidence observation in f8b2079c7.

The _refresh_native_session_sync docstring now documents all three return outcomes accurately:

  • (session, None) for a successful refresh or positive cache hit;
  • (None, None) when every provider definitively rejects the token, including a negative-cache hit;
  • (None, provider_name) when no provider succeeds and at least one provider is unreachable.

This is documentation-only and does not change runtime behavior.

Validation:

  • Ruff check: passed
  • git diff --check: passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 because second_entered guarantees the second provider call has started and release_second is 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_hint is incorporated into the replay-cache key without validation. Since provider comes from the request body, a buggy/malicious client can bypass coalescing/negative-caching by varying an unknown provider value while reusing the same old refresh token (undermining the retry-storm mitigation). Normalize provider_hint to "" unless it matches a registered session provider before deriving cache_key.
    cache_key = _native_refresh_cache_key(refresh_token, provider_hint, client_ip)

@Doud-FR
Doud-FR force-pushed the fix/gateway-native-refresh-singleflight branch 3 times, most recently from d29ddb6 to fc52597 Compare August 1, 2026 14:49
@alt-glitch alt-glitch mentioned this pull request Aug 2, 2026
@Doud-FR
Doud-FR force-pushed the fix/gateway-native-refresh-singleflight branch 6 times, most recently from 6de7c7c to ae348bb Compare August 4, 2026 18:51
@Doud-FR
Doud-FR force-pushed the fix/gateway-native-refresh-singleflight branch from ae348bb to 6f25340 Compare August 10, 2026 22:29
@Doud-FR
Doud-FR force-pushed the fix/gateway-native-refresh-singleflight branch 3 times, most recently from a8ccddd to f358a4a Compare August 15, 2026 14:15
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference, author can ignore or act on any point.

fix(gateway): coalesce concurrent native OAuth refresh requests

  1. _native_refresh_client_ip is byte-for-byte identical to the existing _client_ip (hermes_cli/dashboard_auth/routes.py:108) — both return request.client.host if request.client else "". Pure duplication; reuse _client_ip and drop the new helper.
  2. Single-flight correctness is solid (user-counted per-token locks, waiter keeps the lock registered, deterministic barrier tests). One assumption worth documenting: the replay cache and lock table are in-process, so coalescing only holds within a single ASGI worker — if the dashboard is ever run with more than one worker, concurrent refreshes across workers can still replay a consumed refresh token at the IdP.
  3. Unknown provider_hint canonicalization is good for the cache key, but a hint that is a display name or alias (rather than an exact provider name) is silently canonicalized away — it no longer fragments the cache, but it also silently loses the intended provider-ordering preference. Consider a debug log when a non-empty hint is canonicalized to "" so a typo'd provider name is visible.
  4. The negative-cache path is correctly limited to definitive rejections only (a transient ProviderError is never cached), which keeps real outages immediately retryable. Nice detail: the success TTL (30s) vs failure TTL (5s) split is well reasoned.

@oliver-mee

Copy link
Copy Markdown
Contributor

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 /auth/native/refresh inside the same second, all carrying the same old refresh token. The provider revoked the whole chain rather than just rejecting the replays, so every refresh after that returned "The refresh token has not been found" and the desktop dropped to a sign-in prompt. The 429s you mention showed up too, as Rate Limit Exceeded on the provider's token endpoint. Full evidence, including the provider's refresh-token table showing the newest two rows as revoked=1, is in #55712.

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.

@alt-glitch alt-glitch removed the sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades label Aug 17, 2026
@Doud-FR
Doud-FR force-pushed the fix/gateway-native-refresh-singleflight branch from f358a4a to 0166a0c Compare August 18, 2026 13:31
@Doud-FR
Doud-FR force-pushed the fix/gateway-native-refresh-singleflight branch from 0166a0c to ac9327e Compare August 18, 2026 14:08
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated follow-up, for reference.

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.

@alt-glitch alt-glitch added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data and removed sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants