@opensaas/stack-auth 0.39.2 → 0.41.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 +153 -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,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 ->
|
|
65
|
-
|
|
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('
|
|
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(
|
|
87
|
-
expect(lists.Account.fields.user.isIndexed).toBe(
|
|
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(
|