246 lines
7.9 KiB
JavaScript
246 lines
7.9 KiB
JavaScript
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()
|
|
}
|
|
}
|