Обмен кода и проверенный профиль
Пользователь может начать вход и перейти к Keycloak. После подтверждения личности провайдер возвращает браузер на callback с authorization code. Теперь нужно обменять этот код и связать ответ с нашей одноразовой попыткой. Добавим фактическое использование finish и получение claims через openid-client. Новый результат — подтверждённый в принятом OIDC-потоке профиль, который можно показать для изучения. Постоянная локальная сессия появится в следующем уроке; пока callback лишь выводит полученные сведения.
В lesson-05 сервер вызывает finish(url, pendingId). URL строится от доверенного origin приложения, а id берётся из pending cookie. В серверном Map находятся verifier, state, nonce и срок. Сам браузерный query не становится источником этих ожидаемых значений. Если взять expectedState из параметра state того же callback, сравнение потеряет смысл: приложение будет сравнивать присланное значение с ним самим. Ожидание обязательно должно происходить из ранее сохранённой операции.
Одноразовость прежде обмена
Функция finish извлекает попытку и немедленно удаляет её из attempts. Затем проверяет существование и срок. Даже неудачный callback расходует локальную запись. Это делает повторное использование невозможным через тот же путь: для следующего входа нужна новая begin. Такой порядок уменьшает неоднозначность при повторном запросе браузера или попытке подмены. Правда, случайная техническая ошибка также потребует нового входа. Для лаборатории это разумная простота; более сложное восстановление попытки должно быть спроектировано отдельно.
Authorization code является краткоживущим одноразовым результатом, а не профильным токеном, который приложение хранит в cookie. Его обменивают на token endpoint. Вместе с кодом предъявляется pkceCodeVerifier, а библиотеке передаются expectedState и expectedNonce. Эти проверки связывают разные части процесса, заданные предыдущим уроком. Успех не определяется одним наличием параметра code в URL. Путь callback может получить любой HTTP-запрос, поэтому он должен оставаться строгим обработчиком ожидаемой операции, а не доверенным маршрутом по собственному названию.
Claims из результата библиотеки
После authorizationCodeGrant вызываем tokens.claims(). В нашем контракте ожидается ID Token и строковый sub. Nonce также требует соответствующего ID Token в обмене. Библиотека обрабатывает требования протокола и проверки claims, связанные с конфигурацией и ожидаемыми параметрами. Мы не берём произвольный JWT из браузера, не декодируем его base64 самостоятельно и не объявляем содержимое подтверждённой личностью. В production прямой обмен должен проходить по TLS; локальный HTTP остаётся ограничением доверенного стенда.
Важное различие: проверка OIDC-ответа приложения и проверка bearer-токена отдельным API являются разными задачами. Access token может иметь другой audience, другой формат и другие требования. Наш код не предлагает универсальную функцию «раскодировать токен и пустить пользователя». Claims получены в ожидаемом authorization code обмене от принятого провайдера. Если позднее другой сервис принимает токен независимо, ему нужен свой контекст issuer, audience, алгоритмов и срока. Копирование одной строки tokens.claims не создаст такой API middleware.
Sub — основная идентификационная строка внутри issuer. Preferred_username и email можно показать человеку, но нельзя считать их неизменным ключом. В лаборатории имена anna и boris удобны для чтения; при настоящем продукте пользователь может поменять отображаемое имя. Сочетание issuer и sub помогает избежать случайного объединения разных пространств идентичности. Наш провайдер один и задан конфигурацией, поэтому пример остаётся небольшим. Миграция между провайдерами или связывание социальных аккаунтов не реализованы и не должны подразумеваться автоматически.
Ошибочный и отменённый вход
Пользователь может отменить действие, provider может вернуть error, попытка может истечь или state не совпасть. В этих случаях библиотека или наш код завершат операцию исключением. Сервер показывает короткое сообщение о незавершённом входе и предлагает начать заново. Мы не выводим полный объект ошибки, callback URL или токены в HTML. Они могут содержать чувствительные параметры и внутренние сведения. Для настоящей диагностики полезно отдельное request id и ограниченный журнал причин, без копирования секретного материала в публичный ответ.
Общий catch учебного сервера не различает все категории для посетителя. Это граница интерфейса, а не доказательство, что ошибки одинаковы. В production желательно отделять отказ пользователя, недоступность провайдера, неверную конфигурацию и неожиданный сбой. Однако такое уточнение не должно превращаться в подробное раскрытие токенов или сообщать, существует ли чужая учётная запись. Для собственного анализа сначала смотрите, была ли найдена попытка, истёк ли срок и совпали ли trusted параметры. Неверный пароль является лишь одним возможным случаем у провайдера.
Профиль не равен сессии
В данном состоянии callback показывает JSON claims как экранированный текст. Cookies полноценного пользователя ещё нет, поэтому открытие корневой страницы не должно автоматически сохранить вход. Это намеренный промежуточный результат. Полученные tokens живут только в памяти обработчика и не передаются браузеру целиком. Показанный профиль предназначен для изучения собственной fixture-учётной записи, а не для публикации чужих персональных данных. Даже имя и email следует рассматривать как пользовательские сведения, а не безусловно открытые технические метаданные.
После успешного callback pending cookie очищается. Код обработки не возвращает посетителю verifier или refresh token. Их назначение не требует отображения в HTML. Для обычного приложения страница с сырыми claims также не нужна: следующий шаг преобразует результат в локальную сессию и покажет лишь имя. Учебная остановка на JSON помогает увидеть, откуда берётся профиль, прежде чем скрыть его внутри общего механизма. Это отличается от фиктивной страницы «вход успешен», где фактический контракт не объяснён.
Ожидаемая цепочка
Полный oidc.mjs ниже соответствует исходнику. Для самостоятельной работы начните новый login, подтвердите fixture-пользователя и вернитесь на callback. Ожидается вывод claims с sub и принятым issuer. Повтор того же callback не должен успешно использовать уже удалённую попытку. Здесь ни вход, ни обмен кода не выполнялись. Не ослабляйте проверки ради повторного URL: начните новую операцию и разберите, какая одноразовая связь была нарушена.
Следующий урок создаст новую случайную локальную сессию после завершённого обмена. Это позволит не путать pending cookie с cookie вошедшего пользователя, ограничить срок и удалить старый идентификатор при повторном входе. Протокол подтверждает результат личности; приложение должно отдельно решить, как долго и где оно хранит это состояние. Такая последовательность помогает увидеть, что OIDC не управляет автоматически всеми cookies, ролями и действиями нашего HTTP-сервера.
Полный файл oidc.mjs из состояния lesson-05:
import * as oidc from 'openid-client';
import { config, callback } from './config.mjs';
import { attempts, random } from './sessions.mjs';
export async function begin(action) {
const verifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState(), nonce = oidc.randomNonce();
const id = random();
attempts.set(id, { verifier, state, nonce, until: Date.now() + 300_000 });
const parameters = { redirect_uri: callback, scope: 'openid profile email',
state, nonce, code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
code_challenge_method: 'S256' };
if (action) parameters.kc_action = action;
return { id, url: oidc.buildAuthorizationUrl(config, parameters) };
}
export async function finish(url, id) {
const attempt = attempts.get(id);
attempts.delete(id); // Even an unsuccessful callback consumes the local attempt.
if (!attempt || attempt.until <= Date.now()) throw new Error('Login attempt expired');
const tokens = await oidc.authorizationCodeGrant(config, url, {
pkceCodeVerifier: attempt.verifier, expectedState: attempt.state,
expectedNonce: attempt.nonce,
});
const claims = tokens.claims();
if (!claims || typeof claims.sub !== 'string') throw new Error('No identity claims');
return { tokens, claims };
}
Оглавление серии · Исходники всех шагов · Учебные маршруты
Пример остаётся локальным черновиком. При подготовке он не выполнялся; все описанные эффекты являются ожидаемыми, а не результатами запущенных проверок.