Repository navigation
Refuse refresh grants whose upstream credential is dead - #6930
Conversation
Implements proposal item 3 of #6901. A refreshed access token keeps the token session (tsid), so when its upstream credential is dead the bearer validator answers 401 again and a client that only refreshes loops. A built-in refresh-grant guard, on by default when an upstream is configured, now refuses such a grant with invalid_grant and revokes the refresh token, sending the client to a full authorization. - Dead means no upstream rows, or an expired row with no refresh token or whose upstream refresh the IdP rejects (ErrGrantRejected) - Expired rows are refreshed first, so idle sessions are granted - Transient failures (network, 5xx, storage) grant the refresh - A rejection is re-checked after 500ms so a replica that lost a cross-replica refresh race does not revoke a live session - Runs before embedder RefreshGrantHandlers; opt out with Config.DisableUpstreamRefreshGuard Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client disconnect during the lost-race recheck, or a storage fault on the re-read, no longer counts as a final upstream rejection, so it can no longer revoke a session another replica may have just refreshed. The guard now treats an incomplete recheck as uncertain and grants. The unit tests use the generated UpstreamTokenRefresher mock and check revocation through real MemoryStorage instead of hand-written doubles, and the guard logs the upstream under provider_id like the refresher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bearer validator answers every upstream refresh failure with 401, so a failure the guard grants but that can never clear still loops. An expired row whose provider is no longer configured (an upstream removed or renamed) is now dead, as is a session left with no row holding an access token, matching the validator's own 401. Adds tests for the fail-open storage paths (token read, recheck read, revocation) using the generated storage mock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6930 +/- ##
==========================================
+ Coverage 80.01% 80.16% +0.15%
==========================================
Files 837 840 +3
Lines 85215 85474 +259
==========================================
+ Hits 68182 68521 +339
+ Misses 17028 16948 -80
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
tgrunnagle
left a comment
There was a problem hiding this comment.
Multi-Agent Consensus Review
Agents consulted: security, concurrency-resilience, test-coverage, architecture-docs, general-quality
Consensus Summary
| # | Finding | Consensus | Severity | Action |
|---|---|---|---|---|
| 1 | Irreversible revocation hinges on a fixed 500ms recheck | 10/10 | MEDIUM | Discuss |
| 2 | Redis skips unreadable rows; the guard reads "no rows" and revokes | 10/10 | MEDIUM | Fix |
| 3 | No overall deadline; sequential, detached upstream refreshes on the token endpoint | 10/10 | MEDIUM | Fix |
| 4 | Default-on with a Go-only opt-out; upgrade impact not stated | 10/10 | MEDIUM | Discuss |
| 5 | Recheck delay and the race window it exists for are untested | 8/10 | MEDIUM | Fix |
| 6 | No Redis-backed coverage of the guard | 8/10 | MEDIUM | Fix |
| 7 | "Dead" rules duplicate the bearer validator's with no shared check | 7/10 | MEDIUM | Discuss |
| 8 | Detached revocation context is untested | 7/10 | MEDIUM | Fix |
| 9 | refreshGrantValidatorChain wiring has no unit test |
7/10 | MEDIUM | Fix |
| 10 | deadUpstream/checkRow control flow is hard to follow |
7/10 | MEDIUM | Discuss |
| 11 | Row-gone refresher errors are granted as transient | 10/10 | LOW | Fix |
| 12 | rejectionStands ErrExpired comment and refresh-token comparison |
8/10 | LOW | Fix |
| 13 | Constructor shape and unreachable interface methods | 8/10 | LOW | Discuss |
| 14 | Provider names built separately from the refresher's | 8/10 | LOW | Fix |
| 15 | Doc and comment drift around the refresh-grant seam | 8/10 | LOW | Fix |
Overall
This is a bug fix that adds a default-on refresh-grant validator to the embedded auth server, so a client that answers the bearer validator's 401 by refreshing is sent to a full authorization instead of looping on a dead tsid. The approach is sound. The guard runs after the built-in refresh handler has authenticated the client and loaded the grant. It uses the same storage and the same shared refresher as the other refresh paths. It revokes only the requester's own grant family. It returns invalid_grant (400) rather than a plain error. Fail-open on transient errors and the pre-decision refresh of idle rows are sensible choices. The classification of "dead" agrees with the bearer validator today.
None of the findings blocks merge. The ones worth discussing share one theme: the guard's refusal also revokes, and revocation cannot be undone. A fixed 500ms recheck decides a lost-replica race heuristically (#1), and a Redis read that silently skips unreadable rows is indistinguishable from an absent session (#2). A session-level deadline is also missing, so a slow IdP can hold the token endpoint (#3). The remaining findings are test gaps (the real recheck delay, the detached revocation context, Redis, the wiring), a few places where a row that is gone is granted instead of refused, and readability and doc drift.
Documentation
docs/arch/11-auth-server-storage.md(line 439): the paragraph above the new text still presentsConfig.RefreshGrantHandlersas the way to refuse a dead-credential refresh, andrefresh_grant_handlers.gostill describes its wrapper as running only embedder handlers. Both now run the built-in guard first.- The refusal list is written out in four places (type doc,
Config, the arch doc,docs/middleware.md) and the transient-failure lists already differ (the arch doc includes 429,Configdoes not). - State in the docs that the guard also bounds a refresh token's life by the upstream refresh token's liveness, and that the opt-out is available to Go embedders only.
Generated with Claude Code
Refusals whose cause could be wrong (an IdP rejection that may be a lost replica race, no readable rows, a row deleted mid-refresh) now keep the refresh token, so a false refusal costs one re-authorization, not the grant. The whole check runs within a 10s budget and skips upstream calls once the request is cancelled. The recheck treats any different refresh token as a successor. The guard is built only when enabled, takes its provider set from the resolved upstreams, and returns a refusal value. Addresses #6930 review comments: - MEDIUM pkg/authserver/refresh_grant_guard.go (4362035263): don't revoke on an upstream rejection - MEDIUM pkg/authserver/refresh_grant_guard.go (4362035272): don't revoke when no rows are readable - MEDIUM pkg/authserver/refresh_grant_guard.go (4362035289): bound the check and honour cancellation - MEDIUM pkg/authserver/refresh_grant_guard_test.go (4362035304): test the recheck delay and race window - MEDIUM pkg/authserver/refresh_grant_guard_test.go (4362035312): Redis-backed integration cases - MEDIUM pkg/authserver/refresh_grant_guard_test.go (4362035330): test detached revocation context - MEDIUM pkg/authserver/server_impl.go (4362035336): unit test refreshGrantValidatorChain - MEDIUM pkg/authserver/refresh_grant_guard.go (4362035344): simplify checkRow/deadUpstream flow - LOW pkg/authserver/refresh_grant_guard.go (4362035354): refuse when the row is gone mid-refresh - LOW pkg/authserver/refresh_grant_guard.go (4362035362): fix recheck successor comparison - LOW pkg/authserver/refresh_grant_guard.go (4362035368): constructor never returns nil - LOW pkg/authserver/server_impl.go (4362035374): provider names from resolved upstreams Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep one canonical refusal list, with which causes revoke, in the arch doc and link to it from Config and the middleware doc. Record the upgrade impact, the refresh-token lifetime bound and that the opt-out is Go-only (exposing it is tracked in #6931). Update the seam docs that still described dead-credential refusal as the embedder's job. Addresses #6930 review comments: - MEDIUM pkg/authserver/config.go (4362035295): state upgrade impact and Go-only opt-out - LOW docs/arch/11-auth-server-storage.md (4362035387): fix doc drift around the seam Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
All 15 earlier threads are resolved. The fixes match the code, tests and docs at Two things are still worth changing before this leaves draft. 1. The PR body still describes the old behavior.
2. Revoking on an unconfigured provider rests on this replica's config, not on the stored rows. For awareness, no change requested: a refusal that keeps the refresh token means a client that retries the same token repeats the check each time. For a dead upstream rejection, each retry makes another IdP refresh call, holds the request for the 500ms recheck, and logs another WARN, whereas a revoked token was refused once. Well-behaved clients treat Generated with Claude Code |
Whether a row's provider is configured is decided by the serving replica's config, not the stored rows, and an old replica in a rolling deploy that adds an upstream does not know the new provider yet. A false revoke cannot be undone, so this cause now refuses without revoking, like the other causes that could be wrong. Document the cost of keeping refused tokens: each retry repeats the check. Addresses #6930 review comments: - MEDIUM body:2 (6370757156): don't revoke on a config-based refusal - INFO body:note (6370757156): note the cost of retrying a kept token Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks. Both points are addressed:
On the retry note: also in e904668. The arch doc now describes the cost of keeping a refused token (another IdP call, the 500ms recheck and another WARN on each retry) and why it is bounded. The PR body repeats it as trade-off 3. |
jhrozek
left a comment
There was a problem hiding this comment.
Nice work. The fosite ordering, the keep-vs-revoke split and the cross-replica recheck all check out against fosite 0.49 and both storage backends. Approving. I left a few inline comments; feel free to address them before merging or in a follow-up, your call. The one I'd look at first is about a kept refresh token re-running the full IdP check on every replay, and there's a cheap way to bound that.
Addresses #6930 review comments: - MEDIUM pkg/authserver/refresh_grant_guard.go (4379615535): delete all of the session's upstream rows once an IdP rejection is confirmed, so a replay of the kept refresh token is refused without another IdP call or recheck on any replica; log refusals at WARN only when they revoke or follow a confirmed rejection, DEBUG otherwise - MEDIUM pkg/authserver/refresh_grant_guard.go (4379615562): log whether revocation actually succeeded, and add client_id to the revocation failure line All rows go rather than only the rejected provider's: with siblings kept, a replay would see only usable rows and be granted a token that never gets a credential for the rejected provider. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The token endpoint now reaches the shared refresher through the refresh guard, so its WARN/ERROR lines and error strings carried the token session ID, which the bearer validator and the guard keep at DEBUG as credential-adjacent. Move it to DEBUG companion lines and drop it from error strings. Addresses #6930 review comments: - MEDIUM pkg/authserver/refresh_grant_guard.go (4379615583): refresher logs tsid as session_id at WARN/ERROR and in error strings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pkg/authserver tests do not compile on main. #6930 gave buildProvider two more parameters (the upstream token refresher and the upstreams), and #6945 added BuildProviderForTest in export_test.go, which still called it with three arguments. Each change passed CI on its own; together they broke the Main build. Pass nil for both. BuildProviderForTest skips upstream construction on purpose, and a nil refresher leaves the upstream refresh guard out. The guard would sit inside the refreshGrantValidators wrapper, which is the one handler the tests that use this helper can see, so the handler list they check is the same. Co-authored-by: Mecatl <noreply@mecatl.dev>
Summary
A refreshed access token keeps its token session (
tsid), so it keeps that session's upstream credential too. When that credential is dead, the bearer validator answers every request with 401. A client that handles the 401 by redeeming its refresh token gets a 200 with new tokens for the same dead session, and keeps looping without ever signing in again. This PR is item 3 of #6901. Items 1 and 2 landed in #6902.Adds a built-in upstream refresh guard, a refresh-grant validator that is on by default whenever at least one upstream is configured. When the session's upstream credential is definitively dead, it refuses the
refresh_tokengrant withfosite.ErrInvalidGrant(HTTP 400invalid_grant), which sends the client to a full authorization and a newtsid.It revokes the refresh token (and the grant's access tokens) only when the stored rows prove the credential can never be used again. Otherwise it refuses and keeps the token, so a wrong refusal costs one re-authorization, not the grant. Here is every cause; the arch doc ("Upstream refresh guard") holds the canonical copy:
invalid_grant(upstream.ErrGrantRejected)Before deciding, the guard refreshes an expired row through the server's shared refresher, the same singleflight instance the other refresh paths use, one provider at a time. Upstream refresh is otherwise lazy, so an idle client often has an expired stored token whose upstream refresh token still works. Deciding from stored state alone would force every idle client to sign in again.
Some outcomes are uncertain, and those grant the refresh:
Sessions without a
tsidare not checked.The guard runs ahead of the embedder's
Config.RefreshGrantHandlers, so those handlers only see grants the guard accepted.Adds
Config.DisableUpstreamRefreshGuardas an opt-out, for Go embedders only. A RunConfig/CRD switch is tracked in Expose the upstream refresh guard opt-out in RunConfig and CRDs #6931.Updates
docs/arch/11-auth-server-storage.md,docs/middleware.mdand the seam's comments to describe the new behavior.How this addresses the follow-up notes in #6901 (comment):
invalid_grant. The guard waits 500ms and reads the row again. If the row now holds any different refresh token, the session counts as live and the grant goes through. If the recheck cannot complete (the request was cancelled or the read failed), the result is uncertain and the grant goes through. A confirmed rejection refuses but does not revoke, so a winner whose write lands after the recheck costs the client one re-authorization, not its grant.fosite.ErrInvalidGrant(400), not a plain error, which fosite would turn into a 500.Closes #6901
Type of change
Test plan
task test)task test-e2e): not passing locally. See below.task lint-fix), 0 issuestask test-e2ecould not complete on the author's machine, for reasons unrelated to this change:thvcall failed with "CLI conflict detected".TOOLHIVE_SKIP_DESKTOP_CHECK=1. It hit the 20-minute suite timeout after 65 of 544 specs: 47 passed and 18 failed.None of the failures exercise the embedded auth server:
Relying on CI for e2e.
New tests (the guard tests also pass under
-race):TestUpstreamRefreshGuard: table-driven tests covering every refuse and grant outcome and whether each revokes. Also covers:TestUpstreamRefreshGuard_Gates: the constructor rejects missing storage or refresher and wires the recheck delay and check budget; the guard runs only on therefresh_tokengrant.TestRefreshGrantValidatorChain: the guard runs before embedder handlers; disabled and no-upstream configs leave only the embedder handlers; the embedder's slice is not written.TestUpstreamRefreshGuard_StorageFailures: storage read failures grant the refresh; a failed revocation still refuses; revocation runs on a live, bounded context even when the request is cancelled.TestUpstreamRefreshGuard_RealIdPRejection: aninvalid_grantreturned by a real IdP is classified asErrGrantRejectedand refused, and a 5xx is granted.TestIntegration_UpstreamRefreshGuard: end-to-end through the token endpoint with mockoidc, on memory and on Redis (miniredis) storage. Cases:After a refusal it restores a live row and checks that the same refresh token works again (kept) or fails (revoked).
Changes
pkg/authserver/refresh_grant_guard.goupstreamRefreshGuardrefresh-grant validatorpkg/authserver/server_impl.gopkg/authserver/config.goDisableUpstreamRefreshGuardand updates theRefreshGrantHandlersdocspkg/authserver/refresh_grant_handlers.gopkg/authserver/refresh_grant_guard_test.gopkg/authserver/integration_test.gowithoutUpstreamRefreshGuardtest optiondocs/arch/11-auth-server-storage.mddocs/middleware.mdinvalid_grantDoes this introduce a user-facing change?
Yes. The guard is on by default whenever an upstream is configured.
tsid, so the client looped on 401.invalid_grantand the client has to go through a full authorization. The refresh token and the grant's access tokens are revoked only when the session has rows but no access token, or an expired row has no refresh token. For the other causes the refresh token is kept and refused again on each retry.Config.DisableUpstreamRefreshGuard. There is no RunConfig, CLI or CRD setting yet (Expose the upstream refresh guard opt-out in RunConfig and CRDs #6931).Special notes for reviewers
Accepted trade-offs:
invalid_grantas terminal.storage.ErrNotFound, so the guard grants. The next refresh grant finds no row and refuses. A refresher error that wrapsstorage.ErrNotFoundrefuses.pkg/authandupstreamtoken.Revocation runs on a context detached from the request, with a 5s timeout. This way a client that disconnects right after a revoking refusal does not leave the refresh token live.
🤖 Generated with Claude Code