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,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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user