Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/22398-plugin-auth-session-redispatch.md
Original file line number Diff line number Diff line change
@@ -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).
7 changes: 5 additions & 2 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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;
Expand Down
53 changes: 53 additions & 0 deletions packages/plugins/plugin-auth/src/in-process-redispatch.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
10 changes: 9 additions & 1 deletion packages/plugins/plugin-auth/src/organization-add-member.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<Record<string, unknown> | null>;
}

Expand Down Expand Up @@ -165,14 +168,19 @@ 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,
role,
...(organizationId ? { organizationId } : {}),
...(teamId ? { teamId } : {}),
},
headers: request.headers,
...inProcessSessionReadInput(request.headers),
});
return { status: 200, body: { success: true, data: { member: member ?? null } } };
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading