Skip to content
Open
1 change: 1 addition & 0 deletions apps/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@pinback/contracts": "workspace:*",
"@tanstack/react-query": "^5.85.3",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
Expand Down
3 changes: 2 additions & 1 deletion apps/client/src/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ROUTES_CONFIG } from '@routes/routesConfig';
import { useGetHasJob } from '@shared/apis/queries';
import JobSelectionFunnel from '@shared/components/jobSelectionFunnel/JobSelectionFunnel';
import { Sidebar } from '@shared/components/sidebar/Sidebar';
import { authStorage } from '@shared/utils/authStorage';
import { useQueryClient } from '@tanstack/react-query';
import { Outlet, useLocation } from 'react-router-dom';

Expand All @@ -19,7 +20,7 @@ const Layout = () => {
location.pathname.startsWith(ROUTES_CONFIG.onboardingCallback.path);

const isSidebarHidden = isAuthPage || isPolicyPage;
const isLoggedIn = !!localStorage.getItem('token');
const isLoggedIn = authStorage.hasAccessToken();

const { data: hasJobData, isLoading: isHasJobLoading } = useGetHasJob(
isLoggedIn && !isAuthPage
Expand Down
23 changes: 7 additions & 16 deletions apps/client/src/pages/onBoarding/GoogleCallback.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import apiRequest from '@shared/apis/setting/axiosInstance';
import LoadingChippi from '@shared/components/loadingChippi/LoadingChippi';
import { authStorage } from '@shared/utils/authStorage';
import { extensionBridge } from '@shared/utils/extensionBridge';
import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';

const sendTokenToExtension = (token: string) => {
window.postMessage(
{
type: 'SET_TOKEN',
token,
},
window.location.origin
);
};

const GoogleCallback = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
Expand All @@ -37,16 +29,16 @@ const GoogleCallback = () => {
) => {
if (isUser) {
if (accessToken) {
localStorage.setItem('token', accessToken);
sendTokenToExtension(accessToken);
authStorage.setAccessToken(accessToken);
extensionBridge.syncToken(accessToken);
}

if (refreshToken) {
localStorage.setItem('refreshToken', refreshToken);
authStorage.setRefreshToken(refreshToken);
}

if (typeof hasJob === 'boolean') {
localStorage.setItem('hasJob', String(hasJob));
authStorage.setHasJob(hasJob);
}
navigate('/');
} else {
Expand Down Expand Up @@ -74,8 +66,7 @@ const GoogleCallback = () => {
const { isUser, userId, email, accessToken, refreshToken, hasJob } =
res.data.data;

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

handleUserLogin(isUser, accessToken, refreshToken, hasJob);
} catch (error) {
Expand Down
15 changes: 4 additions & 11 deletions apps/client/src/shared/apis/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
JobsResponse,
} from '@shared/types/api';
import { fetchOGData } from '@shared/utils/fetchOgData';
import { authStorage } from '@shared/utils/authStorage';
import { extensionBridge } from '@shared/utils/extensionBridge';
import {
useMutation,
UseMutationResult,
Expand Down Expand Up @@ -94,17 +96,8 @@ export const usePostSignUp = () => {
const newToken = data?.data?.token || data?.token;

if (newToken) {
localStorage.setItem('token', newToken);
const sendTokenToExtension = (token: string) => {
window.postMessage(
{
type: 'SET_TOKEN',
token,
},
window.location.origin
);
};
sendTokenToExtension(newToken);
authStorage.setAccessToken(newToken);
extensionBridge.syncToken(newToken);
}
},
onError: (error) => {
Expand Down
66 changes: 40 additions & 26 deletions apps/client/src/shared/apis/setting/axiosInstance.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,34 @@
import axios from 'axios';
import { authStorage } from '@shared/utils/authStorage';
import { extensionBridge } from '@shared/utils/extensionBridge';

const noAuthNeeded = [
'/api/v1/auth/token',
'/api/v3/auth/signup',
'/api/v3/auth/google',
'/api/v3/auth/reissue',
];

const reissueToken = async () => {
return await axios.post(
`${import.meta.env.VITE_BASE_URL}/api/v3/auth/reissue`,
{},
{
withCredentials: true,
}
);
};

const syncAccessToken = (token: string) => {
authStorage.setAccessToken(token);
extensionBridge.syncToken(token);
};

const clearAuthSessionAndRedirect = () => {
authStorage.clearSession();
extensionBridge.logout();
window.location.href = '/onboarding?step=SOCIAL_LOGIN';
};

// Axios 인스턴스
const apiRequest = axios.create({
Expand All @@ -10,7 +40,7 @@ const apiRequest = axios.create({

// 요청 인터셉터
apiRequest.interceptors.request.use(async (config) => {
const token = localStorage.getItem('token');
const token = authStorage.getAccessToken();

if (token) {
config.headers.Authorization = `Bearer ${token}`;
Expand All @@ -25,13 +55,6 @@ apiRequest.interceptors.response.use(
async (error) => {
const originalRequest = error.config;

const noAuthNeeded = [
'/api/v1/auth/token',
'/api/v3/auth/signup',
'/api/v3/auth/google',
'/api/v3/auth/reissue',
];

const isNoAuth = noAuthNeeded.some((url) =>
originalRequest.url?.includes(url)
);
Expand All @@ -48,30 +71,21 @@ apiRequest.interceptors.response.use(
originalRequest._retry = true;

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

const newAccessToken = res.data.data.token;
localStorage.setItem('token', newAccessToken);

window.postMessage(
{ type: 'SET_TOKEN', token: newAccessToken },
window.location.origin
);
const res = await reissueToken();
const newAccessToken = res.data?.data?.token;

if (!newAccessToken) {
throw new Error('토큰 재발급 응답에 access token이 없습니다.');
}

syncAccessToken(newAccessToken);
originalRequest.headers = 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';
clearAuthSessionAndRedirect();

return Promise.reject(reissueError);
}
Expand Down
16 changes: 4 additions & 12 deletions apps/client/src/shared/components/profilePopup/ProfilePopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Icon } from '@pinback/design-system/icons';
import { Button } from '@pinback/design-system/ui';
import { useQueryClient } from '@tanstack/react-query';
import formatRemindTime from '@shared/utils/formatRemindTime';
import { authStorage } from '@shared/utils/authStorage';
import { extensionBridge } from '@shared/utils/extensionBridge';
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';

Expand Down Expand Up @@ -42,19 +44,9 @@ export default function ProfilePopup({
if (!open) return null;

const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('email');
localStorage.removeItem('userId');
authStorage.clearSession();
queryClient.clear();
const sendExtensionLogout = () => {
window.postMessage(
{
type: 'Extension-Logout',
},
window.location.origin
);
};
sendExtensionLogout();
extensionBridge.logout();
navigate('/login');
};

Expand Down
27 changes: 27 additions & 0 deletions apps/client/src/shared/utils/authStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const AUTH_STORAGE_KEYS = {
token: 'token',
refreshToken: 'refreshToken',
email: 'email',
userId: 'userId',
hasJob: 'hasJob',
} as const;

export const authStorage = {
getAccessToken: () => localStorage.getItem(AUTH_STORAGE_KEYS.token),
hasAccessToken: () => !!localStorage.getItem(AUTH_STORAGE_KEYS.token),
setAccessToken: (token: string) =>
localStorage.setItem(AUTH_STORAGE_KEYS.token, token),
setRefreshToken: (refreshToken: string) =>
localStorage.setItem(AUTH_STORAGE_KEYS.refreshToken, refreshToken),
setHasJob: (hasJob: boolean) =>
localStorage.setItem(AUTH_STORAGE_KEYS.hasJob, String(hasJob)),
setUserIdentity: (email: string, userId: string) => {
localStorage.setItem(AUTH_STORAGE_KEYS.email, email);
localStorage.setItem(AUTH_STORAGE_KEYS.userId, userId);
},
clearSession: () => {
Object.values(AUTH_STORAGE_KEYS).forEach((key) => {
localStorage.removeItem(key);
});
},
};
21 changes: 21 additions & 0 deletions apps/client/src/shared/utils/extensionBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { EXTENSION_MESSAGE_TYPE } from '@pinback/contracts/extension-messages';

export const extensionBridge = {
syncToken: (token: string) => {
window.postMessage(
{
type: EXTENSION_MESSAGE_TYPE.setToken,
token,
},
window.location.origin
);
},
logout: () => {
window.postMessage(
{
type: EXTENSION_MESSAGE_TYPE.logout,
},
window.location.origin
);
},
};
1 change: 1 addition & 0 deletions apps/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"zip": "vite build && node scripts/zip.mjs"
},
"dependencies": {
"@pinback/contracts": "workspace:*",
"@tanstack/react-query": "^5.85.5",
"axios": "^1.11.0",
"react": "^19.1.1",
Expand Down
6 changes: 4 additions & 2 deletions apps/extension/src/background.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { EXTENSION_MESSAGE_TYPE } from '@pinback/contracts/extension-messages';

chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
chrome.identity.getProfileUserInfo(function (info) {
chrome.storage.local.set({ email: info.email }, () => {
console.log('User email saved:');

Check warning on line 7 in apps/extension/src/background.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement
});
setTimeout(() => {
chrome.tabs.create({
Expand All @@ -14,17 +16,17 @@
});

chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'SET_TOKEN') {
if (message.type === EXTENSION_MESSAGE_TYPE.setToken) {
chrome.storage.local.set({ token: message.token }, () => {
console.log('Token saved!');

Check warning on line 21 in apps/extension/src/background.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement
});
}
});

chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'Extension-Logout') {
if (message.type === EXTENSION_MESSAGE_TYPE.logout) {
chrome.storage.local.remove('token', () => {
console.log('Token removed!');

Check warning on line 29 in apps/extension/src/background.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement
});
}
});
8 changes: 5 additions & 3 deletions apps/extension/src/content.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import { EXTENSION_MESSAGE_TYPE } from '@pinback/contracts/extension-messages';
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

메시지 payload를 런타임에서 검증해야 합니다.

MessageEvent.datanull이나 임의의 값일 수 있는데, 여기서는 두 리스너 모두 event.data.type을 바로 읽고 있습니다. 그래서 잘못된 postMessage 하나만 들어와도 예외가 날 수 있고, setToken 경로는 token이 문자열인지 확인하지 않은 채 그대로 저장/전달합니다. packages/contracts/src/extension-messages.ts의 타입은 컴파일 타임 계약일 뿐이라, 이 경계에서는 별도 가드가 필요합니다.

🛡️ 제안 수정
-import { EXTENSION_MESSAGE_TYPE } from '@pinback/contracts/extension-messages';
+import {
+  EXTENSION_MESSAGE_TYPE,
+  type ExtensionMessage,
+} from '@pinback/contracts/extension-messages';
+
+function isExtensionMessage(data: unknown): data is ExtensionMessage {
+  if (!data || typeof data !== 'object') return false;
+
+  const candidate = data as { type?: unknown; token?: unknown };
+
+  if (candidate.type === EXTENSION_MESSAGE_TYPE.setToken) {
+    return typeof candidate.token === 'string';
+  }
+
+  return candidate.type === EXTENSION_MESSAGE_TYPE.logout;
+}

 window.addEventListener('message', (event) => {
   if (event.source !== window) return;
+  if (!isExtensionMessage(event.data)) return;
   if (event.data.type === EXTENSION_MESSAGE_TYPE.setToken) {
     chrome.runtime.sendMessage({
       type: EXTENSION_MESSAGE_TYPE.setToken,
@@
 window.addEventListener('message', (event) => {
   if (event.source !== window) return;
+  if (!isExtensionMessage(event.data)) return;
   if (event.data.type === EXTENSION_MESSAGE_TYPE.logout) {
     chrome.storage.local.remove('token', () => {
       console.log('Token removed!');

Also applies to: 3-8, 16-18

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

In `@apps/extension/src/content.ts` at line 1, Incoming MessageEvent.data must be
validated at runtime before property access; update the message listeners in
apps/extension/src/content.ts to first check that event.data is non-null and
typeof event.data === 'object', then narrow on ('type' in data) and compare
against EXTENSION_MESSAGE_TYPE values (e.g., EXTENSION_MESSAGE_TYPE.SET_TOKEN)
in a safe switch/if; when handling the SET_TOKEN branch validate that (data as
any).token is a string before calling the setToken path or forwarding it, and
add analogous type guards for any other branches referenced around the listeners
(lines near the current handlers) so no property is accessed on null/invalid
payloads.


window.addEventListener('message', (event) => {
if (event.source !== window) return;
if (event.data.type === 'SET_TOKEN') {
if (event.data.type === EXTENSION_MESSAGE_TYPE.setToken) {
chrome.runtime.sendMessage({
type: 'SET_TOKEN',
type: EXTENSION_MESSAGE_TYPE.setToken,
token: event.data.token,
});
chrome.storage.local.set({ token: event.data.token }, () => {
console.log('Token saved!');

Check warning on line 11 in apps/extension/src/content.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement
});
}
});

window.addEventListener('message', (event) => {
if (event.source !== window) return;
if (event.data.type === 'Extension-Logout') {
if (event.data.type === EXTENSION_MESSAGE_TYPE.logout) {
chrome.storage.local.remove('token', () => {
console.log('Token removed!');

Check warning on line 20 in apps/extension/src/content.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement
});
}
});
16 changes: 16 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "@pinback/contracts",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./extension-messages": "./src/extension-messages.ts"
},
"scripts": {
"check-types": "tsc --noEmit"
},
"devDependencies": {
"@pinback/typescript-config": "workspace:*",
"typescript": "5.9.2"
}
}
16 changes: 16 additions & 0 deletions packages/contracts/src/extension-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const EXTENSION_MESSAGE_TYPE = {
setToken: 'SET_TOKEN',
logout: 'Extension-Logout',
} as const;

export type ExtensionMessageType =
(typeof EXTENSION_MESSAGE_TYPE)[keyof typeof EXTENSION_MESSAGE_TYPE];

export type ExtensionMessage =
| {
type: typeof EXTENSION_MESSAGE_TYPE.setToken;
token: string;
}
| {
type: typeof EXTENSION_MESSAGE_TYPE.logout;
};
10 changes: 10 additions & 0 deletions packages/contracts/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../typescript-config/base.json",
"compilerOptions": {
"rootDir": ".",
"baseUrl": ".",
"lib": ["ES2022"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
Loading
Loading