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.
@@ -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();