Вход через провайдера OpenID Connect и чистого OAuth 2.0 для OneScript /
ОСени. Authorization Code Flow + PKCE (S256): discovery, URL авторизации, обмен
кода на токены (server-to-server), userinfo, проверка клеймов id_token, выход
у провайдера. Для провайдеров без OIDC (GitHub) — OAuth2-ветка: ручные конечные
точки, профиль из userinfo, двухшаговый добор подтверждённого email. Пресеты
GitHub / GitLab / Google.
Публичный интерфейс — один класс и пять методов. Всё остальное лежит в
src/internal и меняется без оглядки на обратную совместимость.
Вход = Новый ВходЧерезПровайдера(ДанныеПровайдера);
Начало = Вход.Начать(); // → URL + Транзакция
Итог = Вход.Завершить(Транзакция, Код, Состояние); // → исход входа
Адрес = Вход.АдресВыхода(ИдТокен, АдресПосле); // → URL выхода или ""
Данные = Вход.Описание(); // → разрешённое описаниеОдин объект — один провайдер. HTTP целиком на 1connector.
Библиотека не хранит ничего. Она знает протокол и порядок проверок. Всё остальное — приложение:
| Приложение | Библиотека |
|---|---|
| какие провайдеры настроены, их метки, значки, порядок кнопок | что нужно, чтобы сходить к провайдеру |
| где живёт транзакция между двумя запросами и когда её гасить | что положить в транзакцию |
| куки, редиректы, HTTP-ответ — браузером двигает приложение | сборка URL, на который увести браузер |
| кто этот человек, заводить ли его, что ему можно | кем он представился провайдеру |
| логирование | ничего не логирует |
Браузер открывает приложение, всегда: в веб-сценарии он вообще на другой машине, а редиректом владеет HTTP-ответ. Библиотека может только отдать URL — отсюда два вызова вместо одного, и это протокол, а не выдумка библиотеки.
Подпись id_token (RS256 и др.) локально НЕ проверяется. Это граница задачи
библиотеки, а не ограничение платформы: проверка RS256 по ключам из JWKS на
чистом OneScript существует — её делает
jwt-verify. Здесь модель доверия
строится на канале: токен берётся напрямую от token endpoint по TLS.
- Доверять
id_tokenможно ТОЛЬКО когда он получен напрямую от token endpoint по TLS (server-to-server) — как внутриЗавершить. Канал TLS плюс прямой ответ провайдера заменяют проверку подписи: подменить токен «в полёте» нельзя. - Никогда не доверяйте
id_token, пришедшему косвенно (из фронтенд-редиректа, из тела запроса от клиента): для таких сценариев нужна проверка подписи по ключам изjwks_uri— возьмитеjwt-verify. Метода «проверь мне вот этот токен» в библиотеке нет намеренно: он был бы готовым ружьём. - Полезная нагрузка сверяется всегда:
iss(побайтно),aud(входит лиclient_id),azp(если клейм есть — обязан указывать на нас),exp(с допуском на расхождение часов),nbfиnonce. - Ответ
userinfoпринимается, только если егоsubсовпадает сsubизid_token(OIDC Core §5.3.2). Не совпал — профиль отбрасывается, вход продолжается на клеймахid_token. - Ответ
userinfoбезsubпри этом принимается целиком. Клейм по спецификации обязателен, но реальные провайдеры (Authentik, Gitea-подобные) его не отдают, а сам ответ получен по токену доступа ЭТОГО же входа. Проверка ловит подмену профиля, а не его неполноту.
PKCE защищает сам код авторизации: обменять его на токены сможет только владелец
исходного code_verifier.
Библиотека ничего не логирует. Ни client_secret, ни code, ни токены не
попадают ни в лог, ни в текст исключений. Одна оговорка: когда тело ответа
провайдера не разбирается как JSON, в сообщение подмешивается платформенное
ОписаниеОшибки(), а оно может процитировать фрагмент этого тела. Завершить
такое исключение гасит и превращает в exchange_failed, ничего не записывая.
opm install oidc-client.ЗависитОт("oidc-client", "0.5.1")// --- настройка, один раз ---
Описание = Новый Структура();
Описание.Вставить("Имя", "keycloak");
Описание.Вставить("Издатель", "https://keycloak.example.com/realms/main");
Описание.Вставить("ИдентификаторКлиента", "openhub");
Описание.Вставить("СекретКлиента", "s3cr3t");
Описание.Вставить("АдресВозврата", "https://hub.example.com/oidc/callback");
Описание.Вставить("Область", "openid profile email groups");
Вход = Новый ВходЧерезПровайдера(Описание);
// --- GET /login/keycloak ---
Начало = Вход.Начать();
Ключ = МоёХранилище.Сохранить(Начало.Транзакция); // сессия, кэш, БД — ваше дело
Ответ.УстановитьКуку(Ключ, "HttpOnly; Secure; SameSite=Lax");
Ответ.Перенаправить(Начало.URL);
// --- GET /oidc/callback?code=…&state=… ---
Транзакция = МоёХранилище.Прочитать(КлючИзКуки);
Если Транзакция = Неопределено Тогда
Возврат СтраницаВойтиЗаново(); // ссылка устарела — штатный случай
КонецЕсли;
Итог = Вход.Завершить(Транзакция, Запрос.Параметр("code"), Запрос.Параметр("state"));
МоёХранилище.Удалить(КлючИзКуки); // ВСЕГДА, в любом исходе: транзакция одноразовая
Если НЕ Итог.Успех Тогда
// Итог.Причина: state_mismatch | unknown_provider | exchange_failed
// | id_token_invalid | no_subject
Возврат;
КонецЕсли;
// связка «пользователь ↔ личность провайдера» — по паре Издатель + Субъект| Поле | Что внутри |
|---|---|
Успех |
Булево |
Причина |
"" либо код отказа |
Провайдер |
имя из конфигурации |
Субъект |
устойчивый идентификатор у провайдера |
Издатель |
issuer ИЗ КОНФИГУРАЦИИ (для OIDC уже сверен с iss) |
Email, EmailПодтверждён |
вычислены (см. ниже) |
Клеймы |
сырьё от провайдера |
Токены |
сырой ответ token endpoint (access_token, id_token, …) |
ПрофильПолучен |
удался ли userinfo |
Клеймы — то, что приехало: для OIDC клеймы id_token, поверх них userinfo;
для OAuth2 — userinfo. Оттуда берите groups, preferred_username, name,
picture и всё прочее. Имя клейма групп ни в одном стандарте не закреплено —
держите его в своей конфигурации.
Вычисляются ровно три поля, и только потому, что приложению их не повторить:
Субъект, Email и EmailПодтверждён. У GitHub субъект зовётся id (а не
login, который переименовывается), а подтверждённый адрес живёт во второй
точке /user/emails.
Пресеты OAuth2 дополнительно дописывают в Клеймы стандартные имена OIDC
поверх сырых (sub ← id, preferred_username ← login, picture ←
avatar_url). Сырые поля остаются на месте: библиотека добавляет словарь, а не
переписывает ответ провайдера. Смысл — читать клеймы одинаково у всех.
state— сравнением, безопасным по времени, и ДО любого обращения к сети. Иначе по чужому редиректу приложение само сходит к провайдеру и обменяет подсунутый код. Пустое ожидаемое значение совпадением не считается;- тот ли провайдер (
unknown_provider) — объект ведёт одного, и завершать им чужой вход нельзя; - обмен кода на токены (
exchange_failed) — сюда же попадают беды discovery.
Дальше ветки расходятся — это перестановка шагов, а не пара условий.
OIDC (id_token обязателен): проверка id_token (id_token_invalid) →
наличие sub (no_subject) → userinfo с деградацией: недоступный или
отдавший чужой sub профиль не отменяет вход.
OAuth2 (id_token не существует): userinfo без деградации — субъект
живёт только здесь, недоступный профиль означает no_subject → наличие субъекта
по маппингу профиля.
Завершить не бросает исключений на исходах протокола: «вход не удался» —
штатное событие веб-страницы, а не авария.
Точки = Новый Структура();
Точки.Вставить("Авторизация", "https://idp.example.com/authorize");
Точки.Вставить("Токен", "https://idp.example.com/token");
Точки.Вставить("Профиль", "https://idp.example.com/userinfo");
Точки.Вставить("Выход", "https://idp.example.com/logout");
Описание.Вставить("КонечныеТочки", Точки);Ещё принимаются Ключи (jwks_uri) и Emails (вторая точка добора email), а
также сырые имена discovery. Для Тип = "oauth2" первые три обязательны.
Адрес = Вход.АдресВыхода(ИдТокенИзМоейСессии, "https://hub.example.com/");
Если ЗначениеЗаполнено(Адрес) Тогда
Ответ.Перенаправить(Адрес); // погасить сессию и у провайдера
КонецЕсли;
// свою сессию приложение гасит в любом случае самоRP-Initiated Logout 1.0: end_session_endpoint из discovery плюс
id_token_hint, post_logout_redirect_uri и client_id. Возвращает пустую
строку, а не исключение, если точки нет (у OAuth2 её не бывает никогда) или
провайдер недоступен — это штатный случай, а не авария.
id_token для id_token_hint возьмите из Итог.Токены на входе и сохраните в
своей сессии.
- SHA-256 — встроенный
ХешированиеДанных. - base64url — свой: url-safe алфавит без дополнения (иначе PKCE-вызов и сегменты JWT не сойдутся). Декодер лоялен к стандартному алфавиту и дополнению.
- CSPRNG —
УникальныйИдентификатор(системный CSPRNG .NET) с отбеливанием SHA-256; встроенныйГенераторСлучайныхЧисел(обёрткаSystem.Random) сознательно не используется. - Аутентификация клиента —
client_secret_post(по умолчанию),client_secret_basic(RFC 6749 §2.3.1:client_idи секрет url-кодируются до base64, иначе секрет с:ломает разбор) иnone.private_key_jwtне реализован: нужна подпись RSA/EC, которой на чистом OneScript нет.
MIT — см. LICENSE.