-
Notifications
You must be signed in to change notification settings - Fork 283
fix(authserver): scope upstream-token callback cleanup to the chain's providers #6476
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1a479f1
a5d4e6c
9c4e1a7
ac6b820
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -150,8 +150,8 @@ func (h *Handler) CallbackHandler(w http.ResponseWriter, req *http.Request) { | |
|
|
||
| // Place the resolved canonical user in the request context so callback storage | ||
| // calls that carry no tokens argument — GetAllUpstreamTokens during chain | ||
| // consistency, DeleteUpstreamTokens on cleanup — can resolve the user from | ||
| // context. We use WithPlatformUser, not WithIdentity: no ToolHive bearer has | ||
| // consistency, DeleteUpstreamTokensForProvider on cleanup — can resolve the user | ||
| // from context. We use WithPlatformUser, not WithIdentity: no ToolHive bearer has | ||
| // been issued at the callback, so there is no authenticated identity to assert — | ||
| // only the canonical user for storage keying. (StoreUpstreamTokens does not need | ||
| // this; it keys off tokens.UserID below.) | ||
|
|
@@ -182,8 +182,10 @@ func (h *Handler) CallbackHandler(w http.ResponseWriter, req *http.Request) { | |
| slog.Error("failed to store upstream tokens", | ||
| "error", err, | ||
| ) | ||
| // Clean up any tokens stored by earlier legs of a multi-upstream chain. | ||
| _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) | ||
| // 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}) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. blocker: A failed |
||
| h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("failed to store session")) | ||
| return | ||
| } | ||
|
|
@@ -398,19 +400,20 @@ func (h *Handler) handleUpstreamError( | |
| pending, err := h.storage.LoadPendingAuthorization(ctx, internalState) | ||
| if err == nil { | ||
| _ = h.storage.DeletePendingAuthorization(ctx, internalState) | ||
| // Clean up any upstream tokens stored by earlier legs of a multi-upstream chain. | ||
| // On a subsequent leg the resolved user is carried in pending.ResolvedUserID; | ||
| // place it in ctx via WithPlatformUser so a context-keyed storage decorator can | ||
| // resolve the canonical user for this delete (DeleteUpstreamTokens takes no tokens | ||
| // argument). WithPlatformUser, not WithIdentity: there is no authenticated identity | ||
| // at the callback, only the storage-scoped user. A first-leg error has no resolved | ||
| // user and no earlier-leg tokens to clean up, so the bare ctx is correct there. | ||
| // Clean up only this leg's token (see cleanupUpstreamTokens); earlier legs are | ||
| // the user's own valid tokens and are left intact. This leg failed at the | ||
| // upstream IdP before a token was stored, so the delete is normally a no-op, | ||
| // but it also removes a row left by a prior attempt on the same leg. On a | ||
| // subsequent leg the resolved user is carried in pending.ResolvedUserID; place | ||
| // it in ctx via WithPlatformUser so a context-keyed storage decorator resolves | ||
| // the canonical user for the cleanup. WithPlatformUser, not WithIdentity: there | ||
| // is no authenticated identity at the callback, only the storage-scoped user. | ||
| 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}) | ||
|
Comment on lines
411
to
+416
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
| ar := h.buildAuthorizeRequesterFromPending(ctx, pending) | ||
| if ar != nil { | ||
|
|
@@ -428,6 +431,53 @@ func (h *Handler) handleUpstreamError( | |
| http.Error(w, "upstream authentication failed", http.StatusBadGateway) | ||
| } | ||
|
|
||
| // cleanupUpstreamTokens best-effort removes upstream tokens on a failed authorization | ||
| // leg, one provider at a time. Callers pass the leg currently being processed, so an | ||
| // ordinary error — a transient store/read failure, or a chain that cannot be resolved — | ||
| // takes down only that leg's token and leaves the user's earlier legs intact. That | ||
| // matters under a storage decorator that re-keys upstream tokens by (gateway, user), | ||
| // where each earlier leg is the user's own live credential for another connector rather | ||
| // than throwaway session state, so a failed connector login must not delete the others. | ||
| // The one exception is the identity-mismatch fail-closed, which passes the whole chain | ||
| // to tear down a session whose stored identity is no longer trustworthy. | ||
| // | ||
| // It deletes per provider rather than calling the session-wide DeleteUpstreamTokens | ||
| // because that method carries no provider name: a (gateway, user)-keyed decorator cannot | ||
| // narrow a session-wide delete back to one leg and would delete every connector token | ||
| // the user holds on the gateway. Naming the provider lets such a decorator scope the | ||
| // delete to exactly the named leg(s). | ||
| // | ||
| // Each name is checked against the configured upstream set before it is deleted, so a | ||
| // name that is not a configured upstream — e.g. from a corrupted or tampered pending | ||
| // row reaching a pre-resolveChain cleanup site, which unlike the post-resolve sites has | ||
| // not been through validateChain — is skipped rather than turned into a delete. | ||
| // | ||
| // providers is the set of provider names whose upstream-token rows should be removed; | ||
| // empty entries and names outside the configured upstream set are skipped, and deleting | ||
| // an already-absent row is a no-op (the storage contract makes a missing-row delete | ||
| // non-fatal), so a repeated name is harmless. Errors are logged and swallowed — cleanup | ||
| // is best-effort and must not mask the original failure. | ||
| func (h *Handler) cleanupUpstreamTokens(ctx context.Context, sessionID string, providers []string) { | ||
| for _, provider := range providers { | ||
| if provider == "" { | ||
| continue | ||
| } | ||
| // Only a configured upstream can legitimately have a stored token, so skip | ||
| // anything else: it keeps a tampered pending row reaching a pre-resolveChain | ||
| // cleanup site from naming an arbitrary delete target. The post-resolveChain | ||
| // sites pass a validateChain-checked chain, so this is a no-op for them. | ||
| if _, ok := h.upstreamByName(provider); !ok { | ||
| continue | ||
| } | ||
| if err := h.storage.DeleteUpstreamTokensForProvider(ctx, sessionID, provider); err != nil { | ||
|
tgrunnagle marked this conversation as resolved.
|
||
| slog.Warn("failed to clean up upstream token for provider", //nolint:gosec // G706 - provider name from server-side chain state | ||
| "provider", provider, | ||
| "error", err, | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // continueChainOrComplete checks whether all upstream providers in the authorization | ||
| // chain have been satisfied. If so, it issues the authorization code and redirects | ||
| // to the client. If not, it redirects to the next upstream provider to continue | ||
|
|
@@ -476,32 +526,43 @@ func (h *Handler) continueChainOrComplete( | |
| chain, err := h.resolveChain(ctx, pending, principal) | ||
| if err != nil { | ||
| slog.Error("failed to resolve upstream chain", "error", err) | ||
| _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) | ||
| // Clean up only this leg's token; earlier legs are the user's own valid tokens | ||
| // and are left intact. This is the issue's most-reachable trigger — a transient | ||
| // filter/Directory failure on leg 1 — so scoping it to the one leg is what keeps | ||
| // a blip from taking the user's other connectors down with it. | ||
| h.cleanupUpstreamTokens(ctx, sessionID, []string{pending.UpstreamProviderName}) | ||
| h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("failed to determine authorization chain")) | ||
| return | ||
| } | ||
|
|
||
| nextProvider, err := h.nextMissingUpstream(ctx, sessionID, chain) | ||
| if err != nil { | ||
| slog.Error("failed to determine next upstream", "error", err) | ||
| _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) | ||
| // Clean up only this leg's token; earlier legs are left intact. | ||
| h.cleanupUpstreamTokens(ctx, sessionID, []string{pending.UpstreamProviderName}) | ||
| h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("failed to check authorization chain state")) | ||
| return | ||
| } | ||
|
|
||
| if nextProvider == "" { | ||
| 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) | ||
|
Comment on lines
548
to
+554
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. blocker: |
||
| h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("identity verification failed")) | ||
| return | ||
| } | ||
|
|
||
| // All upstreams satisfied — issue authorization code | ||
| if err := h.writeAuthorizationResponse(ctx, w, pending, sessionID, subject, name, email); err != nil { | ||
| slog.Error("failed to create authorization response", "error", err) | ||
| _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) | ||
| // 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}) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("failed to create authorization code")) | ||
| } | ||
| return | ||
|
|
@@ -539,7 +600,8 @@ func (h *Handler) continueChainOrComplete( | |
|
|
||
| if err := h.storage.StorePendingAuthorization(ctx, secrets.State, nextPending); err != nil { | ||
| slog.Error("failed to store next chain leg", "error", err) | ||
| _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) | ||
| // Clean up only this leg's token; earlier legs are left intact. | ||
| h.cleanupUpstreamTokens(ctx, sessionID, []string{pending.UpstreamProviderName}) | ||
| h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("failed to continue authorization chain")) | ||
| return | ||
| } | ||
|
|
@@ -553,15 +615,17 @@ func (h *Handler) continueChainOrComplete( | |
| if !ok { | ||
| slog.Error("next upstream provider not found", "provider", nextProvider) | ||
| _ = h.storage.DeletePendingAuthorization(ctx, secrets.State) | ||
| _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) | ||
| // Clean up only this leg's token; earlier legs are left intact. | ||
| h.cleanupUpstreamTokens(ctx, sessionID, []string{pending.UpstreamProviderName}) | ||
| h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("upstream provider configuration error")) | ||
| return | ||
| } | ||
| nextURL, err := nextUpstream.AuthorizationURL(secrets.State, secrets.PKCEChallenge, authOpts...) | ||
| if err != nil { | ||
| slog.Error("failed to build next upstream authorization URL", "error", err) | ||
| _ = h.storage.DeletePendingAuthorization(ctx, secrets.State) | ||
| _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) | ||
| // Clean up only this leg's token; earlier legs are left intact. | ||
| h.cleanupUpstreamTokens(ctx, sessionID, []string{pending.UpstreamProviderName}) | ||
| h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("failed to build authorization URL")) | ||
| return | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.