Локальный выход и завершение SSO
У пользователя две локальные сессии и одна SSO-сессия Keycloak. Теперь слово «выйти» становится неоднозначным. Можно завершить только «Пропуск», сохранив вход у провайдера, или попросить provider завершить SSO. Добавим обе операции и объясним предел их действия. Новый результат — POST-форма с защищённым локальным выходом и RP-initiated logout. Она не обещает мгновенно удалить Map другого приложения: такой общей координации в лаборатории пока нет.
В lesson-10 корневая страница показывает две кнопки для вошедшего пользователя. Обе отправляют POST на /logout с csrf. Первая не задаёт sso=yes, вторая задаёт. Сервер проверяет сессию, Origin и токен, удаляет локальную запись и очищает cookie. Затем либо возвращает человека на /, либо строит end-session URL через openid-client. Исходник не использует GET-ссылку, которая могла бы легко стать побочным выходом при переходе или встраивании чужой страницы.
Локальное завершение
Удаление sessions по id немедленно делает эту cookie бесполезной для текущего процесса. Max-Age=0 дополнительно просит браузер убрать значение. Если браузер по какой-то причине снова отправит прежний id, Map-запись уже отсутствует. Оба действия полезны, но серверное удаление определяет доступ. Нельзя считать очистку браузерной cookie полноценным отзывом, оставив запись действующей: украденная копия id продолжила бы работать, пока запись не истечёт. Такое различие особенно важно для bearer-сессии, где знание идентификатора даёт доступ.
После локального выхода Keycloak может всё ещё помнить человека. Новая кнопка login создаст свежую попытку, а provider подтвердит её без повторного пароля по своей SSO-сессии. Это ожидаемое поведение, а не доказательство, что удаление cookie не сработало. Приложение сначала действительно перестало узнавать пользователя, затем приняло новый OIDC-результат. Если продукту нужен «переключить пользователя», можно использовать предусмотренный prompt или account-selection flow, но это отдельная операция, которую не стоит смешивать с простым выходом.
Другой Node-процесс не получает сообщение о локальном удалении. «Читатель» сохраняет свою Map и может продолжить работу в пределах срока. Cookie имеют разные имена, поэтому очищается только текущая. Мы специально сделали второй клиент прежде logout, чтобы видеть такой эффект. В реальном наборе сервисов пользовательские ожидания нужно объяснить интерфейсом: «Выйти здесь» и «Завершить единый вход» обозначают разные действия. Одна неясная кнопка создаёт трудность даже при технически правильном протоколе.
Запрос к provider
Для SSO-варианта строим buildEndSessionUrl с id_token_hint и post_logout_redirect_uri. Hint помогает provider связать запрос с принятой сессией клиента, а redirect задаёт возвращение после завершения. Адрес возвращения зарегистрирован в client attributes realm. Не принимаем произвольный next от посетителя, чтобы выход не превращался в открытое перенаправление. Приложение использует свой trusted origin, как при callback. Назначение URL определяется конфигурацией, а не строкой формы, которую может изменить браузер.
Локальную запись удаляем до перенаправления. Если пользователь отменил дальнейшее действие у провайдера или provider оказался недоступен, «Пропуск» уже завершил собственную сессию. Это позволяет не сохранять локальный доступ в ожидании внешнего результата. Но сам факт перехода не доказывает, что SSO действительно завершилось. Провайдер может показать подтверждение или применить свои политики. В интерфейсе стоит различать запрос выхода и завершённый внешний результат; наша небольшая лаборатория не сохраняет отдельное состояние подтверждения logout.
ID Token не выводится в страницу целиком, но передаётся как предусмотренный hint в URL провайдера. Это чувствительный маршрут, поэтому не нужно копировать его в публичные отчёты и обычные access logs. Даже если токен уже близок к истечению, он содержит сведения личности. Учебный сервер не логирует callback query и такие значения. Для реального reverse proxy потребуется согласованная политика журналов, а не только отсутствие console.log в приложении. Граница данных проходит через всю инфраструктуру перенаправления.
Почему другой сервис ещё может узнавать человека
RP-initiated logout завершает состояние provider, но уже существующая локальная сессия второго приложения не обязана исчезнуть без отдельного уведомления. Стандартные механизмы front-channel и back-channel logout решают другие части координации. В нашем коде они отсутствуют. «Читатель» может продолжать показывать имя по своей Map до срока или до будущего неудачного refresh. Поэтому мы не утверждаем, что новая кнопка автоматически обеспечивает мгновенный общий выход. Это существенный предел результата, который нужно сохранить в описании курса.
Для критичных сервисов срок локальной сессии и механизм отзыва должны соответствовать ожиданиям продукта. Можно принимать back-channel logout token, очищать записи по session identifier и поддерживать общий store, но такая схема требует корректной валидации, защиты replay и работы между процессами. Нельзя заменить её широким cookie Domain и удалением одной cookie на соседнем поддомене. Мгновенный отзыв является самостоятельным контрактом, а не бесплатным побочным свойством одинакового realm и красивого названия «единый вход».
Защита операции выхода
POST проверяет Origin и csrf так же, как форма заявки. Это предотвращает простой принудительный выход из чужой страницы. При отсутствующей сессии сервер просто возвращает на главную: повторное завершение уже отсутствующего состояния безопасно. Если сессия есть, но csrf неверен, запись не удаляется. Такой порядок показывает, что logout также является изменением состояния и должен обрабатываться предметно. Для идемпотентной пользовательской операции повтор после успешного удаления не должен создавать ошибку с секретными подробностями.
Полный server.mjs ниже содержит обе ветки. При самостоятельном локальном выходе ожидается исчезновение cookie текущего приложения при сохранении provider SSO. При SSO-выходе ожидается переход к end-session endpoint и дальнейший возврат. Сценарии здесь не выполнялись. Не используйте успешный redirect как утверждение, что все остальные приложения уже очистили записи. Проследите отдельно состояние Map второго клиента и следующую попытку login. Такой разбор делает реальную границу logout понятной посетителю и разработчику.
Следующий урок добавит refresh перед серверными действиями. Короткий access token позволит наблюдать обновление, а провайдерский выход может привести к отказу следующего refresh. Это сократит период сохранения старого состояния при некоторых изменениях, но всё ещё не станет гарантией мгновенного отзыва. Мы будем сохранять fixed lifetime локальной сессии и различать доступность токена, актуальность ролей и общий максимум входа.
Политика CSP с form-action требует точного разрешения http://localhost:8080 рядом с self. Иначе браузер может заблокировать переход после POST формы на внешний для приложения порт Keycloak. Документация MDN прямо описывает различия браузеров при обработке таких перенаправлений. Мы не разрешаем все адреса: добавлен только origin локального provider. В production это правило нужно согласовать с точным HTTPS-адресом. Origin-проверка и csrf серверной операции при этом остаются обязательными.
Полный файл server.mjs из состояния lesson-10:
import { createServer } from 'node:http';
import { config, origin, port } from './config.mjs';
import { begin, finish } from './oidc.mjs';
import { sessions, cookies, cookie, current, random, equal } from './sessions.mjs';
import { form } from './body.mjs';
import * as oidc from 'openid-client';
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' http://localhost:8080; 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 === '/metadata') {
send(res, 200, page('<pre>' + esc(JSON.stringify(config.serverMetadata(), null, 2)) + '</pre>'));
return;
}
let { id, value: session } = current(req, name);
if (req.method === 'GET' && url.pathname === '/login') {
const attempt = await begin();
send(res, 303, '', { Location: attempt.url.href, 'Set-Cookie': cookie(pendingName, attempt.id, 300) });
return;
}
if (req.method === 'GET' && url.pathname === '/callback') {
const result = await finish(url, cookies(req)[pendingName]);
if (id) sessions.delete(id);
const newId = random();
sessions.set(newId, { ...result, csrf: random(), until: Date.now() + 900_000,
tokenUntil: Date.now() + Math.max(0, Number(result.tokens.expires_in || 0)) * 1000 });
send(res, 303, '', { Location: '/', 'Set-Cookie': [cookie(name, newId), cookie(pendingName, '', 0)] });
return;
}
const editor = session && Array.isArray(session.claims.realm_roles) && session.claims.realm_roles.includes('editor');
if (req.method === 'GET' && url.pathname === '/editor') {
if (!session) { send(res, 401, page('<p>Требуется вход.</p>')); return; }
if (!editor) { send(res, 403, page('<p>Нужна роль editor.</p>')); return; }
send(res, 200, page('<h2>Редакторская заявка</h2><form method="post" action="/publish"><input type="hidden" name="csrf" value="' + esc(session.csrf) + '"><label>Название <input name="title" maxlength="120" required></label><button>Добавить заявку</button></form><ul>' + requests.map(x => '<li>' + esc(x.title) + '</li>').join('') + '</ul>'));
return;
}
if (req.method === 'POST' && url.pathname === '/publish') {
if (!session) { send(res, 401, page('<p>Войдите.</p>')); return; }
if (!editor) { send(res, 403, page('<p>Нет права editor.</p>')); return; }
const data = await form(req);
if (req.headers.origin !== origin || !equal(data.get('csrf'), session.csrf)) { send(res, 403, page('<p>Недействительное происхождение или CSRF-токен.</p>')); return; }
const title = (data.get('title') || '').trim();
if (!title || title.length > 120) { send(res, 400, page('<p>Нужно название до 120 символов.</p>')); return; }
requests.push({ title, author: session.claims.sub });
if (requests.length > 100) requests.shift();
send(res, 303, '', { Location: '/editor' });
return;
}
if (req.method === 'POST' && url.pathname === '/logout') {
if (!session) { send(res, 303, '', { Location: '/' }); return; }
const data = await form(req);
if (req.headers.origin !== origin || !equal(data.get('csrf'), session.csrf)) { send(res, 403, page('<p>Недействительный выход.</p>')); return; }
sessions.delete(id);
const location = data.get('sso') === 'yes' ? oidc.buildEndSessionUrl(config, {
id_token_hint: session.tokens.id_token, post_logout_redirect_uri: origin + '/',
}).href : '/';
send(res, 303, '', { Location: location, 'Set-Cookie': cookie(name, '', 0) });
return;
}
if (req.method !== 'GET' || url.pathname !== '/') { send(res, 404, page('<p>Страница не найдена.</p>')); return; }
let body = '<p>Локальная лаборатория единого входа.</p>';
body += session ? '<p>Вы вошли: ' + esc(session.claims.preferred_username || session.claims.sub) + '</p>' : '<p>Вы не вошли.</p>';
body += '<p><a href="/login">Войти через Keycloak</a></p>';
body += '<p><a href="/editor">Редакторский кабинет</a></p>';
body += '<p><a href="http://localhost:5100/">Пропуск</a> · <a href="http://localhost:5101/">Читатель</a></p>';
if (session) body += '<form method="post" action="/logout"><input type="hidden" name="csrf" value="' + esc(session.csrf) + '"><button>Выйти здесь</button><button name="sso" value="yes">Завершить SSO</button></form>';
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}`));
Оглавление серии · Исходники всех шагов · Учебные маршруты
Пример остаётся локальным черновиком. При подготовке он не выполнялся; все описанные эффекты являются ожидаемыми, а не результатами запущенных проверок.