diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index 5d09f84..9c09a34 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -1,7 +1,24 @@ +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)}` @@ -81,15 +98,16 @@ export async function registerOAuthSocialRoutes(fastify) { if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен (нет VK_* в env)' }) const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` - const state = fastify.jwt.sign({ oauth: 'vk' }, { expiresIn: '15m' }) + const { codeVerifier, codeChallenge } = generatePkcePair() + const state = fastify.jwt.sign({ oauth: 'vk', cv: codeVerifier }, { expiresIn: '15m' }) - const url = new URL('https://oauth.vk.com/authorize') + const url = new URL('https://id.vk.ru/authorize') url.searchParams.set('client_id', clientId) - url.searchParams.set('display', 'page') url.searchParams.set('redirect_uri', redirectUri) - url.searchParams.set('scope', 'email') url.searchParams.set('response_type', 'code') - url.searchParams.set('v', '5.199') + 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()) @@ -106,15 +124,19 @@ export async function registerOAuthSocialRoutes(fastify) { if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен' }) const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` - const state = fastify.jwt.sign({ oauth: 'vk', action: 'link', userId: request.user.sub }, { expiresIn: '15m' }) + 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://oauth.vk.com/authorize') + const url = new URL('https://id.vk.ru/authorize') url.searchParams.set('client_id', clientId) - url.searchParams.set('display', 'page') url.searchParams.set('redirect_uri', redirectUri) - url.searchParams.set('scope', 'email') url.searchParams.set('response_type', 'code') - url.searchParams.set('v', '5.199') + 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()) @@ -139,28 +161,42 @@ export async function registerOAuthSocialRoutes(fastify) { 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 tokenUrl = new URL('https://oauth.vk.com/access_token') - tokenUrl.searchParams.set('client_id', clientId) - tokenUrl.searchParams.set('client_secret', clientSecret) - tokenUrl.searchParams.set('redirect_uri', redirectUri) - tokenUrl.searchParams.set('code', code) + 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(tokenUrl.toString()) + 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 vkUserId = tokenBody?.user_id - const accessTokenVk = tokenBody?.access_token + const idToken = typeof tokenBody?.id_token === 'string' ? tokenBody.id_token : null + const claims = idToken ? decodeIdTokenPayload(idToken) : null - const emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : 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 @@ -168,7 +204,7 @@ export async function registerOAuthSocialRoutes(fastify) { const user = await findOrCreateUserFromOAuth({ provider: 'vk', providerUserId: String(vkUserId), - accessToken: accessTokenVk ?? null, + accessToken: tokenBody?.access_token ?? null, suggestedEmail: emailSuggestion, linkToUserId, })