Skip to content

fix(authserver): scope upstream-token callback cleanup to the chain's providers - #6476

Open
tgrunnagle wants to merge 4 commits into
mainfrom
fix/upstream-token-cleanup-provider-scoped
Open

fix(authserver): scope upstream-token callback cleanup to the chain's providers#6476
tgrunnagle wants to merge 4 commits into
mainfrom
fix/upstream-token-cleanup-provider-scoped

Conversation

@tgrunnagle

Copy link
Copy Markdown
Collaborator

Summary

The /oauth/callback error paths clean up "the tokens this authorization collected" by calling the session-wide DeleteUpstreamTokens(ctx, sessionID), which removes every provider row under that session. That is correct for the native (session, provider) storage scheme, where each authorization mints its own session.

It is wrong under a storage decorator that re-keys upstream tokens by (gateway, user) so a session-independent caller (e.g. a user-facing UI) can find a user's tokens. Under that scheme every one of a user's connector tokens shares the session slot, so a session-wide delete on any failing login leg deletes the user's entire connector set. A transient identity-provider/storage blip on the most-traversed leg (chain resolution) wipes a user's whole historical token set, and correlated failures widen that to every user logging in during the window. It fails safe (never grants access) and is largely self-healing, but the availability hit is real.

The callback is the only production caller of the session-wide delete. This PR:

  • Routes all nine callback cleanup sites through a new cleanupUpstreamTokens helper that deletes per provider via DeleteUpstreamTokensForProvider (already on the storage.Storage interface, implemented by both memory and redis), sourcing the provider list from the chain config — never a storage read, which a broadening decorator cannot scope. Sites that hold the resolved chain pass it; pre-resolve sites pass pending.ChainUpstreams plus the leg currently being processed.
  • Lets a user-keyed decorator scope each delete to exactly this chain's providers, so a connector outside the chain is never touched.

Behavior note: a stale subsequent-leg pending with no ChainUpstreams can only name the leg just processed; an earlier leg it cannot name is left to expire by TTL rather than risk a session-wide delete widening to the whole user. This matches the existing abandoned-flow orphan behavior already noted in the chain-continuation TODO.

Type of change

  • Bug fix

Test plan

  • Unit tests (task test) — full pkg/authserver/... suite passes
  • Linting (task lint-fix) — golangci-lint clean on the changed package

Adds TestCallbackHandler_Cleanup_LeavesOutOfChainProviderTokens, which stores a token for a provider outside the login's chain under the same session slot and asserts it survives a chain cleanup (while the failing chain's own provider token is removed). Extends the handler storage mock with a DeleteUpstreamTokensForProvider capture, and updates the two existing tests whose assertions depend on the cleanup scope.

API Compatibility

  • This PR does not break the v1beta1 API. No operator API surface is touched; DeleteUpstreamTokensForProvider already exists on the storage interface.

Does this introduce a user-facing change?

No behavior change for the native single-session storage scheme. For deployments layering a (gateway, user)-keyed token store, a failed login leg no longer deletes the user's other connectors' upstream tokens.

Special notes for reviewers

  • The one intentional semantic change is at the stale-pending fail-closed path (TestCallbackHandler_SubsequentLeg_MissingChain_FailsClosed): it now cleans only the just-stored leg and leaves an unnameable earlier leg to TTL, rather than wiping the whole session. This is the inherent trade of provider-scoped cleanup vs. stamping each row with an authorization id; happy to switch to the stamping approach if reviewers prefer the earlier leg also be cleaned.

… providers

The /oauth/callback error paths cleaned up "the tokens this authorization
collected" by calling the session-wide DeleteUpstreamTokens(ctx, sessionID),
which removes every provider row under that session. That is correct for the
native (session, provider) storage scheme, where each authorization mints its
own session.

It is wrong under a storage decorator that re-keys upstream tokens by
(gateway, user) so a session-independent caller (e.g. a user-facing UI) can
find a user's tokens. Under that scheme every one of a user's connector tokens
shares the session slot, so a session-wide delete on any failing login leg
deletes the user's entire connector set — a transient Directory/storage blip on
the most-traversed leg (chain resolution) wipes a user's whole historical token
set, and correlated failures widen that to every user logging in during the
window. It fails safe (never grants access) and is largely self-healing, but the
availability hit is real.

The callback is the only production caller of the session-wide delete, so:

- Route all nine callback cleanup sites through a new cleanupUpstreamTokens
  helper that deletes per provider via DeleteUpstreamTokensForProvider (already
  on the storage interface, implemented by memory and redis), sourcing the
  provider list from the chain config — never a storage read, which a
  broadening decorator cannot scope. Sites with the resolved chain pass it;
  pre-resolve sites pass pending.ChainUpstreams plus the current leg.
- A user-keyed decorator can now scope each delete to exactly this chain's
  providers, so a connector outside the chain is never touched.

Behavior note: a stale subsequent-leg pending with no ChainUpstreams can only
name the leg just processed; an earlier leg it cannot name is left to expire by
TTL rather than risk a session-wide delete widening to the whole user. This
matches the existing abandoned-flow orphan behavior.

Adds a regression test asserting an out-of-chain provider's token survives a
chain cleanup, and extends the handler mock with a DeleteUpstreamTokensForProvider
capture.

Signed-off-by: Trey <tgrunnagle@gmail.com>
@github-actions github-actions Bot added the size/S Small PR: 100-299 lines changed label Aug 31, 2026
@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.73684% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 78.13%. Comparing base (450ba5f) to head (ac6b820).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
pkg/authserver/server/handlers/callback.go 94.73% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6476      +/-   ##
==========================================
+ Coverage   78.05%   78.13%   +0.08%     
==========================================
  Files         767      767              
  Lines       74343    74405      +62     
==========================================
+ Hits        58025    58133     +108     
+ Misses      16313    16267      -46     
  Partials        5        5              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tgrunnagle tgrunnagle left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Multi-Agent Consensus Review

Agents consulted: correctness, security, test-coverage, general-quality

Consensus Summary

# Finding Consensus Severity Action
1 Three cleanup call sites bypass validateChain, trusting an unvalidated stored chain 9/10 MEDIUM Fix
2 Stale comment still names the removed DeleteUpstreamTokens cleanup call 9/10 MEDIUM Fix
3 StoreUpstreamTokens-failure cleanup path has zero test coverage 8/10 MEDIUM Fix
4 3 of 9 cleanupUpstreamTokens call sites are never exercised by a test 8/10 MEDIUM Fix
5 Context-propagation test never asserts the token was actually deleted 8/10 MEDIUM Fix
6 (gateway, user) rationale is duplicated at call sites instead of centralized on the doc comment 7/10 LOW Fix
7 New WARN log lacks the //nolint:gosec // G706 annotation used elsewhere in this file 7/10 LOW Fix
8 The per-provider WARN-and-continue error path is never tested 7/10 LOW Fix
9 Mock doesn't replicate the real store's empty-input rejection 7/10 LOW Fix (optional)
10 Per-provider cleanup adds up to N sequential storage round-trips on error paths 7/10 LOW Fix (optional)
11 providers param doc describes current callers rather than the parameter's contract 7/10 LOW Fix (optional)

Overall

This is a well-targeted fix: it replaces 9 call sites of a session-wide upstream-token delete with a per-provider helper, so a storage layer that re-keys tokens more broadly than "one session, one login" can never have a single failing login leg wipe more than that leg's own chain. I traced every modified call site against how ChainUpstreams is populated and validated elsewhere in this package (resolveChain/computeChain/validateChain), and the provider lists constructed at each site are correct for normal operation. The new regression test is genuinely load-bearing — it fails against the pre-fix code path, not just against a mock that happens to agree with the new implementation.

The findings below are refinement, not pushback on the approach. The most substantive one is that three of the nine migrated call sites source their provider list straight from the stored pending.ChainUpstreams/pending.UpstreamProviderName without running it through the same validateChain check every other call site benefits from — low likelihood of triggering without a corrupted pending row, but worth closing for symmetry with the rest of the fix. The test-coverage gaps are the next tier: three call sites, plus the dedup logic (which actually fires in production on every non-first-leg failure), have no test today. The rest is small comment/consistency polish.

Documentation

The PR description's "Does this introduce a user-facing change?" section states "No behavior change for the native single-session storage scheme." That's very slightly overbroad: in the narrow edge case already flagged in "Special notes for reviewers" (a stale pending missing ChainUpstreams), native storage now also leaves an earlier leg's token alive until TTL instead of deleting it immediately — not only under a re-keying storage layer. Worth a one-line tightening of that claim; no code change implied.


Generated with Claude Code

Comment thread pkg/authserver/server/handlers/callback.go Outdated
Comment thread pkg/authserver/server/handlers/callback.go
Comment thread pkg/authserver/server/handlers/callback.go Outdated
Comment thread pkg/authserver/server/handlers/callback.go Outdated
Comment thread pkg/authserver/server/handlers/callback_test.go
Comment thread pkg/authserver/server/handlers/callback.go Outdated
Comment thread pkg/authserver/server/handlers/callback.go
Comment thread pkg/authserver/server/handlers/helpers_test.go
Comment thread pkg/authserver/server/handlers/callback.go Outdated
Comment thread pkg/authserver/server/handlers/callback.go Outdated
Addresses #6476 review comments:
- MEDIUM callback.go (3897108470): skip provider names that are not a configured
  upstream inside cleanupUpstreamTokens, so the three pre-resolveChain sites that
  pass an unvalidated stored chain can no longer name an arbitrary delete target;
  a no-op for the post-resolveChain sites, which already pass a validateChain-checked
  chain.
- MEDIUM callback.go (3897108480): the WithPlatformUser comment now names
  DeleteUpstreamTokensForProvider instead of the removed session-wide delete.
- LOW callback.go (3897108524): trim the (gateway, user) rationale duplicated at the
  two call sites down to their call-site nuance; the shared rationale lives on the
  cleanupUpstreamTokens doc comment.
- LOW callback.go (3897108537): annotate the cleanup WARN with //nolint:gosec // G706,
  matching the other server-storage-derived logs in this file.
- LOW callback.go (3897108558): note the per-provider round-trip cost (bounded to the
  chain length, error-path only) on the doc comment.
- LOW callback.go (3897108562): state the providers parameter's contract rather than
  what today's callers happen to pass.

Signed-off-by: Trey <tgrunnagle@gmail.com>
Addresses #6476 review comments:
- MEDIUM callback.go (3897108490): add withStoreUpstreamTokensError and a
  subsequent-leg store-failure test, exercising the one cleanup site where the
  dedup in cleanupUpstreamTokens is load-bearing (ChainUpstreams already contains
  the current provider).
- MEDIUM callback.go (3897108499): add tests for the nextMissingUpstream-failure
  site (GetAllUpstreamTokens error) and the writeAuthorizationResponse-failure site
  on a fully satisfied chain (GetClient failing only on its second call). The third
  branch it names — "next upstream provider not found" — is unreachable because
  resolveChain runs validateChain over the chain before it is walked, so every
  nextProvider is a configured upstream; covered by reply, not a test.
- MEDIUM callback_test.go (3897108506): the OnUpstreamErrorCleanup test now asserts
  the earlier-leg row was actually deleted, not merely that the ctx was captured.
- LOW callback.go (3897108545): add withDeleteUpstreamTokensForProviderError and a
  test proving cleanup continues past a per-provider delete failure to remove
  sibling providers.
- LOW helpers_test.go (3897108551): the DeleteUpstreamTokensForProvider mock now
  mirrors the real backends' rejection of an empty session or provider name.

Extracts multiUpstreamProviders and adds multiUpstreamTestSetupWithStorage so the
two-upstream setups can thread storage error injection without duplicating the mock
providers.

Signed-off-by: Trey <tgrunnagle@gmail.com>
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/S Small PR: 100-299 lines changed labels Aug 31, 2026
…hole chain

A failed authorization leg now cleans up only the leg being processed and leaves the
user's earlier legs intact, rather than deleting every provider in the chain. Under a
storage decorator that re-keys upstream tokens by (gateway, user), each earlier leg is
the user's own live credential for another connector, so a transient failure on one
login leg must not take the others down with it — most importantly at the
chain-resolution site, the issue's most-reachable trigger (a Directory/filter blip on
leg 1).

The one exception is the identity-mismatch fail-closed (verifyChainIdentity): a detected
mismatch means the session's stored state is no longer trustworthy, so that site still
tears down the whole chain.

- 8 of the 9 cleanup sites now pass just the current leg's provider; the
  verifyChainIdentity site keeps the whole-chain delete.
- Drop the now-dead dedup in cleanupUpstreamTokens: every caller passes a single
  provider or a validateChain-checked, duplicate-free chain, and a repeated name is a
  harmless idempotent no-op delete.
- Update the affected tests to assert earlier legs survive an ordinary error, and add a
  direct cleanupUpstreamTokens test covering the skip-unconfigured / skip-empty guard.

Signed-off-by: Trey <tgrunnagle@gmail.com>
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Aug 31, 2026
Comment on lines 411 to +416
if pending.SessionID != "" {
cleanupCtx := ctx
if pending.ResolvedUserID != "" {
cleanupCtx = auth.WithPlatformUser(ctx, pending.ResolvedUserID)
}
_ = h.storage.DeleteUpstreamTokens(cleanupCtx, pending.SessionID)
h.cleanupUpstreamTokens(cleanupCtx, pending.SessionID, []string{pending.UpstreamProviderName})

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.

blocker: This callback branches here before exchanging or storing a token for the current attempt. Under the gateway/user-keyed storage model introduced by this PR, deleting the pending upstream provider can therefore remove an existing credential from an earlier successful authorization—for example, canceling a Slack reconnection would disconnect the already-connected Slack account. Please limit cleanup here to the single-use pending authorization and add a regression test that seeds an existing current-provider token and verifies it survives error=access_denied.

Comment on lines 548 to +554
if err := h.verifyChainIdentity(ctx, sessionID, chain, subject); err != nil {
// verifyChainIdentity already logged the specific cause (with structured
// fields for a mismatch); here we just clean up and fail closed.
_ = h.storage.DeleteUpstreamTokens(ctx, sessionID)
// fields for a mismatch). This is the one cleanup site that tears down the
// WHOLE chain rather than just this leg: a detected identity mismatch means
// the session's stored state is no longer trustworthy, so we fail closed and
// remove every leg's token rather than leave a possibly cross-keyed row.
h.cleanupUpstreamTokens(ctx, sessionID, chain)

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.

blocker: verifyChainIdentity returns an error for both a confirmed identity mismatch and a transient GetAllUpstreamTokens failure, but this caller deletes the whole chain for either result. The callback reads chain state once in nextMissingUpstream and again here, so the first read can succeed while the second fails temporarily, causing all valid connector credentials to be deleted. Please distinguish the mismatch error and reserve whole-chain cleanup for that case; storage failures should still fail authorization but preserve the tokens. Add a test where the first bulk read succeeds and the second fails.

// Clean up only this leg's token (see cleanupUpstreamTokens): its store just
// failed, so this removes any partial or carried-forward row and is otherwise a
// no-op. Earlier legs are the user's own valid tokens and are left intact.
h.cleanupUpstreamTokens(ctx, sessionID, []string{providerID})

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.

blocker: A failed StoreUpstreamTokens call does not prove that this attempt created a row, so the subsequent delete can erase the previous valid credential under gateway/user keying. The carried-forward token exists only in the local value until the store succeeds, and the built-in Redis write is atomic. Please remove cleanup from this store-error path and add a regression test that seeds an existing current-provider token, injects a store failure, and verifies the existing token remains unchanged.

// The chain was fully satisfied and identity-verified; only the response
// write failed (a retryable server error). Clean up just this final leg and
// leave the earlier legs, so a retry re-collects only this one.
h.cleanupUpstreamTokens(ctx, sessionID, []string{pending.UpstreamProviderName})

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.

blocker: At this point the final upstream credential was exchanged and stored successfully, the chain is complete, and identity verification passed. A failure in writeAuthorizationResponse concerns ToolHive's downstream client lookup/redirect parsing/code generation and does not invalidate that credential. The single-leg branch explicitly preserves tokens for the same failure class, while this branch deletes the final provider. Please make the multi-leg behavior consistent by preserving all upstream tokens and update the test to assert both providers remain.

Comment on lines +1265 to +1268
// A token for a provider that is NOT part of this login's chain, stored under the
// same session slot — this is what a (gateway, user)-keyed decorator looks like from
// the backend's side: every connector the user holds shares one session key.
outOfChainKey := sessionID + ":provider-out-of-chain"

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.

suggestion: This test still catches regression to session-wide deletion, but its survivor is not a configured upstream, so it is also protected by cleanupUpstreamTokens's independent skip-unconfigured guard. It would not catch cleanup accidentally widening from the effective chain to every configured provider. Could we configure a third provider, exclude it through the upstream filter, and assert that its token survives? That would directly cover the PR's effective-chain scoping guarantee.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M Medium PR: 300-599 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants