Skip to content

Commit ab3ba2c

Browse files
fix(plugin-auth)!: sixteen auth routes refuse an unparseable declared-JSON request body with 400 INVALID_REQUEST instead of reading it as {} (#24049)
Fixes #24032 Clause-②: no (narrowing) ## What Sixteen of `@objectstack/plugin-auth`'s own routes read a request body that does not parse as `{}`. There were nine at round 0, and patch round 1 added seven more: claim amendment `6225083981`, addendum `6225763180`. A truncated body was answered as if the client had sent none. Each of them now refuses a body that is declared `application/json`, is not blank, and does not parse. The refusal is `400 INVALID_REQUEST` in the ADR-0112 envelope, with the same code and message the HTTP transports (`plugin-hono-server`, `hono`) and `cloud-connection` give the same body since PR #24005. `INVALID_REQUEST` is an existing `ERROR_CODE_LEDGER` code, and nothing is added to the ledger. The message never echoes the body. The reader is one package-local module, `src/request-json-body.ts`. It is not re-exported from `index.ts`, and nothing is imported from `plugin-hono-server` or `cloud-connection` (ruling B on #23914: no new export). As in `cloud-connection`, it answers a message, and each route builds its own `400` body beside its other refusals. ## Premise, measured on `origin/main` `2b274600b1` All nine sites swallow a parse failure into `{}`. Triage counted 9 and the card listed 7. The card's grep missed `register-sso-provider.ts:101` and `send-verification-email.ts:96`, because both spell the swallow over several lines (`body = {};` on its own line). They have the same shape, so they are the same family. `:96` is the one site where `{}` is an action rather than a refusal. | site | route (`/api/v1/auth`) | what the handler answers for `{}` | family | |---|---|---|---| | `auth-plugin.ts:2735` | `POST /admin/oauth2/toggle-disabled` | 400 `INVALID_REQUEST` "client_id is required", before the platform-admin gate | A, inline mount | | `auth-plugin.ts:2870` | `POST /admin/unlock-user` | 400 "userId is required", before the gate | A | | `auth-plugin.ts:3423` | `POST /sys-oauth-application/register` | 400 "name is required" | A | | `admin-import-users.ts:493` | `POST /admin/import-users` | after the gate: 400 "Provide format:..." (503 when no data engine) | B, identity import | | `register-sso-provider.ts:101` | `POST /admin/sso/register` | after the gate: 400 "Missing required field(s): providerId, issuer, domain, clientId, clientSecret" | C, SSO bridges | | `register-sso-provider.ts:250` | `POST /admin/sso/register-saml` | 400 "Missing required field(s): providerId, issuer, domain, entryPoint, cert" | C | | `register-sso-provider.ts:444` | `POST /admin/sso/request-domain-verification` | 400 "Missing required field: providerId" | C | | `register-sso-provider.ts:501` | `POST /admin/sso/verify-domain` | 400 "Missing required field: providerId" | C | | `send-verification-email.ts:96` | `POST /send-verification-email` | **an action**: with a session, it resends the verification link to the caller's own address (200). Without a session, 400 "email is required (sign in to resend to your own address)" | D, self-service resend | So, for eight sites, a truncated body got a required-field refusal for a field the body carried. At the resend, a truncated body that named another address (`{"email": "other@example.test"`) was answered as the one-click resend and mailed the caller. ## What a reading answers - **A body that parses:** that value, as before, whatever its `Content-Type`. These routes never read the media type to parse: `request.json()` parses a valid JSON body sent as `text/plain`, and so does `JSON.parse` over `text()`. They still do not, so the transports' `text/plain` refusal (PR #24005 step 4) is not matched. Matching it would narrow a second input that these routes never distinguished. - **Declared `application/json`, not blank, does not parse:** refused, "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." - **Declared JSON whose stream cannot be read:** refused with the same code, "The request body could not be read. It was refused rather than read as an empty body." - **Empty:** unchanged. That is no body and no `Content-Type`, a zero-byte body with `Content-Type: application/json` and `Content-Length: 0`, or a whitespace-only body. Each family's pin drives all three, and each still gets today's answer (the table above). For the resend, that answer is still the one-click resend to the caller's own address, which the `sys_user` action fires with no body. - **Undeclared and does not parse:** still `{}`, the same rule as `cloud-connection` and `createHonoApp` (Acceptance notes). **Order.** Each route refuses where it read its body. `toggle-disabled`, `unlock-user`, `sys-oauth-application/register` and the resend read before anything else runs, as they always did. An anonymous caller's malformed body there is answered 400, and it was answered 400 "... is required" before. The import and the four SSO bridges read after their platform-admin gate. Nothing is looked up, written, sent or forwarded to better-auth. **Exported bridges.** `runAdminImportUsers`, `runRegisterSsoProviderFromForm`, `runRegisterSamlProviderFromForm`, `runRequestDomainVerification`, `runVerifyDomain` and `runResendVerificationEmail` answer the same refusal. A host that mounts them itself, such as the cloud auth proxy, gets the refusal with its next dependency bump. Signatures are unchanged. ## Changeset: `minor`, not the dispatched `patch` The dispatch asked for a `patch` changeset. Under this PR's `Clause-②: no (narrowing)` line, the required `Check Changeset` level axis refuses `patch`. Both were measured at the same tree with `node scripts/check-changeset-no-major.mjs --base 2b27460 --head ... --event` and a `pull_request` payload carrying this body's first three lines: - `plugin-auth: patch` (a scratch commit object, never pushed) exits 1: "clause-② declares `no (narrowing)` while no moved package is graded `minor` or above". - `plugin-auth: minor` (`dccf68bae1`) exits 0: "LEVEL AXIS: this PR declares clause-② `no (narrowing)`, and no package whose published source ... it moves is graded `patch`". So `.changeset/24032-plugin-auth-body-refusal.md` is `minor` under the launch-window convention for breaking changes, the same grade as PR #24005's three changesets. Its first line after the frontmatter is `Clause-②: no (narrowing)`. It carries the `**BREAKING**` banner, an ADR-0087 marker of kind `not-required (no-migration-prescription)` with the other categories closed on facts, the routes, the FROM / TO for an integrator, the one-line remedy, and a statement that an empty body is unchanged. ## Tests New: `src/request-json-body-refusal.test.ts`. It had 43 cases at round 0, and 71 after patch round 1 added blocks E to I for the seven more readers. The ablation turns 21 red, and no CONTROL goes red (addendum `6225763180`). It drives the plugin's real `registerAuthRoutes` on a real Hono app (the `admin-sso-bridge-gate.test.ts` harness), with one block per family: - **A:** the three inline routes. Each gets a truncated body and `{ not json`, both refused, plus three empty CONTROLs. Further rows on `unlock-user`: an unreadable stream, refused; CONTROL, a well-formed body acted on; CONTROL, valid JSON sent as `text/plain` acted on; CONTROL, an undeclared truncated body is still `{}`. - **B:** the import. A truncated body is refused, plus three empty CONTROLs. - **C:** the four SSO bridges. Each gets a truncated body, refused, plus three empty CONTROLs. - **D:** the resend. A truncated body naming another address is refused, plus three empty CONTROLs that still resend to the session address. Each refusal asserts four things: - the status, `envelopeViolations(body)` empty, and `code`; - the message's first sentence, since that message family is the card's contract; - that the message does not echo the body; - that nothing after the read ran: no `unlockUser`, no engine call, no `createOAuthClient`, no better-auth call, nothing sent. `admin-import-users.js` is loaded at module top. The route's own `import()` measured 8.7 s inside a 10 s clocked test on the first run. Fixture triage: in `send-verification-email.test.ts`, the row "tolerates a non-JSON body and falls back to the session email" sent `not-json{` as `application/json`. It pinned exactly the branch this removes. That row now pins the undeclared spelling (`text/plain`), which is unchanged, and a new row pins the declared refusal with no session lookup and nothing sent. Runs (all under `scripts/pm/os-verify-lock.sh`, exit codes captured before any pipe): - **Today's code** (the new file against `2b274600b1`'s sources): 13 failed, 30 passed. The 13 are every refusal row, each failing on today's required-field answer or the 200 resend. The 30 are every CONTROL. - **Fix** (`8d97b20b34`): the new file plus the 7 neighbouring files (`send-verification-email`, `register-sso-provider`, `admin-sso-bridge-gate`, `admin-import-users`, `principal-less-producers-system-context`, both `sso-domain-verification` pins): 8 files, 175 passed. - **Head `1f1a7fd026`** (merges `origin/main` `b74434c581`; install, closure build and full build refreshed after the merge): - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: 151 files, 2986 passed, 10 skipped (pre-existing; none in these files). - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0, `check:test-typecheck` OK, its debt ledger unchanged. - `pnpm exec eslint --no-inline-config` over the 7 changed `.ts` files: exit 0. ## Ablation (WRAP mode through `scripts/ablation-replace.mjs`, on `8d97b20b34`) The suites import their subject by relative path, so they run on source and need no rebuild. | leg | mutation in `request-json-body.ts` | blob | result | |---|---|---|---| | malformed | `return { ok: false, message: MALFORMED_JSON_BODY_MESSAGE };` becomes `return { ok: true, body: {} };` | `71516e4a3cd6` to `a2d9e371a38c` | 13 failed: the 12 malformed refusal rows across families A to D, and the declared-refusal row in `send-verification-email.test.ts`. 38 passed: every CONTROL, and the unreadable row, which is the other branch | | unreadable | `{ ok: false, message: UNREADABLE_BODY_MESSAGE }` becomes `{ ok: true, body: {} }` | `71516e4a3cd6` to `97a4b1b53117` | 1 failed (the unreadable row), 42 passed | Each leg's anchor hit once, and the write was proven on disk. The restore was proven by blob `71516e4a3cd6` matching `HEAD`, with `git diff HEAD` empty. After both legs, the marker count is 0 and `git status --porcelain` is 0 lines. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `1f1a7fd026` (merge base `b74434c58`, 8 paths) derived 69 commands. All 69 ran with the documented capture idiom. 68 exited 0 on the first pass. `pnpm check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET, no `dist/` for 41 packages). After `pnpm turbo run build --filter='!@objectstack/docs' --concurrency=2` (75 of 75 tasks), the same command exited 0: 110 entry points across 68 packages load. `--ran` over the final record: "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN". These include `check:route-envelope`, `check:dispatcher-error-vocabulary`, `check:error-status-conformance`, `check:auth-mount-ledger`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:type-check-coverage`, `check:type-check-debt`, `check:nul-bytes`, `check:published-files`, `check:dts-closure`, `check-adr-0087-registration`, `check-changeset-no-major` and `check-empty-changeset`. Not run locally: `test:repo` and the repo-wide `pnpm lint`. CI owns both. `origin/main` has moved three commits past `b74434c581`. One of them, `invitation-role-cap.ts` and its test, touches this package but none of these files. `git merge-tree --write-tree HEAD origin/main` exits 0. ## Acceptance notes - **A body that does not declare JSON and does not parse is still `{}`.** This is the same rule as `cloud-connection` and `createHonoApp`. At the resend, it still means the one-click resend to the caller's own address, reachable only by a client that sends a non-JSON media type. Refusing it too would narrow a second input. Carrier: none. - **A fourth copy of the two messages.** `plugin-hono-server`, `hono`, `cloud-connection` and this package each keep a module-private copy, per ruling B. A cross-package parity test would import `plugin-hono-server`'s built `dist/` into this package's tests, which `check:test-source-alias` reports as a new unaliased import. So the first sentence is pinned here instead. - **Seven more routes in this package carry the same swallow under another spelling**, outside this claim's file surface. Each reads through a module-private `parseJson` that returns `{}`, or through `parsed = {}`: - `admin-ban-endpoints.ts:106`: `ban-user`, `unban-user`; - `admin-set-user-manager.ts:217`: `set-user-manager`; - `admin-user-endpoints.ts:217`: `create-user`, `set-user-password`; - `organization-add-member.ts:100`: `organization/add-member`; - `set-initial-password.ts:57`: `set-initial-password`, also an exported bridge. Each answered `{}` with a required-field refusal. Neither the card's grep nor triage's `body = {}` count reached them. **These are now in this PR:** the seat answered the dev's open question with A (claim amendment `6225083981`), and patch round 1 converted all seven readers to `readJsonRequestBody`, one pin block each. `set-initial-password`'s signature and result are unchanged; its callers see only the refusal's message. - **Two other body reads in the package are not this family.** `auth-manager.ts:7201` is a best-effort probe over a clone for trusted origins: a parse failure there is `[]`, and better-auth then judges the body itself. `/admin/has-permission` (`auth-plugin.ts:3206`) hands an unreadable body to better-auth's own 400. ## Contract review `Clause-②: no (narrowing)`: this owes one `CONTRACT_REVIEW_TIER` review before the queue, as PR #24005 did. No governed surface is touched. The record is `6226126497`, PASS, at `7160ac93d7`. _Seat edit · `domain:services` seat 2 · `session_018VqJPGA1NDNSCZsnecsChP` · 2026-10-16T22:55Z: brought to patch round 1. The route count is sixteen, the seven routes are now in this PR, the test count is updated, the contract record is linked, and the title follows._ --- _Generated by [Claude Code](https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 44fe210 commit ab3ba2c

14 files changed

Lines changed: 754 additions & 68 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
---
4+
5+
Clause-②: no (narrowing)
6+
7+
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
8+
9+
<!-- 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). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
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:
14+
15+
- `POST /api/v1/auth/admin/oauth2/toggle-disabled`, `/admin/unlock-user` and `/sys-oauth-application/register`;
16+
- `POST /api/v1/auth/admin/import-users`;
17+
- `POST /api/v1/auth/admin/sso/register`, `/admin/sso/register-saml`, `/admin/sso/request-domain-verification` and `/admin/sso/verify-domain`;
18+
- `POST /api/v1/auth/send-verification-email`;
19+
- `POST /api/v1/auth/admin/ban-user` and `/admin/unban-user`;
20+
- `POST /api/v1/auth/admin/set-user-manager`;
21+
- `POST /api/v1/auth/admin/create-user` and `/admin/set-user-password`;
22+
- `POST /api/v1/auth/organization/add-member`;
23+
- `POST /api/v1/auth/set-initial-password`.
24+
25+
`/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.
26+
27+
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.
28+
29+
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.
30+
31+
**Remedy, in one line.** Send a JSON body as well-formed JSON with `Content-Type: application/json`, or send no body.
32+
33+
**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.

‎packages/plugins/plugin-auth/src/admin-ban-endpoints.ts‎

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
} from './last-local-credential.js';
7676
import type { AdminActor, EndpointResult } from './admin-user-endpoints.js';
7777
import { applyUserBan, applyUserUnban } from './user-ban-write.js';
78+
import { readJsonRequestBody } from './request-json-body.js';
7879

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

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

115122
/**
@@ -128,7 +135,9 @@ export async function runAdminBanUser(
128135
actor: AdminActor,
129136
request: Request,
130137
): Promise<EndpointResult> {
131-
const body = await parseJson(request);
138+
const read = await parseJson(request);
139+
if (!read.ok) return invalid(read.message);
140+
const body = read.body;
132141
const userId = readUserId(body);
133142
if (!userId) return invalid('userId is required');
134143

@@ -191,8 +200,9 @@ export async function runAdminUnbanUser(
191200
_actor: AdminActor,
192201
request: Request,
193202
): Promise<EndpointResult> {
194-
const body = await parseJson(request);
195-
const userId = readUserId(body);
203+
const read = await parseJson(request);
204+
if (!read.ok) return invalid(read.message);
205+
const userId = readUserId(read.body);
196206
if (!userId) return invalid('userId is required');
197207

198208
const ctx = await deps.getAuthContext();

‎packages/plugins/plugin-auth/src/admin-import-users.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ import { prepareImportRequest, runImport } from '@objectstack/rest';
113113
import { generatePlaceholderEmail, isPlaceholderEmail } from './placeholder-email.js';
114114
import { generateTemporaryPassword, normalizePhoneNumber, isLikelyEmail, type AdminActor, type EndpointResult } from './admin-user-endpoints.js';
115115
import { SYS_USER_IMPORT_UPDATE_FIELDS } from './sys-user-writable-fields.js';
116+
import { readJsonRequestBody } from './request-json-body.js';
116117
import {
117118
runImportPasswordReset,
118119
type AccountInvitationMarker,
@@ -489,11 +490,15 @@ export async function runAdminImportUsers(
489490
request: Request,
490491
actor: AdminActor,
491492
): Promise<EndpointResult> {
492-
let body: any = {};
493-
try { body = await request.json(); } catch { body = {}; }
494493
const fail = (status: number, code: string, message: string): EndpointResult => ({
495494
status, body: { success: false, error: { code, message } },
496495
});
496+
// [#24032] A declared-JSON body that does not parse is refused, never read as
497+
// `{}`, which answered "Provide format…" for a payload the admin did send;
498+
// see request-json-body.ts.
499+
const reading = await readJsonRequestBody(request);
500+
if (!reading.ok) return fail(400, 'INVALID_REQUEST', reading.message);
501+
const body: any = reading.body;
497502

498503
// ── Request-level validation ─────────────────────────────────────────
499504
// Default policy: `auto` (#3236) — prefer the invite path (no shared secret

‎packages/plugins/plugin-auth/src/admin-set-user-manager.ts‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@
122122

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

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

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

226234
/**
@@ -361,7 +369,9 @@ export async function runSetUserManager(
361369
actor: AdminActor,
362370
request: Request,
363371
): Promise<SetUserManagerResult> {
364-
const body = await parseJson(request);
372+
const read = await parseJson(request);
373+
if (!read.ok) return envelope(deny(400, 'INVALID_REQUEST', 'invalid_body', read.message));
374+
const body = read.body;
365375

366376
const rawUserId = readId(body, 'userId', 'user_id');
367377
if (typeof rawUserId !== 'string' || rawUserId.length === 0) {

‎packages/plugins/plugin-auth/src/admin-user-endpoints.ts‎

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ import { generatePlaceholderEmail } from './placeholder-email.js';
174174
import { revokeSessionsAndApiKeys, type ForcedResetRevocationOutcome } from './forced-reset-revocation.js';
175175
import { reconcileMembership, type MembershipPolicy } from './reconcile-membership.js';
176176
import { resolveDefaultOrgId } from './tenancy-service.js';
177+
import { readJsonRequestBody } from './request-json-body.js';
177178

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

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

215216
// ── Shared helpers ───────────────────────────────────────────────────────
216217

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

226234
function badRequest(message: string): EndpointResult {
@@ -551,7 +559,9 @@ export async function runAdminCreateUser(
551559
request: Request,
552560
actor: AdminActor,
553561
): Promise<EndpointResult> {
554-
const body = await parseJson(request);
562+
const read = await parseJson(request);
563+
if (!read.ok) return badRequest(read.message);
564+
const body = read.body;
555565

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

685697
const userId = body.userId ?? body.user_id;
686698
if (typeof userId !== 'string' || userId.length === 0) {

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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import { MANAGED_EXTENSION_EDITABLE_FIELDS } from './managed-extension-fields.js
7171
import { runSetInitialPassword } from './set-initial-password.js';
7272
import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js';
7373
import { runResendVerificationEmail } from './send-verification-email.js';
74+
import { readJsonRequestBody } from './request-json-body.js';
7475
import type { CounterStore } from './rate-limit-storage.js';
7576
import { authIdentityManifest } from './manifest.js';
7677
import { scheduleLegacySsoSecretMigration } from './sso-client-secret.js';
@@ -2741,8 +2742,14 @@ export class AuthPlugin implements Plugin {
27412742
// Upstream tracking: https://github.com/better-auth/better-auth
27422743
rawApp.post(`${basePath}/admin/oauth2/toggle-disabled`, async (c: any) => {
27432744
try {
2744-
let body: any = {};
2745-
try { body = await c.req.json(); } catch { body = {}; }
2745+
// [#24032] A declared-JSON body that does not parse is refused, never
2746+
// read as `{}` (which answered "client_id is required" for a body that
2747+
// carried it); see request-json-body.ts.
2748+
const reading = await readJsonRequestBody(c.req.raw);
2749+
if (!reading.ok) {
2750+
return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: reading.message } }, 400);
2751+
}
2752+
const body: any = reading.body;
27462753
const clientId: unknown = body?.client_id;
27472754
const disabled: unknown = body?.disabled;
27482755
if (typeof clientId !== 'string' || clientId.length === 0) {
@@ -2876,8 +2883,13 @@ export class AuthPlugin implements Plugin {
28762883
// server-side; mirrors the toggle-disabled route's session+role check.
28772884
rawApp.post(`${basePath}/admin/unlock-user`, async (c: any) => {
28782885
try {
2879-
let body: any = {};
2880-
try { body = await c.req.json(); } catch { body = {}; }
2886+
// [#24032] Same read as toggle-disabled above: a truncated body used to
2887+
// answer "userId is required" for a body that carried `userId`.
2888+
const reading = await readJsonRequestBody(c.req.raw);
2889+
if (!reading.ok) {
2890+
return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: reading.message } }, 400);
2891+
}
2892+
const body: any = reading.body;
28812893
const userId: unknown = body?.userId ?? body?.user_id;
28822894
if (typeof userId !== 'string' || userId.length === 0) {
28832895
return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: 'userId is required' } }, 400);
@@ -3429,8 +3441,12 @@ export class AuthPlugin implements Plugin {
34293441
// just to fix UX. Keeping the wrapper scoped to the self-service flow.
34303442
rawApp.post(`${basePath}/sys-oauth-application/register`, async (c: any) => {
34313443
try {
3432-
let body: any = {};
3433-
try { body = await c.req.json(); } catch { body = {}; }
3444+
// [#24032] Same read as toggle-disabled: refused, never read as `{}`.
3445+
const reading = await readJsonRequestBody(c.req.raw);
3446+
if (!reading.ok) {
3447+
return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: reading.message } }, 400);
3448+
}
3449+
const body: any = reading.body;
34343450

34353451
const name: unknown = body?.name;
34363452
const redirectUrlsInput: unknown = body?.redirectURLs;

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

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272

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

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

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

109116
/**
@@ -155,7 +162,9 @@ export async function runOrganizationAddMember(
155162
};
156163
}
157164

158-
const body = await parseJson(request);
165+
const read = await parseJson(request);
166+
if (!read.ok) return badRequest(read.message);
167+
const body = read.body;
159168
const userId = readString(body, 'userId', 'user_id');
160169
if (!userId) return badRequest('userId is required');
161170
const role = readRole(body);

0 commit comments

Comments
 (0)