import { createHmac, randomBytes } from 'node:crypto' import { createError, deleteCookie, getCookie, getRequestURL, setCookie } from 'h3' import { getRuntimeEnvValue } from './runtime-env.js' import { shouldUseSecureSessionCookie } from './session-cookie.js' const googleAuthorizationUrl = 'https://accounts.google.com/o/oauth2/v2/auth' const googleTokenUrl = 'https://oauth2.googleapis.com/token' const googleUserInfoUrl = 'https://openidconnect.googleapis.com/v1/userinfo' const googleOAuthStateCookieName = 'sori_google_oauth_nonce' const googleOAuthStateMaxAge = 60 * 10 const googleOAuthCallbackPath = '/api/auth/google/callback' /** * Google OAuth 클라이언트 설정을 조회한다. * @returns {{ clientId: string, clientSecret: string }} 클라이언트 설정 */ export const getGoogleOAuthClientConfig = () => { const clientId = getRuntimeEnvValue('GOOGLE_OAUTH_CLIENT_ID', 'googleOAuthClientId', getRuntimeEnvValue('GOOGLE_ADSENSE_CLIENT_ID', 'googleOAuthClientId')).trim() const clientSecret = getRuntimeEnvValue('GOOGLE_OAUTH_CLIENT_SECRET', 'googleOAuthClientSecret', getRuntimeEnvValue('GOOGLE_ADSENSE_CLIENT_SECRET', 'googleOAuthClientSecret')).trim() if (!clientId || !clientSecret) { throw createError({ statusCode: 500, message: 'Google 로그인 환경 변수가 없습니다. GOOGLE_OAUTH_CLIENT_ID와 GOOGLE_OAUTH_CLIENT_SECRET을 설정해 주세요.' }) } return { clientId, clientSecret } } /** * Google OAuth callback URL을 만든다. * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {string} callback URL */ export const getGoogleOAuthRedirectUri = (event) => { const configuredUri = getRuntimeEnvValue('GOOGLE_OAUTH_REDIRECT_URI', 'googleOAuthRedirectUri').trim() if (configuredUri) { return configuredUri } return `${getRequestURL(event).origin}${googleOAuthCallbackPath}` } /** * OAuth state 서명 비밀값을 조회한다. * @returns {string} 서명 비밀값 */ const getGoogleOAuthStateSecret = () => { const secret = getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret').trim() if (!secret) { throw createError({ statusCode: 500, message: '회원 세션 비밀값 환경 변수가 없습니다. MEMBER_SESSION_SECRET을 설정해 주세요.' }) } return secret } /** * OAuth state payload를 서명한다. * @param {string} payload - base64url payload * @returns {string} 서명 */ const signGoogleOAuthStatePayload = (payload) => createHmac('sha256', getGoogleOAuthStateSecret()) .update(payload) .digest('base64url') /** * 내부 경로 redirect만 허용한다. * @param {unknown} redirectTo - 요청 redirect 값 * @param {string} fallback - 기본 경로 * @returns {string} 안전한 redirect 경로 */ export const normalizeGoogleOAuthRedirect = (redirectTo, fallback = '/') => { const value = String(redirectTo || '').trim() if (!value || !value.startsWith('/') || value.startsWith('//') || value.includes('\\')) { return fallback } return value } /** * Google OAuth state를 만들고 nonce 쿠키를 저장한다. * @param {import('h3').H3Event} event - 요청 이벤트 * @param {{ mode: 'member' | 'admin' | 'link', redirectTo: string, userId?: string }} input - state 입력 * @returns {string} state 문자열 */ export const createGoogleOAuthState = (event, input) => { const nonce = randomBytes(24).toString('base64url') const payload = Buffer.from(JSON.stringify({ mode: input.mode, redirectTo: input.redirectTo, userId: input.userId || '', nonce, createdAt: Date.now() })).toString('base64url') const state = `${payload}.${signGoogleOAuthStatePayload(payload)}` setCookie(event, googleOAuthStateCookieName, nonce, { httpOnly: true, sameSite: 'lax', secure: shouldUseSecureSessionCookie(event), path: '/', maxAge: googleOAuthStateMaxAge }) return state } /** * Google OAuth state를 검증한다. * @param {import('h3').H3Event} event - 요청 이벤트 * @param {string} state - state 문자열 * @returns {{ mode: 'member' | 'admin' | 'link', redirectTo: string, userId: string }} state payload */ export const verifyGoogleOAuthState = (event, state) => { const [payload, signature] = String(state || '').split('.') const expectedNonce = getCookie(event, googleOAuthStateCookieName) deleteCookie(event, googleOAuthStateCookieName, { path: '/' }) if (!payload || !signature || signature !== signGoogleOAuthStatePayload(payload)) { throw createError({ statusCode: 400, message: 'Google 로그인 상태값이 올바르지 않습니다.' }) } let parsed = null try { parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) } catch { throw createError({ statusCode: 400, message: 'Google 로그인 상태값을 읽을 수 없습니다.' }) } if (!expectedNonce || parsed.nonce !== expectedNonce || Date.now() - Number(parsed.createdAt || 0) > googleOAuthStateMaxAge * 1000) { throw createError({ statusCode: 400, message: 'Google 로그인 요청이 만료되었습니다. 다시 시도해 주세요.' }) } if (!['member', 'admin', 'link'].includes(parsed.mode)) { throw createError({ statusCode: 400, message: 'Google 로그인 모드가 올바르지 않습니다.' }) } return { mode: parsed.mode, redirectTo: normalizeGoogleOAuthRedirect(parsed.redirectTo, parsed.mode === 'admin' ? '/admin' : '/'), userId: String(parsed.userId || '') } } /** * Google OAuth 인증 URL을 만든다. * @param {import('h3').H3Event} event - 요청 이벤트 * @param {string} state - state 문자열 * @returns {string} Google 인증 URL */ export const createGoogleAuthorizationUrl = (event, state) => { const { clientId } = getGoogleOAuthClientConfig() const url = new URL(googleAuthorizationUrl) url.searchParams.set('client_id', clientId) url.searchParams.set('redirect_uri', getGoogleOAuthRedirectUri(event)) url.searchParams.set('response_type', 'code') url.searchParams.set('scope', 'openid email profile') url.searchParams.set('state', state) url.searchParams.set('prompt', 'select_account') return url.toString() } /** * Google OAuth code를 사용자 정보로 교환한다. * @param {import('h3').H3Event} event - 요청 이벤트 * @param {string} code - OAuth code * @returns {Promise<{ sub: string, email: string, emailVerified: boolean, name: string, picture: string }>} Google 사용자 정보 */ export const exchangeGoogleOAuthCode = async (event, code) => { const { clientId, clientSecret } = getGoogleOAuthClientConfig() const tokenResponse = await fetch(googleTokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ code, client_id: clientId, client_secret: clientSecret, redirect_uri: getGoogleOAuthRedirectUri(event), grant_type: 'authorization_code' }) }) if (!tokenResponse.ok) { throw createError({ statusCode: 502, message: 'Google 로그인 토큰을 발급받지 못했습니다.' }) } const tokenData = await tokenResponse.json() const accessToken = tokenData?.access_token if (!accessToken) { throw createError({ statusCode: 502, message: 'Google access token이 없습니다.' }) } const profileResponse = await fetch(googleUserInfoUrl, { headers: { Authorization: `Bearer ${accessToken}` } }) if (!profileResponse.ok) { throw createError({ statusCode: 502, message: 'Google 사용자 정보를 조회하지 못했습니다.' }) } const profile = await profileResponse.json() return { sub: String(profile?.sub || ''), email: String(profile?.email || '').trim().toLowerCase(), emailVerified: Boolean(profile?.email_verified), name: String(profile?.name || '').trim(), picture: String(profile?.picture || '').trim() } }