Files
at-container-registry/pkg/auth/servicetoken_test.go
T
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

220 lines
7.2 KiB
Go

package auth
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/atcrypto"
indigo_oauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/bluesky-social/indigo/atproto/syntax"
)
func TestGetOrFetchServiceToken_NilRefresher(t *testing.T) {
ctx := context.Background()
did := "did:plc:test123"
holdDID := "did:web:hold.example.com"
pdsEndpoint := "https://pds.example.com"
// Test with nil refresher - should return error
_, err := GetOrFetchServiceToken(ctx, nil, did, holdDID, pdsEndpoint)
if err == nil {
t.Error("Expected error when refresher is nil")
}
expectedErrMsg := "refresher is nil"
if err.Error() != "refresher is nil (OAuth session required for service tokens)" {
t.Errorf("Expected error message to contain %q, got %q", expectedErrMsg, err.Error())
}
}
// Note: Full tests with mocked OAuth refresher and HTTP client will be added
// in the comprehensive test implementation phase
// ----------------------------------------------------------------------------
// Session-deletion gating tests (refresh-cancellation incident regression)
// ----------------------------------------------------------------------------
// stubAuthStore is a minimal indigo ClientAuthStore + GetLatestSessionForDID
// implementation for driving a real Refresher against httptest servers.
type stubAuthStore struct {
mu sync.Mutex
sessions map[string]indigo_oauth.ClientSessionData
deleted []string
}
func newStubAuthStore() *stubAuthStore {
return &stubAuthStore{sessions: make(map[string]indigo_oauth.ClientSessionData)}
}
func (s *stubAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*indigo_oauth.ClientSessionData, error) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[did.String()]
if !ok {
return nil, fmt.Errorf("session not found")
}
return &sess, nil
}
func (s *stubAuthStore) SaveSession(ctx context.Context, sess indigo_oauth.ClientSessionData) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[sess.AccountDID.String()] = sess
return nil
}
func (s *stubAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, did.String())
s.deleted = append(s.deleted, did.String())
return nil
}
func (s *stubAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*indigo_oauth.AuthRequestData, error) {
return nil, fmt.Errorf("not implemented")
}
func (s *stubAuthStore) SaveAuthRequestInfo(ctx context.Context, info indigo_oauth.AuthRequestData) error {
return nil
}
func (s *stubAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
return nil
}
func (s *stubAuthStore) GetLatestSessionForDID(ctx context.Context, did string) (*indigo_oauth.ClientSessionData, string, error) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[did]
if !ok {
return nil, "", fmt.Errorf("no session for DID")
}
return &sess, sess.SessionID, nil
}
func (s *stubAuthStore) deletedDIDs() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.deleted...)
}
type recordingUIStore struct {
mu sync.Mutex
deleted []string
}
func (s *recordingUIStore) Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) {
return "", fmt.Errorf("not implemented")
}
func (s *recordingUIStore) DeleteByDID(did string) {
s.mu.Lock()
defer s.mu.Unlock()
s.deleted = append(s.deleted, did)
}
// setupServiceTokenScenario builds a fake PDS whose getServiceAuth endpoint
// always 401s with invalid_token (forcing a token refresh) and whose token
// endpoint behavior is supplied by the caller, plus a Refresher seeded with a
// session for did.
func setupServiceTokenScenario(t *testing.T, did string, tokenHandler http.HandlerFunc) (*oauth.Refresher, *stubAuthStore, *recordingUIStore, string) {
t.Helper()
mux := http.NewServeMux()
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
mux.HandleFunc(atproto.ServerGetServiceAuth, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token", error_description="expired"`)
w.WriteHeader(http.StatusUnauthorized)
})
mux.HandleFunc("/oauth/token", tokenHandler)
key, err := atcrypto.GeneratePrivateKeyP256()
if err != nil {
t.Fatal(err)
}
parsedDID, err := syntax.ParseDID(did)
if err != nil {
t.Fatal(err)
}
store := newStubAuthStore()
store.sessions[did] = indigo_oauth.ClientSessionData{
AccountDID: parsedDID,
SessionID: "test-session",
HostURL: srv.URL,
AuthServerURL: srv.URL,
AuthServerTokenEndpoint: srv.URL + "/oauth/token",
Scopes: []string{"atproto"},
AccessToken: "old-access",
RefreshToken: "old-refresh",
DPoPPrivateKeyMultibase: key.Multibase(),
}
clientApp, err := oauth.NewClientApp("http://localhost:5000", store, []string{"atproto"}, "", "test")
if err != nil {
t.Fatal(err)
}
refresher := oauth.NewRefresher(clientApp)
uiStore := &recordingUIStore{}
refresher.SetUISessionStore(uiStore)
return refresher, store, uiStore, srv.URL
}
// TestGetOrFetchServiceToken_TransientErrorKeepsSession asserts that a
// transient refresh failure (5xx from the auth server) does NOT delete the
// OAuth session or invalidate UI sessions.
func TestGetOrFetchServiceToken_TransientErrorKeepsSession(t *testing.T) {
const did = "did:web:transient.example.com"
refresher, store, uiStore, pdsURL := setupServiceTokenScenario(t, did,
func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
})
_, err := GetOrFetchServiceToken(context.Background(), refresher, did, "did:web:hold1.example.com", pdsURL)
if err == nil {
t.Fatal("expected error from failed service token fetch")
}
if deleted := store.deletedDIDs(); len(deleted) != 0 {
t.Errorf("session deleted on transient error: %v", deleted)
}
if len(uiStore.deleted) != 0 {
t.Errorf("UI sessions invalidated on transient error: %v", uiStore.deleted)
}
}
// TestGetOrFetchServiceToken_InvalidGrantDeletesSession asserts that a
// genuine invalid_grant (refresh token replayed/revoked) still deletes the
// session and invalidates UI sessions.
func TestGetOrFetchServiceToken_InvalidGrantDeletesSession(t *testing.T) {
const did = "did:web:invalidgrant.example.com"
refresher, store, uiStore, pdsURL := setupServiceTokenScenario(t, did,
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"Refresh token replayed"}`))
})
_, err := GetOrFetchServiceToken(context.Background(), refresher, did, "did:web:hold2.example.com", pdsURL)
if err == nil {
t.Fatal("expected error from invalid_grant")
}
if deleted := store.deletedDIDs(); len(deleted) != 1 || deleted[0] != did {
t.Errorf("expected session deletion for %s, got: %v", did, deleted)
}
if len(uiStore.deleted) != 1 || uiStore.deleted[0] != did {
t.Errorf("expected UI session invalidation for %s, got: %v", did, uiStore.deleted)
}
}