Skip to content

Commit 03966ce

Browse files
fix(plugin-auth)!: lift the better-auth family from 1.7.3 to 1.7.12 (#25109)
Fixes #25096 Clause-②: no (narrowing) Metadata-protocol: none ## What changed - **The lift, one commit (`75aaf74a1d`).** The 11 better-auth family overrides in `pnpm-workspace.yaml` and the 5 exact ranges in `packages/plugins/plugin-auth/package.json` move from `1.7.3` to `1.7.12`, still exact. The selectors keep their bound at the 2.0.0 major boundary; the header's selector rule is untouched. The lockfile is regenerated by `pnpm install` (under the verify lock), never by hand. - **`pnpm-workspace.yaml` family note** states the new target, why, and what moved with it. - **`auth-route-ledger.ts`**: one new row, `POST /api/v1/auth/oauth2/client/rotate-keys`. `@better-auth/oauth-provider` 1.7.12 registers it, so the catch-all publishes it. It is refused `403 access_denied` because `auth-manager.ts` does not set `allowClientKeyRotation` (measured below). This is the one test the lift turned red (`auth-route-ledger.conformance.test.ts`, the mounted-surface equality). - **Two new pin files** for vendor changes plugin-auth callers now depend on, each with one ablation leg (below): - `session-read-failure-answer.test.ts`: a session lookup the store cannot answer is a failure, never a signed-out caller. - `verification-reservation-single-use.test.ts`: the ObjectQL adapter keeps the primary key better-auth forces on a single-use reservation, so a replay is refused. - **Version attestations**: the 32 lines under `packages/plugins/plugin-auth/src` that named `1.7.3` were each re-measured on 1.7.12 (table below). 19 restamped with the date and the card, 12 left as written (they are facts about the 1.7.3 release and still hold), and 1 not restamped but flagged and reported (its claim is false). - **`.changeset/25096-better-auth-1-7-12.md`**: `@objectstack/plugin-auth` `minor`, BREAKING, with the `Clause-②` line, the ADR-0087 disposition `not-required (no-migration-prescription)`, FROM / TO per measured change, Unchanged, and the operator notes. No behaviour of plugin-auth changes beyond the vendor's. No other dependency was moved on purpose; `packages/spec/**` and `packages/objectql/**` are untouched. ## Lockfile: what moved Resolved package set, old vs new (`packages:` keys of `pnpm-lock.yaml`): - The family: all eleven `1.7.3` to `1.7.12`. - Declared by 1.7.12 itself: `better-call` 1.4.0 to 1.4.4, `@better-auth/utils` 0.4.2 removed (0.5.0 was already resolved and is now the only copy; 1.7.12 peers `^0.4.2 || ^0.5.0`), `@better-fetch/fetch` 1.3.1 to 1.3.2. - Gone because 1.7.12 no longer asks for them: the auto-installed `better-sqlite3@12.11.1` copy (better-auth dropped its optional `better-sqlite3 ^12` peer) and the 20 packages only its native build used (`prebuild-install`, `node-abi`, `tar-fs`, `bindings` and the rest); `@cloudflare/workers-types` (core dropped the peer); `@opentelemetry/semantic-conventions` (core dropped the dependency); `@noble/ciphers@2.3.0` and `tldts`/`tldts-core@7.4.9` (deduplicated onto the 2.4.0 and 7.4.16 copies already in the tree). - Nothing added outside the family. One cosmetic swap: the `vitest` snapshot lines of the `packages/apps/account` and `packages/apps/setup` importers exchanged which of the two existing `vite@8.3.4` peer variants (`esbuild@0.28.1` / `0.28.2`) they name. No version entered or left. ## Measured on 1.7.12, item by item (the card's list) Real stack throughout: `ObjectQL` over `@objectstack/driver-sql` (better-sqlite3 `:memory:`), the objects from plugin-auth's manifest, a real `AuthManager`, requests through `AuthManager.handleRequest`. Where a reading comes from a one-shot probe it is marked **probe**: a throwaway vitest file in the package, run once at `347772618d`, not committed. | Card item | Reading on 1.7.12 | How | |---|---|---| | Export surface | `check:vendor-export-contract` PASS, 3 edges (`@better-auth/core/context`, `@better-auth/core/utils/ip`, `better-auth/adapters`); `check:override-consistency` PASS; plugin-auth `build` (DTS) and `typecheck` green | gates | | 1.7.7 better-auth/better-auth#11494 identifier prefixes vs `implicit-account-linking.ts` | A Magic Link row is stored as `magic-link:TOKEN` with value `{"type":"magic-link",...}`; an OAuth state row as `auth-state:STATE`. The unlink tombstone is `account-unlinked:USER:PROVIDER`; its exact read and its user-prefix read each return the tombstone alone, and the user sweep left both vendor rows in place. No collision, and plugin-auth reads neither prefix. `implicit-account-linking.test.ts` green | probe + suite | | 1.7.8 better-auth/better-auth#11391 `customSession` now throws | Over HTTP a store failure on `GET /get-session` answers `500 FAILED_TO_GET_SESSION` and keeps the session cookie; in process `api.getSession` rejects. Before (vendor ablation restoring the 1.7.7 catch): `401 UNAUTHENTICATED` "Sign in first" and `null`. Right answer: yes, see below | new pin + ablation | | better-auth/better-auth#11540 / better-auth/better-auth#10591 / better-auth/better-auth#11718 cookies and session storage, with `secondaryStorage` | **Fork, below.** Cookies: `HttpOnly; SameSite=Lax; Path=/`, no `Secure` on plain HTTP, the same with or without a host `secondaryStorage`, so the RFC 6265bis forcing (`SameSite=None` and `Partitioned` imply `Secure`, in `better-call` 1.4.4) never applies to plugin-auth's cookies. better-auth/better-auth#10591: a stale session cookie on `/get-session` is now cleared (`Max-Age=0` on `session_token`, `session_data`, `dont_remember`) alongside the same `401`. better-auth/better-auth#11718: not armed here | probe | | 1.7.7 better-auth/better-auth#11375 active organization refresh vs the `session.create.before` stamp | Client-only: it widens the organization client's `$activeOrgSignal` matcher to the session signals. No server path changed. The stamp's suites (`first-session-membership-ordering`, `session-positions-security-axis`, `session-grants-resolved-once`, `principal-session-mint`) green. The client that would pick it up is objectui's `@object-ui/auth`, which declares `better-auth ^1.7.2` on its own lockfile; nothing here moves it | dist + suite | | oauth-provider 1.7.9 better-auth/better-auth#10799 DPoP `htu` | Authorization-code exchange with a DPoP proof: `htu` = the public base URL is accepted (`token_type: DPoP`) even when the request reaches the server under another host (`127.0.0.1:3000`); `htu` = that request URL is refused `400 invalid_dpop_proof` "DPoP proof htu does not match the request URL". 1.7.3 compared against the request URL. **Widening for host-rewriting proxies, in open questions** | probe | | oauth-provider 1.7.10 better-auth/better-auth#11724 PKCE 401 to 400 | Wrong `code_verifier`: `400 invalid_grant` "code verification failed"; control with the right verifier: `200`. **No pin of the old 401 exists** (repo-wide grep for PKCE / `code_verifier` in tests: the success-path uses in `auth-manager.mcp-oauth-resource.test.ts` and `auth-manager.oauth-consent-organization.test.ts`, no status pin on a mismatch), so none was updated | probe + grep | | 1.7.8 better-auth/better-auth#11625 replay protection; which ids the adapter generates | plugin-auth sets no `generateId`: user, session, account and verification rows carry better-auth's own 32-character base62 ids, kept as given. A reservation is stored under the forced base64url SHA-256 id, and its replay is refused. The `uuid` path the fix is about is not armed here | new pin + ablation + probe | | Version attestations | 32 lines, table below | dist re-reads, suites, probes | | GHSA-965c-763c-88jm precondition set | With Magic Link on (plain token storage, the default), a social provider configured and database-backed OAuth state, a value minted by the OAuth-state flow presented at `/magic-link/verify` answers `302` to `?error=INVALID_TOKEN`, sets no session cookie, and leaves the state row intact. Control: the real link answers `302` to the callback with a session cookie. The precondition set no longer yields the exposure | probe | **Is the new `customSession` answer right?** Yes. A store outage is not a signed-out caller. Under 1.7.3 the browser was told "Sign in first" while its session still existed; now it is told the read failed and keeps its cookie. That follows AGENTS.md "Absence must be loud" and the degradation rule (a failure handed to the caller is not a silent degradation). The in-process callers that want "anonymous" on a failed read keep it on purpose: `resolveRequestExecutionContext` (core) and `resolveSessionData` (runtime) catch the throw themselves. One in-process path gets stricter: core's auth-gate re-read (`decideAuthGate`) already maps a throw to `AuthzStoreUnavailableError`; under 1.7.3 the swallowed failure reached it as `null` and the gate read as not held. The raw `/admin/*` routes that call `api.getSession` answer through their own `catch`. Also measured, not on the card's list (both in the changeset): an over-long password at `/sign-in/email` now answers `400 PASSWORD_TOO_LONG` before any lookup, and still counts as a failed attempt for the lockout (`failed_login_count` 0 to 1, then 2 after a wrong short password); and the new `rotate-keys` route answers `403 access_denied` "Client key rotation is disabled". ## Forks from the card 1. **"plugin-auth sets session `storeInDatabase` whenever a host supplies `secondaryStorage`."** Measured: it sets `verification.storeInDatabase: true`, not `session.storeSessionInDatabase`. With a host `secondaryStorage` the session of record lives in the cache only (0 `sys_session` rows; the cache holds the token and `active-sessions-USER`), and `/list-sessions` answers from that list. better-auth/better-auth#11718's database fallback (`databaseSessionFallbackEnabled`) requires `storeSessionInDatabase === true`, so it is never armed on this platform. 2. **"Any test that pins the old [PKCE] status needs updating."** None exists; nothing to update. 3. **Changeset level.** The card asks `patch`; this ships `minor`, BREAKING, per the claim's `Clause-②: no (narrowing)`. ## Attestations: the 32 lines R = restamped to 1.7.12 with the date and #25096 after the re-measure; K = kept, a fact about the 1.7.3 release that still holds on 1.7.12; F = flagged, not restamped. | Line at base | Claim | 1.7.12 | | |---|---|---|---| | `account-identity-preflight.ts:11` | 1.7.3 removed the issuer-scoped account identity | account key still `(providerId, accountId)`, no `issuer` in core's account table | K | | `account-invitation-marker.ts:14` | `requestPasswordReset` answers `{ status, message }`; only `sendResetPassword` gets the link | `password.mjs` request leg unchanged | R | | `account-invitation-marker.ts:68` | `runInBackgroundOrAwait` catches the send; answer stays `{ status: true }` | `create-context.mjs` unchanged there | R | | `admin-import-users-invite-delivery-failure.test.ts:9` | same | same; suite green | R | | `admin-user-endpoints.ts:69` | `providerId` + `accountId` is the whole identity since 1.7.3 | holds | K | | `auth-email-locale.test.ts:871` | `/sign-in/magic-link` does no user lookup; `/magic-link/verify` creates a user unless `disableSignUp` | holds (1.7.12 adds a form-CSRF middleware and a record-type check, no lookup) | R | | `auth-email-locale.test.ts:1006` | body schema has no case transform; `findUserByEmail` lowercases | holds | R | | `auth-manager.test.ts:318` | 1.7.3 rolled the issuer key back | holds | K | | `auth-manager.ts:1802` | `appName` read once; two otpauth readers; no cookie name derives from it | same three sites | R | | `auth-manager.ts:2062` | the reset token lives `resetPasswordTokenExpiresIn` seconds, or 3600 when that is falsy | `password.mjs:73` | R | | `auth-manager.ts:2084` | a server-side reset reaches the callback with no request | `to-auth-endpoints.mjs` byte-identical; `sendResetPassword(..., ctx.request)` unchanged | R (extended) | | `auth-manager.ts:4727` | no `database` builds a store keyed by schema KEY, read by `modelName` | probe: `Model sys_user not found` | R | | `auth-manager.ts:9701` | `/sign-in/email` asks `findUserByEmail(email.toLowerCase())` | holds, now after the new length check | R | | `auth-schema-config.ts:104` | 1.7.3 removed the issuer model | holds | K | | `auth-schema-config.ts:956` | scim exposes no `schema` option; six `SCIMOptions` members | holds | R | | `auth-schema-config.ts:961` | the re-read history | 1.7.12 appended | R (extended) | | `implicit-account-linking.ts:45` | the `handleOAuthUserInfo` gate, then `assertValidUserInfo` with `link-account` | holds; the explicit link now runs through `linkOAuthAccount`, same check, noted | R | | `in-process-redispatch.ts:30` | `disableRefresh` is read from the query on both re-dispatches | `in-process-session-renewal.pin.test.ts` green | R | | `internal-field-readback.ts:197` | the read-back census, including "`identifier`: no flow reads it back off a row" | **false, on 1.7.3 and 1.7.12 alike**: `@better-auth/sso`'s `/sso/verify-domain` reads it back (reported below) | F | | `invite-role-not-found-code.ts:16` | invite throws an uncoded `ROLE_NOT_FOUND`; update-member-role a coded one | `crud-invites.mjs` byte-identical; `crud-members.mjs` still coded | R | | `list-user-invitations-verification.ts:11` | `listUserInvitations` never asks the verification rule | `crud-invites.mjs` byte-identical | R | | `objectql-adapter.test.ts:95` | 1.7.3 removed the `issuer` column | holds | K | | `objectql-adapter.ts:1051` | identity is `(provider_id, account_id)` since 1.7.3 | holds | K | | `organization-delete.ts:10` | what `POST /organization/delete` does and leaves | `crud-org.mjs` and the organization adapter byte-identical | R | | `session-envelope-completion.ts:21` | `createSession` awaits the write; line references | holds; lines now `259-332` and `333-370` | R | | `two-factor-reenrollment-verified-reset.test.ts:27, 29, 340, 345, 354, 461` (6 lines) | 1.7.3 added the `TOTP_ALREADY_ENABLED` gate | `plugins/two-factor/index.mjs` byte-identical 1.7.3 to 1.7.12 | K | | `two-factor-totp-issuer.test.ts:11` | what better-auth does with `appName` | same three sites | R | `node scripts/check-vendor-version-stamps.mjs` is green on every plugin-auth line. **It stays red on two sites outside this card's file surface**, which the lift makes false and which I did not touch (claim: stop on breach): - `packages/spec/src/identity/organization.zod.ts:95`: "Both halves measured against the installed better-auth 1.7.3, so fallback A applies". `packages/spec/**` is a hard stop, and the line is TSDoc on a schema property. Re-measured on 1.7.12: the organization plugin's `schema.mjs` is byte-identical and the adapter factory's `transformOutput` still iterates the declared fields only, so the claim holds and a restamp is honest. - `packages/cli/src/commands/migrate/account-issuer.ts:71`: the command description "...is ONE account under the key better-auth 1.7.3 restored". It is operator-facing text. The claim holds on 1.7.12. Both need the seat's word on the file surface before this lands; the report proposes the text. ## Ablations Both on committed code, each leg proven on disk, each restore proven by hash. 1. **Reservation pin** (`aa3112430a`). `scripts/ablation-replace.mjs` (WRAP) replaced the adapter's `dataEngine.insert(objectName, row)` with an insert that drops `id` (an adapter that mints its own key): anchor 1 to 0, replacement 0 to 1, blob `5ed0db896088` to `cbd1309c37a7`. The test reaches the adapter through relative `src` imports, so no build sits between. Result: `Tests 2 failed | 1 passed (3)`: "the engine kept the forced primary key: expected '3hj6lgd1y2K1GdHC' to be 'RhMSgVct...'" and "expected true to be false" (the replay won); the control stayed green. Restore: blob `5ed0db896088` equals HEAD, `git diff HEAD` empty, `git status --porcelain` empty. 2. **Session-read-failure pin** (`aa3112430a`). The behaviour is the vendor's, so the mutation is in the installed `better-auth` 1.7.12 `dist/plugins/custom-session/index.mjs`: the 1.7.7 `.catch(...)` put back around the inner session read. That file is a hard link into the shared pnpm store, so the link was broken first (inode `4096254` to `1565215`, 1 link) and the store copy was never written. On disk: anchor 1 to 0, injected 0 to 1, sha256 `b2e60e53...` to `25e2b179...`. Result: `Tests 2 failed | 2 passed (4)`: over HTTP "expected 401 to be 500" with body `UNAUTHENTICATED` "Sign in first"; in process "promise resolved null instead of rejecting"; both controls green. Restore under a trap: sha256 back to `b2e60e53...`, and the store inode still hashes `b2e60e53...`. ## Tests - plugin-auth, full suite on the lift alone (`75aaf74a1d`): `Test Files 1 failed | 176 passed (177)`, `Tests 1 failed | 3375 passed | 10 skipped`. The one red was the route ledger (`rotate-keys`), fixed by `9ffd7eb619`. - New pins: `session-read-failure-answer.test.ts` `Tests 4 passed (4)`; `verification-reservation-single-use.test.ts` `Tests 3 passed (3)`. - **Final head `89b9affe00`** (this branch with `origin/main` `2e242bb0a9` merged in; every reading below was taken after that merge): - plugin-auth `vitest run`: `Test Files 179 passed (179)`, `Tests 3383 passed | 10 skipped (3393)`. - plugin-auth `typecheck`: exit 0, with `check:test-typecheck: OK` and the debt ledger unchanged (88 errors, 23 pinned signatures). - `pnpm check:vendor-export-contract`: `VERDICT: PASS`, 3 edges. `pnpm check:override-consistency`: all 9 published-manifest declarations resolve to their override targets. - `node scripts/check-vendor-version-stamps.mjs`: exit 1, red only on the two out-of-surface sites above. - `node scripts/pm/dispatch-gates.mjs --commands`, every family run and reconciled with `--ran`: 86 derived, 86 run, 0 NOT-MEASURED, every exit 0. On the first run, at `becfb99a89`, `check:dual-build-cjs-loads` exited 3 (no full build yet); after the dogfood closure build it was measured green. - **Full dogfood battery** (`pnpm --filter @objectstack/dogfood test`, no shard, no path, under the lock, after `pnpm --filter '@objectstack/dogfood^...' build`): `Test Files 286 passed | 3 skipped (289)`, `Tests 2387 passed | 18 skipped (2405)`, lock verdict command-exit 0. The auth suites run in it. - CI check-runs: CI owns them; they had not finished when this PR was opened. ## Acceptance notes - **Reported, out of scope: SSO domain verification cannot succeed with the record plugin-auth tells the operator to publish.** `POST /api/v1/auth/admin/sso/request-domain-verification` answers `dnsRecordValue` as `_better-auth-token-PROVIDER=TOKEN`. `@better-auth/sso`'s `/sso/verify-domain` accepts a TXT record equal to `IDENTIFIER=VALUE`, building `IDENTIFIER` from the verification row it read, or equal to the bare `VALUE`. On this store `sys_verification.identifier` is `internal` and the read hands better-auth `undefined` there, so it expects `undefined=TOKEN`. Probe, vendor door, DNS answered by a double: the record as instructed answers `502 DOMAIN_VERIFICATION_FAILED`; the bare token answers `204`. Same vendor code on 1.7.3. Not changed here. The report asks the seat to file it. - **Reported, out of scope: the Magic Link mail states a lifetime the link does not have.** `sendMagicLink` passes `expiresInMinutes: 10` to `auth.magic_link`, whose copy says "This link expires in 10 minutes"; `magicLink()` is mounted without `expiresIn`, so better-auth stores the row with a 300-second lifetime (probe: `lifetimeSec: 300`). Unchanged by the lift. - Pre-existing, loud, unchanged: a host `secondaryStorage` together with the embedded OAuth provider (on by default with the MCP surface) makes the auth instance throw at build ("OAuth Provider requires `session.storeSessionInDatabase: true` when using secondaryStorage"), on 1.7.3 and 1.7.12 alike. The existing `secondaryStorage` suites set `oidcProvider: false`. - Not measured: better-auth/better-auth#11626 / better-auth/better-auth#11646 say a password reset consumes its token, revokes sessions and writes the password in one transaction. plugin-auth's adapter opens a real transaction only inside a SCIM request and runs other flows sequentially (`objectql-adapter.ts`, the #3653 scoping note), so on this platform those three writes are sequential, in the vendor's order: consume, revoke, write. A refusal before the write (bad token, password length, unknown user) no longer spends the token. - Accidental push, repaired: the throwaway probe file rode one commit for about a minute. I removed it by amending and a `--force-with-lease` push pinned to that commit, with all five AGENTS.md §3 conditions holding (a `claude/issue-*` branch this worktree created, one author, no PR yet, the lease on the sha I last pushed). It holds no secret. ## Patch round 1 (seat rulings `6315255713`, claim amendment `6315268175`) - **`packages/spec/src/identity/organization.zod.ts`**: the `updatedAt` TSDoc keeps its 1.7.3 reading and appends the re-measurement: "(re-measured on 1.7.12 for #25096: the organization plugin schema is byte-identical and `transformOutput` still projects declared fields only)". Nothing else in `packages/spec` moves. `pnpm --filter @objectstack/spec check:generated` after a fresh spec build: all 14 generated artifacts up to date. - **`packages/cli/src/commands/migrate/account-issuer.ts`**: the `description` now reads "...is ONE account under the (provider_id, account_id) key better-auth restored...", with no version, so a later lift cannot falsify it. No test pins the sentence. cli `unit` layer: `Test Files 321 passed (321)`, `Tests 4746 passed (4746)`; cli `typecheck` exit 0. - **Changeset**: `@objectstack/spec` and `@objectstack/cli` ride as `patch`, text only and not breaking. plugin-auth stays the BREAKING `minor`, and the DPoP `htu` FROM / TO still names both halves (ruling Q2). - With this round, the two out-of-surface stamp reds named under **Attestations** are resolved: `node scripts/check-vendor-version-stamps.mjs` exits 0 (`check:vendor-version-stamps: OK — 10187 file(s) scanned`). - `origin/main` was merged at `ba244f1b48` before the edits (merge `28476848b5`). Final head: `8c4bf6489b`. - **Final head readings**: `pnpm check:vendor-export-contract` PASS (3 edges); `pnpm check:override-consistency` (9 declarations resolve to their targets); plugin-auth `Test Files 180 passed (180)`, `Tests 3422 passed | 10 skipped (3432)`, `typecheck` exit 0; `dispatch-gates.mjs --commands` 108 derived, 108 run, 0 NOT-MEASURED, every exit 0 (`--ran`); full dogfood battery (no shard, no path) `Test Files 286 passed | 3 skipped (289)`, `Tests 2387 passed | 18 skipped (2405)`, lock verdict command-exit 0. ## Patch rounds 2 and 3: the Test Core 4/6 red (job `119207996576`) and its fix - **The failing test**: `packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-file-open-failure.test.ts`, "an addon that was never built (no code) steps down to wasm SQLite", `Error: Cannot find module 'bindings'`. It reproduced at `8c4bf6489b` (`Tests 1 failed | 929 passed (930)`) and was green on `origin/main` `ae58b26f9c` (`Tests 930 passed (930)`). - **Root cause**: the fixture required `bindings` relative to `better-sqlite3` 13.0.3, which does not depend on it: its only dependency is `node-addon-api`, and it loads its addon with a plain `require`. On `main`, `bindings@1.5.0` existed only under `better-sqlite3@12.11.1`, the optional peer copy auto-installed for better-auth 1.7.3, and the test reached it through pnpm's hidden hoist. 1.7.12 dropped that peer (see **Lockfile: what moved**), so the phantom left. The producer was never affected: `resolveSqliteDriver` classifies by error code, not message. - **Fix (round 3, seat ruling A1, claim amendment `6316873193`)**: `realNotBuiltFailure()` now produces better-sqlite3 13's own never-built error: a `require()` of `build/Release/better_sqlite3.node`, made from the installed package, against a module root that was never built, which Node answers `MODULE_NOT_FOUND`. No phantom dependency. The case is renamed "an addon that was never built (MODULE_NOT_FOUND) steps down to wasm SQLite" and asserts that code, still driving the producer with a real load error. The two `sqlite-driver-fallback.ts` docblock sentences that named `bindings`' "Could not locate the bindings file" now describe the 13.x error; no code moved. `@objectstack/service-datasource` rides the changeset as `patch`, text only (the docblock ships in `dist/index.d.ts`). - **Ablation** (on `c428137808`, `scripts/ablation-replace.mjs` WRAP, relative `src` import, no build between). A first leg at `isSqliteFileOpenFailure` was a no-op for this case: the classifier sees the driver's `DATABASE_ERROR` wrapper, not the load error's code. Re-anchored at the step-down decision (`if (isSqliteFileOpenFailure(err)) throw err;` also re-throwing on a `MODULE_NOT_FOUND` code or cause): `Tests 1 failed | 8 passed (9)`, the renamed case red and the `ERR_DLOPEN_FAILED` case green. Restore: blob `cd3fd64524ff` equals HEAD and `git diff HEAD` is empty. - **Final head `1a28ccb438`** (`origin/main` `ae58b26f9c` merged, merge `9dcf97cae7`): service-datasource `Test Files 53 passed (53)`, `Tests 930 passed (930)`; plugin-auth `Test Files 180 passed (180)`, `Tests 3422 passed | 10 skipped (3432)`, `typecheck` exit 0; `check-vendor-version-stamps` OK; `check:vendor-export-contract` PASS; `check:override-consistency` green; `dispatch-gates.mjs --commands` 108 derived, 108 run, 0 NOT-MEASURED, every exit 0; full dogfood battery `Test Files 286 passed | 3 skipped (289)`, `Tests 2387 passed | 18 skipped (2405)`. - **Level (seat ruling A, `6317990662`, under the maintainer's ruling A on #25105)**: this lift changes nothing a metadata author can write, so it declares `Metadata-protocol: none` and ships `@objectstack/plugin-auth` as a BREAKING-bannered `patch`, with its ADR-0087 disposition kept. - **Patch round 4 (ruling A, `6317990662`)**: `ea86e48563` regrades `@objectstack/plugin-auth` from `minor` to `patch` under the maintainer's ruling A (a non-protocol breaking change ships `patch` and keeps its BREAKING banner and ADR-0087 disposition) and adds `Metadata-protocol: none` under the changeset's `Clause-②` line; spec, cli and service-datasource stay `patch`, and nothing outside `.changeset/` moved. With origin/main's scripts against this body: `check-changeset-no-major.mjs` exit 0 (level axis: `none` at `patch`) and `check-adr-0087-registration.mjs` exit 0; `check-vendor-version-stamps.mjs` exit 0 on the head. --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 386f60f commit 03966ce

24 files changed

Lines changed: 552 additions & 434 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/spec': patch
4+
'@objectstack/cli': patch
5+
'@objectstack/service-datasource': patch
6+
---
7+
8+
fix(plugin-auth)!: lift the better-auth family from 1.7.3 to 1.7.12 for the security fixes published since
9+
10+
`@objectstack/plugin-auth` declares the better-auth family exactly, so a fresh install resolves what this package tested. All five declared ranges (`better-auth`, `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/scim`, `@better-auth/sso`) move from `1.7.3` to `1.7.12`, still exact. The lift takes the fixes published in 1.7.4 to 1.7.12 for packages this plugin mounts: GHSA-965c-763c-88jm and GHSA-r4xp-prcw-77qf (1.7.7), and the oauth-provider, SSO, SCIM, JWT and password-reset fixes up to 1.7.12. Every symbol this package imports from the family is still exported. It also pulls in `better-call` 1.4.4, `@better-auth/utils` 0.5.0 and `@better-fetch/fetch` 1.3.2, the versions 1.7.12 declares.
11+
12+
Clause-②: no (narrowing)
13+
Metadata-protocol: none
14+
15+
<!-- 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 of this package is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the vendor library under the auth endpoints: some requests it accepted are now refused, and some refusals change status. The remedies are operational (restart an in-flight sign-in, correct an SSO provider's issuer), 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 (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
16+
17+
**BREAKING** (the auth endpoints refuse some requests 1.7.3 accepted, and some refusals change status), shipped as `patch` under ruling A: it changes nothing a metadata author can write, and a non-protocol breaking change ships `patch` while keeping this BREAKING banner and its ADR-0087 disposition.
18+
19+
- **FROM:** a Magic Link, OAuth or social sign-in, SAML or account-linking flow started before the upgrade could complete after it.
20+
**TO:** it fails, and the user starts it again. 1.7.7 stores Magic Link and OAuth-state verification rows under new identifier prefixes and keys the OAuth state cookie per purpose, so a link or a pending identity-provider round-trip minted by 1.7.3 is not found: a Magic Link answers `?error=INVALID_TOKEN`, an OAuth callback answers a state mismatch.
21+
- **FROM:** an OIDC SSO provider whose configured issuer differed from its discovery document's `issuer`, for example only by a trailing slash, signed users in.
22+
**TO:** the provider is refused with `issuer_mismatch`. 1.7.12 also refuses an OIDC ID token that carries no `exp` or `iat`.
23+
- **FROM:** `POST /api/v1/auth/oauth2/token` with a PKCE `code_verifier` that does not match the challenge answered `401`.
24+
**TO:** it answers `400` with `error: 'invalid_grant'`.
25+
- **FROM:** `GET /api/v1/auth/get-session` answered `401 UNAUTHENTICATED` ("Sign in first") when the session store could not be read, the same answer a signed-out browser gets, and an in-process `api.getSession` resolved `null`.
26+
**TO:** it answers `500` with `code: 'FAILED_TO_GET_SESSION'` and keeps the session cookie, and the in-process read rejects. Callers that treat a failed read as anonymous on purpose (the request-context resolution in `@objectstack/core` and `@objectstack/runtime`) still do.
27+
- **FROM:** `POST /api/v1/auth/sign-in/email` with a password longer than the configured maximum answered as an ordinary failed sign-in.
28+
**TO:** it answers `400` with `code: 'PASSWORD_TOO_LONG'` before any account is looked up. It still counts as a failed attempt for the account lockout.
29+
- **FROM:** a DPoP proof sent to the token endpoint was checked against the URL of the request as it reached the server.
30+
**TO:** it is checked against the configured public base URL plus the request path. Behind a proxy that rewrites the host, a proof bound to the public URL is now accepted and a proof bound to the internal URL is refused with `invalid_dpop_proof`.
31+
- **FROM:** `GET /api/v1/auth/get-session` with a cookie for a session that no longer exists answered `401` and left the cookie in place.
32+
**TO:** the same `401` also clears the stale session cookies.
33+
- **New route:** `POST /api/v1/auth/oauth2/client/rotate-keys` (`private_key_jwt` key rotation) is now published, and refused with `403 access_denied` because this package does not enable client key rotation.
34+
- **Unchanged:** the package's exports and configuration; sign-up, sign-in and session cookies (`HttpOnly; SameSite=Lax; Path=/`, no `Secure` on plain HTTP); the active organization stamped on a new session; implicit account linking and its unlink records; single-use reservations (a replayed DPoP proof or client assertion is still refused); and where sessions and verification values are stored with or without a host `secondaryStorage`.
35+
- **`@objectstack/spec` and `@objectstack/cli` (patch, text only, not breaking):** the TSDoc on the organization schema's optional `updatedAt` now records that its better-auth measurement still holds on 1.7.12, and the `os migrate account-issuer` description no longer names a better-auth version. No schema, type, flag or behaviour changes in either package.
36+
- **`@objectstack/service-datasource` (patch, text only, not breaking):** the `resolveSqliteDriver` docs now describe the error better-sqlite3 13 actually throws for an addon that was never built: its loader's `require()` of the absent `build/Release/better_sqlite3.node`, which Node answers with `MODULE_NOT_FOUND`. The docs used to name a helper package's message that 13.x no longer produces. Development still steps down to wasm SQLite on that error, as before. No code changes.
37+
- **What to do as an operator:** tell users that a Magic Link, social, SSO or account-linking sign-in begun before the upgrade must be started again after it. Check each OIDC SSO provider's configured issuer against its discovery document and set it to the exact `issuer` string the document publishes. An OAuth client that branched on `401` for a PKCE failure should branch on `400 invalid_grant`. A browser client that read every non-`200` `/get-session` answer as "signed out" should treat a `500` as "try again".

‎packages/cli/src/commands/migrate/account-issuer.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export default class MigrateAccountIssuer extends Command {
6868
static override description =
6969
'Pre-flight the retirement of sys_account.issuer: report every (provider_id, account_id) ' +
7070
'key held by more than one row — the class that is legal under the retired (issuer, account_id) key ' +
71-
'and is ONE account under the key better-auth 1.7.3 restored. Read-only; exits non-zero when the ' +
71+
'and is ONE account under the (provider_id, account_id) key better-auth restored. Read-only; exits non-zero when the ' +
7272
'drop must not proceed.';
7373

7474
static override examples = [

‎packages/plugins/plugin-auth/package.json‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,18 @@
3131
"gen:test-typecheck-debt": "tsx ../../../scripts/check-test-typecheck.mts --update --package packages/plugins/plugin-auth --project tsconfig.test.json"
3232
},
3333
"dependencies": {
34-
"@better-auth/core": "1.7.3",
35-
"@better-auth/oauth-provider": "1.7.3",
36-
"@better-auth/scim": "1.7.3",
37-
"@better-auth/sso": "1.7.3",
34+
"@better-auth/core": "1.7.12",
35+
"@better-auth/oauth-provider": "1.7.12",
36+
"@better-auth/scim": "1.7.12",
37+
"@better-auth/sso": "1.7.12",
3838
"@noble/hashes": "^2.4.0",
3939
"@objectstack/core": "workspace:*",
4040
"@objectstack/platform-objects": "workspace:*",
4141
"@objectstack/rest": "workspace:*",
4242
"@objectstack/service-messaging": "workspace:*",
4343
"@objectstack/spec": "workspace:*",
4444
"@objectstack/types": "workspace:*",
45-
"better-auth": "1.7.3",
45+
"better-auth": "1.7.12",
4646
"jose": "^6.2.12"
4747
},
4848
"devDependencies": {

‎packages/plugins/plugin-auth/src/account-invitation-marker.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
* is what creates the row's credential on first set. But better-auth hands the
1212
* resulting link only to `sendResetPassword({ user, url, token }, ctx.request)`;
1313
* `requestPasswordReset` itself answers `{ status, message }` and nothing else
14-
* (installed better-auth 1.7.3, `dist/api/routes/password.mjs`). So the import
14+
* (better-auth 1.7.12, `dist/api/routes/password.mjs`, re-measured 2026-10-21
15+
* for #25096). So the import
1516
* never holds the link, and the template choice can only be made inside
1617
* `sendResetPassword` (`auth-manager.ts`). Without a marker that callback
1718
* cannot tell an administrator's invitation from a user who forgot their
@@ -65,8 +66,8 @@
6566
*
6667
* better-auth runs `sendResetPassword` through `runInBackgroundOrAwait`, which
6768
* CATCHES and logs whatever the send throws, and `requestPasswordReset` still
68-
* answers `{ status: true }` (installed 1.7.3, `dist/context/create-context.mjs`
69-
* and `dist/api/routes/password.mjs`). That is right for the public door — the
69+
* answers `{ status: true }` (better-auth 1.7.12, `dist/context/create-context.mjs`
70+
* and `dist/api/routes/password.mjs`, re-measured 2026-10-21 for #25096). That is right for the public door — the
7071
* answer must not say whether an address exists or whether its mail went out —
7172
* and it means a throw inside the send can never reach the import. No
7273
* better-auth option makes the call rethrow: `advanced.backgroundTasks.handler`

‎packages/plugins/plugin-auth/src/admin-import-users-invite-delivery-failure.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
* The import invites an email row by asking better-auth for a password reset,
77
* and better-auth runs `sendResetPassword` through `runInBackgroundOrAwait`,
88
* which catches and logs whatever the send throws and still answers
9-
* `{ status: true }` (installed 1.7.3, `dist/context/create-context.mjs`). So a
9+
* `{ status: true }` (better-auth 1.7.12, `dist/context/create-context.mjs`,
10+
* re-measured 2026-10-21 for #25096). So a
1011
* throw inside the send can never reach the import. `sendResetPassword` now
1112
* settles a delivery receipt the import's own request carries
1213
* (`account-invitation-marker.ts`), and the import reads it after the request

‎packages/plugins/plugin-auth/src/auth-email-locale.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -867,8 +867,8 @@ describe("#14641 — an invitation reads the INVITEE's own sys_user.locale", ()
867867
* recipient-keyed engine, not by asserting it about this send alone.
868868
*
869869
* Two branches, because a magic link is BOTH a sign-in for an existing account
870-
* and a sign-up for a new address (re-measured 2026-09-11 in the installed
871-
* better-auth 1.7.3, unchanged from 1.7.2: the `/sign-in/magic-link` handler
870+
* and a sign-up for a new address (re-measured 2026-10-21 for #25096 on
871+
* better-auth 1.7.12, unchanged since 1.7.2: the `/sign-in/magic-link` handler
872872
* takes `email` straight off the body, stores a verification value and calls
873873
* `sendMagicLink` — no user lookup on that path at all — while
874874
* `/magic-link/verify` reads `findUserByEmail` and, finding nobody, calls
@@ -1003,8 +1003,8 @@ describe("#15106 — the magic link reads the recipient's own sys_user.locale",
10031003
});
10041004

10051005
it('matches the address better-auth itself will resolve the link with — lowercased', async () => {
1006-
// ⚠️ Measured against the installed better-auth 1.7.3 (2026-09-11), not
1007-
// assumed, and unchanged from the 1.7.2 reading this replaces:
1006+
// ⚠️ Measured against better-auth 1.7.12 (2026-10-21, #25096), not
1007+
// assumed, and unchanged since the 1.7.2 reading:
10081008
// `signInMagicLinkBodySchema` declares `email: z.email()` with no case
10091009
// transform, so a typed `Ada@Example.com` arrives here verbatim, while
10101010
// `internalAdapter.findUserByEmail` — what `/magic-link/verify` resolves

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1799,8 +1799,9 @@ export class AuthManager {
17991799
// The deployment's product name, from the one authority every auth
18001800
// email already reads (`getAppName()`: the `branding.workspace_name`
18011801
// override, else the configured `appName`, else 'ObjectStack').
1802-
// better-auth 1.7.3 reads this key once, into its context's `appName`
1803-
// (unset ⇒ "Better Auth"), and reads that in exactly two places: the
1802+
// better-auth 1.7.12 (re-read 2026-10-21 for #25096) reads this key once,
1803+
// into its context's `appName` (unset ⇒ "Better Auth"), and reads that
1804+
// in exactly two places: the
18041805
// issuer and label prefix of the otpauth URI that `/two-factor/enable`
18051806
// and `/two-factor/get-totp-uri` answer. No cookie name derives from it
18061807
// (the prefix is the literal "better-auth") and nothing stores it, so an
@@ -2058,8 +2059,8 @@ export class AuthManager {
20582059
);
20592060
}
20602061
// The lifetime the token in `url` really has: better-auth mints it
2061-
// with `resetPasswordTokenExpiresIn || 3600` seconds (installed
2062-
// 1.7.3 `routes/password.mjs`) — `||`, so a configured `0` is its
2062+
// with `resetPasswordTokenExpiresIn || 3600` seconds (better-auth
2063+
// 1.7.12 `routes/password.mjs`, re-measured 2026-10-21 for #25096) — `||`, so a configured `0` is its
20632064
// default too, and the mail must not say "0 minutes" for it.
20642065
const ttlSec = this.config.emailAndPassword?.resetPasswordTokenExpiresIn || 60 * 60;
20652066
// [#23343] An account an administrator created (the identity
@@ -2081,7 +2082,8 @@ export class AuthManager {
20812082
// commit 35e94c96b — the ladder's stored rung. It matters most HERE: an
20822083
// admin-initiated send (`admin-import-users.ts` calls
20832084
// `requestPasswordReset` server-side) reaches this callback with NO
2084-
// request at all (measured on 1.7.3, #23343), so without this rung
2085+
// request at all (measured on 1.7.3, #23343; unchanged on 1.7.12,
2086+
// re-read for #25096), so without this rung
20852087
// the user's mail carries only the deployment default. Best-effort;
20862088
// `undefined` leaves the #14319 ladder exactly as it was.
20872089
const storedLocale = await this.storedRecipientLocale({ id: user.id });
@@ -4723,8 +4725,8 @@ export class AuthManager {
47234725
//
47244726
// Handing better-auth no `database` makes it build its own in-memory store
47254727
// in `getBaseAdapter`, and that store is keyed by the schema KEY while
4726-
// every read resolves by `modelName`. Re-measured 2026-09-11 on the
4727-
// installed better-auth 1.7.3, unchanged from the 1.7.2 reading it replaces
4728+
// every read resolves by `modelName`. Re-measured 2026-10-21 for #25096 on
4729+
// better-auth 1.7.12, unchanged since the 1.7.2 reading
47284730
// (`db/adapter-base.mjs` builds the store by schema KEY;
47294731
// `@better-auth/memory-adapter` throws on the lookup, naming the model):
47304732
//
@@ -9698,7 +9700,8 @@ export class AuthManager {
96989700
* (plus `id`), or `undefined` when the identifier names no account.
96999701
*
97009702
* It asks exactly what better-auth's `/sign-in/email` credential check asks
9701-
* (1.7.3: `internalAdapter.findUserByEmail(email.toLowerCase())`, an
9703+
* (1.7.12, re-read 2026-10-21 for #25096:
9704+
* `internalAdapter.findUserByEmail(email.toLowerCase())`, an
97029705
* equality match of the `email` column against the lower-cased address,
97039706
* which is also the spelling `internalAdapter.createUser` stores), so the
97049707
* gate, the counter, the ledger and the credential check name one account

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,13 @@ export const BETTER_AUTH_MOUNTED_SURFACE: readonly string[] = [
382382
'POST /api/v1/auth/get-session',
383383
'POST /api/v1/auth/link-social',
384384
'POST /api/v1/auth/oauth2/authorize',
385+
// `private_key_jwt` client key rotation. Added to `@better-auth/oauth-provider`
386+
// after 1.7.3 and published here since the 1.7.12 lift (#25096). The handler
387+
// refuses before it reads anything unless the plugin is given
388+
// `allowClientKeyRotation`, and `auth-manager.ts` does not set it, so every
389+
// call answers 403 `access_denied` ("Client key rotation is disabled"),
390+
// measured on this stack for #25096. Turning it on is its own decision.
391+
'POST /api/v1/auth/oauth2/client/rotate-keys',
385392
'POST /api/v1/auth/oauth2/client/rotate-secret',
386393
'POST /api/v1/auth/oauth2/consent',
387394
'POST /api/v1/auth/oauth2/continue',

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -953,12 +953,12 @@ export const buildOidcProviderPluginSchema = buildOauthProviderPluginSchema;
953953

954954
// NOTE: there is intentionally no scim mapping constant here, and no
955955
// `buildScimPluginSchema()`. `@better-auth/scim` hardcodes its models and exposes
956-
// NO `schema` option — still true of the installed stable `@better-auth/scim@1.7.3`
957-
// (re-read 2026-09-11: `SCIMOptions` declares no `schema` / `modelName` / `fields`
956+
// NO `schema` option — still true of the installed stable `@better-auth/scim@1.7.12`
957+
// (re-read 2026-10-21 for #25096: `SCIMOptions` declares no `schema` / `modelName` / `fields`
958958
// member at all — its six members are still connections, authentication,
959959
// managedConnections, identity, projection, compatibility; measured 2026-08-19 on
960960
// the rc, re-measured 2026-08-27 on stable 1.7.1, again 2026-08-31 on 1.7.2 for
961-
// #13940, and again on 1.7.3 with the family lift), so
961+
// #13940, again on 1.7.3 with the family lift, and on 1.7.12 with #25096), so
962962
// there is nowhere to hand one. This is no longer true
963963
// of `@better-auth/sso@1.7.1`, which now accepts one (#8224) — for scim, and for
964964
// scim alone, the ADAPTER layer is the only available route.

‎packages/plugins/plugin-auth/src/implicit-account-linking.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
*
4343
* ## Why the enforcement point is `user.validateUserInfo`
4444
*
45-
* Measured against the installed better-auth `1.7.3`
45+
* Measured against better-auth `1.7.12` (re-measured 2026-10-21 for #25096)
4646
* (`dist/oauth2/link-account.mjs`, `handleOAuthUserInfo`):
4747
*
4848
* - The library's own gate refuses an implicit link when
@@ -63,7 +63,8 @@
6363
* implicitly (`/callback/:id`, `/sign-in/social` with an id token,
6464
* one-tap, oauth-proxy, and the SSO plugin, which share the function).
6565
* - The EXPLICIT link (`/link-social` → `/callback/:id` with `link` in the
66-
* OAuth state) passes the same `action: 'link-account'`. It is told apart
66+
* OAuth state) passes the same `action: 'link-account'` (1.7.12 routes it
67+
* through `linkOAuthAccount`, in the same file). It is told apart
6768
* by the OAuth state the callback parsed (`getOAuthState().link`), which
6869
* the server writes from the session at `/link-social`; `generateState`
6970
* places `link` AFTER the client-supplied `additionalData`, so a client

0 commit comments

Comments
 (0)