Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
"@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/google-one-tap": "^1.2.7",
"@types/google.accounts": "^0.0.18",
Comment thread
haesol2022 marked this conversation as resolved.
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
Expand Down
19 changes: 0 additions & 19 deletions apps/web/src/_pages/auth/use-cases/social-login-url.ts

This file was deleted.

33 changes: 33 additions & 0 deletions apps/web/src/_pages/auth/use-cases/social-login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { API_BASE_URL, TEAM_ID } from "@/shared/config/env";

/**
* 구글 소셜 로그인
* Google Identity Services SDK로 받은 access_token을 백엔드로 전달
* 백엔드가 토큰 검증 후 자체 JWT(accessToken, refreshToken) 발급
*/
export async function loginWithGoogle(accessToken: string) {
const res = await fetch(`${API_BASE_URL}/${TEAM_ID}/oauth/google`, {
Comment thread
haesol2022 marked this conversation as resolved.
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: accessToken }),
});

if (!res.ok) throw new Error("구글 로그인 실패");
return res.json() as Promise<{ accessToken: string; refreshToken: string }>;
Comment on lines +15 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

에러 응답 디버깅 정보 부족

res.ok가 false일 때 응답 상태 코드나 에러 메시지를 포함하지 않아 디버깅이 어려울 수 있습니다.

♻️ 에러 정보 포함 제안
-  if (!res.ok) throw new Error("구글 로그인 실패");
+  if (!res.ok) {
+    const errorBody = await res.text().catch(() => "");
+    throw new Error(`구글 로그인 실패: ${res.status} ${errorBody}`);
+  }
📝 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
if (!res.ok) throw new Error("구글 로그인 실패");
return res.json() as Promise<{ accessToken: string; refreshToken: string }>;
if (!res.ok) {
const errorBody = await res.text().catch(() => "");
throw new Error(`구글 로그인 실패: ${res.status} ${errorBody}`);
}
return res.json() as Promise<{ accessToken: string; refreshToken: string }>;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/_pages/auth/use-cases/social-login.ts` around lines 15 - 16,
When the fetch response check throws on (!res.ok) the error lacks HTTP status
and body details; update the failure path where the code currently does "if
(!res.ok) throw new Error('구글 로그인 실패')" to read the response body (await
res.text() or await res.json() safely) and include res.status and the parsed
error text/JSON in the thrown Error (or attach them to a custom Error object) so
the thrown error contains both HTTP status and server error message for
debugging.

}

/**
* 카카오 소셜 로그인
* Next.js Route Handler에서 카카오 access_token으로 교환 후 백엔드로 전달
* 백엔드가 토큰 검증 후 자체 JWT(accessToken, refreshToken) 발급
*/
export async function loginWithKakao(accessToken: string) {
const res = await fetch(`${API_BASE_URL}/${TEAM_ID}/oauth/kakao`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: accessToken }),
});

if (!res.ok) throw new Error("카카오 로그인 실패");
return res.json() as Promise<{ accessToken: string; refreshToken: string }>;
}
36 changes: 36 additions & 0 deletions apps/web/src/app/api/oauth/kakao/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { type NextRequest, NextResponse } from "next/server";

// 카카오 인가 코드 -> access_token 교환
// client_secret이 필요하기 때문에 브라우저가 아닌 서버(Route Handler)에서 처리
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");

if (!code) {
return NextResponse.json({ message: "인가 코드가 없습니다." }, { status: 400 });
}

const tokenRes = await fetch("https://kauth.kakao.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID!,
client_secret: process.env.KAKAO_CLIENT_SECRET!,
code,
redirect_uri: process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI!,
Comment on lines +18 to +21

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

환경변수 미설정 시 런타임 오류 발생 가능

process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID!, process.env.KAKAO_CLIENT_SECRET! 등에 non-null assertion을 사용하고 있으나, 환경변수가 설정되지 않은 경우 undefined가 그대로 전달되어 카카오 API 호출이 실패합니다. 서버 시작 시 또는 요청 처리 전에 검증이 필요합니다.

🛡️ 환경변수 검증 추가 제안
 export async function GET(request: NextRequest) {
+  const clientId = process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID;
+  const clientSecret = process.env.KAKAO_CLIENT_SECRET;
+  const redirectUri = process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI;
+
+  if (!clientId || !clientSecret || !redirectUri) {
+    console.error("Missing Kakao OAuth environment variables");
+    return NextResponse.json({ message: "서버 설정 오류" }, { status: 500 });
+  }
+
   const { searchParams } = new URL(request.url);
   const code = searchParams.get("code");
   
   // ... 이하 동일
   
   body: new URLSearchParams({
     grant_type: "authorization_code",
-    client_id: process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID!,
-    client_secret: process.env.KAKAO_CLIENT_SECRET!,
+    client_id: clientId,
+    client_secret: clientSecret,
     code,
-    redirect_uri: process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI!,
+    redirect_uri: redirectUri,
   }),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/api/oauth/kakao/route.ts` around lines 18 - 21, The code
uses non-null assertions for NEXT_PUBLIC_KAKAO_CLIENT_ID, KAKAO_CLIENT_SECRET,
and NEXT_PUBLIC_KAKAO_REDIRECT_URI when building the token request, which can
pass undefined to the Kakao API; add an explicit runtime check at the start of
the route handler (the function that performs the token exchange in route.ts) to
validate process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID,
process.env.KAKAO_CLIENT_SECRET, and process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI
are present, remove the non-null assertions, and if any are missing return a
clear 500/4xx response or throw an error with a descriptive message so missing
environment variables are detected immediately. Ensure the validation runs
before using those values in the token request payload or calling any Kakao API.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

배포 환경에서 환경변수가 항상 설정되어 있어서 별도로 런타임 검증하는 건 불필요하다고 판단함.

}),
});

if (!tokenRes.ok) {
return NextResponse.json({ message: "카카오 토큰 교환 실패" }, { status: 400 });
}

const { access_token } = await tokenRes.json();

if (!access_token) {
return NextResponse.json({ message: "카카오 토큰 응답 오류" }, { status: 400 });
}

return NextResponse.json({ accessToken: access_token });
Comment thread
haesol2022 marked this conversation as resolved.
}
2 changes: 2 additions & 0 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
import Script from "next/script";
import { AmplitudeInit } from "@/amplitude";

export const metadata: Metadata = {
Expand All @@ -23,6 +24,7 @@ export default function RootLayout({
/>
</head>
<body className="bg-background-secondary antialiased">
<Script src="https://accounts.google.com/gsi/client" strategy="beforeInteractive" />
<AmplitudeInit />
{children}
</body>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,52 @@
import { LoadingIndicator } from "@moum-zip/ui/components";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef } from "react";
import { loginWithKakao } from "@/_pages/auth/use-cases/social-login";
import { ROUTES } from "@/shared/config/routes";

function OAuthCallbackContent() {
function KakaoCallbackContent() {
const router = useRouter();
const searchParams = useSearchParams();
const handledRef = useRef(false);

useEffect(() => {
// 토큰 처리 중복 실행 방지
// 중복 실행 방지
if (handledRef.current) return;
handledRef.current = true;

const accessToken = searchParams.get("accessToken");
const refreshToken = searchParams.get("refreshToken");
// 백엔드 OAuth 실패 시 ?error=... 형태로 넘어오는 케이스 대비
const code = searchParams.get("code");
const error = searchParams.get("error");

// 토큰이 없거나 에러가 있으면 로그인 페이지로 이동
if (error || !accessToken || !refreshToken) {
// 카카오 로그인 실패 시 ?error=... 형태로 넘어오는 케이스 대비
if (error || !code) {
router.replace(ROUTES.login);
return;
}

const handleCallback = async () => {
try {
// 토큰을 URL에서 즉시 제거 (히스토리/Referer 헤더로 토큰 노출 방지)
window.history.replaceState({}, "", window.location.pathname);
// 카카오 인가 코드 -> access_token 교환 (Route Handler에서 처리)
const tokenRes = await fetch(`/api/oauth/kakao?code=${code}`);
if (!tokenRes.ok) {
router.replace(ROUTES.login);
return;
}
const { accessToken: kakaoAccessToken } = await tokenRes.json();

// 카카오 access_token -> 백엔드 JWT 발급
const { accessToken, refreshToken } = await loginWithKakao(kakaoAccessToken);

// httpOnly 쿠키는 서버에서만 set 가능하므로 Route Handler에 위임
// httpOnly 쿠키에 저장
const res = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accessToken, refreshToken }),
credentials: "include",
referrerPolicy: "no-referrer", // Referer 헤더로 토큰 노출 방지
referrerPolicy: "no-referrer",
});

if (res.ok) {
// router.replace는 서버 컴포넌트를 재실행하지 않아 네비게이션 상태가 바뀌지 않음
// 풀 리로드로 쿠키 반영
// 쿠키 반영을 위해 홈으로 새 요청 (클라이언트 이동 X)
window.location.replace(ROUTES.home);
} else {
router.replace(ROUTES.login);
Expand All @@ -58,10 +64,10 @@ function OAuthCallbackContent() {
return <LoadingIndicator fullScreen text="로그인 처리 중" />;
}

export default function OAuthCallbackPage() {
export default function KakaoCallbackPage() {
return (
<Suspense fallback={<LoadingIndicator fullScreen text="로그인 처리 중" />}>
<OAuthCallbackContent />
<KakaoCallbackContent />
</Suspense>
);
}
27 changes: 23 additions & 4 deletions apps/web/src/features/auth/ui/login-form.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"use client";

import { Button, InputField, SocialButton } from "@moum-zip/ui/components";
import { Button, InputField, SocialButton, toast } from "@moum-zip/ui/components";
import Link from "next/link";
import { startTransition, useActionState } from "react";
import { useForm } from "react-hook-form";
import { loginAction } from "@/_pages/auth/actions";
import { getGoogleLoginUrl, getKakaoLoginUrl } from "@/_pages/auth/use-cases/social-login-url";
import { loginWithGoogle } from "@/_pages/auth/use-cases/social-login";
import { ROUTES } from "@/shared/config/routes";
import { PasswordInput } from "./password-input";

Expand Down Expand Up @@ -98,14 +98,33 @@ export const LoginForm = () => {
provider="google"
className="w-full md:w-[222px]"
onClick={() => {
window.location.href = getGoogleLoginUrl();
const client = window.google.accounts.oauth2.initTokenClient({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
scope: "email profile",
callback: async (response) => {
try {
const { accessToken, refreshToken } = await loginWithGoogle(response.access_token);
const res = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accessToken, refreshToken }),
});
if (!res.ok) throw new Error("토큰 저장 실패");
window.location.replace(ROUTES.home);
} catch {
toast({ message: "로그인 중 오류가 발생했어요. 다시 시도해주세요.", size: "small" });
window.location.replace(ROUTES.login);
}
},
});
client.requestAccessToken();
}}
Comment thread
haesol2022 marked this conversation as resolved.
/>
<SocialButton
provider="kakao"
className="w-full md:w-[222px]"
onClick={() => {
window.location.href = getKakaoLoginUrl();
window.location.href = `https://kauth.kakao.com/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID}&redirect_uri=${encodeURIComponent(process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI!)}&response_type=code`;
}}
/>
</div>
Expand Down
27 changes: 23 additions & 4 deletions apps/web/src/features/auth/ui/signup-form.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"use client";
import { Button, InputField, SocialButton } from "@moum-zip/ui/components";
import { Button, InputField, SocialButton, toast } from "@moum-zip/ui/components";
import Link from "next/link";
import { startTransition, useActionState } from "react";
import { useForm } from "react-hook-form";
import { signupAction } from "@/_pages/auth/actions";
import { getGoogleLoginUrl, getKakaoLoginUrl } from "@/_pages/auth/use-cases/social-login-url";
import { loginWithGoogle } from "@/_pages/auth/use-cases/social-login";
import { ROUTES } from "@/shared/config/routes";
import { PasswordInput } from "./password-input";

Expand Down Expand Up @@ -127,14 +127,33 @@ export const SignupForm = () => {
provider="google"
className="w-full md:w-[222px]"
onClick={() => {
window.location.href = getGoogleLoginUrl();
const client = window.google.accounts.oauth2.initTokenClient({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
scope: "email profile",
callback: async (response) => {
try {
const { accessToken, refreshToken } = await loginWithGoogle(response.access_token);
const res = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accessToken, refreshToken }),
});
if (!res.ok) throw new Error("토큰 저장 실패");
window.location.replace(ROUTES.home);
} catch {
toast({ message: "회원가입 중 오류가 발생했어요. 다시 시도해주세요.", size: "small" });
window.location.replace(ROUTES.signup);
}
},
});
client.requestAccessToken();
}}
Comment on lines 129 to 150

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

login-form.tsx와 동일한 에러 처리 누락 및 코드 중복

Google OAuth 콜백에 에러 처리가 없으며, login-form.tsx와 완전히 동일한 코드가 중복되어 있습니다.

♻️ 공통 훅 또는 유틸리티로 추출 제안

소셜 로그인 로직을 커스텀 훅으로 추출하여 재사용하세요:

// hooks/use-google-login.ts
export function useGoogleLogin() {
  const handleGoogleLogin = useCallback(() => {
    const client = window.google.accounts.oauth2.initTokenClient({
      client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
      scope: "email profile",
      callback: async (response) => {
        try {
          if (response.error) throw new Error(response.error);
          const { accessToken, refreshToken } = await loginWithGoogle(response.access_token);
          const res = await fetch("/api/auth/token", { /* ... */ });
          if (!res.ok) throw new Error("Token storage failed");
          window.location.replace(ROUTES.home);
        } catch (error) {
          console.error("Google login failed:", error);
          // 에러 상태 업데이트
        }
      },
    });
    client.requestAccessToken();
  }, []);
  
  return { handleGoogleLogin };
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/features/auth/ui/signup-form.tsx` around lines 129 - 145, The
Google OAuth onClick duplicates login-form.tsx and lacks error handling; extract
the flow into a reusable hook (e.g., useGoogleLogin returning handleGoogleLogin)
that calls window.google.accounts.oauth2.initTokenClient and
client.requestAccessToken, and inside the callback wrap logic in try/catch:
check response.error, call loginWithGoogle(response.access_token), POST the
tokens and verify res.ok, throw or set an error state on any failure, and only
call window.location.replace(ROUTES.home) on success; replace the inline onClick
to call handleGoogleLogin.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

에러 처리는 try/catch + toast로 이미 반영함. 공통 훅 추출은 login과 signup의 토스트 메시지와 리다이렉트 경로가 달라서 훅으로 묶으면 오히려 복잡해질 것 같아 그대로 유지하겠음.

/>
<SocialButton
provider="kakao"
className="w-full md:w-[222px]"
onClick={() => {
window.location.href = getKakaoLoginUrl();
window.location.href = `https://kauth.kakao.com/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID}&redirect_uri=${encodeURIComponent(process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI!)}&response_type=code`;
}}
Comment thread
haesol2022 marked this conversation as resolved.
/>
</div>
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
declare namespace google.accounts.oauth2 {
interface TokenResponse {
error?: string;
error_description?: string;
}
}

declare global {
interface Window {
google: typeof google;
}
}

export {};
5 changes: 4 additions & 1 deletion apps/web/src/shared/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "https://together-dallaem-api.vercel.app";
export const API_BASE_URL = (process.env.NEXT_PUBLIC_API_URL ?? "https://together-dallaem-api.vercel.app").replace(
/\/+$/,
"",
);

export const TEAM_ID = process.env.NEXT_PUBLIC_TEAM_ID ?? "moum-zip-dev";
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": "."
"baseUrl": ".",
"types": ["google.accounts"]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
Expand Down
Loading