mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
Follow-up to37bab32. That commit stopped deleting OAuth sessions on transient errors, which fixed spurious sign-outs but overshot on one path: a genuinely dead session stopped being evicted at all, turning a forced re-login into a permanent failure loop. GetOrFetchServiceToken flattened every non-200 from getServiceAuth into fmt.Errorf("service auth failed with status %d: %s"). IsSessionInvalidError then had nothing structured to inspect, and its string fallback could not help: it looks for the OAuth 2.0 code invalid_token, while atproto emits the XRPC name InvalidToken. The difference is the underscore, not the case, so lowercasing never bridged it. A revoked session came back 401 InvalidToken and was classified transient, so /auth/token returned 503 forever and the user was never prompted to re-authenticate. The non-200 branch now wraps an *atclient.APIError carrying the status and the parsed atproto error name, which is what the existing structured checks in IsSessionInvalidError already know how to read. Transient shapes stay transient: atprotoErrorName returns "" for a non-JSON body, so 500s with HTML, 502s, and 429s do not evict. ExpiredToken is deliberately not treated as a dead session. It means "refresh me", and deleting on it would sign the user out of every UI session over an ordinary access-token expiry a refresh would have fixed. isAuthError omits it for the same reason; the two classifiers have to agree about the same condition. The comment on the string fallback claimed it was a looser spelling of the structured check. It is not — it handles a different error family. indigo's RefreshTokens returns OAuth token-endpoint failures as a bare fmt.Errorf carrying the auth server's snake_case code verbatim ("token refresh failed (HTTP 400): invalid_grant"), never a typed error, so a string match is the only thing that can classify a refresh failure, which is the invalid_grant replay case37bab32exists to detect. Both comments now say which family they cover. Two hardening items on the same theme: use_dpop_nonce no longer counts as an auth error in the appview's isOAuthError. It is a routine handshake step indigo retries with the server-supplied nonce, and treating it as fatal signed users out over ordinary nonce rotation. It can still escape when a server sends that error with no DPoP-Nonce header, leaving indigo nothing to retry with; a stuck session there is preferable to signing everyone out in the common case, and the comment says so rather than claiming it cannot happen. Detached session deletes are bounded by SessionDeleteTimeout. They run on context.WithoutCancel so a canceled request cannot leave the cleanup half-done, which also stripped the only deadline they had — a wedged database write blocked the goroutine with no way to shed it. Matches the bound already on the detached persist callback. The unparseable-token-endpoint warning is now deduped per endpoint rather than once per process, since that path fails open by returning the client unwrapped, silently reinstating the refresh burn. The refreshDetachTimeout comment now notes the cap is per-POST: the DPoP-nonce retry means one refresh can issue two, holding the per-DID lock for up to twice the stated value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
91 lines
3.2 KiB
Go
91 lines
3.2 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.
|
|
//
|
|
// 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
|
|
}
|