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, }; const tokens = await this.oauthService.authenticateOAuth(oauthProfile); return { tokens }; } private async exchangeCode(code: string, codeVerifier?: string): Promise { 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 { 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; } }