lsh-framework 1.3.2 → 1.4.1
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/.env.example +43 -3
- package/README.md +25 -4
- package/dist/cli.js +6 -0
- package/dist/commands/config.js +240 -0
- package/dist/daemon/saas-api-routes.js +778 -0
- package/dist/daemon/saas-api-server.js +225 -0
- package/dist/lib/config-manager.js +321 -0
- package/dist/lib/database-persistence.js +75 -3
- package/dist/lib/env-validator.js +17 -0
- package/dist/lib/local-storage-adapter.js +493 -0
- package/dist/lib/saas-audit.js +213 -0
- package/dist/lib/saas-auth.js +427 -0
- package/dist/lib/saas-billing.js +402 -0
- package/dist/lib/saas-email.js +402 -0
- package/dist/lib/saas-encryption.js +220 -0
- package/dist/lib/saas-organizations.js +592 -0
- package/dist/lib/saas-secrets.js +378 -0
- package/dist/lib/saas-types.js +108 -0
- package/dist/lib/supabase-client.js +77 -11
- package/package.json +13 -2
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSH SaaS Authentication Service
|
|
3
|
+
* Handles user signup, login, email verification, and session management
|
|
4
|
+
*/
|
|
5
|
+
import { randomBytes } from 'crypto';
|
|
6
|
+
import bcrypt from 'bcrypt';
|
|
7
|
+
import jwt from 'jsonwebtoken';
|
|
8
|
+
import { getSupabaseClient } from './supabase-client.js';
|
|
9
|
+
const BCRYPT_ROUNDS = 12;
|
|
10
|
+
const TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 days in seconds
|
|
11
|
+
const REFRESH_TOKEN_EXPIRY = 30 * 24 * 60 * 60; // 30 days
|
|
12
|
+
const EMAIL_VERIFICATION_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours in ms
|
|
13
|
+
/**
|
|
14
|
+
* Generate a secure random token
|
|
15
|
+
*/
|
|
16
|
+
function generateToken(length = 32) {
|
|
17
|
+
return randomBytes(length).toString('hex');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Hash a password using bcrypt
|
|
21
|
+
*/
|
|
22
|
+
export async function hashPassword(password) {
|
|
23
|
+
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Verify a password against a hash
|
|
27
|
+
*/
|
|
28
|
+
export async function verifyPassword(password, hash) {
|
|
29
|
+
return bcrypt.compare(password, hash);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Generate JWT access token
|
|
33
|
+
*/
|
|
34
|
+
export function generateAccessToken(userId, email) {
|
|
35
|
+
const secret = process.env.LSH_JWT_SECRET;
|
|
36
|
+
if (!secret) {
|
|
37
|
+
throw new Error('LSH_JWT_SECRET is not set');
|
|
38
|
+
}
|
|
39
|
+
return jwt.sign({
|
|
40
|
+
sub: userId,
|
|
41
|
+
email,
|
|
42
|
+
type: 'access',
|
|
43
|
+
}, secret, {
|
|
44
|
+
expiresIn: TOKEN_EXPIRY,
|
|
45
|
+
issuer: 'lsh-saas',
|
|
46
|
+
audience: 'lsh-api',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Generate JWT refresh token
|
|
51
|
+
*/
|
|
52
|
+
export function generateRefreshToken(userId) {
|
|
53
|
+
const secret = process.env.LSH_JWT_SECRET;
|
|
54
|
+
if (!secret) {
|
|
55
|
+
throw new Error('LSH_JWT_SECRET is not set');
|
|
56
|
+
}
|
|
57
|
+
return jwt.sign({
|
|
58
|
+
sub: userId,
|
|
59
|
+
type: 'refresh',
|
|
60
|
+
}, secret, {
|
|
61
|
+
expiresIn: REFRESH_TOKEN_EXPIRY,
|
|
62
|
+
issuer: 'lsh-saas',
|
|
63
|
+
audience: 'lsh-api',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Verify and decode JWT token
|
|
68
|
+
*/
|
|
69
|
+
export function verifyToken(token) {
|
|
70
|
+
const secret = process.env.LSH_JWT_SECRET;
|
|
71
|
+
if (!secret) {
|
|
72
|
+
throw new Error('LSH_JWT_SECRET is not set');
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const decoded = jwt.verify(token, secret, {
|
|
76
|
+
issuer: 'lsh-saas',
|
|
77
|
+
audience: 'lsh-api',
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
userId: decoded.sub,
|
|
81
|
+
email: decoded.email,
|
|
82
|
+
type: decoded.type,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
throw new Error('Invalid or expired token');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Authentication Service
|
|
91
|
+
*/
|
|
92
|
+
export class AuthService {
|
|
93
|
+
supabase = getSupabaseClient();
|
|
94
|
+
/**
|
|
95
|
+
* Sign up a new user
|
|
96
|
+
*/
|
|
97
|
+
async signup(input) {
|
|
98
|
+
// Check if email already exists
|
|
99
|
+
const { data: existingUser } = await this.supabase
|
|
100
|
+
.from('users')
|
|
101
|
+
.select('id')
|
|
102
|
+
.eq('email', input.email.toLowerCase())
|
|
103
|
+
.single();
|
|
104
|
+
if (existingUser) {
|
|
105
|
+
throw new Error('EMAIL_ALREADY_EXISTS');
|
|
106
|
+
}
|
|
107
|
+
// Hash password
|
|
108
|
+
const passwordHash = await hashPassword(input.password);
|
|
109
|
+
// Generate email verification token
|
|
110
|
+
const verificationToken = generateToken();
|
|
111
|
+
const verificationExpiresAt = new Date(Date.now() + EMAIL_VERIFICATION_EXPIRY);
|
|
112
|
+
// Create user
|
|
113
|
+
const { data: user, error } = await this.supabase
|
|
114
|
+
.from('users')
|
|
115
|
+
.insert({
|
|
116
|
+
email: input.email.toLowerCase(),
|
|
117
|
+
password_hash: passwordHash,
|
|
118
|
+
first_name: input.firstName || null,
|
|
119
|
+
last_name: input.lastName || null,
|
|
120
|
+
email_verified: false,
|
|
121
|
+
email_verification_token: verificationToken,
|
|
122
|
+
email_verification_expires_at: verificationExpiresAt.toISOString(),
|
|
123
|
+
})
|
|
124
|
+
.select()
|
|
125
|
+
.single();
|
|
126
|
+
if (error) {
|
|
127
|
+
throw new Error(`Failed to create user: ${error.message}`);
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
user: this.mapDbUserToUser(user),
|
|
131
|
+
verificationToken,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Verify email address
|
|
136
|
+
*/
|
|
137
|
+
async verifyEmail(token) {
|
|
138
|
+
const { data: user, error } = await this.supabase
|
|
139
|
+
.from('users')
|
|
140
|
+
.select('*')
|
|
141
|
+
.eq('email_verification_token', token)
|
|
142
|
+
.single();
|
|
143
|
+
if (error || !user) {
|
|
144
|
+
throw new Error('INVALID_TOKEN');
|
|
145
|
+
}
|
|
146
|
+
// Check if token expired
|
|
147
|
+
const expiresAt = new Date(user.email_verification_expires_at);
|
|
148
|
+
if (expiresAt < new Date()) {
|
|
149
|
+
throw new Error('INVALID_TOKEN');
|
|
150
|
+
}
|
|
151
|
+
// Mark email as verified
|
|
152
|
+
const { data: updatedUser, error: updateError } = await this.supabase
|
|
153
|
+
.from('users')
|
|
154
|
+
.update({
|
|
155
|
+
email_verified: true,
|
|
156
|
+
email_verification_token: null,
|
|
157
|
+
email_verification_expires_at: null,
|
|
158
|
+
})
|
|
159
|
+
.eq('id', user.id)
|
|
160
|
+
.select()
|
|
161
|
+
.single();
|
|
162
|
+
if (updateError) {
|
|
163
|
+
throw new Error('Failed to verify email');
|
|
164
|
+
}
|
|
165
|
+
return this.mapDbUserToUser(updatedUser);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Resend email verification
|
|
169
|
+
*/
|
|
170
|
+
async resendVerificationEmail(email) {
|
|
171
|
+
const { data: user, error } = await this.supabase
|
|
172
|
+
.from('users')
|
|
173
|
+
.select('*')
|
|
174
|
+
.eq('email', email.toLowerCase())
|
|
175
|
+
.single();
|
|
176
|
+
if (error || !user) {
|
|
177
|
+
throw new Error('NOT_FOUND');
|
|
178
|
+
}
|
|
179
|
+
if (user.email_verified) {
|
|
180
|
+
throw new Error('Email already verified');
|
|
181
|
+
}
|
|
182
|
+
// Generate new token
|
|
183
|
+
const verificationToken = generateToken();
|
|
184
|
+
const verificationExpiresAt = new Date(Date.now() + EMAIL_VERIFICATION_EXPIRY);
|
|
185
|
+
await this.supabase
|
|
186
|
+
.from('users')
|
|
187
|
+
.update({
|
|
188
|
+
email_verification_token: verificationToken,
|
|
189
|
+
email_verification_expires_at: verificationExpiresAt.toISOString(),
|
|
190
|
+
})
|
|
191
|
+
.eq('id', user.id);
|
|
192
|
+
return verificationToken;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Login with email and password
|
|
196
|
+
*/
|
|
197
|
+
async login(input, ipAddress) {
|
|
198
|
+
// Find user
|
|
199
|
+
const { data: user, error } = await this.supabase
|
|
200
|
+
.from('users')
|
|
201
|
+
.select('*')
|
|
202
|
+
.eq('email', input.email.toLowerCase())
|
|
203
|
+
.is('deleted_at', null)
|
|
204
|
+
.single();
|
|
205
|
+
if (error || !user) {
|
|
206
|
+
throw new Error('INVALID_CREDENTIALS');
|
|
207
|
+
}
|
|
208
|
+
// Verify password
|
|
209
|
+
if (!user.password_hash) {
|
|
210
|
+
throw new Error('INVALID_CREDENTIALS');
|
|
211
|
+
}
|
|
212
|
+
const isValidPassword = await verifyPassword(input.password, user.password_hash);
|
|
213
|
+
if (!isValidPassword) {
|
|
214
|
+
throw new Error('INVALID_CREDENTIALS');
|
|
215
|
+
}
|
|
216
|
+
// Check if email is verified
|
|
217
|
+
if (!user.email_verified) {
|
|
218
|
+
throw new Error('EMAIL_NOT_VERIFIED');
|
|
219
|
+
}
|
|
220
|
+
// Update last login
|
|
221
|
+
await this.supabase
|
|
222
|
+
.from('users')
|
|
223
|
+
.update({
|
|
224
|
+
last_login_at: new Date().toISOString(),
|
|
225
|
+
last_login_ip: ipAddress || null,
|
|
226
|
+
})
|
|
227
|
+
.eq('id', user.id);
|
|
228
|
+
// Get user's organizations
|
|
229
|
+
const organizations = await this.getUserOrganizations(user.id);
|
|
230
|
+
// Generate tokens
|
|
231
|
+
const accessToken = generateAccessToken(user.id, user.email);
|
|
232
|
+
const refreshToken = generateRefreshToken(user.id);
|
|
233
|
+
return {
|
|
234
|
+
user: this.mapDbUserToUser(user),
|
|
235
|
+
organizations,
|
|
236
|
+
currentOrganization: organizations[0],
|
|
237
|
+
token: {
|
|
238
|
+
accessToken,
|
|
239
|
+
refreshToken,
|
|
240
|
+
expiresIn: TOKEN_EXPIRY,
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Refresh access token
|
|
246
|
+
*/
|
|
247
|
+
async refreshAccessToken(refreshToken) {
|
|
248
|
+
const { userId } = verifyToken(refreshToken);
|
|
249
|
+
const { data: user, error } = await this.supabase
|
|
250
|
+
.from('users')
|
|
251
|
+
.select('email')
|
|
252
|
+
.eq('id', userId)
|
|
253
|
+
.is('deleted_at', null)
|
|
254
|
+
.single();
|
|
255
|
+
if (error || !user) {
|
|
256
|
+
throw new Error('INVALID_TOKEN');
|
|
257
|
+
}
|
|
258
|
+
const accessToken = generateAccessToken(userId, user.email);
|
|
259
|
+
const newRefreshToken = generateRefreshToken(userId);
|
|
260
|
+
return {
|
|
261
|
+
accessToken,
|
|
262
|
+
refreshToken: newRefreshToken,
|
|
263
|
+
expiresIn: TOKEN_EXPIRY,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Get user by ID
|
|
268
|
+
*/
|
|
269
|
+
async getUserById(userId) {
|
|
270
|
+
const { data: user, error } = await this.supabase
|
|
271
|
+
.from('users')
|
|
272
|
+
.select('*')
|
|
273
|
+
.eq('id', userId)
|
|
274
|
+
.is('deleted_at', null)
|
|
275
|
+
.single();
|
|
276
|
+
if (error || !user) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
return this.mapDbUserToUser(user);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Get user by email
|
|
283
|
+
*/
|
|
284
|
+
async getUserByEmail(email) {
|
|
285
|
+
const { data: user, error } = await this.supabase
|
|
286
|
+
.from('users')
|
|
287
|
+
.select('*')
|
|
288
|
+
.eq('email', email.toLowerCase())
|
|
289
|
+
.is('deleted_at', null)
|
|
290
|
+
.single();
|
|
291
|
+
if (error || !user) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
return this.mapDbUserToUser(user);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Get user's organizations
|
|
298
|
+
*/
|
|
299
|
+
async getUserOrganizations(userId) {
|
|
300
|
+
const { data, error } = await this.supabase
|
|
301
|
+
.from('organization_members')
|
|
302
|
+
.select(`
|
|
303
|
+
organization_id,
|
|
304
|
+
organizations (
|
|
305
|
+
id,
|
|
306
|
+
name,
|
|
307
|
+
slug,
|
|
308
|
+
created_at,
|
|
309
|
+
updated_at,
|
|
310
|
+
stripe_customer_id,
|
|
311
|
+
subscription_tier,
|
|
312
|
+
subscription_status,
|
|
313
|
+
subscription_expires_at,
|
|
314
|
+
settings,
|
|
315
|
+
deleted_at
|
|
316
|
+
)
|
|
317
|
+
`)
|
|
318
|
+
.eq('user_id', userId)
|
|
319
|
+
.is('organizations.deleted_at', null);
|
|
320
|
+
if (error) {
|
|
321
|
+
throw new Error(`Failed to get user organizations: ${error.message}`);
|
|
322
|
+
}
|
|
323
|
+
return (data || []).map((row) => this.mapDbOrgToOrg(row.organizations));
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Request password reset
|
|
327
|
+
*/
|
|
328
|
+
async requestPasswordReset(email) {
|
|
329
|
+
const { data: user, error } = await this.supabase
|
|
330
|
+
.from('users')
|
|
331
|
+
.select('id')
|
|
332
|
+
.eq('email', email.toLowerCase())
|
|
333
|
+
.is('deleted_at', null)
|
|
334
|
+
.single();
|
|
335
|
+
if (error || !user) {
|
|
336
|
+
// Don't reveal if email exists
|
|
337
|
+
return generateToken();
|
|
338
|
+
}
|
|
339
|
+
const resetToken = generateToken();
|
|
340
|
+
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
|
341
|
+
// Store reset token (we'll need a password_reset_tokens table)
|
|
342
|
+
// For now, just return the token
|
|
343
|
+
return resetToken;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Reset password
|
|
347
|
+
*/
|
|
348
|
+
async resetPassword(token, newPassword) {
|
|
349
|
+
// TODO: Implement password reset
|
|
350
|
+
// Need to create password_reset_tokens table
|
|
351
|
+
throw new Error('Not implemented');
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Change password
|
|
355
|
+
*/
|
|
356
|
+
async changePassword(userId, currentPassword, newPassword) {
|
|
357
|
+
const { data: user, error } = await this.supabase
|
|
358
|
+
.from('users')
|
|
359
|
+
.select('password_hash')
|
|
360
|
+
.eq('id', userId)
|
|
361
|
+
.single();
|
|
362
|
+
if (error || !user) {
|
|
363
|
+
throw new Error('NOT_FOUND');
|
|
364
|
+
}
|
|
365
|
+
// Verify current password
|
|
366
|
+
if (!user.password_hash) {
|
|
367
|
+
throw new Error('No password set');
|
|
368
|
+
}
|
|
369
|
+
const isValid = await verifyPassword(currentPassword, user.password_hash);
|
|
370
|
+
if (!isValid) {
|
|
371
|
+
throw new Error('INVALID_CREDENTIALS');
|
|
372
|
+
}
|
|
373
|
+
// Hash new password
|
|
374
|
+
const newHash = await hashPassword(newPassword);
|
|
375
|
+
// Update password
|
|
376
|
+
await this.supabase.from('users').update({ password_hash: newHash }).eq('id', userId);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Map database user to User type
|
|
380
|
+
*/
|
|
381
|
+
mapDbUserToUser(dbUser) {
|
|
382
|
+
return {
|
|
383
|
+
id: dbUser.id,
|
|
384
|
+
email: dbUser.email,
|
|
385
|
+
emailVerified: dbUser.email_verified,
|
|
386
|
+
emailVerificationToken: dbUser.email_verification_token,
|
|
387
|
+
emailVerificationExpiresAt: dbUser.email_verification_expires_at
|
|
388
|
+
? new Date(dbUser.email_verification_expires_at)
|
|
389
|
+
: null,
|
|
390
|
+
passwordHash: dbUser.password_hash,
|
|
391
|
+
oauthProvider: dbUser.oauth_provider,
|
|
392
|
+
oauthProviderId: dbUser.oauth_provider_id,
|
|
393
|
+
firstName: dbUser.first_name,
|
|
394
|
+
lastName: dbUser.last_name,
|
|
395
|
+
avatarUrl: dbUser.avatar_url,
|
|
396
|
+
lastLoginAt: dbUser.last_login_at ? new Date(dbUser.last_login_at) : null,
|
|
397
|
+
lastLoginIp: dbUser.last_login_ip,
|
|
398
|
+
createdAt: new Date(dbUser.created_at),
|
|
399
|
+
updatedAt: new Date(dbUser.updated_at),
|
|
400
|
+
deletedAt: dbUser.deleted_at ? new Date(dbUser.deleted_at) : null,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Map database organization to Organization type
|
|
405
|
+
*/
|
|
406
|
+
mapDbOrgToOrg(dbOrg) {
|
|
407
|
+
return {
|
|
408
|
+
id: dbOrg.id,
|
|
409
|
+
name: dbOrg.name,
|
|
410
|
+
slug: dbOrg.slug,
|
|
411
|
+
createdAt: new Date(dbOrg.created_at),
|
|
412
|
+
updatedAt: new Date(dbOrg.updated_at),
|
|
413
|
+
stripeCustomerId: dbOrg.stripe_customer_id,
|
|
414
|
+
subscriptionTier: dbOrg.subscription_tier,
|
|
415
|
+
subscriptionStatus: dbOrg.subscription_status,
|
|
416
|
+
subscriptionExpiresAt: dbOrg.subscription_expires_at
|
|
417
|
+
? new Date(dbOrg.subscription_expires_at)
|
|
418
|
+
: null,
|
|
419
|
+
settings: dbOrg.settings || {},
|
|
420
|
+
deletedAt: dbOrg.deleted_at ? new Date(dbOrg.deleted_at) : null,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Singleton instance
|
|
426
|
+
*/
|
|
427
|
+
export const authService = new AuthService();
|