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
20 changes: 20 additions & 0 deletions apps/web/messages/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -296,5 +296,25 @@
"report-error-button": "Report Error",
"ranking-error-title": "Unable to load ranking information",
"ranking-error-description": "Please try again later"
},
"Auth": {
"sessionExpiredTitle": "Your session has expired",
"sessionExpiredDescription": "Please sign in again for security.\nYou'll return to your previous page after signing in.",
"sessionExpiredAction": "Sign in again",
"desktopTitle": "Connect desktop app",
"desktopDescription": "The GitAnimals desktop app is requesting GitHub authentication.\nYou'll return to the desktop app once you sign in.",
"desktopContinueButton": "Continue with GitHub",
"desktopAuthenticatedMessage": "Returning to the desktop app…",
"desktopLoadingMessage": "Please wait a moment…",
"desktopErrorBanner": "Something went wrong while signing in. Please try again.",
"desktopInvalidTitle": "Invalid request",
"desktopInvalidDescription": "The redirect_uri is out of the allowed range or a required parameter is missing.",
"errorTitle": "Sign in failed",
"errorDescriptionDefault": "You can try again or go back to the home page.",
"errorDescriptionAccessDenied": "Access was denied. Please try again.",
"errorDescriptionCredentials": "Account verification failed. Please try again in a moment.",
"errorRetryDesktop": "Retry desktop connection",
"errorRetryDefault": "Sign in again",
"errorGoHome": "Back to home"
}
}
20 changes: 20 additions & 0 deletions apps/web/messages/ko-KR.json
Original file line number Diff line number Diff line change
Expand Up @@ -297,5 +297,25 @@
"report-error-button": "오류 보고하기",
"ranking-error-title": "랭킹 정보를 불러올 수 없습니다",
"ranking-error-description": "잠시 후 다시 시도해주세요"
},
"Auth": {
"sessionExpiredTitle": "세션이 만료되었어요",
"sessionExpiredDescription": "보안을 위해 다시 로그인이 필요해요.\n로그인 후 이전 페이지로 자동 이동합니다.",
"sessionExpiredAction": "다시 로그인",
"desktopTitle": "데스크톱 앱 연결",
"desktopDescription": "GitAnimals 데스크톱 앱이 GitHub 계정 인증을 요청했어요.\n로그인하면 데스크톱 앱으로 자동 복귀해요.",
"desktopContinueButton": "GitHub으로 계속하기",
"desktopAuthenticatedMessage": "데스크톱 앱으로 이동하고 있어요…",
"desktopLoadingMessage": "잠시만 기다려주세요…",
"desktopErrorBanner": "로그인 중 문제가 발생했어요. 다시 시도해주세요.",
"desktopInvalidTitle": "잘못된 요청",
"desktopInvalidDescription": "redirect_uri가 허용 범위를 벗어났거나 필수 파라미터가 누락되었습니다.",
"errorTitle": "로그인에 실패했어요",
"errorDescriptionDefault": "다시 시도하거나 처음으로 돌아갈 수 있어요.",
"errorDescriptionAccessDenied": "접근이 거부되었어요. 다시 시도해주세요.",
"errorDescriptionCredentials": "계정 인증에 실패했어요. 잠시 후 다시 시도해주세요.",
"errorRetryDesktop": "데스크톱 연결 다시 시도",
"errorRetryDefault": "다시 로그인",
"errorGoHome": "처음으로"
}
}
32 changes: 13 additions & 19 deletions apps/web/src/apis/interceptor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { cache } from 'react';
import { getSession, signOut } from 'next-auth/react';
import { getSession } from 'next-auth/react';
import {
setRenderRequestInterceptor,
setRenderResponseInterceptor,
Expand All @@ -11,6 +11,7 @@ import type { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConf

import { getServerAuth } from '@/auth';
import type { ApiErrorScheme } from '@/exceptions/type';
import { triggerSessionExpired } from '@/utils/sessionExpired';

// Server path: request-scoped memoization via React cache(). Deduped within a
// single render (one JWT decode instead of one per outbound request), and never
Expand All @@ -32,6 +33,11 @@ let cachedSession: CachedSession | null = null;
let sessionPromise: Promise<string | null> | null = null;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes

export const clearSessionCache = () => {
cachedSession = null;
sessionPromise = null;
};

const getAccessToken = async (): Promise<string | null> => {
if (typeof window === 'undefined') {
return getServerAccessToken();
Expand Down Expand Up @@ -83,27 +89,15 @@ export const interceptorResponseFulfilled = (res: AxiosResponse) => {
};

// Response interceptor
// Latch so a burst of concurrent 401s triggers at most one signOut (which then
// redirects/reloads and resets this module).
let isSigningOut = false;

export const interceptorResponseRejected = async (error: AxiosError<ApiErrorScheme>) => {
if (error?.response?.status === 401) {
if (typeof window === 'undefined') {
// Server: surface as a domain exception (unchanged).
throw new CustomException('TOKEN_EXPIRED', 'token expired and sign out success');
}

// Client: only sign out an actually-authenticated session whose backend
// token expired. A 401 while logged out (e.g. a background query to an
// authed endpoint like /inboxes) must NOT trigger signOut — that loops:
// signOut → session refetch → re-render → re-query → 401 → signOut → …
const session = await getSession();
if (session?.user?.accessToken && !isSigningOut) {
isSigningOut = true;
signOut();
throw new CustomException('TOKEN_EXPIRED', 'token expired and sign out success');
// 캐시 무효화는 환경과 무관하게 먼저 수행 — 만료된 토큰 재사용 방지.
clearSessionCache();
// 복구 UX(세션 만료 다이얼로그)는 클라이언트에서만 띄운다.
if (typeof window !== 'undefined') {
triggerSessionExpired(window.location.pathname + window.location.search);
}
throw new CustomException('TOKEN_EXPIRED', 'token expired');
Comment on lines 93 to +100

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

서버에서 난 401은 세션 캐시를 비우지 못합니다.

지금은 clearSessionCache() 가 브라우저 분기 안에만 있어서, 서버 렌더링/서버 호출에서 401이 발생하면 같은 워커가 최대 5분 동안 만료된 access token을 계속 재사용할 수 있습니다. 캐시 무효화는 환경과 무관하게 먼저 수행하고, triggerSessionExpired() 만 클라이언트에서 호출해야 합니다.

🔧 제안 수정
 export const interceptorResponseRejected = async (error: AxiosError<ApiErrorScheme>) => {
   if (error?.response?.status === 401) {
-    if (typeof window !== 'undefined') {
-      clearSessionCache();
+    clearSessionCache();
+    if (typeof window !== 'undefined') {
       triggerSessionExpired(window.location.pathname + window.location.search);
     }
     throw new CustomException('TOKEN_EXPIRED', 'token expired');
   }
📝 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 (error?.response?.status === 401) {
if (typeof window !== 'undefined') {
signOut();
clearSessionCache();
triggerSessionExpired(window.location.pathname + window.location.search);
}
throw new CustomException('TOKEN_EXPIRED', 'token expired and sign out success');
throw new CustomException('TOKEN_EXPIRED', 'token expired');
if (error?.response?.status === 401) {
clearSessionCache();
if (typeof window !== 'undefined') {
triggerSessionExpired(window.location.pathname + window.location.search);
}
throw new CustomException('TOKEN_EXPIRED', 'token expired');
🤖 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 `@apps/web/src/apis/interceptor.ts` around lines 76 - 81, The 401 handling
currently only clears session cache inside the browser branch so server-side
401s keep using an expired token; move the call to clearSessionCache() so it
runs unconditionally when error?.response?.status === 401 (before the typeof
window check), keep triggerSessionExpired(window.location.pathname +
window.location.search) only inside the client branch, and still throw the same
CustomException('TOKEN_EXPIRED', 'token expired') afterward; update the block
around the error?.response?.status === 401 check to call clearSessionCache() for
all environments and call triggerSessionExpired() only when typeof window !==
'undefined'.

}

// TODO: 403 처리
Expand Down
43 changes: 31 additions & 12 deletions apps/web/src/app/[locale]/auth/desktop/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Button } from '@gitanimals/ui-tailwind';

import { login } from '@/components/AuthButton';
import { buildDesktopCallbackUrl, isValidDesktopRedirect } from '@/constants/desktopAuth';
Expand All @@ -16,43 +18,60 @@ export default function DesktopAuthPage() {
const params = useSearchParams();
const redirectUri = params.get('redirect_uri');
const state = params.get('state');
const errorCode = params.get('error');
const t = useTranslations('Auth');

const { status, data } = useClientSession();

const isValid = isValidDesktopRedirect(redirectUri) && !!state;

useEffect(() => {
if (!isValid) return;

if (status === 'authenticated' && data?.user?.accessToken) {
window.location.replace(buildDesktopCallbackUrl(redirectUri!, data.user.accessToken, state!));
} else if (status === 'unauthenticated') {
login(
`/auth/desktop?redirect_uri=${encodeURIComponent(redirectUri!)}&state=${encodeURIComponent(state!)}`,
);
}
}, [status, isValid, redirectUri, state, data?.user?.accessToken]);

if (!isValid) {
return (
<div className={pageRootClass}>
<div className={cardClass}>
<h1 className="glyph28-bold text-white mobile:glyph24-bold">잘못된 요청</h1>
<p className="glyph16-regular text-center text-white">
redirect_uri가 허용 범위를 벗어났거나 필수 파라미터가 누락되었습니다.
</p>
<h1 className="glyph28-bold text-white mobile:glyph24-bold">{t('desktopInvalidTitle')}</h1>
<p className="glyph16-regular text-center text-white-75">{t('desktopInvalidDescription')}</p>
</div>
</div>
);
}

const message =
status === 'authenticated' ? '데스크톱 앱으로 이동합니다…' : '로그인으로 이동합니다…';
const handleLogin = () => {
login(`/auth/desktop?redirect_uri=${encodeURIComponent(redirectUri!)}&state=${encodeURIComponent(state!)}`);
};

return (
<div className={pageRootClass}>
<div className={cardClass}>
<p className="glyph20-regular text-white">{message}</p>
<h1 className="glyph28-bold text-white mobile:glyph24-bold">{t('desktopTitle')}</h1>

{errorCode && (
<div className="w-full rounded-[8px] bg-[rgba(255,75,75,0.15)] px-[16px] py-[12px] text-center glyph14-regular text-white">
{t('desktopErrorBanner')}
</div>
)}

{status === 'loading' && <p className="glyph20-regular text-white-75">{t('desktopLoadingMessage')}</p>}

{status === 'authenticated' && (
<p className="glyph20-regular text-white-75">{t('desktopAuthenticatedMessage')}</p>
)}

{status === 'unauthenticated' && (
<>
<p className="whitespace-pre-line text-center glyph16-regular text-white-75">{t('desktopDescription')}</p>
<Button variant="primary" size="m" onClick={handleLogin}>
{t('desktopContinueButton')}
</Button>
</>
)}
</div>
</div>
);
Expand Down
77 changes: 77 additions & 0 deletions apps/web/src/app/[locale]/auth/error/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
'use client';

import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Button } from '@gitanimals/ui-tailwind';

import { login } from '@/components/AuthButton';
import { LOCAL_STORAGE_KEY } from '@/constants/storage';

const DESKTOP_CALLBACK_HINTS = ['/auth/desktop', 'redirect_uri='];

const isDesktopCallback = (value: string | null): value is string => {
if (!value) return false;
return DESKTOP_CALLBACK_HINTS.some((hint) => value.includes(hint));
};

const pageRootClass =
'flex min-h-screen flex-col items-center justify-center p-[24px] text-white bg-[linear-gradient(180deg,#000_0%,#004875_38.51%,#005B93_52.46%,#006FB3_73.8%,#0187DB_100%)] mobile:p-[16px]';
const cardClass =
'flex w-fit min-w-[520px] max-w-full flex-col items-center gap-[16px] rounded-[16px] bg-white-10 p-[40px] backdrop-blur-[7px] mobile:min-w-full mobile:bg-[rgba(255,255,255,0.08)] mobile:px-[16px] mobile:py-[24px]';

export default function AuthErrorPage() {
const params = useSearchParams();
const router = useRouter();
const t = useTranslations('Auth');

const errorCode = params.get('error');
const [savedCallbackUrl, setSavedCallbackUrl] = useState<string | null>(null);

useEffect(() => {
setSavedCallbackUrl(localStorage.getItem(LOCAL_STORAGE_KEY.callbackUrl));
}, []);

const isDesktopFlow = isDesktopCallback(savedCallbackUrl);

const description = (() => {
switch (errorCode) {
case 'AccessDenied':
return t('errorDescriptionAccessDenied');
case 'CredentialsSignin':
return t('errorDescriptionCredentials');
default:
return t('errorDescriptionDefault');
}
})();

const handleRetry = () => {
if (isDesktopFlow && savedCallbackUrl) {
router.replace(savedCallbackUrl);
return;
}
login('/mypage');
};

const handleGoHome = () => {
router.replace('/');
};

return (
<main className={pageRootClass}>
<section className={cardClass}>
<h1 className="glyph28-bold text-white mobile:glyph24-bold">{t('errorTitle')}</h1>
<p className="whitespace-pre-line text-center glyph16-regular text-white-75">{description}</p>
{errorCode && <p className="glyph14-regular text-white-50">code: {errorCode}</p>}
<div className="mt-[8px] flex flex-wrap justify-center gap-[12px]">
<Button variant="primary" size="m" onClick={handleRetry}>
{isDesktopFlow ? t('errorRetryDesktop') : t('errorRetryDefault')}
</Button>
<Button variant="secondary" size="m" onClick={handleGoHome}>
{t('errorGoHome')}
</Button>
</div>
</section>
</main>
);
}
6 changes: 4 additions & 2 deletions apps/web/src/components/AuthButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import { COOKIE_KEY, LOCAL_STORAGE_KEY } from '@/constants/storage';
/**
* client용 로그인 함수
*/
export const login = (callbackUrl: string = '/mypage') => {
localStorage.setItem(LOCAL_STORAGE_KEY.callbackUrl, callbackUrl);
export const login = (callbackUrl?: string) => {
// 인자가 없으면(예: 헤더 로그인 버튼) 미들웨어가 심어둔 딥링크 복귀 경로를 우선 사용한다.
const target = callbackUrl ?? localStorage.getItem(LOCAL_STORAGE_KEY.callbackUrl) ?? '/mypage';
localStorage.setItem(LOCAL_STORAGE_KEY.callbackUrl, target);

// cookie set (client)
const currentLocale = window.location.pathname.split('/')[1];
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/Global/GlobalComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import { createPortal } from 'react-dom';
import { Toaster } from 'sonner';

import FeedBack from './FeedbackForm';
import { LoginCallbackWatcher } from './LoginCallbackWatcher';
import { SessionExpiredDialog } from './SessionExpiredDialog';
import { DialogComponent } from './useDialog';

function GlobalComponent() {
return createPortal(
<>
<FeedBack />
<DialogComponent />
<SessionExpiredDialog />
<LoginCallbackWatcher />
<Toaster
position="top-center"
toastOptions={{
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/Global/LoginCallbackWatcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client';

import { useEffect } from 'react';
import { useSearchParams } from 'next/navigation';

import { LOCAL_STORAGE_KEY } from '@/constants/storage';

// 보호 라우트에서 미들웨어가 홈으로 되돌릴 때 실어 보낸 `?callbackUrl` 을 캡처해
// localStorage 에 저장한다. 사용자가 로그인하면 login()/LoginButton 이 이 값을 읽어
// 원래 목적지로 자동 복귀시킨다. (useSearchParams 구독으로 클라 네비게이션 유입도 감지)
export function LoginCallbackWatcher() {
const searchParams = useSearchParams();

useEffect(() => {
const callbackUrl = searchParams.get('callbackUrl');
if (!callbackUrl) return;

// open-redirect 방지: 외부 절대 URL(`//`, `https://…`)은 무시하고 앱 내부 경로만 허용.
const isInternalPath = callbackUrl.startsWith('/') && !callbackUrl.startsWith('//');
if (isInternalPath) {
localStorage.setItem(LOCAL_STORAGE_KEY.callbackUrl, callbackUrl);
}

const params = new URLSearchParams(searchParams.toString());
params.delete('callbackUrl');
const query = params.toString();
window.history.replaceState(null, '', window.location.pathname + (query ? `?${query}` : ''));
}, [searchParams]);

return null;
}
38 changes: 38 additions & 0 deletions apps/web/src/components/Global/SessionExpiredDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use client';

import { useTranslations } from 'next-intl';
import { Button, Dialog } from '@gitanimals/ui-tailwind';
import { useAtomValue } from 'jotai';

import { login } from '@/components/AuthButton';
import { sessionExpiredAtom } from '@/utils/sessionExpired';

export function SessionExpiredDialog() {
const { open, callbackUrl } = useAtomValue(sessionExpiredAtom);
const t = useTranslations('Auth');

const handleLogin = () => {
login(callbackUrl ?? '/mypage');
};

return (
<Dialog open={open}>
<Dialog.Content
isShowClose={false}
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<Dialog.Title className="text-left glyph20-regular">{t('sessionExpiredTitle')}</Dialog.Title>
<Dialog.Description className="w-full text-left glyph16-regular text-white-75">
{t('sessionExpiredDescription')}
</Dialog.Description>
<div className="flex w-full justify-end gap-[8px]">
<Button onClick={handleLogin} variant="primary" size="m">
{t('sessionExpiredAction')}
</Button>
</div>
</Dialog.Content>
</Dialog>
);
}
Loading
Loading