mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 16:26:56 +00:00
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>
179 lines
5.3 KiB
Go
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)
|
|
}
|
|
}
|