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) }) }