mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 08:16:57 +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>
220 lines
7.2 KiB
Go
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)
|
|
}
|
|
}
|