diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 00000000..29fe9fb2 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,25 @@ +DATABASE_URL="postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + +# JWT +JWT_ACCESS_SECRET="secret-access-key" +JWT_REFRESH_SECRET="secret-refresh-key" +JWT_ACCESS_EXPIRES_IN="15m" +JWT_REFRESH_EXPIRES_IN="7d" + +# Frontend URL (CORS & OAuth Redirect) +FRONTEND_URL="http://localhost:5173" + +# Google OAuth2 +GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=xxxx +GOOGLE_CALLBACK_URL=http://localhost:3000/auth/oauth2/callback/google + +# Naver OAuth2 +NAVER_CLIENT_ID=xxxx +NAVER_CLIENT_SECRET=xxxx +NAVER_CALLBACK_URL=http://localhost:3000/auth/oauth2/callback/naver + +# Kakao OAuth2 +KAKAO_CLIENT_ID=xxxx +KAKAO_CLIENT_SECRET=xxxx +KAKAO_CALLBACK_URL=http://localhost:3000/auth/oauth2/callback/kakao diff --git a/apps/api/eslint.config.mjs b/apps/api/eslint.config.mjs index 1dff67c2..58bf9f00 100644 --- a/apps/api/eslint.config.mjs +++ b/apps/api/eslint.config.mjs @@ -2,24 +2,46 @@ import base from '../../eslint.config.mjs'; import { defineConfig } from 'eslint/config'; import globals from 'globals'; -export default defineConfig(...base, { - files: ['**/*.{ts,js}'], - languageOptions: { - globals: { ...globals.node, ...globals.jest }, - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, +export default defineConfig( + ...base, + { + files: ['**/*.{ts,js}'], + languageOptions: { + globals: { ...globals.node, ...globals.jest }, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-unsafe-argument': 'warn', + '@typescript-eslint/no-extraneous-class': [ + 'error', + { + allowWithDecorator: true, // NestJS 데코레이터가 적용된 class + }, + ], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, }, - rules: { - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-floating-promises': 'warn', - '@typescript-eslint/no-unsafe-argument': 'warn', - '@typescript-eslint/no-extraneous-class': [ - 'error', - { - allowWithDecorator: true, // NestJS 데코레이터가 적용된 class - }, - ], + { + files: ['**/*.spec.ts', '**/*.test.ts', 'test/**/*.ts'], + rules: { + '@typescript-eslint/unbound-method': 'off', // Jest expect 관련 에러 해결 + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', // Mock 데이터 전달 시 any 허용 + }, }, -}); +); diff --git a/apps/api/jest.config.cjs b/apps/api/jest.config.cjs index 3e71b6f4..a39dd467 100644 --- a/apps/api/jest.config.cjs +++ b/apps/api/jest.config.cjs @@ -1,24 +1,24 @@ const path = require('path'); module.exports = { - moduleFileExtensions: ['js', 'json', 'ts'], - rootDir: 'src', - testRegex: '.*\\.spec\\.ts$', - transform: { - '^.+\\.(t|j)s$': 'ts-jest', - }, - collectCoverageFrom: ['**/*.(t|j)s'], - coverageDirectory: '../coverage', - testEnvironment: 'node', - extensionsToTreatAsEsm: ['.ts'], - moduleNameMapper: { - // .js 확장자를 제거하여 TypeScript 파일로 매핑 - '^@/(.*)\\.js$': '/$1', - '^@/(.*)$': '/$1', - // 상대 경로 .js 확장자 제거 (모든 경로에서) - '^(\\.{1,2}/.*)\\.js$': '$1', - '^@locus/shared$': path.resolve(__dirname, '../../packages/shared/src'), - '^@locus/shared/(.*)$': - path.resolve(__dirname, '../../packages/shared/src') + '/$1', - }, + moduleFileExtensions: ['js', 'json', 'ts'], + roots: ['/src', '/test'], + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': 'ts-jest', + }, + collectCoverageFrom: ['**/*.(t|j)s'], + coverageDirectory: '../coverage', + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + // .js 확장자를 제거하여 TypeScript 파일로 매핑 + '^@/(.*)\\.js$': '/src/$1', + '^@/(.*)$': '/src/$1', + // 상대 경로 .js 확장자 제거 (모든 경로에서) + '^(\\.{1,2}/.*)\\.js$': '$1', + '^@locus/shared$': path.resolve(__dirname, '../../packages/shared/src'), + '^@locus/shared/(.*)$': + path.resolve(__dirname, '../../packages/shared/src') + '/$1', + }, }; diff --git a/apps/api/package.json b/apps/api/package.json index d0a662eb..71fd75b1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,7 +1,6 @@ { "name": "@locus/api", "version": "0.0.1", - "type": "module", "description": "Locus API 서버 - 공간 기반 기록 서비스 백엔드", "author": "Team Haping", "private": true, @@ -29,9 +28,18 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.3", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", + "passport-kakao": "^1.0.1", + "passport-oauth2": "^1.8.0", "pg": "^8.16.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" @@ -44,6 +52,10 @@ "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.6.0", + "@types/passport-google-oauth20": "^2.0.17", + "@types/passport-jwt": "^4.0.1", + "@types/passport-kakao": "^1.0.3", + "@types/passport-oauth2": "^1.8.0", "@types/supertest": "^6.0.2", "jest": "^30.1.3", "prisma": "^7.2.0", diff --git a/apps/api/prisma/migrations/20251227090925_init_user_schema/migration.sql b/apps/api/prisma/migrations/20251229051259_init_user_schema/migration.sql similarity index 61% rename from apps/api/prisma/migrations/20251227090925_init_user_schema/migration.sql rename to apps/api/prisma/migrations/20251229051259_init_user_schema/migration.sql index 287f04e4..a8e64157 100644 --- a/apps/api/prisma/migrations/20251227090925_init_user_schema/migration.sql +++ b/apps/api/prisma/migrations/20251229051259_init_user_schema/migration.sql @@ -9,9 +9,9 @@ CREATE TABLE "users" ( "nickname" TEXT, "profile_image_url" TEXT, "provider" "Provider" NOT NULL DEFAULT 'LOCAL', - "providerId" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, + "provider_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, CONSTRAINT "users_pkey" PRIMARY KEY ("id") ); @@ -20,7 +20,4 @@ CREATE TABLE "users" ( CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); -- CreateIndex -CREATE INDEX "users_email_idx" ON "users"("email"); - --- CreateIndex -CREATE INDEX "users_provider_providerId_idx" ON "users"("provider", "providerId"); +CREATE INDEX "users_provider_provider_id_idx" ON "users"("provider", "provider_id"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 06b6e38d..27060d5b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -1,6 +1,5 @@ generator client { - provider = "prisma-client" - output = "../generated/prisma" + provider = "prisma-client-js" } datasource db { @@ -12,16 +11,15 @@ model User { email String @unique password String? // OAuth 사용자는 null nickname String? - profile_image_url String? + profileImageUrl String? @map("profile_image_url") // OAuth 관련 필드 provider Provider @default(LOCAL) - providerId String? // OAuth 제공자의 고유 ID + providerId String? @map("provider_id") // OAuth 제공자의 고유 ID + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([email]) @@index([provider, providerId]) @@map("users") } diff --git a/apps/api/src/app.controller.spec.ts b/apps/api/src/app.controller.spec.ts deleted file mode 100644 index 4abed7d0..00000000 --- a/apps/api/src/app.controller.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller.js'; -import { AppService } from './app.service.js'; - -describe('AppController', () => { - let appController: AppController; - - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - controllers: [AppController], - providers: [AppService], - }).compile(); - - appController = app.get(AppController); - }); - - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); - }); - }); -}); diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts index d99dea9f..cce879ee 100644 --- a/apps/api/src/app.controller.ts +++ b/apps/api/src/app.controller.ts @@ -1,12 +1,12 @@ import { Controller, Get } from '@nestjs/common'; -import { AppService } from './app.service.js'; +import { AppService } from './app.service'; @Controller() export class AppController { - constructor(private readonly appService: AppService) {} + constructor(private readonly appService: AppService) {} - @Get() - getHello(): string { - return this.appService.getHello(); - } + @Get() + getHello(): string { + return this.appService.getHello(); + } } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 5899865a..599a4c7d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,10 +1,18 @@ import { Module } from '@nestjs/common'; -import { AppController } from './app.controller.js'; -import { AppService } from './app.service.js'; -import { PrismaModule } from './prisma/prisma.module.js'; +import { ConfigModule } from '@nestjs/config'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { PrismaModule } from './prisma/prisma.module'; +import { UsersModule } from './users/users.module'; +import { AuthModule } from './auth/auth.module'; @Module({ - imports: [PrismaModule], + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + PrismaModule, + AuthModule, + UsersModule, + ], controllers: [AppController], providers: [AppService], }) diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 00000000..83be63b7 --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,73 @@ +import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; +import { Request, Response } from 'express'; +import { AuthService } from './auth.service'; +import { GoogleAuthGuard } from './guards/google-auth.guard'; +import { User } from '@prisma/client'; +import { ConfigService } from '@nestjs/config'; +import { NaverAuthGuard } from './guards/naver-auth.guard'; +import { KakaoAuthGuard } from './guards/kakao-auth.guard'; + +@Controller('auth') +export class AuthController { + constructor( + private readonly authService: AuthService, + private readonly configService: ConfigService, + ) {} + + @Get('oauth2/google') + @UseGuards(GoogleAuthGuard) + async googleLogin() { + // Guard가 Google 로그인 페이지로 리다이렉트 + } + + @Get('oauth2/naver') + @UseGuards(NaverAuthGuard) + async naverLogin() { + // Guard가 Naver 로그인 페이지로 리다이렉트 + } + + @Get('oauth2/kakao') + @UseGuards(KakaoAuthGuard) + async kakaoLogin() { + // Guard가 Kakao 로그인 페이지로 리다이렉트 + } + + @Get('oauth2/callback/google') + @UseGuards(GoogleAuthGuard) + async googleCallback(@Req() req: Request, @Res() res: Response) { + await this.handleOAuthCallback(req, res); + } + + @Get('oauth2/callback/naver') + @UseGuards(NaverAuthGuard) + async naverCallback(@Req() req: Request, @Res() res: Response) { + await this.handleOAuthCallback(req, res); + } + + @Get('oauth2/callback/kakao') + @UseGuards(KakaoAuthGuard) + async kakaoCallback(@Req() req: Request, @Res() res: Response) { + await this.handleOAuthCallback(req, res); + } + + // TODO: POST /auth/reissue + + private async handleOAuthCallback(req: Request, res: Response) { + const user = req.user as User; + + const tokens = await this.authService.generateTokens(user); + + const redirectUrl = this.buildRedirectUrl( + tokens.accessToken, + tokens.refreshToken, + ); + + res.redirect(redirectUrl); + } + + private buildRedirectUrl(accessToken: string, refreshToken: string): string { + const frontendUrl = this.configService.getOrThrow('FRONTEND_URL'); + + return `${frontendUrl}/auth/callback?accessToken=${accessToken}&refreshToken=${refreshToken}`; + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 00000000..933b9649 --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { UsersModule } from '../users/users.module'; +import { GoogleStrategy } from './strategies/google.strategy'; +import { JwtModule } from '@nestjs/jwt'; +import { JwtProvider } from '@/jwt/jwt.provider'; +import { NaverStrategy } from './strategies/naver.strategy'; +import { KakaoStrategy } from './strategies/kakao.strategy'; + +@Module({ + imports: [JwtModule, UsersModule], + providers: [ + AuthService, + GoogleStrategy, + NaverStrategy, + KakaoStrategy, + JwtProvider, + ], + controllers: [AuthController], +}) +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 00000000..68ca1d39 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -0,0 +1,47 @@ +import { + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { UsersService } from '../users/users.service'; +import { UserWithoutPassword } from '../common/type/user.types'; +import { JwtProvider } from '../jwt/jwt.provider'; + +@Injectable() +export class AuthService { + constructor( + private readonly usersService: UsersService, + private readonly jwtProvider: JwtProvider, + ) {} + + // TODO: 이메일 - 패스워드를 통한 회원가입 / 로그인 기능 + + async generateTokens(user: UserWithoutPassword) { + return { + accessToken: await this.jwtProvider.generateAccessToken( + user.id, + user.email, + user.provider, + ), + refreshToken: await this.jwtProvider.generateRefreshToken(user.id), + }; + } + + async reissueAccessToken(refreshToken: string) { + try { + const userId = await this.jwtProvider.verifyRefreshToken(refreshToken); + + const user = await this.usersService.findById(userId); + const accessToken = await this.jwtProvider.generateAccessToken( + user.id, + user.email, + user.provider, + ); + return { accessToken }; + } catch (error) { + if (error instanceof NotFoundException) throw error; + if (error instanceof UnauthorizedException) throw error; + throw new UnauthorizedException('토큰 갱신에 실패했습니다'); + } + } +} diff --git a/apps/api/src/auth/guards/google-auth.guard.ts b/apps/api/src/auth/guards/google-auth.guard.ts new file mode 100644 index 00000000..4a2c87ac --- /dev/null +++ b/apps/api/src/auth/guards/google-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class GoogleAuthGuard extends AuthGuard('google') {} diff --git a/apps/api/src/auth/guards/kakao-auth.guard.ts b/apps/api/src/auth/guards/kakao-auth.guard.ts new file mode 100644 index 00000000..8d7d5b20 --- /dev/null +++ b/apps/api/src/auth/guards/kakao-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class KakaoAuthGuard extends AuthGuard('kakao') {} diff --git a/apps/api/src/auth/guards/naver-auth.guard.ts b/apps/api/src/auth/guards/naver-auth.guard.ts new file mode 100644 index 00000000..92a00c4e --- /dev/null +++ b/apps/api/src/auth/guards/naver-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class NaverAuthGuard extends AuthGuard('naver') {} diff --git a/apps/api/src/auth/strategies/google.strategy.ts b/apps/api/src/auth/strategies/google.strategy.ts new file mode 100644 index 00000000..116f1b9b --- /dev/null +++ b/apps/api/src/auth/strategies/google.strategy.ts @@ -0,0 +1,58 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ConfigService } from '@nestjs/config'; +import { Profile, Strategy, VerifyCallback } from 'passport-google-oauth20'; +import { Provider } from '@prisma/client'; +import { UsersService } from '../../users/users.service'; + +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { + constructor( + readonly configService: ConfigService, + private readonly usersService: UsersService, + ) { + super({ + clientID: configService.getOrThrow('GOOGLE_CLIENT_ID'), + clientSecret: configService.getOrThrow('GOOGLE_CLIENT_SECRET'), + callbackURL: configService.getOrThrow('GOOGLE_CALLBACK_URL'), + scope: ['email', 'profile'], + }); + } + + async validate( + _accessToken: string, + _refreshToken: string, + profile: Profile, + done: VerifyCallback, + ): Promise { + try { + const userInfo = this.extractGoogleProfile(profile); + + const user = await this.usersService.findOrCreateOAuthUser( + userInfo.email, + userInfo.name, + userInfo.profileImageUrl, + Provider.GOOGLE, + userInfo.id, + ); + + done(null, user); + } catch (error) { + done(error); + } + } + + private extractGoogleProfile(profile: Profile) { + const { id, emails, displayName, photos } = profile; + + const email = emails?.[0]?.value; + const name = displayName; + const profileImageUrl = photos?.[0]?.value ?? null; + + if (!email) { + throw new UnauthorizedException('구글 계정에 이메일 정보가 없습니다.'); + } + + return { id, email, name, profileImageUrl }; + } +} diff --git a/apps/api/src/auth/strategies/kakao.strategy.ts b/apps/api/src/auth/strategies/kakao.strategy.ts new file mode 100644 index 00000000..3c0db3ee --- /dev/null +++ b/apps/api/src/auth/strategies/kakao.strategy.ts @@ -0,0 +1,67 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ConfigService } from '@nestjs/config'; +import { Strategy, Profile } from 'passport-kakao'; +import { Provider } from '@prisma/client'; +import { UsersService } from '@/users/users.service'; + +interface KakaoProfile { + id: number; + kakao_account?: { + email?: string; + profile?: { + nickname?: string; + profile_image_url?: string; + }; + }; +} + +@Injectable() +export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') { + constructor( + readonly configService: ConfigService, + private readonly usersService: UsersService, + ) { + super({ + clientID: configService.getOrThrow('KAKAO_CLIENT_ID'), + clientSecret: configService.getOrThrow('KAKAO_CLIENT_SECRET'), + callbackURL: configService.getOrThrow('KAKAO_CALLBACK_URL'), + }); + } + + async validate( + _accessToken: string, + _refreshToken: string, + profile: Profile, + done: (error: any, user?: any, info?: any) => void, + ): Promise { + try { + const userInfo = this.extractKakaoProfile(profile); + + const user = await this.usersService.findOrCreateOAuthUser( + userInfo.email, + userInfo.name ?? null, + userInfo.profileImageUrl ?? null, + Provider.KAKAO, + String(userInfo.id), + ); + + done(null, user); + } catch (error) { + done(error, null); + } + } + + private extractKakaoProfile(profile: Profile) { + const kakaoProfile = profile._json as KakaoProfile; + const { id, kakao_account: kakaoAccount } = kakaoProfile; + + const email = kakaoAccount?.email; + const name = kakaoAccount?.profile?.nickname; + const profileImageUrl = kakaoAccount?.profile?.profile_image_url; + if (!email) { + throw new UnauthorizedException('Kakao 계정에 이메일 정보가 없습니다.'); + } + return { id, email, name, profileImageUrl }; + } +} diff --git a/apps/api/src/auth/strategies/naver.strategy.ts b/apps/api/src/auth/strategies/naver.strategy.ts new file mode 100644 index 00000000..a9652b5f --- /dev/null +++ b/apps/api/src/auth/strategies/naver.strategy.ts @@ -0,0 +1,97 @@ +import { + Injectable, + UnauthorizedException, + InternalServerErrorException, +} from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ConfigService } from '@nestjs/config'; +import { Strategy } from 'passport-oauth2'; +import { Provider } from '@prisma/client'; +import { UsersService } from '@/users/users.service'; + +const NAVER_OAUTH_CONFIG = { + AUTHORIZATION_URL: 'https://nid.naver.com/oauth2.0/authorize', + TOKEN_URL: 'https://nid.naver.com/oauth2.0/token', + PROFILE_API_URL: 'https://openapi.naver.com/v1/nid/me', +} as const; + +interface NaverResponse { + resultcode: string; + message: string; + response: { + id: string; + email: string; + name?: string; + profile_image?: string; + }; +} + +@Injectable() +export class NaverStrategy extends PassportStrategy(Strategy, 'naver') { + constructor( + readonly configService: ConfigService, + private readonly usersService: UsersService, + ) { + super({ + authorizationURL: NAVER_OAUTH_CONFIG.AUTHORIZATION_URL, + tokenURL: NAVER_OAUTH_CONFIG.TOKEN_URL, + clientID: configService.getOrThrow('NAVER_CLIENT_ID'), + clientSecret: configService.getOrThrow('NAVER_CLIENT_SECRET'), + callbackURL: configService.getOrThrow('NAVER_CALLBACK_URL'), + scope: ['email', 'profileImage', 'name'], + }); + } + + async validate( + accessToken: string, + _refreshToken: string, + _profile: any, + done: (err?: unknown, user?: false | Express.User, info?: object) => void, + ): Promise { + try { + const profileData = await this.getNaverProfile(accessToken); + + const { id, email, name, profile_image } = profileData; + this.validateEmail(email); + + const user = await this.usersService.findOrCreateOAuthUser( + email, + name ?? null, + profile_image ?? null, + Provider.NAVER, + id, + ); + + done(null, user); + } catch (error) { + done(error); + } + } + + private async getNaverProfile( + accessToken: string, + ): Promise { + const response = await fetch(NAVER_OAUTH_CONFIG.PROFILE_API_URL, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + throw new InternalServerErrorException('네이버 API 호출에 실패했습니다.'); + } + + const data = (await response.json()) as NaverResponse; + + if (data.resultcode !== '00') { + throw new UnauthorizedException(`네이버 인증 실패: ${data.message}`); + } + + return data.response; + } + + private validateEmail(email: string): void { + if (!email) { + throw new UnauthorizedException('네이버 계정에 이메일 정보가 없습니다.'); + } + } +} diff --git a/apps/api/src/common/decorators/current-user.decorator.ts b/apps/api/src/common/decorators/current-user.decorator.ts new file mode 100644 index 00000000..6436271c --- /dev/null +++ b/apps/api/src/common/decorators/current-user.decorator.ts @@ -0,0 +1,21 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { JwtPayload } from '@/jwt/jwt-payload.interface'; + +interface RequestWithAuthUser extends Request { + user?: JwtPayload; +} + +export const CurrentUser = createParamDecorator( + (data: keyof JwtPayload | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + + const user = request.user; + + if (!user) return null; + const userInfo = { + ...user, + sub: typeof user.sub === 'string' ? parseInt(user.sub, 10) : user.sub, + }; + return data ? userInfo[data] : userInfo; + }, +); diff --git a/apps/api/src/common/type/user.types.ts b/apps/api/src/common/type/user.types.ts new file mode 100644 index 00000000..0180b128 --- /dev/null +++ b/apps/api/src/common/type/user.types.ts @@ -0,0 +1,3 @@ +import { User } from '@prisma/client'; + +export type UserWithoutPassword = Omit; diff --git a/apps/api/src/jwt/guard/jwt.auth.guard.ts b/apps/api/src/jwt/guard/jwt.auth.guard.ts new file mode 100644 index 00000000..5760f4bc --- /dev/null +++ b/apps/api/src/jwt/guard/jwt.auth.guard.ts @@ -0,0 +1,36 @@ +import { Request } from 'express'; +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtProvider } from '../jwt.provider'; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor(private jwtProvider: JwtProvider) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + + if (!token) { + throw new UnauthorizedException('토큰이 제공되지 않았습니다'); + } + + try { + const payload = await this.jwtProvider.verifyAccessToken(token); + request.user = payload; + } catch { + throw new UnauthorizedException('유효하지 않은 토큰입니다'); + } + + return true; + } + + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/apps/api/src/jwt/jwt-payload.interface.ts b/apps/api/src/jwt/jwt-payload.interface.ts new file mode 100644 index 00000000..2665a986 --- /dev/null +++ b/apps/api/src/jwt/jwt-payload.interface.ts @@ -0,0 +1,9 @@ +import { Provider } from '@prisma/client'; + +export interface JwtPayload { + sub: number; // user id + email: string; + provider: Provider; + iat?: number; + exp?: number; +} diff --git a/apps/api/src/jwt/jwt.module.ts b/apps/api/src/jwt/jwt.module.ts new file mode 100644 index 00000000..a7f1c9aa --- /dev/null +++ b/apps/api/src/jwt/jwt.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { JwtModule as NestJwtModule } from '@nestjs/jwt'; +import { JwtProvider } from './jwt.provider'; + +@Module({ + imports: [NestJwtModule], + providers: [JwtProvider], + exports: [JwtProvider], +}) +export class JwtModule {} diff --git a/apps/api/src/jwt/jwt.provider.ts b/apps/api/src/jwt/jwt.provider.ts new file mode 100644 index 00000000..1c1fddf0 --- /dev/null +++ b/apps/api/src/jwt/jwt.provider.ts @@ -0,0 +1,55 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { Provider } from '@prisma/client'; +import { JwtPayload } from './jwt-payload.interface'; + +@Injectable() +export class JwtProvider { + constructor( + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + async generateAccessToken( + userId: number, + email: string, + provider: Provider, + ): Promise { + const payload: JwtPayload = { sub: userId, email, provider }; + return await this.jwtService.signAsync(payload, { + secret: this.configService.get('JWT_ACCESS_SECRET'), + expiresIn: this.configService.get('JWT_ACCESS_EXPIRES_IN'), + }); + } + + async generateRefreshToken(userId: number): Promise { + const payload = { sub: userId }; + return await this.jwtService.signAsync(payload, { + secret: this.configService.get('JWT_REFRESH_SECRET'), + expiresIn: this.configService.get('JWT_REFRESH_EXPIRES_IN'), + }); + } + + async verifyAccessToken(token: string): Promise { + try { + return await this.jwtService.verifyAsync(token, { + secret: this.configService.get('JWT_ACCESS_SECRET'), + }); + } catch { + throw new UnauthorizedException('유효하지 않은 Access Token입니다'); + } + } + + async verifyRefreshToken(token: string): Promise { + try { + const payload = await this.jwtService.verifyAsync<{ sub: number }>( + token, + { secret: this.configService.get('JWT_REFRESH_SECRET') }, + ); + return payload.sub; + } catch { + throw new UnauthorizedException('유효하지 않은 Refresh Token입니다'); + } + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index eebf6458..392ca1cf 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,5 +1,5 @@ import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module.js'; +import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -7,4 +7,4 @@ async function bootstrap() { app.enableShutdownHooks(); } -bootstrap(); +void bootstrap(); diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts index efb7a8dc..4106fdd5 100644 --- a/apps/api/src/prisma/prisma.module.ts +++ b/apps/api/src/prisma/prisma.module.ts @@ -1,6 +1,5 @@ import { Global, Module } from '@nestjs/common'; -import { PrismaService } from './prisma.service.js'; - +import { PrismaService } from './prisma.service'; @Global() @Module({ providers: [PrismaService], diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts index c456106c..e550342d 100644 --- a/apps/api/src/prisma/prisma.service.ts +++ b/apps/api/src/prisma/prisma.service.ts @@ -6,7 +6,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaPg } from '@prisma/adapter-pg'; -import { PrismaClient } from '../../generated/prisma/client.js'; +import { PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts new file mode 100644 index 00000000..56e79814 --- /dev/null +++ b/apps/api/src/users/users.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { UsersService } from './users.service'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { JwtAuthGuard } from '../jwt/guard/jwt.auth.guard'; + +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + // NOTE: 요 친구는 테스트용이라 추후 수정해야 해요!@! + @Get('me') + @UseGuards(JwtAuthGuard) + async getMyProfile(@CurrentUser('sub') userId: number) { + const user = await this.usersService.findById(userId); + return user; + } +} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts new file mode 100644 index 00000000..c594e17a --- /dev/null +++ b/apps/api/src/users/users.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { UsersService } from './users.service'; +import { UsersController } from './users.controller'; +import { JwtModule } from '@/jwt/jwt.module'; + +@Module({ + imports: [JwtModule], + providers: [UsersService], + exports: [UsersService], + controllers: [UsersController], +}) +export class UsersModule {} diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts new file mode 100644 index 00000000..a731f478 --- /dev/null +++ b/apps/api/src/users/users.service.ts @@ -0,0 +1,64 @@ +import { + Injectable, + ConflictException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { Provider, User } from '@prisma/client'; + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + // upsert + async findOrCreateOAuthUser( + email: string, + nickname: string | null, + profileImageUrl: string | null, + provider: Provider, + providerId: string, + ): Promise { + let user = await this.prisma.user.findFirst({ + where: { provider, providerId }, + }); + + if (user) return user; + + // 같은 이메일로 다른 Provider로 이미 가입된 사용자가 있는지 확인. + user = await this.prisma.user.findUnique({ where: { email } }); + + if (user) { + throw new ConflictException( + `이 이메일은 이미 ${user.provider} 계정으로 가입되어 있습니다.`, + ); + } + + try { + user = await this.prisma.user.create({ + data: { + email, + nickname, + profileImageUrl, + provider, + providerId, + }, + }); + + return user; + } catch (_error) { + throw new InternalServerErrorException( + '사용자 생성 중 오류가 발생했습니다.', + ); + } + } + + async findById(id: number): Promise> { + const user = await this.prisma.user.findUnique({ where: { id } }); + + if (!user) throw new NotFoundException('사용자를 찾을 수 없습니다.'); + + const { password: _, ...userWithoutPassword } = user; + return userWithoutPassword; + } +} diff --git a/apps/api/test/auth/auth.service.spec.ts b/apps/api/test/auth/auth.service.spec.ts new file mode 100644 index 00000000..11a56ead --- /dev/null +++ b/apps/api/test/auth/auth.service.spec.ts @@ -0,0 +1,291 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { Provider } from '@prisma/client'; +import { AuthService } from '../../src/auth/auth.service'; +import { UsersService } from '../../src/users/users.service'; +import { JwtProvider } from '../../src/jwt/jwt.provider'; +import { UserWithoutPassword } from '../../src/common/type/user.types'; + +describe('AuthService 테스트', () => { + let authService: AuthService; + let jwtProvider: JwtProvider; + let usersService: UsersService; + + const mockUser: UserWithoutPassword = { + id: 1, + email: 'test@example.com', + nickname: 'Test User', + provider: Provider.GOOGLE, + providerId: 'google-123', + profileImageUrl: 'https://example.com/photo.jpg', + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockJwtProvider = { + generateAccessToken: jest.fn(), + generateRefreshToken: jest.fn(), + verifyRefreshToken: jest.fn(), + }; + + const mockUsersService = { + findById: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { + provide: JwtProvider, + useValue: mockJwtProvider, + }, + { + provide: UsersService, + useValue: mockUsersService, + }, + ], + }).compile(); + + authService = module.get(AuthService); + jwtProvider = module.get(JwtProvider); + usersService = module.get(UsersService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('generateTokens', () => { + test('액세스 토큰과 리프레시 토큰을 모두 생성해야 한다', async () => { + // given + const accessToken = 'access-token'; + const refreshToken = 'refresh-token'; + + mockJwtProvider.generateAccessToken.mockResolvedValue(accessToken); + mockJwtProvider.generateRefreshToken.mockResolvedValue(refreshToken); + + // when + const result = await authService.generateTokens(mockUser); + + // then + expect(result).toEqual({ + accessToken, + refreshToken, + }); + expect(jwtProvider.generateAccessToken).toHaveBeenCalledWith( + mockUser.id, + mockUser.email, + mockUser.provider, + ); + expect(jwtProvider.generateRefreshToken).toHaveBeenCalledWith( + mockUser.id, + ); + }); + + test('제대로된 사용자 정보로 액세스 토큰을 생성해야 한다', async () => { + // given + mockJwtProvider.generateAccessToken.mockResolvedValue('access-token'); + mockJwtProvider.generateRefreshToken.mockResolvedValue('refresh-token'); + + // when + await authService.generateTokens(mockUser); + + // then + expect(jwtProvider.generateAccessToken).toHaveBeenCalledWith( + mockUser.id, + mockUser.email, + mockUser.provider, + ); + expect(jwtProvider.generateAccessToken).toHaveBeenCalledTimes(1); + }); + + test('사용자 ID로 리프레시 토큰을 생성해야 한다', async () => { + // given + mockJwtProvider.generateAccessToken.mockResolvedValue('access-token'); + mockJwtProvider.generateRefreshToken.mockResolvedValue('refresh-token'); + + // when + await authService.generateTokens(mockUser); + + // then + expect(jwtProvider.generateRefreshToken).toHaveBeenCalledWith( + mockUser.id, + ); + expect(jwtProvider.generateRefreshToken).toHaveBeenCalledTimes(1); + }); + + test('사용자 정보가 null이면 에러가 발생해야 한다', async () => { + // when & then + await expect(authService.generateTokens(null as any)).rejects.toThrow(); + }); + + test('사용자 정보가 undefined이면 에러가 발생해야 한다', async () => { + // when & then + await expect( + authService.generateTokens(undefined as any), + ).rejects.toThrow(); + }); + + test('액세스 토큰 생성 중 에러가 발생하면 에러를 던져야 한다', async () => { + // given + mockJwtProvider.generateAccessToken.mockRejectedValue( + new Error('Access token generation failed'), + ); + + // when & then + await expect(authService.generateTokens(mockUser)).rejects.toThrow( + 'Access token generation failed', + ); + }); + + test('리프레시 토큰 생성 중 에러가 발생하면 에러를 던져야 한다', async () => { + // given + mockJwtProvider.generateAccessToken.mockResolvedValue('access-token'); + mockJwtProvider.generateRefreshToken.mockRejectedValue( + new Error('Refresh token generation failed'), + ); + + // when & then + await expect(authService.generateTokens(mockUser)).rejects.toThrow( + 'Refresh token generation failed', + ); + }); + }); + + describe('reissueAccessToken', () => { + const validRefreshToken = 'valid-refresh-token'; + const userId = 1; + + test('유효한 리프레시 토큰으로 새 액세스 토큰을 발급해야 한다', async () => { + // given + const newAccessToken = 'new-access-token'; + mockJwtProvider.verifyRefreshToken.mockResolvedValue(userId); + mockUsersService.findById.mockResolvedValue(mockUser); + mockJwtProvider.generateAccessToken.mockResolvedValue(newAccessToken); + + // when + const result = await authService.reissueAccessToken(validRefreshToken); + + // then + expect(result).toEqual({ accessToken: newAccessToken }); + expect(jwtProvider.verifyRefreshToken).toHaveBeenCalledWith( + validRefreshToken, + ); + expect(usersService.findById).toHaveBeenCalledWith(userId); + }); + + test('리프레시 토큰 검증 후 올바른 사용자 ID로 조회해야 한다', async () => { + // given + mockJwtProvider.verifyRefreshToken.mockResolvedValue(userId); + mockUsersService.findById.mockResolvedValue(mockUser); + mockJwtProvider.generateAccessToken.mockResolvedValue('token'); + + // when + await authService.reissueAccessToken(validRefreshToken); + + // then + expect(jwtProvider.verifyRefreshToken).toHaveBeenCalledWith( + validRefreshToken, + ); + expect(usersService.findById).toHaveBeenCalledWith(userId); + expect(usersService.findById).toHaveBeenCalledTimes(1); + }); + + test('조회한 사용자 정보로 새 액세스 토큰을 생성해야 한다', async () => { + // given + mockJwtProvider.verifyRefreshToken.mockResolvedValue(userId); + mockUsersService.findById.mockResolvedValue(mockUser); + mockJwtProvider.generateAccessToken.mockResolvedValue('new-token'); + + // when + await authService.reissueAccessToken(validRefreshToken); + + // then + expect(jwtProvider.generateAccessToken).toHaveBeenCalledWith( + mockUser.id, + mockUser.email, + mockUser.provider, + ); + }); + + test('만료된 리프레시 토큰이면 UnauthorizedException을 던져야 한다', async () => { + // given + mockJwtProvider.verifyRefreshToken.mockRejectedValue( + new UnauthorizedException('토큰이 만료되었습니다'), + ); + + // when & then + await expect( + authService.reissueAccessToken(validRefreshToken), + ).rejects.toThrow(UnauthorizedException); + await expect( + authService.reissueAccessToken(validRefreshToken), + ).rejects.toThrow('토큰이 만료되었습니다'); + }); + + test('잘못된 형식의 리프레시 토큰이면 UnauthorizedException을 던져야 한다', async () => { + // given + mockJwtProvider.verifyRefreshToken.mockRejectedValue( + new UnauthorizedException('유효하지 않은 토큰입니다'), + ); + + // when & then + await expect( + authService.reissueAccessToken('invalid-token'), + ).rejects.toThrow(UnauthorizedException); + }); + + test('변조된 리프레시 토큰이면 UnauthorizedException을 던져야 한다', async () => { + // given + mockJwtProvider.verifyRefreshToken.mockRejectedValue( + new UnauthorizedException('토큰 서명이 유효하지 않습니다'), + ); + + // when & then + await expect( + authService.reissueAccessToken('tampered-token'), + ).rejects.toThrow(UnauthorizedException); + }); + + test('빈 문자열 토큰이면 UnauthorizedException을 던져야 한다', async () => { + // given + mockJwtProvider.verifyRefreshToken.mockRejectedValue( + new UnauthorizedException('토큰이 제공되지 않았습니다'), + ); + + // when & then + await expect(authService.reissueAccessToken('')).rejects.toThrow( + UnauthorizedException, + ); + }); + + test('null 토큰이면 UnauthorizedException을 던져야 한다', async () => { + // given + mockJwtProvider.verifyRefreshToken.mockRejectedValue( + new UnauthorizedException('토큰이 제공되지 않았습니다'), + ); + + // when & then + await expect(authService.reissueAccessToken(null as any)).rejects.toThrow( + UnauthorizedException, + ); + }); + + test('사용자를 찾을 수 없으면 NotFoundException을 던져야 한다', async () => { + // given + mockJwtProvider.verifyRefreshToken.mockResolvedValue(userId); + mockUsersService.findById.mockRejectedValue( + new NotFoundException('사용자를 찾을 수 없습니다.'), + ); + + // when & then + await expect( + authService.reissueAccessToken(validRefreshToken), + ).rejects.toThrow(NotFoundException); + await expect( + authService.reissueAccessToken(validRefreshToken), + ).rejects.toThrow('사용자를 찾을 수 없습니다.'); + }); + }); +}); diff --git a/apps/api/test/auth/jwt/jwt.provider.spec.ts b/apps/api/test/auth/jwt/jwt.provider.spec.ts new file mode 100644 index 00000000..9142d4e7 --- /dev/null +++ b/apps/api/test/auth/jwt/jwt.provider.spec.ts @@ -0,0 +1,288 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { UnauthorizedException } from '@nestjs/common'; +import { Provider } from '@prisma/client'; +import { JwtProvider } from '../../../src/jwt/jwt.provider'; +import { JwtPayload } from '../../../src/jwt/jwt-payload.interface'; + +describe('JwtProvider 테스트', () => { + let jwtProvider: JwtProvider; + + const mockJwtService = { + signAsync: jest.fn(), + verifyAsync: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JwtProvider, + { + provide: JwtService, + useValue: mockJwtService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + jwtProvider = module.get(JwtProvider); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('generateAccessToken', () => { + test('유저 정보로 Access Token을 생성해야 한다', async () => { + // given + const userId = 1; + const email = 'test@example.com'; + const provider = Provider.LOCAL; + const expectedToken = 'access-token'; + const accessSecret = 'access-secret'; + const expiresIn = '15m'; + + mockConfigService.get + .mockReturnValueOnce(accessSecret) + .mockReturnValueOnce(expiresIn); + mockJwtService.signAsync.mockResolvedValue(expectedToken); + + // when + const result = await jwtProvider.generateAccessToken( + userId, + email, + provider, + ); + + // then + expect(result).toBe(expectedToken); + expect(mockJwtService.signAsync).toHaveBeenCalledWith( + { sub: userId, email, provider }, + { secret: accessSecret, expiresIn }, + ); + expect(mockConfigService.get).toHaveBeenCalledWith('JWT_ACCESS_SECRET'); + expect(mockConfigService.get).toHaveBeenCalledWith( + 'JWT_ACCESS_EXPIRES_IN', + ); + }); + + test('에러가 발생하면 에러를 전파해야 한다', async () => { + // given + const userId = 1; + const email = 'test@example.com'; + const provider = Provider.LOCAL; + const error = new Error('JWT signing failed'); + + mockConfigService.get.mockReturnValue('secret'); + mockJwtService.signAsync.mockRejectedValue(error); + + // when & then + await expect( + jwtProvider.generateAccessToken(userId, email, provider), + ).rejects.toThrow(error); + }); + }); + + describe('generateRefreshToken', () => { + test('유저 ID로 Refresh Token을 생성해야 한다', async () => { + // given + const userId = 1; + const expectedToken = 'refresh-token'; + const refreshSecret = 'refresh-secret'; + const expiresIn = '7d'; + + mockConfigService.get + .mockReturnValueOnce(refreshSecret) + .mockReturnValueOnce(expiresIn); + mockJwtService.signAsync.mockResolvedValue(expectedToken); + + // when + const result = await jwtProvider.generateRefreshToken(userId); + + // then + expect(result).toBe(expectedToken); + expect(mockJwtService.signAsync).toHaveBeenCalledWith( + { sub: userId }, + { secret: refreshSecret, expiresIn }, + ); + expect(mockConfigService.get).toHaveBeenCalledWith('JWT_REFRESH_SECRET'); + expect(mockConfigService.get).toHaveBeenCalledWith( + 'JWT_REFRESH_EXPIRES_IN', + ); + }); + + test('JWT 서비스에서 에러가 발생하면 에러를 전파해야 한다', async () => { + // given + const userId = 1; + const error = new Error('JWT signing failed'); + + mockConfigService.get.mockReturnValue('secret'); + mockJwtService.signAsync.mockRejectedValue(error); + + // when & then + await expect(jwtProvider.generateRefreshToken(userId)).rejects.toThrow( + error, + ); + }); + }); + + describe('verifyAccessToken', () => { + test('유효한 Access Token을 검증하고 jwt 페이로드를 반환해야 한다', async () => { + // given + const token = 'valid-access-token'; + const accessSecret = 'access-secret'; + const expectedPayload: JwtPayload = { + sub: 1, + email: 'test@example.com', + provider: Provider.LOCAL, + }; + + mockConfigService.get.mockReturnValue(accessSecret); + mockJwtService.verifyAsync.mockResolvedValue(expectedPayload); + + // when + const result = await jwtProvider.verifyAccessToken(token); + + // then + expect(result).toEqual(expectedPayload); + expect(mockJwtService.verifyAsync).toHaveBeenCalledWith(token, { + secret: accessSecret, + }); + expect(mockConfigService.get).toHaveBeenCalledWith('JWT_ACCESS_SECRET'); + }); + + test('유효하지 않은 Access Token이면 UnauthorizedException을 던져야 한다', async () => { + // given + const token = 'invalid-token'; + const accessSecret = 'access-secret'; + + mockConfigService.get.mockReturnValue(accessSecret); + mockJwtService.verifyAsync.mockRejectedValue( + new Error('Token verification failed'), + ); + + // when & then + await expect(jwtProvider.verifyAccessToken(token)).rejects.toThrow( + new UnauthorizedException('유효하지 않은 Access Token입니다'), + ); + }); + + test('만료된 Access Token이면 UnauthorizedException을 던져야 한다', async () => { + // given + const token = 'expired-token'; + const accessSecret = 'access-secret'; + + mockConfigService.get.mockReturnValue(accessSecret); + mockJwtService.verifyAsync.mockRejectedValue(new Error('Token expired')); + + // when & then + await expect(jwtProvider.verifyAccessToken(token)).rejects.toThrow( + UnauthorizedException, + ); + }); + + test('빈 문자열 토큰이면 UnauthorizedException을 던져야 한다', async () => { + // given + const token = ''; + const accessSecret = 'access-secret'; + + mockConfigService.get.mockReturnValue(accessSecret); + mockJwtService.verifyAsync.mockRejectedValue(new Error('Invalid token')); + + // when & then + await expect(jwtProvider.verifyAccessToken(token)).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + describe('verifyRefreshToken', () => { + test('유효한 Refresh Token을 검증하고 유저 ID를 반환해야 한다', async () => { + // given + const token = 'valid-refresh-token'; + const refreshSecret = 'refresh-secret'; + const expectedUserId = 1; + + mockConfigService.get.mockReturnValue(refreshSecret); + mockJwtService.verifyAsync.mockResolvedValue({ sub: expectedUserId }); + + // when + const result = await jwtProvider.verifyRefreshToken(token); + + // then + expect(result).toBe(expectedUserId); + expect(mockJwtService.verifyAsync).toHaveBeenCalledWith(token, { + secret: refreshSecret, + }); + expect(mockConfigService.get).toHaveBeenCalledWith('JWT_REFRESH_SECRET'); + }); + + test('유효하지 않은 Refresh Token이면 UnauthorizedException을 던져야 한다', async () => { + // given + const token = 'invalid-token'; + const refreshSecret = 'refresh-secret'; + + mockConfigService.get.mockReturnValue(refreshSecret); + mockJwtService.verifyAsync.mockRejectedValue( + new Error('Token verification failed'), + ); + + // when & then + await expect(jwtProvider.verifyRefreshToken(token)).rejects.toThrow( + new UnauthorizedException('유효하지 않은 Refresh Token입니다'), + ); + }); + + test('만료된 Refresh Token이면 UnauthorizedException을 던져야 한다', async () => { + // given + const token = 'expired-token'; + const refreshSecret = 'refresh-secret'; + + mockConfigService.get.mockReturnValue(refreshSecret); + mockJwtService.verifyAsync.mockRejectedValue(new Error('Token expired')); + + // when & then + await expect(jwtProvider.verifyRefreshToken(token)).rejects.toThrow( + UnauthorizedException, + ); + }); + + test('빈 문자열 토큰이면 UnauthorizedException을 던져야 한다', async () => { + // given + const token = ''; + const refreshSecret = 'refresh-secret'; + + mockConfigService.get.mockReturnValue(refreshSecret); + mockJwtService.verifyAsync.mockRejectedValue(new Error('Invalid token')); + + // when & then + await expect(jwtProvider.verifyRefreshToken(token)).rejects.toThrow( + UnauthorizedException, + ); + }); + + test('잘못된 형식의 토큰이면 UnauthorizedException을 던져야 한다', async () => { + // given + const token = 'malformed.token'; + const refreshSecret = 'refresh-secret'; + + mockConfigService.get.mockReturnValue(refreshSecret); + mockJwtService.verifyAsync.mockRejectedValue( + new Error('Malformed token'), + ); + + // when & then + await expect(jwtProvider.verifyRefreshToken(token)).rejects.toThrow( + UnauthorizedException, + ); + }); + }); +}); diff --git a/apps/api/test/auth/strategies/google.strategy.spec.ts b/apps/api/test/auth/strategies/google.strategy.spec.ts new file mode 100644 index 00000000..2662234a --- /dev/null +++ b/apps/api/test/auth/strategies/google.strategy.spec.ts @@ -0,0 +1,197 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { Provider } from '@prisma/client'; +import { GoogleStrategy } from '../../../src/auth/strategies/google.strategy'; +import { UsersService } from '../../../src/users/users.service'; +import { UnauthorizedException } from '@nestjs/common'; + +describe('GoogleStrategy 테스트', () => { + let strategy: GoogleStrategy; + let usersService: UsersService; + let configService: ConfigService; + + const mockConfigService = { + getOrThrow: jest.fn((key: string) => { + const config = { + GOOGLE_CLIENT_ID: 'test-client-id', + GOOGLE_CLIENT_SECRET: 'test-client-secret', + GOOGLE_CALLBACK_URL: 'http://localhost:3000/auth/google/callback', + }; + return config[key]; + }), + }; + + const mockUsersService = { + findOrCreateOAuthUser: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GoogleStrategy, + { + provide: ConfigService, + useValue: mockConfigService, + }, + { + provide: UsersService, + useValue: mockUsersService, + }, + ], + }).compile(); + + strategy = module.get(GoogleStrategy); + usersService = module.get(UsersService); + configService = module.get(ConfigService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('validate 메서드 테스트', () => { + const mockProfile = { + id: 'google-user-id-123', + displayName: 'beomsic', + emails: [{ value: 'beomsic@google.com', verified: true }], + photos: [{ value: 'https://example.com/photo.jpg' }], + provider: 'google', + }; + + const mockUser = { + id: 1, + email: 'beomsic@google.com', + name: 'beomsic', + profileImageUrl: 'https://example.com/photo.jpg', + provider: Provider.GOOGLE, + providerId: 'google-user-id-123', + }; + + test('authentication에 성공하면 validate를 실행한 후 user를 return 해야 한다', async () => { + // given + const done = jest.fn(); + mockUsersService.findOrCreateOAuthUser.mockResolvedValue(mockUser); + + // when + await strategy.validate( + 'access-token', + 'refresh-token', + mockProfile as any, + done, + ); + + // then + expect(usersService.findOrCreateOAuthUser).toHaveBeenCalledWith( + 'beomsic@google.com', + 'beomsic', + 'https://example.com/photo.jpg', + Provider.GOOGLE, + 'google-user-id-123', + ); + expect(done).toHaveBeenCalledWith(null, mockUser); + }); + + test('이메일 정보가 없는 경우 UnauthorizedException을 던져야 한다', async () => { + // given + const done = jest.fn(); + const profileWithoutEmail = { + ...mockProfile, + emails: [], + }; + mockUsersService.findOrCreateOAuthUser.mockResolvedValue(mockUser); + + // when + await strategy.validate( + 'access-token', + 'refresh-token', + profileWithoutEmail as any, + done, + ); + + // then + expect(usersService.findOrCreateOAuthUser).not.toHaveBeenCalled(); + expect(done).toHaveBeenCalledWith(expect.any(UnauthorizedException)); + }); + + test('이메일이 undefined인 경우 UnauthorizedException을 던져야 한다', async () => { + // given + const done = jest.fn(); + const profileWithUndefinedEmails = { + ...mockProfile, + emails: undefined, + }; + mockUsersService.findOrCreateOAuthUser.mockResolvedValue(mockUser); + + // when + await strategy.validate( + 'access-token', + 'refresh-token', + profileWithUndefinedEmails as any, + done, + ); + + // then + expect(usersService.findOrCreateOAuthUser).not.toHaveBeenCalled(); + expect(done).toHaveBeenCalledWith(expect.any(UnauthorizedException)); + }); + + test('프로필 이미지가 없는 경우 null값을 넣어줘야 한다', async () => { + // given + const done = jest.fn(); + const profileWithoutPhoto = { + ...mockProfile, + photos: [], + }; + mockUsersService.findOrCreateOAuthUser.mockResolvedValue(mockUser); + + // when + await strategy.validate( + 'access-token', + 'refresh-token', + profileWithoutPhoto as any, + done, + ); + + // then + expect(usersService.findOrCreateOAuthUser).toHaveBeenCalledWith( + 'beomsic@google.com', + 'beomsic', + null, + Provider.GOOGLE, + 'google-user-id-123', + ); + }); + + test('유저 서비스에서 에러가 생할 경우 error done 을 실행해야 한다', async () => { + // given + const done = jest.fn(); + const error = new Error('데이터베이스 에러'); + mockUsersService.findOrCreateOAuthUser.mockRejectedValue(error); + + // when + await strategy.validate( + 'access-token', + 'refresh-token', + mockProfile as any, + done, + ); + + // then + expect(done).toHaveBeenCalledWith(error); + expect(done).not.toHaveBeenCalledWith(null, expect.anything()); + }); + }); + + describe('생성자 테스트', () => { + test('환경변수 값을 통해서 init 되어야 한다', () => { + // when & then + expect(configService.getOrThrow).toHaveBeenCalledWith('GOOGLE_CLIENT_ID'); + expect(configService.getOrThrow).toHaveBeenCalledWith( + 'GOOGLE_CLIENT_SECRET', + ); + expect(configService.getOrThrow).toHaveBeenCalledWith( + 'GOOGLE_CALLBACK_URL', + ); + }); + }); +}); diff --git a/apps/api/test/auth/strategies/kakao.strategy.spec.ts b/apps/api/test/auth/strategies/kakao.strategy.spec.ts new file mode 100644 index 00000000..9548acc7 --- /dev/null +++ b/apps/api/test/auth/strategies/kakao.strategy.spec.ts @@ -0,0 +1,247 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { UnauthorizedException } from '@nestjs/common'; +import { Profile } from 'passport-kakao'; +import { KakaoStrategy } from '../../../src/auth/strategies/kakao.strategy'; +import { UsersService } from '../../../src/users/users.service'; +import { Provider } from '@prisma/client'; + +describe('KakaoStrategy 테스트', () => { + let strategy: KakaoStrategy; + let usersService: UsersService; + + const mockConfigService = { + getOrThrow: jest.fn((key: string) => { + const config = { + KAKAO_CLIENT_ID: 'test-kakao-client-id', + KAKAO_CLIENT_SECRET: 'test-kakao-client-secret', + KAKAO_CALLBACK_URL: 'http://localhost:3000/auth/kakao/callback', + }; + return config[key]; + }), + }; + + const mockUsersService = { + findOrCreateOAuthUser: jest.fn(), + }; + + const mockUser = { + id: 1, + email: 'test@kakao.com', + nickname: 'Test User', + profileImageUrl: 'https://kakao.com/profile.jpg', + provider: Provider.KAKAO, + providerId: 'kakao-123', + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + KakaoStrategy, + { + provide: ConfigService, + useValue: mockConfigService, + }, + { + provide: UsersService, + useValue: mockUsersService, + }, + ], + }).compile(); + + strategy = module.get(KakaoStrategy); + usersService = module.get(UsersService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('validate', () => { + const accessToken = 'valid-access-token'; + const refreshToken = 'refresh-token'; + const done = jest.fn(); + + const createMockProfile = ( + id: number, + email?: string, + nickname?: string, + profileImageUrl?: string, + ): Profile => { + return { + provider: 'kakao', + id: String(id), + displayName: nickname ?? '', + _json: { + id, + kakao_account: { + email, + profile: { + nickname, + profile_image_url: profileImageUrl, + }, + }, + }, + } as Profile; + }; + + beforeEach(() => { + done.mockClear(); + }); + + test('카카오 프로필로 사용자를 생성하거나 조회해야 한다', async () => { + // given + const mockProfile = createMockProfile( + 123456789, + 'test@kakao.com', + 'Test User', + 'https://kakao.com/profile.jpg', + ); + mockUsersService.findOrCreateOAuthUser.mockResolvedValue(mockUser); + + // when + await strategy.validate(accessToken, refreshToken, mockProfile, done); + + // then + expect(usersService.findOrCreateOAuthUser).toHaveBeenCalledWith( + 'test@kakao.com', + 'Test User', + 'https://kakao.com/profile.jpg', + Provider.KAKAO, + '123456789', + ); + expect(done).toHaveBeenCalledWith(null, mockUser); + }); + + test('사용자 정보로 findOrCreateOAuthUser를 호출해야 한다', async () => { + // given + const mockProfile = createMockProfile( + 987654321, + 'user@kakao.com', + 'Kakao User', + 'https://kakao.com/user.jpg', + ); + mockUsersService.findOrCreateOAuthUser.mockResolvedValue(mockUser); + + // when + await strategy.validate(accessToken, refreshToken, mockProfile, done); + + // then + expect(usersService.findOrCreateOAuthUser).toHaveBeenCalledWith( + 'user@kakao.com', + 'Kakao User', + 'https://kakao.com/user.jpg', + Provider.KAKAO, + '987654321', + ); + }); + + test('nickname이 없어도 사용자를 생성할 수 있어야 한다', async () => { + // given + const mockProfile = createMockProfile( + 123456789, + 'test@kakao.com', + undefined, + 'https://kakao.com/profile.jpg', + ); + mockUsersService.findOrCreateOAuthUser.mockResolvedValue(mockUser); + + // when + await strategy.validate(accessToken, refreshToken, mockProfile, done); + + // then + expect(usersService.findOrCreateOAuthUser).toHaveBeenCalledWith( + 'test@kakao.com', + null, + 'https://kakao.com/profile.jpg', + Provider.KAKAO, + '123456789', + ); + }); + + test('profile_image_url이 없어도 사용자를 생성할 수 있어야 한다', async () => { + // given + const mockProfile = createMockProfile( + 123456789, + 'test@kakao.com', + 'Test User', + undefined, + ); + mockUsersService.findOrCreateOAuthUser.mockResolvedValue(mockUser); + + // when + await strategy.validate(accessToken, refreshToken, mockProfile, done); + + // then + expect(usersService.findOrCreateOAuthUser).toHaveBeenCalledWith( + 'test@kakao.com', + 'Test User', + null, + Provider.KAKAO, + '123456789', + ); + }); + + test('이메일이 없으면 UnauthorizedException을 던져야 한다', async () => { + // given + const mockProfile = createMockProfile( + 123456789, + undefined, + 'Test User', + 'https://kakao.com/profile.jpg', + ); + + // when + await strategy.validate(accessToken, refreshToken, mockProfile, done); + + // then + expect(done).toHaveBeenCalledWith( + expect.any(UnauthorizedException), + null, + ); + expect(done.mock.calls[0][0].message).toBe( + 'Kakao 계정에 이메일 정보가 없습니다.', + ); + }); + + test('kakao_account가 없으면 UnauthorizedException을 던져야 한다', async () => { + // given + const mockProfile = { + provider: 'kakao', + id: '123456789', + displayName: 'Test User', + _json: { + id: 123456789, + }, + } as Profile; + + // when + await strategy.validate(accessToken, refreshToken, mockProfile, done); + + // then + expect(done).toHaveBeenCalledWith( + expect.any(UnauthorizedException), + null, + ); + }); + + test('UsersService에서 ConflictException이 발생하면 done에 에러를 전달해야 한다', async () => { + // given + const error = new Error('이미 가입된 이메일입니다'); + const mockProfile = createMockProfile( + 123456789, + 'test@kakao.com', + 'Test User', + ); + mockUsersService.findOrCreateOAuthUser.mockRejectedValue(error); + + // when + await strategy.validate(accessToken, refreshToken, mockProfile, done); + + // then + expect(done).toHaveBeenCalledWith(error, null); + }); + }); +}); diff --git a/apps/api/test/auth/strategies/naver.strategy.spec.ts b/apps/api/test/auth/strategies/naver.strategy.spec.ts new file mode 100644 index 00000000..6775444f --- /dev/null +++ b/apps/api/test/auth/strategies/naver.strategy.spec.ts @@ -0,0 +1,280 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { + InternalServerErrorException, + UnauthorizedException, +} from '@nestjs/common'; +import { NaverStrategy } from '../../../src/auth/strategies/naver.strategy'; +import { UsersService } from '../../../src/users/users.service'; +import { Provider } from '@prisma/client'; + +describe('NaverStrategy 테스트', () => { + let strategy: NaverStrategy; + let usersService: UsersService; + + const mockConfigService = { + getOrThrow: jest.fn((key: string) => { + const config = { + NAVER_CLIENT_ID: 'test-naver-client-id', + NAVER_CLIENT_SECRET: 'test-naver-client-secret', + NAVER_CALLBACK_URL: 'http://localhost:3000/auth/naver/callback', + }; + return config[key]; + }), + }; + + const mockUsersService = { + findOrCreateOAuthUser: jest.fn(), + }; + + const mockUser = { + id: 1, + email: 'test@naver.com', + nickname: 'Test User', + profileImageUrl: 'https://naver.com/profile.jpg', + provider: Provider.NAVER, + providerId: 'naver-123', + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NaverStrategy, + { + provide: ConfigService, + useValue: mockConfigService, + }, + { + provide: UsersService, + useValue: mockUsersService, + }, + ], + }).compile(); + + strategy = module.get(NaverStrategy); + usersService = module.get(UsersService); + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('validate', () => { + const accessToken = 'valid-access-token'; + const refreshToken = 'refresh-token'; + const profile = {}; + const done = jest.fn(); + + const mockNaverResponse = { + resultcode: '00', + message: 'success', + response: { + id: 'naver-123', + email: 'test@naver.com', + name: 'Test User', + profile_image: 'https://naver.com/profile.jpg', + }, + }; + + beforeEach(() => { + done.mockClear(); + }); + + test('유효한 액세스 토큰으로 사용자를 생성하거나 조회해야 한다', async () => { + // given + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => mockNaverResponse, + }); + mockUsersService.findOrCreateOAuthUser.mockResolvedValue(mockUser); + + // when + await strategy.validate(accessToken, refreshToken, profile, done); + + // then + expect(global.fetch).toHaveBeenCalledWith( + 'https://openapi.naver.com/v1/nid/me', + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + expect(usersService.findOrCreateOAuthUser).toHaveBeenCalledWith( + 'test@naver.com', + 'Test User', + 'https://naver.com/profile.jpg', + Provider.NAVER, + 'naver-123', + ); + expect(done).toHaveBeenCalledWith(null, mockUser); + }); + + test('네이버 API 호출 시 accessToken을 추가한 헤더를 사용해야 한다', async () => { + // given + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => mockNaverResponse, + }); + mockUsersService.findOrCreateOAuthUser.mockResolvedValue(mockUser); + + // when + await strategy.validate(accessToken, refreshToken, profile, done); + + // then + expect(global.fetch).toHaveBeenCalledWith( + 'https://openapi.naver.com/v1/nid/me', + expect.objectContaining({ + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }), + ); + }); + + test('name이 없어도 사용자를 생성할 수 있어야 한다', async () => { + // given + const responseWithoutName = { + ...mockNaverResponse, + response: { + ...mockNaverResponse.response, + name: undefined, + }, + }; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => responseWithoutName, + }); + mockUsersService.findOrCreateOAuthUser.mockResolvedValue(mockUser); + + // when + await strategy.validate(accessToken, refreshToken, profile, done); + + // then + expect(usersService.findOrCreateOAuthUser).toHaveBeenCalledWith( + 'test@naver.com', + null, + 'https://naver.com/profile.jpg', + Provider.NAVER, + 'naver-123', + ); + }); + + test('profile_image가 없어도 사용자를 생성할 수 있어야 한다', async () => { + // given + const responseWithoutImage = { + ...mockNaverResponse, + response: { + ...mockNaverResponse.response, + profile_image: undefined, + }, + }; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => responseWithoutImage, + }); + mockUsersService.findOrCreateOAuthUser.mockResolvedValue(mockUser); + + // when + await strategy.validate(accessToken, refreshToken, profile, done); + + // then + expect(usersService.findOrCreateOAuthUser).toHaveBeenCalledWith( + 'test@naver.com', + 'Test User', + null, + Provider.NAVER, + 'naver-123', + ); + }); + + test('네이버 API 호출이 실패하면 InternalServerErrorException을 던져야 한다', async () => { + // given + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 401, + }); + + // when + await strategy.validate(accessToken, refreshToken, profile, done); + + // then + expect(done).toHaveBeenCalledWith( + expect.any(InternalServerErrorException), + ); + expect(done.mock.calls[0][0].message).toBe( + '네이버 API 호출에 실패했습니다.', + ); + }); + + test('네이버 API 응답의 resultcode가 00이 아니면 UnauthorizedException을 던져야 한다', async () => { + // given + const errorResponse = { + resultcode: '024', + message: '인증 실패', + response: null, + }; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => errorResponse, + }); + + // when + await strategy.validate(accessToken, refreshToken, profile, done); + + // then + expect(done).toHaveBeenCalledWith(expect.any(UnauthorizedException)); + expect(done.mock.calls[0][0].message).toBe('네이버 인증 실패: 인증 실패'); + }); + + test('이메일이 없으면 UnauthorizedException을 던져야 한다', async () => { + // given + const responseWithoutEmail = { + resultcode: '00', + message: 'success', + response: { + id: 'naver-123', + email: '', + name: 'Test User', + }, + }; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => responseWithoutEmail, + }); + + // when + await strategy.validate(accessToken, refreshToken, profile, done); + + // then + expect(done).toHaveBeenCalledWith(expect.any(UnauthorizedException)); + expect(done.mock.calls[0][0].message).toBe( + '네이버 계정에 이메일 정보가 없습니다.', + ); + }); + + test('UsersService에서 에러가 발생하면 done에 에러를 전달해야 한다', async () => { + // given + const error = new Error('Database error'); + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => mockNaverResponse, + }); + mockUsersService.findOrCreateOAuthUser.mockRejectedValue(error); + + // when + await strategy.validate(accessToken, refreshToken, profile, done); + + // then + expect(done).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/apps/api/test/users/users.service.spec.ts b/apps/api/test/users/users.service.spec.ts new file mode 100644 index 00000000..febfcd2b --- /dev/null +++ b/apps/api/test/users/users.service.spec.ts @@ -0,0 +1,431 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + ConflictException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { UsersService } from '../../src/users/users.service'; +import { PrismaService } from '../../src/prisma/prisma.service'; +import { Provider, User } from '@prisma/client'; + +describe('UsersService', () => { + let usersService: UsersService; + let prismaService: PrismaService; + + const mockUser: User = { + id: 1, + email: 'test@example.com', + nickname: 'Test User', + password: null, + profileImageUrl: 'https://example.com/photo.jpg', + provider: Provider.GOOGLE, + providerId: 'google-123', + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockPrismaService = { + user: { + findFirst: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + usersService = module.get(UsersService); + prismaService = module.get(PrismaService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('서비스가 정의되어야 한다', () => { + expect(usersService).toBeDefined(); + }); + + describe('findOrCreateOAuthUser', () => { + const email = 'test@example.com'; + const nickname = 'Test User'; + const profileImageUrl = 'https://example.com/photo.jpg'; + const provider = Provider.GOOGLE; + const providerId = 'google-123'; + + test('이미 존재하는 사용자를 찾아서 반환해야 한다', async () => { + // given + mockPrismaService.user.findFirst.mockResolvedValue(mockUser); + + // when + const result = await usersService.findOrCreateOAuthUser( + email, + nickname, + profileImageUrl, + provider, + providerId, + ); + + // then + expect(result).toEqual(mockUser); + expect(prismaService.user.findFirst).toHaveBeenCalledWith({ + where: { provider, providerId }, + }); + expect(prismaService.user.findUnique).not.toHaveBeenCalled(); + expect(prismaService.user.create).not.toHaveBeenCalled(); + }); + + test('provider와 providerId로 사용자를 조회해야 한다', async () => { + // given + mockPrismaService.user.findFirst.mockResolvedValue(mockUser); + + // when + await usersService.findOrCreateOAuthUser( + email, + nickname, + profileImageUrl, + provider, + providerId, + ); + + // then + expect(prismaService.user.findFirst).toHaveBeenCalledWith({ + where: { provider, providerId }, + }); + }); + + test('사용자가 없으면 새로운 사용자를 생성해야 한다', async () => { + // given + mockPrismaService.user.findFirst.mockResolvedValue(null); + mockPrismaService.user.findUnique.mockResolvedValue(null); + mockPrismaService.user.create.mockResolvedValue(mockUser); + + // when + const result = await usersService.findOrCreateOAuthUser( + email, + nickname, + profileImageUrl, + provider, + providerId, + ); + + // then + expect(result).toEqual(mockUser); + expect(prismaService.user.create).toHaveBeenCalledWith({ + data: { + email, + nickname, + profileImageUrl, + provider, + providerId, + }, + }); + }); + + test('nickname이 null이어도 사용자를 생성할 수 있어야 한다', async () => { + // given + mockPrismaService.user.findFirst.mockResolvedValue(null); + mockPrismaService.user.findUnique.mockResolvedValue(null); + mockPrismaService.user.create.mockResolvedValue({ + ...mockUser, + nickname: null, + }); + + // when + await usersService.findOrCreateOAuthUser( + email, + null, + profileImageUrl, + provider, + providerId, + ); + + // then + expect(prismaService.user.create).toHaveBeenCalledWith({ + data: { + email, + nickname: null, + profileImageUrl, + provider, + providerId, + }, + }); + }); + + test('profileImageUrl이 null이어도 사용자를 생성할 수 있어야 한다', async () => { + // given + mockPrismaService.user.findFirst.mockResolvedValue(null); + mockPrismaService.user.findUnique.mockResolvedValue(null); + mockPrismaService.user.create.mockResolvedValue({ + ...mockUser, + profileImageUrl: null, + }); + + // when + await usersService.findOrCreateOAuthUser( + email, + nickname, + null, + provider, + providerId, + ); + + // then + expect(prismaService.user.create).toHaveBeenCalledWith({ + data: { + email, + nickname, + profileImageUrl: null, + provider, + providerId, + }, + }); + }); + + test('같은 이메일로 다른 Provider로 가입된 사용자가 있으면 ConflictException을 던져야 한다', async () => { + // given + const existingUser = { + ...mockUser, + provider: Provider.KAKAO, + providerId: 'kakao-456', + }; + + mockPrismaService.user.findFirst.mockResolvedValue(null); + mockPrismaService.user.findUnique.mockResolvedValue(existingUser); + + // when & then + await expect( + usersService.findOrCreateOAuthUser( + email, + nickname, + profileImageUrl, + provider, + providerId, + ), + ).rejects.toThrow(ConflictException); + + await expect( + usersService.findOrCreateOAuthUser( + email, + nickname, + profileImageUrl, + provider, + providerId, + ), + ).rejects.toThrow( + `이 이메일은 이미 ${Provider.KAKAO} 계정으로 가입되어 있습니다.`, + ); + }); + + test('이메일 중복 체크 시 올바른 이메일로 조회해야 한다', async () => { + // given + mockPrismaService.user.findFirst.mockResolvedValue(null); + mockPrismaService.user.findUnique.mockResolvedValue(null); + mockPrismaService.user.create.mockResolvedValue(mockUser); + + // when + await usersService.findOrCreateOAuthUser( + email, + nickname, + profileImageUrl, + provider, + providerId, + ); + + // then + expect(prismaService.user.findUnique).toHaveBeenCalledWith({ + where: { email }, + }); + }); + + test('사용자 생성 중 에러가 발생하면 InternalServerErrorException을 던져야 한다', async () => { + // given + mockPrismaService.user.findFirst.mockResolvedValue(null); + mockPrismaService.user.findUnique.mockResolvedValue(null); + mockPrismaService.user.create.mockRejectedValue( + new Error('Database error'), + ); + + // when & then + await expect( + usersService.findOrCreateOAuthUser( + email, + nickname, + profileImageUrl, + provider, + providerId, + ), + ).rejects.toThrow(InternalServerErrorException); + + await expect( + usersService.findOrCreateOAuthUser( + email, + nickname, + profileImageUrl, + provider, + providerId, + ), + ).rejects.toThrow('사용자 생성 중 오류가 발생했습니다.'); + }); + + test('데이터베이스 연결 실패 시 InternalServerErrorException을 던져야 한다', async () => { + // given + mockPrismaService.user.findFirst.mockResolvedValue(null); + mockPrismaService.user.findUnique.mockResolvedValue(null); + mockPrismaService.user.create.mockRejectedValue( + new Error('Connection timeout'), + ); + + // when & then + await expect( + usersService.findOrCreateOAuthUser( + email, + nickname, + profileImageUrl, + provider, + providerId, + ), + ).rejects.toThrow(InternalServerErrorException); + }); + + test('Prisma 제약 조건 위반 시 InternalServerErrorException을 던져야 한다', async () => { + // given + mockPrismaService.user.findFirst.mockResolvedValue(null); + mockPrismaService.user.findUnique.mockResolvedValue(null); + mockPrismaService.user.create.mockRejectedValue( + new Error('Unique constraint failed'), + ); + + // when & then + await expect( + usersService.findOrCreateOAuthUser( + email, + nickname, + profileImageUrl, + provider, + providerId, + ), + ).rejects.toThrow(InternalServerErrorException); + }); + + test('빈 이메일로 사용자를 생성하려 하면 에러를 던져야 한다', async () => { + // given + mockPrismaService.user.findFirst.mockResolvedValue(null); + mockPrismaService.user.findUnique.mockResolvedValue(null); + mockPrismaService.user.create.mockRejectedValue( + new Error('Email cannot be empty'), + ); + + // when & then + await expect( + usersService.findOrCreateOAuthUser( + '', + nickname, + profileImageUrl, + provider, + providerId, + ), + ).rejects.toThrow(InternalServerErrorException); + }); + + test('잘못된 이메일 형식이면 에러를 던져야 한다', async () => { + // given + mockPrismaService.user.findFirst.mockResolvedValue(null); + mockPrismaService.user.findUnique.mockResolvedValue(null); + mockPrismaService.user.create.mockRejectedValue( + new Error('Invalid email format'), + ); + + // when & then + await expect( + usersService.findOrCreateOAuthUser( + 'invalid-email', + nickname, + profileImageUrl, + provider, + providerId, + ), + ).rejects.toThrow(InternalServerErrorException); + }); + }); + + describe('findById', () => { + test('ID로 사용자를 찾아서 비밀번호를 제외하고 반환해야 한다', async () => { + // given + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + + // when + const result = await usersService.findById(1); + + // then + expect(result).not.toHaveProperty('password'); + expect(result).toEqual({ + id: mockUser.id, + email: mockUser.email, + nickname: mockUser.nickname, + profileImageUrl: mockUser.profileImageUrl, + provider: mockUser.provider, + providerId: mockUser.providerId, + createdAt: mockUser.createdAt, + updatedAt: mockUser.updatedAt, + }); + expect(prismaService.user.findUnique).toHaveBeenCalledWith({ + where: { id: 1 }, + }); + }); + + test('사용자가 없으면 NotFoundException을 던져야 한다', async () => { + // given + mockPrismaService.user.findUnique.mockResolvedValue(null); + + // when & then + await expect(usersService.findById(999)).rejects.toThrow( + NotFoundException, + ); + await expect(usersService.findById(999)).rejects.toThrow( + '사용자를 찾을 수 없습니다.', + ); + }); + + test('존재하지 않는 ID로 조회 시 NotFoundException을 던져야 한다', async () => { + // given + mockPrismaService.user.findUnique.mockResolvedValue(null); + + // when & then + await expect(usersService.findById(-1)).rejects.toThrow( + NotFoundException, + ); + }); + + test('음수 ID로 조회 시 NotFoundException을 던져야 한다', async () => { + // given + mockPrismaService.user.findUnique.mockResolvedValue(null); + + // when & then + await expect(usersService.findById(-100)).rejects.toThrow( + NotFoundException, + ); + }); + + test('데이터베이스 연결 실패 시 에러를 던져야 한다', async () => { + // given + mockPrismaService.user.findUnique.mockRejectedValue( + new Error('Database connection failed'), + ); + + // when & then + await expect(usersService.findById(1)).rejects.toThrow( + 'Database connection failed', + ); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 686dbc1c..22a56de5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,22 +51,49 @@ importers: version: link:../../packages/shared '@nestjs/common': specifier: ^11.0.1 - version: 11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/config': specifier: ^4.0.2 - version: 4.0.2(@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + version: 4.0.2(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.1 - version: 11.1.10(@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.10)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.10)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': + specifier: ^11.0.2 + version: 11.0.2(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/passport': + specifier: ^11.0.5 + version: 11.0.5(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/platform-express': specifier: ^11.0.1 - version: 11.1.10(@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10) + version: 11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10) '@prisma/adapter-pg': specifier: ^7.2.0 version: 7.2.0 '@prisma/client': specifier: ^7.2.0 version: 7.2.0(prisma@7.2.0(@types/react@19.2.7)(magicast@0.5.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.3 + version: 0.14.3 + passport: + specifier: ^0.7.0 + version: 0.7.0 + passport-google-oauth20: + specifier: ^2.0.0 + version: 2.0.0 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 + passport-kakao: + specifier: ^1.0.1 + version: 1.0.1 + passport-oauth2: + specifier: ^1.8.0 + version: 1.8.0 pg: specifier: ^8.16.3 version: 8.16.3 @@ -88,7 +115,7 @@ importers: version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.1 - version: 11.1.10(@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10(@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.10)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.10(@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10)) + version: 11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.10)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10)) '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -98,6 +125,18 @@ importers: '@types/node': specifier: ^24.6.0 version: 24.10.4 + '@types/passport-google-oauth20': + specifier: ^2.0.17 + version: 2.0.17 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 + '@types/passport-kakao': + specifier: ^1.0.3 + version: 1.0.3 + '@types/passport-oauth2': + specifier: ^1.8.0 + version: 1.8.0 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 @@ -174,6 +213,9 @@ importers: '@storybook/addon-vitest': specifier: ^10.1.10 version: 10.1.10(@vitest/browser-playwright@4.0.16(playwright@1.57.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16))(@vitest/browser@4.0.16(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(vitest@4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(@vitest/ui@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)))(@vitest/runner@4.0.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.6.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vitest@4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(@vitest/ui@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)) + '@storybook/react': + specifier: ^10.1.10 + version: 10.1.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.6.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) '@storybook/react-vite': specifier: ^10.1.10 version: 10.1.10(esbuild@0.27.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.54.0)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.6.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(webpack@5.103.0(esbuild@0.27.2)) @@ -2294,6 +2336,23 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/jwt@11.0.2': + resolution: + { + integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==, + } + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + + '@nestjs/passport@11.0.5': + resolution: + { + integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==, + } + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + passport: ^0.5.0 || ^0.6.0 || ^0.7.0 + '@nestjs/platform-express@11.1.10': resolution: { @@ -3246,6 +3305,12 @@ packages: integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==, } + '@types/jsonwebtoken@9.0.10': + resolution: + { + integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==, + } + '@types/mdx@2.0.13': resolution: { @@ -3258,12 +3323,60 @@ packages: integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==, } + '@types/ms@2.1.0': + resolution: + { + integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==, + } + '@types/node@24.10.4': resolution: { integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==, } + '@types/oauth@0.9.6': + resolution: + { + integrity: sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==, + } + + '@types/passport-google-oauth20@2.0.17': + resolution: + { + integrity: sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==, + } + + '@types/passport-jwt@4.0.1': + resolution: + { + integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==, + } + + '@types/passport-kakao@1.0.3': + resolution: + { + integrity: sha512-McK5kpeiOptvjIPkiA/QS3k82//z5JM7Y3yBLMDvEA0QMMhFMcWUHhyebL1Qy+0i0n8jS+Oe4U6xAvkU22SXXA==, + } + + '@types/passport-oauth2@1.8.0': + resolution: + { + integrity: sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==, + } + + '@types/passport-strategy@0.2.38': + resolution: + { + integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==, + } + + '@types/passport@1.0.17': + resolution: + { + integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==, + } + '@types/qs@6.14.0': resolution: { @@ -3338,6 +3451,12 @@ packages: integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==, } + '@types/validator@13.15.10': + resolution: + { + integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==, + } + '@types/yargs-parser@21.0.3': resolution: { @@ -4215,6 +4334,13 @@ packages: integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==, } + base64url@3.0.1: + resolution: + { + integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==, + } + engines: { node: '>=6.0.0' } + baseline-browser-mapping@2.9.11: resolution: { @@ -4281,6 +4407,12 @@ packages: integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==, } + buffer-equal-constant-time@1.0.1: + resolution: + { + integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==, + } + buffer-from@1.1.2: resolution: { @@ -4468,6 +4600,18 @@ packages: integrity: sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==, } + class-transformer@0.5.1: + resolution: + { + integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==, + } + + class-validator@0.14.3: + resolution: + { + integrity: sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==, + } + cli-cursor@3.1.0: resolution: { @@ -4998,6 +5142,12 @@ packages: integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==, } + ecdsa-sig-formatter@1.0.11: + resolution: + { + integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==, + } + ee-first@1.1.1: resolution: { @@ -6701,6 +6851,25 @@ packages: } engines: { node: '>=0.10.0' } + jsonwebtoken@9.0.3: + resolution: + { + integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==, + } + engines: { node: '>=12', npm: '>=6' } + + jwa@2.0.1: + resolution: + { + integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==, + } + + jws@4.0.1: + resolution: + { + integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==, + } + keyv@4.5.4: resolution: { @@ -6721,6 +6890,12 @@ packages: } engines: { node: '>= 0.8.0' } + libphonenumber-js@1.12.33: + resolution: + { + integrity: sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw==, + } + lightningcss-android-arm64@1.30.2: resolution: { @@ -6889,6 +7064,42 @@ packages: integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==, } + lodash.includes@4.3.0: + resolution: + { + integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==, + } + + lodash.isboolean@3.0.3: + resolution: + { + integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==, + } + + lodash.isinteger@4.0.4: + resolution: + { + integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==, + } + + lodash.isnumber@3.0.3: + resolution: + { + integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==, + } + + lodash.isplainobject@4.0.6: + resolution: + { + integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==, + } + + lodash.isstring@4.0.1: + resolution: + { + integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==, + } + lodash.memoize@4.1.2: resolution: { @@ -6901,6 +7112,12 @@ packages: integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==, } + lodash.once@4.1.1: + resolution: + { + integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==, + } + lodash.sortby@4.7.0: resolution: { @@ -7315,6 +7532,18 @@ packages: engines: { node: ^14.16.0 || >=16.10.0 } hasBin: true + oauth@0.10.2: + resolution: + { + integrity: sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==, + } + + oauth@0.9.15: + resolution: + { + integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==, + } + object-assign@4.1.1: resolution: { @@ -7478,6 +7707,53 @@ packages: } engines: { node: '>= 0.8' } + passport-google-oauth20@2.0.0: + resolution: + { + integrity: sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==, + } + engines: { node: '>= 0.4.0' } + + passport-jwt@4.0.1: + resolution: + { + integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==, + } + + passport-kakao@1.0.1: + resolution: + { + integrity: sha512-uItaYRVrTHL6iGPMnMZvPa/O1GrAdh/V6EMjOHcFlQcVroZ9wgG7BZ5PonMNJCxfHQ3L2QVNRnzhKWUzSsumbw==, + } + + passport-oauth2@1.1.2: + resolution: + { + integrity: sha512-wpsGtJDHHQUjyc9WcV9FFB0bphFExpmKtzkQrxpH1vnSr6RcWa3ZEGHx/zGKAh2PN7Po9TKYB1fJeOiIBspNPA==, + } + engines: { node: '>= 0.4.0' } + + passport-oauth2@1.8.0: + resolution: + { + integrity: sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==, + } + engines: { node: '>= 0.4.0' } + + passport-strategy@1.0.0: + resolution: + { + integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==, + } + engines: { node: '>= 0.4.0' } + + passport@0.7.0: + resolution: + { + integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==, + } + engines: { node: '>= 0.4.0' } + path-exists@4.0.0: resolution: { @@ -7545,6 +7821,12 @@ packages: } engines: { node: '>= 14.16' } + pause@0.0.1: + resolution: + { + integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==, + } + perfect-debounce@1.0.0: resolution: { @@ -7671,6 +7953,13 @@ packages: integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==, } + pkginfo@0.3.1: + resolution: + { + integrity: sha512-yO5feByMzAp96LtP58wvPKSbaKAi/1C4kV9XpTctr6EepnP6F33RBNOiVrdz9BrPA98U2BMFsTNHo44TWcbQ2A==, + } + engines: { node: '>= 0.4.0' } + playwright-core@1.57.0: resolution: { @@ -7911,7 +8200,10 @@ packages: react: ^19.2.3 react-error-boundary@6.0.0: - resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==} + resolution: + { + integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==, + } peerDependencies: react: '>=16.13.1' @@ -9164,6 +9456,12 @@ packages: engines: { node: '>=0.8.0' } hasBin: true + uid2@0.0.4: + resolution: + { + integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==, + } + uid@2.0.2: resolution: { @@ -9289,6 +9587,13 @@ packages: integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, } + utils-merge@1.0.1: + resolution: + { + integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==, + } + engines: { node: '>= 0.4.0' } + v8-compile-cache-lib@3.0.1: resolution: { @@ -9313,6 +9618,13 @@ packages: typescript: optional: true + validator@13.15.26: + resolution: + { + integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==, + } + engines: { node: '>= 0.10' } + vary@1.1.2: resolution: { @@ -11324,7 +11636,7 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.1.1 iterare: 1.2.1 @@ -11333,20 +11645,23 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.3 transitivePeerDependencies: - supports-color - '@nestjs/config@4.0.2(@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + '@nestjs/config@4.0.2(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) dotenv: 16.4.7 dotenv-expand: 12.0.1 lodash: 4.17.21 rxjs: 7.8.2 - '@nestjs/core@11.1.10(@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.10)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.10)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -11356,12 +11671,23 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.10(@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10) + '@nestjs/platform-express': 11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10) - '@nestjs/platform-express@11.1.10(@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10)': + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))': dependencies: - '@nestjs/common': 11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.10(@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.10)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 + + '@nestjs/passport@11.0.5(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + + '@nestjs/platform-express@11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10)': + dependencies: + '@nestjs/common': 11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.10)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.5 express: 5.2.1 multer: 2.0.2 @@ -11381,13 +11707,13 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/testing@11.1.10(@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10(@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.10)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.10(@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10))': + '@nestjs/testing@11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.10)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10))': dependencies: - '@nestjs/common': 11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.10(@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.10)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.10)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.10(@nestjs/common@11.1.10(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10) + '@nestjs/platform-express': 11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10) '@noble/hashes@1.8.0': {} @@ -11971,14 +12297,56 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.10.4 + '@types/mdx@2.0.13': {} '@types/methods@1.1.4': {} + '@types/ms@2.1.0': {} + '@types/node@24.10.4': dependencies: undici-types: 7.16.0 + '@types/oauth@0.9.6': + dependencies: + '@types/node': 24.10.4 + + '@types/passport-google-oauth20@2.0.17': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + '@types/passport-oauth2': 1.8.0 + + '@types/passport-jwt@4.0.1': + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/passport-strategy': 0.2.38 + + '@types/passport-kakao@1.0.3': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + + '@types/passport-oauth2@1.8.0': + dependencies: + '@types/express': 5.0.6 + '@types/oauth': 0.9.6 + '@types/passport': 1.0.17 + + '@types/passport-strategy@0.2.38': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 5.0.6 + '@types/qs@6.14.0': {} '@types/range-parser@1.2.7': {} @@ -12020,6 +12388,8 @@ snapshots: '@types/trusted-types@2.0.7': {} + '@types/validator@13.15.10': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -12632,6 +13002,8 @@ snapshots: base64-js@1.5.1: {} + base64url@3.0.1: {} + baseline-browser-mapping@2.9.11: {} bidi-js@1.0.3: @@ -12687,6 +13059,8 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -12792,6 +13166,14 @@ snapshots: cjs-module-lexer@2.1.1: {} + class-transformer@0.5.1: {} + + class-validator@0.14.3: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.12.33 + validator: 13.15.26 + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -13038,6 +13420,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} effect@3.18.4: @@ -14328,6 +14714,30 @@ snapshots: jsonpointer@5.0.1: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -14339,6 +14749,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.12.33: {} + lightningcss-android-arm64@1.30.2: optional: true @@ -14425,10 +14837,24 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.sortby@4.7.0: {} lodash@4.17.21: {} @@ -14627,6 +15053,10 @@ snapshots: pkg-types: 2.3.0 tinyexec: 1.0.2 + oauth@0.10.2: {} + + oauth@0.9.15: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -14733,6 +15163,42 @@ snapshots: parseurl@1.3.3: {} + passport-google-oauth20@2.0.0: + dependencies: + passport-oauth2: 1.8.0 + + passport-jwt@4.0.1: + dependencies: + jsonwebtoken: 9.0.3 + passport-strategy: 1.0.0 + + passport-kakao@1.0.1: + dependencies: + passport-oauth2: 1.1.2 + pkginfo: 0.3.1 + + passport-oauth2@1.1.2: + dependencies: + oauth: 0.9.15 + passport-strategy: 1.0.0 + uid2: 0.0.4 + + passport-oauth2@1.8.0: + dependencies: + base64url: 3.0.1 + oauth: 0.10.2 + passport-strategy: 1.0.0 + uid2: 0.0.4 + utils-merge: 1.0.1 + + passport-strategy@1.0.0: {} + + passport@0.7.0: + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -14759,6 +15225,8 @@ snapshots: pathval@2.0.1: {} + pause@0.0.1: {} + perfect-debounce@1.0.0: {} pg-cloudflare@1.2.7: @@ -14822,6 +15290,8 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + pkginfo@0.3.1: {} + playwright-core@1.57.0: {} playwright@1.57.0: @@ -15782,6 +16252,8 @@ snapshots: uglify-js@3.19.3: optional: true + uid2@0.0.4: {} + uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0 @@ -15865,6 +16337,8 @@ snapshots: util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: @@ -15877,6 +16351,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + validator@13.15.26: {} + vary@1.1.2: {} vite-plugin-pwa@1.2.0(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0):