diff --git a/.env.example b/.env.example index 8a910d6..16bc8ae 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,19 @@ JWT_EXPIRES_IN=15m JWT_REFRESH_SECRET=CHANGE_ME JWT_REFRESH_EXPIRES_IN=7d +# ----------------------------------------------------------------------------- +# OAuth Providers +# ----------------------------------------------------------------------------- +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_CALLBACK_URL=http://localhost:3001/auth/google/callback + +ZALO_APP_ID= +ZALO_APP_SECRET= +ZALO_CALLBACK_URL=http://localhost:3001/auth/zalo/callback + +FRONTEND_URL=http://localhost:3000 + # ----------------------------------------------------------------------------- # Next.js Web # ----------------------------------------------------------------------------- diff --git a/apps/api/package.json b/apps/api/package.json index d5dd7f8..022747c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -40,6 +40,7 @@ "ioredis": "^5.4.0", "nodemailer": "^8.0.5", "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pg": "^8.20.0", @@ -61,6 +62,7 @@ "@types/express": "^5.0.0", "@types/node": "^25.5.2", "@types/nodemailer": "^8.0.0", + "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", "@types/pg": "^8.20.0", diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts index 15d7bbb..9c689ae 100644 --- a/apps/api/src/modules/auth/auth.module.ts +++ b/apps/api/src/modules/auth/auth.module.ts @@ -12,10 +12,14 @@ import { REFRESH_TOKEN_REPOSITORY } from './domain/repositories/refresh-token.re import { USER_REPOSITORY } from './domain/repositories/user.repository'; import { PrismaRefreshTokenRepository } from './infrastructure/repositories/prisma-refresh-token.repository'; import { PrismaUserRepository } from './infrastructure/repositories/prisma-user.repository'; +import { OAuthService } from './infrastructure/services/oauth.service'; import { TokenService } from './infrastructure/services/token.service'; +import { GoogleOAuthStrategy } from './infrastructure/strategies/google-oauth.strategy'; import { JwtStrategy } from './infrastructure/strategies/jwt.strategy'; import { LocalStrategy } from './infrastructure/strategies/local.strategy'; +import { ZaloOAuthStrategy } from './infrastructure/strategies/zalo-oauth.strategy'; import { AuthController } from './presentation/controllers/auth.controller'; +import { OAuthController } from './presentation/controllers/oauth.controller'; const CommandHandlers = [ RegisterUserHandler, @@ -41,7 +45,7 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler]; signOptions: { expiresIn: '15m', audience: 'goodgo-api', issuer: 'goodgo-platform' }, }), ], - controllers: [AuthController], + controllers: [AuthController, OAuthController], providers: [ // Repositories { provide: USER_REPOSITORY, useClass: PrismaUserRepository }, @@ -50,14 +54,17 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler]; // Strategies JwtStrategy, LocalStrategy, + GoogleOAuthStrategy, + ZaloOAuthStrategy, // Services TokenService, + OAuthService, // CQRS ...CommandHandlers, ...QueryHandlers, ], - exports: [TokenService, USER_REPOSITORY], + exports: [TokenService, OAuthService, USER_REPOSITORY], }) export class AuthModule {} diff --git a/apps/api/src/modules/auth/infrastructure/__tests__/google-oauth.strategy.spec.ts b/apps/api/src/modules/auth/infrastructure/__tests__/google-oauth.strategy.spec.ts new file mode 100644 index 0000000..510c59b --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/__tests__/google-oauth.strategy.spec.ts @@ -0,0 +1,103 @@ +import { type OAuthService } from '../services/oauth.service'; +import { type TokenPair } from '../services/token.service'; + +// Mock passport-google-oauth20 before importing GoogleOAuthStrategy +vi.mock('passport-google-oauth20', () => { + return { + Strategy: class MockStrategy { + constructor(_options: unknown, verify: (...args: unknown[]) => void) { + // Store verify callback so we can call it in tests + (this as any)._verify = verify; + } + }, + }; +}); + +// Mock @nestjs/passport +vi.mock('@nestjs/passport', () => { + return { + PassportStrategy: (StrategyClass: any, _name?: string) => { + return class extends StrategyClass {}; + }, + }; +}); + +describe('GoogleOAuthStrategy', () => { + const mockTokenPair: TokenPair = { + accessToken: 'test-jwt', + refreshToken: 'family.token', + expiresIn: 900, + }; + + let mockOAuthService: { authenticateOAuth: ReturnType }; + + beforeEach(() => { + vi.stubEnv('GOOGLE_CLIENT_ID', 'test-client-id'); + vi.stubEnv('GOOGLE_CLIENT_SECRET', 'test-client-secret'); + vi.stubEnv('GOOGLE_CALLBACK_URL', 'http://localhost:3001/auth/google/callback'); + + mockOAuthService = { + authenticateOAuth: vi.fn().mockResolvedValue(mockTokenPair), + }; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('throws if GOOGLE_CLIENT_ID is missing', () => { + vi.stubEnv('GOOGLE_CLIENT_ID', ''); + // Reset module to pick up new env + expect(async () => { + const { GoogleOAuthStrategy } = await import('../strategies/google-oauth.strategy'); + new GoogleOAuthStrategy(mockOAuthService as unknown as OAuthService); + }).rejects.toThrow('GOOGLE_CLIENT_ID'); + }); + + it('creates strategy with correct config', async () => { + const { GoogleOAuthStrategy } = await import('../strategies/google-oauth.strategy'); + const strategy = new GoogleOAuthStrategy(mockOAuthService as unknown as OAuthService); + expect(strategy).toBeDefined(); + }); + + it('validate() calls oauthService.authenticateOAuth with correct profile', async () => { + const { GoogleOAuthStrategy } = await import('../strategies/google-oauth.strategy'); + const strategy = new GoogleOAuthStrategy(mockOAuthService as unknown as OAuthService); + + const done = vi.fn(); + const googleProfile = { + id: 'google-user-123', + displayName: 'Test User', + emails: [{ value: 'test@gmail.com', verified: true }], + photos: [{ value: 'https://photo.url/pic.jpg' }], + name: { givenName: 'Test', familyName: 'User' }, + _json: { sub: 'google-user-123', email: 'test@gmail.com' }, + }; + + await strategy.validate('access-token', 'refresh-token', googleProfile as any, done); + + expect(mockOAuthService.authenticateOAuth).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'GOOGLE', + providerUserId: 'google-user-123', + email: 'test@gmail.com', + fullName: 'Test User', + accessToken: 'access-token', + refreshToken: 'refresh-token', + }), + ); + expect(done).toHaveBeenCalledWith(null, mockTokenPair); + }); + + it('validate() passes error to done callback on failure', async () => { + mockOAuthService.authenticateOAuth.mockRejectedValue(new Error('OAuth failed')); + + const { GoogleOAuthStrategy } = await import('../strategies/google-oauth.strategy'); + const strategy = new GoogleOAuthStrategy(mockOAuthService as unknown as OAuthService); + + const done = vi.fn(); + await strategy.validate('token', 'refresh', { id: '1', displayName: 'X', _json: {} } as any, done); + + expect(done).toHaveBeenCalledWith(expect.any(Error), undefined); + }); +}); diff --git a/apps/api/src/modules/auth/infrastructure/__tests__/oauth.service.spec.ts b/apps/api/src/modules/auth/infrastructure/__tests__/oauth.service.spec.ts new file mode 100644 index 0000000..99bbd77 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/__tests__/oauth.service.spec.ts @@ -0,0 +1,181 @@ +import { type OAuthProvider } from '@prisma/client'; +import { type IUserRepository } from '../../domain/repositories/user.repository'; +import { UserEntity } from '../../domain/entities/user.entity'; +import { Email } from '../../domain/value-objects/email.vo'; +import { Phone } from '../../domain/value-objects/phone.vo'; +import { OAuthService, type OAuthUserProfile } from '../services/oauth.service'; +import { type TokenPair } from '../services/token.service'; + +describe('OAuthService', () => { + let service: OAuthService; + let mockUserRepo: { [K in keyof IUserRepository]: ReturnType }; + let mockTokenService: { generateTokenPair: ReturnType }; + let mockPrisma: { + oAuthAccount: { + findUnique: ReturnType; + create: ReturnType; + update: ReturnType; + }; + }; + let mockEventBus: { publish: ReturnType }; + + const mockTokenPair: TokenPair = { + accessToken: 'test-jwt', + refreshToken: 'family.token', + expiresIn: 900, + }; + + const googleProfile: OAuthUserProfile = { + provider: 'GOOGLE' as OAuthProvider, + providerUserId: 'google-123', + email: 'user@gmail.com', + fullName: 'Test User', + avatarUrl: 'https://photo.url', + accessToken: 'google-access-token', + refreshToken: 'google-refresh-token', + rawProfile: { sub: 'google-123' }, + }; + + const makeUserEntity = (id: string, phone: string, email?: string): UserEntity => { + return new UserEntity(id, { + email: email ? Email.create(email).unwrap() : null, + phone: Phone.create(phone).unwrap(), + passwordHash: null, + fullName: 'Existing User', + avatarUrl: null, + role: 'BUYER', + kycStatus: 'NONE', + kycData: null, + isActive: true, + }); + }; + + beforeEach(() => { + mockUserRepo = { + findById: vi.fn().mockResolvedValue(null), + findByPhone: vi.fn().mockResolvedValue(null), + findByEmail: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + }; + + mockTokenService = { + generateTokenPair: vi.fn().mockResolvedValue(mockTokenPair), + }; + + mockPrisma = { + oAuthAccount: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({}), + update: vi.fn().mockResolvedValue({}), + }, + }; + + mockEventBus = { publish: vi.fn() }; + + service = new OAuthService( + mockUserRepo as any, + mockTokenService as any, + mockPrisma as any, + mockEventBus as any, + ); + }); + + describe('authenticateOAuth', () => { + it('returns tokens for existing OAuth account', async () => { + mockPrisma.oAuthAccount.findUnique.mockResolvedValue({ + id: 'oauth-1', + userId: 'user-1', + provider: 'GOOGLE', + accessToken: 'old-token', + refreshToken: null, + expiresAt: null, + profile: null, + user: { id: 'user-1', phone: '0912345678', role: 'BUYER', isActive: true }, + }); + + const result = await service.authenticateOAuth(googleProfile); + + expect(result).toEqual(mockTokenPair); + expect(mockPrisma.oAuthAccount.update).toHaveBeenCalled(); + expect(mockUserRepo.save).not.toHaveBeenCalled(); + }); + + it('throws for deactivated user with existing OAuth account', async () => { + mockPrisma.oAuthAccount.findUnique.mockResolvedValue({ + id: 'oauth-1', + userId: 'user-1', + user: { id: 'user-1', phone: '0912345678', role: 'BUYER', isActive: false }, + }); + + await expect(service.authenticateOAuth(googleProfile)).rejects.toThrow( + 'Tài khoản đã bị vô hiệu hóa', + ); + }); + + it('links OAuth account to existing user by email', async () => { + const existingUser = makeUserEntity('user-2', '+84912345678', 'user@gmail.com'); + mockUserRepo.findByEmail.mockResolvedValue(existingUser); + + const result = await service.authenticateOAuth(googleProfile); + + expect(result).toEqual(mockTokenPair); + expect(mockPrisma.oAuthAccount.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: 'user-2', + provider: 'GOOGLE', + providerUserId: 'google-123', + }), + }), + ); + expect(mockUserRepo.save).not.toHaveBeenCalled(); + }); + + it('links OAuth account to existing user by phone', async () => { + const profileWithPhone: OAuthUserProfile = { + ...googleProfile, + email: undefined, + phone: '+84912345678', + }; + const existingUser = makeUserEntity('user-3', '+84912345678'); + mockUserRepo.findByPhone.mockResolvedValue(existingUser); + + const result = await service.authenticateOAuth(profileWithPhone); + + expect(result).toEqual(mockTokenPair); + expect(mockPrisma.oAuthAccount.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ userId: 'user-3' }), + }), + ); + }); + + it('creates new user when no existing account matches', async () => { + const result = await service.authenticateOAuth(googleProfile); + + expect(result).toEqual(mockTokenPair); + expect(mockUserRepo.save).toHaveBeenCalled(); + expect(mockPrisma.oAuthAccount.create).toHaveBeenCalled(); + expect(mockEventBus.publish).toHaveBeenCalled(); + }); + + it('creates new user without password hash for OAuth accounts', async () => { + await service.authenticateOAuth(googleProfile); + + const savedUser = mockUserRepo.save.mock.calls[0][0] as UserEntity; + expect(savedUser.passwordHash).toBeNull(); + expect(savedUser.fullName).toBe('Test User'); + }); + + it('throws for deactivated user found by email link', async () => { + const deactivatedUser = makeUserEntity('user-4', '+84912345678', 'user@gmail.com'); + deactivatedUser.deactivate(); + mockUserRepo.findByEmail.mockResolvedValue(deactivatedUser); + + await expect(service.authenticateOAuth(googleProfile)).rejects.toThrow( + 'Tài khoản đã bị vô hiệu hóa', + ); + }); + }); +}); diff --git a/apps/api/src/modules/auth/infrastructure/__tests__/zalo-oauth.strategy.spec.ts b/apps/api/src/modules/auth/infrastructure/__tests__/zalo-oauth.strategy.spec.ts new file mode 100644 index 0000000..1af4aee --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/__tests__/zalo-oauth.strategy.spec.ts @@ -0,0 +1,183 @@ +import { type OAuthService } from '../services/oauth.service'; +import { ZaloOAuthStrategy } from '../strategies/zalo-oauth.strategy'; +import { type TokenPair } from '../services/token.service'; + +describe('ZaloOAuthStrategy', () => { + const mockTokenPair: TokenPair = { + accessToken: 'test-jwt', + refreshToken: 'family.token', + expiresIn: 900, + }; + + let mockOAuthService: { authenticateOAuth: ReturnType }; + let strategy: ZaloOAuthStrategy; + + beforeEach(() => { + vi.stubEnv('ZALO_APP_ID', 'test-zalo-app-id'); + vi.stubEnv('ZALO_APP_SECRET', 'test-zalo-secret'); + vi.stubEnv('ZALO_CALLBACK_URL', 'http://localhost:3001/auth/zalo/callback'); + + mockOAuthService = { + authenticateOAuth: vi.fn().mockResolvedValue(mockTokenPair), + }; + + strategy = new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('throws if ZALO_APP_ID is missing', () => { + vi.stubEnv('ZALO_APP_ID', ''); + expect(() => new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService)) + .toThrow('ZALO_APP_ID'); + }); + + it('throws if ZALO_APP_SECRET is missing', () => { + vi.stubEnv('ZALO_APP_SECRET', ''); + expect(() => new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService)) + .toThrow('ZALO_APP_SECRET'); + }); + + describe('getAuthorizationUrl', () => { + it('returns correct Zalo authorization URL', () => { + const url = strategy.getAuthorizationUrl('test-state'); + + expect(url).toContain('https://oauth.zaloapp.com/v4/permission'); + expect(url).toContain('app_id=test-zalo-app-id'); + expect(url).toContain('redirect_uri='); + expect(url).toContain('state=test-state'); + }); + + it('works without state parameter', () => { + const url = strategy.getAuthorizationUrl(); + expect(url).toContain('https://oauth.zaloapp.com/v4/permission'); + expect(url).toContain('app_id=test-zalo-app-id'); + }); + }); + + describe('handleCallback', () => { + it('exchanges code, fetches user info, and returns tokens', async () => { + // Mock fetch for token exchange + const mockFetch = vi.fn() + .mockResolvedValueOnce({ + json: () => Promise.resolve({ + access_token: 'zalo-access-token', + refresh_token: 'zalo-refresh-token', + expires_in: 3600, + }), + }) + // Mock fetch for user info + .mockResolvedValueOnce({ + json: () => Promise.resolve({ + id: 'zalo-user-123', + name: 'Nguyen Van A', + picture: { data: { url: 'https://zalo.me/avatar.jpg' } }, + }), + }); + + vi.stubGlobal('fetch', mockFetch); + + const result = await strategy.handleCallback('auth-code-123'); + + expect(result.tokens).toEqual(mockTokenPair); + expect(mockOAuthService.authenticateOAuth).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'ZALO', + providerUserId: 'zalo-user-123', + fullName: 'Nguyen Van A', + accessToken: 'zalo-access-token', + refreshToken: 'zalo-refresh-token', + }), + ); + + // Verify token exchange request + expect(mockFetch).toHaveBeenCalledWith( + 'https://oauth.zaloapp.com/v4/access_token', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'secret_key': 'test-zalo-secret', + }), + }), + ); + + // Verify user info request + expect(mockFetch).toHaveBeenCalledWith( + 'https://graph.zalo.me/v2.0/me?fields=id,name,picture', + expect.objectContaining({ + headers: expect.objectContaining({ + 'access_token': 'zalo-access-token', + }), + }), + ); + }); + + it('throws on Zalo token exchange error', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ + error: -216, + error_description: 'Invalid authorization code', + }), + })); + + await expect(strategy.handleCallback('bad-code')).rejects.toThrow( + 'Zalo OAuth error: Invalid authorization code', + ); + }); + + it('throws when Zalo returns no access token', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + json: () => Promise.resolve({}), + })); + + await expect(strategy.handleCallback('code')).rejects.toThrow( + 'No access token', + ); + }); + + it('throws on Zalo user info error', async () => { + const mockFetch = vi.fn() + .mockResolvedValueOnce({ + json: () => Promise.resolve({ + access_token: 'token', + expires_in: 3600, + }), + }) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ + error: -401, + message: 'Unauthorized', + }), + }); + + vi.stubGlobal('fetch', mockFetch); + + await expect(strategy.handleCallback('code')).rejects.toThrow( + 'Zalo OAuth error: Unauthorized', + ); + }); + + it('passes code_verifier when provided', async () => { + const mockFetch = vi.fn() + .mockResolvedValueOnce({ + json: () => Promise.resolve({ + access_token: 'token', + expires_in: 3600, + }), + }) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ id: 'z-1', name: 'User' }), + }); + + vi.stubGlobal('fetch', mockFetch); + + await strategy.handleCallback('code', 'pkce-verifier'); + + const tokenBody = mockFetch.mock.calls[0][1].body as string; + expect(tokenBody).toContain('code_verifier=pkce-verifier'); + }); + }); +}); diff --git a/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts b/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts new file mode 100644 index 0000000..73dba5b --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts @@ -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; +} + +@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 { + // 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 { + 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 { + 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(); + } +} diff --git a/apps/api/src/modules/auth/infrastructure/strategies/google-oauth.strategy.ts b/apps/api/src/modules/auth/infrastructure/strategies/google-oauth.strategy.ts new file mode 100644 index 0000000..12865b5 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/strategies/google-oauth.strategy.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, type Profile, type VerifyCallback } from 'passport-google-oauth20'; +import { type OAuthService, type OAuthUserProfile } from '../services/oauth.service'; + +@Injectable() +export class GoogleOAuthStrategy extends PassportStrategy(Strategy, 'google') { + constructor(private readonly oauthService: OAuthService) { + const clientID = process.env['GOOGLE_CLIENT_ID']; + const clientSecret = process.env['GOOGLE_CLIENT_SECRET']; + const callbackURL = process.env['GOOGLE_CALLBACK_URL'] ?? '/auth/google/callback'; + + if (!clientID || !clientSecret) { + throw new Error('GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables are required'); + } + + super({ + clientID, + clientSecret, + callbackURL, + scope: ['email', 'profile'], + }); + } + + async validate( + accessToken: string, + refreshToken: string, + profile: Profile, + done: VerifyCallback, + ): Promise { + const email = profile.emails?.[0]?.value; + const photo = profile.photos?.[0]?.value; + const fullName = profile.displayName || [profile.name?.givenName, profile.name?.familyName].filter(Boolean).join(' ') || 'Google User'; + + const oauthProfile: OAuthUserProfile = { + provider: 'GOOGLE', + providerUserId: profile.id, + email, + fullName, + avatarUrl: photo, + accessToken, + refreshToken, + rawProfile: profile._json as Record, + }; + + try { + const tokens = await this.oauthService.authenticateOAuth(oauthProfile); + done(null, tokens); + } catch (err) { + done(err as Error, undefined); + } + } +} diff --git a/apps/api/src/modules/auth/infrastructure/strategies/index.ts b/apps/api/src/modules/auth/infrastructure/strategies/index.ts index e8ab195..33df3ca 100644 --- a/apps/api/src/modules/auth/infrastructure/strategies/index.ts +++ b/apps/api/src/modules/auth/infrastructure/strategies/index.ts @@ -1,2 +1,4 @@ +export { GoogleOAuthStrategy } from './google-oauth.strategy'; export { JwtStrategy } from './jwt.strategy'; export { LocalStrategy } from './local.strategy'; +export { ZaloOAuthStrategy } from './zalo-oauth.strategy'; diff --git a/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts b/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts new file mode 100644 index 0000000..9509376 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts @@ -0,0 +1,155 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { type OAuthService, type OAuthUserProfile } from '../services/oauth.service'; + +/** + * Zalo OAuth2 integration. + * + * Zalo does not have a passport strategy npm package, so this service + * handles the OAuth2 flow directly: + * 1. Generate authorization URL → redirect user + * 2. Exchange authorization code for access token + * 3. Fetch user profile from Zalo Graph API + * 4. Delegate to OAuthService for account linking/creation + * + * Zalo OAuth2 endpoints: + * - Authorization: https://oauth.zaloapp.com/v4/permission + * - Token: https://oauth.zaloapp.com/v4/access_token + * - User Info: https://graph.zalo.me/v2.0/me + */ + +interface ZaloTokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + error?: number; + error_description?: string; +} + +interface ZaloUserInfo { + id: string; + name: string; + picture?: { data?: { url?: string } }; + error?: number; + message?: string; +} + +@Injectable() +export class ZaloOAuthStrategy { + private readonly logger = new Logger(ZaloOAuthStrategy.name); + + private readonly appId: string; + private readonly appSecret: string; + private readonly callbackUrl: string; + + constructor(private readonly oauthService: OAuthService) { + const appId = process.env['ZALO_APP_ID']; + const appSecret = process.env['ZALO_APP_SECRET']; + + if (!appId || !appSecret) { + throw new Error('ZALO_APP_ID and ZALO_APP_SECRET environment variables are required'); + } + + this.appId = appId; + this.appSecret = appSecret; + this.callbackUrl = process.env['ZALO_CALLBACK_URL'] ?? '/auth/zalo/callback'; + } + + /** + * Generate Zalo authorization URL for redirecting the user. + */ + getAuthorizationUrl(state?: string): string { + const params = new URLSearchParams({ + app_id: this.appId, + redirect_uri: this.callbackUrl, + state: state ?? '', + }); + return `https://oauth.zaloapp.com/v4/permission?${params.toString()}`; + } + + /** + * Handle the OAuth callback: exchange code for tokens, fetch profile, + * and authenticate via OAuthService. + */ + async handleCallback(code: string, codeVerifier?: string): Promise<{ tokens: import('../services/token.service').TokenPair }> { + // 1. Exchange authorization code for access token + const tokenData = await this.exchangeCode(code, codeVerifier); + + // 2. Fetch user profile + const userInfo = await this.fetchUserInfo(tokenData.access_token); + + // 3. Build OAuthUserProfile and authenticate + const expiresAt = new Date(); + expiresAt.setSeconds(expiresAt.getSeconds() + tokenData.expires_in); + + const oauthProfile: OAuthUserProfile = { + provider: 'ZALO', + providerUserId: userInfo.id, + fullName: userInfo.name || 'Zalo User', + avatarUrl: userInfo.picture?.data?.url, + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresAt, + rawProfile: userInfo as unknown as Record, + }; + + const tokens = await this.oauthService.authenticateOAuth(oauthProfile); + return { tokens }; + } + + private async exchangeCode(code: string, codeVerifier?: string): Promise { + const body = new URLSearchParams({ + code, + app_id: this.appId, + grant_type: 'authorization_code', + }); + if (codeVerifier) { + body.set('code_verifier', codeVerifier); + } + + const response = await fetch('https://oauth.zaloapp.com/v4/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'secret_key': this.appSecret, + }, + body: body.toString(), + }); + + const data = (await response.json()) as ZaloTokenResponse; + + if (data.error) { + this.logger.error(`Zalo token exchange failed: ${data.error_description ?? data.error}`); + throw new Error(`Zalo OAuth error: ${data.error_description ?? 'Token exchange failed'}`); + } + + if (!data.access_token) { + throw new Error('Zalo OAuth error: No access token in response'); + } + + return data; + } + + private async fetchUserInfo(accessToken: string): Promise { + const response = await fetch( + 'https://graph.zalo.me/v2.0/me?fields=id,name,picture', + { + headers: { + 'access_token': accessToken, + }, + }, + ); + + const data = (await response.json()) as ZaloUserInfo; + + if (data.error) { + this.logger.error(`Zalo user info fetch failed: ${data.message ?? data.error}`); + throw new Error(`Zalo OAuth error: ${data.message ?? 'Failed to fetch user info'}`); + } + + if (!data.id) { + throw new Error('Zalo OAuth error: No user ID in response'); + } + + return data; + } +} diff --git a/apps/api/src/modules/auth/presentation/controllers/oauth.controller.ts b/apps/api/src/modules/auth/presentation/controllers/oauth.controller.ts new file mode 100644 index 0000000..2291120 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/controllers/oauth.controller.ts @@ -0,0 +1,112 @@ +import { + Controller, + Get, + Query, + Req, + Res, + UnauthorizedException, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import type { Request, Response } from 'express'; +import { type TokenPair } from '../../infrastructure/services/token.service'; +import { ZaloOAuthStrategy } from '../../infrastructure/strategies/zalo-oauth.strategy'; +import { GoogleOAuthGuard } from '../guards/google-oauth.guard'; + +const IS_PRODUCTION = process.env['NODE_ENV'] === 'production'; +const ACCESS_TOKEN_MAX_AGE = 15 * 60 * 1000; +const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60 * 1000; +const AUTH_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000; + +const FRONTEND_URL = process.env['FRONTEND_URL'] ?? 'http://localhost:3000'; + +function setAuthCookies(res: Response, tokens: TokenPair): void { + res.cookie('access_token', tokens.accessToken, { + httpOnly: true, + secure: IS_PRODUCTION, + sameSite: 'strict', + path: '/', + maxAge: ACCESS_TOKEN_MAX_AGE, + }); + res.cookie('refresh_token', tokens.refreshToken, { + httpOnly: true, + secure: IS_PRODUCTION, + sameSite: 'strict', + path: '/auth', + maxAge: REFRESH_TOKEN_MAX_AGE, + }); + res.cookie('goodgo_authenticated', '1', { + httpOnly: false, + secure: IS_PRODUCTION, + sameSite: 'lax', + path: '/', + maxAge: AUTH_COOKIE_MAX_AGE, + }); +} + +@ApiTags('auth') +@Controller('auth') +export class OAuthController { + constructor(private readonly zaloStrategy: ZaloOAuthStrategy) {} + + // ─── Google OAuth ────────────────────────────────────────────────── + + @Get('google') + @UseGuards(GoogleOAuthGuard) + @ApiOperation({ summary: 'Initiate Google OAuth2 login' }) + @ApiResponse({ status: 302, description: 'Redirect to Google consent screen' }) + googleLogin(): void { + // Guard handles redirect to Google + } + + @Throttle({ default: { ttl: 3_600_000, limit: 10 } }) + @Get('google/callback') + @UseGuards(GoogleOAuthGuard) + @ApiOperation({ summary: 'Google OAuth2 callback' }) + @ApiResponse({ status: 302, description: 'Redirect to frontend with auth cookies' }) + async googleCallback( + @Req() req: Request, + @Res() res: Response, + ): Promise { + const tokens = req.user as TokenPair | undefined; + if (!tokens?.accessToken) { + throw new UnauthorizedException('Google authentication failed'); + } + + setAuthCookies(res, tokens); + res.redirect(`${FRONTEND_URL}/auth/callback?provider=google`); + } + + // ─── Zalo OAuth ──────────────────────────────────────────────────── + + @Get('zalo') + @ApiOperation({ summary: 'Initiate Zalo OAuth2 login' }) + @ApiResponse({ status: 302, description: 'Redirect to Zalo consent screen' }) + zaloLogin(@Res() res: Response): void { + const authUrl = this.zaloStrategy.getAuthorizationUrl(); + res.redirect(authUrl); + } + + @Throttle({ default: { ttl: 3_600_000, limit: 10 } }) + @Get('zalo/callback') + @ApiOperation({ summary: 'Zalo OAuth2 callback' }) + @ApiResponse({ status: 302, description: 'Redirect to frontend with auth cookies' }) + async zaloCallback( + @Query('code') code: string, + @Query('code_verifier') codeVerifier: string | undefined, + @Res() res: Response, + ): Promise { + if (!code) { + throw new UnauthorizedException('Zalo authorization code missing'); + } + + try { + const result = await this.zaloStrategy.handleCallback(code, codeVerifier); + setAuthCookies(res, result.tokens); + res.redirect(`${FRONTEND_URL}/auth/callback?provider=zalo`); + } catch { + res.redirect(`${FRONTEND_URL}/auth/callback?error=zalo_auth_failed`); + } + } +} diff --git a/apps/api/src/modules/auth/presentation/guards/google-oauth.guard.ts b/apps/api/src/modules/auth/presentation/guards/google-oauth.guard.ts new file mode 100644 index 0000000..1dc9447 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/guards/google-oauth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class GoogleOAuthGuard extends AuthGuard('google') {} diff --git a/apps/api/src/modules/shared/infrastructure/env-validation.ts b/apps/api/src/modules/shared/infrastructure/env-validation.ts index d420fef..f222759 100644 --- a/apps/api/src/modules/shared/infrastructure/env-validation.ts +++ b/apps/api/src/modules/shared/infrastructure/env-validation.ts @@ -22,6 +22,10 @@ const REQUIRED_WHEN_USED: ReadonlyMap = new Map([ ['ZALOPAY_KEY2', 'ZaloPay payments'], ['MINIO_ACCESS_KEY', 'Media storage'], ['MINIO_SECRET_KEY', 'Media storage'], + ['GOOGLE_CLIENT_ID', 'Google OAuth'], + ['GOOGLE_CLIENT_SECRET', 'Google OAuth'], + ['ZALO_APP_ID', 'Zalo OAuth'], + ['ZALO_APP_SECRET', 'Zalo OAuth'], ]); export function validateEnv(): void { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d22c307..c94a080 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: passport: specifier: ^0.7.0 version: 0.7.0 + passport-google-oauth20: + specifier: ^2.0.0 + version: 2.0.0 passport-jwt: specifier: ^4.0.1 version: 4.0.1 @@ -208,6 +211,9 @@ importers: '@types/nodemailer': specifier: ^8.0.0 version: 8.0.0 + '@types/passport-google-oauth20': + specifier: ^2.0.17 + version: 2.0.17 '@types/passport-jwt': specifier: ^4.0.1 version: 4.0.1 @@ -2440,12 +2446,21 @@ packages: '@types/nodemailer@8.0.0': resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==} + '@types/oauth@0.9.6': + resolution: {integrity: sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==} + + '@types/passport-google-oauth20@2.0.17': + resolution: {integrity: sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==} + '@types/passport-jwt@4.0.1': resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} '@types/passport-local@1.0.38': resolution: {integrity: sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==} + '@types/passport-oauth2@1.8.0': + resolution: {integrity: sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==} + '@types/passport-strategy@0.2.38': resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} @@ -2926,6 +2941,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64url@3.0.1: + resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} + engines: {node: '>=6.0.0'} + baseline-browser-mapping@2.10.16: resolution: {integrity: sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==} engines: {node: '>=6.0.0'} @@ -4529,6 +4548,9 @@ packages: engines: {node: '>=18'} hasBin: true + oauth@0.10.2: + resolution: {integrity: sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -4597,6 +4619,10 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + passport-google-oauth20@2.0.0: + resolution: {integrity: sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==} + engines: {node: '>= 0.4.0'} + passport-jwt@4.0.1: resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} @@ -4604,6 +4630,10 @@ packages: resolution: {integrity: sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==} engines: {node: '>= 0.4.0'} + passport-oauth2@1.8.0: + resolution: {integrity: sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==} + engines: {node: '>= 0.4.0'} + passport-strategy@1.0.0: resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} engines: {node: '>= 0.4.0'} @@ -5520,6 +5550,9 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + uid2@0.0.4: + resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} + uid@2.0.2: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} @@ -8441,6 +8474,16 @@ snapshots: dependencies: '@types/node': 25.5.2 + '@types/oauth@0.9.6': + dependencies: + '@types/node': 25.5.2 + + '@types/passport-google-oauth20@2.0.17': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + '@types/passport-oauth2': 1.8.0 + '@types/passport-jwt@4.0.1': dependencies: '@types/jsonwebtoken': 9.0.10 @@ -8452,6 +8495,12 @@ snapshots: '@types/passport': 1.0.17 '@types/passport-strategy': 0.2.38 + '@types/passport-oauth2@1.8.0': + dependencies: + '@types/express': 5.0.6 + '@types/oauth': 0.9.6 + '@types/passport': 1.0.17 + '@types/passport-strategy@0.2.38': dependencies: '@types/express': 5.0.6 @@ -8965,6 +9014,8 @@ snapshots: base64-js@1.5.1: {} + base64url@3.0.1: {} + baseline-browser-mapping@2.10.16: {} bcrypt@6.0.0: @@ -10654,6 +10705,8 @@ snapshots: pathe: 2.0.3 tinyexec: 1.1.1 + oauth@0.10.2: {} + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -10726,6 +10779,10 @@ snapshots: parseurl@1.3.3: {} + passport-google-oauth20@2.0.0: + dependencies: + passport-oauth2: 1.8.0 + passport-jwt@4.0.1: dependencies: jsonwebtoken: 9.0.3 @@ -10735,6 +10792,14 @@ snapshots: dependencies: passport-strategy: 1.0.0 + passport-oauth2@1.8.0: + dependencies: + base64url: 3.0.1 + oauth: 0.10.2 + passport-strategy: 1.0.0 + uid2: 0.0.4 + utils-merge: 1.0.1 + passport-strategy@1.0.0: {} passport@0.7.0: @@ -11719,6 +11784,8 @@ snapshots: uglify-js@3.19.3: optional: true + uid2@0.0.4: {} + uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0