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/eleven-mice-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
'@opensaas/stack-auth': minor
---

Bump the `better-auth` dev dependency to `1.7.1` and move the peer range off the stale `^1.3.29` floor to `^1.4.0` (the release line where better-auth's `index: true` flags — the basis for #937's index emission — first shipped; below it the generated schema silently omitted indexes better-auth itself declares).

**New required `account.issuer` column.** better-auth 1.7 adds a required `issuer` column to its `account` model. Because `deriveAuthLists` derives the Auth lists from better-auth's own `getAuthTables()` (#987/#997), this column now appears in the generated schema automatically — no code change was needed, only verification against real generated output. Existing projects upgrading to `better-auth@^1.7` will see a **new NOT NULL column** on their `account` table and need a backfill for existing rows. Following better-auth's own `createLocalAccountIssuer`/`createOAuthAccountIssuer` helpers (`@better-auth/core/db`):

```sql
-- PostgreSQL / SQLite (|| is string concatenation on both)
UPDATE "Account" SET issuer = 'local:' || "providerId" WHERE issuer IS NULL AND "providerId" = 'credential';
UPDATE "Account" SET issuer = 'local:oauth:' || "providerId" WHERE issuer IS NULL AND "providerId" != 'credential';
Comment thread
borisno2 marked this conversation as resolved.
Comment thread
borisno2 marked this conversation as resolved.

-- MySQL (|| is logical OR by default, NOT concatenation — use CONCAT instead)
UPDATE `Account` SET issuer = CONCAT('local:', providerId) WHERE issuer IS NULL AND providerId = 'credential';
UPDATE `Account` SET issuer = CONCAT('local:oauth:', providerId) WHERE issuer IS NULL AND providerId != 'credential';
```

`"Account"`/`` `Account` `` above is the stack's own greenfield default table name — substitute your project's actual (and, on Postgres, schema-qualified) table name if you renamed it via `authPlugin({ account: { tableName } })` or adopted an existing install with `adoptBetterAuthTables({ useBetterAuthTableNames: true })` (physical table `account`, commonly under a non-`public` schema).

URL-encode `providerId` if it can contain characters outside `[A-Za-z0-9_-]`. If you configured a custom OIDC provider with its own issuer URL, use that provider's real issuer instead of the synthetic `local:oauth:` value.

**The new `@@unique([issuer, accountId])` constraint is not yet emitted.** better-auth 1.7 also declares this composite unique index at the table level, but the stack only derives _field_-level `unique`/`index` flags today — table-level index derivation is #985, which hasn't landed. This is deliberately out of scope here (per #986's own triage note: build on #985 once it lands, don't duplicate it). When it does land, make sure your backfilled `issuer` values don't collide on `(issuer, accountId)` for any account, or the constraint will fail to apply.

**Breaking (MCP plugin users only): `@better-auth/mcp` is now a separate package.** better-auth 1.7 split the `mcp` plugin out of `better-auth/plugins` into its own package, rebuilt on the OAuth Provider RFC 8707/9728 resource model. `@opensaas/stack-auth/plugins` now re-exports `mcp` from `@better-auth/mcp` (added as an optional peer — install it if you use MCP). The plugin also now **requires** a `resource` option:

```typescript
import { mcp } from '@opensaas/stack-auth/plugins'
import { jwt } from 'better-auth/plugins'

authPlugin({
betterAuthPlugins: [
// The OAuth Provider mcp() is built on issues JWT-based access tokens
// and requires better-auth's own jwt() plugin registered alongside it —
// omitting it throws `BetterAuthError: jwt_config` at init.
jwt(),
mcp({
loginPage: '/sign-in',
// The page where a user approves/denies an MCP client's requested
// scopes — also required as of better-auth 1.7.
consentPage: '/consent',
// RFC 8707/9728 canonical resource identifier — required as of
// better-auth 1.7. Must match your `mcp.basePath`. HTTP is only
// accepted on loopback hosts.
resource: `${process.env.BETTER_AUTH_URL}/api/mcp`,
}),
],
})
```

better-auth 1.7's MCP plugin also declares a substantially different OAuth table set — the old `oauthApplication`/`oauthAccessToken`/`oauthConsent` three became seven tables (`oauthClient`/`oauthAccessToken`/`oauthConsent`/`oauthRefreshToken`/`oauthResource`/`oauthClientResource`/`oauthClientAssertion`). Since the derivation is schema-driven this needed no code changes, but if you have the MCP plugin enabled, running `pnpm generate` will produce a significantly different Prisma schema for these tables (new/renamed models, and `Session` gains reverse relations to the two token tables that now reference it). Review the diff and migrate your database accordingly.

**Workspace-wide: pins `@better-auth/utils` to `0.5.0` via a root `pnpm.overrides`.** better-auth 1.7.1's own published packages disagree on this transitive dependency — `better-auth` pins it at exactly `0.4.2` while `better-call` (used by `@better-auth/core`, `@better-auth/oauth-provider`, and `@better-auth/mcp`) requires `^0.5.0` — so pnpm resolves two separate physical instances of `@better-auth/core` depending on which peer chain a given package sits in. That split is invisible at runtime but breaks TypeScript: `jwt()` (from `better-auth/plugins`) and `mcp()` (from `@better-auth/mcp`) end up typed against different `@better-auth/core` instances, so `betterAuthPlugins: [jwt(), mcp(...)]` fails to type-check with a `BetterAuthPlugin` structural-mismatch error even though both plugins are otherwise correctly configured. The override forces one instance workspace-wide. If you hit the same error in your own app, add the equivalent override to your own `package.json`.

Also fixes two `deriveAuthLists` gaps surfaced by the MCP plugin's expanded schema:

- the scalar-field builder now threads a static `defaultValue` through for `number`-typed fields (`integer()`/`bigInt()`), matching the existing `string`/`boolean` behavior (e.g. `oauthResource.policyVersion`, which defaults to `1`)
- a plugin table that declares only one of `createdAt`/`updatedAt` upstream (several of the new OAuth tables declare `createdAt` alone) is no longer silently dropped — it derives as an ordinary required column instead. Previously any model with an asymmetric timestamp pair had the field skipped entirely with no replacement, which crashed the first real write that supplied it (better-auth's own OAuth Provider does this at `betterAuth()` init time, seeding an `oauthResource` row)
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,7 @@ The stack provides Model Context Protocol server integration through `@opensaas/

1. Enable MCP in config with `mcp: { enabled: true }` (the runtime reads `enabled`/`basePath`/`defaultTools`)
2. Core runtime derives CRUD tools for each list (query, create, update, delete) at request time
3. OAuth with AI assistants is wired through Better-auth's `mcp` plugin (`authPlugin({ betterAuthPlugins: [mcp({ loginPage: '/sign-in' })] })`) and the `createBetterAuthMcpAdapter` session provider
3. OAuth with AI assistants is wired through Better-auth's `mcp` plugin (`authPlugin({ betterAuthPlugins: [jwt(), mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: '<canonical MCP URL>' })] })`, imported from `@opensaas/stack-auth/plugins` — the plugin itself comes from the optional `@better-auth/mcp` peer since better-auth 1.7 split it out of `better-auth/plugins`, and requires `jwt()` from `better-auth/plugins` registered alongside it) and the `createBetterAuthMcpAdapter` session provider
4. All tools respect existing access control rules
5. Custom tools can be added per-list via `mcp.customTools` (Zod or JSON Schema inputSchema); plugins can register global tools via `registerMcpTool`

Expand Down
18 changes: 17 additions & 1 deletion docs/content/how-to/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,29 @@ In your `opensaas.config.ts`, configure the auth plugin with the MCP plugin:
import { config, list } from '@opensaas/stack-core'
import { authPlugin } from '@opensaas/stack-auth'
import { mcp } from '@opensaas/stack-auth/plugins'
import { jwt } from 'better-auth/plugins'

export default config({
plugins: [
authPlugin({
emailAndPassword: { enabled: true },
// Add MCP plugin to Better Auth
betterAuthPlugins: [mcp({ loginPage: '/sign-in' })],
betterAuthPlugins: [
// better-auth 1.7's mcp() is built on the OAuth Provider, which
// issues JWT-based access tokens and requires better-auth's own
// jwt() plugin registered alongside it.
jwt(),
mcp({
loginPage: '/sign-in',
// The page where a user approves/denies an MCP client's requested
// scopes — also required since better-auth 1.7's MCP plugin.
consentPage: '/consent',
// Canonical protected-resource identifier (RFC 8707/9728) —
// required since better-auth 1.7's MCP plugin, and must match
// `mcp.basePath` below. HTTP is only accepted on loopback hosts.
resource: `${process.env.BETTER_AUTH_URL || 'http://localhost:3000'}/api/mcp`,
}),
],
}),
],

Expand Down
26 changes: 24 additions & 2 deletions docs/content/reference/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,10 +286,23 @@ Add Better Auth plugins for additional functionality:
```typescript
import { authPlugin } from '@opensaas/stack-auth'
import { mcp } from '@opensaas/stack-auth/plugins'
import { jwt } from 'better-auth/plugins'

authPlugin({
betterAuthPlugins: [
mcp({ loginPage: '/sign-in' }),
// better-auth 1.7's mcp() is built on the OAuth Provider, which issues
// JWT-based access tokens and requires better-auth's own jwt() plugin
// registered alongside it.
jwt(),
mcp({
loginPage: '/sign-in',
// The page where a user approves/denies an MCP client's requested
// scopes — also required since better-auth 1.7's MCP plugin.
consentPage: '/consent',
// Canonical protected-resource identifier (RFC 8707/9728) — required
// since better-auth 1.7's MCP plugin. Must match `mcp.basePath` below.
resource: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/mcp`,
}),
// Add other Better Auth plugins here
],
})
Expand Down Expand Up @@ -669,12 +682,21 @@ To enable Model Context Protocol support with Better Auth authentication:
```typescript
import { authPlugin } from '@opensaas/stack-auth'
import { mcp } from '@opensaas/stack-auth/plugins'
import { jwt } from 'better-auth/plugins'

export default config({
plugins: [
authPlugin({
emailAndPassword: { enabled: true },
betterAuthPlugins: [mcp({ loginPage: '/sign-in' })],
betterAuthPlugins: [
// better-auth 1.7's mcp() requires the jwt() plugin alongside it.
jwt(),
mcp({
loginPage: '/sign-in',
consentPage: '/consent',
resource: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/mcp`,
}),
],
}),
],
mcp: {
Expand Down
2 changes: 1 addition & 1 deletion examples/auth-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"@opensaas/stack-ui": "workspace:*",
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"better-auth": "^1.6.25",
"better-auth": "^1.7.1",
"next": "^16.2.10",
"react": "^19.2.8",
"react-dom": "^19.2.8"
Expand Down
29 changes: 16 additions & 13 deletions examples/auth-demo/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ model User {
}

model Session {
id String @id @default(cuid())
token String @unique
expiresAt DateTime?
id String @id @default(cuid())
token String @unique
expiresAt DateTime
ipAddress String?
userAgent String?
userId String? @map("user")
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
userId String @map("userId")
user User @relation(onDelete: Cascade, fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt

@@index([userId])
}
Expand All @@ -54,26 +54,29 @@ model Account {
id String @id @default(cuid())
accountId String
providerId String
issuer String
accessToken String?
refreshToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
idToken String?
password String?
userId String? @map("user")
user User? @relation(fields: [userId], references: [id])
userId String @map("userId")
user User @relation(onDelete: Cascade, fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt

@@index([userId])
}

model Verification {
id String @id @default(cuid())
id String @id @default(cuid())
identifier String
value String
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt

@@index([identifier])
}
17 changes: 16 additions & 1 deletion examples/mcp-demo/opensaas.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { config, list } from '@opensaas/stack-core'
import { text, relationship, select, timestamp } from '@opensaas/stack-core/fields'
import { authPlugin } from '@opensaas/stack-auth'
import { mcp } from '@opensaas/stack-auth/plugins'
import { jwt } from 'better-auth/plugins'
import type { AccessControl } from '@opensaas/stack-core'
import { z } from 'zod'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
Expand Down Expand Up @@ -39,7 +40,21 @@ export default config({
plugins: [
authPlugin({
emailAndPassword: { enabled: true },
betterAuthPlugins: [mcp({ loginPage: '/sign-in' })],
betterAuthPlugins: [
// better-auth 1.7's mcp() is built on the OAuth Provider, which
// issues JWT-based access tokens and requires better-auth's own
// jwt() plugin registered alongside it.
jwt(),
mcp({
loginPage: '/sign-in',
// Not implemented in this demo — a real project needs a page here
// for users to approve/deny an MCP client's requested scopes.
consentPage: '/consent',
// RFC 8707/9728 canonical resource identifier — must match the
// `mcp.basePath` below. HTTP is accepted only on loopback hosts.
resource: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/mcp`,
}),
],
extendUserList: {
fields: {
posts: relationship({
Expand Down
1 change: 1 addition & 0 deletions examples/mcp-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@opensaas/stack-ui": "workspace:*",
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"better-auth": "^1.7.1",
"next": "^16.2.10",
"react": "^19.2.8",
"react-dom": "^19.2.8",
Expand Down
2 changes: 1 addition & 1 deletion examples/starter-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"@tailwindcss/postcss": "^4.3.3",
"better-auth": "^1.6.25",
"better-auth": "^1.7.1",
"better-sqlite3": "^12.6.2",
"next": "^16.2.10",
"postcss": "^8.5.26",
Expand Down
11 changes: 9 additions & 2 deletions examples/starter-auth/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -53,27 +53,32 @@ model Session {
expiresAt DateTime
ipAddress String?
userAgent String?
userId String @map("user")
userId String @map("userId")
user User @relation(onDelete: Cascade, fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt

@@index([userId])
}

model Account {
id String @id @default(cuid())
accountId String
providerId String
issuer String
accessToken String?
refreshToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
idToken String?
password String?
userId String @map("user")
userId String @map("userId")
user User @relation(onDelete: Cascade, fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt

@@index([userId])
}

model Verification {
Expand All @@ -83,4 +88,6 @@ model Verification {
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt

@@index([identifier])
}
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@
],
"author": "",
"license": "MIT",
"pnpm": {
"overrides": {
"@better-auth/utils": "0.5.0"
}
},
"devDependencies": {
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^3.0.0",
Expand Down
21 changes: 17 additions & 4 deletions packages/auth/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,16 @@ Pre-built forms (client components). Each takes **server action** props (not an

### Plugins (`src/plugins/index.ts`)

- Better-auth MCP plugin for OAuth authentication with AI assistants
- `mcp` - re-exported from the optional `@better-auth/mcp` peer (better-auth
1.7 split it out of `better-auth/plugins`) for OAuth authentication with AI
assistants. Its `resource` option (the RFC 8707/9728 canonical
protected-resource URL) and `consentPage` (where a user approves/denies an
MCP client's requested scopes) are both required as of that split, and it
now needs better-auth's own `jwt()` plugin (from `better-auth/plugins`,
unaffected by the `@better-auth/mcp` split) registered alongside it — the
OAuth Provider `mcp()` is built on issues JWT-based access tokens and
throws `BetterAuthError: jwt_config` at init without it. See the
`betterAuthPlugins` example in the root `CLAUDE.md`'s MCP section.

## Architecture Patterns

Expand All @@ -69,7 +78,11 @@ config({

The four Auth lists, the conditional `RateLimit` fifth, and every table a
better-auth plugin declares in its own `schema` (e.g. the `mcp` plugin's
`oauthApplication`/`oauthAccessToken`/`oauthConsent`) are all **derived**
`oauthClient`/`oauthAccessToken`/`oauthConsent`/`oauthRefreshToken`/
`oauthResource`/`oauthClientResource`/`oauthClientAssertion` — better-auth
1.7 split the plugin into `@better-auth/mcp` and rebuilt it on the OAuth
Provider RFC 8707/9728 resource model, a wider table set than the pre-1.7
`oauthApplication`/`oauthAccessToken`/`oauthConsent` three) are all **derived**
from better-auth's own resolved table definitions, not hand-transcribed. The
pure derivation lives in `src/config/derive-auth-lists.ts` (`deriveAuthLists`),
which `getAuthLists` and the plugin's `init` consume. It calls `getAuthTables`
Expand Down Expand Up @@ -108,13 +121,13 @@ lists cannot silently drift from what better-auth itself declares (issue
child model's own key, with a documented override map in
`derive-auth-lists.ts` for a collision or bad pluralization
- a **plugin table**'s list key is PascalCased from better-auth's resolved
`modelName` (`oauthApplication` → `OauthApplication`), with `db.map` set
`modelName` (`oauthClient` → `OauthClient`), with `db.map` set
back to the original whenever the case changed; its scalar/FK fields go
through the exact same derivation as a base model's, including the reverse
relation onto whichever list it references (base or another plugin table).
A reference whose target field isn't the target's `id` (better-auth's own
oidc-provider schema does this — `oauthAccessToken.clientId` references
`oauthApplication.clientId`, not its `id`) stays a plain scalar column,
`oauthClient.clientId`, not its `id`) stays a plain scalar column,
since `relationship()` can only express an `id`-based FK. Plugin tables
ship closed like the base models, with no `access` passthrough at all —
see ADR-0034 and "Access control on Auth lists" below
Expand Down
Loading
Loading