Files
goodgo-platform/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts
Ho Ngoc Hai 312532b1cb fix(api): resolve NestJS DI + ValidationPipe bugs from type-only imports
- Remove `type` modifier from imports used as DI constructor params
  across ~235 files (@Injectable, @Controller, @Module, @Catch,
  @CommandHandler, @QueryHandler, @EventsHandler, @WebSocketGateway).
  TypeScript emitDecoratorMetadata strips type-only imports, leaving
  Reflect.metadata with Function placeholder and breaking Nest DI.
- Fix controllers: DTOs used with @Body/@Query/@Param must be runtime
  imports so ValidationPipe can whitelist properties. Previously
  returned 400 "property X should not exist" on every request.
- Register ProjectsModule in AppModule (was defined but never wired).
- Add approve()/reject() methods to TransferListingEntity referenced by
  ModerateTransferListingHandler.
- Export BankTransferConfirmedEvent from payments barrel for
  subscription activation handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:50:30 +07:00

163 lines
4.9 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { LoggerService } from '@modules/shared';
import { OAuthService, type OAuthUserProfile } from '../services/oauth.service';
import { type TokenPair } from '../services/token.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 appId: string;
private readonly appSecret: string;
private readonly callbackUrl: string;
constructor(
private readonly oauthService: OAuthService,
private readonly logger: LoggerService,
) {
const appId = process.env['ZALO_APP_ID'];
const appSecret = process.env['ZALO_APP_SECRET'];
if (!appId || !appSecret) {
// Allow app to start without Zalo OAuth configured — routes will be non-functional.
this.appId = 'NOT_CONFIGURED';
this.appSecret = 'NOT_CONFIGURED';
this.callbackUrl = process.env['ZALO_CALLBACK_URL'] ?? '/auth/zalo/callback';
return;
}
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: 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<string, unknown>,
};
const tokens = await this.oauthService.authenticateOAuth(oauthProfile);
return { tokens };
}
private async exchangeCode(code: string, codeVerifier?: string): Promise<ZaloTokenResponse> {
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}`, undefined, 'ZaloOAuthStrategy');
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<ZaloUserInfo> {
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}`, undefined, 'ZaloOAuthStrategy');
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;
}
}