diff --git a/backend/.env.example b/backend/.env.example index c9d59cd3..59b8b09e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -50,3 +50,7 @@ DB_USERNAME= DB_NAME= DB_REGION=us-east-2 +## Google OAuth +GOOGLE_CLIENT_ID=YOUR_CLIENT_ID +GOOGLE_SECRET=Your_SECRET +GOOGLE_CALLBACK_URL=http://localhost:8080/auth/google/callback \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index dcceddfe..084cb2d7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -39,6 +39,7 @@ "@nestjs/core": "^10.0.0", "@nestjs/graphql": "^12.2.0", "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", "@nestjs/typeorm": "^10.0.2", "@octokit/auth-app": "^7.1.5", @@ -70,6 +71,8 @@ "octokit": "^4.1.2", "openai": "^4.77.0", "p-queue-es5": "^6.0.2", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "pg": "^8.14.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index ab1b5753..1c37afdd 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -10,6 +10,8 @@ import { AuthResolver } from './auth.resolver'; import { RefreshToken } from './refresh-token/refresh-token.model'; import { JwtCacheModule } from 'src/jwt-cache/jwt-cache.module'; import { MailModule } from 'src/mail/mail.module'; +import { GoogleStrategy } from './oauth/GoogleStrategy'; +import { GoogleController } from './google.controller'; import { AppConfigModule } from 'src/config/config.module'; @Module({ @@ -27,7 +29,8 @@ import { AppConfigModule } from 'src/config/config.module'; JwtCacheModule, MailModule, ], - providers: [AuthService, AuthResolver], + controllers: [GoogleController], + providers: [AuthService, AuthResolver, GoogleStrategy], exports: [AuthService, JwtModule], }) export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 95b18d85..cf68b586 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -534,4 +534,85 @@ export class AuthService { refreshToken: refreshToken, }; } + + /** + * Handles the Google OAuth callback: find or create the user, then issue JWT(s). + * @param googleProfile The user object attached by the GoogleStrategy validate() method. + * @returns an object containing accessToken & refreshToken (if you use refresh tokens). + */ + async handleGoogleCallback(googleProfile: { + googleId: string; + email: string; + firstName?: string; + lastName?: string; + }): Promise<{ accessToken: string; refreshToken?: string }> { + Logger.log(`handle Google Callback for email: ${googleProfile.email}`); + + // First, try to find user by googleId + let user = await this.userRepository.findOne({ + where: { googleId: googleProfile.googleId }, + }); + + if (!user) { + // If not found by googleId, try to find by email + user = await this.userRepository.findOne({ + where: { email: googleProfile.email }, + }); + + if (user) { + // If found by email but not googleId, update the user with googleId + Logger.log( + `Linking existing email account to Google: ${googleProfile.email}`, + ); + user.googleId = googleProfile.googleId; + user.isEmailConfirmed = true; // Ensure email is confirmed since Google verifies emails + + // Update name if it wasn't set before + if (!user.username || user.username === user.email.split('@')[0]) { + const fullName = [googleProfile.firstName, googleProfile.lastName] + .filter(Boolean) + .join(' '); + if (fullName) { + user.username = fullName; + } + } + + user = await this.userRepository.save(user); + } else { + // If user not found at all, create a new one + Logger.log( + `Creating new user from Google account: ${googleProfile.email}`, + ); + const fullName = [googleProfile.firstName, googleProfile.lastName] + .filter(Boolean) + .join(' '); + + user = this.userRepository.create({ + googleId: googleProfile.googleId, + email: googleProfile.email, + username: fullName || googleProfile.email.split('@')[0], + isEmailConfirmed: true, // Google has already verified the email + password: null, // OAuth users don't need a password + }); + + user = await this.userRepository.save(user); + } + } + + // Generate tokens + const accessToken = this.jwtService.sign( + { userId: user.id, email: user.email }, + { expiresIn: '30m' }, + ); + + const refreshTokenEntity = await this.createRefreshToken(user); + this.jwtCacheService.storeAccessToken(accessToken); + + const refreshToken = refreshTokenEntity.token; + + return { + accessToken, + refreshToken, + }; + } } diff --git a/backend/src/auth/google.controller.ts b/backend/src/auth/google.controller.ts new file mode 100644 index 00000000..30c3deca --- /dev/null +++ b/backend/src/auth/google.controller.ts @@ -0,0 +1,44 @@ +import { Controller, Get, Logger, Req, Res, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ConfigService } from '@nestjs/config'; +import { AuthService } from './auth.service'; + +@Controller('auth') +export class GoogleController { + constructor( + private configService: ConfigService, + private authService: AuthService, + ) {} + + @Get('google') + @UseGuards(AuthGuard('google')) + async googleAuth() { + // This route initiates the Google OAuth flow + // The guard redirects to Google + } + + @Get('google/callback') + @UseGuards(AuthGuard('google')) + async googleAuthCallback(@Req() req, @Res() res) { + Logger.log('Google callback'); + const googleProfile = req.user as { + googleId: string; + email: string; + firstName?: string; + lastName?: string; + }; + + // Call the AuthService method + const { accessToken, refreshToken } = + await this.authService.handleGoogleCallback(googleProfile); + + const frontendUrl = + this.configService.get('FRONTEND_URL') || 'http://localhost:3000'; + + // TO DO IS UNSAFE + // Redirect to frontend, pass tokens in query params + return res.redirect( + `${frontendUrl}/auth/oauth-callback?accessToken=${accessToken}&refreshToken=${refreshToken}`, + ); + } +} diff --git a/backend/src/auth/oauth/GoogleStrategy.ts b/backend/src/auth/oauth/GoogleStrategy.ts new file mode 100644 index 00000000..9c9f9181 --- /dev/null +++ b/backend/src/auth/oauth/GoogleStrategy.ts @@ -0,0 +1,48 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, VerifyCallback } from 'passport-google-oauth20'; +import { ConfigService } from '@nestjs/config'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { + constructor( + private configService: ConfigService, + private authService: AuthService, + ) { + super({ + clientID: + configService.get('GOOGLE_CLIENT_ID') || + 'Just_a_placeholder_GOOGLE_CLIENT_ID', + clientSecret: + configService.get('GOOGLE_SECRET') || + 'Just_a_placeholder_GOOGLE_SECRET', + callbackURL: configService.get('GOOGLE_CALLBACK_URL'), + scope: ['email', 'profile'], + prompt: 'select_account', + }); + } + + async validate( + accessToken: string, + refreshToken: string, + profile: any, + done: VerifyCallback, + ): Promise { + const { name, emails, photos, id } = profile; + Logger.log(`Google profile ID: ${id}`); + + const user = { + id: id, // Include the profile ID + googleId: id, // Also map to googleId + email: emails[0].value, + firstName: name.givenName, + lastName: name.familyName, + picture: photos[0].value, + accessToken, + refreshToken, + }; + + done(null, user); + } +} diff --git a/backend/src/user/user.model.ts b/backend/src/user/user.model.ts index 064a955c..8267f03e 100644 --- a/backend/src/user/user.model.ts +++ b/backend/src/user/user.model.ts @@ -21,11 +21,14 @@ export class User extends SystemBaseModel { @PrimaryGeneratedColumn('uuid') id: string; + @Column({ nullable: true }) + googleId: string; + @Field() @Column() username: string; - @Column() + @Column({ nullable: true }) // Made nullable for OAuth users password: string; @Field({ nullable: true }) diff --git a/frontend/.env.example b/frontend/.env.example index b9fc9679..86bea10f 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,4 +1,5 @@ NEXT_PUBLIC_GRAPHQL_URL=http://localhost:8080/graphql +NEXT_PUBLIC_BACKEND_GOOGLE_OAUTH=http://localhost:8080/auth/google NEXT_PUBLIC_BACKEND_URL=http://localhost:8080 # TLS OPTION for HTTPS diff --git a/frontend/src/app/auth/oauth-callback/page.tsx b/frontend/src/app/auth/oauth-callback/page.tsx new file mode 100644 index 00000000..3b47cbc1 --- /dev/null +++ b/frontend/src/app/auth/oauth-callback/page.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useAuthContext } from '@/providers/AuthProvider'; +import { toast } from 'sonner'; + +export default function OAuthCallbackPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { login } = useAuthContext(); + + useEffect(() => { + const handleAuth = async () => { + try { + const accessToken = searchParams.get('accessToken'); + const refreshToken = searchParams.get('refreshToken'); + const error = searchParams.get('error'); + + // Handle error cases + if (error) { + console.error('Authentication error:', error); + toast.error('Authentication failed'); + router.push('/login'); + return; + } + + // Check if tokens exist + if (!accessToken || !refreshToken) { + console.error('Missing tokens in callback'); + toast.error('Authentication failed: Missing tokens'); + router.push('/login'); + return; + } + + // Store tokens using the context + login(accessToken, refreshToken); + + // Show success message + toast.success('Logged in successfully!'); + + // Redirect to home or dashboard + router.push('/'); + } catch (error) { + console.error('Error processing authentication:', error); + toast.error('Authentication processing failed'); + router.push('/login'); + } + }; + + handleAuth(); + }, [searchParams, login, router]); + + return ( +
+
+

+ Completing authentication... +

+

+ Please wait while we sign you in. +

+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/sign-in-modal.tsx b/frontend/src/components/sign-in-modal.tsx index b9777f80..680ec4cd 100644 --- a/frontend/src/components/sign-in-modal.tsx +++ b/frontend/src/components/sign-in-modal.tsx @@ -154,6 +154,12 @@ export function SignInModal({ isOpen, onClose }: SignInModalProps) {