@objectstack/plugin-auth 2.0.2 → 2.0.5

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
4
4
  import { AuthConfig } from '@objectstack/spec/system';
5
+ import { AuthManager } from './auth-manager.js';
5
6
 
6
7
  /**
7
8
  * Auth Plugin Options
@@ -37,9 +38,9 @@ export interface AuthPluginOptions extends Partial<AuthConfig> {
37
38
  * - `auth` service (auth manager instance)
38
39
  * - HTTP routes for authentication endpoints
39
40
  *
40
- * @planned This is a stub implementation. Full better-auth integration
41
- * will be added in a future version. For now, it provides the plugin
42
- * structure and basic route registration.
41
+ * Integrates with better-auth library to provide comprehensive
42
+ * authentication capabilities including email/password, OAuth, 2FA,
43
+ * magic links, passkeys, and organization support.
43
44
  */
44
45
  export class AuthPlugin implements Plugin {
45
46
  name = 'com.objectstack.auth';
@@ -66,8 +67,17 @@ export class AuthPlugin implements Plugin {
66
67
  throw new Error('AuthPlugin: secret is required');
67
68
  }
68
69
 
69
- // Initialize auth manager
70
- this.authManager = new AuthManager(this.options);
70
+ // Get data engine service for database operations
71
+ const dataEngine = ctx.getService<any>('data');
72
+ if (!dataEngine) {
73
+ ctx.logger.warn('No data engine service found - auth will use in-memory storage');
74
+ }
75
+
76
+ // Initialize auth manager with data engine
77
+ this.authManager = new AuthManager({
78
+ ...this.options,
79
+ dataEngine,
80
+ });
71
81
 
72
82
  // Register auth service
73
83
  ctx.registerService('auth', this.authManager);
@@ -95,6 +105,24 @@ export class AuthPlugin implements Plugin {
95
105
  }
96
106
  }
97
107
 
108
+ // Register auth middleware on ObjectQL engine (if available)
109
+ try {
110
+ const ql = ctx.getService<any>('objectql');
111
+ if (ql && typeof ql.registerMiddleware === 'function') {
112
+ ql.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
113
+ // If context already has userId or isSystem, skip auth resolution
114
+ if (opCtx.context?.userId || opCtx.context?.isSystem) {
115
+ return next();
116
+ }
117
+ // Future: resolve session from AsyncLocalStorage or request context
118
+ await next();
119
+ });
120
+ ctx.logger.info('Auth middleware registered on ObjectQL engine');
121
+ }
122
+ } catch (_e) {
123
+ ctx.logger.debug('ObjectQL engine not available, skipping auth middleware registration');
124
+ }
125
+
98
126
  ctx.logger.info('Auth Plugin started successfully');
99
127
  }
100
128
 
@@ -105,118 +133,80 @@ export class AuthPlugin implements Plugin {
105
133
 
106
134
  /**
107
135
  * Register authentication routes with HTTP server
136
+ *
137
+ * Uses better-auth's universal handler for all authentication requests.
138
+ * This forwards all requests under basePath to better-auth, which handles:
139
+ * - Email/password authentication
140
+ * - OAuth providers (Google, GitHub, etc.)
141
+ * - Session management
142
+ * - Password reset
143
+ * - Email verification
144
+ * - 2FA, passkeys, magic links (if enabled)
108
145
  */
109
146
  private registerAuthRoutes(httpServer: IHttpServer, ctx: PluginContext): void {
110
147
  if (!this.authManager) return;
111
148
 
112
149
  const basePath = this.options.basePath || '/api/v1/auth';
113
150
 
114
- // Login endpoint
115
- httpServer.post(`${basePath}/login`, async (req, res) => {
116
- try {
117
- const body = req.body;
118
- const result = await this.authManager!.login(body);
119
- res.status(200).json(result);
120
- } catch (error) {
121
- const err = error instanceof Error ? error : new Error(String(error));
122
- ctx.logger.error('Login error:', err);
123
- res.status(401).json({
124
- success: false,
125
- error: err.message,
126
- });
127
- }
128
- });
151
+ // Get raw Hono app to use native wildcard routing
152
+ // Type assertion is safe here because we explicitly require Hono server as a dependency
153
+ if (!('getRawApp' in httpServer) || typeof (httpServer as any).getRawApp !== 'function') {
154
+ ctx.logger.error('HTTP server does not support getRawApp() - wildcard routing requires Hono server');
155
+ throw new Error(
156
+ 'AuthPlugin requires HonoServerPlugin for wildcard routing support. ' +
157
+ 'Please ensure HonoServerPlugin is loaded before AuthPlugin.'
158
+ );
159
+ }
129
160
 
130
- // Register endpoint
131
- httpServer.post(`${basePath}/register`, async (req, res) => {
132
- try {
133
- const body = req.body;
134
- const result = await this.authManager!.register(body);
135
- res.status(201).json(result);
136
- } catch (error) {
137
- const err = error instanceof Error ? error : new Error(String(error));
138
- ctx.logger.error('Registration error:', err);
139
- res.status(400).json({
140
- success: false,
141
- error: err.message,
142
- });
143
- }
144
- });
161
+ const rawApp = (httpServer as any).getRawApp();
145
162
 
146
- // Logout endpoint
147
- httpServer.post(`${basePath}/logout`, async (req, res) => {
163
+ // Register wildcard route to forward all auth requests to better-auth
164
+ // Better-auth expects requests at its baseURL, so we need to preserve the full path
165
+ rawApp.all(`${basePath}/*`, async (c: any) => {
148
166
  try {
149
- const authHeader = req.headers['authorization'];
150
- const token = typeof authHeader === 'string' ? authHeader.replace('Bearer ', '') : undefined;
151
- await this.authManager!.logout(token);
152
- res.status(200).json({ success: true });
153
- } catch (error) {
154
- const err = error instanceof Error ? error : new Error(String(error));
155
- ctx.logger.error('Logout error:', err);
156
- res.status(400).json({
157
- success: false,
158
- error: err.message,
167
+ // Get the Web standard Request from Hono context
168
+ const request = c.req.raw as Request;
169
+
170
+ // Create a new Request with the path rewritten to match better-auth's expectations
171
+ // Better-auth expects paths like /sign-in/email, /sign-up/email, etc.
172
+ // We need to strip our basePath prefix
173
+ const url = new URL(request.url);
174
+ const authPath = url.pathname.replace(basePath, '');
175
+ const rewrittenUrl = new URL(authPath || '/', url.origin);
176
+ rewrittenUrl.search = url.search; // Preserve query params
177
+
178
+ const rewrittenRequest = new Request(rewrittenUrl, {
179
+ method: request.method,
180
+ headers: request.headers,
181
+ body: request.body,
182
+ duplex: 'half' as any, // Required for Request with body
159
183
  });
160
- }
161
- });
162
184
 
163
- // Session endpoint
164
- httpServer.get(`${basePath}/session`, async (req, res) => {
165
- try {
166
- const authHeader = req.headers['authorization'];
167
- const token = typeof authHeader === 'string' ? authHeader.replace('Bearer ', '') : undefined;
168
- const session = await this.authManager!.getSession(token);
169
- res.status(200).json({ success: true, data: session });
185
+ // Forward to better-auth handler
186
+ const response = await this.authManager!.handleRequest(rewrittenRequest);
187
+
188
+ return response;
170
189
  } catch (error) {
171
190
  const err = error instanceof Error ? error : new Error(String(error));
172
- res.status(401).json({
173
- success: false,
174
- error: err.message,
175
- });
191
+ ctx.logger.error('Auth request error:', err);
192
+
193
+ // Return error response
194
+ return new Response(
195
+ JSON.stringify({
196
+ success: false,
197
+ error: err.message,
198
+ }),
199
+ {
200
+ status: 500,
201
+ headers: { 'Content-Type': 'application/json' },
202
+ }
203
+ );
176
204
  }
177
205
  });
178
206
 
179
- ctx.logger.debug('Auth routes registered:', {
180
- basePath,
181
- routes: [
182
- `POST ${basePath}/login`,
183
- `POST ${basePath}/register`,
184
- `POST ${basePath}/logout`,
185
- `GET ${basePath}/session`,
186
- ],
187
- });
207
+ ctx.logger.info(`Auth routes registered: All requests under ${basePath}/* forwarded to better-auth`);
188
208
  }
189
209
  }
190
210
 
191
- /**
192
- * Auth Manager
193
- *
194
- * @planned This is a stub implementation. Real authentication logic
195
- * will be implemented using better-auth or similar library in future versions.
196
- */
197
- class AuthManager {
198
- constructor(_config: AuthPluginOptions) {
199
- // Store config for future use
200
- }
201
-
202
- async login(_credentials: any): Promise<any> {
203
- // @planned Implement actual login logic with better-auth
204
- throw new Error('Login not yet implemented');
205
- }
206
-
207
- async register(_userData: any): Promise<any> {
208
- // @planned Implement actual registration logic with better-auth
209
- throw new Error('Registration not yet implemented');
210
- }
211
211
 
212
- async logout(_token?: string): Promise<void> {
213
- // @planned Implement actual logout logic
214
- throw new Error('Logout not yet implemented');
215
- }
216
-
217
- async getSession(_token?: string): Promise<any> {
218
- // @planned Implement actual session retrieval
219
- throw new Error('Session retrieval not yet implemented');
220
- }
221
- }
222
212
 
package/src/index.ts CHANGED
@@ -5,7 +5,11 @@
5
5
  *
6
6
  * Authentication & Identity Plugin for ObjectStack
7
7
  * Powered by better-auth for robust, secure authentication
8
+ * Uses ObjectQL for data persistence (no third-party ORM required)
8
9
  */
9
10
 
10
- export * from './auth-plugin';
11
+ export * from './auth-plugin.js';
12
+ export * from './auth-manager.js';
13
+ export * from './objectql-adapter.js';
14
+ export * from './objects/index.js';
11
15
  export type { AuthConfig, AuthProviderConfig, AuthPluginConfig } from '@objectstack/spec/system';
@@ -0,0 +1,181 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { IDataEngine } from '@objectstack/core';
4
+ import type { CleanedWhere } from 'better-auth/adapters';
5
+
6
+ /**
7
+ * ObjectQL Adapter for better-auth
8
+ *
9
+ * Bridges better-auth's database adapter interface with ObjectQL's IDataEngine.
10
+ * This allows better-auth to use ObjectQL for data persistence instead of
11
+ * third-party ORMs like drizzle-orm.
12
+ *
13
+ * Uses better-auth's native naming conventions (camelCase) for seamless migration.
14
+ *
15
+ * @param dataEngine - ObjectQL data engine instance
16
+ * @returns better-auth CustomAdapter
17
+ */
18
+ export function createObjectQLAdapter(dataEngine: IDataEngine) {
19
+ /**
20
+ * Convert better-auth where clause to ObjectQL query format
21
+ */
22
+ function convertWhere(where: CleanedWhere[]): Record<string, any> {
23
+ const filter: Record<string, any> = {};
24
+
25
+ for (const condition of where) {
26
+ // Use field names as-is (no conversion needed)
27
+ const fieldName = condition.field;
28
+
29
+ if (condition.operator === 'eq') {
30
+ filter[fieldName] = condition.value;
31
+ } else if (condition.operator === 'ne') {
32
+ filter[fieldName] = { $ne: condition.value };
33
+ } else if (condition.operator === 'in') {
34
+ filter[fieldName] = { $in: condition.value };
35
+ } else if (condition.operator === 'gt') {
36
+ filter[fieldName] = { $gt: condition.value };
37
+ } else if (condition.operator === 'gte') {
38
+ filter[fieldName] = { $gte: condition.value };
39
+ } else if (condition.operator === 'lt') {
40
+ filter[fieldName] = { $lt: condition.value };
41
+ } else if (condition.operator === 'lte') {
42
+ filter[fieldName] = { $lte: condition.value };
43
+ } else if (condition.operator === 'contains') {
44
+ filter[fieldName] = { $regex: condition.value };
45
+ }
46
+ }
47
+
48
+ return filter;
49
+ }
50
+
51
+ return {
52
+ create: async <T extends Record<string, any>>({ model, data, select: _select }: { model: string; data: T; select?: string[] }): Promise<T> => {
53
+ // Use model name as-is (no conversion needed)
54
+ const objectName = model;
55
+
56
+ // Note: select parameter is currently not supported by ObjectQL's insert operation
57
+ // The full record is always returned after insertion
58
+ const result = await dataEngine.insert(objectName, data);
59
+ return result as T;
60
+ },
61
+
62
+ findOne: async <T>({ model, where, select, join: _join }: { model: string; where: CleanedWhere[]; select?: string[]; join?: any }): Promise<T | null> => {
63
+ const objectName = model;
64
+ const filter = convertWhere(where);
65
+
66
+ // Note: join parameter is not currently supported by ObjectQL's findOne operation
67
+ // Joins/populate functionality is planned for future ObjectQL releases
68
+ // For now, related data must be fetched separately
69
+
70
+ const result = await dataEngine.findOne(objectName, {
71
+ filter,
72
+ select,
73
+ });
74
+
75
+ return result ? result as T : null;
76
+ },
77
+
78
+ findMany: async <T>({ model, where, limit, offset, sortBy, join: _join }: { model: string; where?: CleanedWhere[]; limit: number; offset?: number; sortBy?: { field: string; direction: 'asc' | 'desc' }; join?: any }): Promise<T[]> => {
79
+ const objectName = model;
80
+ const filter = where ? convertWhere(where) : {};
81
+
82
+ // Note: join parameter is not currently supported by ObjectQL's find operation
83
+ // Joins/populate functionality is planned for future ObjectQL releases
84
+
85
+ const sort = sortBy ? [{
86
+ field: sortBy.field,
87
+ order: sortBy.direction as 'asc' | 'desc',
88
+ }] : undefined;
89
+
90
+ const results = await dataEngine.find(objectName, {
91
+ filter,
92
+ limit: limit || 100,
93
+ skip: offset,
94
+ sort,
95
+ });
96
+
97
+ return results as T[];
98
+ },
99
+
100
+ count: async ({ model, where }: { model: string; where?: CleanedWhere[] }): Promise<number> => {
101
+ const objectName = model;
102
+ const filter = where ? convertWhere(where) : {};
103
+
104
+ return await dataEngine.count(objectName, { filter });
105
+ },
106
+
107
+ update: async <T>({ model, where, update }: { model: string; where: CleanedWhere[]; update: Record<string, any> }): Promise<T | null> => {
108
+ const objectName = model;
109
+ const filter = convertWhere(where);
110
+
111
+ // Find the record first to get its ID
112
+ const record = await dataEngine.findOne(objectName, { filter });
113
+ if (!record) {
114
+ return null;
115
+ }
116
+
117
+ const result = await dataEngine.update(objectName, {
118
+ ...update,
119
+ id: record.id,
120
+ });
121
+
122
+ return result ? result as T : null;
123
+ },
124
+
125
+ updateMany: async ({ model, where, update }: { model: string; where: CleanedWhere[]; update: Record<string, any> }): Promise<number> => {
126
+ const objectName = model;
127
+ const filter = convertWhere(where);
128
+
129
+ // Note: Sequential updates are used here because ObjectQL's IDataEngine interface
130
+ // requires an ID for updates. A future optimization could use a bulk update
131
+ // operation if ObjectQL adds support for filter-based updates without IDs.
132
+
133
+ // Find all matching records
134
+ const records = await dataEngine.find(objectName, { filter });
135
+
136
+ // Update each record
137
+ for (const record of records) {
138
+ await dataEngine.update(objectName, {
139
+ ...update,
140
+ id: record.id,
141
+ });
142
+ }
143
+
144
+ return records.length;
145
+ },
146
+
147
+ delete: async ({ model, where }: { model: string; where: CleanedWhere[] }): Promise<void> => {
148
+ const objectName = model;
149
+ const filter = convertWhere(where);
150
+
151
+ // Note: We need to find the record first to get its ID because ObjectQL's
152
+ // delete operation requires an ID. Direct filter-based delete would be more
153
+ // efficient if supported by ObjectQL in the future.
154
+ const record = await dataEngine.findOne(objectName, { filter });
155
+ if (!record) {
156
+ return;
157
+ }
158
+
159
+ await dataEngine.delete(objectName, { filter: { id: record.id } });
160
+ },
161
+
162
+ deleteMany: async ({ model, where }: { model: string; where: CleanedWhere[] }): Promise<number> => {
163
+ const objectName = model;
164
+ const filter = convertWhere(where);
165
+
166
+ // Note: Sequential deletes are used here because ObjectQL's delete operation
167
+ // requires an ID in the filter. A future optimization could use a single
168
+ // delete call with the original filter if ObjectQL supports it.
169
+
170
+ // Find all matching records
171
+ const records = await dataEngine.find(objectName, { filter });
172
+
173
+ // Delete each record
174
+ for (const record of records) {
175
+ await dataEngine.delete(objectName, { filter: { id: record.id } });
176
+ }
177
+
178
+ return records.length;
179
+ },
180
+ };
181
+ }
@@ -0,0 +1,121 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { ObjectSchema, Field } from '@objectstack/spec/data';
4
+
5
+ /**
6
+ * Auth Account Object
7
+ *
8
+ * Uses better-auth's native schema for seamless migration:
9
+ * - id: string
10
+ * - createdAt: Date
11
+ * - updatedAt: Date
12
+ * - providerId: string (e.g., 'google', 'github')
13
+ * - accountId: string (provider's user ID)
14
+ * - userId: string (link to user table)
15
+ * - accessToken: string | null
16
+ * - refreshToken: string | null
17
+ * - idToken: string | null
18
+ * - accessTokenExpiresAt: Date | null
19
+ * - refreshTokenExpiresAt: Date | null
20
+ * - scope: string | null
21
+ * - password: string | null (for email/password provider)
22
+ */
23
+ export const AuthAccount = ObjectSchema.create({
24
+ name: 'account',
25
+ label: 'Account',
26
+ pluralLabel: 'Accounts',
27
+ icon: 'link',
28
+ description: 'OAuth and authentication provider accounts',
29
+ titleFormat: '{providerId} - {accountId}',
30
+ compactLayout: ['providerId', 'userId', 'accountId'],
31
+
32
+ fields: {
33
+ id: Field.text({
34
+ label: 'Account ID',
35
+ required: true,
36
+ readonly: true,
37
+ }),
38
+
39
+ createdAt: Field.datetime({
40
+ label: 'Created At',
41
+ defaultValue: 'NOW()',
42
+ readonly: true,
43
+ }),
44
+
45
+ updatedAt: Field.datetime({
46
+ label: 'Updated At',
47
+ defaultValue: 'NOW()',
48
+ readonly: true,
49
+ }),
50
+
51
+ providerId: Field.text({
52
+ label: 'Provider ID',
53
+ required: true,
54
+ description: 'OAuth provider identifier (google, github, etc.)',
55
+ }),
56
+
57
+ accountId: Field.text({
58
+ label: 'Provider Account ID',
59
+ required: true,
60
+ description: "User's ID in the provider's system",
61
+ }),
62
+
63
+ userId: Field.text({
64
+ label: 'User ID',
65
+ required: true,
66
+ description: 'Link to user table',
67
+ }),
68
+
69
+ accessToken: Field.textarea({
70
+ label: 'Access Token',
71
+ required: false,
72
+ }),
73
+
74
+ refreshToken: Field.textarea({
75
+ label: 'Refresh Token',
76
+ required: false,
77
+ }),
78
+
79
+ idToken: Field.textarea({
80
+ label: 'ID Token',
81
+ required: false,
82
+ }),
83
+
84
+ accessTokenExpiresAt: Field.datetime({
85
+ label: 'Access Token Expires At',
86
+ required: false,
87
+ }),
88
+
89
+ refreshTokenExpiresAt: Field.datetime({
90
+ label: 'Refresh Token Expires At',
91
+ required: false,
92
+ }),
93
+
94
+ scope: Field.text({
95
+ label: 'OAuth Scope',
96
+ required: false,
97
+ }),
98
+
99
+ password: Field.text({
100
+ label: 'Password Hash',
101
+ required: false,
102
+ description: 'Hashed password for email/password provider',
103
+ }),
104
+ },
105
+
106
+ // Database indexes for performance
107
+ indexes: [
108
+ { fields: ['userId'], unique: false },
109
+ { fields: ['providerId', 'accountId'], unique: true },
110
+ ],
111
+
112
+ // Enable features
113
+ enable: {
114
+ trackHistory: false,
115
+ searchable: false,
116
+ apiEnabled: true,
117
+ apiMethods: ['get', 'list', 'create', 'update', 'delete'],
118
+ trash: true,
119
+ mru: false,
120
+ },
121
+ });
@@ -0,0 +1,89 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { ObjectSchema, Field } from '@objectstack/spec/data';
4
+
5
+ /**
6
+ * Auth Session Object
7
+ *
8
+ * Uses better-auth's native schema for seamless migration:
9
+ * - id: string
10
+ * - createdAt: Date
11
+ * - updatedAt: Date
12
+ * - userId: string
13
+ * - expiresAt: Date
14
+ * - token: string
15
+ * - ipAddress: string | null
16
+ * - userAgent: string | null
17
+ */
18
+ export const AuthSession = ObjectSchema.create({
19
+ name: 'session',
20
+ label: 'Session',
21
+ pluralLabel: 'Sessions',
22
+ icon: 'key',
23
+ description: 'Active user sessions',
24
+ titleFormat: 'Session {token}',
25
+ compactLayout: ['userId', 'expiresAt', 'ipAddress'],
26
+
27
+ fields: {
28
+ id: Field.text({
29
+ label: 'Session ID',
30
+ required: true,
31
+ readonly: true,
32
+ }),
33
+
34
+ createdAt: Field.datetime({
35
+ label: 'Created At',
36
+ defaultValue: 'NOW()',
37
+ readonly: true,
38
+ }),
39
+
40
+ updatedAt: Field.datetime({
41
+ label: 'Updated At',
42
+ defaultValue: 'NOW()',
43
+ readonly: true,
44
+ }),
45
+
46
+ userId: Field.text({
47
+ label: 'User ID',
48
+ required: true,
49
+ }),
50
+
51
+ expiresAt: Field.datetime({
52
+ label: 'Expires At',
53
+ required: true,
54
+ }),
55
+
56
+ token: Field.text({
57
+ label: 'Session Token',
58
+ required: true,
59
+ }),
60
+
61
+ ipAddress: Field.text({
62
+ label: 'IP Address',
63
+ required: false,
64
+ maxLength: 45, // Support IPv6
65
+ }),
66
+
67
+ userAgent: Field.textarea({
68
+ label: 'User Agent',
69
+ required: false,
70
+ }),
71
+ },
72
+
73
+ // Database indexes for performance
74
+ indexes: [
75
+ { fields: ['token'], unique: true },
76
+ { fields: ['userId'], unique: false },
77
+ { fields: ['expiresAt'], unique: false },
78
+ ],
79
+
80
+ // Enable features
81
+ enable: {
82
+ trackHistory: false, // Sessions don't need history tracking
83
+ searchable: false,
84
+ apiEnabled: true,
85
+ apiMethods: ['get', 'list', 'create', 'delete'], // No update for sessions
86
+ trash: false, // Sessions should be hard deleted
87
+ mru: false,
88
+ },
89
+ });