Skip to content

Feat(Client): refreshtoken update#308

Merged
jjangminii merged 5 commits intodevelopfrom
feat/#307/refreshtoken-update
Mar 10, 2026
Merged

Feat(Client): refreshtoken update#308
jjangminii merged 5 commits intodevelopfrom
feat/#307/refreshtoken-update

Conversation

@jjangminii
Copy link
Collaborator

@jjangminii jjangminii commented Mar 10, 2026

📌 Related Issues

관련된 Issue를 태그해주세요. (e.g. - close #25)

📄 Tasks

  • API 통신 중 401(Unauthorized) / 403(Forbidden) 에러 발생 시 /api/v3/auth/reissue API를 호출하여 Access Token을 자동 갱신하는 로직 추가
  • 갱신 성공 시 발급받은 새 토큰으로 기존에 실패했던 API 요청을 재시도 (Silent Refresh)
  • 갱신 실패(Refresh Token 만료 등) 시 로컬 스토리지 클리어 및 소셜 로그인 페이지로 리다이렉트 처리

⭐ PR Point (To Reviewer)

  • 테스트 방법: 로그인 후 로컬 스토리지의 token 값을 고의로 변조한 뒤, 새로고침하거나 API를 호출하여 자동으로 재발급 흐름을 타는지 (네트워크 탭 확인) 테스트할 수 있습니다.

📷 Screenshot

Summary by CodeRabbit

릴리스 노트

  • 버그 수정

    • 세션 만료 시 자동 토큰 재발급 흐름 개선으로 로그인 끊김 감소
    • 로그인 시 리프레시 토큰 처리 및 저장 안정성 향상
  • 리팩토링

    • 인증 엔드포인트 및 토큰 갱신 플로우 업데이트로 재시도/리다이렉션 로직 개선
    • 로그인 성공 시 토큰 전파 방식 정비로 외부 연동 신뢰성 향상

@jjangminii jjangminii linked an issue Mar 10, 2026 that may be closed by this pull request
@vercel
Copy link

vercel bot commented Mar 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pinback-client-client Ready Ready Preview, Comment Mar 10, 2026 7:12am
pinback-client-landing Ready Ready Preview, Comment Mar 10, 2026 7:12am

@github-actions github-actions bot added the feat 기능 개발하라 개발 달려라 달려 label Mar 10, 2026
@coderabbitai
Copy link

coderabbitai bot commented Mar 10, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5f9641a8-86a3-4042-9117-0de6f60ab426

📥 Commits

Reviewing files that changed from the base of the PR and between 3e0bbff and def11bb.

📒 Files selected for processing (1)
  • apps/client/src/pages/onBoarding/GoogleCallback.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/client/src/pages/onBoarding/GoogleCallback.tsx

Walkthrough

Google OAuth 콜백에서 refreshToken을 수신·저장하도록 handleUserLogin 및 loginWithCode 호출을 변경하고, axios 인스턴터의 401/403 처리 흐름을 즉시 온보딩으로 리다이렉트하지 않고 /api/v3/auth/reissue로 재발급을 시도해 토큰 갱신·저장·재시도 로직으로 대체했습니다.

Changes

Cohort / File(s) Summary
Google OAuth 콜백 및 토큰 저장
apps/client/src/pages/onBoarding/GoogleCallback.tsx
handleUserLogin 시그니처에 `refreshToken: string
Axios 인터셉터 토큰 재발급 로직
apps/client/src/shared/apis/setting/axiosInstance.ts
no-auth-needed에 /api/v3/auth/google, /api/v3/auth/reissue 추가. 401/403 처리 시 온보딩 리다이렉트 대신 /api/v3/auth/reissue로 POST(withCredentials)하여 토큰 재발급 시도. 재발급 성공 시 accessToken 저장·broadcast(window.postMessage)·Authorization 헤더 갱신 후 원래 요청 재시도, 실패 시 localStorage 정리 후 온보딩(SOCIAL_LOGIN)으로 리다이렉트.

Sequence Diagram(s)

sequenceDiagram
    participant Browser as Client (브라우저)
    participant API as API 서버
    participant Storage as localStorage
    participant PostMsg as window.postMessage

    Browser->>API: 보호된 요청 (Bearer accessToken)
    API-->>Browser: 401/403

    Browser->>API: POST /api/v3/auth/reissue (withCredentials: true)
    alt 재발급 성공
        API-->>Browser: { accessToken, refreshToken? }
        Browser->>Storage: accessToken 저장
        Browser->>Storage: refreshToken 저장 (있다면)
        Browser->>PostMsg: window.postMessage('SET_TOKEN', accessToken)
        Browser->>API: 원래 요청 재시도 (업데이트된 Authorization)
        API-->>Browser: 200 OK
    else 재발급 실패
        API-->>Browser: 에러
        Browser->>Storage: accessToken, refreshToken 제거
        Browser->>Browser: 온보딩 페이지로 리다이렉트 (SOCIAL_LOGIN)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

🛠️ Feature, frontend

Suggested reviewers

  • jllee000
  • constantly-dev

Poem

🐰 코드를 훌쩍 건너와 토큰을 물고
창문에 속삭여 SET_TOKEN을 전했네 🗝️
리이슈로 숨겨진 열쇠도 찾아와서
다시 요청을 밀어주니 길이 열렸네
당근 들고 축하 춤을 출게용 🥕✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 refreshToken 업데이트라는 주요 변경사항을 명확하게 반영하고 있으며, 실제 코드 변경(Google 콜백 및 axios 인스턴스의 토큰 갱신 로직)과 관련이 있습니다.
Description check ✅ Passed PR 설명은 템플릿을 따르고 있으며, 관련 Issue(#307) 태그, 작업 내용(Silent Refresh 로직), 리뷰어를 위한 테스트 방법을 포함하고 있습니다.
Linked Issues check ✅ Passed PR #308은 Issue #307의 모든 요구사항(401/403 시 /api/v3/auth/reissue 호출, 성공 시 재시도, 실패 시 localStorage 초기화)을 충족합니다. 다만 Issue #25(Progress 컴포넌트)는 이 PR과 무관합니다.
Out of Scope Changes check ✅ Passed 모든 변경사항은 Issue #307의 refreshToken 업데이트 범위 내에 있으며, GoogleCallback.tsx와 axiosInstance.ts 파일의 수정이 토큰 갱신 로직 구현에 직접 관련됩니다.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/#307/refreshtoken-update

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.

@jjangminii jjangminii changed the title Feat/#307/refreshtoken update Feat(Client): /#307/refreshtoken update Mar 10, 2026
@jjangminii jjangminii changed the title Feat(Client): /#307/refreshtoken update Feat(Client): refreshtoken update Mar 10, 2026
@github-actions
Copy link

github-actions bot commented Mar 10, 2026

✅ Storybook chromatic 배포 확인:
🐿️ storybook

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/client/src/shared/apis/setting/axiosInstance.ts`:
- Around line 50-77: The token reissue calls must be serialized: introduce a
shared module-level Promise (e.g., reissuePromise) that is set when calling
axios.post(`${import.meta.env.VITE_BASE_URL}/api/v3/auth/reissue`, ...) and
reused by concurrent interceptors so only one network reissue occurs; in the
interceptor code handling originalRequest use await reissuePromise if it exists,
otherwise create it, then on success update localStorage and postMessage, set
originalRequest.headers.Authorization = `Bearer ${newAccessToken}` and retry via
apiRequest(originalRequest), and on failure clear reissuePromise, remove tokens
and redirect (same logic as currently in the catch using reissueError) before
rejecting—ensure reissuePromise is cleared after completion so subsequent 401s
can trigger a new reissue.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1a5f8a24-e4dd-4e4e-bd23-b2d159b4b382

📥 Commits

Reviewing files that changed from the base of the PR and between f95da05 and e22c160.

📒 Files selected for processing (2)
  • apps/client/src/pages/onBoarding/GoogleCallback.tsx
  • apps/client/src/shared/apis/setting/axiosInstance.ts

Comment on lines +50 to +77
try {
const res = await axios.post(
`${import.meta.env.VITE_BASE_URL}/api/v3/auth/reissue`,
{},
{
withCredentials: true,
}
);

return Promise.reject(error);
const newAccessToken = res.data.data.token;
localStorage.setItem('token', newAccessToken);

window.postMessage(
{ type: 'SET_TOKEN', token: newAccessToken },
window.location.origin
);

originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return apiRequest(originalRequest);
} catch (reissueError) {
console.error('토큰 재발급 실패. 다시 로그인해주세요.', reissueError);

localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
window.location.href = '/onboarding?step=SOCIAL_LOGIN';

return Promise.reject(reissueError);
}
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

토큰 재발급을 단일 플로우로 직렬화해 주세요.

여기서는 401/403이 동시에 여러 개 들어오면 각 요청이 /api/v3/auth/reissue를 따로 호출합니다. refresh token rotation을 쓰는 서버라면 한 요청은 성공하고 다른 요청은 실패해서, 결국 Line 72-74의 강제 로그아웃으로 이어질 수 있습니다. 재발급 Promise를 공유하고 대기 중인 요청은 그 결과를 재사용하도록 막는 편이 안전합니다.

예시 수정안
+let refreshRequest: Promise<string> | null = null;
+
 apiRequest.interceptors.response.use(
   (response) => response,
   async (error) => {
@@
-      try {
-        const res = await axios.post(
-          `${import.meta.env.VITE_BASE_URL}/api/v3/auth/reissue`,
-          {},
-          {
-            withCredentials: true,
-          }
-        );
-
-        const newAccessToken = res.data.data.token;
+      try {
+        refreshRequest ??= axios
+          .post(
+            `${import.meta.env.VITE_BASE_URL}/api/v3/auth/reissue`,
+            {},
+            { withCredentials: true }
+          )
+          .then((res) => res.data.data.token)
+          .finally(() => {
+            refreshRequest = null;
+          });
+
+        const newAccessToken = await refreshRequest;
         localStorage.setItem('token', newAccessToken);
@@
-        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
+        originalRequest.headers = {
+          ...originalRequest.headers,
+          Authorization: `Bearer ${newAccessToken}`,
+        };
         return apiRequest(originalRequest);
📝 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
try {
const res = await axios.post(
`${import.meta.env.VITE_BASE_URL}/api/v3/auth/reissue`,
{},
{
withCredentials: true,
}
);
return Promise.reject(error);
const newAccessToken = res.data.data.token;
localStorage.setItem('token', newAccessToken);
window.postMessage(
{ type: 'SET_TOKEN', token: newAccessToken },
window.location.origin
);
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return apiRequest(originalRequest);
} catch (reissueError) {
console.error('토큰 재발급 실패. 다시 로그인해주세요.', reissueError);
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
window.location.href = '/onboarding?step=SOCIAL_LOGIN';
return Promise.reject(reissueError);
}
let refreshRequest: Promise<string> | null = null;
apiRequest.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401 || error.response?.status === 403) {
try {
refreshRequest ??= axios
.post(
`${import.meta.env.VITE_BASE_URL}/api/v3/auth/reissue`,
{},
{ withCredentials: true }
)
.then((res) => res.data.data.token)
.finally(() => {
refreshRequest = null;
});
const newAccessToken = await refreshRequest;
localStorage.setItem('token', newAccessToken);
window.postMessage(
{ type: 'SET_TOKEN', token: newAccessToken },
window.location.origin
);
originalRequest.headers = {
...originalRequest.headers,
Authorization: `Bearer ${newAccessToken}`,
};
return apiRequest(originalRequest);
} catch (reissueError) {
console.error('토큰 재발급 실패. 다시 로그인해주세요.', reissueError);
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
window.location.href = '/onboarding?step=SOCIAL_LOGIN';
return Promise.reject(reissueError);
}
}
return Promise.reject(error);
}
);
🧰 Tools
🪛 ast-grep (0.41.0)

[warning] 59-59: Detected potential storage of sensitive information in browser localStorage. Sensitive data like email addresses, personal information, or authentication tokens should not be stored in localStorage as it's accessible to any script.
Context: localStorage.setItem('token', newAccessToken)
Note: [CWE-312] Cleartext Storage of Sensitive Information [REFERENCES]
- https://owasp.org/www-community/vulnerabilities/HTML5_Security_Cheat_Sheet
- https://cwe.mitre.org/data/definitions/312.html

(browser-storage-sensitive-data)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/client/src/shared/apis/setting/axiosInstance.ts` around lines 50 - 77,
The token reissue calls must be serialized: introduce a shared module-level
Promise (e.g., reissuePromise) that is set when calling
axios.post(`${import.meta.env.VITE_BASE_URL}/api/v3/auth/reissue`, ...) and
reused by concurrent interceptors so only one network reissue occurs; in the
interceptor code handling originalRequest use await reissuePromise if it exists,
otherwise create it, then on success update localStorage and postMessage, set
originalRequest.headers.Authorization = `Bearer ${newAccessToken}` and retry via
apiRequest(originalRequest), and on failure clear reissuePromise, remove tokens
and redirect (same logic as currently in the catch using reissueError) before
rejecting—ensure reissuePromise is cleared after completion so subsequent 401s
can trigger a new reissue.

Copy link
Member

@constantly-dev constantly-dev left a comment

Choose a reason for hiding this comment

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

크리티컬한 백로그는 아니었는데 엄청 빨리 작업해주셨네요!! 수고하셨습니다~~ 👍
리뷰 달았으니 코멘트 몇 개 확인해주세요!!

Comment on lines +72 to +73
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
Copy link
Member

Choose a reason for hiding this comment

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

clearAuthStorage 등의 네이밍을 붙인 하나의 함수로 분리하는 것도 괜찮을 것 같은데 어떻게 생각하시나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

현재 localStorage에 저장하거나 삭제, 변경하는 로직이 적지 않다고 생각하는데 localStorage를 관리하는 함수나 유틸을 따로 파일로 만들어도 좋을 것 같아요-!

Comment on lines +51 to +56
const res = await axios.post(
`${import.meta.env.VITE_BASE_URL}/api/v3/auth/reissue`,
{},
{
withCredentials: true,
}
Copy link
Member

Choose a reason for hiding this comment

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

해당 v3/auth/reissue API 호출은 함수로 분리해서 사용하는 것이 컨벤션에도 맞을 것 같아요!
같은 파일에 둬도 괜찮을 것 같아요.

Comment on lines +60 to +65
localStorage.setItem('token', newAccessToken);

window.postMessage(
{ type: 'SET_TOKEN', token: newAccessToken },
window.location.origin
);
Copy link
Member

Choose a reason for hiding this comment

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

사실 저희 프로젝트가 토큰을 extension이랑 client가 주고 받아야 하는 구조다보니까, localStorage 관련 추가/삭제 로직과 window.postMessage로 메시지 보내는 로직이 거의 함께 작동을 하는 것 같아요.
이런 두 로직이 동시에 묶여서 재사용 되는 경우가 많으니 아예 같이 묶인 Util로 빼도 좋을 것 같은데 어떻게 생각하시나요?

Copy link
Member

Choose a reason for hiding this comment

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

이건 다시 생각해보니까 이 두개를 묶어서 추상화하기가 조금 애매한 거 같기도해요.
이유는 두 로직이 너무 다른 역할(extension send message + localStorage 설정)이라, 단순 두개를 묶기보다 extension 쪽 message 로직과 localStorage 로직을 각각 묶어서 조합해서 사용하는 것이 더 좋을 것 같기도 합니다.

정민님은 어떻게 생각하시나요!??!
@jjangminii

Copy link
Collaborator Author

@jjangminii jjangminii Mar 12, 2026

Choose a reason for hiding this comment

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

저도 각각 묶어서 조합하는데 좋을것 같다고 생각합니다.

현재까지 localStorage에 저장하는 정보가 꽤 바뀌었다고 생각합니다. 다음 스프린트 때도 localStorage에 저장이 필요한 정보가 바뀔 수 있다고 생각하고요.

그래서 localStorage와 extension send message를 각각 묶는 방식이 좋다고 생각합니다-!

Copy link
Member

Choose a reason for hiding this comment

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

지금 401처리에 refresh하는 로직을 잘 작성을 해주시긴 했지만 한가지 문제가 있을 것 같아요.
만약 여러 API에서 동시에 401 등의 에러가 나타나게 되면,이때 한번의 refresh가 일어나야 하는데 현재 코드 기준으로는 이 모든 401이 온 API가 다 refresh를 호출하는 상황이 일어날 것 같아요.

현재 있는 !originalRequest._retry && 조건은 한 API에만 해당되는 것으로 알아서 여러 API에서 401이 터지면 모두 저 refresh를 할 것으로 예상이 돼요.

따라서 이를 refreshPromise 방식이나, isRefreshing + queue 패턴 등을 통해 이를 한번만 refresh하도록 할 수 있을 것 같아요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

refresh가 처음이라 이 부분은 예상하지 못했는데 이슈파서 공부하고 작업해보겠습니다-!

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/client/src/pages/onBoarding/GoogleCallback.tsx`:
- Around line 24-25: The code is persisting a high-value refreshToken into
localStorage (the accessToken/refreshToken fields in GoogleCallback), which
widens XSS attack surface; remove any localStorage writes/reads for refreshToken
(e.g., delete calls that setItem/getItem for refreshToken in the GoogleCallback
flow) and stop passing/storing refreshToken in the client side payloads—keep
only the accessToken if needed or rely solely on cookie-based refresh
(axiosInstance already uses withCredentials). Also remove the refreshToken
parameter from local state/props or any helper functions you control (references
to refreshToken in the GoogleCallback component) so the client no longer
duplicates the server-managed refresh token.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7fd2dd38-57d6-4147-8ba9-4855cf8daf0b

📥 Commits

Reviewing files that changed from the base of the PR and between e22c160 and 3e0bbff.

📒 Files selected for processing (1)
  • apps/client/src/pages/onBoarding/GoogleCallback.tsx

Comment on lines +24 to +25
accessToken: string | null,
refreshToken: string | null,
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

Refresh Token은 localStorage에 저장하지 않는 편이 안전합니다.

apps/client/src/shared/apis/setting/axiosInstance.ts의 Line 12-19는 refreshToken을 전혀 읽지 않고, Line 50-68의 재발급도 withCredentials 쿠키만 사용합니다. 지금 변경은 사용되지도 않는 고가치 토큰을 JS 접근 가능한 저장소에 한 번 더 복제하는 셈이라 XSS 노출면만 넓힙니다. 전달/저장 체인 자체를 제거하는 쪽이 안전합니다.

🔒 제안 수정안
   const handleUserLogin = (
     isUser: boolean,
     accessToken: string | null,
-    refreshToken: string | null,
     hasJob?: boolean
   ) => {
     if (isUser) {
       if (accessToken) {
         localStorage.setItem('token', accessToken);
@@
         sendTokenToExtension(accessToken);
       }
-
-      if (refreshToken) {
-        localStorage.setItem('refreshToken', refreshToken);
-      }
 
       if (typeof hasJob === 'boolean') {
         localStorage.setItem('hasJob', String(hasJob));
       }
       navigate('/');
@@
-      const { isUser, userId, email, accessToken, refreshToken, hasJob } =
+      const { isUser, userId, email, accessToken, hasJob } =
         res.data.data;
@@
-      handleUserLogin(isUser, accessToken, refreshToken, hasJob);
+      handleUserLogin(isUser, accessToken, hasJob);

Also applies to: 44-46, 74-80

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/client/src/pages/onBoarding/GoogleCallback.tsx` around lines 24 - 25,
The code is persisting a high-value refreshToken into localStorage (the
accessToken/refreshToken fields in GoogleCallback), which widens XSS attack
surface; remove any localStorage writes/reads for refreshToken (e.g., delete
calls that setItem/getItem for refreshToken in the GoogleCallback flow) and stop
passing/storing refreshToken in the client side payloads—keep only the
accessToken if needed or rely solely on cookie-based refresh (axiosInstance
already uses withCredentials). Also remove the refreshToken parameter from local
state/props or any helper functions you control (references to refreshToken in
the GoogleCallback component) so the client no longer duplicates the
server-managed refresh token.

Copy link
Member

@constantly-dev constantly-dev left a comment

Choose a reason for hiding this comment

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

수고하셨어요~~ 테스크 남은건 제가 따로 작업해서 올릴게요~~! 401 반복 refresh도 따로 작업해서 올려주세요! 👍

@jjangminii jjangminii merged commit 2661dd0 into develop Mar 10, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat 기능 개발하라 개발 달려라 달려

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] refreshToken 업데이트

2 participants