Files
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

130 lines
3.8 KiB
Go

package oauth
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// TestRefreshDetachTransport_DetachesTokenEndpointPOST verifies the core
// property: a token-refresh POST proceeds and its response body stays
// readable even when the inbound request context is already canceled.
func TestRefreshDetachTransport_DetachesTokenEndpointPOST(t *testing.T) {
var served bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
served = true
_, _ = w.Write([]byte(`{"access_token":"new"}`))
}))
defer srv.Close()
client := newRefreshDetachClient(&http.Client{}, srv.URL+"/oauth/token")
ctx, cancel := context.WithCancel(context.Background())
cancel() // canceled before the request even starts
req, err := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/oauth/token", strings.NewReader("grant_type=refresh_token"))
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("expected detached POST to succeed despite canceled context, got: %v", err)
}
defer resp.Body.Close()
if !served {
t.Fatal("token endpoint handler never ran")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("body not readable after parent cancel: %v", err)
}
if string(body) != `{"access_token":"new"}` {
t.Fatalf("unexpected body: %s", body)
}
}
// TestRefreshDetachTransport_PassThrough verifies that requests other than
// POSTs to the token endpoint keep normal cancellation semantics.
func TestRefreshDetachTransport_PassThrough(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
client := newRefreshDetachClient(&http.Client{}, srv.URL+"/oauth/token")
ctx, cancel := context.WithCancel(context.Background())
cancel()
cases := []struct {
name string
method string
url string
}{
{"GET to token endpoint", http.MethodGet, srv.URL + "/oauth/token"},
{"POST to other path", http.MethodPost, srv.URL + "/xrpc/other"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequestWithContext(ctx, tc.method, tc.url, nil)
if err != nil {
t.Fatal(err)
}
_, err = client.Do(req) //nolint:bodyclose // request must fail
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled pass-through, got: %v", err)
}
})
}
}
// TestRefreshDetachTransport_Timeout verifies the detached context still has
// its own bound so a hung auth server can't pin the session lock forever.
func TestRefreshDetachTransport_Timeout(t *testing.T) {
blocked := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-blocked
}))
defer srv.Close()
defer close(blocked)
endpoint, err := url.Parse(srv.URL + "/oauth/token")
if err != nil {
t.Fatal(err)
}
client := &http.Client{Transport: &refreshDetachTransport{
base: http.DefaultTransport,
tokenEndpoint: endpoint,
timeout: 50 * time.Millisecond,
}}
req, err := http.NewRequest(http.MethodPost, srv.URL+"/oauth/token", nil)
if err != nil {
t.Fatal(err)
}
_, err = client.Do(req) //nolint:bodyclose // request must fail
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected DeadlineExceeded from detached timeout, got: %v", err)
}
}
// TestNewRefreshDetachClient_BadEndpoint verifies fail-open to the unwrapped
// client when the stored token endpoint is unusable.
func TestNewRefreshDetachClient_BadEndpoint(t *testing.T) {
inner := &http.Client{}
if got := newRefreshDetachClient(inner, "://not a url"); got != inner {
t.Error("expected unwrapped client for unparseable endpoint")
}
if got := newRefreshDetachClient(inner, ""); got != inner {
t.Error("expected unwrapped client for empty endpoint")
}
}