@objectstack/plugin-auth 2.0.2

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.
@@ -0,0 +1,216 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ import { AuthPlugin } from './auth-plugin';
5
+ import type { PluginContext } from '@objectstack/core';
6
+
7
+ describe('AuthPlugin', () => {
8
+ let mockContext: PluginContext;
9
+ let authPlugin: AuthPlugin;
10
+
11
+ beforeEach(() => {
12
+ mockContext = {
13
+ registerService: vi.fn(),
14
+ getService: vi.fn(),
15
+ getServices: vi.fn(() => new Map()),
16
+ hook: vi.fn(),
17
+ trigger: vi.fn(),
18
+ logger: {
19
+ info: vi.fn(),
20
+ error: vi.fn(),
21
+ warn: vi.fn(),
22
+ debug: vi.fn(),
23
+ },
24
+ getKernel: vi.fn(),
25
+ };
26
+ });
27
+
28
+ describe('Plugin Metadata', () => {
29
+ it('should have correct plugin metadata', () => {
30
+ authPlugin = new AuthPlugin({
31
+ secret: 'test-secret',
32
+ });
33
+
34
+ expect(authPlugin.name).toBe('com.objectstack.auth');
35
+ expect(authPlugin.type).toBe('standard');
36
+ expect(authPlugin.version).toBe('1.0.0');
37
+ expect(authPlugin.dependencies).toContain('com.objectstack.server.hono');
38
+ });
39
+ });
40
+
41
+ describe('Initialization', () => {
42
+ it('should throw error if secret is not provided', async () => {
43
+ authPlugin = new AuthPlugin({});
44
+
45
+ await expect(authPlugin.init(mockContext)).rejects.toThrow(
46
+ 'AuthPlugin: secret is required'
47
+ );
48
+ });
49
+
50
+ it('should initialize successfully with required config', async () => {
51
+ authPlugin = new AuthPlugin({
52
+ secret: 'test-secret-at-least-32-chars-long',
53
+ baseUrl: 'http://localhost:3000',
54
+ });
55
+
56
+ await authPlugin.init(mockContext);
57
+
58
+ expect(mockContext.logger.info).toHaveBeenCalledWith('Initializing Auth Plugin...');
59
+ expect(mockContext.registerService).toHaveBeenCalledWith('auth', expect.anything());
60
+ expect(mockContext.logger.info).toHaveBeenCalledWith('Auth Plugin initialized successfully');
61
+ });
62
+
63
+ it('should configure OAuth providers', async () => {
64
+ authPlugin = new AuthPlugin({
65
+ secret: 'test-secret-at-least-32-chars-long',
66
+ baseUrl: 'http://localhost:3000',
67
+ providers: [
68
+ {
69
+ id: 'google',
70
+ clientId: 'google-client-id',
71
+ clientSecret: 'google-client-secret',
72
+ scope: ['email', 'profile'],
73
+ },
74
+ ],
75
+ });
76
+
77
+ await authPlugin.init(mockContext);
78
+
79
+ expect(mockContext.registerService).toHaveBeenCalled();
80
+ });
81
+
82
+ it('should configure plugins', async () => {
83
+ authPlugin = new AuthPlugin({
84
+ secret: 'test-secret-at-least-32-chars-long',
85
+ baseUrl: 'http://localhost:3000',
86
+ plugins: {
87
+ organization: true,
88
+ twoFactor: true,
89
+ passkeys: true,
90
+ magicLink: true,
91
+ },
92
+ });
93
+
94
+ await authPlugin.init(mockContext);
95
+
96
+ expect(mockContext.registerService).toHaveBeenCalled();
97
+ });
98
+ });
99
+
100
+ describe('Start Phase', () => {
101
+ beforeEach(async () => {
102
+ authPlugin = new AuthPlugin({
103
+ secret: 'test-secret-at-least-32-chars-long',
104
+ baseUrl: 'http://localhost:3000',
105
+ });
106
+ await authPlugin.init(mockContext);
107
+ });
108
+
109
+ it('should register routes with HTTP server when enabled', async () => {
110
+ const mockHttpServer = {
111
+ post: vi.fn(),
112
+ get: vi.fn(),
113
+ put: vi.fn(),
114
+ delete: vi.fn(),
115
+ patch: vi.fn(),
116
+ use: vi.fn(),
117
+ };
118
+
119
+ mockContext.getService = vi.fn((name: string) => {
120
+ if (name === 'http-server') return mockHttpServer;
121
+ throw new Error(`Service not found: ${name}`);
122
+ });
123
+
124
+ await authPlugin.start(mockContext);
125
+
126
+ expect(mockContext.getService).toHaveBeenCalledWith('http-server');
127
+ expect(mockHttpServer.post).toHaveBeenCalled();
128
+ expect(mockHttpServer.get).toHaveBeenCalled();
129
+ expect(mockContext.logger.info).toHaveBeenCalledWith(
130
+ expect.stringContaining('Auth routes registered')
131
+ );
132
+ });
133
+
134
+ it('should skip route registration when disabled', async () => {
135
+ authPlugin = new AuthPlugin({
136
+ secret: 'test-secret-at-least-32-chars-long',
137
+ baseUrl: 'http://localhost:3000',
138
+ registerRoutes: false,
139
+ });
140
+
141
+ await authPlugin.init(mockContext);
142
+ await authPlugin.start(mockContext);
143
+
144
+ expect(mockContext.getService).not.toHaveBeenCalledWith('http-server');
145
+ });
146
+
147
+ it('should throw error if auth not initialized', async () => {
148
+ const uninitializedPlugin = new AuthPlugin({
149
+ secret: 'test-secret',
150
+ });
151
+
152
+ await expect(uninitializedPlugin.start(mockContext)).rejects.toThrow(
153
+ 'Auth manager not initialized'
154
+ );
155
+ });
156
+ });
157
+
158
+ describe('Destroy Phase', () => {
159
+ it('should cleanup resources', async () => {
160
+ authPlugin = new AuthPlugin({
161
+ secret: 'test-secret-at-least-32-chars-long',
162
+ });
163
+
164
+ await authPlugin.init(mockContext);
165
+ await authPlugin.destroy();
166
+
167
+ // Should not throw
168
+ expect(true).toBe(true);
169
+ });
170
+ });
171
+
172
+ describe('Configuration Options', () => {
173
+ it('should use custom base path', async () => {
174
+ authPlugin = new AuthPlugin({
175
+ secret: 'test-secret-at-least-32-chars-long',
176
+ baseUrl: 'http://localhost:3000',
177
+ basePath: '/custom/auth',
178
+ });
179
+
180
+ await authPlugin.init(mockContext);
181
+
182
+ const mockHttpServer = {
183
+ post: vi.fn(),
184
+ get: vi.fn(),
185
+ put: vi.fn(),
186
+ delete: vi.fn(),
187
+ patch: vi.fn(),
188
+ use: vi.fn(),
189
+ };
190
+
191
+ mockContext.getService = vi.fn(() => mockHttpServer);
192
+
193
+ await authPlugin.start(mockContext);
194
+
195
+ expect(mockHttpServer.post).toHaveBeenCalledWith(
196
+ '/custom/auth/login',
197
+ expect.any(Function)
198
+ );
199
+ });
200
+
201
+ it('should configure session options', async () => {
202
+ authPlugin = new AuthPlugin({
203
+ secret: 'test-secret-at-least-32-chars-long',
204
+ baseUrl: 'http://localhost:3000',
205
+ session: {
206
+ expiresIn: 60 * 60 * 24 * 30, // 30 days
207
+ updateAge: 60 * 60 * 24, // 1 day
208
+ },
209
+ });
210
+
211
+ await authPlugin.init(mockContext);
212
+
213
+ expect(mockContext.registerService).toHaveBeenCalled();
214
+ });
215
+ });
216
+ });
@@ -0,0 +1,222 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
4
+ import { AuthConfig } from '@objectstack/spec/system';
5
+
6
+ /**
7
+ * Auth Plugin Options
8
+ * Extends AuthConfig from spec with additional runtime options
9
+ */
10
+ export interface AuthPluginOptions extends Partial<AuthConfig> {
11
+ /**
12
+ * Whether to automatically register auth routes
13
+ * @default true
14
+ */
15
+ registerRoutes?: boolean;
16
+
17
+ /**
18
+ * Base path for auth routes
19
+ * @default '/api/v1/auth'
20
+ */
21
+ basePath?: string;
22
+ }
23
+
24
+ /**
25
+ * Authentication Plugin
26
+ *
27
+ * Provides authentication and identity services for ObjectStack applications.
28
+ *
29
+ * Features:
30
+ * - Session management
31
+ * - User registration/login
32
+ * - OAuth providers (Google, GitHub, etc.)
33
+ * - Organization/team support
34
+ * - 2FA, passkeys, magic links
35
+ *
36
+ * This plugin registers:
37
+ * - `auth` service (auth manager instance)
38
+ * - HTTP routes for authentication endpoints
39
+ *
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.
43
+ */
44
+ export class AuthPlugin implements Plugin {
45
+ name = 'com.objectstack.auth';
46
+ type = 'standard';
47
+ version = '1.0.0';
48
+ dependencies = ['com.objectstack.server.hono']; // Requires HTTP server
49
+
50
+ private options: AuthPluginOptions;
51
+ private authManager: AuthManager | null = null;
52
+
53
+ constructor(options: AuthPluginOptions = {}) {
54
+ this.options = {
55
+ registerRoutes: true,
56
+ basePath: '/api/v1/auth',
57
+ ...options
58
+ };
59
+ }
60
+
61
+ async init(ctx: PluginContext): Promise<void> {
62
+ ctx.logger.info('Initializing Auth Plugin...');
63
+
64
+ // Validate required configuration
65
+ if (!this.options.secret) {
66
+ throw new Error('AuthPlugin: secret is required');
67
+ }
68
+
69
+ // Initialize auth manager
70
+ this.authManager = new AuthManager(this.options);
71
+
72
+ // Register auth service
73
+ ctx.registerService('auth', this.authManager);
74
+
75
+ ctx.logger.info('Auth Plugin initialized successfully');
76
+ }
77
+
78
+ async start(ctx: PluginContext): Promise<void> {
79
+ ctx.logger.info('Starting Auth Plugin...');
80
+
81
+ if (!this.authManager) {
82
+ throw new Error('Auth manager not initialized');
83
+ }
84
+
85
+ // Register HTTP routes if enabled
86
+ if (this.options.registerRoutes) {
87
+ try {
88
+ const httpServer = ctx.getService<IHttpServer>('http-server');
89
+ this.registerAuthRoutes(httpServer, ctx);
90
+ ctx.logger.info(`Auth routes registered at ${this.options.basePath}`);
91
+ } catch (error) {
92
+ const err = error instanceof Error ? error : new Error(String(error));
93
+ ctx.logger.error('Failed to register auth routes:', err);
94
+ throw err;
95
+ }
96
+ }
97
+
98
+ ctx.logger.info('Auth Plugin started successfully');
99
+ }
100
+
101
+ async destroy(): Promise<void> {
102
+ // Cleanup if needed
103
+ this.authManager = null;
104
+ }
105
+
106
+ /**
107
+ * Register authentication routes with HTTP server
108
+ */
109
+ private registerAuthRoutes(httpServer: IHttpServer, ctx: PluginContext): void {
110
+ if (!this.authManager) return;
111
+
112
+ const basePath = this.options.basePath || '/api/v1/auth';
113
+
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
+ });
129
+
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
+ });
145
+
146
+ // Logout endpoint
147
+ httpServer.post(`${basePath}/logout`, async (req, res) => {
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,
159
+ });
160
+ }
161
+ });
162
+
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 });
170
+ } catch (error) {
171
+ const err = error instanceof Error ? error : new Error(String(error));
172
+ res.status(401).json({
173
+ success: false,
174
+ error: err.message,
175
+ });
176
+ }
177
+ });
178
+
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
+ });
188
+ }
189
+ }
190
+
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
+
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
+
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * @objectstack/plugin-auth
5
+ *
6
+ * Authentication & Identity Plugin for ObjectStack
7
+ * Powered by better-auth for robust, secure authentication
8
+ */
9
+
10
+ export * from './auth-plugin';
11
+ export type { AuthConfig, AuthProviderConfig, AuthPluginConfig } from '@objectstack/spec/system';
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["dist", "node_modules", "**/*.test.ts"]
9
+ }