Files
goodgo-platform/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts
Ho Ngoc Hai 23bb380d34 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>
2026-04-08 14:14:02 +07:00

156 lines
4.6 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { type OAuthService, type OAuthUserProfile } from '../services/oauth.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 logger = new Logger(ZaloOAuthStrategy.name);
private readonly appId: string;
private readonly appSecret: string;
private readonly callbackUrl: string;
constructor(private readonly oauthService: OAuthService) {
const appId = process.env['ZALO_APP_ID'];
const appSecret = process.env['ZALO_APP_SECRET'];
if (!appId || !appSecret) {
throw new Error('ZALO_APP_ID and ZALO_APP_SECRET environment variables are required');
}
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: import('../services/token.service').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}`);
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}`);
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;
}
}