@opensaas/stack-auth 0.39.1 → 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.
Files changed (104) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +153 -0
  3. package/CLAUDE.md +140 -22
  4. package/dist/client/index.d.ts +0 -16
  5. package/dist/client/index.d.ts.map +1 -1
  6. package/dist/config/adopt-better-auth-tables.d.ts +4 -49
  7. package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
  8. package/dist/config/adopt-better-auth-tables.js +4 -51
  9. package/dist/config/adopt-better-auth-tables.js.map +1 -1
  10. package/dist/config/derive-auth-lists.d.ts +42 -37
  11. package/dist/config/derive-auth-lists.d.ts.map +1 -1
  12. package/dist/config/derive-auth-lists.js +579 -223
  13. package/dist/config/derive-auth-lists.js.map +1 -1
  14. package/dist/config/index.d.ts +0 -3
  15. package/dist/config/index.d.ts.map +1 -1
  16. package/dist/config/index.js +4 -34
  17. package/dist/config/index.js.map +1 -1
  18. package/dist/config/plugin.d.ts.map +1 -1
  19. package/dist/config/plugin.js +20 -90
  20. package/dist/config/plugin.js.map +1 -1
  21. package/dist/config/types.d.ts +89 -67
  22. package/dist/config/types.d.ts.map +1 -1
  23. package/dist/index.d.ts +1 -9
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -13
  26. package/dist/index.js.map +1 -1
  27. package/dist/lists/index.d.ts +9 -5
  28. package/dist/lists/index.d.ts.map +1 -1
  29. package/dist/lists/index.js +9 -6
  30. package/dist/lists/index.js.map +1 -1
  31. package/dist/mcp/better-auth.d.ts +6 -19
  32. package/dist/mcp/better-auth.d.ts.map +1 -1
  33. package/dist/mcp/better-auth.js +6 -20
  34. package/dist/mcp/better-auth.js.map +1 -1
  35. package/dist/plugins/index.d.ts +4 -4
  36. package/dist/plugins/index.d.ts.map +1 -1
  37. package/dist/plugins/index.js +4 -4
  38. package/dist/plugins/index.js.map +1 -1
  39. package/dist/runtime/types.d.ts +2 -9
  40. package/dist/runtime/types.d.ts.map +1 -1
  41. package/dist/runtime/types.js +0 -4
  42. package/dist/runtime/types.js.map +1 -1
  43. package/dist/server/index.d.ts +5 -12
  44. package/dist/server/index.d.ts.map +1 -1
  45. package/dist/server/index.js +14 -62
  46. package/dist/server/index.js.map +1 -1
  47. package/dist/ui/components/ForgotPasswordForm.d.ts +0 -3
  48. package/dist/ui/components/ForgotPasswordForm.d.ts.map +1 -1
  49. package/dist/ui/components/ForgotPasswordForm.js +0 -3
  50. package/dist/ui/components/ForgotPasswordForm.js.map +1 -1
  51. package/dist/ui/components/ResetPasswordForm.d.ts +0 -3
  52. package/dist/ui/components/ResetPasswordForm.d.ts.map +1 -1
  53. package/dist/ui/components/ResetPasswordForm.js +0 -5
  54. package/dist/ui/components/ResetPasswordForm.js.map +1 -1
  55. package/dist/ui/components/SignInForm.d.ts +0 -3
  56. package/dist/ui/components/SignInForm.d.ts.map +1 -1
  57. package/dist/ui/components/SignInForm.js +0 -4
  58. package/dist/ui/components/SignInForm.js.map +1 -1
  59. package/dist/ui/components/SignUpForm.d.ts +0 -3
  60. package/dist/ui/components/SignUpForm.d.ts.map +1 -1
  61. package/dist/ui/components/SignUpForm.js +0 -5
  62. package/dist/ui/components/SignUpForm.js.map +1 -1
  63. package/dist/ui/lib/clean-error-message.d.ts +3 -6
  64. package/dist/ui/lib/clean-error-message.d.ts.map +1 -1
  65. package/dist/ui/lib/clean-error-message.js +3 -6
  66. package/dist/ui/lib/clean-error-message.js.map +1 -1
  67. package/dist/ui/types.d.ts +2 -15
  68. package/dist/ui/types.d.ts.map +1 -1
  69. package/dist/ui/types.js +2 -7
  70. package/dist/ui/types.js.map +1 -1
  71. package/package.json +12 -5
  72. package/src/client/index.ts +0 -17
  73. package/src/config/adopt-better-auth-tables.ts +4 -51
  74. package/src/config/derive-auth-lists.ts +685 -252
  75. package/src/config/index.ts +4 -34
  76. package/src/config/plugin.ts +21 -91
  77. package/src/config/types.ts +90 -67
  78. package/src/index.ts +3 -14
  79. package/src/lists/index.ts +17 -5
  80. package/src/mcp/better-auth.ts +6 -25
  81. package/src/plugins/index.ts +4 -5
  82. package/src/runtime/types.ts +2 -10
  83. package/src/server/index.ts +17 -70
  84. package/src/ui/components/ForgotPasswordForm.tsx +0 -3
  85. package/src/ui/components/ResetPasswordForm.tsx +0 -5
  86. package/src/ui/components/SignInForm.tsx +0 -4
  87. package/src/ui/components/SignUpForm.tsx +0 -5
  88. package/src/ui/lib/clean-error-message.ts +3 -6
  89. package/src/ui/types.ts +2 -15
  90. package/tests/auth-lists-drift.test.ts +480 -0
  91. package/tests/config.test.ts +3 -0
  92. package/tests/credential-field-read-deny-e2e.test.ts +295 -0
  93. package/tests/derive-auth-lists.test.ts +263 -6
  94. package/tests/generated-fk-shape.test.ts +432 -9
  95. package/tests/mcp-oauth-cascade-e2e.test.ts +267 -0
  96. package/tests/plugin-derived-keys.test.ts +39 -0
  97. package/tests/plugin-table-derivation.test.ts +270 -0
  98. package/tsconfig.tsbuildinfo +1 -1
  99. package/dist/server/schema-converter.d.ts +0 -81
  100. package/dist/server/schema-converter.d.ts.map +0 -1
  101. package/dist/server/schema-converter.js +0 -157
  102. package/dist/server/schema-converter.js.map +0 -1
  103. package/src/server/schema-converter.ts +0 -243
  104. package/tests/schema-converter.test.ts +0 -399
@@ -0,0 +1,295 @@
1
+ import { describe, it, expect, afterAll } 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 for issue #981 / ADR-0036: the credential-bearing
14
+ * fields the auth plugin now ships read-denied (`Session.token`,
15
+ * `Verification.value`, `Account.password`/`accessToken`/`refreshToken`/
16
+ * `idToken`) are actually stripped from an opened list's `context.db` reads,
17
+ * `sudo()` still reads them, and — the part a unit test on `deriveAuthLists`
18
+ * cannot show — better-auth's own sign-up/sign-in/password-reset flows are
19
+ * unaffected because they write through the raw Prisma adapter, never
20
+ * through the access-controlled context these denies gate.
21
+ *
22
+ * Generates a real Prisma schema, pushes it to a real SQLite database,
23
+ * drives a real `betterAuth()` instance (via `createAuth()`, this package's
24
+ * own source) through real sign-up/sign-in/password-reset requests, and
25
+ * reads the resulting rows back through both an access-opened context and a
26
+ * sudo context. Follows the same opt-in/offline-toolchain pattern as
27
+ * `rate-limit-e2e.test.ts` (see ADR-0002) — kept out of the fast unit lane,
28
+ * run only in the `e2e` CI job where `pnpm install && pnpm build` have
29
+ * already run.
30
+ */
31
+
32
+ const run = promisify(execFile)
33
+ const here = path.dirname(new URL(import.meta.url).pathname)
34
+ const repoRoot = path.resolve(here, '../../..')
35
+
36
+ const opensaasCli = path.join(repoRoot, 'packages/cli/dist/index.js')
37
+ const coreDist = path.join(repoRoot, 'packages/core/dist')
38
+ const authDist = path.join(repoRoot, 'packages/auth/dist')
39
+ const toolchainNodeModules = path.join(repoRoot, 'examples/starter-auth/node_modules')
40
+
41
+ const guardEnabled = process.env.RUN_CREDENTIAL_DENY_E2E === '1'
42
+
43
+ const prerequisitesPresent =
44
+ guardEnabled &&
45
+ existsSync(opensaasCli) &&
46
+ existsSync(coreDist) &&
47
+ existsSync(authDist) &&
48
+ existsSync(path.join(toolchainNodeModules, '.bin', 'opensaas')) &&
49
+ existsSync(path.join(toolchainNodeModules, '.bin', 'prisma')) &&
50
+ existsSync(path.join(toolchainNodeModules, '@prisma', 'adapter-better-sqlite3')) &&
51
+ existsSync(path.join(toolchainNodeModules, 'better-auth', 'dist', 'test-utils'))
52
+
53
+ /**
54
+ * The temp project's `opensaas.config.ts` — sqlite + email/password auth with
55
+ * every derived Auth list opened for `query`, so a `context.db` read is what
56
+ * exercises the field-level deny (a closed list would return `null`/`[]`
57
+ * outright and prove nothing about field stripping). `sendResetPassword`
58
+ * writes the reset token to a file the test reads back, since it runs in the
59
+ * temp project's own module scope, not the test process's.
60
+ */
61
+ function configSource(resetTokenFile: string): string {
62
+ return `import { config } from '@opensaas/stack-core'
63
+ import { authPlugin } from '@opensaas/stack-auth'
64
+ import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
65
+ import { writeFile } from 'node:fs/promises'
66
+
67
+ const openQuery = { operation: { query: () => true } }
68
+
69
+ export default config({
70
+ plugins: [
71
+ authPlugin({
72
+ emailAndPassword: {
73
+ enabled: true,
74
+ sendResetPassword: async ({ token }) => {
75
+ await writeFile(${JSON.stringify(resetTokenFile)}, token, 'utf-8')
76
+ },
77
+ },
78
+ passwordReset: { enabled: true },
79
+ access: {
80
+ user: openQuery,
81
+ session: openQuery,
82
+ account: openQuery,
83
+ verification: openQuery,
84
+ },
85
+ }),
86
+ ],
87
+ db: {
88
+ provider: 'sqlite',
89
+ prismaClientConstructor: (PrismaClient) => {
90
+ const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL || './dev.db' })
91
+ return new PrismaClient({ adapter })
92
+ },
93
+ },
94
+ lists: {},
95
+ })
96
+ `
97
+ }
98
+
99
+ async function setupProject(): Promise<{ dir: string; resetTokenFile: string }> {
100
+ const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'opensaas-credential-deny-e2e-'))
101
+ const dir = path.join(tmpRoot, 'project')
102
+ await fsp.mkdir(dir, { recursive: true })
103
+ const resetTokenFile = path.join(dir, 'reset-token.txt')
104
+
105
+ await fsp.writeFile(path.join(dir, 'opensaas.config.ts'), configSource(resetTokenFile))
106
+ await fsp.writeFile(
107
+ path.join(dir, 'package.json'),
108
+ JSON.stringify(
109
+ { name: 'credential-deny-e2e-project', version: '0.0.0', private: true, type: 'module' },
110
+ null,
111
+ 2,
112
+ ),
113
+ )
114
+ await fsp.writeFile(path.join(dir, '.env'), 'DATABASE_URL=file:./dev.db\n')
115
+ await fsp.symlink(toolchainNodeModules, path.join(dir, 'node_modules'))
116
+
117
+ const env = {
118
+ ...process.env,
119
+ DATABASE_URL: 'file:./dev.db',
120
+ BETTER_AUTH_SECRET: 'e2e-test-secret-not-for-production-0000000000',
121
+ BETTER_AUTH_URL: 'http://localhost:3000',
122
+ }
123
+ const binDir = path.join(dir, 'node_modules', '.bin')
124
+ await run(path.join(binDir, 'opensaas'), ['generate'], { cwd: dir, env })
125
+ await run(path.join(binDir, 'prisma'), ['db', 'push'], { cwd: dir, env })
126
+
127
+ return { dir, resetTokenFile }
128
+ }
129
+
130
+ /** Import the temp project's config + generated context and build a real auth instance via `createAuth()`. */
131
+ async function createAuthInstanceForProject(dir: string) {
132
+ process.env.DATABASE_URL = `file:${path.join(dir, 'dev.db')}`
133
+
134
+ const config = (
135
+ (await import(/* @vite-ignore */ pathToFileURL(path.join(dir, 'opensaas.config.ts')).href)) as {
136
+ default: OpenSaasConfig | Promise<OpenSaasConfig>
137
+ }
138
+ ).default
139
+ const { rawOpensaasContext } = (await import(
140
+ /* @vite-ignore */ pathToFileURL(path.join(dir, '.opensaas/context.ts')).href
141
+ )) as { rawOpensaasContext: Promise<AccessContext> }
142
+
143
+ const resolvedContext = await rawOpensaasContext
144
+
145
+ return { auth: createAuth(config, resolvedContext), context: resolvedContext }
146
+ }
147
+
148
+ async function cleanupProject(dir: string): Promise<void> {
149
+ delete (globalThis as { prisma?: unknown }).prisma
150
+ const linkPath = path.join(dir, 'node_modules')
151
+ if (existsSync(linkPath)) {
152
+ await fsp.unlink(linkPath)
153
+ }
154
+ await fsp.rm(path.dirname(dir), { recursive: true, force: true })
155
+ }
156
+
157
+ describe.skipIf(!prerequisitesPresent)(
158
+ 'plugin-derived credential fields ship read-denied — live end-to-end (issue #981, ADR-0036)',
159
+ () => {
160
+ it('strips credential fields from an opened context.db read, keeps them under sudo(), and leaves sign-up/sign-in/session-refresh/password-reset unaffected', async () => {
161
+ const { dir, resetTokenFile } = await setupProject()
162
+ try {
163
+ const { auth, context } = await createAuthInstanceForProject(dir)
164
+ const { convertSetCookieToCookie } = (await import(
165
+ /* @vite-ignore */ pathToFileURL(
166
+ path.join(dir, 'node_modules/better-auth/dist/test-utils/index.mjs'),
167
+ ).href
168
+ )) as { convertSetCookieToCookie: (h: Headers) => Headers }
169
+
170
+ const email = 'reader@example.com'
171
+ const password = 'correct horse battery staple'
172
+
173
+ // Sign-up writes User + Account (with the hashed password) through
174
+ // better-auth's own raw-Prisma adapter — unaffected by the deny.
175
+ const signUpRes = await auth.api.signUpEmail({
176
+ body: { name: 'Reader', email, password },
177
+ asResponse: true,
178
+ })
179
+ expect(signUpRes.status).toBe(200)
180
+
181
+ // Sign-in writes a Session row and returns a session cookie.
182
+ const signInRes = await auth.api.signInEmail({
183
+ body: { email, password },
184
+ asResponse: true,
185
+ })
186
+ expect(signInRes.status).toBe(200)
187
+ const cookieHeaders = convertSetCookieToCookie(signInRes.headers)
188
+
189
+ // Session refresh: the cookie from sign-in resolves a session twice.
190
+ for (let i = 0; i < 2; i++) {
191
+ const session = await auth.api.getSession({ headers: cookieHeaders })
192
+ expect(session?.user.email).toBe(email)
193
+ }
194
+
195
+ // --- context.db reads, with operation-level access opened ---
196
+
197
+ const sessions = await context.db.session.findMany({})
198
+ expect(sessions.length).toBeGreaterThan(0)
199
+ for (const s of sessions) {
200
+ expect(s.token).toBeUndefined()
201
+ expect(s.ipAddress === null || typeof s.ipAddress === 'string').toBe(true)
202
+ }
203
+
204
+ const accounts = await context.db.account.findMany({})
205
+ expect(accounts.length).toBeGreaterThan(0)
206
+ for (const a of accounts) {
207
+ expect(a.password).toBeUndefined()
208
+ expect(a.accessToken).toBeUndefined()
209
+ expect(a.refreshToken).toBeUndefined()
210
+ expect(a.idToken).toBeUndefined()
211
+ // Identifying fields stay open.
212
+ expect(a.providerId).toBe('credential')
213
+ }
214
+
215
+ // --- sudo() still reads every one of these fields ---
216
+
217
+ const sudoAccounts = await context.sudo().db.account.findMany({})
218
+ expect(sudoAccounts.length).toBeGreaterThan(0)
219
+ expect(typeof sudoAccounts[0].password).toBe('string')
220
+ expect(sudoAccounts[0].password!.length).toBeGreaterThan(0)
221
+
222
+ const sudoSessions = await context.sudo().db.session.findMany({})
223
+ expect(typeof sudoSessions[0].token).toBe('string')
224
+ expect(sudoSessions[0].token!.length).toBeGreaterThan(0)
225
+
226
+ // Naming a denied field in findMany's where/orderBy is rejected up
227
+ // front by the predicate-time read-access check, not silently
228
+ // stripped (findUnique's `where` isn't walked by that check at all —
229
+ // it only unique-selects the row, so it still returns the row with
230
+ // `token` stripped from the output, same as any other read).
231
+ await expect(
232
+ context.db.session.findMany({ where: { token: sudoSessions[0].token } }),
233
+ ).rejects.toThrow()
234
+ await expect(
235
+ context.sudo().db.session.findMany({ where: { token: sudoSessions[0].token } }),
236
+ ).resolves.not.toHaveLength(0)
237
+
238
+ // --- password reset: exercises Verification.value end-to-end ---
239
+
240
+ const resetReq = await auth.api.requestPasswordReset({
241
+ body: { email },
242
+ asResponse: true,
243
+ })
244
+ expect(resetReq.status).toBe(200)
245
+
246
+ // context.db strips Verification.value even though a live reset token exists.
247
+ const verifications = await context.db.verification.findMany({})
248
+ expect(verifications.length).toBeGreaterThan(0)
249
+ for (const v of verifications) {
250
+ expect(v.value).toBeUndefined()
251
+ expect(typeof v.identifier).toBe('string')
252
+ }
253
+ const sudoVerifications = await context.sudo().db.verification.findMany({})
254
+ expect(typeof sudoVerifications[0].value).toBe('string')
255
+
256
+ const resetToken = (await fsp.readFile(resetTokenFile, 'utf-8')).trim()
257
+ expect(resetToken.length).toBeGreaterThan(0)
258
+
259
+ const newPassword = 'a new correct horse battery staple'
260
+ const resetRes = await auth.api.resetPassword({
261
+ body: { newPassword, token: resetToken },
262
+ asResponse: true,
263
+ })
264
+ expect(resetRes.status).toBe(200)
265
+
266
+ // Sign in with the new password proves the reset (through the raw
267
+ // adapter, past the field-level deny) actually took effect.
268
+ const signInAfterReset = await auth.api.signInEmail({
269
+ body: { email, password: newPassword },
270
+ asResponse: true,
271
+ })
272
+ expect(signInAfterReset.status).toBe(200)
273
+
274
+ await context.prisma.$disconnect()
275
+ } finally {
276
+ await cleanupProject(dir)
277
+ }
278
+ }, 120_000)
279
+ },
280
+ )
281
+
282
+ // Surface, in a normal unit run, why this guard was skipped.
283
+ describe.runIf(!prerequisitesPresent)('credential field read-deny e2e (skipped)', () => {
284
+ it('runs only in the e2e job (set RUN_CREDENTIAL_DENY_E2E=1 after install + build)', () => {
285
+ expect(prerequisitesPresent).toBe(false)
286
+ })
287
+
288
+ afterAll(() => {
289
+ if (!guardEnabled) return
290
+ console.warn(
291
+ '[credential-field-read-deny-e2e] RUN_CREDENTIAL_DENY_E2E=1 was set but prerequisites are missing. ' +
292
+ 'Run `pnpm install && pnpm build` (and ensure examples/starter-auth has been installed) first.',
293
+ )
294
+ })
295
+ })
@@ -1,4 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import { mcp } from '@better-auth/mcp'
3
+ import { twoFactor } from 'better-auth/plugins'
2
4
  import { deriveAuthLists } from '../src/config/derive-auth-lists.js'
3
5
  import type { NormalizedAuthModels } from '../src/config/types.js'
4
6
 
@@ -61,8 +63,11 @@ describe('deriveAuthLists - default behaviour (no overrides)', () => {
61
63
 
62
64
  expect(lists.User.fields.name.db?.map).toBeUndefined()
63
65
  expect(lists.Session.fields.token.db?.map).toBeUndefined()
64
- // FK column not overridden -> no foreignKey map on the relationship
65
- expect(lists.Session.fields.user.db?.foreignKey).toBeUndefined()
66
+ // FK column not overridden -> still defaults to better-auth's own
67
+ // `userId` column name, not the generator's Keystone-parity default
68
+ // (issue #935)
69
+ expect(lists.Session.fields.user.db?.foreignKey).toEqual({ map: 'userId' })
70
+ expect(lists.Account.fields.user.db?.foreignKey).toEqual({ map: 'userId' })
66
71
  })
67
72
 
68
73
  it('opts every auth list into auto-timestamps', () => {
@@ -79,12 +84,18 @@ describe('deriveAuthLists - default behaviour (no overrides)', () => {
79
84
  })
80
85
  })
81
86
 
82
- describe('deriveAuthLists - user FK shape mirrors better-auth (issue #679)', () => {
83
- it('disables the default FK index on Session.user and Account.user', () => {
87
+ describe('deriveAuthLists - user FK shape mirrors better-auth (issue #679/#937)', () => {
88
+ it('indexes the user FK on Session.user and Account.user, matching better-auth', () => {
84
89
  const { lists } = deriveAuthLists(defaultModels)
85
90
 
86
- expect(lists.Session.fields.user.isIndexed).toBe(false)
87
- expect(lists.Account.fields.user.isIndexed).toBe(false)
91
+ expect(lists.Session.fields.user.isIndexed).toBe(true)
92
+ expect(lists.Account.fields.user.isIndexed).toBe(true)
93
+ })
94
+
95
+ it('indexes Verification.identifier, matching better-auth', () => {
96
+ const { lists } = deriveAuthLists(defaultModels)
97
+
98
+ expect(lists.Verification.fields.identifier.isIndexed).toBe(true)
88
99
  })
89
100
 
90
101
  it('does not touch isIndexed on the email/token unique fields', () => {
@@ -448,6 +459,252 @@ describe('deriveAuthLists - RateLimit list (rateLimit.storage === "database")',
448
459
  })
449
460
  })
450
461
 
462
+ describe('deriveAuthLists - credential fields ship read-denied (ADR-0036, issue #981)', () => {
463
+ it('denies read on Session.token, Verification.value, and the Account credential fields', async () => {
464
+ const { lists } = deriveAuthLists(defaultModels)
465
+
466
+ const denied: Array<[string, string]> = [
467
+ ['Session', 'token'],
468
+ ['Verification', 'value'],
469
+ ['Account', 'password'],
470
+ ['Account', 'accessToken'],
471
+ ['Account', 'refreshToken'],
472
+ ['Account', 'idToken'],
473
+ ]
474
+
475
+ for (const [listKey, fieldKey] of denied) {
476
+ const field = lists[listKey].fields[fieldKey]
477
+ expect(field.access?.read).toBeTypeOf('function')
478
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal read-access call fixture
479
+ expect(await field.access!.read!({} as any)).toBe(false)
480
+ }
481
+ })
482
+
483
+ it('also curates read-denied credential fields out of the admin default columns (issue #1018)', async () => {
484
+ const { lists } = deriveAuthLists(defaultModels)
485
+
486
+ const denied: Array<[string, string]> = [
487
+ ['Session', 'token'],
488
+ ['Verification', 'value'],
489
+ ['Account', 'password'],
490
+ ['Account', 'accessToken'],
491
+ ['Account', 'refreshToken'],
492
+ ['Account', 'idToken'],
493
+ ]
494
+
495
+ for (const [listKey, fieldKey] of denied) {
496
+ const field = lists[listKey].fields[fieldKey]
497
+ expect(field.ui?.listView?.defaultColumn).toBe(false)
498
+ }
499
+
500
+ // A non-credential field is left with no ui.listView declaration at all.
501
+ expect(lists.Session.fields.ipAddress.ui?.listView).toBeUndefined()
502
+ })
503
+
504
+ it('leaves identifying fields open — session/account metadata and every User field', () => {
505
+ const { lists } = deriveAuthLists(defaultModels)
506
+
507
+ const open: Array<[string, string]> = [
508
+ ['Session', 'ipAddress'],
509
+ ['Session', 'userAgent'],
510
+ ['Session', 'expiresAt'],
511
+ ['Account', 'providerId'],
512
+ ['Account', 'accountId'],
513
+ ['Account', 'scope'],
514
+ ['Verification', 'identifier'],
515
+ ['Verification', 'expiresAt'],
516
+ ['User', 'name'],
517
+ ['User', 'email'],
518
+ ]
519
+
520
+ for (const [listKey, fieldKey] of open) {
521
+ expect(lists[listKey].fields[fieldKey].access).toBeUndefined()
522
+ }
523
+ })
524
+
525
+ it('keys the deny to the better-auth model/field, surviving a modelName + column remap', () => {
526
+ const models: NormalizedAuthModels = {
527
+ user: { modelName: 'AuthUser', fields: {} },
528
+ session: { modelName: 'AuthSession', fields: { token: 'session_token' } },
529
+ account: { modelName: 'AuthAccount', fields: { password: 'password_hash' } },
530
+ verification: { modelName: 'AuthVerification', fields: { value: 'verification_value' } },
531
+ }
532
+
533
+ const { lists } = deriveAuthLists(models)
534
+
535
+ expect(lists.AuthSession.fields.token.access?.read).toBeTypeOf('function')
536
+ expect(lists.AuthSession.fields.token.db?.map).toBe('session_token')
537
+ expect(lists.AuthAccount.fields.password.access?.read).toBeTypeOf('function')
538
+ expect(lists.AuthAccount.fields.password.db?.map).toBe('password_hash')
539
+ expect(lists.AuthVerification.fields.value.access?.read).toBeTypeOf('function')
540
+ expect(lists.AuthVerification.fields.value.db?.map).toBe('verification_value')
541
+ })
542
+
543
+ it('does not add access to fields that already have none, beyond the credential set', () => {
544
+ const { lists } = deriveAuthLists(defaultModels)
545
+
546
+ // Every other scalar/relationship field across the four base lists stays access-less.
547
+ const nonCredentialFields = [
548
+ ...Object.entries(lists.Session.fields).filter(([k]) => k !== 'token'),
549
+ ...Object.entries(lists.Account.fields).filter(
550
+ ([k]) => !['password', 'accessToken', 'refreshToken', 'idToken'].includes(k),
551
+ ),
552
+ ...Object.entries(lists.Verification.fields).filter(([k]) => k !== 'value'),
553
+ ]
554
+ for (const [, field] of nonCredentialFields) {
555
+ expect(field.access).toBeUndefined()
556
+ }
557
+ })
558
+ })
559
+
560
+ describe('deriveAuthLists - credential fields on plugin tables (issue #1014)', () => {
561
+ const mcpPlugin = mcp({
562
+ loginPage: '/sign-in',
563
+ consentPage: '/consent',
564
+ resource: 'https://example.com/mcp',
565
+ })
566
+
567
+ it('denies read on the mcp/oauth-provider credential fields', async () => {
568
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [mcpPlugin])
569
+
570
+ const denied: Array<[string, string]> = [
571
+ ['OauthClient', 'clientSecret'],
572
+ ['OauthAccessToken', 'token'],
573
+ ['OauthRefreshToken', 'token'],
574
+ ]
575
+
576
+ for (const [listKey, fieldKey] of denied) {
577
+ const field = lists[listKey].fields[fieldKey]
578
+ expect(field.access?.read).toBeTypeOf('function')
579
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal read-access call fixture
580
+ expect(await field.access!.read!({} as any)).toBe(false)
581
+ expect(field.ui?.listView?.defaultColumn).toBe(false)
582
+ }
583
+ })
584
+
585
+ it('leaves identifying oauth-provider fields open', () => {
586
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [mcpPlugin])
587
+
588
+ expect(lists.OauthClient.fields.name.access).toBeUndefined()
589
+ expect(lists.OauthClient.fields.uri.access).toBeUndefined()
590
+ expect(lists.OauthClient.fields.clientId.access).toBeUndefined()
591
+ expect(lists.OauthAccessToken.fields.scopes.access).toBeUndefined()
592
+ expect(lists.OauthAccessToken.fields.expiresAt.access).toBeUndefined()
593
+ })
594
+
595
+ it('denies read on twoFactor.secret and twoFactor.backupCodes', async () => {
596
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [twoFactor()])
597
+
598
+ for (const fieldKey of ['secret', 'backupCodes']) {
599
+ const field = lists.TwoFactor.fields[fieldKey]
600
+ expect(field.access?.read).toBeTypeOf('function')
601
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal read-access call fixture
602
+ expect(await field.access!.read!({} as any)).toBe(false)
603
+ expect(field.ui?.listView?.defaultColumn).toBe(false)
604
+ }
605
+ })
606
+
607
+ it('leaves twoFactor.verified open, and twoFactor.userId open despite carrying returned: false upstream', () => {
608
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [twoFactor()])
609
+
610
+ expect(lists.TwoFactor.fields.verified.access).toBeUndefined()
611
+ // userId is a relationship field (references user.id), not a scalar —
612
+ // it's never routed through the credential-deny path at all.
613
+ expect(lists.TwoFactor.fields.user.access).toBeUndefined()
614
+ })
615
+
616
+ it('survives a plugin-table modelName + column remap', () => {
617
+ const plugin = {
618
+ id: 'test-remap',
619
+ schema: {
620
+ widget: {
621
+ modelName: 'Gadget',
622
+ fields: {
623
+ apiKey: { type: 'string' as const, required: true, fieldName: 'api_key' },
624
+ },
625
+ },
626
+ },
627
+ }
628
+
629
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [plugin], {
630
+ widget: ['apiKey'],
631
+ })
632
+
633
+ expect(lists.Gadget.fields.apiKey.access?.read).toBeTypeOf('function')
634
+ expect(lists.Gadget.fields.apiKey.db?.map).toBe('api_key')
635
+ })
636
+
637
+ it('authPlugin({ credentialFields }) marks an additional field on a synthetic plugin table', async () => {
638
+ const plugin = {
639
+ id: 'test-passkey',
640
+ schema: {
641
+ passkey: {
642
+ fields: {
643
+ publicKey: { type: 'string' as const, required: true },
644
+ deviceType: { type: 'string' as const, required: true },
645
+ },
646
+ },
647
+ },
648
+ }
649
+
650
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [plugin], {
651
+ passkey: ['publicKey'],
652
+ })
653
+
654
+ expect(await lists.Passkey.fields.publicKey.access!.read!({} as never)).toBe(false)
655
+ expect(lists.Passkey.fields.deviceType.access).toBeUndefined()
656
+ })
657
+
658
+ it('cannot unmark a stack-seeded credential field', async () => {
659
+ // An empty (or omitted) field list for a seeded model must not remove its
660
+ // seeded deny — credentialFields is additive-only.
661
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [], { session: [] })
662
+
663
+ expect(await lists.Session.fields.token.access!.read!({} as never)).toBe(false)
664
+ })
665
+
666
+ it('throws, naming the model and field, when credentialFields names a field missing from a derived model', () => {
667
+ const plugin = {
668
+ id: 'test-typo',
669
+ schema: {
670
+ widget: { fields: { apiKey: { type: 'string' as const } } },
671
+ },
672
+ }
673
+
674
+ expect(() => deriveAuthLists(defaultModels, {}, {}, [plugin], { widget: ['apiKye'] })).toThrow(
675
+ /widget\.apiKye.*no field "apiKye"/,
676
+ )
677
+ })
678
+
679
+ it('is a silent no-op when credentialFields names a model that is not derived at all', () => {
680
+ // No plugin registers `passkey` here, so `tables` never contains it.
681
+ expect(() =>
682
+ deriveAuthLists(defaultModels, {}, {}, [], { passkey: ['publicKey'] }),
683
+ ).not.toThrow()
684
+ })
685
+
686
+ it('throws, naming the model and field, when credentialFields names an id-referencing relationship field', () => {
687
+ // An id-referencing field (references.field === 'id') derives to a
688
+ // relationship(), never a scalar — withCredentialAccess only ever runs on
689
+ // the scalar-field path, so a deny registered against one would silently
690
+ // never apply. Reject the config instead of accepting a no-op.
691
+ const plugin = {
692
+ id: 'test-fk-credential',
693
+ schema: {
694
+ widget: {
695
+ fields: {
696
+ ownerId: { type: 'string' as const, references: { model: 'user', field: 'id' } },
697
+ },
698
+ },
699
+ },
700
+ }
701
+
702
+ expect(() => deriveAuthLists(defaultModels, {}, {}, [plugin], { widget: ['ownerId'] })).toThrow(
703
+ /widget\.ownerId.*relationship field.*"user\.id"/,
704
+ )
705
+ })
706
+ })
707
+
451
708
  describe('deriveAuthLists - extendUserList', () => {
452
709
  it('adds custom fields to the derived user list', () => {
453
710
  const { lists } = deriveAuthLists(