mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-26 04:04:15 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
934e4a2a59
commit
e75b2e246b
@@ -0,0 +1,16 @@
|
||||
description: |
|
||||
Add a revision counter to oauth_sessions so session writes can compare-and-swap.
|
||||
|
||||
Refresh tokens rotate on every use. The AppView serializes refreshes per DID
|
||||
with an in-process mutex, which is exactly the right thing to do and stops
|
||||
working the moment there is more than one instance: two instances refreshing
|
||||
the same DID concurrently means one of them replays a rotated refresh token,
|
||||
gets invalid_grant, and the isAuthError path deletes the session. The user is
|
||||
signed out mid-push and nothing explains why.
|
||||
|
||||
rev increments on every successful write. A writer that read revision N can
|
||||
detect that someone else has written since, which lets the delete-on-auth-error
|
||||
path tell "this session is genuinely dead" apart from "another instance just
|
||||
refreshed it and my copy is stale".
|
||||
query: |
|
||||
ALTER TABLE oauth_sessions ADD COLUMN rev INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,293 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
atcroauth "atcr.io/pkg/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
// revTestDB returns a file-backed database.
|
||||
//
|
||||
// Not ":memory:" — go-libsql gives each connection to an in-memory DSN its own
|
||||
// private database, and these tests deliberately use two OAuthStore values to
|
||||
// stand in for two AppView instances sharing one database.
|
||||
func revTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
database, err := InitDB(filepath.Join(t.TempDir(), "oauth.db"), LibsqlConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
return database
|
||||
}
|
||||
|
||||
func sessionData(t *testing.T, did, sessionID, accessToken, refreshToken string) oauth.ClientSessionData {
|
||||
t.Helper()
|
||||
parsed, err := syntax.ParseDID(did)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDID(%q): %v", did, err)
|
||||
}
|
||||
return oauth.ClientSessionData{
|
||||
AccountDID: parsed,
|
||||
SessionID: sessionID,
|
||||
HostURL: "https://bsky.social",
|
||||
AuthServerURL: "https://bsky.social",
|
||||
AuthServerTokenEndpoint: "https://bsky.social/oauth/token",
|
||||
Scopes: []string{"atproto"},
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
DPoPPrivateKeyMultibase: "test_key",
|
||||
}
|
||||
}
|
||||
|
||||
func storedTokens(t *testing.T, database *sql.DB, did, sessionID string) (access, refresh string) {
|
||||
t.Helper()
|
||||
var raw string
|
||||
err := database.QueryRow(
|
||||
`SELECT session_data FROM oauth_sessions WHERE session_key = ?`,
|
||||
makeSessionKey(did, sessionID),
|
||||
).Scan(&raw)
|
||||
if err != nil {
|
||||
t.Fatalf("read session: %v", err)
|
||||
}
|
||||
var data oauth.ClientSessionData
|
||||
if err := unmarshalSession(raw, &data); err != nil {
|
||||
t.Fatalf("parse session: %v", err)
|
||||
}
|
||||
return data.AccessToken, data.RefreshToken
|
||||
}
|
||||
|
||||
// TestConcurrentRefreshDoesNotClobberRotatedTokens is the scenario this whole
|
||||
// mechanism exists for.
|
||||
//
|
||||
// Refresh tokens rotate on use. Two AppView instances refreshing the same
|
||||
// account both hold a session they believe is current. Without a revision check
|
||||
// the one that writes second replaces the freshly rotated tokens with ones the
|
||||
// auth server has already invalidated, and the next refresh fails with
|
||||
// invalid_grant for everyone.
|
||||
func TestConcurrentRefreshDoesNotClobberRotatedTokens(t *testing.T) {
|
||||
database := revTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
did = "did:plc:alice"
|
||||
sessionID = "session-1"
|
||||
)
|
||||
|
||||
// One instance creates the session.
|
||||
instanceA := NewOAuthStore(database)
|
||||
if err := instanceA.SaveSession(ctx, sessionData(t, did, sessionID, "access-v1", "refresh-v1")); err != nil {
|
||||
t.Fatalf("initial save: %v", err)
|
||||
}
|
||||
|
||||
// A second instance, with its own in-process revision map, reads it.
|
||||
instanceB := NewOAuthStore(database)
|
||||
parsed, _ := syntax.ParseDID(did)
|
||||
if _, err := instanceB.GetSession(ctx, parsed, sessionID); err != nil {
|
||||
t.Fatalf("instance B read: %v", err)
|
||||
}
|
||||
if _, err := instanceA.GetSession(ctx, parsed, sessionID); err != nil {
|
||||
t.Fatalf("instance A read: %v", err)
|
||||
}
|
||||
|
||||
// B refreshes first and persists the rotated tokens.
|
||||
if err := instanceB.SaveSession(ctx, sessionData(t, did, sessionID, "access-v2", "refresh-v2")); err != nil {
|
||||
t.Fatalf("instance B save: %v", err)
|
||||
}
|
||||
|
||||
// A now tries to persist what it read before B's write.
|
||||
err := instanceA.SaveSession(ctx, sessionData(t, did, sessionID, "access-stale", "refresh-stale"))
|
||||
if !errors.Is(err, ErrSessionRevConflict) {
|
||||
t.Fatalf("expected ErrSessionRevConflict from the losing writer, got %v", err)
|
||||
}
|
||||
|
||||
access, refresh := storedTokens(t, database, did, sessionID)
|
||||
if access != "access-v2" || refresh != "refresh-v2" {
|
||||
t.Errorf("the stale writer clobbered the rotated tokens: stored access=%q refresh=%q, want the v2 pair",
|
||||
access, refresh)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveSessionAdvancesRevision: a successful write must bump the revision, or
|
||||
// nothing else can detect that it happened.
|
||||
func TestSaveSessionAdvancesRevision(t *testing.T) {
|
||||
database := revTestDB(t)
|
||||
ctx := context.Background()
|
||||
store := NewOAuthStore(database)
|
||||
|
||||
const (
|
||||
did = "did:plc:alice"
|
||||
sessionID = "session-1"
|
||||
)
|
||||
|
||||
if err := store.SaveSession(ctx, sessionData(t, did, sessionID, "a1", "r1")); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
first, exists, err := store.GetSessionRev(ctx, did, sessionID)
|
||||
if err != nil || !exists {
|
||||
t.Fatalf("GetSessionRev: exists=%v err=%v", exists, err)
|
||||
}
|
||||
|
||||
if err := store.SaveSession(ctx, sessionData(t, did, sessionID, "a2", "r2")); err != nil {
|
||||
t.Fatalf("second save: %v", err)
|
||||
}
|
||||
second, _, err := store.GetSessionRev(ctx, did, sessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionRev: %v", err)
|
||||
}
|
||||
|
||||
if second <= first {
|
||||
t.Errorf("revision did not advance: %d then %d", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSameStoreRepeatedSavesSucceed: the CAS must not get in the way of the
|
||||
// ordinary case, one instance writing a session it owns over and over as DPoP
|
||||
// nonces change.
|
||||
func TestSameStoreRepeatedSavesSucceed(t *testing.T) {
|
||||
database := revTestDB(t)
|
||||
ctx := context.Background()
|
||||
store := NewOAuthStore(database)
|
||||
|
||||
const (
|
||||
did = "did:plc:alice"
|
||||
sessionID = "session-1"
|
||||
)
|
||||
|
||||
for i := range 10 {
|
||||
if err := store.SaveSession(ctx, sessionData(t, did, sessionID, "a", "r")); err != nil {
|
||||
t.Fatalf("save %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSessionRecordsRevisionForLaterCAS: reading is what establishes the
|
||||
// expectation a later write compares against.
|
||||
func TestGetSessionRecordsRevisionForLaterCAS(t *testing.T) {
|
||||
database := revTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
did = "did:plc:alice"
|
||||
sessionID = "session-1"
|
||||
)
|
||||
|
||||
writer := NewOAuthStore(database)
|
||||
if err := writer.SaveSession(ctx, sessionData(t, did, sessionID, "a1", "r1")); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
reader := NewOAuthStore(database)
|
||||
if _, ok := reader.KnownRev(did, sessionID); ok {
|
||||
t.Fatal("a store that has never read the session should know no revision")
|
||||
}
|
||||
|
||||
parsed, _ := syntax.ParseDID(did)
|
||||
if _, err := reader.GetSession(ctx, parsed, sessionID); err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if _, ok := reader.KnownRev(did, sessionID); !ok {
|
||||
t.Error("GetSession did not record a revision")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetLatestSessionForDIDRecordsRevision covers the path the refresher
|
||||
// actually uses: resumeSession loads through GetLatestSessionForDID, so if that
|
||||
// did not record a revision the CAS would never engage where it matters most.
|
||||
func TestGetLatestSessionForDIDRecordsRevision(t *testing.T) {
|
||||
database := revTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
did = "did:plc:alice"
|
||||
sessionID = "session-1"
|
||||
)
|
||||
|
||||
writer := NewOAuthStore(database)
|
||||
if err := writer.SaveSession(ctx, sessionData(t, did, sessionID, "a1", "r1")); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
reader := NewOAuthStore(database)
|
||||
if _, _, err := reader.GetLatestSessionForDID(ctx, did); err != nil {
|
||||
t.Fatalf("GetLatestSessionForDID: %v", err)
|
||||
}
|
||||
if _, ok := reader.KnownRev(did, sessionID); !ok {
|
||||
t.Error("GetLatestSessionForDID did not record a revision")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteSessionForgetsRevision: a remembered revision must not survive the
|
||||
// session it describes, or a re-created session would be compared against a
|
||||
// revision from the dead one.
|
||||
func TestDeleteSessionForgetsRevision(t *testing.T) {
|
||||
database := revTestDB(t)
|
||||
ctx := context.Background()
|
||||
store := NewOAuthStore(database)
|
||||
|
||||
const (
|
||||
did = "did:plc:alice"
|
||||
sessionID = "session-1"
|
||||
)
|
||||
|
||||
if err := store.SaveSession(ctx, sessionData(t, did, sessionID, "a1", "r1")); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
parsed, _ := syntax.ParseDID(did)
|
||||
if err := store.DeleteSession(ctx, parsed, sessionID); err != nil {
|
||||
t.Fatalf("DeleteSession: %v", err)
|
||||
}
|
||||
if _, ok := store.KnownRev(did, sessionID); ok {
|
||||
t.Error("revision survived the session being deleted")
|
||||
}
|
||||
|
||||
// A re-created session must save cleanly rather than hitting a phantom
|
||||
// conflict.
|
||||
if err := store.SaveSession(ctx, sessionData(t, did, sessionID, "a2", "r2")); err != nil {
|
||||
t.Errorf("re-created session failed to save: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSessionsForDIDForgetsRevisions(t *testing.T) {
|
||||
database := revTestDB(t)
|
||||
ctx := context.Background()
|
||||
store := NewOAuthStore(database)
|
||||
|
||||
const did = "did:plc:alice"
|
||||
for _, sid := range []string{"s1", "s2"} {
|
||||
if err := store.SaveSession(ctx, sessionData(t, did, sid, "a", "r")); err != nil {
|
||||
t.Fatalf("save %s: %v", sid, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := store.DeleteSessionsForDID(ctx, did); err != nil {
|
||||
t.Fatalf("DeleteSessionsForDID: %v", err)
|
||||
}
|
||||
for _, sid := range []string{"s1", "s2"} {
|
||||
if _, ok := store.KnownRev(did, sid); ok {
|
||||
t.Errorf("revision for %s survived the bulk delete", sid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrSessionRevConflictIsSharedAcrossPackages: pkg/auth/oauth compares
|
||||
// against its own sentinel, so the two must be the same value or the refresher
|
||||
// would treat a conflict as an ordinary failure and log it as an error.
|
||||
func TestErrSessionRevConflictIsSharedAcrossPackages(t *testing.T) {
|
||||
if !errors.Is(ErrSessionRevConflict, atcroauth.ErrSessionRevConflict) {
|
||||
t.Error("db.ErrSessionRevConflict and oauth.ErrSessionRevConflict are different errors")
|
||||
}
|
||||
}
|
||||
|
||||
// unmarshalSession is a tiny helper so the test does not need to import
|
||||
// encoding/json separately from the store's own representation.
|
||||
func unmarshalSession(raw string, out *oauth.ClientSessionData) error {
|
||||
return json.Unmarshal([]byte(raw), out)
|
||||
}
|
||||
@@ -4,8 +4,11 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
atoauth "atcr.io/pkg/auth/oauth"
|
||||
@@ -13,9 +16,23 @@ import (
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
// ErrSessionRevConflict is re-exported from pkg/auth/oauth, where it has to live
|
||||
// to avoid an import cycle: this package already imports that one. Callers can
|
||||
// use either name.
|
||||
var ErrSessionRevConflict = atoauth.ErrSessionRevConflict
|
||||
|
||||
// OAuthStore implements oauth.ClientAuthStore with SQLite persistence
|
||||
type OAuthStore struct {
|
||||
db *sql.DB
|
||||
|
||||
// revs maps session_key to the revision this process last read, so
|
||||
// SaveSession can compare-and-swap against it.
|
||||
//
|
||||
// Process-local state is sufficient because the OAuth refresher already
|
||||
// serializes per DID with an in-process mutex. That mutex is what makes at
|
||||
// most one write per session in flight here; the rev closes the gap it
|
||||
// cannot cover, which is a second instance.
|
||||
revs sync.Map
|
||||
}
|
||||
|
||||
// NewOAuthStore creates a new SQLite-backed OAuth store
|
||||
@@ -23,17 +40,59 @@ func NewOAuthStore(db *sql.DB) *OAuthStore {
|
||||
return &OAuthStore{db: db}
|
||||
}
|
||||
|
||||
// rememberRev records the revision observed for a session key.
|
||||
func (s *OAuthStore) rememberRev(sessionKey string, rev int64) {
|
||||
s.revs.Store(sessionKey, rev)
|
||||
}
|
||||
|
||||
// knownRev returns the revision this process last read for a session key.
|
||||
func (s *OAuthStore) knownRev(sessionKey string) (int64, bool) {
|
||||
v, ok := s.revs.Load(sessionKey)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
rev, ok := v.(int64)
|
||||
return rev, ok
|
||||
}
|
||||
|
||||
// GetSessionRev returns the revision currently stored for a session, and whether
|
||||
// the session exists at all.
|
||||
//
|
||||
// Used by the refresher to distinguish a session that is genuinely dead from one
|
||||
// another instance has refreshed since we read it.
|
||||
func (s *OAuthStore) GetSessionRev(ctx context.Context, did, sessionID string) (int64, bool, error) {
|
||||
var rev int64
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT rev FROM oauth_sessions WHERE session_key = ?`,
|
||||
makeSessionKey(did, sessionID),
|
||||
).Scan(&rev)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("failed to query session revision: %w", err)
|
||||
}
|
||||
return rev, true, nil
|
||||
}
|
||||
|
||||
// KnownRev exposes the revision this process last read for a session, so the
|
||||
// refresher can compare it against what is stored now.
|
||||
func (s *OAuthStore) KnownRev(did, sessionID string) (int64, bool) {
|
||||
return s.knownRev(makeSessionKey(did, sessionID))
|
||||
}
|
||||
|
||||
// GetSession retrieves a session by DID and session ID
|
||||
func (s *OAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
|
||||
sessionKey := makeSessionKey(did.String(), sessionID)
|
||||
|
||||
var sessionDataJSON string
|
||||
var rev int64
|
||||
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT session_data
|
||||
SELECT session_data, rev
|
||||
FROM oauth_sessions
|
||||
WHERE session_key = ?
|
||||
`, sessionKey).Scan(&sessionDataJSON)
|
||||
`, sessionKey).Scan(&sessionDataJSON, &rev)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("session not found: %s/%s", did, sessionID)
|
||||
@@ -48,10 +107,22 @@ func (s *OAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID s
|
||||
return nil, fmt.Errorf("failed to parse session data: %w", err)
|
||||
}
|
||||
|
||||
s.rememberRev(sessionKey, rev)
|
||||
return &sessionData, nil
|
||||
}
|
||||
|
||||
// SaveSession saves or updates a session (upsert)
|
||||
// SaveSession saves or updates a session.
|
||||
//
|
||||
// When this process has previously read the session, the write is a
|
||||
// compare-and-swap against the revision it read, and returns
|
||||
// ErrSessionRevConflict if anyone has written since. Otherwise it is an upsert,
|
||||
// which is the path a brand-new session from the OAuth callback takes.
|
||||
//
|
||||
// The conflict case exists because refresh tokens rotate on use. Two instances
|
||||
// refreshing the same account concurrently both hold a session they believe is
|
||||
// current, and the one that writes second would otherwise overwrite freshly
|
||||
// rotated tokens with tokens the auth server has already invalidated, bricking
|
||||
// the session for everyone.
|
||||
func (s *OAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
|
||||
sessionKey := makeSessionKey(sess.AccountDID.String(), sess.SessionID)
|
||||
|
||||
@@ -61,13 +132,38 @@ func (s *OAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionDa
|
||||
return fmt.Errorf("failed to marshal session data: %w", err)
|
||||
}
|
||||
|
||||
if expectedRev, ok := s.knownRev(sessionKey); ok {
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
UPDATE oauth_sessions
|
||||
SET session_data = ?, rev = rev + 1, updated_at = datetime('now')
|
||||
WHERE session_key = ? AND rev = ?
|
||||
`, string(sessionDataJSON), sessionKey, expectedRev)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save session: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save session: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
// Either someone else wrote (rev moved) or the row is gone. Drop our
|
||||
// stale expectation so the next read re-establishes it.
|
||||
s.revs.Delete(sessionKey)
|
||||
return ErrSessionRevConflict
|
||||
}
|
||||
s.rememberRev(sessionKey, expectedRev+1)
|
||||
return nil
|
||||
}
|
||||
|
||||
// No revision known: a session we have never read, i.e. a fresh login.
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO oauth_sessions (
|
||||
session_key, account_did, session_id, session_data,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
created_at, updated_at, rev
|
||||
) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'), 1)
|
||||
ON CONFLICT(session_key) DO UPDATE SET
|
||||
session_data = excluded.session_data,
|
||||
rev = oauth_sessions.rev + 1,
|
||||
updated_at = datetime('now')
|
||||
`,
|
||||
sessionKey,
|
||||
@@ -75,11 +171,19 @@ func (s *OAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionDa
|
||||
sess.SessionID,
|
||||
string(sessionDataJSON),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save session: %w", err)
|
||||
}
|
||||
|
||||
// Read the revision back so subsequent writes for this session can
|
||||
// compare-and-swap.
|
||||
var rev int64
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT rev FROM oauth_sessions WHERE session_key = ?`, sessionKey,
|
||||
).Scan(&rev); err == nil {
|
||||
s.rememberRev(sessionKey, rev)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -91,6 +195,10 @@ func (s *OAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionI
|
||||
DELETE FROM oauth_sessions WHERE session_key = ?
|
||||
`, sessionKey)
|
||||
|
||||
// Drop the remembered revision: keeping it would make the next write for a
|
||||
// re-created session compare against a revision from the deleted one.
|
||||
s.revs.Delete(sessionKey)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -105,6 +213,8 @@ func (s *OAuthStore) DeleteSessionsForDID(ctx context.Context, did string) error
|
||||
return fmt.Errorf("failed to delete sessions for DID: %w", err)
|
||||
}
|
||||
|
||||
s.forgetRevsForDID(did)
|
||||
|
||||
deleted, _ := result.RowsAffected()
|
||||
if deleted > 0 {
|
||||
slog.Info("Deleted OAuth sessions for DID", "count", deleted, "did", did)
|
||||
@@ -113,6 +223,18 @@ func (s *OAuthStore) DeleteSessionsForDID(ctx context.Context, did string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// forgetRevsForDID drops every remembered revision belonging to a DID, so
|
||||
// nothing is left comparing against a revision from a deleted session.
|
||||
func (s *OAuthStore) forgetRevsForDID(did string) {
|
||||
prefix := did + ":"
|
||||
s.revs.Range(func(key, _ any) bool {
|
||||
if k, ok := key.(string); ok && strings.HasPrefix(k, prefix) {
|
||||
s.revs.Delete(k)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteOldSessionsForDID removes all sessions for a DID except the specified session to keep
|
||||
// This is used during OAuth callback to clean up stale sessions with expired refresh tokens
|
||||
func (s *OAuthStore) DeleteOldSessionsForDID(ctx context.Context, did string, keepSessionID string) error {
|
||||
@@ -188,14 +310,15 @@ func (s *OAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) er
|
||||
func (s *OAuthStore) GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error) {
|
||||
var sessionDataJSON string
|
||||
var sessionID string
|
||||
var rev int64
|
||||
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT session_id, session_data
|
||||
SELECT session_id, session_data, rev
|
||||
FROM oauth_sessions
|
||||
WHERE account_did = ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
`, did).Scan(&sessionID, &sessionDataJSON)
|
||||
`, did).Scan(&sessionID, &sessionDataJSON, &rev)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, "", fmt.Errorf("no session found for DID: %s", did)
|
||||
@@ -210,6 +333,7 @@ func (s *OAuthStore) GetLatestSessionForDID(ctx context.Context, did string) (*o
|
||||
return nil, "", fmt.Errorf("failed to parse session data: %w", err)
|
||||
}
|
||||
|
||||
s.rememberRev(makeSessionKey(did, sessionID), rev)
|
||||
return &sessionData, sessionID, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,11 @@ CREATE TABLE IF NOT EXISTS tags (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tags_did_repo ON tags(did, repository);
|
||||
|
||||
-- rev increments on every successful write, so a writer that read revision N can
|
||||
-- tell whether anyone has written since. Refresh tokens rotate on use, and the
|
||||
-- per-DID mutex that serializes refreshes is in-process only, so without this a
|
||||
-- second instance's concurrent refresh looks identical to a dead session and
|
||||
-- gets it deleted out from under the user. See OAuthStore.SaveSession.
|
||||
CREATE TABLE IF NOT EXISTS oauth_sessions (
|
||||
session_key TEXT PRIMARY KEY,
|
||||
account_did TEXT NOT NULL,
|
||||
@@ -101,6 +106,7 @@ CREATE TABLE IF NOT EXISTS oauth_sessions (
|
||||
session_data TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
rev INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(account_did, session_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_sessions_did ON oauth_sessions(account_did);
|
||||
|
||||
Reference in New Issue
Block a user