TypeScript · 16967 bytes Raw Blame History
1 import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2 import { randomUUID } from 'crypto';
3 import { z } from 'zod';
4 import { getDatabase } from '../database/connection.js';
5 import { EmailService } from '../services/email.js';
6 import type { AuthResponse, PublicUser } from '../database/types.js';
7
8 // OAuth callback schemas
9 const githubCallbackSchema = z.object({
10 code: z.string().min(1, 'Authorization code required'),
11 state: z.string().optional(),
12 });
13
14 const userTypeSelectionSchema = z.object({
15 userType: z.enum(['backup', 'volunteer']),
16 tempUserId: z.string().uuid(),
17 });
18
19 interface GitHubUser {
20 id: number;
21 login: string;
22 name?: string;
23 email?: string;
24 avatar_url?: string;
25 }
26
27 interface GitHubEmail {
28 email: string;
29 primary: boolean;
30 verified: boolean;
31 }
32
33 let emailService: EmailService;
34
35 export async function oauthRoutes(fastify: FastifyInstance) {
36 const db = getDatabase();
37
38 // Initialize email service
39 emailService = new EmailService({
40 host: process.env.SMTP_HOST || 'smtp.ethereal.email',
41 port: parseInt(process.env.SMTP_PORT || '587'),
42 secure: process.env.SMTP_SECURE === 'true',
43 auth: process.env.SMTP_USER && process.env.SMTP_PASS ? {
44 user: process.env.SMTP_USER,
45 pass: process.env.SMTP_PASS,
46 } : undefined,
47 from: process.env.SMTP_FROM || 'ZephyrFS <noreply@zephyrfs.org>',
48 });
49
50 // Register GitHub OAuth plugin
51 await fastify.register(import('@fastify/oauth2'), {
52 name: 'githubOAuth2',
53 credentials: {
54 client: {
55 id: process.env.GITHUB_CLIENT_ID!,
56 secret: process.env.GITHUB_CLIENT_SECRET!,
57 },
58 auth: fastify.oauth2.GITHUB_CONFIGURATION,
59 },
60 scope: ['user:email', 'read:user'],
61 startRedirectPath: '/oauth/github/login',
62 callbackUri: `${process.env.BACKEND_URL || 'http://localhost:8080'}/oauth/github/callback`,
63 });
64
65 // Helper function to create public user object
66 const toPublicUser = (user: any): PublicUser => ({
67 id: user.id,
68 email: user.email,
69 username: user.username,
70 userType: user.userType || user.user_type,
71 emailVerified: user.emailVerified || user.email_verified,
72 githubUsername: user.githubUsername || user.github_username,
73 githubAvatarUrl: user.githubAvatarUrl || user.github_avatar_url,
74 createdAt: new Date(user.createdAt || user.created_at),
75 });
76
77 // GitHub OAuth login initiation
78 fastify.get('/oauth/github/login', async (request: FastifyRequest, reply: FastifyReply) => {
79 const state = randomUUID();
80
81 // Store state for verification (in production, use Redis or database)
82 // For now, we'll include it in the OAuth flow and verify in callback
83
84 return fastify.githubOAuth2.generateAuthorizationUri(request, reply, {
85 state,
86 });
87 });
88
89 // GitHub OAuth callback
90 fastify.get<{
91 Querystring: z.infer<typeof githubCallbackSchema>;
92 }>('/oauth/github/callback', async (request: FastifyRequest, reply: FastifyReply) => {
93 const { code, state } = request.query as z.infer<typeof githubCallbackSchema>;
94
95 try {
96 // Exchange code for token
97 const tokenResponse = await fastify.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request);
98
99 if (!tokenResponse.access_token) {
100 throw new Error('No access token received');
101 }
102
103 // Fetch GitHub user data
104 const userResponse = await fetch('https://api.github.com/user', {
105 headers: {
106 'Authorization': `Bearer ${tokenResponse.access_token}`,
107 'User-Agent': 'ZephyrFS/1.0',
108 'Accept': 'application/vnd.github.v3+json',
109 },
110 });
111
112 if (!userResponse.ok) {
113 throw new Error('Failed to fetch GitHub user data');
114 }
115
116 const githubUser: GitHubUser = await userResponse.json();
117
118 // Fetch GitHub user emails
119 const emailsResponse = await fetch('https://api.github.com/user/emails', {
120 headers: {
121 'Authorization': `Bearer ${tokenResponse.access_token}`,
122 'User-Agent': 'ZephyrFS/1.0',
123 'Accept': 'application/vnd.github.v3+json',
124 },
125 });
126
127 let primaryEmail = githubUser.email;
128 if (emailsResponse.ok) {
129 const emails: GitHubEmail[] = await emailsResponse.json();
130 const primary = emails.find(email => email.primary && email.verified);
131 if (primary) {
132 primaryEmail = primary.email;
133 }
134 }
135
136 if (!primaryEmail) {
137 // Redirect to frontend with error
138 const errorUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/error?error=no_email`;
139 return reply.redirect(errorUrl);
140 }
141
142 // Check if user already exists with this GitHub ID
143 let existingOAuthAccount = await db
144 .selectFrom('oauth_accounts')
145 .selectAll()
146 .where('provider', '=', 'github')
147 .where('provider_account_id', '=', githubUser.id.toString())
148 .executeTakeFirst();
149
150 let user: any;
151 let isNewUser = false;
152
153 if (existingOAuthAccount) {
154 // User exists, get their account
155 user = await db
156 .selectFrom('users')
157 .selectAll()
158 .where('id', '=', existingOAuthAccount.user_id)
159 .executeTakeFirst();
160
161 if (!user) {
162 throw new Error('OAuth account exists but user not found');
163 }
164
165 // Update OAuth account tokens
166 await db
167 .updateTable('oauth_accounts')
168 .set({
169 access_token: tokenResponse.access_token,
170 refresh_token: tokenResponse.refresh_token || null,
171 expires_at: tokenResponse.expires_in
172 ? new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
173 : null,
174 updated_at: new Date().toISOString(),
175 })
176 .where('id', '=', existingOAuthAccount.id)
177 .execute();
178
179 // Update user's GitHub info
180 await db
181 .updateTable('users')
182 .set({
183 github_username: githubUser.login,
184 github_avatar_url: githubUser.avatar_url,
185 last_login_at: new Date().toISOString(),
186 updated_at: new Date().toISOString(),
187 })
188 .where('id', '=', user.id)
189 .execute();
190
191 } else {
192 // Check if user exists with the email
193 user = await db
194 .selectFrom('users')
195 .selectAll()
196 .where('email', '=', primaryEmail)
197 .executeTakeFirst();
198
199 if (user) {
200 // Link GitHub to existing account
201 await db
202 .insertInto('oauth_accounts')
203 .values({
204 id: randomUUID(),
205 user_id: user.id,
206 provider: 'github',
207 provider_account_id: githubUser.id.toString(),
208 access_token: tokenResponse.access_token,
209 refresh_token: tokenResponse.refresh_token || null,
210 expires_at: tokenResponse.expires_in
211 ? new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
212 : null,
213 token_type: tokenResponse.token_type || 'bearer',
214 scope: Array.isArray(tokenResponse.scope)
215 ? tokenResponse.scope.join(' ')
216 : tokenResponse.scope || 'user:email read:user',
217 created_at: new Date().toISOString(),
218 updated_at: new Date().toISOString(),
219 })
220 .execute();
221
222 // Update user's GitHub info
223 await db
224 .updateTable('users')
225 .set({
226 github_id: githubUser.id.toString(),
227 github_username: githubUser.login,
228 github_avatar_url: githubUser.avatar_url,
229 last_login_at: new Date().toISOString(),
230 updated_at: new Date().toISOString(),
231 })
232 .where('id', '=', user.id)
233 .execute();
234
235 } else {
236 // New user - create temporary user and redirect to user type selection
237 isNewUser = true;
238 const tempUserId = randomUUID();
239
240 // Store temporary user data in database for user type selection
241 user = await db
242 .insertInto('users')
243 .values({
244 id: tempUserId,
245 email: primaryEmail,
246 username: githubUser.login,
247 user_type: 'backup', // Temporary, will be updated after type selection
248 email_verified: true, // GitHub email is considered verified
249 github_id: githubUser.id.toString(),
250 github_username: githubUser.login,
251 github_avatar_url: githubUser.avatar_url,
252 created_at: new Date().toISOString(),
253 updated_at: new Date().toISOString(),
254 profile_data: JSON.stringify({
255 temp: true,
256 githubName: githubUser.name,
257 needsUserTypeSelection: true,
258 }),
259 })
260 .returningAll()
261 .executeTakeFirstOrThrow();
262
263 // Create OAuth account
264 await db
265 .insertInto('oauth_accounts')
266 .values({
267 id: randomUUID(),
268 user_id: tempUserId,
269 provider: 'github',
270 provider_account_id: githubUser.id.toString(),
271 access_token: tokenResponse.access_token,
272 refresh_token: tokenResponse.refresh_token || null,
273 expires_at: tokenResponse.expires_in
274 ? new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
275 : null,
276 token_type: tokenResponse.token_type || 'bearer',
277 scope: Array.isArray(tokenResponse.scope)
278 ? tokenResponse.scope.join(' ')
279 : tokenResponse.scope || 'user:email read:user',
280 created_at: new Date().toISOString(),
281 updated_at: new Date().toISOString(),
282 })
283 .execute();
284
285 // Redirect to user type selection
286 const userTypeUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/select-type?temp_user=${tempUserId}&github=true`;
287 return reply.redirect(userTypeUrl);
288 }
289 }
290
291 // Create session for existing user
292 const sessionId = randomUUID();
293 const sessionToken = randomUUID();
294 const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
295
296 await db
297 .insertInto('user_sessions')
298 .values({
299 id: sessionId,
300 user_id: user.id,
301 session_token: sessionToken,
302 expires_at: expiresAt.toISOString(),
303 created_at: new Date().toISOString(),
304 last_access_at: new Date().toISOString(),
305 ip_address: request.ip,
306 user_agent: request.headers['user-agent'],
307 })
308 .execute();
309
310 // Generate tokens
311 const accessToken = fastify.jwt.sign(
312 { userId: user.id, username: user.username, sessionId },
313 { expiresIn: fastify.config.jwtExpiresIn }
314 );
315
316 // Redirect to frontend with token
317 const successUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/success?token=${accessToken}&new_user=${isNewUser}`;
318 return reply.redirect(successUrl);
319
320 } catch (error) {
321 fastify.log.error(error, 'GitHub OAuth callback failed');
322 const errorUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/error?error=oauth_failed`;
323 return reply.redirect(errorUrl);
324 }
325 });
326
327 // Complete user type selection for GitHub OAuth users
328 fastify.post<{
329 Body: z.infer<typeof userTypeSelectionSchema>;
330 }>('/oauth/github/complete-registration', {
331 schema: { body: userTypeSelectionSchema },
332 }, async (request: FastifyRequest, reply: FastifyReply) => {
333 const { userType, tempUserId } = request.body as z.infer<typeof userTypeSelectionSchema>;
334
335 try {
336 // Get temporary user
337 const tempUser = await db
338 .selectFrom('users')
339 .selectAll()
340 .where('id', '=', tempUserId)
341 .executeTakeFirst();
342
343 if (!tempUser) {
344 throw fastify.httpErrors.notFound('Temporary user not found');
345 }
346
347 const profileData = tempUser.profile_data ? JSON.parse(tempUser.profile_data) : {};
348 if (!profileData.temp || !profileData.needsUserTypeSelection) {
349 throw fastify.httpErrors.badRequest('User registration already completed');
350 }
351
352 // Update user with selected type
353 const user = await db
354 .updateTable('users')
355 .set({
356 user_type: userType,
357 profile_data: JSON.stringify({
358 ...profileData,
359 temp: false,
360 needsUserTypeSelection: false,
361 }),
362 updated_at: new Date().toISOString(),
363 })
364 .where('id', '=', tempUserId)
365 .returningAll()
366 .executeTakeFirstOrThrow();
367
368 // Initialize onboarding steps
369 const onboardingSteps = userType === 'volunteer'
370 ? ['user-type-selection', 'storage-setup', 'desktop-app', 'node-configuration']
371 : ['user-type-selection', 'backup-setup', 'first-upload'];
372
373 for (const step of onboardingSteps) {
374 await db
375 .insertInto('user_onboarding')
376 .values({
377 id: randomUUID(),
378 user_id: user.id,
379 step,
380 completed: step === 'user-type-selection',
381 data: step === 'user-type-selection' ? JSON.stringify({ userType }) : null,
382 completed_at: step === 'user-type-selection' ? new Date().toISOString() : null,
383 created_at: new Date().toISOString(),
384 })
385 .execute();
386 }
387
388 // Send welcome email
389 try {
390 await emailService.sendWelcomeEmail(user.email, userType, user.username || undefined);
391 } catch (emailError) {
392 fastify.log.error(emailError, 'Failed to send welcome email');
393 }
394
395 // Create session
396 const sessionId = randomUUID();
397 const sessionToken = randomUUID();
398 const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
399
400 await db
401 .insertInto('user_sessions')
402 .values({
403 id: sessionId,
404 user_id: user.id,
405 session_token: sessionToken,
406 expires_at: expiresAt.toISOString(),
407 created_at: new Date().toISOString(),
408 last_access_at: new Date().toISOString(),
409 ip_address: request.ip,
410 user_agent: request.headers['user-agent'],
411 })
412 .execute();
413
414 // Generate tokens
415 const accessToken = fastify.jwt.sign(
416 { userId: user.id, username: user.username, sessionId },
417 { expiresIn: fastify.config.jwtExpiresIn }
418 );
419
420 const refreshToken = fastify.jwt.sign(
421 { userId: user.id, sessionId, type: 'refresh' },
422 { expiresIn: fastify.config.jwtRefreshExpiresIn }
423 );
424
425 const response: AuthResponse = {
426 token: accessToken,
427 refreshToken,
428 expiresIn: 24 * 60 * 60,
429 user: toPublicUser(user),
430 };
431
432 return {
433 success: true,
434 message: 'Registration completed successfully! Welcome to ZephyrFS!',
435 auth: response,
436 };
437
438 } catch (error) {
439 if (error.statusCode) {
440 throw error;
441 }
442 fastify.log.error(error, 'GitHub OAuth registration completion failed');
443 throw fastify.httpErrors.internalServerError('Registration completion failed');
444 }
445 });
446
447 // Link GitHub account to existing user
448 fastify.post('/oauth/github/link', {
449 preHandler: fastify.authenticate,
450 }, async (request: FastifyRequest, reply: FastifyReply) => {
451 const user = request.user as { userId: string };
452
453 // Check if GitHub account already linked
454 const existingLink = await db
455 .selectFrom('oauth_accounts')
456 .selectAll()
457 .where('user_id', '=', user.userId)
458 .where('provider', '=', 'github')
459 .executeTakeFirst();
460
461 if (existingLink) {
462 throw fastify.httpErrors.conflict('GitHub account already linked to this user');
463 }
464
465 // Generate state for OAuth flow
466 const state = `link_${user.userId}_${randomUUID()}`;
467
468 return fastify.githubOAuth2.generateAuthorizationUri(request, reply, {
469 state,
470 });
471 });
472
473 // Unlink GitHub account
474 fastify.delete('/oauth/github/unlink', {
475 preHandler: fastify.authenticate,
476 }, async (request: FastifyRequest) => {
477 const user = request.user as { userId: string };
478
479 // Remove OAuth account
480 await db
481 .deleteFrom('oauth_accounts')
482 .where('user_id', '=', user.userId)
483 .where('provider', '=', 'github')
484 .execute();
485
486 // Clear GitHub info from user
487 await db
488 .updateTable('users')
489 .set({
490 github_id: null,
491 github_username: null,
492 github_avatar_url: null,
493 updated_at: new Date().toISOString(),
494 })
495 .where('id', '=', user.userId)
496 .execute();
497
498 return { success: true, message: 'GitHub account unlinked successfully' };
499 });
500 }