Skip to content
33 changes: 33 additions & 0 deletions .changeset/24032-plugin-auth-body-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@objectstack/plugin-auth': minor
---

Clause-②: no (narrowing)

fix(plugin-auth)!: sixteen of this package's own routes refuse a declared JSON body that does not parse with `400 INVALID_REQUEST`, instead of answering as if no body was sent

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, no stored row is rewritten or deleted, and no exported declaration is added, removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the accept set of sixteen of this package's own routes, and of the exported bridges eleven of them mount, for one input: a body declared as JSON that does not parse, which used to be read as no body. The remedy is in the requests a client sends, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed, every signature is unchanged (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

These routes read a body that does not parse as no body, through `try { body = await request.json() } catch { body = {} }` or a module-private `parseJson` that answered `{}` from its `catch`, so a truncated body was answered as if the client had sent none:

- `POST /api/v1/auth/admin/oauth2/toggle-disabled`, `/admin/unlock-user` and `/sys-oauth-application/register`;
- `POST /api/v1/auth/admin/import-users`;
- `POST /api/v1/auth/admin/sso/register`, `/admin/sso/register-saml`, `/admin/sso/request-domain-verification` and `/admin/sso/verify-domain`;
- `POST /api/v1/auth/send-verification-email`;
- `POST /api/v1/auth/admin/ban-user` and `/admin/unban-user`;
- `POST /api/v1/auth/admin/set-user-manager`;
- `POST /api/v1/auth/admin/create-user` and `/admin/set-user-password`;
- `POST /api/v1/auth/organization/add-member`;
- `POST /api/v1/auth/set-initial-password`.

`/admin/unlock-user` answered `400 "userId is required"` for a body that carried `userId` and lacked its closing brace. `/send-verification-email` read the same input as the one-click resend, and sent the link to the caller's own address for a body that named another one.

What changes for an integrator. A body sent as `application/json` (matched case-insensitively) that is not blank and does not parse: FROM the route's answer for an empty body, TO `400` with `{ "success": false, "error": { "code": "INVALID_REQUEST", "message": "The request body could not be parsed as JSON (it was sent as application/json). It was refused rather than read as an empty body: send a well-formed JSON document, or no body at all." } }`, the answer the HTTP transports give the same body. A declared JSON body whose stream cannot be read is refused with the same code and "The request body could not be read. It was refused rather than read as an empty body." Each route refuses where it reads its body. The import, the four SSO bridges, ban/unban, set-user-manager, create-user, set-user-password and add-member read it after their platform-admin gate (and after the admin-plugin or organization-plugin check where the route has one). toggle-disabled, unlock-user, sys-oauth-application/register, the resend and set-initial-password read it before anything else runs. Nothing is looked up, written, sent or forwarded to better-auth.

The exported bridges eleven of these routes mount (`runAdminImportUsers`, `runRegisterSsoProviderFromForm`, `runRegisterSamlProviderFromForm`, `runRequestDomainVerification`, `runVerifyDomain`, `runResendVerificationEmail`, `runSetInitialPassword`, `runAdminCreateUser`, `runAdminSetUserPassword`, `runAdminBanUser` and `runAdminUnbanUser`) answer the same refusal, so a host that mounts them itself refuses it too. Their signatures and result types are unchanged.

**Remedy, in one line.** Send a JSON body as well-formed JSON with `Content-Type: application/json`, or send no body.

**Unchanged.** An empty body (no body at all, a zero-byte one, or whitespace only) is still no body, and every route answers it as before: `/admin/unlock-user` with `400 "userId is required"`, and `/send-verification-email` as the one-click resend to the caller's own address. A body that parses is read as before, whatever its `Content-Type`, and a body that does not declare JSON and does not parse is still read as no body.
30 changes: 20 additions & 10 deletions packages/plugins/plugin-auth/src/admin-ban-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
} from './last-local-credential.js';
import type { AdminActor, EndpointResult } from './admin-user-endpoints.js';
import { applyUserBan, applyUserUnban } from './user-ban-write.js';
import { readJsonRequestBody } from './request-json-body.js';

/**
* Minimal better-auth `$context` surface these two routes touch. Mirrors what
Expand Down Expand Up @@ -103,13 +104,19 @@ const notFound = (): EndpointResult => ({
body: { success: false, error: { code: 'RESOURCE_NOT_FOUND', message: 'User not found' } },
});

async function parseJson(request: Request): Promise<Record<string, unknown>> {
try {
const parsed = await request.json();
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
/**
* [#24032] The body, or the refusal message for a declared-JSON body that does
* not parse: it was read as `{}`, which answered "userId is required" for a
* body that carried `userId` (see request-json-body.ts). A well-formed body
* that is not an object still reads as no fields, as before.
*/
async function parseJson(
request: Request,
): Promise<{ ok: true; body: Record<string, unknown> } | { ok: false; message: string }> {
const reading = await readJsonRequestBody(request);
if (!reading.ok) return reading;
const parsed = reading.body;
return { ok: true, body: parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {} };
}

/**
Expand All @@ -128,7 +135,9 @@ export async function runAdminBanUser(
actor: AdminActor,
request: Request,
): Promise<EndpointResult> {
const body = await parseJson(request);
const read = await parseJson(request);
if (!read.ok) return invalid(read.message);
const body = read.body;
const userId = readUserId(body);
if (!userId) return invalid('userId is required');

Expand Down Expand Up @@ -191,8 +200,9 @@ export async function runAdminUnbanUser(
_actor: AdminActor,
request: Request,
): Promise<EndpointResult> {
const body = await parseJson(request);
const userId = readUserId(body);
const read = await parseJson(request);
if (!read.ok) return invalid(read.message);
const userId = readUserId(read.body);
if (!userId) return invalid('userId is required');

const ctx = await deps.getAuthContext();
Expand Down
9 changes: 7 additions & 2 deletions packages/plugins/plugin-auth/src/admin-import-users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import { prepareImportRequest, runImport } from '@objectstack/rest';
import { generatePlaceholderEmail, isPlaceholderEmail } from './placeholder-email.js';
import { generateTemporaryPassword, normalizePhoneNumber, isLikelyEmail, type AdminActor, type EndpointResult } from './admin-user-endpoints.js';
import { SYS_USER_IMPORT_UPDATE_FIELDS } from './sys-user-writable-fields.js';
import { readJsonRequestBody } from './request-json-body.js';
import {
runImportPasswordReset,
type AccountInvitationMarker,
Expand Down Expand Up @@ -489,11 +490,15 @@ export async function runAdminImportUsers(
request: Request,
actor: AdminActor,
): Promise<EndpointResult> {
let body: any = {};
try { body = await request.json(); } catch { body = {}; }
const fail = (status: number, code: string, message: string): EndpointResult => ({
status, body: { success: false, error: { code, message } },
});
// [#24032] A declared-JSON body that does not parse is refused, never read as
// `{}`, which answered "Provide format…" for a payload the admin did send;
// see request-json-body.ts.
const reading = await readJsonRequestBody(request);
if (!reading.ok) return fail(400, 'INVALID_REQUEST', reading.message);
const body: any = reading.body;

// ── Request-level validation ─────────────────────────────────────────
// Default policy: `auto` (#3236) — prefer the invite path (no shared secret
Expand Down
26 changes: 18 additions & 8 deletions packages/plugins/plugin-auth/src/admin-set-user-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@

import { authSystemWriteContext } from './auth-actor-attribution.js';
import type { AdminActor, EndpointResult } from './admin-user-endpoints.js';
import { readJsonRequestBody } from './request-json-body.js';

/**
* Hard cap on the reporting chain this endpoint will create, counted in links
Expand Down Expand Up @@ -214,13 +215,20 @@ function envelope(refusal: SetUserManagerRefusal): SetUserManagerResult {
};
}

async function parseJson(request: Request): Promise<Record<string, unknown>> {
try {
const parsed = await request.json();
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
/**
* [#24032] The body, or the refusal message for a declared-JSON body that does
* not parse: it was read as `{}`, which answered "userId is required" for a
* body that carried `userId` (see request-json-body.ts). Refused as the
* `invalid_body` row of the table above. A well-formed body that is not an
* object still reads as no fields, as before.
*/
async function parseJson(
request: Request,
): Promise<{ ok: true; body: Record<string, unknown> } | { ok: false; message: string }> {
const reading = await readJsonRequestBody(request);
if (!reading.ok) return reading;
const parsed = reading.body;
return { ok: true, body: parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {} };
}

/**
Expand Down Expand Up @@ -361,7 +369,9 @@ export async function runSetUserManager(
actor: AdminActor,
request: Request,
): Promise<SetUserManagerResult> {
const body = await parseJson(request);
const read = await parseJson(request);
if (!read.ok) return envelope(deny(400, 'INVALID_REQUEST', 'invalid_body', read.message));
const body = read.body;

const rawUserId = readId(body, 'userId', 'user_id');
if (typeof rawUserId !== 'string' || rawUserId.length === 0) {
Expand Down
30 changes: 21 additions & 9 deletions packages/plugins/plugin-auth/src/admin-user-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ import { generatePlaceholderEmail } from './placeholder-email.js';
import { revokeSessionsAndApiKeys, type ForcedResetRevocationOutcome } from './forced-reset-revocation.js';
import { reconcileMembership, type MembershipPolicy } from './reconcile-membership.js';
import { resolveDefaultOrgId } from './tenancy-service.js';
import { readJsonRequestBody } from './request-json-body.js';

const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };

Expand Down Expand Up @@ -214,13 +215,20 @@ export function generateTemporaryPassword(length = 16): string {

// ── Shared helpers ───────────────────────────────────────────────────────

async function parseJson(request: Request): Promise<Record<string, unknown>> {
try {
const parsed = await request.json();
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
/**
* [#24032] The body, or the refusal message for a declared-JSON body that does
* not parse: it was read as `{}`, which answered "userId is required" (or
* "Either email or phoneNumber is required") for a body that carried the field
* (see request-json-body.ts). A well-formed body that is not an object still
* reads as no fields, as before.
*/
async function parseJson(
request: Request,
): Promise<{ ok: true; body: Record<string, unknown> } | { ok: false; message: string }> {
const reading = await readJsonRequestBody(request);
if (!reading.ok) return reading;
const parsed = reading.body;
return { ok: true, body: parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {} };
}

function badRequest(message: string): EndpointResult {
Expand Down Expand Up @@ -551,7 +559,9 @@ export async function runAdminCreateUser(
request: Request,
actor: AdminActor,
): Promise<EndpointResult> {
const body = await parseJson(request);
const read = await parseJson(request);
if (!read.ok) return badRequest(read.message);
const body = read.body;

// #2766 V1.5 — identity: a real email, a phone number, or both. Phone-only
// users get a generated placeholder address (better-auth requires a unique
Expand Down Expand Up @@ -680,7 +690,9 @@ export async function runAdminSetUserPassword(
request: Request,
actor: AdminActor,
): Promise<EndpointResult> {
const body = await parseJson(request);
const read = await parseJson(request);
if (!read.ok) return badRequest(read.message);
const body = read.body;

const userId = body.userId ?? body.user_id;
if (typeof userId !== 'string' || userId.length === 0) {
Expand Down
28 changes: 22 additions & 6 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import { MANAGED_EXTENSION_EDITABLE_FIELDS } from './managed-extension-fields.js
import { runSetInitialPassword } from './set-initial-password.js';
import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js';
import { runResendVerificationEmail } from './send-verification-email.js';
import { readJsonRequestBody } from './request-json-body.js';
import type { CounterStore } from './rate-limit-storage.js';
import { authIdentityManifest } from './manifest.js';
import { scheduleLegacySsoSecretMigration } from './sso-client-secret.js';
Expand Down Expand Up @@ -2731,8 +2732,14 @@ export class AuthPlugin implements Plugin {
// Upstream tracking: https://github.com/better-auth/better-auth
rawApp.post(`${basePath}/admin/oauth2/toggle-disabled`, async (c: any) => {
try {
let body: any = {};
try { body = await c.req.json(); } catch { body = {}; }
// [#24032] A declared-JSON body that does not parse is refused, never
// read as `{}` (which answered "client_id is required" for a body that
// carried it); see request-json-body.ts.
const reading = await readJsonRequestBody(c.req.raw);
if (!reading.ok) {
return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: reading.message } }, 400);
}
const body: any = reading.body;
const clientId: unknown = body?.client_id;
const disabled: unknown = body?.disabled;
if (typeof clientId !== 'string' || clientId.length === 0) {
Expand Down Expand Up @@ -2866,8 +2873,13 @@ export class AuthPlugin implements Plugin {
// server-side; mirrors the toggle-disabled route's session+role check.
rawApp.post(`${basePath}/admin/unlock-user`, async (c: any) => {
try {
let body: any = {};
try { body = await c.req.json(); } catch { body = {}; }
// [#24032] Same read as toggle-disabled above: a truncated body used to
// answer "userId is required" for a body that carried `userId`.
const reading = await readJsonRequestBody(c.req.raw);
if (!reading.ok) {
return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: reading.message } }, 400);
}
const body: any = reading.body;
const userId: unknown = body?.userId ?? body?.user_id;
if (typeof userId !== 'string' || userId.length === 0) {
return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: 'userId is required' } }, 400);
Expand Down Expand Up @@ -3419,8 +3431,12 @@ export class AuthPlugin implements Plugin {
// just to fix UX. Keeping the wrapper scoped to the self-service flow.
rawApp.post(`${basePath}/sys-oauth-application/register`, async (c: any) => {
try {
let body: any = {};
try { body = await c.req.json(); } catch { body = {}; }
// [#24032] Same read as toggle-disabled: refused, never read as `{}`.
const reading = await readJsonRequestBody(c.req.raw);
if (!reading.ok) {
return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: reading.message } }, 400);
}
const body: any = reading.body;

const name: unknown = body?.name;
const redirectUrlsInput: unknown = body?.redirectURLs;
Expand Down
25 changes: 17 additions & 8 deletions packages/plugins/plugin-auth/src/organization-add-member.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@

import { inProcessSessionReadInput } from '@objectstack/types';
import { mapAuthApiError, type EndpointResult } from './admin-user-endpoints.js';
import { readJsonRequestBody } from './request-json-body.js';

/** Minimal better-auth server-api surface this route drives. */
export interface AddMemberCapableApi {
Expand All @@ -97,13 +98,19 @@ const badRequest = (message: string): EndpointResult => ({
body: { success: false, error: { code: 'INVALID_REQUEST', message } },
});

async function parseJson(request: Request): Promise<Record<string, unknown>> {
try {
const parsed = await request.json();
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
/**
* [#24032] The body, or the refusal message for a declared-JSON body that does
* not parse: it was read as `{}`, which answered "userId is required" for a
* body that carried `userId` (see request-json-body.ts). A well-formed body
* that is not an object still reads as no fields, as before.
*/
async function parseJson(
request: Request,
): Promise<{ ok: true; body: Record<string, unknown> } | { ok: false; message: string }> {
const reading = await readJsonRequestBody(request);
if (!reading.ok) return reading;
const parsed = reading.body;
return { ok: true, body: parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {} };
}

/**
Expand Down Expand Up @@ -155,7 +162,9 @@ export async function runOrganizationAddMember(
};
}

const body = await parseJson(request);
const read = await parseJson(request);
if (!read.ok) return badRequest(read.message);
const body = read.body;
const userId = readString(body, 'userId', 'user_id');
if (!userId) return badRequest('userId is required');
const role = readRole(body);
Expand Down
Loading
Loading