From dc5606b48b9cd8c35f8ccdbba2884938c0f108f0 Mon Sep 17 00:00:00 2001 From: kokkumong Date: Thu, 28 May 2026 14:34:11 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=20=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(OAuth=202.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/auth/google/login — state 생성 후 구글 인가 서버로 리다이렉트 - GET /api/auth/google/callback — state CSRF 검증, 토큰/유저 조회, DB upsert, JWT 발급 - fetchWithTimeout으로 외부 API 타임아웃 처리 - nickname P2002 race condition 방어 처리 - 에러 리다이렉트를 request origin 기반으로 처리 - JwtPayload provider 타입에 google 추가 - 단위 테스트 14개 작성 - .env.example에 GOOGLE_REDIRECT_URI 추가 Close #37 Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 3 +- src/app/api/auth/google/callback/route.ts | 172 +++++++++++++++++++ src/app/api/auth/google/login/route.ts | 32 ++++ src/lib/auth/__tests__/google.test.ts | 191 ++++++++++++++++++++++ src/lib/auth/google.ts | 126 ++++++++++++++ src/lib/auth/jwt.ts | 2 +- 6 files changed, 524 insertions(+), 2 deletions(-) create mode 100644 src/app/api/auth/google/callback/route.ts create mode 100644 src/app/api/auth/google/login/route.ts create mode 100644 src/lib/auth/__tests__/google.test.ts create mode 100644 src/lib/auth/google.ts diff --git a/.env.example b/.env.example index 2d35314..894ea0f 100644 --- a/.env.example +++ b/.env.example @@ -6,9 +6,10 @@ NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url_here NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key_here SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key_here -# 캘린더 연동용 OAuth Client (Google/Apple) +# 구글 로그인 및 캘린더 연동용 OAuth Client GOOGLE_CLIENT_ID=your_google_client_id_here GOOGLE_CLIENT_SECRET=your_google_client_secret_here +GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback # Gemini API (이미지 분석용) GEMINI_API_KEY=your_gemini_api_key_here diff --git a/src/app/api/auth/google/callback/route.ts b/src/app/api/auth/google/callback/route.ts new file mode 100644 index 0000000..24053e5 --- /dev/null +++ b/src/app/api/auth/google/callback/route.ts @@ -0,0 +1,172 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; +import { + getGoogleToken, + getGoogleUser, + extractGoogleUserInfo, +} from "@/lib/auth/google"; +import { signAccessToken, COOKIE_NAME, COOKIE_MAX_AGE } from "@/lib/auth/jwt"; + +export const dynamic = "force-dynamic"; + +const STATE_COOKIE = "google_oauth_state"; + +// 프론트 경로가 확정되면 상수로 관리 +const LOGIN_SUCCESS_REDIRECT = "/"; +// 구글 신규 가입 시 phoneNumber, 약관 동의 등 추가 정보 입력 필요 +const ADDITIONAL_INFO_REDIRECT = "/signup/additional-info?provider=google"; +const LOGIN_FAILURE_REDIRECT = "/login?error=google_login_failed"; + +export async function GET(req: NextRequest) { + const origin = new URL(req.url).origin; + + const { searchParams } = req.nextUrl; + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const error = searchParams.get("error"); + + // 구글 인가 거부 + if (error) { + return NextResponse.redirect(`${origin}${LOGIN_FAILURE_REDIRECT}`); + } + + // code 없음 + if (!code) { + return NextResponse.redirect(`${origin}${LOGIN_FAILURE_REDIRECT}`); + } + + // state 파라미터 누락 + if (!state) { + return NextResponse.redirect(`${origin}${LOGIN_FAILURE_REDIRECT}`); + } + + // CSRF 방어: state 검증 + const savedState = req.cookies.get(STATE_COOKIE)?.value; + if (!savedState || savedState !== state) { + return NextResponse.redirect(`${origin}${LOGIN_FAILURE_REDIRECT}`); + } + + try { + // 구글 access_token 발급 + const googleTokenData = await getGoogleToken(code); + + // 구글 사용자 정보 조회 + const googleUser = await getGoogleUser(googleTokenData.access_token); + + const { googleId, email, nickname } = extractGoogleUserInfo(googleUser); + + // googleId 기준으로 기존 사용자 조회 (SocialAccount → User) + const socialAccount = await prisma.socialAccount.findUnique({ + where: { + provider_providerUserId: { + provider: "google", + providerUserId: googleId, + }, + }, + include: { + user: { + select: { + id: true, + email: true, + nickname: true, + profileCompleted: true, + }, + }, + }, + }); + + let user: { + id: string; + email: string | null; + nickname: string; + profileCompleted: boolean; + }; + + if (socialAccount) { + // 기존 구글 사용자 → 로그인 처리 + user = socialAccount.user; + } else { + // 신규 구글 사용자 → 계정 생성 + // 닉네임 중복 시 googleId 전체로 대체 (googleId는 전역 고유값이므로 충돌 없음) + let finalNickname = nickname; + const existingNick = await prisma.user.findUnique({ + where: { nickname: finalNickname }, + select: { id: true }, + }); + if (existingNick) { + finalNickname = `google_${googleId}`; + } + + const createUserData = (nick: string) => ({ + data: { + email: email ?? null, + nickname: nick, + profileCompleted: false, // phoneNumber, 약관 동의 등 추가 정보 필요 + socialAccounts: { + create: { + provider: "google", + providerUserId: googleId, + }, + }, + }, + select: { + id: true, + email: true, + nickname: true, + profileCompleted: true, + }, + }); + + try { + user = await prisma.user.create(createUserData(finalNickname)); + } catch (createErr) { + if ( + createErr instanceof Prisma.PrismaClientKnownRequestError && + createErr.code === "P2002" + ) { + const randomSuffix = Math.random().toString(36).slice(2, 7); + user = await prisma.user.create( + createUserData(`google_${googleId}_${randomSuffix}`), + ); + } else { + throw createErr; + } + } + } + + // 우리 서비스 JWT 발급 (Google access_token은 포함하지 않음) + const token = await signAccessToken({ + userId: user.id, + ...(user.email ? { email: user.email } : {}), + nickname: user.nickname, + provider: "google", + }); + + const redirectPath = user.profileCompleted + ? LOGIN_SUCCESS_REDIRECT + : ADDITIONAL_INFO_REDIRECT; + + const res = NextResponse.redirect(`${origin}${redirectPath}`); + + // 우리 서비스 accessToken 쿠키 설정 + res.cookies.set(COOKIE_NAME, token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: COOKIE_MAX_AGE, + }); + + // 사용한 state 쿠키 삭제 + res.cookies.delete(STATE_COOKIE); + + return res; + } catch (err) { + console.error( + "[auth.google.callback] 오류:", + err instanceof Error ? err.message : "알 수 없는 오류", + ); + return NextResponse.redirect(`${origin}${LOGIN_FAILURE_REDIRECT}`); + } +} diff --git a/src/app/api/auth/google/login/route.ts b/src/app/api/auth/google/login/route.ts new file mode 100644 index 0000000..5ca8219 --- /dev/null +++ b/src/app/api/auth/google/login/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getGoogleAuthUrl } from "@/lib/auth/google"; + +export const dynamic = "force-dynamic"; + +const STATE_COOKIE = "google_oauth_state"; +const STATE_MAX_AGE = 600; // 10분 + +export async function GET(request: NextRequest) { + try { + const state = crypto.randomUUID(); + const googleAuthUrl = getGoogleAuthUrl(state); + + const res = NextResponse.redirect(googleAuthUrl); + res.cookies.set(STATE_COOKIE, state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: STATE_MAX_AGE, + }); + + return res; + } catch (err) { + console.error( + "[auth.google.login] 오류:", + err instanceof Error ? err.message : "알 수 없는 오류", + ); + const origin = new URL(request.url).origin; + return NextResponse.redirect(`${origin}/login?error=google_login_failed`); + } +} diff --git a/src/lib/auth/__tests__/google.test.ts b/src/lib/auth/__tests__/google.test.ts new file mode 100644 index 0000000..030bb3c --- /dev/null +++ b/src/lib/auth/__tests__/google.test.ts @@ -0,0 +1,191 @@ +// @vitest-environment node +import { describe, test, expect, beforeEach, vi } from "vitest"; +import { + getGoogleAuthUrl, + getGoogleToken, + getGoogleUser, + extractGoogleUserInfo, + type GoogleUser, +} from "../google"; + +// ────────────────────────────────────────────── +// getGoogleAuthUrl +// ────────────────────────────────────────────── +describe("getGoogleAuthUrl", () => { + beforeEach(() => { + process.env.GOOGLE_CLIENT_ID = "test_client_id"; + process.env.GOOGLE_REDIRECT_URI = + "http://localhost:3000/api/auth/google/callback"; + }); + + test("올바른 구글 인가 URL을 반환한다", () => { + const state = "random_state_value"; + const url = getGoogleAuthUrl(state); + + expect(url).toContain("https://accounts.google.com/o/oauth2/v2/auth"); + expect(url).toContain("response_type=code"); + expect(url).toContain("client_id=test_client_id"); + expect(url).toContain( + encodeURIComponent("http://localhost:3000/api/auth/google/callback"), + ); + expect(url).toContain(`state=${state}`); + expect(url).toContain("scope="); + }); + + test("GOOGLE_CLIENT_ID가 없으면 에러를 던진다", () => { + delete process.env.GOOGLE_CLIENT_ID; + expect(() => getGoogleAuthUrl("state")).toThrow( + "GOOGLE_CLIENT_ID 또는 GOOGLE_REDIRECT_URI 환경변수가 설정되지 않았습니다.", + ); + }); + + test("GOOGLE_REDIRECT_URI가 없으면 에러를 던진다", () => { + delete process.env.GOOGLE_REDIRECT_URI; + expect(() => getGoogleAuthUrl("state")).toThrow( + "GOOGLE_CLIENT_ID 또는 GOOGLE_REDIRECT_URI 환경변수가 설정되지 않았습니다.", + ); + }); + + test("state 값이 URL에 그대로 포함된다", () => { + const state = "csrf_token_abc123"; + const url = getGoogleAuthUrl(state); + expect(url).toContain(`state=${state}`); + }); +}); + +// ────────────────────────────────────────────── +// getGoogleToken +// ────────────────────────────────────────────── +describe("getGoogleToken", () => { + beforeEach(() => { + process.env.GOOGLE_CLIENT_ID = "test_client_id"; + process.env.GOOGLE_CLIENT_SECRET = "test_client_secret"; + process.env.GOOGLE_REDIRECT_URI = + "http://localhost:3000/api/auth/google/callback"; + vi.restoreAllMocks(); + }); + + test("토큰 발급에 성공하면 access_token을 반환한다", async () => { + const mockResponse = { + access_token: "google_access_token_mock", + expires_in: 3599, + token_type: "Bearer", + scope: "openid email profile", + }; + + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response); + + const result = await getGoogleToken("auth_code"); + expect(result.access_token).toBe("google_access_token_mock"); + }); + + test("fetch가 ok:false이면 에러를 던진다", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: false, + status: 400, + } as Response); + + await expect(getGoogleToken("bad_code")).rejects.toThrow( + "구글 토큰 발급 실패", + ); + }); + + test("환경변수가 없으면 에러를 던진다", async () => { + delete process.env.GOOGLE_CLIENT_ID; + await expect(getGoogleToken("code")).rejects.toThrow( + "구글 환경변수가 설정되지 않았습니다.", + ); + }); +}); + +// ────────────────────────────────────────────── +// getGoogleUser +// ────────────────────────────────────────────── +describe("getGoogleUser", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + test("사용자 정보를 정상 반환한다", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: "google_user_id_123", + email: "user@gmail.com", + name: "홍길동", + verified_email: true, + }), + } as Response); + + const user = await getGoogleUser("access_token"); + expect(user.id).toBe("google_user_id_123"); + expect(user.email).toBe("user@gmail.com"); + expect(user.name).toBe("홍길동"); + }); + + test("fetch가 ok:false이면 에러를 던진다", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: false, + status: 401, + } as Response); + + await expect(getGoogleUser("invalid_token")).rejects.toThrow( + "구글 사용자 정보 조회 실패", + ); + }); + + test("응답에 id가 없으면 에러를 던진다", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + email: "user@gmail.com", + name: "홍길동", + // id 누락 + }), + } as Response); + + await expect(getGoogleUser("access_token")).rejects.toThrow( + "구글 사용자 정보 조회 오류: id 누락", + ); + }); +}); + +// ────────────────────────────────────────────── +// extractGoogleUserInfo +// ────────────────────────────────────────────── +describe("extractGoogleUserInfo", () => { + test("name이 있으면 그대로 사용한다", () => { + const user: GoogleUser = { + id: "123", + email: "test@gmail.com", + name: "홍길동", + }; + const { googleId, email, nickname } = extractGoogleUserInfo(user); + expect(googleId).toBe("123"); + expect(email).toBe("test@gmail.com"); + expect(nickname).toBe("홍길동"); + }); + + test("name이 없으면 given_name을 사용한다", () => { + const user: GoogleUser = { id: "456", given_name: "길동" }; + const { nickname } = extractGoogleUserInfo(user); + expect(nickname).toBe("길동"); + }); + + test("name과 given_name 모두 없으면 google_{id}를 사용한다", () => { + const user: GoogleUser = { id: "789" }; + const { nickname } = extractGoogleUserInfo(user); + expect(nickname).toBe("google_789"); + }); + + test("email이 없으면 undefined를 반환한다", () => { + const user: GoogleUser = { id: "abc", name: "이름" }; + const { email } = extractGoogleUserInfo(user); + expect(email).toBeUndefined(); + }); +}); diff --git a/src/lib/auth/google.ts b/src/lib/auth/google.ts new file mode 100644 index 0000000..7356a12 --- /dev/null +++ b/src/lib/auth/google.ts @@ -0,0 +1,126 @@ +const FETCH_TIMEOUT_MS = 5000; + +async function fetchWithTimeout( + url: string, + options: RequestInit, +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new Error(`요청 시간 초과: ${url}`); + } + throw err; + } finally { + clearTimeout(timeoutId); + } +} + +interface GoogleTokenResponse { + access_token: string; + expires_in: number; + token_type: string; + scope: string; + id_token?: string; +} + +export interface GoogleUser { + id: string; + email?: string; + name?: string; + given_name?: string; + picture?: string; + verified_email?: boolean; +} + +export function getGoogleAuthUrl(state: string): string { + const clientId = process.env.GOOGLE_CLIENT_ID; + const redirectUri = process.env.GOOGLE_REDIRECT_URI; + if (!clientId || !redirectUri) { + throw new Error( + "GOOGLE_CLIENT_ID 또는 GOOGLE_REDIRECT_URI 환경변수가 설정되지 않았습니다.", + ); + } + + const params = new URLSearchParams({ + response_type: "code", + client_id: clientId, + redirect_uri: redirectUri, + scope: "openid email profile", + state, + access_type: "online", + }); + return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; +} + +export async function getGoogleToken( + code: string, +): Promise { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + const redirectUri = process.env.GOOGLE_REDIRECT_URI; + if (!clientId || !clientSecret || !redirectUri) { + throw new Error("구글 환경변수가 설정되지 않았습니다."); + } + + const params = new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: "authorization_code", + }); + + const res = await fetchWithTimeout("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + + if (!res.ok) { + throw new Error(`구글 토큰 발급 실패 (status: ${res.status})`); + } + + return res.json() as Promise; +} + +export async function getGoogleUser(accessToken: string): Promise { + const res = await fetchWithTimeout( + "https://www.googleapis.com/oauth2/v2/userinfo", + { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!res.ok) { + throw new Error(`구글 사용자 정보 조회 실패 (status: ${res.status})`); + } + + const data = (await res.json()) as GoogleUser; + + if (!data.id) { + throw new Error("구글 사용자 정보 조회 오류: id 누락"); + } + + return data; +} + +export function extractGoogleUserInfo(googleUser: GoogleUser): { + googleId: string; + email?: string; + nickname: string; +} { + const googleId = googleUser.id; + const email = googleUser.email; + const nickname = + googleUser.name ?? googleUser.given_name ?? `google_${googleId}`; + + return { googleId, email, nickname }; +} diff --git a/src/lib/auth/jwt.ts b/src/lib/auth/jwt.ts index 7649935..53a9b81 100644 --- a/src/lib/auth/jwt.ts +++ b/src/lib/auth/jwt.ts @@ -4,7 +4,7 @@ export interface JwtPayload { userId: string; email?: string; nickname: string; - provider?: "local" | "kakao"; + provider?: "local" | "kakao" | "google"; } const ACCESS_TOKEN_TTL = "7d"; From 702b48c241505284f4c2f0da23cf087a5165b6d5 Mon Sep 17 00:00:00 2001 From: kokkumong Date: Thu, 28 May 2026 14:39:02 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20@supabase/ssr=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 122 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3bfdf19..1d6d3be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1051,6 +1051,13 @@ "node": ">=20.19.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", + "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", + "license": "MIT", + "peer": true + }, "node_modules/@dotenvx/dotenvx": { "version": "1.61.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.0.tgz", @@ -2038,7 +2045,7 @@ "version": "1.59.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.59.1" @@ -2612,6 +2619,27 @@ "tslib": "^2.4.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -2739,6 +2767,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -2782,7 +2818,7 @@ "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2792,14 +2828,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -4705,7 +4741,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -4792,6 +4828,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.3.0.tgz", + "integrity": "sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4987,6 +5034,14 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -8497,6 +8552,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -9598,7 +9664,7 @@ "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.59.1" @@ -9617,7 +9683,7 @@ "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -9871,6 +9937,36 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -10067,6 +10163,14 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -11774,7 +11878,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -11817,7 +11921,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicorn-magic": { From 2b46e0ba0d7616fa43a2aef3de16f0c6430549d7 Mon Sep 17 00:00:00 2001 From: kokkumong Date: Thu, 28 May 2026 15:15:28 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=EA=B5=AC=EA=B8=80=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=EB=AA=85=EC=9D=84=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8/=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=9A=A9?= =?UTF-8?q?=EB=8F=84=EB=B3=84=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GOOGLE_CLIENT_ID → GOOGLE_LOGIN_CLIENT_ID GOOGLE_CLIENT_SECRET → GOOGLE_LOGIN_CLIENT_SECRET GOOGLE_REDIRECT_URI → GOOGLE_LOGIN_REDIRECT_URI Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 12 ++++++++---- src/lib/auth/__tests__/google.test.ts | 24 ++++++++++++------------ src/lib/auth/google.ts | 12 ++++++------ 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 894ea0f..a4cb28d 100644 --- a/.env.example +++ b/.env.example @@ -6,10 +6,14 @@ NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url_here NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key_here SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key_here -# 구글 로그인 및 캘린더 연동용 OAuth Client -GOOGLE_CLIENT_ID=your_google_client_id_here -GOOGLE_CLIENT_SECRET=your_google_client_secret_here -GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback +# 구글 소셜 로그인 +GOOGLE_LOGIN_CLIENT_ID=your_google_login_client_id_here +GOOGLE_LOGIN_CLIENT_SECRET=your_google_login_client_secret_here +GOOGLE_LOGIN_REDIRECT_URI=http://localhost:3000/api/auth/google/callback + +# 구글 캘린더 연동 +GOOGLE_CALENDAR_CLIENT_ID=your_google_calendar_client_id_here +GOOGLE_CALENDAR_CLIENT_SECRET=your_google_calendar_client_secret_here # Gemini API (이미지 분석용) GEMINI_API_KEY=your_gemini_api_key_here diff --git a/src/lib/auth/__tests__/google.test.ts b/src/lib/auth/__tests__/google.test.ts index 030bb3c..795cc30 100644 --- a/src/lib/auth/__tests__/google.test.ts +++ b/src/lib/auth/__tests__/google.test.ts @@ -13,8 +13,8 @@ import { // ────────────────────────────────────────────── describe("getGoogleAuthUrl", () => { beforeEach(() => { - process.env.GOOGLE_CLIENT_ID = "test_client_id"; - process.env.GOOGLE_REDIRECT_URI = + process.env.GOOGLE_LOGIN_CLIENT_ID = "test_client_id"; + process.env.GOOGLE_LOGIN_REDIRECT_URI = "http://localhost:3000/api/auth/google/callback"; }); @@ -32,17 +32,17 @@ describe("getGoogleAuthUrl", () => { expect(url).toContain("scope="); }); - test("GOOGLE_CLIENT_ID가 없으면 에러를 던진다", () => { - delete process.env.GOOGLE_CLIENT_ID; + test("GOOGLE_LOGIN_CLIENT_ID가 없으면 에러를 던진다", () => { + delete process.env.GOOGLE_LOGIN_CLIENT_ID; expect(() => getGoogleAuthUrl("state")).toThrow( - "GOOGLE_CLIENT_ID 또는 GOOGLE_REDIRECT_URI 환경변수가 설정되지 않았습니다.", + "GOOGLE_LOGIN_CLIENT_ID 또는 GOOGLE_LOGIN_REDIRECT_URI 환경변수가 설정되지 않았습니다.", ); }); - test("GOOGLE_REDIRECT_URI가 없으면 에러를 던진다", () => { - delete process.env.GOOGLE_REDIRECT_URI; + test("GOOGLE_LOGIN_REDIRECT_URI가 없으면 에러를 던진다", () => { + delete process.env.GOOGLE_LOGIN_REDIRECT_URI; expect(() => getGoogleAuthUrl("state")).toThrow( - "GOOGLE_CLIENT_ID 또는 GOOGLE_REDIRECT_URI 환경변수가 설정되지 않았습니다.", + "GOOGLE_LOGIN_CLIENT_ID 또는 GOOGLE_LOGIN_REDIRECT_URI 환경변수가 설정되지 않았습니다.", ); }); @@ -58,9 +58,9 @@ describe("getGoogleAuthUrl", () => { // ────────────────────────────────────────────── describe("getGoogleToken", () => { beforeEach(() => { - process.env.GOOGLE_CLIENT_ID = "test_client_id"; - process.env.GOOGLE_CLIENT_SECRET = "test_client_secret"; - process.env.GOOGLE_REDIRECT_URI = + process.env.GOOGLE_LOGIN_CLIENT_ID = "test_client_id"; + process.env.GOOGLE_LOGIN_CLIENT_SECRET = "test_client_secret"; + process.env.GOOGLE_LOGIN_REDIRECT_URI = "http://localhost:3000/api/auth/google/callback"; vi.restoreAllMocks(); }); @@ -94,7 +94,7 @@ describe("getGoogleToken", () => { }); test("환경변수가 없으면 에러를 던진다", async () => { - delete process.env.GOOGLE_CLIENT_ID; + delete process.env.GOOGLE_LOGIN_CLIENT_ID; await expect(getGoogleToken("code")).rejects.toThrow( "구글 환경변수가 설정되지 않았습니다.", ); diff --git a/src/lib/auth/google.ts b/src/lib/auth/google.ts index 7356a12..42594d4 100644 --- a/src/lib/auth/google.ts +++ b/src/lib/auth/google.ts @@ -36,11 +36,11 @@ export interface GoogleUser { } export function getGoogleAuthUrl(state: string): string { - const clientId = process.env.GOOGLE_CLIENT_ID; - const redirectUri = process.env.GOOGLE_REDIRECT_URI; + const clientId = process.env.GOOGLE_LOGIN_CLIENT_ID; + const redirectUri = process.env.GOOGLE_LOGIN_REDIRECT_URI; if (!clientId || !redirectUri) { throw new Error( - "GOOGLE_CLIENT_ID 또는 GOOGLE_REDIRECT_URI 환경변수가 설정되지 않았습니다.", + "GOOGLE_LOGIN_CLIENT_ID 또는 GOOGLE_LOGIN_REDIRECT_URI 환경변수가 설정되지 않았습니다.", ); } @@ -58,9 +58,9 @@ export function getGoogleAuthUrl(state: string): string { export async function getGoogleToken( code: string, ): Promise { - const clientId = process.env.GOOGLE_CLIENT_ID; - const clientSecret = process.env.GOOGLE_CLIENT_SECRET; - const redirectUri = process.env.GOOGLE_REDIRECT_URI; + const clientId = process.env.GOOGLE_LOGIN_CLIENT_ID; + const clientSecret = process.env.GOOGLE_LOGIN_CLIENT_SECRET; + const redirectUri = process.env.GOOGLE_LOGIN_REDIRECT_URI; if (!clientId || !clientSecret || !redirectUri) { throw new Error("구글 환경변수가 설정되지 않았습니다."); }