Skip to content

Commit d0d6bb8

Browse files
committed
fix(auth): plugin-auth re-dispatches and vendor endpoint calls stop renewing 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>
1 parent 2b61f2d commit d0d6bb8

7 files changed

Lines changed: 109 additions & 15 deletions

File tree

‎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/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',

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
* (per-environment runtime) — mirroring `runSetInitialPassword`.
2323
*/
2424

25+
import { inProcessRedispatchUrl } from './in-process-redispatch.js';
26+
2527
export interface RegisterSsoFormResult {
2628
/** HTTP status to return to the caller. */
2729
status: number;
@@ -57,7 +59,9 @@ async function resolveActiveOrganizationId(
5759
if (cookie) h.set('cookie', cookie);
5860
const authz = headers.get('authorization');
5961
if (authz) h.set('authorization', authz);
60-
const resp = await handle(new Request(sessionUrl, { method: 'GET', headers: h }));
62+
// [#22398] A cookie request reads without renewal: this response's cookie
63+
// is never sent (`in-process-redispatch.ts`).
64+
const resp = await handle(new Request(inProcessRedispatchUrl(sessionUrl, h), { method: 'GET', headers: h }));
6165
if (!resp.ok) return undefined;
6266
const data: any = await resp.json().catch(() => null);
6367
const org = data?.session?.activeOrganizationId ?? data?.activeOrganizationId;
@@ -201,7 +205,9 @@ export async function runRegisterSsoProviderFromForm(
201205
// regression) when no active org is set.
202206
const organizationId = await resolveActiveOrganizationId(handle, innerUrl, headers);
203207

204-
const innerReq = new Request(innerUrl, {
208+
// [#22398] `/sso/register` reads the session too (the ADR-0135 D6 before-hook
209+
// and `sessionMiddleware`), and only status and body come back from here.
210+
const innerReq = new Request(inProcessRedispatchUrl(innerUrl, headers), {
205211
method: 'POST',
206212
headers,
207213
body: JSON.stringify({ providerId, issuer, domain, oidcConfig, ...(organizationId ? { organizationId } : {}) }),
@@ -298,7 +304,8 @@ export async function runRegisterSamlProviderFromForm(
298304
// can manage the provider — see the OIDC helper above.
299305
const organizationId = await resolveActiveOrganizationId(handle, innerUrl, headers);
300306

301-
const innerReq = new Request(innerUrl, {
307+
// [#22398] Same rule as the OIDC bridge's inner `/sso/register`.
308+
const innerReq = new Request(inProcessRedispatchUrl(innerUrl, headers), {
302309
method: 'POST',
303310
headers,
304311
body: JSON.stringify({ providerId, issuer, domain, samlConfig, ...(organizationId ? { organizationId } : {}) }),
@@ -401,7 +408,9 @@ export async function runRequestDomainVerification(
401408
if (!rw) return { status: 400, body: { success: false, error: { code: 'INVALID_REQUEST', message: 'Bad request URL' } } };
402409
const headers = forwardAuthHeaders(request, rw.origin);
403410

404-
const resp = await handle(new Request(rw.innerUrl, { method: 'POST', headers, body: JSON.stringify({ providerId }) }));
411+
// [#22398] The inner route reads the session (`sessionMiddleware`); only its
412+
// status and body come back from here.
413+
const resp = await handle(new Request(inProcessRedispatchUrl(rw.innerUrl, headers), { method: 'POST', headers, body: JSON.stringify({ providerId }) }));
405414
let parsed: any = {};
406415
try { const t = await resp.text(); parsed = t ? JSON.parse(t) : {}; } catch { parsed = {}; }
407416
if (!resp.ok) {
@@ -451,7 +460,9 @@ export async function runVerifyDomain(
451460
if (!rw) return { status: 400, body: { success: false, error: { code: 'INVALID_REQUEST', message: 'Bad request URL' } } };
452461
const headers = forwardAuthHeaders(request, rw.origin);
453462

454-
const resp = await handle(new Request(rw.innerUrl, { method: 'POST', headers, body: JSON.stringify({ providerId }) }));
463+
// [#22398] The inner route reads the session (`sessionMiddleware`); only its
464+
// status and body come back from here.
465+
const resp = await handle(new Request(inProcessRedispatchUrl(rw.innerUrl, headers), { method: 'POST', headers, body: JSON.stringify({ providerId }) }));
455466
let parsed: any = {};
456467
try { const t = await resp.text(); parsed = t ? JSON.parse(t) : {}; } catch { parsed = {}; }
457468
if (resp.ok) {

‎packages/plugins/plugin-auth/src/send-verification-email.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
*/
3131

3232
import type { AuthRequestHandler } from './register-sso-provider.js';
33+
import { inProcessRedispatchUrl } from './in-process-redispatch.js';
3334

3435
export interface ResendVerificationEmailResult {
3536
/** HTTP status to return to the caller. */
@@ -60,7 +61,9 @@ async function resolveSessionEmail(
6061
if (cookie) h.set('cookie', cookie);
6162
const authz = headers.get('authorization');
6263
if (authz) h.set('authorization', authz);
63-
const resp = await handle(new Request(sessionUrl, { method: 'GET', headers: h }));
64+
// [#22398] A cookie request reads without renewal: this response's cookie
65+
// is never sent (`in-process-redispatch.ts`).
66+
const resp = await handle(new Request(inProcessRedispatchUrl(sessionUrl, h), { method: 'GET', headers: h }));
6467
if (!resp.ok) return undefined;
6568
const data: any = await resp.json().catch(() => null);
6669
// customSession shapes the payload as `{ user, session }`; be tolerant of
@@ -127,7 +130,9 @@ export async function runResendVerificationEmail(
127130

128131
// Re-dispatch to the real better-auth route (the universal handler bypasses
129132
// this wrapper, so there is no recursion) with the resolved email.
130-
const innerReq = new Request(sendUrl, {
133+
// [#22398] The real route reads the session as well, and only its status and
134+
// body come back from here — the same rule as the lookup above.
135+
const innerReq = new Request(inProcessRedispatchUrl(sendUrl, headers), {
131136
method: 'POST',
132137
headers,
133138
body: JSON.stringify({ email, ...(callbackURL ? { callbackURL } : {}) }),

‎packages/plugins/plugin-auth/src/set-initial-password.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,14 @@
2121
* route on one path but not the other).
2222
*/
2323

24-
/** Minimal shape of the better-auth server API we depend on. */
24+
import { inProcessSessionReadInput } from '@objectstack/types';
25+
26+
/**
27+
* Minimal shape of the better-auth server API we depend on. `query` carries
28+
* `disableRefresh` for a cookie request (#22398, see the call below).
29+
*/
2530
export interface SetPasswordCapableApi {
26-
setPassword(opts: { body: { newPassword: string }; headers: Headers }): Promise<unknown>;
31+
setPassword(opts: { body: { newPassword: string }; headers: Headers; query?: { disableRefresh: true } }): Promise<unknown>;
2732
}
2833

2934
export interface SetInitialPasswordResult {
@@ -62,7 +67,13 @@ export async function runSetInitialPassword(
6267
try {
6368
// better-auth's session middleware reads the session from `headers`;
6469
// length checks + the "already set" guard happen inside setPassword.
65-
await authApi.setPassword({ body: { newPassword }, headers: request.headers });
70+
// [#22398] That read is an in-process session read whose renewed cookie
71+
// would be staged on a response nobody sends, so it takes the same input
72+
// a `getSession` reader does: better-auth's `getSessionFromCtx` spreads
73+
// the call's `query` into the read, and the endpoint declares no query
74+
// schema that would strip it. A cookie request does not renew here; a
75+
// bearer-only one does, as before.
76+
await authApi.setPassword({ body: { newPassword }, ...inProcessSessionReadInput(request.headers) });
6677
return { status: 200, body: { success: true } };
6778
} catch (error) {
6879
return mapSetPasswordError(error);

0 commit comments

Comments
 (0)