Skip to content

fix(auth,client): cover token refresh with tests and repair the MCP 401 retry - #165

Merged
tim-thacker-nullify merged 3 commits into
mainfrom
fix/refresh-token-coverage-and-forced-refresh
Aug 21, 2026
Merged

tim-thacker-nullify merged 3 commits into
mainfrom
fix/refresh-token-coverage-and-forced-refresh

Conversation

@tim-thacker-nullify

@tim-thacker-nullify tim-thacker-nullify commented Aug 21, 2026 •

Copy link
Copy Markdown
Member

Claude

Follow-up to #164. login.go had no tests, so refreshToken was broken from February until #164 and nothing caught it. The MCP transport's 401 retry resolved its replacement token via GetValidToken, which only refreshes once ExpiresAt has elapsed — a revoked token returned the same string, so the retry never fired.

Changes

  • internal/auth/login_test.go (new): refreshToken cookie/body/expiry paths, GetValidToken's expiry branch, ForceRefreshToken.
  • authBaseURL is now a package var so tests can point the three auth calls at an httptest server. No behaviour change.
  • auth.ForceRefreshToken refreshes regardless of stored expiry; ErrNotRefreshable when no refresh token is stored.
  • lib.RefreshNullifyToken walks GetNullifyToken's precedence chain but insists on a fresh token. --nullify-token and NULLIFY_TOKEN are fixed strings, so they report client.ErrTokenNotRefreshable.
  • refreshingAuthTransport takes a distinct refreshProvider for its 401 retry.
  • 30s backoff after a failed refresh. cachedAt was untouched on failure, so past the TTL every request re-entered the write-locked path for another 30s-timeout refresh.
  • Cookie loop skips empty access_token values and honours Expires when Max-Age is absent. Neither is reachable today; such a cookie would otherwise store expiresAt = now.
  • A forced refresh that makes no progress -- error, the same token back, or an empty one -- arms its own 30s backoff and leaves the cache alone. Otherwise every request against rejected credentials takes a blocking refresh under the write lock. Trade: an out-of-band nullify auth login heals a session within 30s, not on the next request.
  • Corrected fix: read refreshed access token from Set-Cookie header #164's comment: RefreshTokenOutput has 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 --> G
Loading

G was unreachable for stored credentials: forceRefresh called tokenProvider, which returns the rejected token unchanged while ExpiresAt is 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 Expires fallback, forceRefresh calling tokenProvider, and the backoff.

Adding accessToken to RefreshTokenOutput server-side was rejected: both SPAs call it with credentials: 'include', so it would expose an HttpOnly token to page scripts, and WhoAmIOutput is a type alias of it. A CLI-shaped /auth/cli/refresh is tracked separately.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…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 tim-thacker-nullify added the patch Patch version updates (fixes) label Aug 21, 2026
@tim-thacker-nullify
tim-thacker-nullify marked this pull request as ready for review August 21, 2026 12:28
tim-thacker-nullify and others added 2 commits August 21, 2026 22:48
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
@tim-thacker-nullify

Copy link
Copy Markdown
Member Author

Claude

Two findings noted but deliberately left out of this PR:

  • client.NewRefreshingNullifyClient has no callers anywhere, tests included. This PR widened its signature rather than deleting it. Deleting is right, but it is scope creep on a fix.
  • The provider closures in cmd/cli/cmd/mcp.go capture the command context, not req.Context(), so a refresh taken under the write lock does not observe request cancellation. Pre-existing on the getToken path; fixing it means TokenProvider becomes func(context.Context) (string, error).

@tim-thacker-nullify
tim-thacker-nullify merged commit 4af4e48 into main Aug 21, 2026
4 checks passed
@tim-thacker-nullify
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

patch Patch version updates (fixes)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant