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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user