Files
Evan JarrettandClaude Opus 5 500ee2f8d1 auth: classify service-token failures structurally, not by string
Follow-up to 37bab32. 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 case 37bab32
exists 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>
2026-08-02 19:19:59 -05:00

119 lines
4.4 KiB
Go

package oauth
import (
"context"
"io"
"log/slog"
"net/http"
"net/url"
"sync"
"time"
)
// refreshDetachTimeout bounds a detached token-refresh POST. Refreshes are
// normally sub-second; this only exists so a hung auth server can't pin the
// per-DID session lock forever.
//
// Note this is per-POST, not per-refresh: indigo's postToAuthServer retries once
// on a use_dpop_nonce challenge, so a single refresh can issue two detached
// POSTs and hold the per-DID lock for up to 2x this value. Keep that product
// comfortably under the callers' own deadlines (Docker gives up on /auth/token
// well before it).
const refreshDetachTimeout = 30 * time.Second
// badTokenEndpointOnce dedupes the "couldn't parse the token endpoint" warning
// per endpoint value rather than once per process. That path fails open — it
// returns the client unwrapped, silently reinstating the refresh-burn bug this
// file exists to fix — so it must stay visible for every distinct auth server
// it affects, not just the first one after boot.
var badTokenEndpointOnce sync.Map
// refreshDetachTransport detaches the request context for OAuth token-refresh
// POSTs. A refresh is non-idempotent: the auth server rotates the refresh
// token as soon as it processes the request, whether or not we stick around
// for the response. If the inbound request context is canceled mid-refresh
// (e.g. Docker gives up on a slow /auth/token), aborting the POST strands the
// rotated token server-side; our stored refresh token is then already
// consumed, the next refresh fails with invalid_grant "Refresh token
// replayed", and the whole session gets deleted. Once a refresh starts it
// must run to completion, capped by its own timeout (same rationale as the
// upload finalization in pkg/hold/oci/xrpc.go).
//
// Only POSTs to the session's auth-server token endpoint are detached; every
// other request keeps normal cancellation semantics.
type refreshDetachTransport struct {
base http.RoundTripper
tokenEndpoint *url.URL
timeout time.Duration
}
func (t *refreshDetachTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodPost || !sameEndpoint(req.URL, t.tokenEndpoint) {
return t.base.RoundTrip(req)
}
// context.WithoutCancel keeps values (trace/log metadata) but drops
// cancellation and deadline. The cancel func must outlive RoundTrip: the
// body is read by the caller, so it is released on Body.Close() instead
// of a defer here.
ctx, cancel := context.WithTimeout(context.WithoutCancel(req.Context()), t.timeout)
resp, err := t.base.RoundTrip(req.Clone(ctx))
if err != nil {
cancel()
return nil, err
}
resp.Body = &cancelOnClose{ReadCloser: resp.Body, cancel: cancel}
return resp, nil
}
// cancelOnClose ties a context.CancelFunc to response-body Close so the
// detached timeout context stays alive for the full body read.
type cancelOnClose struct {
io.ReadCloser
cancel context.CancelFunc
}
func (c *cancelOnClose) Close() error {
c.cancel()
return c.ReadCloser.Close()
}
// newRefreshDetachClient wraps inner so that token-refresh POSTs to
// tokenEndpoint survive cancellation of the inbound request context. If the
// endpoint can't be parsed (corrupt session data), the client is returned
// unwrapped, preserving the old behavior.
func newRefreshDetachClient(inner *http.Client, tokenEndpoint string) *http.Client {
endpoint, err := url.Parse(tokenEndpoint)
if err != nil || endpoint.Host == "" {
if _, seen := badTokenEndpointOnce.LoadOrStore(tokenEndpoint, struct{}{}); !seen {
slog.Warn("Not detaching token-refresh context: unparseable auth server token endpoint",
"component", "oauth/refresher",
"tokenEndpoint", tokenEndpoint,
"error", err)
}
return inner
}
if inner == nil {
inner = http.DefaultClient
}
base := inner.Transport
if base == nil {
base = http.DefaultTransport
}
wrapped := *inner // shallow copy: keep Jar, Timeout, redirect policy
wrapped.Transport = &refreshDetachTransport{base: base, tokenEndpoint: endpoint, timeout: refreshDetachTimeout}
return &wrapped
}
// sameEndpoint reports whether two URLs address the same endpoint, comparing
// scheme, host, and path rather than raw strings so that canonicalization
// differences (default ports, escaping) don't cause a miss.
func sameEndpoint(a, b *url.URL) bool {
if a == nil || b == nil {
return false
}
return a.Scheme == b.Scheme && a.Host == b.Host && a.Path == b.Path
}