Идентичность, полномочия и проект «Пропуск»
Единый вход часто начинают с кнопки «Войти», но за ней находятся несколько разных договорённостей. Кто подтверждает личность, кто разрешает действие и кто хранит состояние браузера? Если смешать эти обязанности, приложение начинает доверять случайному полю username или считать любой токен универсальным пропуском. В этой серии построим локальный проект «Пропуск» и проследим цепочку входа по шагам. Первый урок задаёт роли участников и поднимает только простую публичную страницу без настоящего входа.
Для лаборатории выбран Node.js 22.14.0. Позднее добавим openid-client 6.8.8 и Keycloak 26.5.2. Это конкретный учебный профиль, а не рекомендация оставаться на старом патче при реальном выпуске. Пользовательские учётные записи в примере вымышлены. Никакой сервер, контейнер или браузерный вход при подготовке не запускались. Исходники каждой lesson-NN являются самостоятельным состоянием, а описанные результаты нужно проверить при собственной работе.
Три участника одной операции
Браузер показывает страницы и переносит пользователя между приложением и провайдером идентичности. Приложение «Пропуск» работает по адресу http://localhost:5100. Keycloak появится по адресу http://localhost:8080. Пользователь введёт пароль или подтвердит passkey у Keycloak, а наше приложение получит результат по протоколу. Оно не должно собирать пароль Keycloak в собственной форме и затем пересылать его. Такой обход изменил бы границу доверия и лишил нас многих свойств нормального перенаправленного входа.
Провайдер отвечает за подтверждение личности и собственную SSO-сессию. Приложение отвечает за локальную сессию и доступ к своим операциям. Например, Анна может войти как известный пользователь, но право добавить редакторскую заявку требует отдельной роли editor. Борис тоже известен провайдеру, однако имеет только reader. Из факта успешного входа не следует разрешение любого действия. Это различие особенно важно, когда позже подключается второе приложение: одна личность может иметь разные обязанности в разных сервисах.
OAuth задаёт делегированный доступ, а OpenID Connect добавляет договорённость о личности. В нашем маршруте используем OIDC authorization code flow с PKCE. Отдельные шаги объяснят код, verifier, state и nonce. Сейчас достаточно понять, что приложение не принимает произвольную строку из браузера как удостоверение. Результат проходит проверяемый обмен с заранее выбранным провайдером. Термин «авторизация» часто используют для всего входа, но в коде полезно различать подтверждение личности и решение о полномочиях.
Что будет хранить приложение
После входа браузер получит непрозрачный случайный идентификатор локальной сессии. Сам профиль и токены останутся на сервере. Это упрощает контроль жизненного цикла и не заставляет хранить access token в localStorage. Сессия будет лежать в Map памяти процесса с ограниченным сроком. Такой store удобен для объяснения, но исчезает при рестарте и не работает между несколькими процессами. Мы не называем его готовым решением для сайта с несколькими серверами; внешнее хранилище станет явным требованием дальнейшего выпуска.
Приложение получит ID Token для личности, access token для предусмотренного доступа и, при выдаче провайдером, refresh token для обновления. Эти объекты имеют разные назначения. ID Token не является универсальным билетом к любому API, а access token нельзя просто декодировать и считать доказательством входа без принятого контракта. В лаборатории библиотека обработает ответы OIDC, а приложение будет брать claims из результата этого обмена. Профиль посетителя не строится из переданного им самостоятельно JSON.
Постоянный идентификатор пользователя будем читать как sub в сочетании с issuer. Username и email удобны для отображения, но могут измениться. Две разные системы способны выдать одинаковое имя anna, поэтому сама строка имени не задаёт глобальную личность. Когда позже сохраняем заявку, связываем её с sub текущего принятого провайдера. Реальный проект должен определить, как такая пара соотносится со своей базой пользователей и что происходит при смене провайдера; автоматическое объединение по email не включено в эту серию.
Первый самостоятельный сервер
В lesson-01 есть package.json без внешних зависимостей и server.mjs. Используется стандартный HTTP-сервер Node. Он слушает только loopback, строит маленькую HTML-страницу и возвращает 404 для неизвестного пути. Серверный origin задаётся самим приложением, а не берётся без проверки из заголовка Host. Эта граница позже понадобится для callback: URL обмена должен соответствовать зарегистрированному адресу, а не имени узла, которое произвольный клиент прислал вместе с запросом.
Страница пока говорит лишь о локальной лаборатории. Кнопки, работающий callback и cookie появятся последовательно. Не стоит добавлять фиктивное «Вы вошли, Анна» ради красивой демонстрации: такое состояние не подтверждено никаким протоколом. Первый результат достаточно мал, чтобы было видно, какие обязанности ещё отсутствуют. При самостоятельном запуске node server.mjs ожидается адрес локального приложения в консоли, а браузер должен показать заголовок «Пропуск». Эти действия автором не выполнялись.
HTML-значения экранируются функцией esc. Сейчас на странице нет пользовательского профиля, но позже он появится. Единый способ отображения полезно подготовить раньше, чем в случайном месте вставить имя без обработки. Заголовок Content-Security-Policy запрещает произвольные ресурсы и встраивание страницы во фрейм. Он дополняет безопасное построение текста, но не заменяет проверки входа. Техническая оболочка не должна создавать впечатление, что отсутствие ошибок разметки означает корректность identity-протокола.
Граница локальной лаборатории
HTTP допустим здесь только как выбранный режим localhost. В настоящем выпуске провайдер и приложение должны использовать HTTPS, Secure cookies и проверенную внешнюю конфигурацию. Нельзя перенести allowInsecureRequests из будущего config на публичный сервер просто потому, что пример работал локально. Точно так же вымышленные пароли не подходят реальным людям. Лаборатория показывает связность участников и механизм, а не предлагает публиковать незаконченный стенд в интернете с теми же настройками.
Откройте исходник и проследите один GET: он проходит проверку пути, получает подготовленную страницу и завершается отправкой ответа. Здесь нет обращения к Keycloak, токенов или роли. Это полезная исходная точка для сравнения. Следующий урок добавит отдельный локальный realm с двумя вымышленными пользователями и клиентом propusk. Мы будем сохранять те же адреса и имена на протяжении всей серии, чтобы каждый новый механизм объяснялся изменением знакомого проекта.
Полный файл server.mjs из состояния lesson-01:
import { createServer } from 'node:http';
const port = Number(process.env.PORT || 5100);
const origin = `http://localhost:${port}`;
const name = `propusk_${port}`;
const pendingName = `${name}_pending`;
const requests = [];
const esc = value => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
function send(res, status, body, extra = {}) {
const payload = Buffer.from(body);
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store',
'Content-Security-Policy': "default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
'Content-Length': payload.length, ...extra });
res.end(payload);
}
function page(body) { return '<!doctype html><html lang="ru"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Пропуск</title><main><h1>Пропуск</h1>' + body + '</main></html>'; }
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, origin); // Ignore an untrusted Host header.
if (req.method !== 'GET' || url.pathname !== '/') { send(res, 404, page('<p>Страница не найдена.</p>')); return; }
let body = '<p>Локальная лаборатория единого входа.</p>';
send(res, 200, page(body));
} catch {
// Callback URLs, tokens and password values are deliberately not logged.
send(res, 400, page('<p>Операция входа не завершена. Начните новый вход.</p>'));
}
});
server.requestTimeout = 10_000;
server.listen(port, '127.0.0.1', () => console.log(`Local application: ${origin}`));
Оглавление серии · Исходники всех шагов · Учебные маршруты
Пример остаётся локальным черновиком. При подготовке он не выполнялся; все описанные эффекты являются ожидаемыми, а не результатами запущенных проверок.