Вы нажимаете «Войти», приложение вдруг уносит вас на чужую страницу с логотипом Keycloak, вы вводите там пароль — и оказываетесь обратно в приложении уже залогиненным. Между этими двумя кликами происходит целая цепочка обменов, и если не знать, что там внутри, всё выглядит как магия. Эта магия называется Authorization Code Flow, и сейчас мы разберём её медленно, по одному шагу, объясняя на каждом шаге не только «что происходит», но и «зачем именно так».
Что на схеме: три способа доставить доступ в приложение — что при каждом достаётся тому, кто подсмотрел адресную строку, и чем от него отличается законный обмен.
Перехваченный код сам по себе меняется на токены, пока публичному клиенту нечем доказать, что он тот самый. PKCE даёт это доказательство: verifier рождается внутри приложения и уходит только прямым запросом на /token — без него украденный код бесполезен.
Почему приложение не спрашивает пароль само
Начнём с самого первого вопроса, который обычно никто не задаёт вслух: почему приложение не показывает свою собственную форму логина и не спрашивает пароль прямо у себя? Так ведь было бы проще — одна форма, никаких перенаправлений.
Проблема в том, что тогда приложение увидит ваш пароль. А приложений, в которые вы заходите одним аккаунтом, может быть десяток. Доверить настоящий пароль каждому из них — значит размножить его по всем этим серверам. Достаточно взломать один из них или встроить в него зловредный код — и пароль утёк сразу от всего.
Идея OAuth2 и OpenID Connect устроена иначе: пароль знает только один сервер — Keycloak. По-английски такой сервер называют Identity Provider (IdP) или сервером авторизации. Все остальные приложения пароль не видят вообще никогда. Вместо пароля они получают от Keycloak токены — короткие подписанные «пропуска», которые подтверждают, что вы — это вы, и что вам разрешено то-то и то-то.
Отсюда вырастает главная техническая задача: как доставить токен от Keycloak до приложения так, чтобы по дороге его никто не перехватил? Ответ на этот вопрос и есть Authorization Code Flow.
Кто здесь кто
Чтобы дальше не путаться, договоримся о четырёх ролях. В основах OAuth2 и OIDC они введены через аналогию; здесь — кто из них кто в нашей связке:
- Resource Owner — это вы, живой пользователь. Владелец данных, к которым кто-то хочет получить доступ.
- Client — приложение, которое хочет вас пустить и потом ходить за данными: фронтенд в браузере, мобильное приложение или серверный бэкенд.
- Authorization Server — Keycloak. Хранит пользователей и пароли, проверяет вход, выдаёт токены.
- Resource Server — ваш API, который проверяет присланный токен и отдаёт данные, если токен в порядке.
В Keycloak всё это живёт внутри realm — изолированного пространства со своими пользователями, ролями и настройками. Каждое приложение регистрируется внутри realm как client. Подробнее эта модель разобрана в отдельной статье про realm, client и роли.
Что на схеме: кто кому что передаёт в общих чертах, чтобы держать картину в голове.
Главная идея: сначала код, потом токены
Прежде чем разбирать шаги, поймём центральную хитрость, ради которой всё и затеяно.
Самый наивный способ — чтобы Keycloak просто вернул токен прямо в браузер, в адресную строку. Но адресная строка — публичное место: URL попадает в историю браузера, в журналы серверов, в логи прокси, его видно соседнему расширению. Класть туда токен — всё равно что писать пароль на стикере и носить на лбу.
Поэтому Authorization Code Flow разбивает доставку на два этапа:
- Сначала Keycloak возвращает в браузер не токен, а authorization code — одноразовый короткоживущий код. Сам по себе он бесполезен: предъявить его API нельзя, данные по нему не получишь.
- Потом приложение отдельным запросом (не через адресную строку, а напрямую на сервер Keycloak) меняет этот код на настоящие токены.
Смысл такого разделения: код летит через «грязный» канал (браузер), а токены — через «чистый» (прямой серверный запрос). Даже если кто-то подсмотрит код в адресной строке, без второго шага он ничего не получит.
Поток по шагам
Теперь разберём весь поток целиком, по одному шагу, с пояснением «зачем» на каждом.
Что на схеме: полная последовательность — от нажатия «Войти» до обмена кода на токены. Это та самая картина, которую держат в голове, когда говорят «Authorization Code Flow».
А теперь те же шаги словами.
Шаг 1. Вы нажимаете «Войти». Пока ничего особенного — обычный клик в приложении.
Шаг 2. Приложение перенаправляет браузер на Keycloak. Оно формирует адрес /authorize сервера Keycloak и кладёт в него параметры запроса. Разберём их, потому что каждый зачем-то нужен:
client_id— кто спрашивает. По нему Keycloak понимает, какое именно приложение пришло.redirect_uri— куда вернуть пользователя после входа. Keycloak пустит редирект только на адрес, заранее прописанный в настройках клиента, — это защита от того, чтобы вас увели на чужой сайт. Защищает она ровно настолько, насколько узко записан адрес:https://app.example.com/*пустит код на любую страницу вашего домена, а*не защищает вообще ни от чего. Пишите полный путь возврата, а не звёздочку.response_type=code— «я хочу authorization code» (а не токен сразу). Именно эта строчка включает безопасный двухэтапный поток.scope=openid ...— что приложение хочет узнать. Значениеopenidобязательно, если нужен вход через OpenID Connect.state— случайная строка, которую приложение запоминает и сверяет на возврате. Защищает от подделки запроса (CSRF) — чтобы вернувшийся ответ точно соответствовал тому запросу, что вы начали.
Шаг 3. Keycloak показывает свою страницу входа. Браузер оказывается на домене Keycloak. Это важный момент: форма пароля — на стороне Keycloak, не приложения. Приложение в этот момент пароль не видит и видеть не может.
Шаг 4. Вы вводите логин и пароль. Keycloak их проверяет, при необходимости спрашивает второй фактор. Всё это происходит внутри Keycloak.
Шаг 5. Keycloak возвращает браузер обратно — с кодом. Вход успешен, и Keycloak перенаправляет браузер на тот самый redirect_uri, дописав в адрес code=ABC.... Это и есть authorization code. Подчеркнём ещё раз: это не токен, а одноразовый код, который сам по себе ничего не открывает.
Шаг 6. Код попадает в приложение. Браузер открывает redirect_uri приложения, и приложение достаёт код из адреса. Заодно оно сверяет state — тот ли это запрос, что начинали.
Шаг 7. Приложение меняет код на токены. Оно делает отдельный POST-запрос на /token — token endpoint Keycloak — и кладёт туда полученный код. Ключевой момент: этот запрос идёт напрямую на сервер Keycloak, минуя адресную строку браузера. Confidential-клиент (бэкенд с секретом) тут же предъявляет свой client_secret, доказывая, что он — это он.
Шаг 8. Keycloak отдаёт токены. В ответ на корректный обмен приходят три разных токена, и у каждого своя роль — их легко перепутать, поэтому разберём отдельно ниже.
После этого приложение уже может ходить в ваш API, прикладывая access_token — но это уже за рамками самого flow.
redirect_uri проверяют дважды
Незаметная деталь потока, которая объясняет половину ошибок обмена: redirect_uri участвует в двух запросах и сверяется оба раза. На переходе к странице входа Keycloak сверяет его со списком разрешённых адресов клиента. При обмене кода на токены приложение обязано прислать тот же самый адрес, и теперь Keycloak сверяет его не со списком, а с тем, что было в запросе на вход.
Зачем так: иначе перехвативший код подставил бы при обмене свой адрес. А для разработчика это значит, что несовпадение на втором шаге не даёт понятного «адрес не разрешён» — приходит invalid_grant, и причину начинают искать в PKCE или в сроке жизни кода. Чаще всего виноват адрес, собранный по-разному в двух местах: с портом и без, со слешем на конце и без, localhost против 127.0.0.1. Библиотеки OIDC подставляют адрес сами и не ошибаются; ошибаются там, где обмен написан руками.
Три токена и их роли
В ответе на шаге 8 приходят сразу три токена. Это самое частое место путаницы, поэтому разложим строго:
- access_token — пропуск к API. Приложение кладёт его в заголовок
Authorization: Bearer <access_token>каждого запроса к вашему API. Это единственный токен, который ходит в API. - id_token — «удостоверение личности» для самого приложения: кто залогинился (имя, email, идентификатор). Это часть OpenID Connect. id_token нужен клиенту, чтобы знать, кого он впустил; в API его не отправляют.
- refresh_token — «талон на обновление». Когда access_token протухнет (а живёт он недолго, обычно минуты), приложение шлёт refresh_token обратно на
/tokenKeycloak и получает свежий access_token, не дёргая пользователя заново. refresh_token ходит только обратно в Keycloak, больше никуда.
Что на схеме: куда какой токен направляется. Видно, что три токена движутся в три разные стороны.
Отдельно отметим: бывает, что access_token не JWT, а opaque — непрозрачная строка, которую API не может проверить локально и ходит к Keycloak спросить «этот токен ещё живой?» (introspection). Это не «четвёртый токен», а другой формат того же access_token: by-value (JWT, проверяется на месте по JWKS) против by-reference (opaque, проверяется запросом в Keycloak). Деталей касаться не будем — это отдельная тема про устройство и проверку токенов.
Зачем нужен PKCE
Вернёмся к шагу 5–6. Код прилетает в браузер. А что, если на устройстве затаилось вредоносное приложение или зловредное расширение, которое перехватит этот код в момент возврата? Тогда злоумышленник сам пойдёт на шаг 7 и выменяет украденный код на токены — и зайдёт под вами.
Особенно остро это для публичных клиентов — SPA в браузере и мобильных приложений. У них нет надёжного места, чтобы спрятать секрет: весь код приложения открыт, и любой client_secret, зашитый внутрь, рано или поздно вытащат. То есть на шаге 7 публичный клиент не может доказать «я — это я» через секрет. Значит, перехватчику кода ничто не мешает обменять код самому.
PKCE (Proof Key for Code Exchange, произносят «пикси») закрывает именно эту дыру. Идея — связать старт (шаг 2) и обмен (шаг 7) одноразовым секретом, который рождается внутри приложения и наружу никогда не выходит:
- Перед стартом приложение генерирует случайную строку —
code_verifier. Это и есть секрет; он остаётся внутри приложения и никуда не отправляется. - От него считается хеш:
code_challenge = BASE64URL(SHA256(code_verifier)). Метод хеширования называется S256. - На шаге 2 (redirect на логин) приложение отправляет в Keycloak
code_challengeиcode_challenge_method=S256. Keycloak запоминает этот challenge рядом с кодом, который потом выдаст. - На шаге 7 (обмен кода) приложение присылает уже сам
code_verifier. - Keycloak заново считает хеш от присланного verifier и сверяет с запомненным challenge. Совпало — выдаёт токены. Не совпало — отказ.
Что на схеме: где появляется challenge, а где verifier. Видно, что секрет (verifier) уходит только на финальном прямом запросе.
В чём суть защиты: перехватчик кода (шаг 5–6) знает только код и, возможно, challenge — но не знает code_verifier, ведь тот никогда не покидал приложение. А без verifier обменять украденный код на токены невозможно.
Важно использовать именно метод S256, а не plain (где challenge просто равен verifier без хеширования) — plain почти не даёт защиты, потому что в редиректе тогда виден сам секрет.
В Keycloak PKCE включается в настройках клиента: Advanced → Proof Key for Code Exchange Code Challenge Method → S256. Для публичных клиентов это настройка обязательная — им без неё нечем закрыть перехват кода. Но и бэкенду с секретом её сегодня советуют включать: в черновике OAuth 2.1 PKCE требуют уже от всех клиентов без исключения, а не только от публичных.
Почему implicit flow больше не используют
Раньше для SPA существовал упрощённый вариант — implicit flow. В нём Keycloak возвращал в браузер сразу access_token прямо в адресной строке (response_type=token), без промежуточного кода и без второго запроса. Сделали так когда-то потому, что браузеры тогда не умели нормально слать запросы на другой домен.
Проблема ровно та, с которой мы начали: токен летит через адрес страницы. Он оседает в истории браузера, утекает в заголовке Referer на сторонние картинки и счётчики, подгруженные этой страницей, его видит любое расширение и любой скрипт на странице. И аккуратно обновлять его (через refresh_token) тоже было нельзя — для implicit его просто не выдавали.
Сегодня браузеры спокойно делают кросс-доменные запросы (через CORS), поэтому костыль больше не нужен. Implicit flow считается устаревшим и небезопасным. В черновике OAuth 2.1 — следующей редакции стандарта, которую пока дописывают, — implicit исключён совсем, а Authorization Code Flow с PKCE становится способом по умолчанию для всех клиентов, включая бэкенд.
Вывод простой: всегда используйте Authorization Code Flow с PKCE. Опцию implicit flow в настройках клиента Keycloak (Implicit Flow Enabled) держите выключенной.
Где хранить токены после получения
Допустим, токены получены. Куда их положить? От этого напрямую зависит безопасность, и тут есть две разные стратегии.
Вариант 1: токены на бэкенде (рекомендуется для веба). Весь Authorization Code Flow проводит ваш серверный бэкенд. Полученные access_token и refresh_token он держит у себя, в серверной сессии. Браузеру отдаётся только сессионная cookie — с флагами HttpOnly, Secure, SameSite. JavaScript на странице до такой cookie не доберётся в принципе. Каждый запрос фронтенда идёт через бэкенд, тот сам подставляет нужный токен и зовёт API. Этот приём называют BFF (Backend for Frontend). Так токены вообще не лежат в браузере — и даже при XSS-атаке их нечего красть.
Вариант 2: токены в браузере (только если бэкенда нет совсем). Чистый SPA без своего сервера вынужден хранить токены прямо в браузере — в памяти страницы или в localStorage. Это уязвимо: любой зловредный скрипт, попавший на страницу через XSS, прочитает localStorage и заберёт токены. PKCE защищает только сам обмен кода — но не хранение токенов после него. Поэтому localStorage — наименее желательный вариант; как минимум держите access_token коротким и не сохраняйте refresh_token на диск — только в памяти страницы. Совет «положите refresh_token в HttpOnly-cookie» здесь неприменим: такую cookie может поставить только сервер, а его в этом варианте нет. Если сервер есть — возвращайтесь к первому варианту, он лучше во всём.
Простое правило: есть бэкенд — храните токены там (BFF), а в браузер отдавайте только защищённую cookie. Это резко сужает поверхность для атаки.
Сессия истекла: что видит фронтенд
Раздел про хранение токенов заканчивается выбором, а дальше встаёт вопрос, который всплывает в первый же день работы схемы с BFF: как одностраничное приложение узнаёт, что сессия закончилась?
Всё зависит от того, чем отвечает бэкенд. Сервер, отдающий страницы, на истёкшую сессию отвечает перенаправлением на вход, и браузер просто показывает форму. Для запроса из скрипта это худший из ответов: fetch пройдёт по перенаправлению сам, получит HTML страницы входа Keycloak с кодом 200 — и приложение попробует разобрать этот HTML как JSON. В консоли появится ошибка разбора, в интерфейсе пустой экран, а настоящая причина «вы больше не авторизованы» не написана нигде. Если по дороге сменился домен, браузер добавит к этому ещё и CORS-ошибку.
Отсюда правило: API отвечает 401, а не перенаправляет. BFF различает переход по ссылке и вызов из скрипта, и на второй отвечает 401 с пустым телом или коротким JSON. В Spring это разные точки входа: для путей /api/** ставят authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)), а перенаправление на страницу входа оставляют только обычным страницам.
Дальше действует фронтенд: перехватчик ответов видит 401, один раз пробует продлить сессию (у BFF это отдельная ручка, которая обновляет токены на сервере), и если продление тоже отвечает 401 — отправляет человека на вход. Попытка должна быть одна и общая для всех запросов: десять параллельных вызовов, разом побежавших продлевать сессию, при включённой ротации refresh-токена заканчиваются разлогином, о чём подробно статья про токены.
Выход: три адреса вместо одного
Вход разобран по шагам, а выход обычно пишут как удаление cookie — и он не работает, потому что закрывать надо три разные вещи. Здесь важно знать, что эти адреса существуют; подробный разбор — в статье про токены.
Своя сессия. BFF удаляет серверную сессию и отдаёт cookie с истёкшим сроком, одностраничное приложение чистит токены из памяти. После этого приложение не авторизовано, но выданный доступ ещё жив.
Refresh. Пока refresh-токен действует, доступ можно продлить, поэтому его отзывают отдельным запросом на ручку отзыва (/protocol/openid-connect/revoke).
Сессия в Keycloak. Её закрывает адрес завершения сессии, end_session_endpoint из документа настроек realm. На него переходят браузером, передавая id_token_hint, чтобы Keycloak знал, чью сессию закрывать, и post_logout_redirect_uri, куда вернуть человека. Вот для чего приложению нужен id_token, который в API не отправляют. Адрес возврата, как и адрес входа, должен быть в списке разрешённых у клиента — поле «Valid post logout redirect URIs», иначе вместо возврата Keycloak покажет страницу с подтверждением выхода.
Пропущенный третий шаг даёт узнаваемую жалобу: пользователь нажал «Выйти», потом «Войти» — и оказался внутри без ввода пароля, потому что сессия единого входа жива. Это и есть тот самый выход, после которого пользователь всё ещё внутри.
Как это включается в Spring
Если приложение на Spring выступает как client, берётся стартер spring-boot-starter-oauth2-client. Он сам проводит Authorization Code Flow, складывает токены в серверную сессию и заворачивает фронтенд в cookie.
Здесь важно не перепутать роли. Если вход проводит ваш серверный бэкенд, он конфиденциальный клиент: секрет ему хранить негде, кроме собственных настроек, и это правильно — заводите в Keycloak клиента с секретом.
Вариант без секрета (client-authentication-method: none) существует для приложений, у которых спрятать секрет физически негде, — мобильных и полностью браузерных. Для них Spring сам добавляет PKCE, ничего руками считать не нужно:
spring:
security:
oauth2:
client:
provider:
keycloak:
issuer-uri: https://keycloak.example.com/realms/my-realm
registration:
keycloak:
client-id: shop-backend
client-secret: ${KEYCLOAK_CLIENT_SECRET}
authorization-grant-type: authorization_code
scope: openid, profile, email
redirect-uri здесь не написан намеренно: Spring подставляет свой собственный адрес возврата — {baseUrl}/login/oauth2/code/keycloak, где последнее слово — имя регистрации из конфига. Этот же адрес придётся вписать в настройках клиента в Keycloak, в поле Valid redirect URIs. Если этого не сделать, вход сломается не там, где вы будете искать: приложение отработает нормально, а Keycloak покажет свою страницу с invalid_redirect_uri — и выглядеть это будет как проблема на его стороне.
Включить защиту — буквально одна строка oauth2Login():
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.oauth2Login(Customizer.withDefaults())
.build();
}
А ваш API (resource server) про логин вообще ничего не знает. Его задача проще: получить access_token в заголовке Authorization: Bearer ..., проверить его подпись по открытым ключам Keycloak (JWKS, адрес которых берётся из issuer-uri) и пустить. Это отдельная тема — см. статью про интеграцию со Spring Security.
Глубже: CORS в связке SPA, Keycloak и APIрасширенное
Первая стена, в которую упирается браузерный фронтенд: в консоли blocked by CORS policy, запрос к /token или к вашему API «упал», и разработчик читает это как 401. Никакого 401 нет: браузер не отдал ответ скрипту, а статуса у такого запроса вовсе нет, 0 во вкладке Network.
Источников CORS-ошибок в этой связке два, и их путают. Первый это сам Keycloak. Фронтенд с https://shop.example.com обменивает код на токены запросом к https://auth.example.com/realms/shop/protocol/openid-connect/token, а потом обновляет их и проверяет сессию. Keycloak разрешает это только источникам из поля «Web origins» у client: туда пишут адрес фронтенда, а значение + берёт источники из «Valid redirect URIs». Пусто в этом поле означает отказ на предзапрос OPTIONS, и адаптер keycloak-js или любая библиотека OIDC покажут именно CORS-ошибку.
Второй источник это ваш API. Токен получен, фронтенд идёт на https://api.example.com/orders с заголовком Authorization, и это «непростой» запрос с предзапросом OPTIONS. API обязан ответить на него без токена и с заголовками Access-Control-Allow-Origin и Allow-Headers: Authorization; в Spring это настройка cors() в Spring Security, а не в контроллере, потому что предзапрос до контроллера не доходит. Если Security стоит, а cors() не настроен, предзапрос получает 401, и браузер снова показывает CORS-ошибку, хотя настоящий запрос прошёл бы. Механику CORS разбирает статья про HTTP.
Куда смотреть по порядку. Вкладка Network: найти запрос OPTIONS перед упавшим и посмотреть его статус и заголовки ответа. Нет Access-Control-Allow-Origin в ответе от auth.example.com: чинить «Web origins». Нет от api.example.com: чинить cors() в API. OPTIONS отвечает 401: Security обрабатывает предзапрос раньше CORS, порядок фильтров. И одна ловушка вне CORS: если фронтенд и API на одном домене через прокси (/api/ проксируется на бэкенд), предзапросов нет вовсе, и в проде всё работает, а на локальном стенде с разными портами ломается; это не повод открывать *, а повод поднять тот же прокси локально.
Глубже: когда не работает: invalid_redirect_uri, invalid_grant и где смотретьрасширенное
Поток выше описан для счастливого пути. Отлаживать придётся несчастливый, и ошибки Keycloak на нём стандартные, с короткими кодами, по которым сразу видно, где искать.
invalid_redirect_uri на странице Keycloak сразу после перехода на вход: адрес, который приложение прислало в redirect_uri, не совпадает ни с одним из «Valid redirect URIs» у client. Сравнивают побуквенно: http против https, слэш в конце, порт, localhost против 127.0.0.1. Keycloak не подсказывает, что именно не совпало, но событие LOGIN_ERROR в журнале realm (Events, включить «Save events») показывает присланный адрес.
invalid_grant при обмене кода на токены или при обновлении: код уже использован (обмен сделали дважды, часто из-за повторного рендера страницы), код истёк (у него минута), PKCE-проверка не сошлась (code_verifier не тот, что породил code_challenge), refresh_token уже использован при включённой ротации, пользователь отключён или сессия закрыта. Текст в error_description различает эти случаи: Code not valid, PKCE verification failed, Maximum allowed refresh token reuse exceeded, Session not active.
unauthorized_client: client не имеет права на этот тип запроса. Обычно это выключенный «Direct access grants» при попытке входа по паролю, выключенный «Standard flow» или service account у public client. invalid_client: неверный секрет или его отсутствие у confidential client. invalid_scope: запрошен scope, которого у client нет.
На стороне API ошибку описывает заголовок ответа WWW-Authenticate, который Spring ставит на 401: Bearer error="invalid_token", error_description="...", и в описании написано, что не так: Jwt expired at ... (часы или срок), The iss claim is not valid (issuer-uri в настройках отличается от iss в токене, часто из-за прокси и внутреннего имени Keycloak), The aud claim is not valid (не настроен audience mapper, о чём статья про интеграцию со Spring), Signed JWT rejected: Another algorithm expected (токен не от этого realm). Клиент в браузере этот заголовок видит только если он перечислен в Access-Control-Expose-Headers.
Где смотреть в Keycloak. Вкладка Sessions у пользователя и у client показывает живые сессии и с каких клиентов; Events показывает входы, ошибки и обновления с кодами; журнал контейнера с KC_LOG_LEVEL=info,org.keycloak.events:debug печатает те же события с деталями. Token в руках разбирают локальным декодером JWT и сравнивают iss, aud, exp, azp с тем, чего ждёт API.
Коротко
- Пароль знает только Keycloak; приложение пароль не видит и получает вместо него токены.
- Поток идёт в два этапа: браузер приносит одноразовый authorization code, а приложение меняет его на токены прямым запросом на
/token. Код летит через браузер, токены — нет, и перехваченный код бесполезен без второго шага. - На обмене приходят три токена: access_token (в API, через
Authorization: Bearer), id_token (кто залогинился — для клиента, в API не шлётся), refresh_token (только обратно в Keycloak, чтобы обновить access_token). - PKCE связывает старт и обмен секретом
code_verifier: на старте уходит хеш, сам verifier — только при обмене, и перехваченный код без него не обменять. Обязателен для публичных клиентов, а черновик OAuth 2.1 требует его от всех. - Implicit flow устарел и в черновике OAuth 2.1 его нет — всегда Code Flow с PKCE. Токены лучше хранить на бэкенде (BFF), отдавая в браузер только
HttpOnly/Secure/SameSitecookie:localStorageуязвим к XSS. - В Spring клиентскую часть закрывает
spring-boot-starter-oauth2-clientсoauth2Login(), а API проверяет токен отдельно. CORS-ошибка при этом не401: «Web origins» у client для запросов к Keycloak,cors()в Security для предзапросов к API. - Коды ошибок читают буквально:
invalid_redirect_uriэто несовпадение адреса побуквенно,invalid_grantс описанием (повторный код, PKCE, повторный refresh),unauthorized_clientэто выключенный поток; на APIWWW-Authenticateназывает claim, который не сошёлся; события realm и Sessions показывают остальное. redirect_uriсверяется дважды — со списком на входе и с тем же значением при обмене кода; несовпадение на втором шаге приходит какinvalid_grant, а не как ошибка адреса.- API на истёкшую сессию отвечает
401, а не перенаправлением: иначеfetchполучит HTML страницы входа с кодом200. Продление делают одной общей попыткой на все запросы. - Выход это три адреса: своя сессия, отзыв refresh и
end_session_endpointсid_token_hint; без третьего повторный вход пройдёт без пароля.
Что почитать дальше
- OAuth2 и OIDC простыми словами — роли, access/refresh/id_token и чем доступ отличается от «кто ты», если нужна база перед этой статьёй.
- Realm, client, роли и пользователи в Keycloak — что такое realm и client, public против confidential, и как роли попадают в токен.
- Keycloak и Spring Security: проверка токенов — как ваш API проверяет подпись access_token по JWKS.
- Токены Keycloak: проверка, refresh, отзыв и ошибки — устройство JWT, обновление через refresh_token, logout и частые ошибки.