Дисклеймер. Это не универсальная рекомендация для всех. Это итог эволюции требований конкретного продукта, но общие подходы вполне применимы и в других проектах.

Когда открываешь типичный туториал по OAuth, чаще всего это пересказ спецификации, и очень немногие раскрывают три ключевых вопроса:

  1. «Кто ты?» — аутентификация, то есть проверка того, что вы — это вы. Почти всегда делегируется внешнему IdP (Google, GitHub и аналогичным сервисам).

  2. «Что ты у нас за пользователь?» — профиль приложения (ваш id, роль, отображаемое имя), который относится к домену, а не к личности.

  3. «Что можно клиенту от твоего имени?» — делегированная авторизация. Именно на этот вопрос отвечает OAuth, где aud и scope описывают объём полномочий, который вы выдали приложению, действующему от вашего лица.

Третий пункт стоит проговорить отдельно, потому что его чаще всего понимают неверно. scope — это не «что разрешено вам», а «что вы разрешили приложению делать от вашего имени». Всё, что разрешено лично вам (роль, ACL, права на конкретную запись), живёт в профиле и проверяется уже на ресурсе. Итоговое право представляет собой пересечение двух множеств: делегировано клиенту и разрешено пользователю. Дальше это разделение станет ключевым: два множества хранятся в разных местах и приезжают на ресурс разными путями, одно в scope, другое в профильных claim'ах.

Все три вопроса обычно объединяют в одну зону ответственности. Пока бэкенд один, это работает и никто не жалуется, но туториалы почти никогда не показывают, как спецификация ложится на реальный e2e‑сценарий.

Дальше описана история о том, как в нашем случае типичный сценарий перестал работать и к какой архитектуре мы в итоге пришли, проведя путь целиком от кнопки «Войти» до логаута.

Наш Authorization Server не хранит профиль пользователя. Он знает про вас ровно то, что передал IdP: ни id, ни роли, ни даже факта регистрации у нас в системе. Это решение, которое на первый взгляд может показаться незавершённым, оказывается ключевой несущей конструкцией.

Содержание:

  1. Рамки требований

  2. Участники системы

  3. Вход: PKCE и федерация

  4. Профиль: почему его знает не authority

  5. Токены в Service Worker

  6. Ресурсы: verify‑only и обогащение токена

  7. Экран проекта: token‑exchange

  8. 401: восстановление сессии

  9. Логаут: три действия

  10. Модель угроз

  11. А поехал бы тут Keycloak?

  12. То же самое, но для обычного продукта

Рамки требований

Всё решение выросло из трёх ограничений, которые являются исходными условиями, и почти каждое последующее решение станет прямым следствием одного из них.

Первое: сотни изолированных API, создаваемых в рантайме. Продукт устроен так, что пользователь на лету заводит проекты, и каждый проект — это отдельный сервис со своим адресом, в неограниченном количестве.

Второе: публичный браузерный клиент без BFF. Используется SPA, за которым не стоит BFF, способный держать сессию и refresh в httpOnly‑cookie. Значит, безопасного места для bearer‑токена у клиента физически нет, и всё, что видит JS страницы, достаётся первым же XSS.

Почему не BFF

Мы от него отказались из‑за гонок запросов при рефреше, необходимости синхронизации сессий и состояния, а также появления ещё одной единой точки отказа. Плюс всё это нужно тестировать и встраивать в CI/CD. Service Worker выигрывает тем, что делает то же самое непосредственно в браузере пользователя: покрывается теми же юнит‑тестами, а e2e‑тесты поверх него писать заметно проще.

Третье: ресурсы не могут ходить в AS на каждый запрос. Когда динамических API сотни, синхронная проверка токена в центральном сервисе создаёт латентность на горячем пути и превращается в единую точку отказа, поэтому проверка должна быть локальной.

Три пункта выше являются внешними условиями,они нам достались, и спорить с ними бесполезно, а топология стала уже нашим решением и их следствием. Зафиксируем используемую терминологию:

  • платформенный тенант — одно OAuth‑пространство, где лежат Main API (продуктовый бэкенд), Management API (API самого authority: клиенты и ключи) и UI‑клиент (SPA);

  • динамические тенанты — заводятся на лету. В этой модели тенант эквивалентен самому пользователю, а его проекты являются аудиториями внутри его тенанта, при этом уникальный адрес пользовательского проекта и есть одна из аудиторий пользователя.

ам Authorization Server (далее — AS) при этом намеренно сделан простым. Термин «простой» в данном контексте обозначает строгую границу ответственности: AS отвечает только за свой домен и ничего не знает про внешние системы, продуктовое окружение, профили или содержимое за токеном. Весь его мир ограничен следующим списком: клиенты, тенанты, гранты, аудитории, скоупы, ключи, сессии, выдача и отзыв токенов, discovery и JWKS. Всё функционирует на Redis, без реляционной БД и без админки. Всё, что нужно для старта, засеивается сидами, динамические данные (клиенты, аудитории, scope) бэкапятся, а токены удаляются по TTL.

Участники системы

Справочник, к которому можно возвращаться. Главное — держать в голове, кто что хранит и, что важнее, чего НЕ хранит.

  • Публичный клиент (SPA/SSR) — отслеживает только статус авторизации пользователя и о главном токене не знает вообще.

  • RxDB — локальная база в браузере, offline‑first, реплицируется с Main API. В ней лежит профиль пользователя, и наличие профиля определяет ответ на вопрос «залогинен ли». RxDB обслуживает только основное приложение, а экраны проектов в ней не участвуют и ходят в свои Project API напрямую (механика описана в разделе про token‑exchange).

  • Service Worker — невидимый посредник, который держит токены в своей памяти, перехватывает fetch и сам подвешивает Authorization, являясь единым на весь origin.

  • AS — простой issuer, обеспечивающий федерацию к IdP, сессии в Redis, token‑endpoint, JWKS, кастомные гранты и FedCM‑грант (серверную половину FedCM с эндпоинтами accounts и assertion). В тексте разграничиваются нативный FedCM (функциональность браузера) и FedCM‑грант (реализация на AS). Про пользователя AS знает только IdP‑личность и профиль не хранит.

  • Management API — API самого AS: клиенты, ротация ключей, подпись actor‑token'а.

  • Main API — владелец профиля (id, роль, имя, регистрация, обогащение). Токены не выдаёт, только проверяет.

  • Служебный клиент — m2m‑клиент внутри платформенного тенанта, от имени администратора происходит работа с Management API.

  • Project API (их много) — сгенерированные ресурсы, каждый со своей аудиторией, которые тоже только проверяют токены.

  • IdP — Google/GitHub, то есть внешний источник личности.

Вход: PKCE, федерация и sub, который пока ничего не значит

Человек открывает браузер впервые. Система про него ничего не знает, и первая задача — выяснить, кто это.

Как это обычно делается. Заводят таблицу users, кладут туда логин и хеш пароля, проверяя их при входе. Это первое, что делает любой собственный модуль авторизации, и это же становится причиной последующих сложностей: хранение паролей, сброс, защиты от брутфорса, утечки, 2FA, а через некоторое время появляется необходимость в интеграции «войти через Google».

Почему мы решили сделать иначе. Проверка личности не входит в нашу компетенцию и риски, поэтому лучше доверить её специализированным провайдерам. Личность у нас проверяет не AS, а IdP: AS через federated‑логин отправляет пользователя к Google или GitHub, а на выходе получает provider, profileId, email. Эти данные сохраняются в сессию Redis, что составляет максимум сведений, доступных AS о пользователе. Пароли у нас отсутствуют, а если они понадобятся, то будут вынесены в аналогичный IdP внутри нашего SaaS.

Сам вход представляет собой классический OAuth2 Authorization Code + PKCE.

PKCE применяется из‑за публичного клиента (второе ограничение). Спрятать client_secret в SPA невозможно: всё, что попало в браузер, перестаёт быть секретом. Поэтому вместо секрета клиент генерирует случайный code_verifier, на authorize отправляет только его хеш (code_challenge), а при обмене кода на токен предъявляет сам verifier. Перехваченный по дороге код без verifier бесполезен. Параметр state на callback валидируется, а IdP‑state сделан одноразовым, что предотвращает CSRF на возврате, реализуя спецификацию RFC 7636.

Что оказывается в токене. AS выдаёт короткий access‑токен и refresh‑токен. Внутри access‑токена находятся:

  • стандартные claim'ы: cid, scope, exp, iat, jti, sub;

  • claim'ы приложения: aud, tenant, iss.

В связке aud и scope аудитории первичны и принадлежат тенанту. Клиенты создают внутри тенанта и прикрепляют ему аудитории, выбранные из тенантских. Scope всегда создаётся в рамках аудитории по шаблону {aud}/{ресурс}:{действие}, например main-api.example.com/*. Фактически aud в токене берётся из scope клиента, поэтому aud‑claim представляет собой аудитории, чьи scope попали в токен.

Важная деталь: sub на данном этапе содержит provider:profileId, то есть IdP‑личность без внутреннего id и ролей. Система фиксирует факт аутентификации человека, не сопоставляя его с внутренним пользователем.

Профиль: почему его знает не AS, а продуктовый бэкенд

После подтверждения личности необходим профиль: внутренний id, роль и имя.

Прямолинейный подход. Сохранить эти поля в AS, который уже аутентифицировал человека, у него уже есть запись — ну добавь туда role и displayName, делов‑то. Именно так устроено большинство готовых IdP, где authority владеет профилем.

Недостатки прямолинейного подхода. Когда AS начинает хранить роли и профили, он перестаёт быть простым issuer'ом и становится частью продуктового домена. Добавление поля в профиль или изменение модели ролей требует релиза AS, а компрометация AS влечёт утечку профилей всех пользователей.

Реализация в нашей системе. Профиль хранится в Main API, в его собственной базе, а AS не знает о нём ничего. Два сервиса и две базы работают без общего хранилища.

Связывает их контракт по sub:

  • sub = provider:profileId — человек авторизован через IdP, но как пользователь ещё не сопоставлен (или не зарегистрирован);

  • sub = UUID — токен обогащён: sub содержит внутренний id, а плоские claim'ы передают профиль.

Ресурс, получив токен, по sub находит пользователя у себя: при provider:profileId — по связке провайдера и profileId, а при UUID — напрямую по id.

Выигрыш от такого разнесения:

  • маленькая область поражения (blast radius). При компрометации AS профили не утекают, поскольку их там нет, а при компрометации базы профилей аутентификация остаётся нетронутой, так как учётные данные хранятся у IdP;

  • AS остаётся заменяемым. Сервер ничего не знает про домен, поэтому его можно переписать или заменить без изменения продукта;

  • профиль эволюционирует свободно. Новое поле требует релиза Main API, а не authority.

Побочным эффектом является то, что персональные данные не скапливаются в одной общей точке. У AS нет таблицы пользователей, кроме эфемерной сессии с IdP‑личностью, а остальные данные хранятся в ресурсном слое. Для платформы, через которую проходят пользователи разных тенантов, это упрощает распределение ответственности за хранение данных.

Влияние на комплаенс

Компонент без персональных данных проще в обслуживании: его не требуется включать в инвентаризацию, выгружать данные по запросу субъекта или удалять их по требованию. Поскольку данные лежат в сервисах, где работает продуктовая логика, удаление пользователя выполняется локально, без поиска копий по всей системе.

Минимизация данных не отменяет юридических обязательств, но уменьшает поверхность атаки и делает границы ответственности более чёткими.

Практически это выглядит так: сразу после обмена кода приложение обращается в Main API за профилем по эндпоинту me-from-idp (если пользователь зарегистрирован) или register-from-idp (при первом входе), где личность связывается с профилем.

Затем приложение сохраняет профиль в RxDB и удаляет токен. Исходный access‑токен был нужен только для одного запроса. Уходя с экрана входа, приложение отзывает его и больше не касается токенов напрямую.

Токены в Service Worker — и откуда воркер берёт первый

Когда пользователь залогинен и работает в приложении, каждое действие отправляет запрос в API, требующий bearer‑токен.

В моем опыте 80% SPA используют для этого localStorage, но данные в localStorage, cookie без httpOnly или в памяти страницы могут быть получены при XSS‑атаке. Токен, к которому имеет доступ JS страницы, можно считать сразу как скомпрометированным. А как много разработчиков просматривают все npm зависимсости?:). Собственный бэкенд для хранения токена в httpOnly‑cookie отсутствует из‑за второго ограничения.

Предотвращение гонки вкладок

Это уже не про безопасность, а следствие использования localStorage. При открытии нескольких вкладок получение ответа 401 приводит к параллельным запросам на обновление токена по refresh. Первая вкладка обновляет запись, а вторая получает отказ из‑за ротации refresh‑токена. В результате не успевшая вкладка перенаправляется на экран входа.

Для решения этой проблемы потребовались бы блокировки поверх общего хранилища, дедупликация in‑flight запросов и синхронизация черезBroadcastChannel. И все это покрыть тестами.

Реализация. Токен живёт в памяти Service Worker'а в отдельном контексте, не доступном из страницы. SW перехватывает fetch и автоматически подвешивает Authorization, поэтому приложение основной токен не хранит и не запрашивает.

SW является единым на весь origin. Сколько бы вкладок ни открыл пользователь, обновление токена выполняется централизованно в одном процессе, что исключает состояние гонки.

Именно поэтому приложение после входа отзывает токен, соблюдая данное правило.

Откуда SW берёт токен?

Приложение токен удалило и SW ничего не передавало, однако SW добывает токен самостоятельно через FedCM‑грант на AS:

  1. SW обращается к эндпоинту accounts сервера AS, отправляя cookie сессии.

  2. Сверяет живую сессию с профилем из RxDB для подтверждения личности.

  3. При совпадении запрашивает assertion и получает authorization code.

  4. Обменивает код на токен через authorization_code.

Важно, что assertion возвращает authorization code, а не сам токен, благодаря чему кастомный процесс встраивается в стандартный обмен с PKCE.

Ограничения нативного FedCM

Нативный FedCM браузера из Service Worker недоступен, так как navigator.credentials.get({identity}) работает только в контексте основного документа.

Поэтому протокол был расширен: SW выполняет запросы к эндпоинтам accounts и assertion обычным fetch с заголовком X-Grant-Type: fedcm. У браузерного варианта используется маркер Sec-Fetch-Dest: webidentity, по которому AS разграничивает типы запросов.

При вызове из воркера браузер не медиирует origin, поэтому AS самостоятельно проверяет заголовок Origin и сверяет его с зарегистрированным callback‑URL клиента (URL(redirect_uri).origin == Origin).

Связать заголовок Origin и cookie сессии необходимо, поскольку cookie передается браузером только на same‑site запросы.

Жёсткое ограничение на деплой: AS обязан находиться на сабдомене приложения. Чтобы fetch из SW передавал cookie сессии на AS, эта cookie должна быть same‑site, то есть AS размещается на сабдомене основного домена приложения.

Параметры cookie (HttpOnly, Secure, host‑only без Domain, SameSite) должны быть корректно настроены для работы этой архитектуры.

Адаптация для кастомных доменов (white‑label)

При использовании кастомных доменов клиентов (app.клиент.com) клиент создаёт CNAME‑запись на AS, после чего автоматически выпускается SSL‑сертификат (ACME) на его домен. Cookie остаётся same‑site, Origin сверяется с callback‑URL, а протокол не меняется.

Это требует настройки автоматической ротации сертификатов для каждого клиентского домена на инфраструктурном уровне.

На этом же механизме основано получение токена при частичном SSR‑рендеринге, когда серверам доступна cookie сессии на том же домене.

Нюансы использования FedCM и нескольких окружений

Спецификация FedCM требует, чтобы манифест identity‑провайдера (/.well-known/web-identity) запрашивался строго с корневого (eTLD+1) домена сервера авторизации. Для мультистендовой разработки это создаёт особенности:

Как мы к этому пришли

Хронологически всё было обратно тому, как я это изложил. Сначала мы сделали FedCM‑грант под нативный сценарий: браузер медиирует, страница дёргает navigator.credentials.get, AS отвечает по протоколу. Грант нужен был в любом случае — это серверная половина FedCM, без неё и нативный вариант не работает.

А дальше уперлись не в протокол, с ним как раз всё хорошо, а в инфраструктуру вокруг него. Цена поддержки нескольких окружений оказалась такой, что расширить уже написанный грант под вызов из Service Worker'а вышло дешевле, чем содержать корневой манифест на все стенды. Так что воркер здесь не только потому, что токену негде лежать: ограничения нативного FedCM толкали ровно в ту же сторону.

  • привязка к корневому домену: корневой домен (domain.com) должен отдавать конфигурацию, известную для всех dev‑ и staging‑окружений;

  • конфликт конфигураций: изоляция тестовых стендов усложняется из‑за единого корневого манифеста;

  • сложность автоматизации: разработка на localhost или динамических ветках требует проксирования корневого манифеста.

Ресурсы: verify‑only — и как в токен попадает то, чего AS не знает

Когда токен находится у SW и добавляется к запросу в Main API, ресурс выполняет проверку локально.

Использование introspection‑эндпоинта. Синхронный запрос к authority при каждом обращении к сотням динамических Project API создаёт латентность на горячем пути и делает AS единой точкой отказа.

Verify‑only режим. Ресурсы проверяют подпись локально по JWKS и сверяют claim'ы без сетевых обращений в authority, что позволяет создавать множество независимых Project API.

verify_request(req, resource, action):              # любой resource server, verify-only
    token  = bearer(req)
    claims = jwks_verify(token)                      # подпись по JWKS; alg прибит к ключу
    assert CURRENT_AUD in claims.aud                 # RFC 9068: audience confinement
    assert claims.tenant == TENANT_NAME
    assert has_scope(claims, CURRENT_AUD + "/" + resource + ":" + action)
    user = resolve_user(claims.sub)                  # provider:profileId | UUID

Ключевые проверки:

  • Подпись (JWKS). Ресурс загружает публичный ключ AS и проверяет подпись с алгоритмом, привязанным к ключу для защиты от alg‑confusion.

  • aud (RFC 9068). Токен обязан содержать аудиторию текущего сервиса, закрывая уязвимость доверия заявленным аудиториям.

  • Scope. Проверяются разрешения для конкретной аудитории.

  • Tenant и sub. Проверяется принадлежность тенанту и сопоставление пользователя по sub.

Проверенный блок подтверждает делегированные полномочия клиента. Проверка прав пользователя (ACL поверх профиля) выполняется на следующем шаге.

Решение задачи отсутствия профиля у authority

Для работы ресурса в режиме stateless профильные claim'ы (id, роль) должны находиться непосредственно в токенe, иначе ACL‑слою потребуется обращаться в базу пользователей.

Поскольку AS не хранит данные профиля, используется механизм криптографического обогащения токена: владелец профиля подписывает его в короткоживущий JWT (actor_token) и передаёт AS вместе с текущим токеном. AS проверяет подпись, вкладывает содержимое в новый токен и меняет sub на UUID.

Service Worker на клиенте выполняет этот процесс после получения базового токена:

  1. SW имеет базовый токен с sub = provider:profileId.

  2. SW обращается в Main API на эндпоинт обогащения, передавая code_challenge.

  3. Main API через служебный m2m‑клиент просит Management API подписать actor_token с данными профиля (id, email, имя, роль).

  4. SW отправляет на AS грант custom:profile-enrichment: subject_token + actor_token + code_verifier.

  5. AS выполняет проверки, формирует новый токен и отзывает старый.

grant profile-enrichment(subject_token, actor_token, code_verifier):
    assert token_type(subject_token) == access_token
    old   = verify_and_load(subject_token)          # подпись + не expired/не revoked
    actor = verify(actor_token)                     # подписан Management API
    assert sha256(code_verifier) == actor.code_challenge   # PKCE proof-of-possession
    assert tenant_of(old.cid) == tenant_of(actor.cid)      # оба клиента в одном тенанте

    scopes     = refinalize(old.scopes)             # consent уже дан
    new        = issue_access(scopes, user = old.user)
    new.claims = app_claims(client, old.user) + actor.profile   # профиль сверху → sub=UUID
    refresh    = issue_refresh(new)
    revoke(old)                                     # ANTI-REPLAY
    return { access: new, refresh }

Меры защиты внутри гранта:

  • anti‑replay: старый subject_token отзывается, исключая повторный обмен;

  • proof‑of‑possession (PKCE): actor_token привязан к code_challenge;

  • ограничение по времени: короткий TTL для actor_token;

  • сверка тенанта: проверки подтверждают принадлежность клиентов единому тенанту. Ключи общие внутри тенанта, и профиль, подписанный в чужом, здесь не валиден по определению.

На последнем пункте стоит задержаться, потому что из него следует больше, чем кажется. В гранте не зашито «только платформенный тенант» — там именно сверка на совпадение. То есть обогащение не привилегия платформы, а общая возможность модели: заведи Project API у себя такой же эндпоинт подписи — и клиенты внутри тенанта пользователя смогут проделать ровно то же самое со своим профилем. Пока это теоретическая ветка, но закрыта она отсутствием повода, а не архитектурой.

AS не сохраняет профиль, а лишь проверят подпись и вкладывает данные, заменяя sub на UUID для сопоставления пользователя ресурсом.

В режиме verify‑only отзыв access‑токенов ограничен сроком их жизни (exp), поэтому устанавливаются короткие интервалы (около 15 минут) или при необходимости используется проверка через introspection. У нас есть фича‑флаг, который добавляет один шаг‑проверку через introspection‑эндпоинт: платим небольшой деградацией запросов, зато окно схлопывается.

Экран проекта: token‑exchange вместо мастер‑ключа

При переходе на экран проекта требуется доступ к его API в рамках динамического тенанта.

Включение всех аудиторий проектов в единый токен превращает его в мастер‑ключ и приводит к проблеме aud confusion (confused deputy).

Вместо этого доступ к проекту оформляется через отдельный производный токен методом token‑exchange (RFC 8693): основной токен обменивается на токен с aud = projX и scope = projX/*. Этот токен не содержит refresh, а исходный токен не отзывается.

grant project-token(subject_token, audience?):
    old     = verify_and_load(subject_token)
    allowed = tenant.audiences
    auds    = audience ? intersect(audience, allowed) : allowed
    assert auds is not empty                        # иначе access_denied

    new        = issue_access(scopes = [], user = old.user)   # НЕТ refresh
    new.claims = app_claims(client, old.user) + profile_claims(old)  # наследуем профиль, если он был
    new.claims.scope = join(map(auds, a => a + "/*"))
    new.claims.aud   = auds
    # original НЕ revoke → основной токен продолжает работать
    return { access: new, scope, aud }

Производный токен наследует параметры профиля (sub и claim'ы) из исходного токена, а область полномочий ограничивается выбранной аудиторией.

Обмен инициируется владельцем тенанта, а фильтр аудиторий ограничивает доступ к чужим проектам.

Взаимодействие на стороне клиента

  • инициатор: экран запрашивает обмен токена;

  • SW подставляет основной токен в качестве subject_token;

  • результат передаётся в приложение и сохраняется в состоянии экрана (app‑held);

  • при закрытии экрана токен удаляется;

  • запросы к Project API подписываются приложением самостоятельно, а SW пропускает их благодаря установленному заголовку Authorization.

Характеристики токенов:

Токен

aud

scope

refresh

как попадает на запрос

основной

main-api.example.com, mgmt-api.example.com

main-api.example.com/* mgmt-api.example.com/clients:*

есть

вешает SW по совпадению хоста

project‑token

project-n.example.com

project-<id>.example.com/*

нет

вешает приложение само

Обратите внимание на строку основного токена: scope на Management API урезанный — clients:*, а не полный. Та самая граница привилегий, которая задаётся сидами на уровне сервиса.

401: тот же механизм, другой повод

При получении ответа 401 задействуется та же цепочка восстановления сессии: попытка обновиться по refresh, а при необходимости — повторный запуск процедуры получения и обогащения токена.

Сверка живой сессии с профилем из RxDB подтверждает соответствие пользователя активной сессии на AS и предотвращает выдачу токена чужого аккаунта.

Если сверка не прошла, SW возвращает статус 401, вызывая сброс профиля и перенаправление на экран входа.

Режимы работы Service Worker'а:

  • запрос к аудитории основного токена → добавление основного Bearer‑токена;

  • запрос обмена токенов → подстановка основного токена как subject_token;

  • запрос с готовым токеном → пропуск без изменений.

Основное приложение взаимодействует только с локальным состоянием RxDB, а фоновая синхронизация выполняется транспортным слоем через SW.

Логаут: три действия, а не одно

Процесс выхода из системы состоит из трёх согласованных действий:

  1. Сброс профиля в RxDB (null), что синхронизирует состояние авторизации во всех открытых вкладках.

  2. Очистка токенов из памяти Service Worker.

  3. Отзыв токенов на AS (Revocation, RFC 7009) через обращение к revocation_endpoint.

Сессия на AS сохраняется для ускорения повторного входа, а очистка токенов в SW делает продолжение работы невозможным без авторизации.

Приложение отправляет HTTP‑запрос на revocation_endpoint, который перехватывается SW по URL.

Модель угроз: собираем защиту воедино

Угроза

Что защищает

Кража bearer через XSS

основной токен в памяти SW, из JS страницы недостижим

Перехват authorization code

PKCE (S256), публичный клиент без секрета

CSRF на callback

валидация state, одноразовый IdP‑state

Aud confusion / confused deputy

aud‑скоупинг, сверка хоста на клиенте, aud‑guard (RFC 9068)

Alg confusion в JWT

алгоритм прибит к ключу при verify

Replay обмена на обогащение

revoke старого токена, PKCE‑биндинг, короткий TTL actor_token

Подделка профиля в токене

actor_token подписан Management API, AS проверяет подпись

Попытка выпросить чужую аудиторию

фильтр аудиторий: только аудитории своего тенанта

Компрометация AS

профиля там нет → профили не утекают; AS заменяем

Компрометация базы профилей

IdP‑креды хранятся не там (федерация) → вход не скомпрометирован

Токен под чужую живую сессию

сверка живой сессии с профилем из RxDB

Подмена инициатора FedCM‑запроса

AS сам проверяет Origin против зарегистрированного callback‑URL клиента

Вечный доступ по утёкшему project‑token

нет refresh, короткий TTL, привязка к жизни экрана

Ограничения модели:

  • Отзыв access‑токена не является мгновенным при режиме verify‑only.

  • SW защищает от уноса токена, но не от вызовов запросов вредоносным скриптом в рамках активной страницы.

  • project‑token находится в памяти приложения.

  • AS обязан размещаться на сабдомене приложения.

  • Безопасность зависит от корректности реализации Service Worker'а.

А поехал бы тут Keycloak?

Сразу оговорюсь: это не рассказ о том, как мы сравнили варианты и выбрали свой. Мы не сравнивали — я просто не знал про Keycloak, когда всё это строилось. Дальше ретроспектива: прикладываю чужое решение к уже сформулированным требованиям и смотрю, где жмёт. Зато аргументы теперь можно проверить по тексту выше, а не принять на веру.

Начну с того, что Keycloak умеет, потому что умеет он много: федерация к Google и GitHub, реалмы, клиенты, скоупы, JWKS, discovery, админка, ротация ключей. Половину этой статьи не пришлось бы писать. И расширяется он неплохо: свои REST‑эндпоинты добавляются через RealmResourceProvider, claim'ы — мапперами, логика входа — аутентификаторами. Так что аргумент «он не гибкий» я приводить не буду.

Жмёт в трёх местах.

Кастомные гранты. Оба ключевых механизма статьи — это grant_type, которого нет в спецификации. Ещё недавно здесь была бы стена: грант‑типы жили захардкоженными в классе TokenEndpoint. Но в Keycloak 24 появился SPI грант‑типов — интерфейс org.keycloak.protocol.oidc.grants.OAuth2GrantType, — и свой грант к штатному token‑endpoint добавить можно. Стены нет, есть цена: Java‑расширение, прибитое к внутренностям сервера, которое надо пересобирать и перепроверять на каждом апгрейде Keycloak.

Владение профилем. У готового IdP есть user‑store, и это его центр тяжести: вокруг него построены админка, мапперы, половина фич. Вся наша конструкция стоит на обратном — authority без профиля, профиль в ресурсе. Пришлось бы либо держать вторую копию профиля, либо признать, что authority теперь часть домена. Оба варианта отменяют главный тезис.

Темп и размер. Тенанты как OAuth‑пространства, аудитории которых создаются в рантайме пачками, это не то, под что затачивалась реалм‑модель. Плюс требование, которого нет в списке ограничений, но которое определяло всё: issuer должен быть крошечным, целиком на Redis, без SQL и без админки.

И как итог: стены нет ни в одном из трёх пунктов. Есть цена — расширение на внутренностях token‑endpoint, вторая копия профиля или сцепка authority с доменом, и реалм‑модель, работающая не в том темпе. Keycloak под эти требования согнуть можно. Вопрос лишь в том, вышло бы это дешевле, чем написать issuer, вся роль которого умещается в десяток строк псевдокода. У меня ответ отрицательный, но у команды, где Keycloak уже стоит и его умеют эксплуатировать, он вполне может быть обратным.

А теперь то же самое, но для обычного продукта

Всё выше — про сотни динамических API и SPA без бэкенда. Экзотика. Но вырос этот паттерн из проблемы куда более скучной и распространённой — «микросервисы, которые не микросервисы».

Симптом знакомый. Сервисов пять, репозиториев пять, деплоятся отдельно, всё как в книжке. А потом открываешь конфиг и видишь, что все пятеро ходят в одну базу. И самая частая причина этого — авторизация. Каждому сервису нужно знать, кто пришёл и что ему можно. Значит, каждому нужен JOIN с таблицей users. Значит, у каждого коннекшн к общей базе. Значит, независимости нет: миграция users блокирует пять команд, а новый сервис начинается с фразы «дай доступ к основной базе». Если у вас есть сервис, которому от пользователя нужны только id и роль, а он держит подключение к чужой базе — диагноз этот.

Лечение — actor_token. Он доставляет сервису id и роль криптографически, а не через JOIN: сервис проверяет подпись по JWKS, читает claim'ы и работает. Коннекшн к общей базе больше не нужен, и чужие миграции его больше не касаются — пока JWKS на месте.

Как это ложится на нормальную архитектуру

Наш Main API — концептуальное упрощение. В его роли явно сидят два разных сервиса:

  • Владелец профиля (User / Identity Service). Знает, какому provider:profileId соответствует какой UUID и какие у человека роли. Больше ничего. Он подписывает actor_token.

  • Продуктовые сервисы (биллинг, проекты, аналитика). Профиль не хранят вообще. Получают токен, проверяют подпись, читают UUID и роль из claim'ов.

Дальше — вопрос масштаба. Монолиту с парой сателлитов хватит первого приёма: монолит остаётся владельцем профиля, сателлиты становятся verify‑only и перестают ходить в общую базу за пользователем. Настоящим микросервисам с общей users нужен весь набор — выделить владельца профиля, ввести sub‑контракт, раздать каждому свою aud; общая база после этого разъезжается, потому что последняя причина её держать исчезла. А появятся внешние интеграции, публичное API или партнёрский доступ — добавляется второй грант, token‑exchange на узкую аудиторию. У нас это «проекты», у вас может быть «партнёр», «webhook‑consumer» или «мобильное приложение».

Приём

Кому подойдёт

Цена

Authority без профиля

почти всем, у кого больше одного сервиса

нужен sub‑контракт и дисциплина

Actor‑token enrichment

всем, у кого сервисы делят users

нужен кастомный грант — влияние на issuer

Token‑exchange по aud

мультитенант, интеграции, публичное API

ещё один грант плюс aud‑гварды везде

Токены в Service Worker

только публичный браузерный клиент без BFF

AS обязан быть на сабдомене

Последняя строка — самая случайная во всей статье: она выросла из одного ограничения, SPA без своего бэкенда. Есть BFF — держите refresh в httpOnly‑cookie на нём, и весь раздел про SW и FedCM вам не нужен. На первые три приёма это не влияет.

А вторая строка — про вмешательство в issuer. Свой issuer: вы видели, сколько там работы. Чужой: смотрите, что у него с точками расширения, и считайте цену обязательства — в том же Keycloak это Java‑расширение, живущее в вашем релизном цикле. Форма решения переносится всегда, рецепт — не везде.


И напоследок — мораль, которая, пожалуй, важнее технических деталей. Про Keycloak я, повторюсь, тогда не знал. Так вот: иногда именно незнание «правильного» готового инструмента заставляет по‑настоящему разобраться в задаче. Будь у меня соблазн взять коробочное решение и крутить всё вокруг него, я бы к разделению «кто ты / что ты за пользователь / куда тебе можно» не пришёл — а оно оказалось и проще, и безопаснее.

Я не призываю переписывать всё своё. Но понимать, какую задачу на самом деле решает ваш auth: кто проверяет личность, кто владеет профилем, кто решает про доступ. Тогда и с Keycloak, и без него выбор будет осознанным, а не унаследованным от первого попавшегося туториала.

P. S. Ладно, признаюсь: не проще. Пока мы танцевали с SW, мы поняли смысл шутки — «поставил service worker, считай, купил новый домен». Воркер живёт в браузере пользователя и переживает перезагрузки. Стоит один раз выкатить в нём баг и все, кто успел его установить, больше не могут открыть твой сайт: битый воркер намертво засел и перехватывает всё подряд. Так что нужны жёсткое версионирование SW и emergency kill‑switch на случай, если какая‑то версия оказалась битой. Итог: безопаснее — определённо. А «проще»… ну, вы поняли.