@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
package/src/server/index.ts
CHANGED
|
@@ -1,11 +1,58 @@
|
|
|
1
1
|
import { betterAuth } from 'better-auth'
|
|
2
2
|
import { prismaAdapter } from 'better-auth/adapters/prisma'
|
|
3
3
|
import { nextCookies } from 'better-auth/next-js'
|
|
4
|
-
import type { BetterAuthOptions } from 'better-auth'
|
|
5
|
-
import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
|
|
4
|
+
import type { Auth, BetterAuthOptions, BetterAuthPlugin } from 'better-auth'
|
|
5
|
+
import type { OpenSaasConfig, AccessContext, Session } from '@opensaas/stack-core'
|
|
6
6
|
import type { DatabaseConfig } from '@opensaas/stack-core/internal'
|
|
7
7
|
import type { NormalizedAuthConfig, NormalizedAuthModelConfig } from '../config/types.js'
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The `BetterAuthOptions` shape produced when an app's own plugin tuple is
|
|
11
|
+
* passed to `buildBetterAuthOptions()`/`createAuth()` — the tuple plus the
|
|
12
|
+
* `nextCookies()` plugin the stack always appends last. Carrying the literal
|
|
13
|
+
* tuple type (rather than the widened `BetterAuthPlugin[]`) is what lets
|
|
14
|
+
* `betterAuth()` re-infer plugin endpoints (e.g. `emailOTP()`'s
|
|
15
|
+
* `api.signInEmailOTP`) and a `customSession()` plugin's replaced session
|
|
16
|
+
* shape from the resulting options object.
|
|
17
|
+
*/
|
|
18
|
+
type ResolvedBetterAuthOptions<TPlugins extends readonly BetterAuthPlugin[]> = Omit<
|
|
19
|
+
BetterAuthOptions,
|
|
20
|
+
'plugins'
|
|
21
|
+
> & {
|
|
22
|
+
plugins: [...TPlugins, ReturnType<typeof nextCookies>]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Guard against the supplied plugin tuple silently drifting from the plugin
|
|
27
|
+
* array actually resolved from `authPlugin({ betterAuthPlugins })` — the
|
|
28
|
+
* supplied tuple exists for typing only, so if it isn't the exact same
|
|
29
|
+
* instances in the exact same order, the type it produces would be a lie
|
|
30
|
+
* about what `betterAuth()` is actually constructed with.
|
|
31
|
+
*/
|
|
32
|
+
function assertPluginTupleMatchesResolved(
|
|
33
|
+
supplied: readonly BetterAuthPlugin[],
|
|
34
|
+
resolved: readonly BetterAuthPlugin[],
|
|
35
|
+
): void {
|
|
36
|
+
if (supplied.length !== resolved.length) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'[@opensaas/stack-auth] The plugin tuple passed to `buildBetterAuthOptions()` / `createAuth()` ' +
|
|
39
|
+
`has ${supplied.length} plugin(s), but the plugin array resolved from \`authPlugin({ ` +
|
|
40
|
+
`betterAuthPlugins })\` has ${resolved.length}. Pass the exact same array (without ` +
|
|
41
|
+
'`nextCookies()` — the stack appends that itself).',
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const mismatchIndex = supplied.findIndex((plugin, index) => plugin !== resolved[index])
|
|
46
|
+
if (mismatchIndex !== -1) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
'[@opensaas/stack-auth] The plugin tuple passed to `buildBetterAuthOptions()` / `createAuth()` ' +
|
|
49
|
+
`does not match the plugin array resolved from \`authPlugin({ betterAuthPlugins })\` at index ` +
|
|
50
|
+
`${mismatchIndex} (got plugin "${supplied[mismatchIndex]?.id}", expected the same instance as ` +
|
|
51
|
+
`"${resolved[mismatchIndex]?.id}"). Pass the exact same array — same instances, same order.`,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
9
56
|
/**
|
|
10
57
|
* Get better-auth database configuration from OpenSaas config
|
|
11
58
|
*/
|
|
@@ -34,112 +81,345 @@ function toBetterAuthModelOptions(
|
|
|
34
81
|
return Object.keys(options).length > 0 ? options : undefined
|
|
35
82
|
}
|
|
36
83
|
|
|
84
|
+
const MODELS_WITH_NO_ADDITIONAL_FIELDS_PASSTHROUGH = [
|
|
85
|
+
'user',
|
|
86
|
+
'session',
|
|
87
|
+
'account',
|
|
88
|
+
'verification',
|
|
89
|
+
] as const
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reject `betterAuthOptions` keys that already have a dedicated, non-passthrough
|
|
93
|
+
* seam — accepting them here would create two unranked ways to set the same
|
|
94
|
+
* thing, or (for `additionalFields`) silently diverge from the generated
|
|
95
|
+
* Prisma schema. See the `betterAuthOptions` doc comment on `AuthConfig`.
|
|
96
|
+
*/
|
|
97
|
+
function assertNoUnsupportedPassthroughKeys(betterAuthOptions: Record<string, unknown>): void {
|
|
98
|
+
if ('database' in betterAuthOptions) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
'[@opensaas/stack-auth] `betterAuthOptions.database` is not supported — the stack ' +
|
|
101
|
+
'derives the database adapter from your `db` config and the running context. ' +
|
|
102
|
+
'Configure the database through `db` in `opensaas.config.ts` instead.',
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if ('plugins' in betterAuthOptions) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
'[@opensaas/stack-auth] `betterAuthOptions.plugins` is not supported — better-auth ' +
|
|
109
|
+
'plugins are added through `authPlugin({ betterAuthPlugins: [...] })`, which the stack ' +
|
|
110
|
+
'appends `nextCookies()` after. Use `betterAuthPlugins` instead.',
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const model of MODELS_WITH_NO_ADDITIONAL_FIELDS_PASSTHROUGH) {
|
|
115
|
+
const modelOptions = betterAuthOptions[model]
|
|
116
|
+
if (
|
|
117
|
+
modelOptions &&
|
|
118
|
+
typeof modelOptions === 'object' &&
|
|
119
|
+
!Array.isArray(modelOptions) &&
|
|
120
|
+
'additionalFields' in modelOptions
|
|
121
|
+
) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`[@opensaas/stack-auth] \`betterAuthOptions.${model}.additionalFields\` is not ` +
|
|
124
|
+
'supported — it adds columns that would not be reflected in the generated Prisma ' +
|
|
125
|
+
'schema. Add fields to the derived list instead: ' +
|
|
126
|
+
(model === 'user'
|
|
127
|
+
? '`extendUserList`, or declare the list yourself in your own `lists` config.'
|
|
128
|
+
: 'declare the derived list yourself in your own `lists` config (the auth plugin ' +
|
|
129
|
+
'merges in field additions for the models it derives).'),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
136
|
+
return (
|
|
137
|
+
typeof value === 'object' &&
|
|
138
|
+
value !== null &&
|
|
139
|
+
!Array.isArray(value) &&
|
|
140
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Deep-merge `overrides` onto `base`, recursing into plain-object values so a
|
|
146
|
+
* nested addition (one database hook, one session sub-option) merges
|
|
147
|
+
* alongside sibling keys the stack already set there rather than replacing
|
|
148
|
+
* the whole branch. Arrays and any other value type replace outright.
|
|
149
|
+
* `overrides` wins on every key collision.
|
|
150
|
+
*/
|
|
151
|
+
function mergeBetterAuthOptions(
|
|
152
|
+
base: Record<string, unknown>,
|
|
153
|
+
overrides: Record<string, unknown>,
|
|
154
|
+
): Record<string, unknown> {
|
|
155
|
+
const result: Record<string, unknown> = { ...base }
|
|
156
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
157
|
+
const baseValue = result[key]
|
|
158
|
+
result[key] =
|
|
159
|
+
isPlainObject(baseValue) && isPlainObject(value)
|
|
160
|
+
? mergeBetterAuthOptions(baseValue, value)
|
|
161
|
+
: value
|
|
162
|
+
}
|
|
163
|
+
return result
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build the `BetterAuthOptions` a better-auth instance for this OpenSaas
|
|
168
|
+
* config should be constructed with — the same options `createAuth()` uses
|
|
169
|
+
* internally, available standalone for an app that still needs to hand-wire
|
|
170
|
+
* its own `betterAuth()` instance (e.g. a third-party contract that requires
|
|
171
|
+
* a resolved instance at module-init time). Keeps the auth plugin
|
|
172
|
+
* authoritative for everything it models; the app's additions on top become
|
|
173
|
+
* an explicit, reviewable diff instead of a parallel, hand-duplicated config.
|
|
174
|
+
*
|
|
175
|
+
* Called with just `(config, context)`, the return type is the widened
|
|
176
|
+
* `BetterAuthOptions` — `betterAuth()` infers its plugin/session types from
|
|
177
|
+
* the *literal* type of the options object, so constructing from this
|
|
178
|
+
* widened return erases plugin endpoints (e.g. `emailOTP()`'s
|
|
179
|
+
* `api.signInEmailOTP`) and a `customSession()` plugin's replaced session
|
|
180
|
+
* shape. **If your app reads `auth.api.*` in typed code and uses either of
|
|
181
|
+
* those, pass its plugin tuple as the third argument** — the exact same
|
|
182
|
+
* array already passed to `authPlugin({ betterAuthPlugins })` — so the
|
|
183
|
+
* return type carries the literal tuple instead:
|
|
184
|
+
*
|
|
185
|
+
* ```typescript
|
|
186
|
+
* import { betterAuth } from 'better-auth'
|
|
187
|
+
* import { emailOTP } from 'better-auth/plugins'
|
|
188
|
+
* import { buildBetterAuthOptions } from '@opensaas/stack-auth/server'
|
|
189
|
+
*
|
|
190
|
+
* export const appBetterAuthPlugins = [emailOTP()] // same array passed to authPlugin({ betterAuthPlugins })
|
|
191
|
+
*
|
|
192
|
+
* export const auth = betterAuth({
|
|
193
|
+
* ...(await buildBetterAuthOptions(config, context, appBetterAuthPlugins)),
|
|
194
|
+
* databaseHooks: { user: { create: { after: syncDomainUser } } },
|
|
195
|
+
* })
|
|
196
|
+
* // auth.api.signInEmailOTP / auth.api.getSession()'s customSession shape are now typed.
|
|
197
|
+
* ```
|
|
198
|
+
*
|
|
199
|
+
* The supplied tuple is for typing only — the array actually used at runtime
|
|
200
|
+
* is always the one resolved from `authPlugin({ betterAuthPlugins })`, with
|
|
201
|
+
* exactly one `nextCookies()` appended last. Passing a tuple that isn't the
|
|
202
|
+
* same plugin instances in the same order throws, so the two can't silently
|
|
203
|
+
* drift apart.
|
|
204
|
+
*
|
|
205
|
+
* Note `createAuth()`'s lazy Proxy does not behave identically to a real
|
|
206
|
+
* `Auth` instance for every property (see its own doc comment) — reach for
|
|
207
|
+
* this builder plus `betterAuth()` instead when the app reads `auth.api.*`
|
|
208
|
+
* in typed code.
|
|
209
|
+
*/
|
|
210
|
+
export async function buildBetterAuthOptions(
|
|
211
|
+
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
212
|
+
context: AccessContext | Promise<AccessContext>,
|
|
213
|
+
): Promise<BetterAuthOptions>
|
|
214
|
+
export async function buildBetterAuthOptions<const TPlugins extends readonly BetterAuthPlugin[]>(
|
|
215
|
+
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
216
|
+
context: AccessContext | Promise<AccessContext>,
|
|
217
|
+
plugins: TPlugins,
|
|
218
|
+
): Promise<ResolvedBetterAuthOptions<TPlugins>>
|
|
219
|
+
export async function buildBetterAuthOptions<const TPlugins extends readonly BetterAuthPlugin[]>(
|
|
220
|
+
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
221
|
+
context: AccessContext | Promise<AccessContext>,
|
|
222
|
+
plugins?: TPlugins,
|
|
223
|
+
): Promise<BetterAuthOptions | ResolvedBetterAuthOptions<TPlugins>> {
|
|
224
|
+
const resolvedConfig = await Promise.resolve(opensaasConfig)
|
|
225
|
+
const resolvedContext = await Promise.resolve(context)
|
|
226
|
+
|
|
227
|
+
// Extract auth config from plugin data
|
|
228
|
+
const authConfig = resolvedConfig._pluginData?.auth as NormalizedAuthConfig | undefined
|
|
229
|
+
|
|
230
|
+
if (!authConfig) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
'Auth config not found. Make sure to use authPlugin() in your opensaas.config.ts',
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// `requireConfirmation` has no better-auth equivalent — it's a UI-only
|
|
237
|
+
// concern the pre-built forms already take as their own
|
|
238
|
+
// `requirePasswordConfirmation` prop. Warn rather than silently drop it,
|
|
239
|
+
// since setting it here looks like it should do something.
|
|
240
|
+
if (
|
|
241
|
+
authConfig.emailAndPassword.enabled &&
|
|
242
|
+
authConfig.emailAndPassword.requireConfirmation !== true
|
|
243
|
+
) {
|
|
244
|
+
console.warn(
|
|
245
|
+
'[@opensaas/stack-auth] `emailAndPassword.requireConfirmation` has no effect here — ' +
|
|
246
|
+
'createAuth() has no better-auth option to forward it to. Pass ' +
|
|
247
|
+
'`requirePasswordConfirmation` directly to <SignUpForm> / <ResetPasswordForm> instead.',
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// `passwordReset` is wired through better-auth's `emailAndPassword` config
|
|
252
|
+
// (there's no password to reset without a password-based account), so it
|
|
253
|
+
// silently has no effect if email/password auth itself isn't enabled.
|
|
254
|
+
if (authConfig.passwordReset.enabled && !authConfig.emailAndPassword.enabled) {
|
|
255
|
+
console.warn(
|
|
256
|
+
'[@opensaas/stack-auth] `passwordReset.enabled` has no effect here — ' +
|
|
257
|
+
'`emailAndPassword.enabled` is false, so there is no password-based account to reset.',
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
assertNoUnsupportedPassthroughKeys(authConfig.betterAuthOptions as Record<string, unknown>)
|
|
262
|
+
|
|
263
|
+
const resolvedPlugins = authConfig.betterAuthPlugins || []
|
|
264
|
+
if (plugins) {
|
|
265
|
+
assertPluginTupleMatchesResolved(plugins, resolvedPlugins)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Build better-auth configuration
|
|
269
|
+
const betterAuthConfig: BetterAuthOptions = {
|
|
270
|
+
database: getDatabaseConfig(resolvedConfig.db, resolvedContext),
|
|
271
|
+
|
|
272
|
+
// Mirror the per-model config (modelName + field column maps) back to
|
|
273
|
+
// better-auth so the running auth instance reads/writes the same
|
|
274
|
+
// tables/columns the OpenSaaS Auth lists were derived from.
|
|
275
|
+
user: toBetterAuthModelOptions(authConfig.models.user),
|
|
276
|
+
session: {
|
|
277
|
+
...toBetterAuthModelOptions(authConfig.models.session),
|
|
278
|
+
expiresIn: authConfig.session.expiresIn || 604800,
|
|
279
|
+
// better-auth treats `updateAge: 0` as "refresh on every request", not
|
|
280
|
+
// "never refresh" — disabling refresh entirely requires its separate
|
|
281
|
+
// `disableSessionRefresh` flag regardless of `updateAge`.
|
|
282
|
+
...(authConfig.session.updateAge === false
|
|
283
|
+
? { disableSessionRefresh: true }
|
|
284
|
+
: { updateAge: authConfig.session.updateAge }),
|
|
285
|
+
},
|
|
286
|
+
account: toBetterAuthModelOptions(authConfig.models.account),
|
|
287
|
+
verification: toBetterAuthModelOptions(authConfig.models.verification),
|
|
288
|
+
|
|
289
|
+
// Enable email and password if configured
|
|
290
|
+
emailAndPassword: authConfig.emailAndPassword.enabled
|
|
291
|
+
? {
|
|
292
|
+
enabled: true,
|
|
293
|
+
requireEmailVerification: authConfig.emailVerification.enabled,
|
|
294
|
+
minPasswordLength: authConfig.emailAndPassword.minPasswordLength,
|
|
295
|
+
...(authConfig.passwordReset.enabled
|
|
296
|
+
? {
|
|
297
|
+
sendResetPassword: authConfig.emailAndPassword.sendResetPassword,
|
|
298
|
+
resetPasswordTokenExpiresIn: authConfig.passwordReset.tokenExpiration,
|
|
299
|
+
}
|
|
300
|
+
: {}),
|
|
301
|
+
}
|
|
302
|
+
: undefined,
|
|
303
|
+
|
|
304
|
+
// Email verification (independent of emailAndPassword — also covers
|
|
305
|
+
// e.g. a social-provider account whose email isn't yet verified)
|
|
306
|
+
emailVerification: authConfig.emailVerification.enabled
|
|
307
|
+
? {
|
|
308
|
+
sendVerificationEmail: authConfig.emailVerification.sendVerificationEmail,
|
|
309
|
+
sendOnSignUp: authConfig.emailVerification.sendOnSignUp,
|
|
310
|
+
expiresIn: authConfig.emailVerification.tokenExpiration,
|
|
311
|
+
}
|
|
312
|
+
: undefined,
|
|
313
|
+
|
|
314
|
+
// Trust host (required for production)
|
|
315
|
+
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(',') || [],
|
|
316
|
+
|
|
317
|
+
// Social providers
|
|
318
|
+
socialProviders: Object.entries(authConfig.socialProviders)
|
|
319
|
+
.filter(([_, config]) => config?.enabled !== false)
|
|
320
|
+
.reduce(
|
|
321
|
+
(acc, [provider, config]) => {
|
|
322
|
+
if (config) {
|
|
323
|
+
acc[provider] = {
|
|
324
|
+
clientId: config.clientId,
|
|
325
|
+
clientSecret: config.clientSecret,
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return acc
|
|
329
|
+
},
|
|
330
|
+
{} as Record<string, { clientId: string; clientSecret: string }>,
|
|
331
|
+
),
|
|
332
|
+
|
|
333
|
+
// Rate limiting configuration
|
|
334
|
+
rateLimit: authConfig.rateLimit
|
|
335
|
+
? {
|
|
336
|
+
enabled: authConfig.rateLimit.enabled,
|
|
337
|
+
window: authConfig.rateLimit.window,
|
|
338
|
+
max: authConfig.rateLimit.max,
|
|
339
|
+
}
|
|
340
|
+
: undefined,
|
|
341
|
+
|
|
342
|
+
// Pass through any additional Better Auth plugins, then append
|
|
343
|
+
// nextCookies LAST so it can write the Set-Cookie headers produced by
|
|
344
|
+
// any auth.api.* call made inside a Next.js server action into Next's
|
|
345
|
+
// cookie store. This is what makes the server-action auth forms (which
|
|
346
|
+
// call auth.api.signInEmail/signUpEmail/etc. server-side) actually
|
|
347
|
+
// persist a session. It must be the final plugin in the array.
|
|
348
|
+
plugins: [...resolvedPlugins, nextCookies()],
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return mergeBetterAuthOptions(
|
|
352
|
+
betterAuthConfig as unknown as Record<string, unknown>,
|
|
353
|
+
authConfig.betterAuthOptions as Record<string, unknown>,
|
|
354
|
+
) as BetterAuthOptions | ResolvedBetterAuthOptions<TPlugins>
|
|
355
|
+
}
|
|
356
|
+
|
|
37
357
|
/**
|
|
38
358
|
* Create a better-auth instance from OpenSaas config
|
|
39
359
|
* This should be called once at app startup
|
|
40
360
|
*
|
|
41
|
-
*
|
|
361
|
+
* Returns a lazy `Proxy` (see the caveat below), typed as `Auth<BetterAuthOptions>`
|
|
362
|
+
* when called with just `(config, context)` — the widened type, same erasure
|
|
363
|
+
* caveat as {@link buildBetterAuthOptions}'s no-argument form. **If your app
|
|
364
|
+
* reads `auth.api.*` in typed code and relies on a plugin's endpoints (e.g.
|
|
365
|
+
* `emailOTP()`) or a `customSession()`'s replaced session shape, pass its
|
|
366
|
+
* plugin tuple as the third argument** — the exact same array already passed
|
|
367
|
+
* to `authPlugin({ betterAuthPlugins })` — so the declared type carries the
|
|
368
|
+
* literal tuple instead:
|
|
369
|
+
*
|
|
42
370
|
* ```typescript
|
|
43
371
|
* // lib/auth.ts
|
|
44
372
|
* import { createAuth } from '@opensaas/stack-auth/server'
|
|
45
373
|
* import config from '../opensaas.config'
|
|
46
374
|
* import { rawOpensaasContext } from '@/.opensaas/context'
|
|
47
375
|
*
|
|
48
|
-
* export const
|
|
376
|
+
* export const appBetterAuthPlugins = [emailOTP()] // same array passed to authPlugin({ betterAuthPlugins })
|
|
377
|
+
*
|
|
378
|
+
* export const auth = createAuth(config, rawOpensaasContext, appBetterAuthPlugins)
|
|
49
379
|
* ```
|
|
380
|
+
*
|
|
381
|
+
* As with the builder, the supplied tuple is for typing only, and a tuple
|
|
382
|
+
* that isn't the same plugin instances in the same order throws.
|
|
383
|
+
*
|
|
384
|
+
* **Proxy caveat:** the lazy `Proxy` this returns does not behave identically
|
|
385
|
+
* to a real `Auth` instance for every property — every access, including a
|
|
386
|
+
* non-function property, is surfaced through an `async` wrapper (so e.g.
|
|
387
|
+
* `auth.options` reads back as a `Promise`, not the plain object a real
|
|
388
|
+
* instance would return synchronously). The declared type does not model
|
|
389
|
+
* this difference; where it matters, reach for {@link buildBetterAuthOptions}
|
|
390
|
+
* plus `betterAuth()` instead, which constructs a real instance.
|
|
50
391
|
*/
|
|
51
392
|
export function createAuth(
|
|
52
393
|
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
53
394
|
context: AccessContext | Promise<AccessContext>,
|
|
54
|
-
)
|
|
395
|
+
): Auth<BetterAuthOptions>
|
|
396
|
+
export function createAuth<const TPlugins extends readonly BetterAuthPlugin[]>(
|
|
397
|
+
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
398
|
+
context: AccessContext | Promise<AccessContext>,
|
|
399
|
+
plugins: TPlugins,
|
|
400
|
+
): Auth<ResolvedBetterAuthOptions<TPlugins>>
|
|
401
|
+
export function createAuth<const TPlugins extends readonly BetterAuthPlugin[]>(
|
|
402
|
+
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
403
|
+
context: AccessContext | Promise<AccessContext>,
|
|
404
|
+
plugins?: TPlugins,
|
|
405
|
+
): Auth<BetterAuthOptions> | Auth<ResolvedBetterAuthOptions<TPlugins>> {
|
|
55
406
|
// Resolve config and context asynchronously
|
|
56
407
|
const configPromise = Promise.resolve(opensaasConfig)
|
|
57
408
|
const contextPromise = Promise.resolve(context)
|
|
58
409
|
|
|
59
410
|
// Create auth instance lazily when needed
|
|
60
|
-
|
|
61
|
-
let
|
|
411
|
+
type AuthInstance = Auth<BetterAuthOptions> | Auth<ResolvedBetterAuthOptions<TPlugins>>
|
|
412
|
+
let authInstance: AuthInstance | null = null
|
|
413
|
+
let authPromise: Promise<AuthInstance> | null = null
|
|
62
414
|
|
|
63
415
|
async function getAuthInstance() {
|
|
64
416
|
if (authInstance) return authInstance
|
|
65
417
|
|
|
66
418
|
if (!authPromise) {
|
|
67
419
|
authPromise = (async () => {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
// Extract auth config from plugin data
|
|
72
|
-
const authConfig = resolvedConfig._pluginData?.auth as NormalizedAuthConfig | undefined
|
|
73
|
-
|
|
74
|
-
if (!authConfig) {
|
|
75
|
-
throw new Error(
|
|
76
|
-
'Auth config not found. Make sure to use authPlugin() in your opensaas.config.ts',
|
|
77
|
-
)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Build better-auth configuration
|
|
81
|
-
const betterAuthConfig: BetterAuthOptions = {
|
|
82
|
-
database: getDatabaseConfig(resolvedConfig.db, resolvedContext),
|
|
83
|
-
|
|
84
|
-
// Mirror the per-model config (modelName + field column maps) back to
|
|
85
|
-
// better-auth so the running auth instance reads/writes the same
|
|
86
|
-
// tables/columns the OpenSaaS Auth lists were derived from.
|
|
87
|
-
user: toBetterAuthModelOptions(authConfig.models.user),
|
|
88
|
-
session: {
|
|
89
|
-
...toBetterAuthModelOptions(authConfig.models.session),
|
|
90
|
-
expiresIn: authConfig.session.expiresIn || 604800,
|
|
91
|
-
updateAge: authConfig.session.updateAge
|
|
92
|
-
? (authConfig.session.expiresIn || 604800) / 10
|
|
93
|
-
: 0,
|
|
94
|
-
},
|
|
95
|
-
account: toBetterAuthModelOptions(authConfig.models.account),
|
|
96
|
-
verification: toBetterAuthModelOptions(authConfig.models.verification),
|
|
97
|
-
|
|
98
|
-
// Enable email and password if configured
|
|
99
|
-
emailAndPassword: authConfig.emailAndPassword.enabled
|
|
100
|
-
? {
|
|
101
|
-
enabled: true,
|
|
102
|
-
requireEmailVerification: authConfig.emailVerification.enabled,
|
|
103
|
-
}
|
|
104
|
-
: undefined,
|
|
105
|
-
|
|
106
|
-
// Trust host (required for production)
|
|
107
|
-
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(',') || [],
|
|
108
|
-
|
|
109
|
-
// Social providers
|
|
110
|
-
socialProviders: Object.entries(authConfig.socialProviders)
|
|
111
|
-
.filter(([_, config]) => config?.enabled !== false)
|
|
112
|
-
.reduce(
|
|
113
|
-
(acc, [provider, config]) => {
|
|
114
|
-
if (config) {
|
|
115
|
-
acc[provider] = {
|
|
116
|
-
clientId: config.clientId,
|
|
117
|
-
clientSecret: config.clientSecret,
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return acc
|
|
121
|
-
},
|
|
122
|
-
{} as Record<string, { clientId: string; clientSecret: string }>,
|
|
123
|
-
),
|
|
124
|
-
|
|
125
|
-
// Rate limiting configuration
|
|
126
|
-
rateLimit: authConfig.rateLimit
|
|
127
|
-
? {
|
|
128
|
-
enabled: authConfig.rateLimit.enabled,
|
|
129
|
-
window: authConfig.rateLimit.window,
|
|
130
|
-
max: authConfig.rateLimit.max,
|
|
131
|
-
}
|
|
132
|
-
: undefined,
|
|
133
|
-
|
|
134
|
-
// Pass through any additional Better Auth plugins, then append
|
|
135
|
-
// nextCookies LAST so it can write the Set-Cookie headers produced by
|
|
136
|
-
// any auth.api.* call made inside a Next.js server action into Next's
|
|
137
|
-
// cookie store. This is what makes the server-action auth forms (which
|
|
138
|
-
// call auth.api.signInEmail/signUpEmail/etc. server-side) actually
|
|
139
|
-
// persist a session. It must be the final plugin in the array.
|
|
140
|
-
plugins: [...(authConfig.betterAuthPlugins || []), nextCookies()],
|
|
141
|
-
}
|
|
142
|
-
|
|
420
|
+
const betterAuthConfig = plugins
|
|
421
|
+
? await buildBetterAuthOptions(configPromise, contextPromise, plugins)
|
|
422
|
+
: await buildBetterAuthOptions(configPromise, contextPromise)
|
|
143
423
|
authInstance = betterAuth(betterAuthConfig)
|
|
144
424
|
return authInstance
|
|
145
425
|
})()
|
|
@@ -149,7 +429,7 @@ export function createAuth(
|
|
|
149
429
|
}
|
|
150
430
|
|
|
151
431
|
// Return a proxy that lazily initializes the auth instance
|
|
152
|
-
return new Proxy({} as
|
|
432
|
+
return new Proxy({} as AuthInstance, {
|
|
153
433
|
get(_, prop) {
|
|
154
434
|
if (prop === 'then') {
|
|
155
435
|
// Support await on the proxy itself
|
|
@@ -195,37 +475,121 @@ export function createAuth(
|
|
|
195
475
|
}
|
|
196
476
|
|
|
197
477
|
/**
|
|
198
|
-
*
|
|
199
|
-
*
|
|
478
|
+
* Field names already warned about failing to resolve against a session, so a
|
|
479
|
+
* given field warns at most once per process rather than once per request.
|
|
480
|
+
*/
|
|
481
|
+
const unresolvedSessionFieldWarnings = new Set<string>()
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Warn (once per field, per process) that a `sessionFields` entry could not
|
|
485
|
+
* be resolved from the session shape `auth.api.getSession()` actually
|
|
486
|
+
* returned — naming the field and what keys were available to check, so the
|
|
487
|
+
* gap is visible here instead of surfacing later as an access-control
|
|
488
|
+
* function silently reading `undefined`.
|
|
489
|
+
*/
|
|
490
|
+
function warnUnresolvedSessionField(field: string, resolvedSession: Record<string, unknown>): void {
|
|
491
|
+
if (unresolvedSessionFieldWarnings.has(field)) return
|
|
492
|
+
unresolvedSessionFieldWarnings.add(field)
|
|
493
|
+
|
|
494
|
+
const user = isPlainObject(resolvedSession.user) ? resolvedSession.user : undefined
|
|
495
|
+
const sessionRow = isPlainObject(resolvedSession.session) ? resolvedSession.session : undefined
|
|
496
|
+
|
|
497
|
+
console.warn(
|
|
498
|
+
`[@opensaas/stack-auth] sessionFields: "${field}" was not found on the resolved session. ` +
|
|
499
|
+
`Checked its top-level keys (${Object.keys(resolvedSession).join(', ') || 'none'}), ` +
|
|
500
|
+
`its "user" object (${user ? Object.keys(user).join(', ') || 'none' : 'not present'}), ` +
|
|
501
|
+
`and its "session" object (${sessionRow ? Object.keys(sessionRow).join(', ') || 'none' : 'not present'}). ` +
|
|
502
|
+
`The field is omitted from the projected session. A \`customSession\` plugin that nests this ` +
|
|
503
|
+
`value elsewhere is the app's own to reconcile — see the \`sessionFields\` reference. ` +
|
|
504
|
+
`This warning will not repeat for "${field}".`,
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Resolve a single `sessionFields` entry off the resolved better-auth
|
|
510
|
+
* session (whatever `auth.api.getSession()` returned — the default `{
|
|
511
|
+
* session, user }` shape, or a `customSession` plugin's replaced shape).
|
|
512
|
+
*
|
|
513
|
+
* `userId` is special-cased to the authenticated user's `id` — the
|
|
514
|
+
* documented default apps depend on. Every other name resolves against a
|
|
515
|
+
* fixed precedence so a collision between sources is predictable rather
|
|
516
|
+
* than incidental: a top-level key on the resolved session object, then the
|
|
517
|
+
* `user` object, then the `session` sub-object.
|
|
518
|
+
*/
|
|
519
|
+
function resolveSessionField(
|
|
520
|
+
field: string,
|
|
521
|
+
resolvedSession: Record<string, unknown>,
|
|
522
|
+
): { found: true; value: unknown } | { found: false } {
|
|
523
|
+
const user = isPlainObject(resolvedSession.user) ? resolvedSession.user : undefined
|
|
524
|
+
|
|
525
|
+
if (field === 'userId') {
|
|
526
|
+
return user && 'id' in user ? { found: true, value: user.id } : { found: false }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (field in resolvedSession) {
|
|
530
|
+
return { found: true, value: resolvedSession[field] }
|
|
531
|
+
}
|
|
532
|
+
if (user && field in user) {
|
|
533
|
+
return { found: true, value: user[field] }
|
|
534
|
+
}
|
|
535
|
+
const sessionRow = isPlainObject(resolvedSession.session) ? resolvedSession.session : undefined
|
|
536
|
+
if (sessionRow && field in sessionRow) {
|
|
537
|
+
return { found: true, value: sessionRow[field] }
|
|
538
|
+
}
|
|
539
|
+
return { found: false }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Get session from better-auth and transform it to OpenSaas session format —
|
|
544
|
+
* a flattened projection of `sessionFields` off the *resolved* session
|
|
545
|
+
* object, not just its `user` sub-object. This is what makes a
|
|
546
|
+
* `customSession` plugin's fields (added at the top level, or a
|
|
547
|
+
* session-only field like the admin plugin's `impersonatedBy`) reachable.
|
|
548
|
+
* See the `sessionFields` reference for the resolution precedence.
|
|
549
|
+
*
|
|
550
|
+
* Returns `null` only when there is genuinely no session — a resolved
|
|
551
|
+
* session with no `user` key (a `customSession` plugin that dropped it) is
|
|
552
|
+
* still a session and still gets projected, never misreported as anonymous.
|
|
553
|
+
* A listed field that can't be resolved from the session shape is omitted
|
|
554
|
+
* and warns once per field per process (see `warnUnresolvedSessionField`)
|
|
555
|
+
* instead of silently vanishing into an access-control function reading
|
|
556
|
+
* `undefined`.
|
|
557
|
+
*
|
|
558
|
+
* Errors from the underlying `auth.api.getSession()` call propagate rather
|
|
559
|
+
* than becoming `null` — collapsing a lookup failure (e.g. a session-store
|
|
560
|
+
* outage) into "anonymous" is indistinguishable from a mass sign-out under
|
|
561
|
+
* fail-closed access control, so the caller must see it.
|
|
562
|
+
*
|
|
563
|
+
* Not called by any generated code before this helper existed — apps used to
|
|
564
|
+
* hand-roll this same transform against `auth.api.getSession({ headers:
|
|
565
|
+
* await headers() })`. Exported as the single reusable implementation; pass
|
|
566
|
+
* the caller's request headers (e.g. Next.js `await headers()` in a Server
|
|
567
|
+
* Component/action) so a session cookie can actually be resolved.
|
|
200
568
|
*/
|
|
201
569
|
export async function getSessionFromAuth(
|
|
202
570
|
auth: ReturnType<typeof betterAuth>,
|
|
203
571
|
sessionFields: string[],
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
headers: new Headers(),
|
|
208
|
-
})
|
|
209
|
-
|
|
210
|
-
if (!session?.user) {
|
|
211
|
-
return null
|
|
212
|
-
}
|
|
572
|
+
headers: Headers,
|
|
573
|
+
): Promise<Session | null> {
|
|
574
|
+
const resolvedSession = await auth.api.getSession({ headers })
|
|
213
575
|
|
|
214
|
-
|
|
215
|
-
|
|
576
|
+
if (!resolvedSession) {
|
|
577
|
+
return null
|
|
578
|
+
}
|
|
216
579
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
result.userId = session.user.id
|
|
220
|
-
} else if (field in session.user) {
|
|
221
|
-
result[field] = session.user[field as keyof typeof session.user]
|
|
222
|
-
}
|
|
223
|
-
}
|
|
580
|
+
const resolvedSessionRecord = resolvedSession as Record<string, unknown>
|
|
581
|
+
const result: Record<string, unknown> = {}
|
|
224
582
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
583
|
+
for (const field of sessionFields) {
|
|
584
|
+
const resolved = resolveSessionField(field, resolvedSessionRecord)
|
|
585
|
+
if (resolved.found) {
|
|
586
|
+
result[field] = resolved.value
|
|
587
|
+
} else {
|
|
588
|
+
warnUnresolvedSessionField(field, resolvedSessionRecord)
|
|
589
|
+
}
|
|
228
590
|
}
|
|
591
|
+
|
|
592
|
+
return result
|
|
229
593
|
}
|
|
230
594
|
|
|
231
595
|
export type { BetterAuthOptions }
|
|
@@ -12,13 +12,13 @@ import type { ListConfig, FieldConfig } from '@opensaas/stack-core'
|
|
|
12
12
|
* Inferred from better-auth internal types
|
|
13
13
|
*/
|
|
14
14
|
type BetterAuthFieldAttribute = {
|
|
15
|
-
type: string // 'string' | 'number' | 'boolean' | 'date' | etc
|
|
15
|
+
type: string | string[] // 'string' | 'number' | 'boolean' | 'date' | etc., or an enum array
|
|
16
16
|
required?: boolean
|
|
17
17
|
unique?: boolean
|
|
18
18
|
references?: {
|
|
19
19
|
model: string
|
|
20
20
|
field: string
|
|
21
|
-
onDelete?: 'cascade' | 'set null' | '
|
|
21
|
+
onDelete?: 'no action' | 'restrict' | 'cascade' | 'set null' | 'set default'
|
|
22
22
|
}
|
|
23
23
|
defaultValue?: unknown
|
|
24
24
|
returned?: boolean
|
|
@@ -29,7 +29,7 @@ type BetterAuthFieldAttribute = {
|
|
|
29
29
|
* Better Auth table schema structure
|
|
30
30
|
*/
|
|
31
31
|
type BetterAuthTableSchema = {
|
|
32
|
-
modelName
|
|
32
|
+
modelName?: string
|
|
33
33
|
fields: Record<string, BetterAuthFieldAttribute>
|
|
34
34
|
}
|
|
35
35
|
|