@objectstack/plugin-auth 2.0.2 → 2.0.3

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);
@@ -105,118 +115,80 @@ export class AuthPlugin implements Plugin {
105
115
 
106
116
  /**
107
117
  * Register authentication routes with HTTP server
118
+ *
119
+ * Uses better-auth's universal handler for all authentication requests.
120
+ * This forwards all requests under basePath to better-auth, which handles:
121
+ * - Email/password authentication
122
+ * - OAuth providers (Google, GitHub, etc.)
123
+ * - Session management
124
+ * - Password reset
125
+ * - Email verification
126
+ * - 2FA, passkeys, magic links (if enabled)
108
127
  */
109
128
  private registerAuthRoutes(httpServer: IHttpServer, ctx: PluginContext): void {
110
129
  if (!this.authManager) return;
111
130
 
112
131
  const basePath = this.options.basePath || '/api/v1/auth';
113
132
 
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
- });
133
+ // Get raw Hono app to use native wildcard routing
134
+ // Type assertion is safe here because we explicitly require Hono server as a dependency
135
+ if (!('getRawApp' in httpServer) || typeof (httpServer as any).getRawApp !== 'function') {
136
+ ctx.logger.error('HTTP server does not support getRawApp() - wildcard routing requires Hono server');
137
+ throw new Error(
138
+ 'AuthPlugin requires HonoServerPlugin for wildcard routing support. ' +
139
+ 'Please ensure HonoServerPlugin is loaded before AuthPlugin.'
140
+ );
141
+ }
129
142
 
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
- });
143
+ const rawApp = (httpServer as any).getRawApp();
145
144
 
146
- // Logout endpoint
147
- httpServer.post(`${basePath}/logout`, async (req, res) => {
145
+ // Register wildcard route to forward all auth requests to better-auth
146
+ // Better-auth expects requests at its baseURL, so we need to preserve the full path
147
+ rawApp.all(`${basePath}/*`, async (c: any) => {
148
148
  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,
149
+ // Get the Web standard Request from Hono context
150
+ const request = c.req.raw as Request;
151
+
152
+ // Create a new Request with the path rewritten to match better-auth's expectations
153
+ // Better-auth expects paths like /sign-in/email, /sign-up/email, etc.
154
+ // We need to strip our basePath prefix
155
+ const url = new URL(request.url);
156
+ const authPath = url.pathname.replace(basePath, '');
157
+ const rewrittenUrl = new URL(authPath || '/', url.origin);
158
+ rewrittenUrl.search = url.search; // Preserve query params
159
+
160
+ const rewrittenRequest = new Request(rewrittenUrl, {
161
+ method: request.method,
162
+ headers: request.headers,
163
+ body: request.body,
164
+ duplex: 'half' as any, // Required for Request with body
159
165
  });
160
- }
161
- });
162
166
 
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 });
167
+ // Forward to better-auth handler
168
+ const response = await this.authManager!.handleRequest(rewrittenRequest);
169
+
170
+ return response;
170
171
  } catch (error) {
171
172
  const err = error instanceof Error ? error : new Error(String(error));
172
- res.status(401).json({
173
- success: false,
174
- error: err.message,
175
- });
173
+ ctx.logger.error('Auth request error:', err);
174
+
175
+ // Return error response
176
+ return new Response(
177
+ JSON.stringify({
178
+ success: false,
179
+ error: err.message,
180
+ }),
181
+ {
182
+ status: 500,
183
+ headers: { 'Content-Type': 'application/json' },
184
+ }
185
+ );
176
186
  }
177
187
  });
178
188
 
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
- });
189
+ ctx.logger.info(`Auth routes registered: All requests under ${basePath}/* forwarded to better-auth`);
188
190
  }
189
191
  }
190
192
 
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
193
 
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
-
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
194
 
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
+ });