OAuth 2.0 и OpenID Connect
Роли и потоки OAuth 2.0, токены и их жизнь, вход через OpenID Connect в ASP.NET Core и защита SPA. На этих протоколах держится корпоративный единый вход, поэтому на собеседовании проверяют и теорию, и опыт настройки.
Перед этой темой: Основы веб-безопасности.
Что такое OAuth 2.0 и чем от него отличается OpenID Connect?
Заголовок раздела «Что такое OAuth 2.0 и чем от него отличается OpenID Connect?»Что это
- OAuth 2.0 — протокол, по которому пользователь разрешает приложению доступ к своим данным, не отдавая ему пароль.
- OpenID Connect — слой поверх OAuth 2.0, который добавляет вход: приложение узнаёт, кто пользователь.
Роли OAuth
| Роль | Пример |
|---|---|
| Resource owner — владелец ресурса | пользователь |
| Client — клиент | веб-приложение, SPA |
| Authorization server — сервер авторизации | Keycloak, Entra ID, ADFS |
| Resource server — ресурсный сервер | ваш Web API |
Как пользоваться
- Клиент отправляет пользователя на сервер авторизации.
- Пользователь входит там и соглашается выдать доступ.
- Клиент получает access-токен и вызывает с ним API, а в OIDC — ещё и ID-токен.
Частая ошибка
Считать access-токен доказательством входа. Он адресован API, а кто вошёл, говорит ID-токен.
Как устроено
- OAuth 2.0 (RFC 6749) описывает выдачу access-токена: endpoints
/authorizeи/token, типы клиентов и потоки (grant types). Формат токена и способ узнать пользователя он не задаёт. - Спецификации OpenID Connect добавляют поверх:
- scope
openid— признак OIDC-запроса, и стандартныеprofile,email; - ID-токен — всегда JWT с полями
iss,sub,aud,exp,iat; - endpoint UserInfo — профиль пользователя по access-токену;
- discovery-документ
/.well-known/openid-configurationс адресами endpoints и ключей.
- scope
Когда что выбирать
| Задача | Что нужно |
|---|---|
| Войти в приложение под корпоративной учёткой | OIDC: ID-токен и сессия приложения |
| Вызвать API от имени пользователя | access-токен с нужным scope |
| Вызвать API от имени сервиса | access-токен, выданный самому сервису |
Ловушки
- Вход по access-токену. Токен, выданный другому клиенту, подойдёт и вашему. ID-токен защищён от этого проверкой
aud. - ID-токен в API. Отправлять его в заголовке
Authorization— ошибка: его получатель — клиент, а не API.
Правило
Аутентификация пользователя — OIDC, доступ к API — access-токен с узким scope.
Под капотом
- Сервер авторизации — единственное место, где пользователь вводит пароль или проходит Kerberos. Клиент видит только результат — токены.
- API доверяет подписи сервера авторизации: проверяет издателя, аудиторию и срок токена. Подробно — в теме JWT и защита API.
- ID-токен адресован клиенту:
audравенclient_id, аnonceсвязывает ответ с конкретным запросом входа. Чужой или повторно присланный ID-токен клиент отвергнет.
Компромиссы
- Делегирование против пароля в приложении. Пароль хранит один сервер, доступ отзывается по каждому клиенту отдельно. Цена — редиректы, ключи подписи и зависимость от доступности провайдера.
- Единый вход (SSO). Сессия у сервера авторизации общая: вошёл в одно приложение — вошёл во все. Выход тоже приходится согласовывать: RP-initiated, front-channel и back-channel logout.
- Протокол против продукта. OIDC стандартизирует обмен, но не модель прав: роли и группы каждый провайдер передаёт по-своему. ADFS, Entra ID и Keycloak различаются именами полей и поддержкой расширений.
Как проверить в проде
- Discovery-документ провайдера показывает поддерживаемые потоки, scopes и алгоритмы:
grant_types_supported,scopes_supported,id_token_signing_alg_values_supported. - Содержимое токенов смотрят в логах Development или локальным декодером, не отправляя боевые токены на сторонние сайты.
Итог: OAuth отделяет выдачу доступа от его использования, OIDC стандартизирует, кто вошёл, а держатся оба на доверии к подписи сервера авторизации.
// ID-токен после декодирования: адресован клиенту{ "iss": "https://login.example.com", // кто выпустил "sub": "248289761001", // постоянный id пользователя у провайдера "aud": "orders-web", // client_id приложения "exp": 1791374400, // срок действия, Unix time "iat": 1791370800, // время выпуска "nonce": "n-0S6_WzA2Mj", // связь с запросом входа "name": "Olena Kovalenko"}// Access-токен того же входа (если провайдер выдаёт JWT): адресован API{ "iss": "https://login.example.com", "sub": "248289761001", "aud": "https://api.example.com/orders", // получатель — API "client_id": "orders-web", // кому выдан "scope": "orders.read", // что клиенту разрешено "exp": 1791371100}- Почему OAuth называют протоколом авторизации, а не аутентификации? — Он выдаёт доступ к API, но не сообщает клиенту, кто пользователь; это делает OIDC.
- Что будет, если отправить ID-токен в API? — API должен отклонить его по
aud: токен выпущен для клиента. - Что такое relying party? — Термин OIDC для клиента, который доверяет провайдеру вход пользователя.
- Зачем нужен endpoint UserInfo, если есть ID-токен? — Чтобы не раздувать ID-токен: дополнительные данные профиля клиент берёт по access-токену.
- Может ли ADFS быть OIDC-провайдером? — Да, начиная с Windows Server 2016; подробности — в теме «AD, Kerberos и ADFS».
Какие бывают потоки OAuth и какой когда выбрать?
Заголовок раздела «Какие бывают потоки OAuth и какой когда выбрать?»Что это
Поток (grant type) — сценарий, по которому клиент получает токен. Выбор зависит от двух вопросов: есть ли пользователь и может ли клиент хранить секрет.
Основные потоки
| Поток | Когда |
|---|---|
| Authorization code + PKCE | входит пользователь: сайт, SPA, мобильное приложение |
| Client credentials | сервис вызывает сервис, пользователя нет |
| Device code | у устройства нет браузера: ТВ, консольная утилита |
| Refresh token | продлить доступ без повторного входа |
Частая ошибка
Взять поток с паролем (grant_type=password), потому что он «проще»: приложение само принимает логин и пароль. Тогда пароль видит клиент, а MFA и единый вход перестают работать. Современные рекомендации этот поток запрещают.
Как устроено: authorization code + PKCE
- Клиент генерирует случайный
code_verifierи считаетcode_challenge— BASE64URL от его SHA-256. - Браузер уходит на
/authorizeсresponse_type=code,client_id,redirect_uri,scope,stateиcode_challenge. - Пользователь входит на сервере авторизации и подтверждает доступ.
- Сервер авторизации возвращает браузер на
redirect_uriс одноразовымcodeи тем жеstate. - Клиент сверяет
stateи отправляет POST на/tokenсcodeиcode_verifier; конфиденциальный клиент добавляет свой секрет. - Сервер проверяет, что хэш
code_verifierсовпал сcode_challenge, и выдаёт токены.
Когда что выбирать
- Конфиденциальный клиент — серверное веб-приложение или сервис с секретом: authorization code + PKCE для пользователей, client credentials для вызовов без пользователя.
- Публичный клиент — SPA, мобильное или десктопное приложение: секрета нет, PKCE обязателен.
Ловушки
- Implicit (
response_type=token) кладёт access-токен во фрагмент URL: он попадает в историю браузера, его можно подменить. - Нестрогий
redirect_uri. Сравнение по префиксу или маске позволяет увести код на подставной адрес. RFC 9700 требует точного совпадения строк.
Правило
Authorization code + PKCE для всех клиентов с пользователем, client credentials для сервисов, device code для устройств без браузера. Implicit и password в новом коде не используют.
Под капотом
- PKCE защищает от перехвата и подмены кода: украденный
codeбесполезен безcode_verifier, который не покидал клиента. Метод —S256;plainв OAuth 2.1 убран. - Фронт-канал (редиректы браузера) ненадёжен: URL видят история, расширения и логи. Поэтому по нему идёт только короткоживущий одноразовый код, а токены — прямым ответом по TLS.
- Device code (RFC 8628): устройство получает
device_codeиuser_code, показывает адрес и код, пользователь входит с телефона, а устройство опрашивает/token. - Client credentials выдаёт токен самому клиенту, и refresh-токен в нём не нужен: RFC 6749 советует его не выдавать.
Статус стандартов
- RFC 9700 (OAuth 2.0 Security BCP, январь 2025): password — MUST NOT, implicit — SHOULD NOT, PKCE обязателен публичным клиентам и рекомендован конфиденциальным.
- OAuth 2.1 на октябрь 2026 — ещё черновик IETF. В нём нет implicit и password, а PKCE обязателен всем, кроме конфиденциального клиента с проверкой OIDC
nonce.
Компромиссы
Сервису, которому нужен пользователь, client credentials не подходит: токен будет от имени сервиса. Тогда меняют токен пользователя на новый: token exchange (RFC 8693) или on-behalf-of в Entra ID.
Как проверить в проде
- В discovery-документе —
grant_types_supportedиcode_challenge_methods_supportedсS256; у клиента в настройках провайдера выключены implicit и password. - В логах прокси и веб-сервера не должно быть
access_token=в URL.
Итог: выбор потока — это выбор канала для токена и способа доказать, что клиент тот же; стандарты оставили authorization code + PKCE, client credentials и device code.
GET /authorize?response_type=code&client_id=orders-web &redirect_uri=https%3A%2F%2Forders.example.com%2Fsignin-oidc &scope=openid%20profile%20orders.read&state=af0ifjsldkj &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256 HTTP/1.1Host: login.example.comPOST /token HTTP/1.1Host: login.example.comAuthorization: Basic b3JkZXJzLXdlYjpzZWNyZXQ=Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=https%3A%2F%2Forders.example.com%2Fsignin-oidc&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkСтрока запроса разбита для читаемости. Первый запрос идёт через браузер, второй — напрямую от сервера приложения; заголовок Authorization: Basic есть только у конфиденциального клиента.
using System.Buffers.Text;using System.Security.Cryptography;using System.Text;
// code_verifier: 32 случайных байта → 43 символа base64urlvar verifier = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
// code_challenge = BASE64URL(SHA256(code_verifier)), метод S256var challenge = Base64Url.EncodeToString(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));
Console.WriteLine(verifier.Length); // 43Console.WriteLine(challenge.Length); // 43- Почему implicit больше не нужен SPA? — Браузеры поддерживают CORS, и SPA проходит authorization code + PKCE так же, как остальные клиенты.
- Заменяет ли PKCE параметр
state? — Защиту от CSRF при входе он даёт, если сервер авторизации поддерживает PKCE; в OIDC эту роль играет иnonce. - Нужен ли PKCE серверному приложению с секретом? — RFC 9700 его рекомендует: секрет не защищает от подмены кода в чужой сессии.
- Как работает device code? — Устройство показывает код и адрес, пользователь входит на телефоне, устройство опрашивает
/token. - Как сервису вызвать API от имени пользователя? — Обменять токен пользователя: token exchange (RFC 8693) или on-behalf-of.
Какие бывают токены и как управлять их жизнью?
Заголовок раздела «Какие бывают токены и как управлять их жизнью?»Что это
| Токен | Кому адресован | Зачем |
|---|---|---|
| Access | API | вызвать API |
| Refresh | серверу авторизации | получить новый access-токен |
| ID (OIDC) | клиенту | узнать, кто вошёл |
Как пользоваться
- Access-токен отправляют в заголовке
Authorization: Bearer .... Это bearer-токен: кто им владеет, тот и пользуется. - Когда он истекает, клиент отправляет refresh-токен на
/tokenи получает новую пару. - Refresh-токен выдают по scope
offline_access(OIDC) или по настройке клиента.
Частая ошибка
Выдавать access-токен на сутки, чтобы «не возиться с обновлением». Украденный токен будет работать сутки, и если это JWT, отозвать его нельзя.
Как устроено
- Scope — права клиента, а не пользователя:
orders.readзначит «клиент может читать заказы от имени пользователя». Итоговый доступ — пересечение прав пользователя и выданных scopes. - Audience (
aud) — для какого API токен. API обязан отклонить токен с чужой аудиторией, иначе токен одного сервиса подойдёт к другому. - Время жизни —
expires_inв ответе иexpв токене: минуты у access-токена, часы или дни у refresh-токена.
Когда что выбирать
| Признак | JWT | Непрозрачный |
|---|---|---|
| Проверка | локально, по подписи | introspection (RFC 7662) |
| Отзыв | только истечением срока | мгновенно |
| Нагрузка на сервер авторизации | нет | на каждый запрос или промах кеша |
Структура и проверка JWT — в теме JWT и защита API.
Ловушки
- Параллельное обновление. Две вкладки одновременно обновляют токен одним refresh-токеном: при ротации вторая получит
invalid_grant. Обновление на клиенте выполняют по одному. - Выход без отзыва. Удалить токены у себя недостаточно: refresh-токен действует, пока его не отозвали на revocation endpoint (RFC 7009).
Правило
Короткий access-токен с точной audience, refresh-токен с ротацией и отзыв при выходе.
Под капотом
- Ротация (RFC 9700): каждый ответ на
grant_type=refresh_tokenсодержит новый refresh-токен, а старый становится недействительным. Если старый придёт снова, сервер не знает, кто из двоих атакующий, и отзывает всю цепочку. - Отзыв (RFC 7009): отозвав refresh-токен, сервер должен отозвать и access-токены того же гранта, если умеет. Уже выданный JWT API продолжит принимать до
exp. - Introspection (RFC 7662): ответ
{"active": true}со scope, клиентом и сроком или{"active": false}для отозванного и истёкшего токена. - Привязка к отправителю. Refresh-токен конфиденциального клиента бесполезен без его секрета; публичному клиенту привязку даёт DPoP (RFC 9449).
Цена
- Короткий access-токен — регулярные обновления от каждого пользователя и нагрузка на сервер авторизации.
- Introspection добавляет сетевой вызов к запросам API, а кеш его результата снова откладывает отзыв.
Компромиссы
- Срок access-токена — окно, в котором отозванный доступ ещё работает.
- Срок refresh-токена задаёт, как часто пользователь входит заново: абсолютный или скользящий, продлеваемый при использовании.
- Новые права пользователя попадают в токен только при обновлении.
Как проверить в проде
- Рост
invalid_grantпри обновлении в логах сервера авторизации — гонки обновления или обнаруженный повтор. - Тестами проверяют, что API отклоняет истёкший токен и токен с чужой
aud.
Итог: короткий access-токен ограничивает ущерб, а управление сессией переносится на refresh-токен с ротацией, сроком и отзывом.
POST /token HTTP/1.1Host: login.example.comContent-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA&client_id=orders-spa{ "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9...", "token_type": "Bearer", "expires_in": 300, // 5 минут "refresh_token": "8xLOxBtZp8RmMKb7wWUcAw", // новый: прежний больше не действует "scope": "openid orders.read"}POST /revoke HTTP/1.1Host: login.example.comContent-Type: application/x-www-form-urlencoded
token=8xLOxBtZp8RmMKb7wWUcAw&token_type_hint=refresh_token&client_id=orders-spaАдреса /token, /revoke и /introspect у каждого провайдера свои: их берут из discovery-документа.
- Почему refresh-токен не отправляют в API? — Он адресован только серверу авторизации; утечка через API дала бы долгий доступ.
- Как отозвать JWT access-токен? — Никак до
exp: укорачивают срок, проверяют список отозванныхjtiили переходят на непрозрачные токены. - Что такое scope
offline_access? — Запрос refresh-токена в OIDC, чтобы обновлять доступ без пользователя. - Чем scope отличается от роли? — Scope — права клиента от имени пользователя, роль — права самого пользователя.
- Что будет при повторном использовании старого refresh-токена? — При ротации с обнаружением повтора сервер отзовёт всю цепочку.
Как подключить вход через OpenID Connect в ASP.NET Core?
Заголовок раздела «Как подключить вход через OpenID Connect в ASP.NET Core?»Что это
Вход через OIDC в ASP.NET Core — две схемы аутентификации, которые работают вместе:
- OpenID Connect (пакет
Microsoft.AspNetCore.Authentication.OpenIdConnect) ведёт пользователя к провайдеру и разбирает ответ; - Cookie запоминает вход между запросами.
Как пользоваться
- Зарегистрируйте приложение у провайдера:
client_id, секрет, redirect URIhttps://ваш-сайт/signin-oidc. - В
Program.csвызовитеAddAuthenticationсо схемой по умолчанию cookie и схемой challenge OIDC, затемAddCookie()иAddOpenIdConnect(...). - В
AddOpenIdConnectукажитеAuthority,ClientId,ClientSecretиResponseType = "code". - Защитите эндпоинты через
RequireAuthorization()или[Authorize].
Частая ошибка
Хранить ClientSecret в appsettings.json в репозитории. При разработке секрет кладут в user secrets, в проде — в хранилище секретов.
Как устроено
- Анонимный запрос к защищённому эндпоинту вызывает challenge схемы OIDC.
- Обработчик читает и кеширует discovery-документ провайдера и редиректит на
/authorizeсstate,nonceи PKCE. - Браузер возвращается с кодом POST-запросом на
/signin-oidc: по умолчаниюresponse_mode=form_post. - Обработчик меняет код на токены и проверяет у ID-токена подпись,
iss,aud, срок иnonce. - Cookie-схема выдаёт cookie, и браузер возвращается на исходный адрес.
Настройки по умолчанию
| Настройка | Умолчание | Когда менять |
|---|---|---|
ResponseType |
id_token |
всегда на code |
UsePkce |
true |
не трогать |
SaveTokens |
false |
нужен access-токен для API |
MapInboundClaims |
true |
нужны исходные имена claims |
GetClaimsFromUserInfoEndpoint |
false |
в ID-токене мало данных |
Ловушки
- Переименование claims. При
MapInboundClaims = trueclaimsubпревращается вhttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier, иFindFirst("sub")вернётnull. ПриfalseзадайтеNameClaimTypeиRoleClaimType. - Нет обновления токена. Обработчик не обновляет access-токен: по refresh-токену это делает ваш код или библиотека.
- Reverse proxy. Без forwarded headers redirect URI собирается со схемой
http, и провайдер его отвергает.
Правило
ResponseType = code, секрет вне репозитория, MapInboundClaims = false с явными NameClaimType и RoleClaimType.
Под капотом
state— зашифрованныеAuthenticationProperties: адрес возврата иcode_verifierуходят к провайдеру и возвращаются обратно. Correlation-cookie проверяет, что колбэк пришёл в тот же браузер.- Correlation- и nonce-cookie выставляются с
SameSite=None; Secure: колбэк приходит межсайтовым POST, и сLaxбраузер их не отправит. - Ключи подписи берутся из
jwks_uri; при незнакомомkidобработчик перечитывает метаданные (RefreshOnIssuerKeyNotFound). - PAR (.NET 9+,
PushedAuthorizationBehavior.UseIfAvailable): параметры уходят прямым POST, браузер получает толькоrequest_uri. Entra ID PAR не поддерживает.
Цена
SaveTokens = true кладёт токены в cookie: несколько килобайт, которые делятся на части и уходят с каждым запросом. SessionStore (ITicketStore) переносит сессию в кеш, в cookie остаётся ключ.
Компромиссы
- Срок сессии задаёт cookie (
ExpireTimeSpan), а не ID-токен. - Обновление access-токена пишут в
OnValidatePrincipalcookie-схемы или берут библиотеку вроде Duende.AccessTokenManagement. - Для Entra ID Microsoft рекомендует
Microsoft.Identity.Web; ADFS работает со стандартным обработчиком — подробнее в теме «AD, Kerberos и ADFS».
Как проверить в проде
Correlation failed— потерялась correlation-cookie: SameSite или HTTP за прокси.Unable to unprotect the message.State— колбэк попал на узел с другими ключами Data Protection.IdentityModelEventSource.ShowPII = true— только в Development: иначе детали ошибок валидации скрыты.
Итог: OIDC-обработчик — конечный автомат поверх cookie; надёжность входа решают ключи Data Protection, SameSite, прокси и размер сессии.
using Microsoft.AspNetCore.Authentication;using Microsoft.AspNetCore.Authentication.Cookies;using Microsoft.AspNetCore.Authentication.OpenIdConnect;using Microsoft.IdentityModel.Protocols.OpenIdConnect;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; // сессия options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; // вход }) .AddCookie() .AddOpenIdConnect(options => { options.Authority = "https://login.example.com"; // отсюда — discovery-документ options.ClientId = "orders-web"; options.ClientSecret = builder.Configuration["Oidc:ClientSecret"]; options.ResponseType = OpenIdConnectResponseType.Code; // по умолчанию id_token options.Scope.Add("orders.read"); // openid и profile уже есть options.SaveTokens = true; // токены — в cookie сессии options.MapInboundClaims = false; // sub остаётся sub options.TokenValidationParameters.NameClaimType = "name"; });builder.Services.AddAuthorization();
var app = builder.Build();app.UseAuthentication();app.UseAuthorization();
app.MapGet("/", async (HttpContext ctx) => new{ name = ctx.User.Identity?.Name, sub = ctx.User.FindFirst("sub")?.Value, hasAccessToken = await ctx.GetTokenAsync("access_token") is not null}).RequireAuthorization(); // анонимного пользователя отправит к провайдеру
// Выход из обеих схем: удалить cookie и завершить сессию у провайдераapp.MapGet("/logout", () => Results.SignOut(null, [CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]));
app.Run();- Зачем две схемы, если вход через OIDC? — OIDC-схема только выполняет вход, а помнить пользователя между запросами должна cookie.
- Включён ли PKCE по умолчанию? — Да,
UsePkce = true, но он работает только сResponseType = code. - Почему
User.FindFirst("sub")возвращаетnull? —MapInboundClaimsпереименовалsubвClaimTypes.NameIdentifier. - Где взять access-токен для вызова API? —
SaveTokens = trueиHttpContext.GetTokenAsync("access_token"). - Почему вход ломается за балансировщиком? — Нет forwarded headers или общих ключей Data Protection между узлами.
Как безопасно авторизовать SPA: токены в браузере или BFF?
Заголовок раздела «Как безопасно авторизовать SPA: токены в браузере или BFF?»Что это
У SPA нет надёжного места для секрета: весь её код и данные доступны в браузере. Подходов два:
- Токены в браузере. SPA сама проходит authorization code + PKCE, например через angular-oauth2-oidc, и отправляет access-токен в API.
- BFF (Backend for Frontend). Вход выполняет сервер приложения, токены остаются на сервере, а браузер получает только cookie.
Зачем
Если на страницу попал чужой скрипт (XSS), он прочитает токен из localStorage и отправит атакующему. HttpOnly-cookie скрипт прочитать не может.
Частая ошибка
Думать, что sessionStorage вместо localStorage решает проблему. Скрипт на странице читает и то и другое.
Как устроено: BFF
- SPA и BFF живут на одном сайте, например
app.example.comиapp.example.com/bff. - Вход — обычный OIDC-вход серверного приложения: BFF — конфиденциальный клиент с секретом.
- BFF выдаёт сессионную cookie:
HttpOnly,Secure,SameSiteи префикс__Host-. - SPA вызывает
/bff/...с cookie, а BFF берёт access-токен из сессии, при необходимости обновляет его и проксирует запрос в API.
Когда что выбирать
| Критерий | Токены в браузере | BFF |
|---|---|---|
| Кража токена при XSS | возможна | токенов в браузере нет |
| Инфраструктура | только статика | сервер и прокси для API |
| CSRF | к bearer-токену неприменим | нужна защита |
Ловушки
- Silent renew через iframe опирается на cookie провайдера в стороннем контексте. Safari блокирует сторонние cookie, Firefox изолирует их по сайтам, поэтому без BFF обновляют refresh-токеном. angular-oauth2-oidc в code flow так и делает, а iframe включается
useSilentRefresh: true. - Refresh-токен в браузере — самая ценная добыча. RFC 9700 требует для публичных клиентов ротацию или привязку к ключу.
- CSRF в BFF. Cookie прикладывается сама, поэтому эндпоинты BFF требуют свой заголовок, например
X-CSRF: 1: чужой сайт не отправит его без разрешения CORS.
Правило
Корпоративное приложение с персональными данными — BFF. Токены в браузере — когда сервера нет и риск приемлем: PKCE, короткие токены, ротация refresh-токенов.
Под капотом
- RFC 10017 разбирает атаку, против которой хранилище бессильно. Внедрённый скрипт открывает скрытый iframe с
/authorize?prompt=none, читает код из фрейма своего origin и меняет его на свежие токены. - DPoP (RFC 9449) привязывает токен к ключу клиента: каждый запрос подписан доказательством владения ключом. Неизвлекаемый ключ WebCrypto не даёт унести токен, но скрипт на странице может подписывать запросы сам или пройти новый вход со своим ключом.
- Token-mediating backend получает токены на сервере, а access-токен отдаёт браузеру: refresh-токен не утекает, access-токен может.
Цена
- BFF стоит на пути каждого вызова API: задержка прокси, своя доступность и масштабирование.
- Сессия с токенами раздувает cookie, а
SessionStoreтребует общего кеша между узлами.
Компромиссы
- Cookie возвращает CSRF, от которого bearer-токены были свободны: нужны SameSite и свой заголовок.
SameSite=StrictRFC 10017 рекомендует, но после межсайтового колбэка OIDC первый запрос может прийти без cookie. В ASP.NET Core обычно оставляютLax.- Готовые реализации: Duende BFF, YARP с подстановкой токена, пример Microsoft для Blazor Web App с OIDC.
Как объяснить решение команде
- Разобрать модель угроз: что получит атакующий при XSS в каждом варианте.
- Если ASP.NET Core уже отдаёт SPA, BFF обходится в несколько эндпоинтов и прокси.
Итог: XSS в SPA не лечится выбором хранилища; BFF не отдаёт токены браузеру и сужает ущерб до открытой вкладки.
using Microsoft.AspNetCore.Authentication;using Microsoft.AspNetCore.Authentication.Cookies;using Microsoft.AspNetCore.Authentication.OpenIdConnect;using Microsoft.IdentityModel.Protocols.OpenIdConnect;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie(options => { options.Cookie.Name = "__Host-orders"; // Secure, Path=/, без Domain options.Cookie.SecurePolicy = CookieSecurePolicy.Always; // HttpOnly включён по умолчанию options.Cookie.SameSite = SameSiteMode.Lax; // Strict ломает возврат после входа }) .AddOpenIdConnect(options => { options.Authority = "https://login.example.com"; options.ClientId = "orders-bff"; // конфиденциальный клиент options.ClientSecret = builder.Configuration["Oidc:ClientSecret"]; options.ResponseType = OpenIdConnectResponseType.Code; options.SaveTokens = true; // токены — в зашифрованной cookie, JavaScript их не видит });builder.Services.AddAuthorization();builder.Services.AddHttpClient("api", c => c.BaseAddress = new Uri("https://api.example.com"));
var app = builder.Build();
// SPA вызывает /bff/orders с cookie, BFF добавляет токен и идёт в APIapp.MapGet("/bff/orders", async (HttpContext ctx, IHttpClientFactory http) =>{ if (ctx.Request.Headers["X-CSRF"] != "1") return Results.BadRequest(); // защита от CSRF
using var request = new HttpRequestMessage(HttpMethod.Get, "/orders"); request.Headers.Authorization = new("Bearer", await ctx.GetTokenAsync("access_token")); using var response = await http.CreateClient("api").SendAsync(request); return Results.Text(await response.Content.ReadAsStringAsync(), "application/json", statusCode: (int)response.StatusCode);}).RequireAuthorization();
app.Run();import { AuthConfig, OAuthService } from 'angular-oauth2-oidc';
export const authConfig: AuthConfig = { issuer: 'https://login.example.com', clientId: 'orders-spa', // публичный клиент: секрета нет redirectUri: window.location.origin + '/index.html', responseType: 'code', // authorization code + PKCE scope: 'openid profile orders.read offline_access', // offline_access — за refresh-токеном};
export async function initAuth(oauth: OAuthService) { oauth.configure(authConfig); await oauth.loadDiscoveryDocumentAndLogin(); oauth.setupAutomaticSilentRefresh(); // обновит токен на 75% его срока}Токены здесь лежат в sessionStorage — хранилище библиотеки по умолчанию.
- Защищает ли HttpOnly-cookie от XSS? — От кражи сессии — да, от действий скрипта из открытой вкладки — нет.
- Почему silent renew перестал работать в Safari? — Iframe провайдера — сторонний контекст, а Safari блокирует сторонние cookie.
- Чем BFF отличается от token-mediating backend? — Во втором access-токен всё-таки попадает в браузер.
- Как BFF защищается от CSRF? — SameSite-cookie плюс обязательный свой заголовок, который чужой сайт не отправит без CORS.
- Что даёт DPoP в SPA? — Украденный токен бесполезен без ключа, но скрипт на странице может пользоваться ключом сам.