Skip to content

Commit 5872450

Browse files
fix(plugin-auth)!: the OAuth register door refuses an application type the provider does not judge with 400 INVALID_REQUEST, where it registered it as web (#25038)
Fixes #24773 Clause-②: no (narrowing) — `POST /api/v1/auth/sys-oauth-application/register` accepted any `type` value and registered every one other than `web` / `native` as a `web` client with a secret (with one server warning since half 2a); it now refuses each of those values with `400 INVALID_REQUEST`, so it accepts exactly `web`, `native` and no type. Half 2c, the last half of the maintainer's ruling D (`6293344532`) on this card, under the claim `6302570331` as widened for half 2c. Half 1 landed as #24853, half 2a (the door forwards `application_type` and warns) as #24992, half 2b (the form offers Web / Native) as #25011. This PR turns half 2a's warning branch into the refusal, corrects the register action's code comment in `platform-objects`, and carries one BREAKING `minor` changeset. It is a narrowing, so it owes a review at `CONTRACT_REVIEW_TIER` before the queue. ## 「甲」 reading (amended) - Ruling D under the standing rule 「甲」 (#6015 `6292005854`, rule 2) shipped only the loud half in 18.x (the warning, #24992) and left the 400 for 19.0. - The maintainer's amendment (#6015 `6303865025`, quoting 「暂时不考虑 v19,协议的调整原则上也是尽快,不考虑退役版本。」) changes rule 2: a narrowing that no ADR-0087 conversion carries also ships now, in 18.x, as a BREAKING minor in the shape of the `24813-adopt-orphans-readonly-package` changeset. No refusal waits for 19.0, and no `pm:on-hold` card waits on a 19.0 line. - No conversion carries this narrowing: it is the accept set of one runtime REST door, and no metadata moves. So the changeset's ADR-0087 disposition is `not-required (no-migration-prescription)`, and it is shipped as `minor` with `!` in the title, `Clause-②: no (narrowing)` and the **BREAKING** banner. `check-adr-0087-registration --base origin/main` reads it as `[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)`, exit 0. ## What changes `packages/plugins/plugin-auth/src/auth-plugin.ts`, the register wrapper's application-type branch only: | `type` in the body | Before (`73432299c`) | After | |:--|:--|:--| | `web`, `native` | forwarded as `application_type` | unchanged | | absent, `null` | `web`, no warning | unchanged | | `user-agent-based`, `public`, any other string, `''`, a non-string | registered and judged as `web`, with a client secret, plus one `warn` | `400` `{ success: false, error: { code: 'INVALID_REQUEST', message } }`, the route's existing envelope (its `redirectURLs` refusal uses the same one). The refusal comes before `createOAuthClient` is called, so nothing is stored. No warning, no new error code. | The message names the value (its first 64 characters, then an ellipsis when cut, as the warning truncated it; a non-string is named by its type, such as `(number)`) and the two accepted types, and says to choose `native` for an `http://localhost`, `http://127.0.0.1` or `http://[::1]` redirect URI: ```text type "user-agent-based" is not an application type the OAuth provider judges: send 'web' or 'native', or no type for 'web'. Choose 'native' for an http://localhost, http://127.0.0.1 or http://[::1] redirect URI; 'web' needs https on a host that is not a loopback address. ``` `packages/platform-objects/src/identity/sys-oauth-application.object.ts`: the `create_oauth_application` action's code comment only. It said the refusal "waits for a major version"; it now states the 18.x refusal and the stored column's readings (M2). No option, param or field changes. The comment keeps its line count. The generated bundles do not carry comments, so the i18n tooling regenerates nothing (`check:i18n` green). esbuild keeps comments, though: a rebuild changed `dist/index.{js,mjs}` and `dist/identity/index.{js,mjs}` (the comment text only; the `.map` and `.d.ts` files are byte-identical). `platform-objects` is not `private`, so its published output changes, and the changeset names it with `patch`. That also puts this entry in `platform-objects`' CHANGELOG, beside half 2b's entry. ### Absent, `null` and the empty string Measured at `73432299c`: absent and `null` registered `web` with no warning; `''` registered `web` with the warning, like any other unhonoured value. This PR refuses `''` and keeps absent and `null`: - **Absent and `null` stay `web`.** Neither names a type: half 2a's branch read `null` as absent, and JSON `null` is the body's spelling of no value. The provider's own default is `web`, and the form's declared default is `web`. Refusing `null` would narrow the door past the ruling, which refuses present values. - **`''` is refused.** It is a present string that names no type, and it already took the warning branch, whose text said "A future major version refuses this value". So the set this PR refuses is exactly the set that warned. No caller sends it: the Console dialog will not submit an empty required param (M3). ## Measured first ### M1: the register door, value by value Real stack in-process: real better-auth 1.7.3 with the OIDC provider on, behind the plugin's real route registration on a Hono app, over a real ObjectQL engine with `SqlDriver` (better-sqlite3 `:memory:`) and the auth manifest's objects. Stored rows were read at driver level. Each value was sent with an https redirect (`https://app.example.com/callback`) and with a loopback one (`http://localhost:4001/callback`). The probe was a throwaway file and is not in this PR. | `type` | Before, https | Before, loopback | After, https | After, loopback | |:--|:--|:--|:--|:--| | `web` | 200, stored `web` | 400 `OAUTH_REGISTER_FAILED` (`invalid_redirect_uri`) | same | same | | `native` | 200, stored `native` | 200, stored `native` | same | same | | `user-agent-based` | 200, stored `web`, 1 warning | 400 `OAUTH_REGISTER_FAILED`, 1 warning | 400 `INVALID_REQUEST`, nothing stored, no warning | 400 `INVALID_REQUEST` | | `public` | 200, stored `web`, 1 warning | 400 `OAUTH_REGISTER_FAILED`, 1 warning | 400 `INVALID_REQUEST` | 400 `INVALID_REQUEST` | | `spa` (unknown string) | 200, stored `web`, 1 warning | 400, 1 warning | 400 `INVALID_REQUEST` | 400 `INVALID_REQUEST` | | `42`, `true`, `{ a: 1 }` | 200, stored `web`, 1 warning each (`(number)` / `(boolean)` / `(object)`) | 400, 1 warning | 400 `INVALID_REQUEST` | 400 `INVALID_REQUEST` | | absent | 200, stored `web`, no warning | 400 `OAUTH_REGISTER_FAILED` | same | same | | `null` | 200, stored `web`, no warning | 400 `OAUTH_REGISTER_FAILED` | same | same | | `''` | 200, stored `web`, 1 warning | 400, 1 warning | 400 `INVALID_REQUEST` | 400 `INVALID_REQUEST` | | 200 characters | 200, stored `web`, 1 warning (value cut to 64) | 400, 1 warning | 400 `INVALID_REQUEST`, message carries 64 characters | 400 `INVALID_REQUEST` | Every stored row, before and after, had `public` null and `token_endpoint_auth_method` `client_secret_basic`, with a client secret. ### M2: the stored `sys_oauth_application.type` column Not narrowed: the select and the `public` column are unchanged. - **What the installed provider writes.** `@better-auth/oauth-provider` 1.7.3 writes only `web` or `native`, or `null` for a client-metadata-document client that names none (`dist/authorize-9whjxVLJ.mjs:1956`). Every creation path goes through `persistOAuthClientRegistration` and `checkOAuthClient`, which refuses any other `application_type` (`:1770`): dynamic registration `/oauth2/register` (the MCP clients' route), `/oauth2/create-client` (this door and the SDK), `/admin/oauth2/create-client`, and the client-metadata document. Both update paths (`/oauth2/update-client`, `/admin/oauth2/update-client`) declare `application_type` as `web | native` (`:2885`, `:2923`). better-auth 1.7.3 itself ships no `oidc-provider` or `mcp` plugin. The generic data API cannot write the table (`apiMethods: ['get', 'list']`), and no in-tree code writes the column except through the provider (the `applicationType: 'type'` mapping in `auth-schema-config.ts`). - **What this door wrote before #24992.** It sent `type: safeType`, with `safeType` one of `web` / `native` / `user-agent-based` or else `web` (`fbb42d900^:auth-plugin.ts:3640`). 1.7.x strips `type`, so every row was `web`. Under the earlier lines below, the door sent no `token_endpoint_auth_method` (a confidential client), so `user-agent-based` was refused there and `public` became `web`. - **What earlier provider lines could write, through other doors.** `@better-auth/oauth-provider` 1.6.33 and 1.7.0-rc.2 (npm tarballs, read with grep only) declared `type` as `web | native | user-agent-based`, required `native` or `user-agent-based` for a public client and `web` for a confidential one, and wrote `public: isPublic`. plugin-auth's `package.json` history (245 commits, read through the commits API): the provider arrived as `^1.6.9` on 2026-05-08, ran `^1.6.11`–`^1.6.23` to 2026-07-16, `1.7.0-rc.1` from 2026-07-22 and `1.7.0-rc.2` from 2026-08-19, then `^1.7.1` from 2026-08-27. So a deployment that ran plugin-auth between 2026-05-08 and 2026-08-27 can hold rows with `type = 'user-agent-based'` (a public client registered by name through dynamic registration or create-client) and `public` set. - **`type = 'public'`.** No writer found. No `oauth-provider` line declares it. better-auth 1.6.x's own `mcp` plugin wrote `type: 'public'`, but plugin-auth never imported it: `auth-manager.ts` history from 2026-02-01 to 2026-05-09, 31 commits read, 0 hits for `plugins/mcp` or `mcp(` (control: `oidc-provider` / `oidcProvider(`, 8 hits). The deprecated core `oidc-provider` wrote `web` on registration. - **What the select declares.** `web`, `native`, `useragentbased`, `public` (half 2b's reading: the string-array shorthand strips hyphens). So the one non-`web`/`native` value a writer could ever produce, `user-agent-based`, is not a declared value, and the declared `useragentbased` and `public` have no writer in the tree, in the installed provider or in the lines above. - **Readers.** No in-tree reader branches on the column's values (`git grep`: only the select declaration, the form and this wrapper). The installed 1.7.3 has no `user-agent-based` token (`grep -c` 0) and reads no `public` field; a public client is `token_endpoint_auth_method: 'none'`. The earlier lines did branch, requiring PKCE for `native`, `user-agent-based` or `public === true` (1.6.33 `utils-Cdzs0w5-.mjs:420`). One branch remains on legacy data, from source reading only: 1.7.3's two update paths re-run `checkOAuthClient` over the stored client merged with the update, so a legacy row holding `user-agent-based` would answer `application_type must be web or native` to an update that does not also set `application_type`. That is not measured on a row (Acceptance notes). ### M3: who sends a type other than `web` / `native` to this door None found, so this PR breaks no in-repo sender. - **This repo, at `73432299c`.** Every caller of the door sends no type or `web`: `in-process-session-renewal.pin.test.ts`, `request-json-body-refusal.test.ts`, `oauth-register-refusal-status.test.ts` (`web`) and this PR's test file. Docs: `content/docs/protocol/objectui/actions.mdx` shows the action with no `type` param; the platform checklist (`identity-auth.json`) registers with no type. `examples/` and `packages/qa`: 0 hits for the door. The SDK never builds this URL (`disposition: 'server-only'` in the auth route ledger). - **The Console form.** Since #25011 it offers only `web` and `native`, required, default `web`. objectui's `ActionParamDialog` pre-fills `defaultValue` and refuses to submit a required param that is `undefined`, `null`, empty or whitespace (`packages/app-shell/src/views/ActionParamDialog.tsx:503`, `:515`, `:537`, the same at the pinned `.objectui-sha` `cf153eb2e` and at objectui `main` `a91f6bbb1`). So the form sends `web` or `native` only. - **objectui `a91f6bbb1`.** 0 files for the door path, `user-agent-based` or `application_type` (control: `sys_oauth_application`, 5 files). - **HotCRM `d723f6f`.** 0 files for the door path, `user-agent-based`, `application_type`, `oauth2/create-client` or `applications.register` (control: `oauth`, case-insensitive, 18 files). ## Tests `packages/plugins/plugin-auth/src/oauth-register-application-type.test.ts` (half 2a's file, the same real stack as M1). The provider's `createOAuthClient` is wrapped in a pass-through spy, and each request clears it. - Half 2a's warning describe is replaced by the refusal pins. `user-agent-based`, `public`, `spa`, `''`, `42`, `true` and an object, each with an https and with a loopback redirect: `400`, ADR-0112 envelope clean, `error.code` `INVALID_REQUEST`, the provider never called, no row written, no warning and no error logged. - The message pin: a 64-character value plus a tail is echoed up to the 64 characters and never the tail; the message names `'web'`, `'native'`, `http://localhost`, `http://127.0.0.1` and `http://[::1]`; a short value is named whole; a non-string is named `(number)`. - Added: `type: null` registers `web` with no warning. Every accepted registration now also asserts exactly one provider call, carrying the `application_type` it stored. - Half 2a's pins (provider preconditions, `web` / `native` forwarding, the loopback rules, absent → `web`) pass unchanged. Results: - At `ee660f7cd` (the fix, before merging `main`): this file plus `oauth-register-refusal-status.test.ts`: `Test Files 2 passed (2)`, `Tests 42 passed (42)`. The full `plugin-auth` suite: `Test Files 174 passed (174)`, `Tests 3344 passed | 10 skipped (3354)`. `platform-objects`: `Test Files 83 passed (83)`, `Tests 1239 passed (1239)`, half 2b's `oauth-register-form-types.test.ts` included. `platform-objects` typecheck: exit 0, `check:test-typecheck: OK`, 2 pinned signatures held. - At `3f934a772` (after merging `main` `4d991588f`, which touched `plugin-auth`'s `auth-manager.ts`): a rebuilt closure, then the full `plugin-auth` suite: `Test Files 175 passed (175)`, `Tests 3348 passed | 10 skipped (3358)`. `plugin-auth` typecheck: exit 0, `check:test-typecheck: OK`, 23 pinned signatures held. No debt was added, and the test file is in the `tsconfig.test.json` program (`--listFilesOnly`, 1 hit). `platform-objects` is untouched by the merge. ## Ablation (one-shot; no permanent file) Every leg went through `scripts/ablation-replace.mjs` in WRAP mode, in a runner with its own EXIT/INT/TERM trap that re-proved each restore. Each anchor hit once and fell from 1 to 0, with the blob changed on disk. Each restore was proven as blob `d065fc855` == HEAD, with `git diff HEAD` empty and `git status --porcelain` empty at the end. The test imports `./auth-plugin` from source, so there is no `dist` leg. The unmutated control: 25 passed. | Leg | Mutation | Result | |:--|:--|:--| | A | half 2a's warning branch put back: warn, then register as `web` | 15 failed, 10 passed: every refusal case (`expected 200 to be 400`; on loopback, `expected 'OAUTH_REGISTER_FAILED' to be 'INVALID_REQUEST'`) and the message pin | | G | silently mapped to `native` instead of refused | 15 failed, the same cases | | B | the refusal kept, plus a warning | 14 failed: `the door still warns instead of (or as well as) refusing: expected [ [ …(2) ] ] to deeply equal []` | | C | the provider called before the refusal | 14 failed: `the door called the provider for a type it refuses: expected [ { client_name: 'Typed', …(2) } ] to deeply equal []` | | D | the value echoed untruncated | 1 failed: `the message echoes the value past its first 64 characters: expected 'type "aaaa…' not to contain 'TAIL'` | | E | `null` refused | 1 failed: the `null` → `web` pin, `expected 400 to be 200` | | F | absent refused too | 2 failed: the absent and `null` pins | The first runs of legs B and C were refused by the tool and ran nothing ("the anchor count moved 1 -> 1": the replacement contained its anchor). They were redone with an anchor the replacement does not contain. The figures above are from the redone runs. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at the final head `3f934a772` derived 74 commands (the seat's 57 at `73432299c`, plus 17 that this diff's paths and its changeset add). The diff is 190 changed lines (+138 / -52) across 4 files, under the 3000 human-merge threshold. Each command ran as `cmd > log 2>&1; status=$?`, recorded byte for byte with `:: exit N`. - **First pass.** 72 of 74 exited 0. `check:dual-build-cjs-loads` and `check:i18n` exited 3 (`PREREQUISITE NOT MET`: no whole-workspace `dist`, and no built CLI), so they measured nothing. After `turbo run build --filter=!@objectstack/docs --concurrency=2` (76 tasks), both exited 0: "112 published require entry point(s) across 69 package(s)" and `check-i18n-bundles: OK (10 package(s) — all bundles in sync, no undeclared authoring keys)`. - **Reconciliation.** `--ran`: `Run reconciliation — 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN`. - **Verdicts among the 74.** `check-adr-0087-registration --base origin/main`: `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition`, read as `[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)`. Also green: `check-changeset-no-major`, and `check-empty-changeset` ("No changeset from the merge base modified or deleted"). `check:error-status-conformance`: "every derivable runtime status is documented, and every documented status is reachable". `check:issue-citations`, `check:doc-authoring`, `check:nul-bytes`, `check:route-envelope`, `check:auth-mount-ledger`, `check:closing-keyword-parity`, `check:type-check-debt` and `check:engine-double-contract` are green too. - **Not run locally.** `test:repo` is CI's `Test Core`. `check:dev-prereqs` and `check:skill-frame-freshness` do not apply: no `packages/spec` build input or frame file is touched. No exported symbol is renamed or removed, so `check-platform-checklist --changed-from` does not apply. The type-check lanes and the CI jobs that `dispatch-gates` lists outside the 74 belong to CI. - **Dogfood.** This PR has a `(narrowing)` arm, so the full dogfood battery runs once on this head after the PR opens. Its reading is in the dev report on #24773. - **After the patch rounds:** `check-empty-changeset` is red by design at the final head `48857fcc6` (see `## Pending release notes corrected` below). The re-verification at `2da95c6da`, after merging `main` `dfe60e3a0`: - the full `plugin-auth` suite: `Test Files 175 passed (175)`, `Tests 3348 passed | 10 skipped (3358)`; - `plugin-auth` typecheck: `check:test-typecheck: OK`, 23 pinned signatures held; - `--ran`: `74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN`. The 21 families that the edited changesets trigger were re-run at `2da95c6da`: 20 exit 0, and `check-empty-changeset` exits 1 as above. The other 53 are carried from `3f934a772`. - **r3, `8eba09f8c` (prose only, this PR's own changeset).** `.changeset/24773-oauth-type-refusal.md` now describes the change from the last published state, 18.0.0, where the door registered such a type as `web` silently. The **FROM** bullet drops ", and the server logged one warning naming the value", and the **TO** bullet drops ": the refusal replaces that warning": half 2a's warning never reached a published release. `dispatch-gates --commands` over that one path derived the same 21 families. All 21 re-ran at `8eba09f8c`: 20 exit 0 (`check-adr-0087-registration --base origin/main`: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition"), and `check-empty-changeset --base origin/main` exits 1, the expected red naming only the two corrected foreign changesets. `--ran`: `74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN`, with the other 53 carried from `3f934a772`. - **r4, `48857fcc6` (prose only, this PR's own changeset).** `.changeset/24773-oauth-type-refusal.md` was re-read against the last published state, 18.0.0. In 18.0.0 the door sent the type as `type`, which the provider stripped, so every client was `web`. Provider refusals answered `500`, and the form offered four types. Three phrases stated this release's intermediate behaviour as the FROM state, and were changed: - **FROM.** "answered the web rule's `400 OAUTH_REGISTER_FAILED` for a loopback redirect URI" became "was refused by the web rule (`OAUTH_REGISTER_FAILED`) for a loopback redirect URI". In 18.0.0 that refusal answered `500`. - **Unchanged.** "**Unchanged:** `web` and `native` are forwarded as the provider's `application_type`, and the provider's own redirect-URI rule decides. ... The other refusals keep their codes and statuses." was split into two bullets. The first is "**Accepted:** `web` and `native` are forwarded as the provider's `application_type` (this release's Application Type entry), and the provider's own redirect-URI rule decides." The second is "**Unchanged:** ... The other refusals keep their codes (their statuses are this release's refusal-status entry)." The reason: in 18.0.0, `native` was judged and stored as `web`, and the statuses change in this release's refusal-status entry. - **The form.** "The form already offers only Web and Native." became "The form offers only Web and Native (this release's register-form entry)." In 18.0.0 the form offered four types. - Gates: `dispatch-gates --commands` over that path derived the same 21 families, re-run at `48857fcc6`. 20 exit 0 (`check-adr-0087-registration --base origin/main`: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition"); `check-empty-changeset --base origin/main` exits 1, the expected red naming only the two corrected foreign changesets. `--ran`: `74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN`, with the other 53 carried from `3f934a772`. - The dogfood battery is not re-run. The merge moved `driver-sql`, `objectql`, `service-settings`, `service-messaging`, `cli` and `core`, none of it under this diff, and this round's delta is prose only. ## Pending release notes corrected This PR's refusal made sentences in two of the card's own pending changesets false. They are corrected in `2da95c6da` (final head `48857fcc6`). Every still-true sentence is kept, and each file keeps its frontmatter (package and bump). `check-empty-changeset` refuses the edit as a foreign-changeset modification. That is its DELIBERATE CORRECTION class, red by design at the final head `48857fcc6`, as since `2da95c6da` (CI `Check Changeset`: "exists on the merge base and was not added by this PR"), for the seat's same-head contract review to confirm. The evidence for every correction is M1 AFTER above: `user-agent-based`, `public`, `spa`, `''` and non-strings answer `400 INVALID_REQUEST`, with no row stored and no warning logged. **`.changeset/24773-oauth-application-type.md`** (`@objectstack/plugin-auth`: patch, unchanged) - The bullet label changes from "**Unchanged, with a warning.**" to "**Refused.**". - BEFORE: "It keeps today's outcome: registered and judged as `web`, with a client secret. The server now logs one warning per such registration, naming the value." - AFTER: "The register route refuses it with `400 INVALID_REQUEST` and registers nothing (this release's breaking entry for the route)." - Why the original is false: M1 AFTER. These types are refused with 400; no `web` client is registered and no warning is logged. - Kept verbatim: "Any other type, including the form's `user-agent-based` and `public`, has no `application_type` in the provider." and "To use a loopback redirect URI, register the client as `native`." **`.changeset/24773-oauth-form-types.md`** (`@objectstack/platform-objects`: patch, unchanged) - The Clause-② reason. - BEFORE: "Clause-②: no (a form's offering: the register route still accepts every type value it accepted, and nothing on the server reads the action's param options, so no accept set or served shape moves)" - AFTER: "Clause-②: no (a form's offering: nothing on the server reads the action's param options, so this change moves no accept set or served shape; the register route's refusal of the other types is its own breaking entry in this release)" - Why the original is false: M1 AFTER; the route no longer accepts every value it accepted. The value stays `no`, because the form change itself moves no accept set. The parenthetical does not start with an arm word, so the line's reading is unchanged. - BEFORE: "The OAuth provider judges only `web` and `native`, so the register route registers the other two as `web`, with a client secret." - AFTER: "The OAuth provider judges only `web` and `native`, and the register route refuses any other type with `400 INVALID_REQUEST`." - Why the original is false: M1 AFTER. - BEFORE: "The register route accepts the same values as before, including `user-agent-based` and `public`." (the first sentence of the **Unchanged.** bullet) - AFTER: removed. The bullet keeps "The stored `sys_oauth_application.type` column still lists all four values, so a row written under an earlier provider line keeps its value. The `public` column is unchanged." - Why the original is false: M1 AFTER. The refusal is stated in this PR's own entry and in the corrected sentence above. **`.changeset/24773-oauth-register-refusal-status.md`** (`@objectstack/plugin-auth`: patch) is not edited, because none of its sentences is false. Each one speaks of the refusals the provider raises (`400` for `invalid_redirect_uri`, `401` for a missing session, `500` for a throw that declares no status, the body-schema `400`), and those are unchanged. The new type refusal comes before the provider is called. So do the route's own `name` / `redirectURLs` refusals, which this note never covered either. Its closing sentence, "a `web` client still needs an `https` URI on a non-loopback host", stays true. ## Acceptance notes - **Legacy rows and the update paths.** Rows written under `@better-auth/oauth-provider` 1.6.x / 1.7.0-rc (2026-05-08 to 2026-08-27) can hold `type = 'user-agent-based'` and a `public` value. 1.7.3's update paths re-validate the stored client, so such a row would answer `application_type must be web or native` to an update that does not also set `application_type`. This comes from reading source; it was not measured on a row, and no deployment's data is in reach. - **The stored select declares two values no writer produces.** `useragentbased` and `public` (M2). Reported to the seat in the dev report, not narrowed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a55a806 commit 5872450

6 files changed

Lines changed: 143 additions & 56 deletions

File tree

‎.changeset/24773-oauth-application-type.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ Clause-②: no — the declared type now takes effect under the OAuth provider's
99
`POST /api/v1/auth/sys-oauth-application/register` (the Setup → OAuth Applications form) forwarded the form's Application Type as `type`. The provider's `/oauth2/create-client` body declares `application_type` (`web` | `native`) and no `type`, so the value was dropped and every client was judged and stored as `web`. A `native` client therefore could not register a loopback redirect URI such as `http://localhost:4001/callback`, and its `sys_oauth_application.type` read `web`.
1010

1111
- **Now.** `web` and `native` are forwarded as `application_type`, and the provider's own rule decides. A `native` client may use http only on exactly `localhost`, `127.0.0.1` or `[::1]` (any port), and is stored as `native`. A `web` client still needs an https URI on a non-loopback host.
12-
- **Unchanged, with a warning.** Any other type, including the form's `user-agent-based` and `public`, has no `application_type` in the provider. It keeps today's outcome: registered and judged as `web`, with a client secret. The server now logs one warning per such registration, naming the value. To use a loopback redirect URI, register the client as `native`.
12+
- **Refused.** Any other type, including the form's `user-agent-based` and `public`, has no `application_type` in the provider. The register route refuses it with `400 INVALID_REQUEST` and registers nothing (this release's breaking entry for the route). To use a loopback redirect URI, register the client as `native`.
1313
- **Unchanged.** No type at all is `web`. Refusals keep the codes and statuses they had: `400 OAUTH_REGISTER_FAILED`, with `error.details.upstreamError` `invalid_redirect_uri` when the redirect-URI rule refuses.

‎.changeset/24773-oauth-form-types.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44

55
fix(platform-objects): the OAuth application register form offers only Web and Native, and its Redirect URLs help states the rule for each (#24773)
66

7-
Clause-②: no (a form's offering: the register route still accepts every type value it accepted, and nothing on the server reads the action's param options, so no accept set or served shape moves)
7+
Clause-②: no (a form's offering: nothing on the server reads the action's param options, so this change moves no accept set or served shape; the register route's refusal of the other types is its own breaking entry in this release)
88

9-
Setup › OAuth Applications › Register offered four application types: Web, Native, User-agent based and Public. The OAuth provider judges only `web` and `native`, so the register route registers the other two as `web`, with a client secret. The form now offers Web and Native, and still defaults to Web.
9+
Setup › OAuth Applications › Register offered four application types: Web, Native, User-agent based and Public. The OAuth provider judges only `web` and `native`, and the register route refuses any other type with `400 INVALID_REQUEST`. The form now offers Web and Native, and still defaults to Web.
1010

1111
- **Redirect URLs help.** It said "Must use https:// in production", which did not tell an admin that a local `http://localhost` callback needs the Native type. It now states the rule for each type: a Web application needs `https://` on a host other than localhost or another loopback address; a Native application may also use `http://` on exactly `localhost`, `127.0.0.1` or `[::1]`, on any port.
1212
- **Application Type help.** New: choose Native for a desktop or mobile app, or for an app in local development that redirects to `http://localhost`.
13-
- **Unchanged.** The register route accepts the same values as before, including `user-agent-based` and `public`. The stored `sys_oauth_application.type` column still lists all four values, so a row written under an earlier provider line keeps its value. The `public` column is unchanged.
13+
- **Unchanged.** The stored `sys_oauth_application.type` column still lists all four values, so a row written under an earlier provider line keeps its value. The `public` column is unchanged.
1414

1515
The zh-CN, ja-JP and es-ES translations carry the new help texts.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
fix(plugin-auth)!: `POST /api/v1/auth/sys-oauth-application/register` refuses an application type the OAuth provider does not judge with `400 INVALID_REQUEST`, where it registered it as a `web` client
7+
8+
Clause-②: no (narrowing) — the register door accepted any `type` value and registered every one other than `web` / `native` as a `web` client with a secret; it now refuses each of those values with 400, so it accepts exactly `web`, `native` and no type.
9+
10+
<!-- 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, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer one runtime REST door gives a caller whose request body names an application type the OAuth provider has no counterpart for; the remedy is a different value in that request, never a rewrite of a package source or a stored row. The Console form that posts to this door already offers only Web and Native, and `sys_oauth_application` rows are not touched. 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 (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
11+
12+
**BREAKING** (a REST door's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
13+
14+
The OAuth provider (`@better-auth/oauth-provider`) judges two application types, `web` and `native`, and this door forwards the type the Setup → OAuth Applications form chose as the provider's `application_type`. Any other value has no provider counterpart. The door used to register such a request as a confidential `web` client, with a client secret and the web redirect-URI rule, which is a different kind of client from the one the request asked for. It now refuses the request instead.
15+
16+
- **FROM:** a request whose `type` is present and is neither `web` nor `native` (`user-agent-based`, `public`, any other string including the empty string, or a value that is not a string) answered `200` and stored a `web` client, or was refused by the web rule (`OAUTH_REGISTER_FAILED`) for a loopback redirect URI.
17+
- **TO:** the same request answers `400` with `error.code: 'INVALID_REQUEST'`. The message names the value (its first 64 characters) and the two accepted types, and says to choose `native` for an `http://localhost`, `http://127.0.0.1` or `http://[::1]` redirect URI. The provider is not called, nothing is stored, and no warning is logged.
18+
- **Accepted:** `web` and `native` are forwarded as the provider's `application_type` (this release's Application Type entry), and the provider's own redirect-URI rule decides.
19+
- **Unchanged:** A request with no `type`, or with `type: null`, registers a `web` client, the provider's default. The other refusals keep their codes (their statuses are this release's refusal-status entry). Clients already registered are not touched.
20+
- **`@objectstack/platform-objects`:** only the code comment on the `create_oauth_application` action changes, to state this refusal; it ships in the package's JavaScript. The form offers only Web and Native (this release's register-form entry).
21+
- **The fix for an integrator:** send `type: 'web'` for a client whose redirect URIs are https on a host that is not a loopback address, or `type: 'native'` for a client that also redirects to `http://localhost`, `http://127.0.0.1` or `http://[::1]`. Omitting `type` registers `web`.

‎packages/platform-objects/src/identity/sys-oauth-application.object.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -151,14 +151,14 @@ export const SysOauthApplication = ObjectSchema.create({
151151
// provider judges (`@better-auth/oauth-provider` 1.7.3 declares
152152
// `application_type` as `web` | `native`), and the redirect-URL help
153153
// states that provider's redirect-URI rule per type, which the register
154-
// route forwards the type to. `user-agent-based` and `public` are no
155-
// longer offered: the provider has no such application type, so the
156-
// route registers either as `web`. The route itself still accepts both
157-
// (refusing them narrows the door, which waits for a major version), and
158-
// nothing on the server reads these options: the generic action door
159-
// refuses a `type: 'api'` action before its param contract runs, so the
160-
// route is the only judge. The stored `type` column below is a separate
161-
// surface and is not narrowed here: that waits for the same major version.
154+
// route forwards the type to. The route accepts only those two: in 18.x it
155+
// refuses any other present type (`user-agent-based`, `public`, ...) with
156+
// 400 before the provider runs; no type (absent or null) registers `web`.
157+
// Nothing on the server reads these options: the generic action door
158+
// refuses a `type: 'api'` action before its param contract runs. The
159+
// stored `type` column below is not narrowed: 1.7.3 writes only `web` or
160+
// `native` (null for a client-metadata-document client), an earlier
161+
// provider line could write `user-agent-based`, and no in-tree reader branches on it.
162162
params: [
163163
{ name: 'name', label: 'Application Name', type: 'text', required: true },
164164
{

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

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3647,26 +3647,35 @@ export class AuthPlugin implements Plugin {
36473647
// non-loopback host, `native` may use http only on exactly `localhost`,
36483648
// `127.0.0.1` or `[::1]` — and nothing here re-judges a URI.
36493649
//
3650-
// Any other value (the form's `user-agent-based` and `public`, which
3651-
// have no `application_type`, or anything else) keeps the outcome it
3652-
// always had: registered and judged as `web`. Refusing it would narrow
3653-
// what this door accepts, and such a narrowing ships its loud half
3654-
// first (the standing post-GA rule, #6015 `6292005854`): one warning
3655-
// per registration, naming the value. No type at all is the
3656-
// provider's own default, `web`, and says nothing.
3650+
// The door accepts exactly those two types. Any other value present in
3651+
// the body — the form's former `user-agent-based` and `public`, which
3652+
// have no `application_type`, an empty string, a non-string, anything
3653+
// else — is refused here with the route's own 400 `INVALID_REQUEST`,
3654+
// before the provider is called, so nothing is stored. It is never
3655+
// registered as `web` and never mapped to another type: that silent
3656+
// substitution issued a confidential web client, with a secret, for a
3657+
// request that asked for something else. The refusal ships in 18.x as
3658+
// a breaking minor (the standing post-GA rule as amended, #6015
3659+
// `6303865025`). No type at all — absent, or JSON `null` — is the
3660+
// provider's own default, `web`, and is unchanged.
36573661
let applicationType: 'web' | 'native' = 'web';
36583662
if (type === 'web' || type === 'native') {
36593663
applicationType = type;
36603664
} else if (type !== undefined && type !== null) {
3661-
const requestedType = typeof type === 'string' ? type.slice(0, 64) : `(${typeof type})`;
3662-
ctx.logger.warn(
3663-
`[AuthPlugin] sys-oauth-application/register: application type ${JSON.stringify(requestedType)} is not ` +
3664-
"one the OAuth provider judges (it knows 'web' and 'native'), so this client is registered and judged " +
3665-
"as 'web': https redirect URIs on non-loopback hosts only, and a client secret is issued. Register it " +
3666-
"as 'web', or as 'native' to use an http://localhost, http://127.0.0.1 or http://[::1] redirect URI. " +
3667-
'A future major version refuses this value.',
3668-
{ requestedType, registeredAs: 'web' },
3669-
);
3665+
const shown =
3666+
typeof type === 'string'
3667+
? JSON.stringify(type.length > 64 ? `${type.slice(0, 64)}…` : type)
3668+
: `(${typeof type})`;
3669+
return c.json({
3670+
success: false,
3671+
error: {
3672+
code: 'INVALID_REQUEST',
3673+
message:
3674+
`type ${shown} is not an application type the OAuth provider judges: send 'web' or 'native', ` +
3675+
"or no type for 'web'. Choose 'native' for an http://localhost, http://127.0.0.1 or http://[::1] " +
3676+
"redirect URI; 'web' needs https on a host that is not a loopback address.",
3677+
},
3678+
}, 400);
36703679
}
36713680

36723681
const authApi: any = await this.authManager!.getApi();

0 commit comments

Comments
 (0)