Google 계정 로그인 연동 추가

This commit is contained in:
2026-07-29 14:02:07 +09:00
parent 99af25b8fa
commit 52ed053876
20 changed files with 897 additions and 9 deletions

View File

@@ -0,0 +1,179 @@
import bcrypt from 'bcrypt'
import { randomBytes } from 'node:crypto'
import { createError, getQuery, sendRedirect } from 'h3'
import {
createGoogleUser,
getUserByEmail,
getUserByGoogleSub,
isPrivilegedMemberSession,
linkGoogleIdentityToUser,
touchUserActivity
} from '../../../repositories/member-repository'
import { clearAdminSession, setAdminSession } from '../../../utils/admin-auth'
import { clearMemberSession, requireMemberSession, setMemberSession } from '../../../utils/member-auth'
import { getClientIp } from '../../../utils/request-ip'
import {
exchangeGoogleOAuthCode,
normalizeGoogleOAuthRedirect,
verifyGoogleOAuthState
} from '../../../utils/google-oauth'
/**
* Google 콜백 redirect 경로에 상태 query를 붙인다.
* @param {string} redirectTo - redirect 경로
* @param {string} status - 상태 코드
* @returns {string} redirect URL
*/
const appendGoogleStatus = (redirectTo, status) => {
const url = new URL(normalizeGoogleOAuthRedirect(redirectTo, '/'), 'http://local')
url.searchParams.set('google', status)
return `${url.pathname}${url.search}${url.hash}`
}
/**
* Google 이름에서 사이트 닉네임을 만든다.
* @param {{ name: string, email: string }} profile - Google 프로필
* @returns {string} 닉네임
*/
const createGoogleUsername = (profile) => {
const localPart = String(profile.email || '').split('@')[0] || 'google'
return String(profile.name || localPart)
.replace(/\s+/g, ' ')
.trim()
.slice(0, 60) || localPart
}
/**
* 회원 세션을 발급하고 활동을 기록한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {Object} user - 회원 정보
* @returns {Promise<void>}
*/
const signInMember = async (event, user) => {
setMemberSession(event, {
userId: user.id,
email: user.email,
sessionVersion: user.sessionVersion
})
await touchUserActivity({
userId: user.id,
ip: getClientIp(event)
})
}
/**
* 관리자 세션을 발급하고 활동을 기록한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {Object} user - 관리자 회원 정보
* @returns {Promise<void>}
*/
const signInAdmin = async (event, user) => {
setAdminSession(event, {
userId: user.id,
email: user.email,
sessionVersion: user.sessionVersion
})
await signInMember(event, user)
}
/**
* Google OAuth 콜백 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<void>} 완료 후 화면 리다이렉트
*/
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const code = String(query.code || '')
const stateValue = String(query.state || '')
if (query.error) {
let cancelledRedirect = '/signin'
if (stateValue) {
try {
const cancelledState = verifyGoogleOAuthState(event, stateValue)
cancelledRedirect = cancelledState.mode === 'admin' ? '/admin/login' : cancelledState.redirectTo
} catch {
cancelledRedirect = '/signin'
}
}
return sendRedirect(event, appendGoogleStatus(cancelledRedirect, 'cancelled'), 302)
}
if (!code || !stateValue) {
throw createError({
statusCode: 400,
message: 'Google 로그인 응답이 올바르지 않습니다.'
})
}
const state = verifyGoogleOAuthState(event, stateValue)
const googleProfile = await exchangeGoogleOAuthCode(event, code)
if (!googleProfile.sub || !googleProfile.email || !googleProfile.emailVerified) {
return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'email-unverified'), 302)
}
const linkedUser = await getUserByGoogleSub(googleProfile.sub)
if (state.mode === 'link') {
const session = await requireMemberSession(event)
if (state.userId && String(state.userId) !== String(session.userId)) {
clearMemberSession(event)
clearAdminSession(event)
return sendRedirect(event, appendGoogleStatus('/signin', 'session-mismatch'), 302)
}
if (linkedUser && String(linkedUser.id) !== String(session.userId)) {
return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'already-linked'), 302)
}
await linkGoogleIdentityToUser({
userId: session.userId,
googleSub: googleProfile.sub,
googleEmail: googleProfile.email,
googleName: googleProfile.name,
googleAvatarUrl: googleProfile.picture
})
return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'linked'), 302)
}
if (state.mode === 'admin') {
if (!linkedUser || !(await isPrivilegedMemberSession(linkedUser.id, linkedUser.sessionVersion))) {
return sendRedirect(event, appendGoogleStatus('/admin/login', 'admin-not-linked'), 302)
}
await signInAdmin(event, linkedUser)
return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'signed-in'), 302)
}
if (linkedUser) {
await signInMember(event, linkedUser)
return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'signed-in'), 302)
}
const existingEmailUser = await getUserByEmail(googleProfile.email)
if (existingEmailUser) {
return sendRedirect(event, appendGoogleStatus('/signin', 'email-exists'), 302)
}
const passwordHash = await bcrypt.hash(randomBytes(32).toString('base64url'), 12)
const created = await createGoogleUser({
username: createGoogleUsername(googleProfile),
email: googleProfile.email,
passwordHash,
avatarUrl: googleProfile.picture,
googleSub: googleProfile.sub,
googleEmail: googleProfile.email,
googleName: googleProfile.name,
googleAvatarUrl: googleProfile.picture
})
if (created.isAdmin) {
await signInAdmin(event, created)
} else {
await signInMember(event, created)
}
return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'signed-in'), 302)
})

View File

@@ -0,0 +1,43 @@
import { createError, getQuery, sendRedirect } from 'h3'
import {
createGoogleAuthorizationUrl,
createGoogleOAuthState,
normalizeGoogleOAuthRedirect
} from '../../../utils/google-oauth'
import { requireMemberSession } from '../../../utils/member-auth'
/**
* Google OAuth 시작 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<void>} Google 인증 화면 리다이렉트
*/
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const mode = String(query.mode || 'member')
if (!['member', 'admin', 'link'].includes(mode)) {
throw createError({
statusCode: 400,
message: 'Google 로그인 모드가 올바르지 않습니다.'
})
}
const redirectTo = normalizeGoogleOAuthRedirect(
query.redirect,
mode === 'admin' ? '/admin' : mode === 'link' ? '/settings' : '/'
)
let userId = ''
if (mode === 'link') {
const session = await requireMemberSession(event)
userId = session.userId
}
const state = createGoogleOAuthState(event, {
mode,
redirectTo,
userId
})
return sendRedirect(event, createGoogleAuthorizationUrl(event, state), 302)
})

View File

@@ -5,7 +5,7 @@ import { createError } from 'h3'
/**
* 회원 프로필 조회 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number }>} 회원 프로필
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string, googleEmail: string, googleName: string, googleAvatarUrl: string, googleLinkedAt: string | null, createdAt: string, updatedAt: string, lastSeenAt: string | null, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number }>} 회원 프로필
*/
export default defineEventHandler(async (event) => {
const session = await requireMemberSession(event)
@@ -23,6 +23,10 @@ export default defineEventHandler(async (event) => {
email: user.email,
username: user.username,
avatarUrl: user.avatarUrl || '',
googleEmail: user.googleEmail || '',
googleName: user.googleName || '',
googleAvatarUrl: user.googleAvatarUrl || '',
googleLinkedAt: user.googleLinkedAt ? user.googleLinkedAt.toISOString() : null,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
lastSeenAt: user.lastSeenAt ? user.lastSeenAt.toISOString() : null,

View File

@@ -131,6 +131,11 @@ const mapAdminMemberRow = (row) => {
* @property {boolean} isAdmin - 관리자 여부
* @property {'owner' | 'admin' | 'vip' | 'member'} role - 권한 코드
* @property {number} sessionVersion - 세션 무효화 버전
* @property {string} googleSub - Google 사용자 고유 ID
* @property {string} googleEmail - Google 계정 이메일
* @property {string} googleName - Google 계정 이름
* @property {string} googleAvatarUrl - Google 계정 프로필 이미지
* @property {string | null} googleLinkedAt - Google 계정 연결 시각
* @property {string} createdAt - 생성 시각(ISO)
* @property {string} updatedAt - 수정 시각(ISO)
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
@@ -171,6 +176,11 @@ export const getUserByEmail = async (email) => {
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
google_sub AS "googleSub",
google_email AS "googleEmail",
google_name AS "googleName",
google_avatar_url AS "googleAvatarUrl",
google_linked_at AS "googleLinkedAt",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
@@ -185,6 +195,42 @@ export const getUserByEmail = async (email) => {
return rows?.[0] || null
}
/**
* Google 사용자 고유 ID로 회원을 조회한다.
* @param {string} googleSub - Google 사용자 고유 ID
* @returns {Promise<MemberUser | null>} 회원
*/
export const getUserByGoogleSub = async (googleSub) => {
const sql = requireSql()
const rows = await sql`
SELECT
id,
username,
email,
password_hash AS "passwordHash",
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
google_sub AS "googleSub",
google_email AS "googleEmail",
google_name AS "googleName",
google_avatar_url AS "googleAvatarUrl",
google_linked_at AS "googleLinkedAt",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
last_seen_ip AS "lastSeenIp",
previous_last_seen_at AS "previousLastSeenAt",
previous_last_seen_ip AS "previousLastSeenIp"
FROM users
WHERE google_sub = ${googleSub}
LIMIT 1
`
return rows?.[0] || null
}
/**
* ID로 회원 조회
* @param {string} id - 사용자 ID
@@ -200,6 +246,11 @@ export const getUserById = async (id) => {
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
google_sub AS "googleSub",
google_email AS "googleEmail",
google_name AS "googleName",
google_avatar_url AS "googleAvatarUrl",
google_linked_at AS "googleLinkedAt",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
@@ -229,6 +280,11 @@ export const getUserProfileById = async (id) => {
users.avatar_url AS "avatarUrl",
users.is_admin AS "isAdmin",
users.user_role AS "role",
users.google_sub AS "googleSub",
users.google_email AS "googleEmail",
users.google_name AS "googleName",
users.google_avatar_url AS "googleAvatarUrl",
users.google_linked_at AS "googleLinkedAt",
users.created_at AS "createdAt",
users.updated_at AS "updatedAt",
users.last_seen_at AS "lastSeenAt",
@@ -262,6 +318,12 @@ export const getUserByIdWithPassword = async (id) => {
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
google_sub AS "googleSub",
google_email AS "googleEmail",
google_name AS "googleName",
google_avatar_url AS "googleAvatarUrl",
google_linked_at AS "googleLinkedAt",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
@@ -336,6 +398,135 @@ export const createUser = async (input) => {
return created
}
/**
* Google 계정 기반 회원을 생성한다.
* @param {{ username: string, email: string, passwordHash: string, avatarUrl: string, googleSub: string, googleEmail: string, googleName: string, googleAvatarUrl: string }} input - 입력
* @returns {Promise<Omit<MemberUser, 'passwordHash'>>} 생성된 회원
*/
export const createGoogleUser = async (input) => {
const sql = requireSql()
const rows = await sql.begin(async (tx) => {
await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE`
const privilegedRows = await tx`
SELECT EXISTS (
SELECT 1
FROM users
WHERE user_role = ANY(${PRIVILEGED_ROLES})
) AS "hasPrivilegedUsers"
`
const shouldCreateOwner = !Boolean(privilegedRows?.[0]?.hasPrivilegedUsers)
return tx`
INSERT INTO users (
username,
email,
password_hash,
avatar_url,
is_admin,
user_role,
google_sub,
google_email,
google_name,
google_avatar_url,
google_linked_at
)
VALUES (
${input.username},
${input.email},
${input.passwordHash},
${input.avatarUrl},
${shouldCreateOwner},
CASE
WHEN ${shouldCreateOwner} THEN ${MEMBER_ROLE.OWNER}
ELSE ${MEMBER_ROLE.MEMBER}
END,
${input.googleSub},
${input.googleEmail},
${input.googleName},
${input.googleAvatarUrl},
now()
)
RETURNING
id,
username,
email,
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
google_sub AS "googleSub",
google_email AS "googleEmail",
google_name AS "googleName",
google_avatar_url AS "googleAvatarUrl",
google_linked_at AS "googleLinkedAt",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
last_seen_ip AS "lastSeenIp",
previous_last_seen_at AS "previousLastSeenAt",
previous_last_seen_ip AS "previousLastSeenIp"
`
})
const created = rows?.[0]
if (!created) {
throw createError({
statusCode: 500,
message: 'Google 회원 생성에 실패했습니다.'
})
}
return created
}
/**
* 기존 회원에 Google 계정을 연결한다.
* @param {{ userId: string, googleSub: string, googleEmail: string, googleName: string, googleAvatarUrl: string }} input - 연결 정보
* @returns {Promise<MemberUser | null>} 수정된 회원
*/
export const linkGoogleIdentityToUser = async (input) => {
const sql = requireSql()
const rows = await sql`
UPDATE users
SET
google_sub = ${input.googleSub},
google_email = ${input.googleEmail},
google_name = ${input.googleName},
google_avatar_url = ${input.googleAvatarUrl},
google_linked_at = now(),
avatar_url = CASE
WHEN avatar_url = '' THEN ${input.googleAvatarUrl}
ELSE avatar_url
END,
updated_at = now()
WHERE id = ${input.userId}
RETURNING
id,
username,
email,
password_hash AS "passwordHash",
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
google_sub AS "googleSub",
google_email AS "googleEmail",
google_name AS "googleName",
google_avatar_url AS "googleAvatarUrl",
google_linked_at AS "googleLinkedAt",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
last_seen_ip AS "lastSeenIp",
previous_last_seen_at AS "previousLastSeenAt",
previous_last_seen_ip AS "previousLastSeenIp"
`
return rows?.[0] || null
}
/**
* 관리자 권한이 없을 때 환경 변수 기반 owner 계정을 생성하거나 기존 회원을 승격한다.
* @param {{ username: string, email: string, passwordHash: string }} input - 입력

View File

@@ -0,0 +1,245 @@
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()
}
}