@opensaas/stack-auth 0.38.0 → 0.39.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 +23 -0
- package/dist/config/adopt-better-auth-tables.d.ts +23 -3
- package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
- package/dist/config/adopt-better-auth-tables.js +7 -2
- package/dist/config/adopt-better-auth-tables.js.map +1 -1
- package/dist/config/derive-auth-lists.d.ts +6 -1
- package/dist/config/derive-auth-lists.d.ts.map +1 -1
- package/dist/config/derive-auth-lists.js +63 -16
- package/dist/config/derive-auth-lists.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +12 -5
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin.d.ts.map +1 -1
- package/dist/config/plugin.js +6 -1
- package/dist/config/plugin.js.map +1 -1
- package/dist/config/types.d.ts +44 -2
- package/dist/config/types.d.ts.map +1 -1
- package/dist/server/get-session-from-auth.test.d.ts +2 -0
- package/dist/server/get-session-from-auth.test.d.ts.map +1 -0
- package/dist/server/get-session-from-auth.test.js +25 -0
- package/dist/server/get-session-from-auth.test.js.map +1 -0
- package/dist/server/index.d.ts +17 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +28 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/schema-converter.d.ts +12 -3
- package/dist/server/schema-converter.d.ts.map +1 -1
- package/dist/server/schema-converter.js +14 -2
- package/dist/server/schema-converter.js.map +1 -1
- package/package.json +3 -3
- package/src/config/adopt-better-auth-tables.ts +31 -4
- package/src/config/derive-auth-lists.ts +82 -21
- package/src/config/index.ts +18 -5
- package/src/config/plugin.ts +6 -1
- package/src/config/types.ts +45 -2
- package/src/server/get-session-from-auth.test.ts +52 -0
- package/src/server/index.ts +35 -3
- package/src/server/schema-converter.ts +26 -5
- package/tests/adopt-better-auth-tables.test.ts +73 -0
- package/tests/config.test.ts +161 -0
- package/tests/derive-auth-lists.test.ts +104 -0
- package/tests/generated-fk-shape.test.ts +81 -0
- package/tests/plugin-schema-placement.test.ts +39 -0
- package/tests/rate-limit-e2e.test.ts +239 -0
- package/tests/schema-converter.test.ts +58 -0
- package/tests/server.test.ts +100 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/config/types.ts
CHANGED
|
@@ -203,6 +203,14 @@ export type AuthAccessConfig = {
|
|
|
203
203
|
account?: ListConfig<any>['access']
|
|
204
204
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
205
205
|
verification?: ListConfig<any>['access']
|
|
206
|
+
/**
|
|
207
|
+
* Access control for the `RateLimit` list — only meaningful when
|
|
208
|
+
* `rateLimit.storage: 'database'` derives it. Per ADR-0013 the list ships
|
|
209
|
+
* closed like the other four; grant access here (e.g. to inspect throttled
|
|
210
|
+
* keys in the Admin UI).
|
|
211
|
+
*/
|
|
212
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
213
|
+
rateLimit?: ListConfig<any>['access']
|
|
206
214
|
}
|
|
207
215
|
|
|
208
216
|
export type AuthModelConfig = {
|
|
@@ -413,6 +421,22 @@ export type AuthConfig = {
|
|
|
413
421
|
* Rate limiting configuration
|
|
414
422
|
* Controls rate limiting for authentication endpoints
|
|
415
423
|
*
|
|
424
|
+
* `storage` mirrors better-auth's own `rateLimit.storage` option
|
|
425
|
+
* (`'memory' | 'database' | 'secondary-storage'`, default `'memory'`). Set
|
|
426
|
+
* it to `'database'` to persist the limiter across restarts/instances — the
|
|
427
|
+
* plugin then derives a fifth `RateLimit` Auth list (per ADR-0007) so the
|
|
428
|
+
* required table exists in the generated Prisma schema, following the same
|
|
429
|
+
* adoption knobs (`modelName`/`fields`/`tableName`/`schema`) the other four
|
|
430
|
+
* models carry. Derivation keys off `storage` alone — `enabled: false` with
|
|
431
|
+
* `storage: 'database'` still produces the list, since better-auth still
|
|
432
|
+
* expects the table regardless of whether the limiter is currently active
|
|
433
|
+
* (`enabled` is routinely environment-driven; tying the schema to it would
|
|
434
|
+
* make dev/prod schemas differ).
|
|
435
|
+
*
|
|
436
|
+
* Setting `storage` via `betterAuthOptions.rateLimit.storage` is rejected —
|
|
437
|
+
* use this option instead, since it also has schema consequences the
|
|
438
|
+
* passthrough can't apply.
|
|
439
|
+
*
|
|
416
440
|
* @example
|
|
417
441
|
* ```typescript
|
|
418
442
|
* // Disable rate limiting for testing
|
|
@@ -426,6 +450,12 @@ export type AuthConfig = {
|
|
|
426
450
|
* window: 60, // 60 seconds
|
|
427
451
|
* max: 100, // 100 requests per window
|
|
428
452
|
* }
|
|
453
|
+
*
|
|
454
|
+
* // Persist the limiter in the database (derives a RateLimit list)
|
|
455
|
+
* rateLimit: {
|
|
456
|
+
* enabled: true,
|
|
457
|
+
* storage: 'database',
|
|
458
|
+
* }
|
|
429
459
|
* ```
|
|
430
460
|
*/
|
|
431
461
|
rateLimit?: {
|
|
@@ -440,7 +470,12 @@ export type AuthConfig = {
|
|
|
440
470
|
* @default 100
|
|
441
471
|
*/
|
|
442
472
|
max?: number
|
|
443
|
-
|
|
473
|
+
/**
|
|
474
|
+
* Where better-auth persists the rate limiter.
|
|
475
|
+
* @default 'memory'
|
|
476
|
+
*/
|
|
477
|
+
storage?: 'memory' | 'database' | 'secondary-storage'
|
|
478
|
+
} & AuthModelConfig
|
|
444
479
|
|
|
445
480
|
/**
|
|
446
481
|
* Escape hatch for better-auth options the stack doesn't model — typed as
|
|
@@ -498,7 +533,8 @@ export type NormalizedAuthModelConfig = {
|
|
|
498
533
|
}
|
|
499
534
|
|
|
500
535
|
/**
|
|
501
|
-
* Resolved auth model configuration for all four better-auth models
|
|
536
|
+
* Resolved auth model configuration for all four better-auth models, plus an
|
|
537
|
+
* optional fifth for the database-backed rate limiter.
|
|
502
538
|
* Consumed by the Auth-list derivation and the runtime user-key resolution.
|
|
503
539
|
*/
|
|
504
540
|
export type NormalizedAuthModels = {
|
|
@@ -506,6 +542,12 @@ export type NormalizedAuthModels = {
|
|
|
506
542
|
session: NormalizedAuthModelConfig
|
|
507
543
|
account: NormalizedAuthModelConfig
|
|
508
544
|
verification: NormalizedAuthModelConfig
|
|
545
|
+
/**
|
|
546
|
+
* Present only when `rateLimit.storage === 'database'` — derives a fifth
|
|
547
|
+
* `RateLimit` Auth list. Absent (not `undefined`-valued) otherwise, so
|
|
548
|
+
* `Object.values(models)` never yields a model-less entry.
|
|
549
|
+
*/
|
|
550
|
+
rateLimit?: NormalizedAuthModelConfig
|
|
509
551
|
}
|
|
510
552
|
|
|
511
553
|
/**
|
|
@@ -545,5 +587,6 @@ export type NormalizedAuthConfig = Required<
|
|
|
545
587
|
enabled: boolean
|
|
546
588
|
window?: number
|
|
547
589
|
max?: number
|
|
590
|
+
storage?: 'memory' | 'database' | 'secondary-storage'
|
|
548
591
|
}
|
|
549
592
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from 'vitest'
|
|
2
|
+
import type { OpenSaasConfig, AccessContext, Session } from '@opensaas/stack-core'
|
|
3
|
+
import { emailOTP } from 'better-auth/plugins'
|
|
4
|
+
import { createAuth, getSessionFromAuth } from './index.js'
|
|
5
|
+
|
|
6
|
+
// Type-level regression coverage for #906: `getSessionFromAuth` must accept
|
|
7
|
+
// an auth instance from either `createAuth()` overload — the widened
|
|
8
|
+
// `Auth<BetterAuthOptions>` returned with no plugin tuple, and the narrowed
|
|
9
|
+
// `Auth<ResolvedBetterAuthOptions<TPlugins>>` returned when one is passed —
|
|
10
|
+
// with no cast and no intermediate widening assignment. `Parameters<typeof
|
|
11
|
+
// createAuth>` on the overloaded export resolves against its last (generic)
|
|
12
|
+
// signature rather than the call-site-selected one, so each call shape is
|
|
13
|
+
// wrapped in its own ordinary function and read back via `typeof`, mirroring
|
|
14
|
+
// `build-better-auth-options.test.ts`.
|
|
15
|
+
type TestPlugins = [ReturnType<typeof emailOTP>]
|
|
16
|
+
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced only via `typeof` below
|
|
18
|
+
function callWithNoPlugins(
|
|
19
|
+
config: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
20
|
+
context: AccessContext | Promise<AccessContext>,
|
|
21
|
+
) {
|
|
22
|
+
return createAuth(config, context)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced only via `typeof` below
|
|
26
|
+
function callWithPlugins(
|
|
27
|
+
config: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
28
|
+
context: AccessContext | Promise<AccessContext>,
|
|
29
|
+
plugins: TestPlugins,
|
|
30
|
+
) {
|
|
31
|
+
return createAuth(config, context, plugins)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type WidenedAuth = ReturnType<typeof callWithNoPlugins>
|
|
35
|
+
type NarrowedAuth = ReturnType<typeof callWithPlugins>
|
|
36
|
+
|
|
37
|
+
describe('getSessionFromAuth accepts either createAuth() overload (#906)', () => {
|
|
38
|
+
it('accepts the widened Auth<BetterAuthOptions> instance with no cast', () => {
|
|
39
|
+
expectTypeOf<WidenedAuth>().toExtend<Parameters<typeof getSessionFromAuth>[0]>()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('accepts the narrowed, plugin-typed Auth instance with no cast', () => {
|
|
43
|
+
// This is the exact case #906 reported as a type error: a `createAuth`
|
|
44
|
+
// instance narrowed by a plugin tuple, passed straight into
|
|
45
|
+
// `getSessionFromAuth` without widening it back to `Auth<BetterAuthOptions>`.
|
|
46
|
+
expectTypeOf<NarrowedAuth>().toExtend<Parameters<typeof getSessionFromAuth>[0]>()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('return type stays Promise<Session | null>, unchanged by the widened/narrowed instance', () => {
|
|
50
|
+
expectTypeOf(getSessionFromAuth).returns.toEqualTypeOf<Promise<Session | null>>()
|
|
51
|
+
})
|
|
52
|
+
})
|
package/src/server/index.ts
CHANGED
|
@@ -111,6 +111,21 @@ function assertNoUnsupportedPassthroughKeys(betterAuthOptions: Record<string, un
|
|
|
111
111
|
)
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
const rateLimitOptions = betterAuthOptions.rateLimit
|
|
115
|
+
if (
|
|
116
|
+
rateLimitOptions &&
|
|
117
|
+
typeof rateLimitOptions === 'object' &&
|
|
118
|
+
!Array.isArray(rateLimitOptions) &&
|
|
119
|
+
'storage' in rateLimitOptions
|
|
120
|
+
) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
'[@opensaas/stack-auth] `betterAuthOptions.rateLimit.storage` is not supported — it has ' +
|
|
123
|
+
'schema consequences (deriving the `RateLimit` list) that a passthrough cannot also ' +
|
|
124
|
+
"apply to the generated Prisma schema. Use `authPlugin({ rateLimit: { storage: 'database' } })` " +
|
|
125
|
+
'instead.',
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
114
129
|
for (const model of MODELS_WITH_NO_ADDITIONAL_FIELDS_PASSTHROUGH) {
|
|
115
130
|
const modelOptions = betterAuthOptions[model]
|
|
116
131
|
if (
|
|
@@ -330,12 +345,19 @@ export async function buildBetterAuthOptions<const TPlugins extends readonly Bet
|
|
|
330
345
|
{} as Record<string, { clientId: string; clientSecret: string }>,
|
|
331
346
|
),
|
|
332
347
|
|
|
333
|
-
// Rate limiting configuration
|
|
348
|
+
// Rate limiting configuration. `modelName`/`fields` are only forwarded
|
|
349
|
+
// when `models.rateLimit` was derived (rateLimit.storage === 'database')
|
|
350
|
+
// — mirroring the running instance's model options back to the table the
|
|
351
|
+
// `RateLimit` Auth list was derived from.
|
|
334
352
|
rateLimit: authConfig.rateLimit
|
|
335
353
|
? {
|
|
336
354
|
enabled: authConfig.rateLimit.enabled,
|
|
337
355
|
window: authConfig.rateLimit.window,
|
|
338
356
|
max: authConfig.rateLimit.max,
|
|
357
|
+
...(authConfig.rateLimit.storage ? { storage: authConfig.rateLimit.storage } : {}),
|
|
358
|
+
...(authConfig.models.rateLimit
|
|
359
|
+
? toBetterAuthModelOptions(authConfig.models.rateLimit)
|
|
360
|
+
: {}),
|
|
339
361
|
}
|
|
340
362
|
: undefined,
|
|
341
363
|
|
|
@@ -565,9 +587,19 @@ function resolveSessionField(
|
|
|
565
587
|
* await headers() })`. Exported as the single reusable implementation; pass
|
|
566
588
|
* the caller's request headers (e.g. Next.js `await headers()` in a Server
|
|
567
589
|
* Component/action) so a session cookie can actually be resolved.
|
|
590
|
+
*
|
|
591
|
+
* `auth` is typed structurally over just the one member this function reads
|
|
592
|
+
* — `api.getSession` — rather than a single concrete `Auth<Options>`
|
|
593
|
+
* instantiation, so it accepts an instance from either `createAuth()`
|
|
594
|
+
* overload: the widened `Auth<BetterAuthOptions>`, or the narrowed
|
|
595
|
+
* `Auth<ResolvedBetterAuthOptions<TPlugins>>` returned when a plugin tuple is
|
|
596
|
+
* passed. `TResolvedSession` is inferred from whatever `auth.api.getSession`
|
|
597
|
+
* actually returns (the default `{ session, user }` shape, or a
|
|
598
|
+
* `customSession` plugin's replaced shape) — it is not constrained, so no
|
|
599
|
+
* `any`/`unknown` is introduced at the call boundary.
|
|
568
600
|
*/
|
|
569
|
-
export async function getSessionFromAuth(
|
|
570
|
-
auth:
|
|
601
|
+
export async function getSessionFromAuth<TResolvedSession>(
|
|
602
|
+
auth: { api: { getSession: (args: { headers: Headers }) => Promise<TResolvedSession> } },
|
|
571
603
|
sessionFields: string[],
|
|
572
604
|
headers: Headers,
|
|
573
605
|
): Promise<Session | null> {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { list } from '@opensaas/stack-core'
|
|
7
|
-
import { text, timestamp, checkbox, integer } from '@opensaas/stack-core/fields'
|
|
7
|
+
import { text, timestamp, checkbox, integer, bigInt } from '@opensaas/stack-core/fields'
|
|
8
8
|
import type { ListConfig, FieldConfig } from '@opensaas/stack-core'
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -23,6 +23,14 @@ type BetterAuthFieldAttribute = {
|
|
|
23
23
|
defaultValue?: unknown
|
|
24
24
|
returned?: boolean
|
|
25
25
|
input?: boolean
|
|
26
|
+
/**
|
|
27
|
+
* "If the field should be a bigint on the database instead of integer" —
|
|
28
|
+
* only meaningful alongside `type: 'number'`. Modelled here so the `number`
|
|
29
|
+
* branch below can read it; better-auth's rate limiter is the only shipped
|
|
30
|
+
* example (`lastRequest`, a millisecond epoch that overflows a 32-bit
|
|
31
|
+
* `Int`), but any plugin can declare it (issue #917).
|
|
32
|
+
*/
|
|
33
|
+
bigint?: boolean
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
/**
|
|
@@ -40,7 +48,7 @@ function convertField(
|
|
|
40
48
|
fieldName: string,
|
|
41
49
|
betterAuthField: BetterAuthFieldAttribute,
|
|
42
50
|
): FieldConfig | null {
|
|
43
|
-
const { type, required, unique, defaultValue, references } = betterAuthField
|
|
51
|
+
const { type, required, unique, defaultValue, references, bigint } = betterAuthField
|
|
44
52
|
|
|
45
53
|
// System fields are auto-generated by OpenSaaS
|
|
46
54
|
if (fieldName === 'id' || fieldName === 'createdAt' || fieldName === 'updatedAt') {
|
|
@@ -64,6 +72,16 @@ function convertField(
|
|
|
64
72
|
})
|
|
65
73
|
|
|
66
74
|
case 'number':
|
|
75
|
+
// A millisecond epoch (e.g. Date.now()) overflows a 32-bit Int, so a
|
|
76
|
+
// field declaring `bigint: true` must generate a BigInt column instead
|
|
77
|
+
// (issue #917) — matching what deriveAuthLists produces for the same
|
|
78
|
+
// upstream declaration (e.g. the rate limiter's `lastRequest`).
|
|
79
|
+
if (bigint) {
|
|
80
|
+
return bigInt({
|
|
81
|
+
validation: { isRequired: required },
|
|
82
|
+
defaultValue: typeof defaultValue === 'number' ? defaultValue : undefined,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
67
85
|
return integer({
|
|
68
86
|
validation: { isRequired: required },
|
|
69
87
|
defaultValue: typeof defaultValue === 'number' ? defaultValue : undefined,
|
|
@@ -137,10 +155,11 @@ export function convertTableToList(
|
|
|
137
155
|
}
|
|
138
156
|
|
|
139
157
|
/**
|
|
140
|
-
* Base better-auth model keys — the
|
|
141
|
-
*
|
|
158
|
+
* Base better-auth model keys — the tables the auth plugin's own model config
|
|
159
|
+
* (`user`/`session`/`account`/`verification`, plus `rateLimit` when
|
|
160
|
+
* `rateLimit.storage: 'database'` derives it) can remap via `modelName`.
|
|
142
161
|
*/
|
|
143
|
-
type BaseAuthModelKey = 'user' | 'session' | 'account' | 'verification'
|
|
162
|
+
type BaseAuthModelKey = 'user' | 'session' | 'account' | 'verification' | 'rateLimit'
|
|
144
163
|
|
|
145
164
|
/**
|
|
146
165
|
* Resolved list key for each base better-auth model, as derived by
|
|
@@ -171,6 +190,8 @@ function resolveBaseModelKey(
|
|
|
171
190
|
return baseModelKeys.account
|
|
172
191
|
case 'verification':
|
|
173
192
|
return baseModelKeys.verification
|
|
193
|
+
case 'ratelimit':
|
|
194
|
+
return baseModelKeys.rateLimit
|
|
174
195
|
default:
|
|
175
196
|
return undefined
|
|
176
197
|
}
|
|
@@ -280,3 +280,76 @@ describe('adoptBetterAuthTables - clean-diff adoption (Auth lists ≠ app User)'
|
|
|
280
280
|
expect(result.lists.AuthSession.fields.user.db?.foreignKey).toEqual({ map: 'user_id' })
|
|
281
281
|
})
|
|
282
282
|
})
|
|
283
|
+
|
|
284
|
+
describe('adoptBetterAuthTables - rateLimit adoption (issue #909)', () => {
|
|
285
|
+
it('omits rateLimit from the fragment by default', () => {
|
|
286
|
+
const fragment = adoptBetterAuthTables()
|
|
287
|
+
|
|
288
|
+
expect(fragment.rateLimit).toBeUndefined()
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('adds a prefixed, database-storage rateLimit fragment when opted in', () => {
|
|
292
|
+
const fragment = adoptBetterAuthTables({ rateLimit: true })
|
|
293
|
+
|
|
294
|
+
expect(fragment.rateLimit).toEqual({
|
|
295
|
+
enabled: true,
|
|
296
|
+
storage: 'database',
|
|
297
|
+
modelName: 'AuthRateLimit',
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('honours a custom schema/prefix on the rateLimit fragment like the other four models', () => {
|
|
302
|
+
const fragment = adoptBetterAuthTables({
|
|
303
|
+
rateLimit: true,
|
|
304
|
+
schema: 'identity',
|
|
305
|
+
modelNamePrefix: 'BA',
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
expect(fragment.rateLimit).toEqual({
|
|
309
|
+
enabled: true,
|
|
310
|
+
storage: 'database',
|
|
311
|
+
modelName: 'BARateLimit',
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('useBetterAuthTableNames sets the rateLimit tableName to the better-auth default', () => {
|
|
316
|
+
const fragment = adoptBetterAuthTables({ rateLimit: true, useBetterAuthTableNames: true })
|
|
317
|
+
|
|
318
|
+
expect(fragment.rateLimit).toEqual({
|
|
319
|
+
enabled: true,
|
|
320
|
+
storage: 'database',
|
|
321
|
+
modelName: 'AuthRateLimit',
|
|
322
|
+
tableName: 'rateLimit',
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('merges a rateLimit field column map when provided', () => {
|
|
327
|
+
const fragment = adoptBetterAuthTables({
|
|
328
|
+
rateLimit: true,
|
|
329
|
+
fields: { rateLimit: { key: 'limit_key' } },
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
expect(fragment.rateLimit).toEqual({
|
|
333
|
+
enabled: true,
|
|
334
|
+
storage: 'database',
|
|
335
|
+
modelName: 'AuthRateLimit',
|
|
336
|
+
fields: { key: 'limit_key' },
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('composes with authPlugin to actually derive the fifth Auth list', async () => {
|
|
341
|
+
const result = await generationConfig({
|
|
342
|
+
db: { provider: 'postgresql' },
|
|
343
|
+
plugins: [
|
|
344
|
+
authPlugin({
|
|
345
|
+
...adoptBetterAuthTables({ rateLimit: true }),
|
|
346
|
+
emailAndPassword: { enabled: true },
|
|
347
|
+
}),
|
|
348
|
+
],
|
|
349
|
+
lists: {},
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
expect(result.lists).toHaveProperty('AuthRateLimit')
|
|
353
|
+
expect(result.lists.AuthRateLimit.db?.schema).toBe('auth')
|
|
354
|
+
})
|
|
355
|
+
})
|
package/tests/config.test.ts
CHANGED
|
@@ -223,6 +223,82 @@ describe('normalizeAuthConfig', () => {
|
|
|
223
223
|
expect(result.models.session.tableName).toBe('sessions')
|
|
224
224
|
})
|
|
225
225
|
})
|
|
226
|
+
|
|
227
|
+
describe('models.rateLimit (issue #909)', () => {
|
|
228
|
+
it('is absent when rateLimit is not configured', () => {
|
|
229
|
+
const result = normalizeAuthConfig({})
|
|
230
|
+
|
|
231
|
+
expect(result.models.rateLimit).toBeUndefined()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('is absent when storage is "memory" or unset', () => {
|
|
235
|
+
expect(normalizeAuthConfig({ rateLimit: { enabled: true } }).models.rateLimit).toBeUndefined()
|
|
236
|
+
expect(
|
|
237
|
+
normalizeAuthConfig({ rateLimit: { enabled: true, storage: 'memory' } }).models.rateLimit,
|
|
238
|
+
).toBeUndefined()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('is absent when storage is "secondary-storage"', () => {
|
|
242
|
+
expect(
|
|
243
|
+
normalizeAuthConfig({ rateLimit: { enabled: true, storage: 'secondary-storage' } }).models
|
|
244
|
+
.rateLimit,
|
|
245
|
+
).toBeUndefined()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('is present with the default RateLimit model name when storage is "database"', () => {
|
|
249
|
+
const result = normalizeAuthConfig({ rateLimit: { enabled: true, storage: 'database' } })
|
|
250
|
+
|
|
251
|
+
expect(result.models.rateLimit).toEqual({
|
|
252
|
+
modelName: 'RateLimit',
|
|
253
|
+
tableName: undefined,
|
|
254
|
+
fields: {},
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('is present even when enabled is false, since better-auth still expects the table', () => {
|
|
259
|
+
const result = normalizeAuthConfig({ rateLimit: { enabled: false, storage: 'database' } })
|
|
260
|
+
|
|
261
|
+
expect(result.models.rateLimit).toBeDefined()
|
|
262
|
+
expect(result.models.rateLimit?.modelName).toBe('RateLimit')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('honours a custom modelName/tableName/fields/schema on the rateLimit model', () => {
|
|
266
|
+
const result = normalizeAuthConfig({
|
|
267
|
+
rateLimit: {
|
|
268
|
+
enabled: true,
|
|
269
|
+
storage: 'database',
|
|
270
|
+
modelName: 'AuthRateLimit',
|
|
271
|
+
tableName: 'rate_limit',
|
|
272
|
+
fields: { key: 'limit_key' },
|
|
273
|
+
schema: 'auth',
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
expect(result.models.rateLimit).toEqual({
|
|
278
|
+
modelName: 'AuthRateLimit',
|
|
279
|
+
tableName: 'rate_limit',
|
|
280
|
+
fields: { key: 'limit_key' },
|
|
281
|
+
schema: 'auth',
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('resolves the plugin-level schema default for the rateLimit model like the other four', () => {
|
|
286
|
+
const result = normalizeAuthConfig({
|
|
287
|
+
schema: 'auth',
|
|
288
|
+
rateLimit: { enabled: true, storage: 'database' },
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
expect(result.models.rateLimit?.schema).toBe('auth')
|
|
292
|
+
expect(result.models.user.schema).toBe('auth')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('keeps storage on the normalized top-level rateLimit config', () => {
|
|
296
|
+
const result = normalizeAuthConfig({ rateLimit: { enabled: true, storage: 'database' } })
|
|
297
|
+
|
|
298
|
+
expect(result.rateLimit?.storage).toBe('database')
|
|
299
|
+
expect(result.rateLimit?.enabled).toBe(true)
|
|
300
|
+
})
|
|
301
|
+
})
|
|
226
302
|
})
|
|
227
303
|
|
|
228
304
|
describe('authPlugin', () => {
|
|
@@ -585,4 +661,89 @@ describe('authPlugin', () => {
|
|
|
585
661
|
expect(result.lists.User.access).toBe(extendAccess)
|
|
586
662
|
})
|
|
587
663
|
})
|
|
664
|
+
|
|
665
|
+
describe('RateLimit list (issue #909)', () => {
|
|
666
|
+
it('does not inject a RateLimit list when rateLimit is unconfigured', async () => {
|
|
667
|
+
const result = await config({
|
|
668
|
+
plugins: [authPlugin({})],
|
|
669
|
+
lists: {},
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
expect(result.lists).not.toHaveProperty('RateLimit')
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
it('does not inject a RateLimit list for storage "memory" or "secondary-storage"', async () => {
|
|
676
|
+
const memory = await config({
|
|
677
|
+
plugins: [authPlugin({ rateLimit: { enabled: true, storage: 'memory' } })],
|
|
678
|
+
lists: {},
|
|
679
|
+
})
|
|
680
|
+
expect(memory.lists).not.toHaveProperty('RateLimit')
|
|
681
|
+
|
|
682
|
+
const secondary = await config({
|
|
683
|
+
plugins: [authPlugin({ rateLimit: { enabled: true, storage: 'secondary-storage' } })],
|
|
684
|
+
lists: {},
|
|
685
|
+
})
|
|
686
|
+
expect(secondary.lists).not.toHaveProperty('RateLimit')
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
it('injects a RateLimit list when storage is "database"', async () => {
|
|
690
|
+
const result = await config({
|
|
691
|
+
plugins: [authPlugin({ rateLimit: { enabled: true, storage: 'database' } })],
|
|
692
|
+
lists: {},
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
const rateLimit = result.lists.RateLimit
|
|
696
|
+
expect(rateLimit).toBeDefined()
|
|
697
|
+
expect(rateLimit.fields).toHaveProperty('key')
|
|
698
|
+
expect(rateLimit.fields).toHaveProperty('count')
|
|
699
|
+
expect(rateLimit.fields).toHaveProperty('lastRequest')
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
it('injects a RateLimit list even when enabled is false, since better-auth still expects the table', async () => {
|
|
703
|
+
const result = await config({
|
|
704
|
+
plugins: [authPlugin({ rateLimit: { enabled: false, storage: 'database' } })],
|
|
705
|
+
lists: {},
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
expect(result.lists.RateLimit).toBeDefined()
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
it('ships the RateLimit list closed by default (ADR-0013)', async () => {
|
|
712
|
+
const result = await config({
|
|
713
|
+
plugins: [authPlugin({ rateLimit: { enabled: true, storage: 'database' } })],
|
|
714
|
+
lists: {},
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
expect(result.lists.RateLimit.access).toBeUndefined()
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
it('applies access.rateLimit to the derived list', async () => {
|
|
721
|
+
const rateLimitQuery = () => true
|
|
722
|
+
const result = await config({
|
|
723
|
+
plugins: [
|
|
724
|
+
authPlugin({
|
|
725
|
+
rateLimit: { enabled: true, storage: 'database' },
|
|
726
|
+
access: { rateLimit: { operation: { query: rateLimitQuery } } },
|
|
727
|
+
}),
|
|
728
|
+
],
|
|
729
|
+
lists: {},
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
expect(result.lists.RateLimit.access?.operation?.query).toBe(rateLimitQuery)
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
it('respects a custom modelName on the rateLimit config', async () => {
|
|
736
|
+
const result = await config({
|
|
737
|
+
plugins: [
|
|
738
|
+
authPlugin({
|
|
739
|
+
rateLimit: { enabled: true, storage: 'database', modelName: 'AuthRateLimit' },
|
|
740
|
+
}),
|
|
741
|
+
],
|
|
742
|
+
lists: {},
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
expect(result.lists).toHaveProperty('AuthRateLimit')
|
|
746
|
+
expect(result.lists).not.toHaveProperty('RateLimit')
|
|
747
|
+
})
|
|
748
|
+
})
|
|
588
749
|
})
|
|
@@ -344,6 +344,110 @@ describe('deriveAuthLists - schema placement', () => {
|
|
|
344
344
|
})
|
|
345
345
|
})
|
|
346
346
|
|
|
347
|
+
describe('deriveAuthLists - RateLimit list (rateLimit.storage === "database")', () => {
|
|
348
|
+
it('is absent when no rateLimit model is supplied', () => {
|
|
349
|
+
const { keys, lists } = deriveAuthLists(defaultModels)
|
|
350
|
+
|
|
351
|
+
expect(keys.rateLimit).toBeUndefined()
|
|
352
|
+
expect(lists.RateLimit).toBeUndefined()
|
|
353
|
+
expect(Object.keys(lists).sort()).toEqual(['Account', 'Session', 'User', 'Verification'])
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('derives a RateLimit list keyed by the default model name when present', () => {
|
|
357
|
+
const models: NormalizedAuthModels = {
|
|
358
|
+
...defaultModels,
|
|
359
|
+
rateLimit: { modelName: 'RateLimit', fields: {} },
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const { keys, lists } = deriveAuthLists(models)
|
|
363
|
+
|
|
364
|
+
expect(keys.rateLimit).toBe('RateLimit')
|
|
365
|
+
expect(Object.keys(lists).sort()).toEqual([
|
|
366
|
+
'Account',
|
|
367
|
+
'RateLimit',
|
|
368
|
+
'Session',
|
|
369
|
+
'User',
|
|
370
|
+
'Verification',
|
|
371
|
+
])
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('mirrors better-auth’s rateLimit table shape: key/count/lastRequest, all required, no defaults', () => {
|
|
375
|
+
const models: NormalizedAuthModels = {
|
|
376
|
+
...defaultModels,
|
|
377
|
+
rateLimit: { modelName: 'RateLimit', fields: {} },
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const { lists } = deriveAuthLists(models)
|
|
381
|
+
const rateLimit = lists.RateLimit
|
|
382
|
+
|
|
383
|
+
expect(rateLimit.fields.key.type).toBe('text')
|
|
384
|
+
expect(rateLimit.fields.key.isIndexed).toBe('unique')
|
|
385
|
+
expect(rateLimit.fields.key.validation?.isRequired).toBe(true)
|
|
386
|
+
expect(rateLimit.fields.key.defaultValue).toBeUndefined()
|
|
387
|
+
|
|
388
|
+
expect(rateLimit.fields.count.type).toBe('integer')
|
|
389
|
+
expect(rateLimit.fields.count.validation?.isRequired).toBe(true)
|
|
390
|
+
expect(rateLimit.fields.count.db?.isNullable).toBe(false)
|
|
391
|
+
expect(rateLimit.fields.count.defaultValue).toBeUndefined()
|
|
392
|
+
|
|
393
|
+
expect(rateLimit.fields.lastRequest.type).toBe('bigInt')
|
|
394
|
+
expect(rateLimit.fields.lastRequest.validation?.isRequired).toBe(true)
|
|
395
|
+
expect(rateLimit.fields.lastRequest.db?.isNullable).toBe(false)
|
|
396
|
+
expect(rateLimit.fields.lastRequest.defaultValue).toBeUndefined()
|
|
397
|
+
|
|
398
|
+
// Exactly these three fields — no createdAt/updatedAt columns on this list.
|
|
399
|
+
expect(Object.keys(rateLimit.fields).sort()).toEqual(['count', 'key', 'lastRequest'])
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('does not opt into auto-timestamps, unlike the other four Auth lists', () => {
|
|
403
|
+
const models: NormalizedAuthModels = {
|
|
404
|
+
...defaultModels,
|
|
405
|
+
rateLimit: { modelName: 'RateLimit', fields: {} },
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const { lists } = deriveAuthLists(models)
|
|
409
|
+
|
|
410
|
+
expect(lists.RateLimit.db?.timestamps).toBeUndefined()
|
|
411
|
+
expect(lists.User.db?.timestamps).toBe(true)
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('applies a custom modelName, tableName, field column maps, and schema like the other models', () => {
|
|
415
|
+
const models: NormalizedAuthModels = {
|
|
416
|
+
...defaultModels,
|
|
417
|
+
rateLimit: {
|
|
418
|
+
modelName: 'AuthRateLimit',
|
|
419
|
+
tableName: 'rate_limit',
|
|
420
|
+
fields: { key: 'limit_key', count: 'hit_count', lastRequest: 'last_hit_at' },
|
|
421
|
+
schema: 'auth',
|
|
422
|
+
},
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const { keys, lists } = deriveAuthLists(models)
|
|
426
|
+
|
|
427
|
+
expect(keys.rateLimit).toBe('AuthRateLimit')
|
|
428
|
+
const rateLimit = lists.AuthRateLimit
|
|
429
|
+
expect(rateLimit.db?.map).toBe('rate_limit')
|
|
430
|
+
expect(rateLimit.db?.schema).toBe('auth')
|
|
431
|
+
expect(rateLimit.fields.key.db?.map).toBe('limit_key')
|
|
432
|
+
expect(rateLimit.fields.count.db?.map).toBe('hit_count')
|
|
433
|
+
expect(rateLimit.fields.lastRequest.db?.map).toBe('last_hit_at')
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('ships closed (no access) unless accessConfig.rateLimit is supplied', () => {
|
|
437
|
+
const models: NormalizedAuthModels = {
|
|
438
|
+
...defaultModels,
|
|
439
|
+
rateLimit: { modelName: 'RateLimit', fields: {} },
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const { lists: closed } = deriveAuthLists(models)
|
|
443
|
+
expect(closed.RateLimit.access).toBeUndefined()
|
|
444
|
+
|
|
445
|
+
const rateLimitAccess = { operation: { query: () => true } }
|
|
446
|
+
const { lists: open } = deriveAuthLists(models, {}, { rateLimit: rateLimitAccess })
|
|
447
|
+
expect(open.RateLimit.access).toBe(rateLimitAccess)
|
|
448
|
+
})
|
|
449
|
+
})
|
|
450
|
+
|
|
347
451
|
describe('deriveAuthLists - extendUserList', () => {
|
|
348
452
|
it('adds custom fields to the derived user list', () => {
|
|
349
453
|
const { lists } = deriveAuthLists(
|