Начало входа: state, nonce и PKCE
Приложение уже знает endpoints принятого провайдера. Теперь пользователь может начать вход. На этом шаге важно создать новую одноразовую попытку и связать её с браузером, который нажал кнопку. Одного redirect на authorization endpoint недостаточно: приложение должно отличить свой ожидаемый ответ от чужого и подтвердить связь обмена кода с исходной попыткой. Добавим state, nonce и PKCE, сохранив каждый параметр на сервере. Callback завершит обмен в следующем уроке.
Состояние lesson-04 содержит oidc.mjs и sessions.mjs. Функция begin создаёт случайные значения, сохраняет запись в attempts и возвращает URL перенаправления. Браузер получает cookie pending, действующую пять минут. Это ещё не локальная сессия вошедшего пользователя. Не следует показывать «Вы вошли» после создания такой записи: пароль или passkey у провайдера пока не подтверждены, и приложение ещё не получило результат обмена.
Зачем нужны разные случайные значения
State привязывает authorization response к нашей ожидаемой попытке. Значение хранится на сервере и позже сравнивается библиотекой. Nonce связывает получаемый ID Token с начатой OIDC-операцией. PKCE использует секретный verifier и производный challenge: challenge отправляется при начале, verifier — при обмене кода. Эти параметры защищают разные связи, поэтому нельзя заменить их одним постоянным «секретом приложения» или случайной строкой, общей для всех посетителей. У каждой попытки должен быть собственный набор.
Verifier генерируется библиотекой, challenge вычисляется через calculatePKCECodeChallenge, метод S256 задаётся явно. Сам verifier остаётся в attempts и не попадает в адрес браузера. Код, который провайдер позже отправит на callback, нельзя успешно обменять без правильного verifier в согласованном PKCE-потоке. Однако PKCE не подтверждает роль пользователя и не заменяет проверку результата OIDC. Даже правильный обмен ещё нужно связать с accepted issuer, claims и ожидаемым nonce, прежде чем создать локальную сессию.
Случайный id попытки генерируется отдельно. Он помещается в cookie, а реальные протокольные значения лежат на сервере. Это облегчает одноразовое использование и ограничение срока. Если положить verifier прямо в доступный JavaScript storage, расширится число мест, которые могут его прочитать. Наш серверный пример не требует этого. Cookie имеет HttpOnly, SameSite=Lax, Path=/ и Max-Age. В локальном HTTP нет Secure, что является явной границей лаборатории; настоящий HTTPS-профиль должен добавить Secure и согласовать область cookie.
Перенаправление и scope
Функция buildAuthorizationUrl получает redirect_uri, scope, state, nonce, challenge и метод. Scope включает openid, profile и email. Openid обозначает OIDC-контракт личности; profile и email запрашивают предусмотренные сведения. Запрос scope не гарантирует, что приложение получит любые произвольные поля. Провайдер решает, что возвращать согласно конфигурации. Роли editor появятся позже через отдельный mapper, а доступ к операции всё равно проверит сервер. Нельзя выводить полномочия из самого факта, что строка scope упоминала знакомую тему.
Приложение отвечает браузеру 303 и Location с готовым URL. Мы не собираем query конкатенацией имён и значений вручную. Библиотека корректно формирует протокольные параметры и фиксирует response type authorization code. Redirect URI совпадает с зарегистрированным адресом /callback. Его origin берётся из trusted config, а не из текущего Host. В этой цепочке есть несколько разных URL, но каждый имеет назначение: app origin, issuer, authorization endpoint и callback. Смешение их названий часто затрудняет диагностику даже простого стенда.
Срок и несколько вкладок
Запись попытки имеет until на пять минут. Это короткий срок незавершённой операции, не срок пользовательского access token или общей SSO-сессии. Если человек оставил страницу входа надолго, callback может оказаться просроченным и нужно начать заново. Такой отказ понятнее бесконечного хранения verifier. При любом успешном или неуспешном завершении приложение позже удалит запись попытки. Одноразовость снижает риск повторного использования и делает состояние входа проще для разбора.
Pending cookie одна на приложение. Если открыть две новые попытки в разных вкладках, вторая заменит ссылку на первую. Возврат первой может быть отвергнут, потому что её параметры не соответствуют текущей pending записи браузера. Это безопасное, но не максимально удобное ограничение лаборатории. Настоящий интерфейс может хранить несколько ожидаемых попыток с отдельным связыванием, однако нельзя ради удобства отключать expectedState. Сначала определите модель вкладок, затем поддерживайте её явно, сохраняя проверку одноразовых значений.
В sessions.mjs уборка просроченных записей выполняется при запросах. Это достаточная простая стратегия локального процесса, но память attempts может расти между редкими запросами без отдельного фонового обслуживания и предела количества. В публичном сервисе понадобится общий store, TTL и защита от слишком многих begin-запросов. Наличие случайных id не является ограничением нагрузки. Мы пока строим последовательную лабораторию протокола; масштаб и злоупотребления станут частью отдельного эксплуатационного контракта.
Что увидит читатель
Полный oidc.mjs ниже уже содержит функцию finish для следующего состояния, но route callback в текущем сервере её ещё не вызывает: он показывает сообщение о будущем обмене. Это позволяет читать общий модуль, не выдавая попытку за завершённый вход. При самостоятельном открытии /login ожидается redirect к Keycloak и новая pending cookie. Сам браузерный маршрут здесь не выполнялся. Если провайдер уже знает пользователя по SSO, парольная форма может не появиться; это не означает отсутствие новой OIDC-попытки приложения.
Посмотрите адрес перенаправления: в нём должны быть challenge, state, nonce и exact redirect_uri, но не verifier. Не копируйте целые действующие callback URL и cookies в публичный журнал или снимок для статьи. Учебный разбор можно провести по названиям параметров и исходнику. В следующем уроке обменяем полученный code, предъявим verifier и проверим ожидаемые значения через библиотеку. Только после этого приложение сможет считать claims результатом принятого входа, а не строкой, которую браузер принёс по удобному пути.
Если человек нажал кнопку повторно после неудачного возврата, создаётся новая попытка, а не продлевается старый verifier. Это помогает отделить повтор пользовательского намерения от повторного использования протокольного материала. Для диагностики сравнивайте безопасный идентификатор операции и срок, сохраняя сами verifier и nonce вне обычного журнала.
Полный файл oidc.mjs из состояния lesson-04:
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 };
}
Полный файл sessions.mjs из состояния lesson-04:
import { randomBytes, timingSafeEqual } from 'node:crypto';
export const sessions = new Map();
export const attempts = new Map();
export const random = () => randomBytes(32).toString('base64url');
export function cookies(req) {
return Object.fromEntries((req.headers.cookie || '').split(';').map(x => x.trim())
.filter(x => x.includes('=')).map(x => { const at = x.indexOf('='); return [x.slice(0, at), x.slice(at + 1)]; }));
}
export function cookie(name, value, seconds = 900) {
return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${seconds}`;
}
export function current(req, name) {
const now = Date.now();
for (const [id, value] of sessions) if (value.until <= now) sessions.delete(id);
for (const [id, value] of attempts) if (value.until <= now) attempts.delete(id);
const id = cookies(req)[name];
return { id, value: sessions.get(id) };
}
export function equal(left, right) {
if (typeof left !== 'string' || typeof right !== 'string') return false;
const a = Buffer.from(left), b = Buffer.from(right);
return a.length === b.length && timingSafeEqual(a, b);
}
Оглавление серии · Исходники всех шагов · Учебные маршруты
Пример остаётся локальным черновиком. При подготовке он не выполнялся; все описанные эффекты являются ожидаемыми, а не результатами запущенных проверок.