feat: implement Google OAuth authentication

- Add Google OAuth 2.0 login flow with passport-google-oauth20
- Create User and RefreshToken entities for session management
- Implement JWT access tokens (15min) + HttpOnly refresh cookies (7 days)
- Add auth endpoints: /google, /google/callback, /refresh, /me, /logout
- Create LoginPage with Google sign-in button (shadcn/ui)
- Add AuthGuard for protected routes with redirect preservation
- Implement silent token refresh on app mount
- Add UserMenu component with avatar and sign-out

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 13:05:18 -03:00
parent fe2c861007
commit 130f35c4f8
32 changed files with 2477 additions and 98 deletions

View File

@@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThumbnailsModule } from './modules/thumbnails/thumbnails.module';
import { YouTubeModule } from './modules/youtube/youtube.module';
import { AuthModule } from './modules/auth/auth.module';
@Module({
imports: [
@@ -25,6 +26,7 @@ import { YouTubeModule } from './modules/youtube/youtube.module';
}),
ThumbnailsModule,
YouTubeModule,
AuthModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,36 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity';
@Entity('refresh_tokens')
export class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
userId: string;
@ManyToOne(() => User, (user) => user.refreshTokens, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@Column({ unique: true })
@Index('idx_refresh_token_token')
token: string;
@Column({ type: 'timestamp' })
expiresAt: Date;
@CreateDateColumn()
createdAt: Date;
@Column({ type: 'timestamp', nullable: true })
revokedAt: Date;
}

View File

@@ -0,0 +1,38 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
OneToMany,
} from 'typeorm';
import { RefreshToken } from './refresh-token.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
@Index('idx_user_google_id')
googleId: string;
@Column({ unique: true })
@Index('idx_user_email')
email: string;
@Column()
displayName: string;
@Column({ nullable: true })
avatarUrl: string;
@CreateDateColumn()
createdAt: Date;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
lastLoginAt: Date;
@OneToMany(() => RefreshToken, (refreshToken) => refreshToken.user)
refreshTokens: RefreshToken[];
}

View File

@@ -3,10 +3,14 @@ import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { join } from 'path';
import { NestExpressApplication } from '@nestjs/platform-express';
import * as cookieParser from 'cookie-parser';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Cookie parser for refresh tokens
app.use(cookieParser());
// Global prefix
app.setGlobalPrefix('api');
@@ -19,8 +23,9 @@ async function bootstrap() {
);
// CORS
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
app.enableCors({
origin: ['http://localhost:3000', 'http://localhost:5173'],
origin: [frontendUrl, 'http://localhost:3000', 'http://localhost:5173'],
credentials: true,
});

View File

@@ -0,0 +1,114 @@
import {
Controller,
Get,
Post,
UseGuards,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { GoogleAuthGuard } from './guards/google-auth.guard';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { User } from '../../entities/user.entity';
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly configService: ConfigService,
) {}
@Get('google')
@UseGuards(GoogleAuthGuard)
googleAuth() {
// Guard redirects to Google
}
@Get('google/callback')
@UseGuards(GoogleAuthGuard)
async googleCallback(@Req() req: Request, @Res() res: Response) {
try {
const user = req.user as User;
const { accessToken, refreshToken } =
await this.authService.generateTokens(user);
// Set HttpOnly cookie for refresh token
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: this.configService.get('NODE_ENV') === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
// Redirect to frontend with access token
const frontendUrl = this.configService.get<string>(
'FRONTEND_URL',
'http://localhost:3000',
);
res.redirect(`${frontendUrl}/auth/callback?token=${accessToken}`);
} catch {
const frontendUrl = this.configService.get<string>(
'FRONTEND_URL',
'http://localhost:3000',
);
res.redirect(`${frontendUrl}/login?error=auth_failed`);
}
}
@Post('refresh')
async refresh(@Req() req: Request, @Res() res: Response) {
const refreshToken = (req.cookies as Record<string, string>)?.refreshToken;
if (!refreshToken) {
throw new UnauthorizedException('No refresh token provided');
}
const result = await this.authService.refreshTokens(refreshToken);
// Set new HttpOnly cookie for refresh token
res.cookie('refreshToken', result.refreshToken, {
httpOnly: true,
secure: this.configService.get('NODE_ENV') === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
return res.json({
accessToken: result.accessToken,
user: result.user,
});
}
@Get('me')
@UseGuards(JwtAuthGuard)
getMe(@Req() req: Request) {
const user = req.user as User;
return {
id: user.id,
email: user.email,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
};
}
@Post('logout')
@UseGuards(JwtAuthGuard)
async logout(@Req() req: Request, @Res() res: Response) {
const refreshToken = (req.cookies as Record<string, string>)?.refreshToken;
if (refreshToken) {
await this.authService.revokeRefreshToken(refreshToken);
}
res.clearCookie('refreshToken', {
httpOnly: true,
secure: this.configService.get('NODE_ENV') === 'production',
sameSite: 'strict',
});
return res.json({ message: 'Successfully logged out' });
}
}

View File

@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy';
import { JwtStrategy } from './jwt.strategy';
import { User } from '../../entities/user.entity';
import { RefreshToken } from '../../entities/refresh-token.entity';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService): JwtModuleOptions => {
return {
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: '15m',
},
};
},
inject: [ConfigService],
}),
TypeOrmModule.forFeature([User, RefreshToken]),
],
controllers: [AuthController],
providers: [AuthService, GoogleStrategy, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,158 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, MoreThan } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from '../../entities/user.entity';
import { RefreshToken } from '../../entities/refresh-token.entity';
interface GoogleProfile {
id: string;
emails: Array<{ value: string; verified: boolean }>;
displayName: string;
photos: Array<{ value: string }>;
}
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(RefreshToken)
private readonly refreshTokenRepository: Repository<RefreshToken>,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async validateOAuthUser(profile: GoogleProfile): Promise<User> {
const email = profile.emails?.[0]?.value;
const googleId = profile.id;
const displayName = profile.displayName;
const avatarUrl = profile.photos?.[0]?.value;
if (!email || !googleId) {
throw new UnauthorizedException('Invalid Google profile');
}
let user = await this.userRepository.findOne({
where: { googleId },
});
if (user) {
// Update last login and profile info
user.lastLoginAt = new Date();
user.displayName = displayName;
user.avatarUrl = avatarUrl;
await this.userRepository.save(user);
} else {
// Create new user
user = this.userRepository.create({
googleId,
email,
displayName,
avatarUrl,
lastLoginAt: new Date(),
});
await this.userRepository.save(user);
}
return user;
}
async generateTokens(
user: User,
): Promise<{ accessToken: string; refreshToken: string }> {
// Generate access token
const payload = { sub: user.id, email: user.email };
const accessToken = this.jwtService.sign(payload);
// Generate refresh token
const refreshTokenValue = this.generateRandomToken();
const hashedToken = await bcrypt.hash(refreshTokenValue, 10);
// Calculate expiration (7 days)
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
// Save refresh token to database
const refreshTokenEntity = this.refreshTokenRepository.create({
userId: user.id,
token: hashedToken,
expiresAt,
});
await this.refreshTokenRepository.save(refreshTokenEntity);
return {
accessToken,
refreshToken: `${refreshTokenEntity.id}:${refreshTokenValue}`,
};
}
async refreshTokens(
token: string,
): Promise<{ accessToken: string; refreshToken: string; user: User }> {
const [tokenId, tokenValue] = token.split(':');
if (!tokenId || !tokenValue) {
throw new UnauthorizedException('Invalid refresh token format');
}
const storedToken = await this.refreshTokenRepository.findOne({
where: {
id: tokenId,
revokedAt: IsNull(),
expiresAt: MoreThan(new Date()),
},
relations: ['user'],
});
if (!storedToken) {
throw new UnauthorizedException('Invalid or expired refresh token');
}
const isValid = await bcrypt.compare(tokenValue, storedToken.token);
if (!isValid) {
throw new UnauthorizedException('Invalid refresh token');
}
// Revoke old token (rotation)
await this.revokeRefreshToken(token);
// Generate new tokens
const { accessToken, refreshToken } = await this.generateTokens(
storedToken.user,
);
return {
accessToken,
refreshToken,
user: storedToken.user,
};
}
async revokeRefreshToken(token: string): Promise<void> {
const [tokenId] = token.split(':');
if (tokenId) {
await this.refreshTokenRepository.update(
{ id: tokenId },
{ revokedAt: new Date() },
);
}
}
async validateUserById(userId: string): Promise<User | null> {
return this.userRepository.findOne({ where: { id: userId } });
}
private generateRandomToken(): string {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 64; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
}

View File

@@ -0,0 +1,28 @@
import { IsString, IsUUID, IsEmail, IsOptional, IsUrl } from 'class-validator';
export class UserResponseDto {
@IsUUID()
id: string;
@IsEmail()
email: string;
@IsString()
displayName: string;
@IsOptional()
@IsUrl()
avatarUrl?: string;
}
export class AuthResponseDto {
@IsString()
accessToken: string;
user: UserResponseDto;
}
export class MessageResponseDto {
@IsString()
message: string;
}

View File

@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback, Profile, StrategyOptions } from 'passport-google-oauth20';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
const options: StrategyOptions = {
clientID: configService.get<string>('GOOGLE_CLIENT_ID') || '',
clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET') || '',
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL') || '',
scope: ['email', 'profile'],
};
super(options);
}
async validate(
_accessToken: string,
_refreshToken: string,
profile: Profile,
done: VerifyCallback,
): Promise<void> {
try {
const user = await this.authService.validateOAuthUser({
id: profile.id,
emails: profile.emails as Array<{ value: string; verified: boolean }>,
displayName: profile.displayName,
photos: profile.photos as Array<{ value: string }>,
});
done(null, user);
} catch (error) {
done(error as Error, undefined);
}
}
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class GoogleAuthGuard extends AuthGuard('google') {}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@@ -0,0 +1,41 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import {
Strategy,
ExtractJwt,
StrategyOptionsWithoutRequest,
} from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
interface JwtPayload {
sub: string;
email: string;
iat: number;
exp: number;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
const options: StrategyOptionsWithoutRequest = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET') || 'fallback-secret',
};
super(options);
}
async validate(payload: JwtPayload) {
const user = await this.authService.validateUserById(payload.sub);
if (!user) {
throw new UnauthorizedException('User not found');
}
return user;
}
}