Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
11 changes: 11 additions & 0 deletions apps/client/public/assets/onBoarding/icons/googleLogo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions apps/client/src/assets/chippi_extension_popup.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
64 changes: 64 additions & 0 deletions apps/client/src/pages/onBoarding/GoogleCallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import apiRequest from '@shared/apis/setting/axiosInstance';
import LoadingChippi from '@shared/components/loadingChippi/LoadingChippi';
import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';

const GoogleCallback = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();

useEffect(() => {
const code = searchParams.get('code');

if (!code) {
alert('로그인 실패. 다시 시도해주세요.');
navigate('/onboarding?step=SOCIAL_LOGIN');
return;
}

loginWithCode(code);
}, []);

const handleUserLogin = (
isUser: boolean,
accessToken: string | undefined
) => {
if (isUser) {
if (accessToken) {
localStorage.setItem('token', accessToken);
}
navigate('/');
} else {
navigate('/onboarding?step=ALARM');
}
};

const loginWithCode = async (code: string) => {
try {
const res = await apiRequest.post('/api/v2/auth/google', { code });
const { isUser, userId, email, accessToken } = res.data.data;

localStorage.setItem('email', email);
localStorage.setItem('userId', userId);

handleUserLogin(isUser, accessToken);
} catch (error) {
console.error('로그인 오류:', error);
navigate('/onboarding?step=SOCIAL_LOGIN');
}
};

return (
<div className="flex h-screen flex-col items-center justify-center">
<LoadingChippi className="mb-6" />
<p className="text-font-black-2 head3 mt-[1.6rem]">
잠시만 기다려주세요…
</p>
<p className="body1-m text-font-gray-3 text-center">
치삐가 로그인 중입니다
</p>
</div>
);
};

export default GoogleCallback;
158 changes: 92 additions & 66 deletions apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
import { Progress, Button } from '@pinback/design-system/ui';
import { useState, useEffect, lazy, Suspense } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import SocialLoginStep from './step/SocialLoginStep';
const StoryStep = lazy(() => import('./step/StoryStep'));
const AlarmStep = lazy(() => import('./step/AlarmStep'));
const MacStep = lazy(() => import('./step/MacStep'));
const FinalStep = lazy(() => import('./step/FinalStep'));

import { cva } from 'class-variance-authority';
import { usePostSignUp } from '@shared/apis/queries';
const stepProgress = [{ progress: 30 }, { progress: 60 }, { progress: 100 }];
import { AlarmsType } from '@constants/alarms';
import { normalizeTime } from '@pages/onBoarding/utils/formatRemindTime';
import { useNavigate, useLocation } from 'react-router-dom';
import { firebaseConfig } from '../../../../firebase-config';
import { initializeApp } from 'firebase/app';
import { getMessaging, getToken } from 'firebase/messaging';
import { registerServiceWorker } from '@pages/onBoarding/utils/registerServiceWorker';
import { useLocation } from 'react-router-dom';
import { AlarmsType } from '@constants/alarms';
import { normalizeTime } from '@pages/onBoarding/utils/formatRemindTime';
const stepProgress = [{ progress: 33 }, { progress: 66 }, { progress: 100 }];
import {
Step,
stepOrder,
StepType,
storySteps,
} from '@pages/onBoarding/constants/onboardingSteps';
Comment on lines +16 to +24
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx | head -40

Repository: Pinback-Team/pinback-client

Length of output: 1931


🏁 Script executed:

fd tsconfig.json eslintrc -o json 2>/dev/null | head -20

Repository: Pinback-Team/pinback-client

Length of output: 53


🏁 Script executed:

rg "stepProgress" apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx -B 5 -A 2

Repository: Pinback-Team/pinback-client

Length of output: 746


🏁 Script executed:

find . -name "tsconfig.json" -o -name ".eslintrc*" -o -name "eslint.config.*" | head -10

Repository: Pinback-Team/pinback-client

Length of output: 337


🏁 Script executed:

cat tsconfig.json 2>/dev/null || cat apps/client/tsconfig.json 2>/dev/null

Repository: Pinback-Team/pinback-client

Length of output: 539


🏁 Script executed:

rg "import/order|sort-imports" --type json --type yaml

Repository: Pinback-Team/pinback-client

Length of output: 1148


🏁 Script executed:

cat apps/client/eslint.config.js | head -100

Repository: Pinback-Team/pinback-client

Length of output: 288


🏁 Script executed:

rg "import-order|no-mixed-require|imports-on-top" apps/client/eslint.config.js

Repository: Pinback-Team/pinback-client

Length of output: 53


🏁 Script executed:

find . -path "*/eslint-config*" -name "*.js" -o -name "*.json" | grep -v node_modules | head -20

Repository: Pinback-Team/pinback-client

Length of output: 733


🏁 Script executed:

rg "@pinback/eslint-config" --type json --type js | head -5

Repository: Pinback-Team/pinback-client

Length of output: 453


🏁 Script executed:

python3 << 'EOF'
# According to ECMAScript specification (ES2015+):
# - All import/export statements must come before other module code
# - Mixing const declarations between imports violates module structure
# - This is enforced by strict ES module parsers

# Check if the pattern would cause issues:
pattern_lines = """
import { AlarmsType } from '@constants/alarms';
import { normalizeTime } from '@pages/onBoarding/utils/formatRemindTime';
const stepProgress = [{ progress: 33 }, { progress: 66 }, { progress: 100 }];
import {
  Step,
  stepOrder,
  StepType,
  storySteps,
} from '@pages/onBoarding/constants/onboardingSteps';
"""

import_count_before_const = 2  # Lines 16-17
const_statement = 1  # Line 18
import_count_after_const = 1  # Lines 19-24

if import_count_after_const > 0 and const_statement > 0:
    print("VIOLATION: Imports found after non-import code (const declaration)")
    print("ES Module Specification: All imports MUST come before other code")
    print("This violates: https://tc39.es/ecma262/#sec-parse-script")
    print("\nResult: This pattern WILL cause build/parse errors in ES module mode")
else:
    print("No violation")
EOF

Repository: Pinback-Team/pinback-client

Length of output: 157


🏁 Script executed:

cat > /tmp/test-module.ts << 'EOF'
import { A } from 'module-a';
import { B } from 'module-b';
const x = [1, 2, 3];
import { C } from 'module-c';
EOF

# Check TypeScript parsing
cat /tmp/test-module.ts

Repository: Pinback-Team/pinback-client

Length of output: 181


🏁 Script executed:

node << 'EOF'
// Check if Node.js would accept this pattern
const moduleCode = `
import { A } from 'module-a';
import { B } from 'module-b';
const x = [1, 2, 3];
import { C } from 'module-c';
`;

console.log("ES Module Specification (ECMAScript 2015):");
console.log("- All imports MUST appear before non-import code at module level");
console.log("- Placing import statements after other code violates the spec");
console.log("");
console.log("Pattern in the file violates this rule:");
console.log("✗ const between imports");
console.log("");
console.log("This WILL cause parse errors in strict ES module mode");
console.log("Build tools like Vite, TypeScript, and bundlers enforce this strictly");
EOF

Repository: Pinback-Team/pinback-client

Length of output: 431


Move all import statements before the const stepProgress declaration

ES modules require all imports to come before other module-level code. Currently, const stepProgress on line 18 breaks this rule by appearing between import statements (lines 16–17 and 19–24), which causes a parse error.

Reorganize to:

import { AlarmsType } from '@constants/alarms';
import { normalizeTime } from '@pages/onBoarding/utils/formatRemindTime';
import {
  Step,
  stepOrder,
  StepType,
  storySteps,
} from '@pages/onBoarding/constants/onboardingSteps';

const stepProgress = [{ progress: 33 }, { progress: 66 }, { progress: 100 }];
🤖 Prompt for AI Agents
In apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx around lines
16 to 24, an import order issue exists: the module-level const stepProgress is
declared between import statements which violates ES module rules; move the
stepProgress declaration after all import statements so every import appears
first (i.e., place the import of Step/stepOrder/StepType/storySteps on lines
after the other imports, then define const stepProgress = [{ progress: 33 }, {
progress: 66 }, { progress: 100 }] immediately following those imports).


const variants = {
slideIn: (direction: number) => ({
x: direction > 0 ? 200 : -200,
Expand All @@ -27,6 +34,7 @@ const variants = {
opacity: 0,
}),
};

const CardStyle = cva(
'bg-white-bg flex h-[54.8rem] w-[63.2rem] flex-col items-center justify-between rounded-[2.4rem] pt-[3.2rem]',
{
Expand All @@ -39,36 +47,41 @@ const CardStyle = cva(
defaultVariants: { overflow: false },
}
);

const MainCard = () => {
const [step, setStep] = useState(0);
const navigate = useNavigate();
const location = useLocation();
const { mutate: postSignData } = usePostSignUp();

const [step, setStep] = useState<StepType>(Step.STORY_0);
const [direction, setDirection] = useState(0);
const [alarmSelected, setAlarmSelected] = useState<1 | 2 | 3>(1);
const [isMac, setIsMac] = useState(false);
// api 구간
const { mutate: postSignData } = usePostSignUp();

// 익스텐션에서부터 이메일 받아오는 구간!
const [userEmail, setUserEmail] = useState('');
const location = useLocation();
const [remindTime, setRemindTime] = useState('09:00');
const [fcmToken, setFcmToken] = useState<string | null>(null);

useEffect(() => {
const params = new URLSearchParams(location.search);
const emailParam = params.get('email');
if (emailParam) {
setUserEmail(emailParam);
localStorage.setItem('email', emailParam);
const storedEmail = localStorage.getItem('email');
if (storedEmail) {
setUserEmail(storedEmail);
}

const stepParam = params.get('step') as StepType;
if (stepParam && Object.values(Step).includes(stepParam)) {
setStep(stepParam);
}
}, [location.search]);

// FCM 구간
const [fcmToken, setFcmToken] = useState<string | null>(null);
const app = initializeApp(firebaseConfig);
const messaging = getMessaging(app);

Comment on lines 77 to 79
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Firebase JavaScript SDK v9 initializeApp duplicate initialization error handling

💡 Result:

Short answer — prevent duplicate initialization by checking for an existing App before calling initializeApp(). Two common patterns for v9 (modular):

  1. Check getApps():
    import { initializeApp, getApps } from 'firebase/app'

if (!getApps().length) {
initializeApp(firebaseConfig)
}

  1. Try/get existing app:
    import { initializeApp, getApp } from 'firebase/app'

let app
try {
app = getApp()
} catch {
app = initializeApp(firebaseConfig)
}

Both avoid the "Firebase App named '[DEFAULT]' already exists (app/duplicate-app)" error. See Firebase docs and examples. [1][2]

Sources:
[1] Firebase JavaScript SDK — initializeApp / app docs.
[2] Example try/catch and getApp() pattern (Stack Overflow).


🌐 Web query:

Firebase initialization React component vs module scope best practice

💡 Result:

Short answer: initialize Firebase once in module scope (a single firebase.js / firebase.ts file), export the app and needed service instances, and import those where needed — or provide them via React Context. This avoids duplicate apps, keeps tree-shaking with the modular SDK, and makes testing/config clearer. [1][2][3][4]

Recommended pattern (modular SDK v9+):

  • Create one module that initializes and exports the app/services.
  • Guard against duplicate initialization (use getApps()/getApp() or try/catch).
  • Create and export service handles (getAuth, getFirestore, etc.) from that module.
  • If many components need Firebase, consider a Context/provider around your app rather than initializing inside components.

Minimal example (TypeScript/ESM, cite docs):
// firebase.ts
import { initializeApp, getApps, getApp } from "firebase/app";
import { getAuth } from "firebase/auth";
import { getFirestore } from "firebase/firestore";

const firebaseConfig = { /* your config */ };

const app = !getApps().length ? initializeApp(firebaseConfig) : getApp();
export const auth = getAuth(app);
export const db = getFirestore(app);
export default app;

Why this is best:

  • Single initialization prevents app/duplicate-app errors. [4]
  • Modular SDK + single module enables tree-shaking and smaller bundles. [1][2]
  • Exported services make testing and dependency injection (or React Context) simple. [1][3]

If you want, I can show a React Context/provider wrapper and a small test-mocking pattern.


Firebase initializeApp must be moved outside the component to prevent re-initialization errors on every render

MainCard is calling initializeApp() and getMessaging() inside the component body. When step or alarmSelected changes and MainCard re-renders, Firebase will attempt to reinitialize the app. Firebase v9+ throws a runtime error ("Firebase App named '[DEFAULT]' already exists") when the same app is initialized multiple times.

Recommended fix:

  1. Create a separate Firebase initialization file (or add to an existing one) at module scope:
// firebase.ts or similar, at file top level
import { initializeApp, getApps, getApp } from 'firebase/app';
import { getMessaging } from 'firebase/messaging';

const firebaseConfig = { /* ... */ };

// Guard against duplicate initialization
const app = !getApps().length ? initializeApp(firebaseConfig) : getApp();
export const messaging = getMessaging(app);
  1. Import the pre-initialized messaging in MainCard instead:
import { messaging } from '@/firebase'; // or wherever you define it

// Inside MainCard, remove these lines:
// const app = initializeApp(firebaseConfig);
// const messaging = getMessaging(app);

// Use the imported messaging directly:
const requestFCMToken = async (): Promise<string | null> => {
  const forFcmtoken = await getToken(messaging, {
    vapidKey: import.meta.env.VITE_FIREBASE_VAPID_KEY,
  });
  // ...
};

This ensures Firebase initializes only once, regardless of how many times MainCard renders.

🤖 Prompt for AI Agents
In apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx around lines
77-79, Firebase is being initialized inside the React component which causes
"Firebase App named '[DEFAULT]' already exists" on re-renders; move
initialization to a module-scoped file (e.g., src/firebase.ts) that imports
initializeApp, getApps, getApp and getMessaging, guards with !getApps().length ?
initializeApp(firebaseConfig) : getApp(), and exports a pre-initialized
messaging instance (export const messaging = getMessaging(app)); then remove the
initializeApp/getMessaging calls from MainCard and import the exported
messaging, using it in requestFCMToken/getToken with the VAPID key as before.

const requestFCMToken = async (): Promise<string | null> => {
try {
const permission = await Notification.requestPermission();
registerServiceWorker();

if (permission !== 'granted') {
alert('알림 권한 허용이 필요합니다!');
return null;
Expand Down Expand Up @@ -107,80 +120,92 @@ const MainCard = () => {
}
})();
}, []);

const renderStep = () => {
switch (step) {
case 0:
case 1:
case 2:
return <StoryStep step={step as 0 | 1 | 2} />;
case 3:
case Step.STORY_0:
case Step.STORY_1:
case Step.STORY_2:
return (
<StoryStep step={Number(step.replace('STORY_', '')) as 0 | 1 | 2} />
);
case Step.SOCIAL_LOGIN:
return <SocialLoginStep />;
case Step.ALARM:
return (
<AlarmStep selected={alarmSelected} setSelected={setAlarmSelected} />
);
case 4:
if (isMac) return <MacStep />;
case Step.MAC:
return <MacStep />;
case Step.FINAL:
return <FinalStep />;
case 5:
if (isMac) return <FinalStep />;
return null;
default:
return <FinalStep />;
}
};

const [remindTime, setRemindTime] = useState('09:00');
const nextStep = async () => {
if (step === 3) {
if (alarmSelected == 1) {
setRemindTime('09:00');
} else if (alarmSelected == 2) {
setRemindTime('20:00');
} else {
const idx = stepOrder.indexOf(step);
const next = stepOrder[idx + 1];
const isAlarmStep = step === Step.ALARM;
const isFinalStep = step === Step.FINAL;
const isMacStep = next === Step.MAC;
const shouldSkipMacStep = isMacStep && !isMac;

if (isAlarmStep) {
if (alarmSelected === 1) setRemindTime('09:00');
else if (alarmSelected === 2) setRemindTime('20:00');
else {
const raw = AlarmsType[alarmSelected - 1].time;
setRemindTime(normalizeTime(raw));
}
}

if (shouldSkipMacStep) {
setDirection(1);
setStep((prev) => prev + 1);
setStep(Step.FINAL);
navigate(`/onboarding?step=${Step.FINAL}`);
return;
}
if ((isMac && step < 5) || (!isMac && step < 4)) {
setDirection(1);
setStep((prev) => prev + 1);
} else if ((isMac && step === 5) || (!isMac && step == 4)) {

if (isFinalStep) {
postSignData(
{ email: userEmail, remindDefault: remindTime, fcmToken },
{
email: userEmail,
remindDefault: remindTime,
fcmToken: fcmToken,
},
{
onSuccess: () => {
window.location.href = '/';
},
onSuccess: () => (window.location.href = '/'),
onError: () => {
const savedEmail = localStorage.getItem('email');
if (savedEmail) {
window.location.href = '/';
}
if (savedEmail) window.location.href = '/';
},
}
);
return;
}

setDirection(1);
setStep(next);
navigate(`/onboarding?step=${next}`);
};

const prevStep = () => {
if (step > 0) {
const idx = stepOrder.indexOf(step);
if (idx > 0) {
const previous = stepOrder[idx - 1];
setDirection(-1);
setStep((prev) => prev - 1);
setStep(previous);
navigate(`/onboarding?step=${previous}`);
}
};
Comment on lines 190 to 198
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

비Mac 사용자가 뒤로가기 시 MAC 단계에 도달할 수 있습니다.

nextStep에서는 비Mac 사용자일 때 MAC 단계를 건너뛰지만, prevStep에서는 이 로직이 없습니다. FINAL 단계에서 뒤로가기 하면 비Mac 사용자도 MAC 단계가 표시됩니다.

  const prevStep = () => {
    const idx = stepOrder.indexOf(step);
    if (idx > 0) {
-     const previous = stepOrder[idx - 1];
+     let previous = stepOrder[idx - 1];
+     // 비Mac 사용자가 MAC 단계를 건너뛰도록 처리
+     if (previous === Step.MAC && !isMac) {
+       previous = Step.ALARM;
+     }
      setDirection(-1);
      setStep(previous);
      navigate(`/onboarding?step=${previous}`);
    }
  };
🤖 Prompt for AI Agents
In apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx around lines
207 to 215, prevStep currently just moves one index back in stepOrder which
causes non-Mac users to land on the MAC step when going back; update prevStep to
mirror nextStep's logic by skipping the MAC step for non-Mac users — determine
the candidate previous step, if it is the MAC step and the user is not on a Mac
(using the same isMac check or prop used in nextStep) then step back one more
index (or find the previous valid step) before calling setDirection(-1),
setStep(...) and navigate(`/onboarding?step=...`) so non-Mac users never
transition into the MAC step on back navigation.


return (
<div className={CardStyle({ overflow: step === 3 && alarmSelected === 3 })}>
{step < 3 && (
<div
className={CardStyle({
overflow: step === Step.ALARM && alarmSelected === 3,
})}
>
{storySteps.includes(step) && (
<Progress
value={stepProgress[step].progress}
value={stepProgress[storySteps.indexOf(step)].progress}
variant="profile"
className="w-[15.6rem]"
/>
Expand All @@ -204,26 +229,27 @@ const MainCard = () => {
</div>

<div className="mb-[4.8rem] mt-[1.2rem] flex w-full justify-between px-[3.2rem]">
{step < 4 && step > 0 && (
{!([Step.STORY_0, Step.SOCIAL_LOGIN] as StepType[]).includes(step) && (
<Button
variant="secondary"
size="medium"
isDisabled={step === 0}
className="w-[4.8rem]"
onClick={prevStep}
>
뒤로
</Button>
)}
<Button
variant="primary"
size="medium"
isDisabled={step === 6}
className="ml-auto w-[4.8rem]"
onClick={nextStep}
>
다음
</Button>

{step !== Step.SOCIAL_LOGIN && (
<Button
variant="primary"
size="medium"
className="ml-auto w-[4.8rem]"
onClick={nextStep}
>
다음
</Button>
)}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Chippi from '@assets/chippi_extension_popup.svg';
import GoogleLogo from '/assets/onBoarding/icons/googleLogo.svg';

const SocialLoginStep = () => {
const handleGoogleLogin = () => {
const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
const redirectUri = import.meta.env.VITE_GOOGLE_REDIRECT_URI;

if (!clientId || !redirectUri) {
alert('Google OAuth 설정이 누락되었습니다.');
return;
}

const googleAuthUrl =
`https://accounts.google.com/o/oauth2/v2/auth?` +
`client_id=${clientId}` +
`&redirect_uri=${redirectUri}` +
`&response_type=code` +
`&scope=email profile`;

window.location.href = googleAuthUrl;
};
Comment on lines +4 to +22
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Google OAuth URL 구성 시 보안(state) 및 인코딩 개선 필요

현재 handleGoogleLogin에서 Google OAuth URL을 직접 문자열로 이어 붙이고 있고, state 파라미터가 빠져 있습니다. 두 가지 개선을 강하게 추천드립니다.

  1. state 파라미터 추가 및 콜백에서 검증

    • 랜덤한 state 값을 생성해 sessionStorage 등에 저장하고, OAuth URL에 함께 실어 보내야 CSRF 공격 및 콜백 URL 오염을 방지할 수 있습니다.
    • 이후 콜백 컴포넌트(GoogleCallback)에서 state 쿼리값과 저장된 값을 비교해 일치하지 않으면 에러 처리해야 안전합니다.
  2. URL 파라미터 인코딩

    • redirectUri, scope 등의 값은 공백/특수문자를 포함할 수 있으므로 URLSearchParams를 사용해 안전하게 인코딩하는 편이 좋습니다.

예시는 아래처럼 구현할 수 있습니다.

-    const googleAuthUrl =
-      `https://accounts.google.com/o/oauth2/v2/auth?` +
-      `client_id=${clientId}` +
-      `&redirect_uri=${redirectUri}` +
-      `&response_type=code` +
-      `&scope=email profile`;
-
-    window.location.href = googleAuthUrl;
+    const state = crypto.randomUUID();
+    sessionStorage.setItem('google_oauth_state', state);
+
+    const params = new URLSearchParams({
+      client_id: clientId,
+      redirect_uri: redirectUri,
+      response_type: 'code',
+      scope: 'email profile',
+      state,
+    });
+
+    window.location.href =
+      `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;

(콜백 쪽에서 state 검증 로직도 같이 추가해 주셔야 합니다.)

Also applies to: 40-50


🌐 Web query:

Google OAuth 2.0 authorization endpoint에서 `state` 파라미터의 역할과 SPA에서 사용 시 권장되는 보안 패턴(PKCE 등)을 확인해 주세요.

💡 Result:

요약 — 목적과 권장 패턴

  1. state 파라미터 역할
  • OAuth 요청과 응답 사이에 임의의 문자열을 유지해 "요청 ↔ 응답"이 같은 브라우저/세션에서 발생했음을 검증(특히 CSRF 방지용). 서버가 받은 state가 요청할 때 보낸 값과 일치하는지 확인해야 함. 또한 리다이렉트 후 원래 위치 복원, nonce 전달 등에도 사용 가능. [1][2].
  1. SPA(브라우저 앱)에서 권장 보안 패턴
  • Authorization Code flow + PKCE 사용: 브라우저 앱은 Authorization Code 흐름에 PKCE(code_challenge/code_verifier)를 결합해 사용해야 하며(implicit flow 사용 권장 안 함), PKCE는 공용 클라이언트에서 인증 코드 탈취를 방지. Google도 코드 모델(PKCE 포함) 및 팝업 UX 권장을 제시. [3][4].
  • state(또는 OIDC nonce) 생성·검증: 요청 시 충분히 랜덤한 state(권장: 고비트 랜덤, 일회용) 생성→ 로컬(세션 쿠키/메모리/secure storage)에 보관→ 리다이렉트 응답에서 비교 검증. CSRF 방지 목적이라면 state를 쿠키 해시나 세션 식별자와 결합해 검증. [1][2].
  • code_verifier 보관 방식: PKCE의 code_verifier는 교환 전까지 안전하게 보관(권장: 메모리 또는 짧은 수명 세션 스토리지). 장기 저장 금지. [3].
  • 토큰 취급: 액세스/리프레시 토큰을 브라우저에 장기 저장하지 말 것(가능하면 HttpOnly SameSite 쿠키나 백엔드로 토큰 보관). 브라우저에 리프레시 토큰을 발급할 경우 리프레시 토큰 회전(rotation)과 제한된 수명 적용 권장. [5].
  • 리다이렉트 검증: 등록된 redirect_uri와 정확히 일치하는지 확인(리다이렉트 URI 검증 엄격 적용). [5].
  • 라이브러리 사용: 구현 실수 위험이 있으므로 Google Identity Services 등 검증된 라이브러리 사용 권장. [4].

참고(주요 출처)

  • Google OAuth 문서(상태 파라미터 설명, 클라이언트 측/서버 측 권장사항). [1][2][4]
  • Google 권장: Authorization Code model (팝업 UX 등) 및 PKCE 사용 가이드. [3]
  • IETF 권고(“OAuth 2.0 for Browser-Based Apps”) — 브라우저 앱에 PKCE, 리프레시 토큰 회전, 정확한 redirect URI, 액세스 토큰 응답 금지 등 요구사항. [5]

원하시면 샘플 구현 절차(요청 예시, state/PKCE 생성·검증 코드 스니펫, 안전한 토큰 저장 방안)를 한국어 코드 예제로 바로 드리겠습니다.


웹 검색 결과를 검토했습니다. Google OAuth 공식 문서와 IETF 권고에서 확인된 내용:

state 파라미터는 필수 — CSRF 공격 방지 및 요청-응답 매칭 검증용
SPA에서는 Authorization Code + PKCE 조합 권장 — 인증 코드 탈취 방지
URLSearchParams 사용 — 안전한 파라미터 인코딩
콜백에서 state 검증 필수 — 일치 확인 후 진행

원본 리뷰의 보안 지적이 모두 검증되었습니다. 최종 리뷰 코멘트를 생성하겠습니다.


Google OAuth URL 구성 시 CSRF 방지(state) 및 보안 개선 필수

현재 handleGoogleLogin에서 Google OAuth URL을 문자열 연결로 구성하고 있으며, state 파라미터가 누락되어 CSRF 공격 및 콜백 리다이렉트 오염에 취약합니다. 다음 개선이 필수입니다.

  1. state 파라미터 생성 및 검증 (CSRF 방지)

    • 랜덤한 state 값을 생성해 sessionStorage에 저장하고 OAuth URL에 포함
    • 콜백 컴포넌트(GoogleCallback)에서 쿼리의 state 값과 저장된 값을 비교해 일치 확인 후 진행
  2. URL 파라미터 안전한 인코딩

    • URLSearchParams 사용으로 redirectUri, scope 등 특수문자 안전 처리
  3. PKCE 검토 (권장)

    • SPA에서는 Authorization Code + PKCE 조합이 권장 패턴입니다. 라이브러리(Google Identity Services) 사용 권장.

구현 예시:

-    const googleAuthUrl =
-      `https://accounts.google.com/o/oauth2/v2/auth?` +
-      `client_id=${clientId}` +
-      `&redirect_uri=${redirectUri}` +
-      `&response_type=code` +
-      `&scope=email profile`;
-
-    window.location.href = googleAuthUrl;
+    const state = crypto.randomUUID();
+    sessionStorage.setItem('google_oauth_state', state);
+
+    const params = new URLSearchParams({
+      client_id: clientId,
+      redirect_uri: redirectUri,
+      response_type: 'code',
+      scope: 'email profile',
+      state,
+    });
+
+    window.location.href =
+      `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;

콜백 컴포넌트에서도 state 검증 로직을 추가하세요.

(Also applies to: lines 40-50)

🤖 Prompt for AI Agents
In apps/client/src/pages/onBoarding/components/funnel/step/SocialLoginStep.tsx
around lines 4-22 (and also apply the same fix to lines 40-50), the Google OAuth
URL is built by string concatenation and omits a required state parameter;
generate a cryptographically random state, save it to sessionStorage, build the
auth URL with URLSearchParams (including client_id, redirect_uri,
response_type=code, scope, and state) to ensure proper encoding, and redirect to
that URL; additionally, plan to implement state verification in the callback
component to compare the incoming state against the stored value and consider
adding PKCE (or a Google Identity library) for SPA-safe authorization code flow.


return (
<div className="flex flex-col items-center justify-center pt-6">
<img
src={Chippi}
alt="치삐 이미지"
className="h-[19.4rem] w-[19.4rem] object-contain"
/>

<h1 className="head2 text-font-black-1 mb-[0.8rem] text-center">
치삐를 만나려면 로그인이 필요해요!
</h1>

<p className="body2-m text-font-gray-3 mb-[3.5rem] text-center">
로그인하고 북마크한 정보를 리마인드를 받아보세요.
</p>

<button
onClick={handleGoogleLogin}
className="sub2-sb flex h-[5.2rem] w-[22.7rem] items-center justify-between gap-3 rounded-full border border-gray-100 bg-white px-[2rem]"
>
<img
src={GoogleLogo}
alt="구글 로고"
className="h-[2.435rem] w-[2.435rem]"
/>
구글 계정으로 로그인
</button>
</div>
);
};

export default SocialLoginStep;
Loading
Loading