@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.
- package/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +32 -0
- package/IMPLEMENTATION_SUMMARY.md +150 -0
- package/LICENSE +202 -0
- package/README.md +120 -0
- package/dist/index.d.mts +58 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +165 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +138 -0
- package/dist/index.mjs.map +1 -0
- package/examples/basic-usage.ts +95 -0
- package/package.json +29 -0
- package/src/auth-plugin.test.ts +216 -0
- package/src/auth-plugin.ts +222 -0
- package/src/index.ts +11 -0
- package/tsconfig.json +9 -0
|
@@ -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';
|