mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 01:04:15 +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>
130 lines
3.8 KiB
Go
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")
|
|
}
|
|
}
|