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:
Evan Jarrett
2026-08-11 21:40:12 -05:00
co-authored by Claude Opus 5
parent 934e4a2a59
commit e75b2e246b
6 changed files with 744 additions and 9 deletions
+6
View File
@@ -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);