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:
Ho Ngoc Hai
2026-04-08 14:14:02 +07:00
parent bac3313873
commit 23bb380d34
14 changed files with 1068 additions and 2 deletions

View File

@@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, type Profile, type VerifyCallback } from 'passport-google-oauth20';
import { type OAuthService, type OAuthUserProfile } from '../services/oauth.service';
@Injectable()
export class GoogleOAuthStrategy extends PassportStrategy(Strategy, 'google') {
constructor(private readonly oauthService: OAuthService) {
const clientID = process.env['GOOGLE_CLIENT_ID'];
const clientSecret = process.env['GOOGLE_CLIENT_SECRET'];
const callbackURL = process.env['GOOGLE_CALLBACK_URL'] ?? '/auth/google/callback';
if (!clientID || !clientSecret) {
throw new Error('GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables are required');
}
super({
clientID,
clientSecret,
callbackURL,
scope: ['email', 'profile'],
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: Profile,
done: VerifyCallback,
): Promise<void> {
const email = profile.emails?.[0]?.value;
const photo = profile.photos?.[0]?.value;
const fullName = profile.displayName || [profile.name?.givenName, profile.name?.familyName].filter(Boolean).join(' ') || 'Google User';
const oauthProfile: OAuthUserProfile = {
provider: 'GOOGLE',
providerUserId: profile.id,
email,
fullName,
avatarUrl: photo,
accessToken,
refreshToken,
rawProfile: profile._json as Record<string, unknown>,
};
try {
const tokens = await this.oauthService.authenticateOAuth(oauthProfile);
done(null, tokens);
} catch (err) {
done(err as Error, undefined);
}
}
}