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>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user