fix: persist rotated refresh tokens, close silent-empty auth gaps - #79
Open
ryantlee25-droid wants to merge 1 commit into
Open
fix: persist rotated refresh tokens, close silent-empty auth gaps#79ryantlee25-droid wants to merge 1 commit into
ryantlee25-droid wants to merge 1 commit into
Conversation
Supabase rotates the refresh token on every refresh and invalidates the previous one immediately. createSupabaseClient used client defaults (autoRefreshToken: true) with Node's in-memory session store, so the long-lived MCP server kept spending the CLI's on-disk refresh token on its 30s auto-refresh tick without ever writing the rotation back to the ~/.config/tages/auth.json the CLI and server share. The on-disk credential eventually hit refresh_token_already_used and stayed dead. rlee@mersive.com's session died about an hour after login and stayed dead for four days; both pending phoenix teammates would have hit the same wall within an hour of joining. - packages/shared/src/auth-store.ts (new): single auth.json reader/writer shared by CLI and server. Atomic writes (temp file + rename), unconditional 0600, cleans up the temp file on failure. - packages/shared/src/auth-persist.ts (new): persistSessionOnRefresh listens for TOKEN_REFRESHED and writes it to disk; persistRotatedTokens refuses to overwrite a disk token with a later expiry than the incoming one, so a stale background process can't clobber a fresh `tages login` from another terminal. Registered before setSession() at every long-lived call site (server index.ts/config.ts, CLI auth/session.ts, both backfill-*.ts scripts), since an expired access token makes setSession refresh immediately and that first rotation was the one spending the disk token. - init.ts, link.ts, migrate.ts now route their auth.json writes through the shared writer instead of a bare writeFileSync. link is the command a teammate runs to join a project; the old truncate-then-write could race the server's rename and drop a freshly minted OAuth token into an orphaned inode while still reporting success. - New requireLiveSession guard on team (all 4 subcommands), status, and onboard. A dead session previously fell back silently to an anonymous client, read zero rows through RLS, and exited 0 with "No team members" / "Memories: 0" / an empty briefing. Covers anonymous as well as expired (`tages logout && tages team list` reproduces it in one step). - Versions: @tages/shared 0.2.3->0.2.4, @tages/server 0.3.4->0.3.5, @tages/cli 0.5.5->0.5.6. Docs refreshed to match, plus a new trap section on this bug. Not fixed, noted in the PR: persistRotatedTokens has no lock, so two simultaneous refreshes can still lose one rotation; no fsync before rename; 29 other CLI commands still share the old empty-vs-expired ambiguity (recall-context, pending, brief, query next). Tests: +12, each mutation-tested against its own removal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012j48bNYeNWSu4dorw5wG6V
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Rotated Supabase refresh tokens were only ever kept in memory by the long-lived MCP server, so the server was silently spending the CLI's on-disk refresh token (
~/.config/tages/auth.json, shared by both) on its 30s auto-refresh tick without ever writing the rotation back. The on-disk credential eventually hitrefresh_token_already_usedand stayed dead. Confirmed empirically:auth.json's mtime was still the original login while a direct token-endpoint call returnedrefresh_token_already_used.rlee@mersive.com's session died about an hour after login and stayed dead for four days; both pendingphoenixteammates would have hit the same wall within an hour of joining.packages/shared/src/auth-store.ts(new): the singleauth.jsonreader/writer, moved out of the CLI so the server uses the same code path. Atomic writes (temp file +rename), unconditional0600, cleans up the temp file on any write failure.packages/shared/src/auth-persist.ts(new):persistSessionOnRefreshregisters anonAuthStateChangelistener that writesTOKEN_REFRESHEDsessions back to disk.persistRotatedTokensrefuses to write when the token already on disk has a later expiry than the incoming one, so a stale long-lived process can't clobber a freshtages loginrun elsewhere. Registered beforesetSession()at every long-lived call site — serverindex.ts/config.ts, CLIauth/session.ts, bothbackfill-*.tsscripts — because an expired access token makessetSessionrefresh immediately, and that first rotation is the one that was spending the disk token.init.ts,link.ts,migrate.tshad their own barewriteFileSynconauth.json; now route through the shared writer.linkis the command a teammate runs to join — a truncate-then-write racing the server'srenamecould drop a freshly minted OAuth token into an orphaned inode while still reporting success.requireLiveSessionguard onteam(all 4 subcommands),status, andonboard. On a dead session these previously fell back to an anonymous client, read zero rows through RLS, and exited0with "No team members" / "Memories: 0" / an empty briefing —tages team listreported no members for a project with two pending invites. The guard coversanonymoussessions as well asexpired(tages logout && tages team listreproduces it in one step).packages/shared/tsconfig.build.json(new) keeps tests out of the publisheddistwhiletsconfig.jsonkeeps them undertsc --noEmit;sharedalso gained@types/node(TS 6.0.2 stopped auto-discovering them).@tages/shared0.2.3→0.2.4,@tages/server0.3.4→0.3.5,@tages/cli0.5.5→0.5.6.docs/quickstart.mdanddocs/team-onboarding.mdversion numbers refreshed, a stale claim about MCPremembersilently reporting success corrected, and a new trap section documenting this bug.Not fixed
persistRotatedTokensdoes read-compare-write with no lock. Two processes refreshing at the same instant can still lose one rotation — strictly better than always losing it, not zero. AnO_EXCLlockfile would close it.renamecan leave anauth.json.tmp.<pid>holding a live refresh token at0600inside a0700directory. Not a disclosure, but nothing reaps it.fsyncbeforerename: atomic against concurrent readers, not against power loss.createAuthenticatedClientand keep the same empty-vs-expired ambiguity this PR closed forteam/status/onboard.recall-context,pending,brief, andqueryare next.Test plan
pnpm build— 0 errorspnpm typecheck— 0 errorspnpm -r test— 1554 passingpnpm install --frozen-lockfile— cleanTOKEN_REFRESHEDevent filter,team listguard,anonymousbranch, missing-token guard, listener write-failure catch,readAuthFilenull returns, temp-file cleanup on failureNote: the 6 pre-existing eslint errors in
packages/server/src/index.tsandpackages/cli/src/__tests__/commands-smoke.test.tsare outside these hunks and unrelated to this change; lint is not a required CI check.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_012j48bNYeNWSu4dorw5wG6V