@opensaas/stack-auth 0.39.2 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +151 -0
- package/CLAUDE.md +140 -22
- package/dist/client/index.d.ts +0 -16
- package/dist/client/index.d.ts.map +1 -1
- package/dist/config/adopt-better-auth-tables.d.ts +4 -49
- package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
- package/dist/config/adopt-better-auth-tables.js +4 -51
- package/dist/config/adopt-better-auth-tables.js.map +1 -1
- package/dist/config/derive-auth-lists.d.ts +42 -37
- package/dist/config/derive-auth-lists.d.ts.map +1 -1
- package/dist/config/derive-auth-lists.js +579 -223
- package/dist/config/derive-auth-lists.js.map +1 -1
- package/dist/config/index.d.ts +0 -3
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +4 -34
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin.d.ts.map +1 -1
- package/dist/config/plugin.js +20 -90
- package/dist/config/plugin.js.map +1 -1
- package/dist/config/types.d.ts +89 -67
- package/dist/config/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -13
- package/dist/index.js.map +1 -1
- package/dist/lists/index.d.ts +9 -5
- package/dist/lists/index.d.ts.map +1 -1
- package/dist/lists/index.js +9 -6
- package/dist/lists/index.js.map +1 -1
- package/dist/mcp/better-auth.d.ts +6 -19
- package/dist/mcp/better-auth.d.ts.map +1 -1
- package/dist/mcp/better-auth.js +6 -20
- package/dist/mcp/better-auth.js.map +1 -1
- package/dist/plugins/index.d.ts +4 -4
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +4 -4
- package/dist/plugins/index.js.map +1 -1
- package/dist/runtime/types.d.ts +2 -9
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime/types.js +0 -4
- package/dist/runtime/types.js.map +1 -1
- package/dist/server/index.d.ts +5 -12
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +14 -62
- package/dist/server/index.js.map +1 -1
- package/dist/ui/components/ForgotPasswordForm.d.ts +0 -3
- package/dist/ui/components/ForgotPasswordForm.d.ts.map +1 -1
- package/dist/ui/components/ForgotPasswordForm.js +0 -3
- package/dist/ui/components/ForgotPasswordForm.js.map +1 -1
- package/dist/ui/components/ResetPasswordForm.d.ts +0 -3
- package/dist/ui/components/ResetPasswordForm.d.ts.map +1 -1
- package/dist/ui/components/ResetPasswordForm.js +0 -5
- package/dist/ui/components/ResetPasswordForm.js.map +1 -1
- package/dist/ui/components/SignInForm.d.ts +0 -3
- package/dist/ui/components/SignInForm.d.ts.map +1 -1
- package/dist/ui/components/SignInForm.js +0 -4
- package/dist/ui/components/SignInForm.js.map +1 -1
- package/dist/ui/components/SignUpForm.d.ts +0 -3
- package/dist/ui/components/SignUpForm.d.ts.map +1 -1
- package/dist/ui/components/SignUpForm.js +0 -5
- package/dist/ui/components/SignUpForm.js.map +1 -1
- package/dist/ui/lib/clean-error-message.d.ts +3 -6
- package/dist/ui/lib/clean-error-message.d.ts.map +1 -1
- package/dist/ui/lib/clean-error-message.js +3 -6
- package/dist/ui/lib/clean-error-message.js.map +1 -1
- package/dist/ui/types.d.ts +2 -15
- package/dist/ui/types.d.ts.map +1 -1
- package/dist/ui/types.js +2 -7
- package/dist/ui/types.js.map +1 -1
- package/package.json +12 -5
- package/src/client/index.ts +0 -17
- package/src/config/adopt-better-auth-tables.ts +4 -51
- package/src/config/derive-auth-lists.ts +685 -252
- package/src/config/index.ts +4 -34
- package/src/config/plugin.ts +21 -91
- package/src/config/types.ts +90 -67
- package/src/index.ts +3 -14
- package/src/lists/index.ts +17 -5
- package/src/mcp/better-auth.ts +6 -25
- package/src/plugins/index.ts +4 -5
- package/src/runtime/types.ts +2 -10
- package/src/server/index.ts +17 -70
- package/src/ui/components/ForgotPasswordForm.tsx +0 -3
- package/src/ui/components/ResetPasswordForm.tsx +0 -5
- package/src/ui/components/SignInForm.tsx +0 -4
- package/src/ui/components/SignUpForm.tsx +0 -5
- package/src/ui/lib/clean-error-message.ts +3 -6
- package/src/ui/types.ts +2 -15
- package/tests/auth-lists-drift.test.ts +480 -0
- package/tests/config.test.ts +3 -0
- package/tests/credential-field-read-deny-e2e.test.ts +295 -0
- package/tests/derive-auth-lists.test.ts +263 -6
- package/tests/generated-fk-shape.test.ts +432 -9
- package/tests/mcp-oauth-cascade-e2e.test.ts +267 -0
- package/tests/plugin-derived-keys.test.ts +39 -0
- package/tests/plugin-table-derivation.test.ts +270 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/server/schema-converter.d.ts +0 -81
- package/dist/server/schema-converter.d.ts.map +0 -1
- package/dist/server/schema-converter.js +0 -157
- package/dist/server/schema-converter.js.map +0 -1
- package/src/server/schema-converter.ts +0 -243
- package/tests/schema-converter.test.ts +0 -399
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { execFile } from 'node:child_process'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
import { existsSync } from 'node:fs'
|
|
5
|
+
import fsp from 'node:fs/promises'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { pathToFileURL } from 'node:url'
|
|
9
|
+
import { createAuth } from '../src/server/index.js'
|
|
10
|
+
import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Live end-to-end proof that consolidating the plugin-table derivation onto
|
|
14
|
+
* `deriveAuthLists` (issue #992) actually closes the orphaned-row defect
|
|
15
|
+
* #992's own triage confirmed: generates a real Prisma schema (MCP plugin
|
|
16
|
+
* enabled, so the OAuth tables are derived with real foreign keys), pushes
|
|
17
|
+
* it to a real SQLite database, signs up a real user through a real
|
|
18
|
+
* `betterAuth()` instance (via `createAuth()`, the package's own source, not
|
|
19
|
+
* a stale build), attaches OAuth rows to that user via the raw Prisma client
|
|
20
|
+
* (the same path better-auth's own adapter writes through — these lists ship
|
|
21
|
+
* closed per ADR-0013), deletes the user through better-auth's own
|
|
22
|
+
* `/delete-user` endpoint (the exact `internalAdapter.deleteUser` path
|
|
23
|
+
* #992's triage traced), and asserts the database cascade removed every
|
|
24
|
+
* OAuth row rather than leaving them orphaned.
|
|
25
|
+
*
|
|
26
|
+
* Follows the same opt-in/offline-toolchain pattern as `rate-limit-e2e.test.ts`
|
|
27
|
+
* (see ADR-0002) — kept out of the fast unit lane, run only in the `e2e` CI
|
|
28
|
+
* job where `pnpm install && pnpm build` have already run.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const run = promisify(execFile)
|
|
32
|
+
const here = path.dirname(new URL(import.meta.url).pathname)
|
|
33
|
+
const repoRoot = path.resolve(here, '../../..')
|
|
34
|
+
|
|
35
|
+
const opensaasCli = path.join(repoRoot, 'packages/cli/dist/index.js')
|
|
36
|
+
const coreDist = path.join(repoRoot, 'packages/core/dist')
|
|
37
|
+
const authDist = path.join(repoRoot, 'packages/auth/dist')
|
|
38
|
+
const toolchainNodeModules = path.join(repoRoot, 'examples/starter-auth/node_modules')
|
|
39
|
+
|
|
40
|
+
const guardEnabled = process.env.RUN_MCP_OAUTH_CASCADE_E2E === '1'
|
|
41
|
+
|
|
42
|
+
const prerequisitesPresent =
|
|
43
|
+
guardEnabled &&
|
|
44
|
+
existsSync(opensaasCli) &&
|
|
45
|
+
existsSync(coreDist) &&
|
|
46
|
+
existsSync(authDist) &&
|
|
47
|
+
existsSync(path.join(toolchainNodeModules, '.bin', 'opensaas')) &&
|
|
48
|
+
existsSync(path.join(toolchainNodeModules, '.bin', 'prisma')) &&
|
|
49
|
+
existsSync(path.join(toolchainNodeModules, '@prisma', 'adapter-better-sqlite3'))
|
|
50
|
+
|
|
51
|
+
/** The temp project's `opensaas.config.ts`: sqlite + the MCP plugin + self-service account deletion enabled. */
|
|
52
|
+
function configSource(): string {
|
|
53
|
+
return `import { config } from '@opensaas/stack-core'
|
|
54
|
+
import { authPlugin } from '@opensaas/stack-auth'
|
|
55
|
+
import { mcp } from '@opensaas/stack-auth/plugins'
|
|
56
|
+
import { jwt } from 'better-auth/plugins'
|
|
57
|
+
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
|
|
58
|
+
|
|
59
|
+
export default config({
|
|
60
|
+
plugins: [
|
|
61
|
+
authPlugin({
|
|
62
|
+
emailAndPassword: { enabled: true },
|
|
63
|
+
// better-auth 1.7's mcp() is built on the OAuth Provider, which issues
|
|
64
|
+
// JWT-based access tokens and requires better-auth's own jwt() plugin
|
|
65
|
+
// to be registered alongside it (throws BetterAuthError: jwt_config
|
|
66
|
+
// otherwise) — see @better-auth/mcp's own usage example.
|
|
67
|
+
betterAuthPlugins: [
|
|
68
|
+
jwt(),
|
|
69
|
+
mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'http://localhost:3000/api/mcp' }),
|
|
70
|
+
],
|
|
71
|
+
// Not modelled by AuthConfig — passed through verbatim so /delete-user
|
|
72
|
+
// deletes immediately instead of requiring an email-verification
|
|
73
|
+
// round trip (see packages/auth/CLAUDE.md, "betterAuthOptions").
|
|
74
|
+
// baseURL is explicit rather than relying on the BETTER_AUTH_URL env
|
|
75
|
+
// var: better-auth 1.7's OAuth Provider needs a resolvable URL to build
|
|
76
|
+
// its own endpoint/issuer URLs during init, and this test constructs
|
|
77
|
+
// \`auth\` in-process via createAuth() (not through the CLI subprocess
|
|
78
|
+
// that does see BETTER_AUTH_URL), so the auto-detected origin would be
|
|
79
|
+
// undefined here.
|
|
80
|
+
betterAuthOptions: {
|
|
81
|
+
baseURL: 'http://localhost:3000',
|
|
82
|
+
user: { deleteUser: { enabled: true } },
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
],
|
|
86
|
+
db: {
|
|
87
|
+
provider: 'sqlite',
|
|
88
|
+
prismaClientConstructor: (PrismaClient) => {
|
|
89
|
+
const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL || './dev.db' })
|
|
90
|
+
return new PrismaClient({ adapter })
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
lists: {},
|
|
94
|
+
})
|
|
95
|
+
`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Scaffold a fresh temp project with the MCP plugin enabled and push its schema. Returns the project dir. */
|
|
99
|
+
async function setupProject(): Promise<string> {
|
|
100
|
+
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'opensaas-mcp-cascade-e2e-'))
|
|
101
|
+
const dir = path.join(tmpRoot, 'project')
|
|
102
|
+
await fsp.mkdir(dir, { recursive: true })
|
|
103
|
+
|
|
104
|
+
await fsp.writeFile(path.join(dir, 'opensaas.config.ts'), configSource())
|
|
105
|
+
await fsp.writeFile(
|
|
106
|
+
path.join(dir, 'package.json'),
|
|
107
|
+
JSON.stringify(
|
|
108
|
+
{ name: 'mcp-cascade-e2e-project', version: '0.0.0', private: true, type: 'module' },
|
|
109
|
+
null,
|
|
110
|
+
2,
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
await fsp.writeFile(path.join(dir, '.env'), 'DATABASE_URL=file:./dev.db\n')
|
|
114
|
+
await fsp.symlink(toolchainNodeModules, path.join(dir, 'node_modules'))
|
|
115
|
+
|
|
116
|
+
const env = {
|
|
117
|
+
...process.env,
|
|
118
|
+
DATABASE_URL: 'file:./dev.db',
|
|
119
|
+
BETTER_AUTH_SECRET: 'e2e-test-secret-not-for-production-0000000000',
|
|
120
|
+
BETTER_AUTH_URL: 'http://localhost:3000',
|
|
121
|
+
}
|
|
122
|
+
const binDir = path.join(dir, 'node_modules', '.bin')
|
|
123
|
+
await run(path.join(binDir, 'opensaas'), ['generate'], { cwd: dir, env })
|
|
124
|
+
await run(path.join(binDir, 'prisma'), ['db', 'push'], { cwd: dir, env })
|
|
125
|
+
|
|
126
|
+
return dir
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Import the temp project's config + generated context and build a real auth instance via `createAuth()`. */
|
|
130
|
+
async function createAuthInstanceForProject(dir: string) {
|
|
131
|
+
process.env.DATABASE_URL = `file:${path.join(dir, 'dev.db')}`
|
|
132
|
+
|
|
133
|
+
// `@vite-ignore` suppresses Vite's static analysis of this computed,
|
|
134
|
+
// external (outside the package root) specifier.
|
|
135
|
+
const config = (
|
|
136
|
+
(await import(/* @vite-ignore */ pathToFileURL(path.join(dir, 'opensaas.config.ts')).href)) as {
|
|
137
|
+
default: OpenSaasConfig | Promise<OpenSaasConfig>
|
|
138
|
+
}
|
|
139
|
+
).default
|
|
140
|
+
const { rawOpensaasContext } = (await import(
|
|
141
|
+
/* @vite-ignore */ pathToFileURL(path.join(dir, '.opensaas/context.ts')).href
|
|
142
|
+
)) as { rawOpensaasContext: Promise<AccessContext> }
|
|
143
|
+
|
|
144
|
+
const resolvedContext = await rawOpensaasContext
|
|
145
|
+
return { auth: createAuth(config, resolvedContext), context: resolvedContext }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function cleanupProject(dir: string): Promise<void> {
|
|
149
|
+
// See rate-limit-e2e.test.ts for why this global must be cleared between projects.
|
|
150
|
+
delete (globalThis as { prisma?: unknown }).prisma
|
|
151
|
+
|
|
152
|
+
const linkPath = path.join(dir, 'node_modules')
|
|
153
|
+
if (existsSync(linkPath)) {
|
|
154
|
+
await fsp.unlink(linkPath)
|
|
155
|
+
}
|
|
156
|
+
await fsp.rm(path.dirname(dir), { recursive: true, force: true })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
describe.skipIf(!prerequisitesPresent)(
|
|
160
|
+
'MCP plugin OAuth tables cascade on user deletion — live end-to-end (issue #992)',
|
|
161
|
+
() => {
|
|
162
|
+
it('deleting a user via better-auth’s own /delete-user removes their OAuth clients, access tokens and consents; another user’s rows survive', async () => {
|
|
163
|
+
const dir = await setupProject()
|
|
164
|
+
try {
|
|
165
|
+
const { auth, context } = await createAuthInstanceForProject(dir)
|
|
166
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the temp project's own generated Prisma Client, not a type this package can import
|
|
167
|
+
const prisma = context.prisma as any
|
|
168
|
+
|
|
169
|
+
const { headers: deletedHeaders } = await auth.api.signUpEmail({
|
|
170
|
+
body: { email: 'deleted@example.com', password: 'password1234', name: 'Deleted User' },
|
|
171
|
+
returnHeaders: true,
|
|
172
|
+
})
|
|
173
|
+
const deletedCookie = deletedHeaders.get('set-cookie') ?? ''
|
|
174
|
+
const deletedSession = await auth.api.getSession({
|
|
175
|
+
headers: new Headers({ cookie: deletedCookie }),
|
|
176
|
+
})
|
|
177
|
+
const deletedUserId = deletedSession!.user.id
|
|
178
|
+
|
|
179
|
+
const { headers: survivorHeaders } = await auth.api.signUpEmail({
|
|
180
|
+
body: { email: 'survivor@example.com', password: 'password1234', name: 'Survivor User' },
|
|
181
|
+
returnHeaders: true,
|
|
182
|
+
})
|
|
183
|
+
const survivorSession = await auth.api.getSession({
|
|
184
|
+
headers: new Headers({ cookie: survivorHeaders.get('set-cookie') ?? '' }),
|
|
185
|
+
})
|
|
186
|
+
const survivorUserId = survivorSession!.user.id
|
|
187
|
+
|
|
188
|
+
// OAuth rows are created through the raw Prisma client — these lists
|
|
189
|
+
// ship closed (ADR-0013), and this is the same path better-auth's own
|
|
190
|
+
// OAuth flows write through in production. better-auth 1.7's OAuth
|
|
191
|
+
// Provider (issue #986) splits what the pre-1.7 MCP plugin modelled
|
|
192
|
+
// as a single "application" with embedded access/refresh tokens into
|
|
193
|
+
// oauthClient (the registered client) plus a standalone
|
|
194
|
+
// oauthAccessToken (no more combined access+refresh token row).
|
|
195
|
+
for (const [suffix, userId] of [
|
|
196
|
+
['deleted', deletedUserId],
|
|
197
|
+
['survivor', survivorUserId],
|
|
198
|
+
]) {
|
|
199
|
+
await prisma.oauthClient.create({
|
|
200
|
+
data: {
|
|
201
|
+
name: `App ${suffix}`,
|
|
202
|
+
clientId: `client-${suffix}`,
|
|
203
|
+
redirectUris: 'http://localhost/callback',
|
|
204
|
+
userId,
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
await prisma.oauthAccessToken.create({
|
|
208
|
+
data: {
|
|
209
|
+
token: `access-${suffix}`,
|
|
210
|
+
clientId: `client-${suffix}`,
|
|
211
|
+
scopes: 'openid',
|
|
212
|
+
expiresAt: new Date(Date.now() + 3_600_000),
|
|
213
|
+
// oauthAccessToken declares createdAt but not updatedAt
|
|
214
|
+
// upstream, so it derives as an ordinary required column with
|
|
215
|
+
// no DB-level default (see hasSymmetricTimestamps in
|
|
216
|
+
// derive-auth-lists.ts) — better-auth's own adapter always
|
|
217
|
+
// supplies it explicitly, so this raw-Prisma seed must too.
|
|
218
|
+
createdAt: new Date(),
|
|
219
|
+
userId,
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
await prisma.oauthConsent.create({
|
|
223
|
+
data: { clientId: `client-${suffix}`, scopes: 'openid', userId },
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
expect(await prisma.oauthClient.count()).toBe(2)
|
|
228
|
+
expect(await prisma.oauthAccessToken.count()).toBe(2)
|
|
229
|
+
expect(await prisma.oauthConsent.count()).toBe(2)
|
|
230
|
+
|
|
231
|
+
const deleteResult = await auth.api.deleteUser({
|
|
232
|
+
body: {},
|
|
233
|
+
headers: new Headers({ cookie: deletedCookie }),
|
|
234
|
+
})
|
|
235
|
+
expect(deleteResult).toEqual({ success: true, message: 'User deleted' })
|
|
236
|
+
|
|
237
|
+
expect(await prisma.user.findUnique({ where: { id: deletedUserId } })).toBeNull()
|
|
238
|
+
|
|
239
|
+
// No orphans: every row belonging to the deleted user is gone via the
|
|
240
|
+
// database cascade, not just the user row itself.
|
|
241
|
+
expect(await prisma.oauthClient.findFirst({ where: { userId: deletedUserId } })).toBeNull()
|
|
242
|
+
expect(
|
|
243
|
+
await prisma.oauthAccessToken.findFirst({ where: { userId: deletedUserId } }),
|
|
244
|
+
).toBeNull()
|
|
245
|
+
expect(await prisma.oauthConsent.findFirst({ where: { userId: deletedUserId } })).toBeNull()
|
|
246
|
+
|
|
247
|
+
// The other user's rows are untouched — the cascade is scoped to the
|
|
248
|
+
// deleted user's own foreign key, not a wholesale table wipe.
|
|
249
|
+
expect(await prisma.user.findUnique({ where: { id: survivorUserId } })).not.toBeNull()
|
|
250
|
+
expect(await prisma.oauthClient.count()).toBe(1)
|
|
251
|
+
expect(await prisma.oauthAccessToken.count()).toBe(1)
|
|
252
|
+
expect(await prisma.oauthConsent.count()).toBe(1)
|
|
253
|
+
|
|
254
|
+
await context.prisma.$disconnect()
|
|
255
|
+
} finally {
|
|
256
|
+
await cleanupProject(dir)
|
|
257
|
+
}
|
|
258
|
+
}, 120_000)
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
// Surface, in a normal unit run, why this guard was skipped.
|
|
263
|
+
describe.runIf(!prerequisitesPresent)('MCP OAuth cascade e2e (skipped)', () => {
|
|
264
|
+
it('runs only in the e2e job (set RUN_MCP_OAUTH_CASCADE_E2E=1 after install + build)', () => {
|
|
265
|
+
expect(prerequisitesPresent).toBe(false)
|
|
266
|
+
})
|
|
267
|
+
})
|
|
@@ -209,6 +209,45 @@ describe('authPlugin - add-vs-extend with derived keys', () => {
|
|
|
209
209
|
// ...or its access config.
|
|
210
210
|
expect(authUser.access?.operation?.query).toBe(userQuery)
|
|
211
211
|
})
|
|
212
|
+
|
|
213
|
+
it('keeps a plugin-table credential field read-denied even when the app redeclares that list and field (issue #1014)', async () => {
|
|
214
|
+
// An app opening a plugin-derived list the documented way (declaring the
|
|
215
|
+
// list itself under the derived key) merges in via extendList — field
|
|
216
|
+
// additions win over the app's own field of the same key (core's
|
|
217
|
+
// extendList spreads `extension.fields` last), so the derived,
|
|
218
|
+
// read-denied clientSecret must survive even though the app also
|
|
219
|
+
// declares a `clientSecret` field on its own `OauthClient` list.
|
|
220
|
+
const mcpBetterAuthPlugin = {
|
|
221
|
+
id: 'test-mcp',
|
|
222
|
+
schema: {
|
|
223
|
+
oauthClient: {
|
|
224
|
+
fields: {
|
|
225
|
+
clientSecret: { type: 'string' as const, required: false },
|
|
226
|
+
name: { type: 'string' as const, required: false },
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const result = await config({
|
|
233
|
+
db: { provider: 'sqlite' },
|
|
234
|
+
plugins: [authPlugin({ betterAuthPlugins: [mcpBetterAuthPlugin] })],
|
|
235
|
+
lists: {
|
|
236
|
+
OauthClient: list({
|
|
237
|
+
fields: { clientSecret: text() },
|
|
238
|
+
access: { operation: { query: () => true } },
|
|
239
|
+
}),
|
|
240
|
+
},
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
const oauthClient = result.lists.OauthClient
|
|
244
|
+
expect(oauthClient.fields.clientSecret.access?.read).toBeTypeOf('function')
|
|
245
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal read-access call fixture
|
|
246
|
+
expect(await oauthClient.fields.clientSecret.access!.read!({} as any)).toBe(false)
|
|
247
|
+
// The app's own access survives (extendList never forwards access).
|
|
248
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- access control parameters are runtime values
|
|
249
|
+
expect(oauthClient.access?.operation?.query?.({} as any)).toBe(true)
|
|
250
|
+
})
|
|
212
251
|
})
|
|
213
252
|
|
|
214
253
|
describe('authPlugin - runtime user-key resolution', () => {
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { mcp } from '@better-auth/mcp'
|
|
3
|
+
import { deriveAuthLists } from '../src/config/derive-auth-lists.js'
|
|
4
|
+
import type { NormalizedAuthModels } from '../src/config/types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Coverage for the consolidated plugin-table derivation (issue #992):
|
|
8
|
+
* `deriveAuthLists` now covers better-auth plugin tables (e.g. the MCP
|
|
9
|
+
* plugin's OAuth tables) through the same registry and field-derivation pass
|
|
10
|
+
* as the four base models, instead of a separate converter that dropped
|
|
11
|
+
* references to bare columns. See `auth-lists-drift.test.ts` for a
|
|
12
|
+
* field-by-field comparison against better-auth's own table definitions, and
|
|
13
|
+
* `generated-fk-shape.test.ts` for the generated-schema-text assertions.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const defaultModels: NormalizedAuthModels = {
|
|
17
|
+
user: { modelName: 'User', fields: {} },
|
|
18
|
+
session: { modelName: 'Session', fields: {} },
|
|
19
|
+
account: { modelName: 'Account', fields: {} },
|
|
20
|
+
verification: { modelName: 'Verification', fields: {} },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('deriveAuthLists — better-auth plugin tables (issue #992)', () => {
|
|
24
|
+
it('derives every MCP OAuth table under a PascalCase key, mapped back to its camelCase physical table', () => {
|
|
25
|
+
const { lists } = deriveAuthLists(defaultModels, {}, {}, [
|
|
26
|
+
mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'https://example.com/mcp' }),
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
expect(Object.keys(lists).sort()).toEqual([
|
|
30
|
+
'Account',
|
|
31
|
+
'OauthAccessToken',
|
|
32
|
+
'OauthClient',
|
|
33
|
+
'OauthClientAssertion',
|
|
34
|
+
'OauthClientResource',
|
|
35
|
+
'OauthConsent',
|
|
36
|
+
'OauthRefreshToken',
|
|
37
|
+
'OauthResource',
|
|
38
|
+
'Session',
|
|
39
|
+
'User',
|
|
40
|
+
'Verification',
|
|
41
|
+
])
|
|
42
|
+
expect(lists.OauthClient.db?.map).toBe('oauthClient')
|
|
43
|
+
expect(lists.OauthAccessToken.db?.map).toBe('oauthAccessToken')
|
|
44
|
+
expect(lists.OauthConsent.db?.map).toBe('oauthConsent')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('generates a real relationship (not a bare column) for a PK-targeting reference (userId -> user.id)', () => {
|
|
48
|
+
const { lists } = deriveAuthLists(defaultModels, {}, {}, [
|
|
49
|
+
mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'https://example.com/mcp' }),
|
|
50
|
+
])
|
|
51
|
+
const userField = lists.OauthClient.fields.user
|
|
52
|
+
|
|
53
|
+
expect(userField.type).toBe('relationship')
|
|
54
|
+
expect(userField.ref).toBe('User.oauthClients')
|
|
55
|
+
expect(userField.db?.foreignKey).toEqual({ map: 'userId' })
|
|
56
|
+
// oauthClient.userId is optional upstream (required: false).
|
|
57
|
+
expect(userField.db?.isNullable).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('carries the referential action from `onDelete: cascade` through to the generated relation', () => {
|
|
61
|
+
const { lists } = deriveAuthLists(defaultModels, {}, {}, [
|
|
62
|
+
mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'https://example.com/mcp' }),
|
|
63
|
+
])
|
|
64
|
+
const userField = lists.OauthClient.fields.user
|
|
65
|
+
const extend = userField.db?.extendPrismaSchema
|
|
66
|
+
const relationLine = extend
|
|
67
|
+
? extend({ relationLine: '@relation(fields: [x], references: [id])' }).relationLine
|
|
68
|
+
: ''
|
|
69
|
+
|
|
70
|
+
expect(relationLine).toContain('onDelete: Cascade')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('maps every declared referential action, not just cascade', () => {
|
|
74
|
+
const plugin = {
|
|
75
|
+
id: 'test-referential-actions',
|
|
76
|
+
schema: {
|
|
77
|
+
widget: {
|
|
78
|
+
modelName: 'widget',
|
|
79
|
+
fields: {
|
|
80
|
+
ownerId: {
|
|
81
|
+
type: 'string',
|
|
82
|
+
required: true,
|
|
83
|
+
references: { model: 'user', field: 'id', onDelete: 'restrict' as const },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
const { lists } = deriveAuthLists(defaultModels, {}, {}, [plugin])
|
|
90
|
+
const extend = lists.Widget.fields.owner.db?.extendPrismaSchema
|
|
91
|
+
const relationLine = extend
|
|
92
|
+
? extend({ relationLine: '@relation(fields: [x], references: [id])' }).relationLine
|
|
93
|
+
: ''
|
|
94
|
+
|
|
95
|
+
expect(relationLine).toContain('onDelete: Restrict')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('leaves a non-PK-targeting reference as a plain scalar column (clientId -> oauthClient.clientId)', () => {
|
|
99
|
+
const { lists } = deriveAuthLists(defaultModels, {}, {}, [
|
|
100
|
+
mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'https://example.com/mcp' }),
|
|
101
|
+
])
|
|
102
|
+
const clientId = lists.OauthAccessToken.fields.clientId
|
|
103
|
+
|
|
104
|
+
expect(clientId.type).toBe('text')
|
|
105
|
+
expect(clientId).not.toHaveProperty('ref')
|
|
106
|
+
// Still indexed, since better-auth declares `index: true` on this column.
|
|
107
|
+
expect(clientId.isIndexed).toBe(true)
|
|
108
|
+
expect(clientId.validation?.isRequired).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('adds a reverse relation on the target base list for every plugin-table reference to it, with no name collisions', () => {
|
|
112
|
+
const { lists } = deriveAuthLists(defaultModels, {}, {}, [
|
|
113
|
+
mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'https://example.com/mcp' }),
|
|
114
|
+
])
|
|
115
|
+
|
|
116
|
+
expect(lists.User.fields.oauthClients).toMatchObject({
|
|
117
|
+
type: 'relationship',
|
|
118
|
+
ref: 'OauthClient.user',
|
|
119
|
+
many: true,
|
|
120
|
+
})
|
|
121
|
+
expect(lists.User.fields.oauthAccessTokens).toMatchObject({
|
|
122
|
+
type: 'relationship',
|
|
123
|
+
ref: 'OauthAccessToken.user',
|
|
124
|
+
many: true,
|
|
125
|
+
})
|
|
126
|
+
expect(lists.User.fields.oauthConsents).toMatchObject({
|
|
127
|
+
type: 'relationship',
|
|
128
|
+
ref: 'OauthConsent.user',
|
|
129
|
+
many: true,
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('honours fieldName column maps and index: true on plugin scalar fields', () => {
|
|
134
|
+
const plugin = {
|
|
135
|
+
id: 'test-column-map',
|
|
136
|
+
schema: {
|
|
137
|
+
widget: {
|
|
138
|
+
modelName: 'widget',
|
|
139
|
+
fields: {
|
|
140
|
+
label: { type: 'string', fieldName: 'display_label', index: true },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
const { lists } = deriveAuthLists(defaultModels, {}, {}, [plugin])
|
|
146
|
+
const label = lists.Widget.fields.label
|
|
147
|
+
|
|
148
|
+
expect(label.db?.map).toBe('display_label')
|
|
149
|
+
expect(label.isIndexed).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('ships plugin-table lists closed — no access control', () => {
|
|
153
|
+
const { lists } = deriveAuthLists(defaultModels, {}, {}, [
|
|
154
|
+
mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'https://example.com/mcp' }),
|
|
155
|
+
])
|
|
156
|
+
|
|
157
|
+
expect(lists.OauthClient.access).toBeUndefined()
|
|
158
|
+
expect(lists.OauthAccessToken.access).toBeUndefined()
|
|
159
|
+
expect(lists.OauthConsent.access).toBeUndefined()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('sets no db.map when a plugin already declares a PascalCase modelName', () => {
|
|
163
|
+
const plugin = {
|
|
164
|
+
id: 'test-passkey',
|
|
165
|
+
schema: { passkey: { modelName: 'Passkey', fields: { publicKey: { type: 'string' } } } },
|
|
166
|
+
}
|
|
167
|
+
const { lists } = deriveAuthLists(defaultModels, {}, {}, [plugin])
|
|
168
|
+
|
|
169
|
+
expect(lists).toHaveProperty('Passkey')
|
|
170
|
+
expect(lists.Passkey.db?.map).toBeUndefined()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('PascalCases a snake_case schema key with no modelName override', () => {
|
|
174
|
+
const plugin = {
|
|
175
|
+
id: 'test-snake-case',
|
|
176
|
+
schema: { oauth_widget: { fields: { data: { type: 'string' } } } },
|
|
177
|
+
}
|
|
178
|
+
const { lists } = deriveAuthLists(defaultModels, {}, {}, [plugin])
|
|
179
|
+
|
|
180
|
+
expect(lists).toHaveProperty('OauthWidget')
|
|
181
|
+
expect(lists.OauthWidget.db?.map).toBe('oauth_widget')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('throws a clear error when a plugin table references a model outside the registry', () => {
|
|
185
|
+
const plugin = {
|
|
186
|
+
id: 'test-bad-reference',
|
|
187
|
+
schema: {
|
|
188
|
+
widget: {
|
|
189
|
+
modelName: 'widget',
|
|
190
|
+
fields: {
|
|
191
|
+
ownerId: {
|
|
192
|
+
type: 'string',
|
|
193
|
+
references: { model: 'nonexistentModel', field: 'id' },
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
expect(() => deriveAuthLists(defaultModels, {}, {}, [plugin])).toThrow(
|
|
201
|
+
/widget\.ownerId.*references unknown model "nonexistentModel"/,
|
|
202
|
+
)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('throws a clear error instead of silently colliding when two id-references on the same table target the same model', () => {
|
|
206
|
+
const plugin = {
|
|
207
|
+
id: 'test-reverse-collision',
|
|
208
|
+
schema: {
|
|
209
|
+
widget: {
|
|
210
|
+
modelName: 'widget',
|
|
211
|
+
fields: {
|
|
212
|
+
ownerId: { type: 'string', references: { model: 'user', field: 'id' } },
|
|
213
|
+
reviewerId: { type: 'string', references: { model: 'user', field: 'id' } },
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
expect(() => deriveAuthLists(defaultModels, {}, {}, [plugin])).toThrow(
|
|
220
|
+
/"widget" has more than one reference to "user".*"widgets"/,
|
|
221
|
+
)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('falls back to a text() column with a warning for a better-auth field type this derivation does not model (e.g. json)', () => {
|
|
225
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
226
|
+
try {
|
|
227
|
+
const plugin = {
|
|
228
|
+
id: 'test-unsupported-type',
|
|
229
|
+
schema: {
|
|
230
|
+
widget: { modelName: 'widget', fields: { metadata: { type: 'json' } } },
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const { lists } = deriveAuthLists(defaultModels, {}, {}, [plugin])
|
|
235
|
+
|
|
236
|
+
expect(lists.Widget.fields.metadata.type).toBe('text')
|
|
237
|
+
expect(warn).toHaveBeenCalledWith(
|
|
238
|
+
expect.stringContaining('Unknown better-auth field type "json"'),
|
|
239
|
+
)
|
|
240
|
+
} finally {
|
|
241
|
+
warn.mockRestore()
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('merges a plugin schema extension of a base model directly into that base list, without a separate table', () => {
|
|
246
|
+
const anonymousPlugin = {
|
|
247
|
+
id: 'anonymous',
|
|
248
|
+
schema: { user: { fields: { isAnonymous: { type: 'boolean', required: false } } } },
|
|
249
|
+
}
|
|
250
|
+
const { lists } = deriveAuthLists(defaultModels, {}, {}, [anonymousPlugin])
|
|
251
|
+
|
|
252
|
+
expect(Object.keys(lists).sort()).toEqual(['Account', 'Session', 'User', 'Verification'])
|
|
253
|
+
expect(lists.User.fields).toHaveProperty('isAnonymous')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('resolves a plugin table reference to a base model through a modelName remap (e.g. AuthUser)', () => {
|
|
257
|
+
const remappedModels: NormalizedAuthModels = {
|
|
258
|
+
user: { modelName: 'AuthUser', fields: {} },
|
|
259
|
+
session: { modelName: 'AuthSession', fields: {} },
|
|
260
|
+
account: { modelName: 'AuthAccount', fields: {} },
|
|
261
|
+
verification: { modelName: 'AuthVerification', fields: {} },
|
|
262
|
+
}
|
|
263
|
+
const { lists } = deriveAuthLists(remappedModels, {}, {}, [
|
|
264
|
+
mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'https://example.com/mcp' }),
|
|
265
|
+
])
|
|
266
|
+
|
|
267
|
+
expect(lists.OauthClient.fields.user.ref).toBe('AuthUser.oauthClients')
|
|
268
|
+
expect(lists.AuthUser.fields.oauthClients).toBeDefined()
|
|
269
|
+
})
|
|
270
|
+
})
|