Модуль: Backend · Уровень: Middle+/Senior
TL;DR#
- OAuth2 — это про авторизацию (делегированный доступ), а НЕ про аутентификацию. Он отвечает на вопрос «можно ли этому клиенту делать X от имени пользователя», а не «кто этот пользователь». Аутентификацию поверх OAuth2 даёт OIDC (OpenID Connect) через
id_token. - 4 роли: Resource Owner (пользователь), Client (приложение), Authorization Server (выдаёт токены), Resource Server (API, проверяет токены).
- Основной flow сегодня — Authorization Code + PKCE для всех типов клиентов (web, SPA, mobile, desktop). Client Credentials — для machine-to-machine без пользователя. Refresh Token grant — для обновления access token без повторного логина.
- Deprecated: Implicit (утечка токена через URL/Referer, отсутствие refresh) и Resource Owner Password Credentials (anti-pattern — клиент видит пароль пользователя).
- access token — короткоживущий (минуты), bearer, передаётся в API. refresh token — долгоживущий, хранится максимально защищённо, обменивается на новый access token.
- Безопасность держится на:
state(CSRF),PKCE(перехват кода), строгая валидацияredirect_uri(open redirect), проверкаaud/iss/exp/подписи токена на Resource Server.
Теория#
1. OAuth2 ≠ аутентификация#
Самая частая концептуальная ошибка. OAuth2 (RFC 6749) спроектирован как протокол делегированной авторизации: ресурс-овнер разрешает третьему приложению (клиенту) доступ к своим ресурсам на Resource Server без передачи пароля.
Access token — это bearer-токен доступа, а не доказательство личности. Сам по себе он не говорит надёжно, «кто залогинился»:
- access token может быть непрозрачным (opaque) — клиент не должен его парсить;
- access token адресован Resource Server (
aud), а не клиенту; - факт «у меня есть валидный access token» не означает «прямо сейчас этот пользователь аутентифицировался у меня» (классическая атака — подмена токена, «confused deputy»).
Для аутентификации существует OIDC — слой поверх OAuth2, который добавляет id_token (JWT), адресованный именно клиенту, с claim’ами о пользователе и о факте/времени аутентификации.
2. Роли#
| Роль | Кто это | Назначение |
|---|---|---|
| Resource Owner | Пользователь | Владеет данными, даёт согласие (consent) |
| Client | Приложение (SPA, mobile, backend) | Хочет доступ к ресурсам от имени пользователя |
| Authorization Server (AS) | IdP / Keycloak / Auth0 / Okta | Аутентифицирует пользователя, выдаёт токены (/authorize, /token) |
| Resource Server (RS) | API | Хранит ресурсы, валидирует access token, отдаёт данные |
Типы клиентов:
- Confidential — может безопасно хранить секрет (backend). Аутентифицируется
client_secret/mTLS/private_key_jwt. - Public — не может хранить секрет (SPA, mobile, desktop). Поэтому обязан использовать PKCE.
3. Authorization Code + PKCE#
Основной flow для всех типов клиентов. PKCE (RFC 7636) изначально создан для public-клиентов, но текущий best practice (OAuth 2.1) — применять PKCE всегда, даже для confidential-клиентов.
PKCE-параметры:
code_verifier— случайная высокоэнтропийная строка (43–128 символов, base64url), генерируется клиентом на каждый запрос.code_challenge=BASE64URL(SHA256(code_verifier))приcode_challenge_method=S256(никогда неplainв проде).
Идея: код авторизации, даже если его перехватят (через лог, Referer, кастомную URL-схему на мобиле), бесполезен без code_verifier, который никогда не покидает клиент до обмена кода на токен.
┌──────────┐ ┌───────────────────┐
│ Client │ │ Authorization Srv │
└────┬─────┘ └─────────┬─────────┘
│ 1. генерирует code_verifier, │
│ code_challenge = S256(verifier), state │
│ │
│ 2. GET /authorize?response_type=code │
│ &client_id&redirect_uri&scope │
│ &state&code_challenge&code_challenge_method │
│ ────────────────────────────────────────────────►
│ │
│ 3. логин + consent пользователя │
│ (Resource Owner) │
│ │
│ 4. 302 redirect_uri?code=...&state=... │
│ ◄────────────────────────────────────────────────
│ │
│ 5. проверяет, что state совпадает (CSRF) │
│ │
│ 6. POST /token grant_type=authorization_code │
│ &code&redirect_uri&client_id │
│ &code_verifier (+client_secret если confid.) │
│ ────────────────────────────────────────────────►
│ │
│ 7. AS проверяет S256(verifier) │
│ == сохранённый code_challenge │
│ │
│ 8. { access_token, refresh_token, │
│ id_token (если openid), expires_in } │
│ ◄────────────────────────────────────────────────
▼ ▼Запрос на /authorize:
GET /authorize?response_type=code
&client_id=s6BhdRkqt3
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid%20profile%20email%20read:orders
&state=af0ifjsldkj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 HTTP/1.1
Host: auth.example.comОбмен кода на токен:
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&client_id=s6BhdRkqt3
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkОтвет:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
"id_token": "eyJhbGciOi...",
"scope": "openid profile email read:orders"
}4. Client Credentials#
Machine-to-machine: нет пользователя, нет Resource Owner. Сервис аутентифицируется сам и получает токен от своего имени. Использовать только для confidential-клиентов. PKCE/state/redirect_uri здесь не применимы — нет браузерного редиректа.
POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW # client_id:client_secret
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=read:metrics write:metricsНикакого refresh_token — клиент просто запрашивает новый токен по истечении. Для повышенной безопасности вместо client_secret используют private_key_jwt (RFC 7523) или mTLS.
5. Refresh Token grant#
Позволяет получить новый access token без участия пользователя.
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA
&client_id=s6BhdRkqt3
&scope=read:orders # можно только сужать scope, не расширятьBest practices:
- Refresh token rotation — на каждый refresh выдаётся новый refresh token, старый инвалидируется.
- Reuse detection — если использован уже отозванный (ротированный) refresh token, AS отзывает всю цепочку (признак кражи).
- Для public-клиентов refresh token обязан быть либо ротируемым, либо sender-constrained (DPoP/mTLS).
6. Устаревшие flows#
Implicit (response_type=token) — deprecated (OAuth 2.1 убирает его). Access token возвращался прямо в URL-фрагменте редиректа. Проблемы:
- токен утекает в историю браузера, логи, через
Referer; - нет защиты от подмены (нет PKCE по дизайну);
- нет refresh token. Замена: Authorization Code + PKCE для SPA.
Resource Owner Password Credentials (ROPC) — anti-pattern. Клиент сам собирает логин/пароль пользователя и шлёт на /token. Нарушает базовый принцип OAuth2 (клиент не должен видеть пароль), несовместим с MFA/SSO/федерацией. Допустим только в крайне ограниченных legacy-сценариях с собственным first-party клиентом, и тоже исключён из OAuth 2.1.
7. access token vs refresh token#
| access token | refresh token | |
|---|---|---|
| Назначение | доступ к API (RS) | получить новый access token (на AS) |
Аудитория (aud) | Resource Server | Authorization Server |
| TTL | короткий (мин: 5–15 мин) | длинный (часы/дни/недели) |
| Формат | JWT или opaque | обычно opaque/ротируемый |
| Где предъявляется | Authorization: Bearer в API | только на /token endpoint |
| Хранение (web) | в памяти JS / HttpOnly cookie (BFF) | НЕ в localStorage; HttpOnly+Secure cookie или backend |
| Отзыв | сложно отозвать JWT до истечения | легко отозвать на AS |
Ключевой trade-off JWT access token: его нельзя «мгновенно» отозвать — поэтому делают короткий TTL + (опц.) introspection/revocation list. Это причина, почему access делают коротким, а долгоживучесть выносят в легко отзываемый refresh.
8. state и redirect_uri#
state— непрозрачное случайное значение, привязанное к сессии браузера. Клиент кладёт его в/authorize, AS возвращает в редиректе, клиент обязан сверить. Это защита от CSRF (подмена кода авторизации злоумышленником). С PKCE частично перекрывается, ноstateвсё равно нужен (особенно для CSRF на сам callback). Часто также служит для возврата на исходную страницу.nonce(OIDC) — аналог state, но привязывается кid_tokenдля защиты от replay; AS кладёт его в claimnonce.redirect_uri— должен проверяться по принципу точного совпадения (exact match) с заранее зарегистрированным значением. Никаких wildcard, никакого «startsWith». Open redirect здесь = кража кода/токена.
9. OIDC поверх OAuth2#
OIDC добавляет к OAuth2 идентификацию:
id_token— JWT, адресованный клиенту (aud= client_id), с claim’ами:iss,sub(стабильный ID пользователя),aud,exp,iat,auth_time,nonce, плюс профильные (name,email, …).- Запускается добавлением scope
openidв Authorization Code flow. - scopes:
openid(обязателен для OIDC),profile,email,address,phone— определяют, какие claim’ы доступны. /userinfo— endpoint, отдаёт claim’ы пользователя по предъявлении access token (когда не хочется раздувать id_token).- Discovery —
GET /.well-known/openid-configurationотдаёт метаданные:authorization_endpoint,token_endpoint,userinfo_endpoint,jwks_uri,issuer, поддерживаемые scopes/grant types/алгоритмы. JWKS (jwks_uri) — публичные ключи для проверки подписи JWT.
GET /.well-known/openid-configuration HTTP/1.1
Host: auth.example.comПравильная валидация id_token: проверить подпись по JWKS, iss == ожидаемый issuer, aud == свой client_id, exp/iat, nonce == отправленный, azp при множественных audience.
10. Go: golang.org/x/oauth2#
Authorization Code + PKCE:
package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"golang.org/x/oauth2"
)
func newPKCE() (verifier, challenge string) {
b := make([]byte, 32)
_, _ = rand.Read(b)
verifier = base64.RawURLEncoding.EncodeToString(b)
sum := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(sum[:])
return
}
var conf = &oauth2.Config{
ClientID: "s6BhdRkqt3",
ClientSecret: "secret", // пусто для public-клиента
RedirectURL: "https://app.example.com/callback",
Scopes: []string{"openid", "profile", "email", "read:orders"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://auth.example.com/authorize",
TokenURL: "https://auth.example.com/token",
},
}
// Шаг 1: редирект пользователя на /authorize
func authURL(state, challenge string) string {
return conf.AuthCodeURL(state,
oauth2.AccessTypeOffline, // запросить refresh_token
oauth2.SetAuthURLParam("code_challenge", challenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
}
// Шаг 2: в callback — проверить state, обменять code на токен
func exchange(ctx context.Context, code, verifier string) (*oauth2.Token, error) {
return conf.Exchange(ctx, code,
oauth2.SetAuthURLParam("code_verifier", verifier),
)
}В x/oauth2 v0.9+ есть готовые
oauth2.GenerateVerifier(),oauth2.S256ChallengeOption(verifier)иoauth2.VerifierOption(verifier), которые делают то же самое без ручного SHA256.
Client Credentials:
import "golang.org/x/oauth2/clientcredentials"
cc := &clientcredentials.Config{
ClientID: "svc-id",
ClientSecret: "svc-secret",
TokenURL: "https://auth.example.com/token",
Scopes: []string{"read:metrics"},
}
// httpClient автоматически добавляет Bearer и обновляет токен
httpClient := cc.Client(context.Background())
resp, err := httpClient.Get("https://api.example.com/metrics")Автообновление access token по refresh token:
// token уже содержит refresh_token; TokenSource сам обновит при истечении
ts := conf.TokenSource(ctx, token)
client := oauth2.NewClient(ctx, ts) // прозрачно рефрешит и подставляет Bearer
// чтобы перехватить новый refresh token (rotation) — обернуть TokenSource:
newTok, _ := ts.Token() // вернёт обновлённый токен, если истёкПодводные камни / gotchas#
oauth2.TokenSourceкеширует токен в памяти и не сообщает о ротации refresh token «наружу». Если AS использует refresh rotation, нужно самостоятельно вычитывать обновлённый токен изTokenSourceи персистить новый refresh token, иначе после рестарта или второго рефреша получитеinvalid_grant.expires_in— это секунды, а не unix-время. В x/oauth2 полеToken.Expiry— это абсолютное время; путаница приводит к токенам, которые «всегда истекли» или «никогда не истекают».- JWT access token не валидируется библиотекой клиента — это работа Resource Server. RS обязан проверять подпись (JWKS с кешированием и ротацией ключей по
kid),iss,aud,exp,nbf, и scope. Кеш JWKS без учёта ротации = внезапные отказы или, наоборот, приём токенов отозванным ключом. audmismatch / token passthrough. Access token, выданный для API A, нельзя слепо переслать в API B. RS обязан проверять, чтоaud== он сам, иначе «confused deputy».alg: noneи алгоритм-конфьюжен (RS256→HS256). Если верификатор берёт алгоритм из заголовка токена, атакующий может подписать токен публичным ключом как HMAC. Алгоритм должен быть зафиксирован конфигом, не взят из JWT-заголовка.code_challenge_method=plainсводит PKCE на нет — всегдаS256.- redirect_uri через
startsWith/wildcard — классический open redirect → кража кода. Только exact match. - state не привязан к сессии (хранится только в куке без HttpOnly или глобально) — обход CSRF-защиты.
- Хранение токенов в
localStorageв SPA — доступно любому XSS. Предпочтительно паттерн BFF (Backend-for-Frontend): токены живут на бэкенде, в браузере только HttpOnly-сессионная кука. - Scope ≠ permission на уровне данных. Scope
read:ordersговорит «клиент может читать заказы», но не «этого конкретного пользователя». Авторизацию на уровень объекта (IDOR) всё равно делает RS. - Слишком долгий access token «потому что неудобно рефрешить» — теряется возможность отзыва. Решают коротким TTL + refresh.
Вопросы на собеседовании#
В: В чём принципиальная разница между OAuth2 и OIDC, и почему «логин через OAuth2» — это часто ошибка?
О: OAuth2 — протокол делегированной авторизации: выдаёт access token для доступа к ресурсам. Он не предназначен отвечать «кто пользователь». Использование access token как доказательства личности уязвимо (подмена токена, confused deputy, token substitution). OIDC — слой поверх OAuth2, добавляющий id_token (JWT, адресованный клиенту через aud) с проверяемыми claim’ами sub, auth_time, nonce. Именно id_token, а не access token, отвечает на вопрос аутентификации.
В: Зачем нужен PKCE, если уже есть client_secret и state?
О: PKCE защищает от перехвата кода авторизации. Public-клиенты (SPA, mobile) не могут хранить секрет, а на мобиле редирект по кастомной URL-схеме может перехватить вредоносное приложение. PKCE привязывает код к code_verifier, известному только легитимному клиенту: даже перехватив код, атакующий не обменяет его без verifier. state решает другую задачу — CSRF на callback. OAuth 2.1 рекомендует PKCE даже для confidential-клиентов (defence in depth, защита от утечки кода через логи/прокси).
В: Чем code_challenge_method=S256 отличается от plain и почему plain опасен?
О: При S256 в /authorize уходит SHA256(verifier), а сам verifier — только в /token. При plain code_challenge == code_verifier, то есть секрет уже виден в первом запросе (в логах/истории браузера) — защита теряется. В проде только S256.
В: access token vs refresh token — TTL, аудитория, хранение?
О: access — короткоживущий (минуты), aud = Resource Server, предъявляется в каждом API-запросе как Bearer, в SPA хранится в памяти/через BFF. refresh — долгоживущий, aud = Authorization Server, предъявляется только на /token, хранится максимально защищённо (HttpOnly cookie / backend), легко отзывается. Короткий access + отзываемый refresh — это баланс между удобством и контролем: JWT access нельзя мгновенно отозвать, поэтому его делают коротким.
В: Почему Implicit flow устарел и чем его заменили?
О: Implicit возвращал access token прямо в URL-фрагменте: токен утекает через историю браузера, Referer, логи; нет PKCE, нет refresh token. Современная альтернатива для SPA — Authorization Code + PKCE (код одноразовый и бесполезен без verifier, токены приходят в теле POST-ответа, а не в URL). OAuth 2.1 удаляет Implicit.
В: Когда применять Client Credentials и почему там нет refresh token и PKCE?
О: Client Credentials — machine-to-machine, где нет пользователя и нет браузерного редиректа. Клиент аутентифицируется сам (client_secret/private_key_jwt/mTLS) и получает токен от своего имени. refresh не нужен — клиент в любой момент запросит новый токен по своим учётным данным. PKCE/state/redirect_uri неприменимы, так как нет фронтенд-редиректа и пользовательской сессии.
В: Что и как должен проверять Resource Server при получении JWT access token?
О: Подпись по ключу из JWKS (по kid, с кешем и учётом ротации); зафиксированный конфигом алгоритм (не из заголовка токена — иначе атака alg:none/RS256→HS256); iss == ожидаемый issuer; aud == собственный идентификатор (защита от confused deputy/passthrough); exp/nbf/iat; наличие нужных scope. Авторизацию на уровне конкретного объекта (что пользователь имеет право именно на эти данные) RS делает отдельно — scope её не заменяет.
В: Что такое refresh token rotation и reuse detection?
О: Rotation — при каждом использовании refresh token AS выдаёт новый и инвалидирует старый. Reuse detection — если приходит уже использованный (ротированный) refresh token, это признак кражи, и AS отзывает всю цепочку токенов сессии. Это критично для public-клиентов, где refresh token хуже защищён. Подводный камень в клиенте: нужно персистить именно новый refresh token, иначе после рестарта получите invalid_grant.
В: Зачем нужен nonce в OIDC, если есть state?
О: state защищает OAuth2-редирект от CSRF (привязка к браузерной сессии клиента). nonce привязывается к самому id_token: клиент кладёт его в /authorize, AS возвращает в claim nonce внутри id_token, клиент сверяет. Это защита от replay id_token и от подстановки чужого токена. Они решают разные задачи и используются вместе.
В: Как безопасно хранить токены в SPA?
О: Не в localStorage/sessionStorage — это легко вычитывается при XSS. Лучший паттерн — BFF (Backend-for-Frontend): OAuth-обмен и хранение токенов на сервере, в браузере только HttpOnly+Secure+SameSite сессионная кука, а вызовы к API проксируются через бэкенд. Если BFF невозможен — access token держат в памяти JS (теряется при перезагрузке, и это норм), refresh либо ротируемый в HttpOnly cookie, либо sender-constrained (DPoP).
На что копают на senior+#
- Confused deputy / token passthrough. Понимаете ли, что access token нельзя слепо проксировать между микросервисами, и как это решается (
aud-валидация, token exchange RFC 8693, audience-restricted токены). - Алгоритм-конфьюжен и валидация JWT.
alg:none, RS256→HS256, ротация ключей JWKS поkid, кеширование JWKS и его инвалидация. Понимание, что верификация алгоритма не должна доверять заголовку токена. - Отзыв JWT. Дилемма «stateless JWT vs мгновенный отзыв»: короткий TTL, introspection (RFC 7662), revocation list, версия токена/
jti-blacklist, событийная инвалидация. - Sender-constrained tokens. DPoP (RFC 9449) и mTLS-bound токены (RFC 8705) — привязка токена к ключу клиента, чтобы украденный bearer был бесполезен.
- OAuth 2.1 и BCP. Знание, что Implicit и ROPC удалены, PKCE обязателен, exact-match redirect_uri обязателен, refresh rotation для public-клиентов (RFC 9700 / Security BCP).
- PAR и JAR. Pushed Authorization Requests (RFC 9126) и signed request objects (RFC 9101) — защита параметров авторизационного запроса от подмены, актуально для FAPI/финтеха.
- Token exchange / delegation / impersonation (RFC 8693) в микросервисной архитектуре: как пробросить контекст пользователя вглубь без передачи исходного токена.
- Multi-tenancy и
iss/aud-изоляция. Как не дать токену одного тенанта/клиента ходить в чужой контекст. - Логаут и сессии в OIDC. RP-initiated logout, back-channel logout, рассинхрон сессий между несколькими RP при едином IdP.
- Practical Go. Поведение
oauth2.TokenSource(кеш, потокобезопасность, отсутствие сигнала о ротации refresh), корректная персистентность токенов, кастомныйhttp.Client/transport для добавления mTLS/DPoP.