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
58 changes: 58 additions & 0 deletions .changeset/better-auth-1-7-0-rc-2-and-prod-dep-batch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
"@objectstack/plugin-auth": major
"@objectstack/platform-objects": major
"@objectstack/client": major
"@objectstack/cli": patch
"@objectstack/create-objectstack": patch
"@objectstack/plugin-hono-server": patch
"@objectstack/plugin-pinyin-search": patch
"@objectstack/hono": patch
---

chore(deps)!: better-auth 1.7.0-rc.2 (account identity restructuring) + the
production-dependency batch from #3517

**better-auth 1.7.0-rc.1 → 1.7.0-rc.2** across the family (`better-auth`,
`@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/sso`, and the
adapter/telemetry overrides). `@better-auth/scim` deliberately stays on
1.7.0-rc.1 — rc.2 replaces its whole model (code-defined connections; the
`scimProvider` model and the generate-token endpoint are gone), which is a
feature migration, not a version bump. Its peer range accepts rc.2 core, and the
advisory that forced the original pin (GHSA-j8v8-g9cx-5qf4) is still fixed.

**BREAKING — account identity.** better-auth renamed `account.accountId` to
`account.providerAccountId` and added a REQUIRED `account.issuer`; sign-in now
resolves accounts by `(issuer, providerAccountId)`.

- FROM `fields: { accountId: 'account_id' }` → TO
`fields: { issuer: 'issuer', providerAccountId: 'account_id' }`. The provider
account id keeps its `account_id` column — only the better-auth-side name
moved — and `sys_account` gains an `issuer` column.
- FROM `internalAdapter.createAccount({ providerId, accountId, … })` → TO
`createAccount({ providerId, issuer, providerAccountId, … })`. A local
password account carries the issuer better-auth mints for itself,
`local:credential`.
- FROM `client.auth.accounts.unlink({ providerId, accountId })` → TO
`unlink({ accountId })`, where `accountId` is now the account ROW id (the `id`
from `accounts.list()`), matching better-auth's narrowed body.
`accounts.list()` returns `issuer` + `providerAccountId` in place of
`accountId`.

**Existing deployments:** rows written before 1.7 have no issuer and are
invisible to sign-in until stamped. The auth plugin now runs an idempotent
boot-time backfill that stamps what it can derive — `local:credential` for
password accounts, `local:oauth:<providerId>` for configured social providers,
and the registered IdP's real `iss` from `sys_sso_provider` for federated ones.
Accounts from a federated IdP that is no longer registered cannot be derived;
they are logged with their provider id and row count rather than guessed, and
those users cannot sign in through that provider until the row is stamped with
the IdP's issuer or removed so a fresh login re-links it.

**Also required by 1.7:** `SecondaryStorage` gained two mandatory methods, both
now implemented over the kernel cache service — `getAndDelete` (single-use
verification values) and `increment` (fixed-window rate-limit counter;
`rateLimit.storage: 'secondary-storage'` throws at boot without it).

The rest of #3517's production-dependency batch rides along: `@oclif/core`
4.13.0, `@hono/node-server` 2.0.12, `hono` 4.12.32, `tar` 7.5.22, `jose` 6.2.4,
`pinyin-pro` 3.28.2, plus the private docs app's fumadocs/next/react bumps.
12 changes: 6 additions & 6 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@
"postinstall": "fumadocs-mdx"
},
"dependencies": {
"fumadocs-core": "16.11.5",
"fumadocs-core": "16.12.1",
"fumadocs-mdx": "15.2.0",
"fumadocs-ui": "16.11.5",
"lucide-react": "^1.25.0",
"fumadocs-ui": "16.12.1",
"lucide-react": "^1.27.0",
"mermaid": "^11.16.0",
"next": "16.2.11",
"next": "16.2.12",
"next-themes": "^0.4.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwind-merge": "^3.6.0",
"unist-util-visit": "^5.1.0"
},
Expand Down
9 changes: 8 additions & 1 deletion content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -945,10 +945,17 @@ 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', accountId: 'account_id', … } },
account: { modelName: 'sys_account', fields: { providerId: 'provider_id', issuer: 'issuer', providerAccountId: '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` /
`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.

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 packages/adapters/hono/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
},
"devDependencies": {
"@objectstack/runtime": "workspace:*",
"hono": "^4.12.31",
"hono": "^4.12.32",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
"@objectstack/trigger-schedule": "workspace:*",
"@objectstack/types": "workspace:*",
"@objectstack/verify": "workspace:*",
"@oclif/core": "^4.11.14",
"@oclif/core": "^4.13.0",
"bundle-require": "^5.1.0",
"chalk": "^5.6.2",
"chokidar": "^5.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"@objectstack/spec": "workspace:*"
},
"devDependencies": {
"@hono/node-server": "^2.0.10",
"@hono/node-server": "^2.0.12",
"@objectstack/driver-memory": "workspace:*",
"@objectstack/hono": "workspace:*",
"@objectstack/objectql": "workspace:*",
Expand Down
14 changes: 9 additions & 5 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2162,19 +2162,23 @@ export class ObjectStackClient {
return { accounts: accounts as Array<{
id: string;
providerId: string;
accountId: string;
/** Authority that vouched for `providerAccountId` — an OIDC issuer, or `local:…`. */
issuer: string;
/** The user's id at the provider — better-auth 1.7 renamed this from `accountId`. */
providerAccountId: string;
createdAt?: string;
updatedAt?: string;
}> };
},

/**
* Unlink a provider connection.
* better-auth: POST /unlink-account — `{ providerId, accountId? }`.
* `accountId` is required when the user has more than one account
* for the same provider.
* better-auth: POST /unlink-account — `{ accountId }`, where `accountId`
* is the account ROW id (the `id` from `accounts.list()`), NOT the user's
* id at the provider. 1.7 narrowed the body from the old
* `{ providerId, accountId? }` pair; the row id implies the provider.
*/
unlink: async (req: { providerId: string; accountId?: string }) => {
unlink: async (req: { accountId: string }) => {
const route = this.getRoute('auth');
const res = await this.fetch(`${this.baseUrl}${route}/unlink-account`, {
method: 'POST',
Expand Down
2 changes: 1 addition & 1 deletion packages/create-objectstack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"dependencies": {
"chalk": "^5.6.2",
"commander": "^15.0.0",
"tar": "^7.5.21"
"tar": "^7.5.22"
},
"devDependencies": {
"@types/node": "^26.1.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
label: "Provider ID",
help: "OAuth provider identifier (google, github, etc.)"
},
issuer: {
label: "Issuer",
help: "Authority that vouched for the provider account id — an OIDC issuer, or local:… for providers without one"
},
account_id: {
label: "Provider Account ID",
help: "User's ID in the provider's system"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
label: "ID del proveedor",
help: "Identificador del proveedor OAuth (google, github, etc.)."
},
issuer: {
label: "Emisor",
help: "Autoridad que avaló el id de cuenta del proveedor: un emisor OIDC, o local:… para proveedores que no tienen uno"
},
account_id: {
label: "ID de cuenta del proveedor",
help: "ID del usuario en el sistema del proveedor."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
label: "プロバイダー ID",
help: "OAuth プロバイダー識別子(google、github など)"
},
issuer: {
label: "発行者",
help: "プロバイダーのアカウント ID を保証した発行主体 — OIDC の issuer、または issuer を持たないプロバイダーの場合は local:…"
},
account_id: {
label: "プロバイダーアカウント ID",
help: "プロバイダーシステム内のユーザー ID"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
label: "提供方 ID",
help: "OAuth 提供方标识(google、github 等)"
},
issuer: {
label: "颁发者",
help: "为该提供方账号 ID 背书的权威方 —— OIDC 的 issuer,或对自身没有 issuer 的提供方使用 local:…"
},
account_id: {
label: "提供方账号 ID",
help: "用户在该提供方系统中的 ID"
Expand Down
34 changes: 28 additions & 6 deletions packages/platform-objects/src/identity/sys-account.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ export const SysAccount = ObjectSchema.create({

// Custom actions — sysadmins routinely need to revoke a user's OAuth
// link (e.g. when an SSO provider is decommissioned or the user
// requests it). Better-auth exposes `/unlink-account { providerId,
// accountId }` for this. The form is locked to the row's values so
// it acts as a one-click confirmation rather than a free-form edit.
// requests it). Better-auth exposes `/unlink-account { accountId }` for
// 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
// as a one-click confirmation rather than a free-form edit.
//
// `link_social` is the self-service counterpart — a toolbar action
// that redirects the browser to better-auth's social sign-in endpoint
Expand Down Expand Up @@ -82,8 +85,7 @@ export const SysAccount = ObjectSchema.create({
successMessage: 'Identity link removed',
refreshAfter: true,
params: [
{ name: 'providerId', field: 'provider_id', defaultFromRow: true, required: true },
{ name: 'accountId', field: 'account_id', defaultFromRow: true, required: true },
{ name: 'accountId', field: 'id', defaultFromRow: true, required: true },
],
},
],
Expand Down Expand Up @@ -144,7 +146,23 @@ export const SysAccount = ObjectSchema.create({
required: true,
description: 'OAuth provider identifier (google, github, etc.)',
}),


// better-auth 1.7 keys account identity on (issuer, account_id) rather than
// on the provider id alone: the issuer names the authority that vouched for
// that id — an OIDC `iss` claim for federated logins, or a synthetic
// `local:credential` / `local:oauth:<provider>` for providers that have
// none. better-auth writes it on every new account; rows created before the
// 1.7 upgrade are stamped at boot by the auth plugin's issuer backfill.
//
// Deliberately NOT `required` even though better-auth always supplies it: a
// NOT NULL column cannot be added to a table that already holds rows, and
// schema sync runs before the backfill.
issuer: Field.text({
label: 'Issuer',
required: false,
description: 'Authority that vouched for the provider account id — an OIDC issuer, or local:… for providers without one',
}),

account_id: Field.text({
label: 'Provider Account ID',
required: true,
Expand Down Expand Up @@ -208,6 +226,10 @@ 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
// 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 },
],

enable: {
Expand Down
8 changes: 6 additions & 2 deletions packages/platform-objects/src/platform-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,12 @@ describe('@objectstack/platform-objects', () => {
const unlink = (SysAccount.actions ?? []).find((a) => a.name === 'unlink_account');
expect(unlink).toBeDefined();
expect(unlink?.target).toBe('/api/v1/auth/unlink-account');
const paramNames = (unlink?.params ?? []).map((p) => p.name);
expect(paramNames).toEqual(['providerId', 'accountId']);
// better-auth 1.7 narrowed the body to `{ accountId }`, where accountId
// is the account ROW id — the old `{ providerId, accountId }` pair (in
// which accountId meant the provider's id for the user) is gone.
const params = unlink?.params ?? [];
expect(params.map((p) => p.name)).toEqual(['accountId']);
expect(params[0]?.field).toBe('id');
});

it('SysOauthApplication routes all mutations through better-auth, not the data layer', () => {
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 @@ -18,18 +18,18 @@
"test": "vitest run"
},
"dependencies": {
"@better-auth/core": "1.7.0-rc.1",
"@better-auth/oauth-provider": "1.7.0-rc.1",
"@better-auth/core": "1.7.0-rc.2",
"@better-auth/oauth-provider": "1.7.0-rc.2",
"@better-auth/scim": "1.7.0-rc.1",
"@better-auth/sso": "1.7.0-rc.1",
"@better-auth/sso": "1.7.0-rc.2",
"@noble/hashes": "^2.2.0",
"@objectstack/core": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/rest": "workspace:*",
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*",
"better-auth": "1.7.0-rc.1",
"jose": "^6.2.3"
"better-auth": "1.7.0-rc.2",
"jose": "^6.2.4"
},
"devDependencies": {
"@types/node": "^26.1.1",
Expand Down
13 changes: 11 additions & 2 deletions packages/plugins/plugin-auth/src/admin-user-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,14 @@ export interface AuthContextLike {
createAccount(account: {
userId: string;
providerId: string;
accountId: string;
/**
* better-auth 1.7 keys accounts on (issuer, providerAccountId) 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.
*/
issuer: string;
providerAccountId: string;
password: string;
}): Promise<unknown>;
};
Expand Down Expand Up @@ -122,6 +129,7 @@ export interface EndpointResult {
};
}

import { CREDENTIAL_ISSUER } from './backfill-account-issuer.js';
import { generatePlaceholderEmail } from './placeholder-email.js';
import { reconcileMembership } from './reconcile-membership.js';
import { resolveDefaultOrgId } from './tenancy-service.js';
Expand Down Expand Up @@ -533,7 +541,8 @@ export async function runAdminSetUserPassword(
await authCtx.internalAdapter.createAccount({
userId,
providerId: 'credential',
accountId: userId,
issuer: CREDENTIAL_ISSUER,
providerAccountId: userId,
password: hashed,
});
}
Expand Down
5 changes: 4 additions & 1 deletion packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,10 @@ describe('AuthManager', () => {
expect(capturedConfig.account.fields).toEqual(expect.objectContaining({
userId: 'user_id',
providerId: 'provider_id',
accountId: 'account_id',
// 1.7 identity key: (issuer, providerAccountId), the latter renamed
// from `accountId` but still living in the `account_id` column.
issuer: 'issuer',
providerAccountId: 'account_id',
accessToken: 'access_token',
refreshToken: 'refresh_token',
idToken: 'id_token',
Expand Down
25 changes: 25 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,31 @@ export class AuthPlugin implements Plugin {
await this.maybeSeedDevAdmin(ctx);
});

// better-auth 1.7 resolves every account by (issuer, providerAccountId).
// Rows written before the upgrade have no issuer and are therefore
// invisible to sign-in, so stamp them once at boot. Idempotent: a database
// whose rows already carry an issuer costs one empty query.
ctx.hook('kernel:ready', async () => {
try {
const ql: any = ctx.getService<any>('objectql');
if (!ql) return;
const { backfillAccountIssuer } = await import('./backfill-account-issuer.js');
await backfillAccountIssuer(ql, {
logger: ctx.logger,
socialProviderIds: Object.keys(this.configuredSocialProviders ?? {}),
oidcProviderIssuers: Object.fromEntries(
(this.options.oidcProviders ?? [])
.filter((p): p is typeof p & { issuer: string } => typeof p.issuer === 'string' && !!p.issuer)
.map((p) => [p.providerId, p.issuer]),
),
});
} catch (e) {
ctx.logger.warn?.('[auth] account issuer backfill failed', {
error: (e as Error).message,
});
}
});

// ADR-0081 D1 — single-org default-organization bootstrap. Every WALLED
// posture (`group` and `isolated`) keeps its existing owner: the enterprise
// organizations package, which runs the same idempotent helper with the
Expand Down
Loading
Loading