Repository navigation
fix(auth,client): cover token refresh with tests and repair the MCP 401 retry - #165
Merged
tim-thacker-nullify merged 3 commits intoAug 21, 2026
Merged
Conversation
…01 retry internal/auth/login.go had no test coverage, so refreshToken was broken from February until #164 and nothing caught it. The MCP transport's 401 retry resolved its replacement token through GetValidToken, which only refreshes once ExpiresAt has elapsed, so a revoked token yielded the same string and the retry never fired. - add login_test.go: refreshToken cookie/body/expiry paths, GetValidToken expiry branch, ForceRefreshToken - add auth.ForceRefreshToken, which refreshes regardless of stored expiry - add lib.RefreshNullifyToken; fixed token sources (--nullify-token, NULLIFY_TOKEN) report client.ErrTokenNotRefreshable instead of replaying the same value - refreshingAuthTransport takes a distinct refreshProvider for its 401 retry - back off after a failed refresh so a down provider stops putting a blocking refresh in front of every request - skip empty access_token cookies and honour Expires when Max-Age is absent Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QxQZtuPdw7fc8P8nb794hG
tim-thacker-nullify
marked this pull request as ready for review
August 21, 2026 12:28
The 30s backoff added for the TTL path did not cover the 401 path, which this change to refreshProvider had just turned into a network call: every request against rejected credentials re-entered a blocking refresh under the write lock, forever. - refreshRetryAfter is a second clock, separate from the TTL one, because tokenProvider and refreshProvider are separate functions. - Arm it whenever a forced refresh cannot produce a token the caller has not already tried: provider error, the same token back, or an empty one. - The last two now leave the cache alone rather than overwriting a working token with the rejected or empty value. - Correct cookieLifetime's comment: its only caller cannot distinguish "no expiry given" from "expires now", and should not -- storing 0 would read as never-expires in GetValidToken. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197YS8554gBqdNQihMBR7HQ
RefreshNullifyToken shipped with no test, including the auth.ErrNotRefreshable -> client.ErrTokenNotRefreshable translation that decides whether the transport retries a 401. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197YS8554gBqdNQihMBR7HQ
Member
Author
|
Two findings noted but deliberately left out of this PR:
|
tim-thacker-nullify
deleted the
fix/refresh-token-coverage-and-forced-refresh
branch
August 21, 2026 12:59
scrut-nick
pushed a commit
to scrut-nick/nullify-cli
that referenced
this pull request
Aug 28, 2026
An MCP server almost never fails loudly. It either dies during startup and appears in the client as a server with no tools, or it starts, advertises its whole tool set, and answers every call with an empty result because its credential is rejected. Both read as "nothing to report" rather than "outage", and the second is worse: work gets published against data that was never there. Preflight now takes its server list from .mcp.json rather than a list kept by hand, so a newly added server cannot quietly become a blind spot - it is reported WARN "no probe defined" until someone gives it one. Each server is started through its real .mcp.json command, so the wrapper and argument wiring are under test too, not just the binary. Authorization probes move into .claude/mcp/health-probes.json, with three kinds: an HTTP call against a data endpoint, JWT inspection for expiry and token type, and a presence check for servers with no cheap authenticated endpoint. The file documents why latent-defense is probed on /api/infra/records and explicitly not on /auth/me, which serves login sessions and answers 401 to a valid API key - the cause of a six-day false outage. Results are written to .claude/mcp/health.json (gitignored, per-session) so a later session can read coverage instead of assuming it, and the session-start hook now prints an unmissable banner when anything is unusable. CLAUDE.md tells whoever comes next to read that file before reporting on security data, and that an empty result from a failing server means absent data, never "nothing found" - the loop that failed the first time round. Also adds `make preflight`, and records upstream's refresh-token contract and the one fix (Nullify-Platform#165) this fork still lacks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mcq6LfAVPzKZL5rpvww13R
scrut-nick
pushed a commit
to scrut-nick/nullify-cli
that referenced
this pull request
Aug 28, 2026
Our transport had no 401 handling at all. It refreshed on a five-minute TTL and, when the provider failed, fell back to the stale cached token - so against a credential revoked mid-life every single request failed, forever, with no attempt to fetch a replacement. That is the shape of the outage we just spent a week diagnosing. This ports the behaviour of upstream Nullify-Platform#165 onto our simpler transport rather than adopting the file wholesale, because this fork branched at Nullify-Platform#138 and the upstream version depends on Nullify-Platform#151/Nullify-Platform#158 that we do not carry. - refreshProvider is a second, distinct provider used only after a 401. tokenProvider returns the stored token untouched while it still looks valid, so reusing it for the retry would replay the rejected string - the exact bug Nullify-Platform#165 was written to fix. - auth.ForceRefreshToken refreshes regardless of the recorded expiry, and reports ErrNotRefreshable when the credentials carry no refresh token. - lib.RefreshNullifyToken reports client.ErrTokenNotRefreshable for sources that yield a fixed string (--nullify-token, NULLIFY_TOKEN), so the transport surfaces the 401 instead of retrying something that cannot change. - Separate backoff clocks for the TTL and 401 paths: one provider failing says nothing about the other, and without them a rejected credential puts a blocking refresh in front of every request. - A refresh that returns nothing, or returns the token just rejected, leaves the cache alone rather than overwriting a working value with junk. - Request bodies are buffered so a retried POST carries its payload. Tested: retry-and-succeed, fixed-source surfaces the 401, same-token and empty returns do not loop or clobber the cache, body replay, backoff suppression, concurrent rotation, non-401 passthrough, and provider-failure backoff. Upstream's own note on Nullify-Platform#165 was that this code went broken from February to August precisely because it had no tests. CLAUDE.md records what remains unported, including Nullify-Platform#159 - the GitHub PAT is still sent in a URL query string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mcq6LfAVPzKZL5rpvww13R
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.
Follow-up to #164.
login.gohad no tests, sorefreshTokenwas broken from February until #164 and nothing caught it. The MCP transport's 401 retry resolved its replacement token viaGetValidToken, which only refreshes onceExpiresAthas elapsed — a revoked token returned the same string, so the retry never fired.Changes
internal/auth/login_test.go(new):refreshTokencookie/body/expiry paths,GetValidToken's expiry branch,ForceRefreshToken.authBaseURLis now a package var so tests can point the three auth calls at anhttptestserver. No behaviour change.auth.ForceRefreshTokenrefreshes regardless of stored expiry;ErrNotRefreshablewhen no refresh token is stored.lib.RefreshNullifyTokenwalksGetNullifyToken's precedence chain but insists on a fresh token.--nullify-tokenandNULLIFY_TOKENare fixed strings, so they reportclient.ErrTokenNotRefreshable.refreshingAuthTransporttakes a distinctrefreshProviderfor its 401 retry.cachedAtwas untouched on failure, so past the TTL every request re-entered the write-locked path for another 30s-timeout refresh.access_tokenvalues and honoursExpireswhenMax-Ageis absent. Neither is reachable today; such a cookie would otherwise storeexpiresAt = now.nullify auth loginheals a session within 30s, not on the next request.RefreshTokenOutputhas no token field, so the cookie is the sole source, not a fallback.flowchart TD A["RoundTrip gets 401"] --> B["forceRefresh"] B --> C{"token source"} C -->|"--nullify-token / NULLIFY_TOKEN"| D["ErrTokenNotRefreshable<br/>surface the 401, no retry"] C -->|"GitHub Actions"| E["re-exchange PAT"] C -->|"stored credentials"| F["ForceRefreshToken<br/>ignores ExpiresAt"] E --> G["retry once with the new token"] F --> GGwas unreachable for stored credentials:forceRefreshcalledtokenProvider, which returns the rejected token unchanged whileExpiresAtis in the future. The Actions branch already re-exchanged the PAT and is unchanged here.Evidence
Each regression test is mutation-proved — reverting the matching production line fails exactly that test: #164's cookie block, the empty-value skip, the
Expiresfallback,forceRefreshcallingtokenProvider, and the backoff.Adding
accessTokentoRefreshTokenOutputserver-side was rejected: both SPAs call it withcredentials: 'include', so it would expose anHttpOnlytoken to page scripts, andWhoAmIOutputis a type alias of it. A CLI-shaped/auth/cli/refreshis tracked separately.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.