diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index 92b8707c..2f2e92c8 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -16,30 +16,40 @@ export async function GET(request: NextRequest) { cookieStore.delete('oauth_state') - const origin = process.env.NEXT_PUBLIC_APP_URL! - if (error || !code || !state || state !== storedState) { const errorMessage = error || 'OAuth 검증에 실패했습니다.' return new NextResponse( - buildPostMessageHtml('AUTH_ERROR', origin, { error: errorMessage }), + buildPostMessageHtml('AUTH_ERROR', { error: errorMessage }), { - headers: { 'Content-Type': 'text/html' }, + headers: { + 'Content-Type': 'text/html', + 'Cross-Origin-Opener-Policy': 'unsafe-none', + }, } ) } - // authorization code를 백엔드 GET /auth/login에 Query Parameter로 전달 + // authorization code를 백엔드 POST /auth/login에 JSON body로 전달 try { const backendResponse = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/auth/login?provider=google&code=${code}`, - { method: 'GET' } + `${process.env.NEXT_PUBLIC_API_URL}/auth/login`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: process.env.NEXT_PUBLIC_APP_URL!, + }, + body: JSON.stringify({ provider: 'google', code }), + } ) if (!backendResponse.ok) { - throw new Error('백엔드 인증에 실패했습니다.') + const errText = await backendResponse.text() + throw new Error(`백엔드 ${backendResponse.status}: ${errText}`) } const data: LoginResponse = await backendResponse.json() + console.log('[auth/callback] backend data:', JSON.stringify(data)) if (!data.success) { throw new Error( @@ -50,7 +60,7 @@ export async function GET(request: NextRequest) { } if (typeof data.responseDto !== 'object') { - throw new Error('백엔드 인증에 실패했습니다.') + throw new Error(`responseDto 타입 오류: ${typeof data.responseDto}`) } const { accessToken, userDetails, userChannelDetails } = data.responseDto @@ -63,7 +73,7 @@ export async function GET(request: NextRequest) { if (refreshToken) { cookieStore.set('refreshToken', refreshToken, { httpOnly: true, - secure: false, + secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 7, @@ -73,34 +83,42 @@ export async function GET(request: NextRequest) { const user = { userDetails, userChannelDetails: userChannelDetails ?? null } return new NextResponse( - buildPostMessageHtml('AUTH_SUCCESS', origin, { + buildPostMessageHtml('AUTH_SUCCESS', { accessToken, user, }), - { headers: { 'Content-Type': 'text/html' } } + { + headers: { + 'Content-Type': 'text/html', + 'Cross-Origin-Opener-Policy': 'unsafe-none', + }, + } ) - } catch { + } catch (e) { + console.error('[auth/callback] error:', e) return new NextResponse( - buildPostMessageHtml('AUTH_ERROR', origin, { - error: '로그인 처리 중 오류가 발생했습니다.', + buildPostMessageHtml('AUTH_ERROR', { + error: e instanceof Error ? e.message : String(e), }), - { headers: { 'Content-Type': 'text/html' } } + { + headers: { + 'Content-Type': 'text/html', + 'Cross-Origin-Opener-Policy': 'unsafe-none', + }, + } ) } } //HTML 페이지를 반환하여 postMessage로 accessToken + user를 부모 창에 전달하고 팝업을 닫는다 -function buildPostMessageHtml( - type: string, - origin: string, - payload: Record -) { +function buildPostMessageHtml(type: string, payload: Record) { + const targetOrigin = process.env.NEXT_PUBLIC_APP_URL! const message = JSON.stringify({ type, ...payload }) return ` diff --git a/app/auth/refresh/route.test.ts b/app/auth/refresh/route.test.ts index 1806f257..8811743d 100644 --- a/app/auth/refresh/route.test.ts +++ b/app/auth/refresh/route.test.ts @@ -5,7 +5,6 @@ import { mockRefreshToken, mockNewRefreshToken, mockReissueResponse, - mockUserDetails, } from '@/shared/api/mock/mockUser' const mockCookieStore = { @@ -80,7 +79,7 @@ describe('POST /auth/reissue', () => { ) }) - it('백엔드 성공 시 { accessToken, user }를 반환한다', async () => { + it('백엔드 성공 시 { accessToken }를 반환한다', async () => { mockCookieStore.get.mockReturnValue({ value: mockRefreshToken }) vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( new Response(JSON.stringify(mockReissueResponse), { status: 200 }) @@ -91,14 +90,6 @@ describe('POST /auth/reissue', () => { const data = await response.json() expect(data.accessToken).toBe(mockAccessToken) - expect(data.user).toEqual( - expect.objectContaining({ - userDetails: expect.objectContaining({ - id: mockUserDetails.id, - isOnboardingCompleted: false, - }), - }) - ) }) it('백엔드 실패 시 RT 쿠키를 삭제하고 401을 반환한다', async () => { diff --git a/app/auth/refresh/route.ts b/app/auth/refresh/route.ts index 343481f0..307c560c 100644 --- a/app/auth/refresh/route.ts +++ b/app/auth/refresh/route.ts @@ -19,22 +19,28 @@ export async function POST() { `${process.env.NEXT_PUBLIC_API_URL}/auth/reissue`, { method: 'POST', - headers: { Cookie: `refreshToken=${refreshToken}` }, + headers: { + Cookie: `refreshToken=${refreshToken}`, + Origin: process.env.NEXT_PUBLIC_APP_URL!, + }, } ) const responseText = await backendResponse.text() + console.log('[auth/refresh] backend status:', backendResponse.status) + console.log('[auth/refresh] backend body:', responseText) + if (!backendResponse.ok) { cookieStore.delete('refreshToken') return NextResponse.json( { error: '토큰 갱신에 실패했습니다.' }, - { status: 401 } + { status: backendResponse.status } ) } const { responseDto } = JSON.parse(responseText) - const { accessToken, userDetails, userChannelDetails } = responseDto + const { accessToken } = responseDto // 백엔드가 새 refreshToken을 Set-Cookie로 내려주는 경우 재설정 const setCookieHeader = backendResponse.headers.get('set-cookie') @@ -44,17 +50,14 @@ export async function POST() { if (newRefreshToken) { cookieStore.set('refreshToken', newRefreshToken, { httpOnly: true, - secure: false, + secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 7, }) } - const user = { userDetails, userChannelDetails: userChannelDetails ?? null } - - //새 AT와 user 정보는 response body로 전달 - return NextResponse.json({ accessToken, user }) + return NextResponse.json({ accessToken }) } catch { return NextResponse.json( { error: '토큰 갱신 중 오류가 발생했습니다.' }, diff --git a/next.config.ts b/next.config.ts index 5fa01d50..f9e98a8b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,18 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { + /* Cross-Origin-Opener-Policy로 인해 팝업이 닫히지 않는 문제 해결 */ + async headers() { + return [ + { + source: '/auth/callback', + headers: [ + { key: 'Cross-Origin-Opener-Policy', value: 'unsafe-none' }, + { key: 'Cross-Origin-Embedder-Policy', value: 'unsafe-none' }, + ], + }, + ] + }, webpack(config) { const fileLoaderRule = config.module.rules.find( (rule: { test?: { test?: (s: string) => boolean } }) => diff --git a/src/features/auth/mock/mockAuth.ts b/src/features/auth/mock/mockAuth.ts index c2a048fe..0848ce59 100644 --- a/src/features/auth/mock/mockAuth.ts +++ b/src/features/auth/mock/mockAuth.ts @@ -5,9 +5,10 @@ import { } from '@/shared/api/mock/mockUser' import type { LoginResponse } from '../model/types' +import type { ApiResponse } from '@/shared/api/types' /* 로그인 응답 DTO */ -const mockResponseDto = { +const mockLoginResponseDto = { accessToken: mockAccessToken, userDetails: mockUserDetails, userChannelDetails: mockUserChannelDetails, @@ -16,14 +17,14 @@ const mockResponseDto = { /* 로그인 응답 */ export const mockLoginResponse: LoginResponse = { success: true, - responseDto: mockResponseDto, + responseDto: mockLoginResponseDto, error: null, } /* 리프레시 응답 */ -export const mockReissueResponse: LoginResponse = { +export const mockReissueResponse: ApiResponse<{ accessToken: string } | string> = { success: true, - responseDto: mockResponseDto, + responseDto: { accessToken: mockAccessToken }, error: null, } diff --git a/src/features/auth/model/usePopupOAuth.ts b/src/features/auth/model/usePopupOAuth.ts index 4f269081..5f7a58df 100644 --- a/src/features/auth/model/usePopupOAuth.ts +++ b/src/features/auth/model/usePopupOAuth.ts @@ -20,6 +20,7 @@ export function usePopupOAuth({ apiPath, popupName }: PopupOAuthConfig) { const { type, error: authError } = event.data if (type === 'AUTH_SUCCESS') { + setIsLoading(false) window.location.href = '/' } else if (type === 'AUTH_ERROR') { setError(authError || '로그인에 실패했습니다.') @@ -54,13 +55,6 @@ export function usePopupOAuth({ apiPath, popupName }: PopupOAuthConfig) { } popupRef.current = popup - - const timer = setInterval(() => { - if (popup.closed) { - clearInterval(timer) - setIsLoading(false) - } - }, 500) } return { isLoading, error, handleClick } diff --git a/src/shared/api/mock/mockUser.ts b/src/shared/api/mock/mockUser.ts index 7e5e5e9f..bade748c 100644 --- a/src/shared/api/mock/mockUser.ts +++ b/src/shared/api/mock/mockUser.ts @@ -53,7 +53,5 @@ export const mockReissueResponse = { error: null, responseDto: { accessToken: mockAccessToken, - userDetails: mockUserDetails, - userChannelDetails: mockUserChannelDetails, }, }