Repository navigation
fix(auth): plugin-auth re-dispatch and vendor-call doors stop renewing a cookie session in-process (#22398) - #22461
Conversation
…enewing a cookie session in-process A /get-session re-dispatch, a bridge's re-dispatch to a better-auth route, and an in-process vendor endpoint call each read the session through better-auth, whose renewal stages its cookie on a response this plugin never sends. A request carrying a session cookie now reads without renewal at each of them (disableRefresh in the re-dispatched URL's query, or the getSession reader's input spread into the vendor call); a bearer-only request renews as before. Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
…tter-auth Nine door shapes (the SSO register and domain-verification bridges, the send-verification-email wrapper with and without an email, add-member, set-initial-password and the OAuth self-service register), each by cookie past updateAge (expires_at unchanged, no cookie) and bearer-only (renews to now + expiresIn, no cookie), on the existing real AuthManager + registerAuthRoutes harness. Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
…ll session rule Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 15 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 60b4c8fd975c772831023ce98b941baa5e886111 && git checkout 60b4c8fd975c772831023ce98b941baa5e886111
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 5816df41832b110a4cc3dc6ece0f8a4db8d6d7a8 && git checkout -B drift-repro 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 && git merge --no-ff 5816df41832b110a4cc3dc6ece0f8a4db8d6d7a8
node scripts/docs-audit/affected-docs.mjs --json 3ca71b6e05efbfc6ec5908c8c263fee6cceba389
|
Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
…n optional query The published SetPasswordCapableApi.setPassword options gain an optional `query` key, an additive widening of the package's public surface, which takes at least minor. Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #22398 (body and all five comments: triage ① Derived judgmentsThe published surface this diff is judged against. Every surface change the diff makes, named right or wrong:
Accept-set judgments (what each door accepts and answers):
The docs-drift rows (comment Check-runs on the head (latest per name, 33 names): 28 success, 3 skipped ( ② Semver level
③ Boundary flagsFile list against claim Dev flags (reports
Not yet reported on the head: Checks read 2026-10-09T11:01Z. Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22398
Clause-②: yes (widening)
What this changes
Eight
plugin-authdoors read the session through better-auth in-process by a route other thanauth.api.getSession, so the cookie-conditional rule from #22258 (inProcessSessionReadInput,@objectstack/types) did not reach them. Each read renewed a session older thanupdateAgeand staged the renewed cookie on a response the door threw away: the split session. The same rule now applies at every one of those reads, spelled for its call shape:/get-sessionlookup whose JSON is all the door keeps, or a bridge's forward to a better-auth route whose status and body are all it keeps): a new helper,in-process-redispatch.ts, addsdisableRefresh=trueto the re-dispatched URL when the caller's headers carry a session cookie. better-auth 1.7.3 reads it on both kinds of re-dispatch:/get-sessiondeclares it (getSessionQuerySchema, coerced), andgetSessionFromCtx(used bysessionMiddlewareand byAuthManager's own before-hooks) spreads the route'sctx.queryinto its read; none of the re-dispatched routes declares a query schema that would strip it.addMember,setPassword,createOAuthClient): each spreadsinProcessSessionReadInput(request.headers)in place ofheaders: request.headers, so a cookie request handsquery: { disableRefresh: true }, whichgetSessionFromCtxpasses into the endpoint's session read. None of the three endpoints declares a query schema.Cookie request: no in-process read renews, no cookie is set. Bearer-only request: unchanged, still renews to
now + expiresIn, still no cookie.inProcessSessionReadInput's semantics are untouched (nopackages/typeschange);carriesSessionCookieis reused for the URL helper.Sites changed (line numbers at this head):
register-sso-provider.ts:64/admin/sso/register,/admin/sso/register-samlhandle(new Request(sessionUrl, …))(/get-session)inProcessRedispatchUrl(sessionUrl, h)register-sso-provider.ts:210/admin/sso/register/sso/register(OIDC)inProcessRedispatchUrl(innerUrl, headers)register-sso-provider.ts:308/admin/sso/register-saml/sso/register(SAML)register-sso-provider.ts:413/admin/sso/request-domain-verificationinProcessRedispatchUrl(rw.innerUrl, headers)register-sso-provider.ts:465/admin/sso/verify-domainsend-verification-email.ts:66/send-verification-email(no email in body)/get-sessionre-dispatchinProcessRedispatchUrl(sessionUrl, h)send-verification-email.ts:135/send-verification-emailinProcessRedispatchUrl(sendUrl, headers)organization-add-member.ts:176/organization/add-memberauthApi.addMember({ body, headers })...inProcessSessionReadInput(request.headers)set-initial-password.ts:76/set-initial-passwordauthApi.setPassword({ body, headers })auth-plugin.ts:3173/sys-oauth-application/registerauthApi.createOAuthClient({ body, headers })The four SSO bridges and the send-verification wrapper are the shared helpers the cloud auth proxy also mounts, so both mount points carry the rule.
SetPasswordCapableApi.setPassword(exported) gains an optionalquerymember; theAddMemberCapableApishape (not exported from the entry) gains the same.Also corrects PR #22367's H5 table, as the card says: the
/admin/sso/registersplit was notgateAdmin's alone (its/get-sessionre-dispatch and the inner/sso/registerread renewed too).The
packages/**enumerationCensus over every non-test source under
packages/**(7,970 files;*.test.*,*.spec.*,__tests__/,test(s)/and*-test-support.tsexcluded; tests are not doors), by TypeScript AST at this head:apicase-insensitively (authApi.,api.,auth.api.,(authApi as any).,(await m.getApi()).) with an argument namingheaders: 18 hits.handle,handleRequestorhandleron any receiver (the better-auth universal-handler re-dispatch): 57 hits, 19 of them better-auth.getSession, whatever the receiver and argument spelling: 40 hits.headersmember on a receiver NOT containingapi:fetch/fetchImpl/resilientFetch(network clients),resolveAuthzContext(takes an injectedgetSession, rows below), zodobject/strictObjectschema builders, the verify harness's HTTPapi(helper, and other non-auth helpers. No in-process better-auth call outside A.Verdicts: converted (#22258) (already carries
inProcessSessionReadInput, PR #22367 / PR #22396); fixed here (this PR); not renewing (with the reason); or not better-auth.plugin-auth/src/auth-plugin.ts:3173authApi.createOAuthClient({ …headers })plugin-auth/src/organization-add-member.ts:176authApi.addMember({ …headers })plugin-auth/src/set-initial-password.ts:76authApi.setPassword({ …headers })plugin-auth/src/auth-plugin.ts:2465,:2528,:2595,:2913authApi.getSession(inProcessSessionReadInput(…))plugin-auth/src/list-user-invitations-verification.ts:195APIError.fromStatus('BAD_REQUEST', { message: '…headers…' })APIError,headersis inside a message string)cloud-connection/src/cloud-connection-plugin.ts:209api.getSession(inProcessSessionReadInput(rawReq.headers))cloud-connection/src/marketplace-install-local-plugin.ts:2624api.getSession(inProcessSessionReadInput(…))plugin-hono-server/src/current-user-endpoints.ts:412plugin-webhooks/src/webhook-outbox-plugin.ts:483rest/src/rest-server.ts:3224runtime/src/http-dispatcher.ts:1365,:1445runtime/src/security/resolve-session-principal.ts:57services/service-datasource/src/admin-routes.ts:212services/service-storage/src/storage-service-plugin.ts:844plugin-auth/src/register-sso-provider.ts:64handle(new Request(…/get-session))plugin-auth/src/register-sso-provider.ts:216,:313handle(innerReq)(inner/sso/register, OIDC and SAML)innerReqURL,:210/:308)plugin-auth/src/register-sso-provider.ts:413,:465handle(new Request(rw.innerUrl, …))plugin-auth/src/send-verification-email.ts:66handle(new Request(…/get-session))plugin-auth/src/send-verification-email.ts:141handle(innerReq)innerReqURL,:135)plugin-auth/src/auth-plugin.ts:2567,:3059,:3085,:3102,:3226(req) => this.authManager!.handleRequest(req)passed to a bridgeplugin-auth/src/auth-plugin.ts:2861(/admin/remove-user),:2937(/admin/has-permission, delegated)return await this.authManager!.handleRequest(c.req.raw)Set-Cookiereaches the browserplugin-auth/src/auth-plugin.ts:3265handleRequest(c.req.raw)plugin-auth/src/auth-plugin.ts:3545handler(req)plugin-auth/src/auth-manager.ts:5957auth.handler(request)AuthManager.handleRequest); whoever calls it owns the Response (rows above)adapters/hono/src/index.ts:640authService.handleRequest(c.req.raw)response.headersruntime/src/domains/auth.ts:138authService.handleRequest(context.request)cli/bin,client,core(hook dispatch, memory job),mcp(transport),objectql(hooks),plugin-hono-server(adapter.ts:688,current-user-endpoints.ts:749),plugin-security,qa/http-conformance,runtime(route/liveness/domain handlers, artifact jobs, instrumentation),service-automation,service-cluster(-redis),service-job,service-queue,service-realtime,service-settings,trigger-apicloud-connection/…/marketplace-install-local-plugin.ts:2794,plugin-sharing/src/sharing-plugin.ts:941,rest/src/rest-server.ts:3037,runtime/src/security/resolve-execution-context.ts:165,services/service-settings/src/settings-service-plugin.ts:300api.getSession(inProcessSessionReadInput(h))(argument not spelledheaders, so A does not see them)core/src/security/resolve-authz-context.ts:401,services/service-storage/src/storage-service-plugin.ts:1058input.getSession(headers)/getSession(headers)mcp/src/plugin.ts:172injects none)drivers/driver-mongodb/src/mongodb-driver.ts(13 hits),services/service-storage/src/metadata-store.ts:667,storage-routes.ts:958,:1066,:1106,:1211this.getSession(options)/store.getSession(uploadId)Related spelling, not a call with request headers:
getSessionFromCtx(ctx)atplugin-auth/src/auth-manager.ts:2053and:7111(before-hooks) andlist-user-invitations-verification.ts:180(an endpoint) read inside the request's own better-auth pipeline, so a renewal'sSet-Cookiemerges into that pipeline's Response; on a re-dispatch from B1–B7 itsctx.querycarries the rule (ablation L2 below runs exactly that hook read at:7111).Other lanes: none. Every hit outside
plugin-authis either converted (#22258) or not better-auth, so no card is owed from this PR.Pins and ablations
src/in-process-session-renewal.pin.test.ts(the #22258 real-better-auth harness: a realAuthManageron better-auth 1.7.3 over the shared in-memory engine, the realregisterAuthRouteson Hono, a session aged tonow + expiresIn − updateAge − 60 s,sys_sessionread off the engine) gains 9 door shapes × 2 cases. The fixture now turns on SSO with domain verification, the OIDC provider and email verification, marks the admin's address verified and seeds one org-less SSO provider owned by the member; nothing reaches the network.Each door's answer proves its last in-process read ran:
/admin/sso/registerSSO_REGISTER_FAILED(the inner ADR-0135 D6 hook resolved the actor, then refused it)/get-sessionre-dispatch; L2 inner OIDC/sso/register/admin/sso/register-samlSAML_REGISTER_FAILED/sso/register/admin/sso/request-domain-verificationcheckProviderAccess, aftersessionMiddleware)/admin/sso/verify-domain/send-verification-email{}EMAIL_ALREADY_VERIFIED(needs the session's user; the email came from the/get-sessionre-dispatch)/get-sessionre-dispatch; L7 inner route/send-verification-email{ email }EMAIL_ALREADY_VERIFIED/organization/add-memberORGANIZATION_NOT_FOUNDheaders: request.headers/set-initial-passwordPASSWORD_ALREADY_SETheaders: request.headers/sys-oauth-application/registerheaders: c.req.raw.headersThe
get-sessioncontrol (renews and re-issues withMax-Age = expiresIn) and the #22258 precondition (a bare in-process read renews) are unchanged in the same file.Ablation, run at
b9b04ef93(source-identical to the head;feea8a863adds only the changeset) throughscripts/ablation-replace.mjsin WRAP mode (literal anchor, hit count 1 → 0, blob changed, then restored withgit checkout HEADand proven blob == HEAD withgit diff HEADempty), inside a driver whose own trap restored every touched file to HEAD by absolute path and re-proved it. Predicted direction: turn red on exactly the named door's cookie case(s), bearer controls green. Observed: exactly that, every leg.2 failed | 37 passed (39)—POST /admin/sso/register …: the session renewed (+86460 s) but its cookie was not re-issued, and the same for register-saml.1 failed | 38 passed (39), the named door's by-cookie case, same message (+86460 s).2 failed | 37 passed (39), both send-verification cookie cases.register-sso-provider.ts7e90a28d0efe,send-verification-email.tsabdc89488528,organization-add-member.ts124215441d17,set-initial-password.ts590471136670,auth-plugin.ts3235e929a46b.Every mutated file is imported by the pin through relative
src/imports, so nodist/leg applies.Pin file runtime (shared box, read as a ratio): before, 21 tests (11 own + 10 from
impersonation-bearer-rotation.test.ts, which the file already imported forcreateMemoryEngine),tests 4.66s,Duration 19.87s; after, 39 tests,tests 4.72s,Duration 18.66s. No new sibling test file is imported.Verification at
feea8a863Every reading below was taken at
feea8a863(git rev-parse --short HEAD), the head this PR opens with.turbo run build --filter='@objectstack/plugin-auth^...' --concurrency=2: 27/27;pnpm --filter @objectstack/plugin-auth build: exit 0 (2/2 declaration files); then the fullturbo run build --filter='!@objectstack/docs' --concurrency=2: 72/72 (71 from the shared cache), for the gates that read every package'sdist/.pnpm --filter @objectstack/plugin-auth typecheck: exit 0 (tsc --noEmit, the examples config, andcheck:test-typecheck: "10 file(s) / 94 error(s) / 23 pinned signature(s) held", unchanged).tsc --listFilesOnlylists the pin file andregister-sso-provider.test.tsundertsconfig.test.json, andin-process-redispatch.tsundertsconfig.json.pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2:Test Files 133 passed (133),Tests 2711 passed | 10 skipped (2721), 18 of them this PR's. The pin file alone:Tests 39 passed (39). Public surface: no new export from the package entry;SetPasswordCapableApigains an optional member, so no import-side suite is owed.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 68 commands from this diff; each ran with its exit code recorded, and all 68 exit 0.check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET, nodist/for 39 packages) and exits 0 after the full build;check:dts-closure(72 packages),check:sourcemap-no-sources-content(68),check:lean-entry-closureandcheck:published-fileswere re-run after it, all exit 0.--ran: "Run reconciliation — 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.".tsfiles (the changeset answers "File ignored because no matching configuration was supplied").eslint --no-inline-config --format jsonover them: 8 linted, 0 errors, 0 warnings.eslint.config.mjsenables no type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move a verdict on any untouched file. The repo-widepnpm lintis CI's.Acceptance notes
authApi.*/api.*withheaders, any receiver) are covered by one case-insensitive receiver test; a control pass over every call with aheadersmember on any other receiver found no in-process better-auth call.handler(re-dispatches are counted for every route, not only/get-session, because the bridges' inner forwards (card item 3) renew the same way.register-sso-provider.test.tspinned the SAML bridge's inner URL as…/sso/registerfor a request carrying a session cookie; it now expects…/sso/register?disableRefresh=true, the rule's URL.organization-add-member.ts'sAddMemberCapableApiand the exportedSetPasswordCapableApigain an optionalquery: { disableRefresh: true }; implementers that pass better-auth's ownauth.apineed no change (it honours the key, measured by L8/L9).origin/main: the one commit since the base (3ca71b6e0,metadata-protocol) touches nothing inplugin-authortypes.POST /api/v1/auth/send-verification-emailfor an unverified user answers500 {"success":false}; the "no email service is configured" reason the AuthManager throws reaches the server log only (better-auth answers a thrown non-API error with an empty 500 body). A misconfiguration path; noted, not filed.POST /api/v1/auth/admin/sso/request-domain-verificationand/admin/sso/verify-domainanswer an unknownproviderIdwith400 DOMAIN_VERIFICATION_DISABLED("not enabled … set OS_SSO_DOMAIN_VERIFICATION"). The vendor's answer is404 {"message":"Provider not found"}, and the bridge treats any 404 without acodeas "feature off" (feature off is a 404 with an empty body). Measured in this PR's harness before the pins were written.Generated by Claude Code