Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
54 changes: 54 additions & 0 deletions jobdri/src/app/oauth2/redirect/OAuthRedirectClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"use client";

import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ROUTES } from "@/constants/routes";
import { getEmailFromAccessToken, saveAuthTokens } from "@/lib/auth";

export default function OAuthRedirectClient() {
const router = useRouter();
const searchParams = useSearchParams();
const accessToken = searchParams.get("accessToken");
const refreshToken = searchParams.get("refreshToken");
const error = searchParams.get("error");
const errorMessage = searchParams.get("message");
const message =
accessToken && refreshToken
? "Google 로그인 처리 중입니다."
: errorMessage ||
(error ? "Google 로그인에 실패했습니다." : "로그인 정보를 확인할 수 없습니다.");

useEffect(() => {
if (accessToken && refreshToken) {
saveAuthTokens(
{ accessToken, refreshToken },
searchParams.get("email") ||
getEmailFromAccessToken(accessToken) ||
undefined,
);
router.replace(ROUTES.APPLY);
return;
}

const timerId = window.setTimeout(() => {
router.replace(ROUTES.LOGIN);
}, 2000);
Comment on lines +11 to +35
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 | 🏗️ Heavy lift

Avoid query-string token delivery in OAuth callback.

Reading accessToken/refreshToken from URL query on Line 11-12 exposes tokens to browser history and potential referrer leakage. This flow should move to a safer contract (e.g., backend-set HttpOnly cookies or one-time code exchange).

🛡️ Immediate client-side mitigation (partial)
   useEffect(() => {
     if (accessToken && refreshToken) {
+      window.history.replaceState(null, "", ROUTES.OAUTH_REDIRECT);
       saveAuthTokens(
         { accessToken, refreshToken },
         searchParams.get("email") ||
           getEmailFromAccessToken(accessToken) ||
           undefined,
       );
🤖 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 `@jobdri/src/app/oauth2/redirect/OAuthRedirectClient.tsx` around lines 11 - 35,
The component OAuthRedirectClient must stop reading accessToken/refreshToken
from URL query (avoid saveAuthTokens/getEmailFromAccessToken usage) and instead
read an authorization code (e.g., "code" and optional "state") and call a
backend endpoint to exchange that code server-side so the server issues HttpOnly
cookies; update the useEffect in OAuthRedirectClient to POST the code to an API
(e.g., /auth/oauth/callback), handle success by router.replace(ROUTES.APPLY) and
failure by router.replace(ROUTES.LOGIN), and remove any client-side storage of
raw tokens; keep getEmailFromAccessToken only if used elsewhere but do not use
it here, and ensure the redirect replaces history (router.replace) so the code
param is not retained.


return () => {
window.clearTimeout(timerId);
};
}, [accessToken, refreshToken, router, searchParams]);

return (
<main className="flex min-h-screen w-full items-center justify-center bg-bg-default px-4">
<section className="flex w-[440px] max-w-full flex-col items-center gap-6 rounded-card bg-bg-contents-default p-10 shadow-[0_0_24px_0_var(--color-bg-shadow-default)]">
<h1 className="text-center text-[32px] leading-[130%] font-bold text-text-neutral-title [font-feature-settings:'liga'_off,'clig'_off]">
JobDri
</h1>
<p className="text-sub14-med text-center text-text-neutral-caption [font-feature-settings:'liga'_off,'clig'_off]">
{message}
</p>
</section>
</main>
);
}
25 changes: 25 additions & 0 deletions jobdri/src/app/oauth2/redirect/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Suspense } from "react";
import OAuthRedirectClient from "./OAuthRedirectClient";

function OAuthRedirectFallback() {
return (
<main className="flex min-h-screen w-full items-center justify-center bg-bg-default px-4">
<section className="flex w-[440px] max-w-full flex-col items-center gap-6 rounded-card bg-bg-contents-default p-10 shadow-[0_0_24px_0_var(--color-bg-shadow-default)]">
<h1 className="text-center text-[32px] leading-[130%] font-bold text-text-neutral-title [font-feature-settings:'liga'_off,'clig'_off]">
JobDri
</h1>
<p className="text-sub14-med text-center text-text-neutral-caption [font-feature-settings:'liga'_off,'clig'_off]">
Google 로그인 처리 중입니다.
</p>
</section>
</main>
);
}

export default function OAuthRedirectPage() {
return (
<Suspense fallback={<OAuthRedirectFallback />}>
<OAuthRedirectClient />
</Suspense>
);
}
44 changes: 40 additions & 4 deletions jobdri/src/components/common/lnb/Lnb.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"use client";

import { useState } from "react";
import { useState, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation";
import { createPortal } from "react-dom";
import Icon, { type IconType } from "@/components/common/icons/Icon";
import { ModalNotice } from "@/components/common/modal";
import { AUTH_STORAGE_KEYS, getStoredAuthEmail } from "@/lib/auth";

type LnbItemKey = "experience" | "apply";

Expand Down Expand Up @@ -34,12 +35,47 @@ const navItems: LnbNavItem[] = [
const navItemBaseClassName =
"flex h-9 items-center gap-2 rounded-cta-l p-3 text-sub14-med";

const defaultEmail = "jobdri@gmail.com";

function subscribeToStoredEmail(onStoreChange: () => void) {
const handleStorage = (event: StorageEvent) => {
if (event.key === AUTH_STORAGE_KEYS.userEmail) {
onStoreChange();
}
};

window.addEventListener("storage", handleStorage);

return () => {
window.removeEventListener("storage", handleStorage);
};
}

function getStoredEmailSnapshot() {
return getStoredAuthEmail() ?? "";
}

function getServerStoredEmailSnapshot() {
return "";
}

function getEmailInitial(email: string) {
return email.trim().charAt(0).toUpperCase() || "J";
}

export default function Lnb({
initialActiveItem,
email = "jobdri@gmail.com",
email,
creditCount = 32,
}: LnbProps) {
const router = useRouter();
const storedEmail = useSyncExternalStore(
subscribeToStoredEmail,
getStoredEmailSnapshot,
getServerStoredEmailSnapshot,
);
const displayEmail = (email ?? storedEmail) || defaultEmail;
const emailInitial = getEmailInitial(displayEmail);
const [isFold, setIsFold] = useState(false);
const [showComingSoonModal, setShowComingSoonModal] = useState(false);
const [activeItem, setActiveItem] = useState<LnbItemKey | undefined>(
Expand Down Expand Up @@ -151,11 +187,11 @@ export default function Lnb({
}`}
>
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-text-neutral-caption text-[14px] font-medium leading-[140%] text-text-neutral-white">
J
{emailInitial}
</div>
{!isFold && (
<span className="truncate text-cap12-med text-text-neutral-caption">
{email}
{displayEmail}
</span>
)}
</div>
Expand Down
Loading