feat(auth): implement Google and Zalo OAuth backend strategies

Add complete OAuth2 authentication flow for Google and Zalo providers:
- OAuthService: handles account linking (by email/phone), new user
  creation for OAuth-only accounts, and JWT token generation
- GoogleOAuthStrategy: passport-google-oauth20 integration
- ZaloOAuthStrategy: custom OAuth2 implementation using Zalo's API
  (authorization URL generation, code exchange, user info fetch)
- OAuthController: redirect and callback endpoints for both providers
  with httpOnly cookie-based token management
- Unit tests for OAuthService (7 tests), GoogleOAuthStrategy (4 tests),
  and ZaloOAuthStrategy (7 tests)
- OAuth env vars added to .env.example and env-validation warnings

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 14:14:02 +07:00
parent bac3313873
commit 23bb380d34
14 changed files with 1068 additions and 2 deletions

View File

@@ -0,0 +1,179 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type EventBus } from '@nestjs/cqrs';
import { type OAuthProvider, type Prisma } from '@prisma/client';
import { createId } from '@paralleldrive/cuid2';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { UserEntity } from '../../domain/entities/user.entity';
import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository';
import { Email } from '../../domain/value-objects/email.vo';
import { Phone } from '../../domain/value-objects/phone.vo';
import { UserRegisteredEvent } from '../../domain/events/user-registered.event';
import { type TokenService, type TokenPair } from './token.service';
export interface OAuthUserProfile {
provider: OAuthProvider;
providerUserId: string;
email?: string;
phone?: string;
fullName: string;
avatarUrl?: string;
accessToken?: string;
refreshToken?: string;
expiresAt?: Date;
rawProfile?: Record<string, unknown>;
}
@Injectable()
export class OAuthService {
private readonly logger = new Logger(OAuthService.name);
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly tokenService: TokenService,
private readonly prisma: PrismaService,
private readonly eventBus: EventBus,
) {}
/**
* Authenticate via OAuth: find existing linked account, link to existing user,
* or create a new user. Returns JWT token pair.
*/
async authenticateOAuth(profile: OAuthUserProfile): Promise<TokenPair> {
// 1. Check if this OAuth account already exists
const existingOAuth = await this.prisma.oAuthAccount.findUnique({
where: {
provider_providerUserId: {
provider: profile.provider,
providerUserId: profile.providerUserId,
},
},
include: { user: true },
});
if (existingOAuth) {
// Update tokens
await this.prisma.oAuthAccount.update({
where: { id: existingOAuth.id },
data: {
accessToken: profile.accessToken ?? existingOAuth.accessToken,
refreshToken: profile.refreshToken ?? existingOAuth.refreshToken,
expiresAt: profile.expiresAt ?? existingOAuth.expiresAt,
profile: (profile.rawProfile ?? existingOAuth.profile) as Prisma.InputJsonValue,
},
});
if (!existingOAuth.user.isActive) {
throw new Error('Tài khoản đã bị vô hiệu hóa');
}
this.logger.log(`OAuth login: existing account for ${profile.provider}/${profile.providerUserId}`);
return this.generateTokensForUser(existingOAuth.user);
}
// 2. Try to link to existing user by email
if (profile.email) {
const existingUser = await this.userRepo.findByEmail(profile.email);
if (existingUser) {
if (!existingUser.isActive) {
throw new Error('Tài khoản đã bị vô hiệu hóa');
}
await this.createOAuthAccount(existingUser.id, profile);
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by email`);
return this.generateTokensForUser({
id: existingUser.id,
phone: existingUser.phone.value,
role: existingUser.role,
});
}
}
// 3. Try to link to existing user by phone
if (profile.phone) {
const phoneVo = Phone.create(profile.phone);
if (phoneVo.isOk) {
const existingUser = await this.userRepo.findByPhone(phoneVo.unwrap().value);
if (existingUser) {
if (!existingUser.isActive) {
throw new Error('Tài khoản đã bị vô hiệu hóa');
}
await this.createOAuthAccount(existingUser.id, profile);
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by phone`);
return this.generateTokensForUser({
id: existingUser.id,
phone: existingUser.phone.value,
role: existingUser.role,
});
}
}
}
// 4. Create new user + OAuth account
const userId = createId();
const phone = this.resolvePhone(profile.phone);
const email = profile.email ? Email.create(profile.email) : null;
const user = new UserEntity(userId, {
email: email?.isOk ? email.unwrap() : null,
phone,
passwordHash: null, // OAuth users have no password
fullName: profile.fullName,
avatarUrl: profile.avatarUrl ?? null,
role: 'BUYER',
kycStatus: 'NONE',
kycData: null,
isActive: true,
});
await this.userRepo.save(user);
await this.createOAuthAccount(userId, profile);
// Publish domain event
this.eventBus.publish(new UserRegisteredEvent(userId, phone.value, 'BUYER'));
this.logger.log(`OAuth register: new user created via ${profile.provider}`);
return this.generateTokensForUser({
id: userId,
phone: phone.value,
role: 'BUYER',
});
}
private async createOAuthAccount(userId: string, profile: OAuthUserProfile): Promise<void> {
await this.prisma.oAuthAccount.create({
data: {
id: createId(),
userId,
provider: profile.provider,
providerUserId: profile.providerUserId,
accessToken: profile.accessToken,
refreshToken: profile.refreshToken,
expiresAt: profile.expiresAt,
profile: (profile.rawProfile as Prisma.InputJsonValue) ?? undefined,
},
});
}
private generateTokensForUser(user: { id: string; phone: string; role: string }): Promise<TokenPair> {
return this.tokenService.generateTokenPair({
sub: user.id,
phone: user.phone,
role: user.role,
});
}
/**
* Generate a placeholder phone for OAuth users who don't provide one.
* Vietnamese phone format required by the schema — uses 09xx prefix
* to satisfy validation. User should update their phone later.
*/
private resolvePhone(phone?: string): Phone {
if (phone) {
const result = Phone.create(phone);
if (result.isOk) return result.unwrap();
}
// Generate a valid Vietnamese placeholder: +849XXXXXXXX (10 digits after +84)
const random7 = Math.floor(1000000 + Math.random() * 9000000).toString();
const placeholder = `+8490${random7}`;
return Phone.create(placeholder).unwrap();
}
}