feat: migrate VK OAuth to VK ID flow with PKCE

This commit is contained in:
Kirill
2026-05-22 20:54:48 +05:00
parent bead725036
commit 9d7e7949b9
+56 -20
View File
@@ -1,7 +1,24 @@
import crypto from 'node:crypto'
import { normalizeEmail } from '../lib/auth.js' import { normalizeEmail } from '../lib/auth.js'
import { generateAvatar } from '../lib/generate-avatar.js' import { generateAvatar } from '../lib/generate-avatar.js'
import { prisma } from '../lib/prisma.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) { function clientRedirect(fastify, reply, token) {
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
const url = `${base.replace(/\/$/, '')}/auth/callback?token=${encodeURIComponent(token)}` 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)' }) if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен (нет VK_* в env)' })
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` 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('client_id', clientId)
url.searchParams.set('display', 'page')
url.searchParams.set('redirect_uri', redirectUri) url.searchParams.set('redirect_uri', redirectUri)
url.searchParams.set('scope', 'email')
url.searchParams.set('response_type', 'code') 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) url.searchParams.set('state', state)
return reply.redirect(url.toString()) 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 не настроен' }) if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен' })
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` 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('client_id', clientId)
url.searchParams.set('display', 'page')
url.searchParams.set('redirect_uri', redirectUri) url.searchParams.set('redirect_uri', redirectUri)
url.searchParams.set('scope', 'email')
url.searchParams.set('response_type', 'code') 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) url.searchParams.set('state', state)
return reply.redirect(url.toString()) return reply.redirect(url.toString())
@@ -139,28 +161,42 @@ export async function registerOAuthSocialRoutes(fastify) {
const code = typeof query.code === 'string' ? query.code.trim() : '' const code = typeof query.code === 'string' ? query.code.trim() : ''
if (!code) return oauthErrorRedirect(reply, 'Не получен код от VK') 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 clientId = process.env.VK_CLIENT_ID
const clientSecret = process.env.VK_CLIENT_SECRET const clientSecret = process.env.VK_CLIENT_SECRET
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` 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') const body = new URLSearchParams()
tokenUrl.searchParams.set('client_id', clientId) body.set('grant_type', 'authorization_code')
tokenUrl.searchParams.set('client_secret', clientSecret) body.set('client_id', clientId)
tokenUrl.searchParams.set('redirect_uri', redirectUri) body.set('client_secret', clientSecret)
tokenUrl.searchParams.set('code', code) 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() const tokenBody = await tokenRes.json()
if (tokenBody?.error_description || tokenBody?.error || !tokenRes.ok) { if (tokenBody?.error_description || tokenBody?.error || !tokenRes.ok) {
return oauthErrorRedirect(reply, tokenBody?.error_description || tokenBody?.error || 'Не удалось обменять код VK') return oauthErrorRedirect(reply, tokenBody?.error_description || tokenBody?.error || 'Не удалось обменять код VK')
} }
const vkUserId = tokenBody?.user_id const idToken = typeof tokenBody?.id_token === 'string' ? tokenBody.id_token : null
const accessTokenVk = tokenBody?.access_token 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') if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email')
const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined
@@ -168,7 +204,7 @@ export async function registerOAuthSocialRoutes(fastify) {
const user = await findOrCreateUserFromOAuth({ const user = await findOrCreateUserFromOAuth({
provider: 'vk', provider: 'vk',
providerUserId: String(vkUserId), providerUserId: String(vkUserId),
accessToken: accessTokenVk ?? null, accessToken: tokenBody?.access_token ?? null,
suggestedEmail: emailSuggestion, suggestedEmail: emailSuggestion,
linkToUserId, linkToUserId,
}) })