base.strategy.ts

// src/auth/strategies/base.strategy.ts  
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';  
import { ConfigService } from '@nestjs/config';  
import { HttpService } from '@nestjs/axios';  
import { OAuthTokenResponse, OAuthUserProfile } from '../types';  
  
@Injectable()  
export abstract class BaseOAuthStrategy {  
  protected constructor(  
    protected readonly configService: ConfigService,  
    protected readonly httpService: HttpService,  
  ) {}  
  
  // OAuth URL 생성  
  abstract generateAuthUrl(state?: string): string;  
  
  // 토큰 발급  
  abstract getTokens(code: string): Promise<OAuthTokenResponse>;  
  
  // 사용자 프로필 조회  
  abstract getUserProfile(accessToken: string): Promise<OAuthUserProfile>;  
  
  // 토큰 갱신  
  abstract refreshToken(refreshToken: string): Promise<OAuthTokenResponse>;  
  
  // 토큰 검증  
  abstract validateToken(accessToken: string): Promise<boolean>;  
  
  // HTTP 요청 헬퍼 메서드  
  protected async httpRequest<T>(  
    method: 'GET' | 'POST',  
    url: string,  
    config: {  
      headers?: Record<string, string>;  
      params?: Record<string, string>;  
      data?: any;  
    },  
  ): Promise<T> {  
    try {  
      const response = await this.httpService.axiosRef.request<T>({  
        method,  
        url,  
        ...config,  
      });  
      return response.data;  
    } catch (error) {  
      if (error.response) {  
        throw new HttpException(  
          `OAuth request failed: ${JSON.stringify(error.response.data)}`,  
          HttpStatus.BAD_REQUEST,  
        );  
      }  
      throw new HttpException(  
        'Unexpected error occurred',  
        HttpStatus.INTERNAL_SERVER_ERROR,  
      );  
    }  
  }  
  
  // 상태 토큰 생성  
  protected generateState(): string {  
    return Math.random().toString(36).substring(2);  
  }  
  
  // URL 파라미터 인코딩  
  protected encodeParams(params: Record<string, string>): string {  
    return Object.entries(params)  
      .map(  
        ([key, value]) =>  
          `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,  
      )  
      .join('&');  
  }  
}

kakao.strategy.ts

import { Injectable, UnauthorizedException } from '@nestjs/common';  
import { ConfigService } from '@nestjs/config';  
import { HttpService } from '@nestjs/axios';  
import { BaseOAuthStrategy } from './base.strategy';  
import { Provider } from '../enums/provider.enum';  
import {  
  OAuthTokenResponse,  
  OAuthProfile,  
  KakaoUserResponse,  
} from '../interfaces';  
  
@Injectable()  
export class KakaoStrategy extends BaseOAuthStrategy {  
  private readonly clientId: string;  
  private readonly clientSecret: string;  
  private readonly redirectUri: string;  
  
  constructor(configService: ConfigService, httpService: HttpService) {  
    super(configService, httpService);  
    this.clientId = this.configService.getOrThrow<string>('KAKAO_CLIENT_ID');  
    this.clientSecret = this.configService.getOrThrow<string>(  
      'KAKAO_CLIENT_SECRET',  
    );  
    this.redirectUri =  
      this.configService.getOrThrow<string>('KAKAO_REDIRECT_URI');  
  }  
  
  generateAuthUrl(state?: string): string {  
    const params = {  
      client_id: this.clientId,  
      redirect_uri: this.redirectUri,  
      response_type: 'code',  
      state: state || this.generateState(),  
    };  
  
    return `https://kauth.kakao.com/oauth/authorize?${this.encodeParams(params)}`;  
  }  
  
  async getTokens(code: string): Promise<OAuthTokenResponse> {  
    const params = {  
      grant_type: 'authorization_code',  
      client_id: this.clientId,  
      client_secret: this.clientSecret,  
      redirect_uri: this.redirectUri,  
      code,  
    };  
  
    try {  
      return await this.httpRequest<OAuthTokenResponse>(  
        'POST',  
        'https://kauth.kakao.com/oauth/token',  
        {  
          headers: {  
            'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',  
          },  
          data: this.encodeParams(params),  
        },  
      );  
    } catch (error) {  
      throw new UnauthorizedException(  
        `Failed to get Kakao tokens: ${error.response?.data?.error_description || error.message}`,  
      );  
    }  
  }  
  
  async getUserProfile(accessToken: string): Promise<OAuthProfile> {  
    try {  
      const response = await this.httpRequest<KakaoUserResponse>(  
        'GET',  
        'https://kapi.kakao.com/v2/user/me',  
        {  
          headers: {  
            Authorization: `Bearer ${accessToken}`,  
          },  
        },  
      );  
  
      return {  
        id: response.id.toString(),  
        email: response.kakao_account?.email,  
        name: response.kakao_account?.profile?.nickname,  
        picture: response.kakao_account?.profile?.profile_image_url,  
        provider: Provider.KAKAO,  
        raw: response,  
      };  
    } catch (error) {  
      throw new UnauthorizedException(  
        `Failed to get Kakao user profile: ${error.response?.data?.error_description || error.message}`,  
      );  
    }  
  }  
  
  async refreshToken(refreshToken: string): Promise<OAuthTokenResponse> {  
    const params = {  
      grant_type: 'refresh_token',  
      client_id: this.clientId,  
      client_secret: this.clientSecret,  
      refresh_token: refreshToken,  
    };  
  
    try {  
      return await this.httpRequest<OAuthTokenResponse>(  
        'POST',  
        'https://kauth.kakao.com/oauth/token',  
        {  
          headers: {  
            'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',  
          },  
          data: this.encodeParams(params),  
        },  
      );  
    } catch (error) {  
      throw new UnauthorizedException(  
        `Failed to refresh Kakao token: ${error.response?.data?.error_description || error.message}`,  
      );  
    }  
  }  
  
  async validateToken(accessToken: string): Promise<boolean> {  
    try {  
      await this.httpRequest(  
        'GET',  
        'https://kapi.kakao.com/v1/user/access_token_info',  
        {  
          headers: {  
            Authorization: `Bearer ${accessToken}`,  
          },  
        },  
      );  
      return true;  
    } catch (error) {  
      return false;  
    }  
  }  
}