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. // // Deliberately absent: use_dpop_nonce. It means the DPoP nonce was stale, // which indigo normally handles by retrying with the server-supplied nonce — // a routine handshake step, not a dead session. Treating it as an auth error // signed users out over ordinary nonce rotation, the same // transient-misclassified-as-fatal bug this file's detached-delete logic // exists to avoid. // // It can still escape in one case: a server that returns error=use_dpop_nonce // with no DPoP-Nonce header leaves indigo nothing to retry with, and the // reason reaches us verbatim. We accept a stuck session there rather than // signing every user out over the common case; a server doing that is // broken, and the resulting failures are visible in the logs. errStr := strings.ToLower(err.Error()) return strings.Contains(errStr, "invalid_token") || strings.Contains(errStr, "invalid_grant") || 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. delCtx, cancelDel := context.WithTimeout(context.WithoutCancel(ctx), oauth.SessionDeleteTimeout) defer cancelDel() if delErr := refresher.DeleteSession(delCtx, did); delErr != nil { slog.Warn("Failed to delete OAuth session after error", "component", "handlers", "did", did, "error", delErr) } return true }