-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathauth-manager.ts
More file actions
403 lines (360 loc) · 14.3 KB
/
auth-manager.ts
File metadata and controls
403 lines (360 loc) · 14.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { betterAuth } from 'better-auth';
import type { Auth, BetterAuthOptions } from 'better-auth';
import { organization } from 'better-auth/plugins/organization';
import { twoFactor } from 'better-auth/plugins/two-factor';
import { magicLink } from 'better-auth/plugins/magic-link';
import type {
AuthConfig,
EmailAndPasswordConfig,
AuthPluginConfig,
} from '@objectstack/spec/system';
import type { IDataEngine } from '@objectstack/core';
import { createObjectQLAdapterFactory } from './objectql-adapter.js';
import {
AUTH_USER_CONFIG,
AUTH_SESSION_CONFIG,
AUTH_ACCOUNT_CONFIG,
AUTH_VERIFICATION_CONFIG,
buildOrganizationPluginSchema,
buildTwoFactorPluginSchema,
} from './auth-schema-config.js';
/**
* Extended options for AuthManager
*/
export interface AuthManagerOptions extends Partial<AuthConfig> {
/**
* Better-Auth instance (for advanced use cases)
* If not provided, one will be created from config
*/
authInstance?: Auth<any>;
/**
* ObjectQL Data Engine instance
* Required for database operations using ObjectQL instead of third-party ORMs
*/
dataEngine?: IDataEngine;
/**
* Base path for auth routes
* Forwarded to better-auth's basePath option so it can match incoming
* request URLs without manual path rewriting.
* @default '/api/v1/auth'
*/
basePath?: string;
}
/**
* Authentication Manager
*
* Wraps better-auth and provides authentication services for ObjectStack.
* Supports multiple authentication methods:
* - Email/password
* - OAuth providers (Google, GitHub, etc.)
* - Magic links
* - Two-factor authentication
* - Passkeys
* - Organization/teams
*/
export class AuthManager {
private auth: Auth<any> | null = null;
private config: AuthManagerOptions;
constructor(config: AuthManagerOptions) {
this.config = config;
// Use provided auth instance
if (config.authInstance) {
this.auth = config.authInstance;
}
// Don't create auth instance automatically to avoid database initialization errors
// It will be created lazily when needed
}
/**
* Get or create the better-auth instance (lazy initialization)
*/
private getOrCreateAuth(): Auth<any> {
if (!this.auth) {
this.auth = this.createAuthInstance();
}
return this.auth;
}
/**
* Create a better-auth instance from configuration
*/
private createAuthInstance(): Auth<any> {
const betterAuthConfig: BetterAuthOptions = {
// Base configuration
secret: this.config.secret || this.generateSecret(),
baseURL: this.config.baseUrl || 'http://localhost:3000',
basePath: this.config.basePath || '/api/v1/auth',
// Database adapter configuration
database: this.createDatabaseConfig(),
// Model/field mapping: camelCase (better-auth) → snake_case (ObjectStack)
// These declarations tell better-auth the actual table/column names used
// by ObjectStack's protocol layer, enabling automatic transformation via
// createAdapterFactory.
user: {
...AUTH_USER_CONFIG,
},
account: {
...AUTH_ACCOUNT_CONFIG,
},
verification: {
...AUTH_VERIFICATION_CONFIG,
},
// Social / OAuth providers
...(this.config.socialProviders ? { socialProviders: this.config.socialProviders as any } : {}),
// Email and password configuration
emailAndPassword: {
enabled: this.config.emailAndPassword?.enabled ?? true,
...(this.config.emailAndPassword?.disableSignUp != null
? { disableSignUp: this.config.emailAndPassword.disableSignUp } : {}),
...(this.config.emailAndPassword?.requireEmailVerification != null
? { requireEmailVerification: this.config.emailAndPassword.requireEmailVerification } : {}),
...(this.config.emailAndPassword?.minPasswordLength != null
? { minPasswordLength: this.config.emailAndPassword.minPasswordLength } : {}),
...(this.config.emailAndPassword?.maxPasswordLength != null
? { maxPasswordLength: this.config.emailAndPassword.maxPasswordLength } : {}),
...(this.config.emailAndPassword?.resetPasswordTokenExpiresIn != null
? { resetPasswordTokenExpiresIn: this.config.emailAndPassword.resetPasswordTokenExpiresIn } : {}),
...(this.config.emailAndPassword?.autoSignIn != null
? { autoSignIn: this.config.emailAndPassword.autoSignIn } : {}),
...(this.config.emailAndPassword?.revokeSessionsOnPasswordReset != null
? { revokeSessionsOnPasswordReset: this.config.emailAndPassword.revokeSessionsOnPasswordReset } : {}),
},
// Email verification
...(this.config.emailVerification ? {
emailVerification: {
...(this.config.emailVerification.sendOnSignUp != null
? { sendOnSignUp: this.config.emailVerification.sendOnSignUp } : {}),
...(this.config.emailVerification.sendOnSignIn != null
? { sendOnSignIn: this.config.emailVerification.sendOnSignIn } : {}),
...(this.config.emailVerification.autoSignInAfterVerification != null
? { autoSignInAfterVerification: this.config.emailVerification.autoSignInAfterVerification } : {}),
...(this.config.emailVerification.expiresIn != null
? { expiresIn: this.config.emailVerification.expiresIn } : {}),
},
} : {}),
// Session configuration
session: {
...AUTH_SESSION_CONFIG,
expiresIn: this.config.session?.expiresIn || 60 * 60 * 24 * 7, // 7 days default
updateAge: this.config.session?.updateAge || 60 * 60 * 24, // 1 day default
},
// better-auth plugins — registered based on AuthPluginConfig flags
plugins: this.buildPluginList(),
// Trusted origins for CSRF protection (supports wildcards like "https://*.example.com")
...(this.config.trustedOrigins?.length ? { trustedOrigins: this.config.trustedOrigins } : {}),
// Advanced options (cross-subdomain cookies, secure cookies, CSRF, etc.)
...(this.config.advanced ? {
advanced: {
...(this.config.advanced.crossSubDomainCookies
? { crossSubDomainCookies: this.config.advanced.crossSubDomainCookies } : {}),
...(this.config.advanced.useSecureCookies != null
? { useSecureCookies: this.config.advanced.useSecureCookies } : {}),
...(this.config.advanced.disableCSRFCheck != null
? { disableCSRFCheck: this.config.advanced.disableCSRFCheck } : {}),
...(this.config.advanced.cookiePrefix != null
? { cookiePrefix: this.config.advanced.cookiePrefix } : {}),
},
} : {}),
};
return betterAuth(betterAuthConfig);
}
/**
* Build the list of better-auth plugins based on AuthPluginConfig flags.
*
* Each plugin that introduces its own database tables is configured with
* a `schema` option containing the appropriate snake_case field mappings,
* so that `createAdapterFactory` transforms them automatically.
*/
private buildPluginList(): any[] {
const pluginConfig = this.config.plugins;
const plugins: any[] = [];
if (pluginConfig?.organization) {
plugins.push(organization({
schema: buildOrganizationPluginSchema(),
}));
}
if (pluginConfig?.twoFactor) {
plugins.push(twoFactor({
schema: buildTwoFactorPluginSchema(),
}));
}
if (pluginConfig?.magicLink) {
// magic-link reuses the `verification` table — no extra schema mapping needed.
// The sendMagicLink callback must be provided by the application at a higher level.
// Here we provide a no-op default that logs a warning; real applications should
// override this via AuthManagerOptions or a config extension point.
plugins.push(magicLink({
sendMagicLink: async ({ email, url }) => {
console.warn(
`[AuthManager] Magic-link requested for ${email} but no sendMagicLink handler configured. URL: ${url}`,
);
},
}));
}
return plugins;
}
/**
* Create database configuration using ObjectQL adapter
*
* better-auth resolves the `database` option as follows:
* - `undefined` → in-memory adapter
* - `typeof fn === "function"` → treated as `DBAdapterInstance`, called with `(options)`
* - otherwise → forwarded to Kysely adapter factory (pool/dialect)
*
* A raw `CustomAdapter` object would fall into the third branch and fail
* silently. We therefore wrap the ObjectQL adapter in a factory function
* so it is correctly recognised as a `DBAdapterInstance`.
*/
private createDatabaseConfig(): any {
// Use ObjectQL adapter factory if dataEngine is provided
if (this.config.dataEngine) {
// createObjectQLAdapterFactory returns an AdapterFactory
// (options => DBAdapter) which better-auth invokes via getBaseAdapter().
// The factory is created by better-auth's createAdapterFactory and
// automatically applies modelName/fields transformations declared in
// the betterAuth config above.
return createObjectQLAdapterFactory(this.config.dataEngine);
}
// Fallback warning if no dataEngine is provided
console.warn(
'⚠️ WARNING: No dataEngine provided to AuthManager! ' +
'Using in-memory storage. This is NOT suitable for production. ' +
'Please provide a dataEngine instance (e.g., ObjectQL) in AuthManagerOptions.'
);
// Return a minimal in-memory configuration as fallback
// This allows the system to work in development/testing without a real database
return undefined; // better-auth will use its default in-memory adapter
}
/**
* Generate a secure secret if not provided
*/
private generateSecret(): string {
const envSecret = process.env.AUTH_SECRET;
if (!envSecret) {
// In production, a secret MUST be provided
// For development/testing, we'll use a fallback but warn about it
const fallbackSecret = 'dev-secret-' + Date.now();
console.warn(
'⚠️ WARNING: No AUTH_SECRET environment variable set! ' +
'Using a temporary development secret. ' +
'This is NOT secure for production use. ' +
'Please set AUTH_SECRET in your environment variables.'
);
return fallbackSecret;
}
return envSecret;
}
/**
* Update the base URL at runtime.
*
* This **must** be called before the first request triggers lazy
* initialisation of the better-auth instance — typically from a
* `kernel:ready` hook where the actual server port is known.
*
* If the auth instance has already been created this is a no-op and
* a warning is emitted.
*/
setRuntimeBaseUrl(url: string): void {
if (this.auth) {
console.warn(
'[AuthManager] setRuntimeBaseUrl() called after the auth instance was already created — ignoring. ' +
'Ensure this method is called before the first request.',
);
return;
}
this.config = { ...this.config, baseUrl: url };
}
/**
* Get the underlying better-auth instance
* Useful for advanced use cases
*/
getAuthInstance(): Auth<any> {
return this.getOrCreateAuth();
}
/**
* Handle an authentication request
* Forwards the request directly to better-auth's universal handler
*
* better-auth catches internal errors (database / adapter / ORM) and
* returns a 500 Response instead of throwing. We therefore inspect the
* response status and log server errors so they are not silently swallowed.
*
* @param request - Web standard Request object
* @returns Web standard Response object
*/
async handleRequest(request: Request): Promise<Response> {
const auth = this.getOrCreateAuth();
const response = await auth.handler(request);
if (response.status >= 500) {
try {
const body = await response.clone().text();
console.error('[AuthManager] better-auth returned error:', response.status, body);
} catch {
console.error('[AuthManager] better-auth returned error:', response.status, '(unable to read body)');
}
}
return response;
}
/**
* Get the better-auth API for programmatic access
* Use this for server-side operations (e.g., creating users, checking sessions)
*/
get api() {
return this.getOrCreateAuth().api;
}
/**
* Get public authentication configuration
* Returns safe, non-sensitive configuration that can be exposed to the frontend
*
* This allows the frontend to discover:
* - Which social/OAuth providers are available
* - Whether email/password login is enabled
* - Which advanced features are enabled (2FA, magic links, etc.)
*/
getPublicConfig() {
// Extract social providers info (without sensitive data)
const socialProviders = [];
if (this.config.socialProviders) {
for (const [id, providerConfig] of Object.entries(this.config.socialProviders)) {
if (providerConfig.enabled !== false) {
// Map provider ID to friendly name
const nameMap: Record<string, string> = {
google: 'Google',
github: 'GitHub',
microsoft: 'Microsoft',
apple: 'Apple',
facebook: 'Facebook',
twitter: 'Twitter',
discord: 'Discord',
gitlab: 'GitLab',
linkedin: 'LinkedIn',
};
socialProviders.push({
id,
name: nameMap[id] || id.charAt(0).toUpperCase() + id.slice(1),
enabled: true,
});
}
}
}
// Extract email/password config (safe fields only)
const emailPasswordConfig: Partial<EmailAndPasswordConfig> = this.config.emailAndPassword ?? {};
const emailPassword = {
enabled: emailPasswordConfig.enabled !== false, // Default to true
disableSignUp: emailPasswordConfig.disableSignUp ?? false,
requireEmailVerification: emailPasswordConfig.requireEmailVerification ?? false,
};
// Extract enabled features
const pluginConfig: Partial<AuthPluginConfig> = this.config.plugins ?? {};
const features = {
twoFactor: pluginConfig.twoFactor ?? false,
passkeys: pluginConfig.passkeys ?? false,
magicLink: pluginConfig.magicLink ?? false,
organization: pluginConfig.organization ?? false,
};
return {
emailPassword,
socialProviders,
features,
};
}
}