Модуль: 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 tokenrefresh token
Назначениедоступ к API (RS)получить новый access token (на AS)
Аудитория (aud)Resource ServerAuthorization 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 кладёт его в claim nonce.
  • 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).
  • DiscoveryGET /.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 без учёта ротации = внезапные отказы или, наоборот, приём токенов отозванным ключом.
  • aud mismatch / 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.