Report a rejected stored credential as re-login required - #6389
Merged
aponcedeleonch merged 2 commits intoAug 24, 2026
Merged
Conversation
When a non-interactive token source exhausted every cache tier, Token() returned the last error verbatim. For a refresh token the IdP had rejected (expired, revoked, or rotated out from under us) that meant the raw invalid_grant surfaced instead of the caller's FallbackErr sentinel. That inverted the two outcomes. A cache MISS got the actionable "you must log in again" error, while a DEAD credential, the case that genuinely requires an interactive login, got an opaque OAuth error that every downstream consumer reads as a transient provider fault. The LLM proxy renders it as a 502 server_error rather than the 401 that names `thv llm setup`, so a user whose refresh token died sees a gateway problem and has nothing to act on. Token() now classifies the terminal error: a permanent token-endpoint verdict is reported as FallbackErr with the cause still reachable via errors.As, while everything else (5xx, 429, a WAF page, a locked keyring) keeps surfacing verbatim so callers can retry or fix the real problem. The rendered message carries only the sentinel and the RFC 6749 error code, never the raw token-endpoint response body, which can echo back bearer material. The stored credential is deliberately not deleted on a rejection. With an IdP that rotates refresh tokens, a sibling process may have just written a newer token under the same key and the next call re-reads the secrets provider; deleting here would destroy that cross-process recovery. The transient/permanent rules move to a new leaf package, pkg/auth/oautherr, so the token source and the workload auth monitor share one implementation instead of each carrying its own copy. The monitor's private helpers now delegate to it, leaving its behaviour and tests unchanged. ErrTokenRequired's wording widens from "no cached credentials found" to "no usable cached credentials", which is accurate for both an empty cache and a rejected one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aponcedeleonch
requested review from
ChrisJBurns,
JAORMX,
jhrozek,
rdimitrov and
tgrunnagle
as code owners
August 19, 2026 16:20
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6389 +/- ##
==========================================
+ Coverage 73.04% 77.72% +4.67%
==========================================
Files 745 750 +5
Lines 79208 72128 -7080
==========================================
- Hits 57857 56060 -1797
+ Misses 17300 16063 -1237
+ Partials 4051 5 -4046 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jhrozek
reviewed
Aug 19, 2026
The first cut reused the monitor's broad "permanent token-endpoint error" classifier to decide that the stored credential was dead. Those are not the same question. invalid_client, unauthorized_client, and invalid_scope are equally permanent and equally pointless to retry, but they indict the client registration or the requested scopes, not the refresh token. Running the login flow again against the same broken configuration reproduces them exactly, so reporting them as FallbackErr sent the user in a circle and hid the error code that named the real problem. Only an RFC 6749 invalid_grant means the refresh token itself was rejected and a fresh interactive login is the remedy. A new oautherr.IsRejectedRefreshGrant predicate encodes that, leaving IsPermanentCredentialError untouched for the monitor, which asks the broader "should I stop retrying" question. Narrowing also removes the last untrusted interpolation from the rendered message. The 'error' field is arbitrary server-chosen text that may carry secrets or control characters, and the previous leak test only planted its secret in a separate response-body field, so that path went unexercised. Since the sentinel error is now built for invalid_grant alone, the code conveys nothing the fixed sentence does not, and Error() interpolates only the caller's own sentinel. Diagnostics still reach the exact verdict through errors.As. RetrieveErrorCode loses its only caller and goes with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jhrozek
approved these changes
Aug 21, 2026
aponcedeleonch
deleted the
fix/token-source-permanent-credential-errors
branch
August 24, 2026 09:12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A non-interactive token source that had exhausted every cache tier returned its last error verbatim. For a refresh token the IdP had rejected (expired, revoked, or rotated out from under us) that meant a raw
invalid_grantsurfaced instead of the caller'sFallbackErrsentinel.That inverts the two outcomes:
ErrTokenRequired, the actionable "you must log in again" error.Concretely,
pkg/llm/proxykeys offErrTokenRequiredto return a 401 namingthv llm setup, and everything else becomes a 502server_error. So a user whose refresh token died was told the gateway was having a problem, with nothing to act on, and retry loops kept hammering the token endpoint with a credential the IdP had already refused.This was found while diagnosing a real incident: an agent lost gateway access for nearly three hours because the only error it could show was "provider unhealthy, retry in 30s", while the underlying
invalid_grantnever surfaced.What changed:
Token()now classifies the terminal error. An RFC 6749invalid_grantis reported asFallbackErrwith the cause still reachable viaerrors.As; everything else keeps surfacing verbatim so callers can retry or fix the real problem.invalid_grantalone.invalid_client,unauthorized_client, andinvalid_scopeare just as permanent and just as pointless to retry, but they indict the client registration or the requested scopes rather than the refresh token, so a fresh login reproduces them unchanged. Reporting them asFallbackErrwould send the user in a circle while hiding the code that names the real problem.*oauth2.RetrieveError's ownError()embeds it, and a token endpoint can echo back bearer material) and the parsederrorfield (arbitrary server-chosen text that may carry secrets or control characters) are untrusted. Since the sentinel is only ever built forinvalid_grant, the code conveys nothing the fixed sentence does not. There are tests pinning this.pkg/auth/oautherr, so the token source and the workload auth monitor share one implementation. The monitor keeps the broadIsPermanentCredentialError("should I stop retrying"); the token source uses the narrowerIsRejectedRefreshGrant("is the stored credential dead"). The monitor's behaviour and tests are unchanged.ErrTokenRequired's wording widens from "no cached credentials found" to "no usable cached credentials", which is accurate for both an empty cache and a rejected one.Deliberately not done: the rejected credential is not deleted from the secrets provider. With an IdP that rotates refresh tokens, a sibling process may have just written a newer token under the same key, and the next
Token()call re-reads the provider. Deleting on a rejection would destroy that cross-process recovery. Throttling comes from callers no longer treating the failure as retryable.Type of change
Test plan
task test)task lint-fix)go test -race ./pkg/auth/...andgo test ./pkg/llm/...are green;task lint-fixreports 0 issues.New tests in
pkg/auth/tokensource, each verified to fail before the fix:RejectedGrant_ReportsFallbackErrinvalid_grantreports asFallbackErrRejectedGrant_KeepsCauseReachable*oauth2.RetrieveErrorstays reachable viaerrors.AsRejectedGrant_MessageCarriesNoServerTexterror_descriptionClientVerdict_SurfacesVerbatiminvalid_client,unauthorized_client, andinvalid_scopeare not reported as a dead credentialHostileErrorCode_NeverReachesSentinelerrorfield carrying CRLF and a secret cannot reach the sentinel messageTransientVerdict_SurfacesVerbatimUnparseableRejection_SurfacesVerbatimPlus table-driven suites for the new
pkg/auth/oautherrpackage covering both predicates.API Compatibility
v1beta1API.Changes
pkg/auth/oautherr/oautherr.goIsRejectedRefreshGrantpkg/auth/tokensource/tokensource.goclassifyTerminalError+credentialRejectedErrorpkg/auth/monitored_token_source.gooautherr; no behaviour changepkg/llm/tokensource.goErrTokenRequiredwording covers a rejected credentialDoes this introduce a user-facing change?
Yes. A user whose stored LLM gateway credential has expired or been revoked now gets "authentication required, run
thv llm setup" instead of an opaque OAuth error, and the LLM proxy returns a 401 with that remediation rather than a 502server_error.Special notes for reviewers
The one judgement call worth scrutiny is the boundary of "the stored credential is dead". It is deliberately narrow: only the literal RFC 6749
invalid_grant, and only on a response the transient classifier does not already excuse (so a 429 or 5xx claiminginvalid_grantstill surfaces verbatim). Everything else, including the other permanent codes, keeps its own error so the operator sees what the IdP actually said.That narrowness is also what removes the last untrusted interpolation from the message: with only one possible code, there is nothing left to render. Note that on the verbatim path an
*oauth2.RetrieveErrorstill prints its response body, which is pre-existing behaviour this PR does not change.Note also that a locked keyring intentionally still surfaces verbatim rather than as
ErrTokenRequired. "Unlock your keyring" and "log in again" are different remediations, and an existing test pins that distinction.🤖 Generated with Claude Code