base.strategy.ts
- OAuth 전략 구현의 추상 클래스로 설계
- 구체적인 인증 제공자(예: Kakao, Naver, Apple)별 전략에서 공통적으로 사용되는 메서드와 인터페이스를 정의
HttpService를 이용해 HTTP 요청을 통합적으로 관리하며, 실패 시 예외 처리를 포함한 헬퍼 메서드(httpRequest)를 제공
- 환경 변수 관리를 통해 동적으로 설정을 주입받아 유연한 구성을 지원
generateState와 encodeParams 메서드로 상태 토큰 생성과 URL 인코딩을 지원하며, 보안과 편의성을 모두 제공
// 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
- Kakao OAuth 인증 로직을 구현한 전략 클래스
- **
BaseOAuthStrategy**를 상속받아 필요한 메서드(generateAuthUrl, getTokens, getUserProfile, refreshToken, validateToken)를 구체적으로 구현
- Kakao 관련 설정(
clientId, clientSecret, redirectUri)을 ConfigService에서 동적으로 주입받아 환경에 따라 유연하게 작동
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;
}
}
}