fix(WEB): FCM 토큰 등록을 위한 is_authenticated 쿠키 추가

- HttpOnly 쿠키(access_token)는 JavaScript에서 읽을 수 없어 FCM 초기화 실패
- non-HttpOnly is_authenticated 쿠키 추가로 클라이언트에서 인증 상태 확인 가능
- login/logout/refresh/proxy 라우트에서 쿠키 설정/삭제 처리
- hasAuthToken()이 is_authenticated 쿠키 확인하도록 변경
This commit is contained in:
2026-01-06 21:47:57 +09:00
parent 50a01e1e47
commit df51cf6852
5 changed files with 63 additions and 11 deletions

View File

@@ -170,12 +170,23 @@ export async function POST(request: NextRequest) {
'Max-Age=604800', // TODO: 테스트용 10초, 원래 604800 (7 days)
].join('; ');
// ✅ FCM 등에서 인증 상태 확인용 (non-HttpOnly - JavaScript 접근 가능)
const isAuthenticatedCookie = [
'is_authenticated=true',
// HttpOnly 제외 - JavaScript에서 접근 가능해야 함
...(isProduction ? ['Secure'] : []),
'SameSite=Lax',
'Path=/',
`Max-Age=${data.expires_in || 7200}`,
].join('; ');
console.log('✅ Login successful - Access & Refresh tokens stored in HttpOnly cookies');
const response = NextResponse.json(responseData, { status: 200 });
response.headers.append('Set-Cookie', accessTokenCookie);
response.headers.append('Set-Cookie', refreshTokenCookie);
response.headers.append('Set-Cookie', isAuthenticatedCookie);
return response;

View File

@@ -70,6 +70,15 @@ export async function POST(request: NextRequest) {
'Max-Age=0', // Delete immediately
].join('; ');
// ✅ is_authenticated 쿠키도 삭제 (FCM 인증 상태 플래그)
const clearIsAuthenticated = [
'is_authenticated=',
...(isProduction ? ['Secure'] : []),
'SameSite=Lax',
'Path=/',
'Max-Age=0',
].join('; ');
console.log('✅ Logout complete - Access & Refresh tokens cleared');
const response = NextResponse.json(
@@ -79,6 +88,7 @@ export async function POST(request: NextRequest) {
response.headers.append('Set-Cookie', clearAccessToken);
response.headers.append('Set-Cookie', clearRefreshToken);
response.headers.append('Set-Cookie', clearIsAuthenticated);
return response;

View File

@@ -60,9 +60,12 @@ export async function POST(request: NextRequest) {
// Refresh token is invalid or expired
console.warn('⚠️ Token refresh failed - user needs to re-login');
// Clear both tokens
const clearAccessToken = 'access_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0';
const clearRefreshToken = 'refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0';
const isProduction = process.env.NODE_ENV === 'production';
// Clear all tokens
const clearAccessToken = `access_token=; HttpOnly; ${isProduction ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=0`;
const clearRefreshToken = `refresh_token=; HttpOnly; ${isProduction ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=0`;
const clearIsAuthenticated = `is_authenticated=; ${isProduction ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=0`;
const failResponse = NextResponse.json(
{ error: 'Token refresh failed', needsReauth: true },
@@ -71,6 +74,7 @@ export async function POST(request: NextRequest) {
failResponse.headers.append('Set-Cookie', clearAccessToken);
failResponse.headers.append('Set-Cookie', clearRefreshToken);
failResponse.headers.append('Set-Cookie', clearIsAuthenticated);
return failResponse;
}
@@ -86,11 +90,13 @@ export async function POST(request: NextRequest) {
};
// Set new HttpOnly cookies
const isProduction = process.env.NODE_ENV === 'production';
const accessTokenCookie = [
`access_token=${data.access_token}`,
'HttpOnly',
'Secure',
'SameSite=Strict',
...(isProduction ? ['Secure'] : []),
'SameSite=Lax',
'Path=/',
`Max-Age=${data.expires_in || 7200}`,
].join('; ');
@@ -98,18 +104,28 @@ export async function POST(request: NextRequest) {
const refreshTokenCookie = [
`refresh_token=${data.refresh_token}`,
'HttpOnly',
'Secure',
'SameSite=Strict',
...(isProduction ? ['Secure'] : []),
'SameSite=Lax',
'Path=/',
'Max-Age=604800', // 7 days
].join('; ');
// ✅ FCM 등에서 인증 상태 확인용 (non-HttpOnly)
const isAuthenticatedCookie = [
'is_authenticated=true',
...(isProduction ? ['Secure'] : []),
'SameSite=Lax',
'Path=/',
`Max-Age=${data.expires_in || 7200}`,
].join('; ');
console.log('✅ Token refresh successful - New tokens stored');
const successResponse = NextResponse.json(responseData, { status: 200 });
successResponse.headers.append('Set-Cookie', accessTokenCookie);
successResponse.headers.append('Set-Cookie', refreshTokenCookie);
successResponse.headers.append('Set-Cookie', isAuthenticatedCookie);
return successResponse;

View File

@@ -79,6 +79,15 @@ function createTokenCookies(tokens: { accessToken?: string; refreshToken?: strin
'Path=/',
`Max-Age=${tokens.expiresIn || 7200}`,
].join('; '));
// ✅ FCM 등에서 인증 상태 확인용 (non-HttpOnly)
cookies.push([
'is_authenticated=true',
...(isProduction ? ['Secure'] : []),
'SameSite=Lax',
'Path=/',
`Max-Age=${tokens.expiresIn || 7200}`,
].join('; '));
}
if (tokens.refreshToken) {
@@ -104,6 +113,7 @@ function createClearTokenCookies(): string[] {
return [
`access_token=; HttpOnly${secureFlag}; SameSite=Lax; Path=/; Max-Age=0`,
`refresh_token=; HttpOnly${secureFlag}; SameSite=Lax; Path=/; Max-Age=0`,
`is_authenticated=${secureFlag}; SameSite=Lax; Path=/; Max-Age=0`,
];
}

View File

@@ -32,11 +32,16 @@ export const getMultipartHeaders = (): HeadersInit => {
};
/**
* 토큰 존재 여부 확인
* 인증 상태 확인
*
* ⚠️ 중요: access_token은 HttpOnly 쿠키로 JavaScript에서 읽을 수 없음
* - is_authenticated 쿠키는 non-HttpOnly로 JavaScript에서 접근 가능
* - 로그인/토큰갱신 시 함께 설정되어 인증 상태를 나타냄
* - FCM 토큰 등록 등 클라이언트에서 인증 상태 확인이 필요한 경우 사용
*/
export const hasAuthToken = (): boolean => {
if (typeof window === 'undefined') return false;
// ✅ access_token 쿠키 존재 여부 확인
const token = document.cookie.split('; ').find(row => row.startsWith('access_token='))?.split('=')[1];
return !!token;
// ✅ is_authenticated 쿠키로 인증 상태 확인 (non-HttpOnly)
const isAuth = document.cookie.split('; ').find(row => row.startsWith('is_authenticated='))?.split('=')[1];
return isAuth === 'true';
};