@opensaas/stack-auth 0.36.0 → 0.38.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 +146 -0
- package/CLAUDE.md +153 -9
- package/README.md +18 -7
- package/dist/config/adopt-better-auth-tables.d.ts +47 -0
- package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
- package/dist/config/adopt-better-auth-tables.js +29 -1
- package/dist/config/adopt-better-auth-tables.js.map +1 -1
- package/dist/config/derive-auth-lists.d.ts +7 -5
- package/dist/config/derive-auth-lists.d.ts.map +1 -1
- package/dist/config/derive-auth-lists.js +33 -34
- package/dist/config/derive-auth-lists.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +41 -11
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin.d.ts.map +1 -1
- package/dist/config/plugin.js +39 -27
- package/dist/config/plugin.js.map +1 -1
- package/dist/config/types.d.ts +143 -28
- package/dist/config/types.d.ts.map +1 -1
- package/dist/server/build-better-auth-options.test.d.ts +2 -0
- package/dist/server/build-better-auth-options.test.d.ts.map +1 -0
- package/dist/server/build-better-auth-options.test.js +29 -0
- package/dist/server/build-better-auth-options.test.js.map +1 -0
- package/dist/server/index.d.ts +112 -8
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +284 -96
- package/dist/server/index.js.map +1 -1
- package/dist/server/schema-converter.d.ts +3 -3
- package/dist/server/schema-converter.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/config/adopt-better-auth-tables.ts +70 -1
- package/src/config/derive-auth-lists.ts +37 -38
- package/src/config/index.ts +47 -12
- package/src/config/plugin.ts +40 -28
- package/src/config/types.ts +144 -27
- package/src/server/build-better-auth-options.test.ts +59 -0
- package/src/server/index.ts +470 -106
- package/src/server/schema-converter.ts +3 -3
- package/tests/adopt-better-auth-tables.test.ts +99 -0
- package/tests/config.test.ts +66 -8
- package/tests/derive-auth-lists.test.ts +79 -5
- package/tests/generated-fk-shape.test.ts +65 -0
- package/tests/plugin-derived-keys.test.ts +48 -0
- package/tests/server.test.ts +723 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +7 -1
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import type { BetterAuthOptions } from 'better-auth'
|
|
3
|
+
import type { NormalizedAuthConfig } from '../src/config/types.js'
|
|
4
|
+
import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
|
|
5
|
+
|
|
6
|
+
const betterAuthMock = vi.fn(() => ({ api: { getSession: vi.fn(async () => null) } }))
|
|
7
|
+
const prismaAdapterMock = vi.fn((client: unknown, opts: unknown) => ({ client, opts }))
|
|
8
|
+
const nextCookiesMock = vi.fn(() => ({ id: 'next-cookies' }))
|
|
9
|
+
|
|
10
|
+
vi.mock('better-auth', () => ({
|
|
11
|
+
betterAuth: betterAuthMock,
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
vi.mock('better-auth/adapters/prisma', () => ({
|
|
15
|
+
prismaAdapter: prismaAdapterMock,
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
vi.mock('better-auth/next-js', () => ({
|
|
19
|
+
nextCookies: nextCookiesMock,
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
const { createAuth, buildBetterAuthOptions, getSessionFromAuth } =
|
|
23
|
+
await import('../src/server/index.js')
|
|
24
|
+
|
|
25
|
+
function makeAuthConfig(overrides: Partial<NormalizedAuthConfig> = {}): NormalizedAuthConfig {
|
|
26
|
+
return {
|
|
27
|
+
emailAndPassword: {
|
|
28
|
+
enabled: true,
|
|
29
|
+
minPasswordLength: 8,
|
|
30
|
+
requireConfirmation: true,
|
|
31
|
+
sendResetPassword: vi.fn(async () => {}),
|
|
32
|
+
},
|
|
33
|
+
emailVerification: {
|
|
34
|
+
enabled: false,
|
|
35
|
+
sendOnSignUp: true,
|
|
36
|
+
tokenExpiration: 86400,
|
|
37
|
+
sendVerificationEmail: vi.fn(async () => {}),
|
|
38
|
+
},
|
|
39
|
+
passwordReset: { enabled: false, tokenExpiration: 3600 },
|
|
40
|
+
socialProviders: {},
|
|
41
|
+
session: { expiresIn: 604800, updateAge: 86400 },
|
|
42
|
+
models: {
|
|
43
|
+
user: { modelName: 'User', fields: {} },
|
|
44
|
+
session: { modelName: 'Session', fields: {} },
|
|
45
|
+
account: { modelName: 'Account', fields: {} },
|
|
46
|
+
verification: { modelName: 'Verification', fields: {} },
|
|
47
|
+
},
|
|
48
|
+
sessionFields: ['userId', 'email', 'name'],
|
|
49
|
+
extendUserList: {},
|
|
50
|
+
access: {},
|
|
51
|
+
betterAuthPlugins: [],
|
|
52
|
+
rateLimit: undefined,
|
|
53
|
+
betterAuthOptions: {},
|
|
54
|
+
...overrides,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeOpensaasConfig(authConfig: NormalizedAuthConfig): OpenSaasConfig {
|
|
59
|
+
return {
|
|
60
|
+
db: { provider: 'sqlite' },
|
|
61
|
+
lists: {},
|
|
62
|
+
_pluginData: { auth: authConfig },
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal test fixture
|
|
64
|
+
} as any
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeContext(): AccessContext {
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal test fixture
|
|
69
|
+
return { prisma: { __mockPrisma: true } } as any
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function buildBetterAuthConfig(authConfig: NormalizedAuthConfig): Promise<BetterAuthOptions> {
|
|
73
|
+
const auth = createAuth(makeOpensaasConfig(authConfig), makeContext())
|
|
74
|
+
// Calling a nested method is what actually forces the lazy proxy to resolve
|
|
75
|
+
// the underlying betterAuth() instance — merely accessing `auth.api` does not.
|
|
76
|
+
await auth.api.getSession({})
|
|
77
|
+
expect(betterAuthMock).toHaveBeenCalledTimes(1)
|
|
78
|
+
return betterAuthMock.mock.calls[0][0] as BetterAuthOptions
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('createAuth', () => {
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
betterAuthMock.mockClear()
|
|
84
|
+
prismaAdapterMock.mockClear()
|
|
85
|
+
nextCookiesMock.mockClear()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('forwards emailAndPassword.minPasswordLength', async () => {
|
|
89
|
+
const config = await buildBetterAuthConfig(
|
|
90
|
+
makeAuthConfig({
|
|
91
|
+
emailAndPassword: {
|
|
92
|
+
enabled: true,
|
|
93
|
+
minPasswordLength: 12,
|
|
94
|
+
requireConfirmation: true,
|
|
95
|
+
sendResetPassword: vi.fn(async () => {}),
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
expect(config.emailAndPassword).toMatchObject({ enabled: true, minPasswordLength: 12 })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('omits emailAndPassword entirely when disabled', async () => {
|
|
104
|
+
const config = await buildBetterAuthConfig(
|
|
105
|
+
makeAuthConfig({
|
|
106
|
+
emailAndPassword: {
|
|
107
|
+
enabled: false,
|
|
108
|
+
minPasswordLength: 8,
|
|
109
|
+
requireConfirmation: true,
|
|
110
|
+
sendResetPassword: vi.fn(async () => {}),
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
expect(config.emailAndPassword).toBeUndefined()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('forwards emailVerification.sendOnSignUp and tokenExpiration when enabled', async () => {
|
|
119
|
+
const config = await buildBetterAuthConfig(
|
|
120
|
+
makeAuthConfig({
|
|
121
|
+
emailVerification: {
|
|
122
|
+
enabled: true,
|
|
123
|
+
sendOnSignUp: false,
|
|
124
|
+
tokenExpiration: 1234,
|
|
125
|
+
sendVerificationEmail: vi.fn(async () => {}),
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
expect(config.emailVerification).toBeDefined()
|
|
131
|
+
expect(config.emailVerification?.sendOnSignUp).toBe(false)
|
|
132
|
+
expect(config.emailVerification?.expiresIn).toBe(1234)
|
|
133
|
+
expect(typeof config.emailVerification?.sendVerificationEmail).toBe('function')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('omits emailVerification entirely when disabled', async () => {
|
|
137
|
+
const config = await buildBetterAuthConfig(
|
|
138
|
+
makeAuthConfig({
|
|
139
|
+
emailVerification: {
|
|
140
|
+
enabled: false,
|
|
141
|
+
sendOnSignUp: true,
|
|
142
|
+
tokenExpiration: 86400,
|
|
143
|
+
sendVerificationEmail: vi.fn(async () => {}),
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
expect(config.emailVerification).toBeUndefined()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('forwards the configured sendVerificationEmail callback straight through, unwrapped', async () => {
|
|
152
|
+
const sendVerificationEmail = vi.fn(async () => {})
|
|
153
|
+
const config = await buildBetterAuthConfig(
|
|
154
|
+
makeAuthConfig({
|
|
155
|
+
emailVerification: {
|
|
156
|
+
enabled: true,
|
|
157
|
+
sendOnSignUp: true,
|
|
158
|
+
tokenExpiration: 86400,
|
|
159
|
+
sendVerificationEmail,
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
expect(config.emailVerification?.sendVerificationEmail).toBe(sendVerificationEmail)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('forwards passwordReset.tokenExpiration and the configured sendResetPassword callback straight through, unwrapped', async () => {
|
|
168
|
+
const sendResetPassword = vi.fn(async () => {})
|
|
169
|
+
const config = await buildBetterAuthConfig(
|
|
170
|
+
makeAuthConfig({
|
|
171
|
+
passwordReset: { enabled: true, tokenExpiration: 4321 },
|
|
172
|
+
emailAndPassword: {
|
|
173
|
+
enabled: true,
|
|
174
|
+
minPasswordLength: 8,
|
|
175
|
+
requireConfirmation: true,
|
|
176
|
+
sendResetPassword,
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
|
|
182
|
+
const emailAndPassword = config.emailAndPassword as any
|
|
183
|
+
expect(emailAndPassword.resetPasswordTokenExpiresIn).toBe(4321)
|
|
184
|
+
expect(emailAndPassword.sendResetPassword).toBe(sendResetPassword)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('does not add sendResetPassword when passwordReset is disabled', async () => {
|
|
188
|
+
const config = await buildBetterAuthConfig(
|
|
189
|
+
makeAuthConfig({ passwordReset: { enabled: false, tokenExpiration: 3600 } }),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
|
|
193
|
+
const emailAndPassword = config.emailAndPassword as any
|
|
194
|
+
expect(emailAndPassword.sendResetPassword).toBeUndefined()
|
|
195
|
+
expect(emailAndPassword.resetPasswordTokenExpiresIn).toBeUndefined()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('passes session.updateAge through as a number', async () => {
|
|
199
|
+
const config = await buildBetterAuthConfig(
|
|
200
|
+
makeAuthConfig({ session: { expiresIn: 604800, updateAge: 3600 } }),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
expect(config.session?.updateAge).toBe(3600)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('sets disableSessionRefresh instead of updateAge: 0 when updateAge is false', async () => {
|
|
207
|
+
const config = await buildBetterAuthConfig(
|
|
208
|
+
makeAuthConfig({ session: { expiresIn: 604800, updateAge: false } }),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
// better-auth treats `updateAge: 0` as "refresh on every request", not
|
|
212
|
+
// "never refresh" — disabling refresh requires the separate flag.
|
|
213
|
+
expect(config.session?.disableSessionRefresh).toBe(true)
|
|
214
|
+
expect(config.session?.updateAge).toBeUndefined()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('warns when requireConfirmation is set to false since it has no server-side effect', async () => {
|
|
218
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
219
|
+
|
|
220
|
+
await buildBetterAuthConfig(
|
|
221
|
+
makeAuthConfig({
|
|
222
|
+
emailAndPassword: {
|
|
223
|
+
enabled: true,
|
|
224
|
+
minPasswordLength: 8,
|
|
225
|
+
requireConfirmation: false,
|
|
226
|
+
sendResetPassword: vi.fn(async () => {}),
|
|
227
|
+
},
|
|
228
|
+
}),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('requireConfirmation'))
|
|
232
|
+
warnSpy.mockRestore()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('does not warn when requireConfirmation is left at its default (true)', async () => {
|
|
236
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
237
|
+
|
|
238
|
+
await buildBetterAuthConfig(
|
|
239
|
+
makeAuthConfig({
|
|
240
|
+
emailAndPassword: {
|
|
241
|
+
enabled: true,
|
|
242
|
+
minPasswordLength: 8,
|
|
243
|
+
requireConfirmation: true,
|
|
244
|
+
sendResetPassword: vi.fn(async () => {}),
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
250
|
+
warnSpy.mockRestore()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('does not warn about requireConfirmation when emailAndPassword is disabled', async () => {
|
|
254
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
255
|
+
|
|
256
|
+
await buildBetterAuthConfig(
|
|
257
|
+
makeAuthConfig({
|
|
258
|
+
emailAndPassword: {
|
|
259
|
+
enabled: false,
|
|
260
|
+
minPasswordLength: 8,
|
|
261
|
+
requireConfirmation: false,
|
|
262
|
+
sendResetPassword: vi.fn(async () => {}),
|
|
263
|
+
},
|
|
264
|
+
}),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('requireConfirmation'))
|
|
268
|
+
warnSpy.mockRestore()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('warns when passwordReset is enabled but emailAndPassword is disabled', async () => {
|
|
272
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
273
|
+
|
|
274
|
+
await buildBetterAuthConfig(
|
|
275
|
+
makeAuthConfig({
|
|
276
|
+
emailAndPassword: {
|
|
277
|
+
enabled: false,
|
|
278
|
+
minPasswordLength: 8,
|
|
279
|
+
requireConfirmation: true,
|
|
280
|
+
sendResetPassword: vi.fn(async () => {}),
|
|
281
|
+
},
|
|
282
|
+
passwordReset: { enabled: true, tokenExpiration: 3600 },
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('passwordReset'))
|
|
287
|
+
warnSpy.mockRestore()
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('does not warn about passwordReset when emailAndPassword is enabled', async () => {
|
|
291
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
292
|
+
|
|
293
|
+
await buildBetterAuthConfig(
|
|
294
|
+
makeAuthConfig({
|
|
295
|
+
emailAndPassword: {
|
|
296
|
+
enabled: true,
|
|
297
|
+
minPasswordLength: 8,
|
|
298
|
+
requireConfirmation: true,
|
|
299
|
+
sendResetPassword: vi.fn(async () => {}),
|
|
300
|
+
},
|
|
301
|
+
passwordReset: { enabled: true, tokenExpiration: 3600 },
|
|
302
|
+
}),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('passwordReset'))
|
|
306
|
+
warnSpy.mockRestore()
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
describe('betterAuthOptions passthrough', () => {
|
|
311
|
+
beforeEach(() => {
|
|
312
|
+
betterAuthMock.mockClear()
|
|
313
|
+
prismaAdapterMock.mockClear()
|
|
314
|
+
nextCookiesMock.mockClear()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('produces byte-for-byte identical options to today when unused', async () => {
|
|
318
|
+
// `normalizeAuthConfig` is what actually defaults a config without
|
|
319
|
+
// `betterAuthOptions` to `{}` (see config.test.ts) — reproduce that
|
|
320
|
+
// realistic normalized shape here rather than an AuthConfig missing a
|
|
321
|
+
// required NormalizedAuthConfig field.
|
|
322
|
+
const authConfig = makeAuthConfig({ betterAuthOptions: {} })
|
|
323
|
+
|
|
324
|
+
const built = await buildBetterAuthConfig(authConfig)
|
|
325
|
+
|
|
326
|
+
expect(built).toEqual({
|
|
327
|
+
database: { client: { __mockPrisma: true }, opts: { provider: 'sqlite' } },
|
|
328
|
+
user: { modelName: 'User' },
|
|
329
|
+
session: { modelName: 'Session', expiresIn: 604800, updateAge: 86400 },
|
|
330
|
+
account: { modelName: 'Account' },
|
|
331
|
+
verification: { modelName: 'Verification' },
|
|
332
|
+
emailAndPassword: {
|
|
333
|
+
enabled: true,
|
|
334
|
+
requireEmailVerification: false,
|
|
335
|
+
minPasswordLength: 8,
|
|
336
|
+
},
|
|
337
|
+
emailVerification: undefined,
|
|
338
|
+
trustedOrigins: [],
|
|
339
|
+
socialProviders: {},
|
|
340
|
+
rateLimit: undefined,
|
|
341
|
+
plugins: [{ id: 'next-cookies' }],
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('surfaces a top-level option the stack does not model (e.g. baseURL)', async () => {
|
|
346
|
+
const config = await buildBetterAuthConfig(
|
|
347
|
+
makeAuthConfig({ betterAuthOptions: { baseURL: 'https://example.com' } }),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
expect(config.baseURL).toBe('https://example.com')
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('merges a nested database hook without clobbering sibling top-level keys', async () => {
|
|
354
|
+
const after = vi.fn()
|
|
355
|
+
const config = await buildBetterAuthConfig(
|
|
356
|
+
makeAuthConfig({
|
|
357
|
+
session: { expiresIn: 604800, updateAge: 86400 },
|
|
358
|
+
betterAuthOptions: {
|
|
359
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only shape
|
|
360
|
+
databaseHooks: { user: { create: { after } } } as any,
|
|
361
|
+
},
|
|
362
|
+
}),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
|
|
366
|
+
expect((config as any).databaseHooks.user.create.after).toBe(after)
|
|
367
|
+
// The stack's own session config survives — passthrough added a sibling
|
|
368
|
+
// top-level key, it didn't replace the whole options object.
|
|
369
|
+
expect(config.session?.expiresIn).toBe(604800)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('merges a nested session option without clobbering the stack-set session keys', async () => {
|
|
373
|
+
const config = await buildBetterAuthConfig(
|
|
374
|
+
makeAuthConfig({
|
|
375
|
+
session: { expiresIn: 604800, updateAge: 3600 },
|
|
376
|
+
betterAuthOptions: {
|
|
377
|
+
session: { cookieCache: { enabled: true, maxAge: 300 } },
|
|
378
|
+
},
|
|
379
|
+
}),
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
|
|
383
|
+
expect((config.session as any).cookieCache).toEqual({ enabled: true, maxAge: 300 })
|
|
384
|
+
expect(config.session?.expiresIn).toBe(604800)
|
|
385
|
+
expect(config.session?.updateAge).toBe(3600)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('lets the passthrough win on a genuine key collision', async () => {
|
|
389
|
+
const config = await buildBetterAuthConfig(
|
|
390
|
+
makeAuthConfig({
|
|
391
|
+
session: { expiresIn: 604800, updateAge: 3600 },
|
|
392
|
+
betterAuthOptions: {
|
|
393
|
+
session: { expiresIn: 999 },
|
|
394
|
+
},
|
|
395
|
+
}),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
expect(config.session?.expiresIn).toBe(999)
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('replaces an array outright instead of concatenating (e.g. trustedOrigins)', async () => {
|
|
402
|
+
process.env.BETTER_AUTH_TRUSTED_ORIGINS = 'https://env-origin.com'
|
|
403
|
+
try {
|
|
404
|
+
const config = await buildBetterAuthConfig(
|
|
405
|
+
makeAuthConfig({
|
|
406
|
+
betterAuthOptions: { trustedOrigins: ['https://config-origin.com'] },
|
|
407
|
+
}),
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
expect(config.trustedOrigins).toEqual(['https://config-origin.com'])
|
|
411
|
+
} finally {
|
|
412
|
+
delete process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
|
413
|
+
}
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('rejects betterAuthOptions.database', async () => {
|
|
417
|
+
await expect(
|
|
418
|
+
buildBetterAuthOptions(
|
|
419
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only shape
|
|
420
|
+
makeOpensaasConfig(makeAuthConfig({ betterAuthOptions: { database: {} as any } })),
|
|
421
|
+
makeContext(),
|
|
422
|
+
),
|
|
423
|
+
).rejects.toThrow(/betterAuthOptions\.database/)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('rejects betterAuthOptions.plugins', async () => {
|
|
427
|
+
await expect(
|
|
428
|
+
buildBetterAuthOptions(
|
|
429
|
+
makeOpensaasConfig(makeAuthConfig({ betterAuthOptions: { plugins: [] } })),
|
|
430
|
+
makeContext(),
|
|
431
|
+
),
|
|
432
|
+
).rejects.toThrow(/betterAuthOptions\.plugins/)
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it.each(['user', 'session', 'account', 'verification'] as const)(
|
|
436
|
+
'rejects betterAuthOptions.%s.additionalFields',
|
|
437
|
+
async (model) => {
|
|
438
|
+
await expect(
|
|
439
|
+
buildBetterAuthOptions(
|
|
440
|
+
makeOpensaasConfig(
|
|
441
|
+
makeAuthConfig({
|
|
442
|
+
betterAuthOptions: {
|
|
443
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only shape
|
|
444
|
+
[model]: { additionalFields: { foo: { type: 'string' } } } as any,
|
|
445
|
+
},
|
|
446
|
+
}),
|
|
447
|
+
),
|
|
448
|
+
makeContext(),
|
|
449
|
+
),
|
|
450
|
+
).rejects.toThrow(new RegExp(`betterAuthOptions\\.${model}\\.additionalFields`))
|
|
451
|
+
},
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
it('does not reject additionalFields nested under an unrelated model key', async () => {
|
|
455
|
+
// A plain object at `user` with no `additionalFields` key must not trip the guard.
|
|
456
|
+
const config = await buildBetterAuthConfig(
|
|
457
|
+
makeAuthConfig({ betterAuthOptions: { user: { modelName: 'CustomUser' } } }),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
expect(config.user).toMatchObject({ modelName: 'CustomUser' })
|
|
461
|
+
})
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
describe('buildBetterAuthOptions / createAuth parity', () => {
|
|
465
|
+
beforeEach(() => {
|
|
466
|
+
betterAuthMock.mockClear()
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('createAuth constructs betterAuth with exactly what buildBetterAuthOptions returns', async () => {
|
|
470
|
+
const authConfig = makeAuthConfig({
|
|
471
|
+
betterAuthOptions: { baseURL: 'https://example.com' },
|
|
472
|
+
})
|
|
473
|
+
const opensaasConfig = makeOpensaasConfig(authConfig)
|
|
474
|
+
const context = makeContext()
|
|
475
|
+
|
|
476
|
+
const built = await buildBetterAuthOptions(opensaasConfig, context)
|
|
477
|
+
|
|
478
|
+
const auth = createAuth(opensaasConfig, context)
|
|
479
|
+
await auth.api.getSession({})
|
|
480
|
+
|
|
481
|
+
expect(betterAuthMock).toHaveBeenCalledTimes(1)
|
|
482
|
+
expect(betterAuthMock.mock.calls[0][0]).toEqual(built)
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('createAuth with a plugin tuple constructs betterAuth with exactly what buildBetterAuthOptions returns for the same tuple', async () => {
|
|
486
|
+
const pluginA = { id: 'plugin-a' }
|
|
487
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
|
|
488
|
+
const opensaasConfig = makeOpensaasConfig(authConfig)
|
|
489
|
+
const context = makeContext()
|
|
490
|
+
|
|
491
|
+
const built = await buildBetterAuthOptions(opensaasConfig, context, [pluginA])
|
|
492
|
+
|
|
493
|
+
const auth = createAuth(opensaasConfig, context, [pluginA])
|
|
494
|
+
await auth.api.getSession({})
|
|
495
|
+
|
|
496
|
+
expect(betterAuthMock).toHaveBeenCalledTimes(1)
|
|
497
|
+
expect(betterAuthMock.mock.calls[0][0]).toEqual(built)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('createAuth rejects when its plugin tuple does not match the resolved betterAuthPlugins', async () => {
|
|
501
|
+
const pluginA = { id: 'plugin-a' }
|
|
502
|
+
const differentInstance = { id: 'plugin-a' }
|
|
503
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
|
|
504
|
+
const opensaasConfig = makeOpensaasConfig(authConfig)
|
|
505
|
+
const context = makeContext()
|
|
506
|
+
|
|
507
|
+
const auth = createAuth(opensaasConfig, context, [differentInstance])
|
|
508
|
+
|
|
509
|
+
await expect(auth.api.getSession({})).rejects.toThrow(
|
|
510
|
+
/does not match the plugin array resolved/,
|
|
511
|
+
)
|
|
512
|
+
expect(betterAuthMock).not.toHaveBeenCalled()
|
|
513
|
+
})
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
describe('buildBetterAuthOptions plugin-tuple argument', () => {
|
|
517
|
+
beforeEach(() => {
|
|
518
|
+
betterAuthMock.mockClear()
|
|
519
|
+
prismaAdapterMock.mockClear()
|
|
520
|
+
nextCookiesMock.mockClear()
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it('rejects when the supplied tuple has a different length than the resolved betterAuthPlugins', async () => {
|
|
524
|
+
const pluginA = { id: 'plugin-a' }
|
|
525
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
|
|
526
|
+
|
|
527
|
+
await expect(
|
|
528
|
+
buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), []),
|
|
529
|
+
).rejects.toThrow(/has 0 plugin\(s\), but the plugin array resolved.*has 1/)
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
it('rejects naming the mismatching index when a supplied plugin is not the same instance', async () => {
|
|
533
|
+
const pluginA = { id: 'plugin-a' }
|
|
534
|
+
const pluginB = { id: 'plugin-b' }
|
|
535
|
+
const differentInstance = { id: 'plugin-a' } // same id, different identity
|
|
536
|
+
|
|
537
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] })
|
|
538
|
+
|
|
539
|
+
await expect(
|
|
540
|
+
buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [
|
|
541
|
+
differentInstance,
|
|
542
|
+
pluginB,
|
|
543
|
+
]),
|
|
544
|
+
).rejects.toThrow(/at index 0/)
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
it('rejects naming the mismatching index when the supplied order differs', async () => {
|
|
548
|
+
const pluginA = { id: 'plugin-a' }
|
|
549
|
+
const pluginB = { id: 'plugin-b' }
|
|
550
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] })
|
|
551
|
+
|
|
552
|
+
await expect(
|
|
553
|
+
buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [pluginB, pluginA]),
|
|
554
|
+
).rejects.toThrow(/at index 0/)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('does not throw when the supplied tuple is the exact same instances in the same order', async () => {
|
|
558
|
+
const pluginA = { id: 'plugin-a' }
|
|
559
|
+
const pluginB = { id: 'plugin-b' }
|
|
560
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] })
|
|
561
|
+
|
|
562
|
+
const config = await buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [
|
|
563
|
+
pluginA,
|
|
564
|
+
pluginB,
|
|
565
|
+
])
|
|
566
|
+
|
|
567
|
+
expect(config.plugins).toEqual([pluginA, pluginB, { id: 'next-cookies' }])
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('appends exactly one nextCookies() plugin, last, whether or not a plugin tuple is supplied', async () => {
|
|
571
|
+
const pluginA = { id: 'plugin-a' }
|
|
572
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
|
|
573
|
+
const opensaasConfig = makeOpensaasConfig(authConfig)
|
|
574
|
+
const context = makeContext()
|
|
575
|
+
|
|
576
|
+
const withoutArg = await buildBetterAuthOptions(opensaasConfig, context)
|
|
577
|
+
expect(nextCookiesMock).toHaveBeenCalledTimes(1)
|
|
578
|
+
expect(withoutArg.plugins).toEqual([pluginA, { id: 'next-cookies' }])
|
|
579
|
+
|
|
580
|
+
nextCookiesMock.mockClear()
|
|
581
|
+
|
|
582
|
+
const withArg = await buildBetterAuthOptions(opensaasConfig, context, [pluginA])
|
|
583
|
+
expect(nextCookiesMock).toHaveBeenCalledTimes(1)
|
|
584
|
+
expect(withArg.plugins).toEqual([pluginA, { id: 'next-cookies' }])
|
|
585
|
+
})
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
describe('getSessionFromAuth', () => {
|
|
589
|
+
it('passes the caller-supplied headers to auth.api.getSession', async () => {
|
|
590
|
+
const headers = new Headers({ cookie: 'session=abc' })
|
|
591
|
+
const getSession = vi.fn(async () => ({ user: { id: 'user-1', email: 'a@b.com' } }))
|
|
592
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
593
|
+
|
|
594
|
+
const result = await getSessionFromAuth(auth, ['userId', 'email'], headers)
|
|
595
|
+
|
|
596
|
+
expect(getSession).toHaveBeenCalledWith({ headers })
|
|
597
|
+
expect(result).toEqual({ userId: 'user-1', email: 'a@b.com' })
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
it('returns null when there is no session', async () => {
|
|
601
|
+
const getSession = vi.fn(async () => null)
|
|
602
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
603
|
+
|
|
604
|
+
const result = await getSessionFromAuth(auth, ['userId'], new Headers())
|
|
605
|
+
|
|
606
|
+
expect(result).toBeNull()
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
it('propagates an error thrown by the underlying session lookup, distinguishable from no session', async () => {
|
|
610
|
+
const getSession = vi.fn(async () => {
|
|
611
|
+
throw new Error('boom')
|
|
612
|
+
})
|
|
613
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
614
|
+
|
|
615
|
+
await expect(getSessionFromAuth(auth, ['userId'], new Headers())).rejects.toThrow('boom')
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
it('resolves the documented happy path unchanged: fields on the user, userId from user.id', async () => {
|
|
619
|
+
const getSession = vi.fn(async () => ({
|
|
620
|
+
user: { id: 'user-1', email: 'a@b.com', name: 'Ada' },
|
|
621
|
+
}))
|
|
622
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
623
|
+
|
|
624
|
+
const result = await getSessionFromAuth(auth, ['userId', 'email', 'name'], new Headers())
|
|
625
|
+
|
|
626
|
+
expect(result).toEqual({ userId: 'user-1', email: 'a@b.com', name: 'Ada' })
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
it('projects a customSession shape with no top-level user key instead of reporting anonymous', async () => {
|
|
630
|
+
// A customSession plugin can fully replace the resolved shape (e.g.
|
|
631
|
+
// nesting fields under a custom key) and drop the `user` object entirely
|
|
632
|
+
// — that must still be treated as "a session", not "no session".
|
|
633
|
+
const getSession = vi.fn(async () => ({
|
|
634
|
+
email: 'nested@example.com',
|
|
635
|
+
data: { role: 'admin' },
|
|
636
|
+
}))
|
|
637
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
638
|
+
|
|
639
|
+
const result = await getSessionFromAuth(auth, ['email'], new Headers())
|
|
640
|
+
|
|
641
|
+
expect(result).not.toBeNull()
|
|
642
|
+
expect(result).toEqual({ email: 'nested@example.com' })
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
it('resolves a field living on the session sub-object, not just the user', async () => {
|
|
646
|
+
const getSession = vi.fn(async () => ({
|
|
647
|
+
user: { id: 'user-1' },
|
|
648
|
+
session: { impersonatedBy: 'admin-1' },
|
|
649
|
+
}))
|
|
650
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
651
|
+
|
|
652
|
+
const result = await getSessionFromAuth(auth, ['userId', 'impersonatedBy'], new Headers())
|
|
653
|
+
|
|
654
|
+
expect(result).toEqual({ userId: 'user-1', impersonatedBy: 'admin-1' })
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
describe('resolution precedence', () => {
|
|
658
|
+
it('prefers a top-level key over the same name on user or session (deliberate collision)', async () => {
|
|
659
|
+
const getSession = vi.fn(async () => ({
|
|
660
|
+
role: 'top-level-role',
|
|
661
|
+
user: { role: 'user-role' },
|
|
662
|
+
session: { role: 'session-role' },
|
|
663
|
+
}))
|
|
664
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
665
|
+
|
|
666
|
+
const result = await getSessionFromAuth(auth, ['role'], new Headers())
|
|
667
|
+
|
|
668
|
+
expect(result).toEqual({ role: 'top-level-role' })
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it('prefers the user object over the session sub-object when there is no top-level key', async () => {
|
|
672
|
+
const getSession = vi.fn(async () => ({
|
|
673
|
+
user: { role: 'user-role' },
|
|
674
|
+
session: { role: 'session-role' },
|
|
675
|
+
}))
|
|
676
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
677
|
+
|
|
678
|
+
const result = await getSessionFromAuth(auth, ['role'], new Headers())
|
|
679
|
+
|
|
680
|
+
expect(result).toEqual({ role: 'user-role' })
|
|
681
|
+
})
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
// The warn-once cache is module-level state, so these tests re-import the
|
|
685
|
+
// module fresh via vi.resetModules() — same pattern as the `select` no-op
|
|
686
|
+
// warning tests in packages/core/tests/context.test.ts.
|
|
687
|
+
describe('unresolved field warning', () => {
|
|
688
|
+
let warnSpy: ReturnType<typeof vi.spyOn>
|
|
689
|
+
let freshGetSessionFromAuth: typeof getSessionFromAuth
|
|
690
|
+
|
|
691
|
+
beforeEach(async () => {
|
|
692
|
+
vi.resetModules()
|
|
693
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
694
|
+
const mod = await import('../src/server/index.js')
|
|
695
|
+
freshGetSessionFromAuth = mod.getSessionFromAuth
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
afterEach(() => {
|
|
699
|
+
warnSpy.mockRestore()
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
it('omits an unresolvable field, warns once naming it, and does not throw', async () => {
|
|
703
|
+
const getSession = vi.fn(async () => ({ user: { id: 'user-1' } }))
|
|
704
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
705
|
+
|
|
706
|
+
const result = await freshGetSessionFromAuth(auth, ['userId', 'nickname'], new Headers())
|
|
707
|
+
|
|
708
|
+
expect(result).toEqual({ userId: 'user-1' })
|
|
709
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
710
|
+
expect(warnSpy.mock.calls[0][0]).toContain('"nickname"')
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
it('does not warn again for the same field on a second call', async () => {
|
|
714
|
+
const getSession = vi.fn(async () => ({ user: { id: 'user-1' } }))
|
|
715
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
716
|
+
|
|
717
|
+
await freshGetSessionFromAuth(auth, ['nickname'], new Headers())
|
|
718
|
+
await freshGetSessionFromAuth(auth, ['nickname'], new Headers())
|
|
719
|
+
|
|
720
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
721
|
+
})
|
|
722
|
+
})
|
|
723
|
+
})
|