Перейти к содержимому

6. OAuth 2.0

OAuth 2.0 — протокол авторизации, позволяющий приложениям получать ограниченный доступ к аккаунтам пользователей на других сервисах (Google, GitHub, Facebook) без передачи логина/пароля.

Зачем это нужно?

  • Пользователь не вводит пароль в твоё приложение
  • Твоё приложение получает только нужные права (scope)
  • Пользователь может отозвать доступ в любой момент
Пользователь (Resource Owner)
│ хочет войти через Google
Твоё приложение (Client)
│ перенаправляет на Google
Google (Authorization Server)
│ выдаёт Authorization Code
Твоё приложение (Client)
│ обменивает Code на Access Token
Google API (Resource Server)
│ возвращает данные пользователя
Твоё приложение

Самый безопасный flow для веб-приложений:

// Генерируем state для защиты от CSRF
const state = crypto.randomBytes(16).toString('hex');
// Сохраняем state в сессии для проверки
req.session.oauthState = state;
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.GOOGLE_CLIENT_ID,
redirect_uri: 'https://myapp.com/auth/google/callback',
scope: 'openid email profile',
state,
// PKCE (для public clients)
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
app.get('/auth/google/callback', async (req, res) => {
const { code, state, error } = req.query;
// Проверяем state (защита от CSRF)
if (state !== req.session.oauthState) {
return res.status(400).json({ error: 'Invalid state' });
}
if (error) {
return res.redirect(`/login?error=${error}`);
}
// Обмениваем code на токены
const tokens = await exchangeCodeForTokens(code);
// Получаем данные пользователя
const userInfo = await getUserInfo(tokens.access_token);
// Логиним пользователя в нашей системе
const user = await findOrCreateUser(userInfo);
req.session.userId = user.id;
res.redirect('/dashboard');
});
async function exchangeCodeForTokens(code) {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
redirect_uri: 'https://myapp.com/auth/google/callback',
grant_type: 'authorization_code',
// PKCE
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
throw new Error('Token exchange failed');
}
return response.json();
// { access_token, refresh_token, id_token, expires_in, token_type }
}

PKCE — расширение для публичных клиентов (SPA, мобильные), где нельзя безопасно хранить client_secret.

import { createHash, randomBytes } from 'crypto';
function generatePKCE() {
// code_verifier — случайная строка 43-128 символов
const verifier = randomBytes(32).toString('base64url');
// code_challenge = BASE64URL(SHA256(verifier))
const challenge = createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
// При начале OAuth flow
const { verifier, challenge } = generatePKCE();
req.session.codeVerifier = verifier; // сохраняем для callback
// В URL добавляем challenge
params.append('code_challenge', challenge);
params.append('code_challenge_method', 'S256');
// В callback передаём verifier
body.append('code_verifier', req.session.codeVerifier);

Для машина-машина коммуникации, без участия пользователя:

async function getServiceToken() {
const response = await fetch('https://api.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
scope: 'read:users write:reports',
}),
});
return response.json();
}

Для устройств без браузера:

// Шаг 1: получаем device code
const { device_code, user_code, verification_uri } = await fetch(
'https://accounts.google.com/o/oauth2/device/code',
{ method: 'POST', body: { client_id, scope } }
).then(r => r.json());
// Показываем пользователю:
console.log(`Перейди на ${verification_uri} и введи код: ${user_code}`);
// Шаг 2: опрашиваем сервер пока пользователь не авторизует
while (true) {
const tokens = await fetch('/token', {
body: { grant_type: 'device_code', device_code, client_id }
});
if (tokens.access_token) break;
if (tokens.error === 'authorization_pending') {
await sleep(5000); // ждём
}
}

Scopes ограничивают доступ к данным:

Google OAuth scopes:
- openid — базовая аутентификация
- email — email адрес
- profile — имя, фото
- https://www.googleapis.com/auth/gmail.readonly — чтение почты
- https://www.googleapis.com/auth/drive.file — файлы Drive
GitHub OAuth scopes:
- user — данные пользователя
- user:email — email
- repo — публичные репозитории
- repo:status — статусы коммитов

Принцип минимальных прав: запрашивай только нужные scopes!

// В БД (для refresh token)
await db.oauthTokens.upsert({
where: { userId_provider: { userId: user.id, provider: 'google' } },
update: {
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
},
create: {
userId: user.id,
provider: 'google',
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
},
});
async function getValidGoogleToken(userId) {
const stored = await db.oauthTokens.findUnique({
where: { userId_provider: { userId, provider: 'google' } }
});
// Проверяем, не истёк ли токен
if (stored.expiresAt > new Date(Date.now() + 60000)) {
return decrypt(stored.accessToken);
}
// Обновляем через refresh token
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: decrypt(stored.refreshToken),
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
}),
});
const tokens = await response.json();
await db.oauthTokens.update({
where: { userId_provider: { userId, provider: 'google' } },
data: {
accessToken: encrypt(tokens.access_token),
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
}
});
return tokens.access_token;
}
  1. Настрой OAuth приложение в Google Console
  2. Реализуй Authorization Code Flow для GitHub OAuth
  3. Добавь PKCE для SPA приложения
  4. Реализуй автоматическое обновление access token