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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/25096-better-auth-1-7-12.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/spec': patch
'@objectstack/cli': patch
'@objectstack/service-datasource': patch
---

fix(plugin-auth)!: lift the better-auth family from 1.7.3 to 1.7.12 for the security fixes published since

`@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.

Clause-②: no (narrowing)
Metadata-protocol: none

<!-- 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). -->

**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.

- **FROM:** a Magic Link, OAuth or social sign-in, SAML or account-linking flow started before the upgrade could complete after it.
**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.
- **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.
**TO:** the provider is refused with `issuer_mismatch`. 1.7.12 also refuses an OIDC ID token that carries no `exp` or `iat`.
- **FROM:** `POST /api/v1/auth/oauth2/token` with a PKCE `code_verifier` that does not match the challenge answered `401`.
**TO:** it answers `400` with `error: 'invalid_grant'`.
- **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`.
**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.
- **FROM:** `POST /api/v1/auth/sign-in/email` with a password longer than the configured maximum answered as an ordinary failed sign-in.
**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.
- **FROM:** a DPoP proof sent to the token endpoint was checked against the URL of the request as it reached the server.
**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`.
- **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.
**TO:** the same `401` also clears the stale session cookies.
- **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.
- **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`.
- **`@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.
- **`@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.
- **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".
2 changes: 1 addition & 1 deletion packages/cli/src/commands/migrate/account-issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export default class MigrateAccountIssuer extends Command {
static override description =
'Pre-flight the retirement of sys_account.issuer: report every (provider_id, account_id) ' +
'key held by more than one row — the class that is legal under the retired (issuer, account_id) key ' +
'and is ONE account under the key better-auth 1.7.3 restored. Read-only; exits non-zero when the ' +
'and is ONE account under the (provider_id, account_id) key better-auth restored. Read-only; exits non-zero when the ' +
'drop must not proceed.';

static override examples = [
Expand Down
10 changes: 5 additions & 5 deletions packages/plugins/plugin-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,18 @@
"gen:test-typecheck-debt": "tsx ../../../scripts/check-test-typecheck.mts --update --package packages/plugins/plugin-auth --project tsconfig.test.json"
},
"dependencies": {
"@better-auth/core": "1.7.3",
"@better-auth/oauth-provider": "1.7.3",
"@better-auth/scim": "1.7.3",
"@better-auth/sso": "1.7.3",
"@better-auth/core": "1.7.12",
"@better-auth/oauth-provider": "1.7.12",
"@better-auth/scim": "1.7.12",
"@better-auth/sso": "1.7.12",
"@noble/hashes": "^2.4.0",
"@objectstack/core": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/rest": "workspace:*",
"@objectstack/service-messaging": "workspace:*",
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*",
"better-auth": "1.7.3",
"better-auth": "1.7.12",
"jose": "^6.2.12"
},
"devDependencies": {
Expand Down
7 changes: 4 additions & 3 deletions packages/plugins/plugin-auth/src/account-invitation-marker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
* is what creates the row's credential on first set. But better-auth hands the
* resulting link only to `sendResetPassword({ user, url, token }, ctx.request)`;
* `requestPasswordReset` itself answers `{ status, message }` and nothing else
* (installed better-auth 1.7.3, `dist/api/routes/password.mjs`). So the import
* (better-auth 1.7.12, `dist/api/routes/password.mjs`, re-measured 2026-10-21
* for #25096). So the import
* never holds the link, and the template choice can only be made inside
* `sendResetPassword` (`auth-manager.ts`). Without a marker that callback
* cannot tell an administrator's invitation from a user who forgot their
Expand Down Expand Up @@ -65,8 +66,8 @@
*
* better-auth runs `sendResetPassword` through `runInBackgroundOrAwait`, which
* CATCHES and logs whatever the send throws, and `requestPasswordReset` still
* answers `{ status: true }` (installed 1.7.3, `dist/context/create-context.mjs`
* and `dist/api/routes/password.mjs`). That is right for the public door — the
* answers `{ status: true }` (better-auth 1.7.12, `dist/context/create-context.mjs`
* and `dist/api/routes/password.mjs`, re-measured 2026-10-21 for #25096). That is right for the public door — the
* answer must not say whether an address exists or whether its mail went out —
* and it means a throw inside the send can never reach the import. No
* better-auth option makes the call rethrow: `advanced.backgroundTasks.handler`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
* The import invites an email row by asking better-auth for a password reset,
* and better-auth runs `sendResetPassword` through `runInBackgroundOrAwait`,
* which catches and logs whatever the send throws and still answers
* `{ status: true }` (installed 1.7.3, `dist/context/create-context.mjs`). So a
* `{ status: true }` (better-auth 1.7.12, `dist/context/create-context.mjs`,
* re-measured 2026-10-21 for #25096). So a
* throw inside the send can never reach the import. `sendResetPassword` now
* settles a delivery receipt the import's own request carries
* (`account-invitation-marker.ts`), and the import reads it after the request
Expand Down
8 changes: 4 additions & 4 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -867,8 +867,8 @@ describe("#14641 — an invitation reads the INVITEE's own sys_user.locale", ()
* recipient-keyed engine, not by asserting it about this send alone.
*
* Two branches, because a magic link is BOTH a sign-in for an existing account
* and a sign-up for a new address (re-measured 2026-09-11 in the installed
* better-auth 1.7.3, unchanged from 1.7.2: the `/sign-in/magic-link` handler
* and a sign-up for a new address (re-measured 2026-10-21 for #25096 on
* better-auth 1.7.12, unchanged since 1.7.2: the `/sign-in/magic-link` handler
* takes `email` straight off the body, stores a verification value and calls
* `sendMagicLink` — no user lookup on that path at all — while
* `/magic-link/verify` reads `findUserByEmail` and, finding nobody, calls
Expand Down Expand Up @@ -1003,8 +1003,8 @@ describe("#15106 — the magic link reads the recipient's own sys_user.locale",
});

it('matches the address better-auth itself will resolve the link with — lowercased', async () => {
// ⚠️ Measured against the installed better-auth 1.7.3 (2026-09-11), not
// assumed, and unchanged from the 1.7.2 reading this replaces:
// ⚠️ Measured against better-auth 1.7.12 (2026-10-21, #25096), not
// assumed, and unchanged since the 1.7.2 reading:
// `signInMagicLinkBodySchema` declares `email: z.email()` with no case
// transform, so a typed `Ada@Example.com` arrives here verbatim, while
// `internalAdapter.findUserByEmail` — what `/magic-link/verify` resolves
Expand Down
19 changes: 11 additions & 8 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1799,8 +1799,9 @@ export class AuthManager {
// The deployment's product name, from the one authority every auth
// email already reads (`getAppName()`: the `branding.workspace_name`
// override, else the configured `appName`, else 'ObjectStack').
// better-auth 1.7.3 reads this key once, into its context's `appName`
// (unset ⇒ "Better Auth"), and reads that in exactly two places: the
// better-auth 1.7.12 (re-read 2026-10-21 for #25096) reads this key once,
// into its context's `appName` (unset ⇒ "Better Auth"), and reads that
// in exactly two places: the
// issuer and label prefix of the otpauth URI that `/two-factor/enable`
// and `/two-factor/get-totp-uri` answer. No cookie name derives from it
// (the prefix is the literal "better-auth") and nothing stores it, so an
Expand Down Expand Up @@ -2058,8 +2059,8 @@ export class AuthManager {
);
}
// The lifetime the token in `url` really has: better-auth mints it
// with `resetPasswordTokenExpiresIn || 3600` seconds (installed
// 1.7.3 `routes/password.mjs`) — `||`, so a configured `0` is its
// with `resetPasswordTokenExpiresIn || 3600` seconds (better-auth
// 1.7.12 `routes/password.mjs`, re-measured 2026-10-21 for #25096) — `||`, so a configured `0` is its
// default too, and the mail must not say "0 minutes" for it.
const ttlSec = this.config.emailAndPassword?.resetPasswordTokenExpiresIn || 60 * 60;
// [#23343] An account an administrator created (the identity
Expand All @@ -2081,7 +2082,8 @@ export class AuthManager {
// commit 35e94c96b — the ladder's stored rung. It matters most HERE: an
// admin-initiated send (`admin-import-users.ts` calls
// `requestPasswordReset` server-side) reaches this callback with NO
// request at all (measured on 1.7.3, #23343), so without this rung
// request at all (measured on 1.7.3, #23343; unchanged on 1.7.12,
// re-read for #25096), so without this rung
// the user's mail carries only the deployment default. Best-effort;
// `undefined` leaves the #14319 ladder exactly as it was.
const storedLocale = await this.storedRecipientLocale({ id: user.id });
Expand Down Expand Up @@ -4723,8 +4725,8 @@ export class AuthManager {
//
// Handing better-auth no `database` makes it build its own in-memory store
// in `getBaseAdapter`, and that store is keyed by the schema KEY while
// every read resolves by `modelName`. Re-measured 2026-09-11 on the
// installed better-auth 1.7.3, unchanged from the 1.7.2 reading it replaces
// every read resolves by `modelName`. Re-measured 2026-10-21 for #25096 on
// better-auth 1.7.12, unchanged since the 1.7.2 reading
// (`db/adapter-base.mjs` builds the store by schema KEY;
// `@better-auth/memory-adapter` throws on the lookup, naming the model):
//
Expand Down Expand Up @@ -9698,7 +9700,8 @@ export class AuthManager {
* (plus `id`), or `undefined` when the identifier names no account.
*
* It asks exactly what better-auth's `/sign-in/email` credential check asks
* (1.7.3: `internalAdapter.findUserByEmail(email.toLowerCase())`, an
* (1.7.12, re-read 2026-10-21 for #25096:
* `internalAdapter.findUserByEmail(email.toLowerCase())`, an
* equality match of the `email` column against the lower-cased address,
* which is also the spelling `internalAdapter.createUser` stores), so the
* gate, the counter, the ledger and the credential check name one account
Expand Down
7 changes: 7 additions & 0 deletions packages/plugins/plugin-auth/src/auth-route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,13 @@ export const BETTER_AUTH_MOUNTED_SURFACE: readonly string[] = [
'POST /api/v1/auth/get-session',
'POST /api/v1/auth/link-social',
'POST /api/v1/auth/oauth2/authorize',
// `private_key_jwt` client key rotation. Added to `@better-auth/oauth-provider`
// after 1.7.3 and published here since the 1.7.12 lift (#25096). The handler
// refuses before it reads anything unless the plugin is given
// `allowClientKeyRotation`, and `auth-manager.ts` does not set it, so every
// call answers 403 `access_denied` ("Client key rotation is disabled"),
// measured on this stack for #25096. Turning it on is its own decision.
'POST /api/v1/auth/oauth2/client/rotate-keys',
'POST /api/v1/auth/oauth2/client/rotate-secret',
'POST /api/v1/auth/oauth2/consent',
'POST /api/v1/auth/oauth2/continue',
Expand Down
6 changes: 3 additions & 3 deletions packages/plugins/plugin-auth/src/auth-schema-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -953,12 +953,12 @@ export const buildOidcProviderPluginSchema = buildOauthProviderPluginSchema;

// NOTE: there is intentionally no scim mapping constant here, and no
// `buildScimPluginSchema()`. `@better-auth/scim` hardcodes its models and exposes
// NO `schema` option — still true of the installed stable `@better-auth/scim@1.7.3`
// (re-read 2026-09-11: `SCIMOptions` declares no `schema` / `modelName` / `fields`
// NO `schema` option — still true of the installed stable `@better-auth/scim@1.7.12`
// (re-read 2026-10-21 for #25096: `SCIMOptions` declares no `schema` / `modelName` / `fields`
// member at all — its six members are still connections, authentication,
// managedConnections, identity, projection, compatibility; measured 2026-08-19 on
// the rc, re-measured 2026-08-27 on stable 1.7.1, again 2026-08-31 on 1.7.2 for
// #13940, and again on 1.7.3 with the family lift), so
// #13940, again on 1.7.3 with the family lift, and on 1.7.12 with #25096), so
// there is nowhere to hand one. This is no longer true
// of `@better-auth/sso@1.7.1`, which now accepts one (#8224) — for scim, and for
// scim alone, the ADAPTER layer is the only available route.
Expand Down
5 changes: 3 additions & 2 deletions packages/plugins/plugin-auth/src/implicit-account-linking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
*
* ## Why the enforcement point is `user.validateUserInfo`
*
* Measured against the installed better-auth `1.7.3`
* Measured against better-auth `1.7.12` (re-measured 2026-10-21 for #25096)
* (`dist/oauth2/link-account.mjs`, `handleOAuthUserInfo`):
*
* - The library's own gate refuses an implicit link when
Expand All @@ -63,7 +63,8 @@
* implicitly (`/callback/:id`, `/sign-in/social` with an id token,
* one-tap, oauth-proxy, and the SSO plugin, which share the function).
* - The EXPLICIT link (`/link-social` → `/callback/:id` with `link` in the
* OAuth state) passes the same `action: 'link-account'`. It is told apart
* OAuth state) passes the same `action: 'link-account'` (1.7.12 routes it
* through `linkOAuthAccount`, in the same file). It is told apart
* by the OAuth state the callback parsed (`getOAuthState().link`), which
* the server writes from the session at `/link-social`; `generateState`
* places `link` AFTER the client-supplied `additionalData`, so a client
Expand Down
3 changes: 2 additions & 1 deletion packages/plugins/plugin-auth/src/in-process-redispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
* renewing — there is no cookie to fall behind.
*
* better-auth reads the flag from the query on both kinds of re-dispatch
* (1.7.3, measured by `in-process-session-renewal.pin.test.ts`): the
* (1.7.12, re-measured 2026-10-21 for #25096 by
* `in-process-session-renewal.pin.test.ts`): the
* `/get-session` route declares it (`getSessionQuerySchema`, coerced), and
* `getSessionFromCtx` — which `sessionMiddleware` and this plugin's own
* before-hooks call — spreads the route's `ctx.query` into the read it makes,
Expand Down
Loading
Loading