import { NextResponse } from 'next/server'; import { getPublicOrigin } from '@/lib/auth/public-origin'; import { AUTH_COOKIE, OAUTH_STATE_COOKIE, authCookieOptions, createAuthSession, parseOAuthStateCookie, serializeAuthSession, } from '@/lib/auth/session'; import { exchangeAuthorizationCode, fetchAuthedUser, revokeToken, } from '@/lib/chzzk/oauth'; import { cancelScheduledDisconnectRoomChat, ensureRoomChat, } from '@/lib/chzzk/chat-session'; import { clearDiceQueue, createRoom, findRoomByChannelId, getRoom, notifyRoom, } from '@/lib/rooms/store'; export const runtime = 'nodejs'; function homeRedirect(origin: string, error?: string) { const url = new URL('/', origin); if (error) url.searchParams.set('authError', error); else url.searchParams.set('auth', 'ok'); return url; } /** * 치지직 OAuth 콜백. * 채널 ID 확인 → 우리 세션 쿠키만 저장 → 치지직 토큰 폐기 → 룸 입장 */ export async function GET(request: Request) { const url = new URL(request.url); const origin = getPublicOrigin(request); const code = url.searchParams.get('code') || ''; const state = url.searchParams.get('state') || ''; const stateCookie = request.headers .get('cookie') ?.match(/(?:^|;\s*)drinkmarble_oauth_state=([^;]+)/)?.[1]; const decodedState = stateCookie ? parseOAuthStateCookie(decodeURIComponent(stateCookie)) : null; if (!code || !state || !decodedState || decodedState.state !== state) { const res = NextResponse.redirect( homeRedirect(origin, '로그인 검증에 실패했습니다. 다시 시도해 주세요.'), ); res.cookies.delete(OAUTH_STATE_COOKIE); return res; } let accessToken: string | null = null; let refreshToken: string | null = null; try { const tokens = await exchangeAuthorizationCode(code, state); accessToken = tokens.accessToken; refreshToken = tokens.refreshToken; const user = await fetchAuthedUser(accessToken); const session = createAuthSession(user.channelId, user.channelName); // 치지직 토큰은 저장하지 않고 즉시 폐기 await revokeToken(accessToken, 'access_token'); if (refreshToken) await revokeToken(refreshToken, 'refresh_token'); accessToken = null; refreshToken = null; let room = findRoomByChannelId(user.channelId); if (!room) { room = createRoom({ chzzkEnabled: true, liveUrl: `https://chzzk.naver.com/live/${user.channelId}`, channelId: user.channelId, cheesePerDice: decodedState.cheesePerDice, multiplyByCheese: decodedState.multiplyByCheese, maxMultiplier: decodedState.maxMultiplier, }); } cancelScheduledDisconnectRoomChat(room.id); if (decodedState.resetDiceQueue) { clearDiceQueue(room.id); notifyRoom(room.id); } try { await ensureRoomChat(room.id); } catch { // 방송 중이 아니어도 세션·룸은 유지하고 게임으로 보냄 } // room.id = 서버 룸 UUID (게임 URL용) // user.channelId = 치지직 채널 (세션·room.settings.channelId에 보관) const roomId = getRoom(room.id)?.id ?? room.id; const res = NextResponse.redirect(new URL(`/game?roomId=${roomId}`, origin)); res.cookies.set(AUTH_COOKIE, serializeAuthSession(session), authCookieOptions()); res.cookies.delete(OAUTH_STATE_COOKIE); return res; } catch { if (accessToken) await revokeToken(accessToken, 'access_token'); if (refreshToken) await revokeToken(refreshToken, 'refresh_token'); // 외부 오류 원문에는 계정 식별자 등이 포함될 수 있어 고정 문구만 전달한다. const res = NextResponse.redirect( homeRedirect(origin, '치지직 로그인에 실패했습니다. 다시 시도해 주세요.'), ); res.cookies.delete(OAUTH_STATE_COOKIE); return res; } }