import { BadRequestException, Controller, Delete, Get, HttpCode, Query, Res, UseGuards, } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { type Response } from 'express'; import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard'; import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator'; import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service'; import { ZaloOaService } from '../../infrastructure/services/zalo-oa.service'; const FRONTEND_URL = process.env['FRONTEND_URL'] ?? 'http://localhost:3000'; const CSRF_STATE_LENGTH = 32; function generateCsrfState(): string { return Buffer.from( Array.from({ length: CSRF_STATE_LENGTH }, () => Math.floor(Math.random() * 256)), ).toString('base64url'); } @ApiTags('auth') @Controller('auth/zalo-oa') export class ZaloOaLinkController { constructor(private readonly zaloOaService: ZaloOaService) {} /** * Initiate Zalo OA account linking for the authenticated user. * * Returns 302 redirect to the Zalo OA consent screen. * On return, Zalo calls back to `/auth/zalo-oa/callback`. * * The `state` param encodes `userId:csrfToken` so the callback can verify * the request origin without a server-side session. */ @Get('link') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Initiate Zalo OA account linking' }) @ApiResponse({ status: 302, description: 'Redirect to Zalo OA consent screen' }) initiateLink( @CurrentUser() user: JwtPayload, @Res() res: Response, ): void { if (!this.zaloOaService.isOAuthEnabled) { throw new BadRequestException('Zalo OA linking is not configured on this server'); } const csrf = generateCsrfState(); // Encode userId + csrf into state so the callback can verify const state = Buffer.from(JSON.stringify({ uid: user.sub, csrf })).toString('base64url'); const authUrl = this.zaloOaService.getOAuthAuthorizeUrl(state); res.redirect(authUrl); } /** * Zalo OA OAuth callback. * * Exchanges the authorization code for OA-scoped tokens, resolves the Zalo OA UID, * and stores encrypted tokens in `zalo_account_links`. * * On success redirects to frontend `/settings/zalo?linked=true`. * On failure redirects to frontend `/settings/zalo?error=`. */ @Throttle({ default: { ttl: 3_600_000, limit: 10 } }) @Get('callback') @ApiOperation({ summary: 'Zalo OA OAuth2 callback' }) @ApiResponse({ status: 302, description: 'Redirect to frontend settings page' }) async handleCallback( @Query('code') code: string, @Query('state') state: string, @Res() res: Response, ): Promise { if (!code || !state) { res.redirect(`${FRONTEND_URL}/settings/zalo?error=missing_params`); return; } let userId: string; try { const decoded = JSON.parse(Buffer.from(state, 'base64url').toString('utf8')) as { uid?: string; }; if (!decoded.uid) throw new Error('missing uid in state'); userId = decoded.uid; } catch { res.redirect(`${FRONTEND_URL}/settings/zalo?error=invalid_state`); return; } try { await this.zaloOaService.handleOAuthCallback(userId, code); res.redirect(`${FRONTEND_URL}/settings/zalo?linked=true`); } catch (error) { const msg = error instanceof Error ? error.message : 'unknown'; res.redirect( `${FRONTEND_URL}/settings/zalo?error=link_failed&detail=${encodeURIComponent(msg)}`, ); } } /** * Unlink the authenticated user's Zalo OA account. */ @Delete('link') @UseGuards(JwtAuthGuard) @HttpCode(204) @ApiOperation({ summary: 'Unlink Zalo OA account' }) @ApiResponse({ status: 204, description: 'Account unlinked' }) async unlink(@CurrentUser() user: JwtPayload): Promise { await this.zaloOaService.unlinkAccount(user.sub); } }