Repository navigation
fix(plugin-auth): a minted principal session is never refreshed by a read, so it boots the console - #23828
Conversation
…read better-auth renewed a minted session on every read: the row's updatedAt moved, the session cookie was re-set, and bearer() answered set-auth-token on every request, which the console reads as a token rotation and re-resolves identity on, looping its boot. A request that carries a minted session now sets better-auth's own per-request skip-refresh switch from the global before-hook, so no endpoint renews it on either lane. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138Lk1GC4xpkss8esKzWVFA
Real Chromium, the built console composed into the showcase's plugins as os serve mounts it, and a session cookie minted by mintPrincipalSession, for an administrator and a member; a normal sign-in session is the control. Skips, and says so, without a built console or a Chromium. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138Lk1GC4xpkss8esKzWVFA
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138Lk1GC4xpkss8esKzWVFA
…le-boot pin Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138Lk1GC4xpkss8esKzWVFA
📓 Docs Drift Check2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 18671a904b7430e8a56b8a376729e28d79351de7 && git checkout 18671a904b7430e8a56b8a376729e28d79351de7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1ecb08a1ccb8f574ec4df8d4a65e1354ca55596c d4839df3ada8d8b73eaf404a54189047e998e8b6 && git checkout -B drift-repro 1ecb08a1ccb8f574ec4df8d4a65e1354ca55596c && git merge --no-ff d4839df3ada8d8b73eaf404a54189047e998e8b6
node scripts/docs-audit/affected-docs.mjs --json 1ecb08a1ccb8f574ec4df8d4a65e1354ca55596c |
席位核对 · ACCEPT ·
|
Fixes #23813
Clause-②: no
What was wrong
better-auth refreshed a session minted by
mintPrincipalSessionon every read. better-auth renews a session onceexpiresAt - expiresIn + updateAgehas passed, and a 120-second session is past that from its first read. #23772'sholdPrincipalSessionExpirykeptexpiresAtwhere it was, but the refresh still ran. On each request it:updatedAt,Max-Age,bearer()to answerset-auth-token.The console's auth client (objectui
TokenStorage.subscribeRotationinAuthProvider) treatsset-auth-tokenas a token rotation and runsloadSessionagain. The newuserobject then re-runsrefreshOrganizations, and the organization reads refresh the session again. The console never leaves "Opening your console…".Measured cause
Both of the card's hypotheses were tested on the same stack. The only variable toggled was the refresh skip; the minted
activeOrganizationIdstayed the same..objectui-shadaeb77fc84, Chromium 141)get-sessionin 30 s, 1238set-auth-tokenresponses, 65 moreget-sessionin the 3 s afterget-session, 0set-auth-token, 0 afteractiveOrganizationIdboots once the refresh is gone.At the wire, a scratch probe on the real better-auth (
AuthManager.handleRequest) before the fix showed every mintedget-sessionon both lanes answeringset-auth-tokenplus a sessionSet-Cookie, withupdated_atadvancing on every read andexpires_atheld. After the fix: noset-auth-token, noSet-Cookie, andupdated_atunchanged.The fix (
packages/plugins/plugin-auth)principal-session-mint.ts: addscarriesPrincipalSession(ctx). It answers true when the request carries a minted session, either as the session cookie or asAuthorization: Bearer(every spelling). It reads only the token brand and verifies nothing. That is deliberate: all it decides is "do not renew", which grants nothing. The module header gains section 3b.auth-manager.ts: the globalhooks.beforeis the first hook every endpoint passes, beforebearer(). When the request carries a minted session, it calls better-auth's own per-request switchsetShouldSkipSessionRefresh(true). better-auth's Next.js plugin sets the same switch for RSC reads. The switch is request-scoped, so it coversget-sessionand every endpoint that reads the session through better-auth's middleware (the organization reads included), on the cookie lane, the bearer lane and in-process.holdPrincipalSessionExpirystays as the backstop for any renewal that does not ask the switch.No
IAuthServicecontract change, nothing inpackages/spec. The built declaration closure of@objectstack/plugin-auth(index.d.ts,index.d.mts,rate-limit-storage.d.ts,rate-limit-storage.d.mts) is byte-identical at based7957199and at head (sha256 compared after a build of each). HenceClause-②: no.Alternatives (four axes, in short)
PrincipalSession, which is apackages/speccontract change. It would also hold only on the cookie lane: a bearer request carries no cookie. Rejected.falsefromsession.update.before.updateSessionwould then answer null, andget-sessiondeletes the cookie and throws 401. Rejected.Pins
plugin-auth/src/principal-session-mint.no-refresh.pin.test.ts: a real better-auth through the real request seam, cookie and bearer lanes ×/get-sessionand/list-sessions(which reads the session throughsessionMiddleware, as the organization reads do). Each read must answer with noset-auth-token, no sessionSet-Cookie, andupdated_at/expires_atunchanged. A control proves the same read does refresh an unbranded short session, so the pin cannot pass vacuously. Unit cases covercarriesPrincipalSession.qa/dogfood/test/principal-session-console-boot.dogfood.test.ts: the end-to-end pin. The showcase is booted in-process, with the built console composed into its plugins by the CLI's owncreateConsoleStaticPlugin(whatos servemounts). Each browser request goes to the real Hono app from a real Chromium. The minted cookie is set exactly as handed out. The test pins boot complete, noget-sessionin the settle window, and noset-auth-token, for an administrator and a member. A normal sign-in is the control. It needs the built console and a Chromium, and saysSKIPPEDwithout them.playwright-core1.63.0(already in the lockfile) becomes a devDependency of the private@objectstack/dogfood.IAuthServicemember with itsAuthManagerimplementation, the seam #22269's print service consumes (S2 of #22269) #23772's pins (principal-session-mint.test.ts,.cookie-lane.pin,.bearer-lane.pin: TTL, no extension, revoke, refusals) are unchanged and green.Ablation
The defect was put back with
scripts/ablation-replace.mjson theauth-manager.tsline, gating the switch on an env var that is never set: injected 1, removed 0. The marker was confirmed indist/byablation-dist-preflight(exit 0). Results:read 1 reissued the minted session. auth(spec,plugin-auth): a short-lived principal-session mint, an optionalIAuthServicemember with itsAuthManagerimplementation, the seam #22269's print service consumes (S2 of #22269) #23772's lane pins stayed green, as they should: the hold still holds.Restored with
git checkout HEAD --. The HEAD-blob hash matched,git diff HEADwas empty, and after a rebuild the marker was absent fromdist/(exit 0 on the dist reading).Tests and gates (head
d4839df3ad)@objectstack/plugin-auth:pnpm --filter @objectstack/plugin-auth test→ 150 files, 2942 passed, 10 skipped (pre-existing), exit 0.typecheckexit 0.@objectstack/dogfood:test/principal-session-console-boot.dogfood.test.ts→ 3/3 passed with the built console (daeb77fc84) and Chromium (/opt/pw-browsers/chromium).typecheckexit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackgave 82 commands. Each was run with its exit code recorded before any pipe and stdin from/dev/null: 82 × exit 0.--ran: "82 derived famil(ies) accounted for — 82 run, 0 NOT-MEASURED". One real red on the first pass was fixed:check:test-source-aliasasked for@objectstack/cli/consoleto be aliased to source in dogfood's vitest config. Two first-pass exit-3 results were prerequisites, now met: the shallow clone was deepened (git fetch --unshallow origin main) forcheck-plugin-teardown-shape --self-test, and the 8 packagescheck:dual-build-cjs-loadsneeded were built.Acceptance notes
Dogfood Regression Gateon CI. The wire half of the pin (no-refresh.pin) runs everywhere.plugin-auth'stypecheckexcludes**/*.test.ts, so the new unit pin is type-checked by nothing local. Vitest transpiles it.@objectstack/dogfood's typecheck does cover the e2e file.pnpm installalso re-resolvedpackages/apps/account's vitest peer toesbuild 0.28.1. That is pnpm's own output and was not hand-edited; print page ③ of #8346: a render service with one headless-Chromium driver renders a print page under the requesting user's principal and archives the PDF as a sys_file (M2) #22269's dev recorded the same churn.packages/services/service-print/**was not touched.🤖 Generated with Claude Code
https://claude.ai/code/session_0138Lk1GC4xpkss8esKzWVFA
Generated by Claude Code