Skip to content

feat: 구글 소셜 로그인 구현 (OAuth 2.0) - #39

Closed
kokkumong wants to merge 3 commits into
devfrom
feature/37-google-login
Closed

feat: 구글 소셜 로그인 구현 (OAuth 2.0)#39
kokkumong wants to merge 3 commits into
devfrom
feature/37-google-login

Conversation

@kokkumong

Copy link
Copy Markdown
Collaborator

🚀 작업 내용 (What)

  • 구글 OAuth 2.0 로그인 플로우 구현
  • GET /api/auth/google/login — state 생성 후 구글 인가 서버로 리다이렉트
  • GET /api/auth/google/callback — state CSRF 검증, 토큰/유저 조회, DB upsert, JWT 발급
  • 기존 SocialAccount 모델 활용 (provider: "google")
  • fetchWithTimeout으로 외부 API 타임아웃 처리
  • nickname P2002 race condition 방어 처리
  • 에러 리다이렉트를 request origin 기반으로 처리
  • JwtPayload provider 타입에 google 추가
  • 환경변수명 로그인/캘린더 용도별 분리 (GOOGLE_LOGIN_, GOOGLE_CALENDAR_)
  • 단위 테스트 14개 작성

📣 핵심 변경 이유 (Why)

  • 구글 아이디로 로그인 기능 제공으로 가입 허들 낮추기
  • 네이버/카카오에 이어 소셜 로그인 확장

📸 스크린샷 (Visuals, 선택)

  • 해당 없음 (백엔드 API) — 로컬 환경에서 실제 로그인 동작 확인 완료

⚠️ 체크리스트 (Checklist)

  • 브랜치 컨벤션(feature/00-name)을 지켰나요?
  • 커밋 컨벤션(feat:, fix: 등)을 지켰나요?
  • 작업 전에 관련 이슈를 생성하고 연결했나요?
  • 내 코드가 팀의 기존 코드를 망가뜨리지 않았는지 확인했나요?

🔗 관련 이슈 (Issue)

Close #37

kokkumong and others added 3 commits May 28, 2026 14:34
- 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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Review Change Stack

Summary by CodeRabbit

릴리스 노트

  • New Features

    • Google 소셜 로그인을 통한 신규 사용자 가입 및 기존 사용자 로그인 지원 추가
    • Google OAuth 설정 분리: 소셜 로그인 전용 환경 변수와 캘린더 연동 전용 환경 변수를 별도로 관리
  • Tests

    • Google OAuth 인증 흐름 관련 단위 테스트 추가

Walkthrough

Google OAuth 2.0 기반 소셜 로그인 기능을 구현. 로그인 초기화 엔드포인트에서 state 생성 후 Google 인증 서버로 리다이렉트하고, 콜백 엔드포인트에서 CSRF 검증·토큰 교환·사용자 조회/생성·JWT 발급을 처리한다. Google API 통신 유틸리티는 5초 타임아웃 보호를 적용하며 단위 테스트로 검증됨.

Changes

Google 소셜 로그인 인증 플로우

Layer / File(s) Summary
환경 변수 설정
.env.example
기존 GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET를 제거하고 로그인 전용(GOOGLE_LOGIN_CLIENT_ID, GOOGLE_LOGIN_CLIENT_SECRET, GOOGLE_LOGIN_REDIRECT_URI)과 캘린더 연동 전용(GOOGLE_CALENDAR_CLIENT_ID, GOOGLE_CALENDAR_CLIENT_SECRET) 환경변수로 분리.
Google OAuth 유틸리티 함수
src/lib/auth/google.ts
getGoogleAuthUrl() — 환경변수 검증 후 Google 인증 URL 생성. getGoogleToken() — code를 accessToken으로 교환. getGoogleUser() — accessToken으로 사용자 정보 조회. extractGoogleUserInfo() — 응답에서 googleId/email/nickname 추출. 모든 API 호출은 fetchWithTimeout() 헬퍼로 5초 타임아웃 처리.
Google OAuth 유틸리티 테스트
src/lib/auth/__tests__/google.test.ts
각 함수의 정상 동작(쿼리 파라미터 검증, 토큰 파싱, 사용자 정보 매핑) 및 실패 케이스(환경변수 누락, fetch 오류, 응답 필드 누락) 검증.
JWT 페이로드 타입 확장
src/lib/auth/jwt.ts
JwtPayload.provider 타입을 "local" | "kakao" | "google"으로 확장. 기존 카카오 JWT 스킴에 Google 지원 추가.
로그인 시작 엔드포인트
src/app/api/auth/google/login/route.ts
GET /api/auth/google/login에서 UUID state 생성 후 google_oauth_state 쿠키에 저장(httpOnly, secure in prod, sameSite=lax, maxAge=600초)하고 Google 인증 URL로 리다이렉트. 실패 시 /login?error=google_login_failed로 리다이렉트.
OAuth 콜백 엔드포인트
src/app/api/auth/google/callback/route.ts
GET /api/auth/google/callback에서 error/code/state 쿼리 검증 → CSRF 방어(쿠키 state와 비교) → accessToken 획득 → 사용자 정보 조회. SocialAccount(provider='google', providerUserId=googleId) 기준으로 기존 사용자 조회. 신규 사용자는 생성(닉네임 충돌 시 google_{googleId} 대체, P2012 고유제약 시 랜덤 suffix로 재시도). JWT 발급(profileCompleted 여부 판단) → 쿠키 저장(httpOnly, secure in prod, sameSite=lax) → 성공/추가정보 리다이렉트. 예외 시 콘솔 로그 후 /auth/google/failure로 리다이렉트, state 쿠키 삭제.

Sequence Diagrams

sequenceDiagram
    participant Client
    participant LoginRoute as /api/auth/google/login
    participant GoogleAuth as Google 인증 서버
    participant CallbackRoute as /api/auth/google/callback
    participant GoogleLib as google.ts
    participant Prisma
    participant JWT as jwt.ts
    
    Client->>LoginRoute: GET 로그인 시작
    LoginRoute->>LoginRoute: UUID state 생성
    LoginRoute->>LoginRoute: google_oauth_state 쿠키 저장 (10분)
    LoginRoute-->>GoogleAuth: 302 리다이렉트 (clientId, redirectUri, state, scope)
    GoogleAuth-->>Client: 인증 화면 표시
    Client->>GoogleAuth: 사용자 인증
    GoogleAuth-->>CallbackRoute: 302 리다이렉트 (code, state)
    
    CallbackRoute->>CallbackRoute: state CSRF 검증 (쿠키 vs 쿼리)
    CallbackRoute->>GoogleLib: getGoogleToken(code)
    GoogleLib-->>CallbackRoute: accessToken
    CallbackRoute->>GoogleLib: getGoogleUser(accessToken)
    GoogleLib-->>CallbackRoute: {id, email, name}
    
    CallbackRoute->>Prisma: 조회 SocialAccount(googleId)
    alt 기존 사용자
        Prisma-->>CallbackRoute: user 찾음
    else 신규 사용자
        CallbackRoute->>Prisma: upsert User + SocialAccount<br/>(닉네임 충돌: google_{id} 대체)<br/>(P2012 재시도: suffix 추가)
        Prisma-->>CallbackRoute: user 생성됨
    end
    
    CallbackRoute->>JWT: signAccessToken(userId, 'google', profileCompleted)
    JWT-->>CallbackRoute: jwtToken
    CallbackRoute->>CallbackRoute: 쿠키 저장 (httpOnly, secure, sameSite=lax)
    CallbackRoute->>CallbackRoute: google_oauth_state 쿠키 삭제
    CallbackRoute-->>Client: 302 리다이렉트 (성공/프로필 완성 필요)
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

근거:

  • 밀도 높은 로직: OAuth 상태 머신(초기화→검증→토큰 교환→사용자 조회/생성→JWT 발급), CSRF 방어, 타임아웃 처리, 고유제약 재시도 로직
  • 다양한 파일 영역: 환경변수, 라이브러리 함수(5개), 엔드포인트(2개), 타입 확장, 테스트(4개 스위트, 20개 케이스)
  • 핵심 검토 포인트:
    • CSRF state 검증 로직의 정확성 (cookie-stealing 방어)
    • Prisma P2012 재시도 loop 종료 조건 검증
    • fetchWithTimeout AbortController 정리 확인
    • 콜백 핸들러의 모든 리다이렉트 경로와 쿠키 정리 추적
    • JWT payload provider 타입과 실제 값 일치성

Possibly Related PRs

  • Siul49/moim#29: JwtPayload.provider 타입 확장에서 기존 Kakao 소셜 로그인 JWT 스킴을 상속. 동일한 signAccessToken/verifyAccessToken 체계 활용.

Suggested Labels

feature

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 'feat:' 접두사로 시작하며 구글 소셜 로그인 구현이라는 주요 변경사항을 명확히 요약했습니다.
Description check ✅ Passed PR 설명은 구글 OAuth 2.0 로그인 플로우 구현과 관련된 상세한 내용을 포함하고 있으며 변경사항과 직접적으로 연관됩니다.
Linked Issues check ✅ Passed 모든 코딩 요구사항 충족: GET /api/auth/google/login, GET /api/auth/google/callback 엔드포인트 구현, SocialAccount 모델 재사용(provider: 'google'), 단위 테스트 14개 작성, state CSRF 검증, JWT 발급 등.
Out of Scope Changes check ✅ Passed 모든 변경사항이 Google OAuth 2.0 로그인 구현 범위 내입니다. .env.example 환경변수 분리, google.ts/callback/login 라우트, JWT provider 확장, 테스트는 모두 이슈 #37과 직접 관련됩니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/37-google-login

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/api/auth/google/callback/route.ts`:
- Around line 45-48: The STATE_COOKIE nonce is not cleared on failure paths, so
update the Google callback flow to always remove STATE_COOKIE whenever state
validation fails or any error branch returns a failure redirect; specifically,
invoke the common cookie-cleanup helper (or call
response.cookies.delete(STATE_COOKIE) via the same helper) before returning
NextResponse.redirect(...) in the state-mismatch branch and the generic failure
branch (the places using req.cookies.get(...) and NextResponse.redirect(...));
ensure the cleanup is idempotent/safe for missing cookies and use the same
helper to keep behavior consistent across all failure returns.
- Around line 60-77: The user.create catch currently treats all Prisma P2002
errors as nickname collisions and always retries with a suffixed nickname;
change the handling so you inspect createErr.meta?.target (from the
prisma.user.create catch) and only perform the suffix retry when the target
includes "nickname"; for other P2002 targets such as "email" rethrow the error
(or propagate it) instead of retrying, ensuring the retry path that modifies
nickname is only taken when createErr.meta?.target indicates the nickname unique
constraint failed.

In `@src/lib/auth/__tests__/google.test.ts`:
- Around line 59-156: Add tests to cover timeout and empty-input boundary cases
for getGoogleToken and getGoogleUser: (1) simulate a timeout by mocking
global.fetch (or the internal fetchWithTimeout) to reject with an
AbortError-like error and assert getGoogleToken/getGoogleUser reject with the
same/translated error (to catch abort conversion); (2) add input-guard tests
that call getGoogleToken with an empty/undefined code and getGoogleUser with an
empty/undefined accessToken and assert they throw the expected validation
errors; target the test suite names/subjects getGoogleToken and getGoogleUser
and mock/spy on global.fetch (or fetchWithTimeout) to trigger the timeout and
error conditions.

In `@src/lib/auth/google.ts`:
- Around line 58-60: Add input guards that validate required parameters at the
top of the Google auth helpers: in getGoogleToken(code) check that code is a
non-empty string and throw a clear, immediate error (e.g. "Missing required
parameter: code") instead of letting the request proceed, and do the same for
the function that accepts accessToken (e.g. getGoogleProfile(accessToken) or the
access-token-consuming helper) to throw "Missing required parameter:
accessToken" when empty; this keeps client errors local and prevents unnecessary
calls to Google APIs.
- Line 88: The current return simply casts res.json() to
Promise<GoogleTokenResponse> without runtime checks; update the function that
calls res.json() (the method returning GoogleTokenResponse) to await and parse
the JSON, verify that parsed.access_token exists (and other required fields if
needed), and if missing reject/throw a descriptive error (e.g., throw new
Error("Missing access_token in Google token response")) so callers (like the
callback handler) fail fast instead of later; keep the function's outward type
but ensure it returns the validated object when access_token is present.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a8fa7ae2-a6f5-434a-914d-4a51571c4943

📥 Commits

Reviewing files that changed from the base of the PR and between e40c252 and 2b46e0b.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json, !**/*.json, !package-lock.json
📒 Files selected for processing (6)
  • .env.example
  • src/app/api/auth/google/callback/route.ts
  • src/app/api/auth/google/login/route.ts
  • src/lib/auth/__tests__/google.test.ts
  • src/lib/auth/google.ts
  • src/lib/auth/jwt.ts

Comment on lines +45 to +48
const savedState = req.cookies.get(STATE_COOKIE)?.value;
if (!savedState || savedState !== state) {
return NextResponse.redirect(`${origin}${LOGIN_FAILURE_REDIRECT}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

검증이 끝난 state를 실패 경로에서 지우지 않아 재사용 창이 남습니다.

Why
Line 46에서 state 검증이 끝났으면 이 nonce는 이미 소모된 값입니다. 그런데 이후 예외가 나면 Line 170에서 실패 리다이렉트만 하고 쿠키를 남겨 둡니다. 그러면 같은 브라우저에서 동일 콜백 URL을 새 로그인 시작 없이 다시 태울 수 있어 CSRF 토큰의 일회성 보장이 약해집니다.

How
실패 응답도 공통 helper를 거쳐 STATE_COOKIE를 항상 정리하세요. 삭제는 존재하지 않는 쿠키에도 안전하므로, 모든 실패 분기에 재사용해도 부작용이 없습니다.

코드 스니펫
 const LOGIN_FAILURE_REDIRECT = "/login?error=google_login_failed";
+
+function redirectWithStateCleanup(origin: string, path: string) {
+  const res = NextResponse.redirect(`${origin}${path}`);
+  res.cookies.delete(STATE_COOKIE);
+  return res;
+}

 export async function GET(req: NextRequest) {
@@
   const savedState = req.cookies.get(STATE_COOKIE)?.value;
   if (!savedState || savedState !== state) {
-    return NextResponse.redirect(`${origin}${LOGIN_FAILURE_REDIRECT}`);
+    return redirectWithStateCleanup(origin, LOGIN_FAILURE_REDIRECT);
   }
@@
   } catch (err) {
     console.error(
       "[auth.google.callback] 오류:",
       err instanceof Error ? err.message : "알 수 없는 오류",
     );
-    return NextResponse.redirect(`${origin}${LOGIN_FAILURE_REDIRECT}`);
+    return redirectWithStateCleanup(origin, LOGIN_FAILURE_REDIRECT);
   }
 }

Also applies to: 165-170

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/auth/google/callback/route.ts` around lines 45 - 48, The
STATE_COOKIE nonce is not cleared on failure paths, so update the Google
callback flow to always remove STATE_COOKIE whenever state validation fails or
any error branch returns a failure redirect; specifically, invoke the common
cookie-cleanup helper (or call response.cookies.delete(STATE_COOKIE) via the
same helper) before returning NextResponse.redirect(...) in the state-mismatch
branch and the generic failure branch (the places using req.cookies.get(...) and
NextResponse.redirect(...)); ensure the cleanup is idempotent/safe for missing
cookies and use the same helper to keep behavior consistent across all failure
returns.

Comment on lines +60 to +77
const socialAccount = await prisma.socialAccount.findUnique({
where: {
provider_providerUserId: {
provider: "google",
providerUserId: googleId,
},
},
include: {
user: {
select: {
id: true,
email: true,
nickname: true,
profileCompleted: true,
},
},
},
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

SCHEMA_FILE="$(fd -i 'schema.prisma' | head -n 1)"
echo "== schema.prisma =="
echo "$SCHEMA_FILE"

echo
echo "== User / SocialAccount 유니크 제약 확인 =="
rg -n -A20 -B5 'model User|model SocialAccount|`@unique`|@@unique' "$SCHEMA_FILE"

echo
echo "== 콜백 라우트의 P2002 처리 확인 =="
rg -n -A25 -B10 'provider_providerUserId|P2002|socialAccount\.findUnique|user\.create' src/app/api/auth/google/callback/route.ts

Repository: Siul49/moim

Length of output: 3648


[major] P2002를 닉네임 충돌로만 가정한 재시도 로직을 분기 처리하세요 (user.create catch)

Why
prisma/schema.prisma 기준 User.nicknameUser.email 둘 다 @unique입니다. 그런데 src/app/api/auth/google/callback/route.tsprisma.user.create(...)에서 발생한 P2002를 전부 닉네임 중복으로 보고 suffix 재시도를 하고 있어, 실제 원인이 email 유니크 충돌이면 재시도 자체가 계속 실패할 수 있습니다.

How
createErr.meta?.target으로 어떤 unique 필드 충돌인지 판별하고, nickname일 때만 suffix 재시도를 하세요. 그 외 타겟이면 그대로 에러를 전파(또는 필요한 경우에만 별도 처리)하세요.

핵심 수정(diff)
       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}`),
-          );
+          const target = Array.isArray(createErr.meta?.target)
+            ? createErr.meta?.target.map(String)
+            : [String(createErr.meta?.target ?? "")];
+
+          if (target.includes("nickname")) {
+            const randomSuffix = Math.random().toString(36).slice(2, 7);
+            user = await prisma.user.create(
+              createUserData(`google_${googleId}_${randomSuffix}`),
+            );
+          } else {
+            throw createErr;
+          }
         } else {
           throw createErr;
         }
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/auth/google/callback/route.ts` around lines 60 - 77, The
user.create catch currently treats all Prisma P2002 errors as nickname
collisions and always retries with a suffixed nickname; change the handling so
you inspect createErr.meta?.target (from the prisma.user.create catch) and only
perform the suffix retry when the target includes "nickname"; for other P2002
targets such as "email" rethrow the error (or propagate it) instead of retrying,
ensuring the retry path that modifies nickname is only taken when
createErr.meta?.target indicates the nickname unique constraint failed.

Comment on lines +59 to +156
describe("getGoogleToken", () => {
beforeEach(() => {
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();
});

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_LOGIN_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 누락",
);
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

타임아웃·빈 입력 경계값 테스트가 빠져 회귀를 놓칠 수 있습니다.

Why: 현재 스위트는 happy path 중심이라 fetchWithTimeout의 Abort 변환 에러와 입력 가드(빈 code/accessToken)가 깨져도 탐지하지 못합니다.
How: 공개 인터페이스 기준으로 아래 케이스를 추가하세요(구현 세부가 아닌 에러 계약 검증).

최소 추가 예시
 describe("getGoogleToken", () => {
@@
+  test("code가 빈 문자열이면 즉시 에러를 던진다", async () => {
+    await expect(getGoogleToken("   ")).rejects.toThrow("인가 코드가 비어 있습니다.");
+  });
+
+  test("fetch 타임아웃(AbortError)을 요청 시간 초과 에러로 변환한다", async () => {
+    vi.spyOn(global, "fetch").mockRejectedValueOnce(
+      Object.assign(new Error("aborted"), { name: "AbortError" }),
+    );
+    await expect(getGoogleToken("auth_code")).rejects.toThrow("요청 시간 초과");
+  });
 });
@@
 describe("getGoogleUser", () => {
@@
+  test("accessToken이 빈 문자열이면 즉시 에러를 던진다", async () => {
+    await expect(getGoogleUser("")).rejects.toThrow("accessToken이 비어 있습니다.");
+  });
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
describe("getGoogleToken", () => {
beforeEach(() => {
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();
});
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_LOGIN_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 누락",
);
});
});
describe("getGoogleToken", () => {
beforeEach(() => {
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();
});
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_LOGIN_CLIENT_ID;
await expect(getGoogleToken("code")).rejects.toThrow(
"구글 환경변수가 설정되지 않았습니다.",
);
});
test("code가 빈 문자열이면 즉시 에러를 던진다", async () => {
await expect(getGoogleToken(" ")).rejects.toThrow("인가 코드가 비어 있습니다.");
});
test("fetch 타임아웃(AbortError)을 요청 시간 초과 에러로 변환한다", async () => {
vi.spyOn(global, "fetch").mockRejectedValueOnce(
Object.assign(new Error("aborted"), { name: "AbortError" }),
);
await expect(getGoogleToken("auth_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 누락",
);
});
test("accessToken이 빈 문자열이면 즉시 에러를 던진다", async () => {
await expect(getGoogleUser("")).rejects.toThrow("accessToken이 비어 있습니다.");
});
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/auth/__tests__/google.test.ts` around lines 59 - 156, Add tests to
cover timeout and empty-input boundary cases for getGoogleToken and
getGoogleUser: (1) simulate a timeout by mocking global.fetch (or the internal
fetchWithTimeout) to reject with an AbortError-like error and assert
getGoogleToken/getGoogleUser reject with the same/translated error (to catch
abort conversion); (2) add input-guard tests that call getGoogleToken with an
empty/undefined code and getGoogleUser with an empty/undefined accessToken and
assert they throw the expected validation errors; target the test suite
names/subjects getGoogleToken and getGoogleUser and mock/spy on global.fetch (or
fetchWithTimeout) to trigger the timeout and error conditions.

Comment thread src/lib/auth/google.ts
Comment on lines +58 to +60
export async function getGoogleToken(
code: string,
): Promise<GoogleTokenResponse> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

code/accessToken 선검증이 없어 외부 호출 실패로 전파됩니다.

Why: 입력 공백은 클라이언트 오류인데 현재는 Google API까지 호출해 실패 원인 분리가 안 되고 장애 분석이 어려워집니다.
How: 함수 진입부에서 즉시 가드 후 명시적 에러를 던지세요.

최소 수정 예시
 export async function getGoogleToken(
   code: string,
 ): Promise<GoogleTokenResponse> {
+  if (!code.trim()) {
+    throw new Error("인가 코드가 비어 있습니다.");
+  }
   const clientId = process.env.GOOGLE_LOGIN_CLIENT_ID;
   const clientSecret = process.env.GOOGLE_LOGIN_CLIENT_SECRET;
   const redirectUri = process.env.GOOGLE_LOGIN_REDIRECT_URI;
@@
 export async function getGoogleUser(accessToken: string): Promise<GoogleUser> {
+  if (!accessToken.trim()) {
+    throw new Error("accessToken이 비어 있습니다.");
+  }
   const res = await fetchWithTimeout(

Also applies to: 91-91

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/auth/google.ts` around lines 58 - 60, Add input guards that validate
required parameters at the top of the Google auth helpers: in
getGoogleToken(code) check that code is a non-empty string and throw a clear,
immediate error (e.g. "Missing required parameter: code") instead of letting the
request proceed, and do the same for the function that accepts accessToken (e.g.
getGoogleProfile(accessToken) or the access-token-consuming helper) to throw
"Missing required parameter: accessToken" when empty; this keeps client errors
local and prevents unnecessary calls to Google APIs.

Comment thread src/lib/auth/google.ts
throw new Error(`구글 토큰 발급 실패 (status: ${res.status})`);
}

return res.json() as Promise<GoogleTokenResponse>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

토큰 응답을 캐스팅만 하고 필수 필드 검증을 생략했습니다.

Why: res.json() as Promise<GoogleTokenResponse>는 런타임 보장을 못 해서 access_token 누락 시 콜백 흐름(Line 50-57 in src/app/api/auth/google/callback/route.ts)에서 늦게 터집니다.
How: 파싱 후 access_token 존재를 확인하고 없으면 즉시 실패 처리하세요.

최소 수정 예시
-  return res.json() as Promise<GoogleTokenResponse>;
+  const data = (await res.json()) as Partial<GoogleTokenResponse>;
+  if (!data.access_token) {
+    throw new Error("구글 토큰 응답 오류: access_token 누락");
+  }
+  return data as GoogleTokenResponse;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return res.json() as Promise<GoogleTokenResponse>;
const data = (await res.json()) as Partial<GoogleTokenResponse>;
if (!data.access_token) {
throw new Error("구글 토큰 응답 오류: access_token 누락");
}
return data as GoogleTokenResponse;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/auth/google.ts` at line 88, The current return simply casts
res.json() to Promise<GoogleTokenResponse> without runtime checks; update the
function that calls res.json() (the method returning GoogleTokenResponse) to
await and parse the JSON, verify that parsed.access_token exists (and other
required fields if needed), and if missing reject/throw a descriptive error
(e.g., throw new Error("Missing access_token in Google token response")) so
callers (like the callback handler) fail fast instead of later; keep the
function's outward type but ensure it returns the validated object when
access_token is present.

@Siul49 Siul49 mentioned this pull request May 30, 2026
11 tasks
@Siul49 Siul49 closed this Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants