feat: migrate VK OAuth to VK ID flow with PKCE
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user