OAuth 2.0 / 2.1: делегированный доступ без передачи паролей
JWT — это формат токена. OAuth 2.0 — протокол, описывающий, как клиент получает токен от authorization server, не передавая пароль пользователя ни себе, ни ресурсу. Это фундаментальная задача: «можно ли разрешить приложению X читать мою почту в Gmail, не отдавая ему пароль от Google?»
Без OAuth ответ был «нет» или «отдай пароль и моли богов». С OAuth — «да, и в любой момент могу отозвать доступ конкретному приложению, не меняя пароль».
OAuth 2.0 (RFC 6749, 2012) — основа всех современных delegate-сценариев: «войти через Google», доступ Slack-app к workspace, GitHub Apps, Stripe Connect. OAuth 2.1 (черновик, финализация в 2026) — это консолидация best practices: deprecation Implicit и Password flows, обязательный PKCE для public clients, более строгие redirect URI checks.
В этом уроке пройдём роли, ключевые flows, sequence diagram Authorization Code и его защитные механизмы.
Четыре роли OAuth 2.0
OAuth 2.0 определяет четыре роли. Когда вы читаете спецификацию или интегрируетесь — важно их различать.
В малых системах authorization server и resource server — одно приложение. В больших (Google, AWS) — разделены: auth-сервер — на отдельном поддомене, выдаёт токены, не знает про конкретные ресурсы; resource-серверов десятки, они валидируют токены и отдают данные.
HTTP stateless: куки, сессии и state management
OAuth 2.0 vs 2.1: что меняется
OAuth 2.1 — не революция, а очистка 2.0 от устаревших и небезопасных паттернов. Финализация ожидается в 2026, но best practices уже применяются в новых проектах.
В практике 2026 года: даже если интегрируетесь по OAuth 2.0, используйте Authorization Code с PKCE везде, где можно. Implicit и Password не используйте никогда.
Три основных flow и где их применять
OAuth 2.0 определяет несколько grant types (flows). Из них в современной практике актуальны три.
Junior DE чаще всего сталкивается с Client Credentials — backend-ETL читает данные из external API от имени своего сервиса. Иногда с Authorization Code, когда нужно делать что-то от имени конкретного пользователя (например, OAuth-приложение для Slack).
Authorization Code flow с PKCE: пошагово
Это главный flow OAuth — пользователь логинится у auth-сервера, разрешает приложению доступ, приложение получает токен. С PKCE флоу выглядит так:
Шесть точек, на которые обратить внимание:
- PKCE (шаги m1, m6) — клиент генерирует пару
code_verifier/code_challenge. Auth-сервер связывает их с issued code. Перехватчик code не сможет обменять его на токен без verifier. - state (шаги m2, m5) — случайная строка, отправляемая туда и обратно. Защищает от CSRF: если злоумышленник пытается обманом заставить пользователя «подтвердить» доступ для чужого приложения, state не совпадёт.
- scope (шаг m2) — список запрошенных разрешений (
calendar.read,email,profile). Пользователь видит их в consent-экране и решает, дать ли все. - Authorization code (шаг m4) — одноразовый, короткоживущий (5-10 минут), может быть обменян только один раз.
- redirect_uri (m2, m4) — должен быть зарегистрирован в auth-сервере заранее. Защита от перенаправления на чужой сайт.
- Token endpoint (шаг m6) — POST с client_id, code, code_verifier, redirect_uri. Возвращает access + refresh tokens.
PKCE: что внутри
PKCE (Proof Key for Code Exchange, RFC 7636) — это защита от перехвата authorization code. Сценарий атаки без PKCE:
- Mobile app открывает браузер для OAuth flow
- После одобрения браузер редиректит на
myapp://callback?code=ABC - Если на устройстве есть другое приложение, зарегистрированное на ту же URL-схему
myapp://, оно может перехватить redirect и забрать code - Перехватчик обменивает code на токен, получая доступ
С PKCE:
- App генерирует
code_verifier(случайная строка) и хранит её локально - Вычисляет
code_challenge = base64url(SHA256(code_verifier))и отправляет в auth-сервер вместе с запросом authorization - Auth-сервер сохраняет
code_challengeрядом с issued code - Когда app обменивает code на токен, шлёт
code_verifierв открытом виде - Auth-сервер хеширует verifier и сравнивает с сохранённым challenge — если совпадает, выдаёт токен
Перехватчик кода не знает code_verifier (он остался в памяти app) — поэтому обмен не сработает.
import secrets
import hashlib
import base64
# 1. Генерация verifier
code_verifier = secrets.token_urlsafe(64) # 64+ символов
print("verifier:", code_verifier)
# 2. Вычисление challenge
challenge_bytes = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(challenge_bytes).rstrip(b"=").decode()
print("challenge:", code_challenge)
# 3. authorization URL включает challenge
auth_url = (
f"https://auth.example.com/authorize?"
f"client_id={CLIENT_ID}&"
f"redirect_uri={REDIRECT_URI}&"
f"response_type=code&"
f"scope=read:user&"
f"state={STATE}&"
f"code_challenge={code_challenge}&"
f"code_challenge_method=S256"
)
В OAuth 2.1 PKCE обязательно для всех клиентов (не только public). В 2026 — best practice везде, где есть Authorization Code.
Client Credentials flow: machine-to-machine
Это самый простой flow. Никакого пользователя — клиент идентифицирует себя, обменивает credentials на токен.
import requests
response = requests.post(
"https://auth.example.com/oauth/token",
data={
"grant_type": "client_credentials",
"client_id": "my-etl-service",
"client_secret": "super-secret",
"scope": "read:transactions",
},
timeout=10,
)
response.raise_for_status()
token_data = response.json()
print(token_data)
# {
# "access_token": "eyJhbGc...",
# "token_type": "Bearer",
# "expires_in": 3600
# }
access_token = token_data["access_token"]
# Теперь зовём API
api_response = requests.get(
"https://api.example.com/v1/transactions",
headers={"Authorization": f"Bearer {access_token}"},
)
Используется когда:
- Backend читает данные из API партнёра (ETL, синхронизация)
- Сервис-к-сервису внутри одной компании, без контекста пользователя
- Cron-jobs, batch processing
В отличие от Authorization Code, нет refresh-токена — когда access истекает, повторно вызываете client_credentials.
Client Credentials — это OAuth-обёртка над тем же по сути паттерном, что API key. Разница: вы получаете короткоживущий access-токен (security benefit) вместо вечно живущего ключа. Большие платформы (Google Cloud, AWS, Azure) предлагают этот flow для service accounts.
Device Code flow: для CLI и TV
Сценарий: вы на TV хотите авторизоваться в Netflix. На TV нет удобной клавиатуры для ввода пароля. Решение — Device Code:
- TV запрашивает у auth-сервера device code и user code (короткий, типа
BHWX-RDFL) - TV показывает: «Откройте на телефоне
netflix.com/activateи введите BHWX-RDFL» - Пользователь делает это на телефоне, логинится, подтверждает доступ
- TV в это время поллит auth-сервер каждые 5 секунд: «авторизация подтверждена?»
- Когда пользователь подтвердил — TV получает access token
import time
import requests
# 1. Запрос device code
response = requests.post(
"https://github.com/login/device/code",
data={"client_id": "my-cli-tool", "scope": "repo"},
headers={"Accept": "application/json"},
)
data = response.json()
print(f"Open {data['verification_uri']} and enter: {data['user_code']}")
device_code = data["device_code"]
interval = data["interval"]
# 2. Поллинг до подтверждения
while True:
time.sleep(interval)
poll = requests.post(
"https://github.com/login/oauth/access_token",
data={
"client_id": "my-cli-tool",
"device_code": device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
},
headers={"Accept": "application/json"},
)
result = poll.json()
if "access_token" in result:
print("Got token!")
access_token = result["access_token"]
break
if result.get("error") == "authorization_pending":
continue
raise RuntimeError(f"Auth error: {result}")
GitHub CLI (gh auth login), Cloudflare Wrangler, kubectl с OIDC — все используют этот flow для удобной авторизации без ввода паролей в терминале.
Scopes: гранулярный доступ
Scopes — это разрешения, которые клиент запрашивает у пользователя. Имена scope не стандартизированы — каждый API определяет свой набор.
Примеры из крупных API:
- Google:
https://www.googleapis.com/auth/calendar.readonly,https://www.googleapis.com/auth/drive.file - GitHub:
repo,user,admin:org,gist - Slack:
channels:read,chat:write,users:read.email
Принцип least privilege: запрашивайте минимум, нужный для функционирования. Если вашему сервису нужно только читать публичные events Google Calendar — запрашивайте calendar.events.readonly, не calendar (полный доступ).
В коде scope передаются в шагах authorization (от клиента к auth-серверу) и могут попадать в JWT-payload (scope: "read:users write:orders"). Resource-сервер на каждый запрос проверяет scope: «у этого токена есть write:orders для эндпоинта POST /orders?» — если нет, 403.
State и nonce: защита от CSRF и replay
state — случайная строка, передаваемая в /authorize и возвращаемая в callback. Защита от CSRF.
Сценарий атаки без state:
- Атакующий начинает OAuth flow для своего аккаунта Google и получает authorization code
- Атакующий заставляет жертву (через ссылку, фишинг) сделать GET на наш
redirect_uri?code=ATTACKER_CODE - Наш сервер обменивает code на токен и привязывает аккаунт атакующего к сессии жертвы
- Жертва теперь использует Google-аккаунт атакующего, не зная об этом
С state:
- Мы генерируем state и сохраняем в сессии жертвы
- Передаём в /authorize
- В callback проверяем, что присланный state совпадает с сохранённым
- Атакующий не знает state жертвы — атака не работает
import secrets
# При начале flow
state = secrets.token_urlsafe(32)
session["oauth_state"] = state
auth_url = f"...&state={state}"
# В callback
def callback(request):
received_state = request.GET["state"]
expected_state = session["oauth_state"]
if received_state != expected_state:
raise SecurityError("State mismatch -- possible CSRF")
# ...continue with code exchange
nonce — аналогичная защита, но для OpenID Connect (OAuth-расширение для аутентификации). Nonce включается в id_token и проверяется при получении.
OAuth 2.0 vs OpenID Connect: одна буква разницы
OAuth 2.0 — это про авторизацию (что клиенту разрешено делать). Возвращает access_token, который — opaque или JWT — валидируется resource-сервером.
OpenID Connect (OIDC) — это надстройка над OAuth 2.0 для аутентификации (кто пользователь). Дополнительно к access_token возвращается id_token — JWT с claims о пользователе (sub, email, name, picture).
«Войти через Google/Apple/GitHub» — это OIDC, не голый OAuth. Разработчик получает id_token, парсит его, узнаёт email пользователя — и создаёт сессию в своём приложении.
# OIDC scope включает 'openid'
auth_url = f"...&scope=openid+profile+email&..."
# Token endpoint вернёт ещё id_token
{
"access_token": "...",
"id_token": "eyJhbGc...", # JWT с email, sub, name
"refresh_token": "...",
"expires_in": 3600
}
# Декодируем id_token (с проверкой подписи!)
import jwt
user_info = jwt.decode(id_token, public_key, algorithms=["RS256"], audience=CLIENT_ID)
print(user_info["email"], user_info["sub"])
Junior DE редко занимается OIDC напрямую (это больше про user-facing applications), но знать про разницу полезно — особенно когда читаете чужие интеграции и видите id_token.
Какие библиотеки использовать
Писать OAuth flow руками — реально (как мы видели), но в продакшене обычно используют библиотеки.
Для большинства DE-задач (Client Credentials с external API партнёра) достаточно простого requests.post к token endpoint и requests.get с Bearer-заголовком. Сложности OAuth начинаются при user-facing flows (Authorization Code) — там стоит использовать готовую библиотеку.
Полный пример: Client Credentials в production-стиле
Соберём ETL-клиент, который получает токен по Client Credentials, кэширует его до истечения, автоматически перезапрашивает.
import time
import threading
from typing import Optional
import requests
class OAuthClient:
def __init__(
self,
token_url: str,
client_id: str,
client_secret: str,
scope: str,
):
self.token_url = token_url
self.client_id = client_id
self.client_secret = client_secret
self.scope = scope
self._access_token: Optional[str] = None
self._expires_at: float = 0
self._lock = threading.Lock()
def get_token(self) -> str:
# Refresh за 60 секунд до истечения
with self._lock:
if self._access_token is None or time.time() >= self._expires_at - 60:
self._refresh_token()
return self._access_token
def _refresh_token(self):
response = requests.post(
self.token_url,
data={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": self.scope,
},
timeout=10,
)
response.raise_for_status()
data = response.json()
self._access_token = data["access_token"]
self._expires_at = time.time() + data["expires_in"]
def fetch_data(oauth: OAuthClient, base_url: str):
token = oauth.get_token()
response = requests.get(
f"{base_url}/v1/transactions",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
response.raise_for_status()
return response.json()
Что здесь правильно:
- Кэширование токена — не запрашиваем каждый раз
- Threading lock — безопасно из нескольких потоков
- Refresh за 60 секунд до экспирации — защита от race с in-flight запросами
- Никаких токенов в логах
- Timeout на все запросы
Итоги урока
OAuth 2.0 — протокол делегированного доступа. Четыре роли: Resource Owner (пользователь), Client (приложение), Authorization Server (выдаёт токены), Resource Server (отдаёт данные).
OAuth 2.1 — консолидация best practices: deprecated Implicit и Password flows, обязательный PKCE, sender-constrained refresh.
Три актуальных flow:
- Authorization Code + PKCE — для всех user-facing приложений (web, SPA, mobile, desktop)
- Client Credentials — машинно-машинные сценарии (ETL, backend-to-backend)
- Device Code — TV, CLI, IoT
Защитные механизмы: PKCE (от перехвата code), state (от CSRF), nonce (от replay в OIDC), scopes (least privilege).
OAuth ≠ OpenID Connect: OAuth — авторизация (что разрешено), OIDC — аутентификация (кто пользователь, через id_token).
В Python: requests-oauthlib, authlib, google-auth, msal. Для простых Client Credentials достаточно сырого requests.
В следующем уроке — финальный security checklist для всего раздела аутентификации: что делать и не делать в практике Junior DE.