Files
at-container-registry/pkg/auth/oauth/session_rev_test.go
T
Evan JarrettandClaude Opus 5 e75b2e246b oauth: compare-and-swap session writes so a concurrent refresh cannot delete a live session
Refresh tokens rotate on use, and DoWithSession serializes refreshes per DID with
an in-process mutex. That is the right mechanism and it protects nothing once
there are two instances: both can refresh the same account at the same time, the
slower one presents a refresh token the auth server has already superseded, gets
invalid_grant, and isAuthError deletes the session. The user is signed out
mid-push, and the session another instance had just legitimately refreshed is
destroyed along with it.

oauth_sessions gains a rev that increments on every write. A store that has read
a session writes with a compare-and-swap against the revision it read and gets
ErrSessionRevConflict if anyone wrote first, so a stale writer can no longer
replace rotated tokens with invalidated ones. The persist callback treats that
conflict as an ordinary outcome rather than an error, since leaving the newer
state alone is exactly right.

The delete path is now guarded by the same signal. An auth error on a session
whose revision has moved since we read it means "someone else refreshed this",
not "this session is dead", so it retries once against the newer tokens instead
of deleting. Exactly once: a second failure means staleness was not the problem,
and looping would hold the per-DID lock while getting the same answer.

The guard is deliberately conservative. A store without revisions, no recorded
revision, a failed lookup, a session that is simply gone: all answer "not
advanced" and keep the previous delete-on-error behavior. Wrongly claiming a
concurrent refresh would keep a genuinely dead session alive with no way out but
waiting; wrongly missing one costs a re-login.

The sentinel lives in pkg/auth/oauth rather than next to the SQLite store,
because pkg/appview/db already imports pkg/auth/oauth and the other direction
would be an import cycle. The db package re-exports it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:40:12 -05:00

179 lines
5.3 KiB
Go

package oauth
import (
"context"
"errors"
"testing"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
)
// revStore is a ClientAuthStore that also reports session revisions, standing in
// for the SQLite store.
type revStore struct {
oauth.ClientAuthStore
known map[string]int64
current map[string]int64
exists map[string]bool
lookupErr error
}
func newRevStore() *revStore {
return &revStore{
known: map[string]int64{},
current: map[string]int64{},
exists: map[string]bool{},
}
}
func (s *revStore) KnownRev(did, sessionID string) (int64, bool) {
rev, ok := s.known[did+":"+sessionID]
return rev, ok
}
func (s *revStore) GetSessionRev(_ context.Context, did, sessionID string) (int64, bool, error) {
if s.lookupErr != nil {
return 0, false, s.lookupErr
}
key := did + ":" + sessionID
return s.current[key], s.exists[key], nil
}
// plainStore implements no revision tracking, standing in for any store that
// predates this mechanism.
type plainStore struct{ oauth.ClientAuthStore }
func refresherWithStore(store oauth.ClientAuthStore) *Refresher {
return NewRefresher(&oauth.ClientApp{Store: store})
}
// TestSessionRevisionAdvanced covers the decision that gates session deletion.
//
// Getting this wrong in the "true" direction keeps a genuinely dead session
// alive and leaves the user stuck with no way to recover but waiting; getting it
// wrong in the "false" direction costs a re-login. So every case it cannot
// resolve must answer false.
func TestSessionRevisionAdvanced(t *testing.T) {
const (
did = "did:plc:alice"
sessionID = "session-1"
key = did + ":" + sessionID
)
t.Run("another instance wrote since we read", func(t *testing.T) {
s := newRevStore()
s.known[key] = 3
s.current[key] = 4
s.exists[key] = true
if !refresherWithStore(s).sessionRevisionAdvanced(context.Background(), did, sessionID) {
t.Error("expected the advanced revision to be detected")
}
})
t.Run("nobody wrote since we read", func(t *testing.T) {
s := newRevStore()
s.known[key] = 3
s.current[key] = 3
s.exists[key] = true
if refresherWithStore(s).sessionRevisionAdvanced(context.Background(), did, sessionID) {
t.Error("an unchanged revision must not look like a concurrent refresh")
}
})
t.Run("session no longer exists", func(t *testing.T) {
s := newRevStore()
s.known[key] = 3
s.exists[key] = false
if refresherWithStore(s).sessionRevisionAdvanced(context.Background(), did, sessionID) {
t.Error("a missing session must not be treated as concurrently refreshed")
}
})
t.Run("no revision recorded for this session", func(t *testing.T) {
s := newRevStore()
s.current[key] = 9
s.exists[key] = true
if refresherWithStore(s).sessionRevisionAdvanced(context.Background(), did, sessionID) {
t.Error("without a recorded revision there is nothing to compare against")
}
})
t.Run("revision lookup fails", func(t *testing.T) {
s := newRevStore()
s.known[key] = 3
s.lookupErr = errors.New("database unavailable")
if refresherWithStore(s).sessionRevisionAdvanced(context.Background(), did, sessionID) {
t.Error("a failed lookup must fall back to the previous behavior")
}
})
t.Run("store does not track revisions", func(t *testing.T) {
if refresherWithStore(&plainStore{}).sessionRevisionAdvanced(context.Background(), did, sessionID) {
t.Error("a store without revisions must keep the previous behavior")
}
})
t.Run("canceled request context still allows the check", func(t *testing.T) {
s := newRevStore()
s.known[key] = 3
s.current[key] = 4
s.exists[key] = true
// The inbound request being canceled must not push us into deleting a
// session another instance just refreshed, so the check runs detached.
ctx, cancel := context.WithCancel(context.Background())
cancel()
if !refresherWithStore(s).sessionRevisionAdvanced(ctx, did, sessionID) {
t.Error("a canceled inbound context must not suppress the check")
}
})
}
// TestStaleSessionErrorUnwraps: DoWithSession reports the underlying auth error
// when a retry does not help, so callers must still be able to inspect it.
func TestStaleSessionErrorUnwraps(t *testing.T) {
cause := errors.New("invalid_grant")
err := error(&staleSessionError{did: "did:plc:alice", cause: cause})
if !errors.Is(err, cause) {
t.Error("staleSessionError does not unwrap to its cause")
}
var stale *staleSessionError
if !errors.As(err, &stale) {
t.Error("staleSessionError is not detectable with errors.As")
}
}
// TestDoWithSessionRetriesOnceOnStaleSession pins the retry policy: exactly one
// retry. Looping would hold the per-DID lock while getting the same answer.
func TestDoWithSessionRetriesOnceOnStaleSession(t *testing.T) {
// resumeSession requires a store implementing GetLatestSessionForDID, which
// this deliberately does not, so every attempt fails early and we can count
// attempts without standing up a PDS.
r := refresherWithStore(&plainStore{})
attempts := 0
fn := func(*oauth.ClientSession) error {
attempts++
return nil
}
// The DID never resolves to a session, so fn is never reached; what matters
// is that DoWithSession returns rather than looping.
err := r.DoWithSession(context.Background(), "did:plc:alice", fn)
if err == nil {
t.Fatal("expected an error when no session can be resumed")
}
if attempts != 0 {
t.Errorf("fn ran %d times despite the session never resuming", attempts)
}
}