Skip to content

Commit 8102632

Browse files
feat(backend, frontend) google login (#192)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced Google authentication, allowing users to sign in or register with their Google account. - Enhanced the login experience with a dedicated OAuth callback process for secure token generation. - Updated sign-in and sign-up interfaces to include a Google sign-in option. - Added environment configuration for Google OAuth integration. - **Bug Fixes** - Improved error handling for OAuth authentication failures and missing tokens. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 9413a79 commit 8102632

File tree

12 files changed

+1153
-817
lines changed

12 files changed

+1153
-817
lines changed

backend/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,7 @@ DB_USERNAME=
5050
DB_NAME=
5151
DB_REGION=us-east-2
5252

53+
## Google OAuth
54+
GOOGLE_CLIENT_ID=YOUR_CLIENT_ID
55+
GOOGLE_SECRET=Your_SECRET
56+
GOOGLE_CALLBACK_URL=http://localhost:8080/auth/google/callback

backend/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@nestjs/core": "^10.0.0",
4040
"@nestjs/graphql": "^12.2.0",
4141
"@nestjs/jwt": "^10.2.0",
42+
"@nestjs/passport": "^11.0.5",
4243
"@nestjs/platform-express": "^10.0.0",
4344
"@nestjs/typeorm": "^10.0.2",
4445
"@octokit/auth-app": "^7.1.5",
@@ -70,6 +71,8 @@
7071
"octokit": "^4.1.2",
7172
"openai": "^4.77.0",
7273
"p-queue-es5": "^6.0.2",
74+
"passport": "^0.7.0",
75+
"passport-google-oauth20": "^2.0.0",
7376
"pg": "^8.14.1",
7477
"reflect-metadata": "^0.2.2",
7578
"rxjs": "^7.8.1",

backend/src/auth/auth.module.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { AuthResolver } from './auth.resolver';
1010
import { RefreshToken } from './refresh-token/refresh-token.model';
1111
import { JwtCacheModule } from 'src/jwt-cache/jwt-cache.module';
1212
import { MailModule } from 'src/mail/mail.module';
13+
import { GoogleStrategy } from './oauth/GoogleStrategy';
14+
import { GoogleController } from './google.controller';
1315
import { AppConfigModule } from 'src/config/config.module';
1416

1517
@Module({
@@ -27,7 +29,8 @@ import { AppConfigModule } from 'src/config/config.module';
2729
JwtCacheModule,
2830
MailModule,
2931
],
30-
providers: [AuthService, AuthResolver],
32+
controllers: [GoogleController],
33+
providers: [AuthService, AuthResolver, GoogleStrategy],
3134
exports: [AuthService, JwtModule],
3235
})
3336
export class AuthModule {}

backend/src/auth/auth.service.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,4 +534,85 @@ export class AuthService {
534534
refreshToken: refreshToken,
535535
};
536536
}
537+
538+
/**
539+
* Handles the Google OAuth callback: find or create the user, then issue JWT(s).
540+
* @param googleProfile The user object attached by the GoogleStrategy validate() method.
541+
* @returns an object containing accessToken & refreshToken (if you use refresh tokens).
542+
*/
543+
async handleGoogleCallback(googleProfile: {
544+
googleId: string;
545+
email: string;
546+
firstName?: string;
547+
lastName?: string;
548+
}): Promise<{ accessToken: string; refreshToken?: string }> {
549+
Logger.log(`handle Google Callback for email: ${googleProfile.email}`);
550+
551+
// First, try to find user by googleId
552+
let user = await this.userRepository.findOne({
553+
where: { googleId: googleProfile.googleId },
554+
});
555+
556+
if (!user) {
557+
// If not found by googleId, try to find by email
558+
user = await this.userRepository.findOne({
559+
where: { email: googleProfile.email },
560+
});
561+
562+
if (user) {
563+
// If found by email but not googleId, update the user with googleId
564+
Logger.log(
565+
`Linking existing email account to Google: ${googleProfile.email}`,
566+
);
567+
user.googleId = googleProfile.googleId;
568+
user.isEmailConfirmed = true; // Ensure email is confirmed since Google verifies emails
569+
570+
// Update name if it wasn't set before
571+
if (!user.username || user.username === user.email.split('@')[0]) {
572+
const fullName = [googleProfile.firstName, googleProfile.lastName]
573+
.filter(Boolean)
574+
.join(' ');
575+
if (fullName) {
576+
user.username = fullName;
577+
}
578+
}
579+
580+
user = await this.userRepository.save(user);
581+
} else {
582+
// If user not found at all, create a new one
583+
Logger.log(
584+
`Creating new user from Google account: ${googleProfile.email}`,
585+
);
586+
const fullName = [googleProfile.firstName, googleProfile.lastName]
587+
.filter(Boolean)
588+
.join(' ');
589+
590+
user = this.userRepository.create({
591+
googleId: googleProfile.googleId,
592+
email: googleProfile.email,
593+
username: fullName || googleProfile.email.split('@')[0],
594+
isEmailConfirmed: true, // Google has already verified the email
595+
password: null, // OAuth users don't need a password
596+
});
597+
598+
user = await this.userRepository.save(user);
599+
}
600+
}
601+
602+
// Generate tokens
603+
const accessToken = this.jwtService.sign(
604+
{ userId: user.id, email: user.email },
605+
{ expiresIn: '30m' },
606+
);
607+
608+
const refreshTokenEntity = await this.createRefreshToken(user);
609+
this.jwtCacheService.storeAccessToken(accessToken);
610+
611+
const refreshToken = refreshTokenEntity.token;
612+
613+
return {
614+
accessToken,
615+
refreshToken,
616+
};
617+
}
537618
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Controller, Get, Logger, Req, Res, UseGuards } from '@nestjs/common';
2+
import { AuthGuard } from '@nestjs/passport';
3+
import { ConfigService } from '@nestjs/config';
4+
import { AuthService } from './auth.service';
5+
6+
@Controller('auth')
7+
export class GoogleController {
8+
constructor(
9+
private configService: ConfigService,
10+
private authService: AuthService,
11+
) {}
12+
13+
@Get('google')
14+
@UseGuards(AuthGuard('google'))
15+
async googleAuth() {
16+
// This route initiates the Google OAuth flow
17+
// The guard redirects to Google
18+
}
19+
20+
@Get('google/callback')
21+
@UseGuards(AuthGuard('google'))
22+
async googleAuthCallback(@Req() req, @Res() res) {
23+
Logger.log('Google callback');
24+
const googleProfile = req.user as {
25+
googleId: string;
26+
email: string;
27+
firstName?: string;
28+
lastName?: string;
29+
};
30+
31+
// Call the AuthService method
32+
const { accessToken, refreshToken } =
33+
await this.authService.handleGoogleCallback(googleProfile);
34+
35+
const frontendUrl =
36+
this.configService.get<string>('FRONTEND_URL') || 'http://localhost:3000';
37+
38+
// TO DO IS UNSAFE
39+
// Redirect to frontend, pass tokens in query params
40+
return res.redirect(
41+
`${frontendUrl}/auth/oauth-callback?accessToken=${accessToken}&refreshToken=${refreshToken}`,
42+
);
43+
}
44+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { PassportStrategy } from '@nestjs/passport';
3+
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
4+
import { ConfigService } from '@nestjs/config';
5+
import { AuthService } from '../auth.service';
6+
7+
@Injectable()
8+
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
9+
constructor(
10+
private configService: ConfigService,
11+
private authService: AuthService,
12+
) {
13+
super({
14+
clientID:
15+
configService.get<string>('GOOGLE_CLIENT_ID') ||
16+
'Just_a_placeholder_GOOGLE_CLIENT_ID',
17+
clientSecret:
18+
configService.get<string>('GOOGLE_SECRET') ||
19+
'Just_a_placeholder_GOOGLE_SECRET',
20+
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL'),
21+
scope: ['email', 'profile'],
22+
prompt: 'select_account',
23+
});
24+
}
25+
26+
async validate(
27+
accessToken: string,
28+
refreshToken: string,
29+
profile: any,
30+
done: VerifyCallback,
31+
): Promise<any> {
32+
const { name, emails, photos, id } = profile;
33+
Logger.log(`Google profile ID: ${id}`);
34+
35+
const user = {
36+
id: id, // Include the profile ID
37+
googleId: id, // Also map to googleId
38+
email: emails[0].value,
39+
firstName: name.givenName,
40+
lastName: name.familyName,
41+
picture: photos[0].value,
42+
accessToken,
43+
refreshToken,
44+
};
45+
46+
done(null, user);
47+
}
48+
}

backend/src/user/user.model.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,14 @@ export class User extends SystemBaseModel {
2121
@PrimaryGeneratedColumn('uuid')
2222
id: string;
2323

24+
@Column({ nullable: true })
25+
googleId: string;
26+
2427
@Field()
2528
@Column()
2629
username: string;
2730

28-
@Column()
31+
@Column({ nullable: true }) // Made nullable for OAuth users
2932
password: string;
3033

3134
@Field({ nullable: true })

frontend/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
NEXT_PUBLIC_GRAPHQL_URL=http://localhost:8080/graphql
2+
NEXT_PUBLIC_BACKEND_GOOGLE_OAUTH=http://localhost:8080/auth/google
23
NEXT_PUBLIC_BACKEND_URL=http://localhost:8080
34

45
# TLS OPTION for HTTPS
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
import { useRouter, useSearchParams } from 'next/navigation';
5+
import { useAuthContext } from '@/providers/AuthProvider';
6+
import { toast } from 'sonner';
7+
8+
export default function OAuthCallbackPage() {
9+
const router = useRouter();
10+
const searchParams = useSearchParams();
11+
const { login } = useAuthContext();
12+
13+
useEffect(() => {
14+
const handleAuth = async () => {
15+
try {
16+
const accessToken = searchParams.get('accessToken');
17+
const refreshToken = searchParams.get('refreshToken');
18+
const error = searchParams.get('error');
19+
20+
// Handle error cases
21+
if (error) {
22+
console.error('Authentication error:', error);
23+
toast.error('Authentication failed');
24+
router.push('/login');
25+
return;
26+
}
27+
28+
// Check if tokens exist
29+
if (!accessToken || !refreshToken) {
30+
console.error('Missing tokens in callback');
31+
toast.error('Authentication failed: Missing tokens');
32+
router.push('/login');
33+
return;
34+
}
35+
36+
// Store tokens using the context
37+
login(accessToken, refreshToken);
38+
39+
// Show success message
40+
toast.success('Logged in successfully!');
41+
42+
// Redirect to home or dashboard
43+
router.push('/');
44+
} catch (error) {
45+
console.error('Error processing authentication:', error);
46+
toast.error('Authentication processing failed');
47+
router.push('/login');
48+
}
49+
};
50+
51+
handleAuth();
52+
}, [searchParams, login, router]);
53+
54+
return (
55+
<div className="flex h-screen w-full items-center justify-center">
56+
<div className="text-center p-8 max-w-md rounded-xl bg-white dark:bg-zinc-900 shadow-lg">
57+
<h1 className="text-2xl font-bold mb-4">
58+
Completing authentication...
59+
</h1>
60+
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
61+
Please wait while we sign you in.
62+
</p>
63+
<div className="flex justify-center">
64+
<div className="w-8 h-8 border-4 border-t-primary rounded-full animate-spin"></div>
65+
</div>
66+
</div>
67+
</div>
68+
);
69+
}

frontend/src/components/sign-in-modal.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,12 @@ export function SignInModal({ isOpen, onClose }: SignInModalProps) {
154154
<Button
155155
variant="outline"
156156
className="flex items-center gap-2 w-full"
157+
onClick={() => {
158+
// Redirect to your NestJS backend's Google OAuth endpoint
159+
window.location.href =
160+
process.env.NEXT_PUBLIC_BACKEND_GOOGLE_OAUTH ||
161+
'http://localhost:8080/auth/google';
162+
}}
157163
>
158164
<img
159165
src="/images/google.svg"

0 commit comments

Comments
 (0)