mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-30 20:57:01 +00:00
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>
77 lines
2.4 KiB
Go
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
|
|
}
|