- 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>
163 lines
4.9 KiB
TypeScript
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;
|
|
}
|
|
}
|