Skip to content

Commit f66c440

Browse files
fix(auth): plugin-auth re-dispatch and vendor-call doors stop renewing a cookie session in-process (#22398) (#22461)
Fixes #22398 Clause-②: yes (widening) ## What this changes Eight `plugin-auth` doors read the session through better-auth in-process by a route other than `auth.api.getSession`, so the cookie-conditional rule from #22258 (`inProcessSessionReadInput`, `@objectstack/types`) did not reach them. Each read renewed a session older than `updateAge` and 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: - **Handler re-dispatches** (a `/get-session` lookup 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`, adds `disableRefresh=true` to 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-session` declares it (`getSessionQuerySchema`, coerced), and `getSessionFromCtx` (used by `sessionMiddleware` and by `AuthManager`'s own before-hooks) spreads the route's `ctx.query` into its read; none of the re-dispatched routes declares a query schema that would strip it. - **In-process vendor endpoint calls** (`addMember`, `setPassword`, `createOAuthClient`): each spreads `inProcessSessionReadInput(request.headers)` in place of `headers: request.headers`, so a cookie request hands `query: { disableRefresh: true }`, which `getSessionFromCtx` passes 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 (no `packages/types` change); `carriesSessionCookie` is reused for the URL helper. Sites changed (line numbers at this head): | Site | Door(s) | Spelling before | Now | |---|---|---|---| | `register-sso-provider.ts:64` | `/admin/sso/register`, `/admin/sso/register-saml` | `handle(new Request(sessionUrl, …))` (`/get-session`) | `inProcessRedispatchUrl(sessionUrl, h)` | | `register-sso-provider.ts:210` | `/admin/sso/register` | inner `/sso/register` (OIDC) | `inProcessRedispatchUrl(innerUrl, headers)` | | `register-sso-provider.ts:308` | `/admin/sso/register-saml` | inner `/sso/register` (SAML) | same | | `register-sso-provider.ts:413` | `/admin/sso/request-domain-verification` | inner route | `inProcessRedispatchUrl(rw.innerUrl, headers)` | | `register-sso-provider.ts:465` | `/admin/sso/verify-domain` | inner route | same | | `send-verification-email.ts:66` | `/send-verification-email` (no email in body) | `/get-session` re-dispatch | `inProcessRedispatchUrl(sessionUrl, h)` | | `send-verification-email.ts:135` | `/send-verification-email` | inner route | `inProcessRedispatchUrl(sendUrl, headers)` | | `organization-add-member.ts:176` | `/organization/add-member` | `authApi.addMember({ body, headers })` | `...inProcessSessionReadInput(request.headers)` | | `set-initial-password.ts:76` | `/set-initial-password` | `authApi.setPassword({ body, headers })` | same | | `auth-plugin.ts:3173` | `/sys-oauth-application/register` | `authApi.createOAuthClient({ body, headers })` | same | 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 optional `query` member; the `AddMemberCapableApi` shape (not exported from the entry) gains the same. Also corrects PR #22367's H5 table, as the card says: the `/admin/sso/register` split was not `gateAdmin`'s alone (its `/get-session` re-dispatch and the inner `/sso/register` read renewed too). ## The `packages/**` enumeration Census over every non-test source under `packages/**` (7,970 files; `*.test.*`, `*.spec.*`, `__tests__/`, `test(s)/` and `*-test-support.ts` excluded; tests are not doors), by TypeScript AST at this head: - **A** — a method call on ANY receiver whose text contains `api` case-insensitively (`authApi.`, `api.`, `auth.api.`, `(authApi as any).`, `(await m.getApi()).`) with an argument naming `headers`: **18** hits. - **B** — a call whose callee is named `handle`, `handleRequest` or `handler` on any receiver (the better-auth universal-handler re-dispatch): **57** hits, 19 of them better-auth. - **C** (control) — any call to a method named `getSession`, whatever the receiver and argument spelling: **40** hits. - **Control for A's receiver filter** — every call whose argument is an object literal with a `headers` member on a receiver NOT containing `api`: `fetch`/`fetchImpl`/`resilientFetch` (network clients), `resolveAuthzContext` (takes an injected `getSession`, rows below), zod `object`/`strictObject` schema builders, the verify harness's HTTP `api(` 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**. | # | Hit (file:line) | Spelling | Verdict | |---|---|---|---| | A1 | `plugin-auth/src/auth-plugin.ts:3173` | `authApi.createOAuthClient({ …headers })` | **fixed here** | | A2 | `plugin-auth/src/organization-add-member.ts:176` | `authApi.addMember({ …headers })` | **fixed here** | | A3 | `plugin-auth/src/set-initial-password.ts:76` | `authApi.setPassword({ …headers })` | **fixed here** | | A4–A7 | `plugin-auth/src/auth-plugin.ts:2465`, `:2528`, `:2595`, `:2913` | `authApi.getSession(inProcessSessionReadInput(…))` | converted (#22258) | | A8 | `plugin-auth/src/list-user-invitations-verification.ts:195` | `APIError.fromStatus('BAD_REQUEST', { message: '…headers…' })` | not a better-auth call (receiver `APIError`, `headers` is inside a message string) | | A9 | `cloud-connection/src/cloud-connection-plugin.ts:209` | `api.getSession(inProcessSessionReadInput(rawReq.headers))` | converted (#22258) | | A10 | `cloud-connection/src/marketplace-install-local-plugin.ts:2624` | `api.getSession(inProcessSessionReadInput(…))` | converted (#22258) | | A11 | `plugin-hono-server/src/current-user-endpoints.ts:412` | same | converted (#22258) | | A12 | `plugin-webhooks/src/webhook-outbox-plugin.ts:483` | same | converted (#22258) | | A13 | `rest/src/rest-server.ts:3224` | same | converted (#22258) | | A14–A15 | `runtime/src/http-dispatcher.ts:1365`, `:1445` | same | converted (#22258) | | A16 | `runtime/src/security/resolve-session-principal.ts:57` | same | converted (#22258) | | A17 | `services/service-datasource/src/admin-routes.ts:212` | same | converted (#22258) | | A18 | `services/service-storage/src/storage-service-plugin.ts:844` | same | converted (#22258) | | B1 | `plugin-auth/src/register-sso-provider.ts:64` | `handle(new Request(…/get-session))` | **fixed here** | | B2–B3 | `plugin-auth/src/register-sso-provider.ts:216`, `:313` | `handle(innerReq)` (inner `/sso/register`, OIDC and SAML) | **fixed here** (the `innerReq` URL, `:210` / `:308`) | | B4–B5 | `plugin-auth/src/register-sso-provider.ts:413`, `:465` | `handle(new Request(rw.innerUrl, …))` | **fixed here** | | B6 | `plugin-auth/src/send-verification-email.ts:66` | `handle(new Request(…/get-session))` | **fixed here** | | B7 | `plugin-auth/src/send-verification-email.ts:141` | `handle(innerReq)` | **fixed here** (the `innerReq` URL, `:135`) | | B8–B12 | `plugin-auth/src/auth-plugin.ts:2567`, `:3059`, `:3085`, `:3102`, `:3226` | `(req) => this.authManager!.handleRequest(req)` passed to a bridge | not renewing: the handler a bridge re-dispatches through; every request it receives is one of B1–B7 | | B13–B14 | `plugin-auth/src/auth-plugin.ts:2861` (`/admin/remove-user`), `:2937` (`/admin/has-permission`, delegated) | `return await this.authManager!.handleRequest(c.req.raw)` | not renewing behind the cookie: the vendor's Response is returned verbatim, so a renewal's `Set-Cookie` reaches the browser | | B15 | `plugin-auth/src/auth-plugin.ts:3265` | catch-all `handleRequest(c.req.raw)` | not renewing behind the cookie: the browser's own request, Response returned | | B16 | `plugin-auth/src/auth-plugin.ts:3545` | OIDC discovery-document `handler(req)` | not renewing: no session read | | B17 | `plugin-auth/src/auth-manager.ts:5957` | `auth.handler(request)` | the universal handler itself (`AuthManager.handleRequest`); whoever calls it owns the Response (rows above) | | B18 | `adapters/hono/src/index.ts:640` | `authService.handleRequest(c.req.raw)` | not renewing behind the cookie: forwards the browser's own request and returns `response.headers` | | B19 | `runtime/src/domains/auth.ts:138` | `authService.handleRequest(context.request)` | not renewing behind the cookie: the dispatcher's auth domain answers with that Response | | B20–B57 | 38 hits in `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-api` | job / queue / hook / pubsub / route handlers | not better-auth | | C | `cloud-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:300` | `api.getSession(inProcessSessionReadInput(h))` (argument not spelled `headers`, so A does not see them) | converted (#22258) | | C | `core/src/security/resolve-authz-context.ts:401`, `services/service-storage/src/storage-service-plugin.ts:1058` | `input.getSession(headers)` / `getSession(headers)` | wrappers: the function they call is a converted (#22258) reader (A18, and the injected readers in the row above; `mcp/src/plugin.ts:172` injects none) | | C | `drivers/driver-mongodb/src/mongodb-driver.ts` (13 hits), `services/service-storage/src/metadata-store.ts:667`, `storage-routes.ts:958`, `:1066`, `:1106`, `:1211` | `this.getSession(options)` / `store.getSession(uploadId)` | not better-auth (MongoDB client sessions, upload sessions) | Related spelling, not a call with request headers: `getSessionFromCtx(ctx)` at `plugin-auth/src/auth-manager.ts:2053` and `:7111` (before-hooks) and `list-user-invitations-verification.ts:180` (an endpoint) read inside the request's own better-auth pipeline, so a renewal's `Set-Cookie` merges into that pipeline's Response; on a re-dispatch from B1–B7 its `ctx.query` carries the rule (ablation L2 below runs exactly that hook read at `:7111`). **Other lanes: none.** Every hit outside `plugin-auth` is 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 real `AuthManager` on better-auth 1.7.3 over the shared in-memory engine, the real `registerAuthRoutes` on Hono, a session aged to `now + expiresIn − updateAge − 60 s`, `sys_session` read 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: | Door | Answer (both cases) | By cookie | Bearer only | Ablation leg (old call put back) | Ablation result | |---|---|---|---|---|---| | `/admin/sso/register` | 403 `SSO_REGISTER_FAILED` (the inner ADR-0135 D6 hook resolved the actor, then refused it) | 0 s, no cookie | +renewed to now + expiresIn, no cookie | L1 `/get-session` re-dispatch; L2 inner OIDC `/sso/register` | L1: this door and register-saml red; L2: this door red | | `/admin/sso/register-saml` | 403 `SAML_REGISTER_FAILED` | 0 s, no cookie | renewed, no cookie | L1; L3 inner SAML `/sso/register` | L3: this door red | | `/admin/sso/request-domain-verification` | 403 (`checkProviderAccess`, after `sessionMiddleware`) | 0 s, no cookie | renewed, no cookie | L4 inner route | this door red | | `/admin/sso/verify-domain` | 403 | 0 s, no cookie | renewed, no cookie | L5 inner route | this door red | | `/send-verification-email` `{}` | 400 `EMAIL_ALREADY_VERIFIED` (needs the session's user; the email came from the `/get-session` re-dispatch) | 0 s, no cookie | renewed, no cookie | L6 `/get-session` re-dispatch; L7 inner route | L6: this door red; L7: both send-verification doors red | | `/send-verification-email` `{ email }` | 400 `EMAIL_ALREADY_VERIFIED` | 0 s, no cookie | renewed, no cookie | L7 | red | | `/organization/add-member` | 400 `ORGANIZATION_NOT_FOUND` | 0 s, no cookie | renewed, no cookie | L8 `headers: request.headers` | red | | `/set-initial-password` | 409 `PASSWORD_ALREADY_SET` | 0 s, no cookie | renewed, no cookie | L9 `headers: request.headers` | red | | `/sys-oauth-application/register` | 200 (a client minted for the session's user) | 0 s, no cookie | renewed, no cookie | L10 `headers: c.req.raw.headers` | red | The `get-session` control (renews and re-issues with `Max-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; `feea8a863` adds only the changeset) through `scripts/ablation-replace.mjs` in WRAP mode (literal anchor, hit count 1 → 0, blob changed, then restored with `git checkout HEAD` and proven blob == HEAD with `git diff HEAD` empty), 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. - L1: `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. - L2, L3, L4, L5, L6, L8, L9, L10: `1 failed | 38 passed (39)`, the named door's by-cookie case, same message (+86460 s). - L7: `2 failed | 37 passed (39)`, both send-verification cookie cases. - Restored blobs (all == HEAD): `register-sso-provider.ts` 7e90a28, `send-verification-email.ts` abdc894, `organization-add-member.ts` 1242154, `set-initial-password.ts` 5904711, `auth-plugin.ts` 3235e92. Every mutated file is imported by the pin through relative `src/` imports, so no `dist/` 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 for `createMemoryEngine`), `tests 4.66s`, `Duration 19.87s`; after, 39 tests, `tests 4.72s`, `Duration 18.66s`. No new sibling test file is imported. ## Verification at `feea8a863` Every reading below was taken at `feea8a863` (`git rev-parse --short HEAD`), the head this PR opens with. - **Build.** `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 full `turbo run build --filter='!@objectstack/docs' --concurrency=2`: 72/72 (71 from the shared cache), for the gates that read every package's `dist/`. - **Typecheck.** `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0 (`tsc --noEmit`, the examples config, and `check:test-typecheck`: "10 file(s) / 94 error(s) / 23 pinned signature(s) held", unchanged). `tsc --listFilesOnly` lists the pin file and `register-sso-provider.test.ts` under `tsconfig.test.json`, and `in-process-redispatch.ts` under `tsconfig.json`. - **Tests.** `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; `SetPasswordCapableApi` gains an optional member, so no import-side suite is owed. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 68 commands from this diff; each ran with its exit code recorded, and all 68 exit 0. `check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`, no `dist/` for 39 packages) and exits 0 after the full build; `check:dts-closure` (72 packages), `check:sourcemap-no-sources-content` (68), `check:lean-entry-closure` and `check:published-files` were re-run after it, all exit 0. `--ran`: "Run reconciliation — 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN." - **Lint, narrowed and declared.** The population, read from eslint's own config, is the 8 changed `.ts` files (the changeset answers "File ignored because no matching configuration was supplied"). `eslint --no-inline-config --format json` over them: 8 linted, 0 errors, 0 warnings. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move a verdict on any untouched file. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - Census method: the triage's two spellings (`authApi.*` / `api.*` with `headers`, any receiver) are covered by one case-insensitive receiver test; a control pass over every call with a `headers` member 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. - The test `register-sso-provider.test.ts` pinned the SAML bridge's inner URL as `…/sso/register` for a request carrying a session cookie; it now expects `…/sso/register?disableRefresh=true`, the rule's URL. - `organization-add-member.ts`'s `AddMemberCapableApi` and the exported `SetPasswordCapableApi` gain an optional `query: { disableRefresh: true }`; implementers that pass better-auth's own `auth.api` need no change (it honours the key, measured by L8/L9). - The branch is not merged with `origin/main`: the one commit since the base (`3ca71b6e0`, `metadata-protocol`) touches nothing in `plugin-auth` or `types`. - Observed while building the fixture, not investigated further: with email verification on and no email service wired, `POST /api/v1/auth/send-verification-email` for an unverified user answers `500 {"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. - Out of scope, reported to the seat (not fixed here): with domain verification ON, `POST /api/v1/auth/admin/sso/request-domain-verification` and `/admin/sso/verify-domain` answer an unknown `providerId` with `400 DOMAIN_VERIFICATION_DISABLED` ("not enabled … set OS_SSO_DOMAIN_VERIFICATION"). The vendor's answer is `404 {"message":"Provider not found"}`, and the bridge treats any 404 without a `code` as "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](https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7806a14 commit f66c440

9 files changed

Lines changed: 295 additions & 16 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
---
4+
5+
fix(auth): the plugin-auth doors that re-dispatch to better-auth or call its endpoints in-process no longer renew a browser session behind its cookie (#22398)
6+
7+
**What was wrong.** A better-auth session read renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that read's own response. Eight doors read the session in-process by a route other than `auth.api.getSession`, so the rule the `getSession` readers follow did not reach them, and each kept only the JSON, or the status and body, of the response the cookie was staged on. Measured on better-auth 1.7.3 with a session aged to `now + expiresIn − updateAge − 60 s`, each moved `expires_at` by +86460 s on a cookie request and set no session cookie, before its own answer (a refusal included):
8+
9+
- through a `/get-session` re-dispatch and the bridge's forward to a better-auth route: `POST /api/v1/auth/admin/sso/register`, `POST /api/v1/auth/admin/sso/register-saml`, `POST /api/v1/auth/admin/sso/request-domain-verification`, `POST /api/v1/auth/admin/sso/verify-domain` and `POST /api/v1/auth/send-verification-email`;
10+
- through an in-process vendor endpoint call carrying the request's headers: `POST /api/v1/auth/organization/add-member` (`addMember`), `POST /api/v1/auth/set-initial-password` (`setPassword`) and `POST /api/v1/auth/sys-oauth-application/register` (`createOAuthClient`).
11+
12+
**The rule now** is the one every in-process `getSession` reader follows, decided by what the request carries:
13+
14+
- **A session cookie** (a browser): the re-dispatched URL carries `disableRefresh=true`, and a vendor endpoint call takes `inProcessSessionReadInput(headers)` from `@objectstack/types`, whose `query` better-auth's session middleware passes into its read. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie, so cookie and session expire together. Measured after the change: each door leaves `expires_at` unchanged on a cookie request and sets no cookie.
15+
- **No session cookie** (a bearer-only client): unchanged. Each door still renews the session to `now + expiresIn` and sets no cookie on a response to a request that sent none.
16+
17+
**Upgrading.** Nothing to change. The shared helpers the cloud auth proxy mounts (`runRegisterSsoProviderFromForm`, `runRegisterSamlProviderFromForm`, `runRequestDomainVerification`, `runVerifyDomain`, `runResendVerificationEmail`, `runSetInitialPassword`) carry the same rule, so both mount points stay in step. `SetPasswordCapableApi.setPassword` now also accepts an optional `query: { disableRefresh: true }`; better-auth's own `auth.api.setPassword` honours it (measured on 1.7.3).

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3164,7 +3164,10 @@ export class AuthPlugin implements Plugin {
31643164
// Forward request headers so better-auth can resolve the caller's
31653165
// session (sessionMiddleware on /oauth2/create-client). Without
31663166
// the session the row would lack `user_id` and never appear in
3167-
// the My Applications view.
3167+
// the My Applications view. [#22398] That read is in-process, so it
3168+
// takes the `getSession` reader's input: a cookie request reads
3169+
// without renewal (its cookie would be staged on a response nobody
3170+
// sends); a bearer-only one renews as before.
31683171
let result: any;
31693172
try {
31703173
result = await authApi.createOAuthClient({
@@ -3173,7 +3176,7 @@ export class AuthPlugin implements Plugin {
31733176
redirect_uris: redirectUris,
31743177
type: safeType,
31753178
},
3176-
headers: c.req.raw.headers,
3179+
...inProcessSessionReadInput(c.req.raw.headers),
31773180
});
31783181
} catch (err: any) {
31793182
const status = typeof err?.status === 'number' ? err.status : 500;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The URL for a request this plugin RE-DISPATCHES through the better-auth
5+
* universal handler on the caller's behalf: the in-process `/get-session`
6+
* lookups and the bridges that reshape a body and forward it to a better-auth
7+
* route (`register-sso-provider.ts`, `send-verification-email.ts`).
8+
*
9+
* ## Why a re-dispatch needs a rule (#22398)
10+
*
11+
* Every better-auth session read renews a session older than `updateAge` — it
12+
* moves `sys_session.expires_at` to `now + expiresIn` — and stages the renewed
13+
* cookie on THAT read's response. A re-dispatched `/get-session` answers that
14+
* response to this plugin, which keeps only its JSON; a re-dispatched route
15+
* behind `sessionMiddleware` does the same read, and the bridge keeps only its
16+
* status and body. So the renewal lands in the database and its cookie is
17+
* thrown away: the browser keeps its old cookie and its old `Max-Age`, and the
18+
* session splits exactly as `inProcessSessionReadInput` (`@objectstack/types`)
19+
* describes for an in-process `getSession` call.
20+
*
21+
* ## The rule — the same one, spelled for a URL
22+
*
23+
* A request carrying a session cookie is re-dispatched with
24+
* `disableRefresh=true` in its query, so no in-process read renews it: the
25+
* session renews only where its cookie is re-issued, the browser-facing
26+
* `/get-session`. A bearer-only request is re-dispatched unchanged and keeps
27+
* renewing — there is no cookie to fall behind.
28+
*
29+
* better-auth reads the flag from the query on both kinds of re-dispatch
30+
* (1.7.3, measured by `in-process-session-renewal.pin.test.ts`): the
31+
* `/get-session` route declares it (`getSessionQuerySchema`, coerced), and
32+
* `getSessionFromCtx` — which `sessionMiddleware` and this plugin's own
33+
* before-hooks call — spreads the route's `ctx.query` into the read it makes,
34+
* so a route that declares no query schema of its own passes the flag through.
35+
*
36+
* ⛔ The rule only ever ADDS `disableRefresh`. It never sets or forwards a
37+
* cookie, and the request's headers — which session it resolves — are not
38+
* touched.
39+
*/
40+
41+
import { carriesSessionCookie } from '@objectstack/types';
42+
43+
/**
44+
* `url` with `disableRefresh=true` in its query when `headers` (the caller's
45+
* own, as forwarded on the re-dispatch) carry a session cookie; `url` itself
46+
* otherwise.
47+
*/
48+
export function inProcessRedispatchUrl(url: string, headers: unknown): string {
49+
if (!carriesSessionCookie(headers)) return url;
50+
const target = new URL(url);
51+
target.searchParams.set('disableRefresh', 'true');
52+
return target.href;
53+
}

‎packages/plugins/plugin-auth/src/in-process-session-renewal.pin.test.ts‎

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,42 @@
3838
* `POST /admin/has-permission` → its own platform-admin branch read
3939
* Each body is one the door answers right after its read, so no second session
4040
* read follows the one under test.
41+
*
42+
* ## [#22398] The doors whose in-process read is not a `getSession` call
43+
*
44+
* The same split happened at doors that read the session through better-auth
45+
* by another route, so the `getSession` rule could not reach them:
46+
*
47+
* - a RE-DISPATCH through the better-auth handler — a `/get-session` lookup
48+
* whose response the door keeps only the JSON of, or a bridge's forward to a
49+
* better-auth route behind `sessionMiddleware` whose status and body are all
50+
* it keeps. Each now carries `disableRefresh` in its URL for a cookie
51+
* request (`in-process-redispatch.ts`);
52+
* - an in-process VENDOR ENDPOINT call carrying the request's headers, whose
53+
* session middleware reads the session and stages the renewed cookie on a
54+
* response that is dropped. Each now takes `inProcessSessionReadInput`'s
55+
* input, which better-auth's `getSessionFromCtx` spreads into that read.
56+
*
57+
* Doors and the in-process reads each one makes after the mount's own gate:
58+
* `POST /admin/sso/register` → `/get-session` re-dispatch, then the
59+
* inner `/sso/register` (its ADR-0135
60+
* D6 before-hook reads the actor)
61+
* `POST /admin/sso/register-saml` → the same pair, SAML bridge
62+
* `POST /admin/sso/request-domain-verification`,
63+
* `POST /admin/sso/verify-domain` → the inner route (`sessionMiddleware`)
64+
* `POST /send-verification-email` → `/get-session` re-dispatch (no email
65+
* in the body), then the inner route
66+
* `POST /organization/add-member` → `authApi.addMember`
67+
* `POST /set-initial-password` → `authApi.setPassword`
68+
* `POST /sys-oauth-application/register` → `authApi.createOAuthClient`
69+
* Each answer below is the one the door gives right after the LAST of its reads
70+
* ran, so it proves every read under test happened (the comment on
71+
* `RE_DISPATCH_AND_VENDOR_DOORS` says how).
72+
*
73+
* The fixture turns on what those reads need — SSO with domain verification,
74+
* the OIDC provider, email verification — and nothing reaches the network: the
75+
* SSO and domain-verification answers are refusals the vendor gives before any
76+
* discovery fetch or DNS lookup.
4177
*/
4278

4379
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
@@ -54,6 +90,8 @@ const ORIGIN = 'http://localhost:3000';
5490
const BASE = '/api/v1/auth';
5591
const ADMIN_EMAIL = 'admin.22258@example.com';
5692
const MEMBER_EMAIL = 'member.22258@example.com';
93+
/** [#22398] An SSO provider the MEMBER registered — the admin may not manage it. */
94+
const MEMBER_IDP = 'pin-22398-member-idp';
5795
/** Clock slack between the server's `now` and this file's, in ms. */
5896
const SLACK_MS = 5_000;
5997

@@ -141,7 +179,9 @@ beforeAll(async () => {
141179
secret: SECRET,
142180
baseUrl: ORIGIN,
143181
dataEngine: engine,
144-
plugins: { admin: true },
182+
// [#22398] The #22398 doors reach their in-process reads only with these on.
183+
plugins: { admin: true, sso: true, ssoDomainVerification: true, oidcProvider: true },
184+
emailVerification: {},
145185
} as any);
146186

147187
const direct = (path: string, body: unknown) =>
@@ -169,6 +209,20 @@ beforeAll(async () => {
169209
// The legacy scalar `isPlatformAdminUser` accepts as its documented
170210
// back-compat signal — every door below admits this caller.
171211
users.find((r) => r.email === ADMIN_EMAIL)!.role = 'admin';
212+
// [#22398] A verified address: `/send-verification-email` then answers 400
213+
// EMAIL_ALREADY_VERIFIED right after its session read, with no transport.
214+
users.find((r) => r.email === ADMIN_EMAIL)!.email_verified = true;
215+
// [#22398] Org-less and the member's: `checkProviderAccess` refuses the admin
216+
// 403 right after `sessionMiddleware` read the session.
217+
await engine.insert('sys_sso_provider', {
218+
id: 'ssop_pin_22398',
219+
provider_id: MEMBER_IDP,
220+
issuer: 'https://idp.pin-22398.example.com',
221+
domain: 'pin-22398.example.com',
222+
user_id: memberId,
223+
organization_id: null,
224+
domain_verified: false,
225+
});
172226

173227
// The version this package pins, read off the running instance — never assumed.
174228
const authContext: any = await manager.getAuthContext();
@@ -263,6 +317,120 @@ describe('[#22258] each admin door leaves cookie and session expiry aligned', ()
263317
}
264318
});
265319

320+
/** The error code a door answered with: the envelope's, or better-auth's native body's. */
321+
const codeOf = (json: any): string | undefined => json?.error?.code ?? json?.code;
322+
323+
/**
324+
* [#22398] Each door, and the answer that proves its last in-process read ran:
325+
*
326+
* - `/admin/sso/register(-saml)`: 403 with the bridge's own code. The inner
327+
* `/sso/register` before-hook resolved the actor and refused it (the
328+
* legacy `role` admits at the mount's gate, not at the ADR-0068 D4 hook) —
329+
* an unresolved session would have been the vendor's 401;
330+
* - the domain-verification bridges: 403 — `checkProviderAccess` refused a
331+
* provider the admin does not own, after `sessionMiddleware` resolved the
332+
* admin;
333+
* - `/send-verification-email`: better-auth's own 400 EMAIL_ALREADY_VERIFIED,
334+
* which needs the session's user (with no email in the body, that email
335+
* came from the `/get-session` re-dispatch);
336+
* - add-member: the vendor's 400 ORGANIZATION_NOT_FOUND, read after its
337+
* session; set-initial-password: 409 PASSWORD_ALREADY_SET, read after
338+
* `sensitiveSessionMiddleware`; the OAuth register: 200, the client minted
339+
* for the session's user.
340+
*/
341+
const RE_DISPATCH_AND_VENDOR_DOORS: Array<{
342+
label: string;
343+
path: string;
344+
body: () => unknown;
345+
answers: { status: number; code?: string };
346+
}> = [
347+
{
348+
label: 'POST /admin/sso/register (get-session re-dispatch + inner /sso/register)',
349+
path: '/admin/sso/register',
350+
body: () => ({ providerId: 'pin-22398-oidc', issuer: 'https://idp.pin-22398.example.com', domain: 'pin-22398.example.com', clientId: 'cid', clientSecret: 'csecret' }),
351+
answers: { status: 403, code: 'SSO_REGISTER_FAILED' },
352+
},
353+
{
354+
label: 'POST /admin/sso/register-saml (get-session re-dispatch + inner /sso/register)',
355+
path: '/admin/sso/register-saml',
356+
body: () => ({ providerId: 'pin-22398-saml', issuer: 'https://idp.pin-22398.example.com', domain: 'pin-22398.example.com', entryPoint: 'https://idp.pin-22398.example.com/sso', cert: 'MIIBpin22398' }),
357+
answers: { status: 403, code: 'SAML_REGISTER_FAILED' },
358+
},
359+
{
360+
label: 'POST /admin/sso/request-domain-verification (inner re-dispatch)',
361+
path: '/admin/sso/request-domain-verification',
362+
body: () => ({ providerId: MEMBER_IDP }),
363+
answers: { status: 403 },
364+
},
365+
{
366+
label: 'POST /admin/sso/verify-domain (inner re-dispatch)',
367+
path: '/admin/sso/verify-domain',
368+
body: () => ({ providerId: MEMBER_IDP }),
369+
answers: { status: 403 },
370+
},
371+
{
372+
label: 'POST /send-verification-email, no email (get-session re-dispatch + inner route)',
373+
path: '/send-verification-email',
374+
body: () => ({}),
375+
answers: { status: 400, code: 'EMAIL_ALREADY_VERIFIED' },
376+
},
377+
{
378+
label: 'POST /send-verification-email, explicit email (inner route)',
379+
path: '/send-verification-email',
380+
body: () => ({ email: ADMIN_EMAIL }),
381+
answers: { status: 400, code: 'EMAIL_ALREADY_VERIFIED' },
382+
},
383+
{
384+
label: 'POST /organization/add-member (authApi.addMember)',
385+
path: '/organization/add-member',
386+
body: () => ({ userId: memberId, role: 'member', organizationId: 'org_pin_22398_absent' }),
387+
answers: { status: 400, code: 'ORGANIZATION_NOT_FOUND' },
388+
},
389+
{
390+
label: 'POST /set-initial-password (authApi.setPassword)',
391+
path: '/set-initial-password',
392+
body: () => ({ newPassword: 'Another!Passw0rd-22398' }),
393+
answers: { status: 409, code: 'PASSWORD_ALREADY_SET' },
394+
},
395+
{
396+
label: 'POST /sys-oauth-application/register (authApi.createOAuthClient)',
397+
path: '/sys-oauth-application/register',
398+
body: () => ({ name: 'pin-22398', redirectURLs: 'https://app.pin-22398.example.com/callback' }),
399+
answers: { status: 200 },
400+
},
401+
];
402+
403+
/** The door gave the answer that proves its last in-process read ran. */
404+
async function expectAnswered(door: (typeof RE_DISPATCH_AND_VENDOR_DOORS)[number], res: Response) {
405+
const json: any = await res.clone().json().catch(() => null);
406+
expect(res.status, `${door.label} answered ${res.status}: ${JSON.stringify(json)}`).toBe(door.answers.status);
407+
if (door.answers.code) expect(codeOf(json), `${door.label}: ${JSON.stringify(json)}`).toBe(door.answers.code);
408+
}
409+
410+
describe('[#22398] each re-dispatch and vendor-call door leaves cookie and session expiry aligned', () => {
411+
for (const door of RE_DISPATCH_AND_VENDOR_DOORS) {
412+
it(`${door.label} — by cookie: no renewal, no cookie`, async () => {
413+
const aged = ageSession();
414+
const res = await fire(door.path, door.body(), asCookie());
415+
await expectAnswered(door, res);
416+
const after = storedExpiry();
417+
expectAligned(door.label, aged, after, res);
418+
expect(after, `${door.label}: a cookie request renewed in-process`).toBe(aged);
419+
});
420+
421+
it(`${door.label} — bearer only: renews as before, sets no cookie`, async () => {
422+
const aged = ageSession();
423+
const res = await fire(door.path, door.body(), asBearer());
424+
await expectAnswered(door, res);
425+
const after = storedExpiry();
426+
expect(after, `${door.label}: a bearer-only read no longer renews`).toBeGreaterThan(aged);
427+
expect(Math.abs(after - (Date.now() + expiresInSec * 1000)), `${door.label}: the renewal is not to now + expiresIn`)
428+
.toBeLessThan(SLACK_MS);
429+
expect(sessionCookieOf(res), `${door.label}: a cookie was set on a response to a request that sent none`).toBeNull();
430+
});
431+
}
432+
});
433+
266434
describe('[#22258] control — the browser-facing get-session still renews and re-issues', () => {
267435
it('renews an aged session and re-issues the cookie with Max-Age = expiresIn', async () => {
268436
const aged = ageSession();

‎packages/plugins/plugin-auth/src/organization-add-member.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
* `organization-add-member-team-fallback.test.ts`.
7171
*/
7272

73+
import { inProcessSessionReadInput } from '@objectstack/types';
7374
import { mapAuthApiError, type EndpointResult } from './admin-user-endpoints.js';
7475

7576
/** Minimal better-auth server-api surface this route drives. */
@@ -82,6 +83,8 @@ export interface AddMemberCapableApi {
8283
teamId?: string;
8384
};
8485
headers?: Headers;
86+
/** `disableRefresh` for a cookie request (#22398, see the call below). */
87+
query?: { disableRefresh: true };
8588
}): Promise<Record<string, unknown> | null>;
8689
}
8790

@@ -165,14 +168,19 @@ export async function runOrganizationAddMember(
165168
// admin's ACTIVE organization (the action metadata's documented
166169
// behaviour). The vendor endpoint is server-only and does no
167170
// authorization of its own — the mount's platform-admin gate already ran.
171+
// [#22398] The vendor reads the session from those headers in-process, and
172+
// a renewal would stage its cookie on a response nobody sends; so the call
173+
// takes the same input a `getSession` reader does (better-auth's
174+
// `getSessionFromCtx` spreads the call's `query` into that read). A cookie
175+
// request does not renew here; a bearer-only one does, as before.
168176
const member = await authApi.addMember({
169177
body: {
170178
userId,
171179
role,
172180
...(organizationId ? { organizationId } : {}),
173181
...(teamId ? { teamId } : {}),
174182
},
175-
headers: request.headers,
183+
...inProcessSessionReadInput(request.headers),
176184
});
177185
return { status: 200, body: { success: true, data: { member: member ?? null } } };
178186
} catch (error) {

‎packages/plugins/plugin-auth/src/register-sso-provider.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,11 @@ describe('runRegisterSamlProviderFromForm (ADR-0069 P3)', () => {
138138
expect(res.body.success).toBe(true);
139139
expect(res.body.acsUrl).toBe('http://localhost:3000/api/v1/auth/sso/saml2/sp/acs/acme-saml');
140140
expect(res.body.spMetadataUrl).toBe('http://localhost:3000/api/v1/auth/sso/saml2/sp/metadata?providerId=acme-saml');
141-
// re-dispatched to the real /sso/register with the nested shape
142-
expect(dispatched!.url).toBe('http://localhost:3000/api/v1/auth/sso/register');
141+
// re-dispatched to the real /sso/register with the nested shape — with
142+
// `disableRefresh`, because this request carries a session cookie (#22398:
143+
// the inner read must not renew a session whose cookie this bridge never
144+
// sends back; `in-process-session-renewal.pin.test.ts` measures it).
145+
expect(dispatched!.url).toBe('http://localhost:3000/api/v1/auth/sso/register?disableRefresh=true');
143146
expect(dispatched!.body).toMatchObject({
144147
providerId: 'acme-saml',
145148
issuer: 'https://idp.acme.com/entity',

0 commit comments

Comments
 (0)