6. OAuth 2.0
Что такое OAuth 2.0?
Заголовок раздела «Что такое 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) │ возвращает данные пользователя ▼Твоё приложениеAuthorization Code Flow (рекомендуется)
Заголовок раздела «Authorization Code Flow (рекомендуется)»Самый безопасный flow для веб-приложений:
Шаг 1: Перенаправление на Authorization Server
Заголовок раздела «Шаг 1: Перенаправление на Authorization Server»// Генерируем state для защиты от CSRFconst 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}`);Шаг 2: Callback — получаем Authorization Code
Заголовок раздела «Шаг 2: Callback — получаем Authorization Code»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');});Шаг 3: Обмен Code на токены
Заголовок раздела «Шаг 3: Обмен Code на токены»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 (Proof Key for Code Exchange)
Заголовок раздела «PKCE (Proof Key for Code Exchange)»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 flowconst { verifier, challenge } = generatePKCE();req.session.codeVerifier = verifier; // сохраняем для callback
// В URL добавляем challengeparams.append('code_challenge', challenge);params.append('code_challenge_method', 'S256');
// В callback передаём verifierbody.append('code_verifier', req.session.codeVerifier);Другие OAuth Flows
Заголовок раздела «Другие OAuth Flows»Client Credentials (серверные приложения)
Заголовок раздела «Client Credentials (серверные приложения)»Для машина-машина коммуникации, без участия пользователя:
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();}Device Authorization Flow (TV, консоли)
Заголовок раздела «Device Authorization Flow (TV, консоли)»Для устройств без браузера:
// Шаг 1: получаем device codeconst { 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!
Хранение OAuth токенов
Заголовок раздела «Хранение OAuth токенов»// В БД (для 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), },});Обновление access token
Заголовок раздела «Обновление access token»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;}Практические задания
Заголовок раздела «Практические задания»- Настрой OAuth приложение в Google Console
- Реализуй Authorization Code Flow для GitHub OAuth
- Добавь PKCE для SPA приложения
- Реализуй автоматическое обновление access token