diff --git a/.changeset/22398-plugin-auth-session-redispatch.md b/.changeset/22398-plugin-auth-session-redispatch.md new file mode 100644 index 00000000000..37adf349950 --- /dev/null +++ b/.changeset/22398-plugin-auth-session-redispatch.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-auth': minor +--- + +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) + +**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): + +- 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`; +- 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`). + +**The rule now** is the one every in-process `getSession` reader follows, decided by what the request carries: + +- **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. +- **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. + +**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). diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 32c004a7d80..3235e929a46 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -3164,7 +3164,10 @@ export class AuthPlugin implements Plugin { // Forward request headers so better-auth can resolve the caller's // session (sessionMiddleware on /oauth2/create-client). Without // the session the row would lack `user_id` and never appear in - // the My Applications view. + // the My Applications view. [#22398] That read is in-process, so it + // takes the `getSession` reader's input: a cookie request reads + // without renewal (its cookie would be staged on a response nobody + // sends); a bearer-only one renews as before. let result: any; try { result = await authApi.createOAuthClient({ @@ -3173,7 +3176,7 @@ export class AuthPlugin implements Plugin { redirect_uris: redirectUris, type: safeType, }, - headers: c.req.raw.headers, + ...inProcessSessionReadInput(c.req.raw.headers), }); } catch (err: any) { const status = typeof err?.status === 'number' ? err.status : 500; diff --git a/packages/plugins/plugin-auth/src/in-process-redispatch.ts b/packages/plugins/plugin-auth/src/in-process-redispatch.ts new file mode 100644 index 00000000000..2630df2167e --- /dev/null +++ b/packages/plugins/plugin-auth/src/in-process-redispatch.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The URL for a request this plugin RE-DISPATCHES through the better-auth + * universal handler on the caller's behalf: the in-process `/get-session` + * lookups and the bridges that reshape a body and forward it to a better-auth + * route (`register-sso-provider.ts`, `send-verification-email.ts`). + * + * ## Why a re-dispatch needs a rule (#22398) + * + * Every better-auth session read renews a session older than `updateAge` — it + * moves `sys_session.expires_at` to `now + expiresIn` — and stages the renewed + * cookie on THAT read's response. A re-dispatched `/get-session` answers that + * response to this plugin, which keeps only its JSON; a re-dispatched route + * behind `sessionMiddleware` does the same read, and the bridge keeps only its + * status and body. So the renewal lands in the database and its cookie is + * thrown away: the browser keeps its old cookie and its old `Max-Age`, and the + * session splits exactly as `inProcessSessionReadInput` (`@objectstack/types`) + * describes for an in-process `getSession` call. + * + * ## The rule — the same one, spelled for a URL + * + * A request carrying a session cookie is re-dispatched with + * `disableRefresh=true` in its query, so no in-process read renews it: the + * session renews only where its cookie is re-issued, the browser-facing + * `/get-session`. A bearer-only request is re-dispatched unchanged and keeps + * renewing — there is no cookie to fall behind. + * + * better-auth reads the flag from the query on both kinds of re-dispatch + * (1.7.3, measured by `in-process-session-renewal.pin.test.ts`): the + * `/get-session` route declares it (`getSessionQuerySchema`, coerced), and + * `getSessionFromCtx` — which `sessionMiddleware` and this plugin's own + * before-hooks call — spreads the route's `ctx.query` into the read it makes, + * so a route that declares no query schema of its own passes the flag through. + * + * ⛔ The rule only ever ADDS `disableRefresh`. It never sets or forwards a + * cookie, and the request's headers — which session it resolves — are not + * touched. + */ + +import { carriesSessionCookie } from '@objectstack/types'; + +/** + * `url` with `disableRefresh=true` in its query when `headers` (the caller's + * own, as forwarded on the re-dispatch) carry a session cookie; `url` itself + * otherwise. + */ +export function inProcessRedispatchUrl(url: string, headers: unknown): string { + if (!carriesSessionCookie(headers)) return url; + const target = new URL(url); + target.searchParams.set('disableRefresh', 'true'); + return target.href; +} diff --git a/packages/plugins/plugin-auth/src/in-process-session-renewal.pin.test.ts b/packages/plugins/plugin-auth/src/in-process-session-renewal.pin.test.ts index dadd644fd16..37c2598be30 100644 --- a/packages/plugins/plugin-auth/src/in-process-session-renewal.pin.test.ts +++ b/packages/plugins/plugin-auth/src/in-process-session-renewal.pin.test.ts @@ -38,6 +38,42 @@ * `POST /admin/has-permission` → its own platform-admin branch read * Each body is one the door answers right after its read, so no second session * read follows the one under test. + * + * ## [#22398] The doors whose in-process read is not a `getSession` call + * + * The same split happened at doors that read the session through better-auth + * by another route, so the `getSession` rule could not reach them: + * + * - a RE-DISPATCH through the better-auth handler — a `/get-session` lookup + * whose response the door keeps only the JSON of, or a bridge's forward to a + * better-auth route behind `sessionMiddleware` whose status and body are all + * it keeps. Each now carries `disableRefresh` in its URL for a cookie + * request (`in-process-redispatch.ts`); + * - an in-process VENDOR ENDPOINT call carrying the request's headers, whose + * session middleware reads the session and stages the renewed cookie on a + * response that is dropped. Each now takes `inProcessSessionReadInput`'s + * input, which better-auth's `getSessionFromCtx` spreads into that read. + * + * Doors and the in-process reads each one makes after the mount's own gate: + * `POST /admin/sso/register` → `/get-session` re-dispatch, then the + * inner `/sso/register` (its ADR-0135 + * D6 before-hook reads the actor) + * `POST /admin/sso/register-saml` → the same pair, SAML bridge + * `POST /admin/sso/request-domain-verification`, + * `POST /admin/sso/verify-domain` → the inner route (`sessionMiddleware`) + * `POST /send-verification-email` → `/get-session` re-dispatch (no email + * in the body), then the inner route + * `POST /organization/add-member` → `authApi.addMember` + * `POST /set-initial-password` → `authApi.setPassword` + * `POST /sys-oauth-application/register` → `authApi.createOAuthClient` + * Each answer below is the one the door gives right after the LAST of its reads + * ran, so it proves every read under test happened (the comment on + * `RE_DISPATCH_AND_VENDOR_DOORS` says how). + * + * The fixture turns on what those reads need — SSO with domain verification, + * the OIDC provider, email verification — and nothing reaches the network: the + * SSO and domain-verification answers are refusals the vendor gives before any + * discovery fetch or DNS lookup. */ import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; @@ -54,6 +90,8 @@ const ORIGIN = 'http://localhost:3000'; const BASE = '/api/v1/auth'; const ADMIN_EMAIL = 'admin.22258@example.com'; const MEMBER_EMAIL = 'member.22258@example.com'; +/** [#22398] An SSO provider the MEMBER registered — the admin may not manage it. */ +const MEMBER_IDP = 'pin-22398-member-idp'; /** Clock slack between the server's `now` and this file's, in ms. */ const SLACK_MS = 5_000; @@ -141,7 +179,9 @@ beforeAll(async () => { secret: SECRET, baseUrl: ORIGIN, dataEngine: engine, - plugins: { admin: true }, + // [#22398] The #22398 doors reach their in-process reads only with these on. + plugins: { admin: true, sso: true, ssoDomainVerification: true, oidcProvider: true }, + emailVerification: {}, } as any); const direct = (path: string, body: unknown) => @@ -169,6 +209,20 @@ beforeAll(async () => { // The legacy scalar `isPlatformAdminUser` accepts as its documented // back-compat signal — every door below admits this caller. users.find((r) => r.email === ADMIN_EMAIL)!.role = 'admin'; + // [#22398] A verified address: `/send-verification-email` then answers 400 + // EMAIL_ALREADY_VERIFIED right after its session read, with no transport. + users.find((r) => r.email === ADMIN_EMAIL)!.email_verified = true; + // [#22398] Org-less and the member's: `checkProviderAccess` refuses the admin + // 403 right after `sessionMiddleware` read the session. + await engine.insert('sys_sso_provider', { + id: 'ssop_pin_22398', + provider_id: MEMBER_IDP, + issuer: 'https://idp.pin-22398.example.com', + domain: 'pin-22398.example.com', + user_id: memberId, + organization_id: null, + domain_verified: false, + }); // The version this package pins, read off the running instance — never assumed. const authContext: any = await manager.getAuthContext(); @@ -263,6 +317,120 @@ describe('[#22258] each admin door leaves cookie and session expiry aligned', () } }); +/** The error code a door answered with: the envelope's, or better-auth's native body's. */ +const codeOf = (json: any): string | undefined => json?.error?.code ?? json?.code; + +/** + * [#22398] Each door, and the answer that proves its last in-process read ran: + * + * - `/admin/sso/register(-saml)`: 403 with the bridge's own code. The inner + * `/sso/register` before-hook resolved the actor and refused it (the + * legacy `role` admits at the mount's gate, not at the ADR-0068 D4 hook) — + * an unresolved session would have been the vendor's 401; + * - the domain-verification bridges: 403 — `checkProviderAccess` refused a + * provider the admin does not own, after `sessionMiddleware` resolved the + * admin; + * - `/send-verification-email`: better-auth's own 400 EMAIL_ALREADY_VERIFIED, + * which needs the session's user (with no email in the body, that email + * came from the `/get-session` re-dispatch); + * - add-member: the vendor's 400 ORGANIZATION_NOT_FOUND, read after its + * session; set-initial-password: 409 PASSWORD_ALREADY_SET, read after + * `sensitiveSessionMiddleware`; the OAuth register: 200, the client minted + * for the session's user. + */ +const RE_DISPATCH_AND_VENDOR_DOORS: Array<{ + label: string; + path: string; + body: () => unknown; + answers: { status: number; code?: string }; +}> = [ + { + label: 'POST /admin/sso/register (get-session re-dispatch + inner /sso/register)', + path: '/admin/sso/register', + body: () => ({ providerId: 'pin-22398-oidc', issuer: 'https://idp.pin-22398.example.com', domain: 'pin-22398.example.com', clientId: 'cid', clientSecret: 'csecret' }), + answers: { status: 403, code: 'SSO_REGISTER_FAILED' }, + }, + { + label: 'POST /admin/sso/register-saml (get-session re-dispatch + inner /sso/register)', + path: '/admin/sso/register-saml', + 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' }), + answers: { status: 403, code: 'SAML_REGISTER_FAILED' }, + }, + { + label: 'POST /admin/sso/request-domain-verification (inner re-dispatch)', + path: '/admin/sso/request-domain-verification', + body: () => ({ providerId: MEMBER_IDP }), + answers: { status: 403 }, + }, + { + label: 'POST /admin/sso/verify-domain (inner re-dispatch)', + path: '/admin/sso/verify-domain', + body: () => ({ providerId: MEMBER_IDP }), + answers: { status: 403 }, + }, + { + label: 'POST /send-verification-email, no email (get-session re-dispatch + inner route)', + path: '/send-verification-email', + body: () => ({}), + answers: { status: 400, code: 'EMAIL_ALREADY_VERIFIED' }, + }, + { + label: 'POST /send-verification-email, explicit email (inner route)', + path: '/send-verification-email', + body: () => ({ email: ADMIN_EMAIL }), + answers: { status: 400, code: 'EMAIL_ALREADY_VERIFIED' }, + }, + { + label: 'POST /organization/add-member (authApi.addMember)', + path: '/organization/add-member', + body: () => ({ userId: memberId, role: 'member', organizationId: 'org_pin_22398_absent' }), + answers: { status: 400, code: 'ORGANIZATION_NOT_FOUND' }, + }, + { + label: 'POST /set-initial-password (authApi.setPassword)', + path: '/set-initial-password', + body: () => ({ newPassword: 'Another!Passw0rd-22398' }), + answers: { status: 409, code: 'PASSWORD_ALREADY_SET' }, + }, + { + label: 'POST /sys-oauth-application/register (authApi.createOAuthClient)', + path: '/sys-oauth-application/register', + body: () => ({ name: 'pin-22398', redirectURLs: 'https://app.pin-22398.example.com/callback' }), + answers: { status: 200 }, + }, +]; + +/** The door gave the answer that proves its last in-process read ran. */ +async function expectAnswered(door: (typeof RE_DISPATCH_AND_VENDOR_DOORS)[number], res: Response) { + const json: any = await res.clone().json().catch(() => null); + expect(res.status, `${door.label} answered ${res.status}: ${JSON.stringify(json)}`).toBe(door.answers.status); + if (door.answers.code) expect(codeOf(json), `${door.label}: ${JSON.stringify(json)}`).toBe(door.answers.code); +} + +describe('[#22398] each re-dispatch and vendor-call door leaves cookie and session expiry aligned', () => { + for (const door of RE_DISPATCH_AND_VENDOR_DOORS) { + it(`${door.label} — by cookie: no renewal, no cookie`, async () => { + const aged = ageSession(); + const res = await fire(door.path, door.body(), asCookie()); + await expectAnswered(door, res); + const after = storedExpiry(); + expectAligned(door.label, aged, after, res); + expect(after, `${door.label}: a cookie request renewed in-process`).toBe(aged); + }); + + it(`${door.label} — bearer only: renews as before, sets no cookie`, async () => { + const aged = ageSession(); + const res = await fire(door.path, door.body(), asBearer()); + await expectAnswered(door, res); + const after = storedExpiry(); + expect(after, `${door.label}: a bearer-only read no longer renews`).toBeGreaterThan(aged); + expect(Math.abs(after - (Date.now() + expiresInSec * 1000)), `${door.label}: the renewal is not to now + expiresIn`) + .toBeLessThan(SLACK_MS); + expect(sessionCookieOf(res), `${door.label}: a cookie was set on a response to a request that sent none`).toBeNull(); + }); + } +}); + describe('[#22258] control — the browser-facing get-session still renews and re-issues', () => { it('renews an aged session and re-issues the cookie with Max-Age = expiresIn', async () => { const aged = ageSession(); diff --git a/packages/plugins/plugin-auth/src/organization-add-member.ts b/packages/plugins/plugin-auth/src/organization-add-member.ts index 8139b1cde11..124215441d1 100644 --- a/packages/plugins/plugin-auth/src/organization-add-member.ts +++ b/packages/plugins/plugin-auth/src/organization-add-member.ts @@ -70,6 +70,7 @@ * `organization-add-member-team-fallback.test.ts`. */ +import { inProcessSessionReadInput } from '@objectstack/types'; import { mapAuthApiError, type EndpointResult } from './admin-user-endpoints.js'; /** Minimal better-auth server-api surface this route drives. */ @@ -82,6 +83,8 @@ export interface AddMemberCapableApi { teamId?: string; }; headers?: Headers; + /** `disableRefresh` for a cookie request (#22398, see the call below). */ + query?: { disableRefresh: true }; }): Promise | null>; } @@ -165,6 +168,11 @@ export async function runOrganizationAddMember( // admin's ACTIVE organization (the action metadata's documented // behaviour). The vendor endpoint is server-only and does no // authorization of its own — the mount's platform-admin gate already ran. + // [#22398] The vendor reads the session from those headers in-process, and + // a renewal would stage its cookie on a response nobody sends; so the call + // takes the same input a `getSession` reader does (better-auth's + // `getSessionFromCtx` spreads the call's `query` into that read). A cookie + // request does not renew here; a bearer-only one does, as before. const member = await authApi.addMember({ body: { userId, @@ -172,7 +180,7 @@ export async function runOrganizationAddMember( ...(organizationId ? { organizationId } : {}), ...(teamId ? { teamId } : {}), }, - headers: request.headers, + ...inProcessSessionReadInput(request.headers), }); return { status: 200, body: { success: true, data: { member: member ?? null } } }; } catch (error) { diff --git a/packages/plugins/plugin-auth/src/register-sso-provider.test.ts b/packages/plugins/plugin-auth/src/register-sso-provider.test.ts index 6b642880f5e..08b52ebba8f 100644 --- a/packages/plugins/plugin-auth/src/register-sso-provider.test.ts +++ b/packages/plugins/plugin-auth/src/register-sso-provider.test.ts @@ -138,8 +138,11 @@ describe('runRegisterSamlProviderFromForm (ADR-0069 P3)', () => { expect(res.body.success).toBe(true); expect(res.body.acsUrl).toBe('http://localhost:3000/api/v1/auth/sso/saml2/sp/acs/acme-saml'); expect(res.body.spMetadataUrl).toBe('http://localhost:3000/api/v1/auth/sso/saml2/sp/metadata?providerId=acme-saml'); - // re-dispatched to the real /sso/register with the nested shape - expect(dispatched!.url).toBe('http://localhost:3000/api/v1/auth/sso/register'); + // re-dispatched to the real /sso/register with the nested shape — with + // `disableRefresh`, because this request carries a session cookie (#22398: + // the inner read must not renew a session whose cookie this bridge never + // sends back; `in-process-session-renewal.pin.test.ts` measures it). + expect(dispatched!.url).toBe('http://localhost:3000/api/v1/auth/sso/register?disableRefresh=true'); expect(dispatched!.body).toMatchObject({ providerId: 'acme-saml', issuer: 'https://idp.acme.com/entity', diff --git a/packages/plugins/plugin-auth/src/register-sso-provider.ts b/packages/plugins/plugin-auth/src/register-sso-provider.ts index d2e08db8a00..7e90a28d0ef 100644 --- a/packages/plugins/plugin-auth/src/register-sso-provider.ts +++ b/packages/plugins/plugin-auth/src/register-sso-provider.ts @@ -22,6 +22,8 @@ * (per-environment runtime) — mirroring `runSetInitialPassword`. */ +import { inProcessRedispatchUrl } from './in-process-redispatch.js'; + export interface RegisterSsoFormResult { /** HTTP status to return to the caller. */ status: number; @@ -57,7 +59,9 @@ async function resolveActiveOrganizationId( if (cookie) h.set('cookie', cookie); const authz = headers.get('authorization'); if (authz) h.set('authorization', authz); - const resp = await handle(new Request(sessionUrl, { method: 'GET', headers: h })); + // [#22398] A cookie request reads without renewal: this response's cookie + // is never sent (`in-process-redispatch.ts`). + const resp = await handle(new Request(inProcessRedispatchUrl(sessionUrl, h), { method: 'GET', headers: h })); if (!resp.ok) return undefined; const data: any = await resp.json().catch(() => null); const org = data?.session?.activeOrganizationId ?? data?.activeOrganizationId; @@ -201,7 +205,9 @@ export async function runRegisterSsoProviderFromForm( // regression) when no active org is set. const organizationId = await resolveActiveOrganizationId(handle, innerUrl, headers); - const innerReq = new Request(innerUrl, { + // [#22398] `/sso/register` reads the session too (the ADR-0135 D6 before-hook + // and `sessionMiddleware`), and only status and body come back from here. + const innerReq = new Request(inProcessRedispatchUrl(innerUrl, headers), { method: 'POST', headers, body: JSON.stringify({ providerId, issuer, domain, oidcConfig, ...(organizationId ? { organizationId } : {}) }), @@ -298,7 +304,8 @@ export async function runRegisterSamlProviderFromForm( // can manage the provider — see the OIDC helper above. const organizationId = await resolveActiveOrganizationId(handle, innerUrl, headers); - const innerReq = new Request(innerUrl, { + // [#22398] Same rule as the OIDC bridge's inner `/sso/register`. + const innerReq = new Request(inProcessRedispatchUrl(innerUrl, headers), { method: 'POST', headers, body: JSON.stringify({ providerId, issuer, domain, samlConfig, ...(organizationId ? { organizationId } : {}) }), @@ -401,7 +408,9 @@ export async function runRequestDomainVerification( if (!rw) return { status: 400, body: { success: false, error: { code: 'INVALID_REQUEST', message: 'Bad request URL' } } }; const headers = forwardAuthHeaders(request, rw.origin); - const resp = await handle(new Request(rw.innerUrl, { method: 'POST', headers, body: JSON.stringify({ providerId }) })); + // [#22398] The inner route reads the session (`sessionMiddleware`); only its + // status and body come back from here. + const resp = await handle(new Request(inProcessRedispatchUrl(rw.innerUrl, headers), { method: 'POST', headers, body: JSON.stringify({ providerId }) })); let parsed: any = {}; try { const t = await resp.text(); parsed = t ? JSON.parse(t) : {}; } catch { parsed = {}; } if (!resp.ok) { @@ -451,7 +460,9 @@ export async function runVerifyDomain( if (!rw) return { status: 400, body: { success: false, error: { code: 'INVALID_REQUEST', message: 'Bad request URL' } } }; const headers = forwardAuthHeaders(request, rw.origin); - const resp = await handle(new Request(rw.innerUrl, { method: 'POST', headers, body: JSON.stringify({ providerId }) })); + // [#22398] The inner route reads the session (`sessionMiddleware`); only its + // status and body come back from here. + const resp = await handle(new Request(inProcessRedispatchUrl(rw.innerUrl, headers), { method: 'POST', headers, body: JSON.stringify({ providerId }) })); let parsed: any = {}; try { const t = await resp.text(); parsed = t ? JSON.parse(t) : {}; } catch { parsed = {}; } if (resp.ok) { diff --git a/packages/plugins/plugin-auth/src/send-verification-email.ts b/packages/plugins/plugin-auth/src/send-verification-email.ts index edbba2f0945..abdc8948852 100644 --- a/packages/plugins/plugin-auth/src/send-verification-email.ts +++ b/packages/plugins/plugin-auth/src/send-verification-email.ts @@ -30,6 +30,7 @@ */ import type { AuthRequestHandler } from './register-sso-provider.js'; +import { inProcessRedispatchUrl } from './in-process-redispatch.js'; export interface ResendVerificationEmailResult { /** HTTP status to return to the caller. */ @@ -60,7 +61,9 @@ async function resolveSessionEmail( if (cookie) h.set('cookie', cookie); const authz = headers.get('authorization'); if (authz) h.set('authorization', authz); - const resp = await handle(new Request(sessionUrl, { method: 'GET', headers: h })); + // [#22398] A cookie request reads without renewal: this response's cookie + // is never sent (`in-process-redispatch.ts`). + const resp = await handle(new Request(inProcessRedispatchUrl(sessionUrl, h), { method: 'GET', headers: h })); if (!resp.ok) return undefined; const data: any = await resp.json().catch(() => null); // customSession shapes the payload as `{ user, session }`; be tolerant of @@ -127,7 +130,9 @@ export async function runResendVerificationEmail( // Re-dispatch to the real better-auth route (the universal handler bypasses // this wrapper, so there is no recursion) with the resolved email. - const innerReq = new Request(sendUrl, { + // [#22398] The real route reads the session as well, and only its status and + // body come back from here — the same rule as the lookup above. + const innerReq = new Request(inProcessRedispatchUrl(sendUrl, headers), { method: 'POST', headers, body: JSON.stringify({ email, ...(callbackURL ? { callbackURL } : {}) }), diff --git a/packages/plugins/plugin-auth/src/set-initial-password.ts b/packages/plugins/plugin-auth/src/set-initial-password.ts index 093906bbdc2..59047113667 100644 --- a/packages/plugins/plugin-auth/src/set-initial-password.ts +++ b/packages/plugins/plugin-auth/src/set-initial-password.ts @@ -21,9 +21,14 @@ * route on one path but not the other). */ -/** Minimal shape of the better-auth server API we depend on. */ +import { inProcessSessionReadInput } from '@objectstack/types'; + +/** + * Minimal shape of the better-auth server API we depend on. `query` carries + * `disableRefresh` for a cookie request (#22398, see the call below). + */ export interface SetPasswordCapableApi { - setPassword(opts: { body: { newPassword: string }; headers: Headers }): Promise; + setPassword(opts: { body: { newPassword: string }; headers: Headers; query?: { disableRefresh: true } }): Promise; } export interface SetInitialPasswordResult { @@ -62,7 +67,13 @@ export async function runSetInitialPassword( try { // better-auth's session middleware reads the session from `headers`; // length checks + the "already set" guard happen inside setPassword. - await authApi.setPassword({ body: { newPassword }, headers: request.headers }); + // [#22398] That read is an in-process session read whose renewed cookie + // would be staged on a response nobody sends, so it takes the same input + // a `getSession` reader does: better-auth's `getSessionFromCtx` spreads + // the call's `query` into the read, and the endpoint declares no query + // schema that would strip it. A cookie request does not renew here; a + // bearer-only one does, as before. + await authApi.setPassword({ body: { newPassword }, ...inProcessSessionReadInput(request.headers) }); return { status: 200, body: { success: true } }; } catch (error) { return mapSetPasswordError(error);