Files
at-container-registry/pkg/appview/handlers/oauth_errors.go
T
Evan JarrettandClaude Fable 5 37bab324d7 fix OAuth refresh-token burn on client cancellation causing sign-outs
When a Docker client canceled a slow /auth/token request mid-refresh, the
token-refresh POST was aborted client-side but completed on the PDS, which
rotated the refresh token. The rotated token was never received or persisted,
so the next refresh replayed the consumed token, got invalid_grant, and the
session (OAuth + UI) was deleted, signing the user out everywhere.

- Detach refresh POSTs from the inbound request context via a per-session
  RoundTripper (WithoutCancel + 30s cap); once a refresh starts it completes
- Persist session updates (rotated tokens, DPoP nonces) on a detached context
- Gate session deletion on IsSessionInvalidError: cancellation, timeouts, and
  transport errors no longer delete sessions; genuine invalid_grant still does
- Add phase timing to /auth/token and per-DID lock wait warnings to attribute
  the ~14s pre-refresh stalls that push requests past Docker's deadline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 13:38:45 -05:00

77 lines
2.4 KiB
Go

package handlers
import (
"context"
"errors"
"log/slog"
"strings"
"atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/atclient"
"github.com/bluesky-social/indigo/xrpc"
)
// isOAuthError checks if an error indicates OAuth authentication failure
// These errors indicate the OAuth session is invalid and should be cleaned up
// Uses structured error types to avoid false positives from substring matching
func isOAuthError(err error) bool {
if err == nil {
return false
}
// A canceled or timed-out request says nothing about session validity;
// deleting the session on those signs the user out over a transient blip.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
// Check structured error types first
var xrpcErr *xrpc.Error
if errors.As(err, &xrpcErr) && (xrpcErr.StatusCode == 401 || xrpcErr.StatusCode == 403) {
return true
}
var apiErr *atclient.APIError
if errors.As(err, &apiErr) {
if apiErr.StatusCode == 401 || apiErr.StatusCode == 403 {
return true
}
if apiErr.Name == "InvalidToken" || apiErr.Name == "InsufficientScope" || apiErr.Name == "InvalidGrant" {
return true
}
}
// Fallback: check for known auth-specific error strings that won't
// appear in digests or URIs
errStr := strings.ToLower(err.Error())
return strings.Contains(errStr, "invalid_token") ||
strings.Contains(errStr, "invalid_grant") ||
strings.Contains(errStr, "use_dpop_nonce") ||
strings.Contains(errStr, "authentication failed") ||
strings.Contains(errStr, "token expired")
}
// handleOAuthError checks if an error is OAuth-related and invalidates UI sessions if so
// Returns true if the error was an OAuth error (caller should return early)
func handleOAuthError(ctx context.Context, refresher *oauth.Refresher, did string, err error) bool {
if !isOAuthError(err) {
return false
}
slog.Warn("OAuth error detected, invalidating sessions",
"component", "handlers",
"did", did,
"error", err)
// Invalidate all UI sessions for this DID. Detached context: once we
// decide to delete, the cleanup must finish even if the inbound request
// is canceled mid-way.
if delErr := refresher.DeleteSession(context.WithoutCancel(ctx), did); delErr != nil {
slog.Warn("Failed to delete OAuth session after error",
"component", "handlers",
"did", did,
"error", delErr)
}
return true
}