339 lines
13 KiB
JavaScript
339 lines
13 KiB
JavaScript
import crypto from 'node:crypto'
|
|
import { normalizeEmail } from '../lib/auth.js'
|
|
import { generateAvatar } from '../lib/generate-avatar.js'
|
|
import { prisma } from '../lib/prisma.js'
|
|
|
|
function generatePkcePair() {
|
|
const verifier = crypto.randomBytes(48).toString('base64url').slice(0, 64)
|
|
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
|
|
return { codeVerifier: verifier, codeChallenge: challenge }
|
|
}
|
|
|
|
function decodeIdTokenPayload(idToken) {
|
|
const parts = idToken.split('.')
|
|
if (parts.length !== 3) return null
|
|
try {
|
|
return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function clientRedirect(fastify, reply, token) {
|
|
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
|
|
const url = `${base.replace(/\/$/, '')}/auth/callback?token=${encodeURIComponent(token)}`
|
|
return reply.redirect(url)
|
|
}
|
|
|
|
function oauthErrorRedirect(reply, msg) {
|
|
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
|
|
const url = `${base.replace(/\/$/, '')}/auth?oauthError=${encodeURIComponent(msg)}`
|
|
return reply.redirect(url)
|
|
}
|
|
|
|
async function issueUserJwt(fastify, userId, email) {
|
|
return fastify.jwt.sign({ sub: userId, email })
|
|
}
|
|
|
|
async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail, linkToUserId }) {
|
|
const existingLink = await prisma.oAuthAccount.findUnique({
|
|
where: { provider_providerUserId: { provider, providerUserId } },
|
|
include: { user: true },
|
|
})
|
|
if (existingLink?.user) {
|
|
if (accessToken !== undefined) {
|
|
await prisma.oAuthAccount.update({
|
|
where: { provider_providerUserId: { provider, providerUserId } },
|
|
data: { accessToken },
|
|
})
|
|
}
|
|
return existingLink.user
|
|
}
|
|
|
|
const trimmed = typeof suggestedEmail === 'string' ? suggestedEmail.trim() : ''
|
|
const norm = trimmed ? normalizeEmail(trimmed) : null
|
|
|
|
if (linkToUserId) {
|
|
if (!norm) return null
|
|
await prisma.oAuthAccount.create({
|
|
data: { provider, providerUserId: String(providerUserId), userId: linkToUserId, accessToken },
|
|
})
|
|
return prisma.user.findUnique({ where: { id: linkToUserId } })
|
|
}
|
|
|
|
let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null
|
|
if (user) {
|
|
await prisma.oAuthAccount.create({
|
|
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
|
|
})
|
|
return user
|
|
}
|
|
|
|
if (!norm) return null
|
|
|
|
user = await prisma.user.create({
|
|
data: {
|
|
email: norm,
|
|
displayName: norm.split('@')[0],
|
|
avatar: await generateAvatar(norm),
|
|
avatarStyle: 'avataaars',
|
|
},
|
|
})
|
|
await prisma.oAuthAccount.create({
|
|
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
|
|
})
|
|
await prisma.notificationPreference.create({
|
|
data: { userId: user.id, globalEnabled: true },
|
|
})
|
|
return user
|
|
}
|
|
|
|
export async function registerOAuthSocialRoutes(fastify) {
|
|
const serverPublic = (process.env.SERVER_PUBLIC_URL || 'http://127.0.0.1:3333').replace(/\/$/, '')
|
|
|
|
/** --- VK --- */
|
|
fastify.get('/api/auth/oauth/vk', async (_request, reply) => {
|
|
const clientId = process.env.VK_CLIENT_ID
|
|
const clientSecret = process.env.VK_CLIENT_SECRET
|
|
if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен (нет VK_* в env)' })
|
|
|
|
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
|
|
const { codeVerifier, codeChallenge } = generatePkcePair()
|
|
const state = fastify.jwt.sign({ oauth: 'vk', cv: codeVerifier }, { expiresIn: '15m' })
|
|
|
|
const url = new URL('https://id.vk.ru/authorize')
|
|
url.searchParams.set('client_id', clientId)
|
|
url.searchParams.set('redirect_uri', redirectUri)
|
|
url.searchParams.set('response_type', 'code')
|
|
url.searchParams.set('scope', 'email')
|
|
url.searchParams.set('code_challenge', codeChallenge)
|
|
url.searchParams.set('code_challenge_method', 'S256')
|
|
url.searchParams.set('state', state)
|
|
|
|
return reply.redirect(url.toString())
|
|
})
|
|
|
|
fastify.get('/api/auth/oauth/vk/link', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
|
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
|
if (request.user.email === adminEmail) {
|
|
return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' })
|
|
}
|
|
|
|
const clientId = process.env.VK_CLIENT_ID
|
|
const clientSecret = process.env.VK_CLIENT_SECRET
|
|
if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен' })
|
|
|
|
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
|
|
const { codeVerifier, codeChallenge } = generatePkcePair()
|
|
const state = fastify.jwt.sign(
|
|
{ oauth: 'vk', action: 'link', userId: request.user.sub, cv: codeVerifier },
|
|
{ expiresIn: '15m' },
|
|
)
|
|
|
|
const url = new URL('https://id.vk.ru/authorize')
|
|
url.searchParams.set('client_id', clientId)
|
|
url.searchParams.set('redirect_uri', redirectUri)
|
|
url.searchParams.set('response_type', 'code')
|
|
url.searchParams.set('scope', 'email')
|
|
url.searchParams.set('code_challenge', codeChallenge)
|
|
url.searchParams.set('code_challenge_method', 'S256')
|
|
url.searchParams.set('state', state)
|
|
|
|
return reply.redirect(url.toString())
|
|
})
|
|
|
|
fastify.get('/api/auth/oauth/vk/callback', async (request, reply) => {
|
|
const query = request.query ?? {}
|
|
if (query.error || query.error_description) {
|
|
return oauthErrorRedirect(reply, String(query.error_description || query.error || 'ошибка VK'))
|
|
}
|
|
|
|
const statePayload = (() => {
|
|
try {
|
|
const raw = typeof query.state === 'string' ? query.state : ''
|
|
return fastify.jwt.verify(raw || '')
|
|
} catch {
|
|
return null
|
|
}
|
|
})()
|
|
if (!statePayload) return oauthErrorRedirect(reply, 'Недействительный state OAuth')
|
|
|
|
const code = typeof query.code === 'string' ? query.code.trim() : ''
|
|
if (!code) return oauthErrorRedirect(reply, 'Не получен код от VK')
|
|
|
|
const deviceId = typeof query.device_id === 'string' ? query.device_id : null
|
|
|
|
const clientId = process.env.VK_CLIENT_ID
|
|
const clientSecret = process.env.VK_CLIENT_SECRET
|
|
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
|
|
const codeVerifier = typeof statePayload.cv === 'string' ? statePayload.cv : ''
|
|
|
|
const body = new URLSearchParams()
|
|
body.set('grant_type', 'authorization_code')
|
|
body.set('client_id', clientId)
|
|
body.set('client_secret', clientSecret)
|
|
body.set('code', code)
|
|
body.set('code_verifier', codeVerifier)
|
|
body.set('redirect_uri', redirectUri)
|
|
if (deviceId) {
|
|
body.set('device_id', deviceId)
|
|
}
|
|
|
|
const tokenRes = await fetch('https://id.vk.ru/oauth2/auth', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: body.toString(),
|
|
})
|
|
const tokenBody = await tokenRes.json()
|
|
|
|
if (tokenBody?.error_description || tokenBody?.error || !tokenRes.ok) {
|
|
return oauthErrorRedirect(reply, tokenBody?.error_description || tokenBody?.error || 'Не удалось обменять код VK')
|
|
}
|
|
|
|
const idToken = typeof tokenBody?.id_token === 'string' ? tokenBody.id_token : null
|
|
const claims = idToken ? decodeIdTokenPayload(idToken) : null
|
|
|
|
const vkUserId = claims?.sub ?? tokenBody?.user_id
|
|
const emailSuggestion = claims?.email ?? tokenBody?.email ?? null
|
|
|
|
if (!vkUserId) return oauthErrorRedirect(reply, 'no_user_id')
|
|
if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email')
|
|
|
|
const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined
|
|
|
|
const user = await findOrCreateUserFromOAuth({
|
|
provider: 'vk',
|
|
providerUserId: String(vkUserId),
|
|
accessToken: tokenBody?.access_token ?? null,
|
|
suggestedEmail: emailSuggestion,
|
|
linkToUserId,
|
|
})
|
|
|
|
if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от VK')
|
|
|
|
if (linkToUserId) {
|
|
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
|
|
return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`)
|
|
}
|
|
|
|
const token = await issueUserJwt(fastify, user.id, user.email)
|
|
return clientRedirect(fastify, reply, token)
|
|
})
|
|
|
|
/** --- Yandex --- */
|
|
fastify.get('/api/auth/oauth/yandex', async (_request, reply) => {
|
|
const clientId = process.env.YANDEX_CLIENT_ID
|
|
if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен (нет YANDEX_* в env)' })
|
|
|
|
const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback`
|
|
const state = fastify.jwt.sign({ oauth: 'yandex' }, { expiresIn: '15m' })
|
|
|
|
const url = new URL('https://oauth.yandex.ru/authorize')
|
|
url.searchParams.set('response_type', 'code')
|
|
url.searchParams.set('client_id', clientId)
|
|
url.searchParams.set('redirect_uri', redirectUri)
|
|
url.searchParams.set('scope', 'login:email')
|
|
url.searchParams.set('state', state)
|
|
|
|
return reply.redirect(url.toString())
|
|
})
|
|
|
|
fastify.get('/api/auth/oauth/yandex/link', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
|
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
|
if (request.user.email === adminEmail) {
|
|
return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' })
|
|
}
|
|
|
|
const clientId = process.env.YANDEX_CLIENT_ID
|
|
if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен' })
|
|
|
|
const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback`
|
|
const state = fastify.jwt.sign({ oauth: 'yandex', action: 'link', userId: request.user.sub }, { expiresIn: '15m' })
|
|
|
|
const url = new URL('https://oauth.yandex.ru/authorize')
|
|
url.searchParams.set('response_type', 'code')
|
|
url.searchParams.set('client_id', clientId)
|
|
url.searchParams.set('redirect_uri', redirectUri)
|
|
url.searchParams.set('scope', 'login:email')
|
|
url.searchParams.set('state', state)
|
|
|
|
return reply.redirect(url.toString())
|
|
})
|
|
|
|
fastify.get('/api/auth/oauth/yandex/callback', async (request, reply) => {
|
|
const query = request.query ?? {}
|
|
if (query.error) return oauthErrorRedirect(reply, String(query.error))
|
|
|
|
const statePayload = (() => {
|
|
try {
|
|
const raw = typeof query.state === 'string' ? query.state : ''
|
|
return fastify.jwt.verify(raw || '')
|
|
} catch {
|
|
return null
|
|
}
|
|
})()
|
|
if (!statePayload) return oauthErrorRedirect(reply, 'Недействительный state OAuth')
|
|
|
|
const code = typeof query.code === 'string' ? query.code.trim() : ''
|
|
if (!code) return oauthErrorRedirect(reply, 'Не получен код от Яндекс')
|
|
|
|
const clientId = process.env.YANDEX_CLIENT_ID
|
|
const clientSecret = process.env.YANDEX_CLIENT_SECRET
|
|
const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback`
|
|
|
|
const body = new URLSearchParams()
|
|
body.set('grant_type', 'authorization_code')
|
|
body.set('code', code)
|
|
body.set('client_id', clientId)
|
|
body.set('client_secret', clientSecret)
|
|
if (redirectUri) body.set('redirect_uri', redirectUri)
|
|
|
|
const tokenRes = await fetch('https://oauth.yandex.ru/token', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: body.toString(),
|
|
})
|
|
const tokenBody = await tokenRes.json()
|
|
|
|
if (!tokenRes.ok || !tokenBody.access_token) {
|
|
return oauthErrorRedirect(
|
|
reply,
|
|
tokenBody.error_description || tokenBody.error || 'Не удалось обменять код Yandex',
|
|
)
|
|
}
|
|
|
|
const yaToken = tokenBody.access_token
|
|
|
|
const infoRes = await fetch('https://login.yandex.ru/info', {
|
|
headers: { Authorization: `OAuth ${yaToken}` },
|
|
})
|
|
const info = await infoRes.json()
|
|
const yaUserId = String(info?.id || '')
|
|
if (!yaUserId) return oauthErrorRedirect(reply, 'Не удалось получить профиль Yandex')
|
|
|
|
const emailGuess = (Array.isArray(info?.emails) && info.emails[0]) || info?.default_email || null
|
|
|
|
if (!emailGuess) return oauthErrorRedirect(reply, 'no_email')
|
|
|
|
const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined
|
|
|
|
const user = await findOrCreateUserFromOAuth({
|
|
provider: 'yandex',
|
|
providerUserId: yaUserId,
|
|
accessToken: yaToken,
|
|
suggestedEmail: emailGuess,
|
|
linkToUserId,
|
|
})
|
|
|
|
if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от Яндекс')
|
|
|
|
if (linkToUserId) {
|
|
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
|
|
return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=yandex`)
|
|
}
|
|
|
|
const token = await issueUserJwt(fastify, user.id, user.email)
|
|
return clientRedirect(fastify, reply, token)
|
|
})
|
|
}
|