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
55 changes: 55 additions & 0 deletions .changeset/better-auth-family-stable-1-7.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/plugin-auth": patch
"@objectstack/platform-objects": patch
"@objectstack/client": patch
---

deps(auth): the better-auth family moves off the `1.7.0-rc.2` prerelease onto stable `^1.7.1` (#3002)

`@objectstack/plugin-auth` shipped with **exact pins on a release candidate** —
`better-auth`, `@better-auth/core`, `@better-auth/oauth-provider` and
`@better-auth/sso` all at `1.7.0-rc.2`. That pin was never housekeeping debt: it was
the remediation for **GHSA-p2fr-6hmx-4528** (`@better-auth/oauth-provider`) and
**GHSA-j8v8-g9cx-5qf4** (`@better-auth/scim`, high — account/provider takeover), both
patched only in `>=1.7.0-beta.4`, so there was no stable line to move to. Upstream has
now shipped one: `npm view <pkg> dist-tags` reports `latest: 1.7.1` for every family
member. The declarations become `^1.7.1`, which is what a downstream
`npx create-objectstack` install now resolves.

**`@better-auth/scim` deliberately stays at `1.7.0-rc.1`.** Measured against the
published stable tarball rather than assumed: `@better-auth/scim@1.7.1` ships the rc.2
**rewrite** — no `scimProvider` model, no generate-token endpoint, and six replacement
models (`scimUser`, `scimGroup`, `scimGroupMember`, `scimSubject`,
`scimConnectionBinding`, `scimIdentityTombstone`). Adopting it is a feature migration
(ADR-0071, tracked separately), not a version bump. The hold stays security-clean: rc.1
is above the advisory's fix floor, `pnpm audit --audit-level=high` is green, and rc.1's
peer ranges accept the stable 1.7.1 core the rest of the family resolves to.

**Three pieces of upstream drift are absorbed here, and one of them was a live
sign-in outage waiting to happen.**

`1.7.0-rc.2` renamed the account model's `accountId` field to `providerAccountId`;
**stable 1.7.0/1.7.1 renamed it back to `accountId`**, keeping the new required
`issuer`. Carrying the rc.2 spelling into the stable line left the field unmapped, so
better-auth's adapter asked for a column named `accountId` and **every sign-up answered
500** — `Unknown field 'accountId' on object 'sys_account'`. The `account_id` column
itself never changed and no data moves; only the camelCase key does. The same rename
reaches `@objectstack/client`: `auth.accounts.list()` (better-auth's `/list-accounts`)
returns `accountId`, and its declared response type said `providerAccountId`. If you
read that field off the client's typed response, rename it.

`@better-auth/oauth-provider` 1.7.1's client model writes three fields the platform
object did not answer for. `applicationType` is the OIDC spelling of what rc.2 called
`type`, so it maps onto the **existing** `type` column and no data moves;
`clientDiscoveryId` and `clientCredentialsScopes` are genuinely new and are now
declared on `sys_oauth_application` as `client_discovery_id` and
`client_credentials_scopes`. Without them, dynamic client registration
(`POST /oauth2/register`) fails at the driver.

Two endpoints are newly mounted by the auth catch-all and are now ledgered:
`POST /oauth2/end-session` and `POST /oauth2/end-session/confirm` — the POST form of
OIDC RP-initiated logout, whose `GET` counterpart was already published.

**Nothing here needs an action on upgrade.** The new columns are additive and optional,
and the field rename is internal to how the plugin talks to better-auth — with the one
exception of the `@objectstack/client` response type named above.
17 changes: 13 additions & 4 deletions content/docs/permissions/authentication.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1048,17 +1048,26 @@ The plugin bridges this gap using better-auth's official **`modelName` / `fields
// Declared in the betterAuth() config via AUTH_*_CONFIG constants:
user: { modelName: 'sys_user', fields: { emailVerified: 'email_verified', … } },
session: { modelName: 'sys_session', fields: { userId: 'user_id', expiresAt: 'expires_at', … } },
account: { modelName: 'sys_account', fields: { providerId: 'provider_id', issuer: 'issuer', providerAccountId: 'account_id', … } },
account: { modelName: 'sys_account', fields: { providerId: 'provider_id', issuer: 'issuer', accountId: 'account_id', … } },
verification: { modelName: 'sys_verification', fields: { expiresAt: 'expires_at', … } },
```

better-auth 1.7 identifies an account by `(issuer, providerAccountId)` — `providerAccountId` is
the field formerly called `accountId` (same `account_id` column) and `issuer` names the authority
that vouched for it: an OIDC `iss` for federated logins, or a synthetic `local:credential` /
better-auth 1.7 identifies an account by `(issuer, accountId)`. `issuer` names the authority
that vouched for the id: an OIDC `iss` for federated logins, or a synthetic `local:credential` /
`local:oauth:<providerId>` for providers that carry none. Rows written before 1.7 have no issuer,
so the auth plugin stamps them once at boot; accounts from a federated IdP that is no longer
registered cannot be derived and are reported in the boot log instead of guessed.

<Callout type="warn">
The account id field's NAME changed twice inside the 1.7 line, so read it off the version you
run rather than off an older note. The `1.7.0-rc.2` pre-release renamed `accountId` →
`providerAccountId`; the stable `1.7.0` / `1.7.1` releases renamed it **back to `accountId`**,
keeping the new `issuer`. The `account_id` column is the same throughout — only the camelCase
key moved. On stable 1.7 the mapping above, and any `internalAdapter.createAccount({ …,
accountId })` call, must use `accountId`; the rc.2 spelling leaves the field unmapped and every
sign-up answers 500 `Unknown field 'accountId' on object 'sys_account'`.
</Callout>

The ObjectQL adapter factory (`createObjectQLAdapterFactory`) then uses better-auth's `createAdapterFactory`
which automatically transforms all data and where-clauses using these mappings — no manual
camelCase ↔ snake_case conversion is needed in the adapter.
Expand Down
2 changes: 1 addition & 1 deletion examples/app-showcase/src/security/demo-personas.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@
* distinct people, a submitter who is not an approver, an out-of-office
* delegation decided under the delegate's own identity — was stuck on it, and
* each rediscovered the same non-obvious cause: a password hash is not enough.
* better-auth 1.7 keys accounts on `(issuer, providerAccountId)`, so a
* better-auth 1.7 keys accounts on `(issuer, accountId)`, so a
* credential row whose `issuer` is not the local credential issuer is INVISIBLE
* to sign-in, which then fails `INVALID_EMAIL_OR_PASSWORD` behind a misleading
* "User not found" — pointing at the row, which is fine, instead of at the
Expand Down
12 changes: 9 additions & 3 deletions examples/app-showcase/src/security/seed-approval-demo.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,7 +92,13 @@ interface AuthContextLike {
userId: string;
providerId: string;
issuer: string;
providerAccountId: string;
/**
* The STABLE better-auth 1.7 spelling. `1.7.0-rc.2` briefly called this
* `providerAccountId` and stable 1.7.0 renamed it back (#3002) — under
* the rc.2 spelling the account row is written with no account id and
* the persona silently stays un-loginable.
*/
accountId: string;
password: string;
}) => Promise<unknown>;
};
Expand DownExpand Up@@ -176,7 +182,7 @@ async function assignPositions(
*
* ## Why this is read and not written
*
* better-auth 1.7 keys account identity on `(issuer, providerAccountId)`:
* better-auth 1.7 keys account identity on `(issuer, accountId)`:
* `findAccountByKey` looks a credential up under the issuer better-auth mints
* for itself, so a row carrying any other value — or none — is INVISIBLE and
* sign-in fails `INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn that
Expand DownExpand Up@@ -245,7 +251,7 @@ async function ensureCredentialAccount(
userId,
providerId: 'credential',
issuer,
providerAccountId: userId,
accountId: userId,
password: hashed,
});
ctx.logger?.info?.('[showcase] approval-demo persona is now loginable', { userId });
Expand Down
10 changes: 7 additions & 3 deletions packages/client/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2788,10 +2788,14 @@ export class ObjectStackClient {
return { accounts: accounts as Array<{
id: string;
providerId: string;
/** Authority that vouched for `providerAccountId` — an OIDC issuer, or `local:…`. */
/** Authority that vouched for `accountId` — an OIDC issuer, or `local:…`. */
issuer: string;
/** The user's id at the provider — better-auth 1.7 renamed this from `accountId`. */
providerAccountId: string;
/**
* The user's id at the provider. `1.7.0-rc.2` briefly published this
* as `providerAccountId`; stable 1.7 answers with `accountId` again
* (#3002), which is what this route returns today.
*/
accountId: string;
createdAt?: string;
updatedAt?: string;
}> };
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -1228,6 +1228,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
label: "Client ID",
help: "Public OAuth client identifier"
},
client_discovery_id: {
label: "Client Discovery ID",
help: "Opaque identifier the provider uses to look this client up on the discovery path, kept apart from the public `client_id`"
},
client_secret: {
label: "Client Secret",
help: "OAuth client secret — stored as a SHA-256 digest, never plaintext (`@better-auth/oauth-provider`'s `storeClientSecret`, which defaults to hashed whenever the jwt plugin is enabled; wired in plugin-auth's `AuthManager.buildPluginList()`, oidcProvider branch). Shown once at registration."
Expand DownExpand Up@@ -1272,6 +1276,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
label: "Allowed Scopes",
help: "JSON-serialized list of scopes the client may request"
},
client_credentials_scopes: {
label: "Client-Credentials Scopes",
help: "JSON-serialized list of scopes the client may request on the client_credentials grant, where there is no user to consent — kept apart from `scopes`, which governs user-delegated grants"
},
subject_type: {
label: "Subject Type",
help: "OIDC subject type (e.g. public, pairwise)"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -1228,6 +1228,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
label: "ID de cliente",
help: "Identificador público del cliente OAuth."
},
client_discovery_id: {
label: "Client Discovery ID",
help: "Opaque identifier the provider uses to look this client up on the discovery path, kept apart from the public `client_id`"
},
client_secret: {
label: "Secreto de cliente",
help: "Secreto del cliente OAuth (con hash/cifrado en reposo)."
Expand DownExpand Up@@ -1272,6 +1276,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
label: "Ámbitos permitidos",
help: "Lista serializada en JSON de ámbitos que el cliente puede solicitar."
},
client_credentials_scopes: {
label: "Client-Credentials Scopes",
help: "JSON-serialized list of scopes the client may request on the client_credentials grant, where there is no user to consent — kept apart from `scopes`, which governs user-delegated grants"
},
subject_type: {
label: "Tipo de sujeto",
help: "Tipo de sujeto OIDC (p. ej. public, pairwise)."
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -1228,6 +1228,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
label: "クライアント ID",
help: "公開 OAuth クライアント識別子"
},
client_discovery_id: {
label: "Client Discovery ID",
help: "Opaque identifier the provider uses to look this client up on the discovery path, kept apart from the public `client_id`"
},
client_secret: {
label: "クライアントシークレット",
help: "OAuth クライアントシークレット(保存時にハッシュ/暗号化済み)"
Expand DownExpand Up@@ -1272,6 +1276,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
label: "許可スコープ",
help: "クライアントがリクエスト可能なスコープの JSON シリアライズリスト"
},
client_credentials_scopes: {
label: "Client-Credentials Scopes",
help: "JSON-serialized list of scopes the client may request on the client_credentials grant, where there is no user to consent — kept apart from `scopes`, which governs user-delegated grants"
},
subject_type: {
label: "サブジェクトタイプ",
help: "OIDC サブジェクトタイプ(例: public、pairwise)"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -1228,6 +1228,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
label: "客户端 ID",
help: "公开的 OAuth 客户端标识"
},
client_discovery_id: {
label: "Client Discovery ID",
help: "Opaque identifier the provider uses to look this client up on the discovery path, kept apart from the public `client_id`"
},
client_secret: {
label: "客户端密钥",
help: "OAuth 客户端密钥(静态存储时会哈希/加密)"
Expand DownExpand Up@@ -1272,6 +1276,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
label: "允许的范围",
help: "客户端可申请的 scopes 的 JSON 序列化列表"
},
client_credentials_scopes: {
label: "Client-Credentials Scopes",
help: "JSON-serialized list of scopes the client may request on the client_credentials grant, where there is no user to consent — kept apart from `scopes`, which governs user-delegated grants"
},
subject_type: {
label: "主体类型",
help: "OIDC 主体类型(例如 public、pairwise)"
Expand Down
4 changes: 2 additions & 2 deletions packages/platform-objects/src/identity/sys-account.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,7 +35,7 @@ export const SysAccount = ObjectSchema.create({
// this, where `accountId` is the account ROW id (better-auth 1.7 narrowed
// the body from the old `{ providerId, accountId }` pair, and `accountId`
// no longer means the provider's id for the user — that field is now
// `providerAccountId`). The form is locked to the row's values so it acts
// `accountId`). The form is locked to the row's values so it acts
// as a one-click confirmation rather than a free-form edit.
//
// `link_social` is the self-service counterpart — a toolbar action
Expand DownExpand Up@@ -306,7 +306,7 @@ export const SysAccount = ObjectSchema.create({
indexes: [
{ fields: ['user_id'], unique: false },
{ fields: ['provider_id', 'account_id'], unique: true },
// better-auth 1.7 resolves accounts by (issuer, providerAccountId) and
// better-auth 1.7 resolves accounts by (issuer, accountId) and
// declares that pair unique on its own `account` table — mirror it here so
// the physical table enforces the same identity key the auth code assumes.
{ fields: ['issuer', 'account_id'], unique: true },
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -315,6 +315,15 @@ export const SysOauthApplication = ObjectSchema.create({
group: 'Credentials',
}),

// Added with the stable `@better-auth/oauth-provider` 1.7 line (#3002).
client_discovery_id: Field.text({
label: 'Client Discovery ID',
required: false,
maxLength: 255,
description: 'Opaque identifier the provider uses to look this client up on the discovery path, kept apart from the public `client_id`',
group: 'Credentials',
}),

client_secret: Field.text({
label: 'Client Secret',
required: false,
Expand DownExpand Up@@ -342,6 +351,11 @@ export const SysOauthApplication = ObjectSchema.create({
group: 'Credentials',
}),

// Upstream's `applicationType` (OIDC `application_type`) maps onto this
// column — `1.7.0-rc.2` called the field `type`, stable 1.7 renamed it
// (#3002). The column keeps its name and its data; the rename is absorbed
// by the `applicationType: 'type'` mapping in plugin-auth's
// `auth-schema-config.ts`.
type: Field.select(['web', 'native', 'user-agent-based', 'public'], {
label: 'Client Type',
required: false,
Expand DownExpand Up@@ -391,6 +405,18 @@ export const SysOauthApplication = ObjectSchema.create({
group: 'Credentials',
}),

// Added with the stable `@better-auth/oauth-provider` 1.7 line (#3002).
// The plugin writes these two through the adapter, so without the
// declarations a client registration answers 500 at the driver — the
// failure mode `oauth-provider-schema-parity.test.ts` exists to catch.
client_credentials_scopes: Field.textarea({
label: 'Client-Credentials Scopes',
required: false,
description:
'JSON-serialized list of scopes the client may request on the client_credentials grant, where there is no user to consent — kept apart from `scopes`, which governs user-delegated grants',
group: 'Credentials',
}),

subject_type: Field.text({
label: 'Subject Type',
required: false,
Expand Down
8 changes: 4 additions & 4 deletions packages/plugins/plugin-auth/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,17 +24,17 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@better-auth/core": "1.7.0-rc.2",
"@better-auth/oauth-provider": "1.7.0-rc.2",
"@better-auth/core": "^1.7.1",
"@better-auth/oauth-provider": "^1.7.1",
"@better-auth/scim": "1.7.0-rc.1",
"@better-auth/sso": "1.7.0-rc.2",
"@better-auth/sso": "^1.7.1",
"@noble/hashes": "^2.3.0",
"@objectstack/core": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/rest": "workspace:*",
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*",
"better-auth": "1.7.0-rc.2",
"better-auth": "^1.7.1",
"jose": "^6.2.8"
},
"devDependencies": {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { backfillAccountIssuer, oauthIssuerFor } from './backfill-account-issuer
/**
* Account-issuer parity gate.
*
* better-auth 1.7 keys every account on `(issuer, providerAccountId)`, and the
* better-auth 1.7 keys every account on `(issuer, accountId)`, and the
* issuer is the PROVIDER's to declare: `resolveOAuthAccountKey` takes
* `provider.accountIssuer` when there is one and synthesizes
* `local:oauth:<id>` only when there is not. A boot-time backfill that stamps a
Expand Down
12 changes: 8 additions & 4 deletions packages/plugins/plugin-auth/src/admin-user-endpoints.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,13 +62,17 @@ export interface AuthContextLike {
userId: string;
providerId: string;
/**
* better-auth 1.7 keys accounts on (issuer, providerAccountId) and
* requires both. A local password account carries the synthetic issuer
* better-auth 1.7 keys accounts on (issuer, accountId) and requires
* both. A local password account carries the synthetic issuer
* better-auth mints for itself, `local:credential` — write anything else
* and the row exists but no sign-in ever finds it.
*
* `accountId` is the STABLE 1.7 spelling. `1.7.0-rc.2` briefly called it
* `providerAccountId` and stable 1.7.0 renamed it back (#3002); the
* rc.2 spelling here would have created accounts with no account id.
*/
issuer: string;
providerAccountId: string;
accountId: string;
password: string;
}): Promise<unknown>;
};
Expand DownExpand Up@@ -579,7 +583,7 @@ export async function runAdminSetUserPassword(
userId,
providerId: 'credential',
issuer: CREDENTIAL_ISSUER,
providerAccountId: userId,
accountId: userId,
password: hashed,
});
}
Expand Down
7 changes: 4 additions & 3 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -311,10 +311,11 @@ describe('AuthManager', () => {
expect(capturedConfig.account.fields).toEqual(expect.objectContaining({
userId: 'user_id',
providerId: 'provider_id',
// 1.7 identity key: (issuer, providerAccountId), the latter renamed
// from `accountId` but still living in the `account_id` column.
// 1.7 identity key: (issuer, accountId). The id field kept the
// `account_id` column across the rc.2 `providerAccountId` detour and
// the stable 1.7 rename back (#3002).
issuer: 'issuer',
providerAccountId: 'account_id',
accountId: 'account_id',
accessToken: 'access_token',
refreshToken: 'refresh_token',
idToken: 'id_token',
Expand Down
Loading
Loading