Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 {}
81 changes: 81 additions & 0 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
message: 'Email already confirmed or user not found.',
success: false,
};
} catch (error) {

Check warning on line 80 in backend/src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / autofix

'error' is defined but never used
return {
message: 'Invalid or expired token',
success: false,
Expand Down Expand Up @@ -261,7 +261,7 @@
}

return true;
} catch (error) {

Check warning on line 264 in backend/src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / autofix

'error' is defined but never used
return false;
}
}
Expand Down Expand Up @@ -534,4 +534,85 @@
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,
};
}
}
44 changes: 44 additions & 0 deletions backend/src/auth/google.controller.ts
Original file line number Diff line number Diff line change
@@ -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<string>('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}`,
);
}
Comment on lines +38 to +43
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Avoid passing tokens in the query string to prevent potential token leakage.

Embedding tokens in the URL query parameters can lead to security issues (e.g., tokens in logs, browser history, and referrer headers). As an alternative, you could set an HTTP-only cookie or use a secure POST form submission. For instance:

- return res.redirect(
-   `${frontendUrl}/auth/oauth-callback?accessToken=${accessToken}&refreshToken=${refreshToken}`
- );
+ res.cookie('accessToken', accessToken, {
+   httpOnly: true,
+   secure: true,
+   sameSite: 'strict',
+ });
+ res.cookie('refreshToken', refreshToken, {
+   httpOnly: true,
+   secure: true,
+   sameSite: 'strict',
+ });
+ return res.redirect(`${frontendUrl}/auth/oauth-callback`);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// TO DO IS UNSAFE
// Redirect to frontend, pass tokens in query params
return res.redirect(
`${frontendUrl}/auth/oauth-callback?accessToken=${accessToken}&refreshToken=${refreshToken}`,
);
}
// TO DO IS UNSAFE
// Redirect to frontend, pass tokens in query params
res.cookie('accessToken', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
return res.redirect(`${frontendUrl}/auth/oauth-callback`);
}

}
48 changes: 48 additions & 0 deletions backend/src/auth/oauth/GoogleStrategy.ts
Original file line number Diff line number Diff line change
@@ -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<string>('GOOGLE_CLIENT_ID') ||
'Just_a_placeholder_GOOGLE_CLIENT_ID',
clientSecret:
configService.get<string>('GOOGLE_SECRET') ||
'Just_a_placeholder_GOOGLE_SECRET',
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL'),
scope: ['email', 'profile'],
prompt: 'select_account',
});
Comment on lines +14 to +23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Remove placeholder secrets in production code

Using placeholder values for sensitive credentials is risky and could lead to security issues if deployed to production. Instead of hardcoded fallbacks:

  1. Fail fast by throwing clear configuration errors
  2. Use environment validation at application startup
- clientID:
-   configService.get<string>('GOOGLE_CLIENT_ID') ||
-   'Just_a_placeholder_GOOGLE_CLIENT_ID',
- clientSecret:
-   configService.get<string>('GOOGLE_SECRET') ||
-   'Just_a_placeholder_GOOGLE_SECRET',
+ clientID: configService.get<string>('GOOGLE_CLIENT_ID'),
+ clientSecret: configService.get<string>('GOOGLE_SECRET'),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
clientID:
configService.get<string>('GOOGLE_CLIENT_ID') ||
'Just_a_placeholder_GOOGLE_CLIENT_ID',
clientSecret:
configService.get<string>('GOOGLE_SECRET') ||
'Just_a_placeholder_GOOGLE_SECRET',
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL'),
scope: ['email', 'profile'],
prompt: 'select_account',
});
clientID: configService.get<string>('GOOGLE_CLIENT_ID'),
clientSecret: configService.get<string>('GOOGLE_SECRET'),
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL'),
scope: ['email', 'profile'],
prompt: 'select_account',
});

}

async validate(
accessToken: string,
refreshToken: string,
profile: any,
done: VerifyCallback,
): Promise<any> {
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);
}
Comment on lines +26 to +47
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error handling and type safety to the validate method

The validate method has several potential improvements:

  1. Use proper TypeScript types for the Google profile
  2. Add error handling for missing profile data
  3. Consider privacy implications of logging Google profile IDs
- async validate(
-   accessToken: string,
-   refreshToken: string,
-   profile: any,
-   done: VerifyCallback,
- ): Promise<any> {
+ async validate(
+   accessToken: string,
+   refreshToken: string,
+   profile: Profile,
+   done: VerifyCallback,
+ ): Promise<void> {
    const { name, emails, photos, id } = profile;
-   Logger.log(`Google profile ID: ${id}`);
+   Logger.debug('Google authentication successful');

+   if (!emails || emails.length === 0) {
+     return done(new Error('No email provided from Google'), null);
+   }

    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,
+     firstName: name?.givenName || '',
+     lastName: name?.familyName || '',
+     picture: photos && photos.length > 0 ? photos[0].value : null,
      accessToken,
      refreshToken,
    };

    done(null, user);
  }

Don't forget to add the Profile type import:

import { Profile, Strategy, VerifyCallback } from 'passport-google-oauth20';

}
5 changes: 4 additions & 1 deletion backend/src/user/user.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +24 to 32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify database migration for schema changes

These model changes will require a database migration. Ensure you've created a migration file to update the schema for existing databases.


🏁 Script executed:

#!/bin/bash
# Check if there are migration files related to this change
fd -e ts -d 3 "migration|user.*google" src

Length of output: 135


Action: Verify and Confirm Database Migration Implementation

The changes in backend/src/user/user.model.ts—adding the googleId field and making the password column nullable—require a corresponding database migration. The previous script failed due to an incorrect search path (src is not a directory). Please verify that a migration file addressing these schema changes exists in the correct directory (e.g., within the backend directory).

  • The model changes affecting googleId and password need an accompanying migration for existing databases.

  • Next Step: Manually confirm or run the following shell script to search for migration files in the backend folder:

    #!/bin/bash
    # Search for migration files in the 'backend' directory
    fd -e ts "migration" backend

Once you confirm that an appropriate migration file is present (or create one if missing), please update the migration as necessary so that the database schema matches these model changes.


Action Required: Verify Database Migration for User Model Changes

The model change in backend/src/user/user.model.ts—adding the googleId field and marking the password field as nullable—requires a corresponding database migration. Since the initial search script used an invalid path, please run the following shell script to check for migration files in the common directories. If no migration file is found that addresses these schema changes, please create the necessary migration file.

  • Ensure a migration file exists (typically in backend/migrations or database/migrations) to update existing databases.
  • Use the provided script to search for migration files:

🏁 Script executed:

#!/bin/bash
# Search for migration files in common migration directories

echo "Searching for migration files in backend/migrations:"
find backend/migrations -type f -name "*.ts" 2>/dev/null

echo "Searching for migration files in database/migrations:"
find database/migrations -type f -name "*.ts" 2>/dev/null

Length of output: 448


Action: Create the Required Migration File for User Model Schema Changes

It appears that no migration files were found in the common migration directories (i.e., backend/migrations and database/migrations). Since the model changes—adding the googleId field and marking the password field as nullable—affect the database schema, a corresponding migration file is required to update existing databases.

  • User Model Location: backend/src/user/user.model.ts (Lines 24-32)
  • Required Changes:
    • Add migration steps for adding the new googleId column.
    • Update the password column to be nullable.
  • Next Steps:
    • Verify if a migration file exists in a non-standard directory.
    • If not, create a migration file reflecting these model changes.

Please create or update the migration accordingly.


@Field({ nullable: true })
Expand Down
1 change: 1 addition & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
69 changes: 69 additions & 0 deletions frontend/src/app/auth/oauth-callback/page.tsx
Original file line number Diff line number Diff line change
@@ -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');
Comment on lines +16 to +18
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Security risk: Tokens exposed in URL parameters

Passing tokens as URL parameters poses security risks:

  • Tokens can be captured in browser history
  • They may appear in server logs and referrer headers
  • They're visible in bookmarks

Consider using a more secure approach like exchanging a temporary code for tokens.


// 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 (
<div className="flex h-screen w-full items-center justify-center">
<div className="text-center p-8 max-w-md rounded-xl bg-white dark:bg-zinc-900 shadow-lg">
<h1 className="text-2xl font-bold mb-4">
Completing authentication...
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
Please wait while we sign you in.
</p>
<div className="flex justify-center">
<div className="w-8 h-8 border-4 border-t-primary rounded-full animate-spin"></div>
</div>
</div>
</div>
);
}
6 changes: 6 additions & 0 deletions frontend/src/components/sign-in-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ export function SignInModal({ isOpen, onClose }: SignInModalProps) {
<Button
variant="outline"
className="flex items-center gap-2 w-full"
onClick={() => {
// Redirect to your NestJS backend's Google OAuth endpoint
window.location.href =
process.env.NEXT_PUBLIC_BACKEND_GOOGLE_OAUTH ||
'http://localhost:8080/auth/google';
}}
>
<img
src="/images/google.svg"
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/sign-up-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,12 @@ export function SignUpModal({
variant="outline"
className="flex items-center justify-center gap-2 w-full"
type="button"
onClick={() => {
// Redirect to your NestJS backend's Google OAuth endpoint
window.location.href =
process.env.NEXT_PUBLIC_BACKEND_GOOGLE_OAUTH ||
'http://localhost:8080/auth/google';
}}
>
<img
src="/images/google.svg"
Expand Down
Loading
Loading