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,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<void> {
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<void> {
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`);
}
}
}

View File

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