Files
transcriber/openspec/changes/archive/2026-08-12-oidc-login/design.md
T
av c44f0e7582 HTTP API закрыт за вход через OIDC у Authelia
- шаг схемы закрывает поверхность, которую хранилище приносит открытой:
  собственную регистрацию, вход по паролю и одноразовый код — без этого
  закрытие приёма обходилось двумя запросами
- продление сессии выключено, срок семь суток: иначе отзыв доступа у
  провайдера до сервиса не доходит никогда
- файл записи отдаётся вошедшему по токену файла — пересмотр
  ADR-2026-08-12-file-link-open-but-not-logged
2026-08-12 17:44:22 +03:00

23 KiB
Raw Blame History

Context

Сегодня HTTP API открыт наружу без проверки — так записано первой строкой модели угроз. Приглашение второго человека упирается в это: у записей нет владельца, а подобранный идентификатор задачи отдаёт чужую расшифровку.

Решение от 2026-08-11 (ADR-2026-08-11-pocketbase-storage-with-admin-panel) назвало способ: ответ провайдера разбирает хранилище, а не наш код. У коллекции пользователей включается провайдер oidc с адресами Authelia, учётные записи заводятся сами, и панель их видит. Проверено на версии 0.39.10 — docs/research/pocketbase.md, раздел «Пользователи — только те, кого туда положат».

Способ остаётся верным, но его механика уже проверена по исходникам библиотеки, и три ожидания постановки она не подтверждает. Проверено чтением pocketbase@v0.39.10:

  1. Куки библиотека не читает вовсе. Сессию она берёт единственным способом — заголовком Authorization (apis/middlewares.go, getAuthTokenFromRequest). Критерий приёмки задачи написан про куку.
  2. Эндпоинта выхода библиотека не приносит. Список её адресов аутентификации — auth-methods, auth-refresh, auth-with-password, auth-with-oauth2, request-otp, auth-with-otp, восстановление пароля, подтверждение почты и смена почты (apis/record_auth.go). Выхода среди них нет.
  3. Браузерный вход по редиректу библиотека своим не приносит. Она приносит обмен уже полученного кода: POST /api/collections/{c}/auth-with-oauth2 требует provider, code, codeVerifier и redirectURL. Её собственный /api/oauth2-redirect служит другому: он ищет клиента realtime-подписки по параметру state и отдаёт код туда (apis/record_auth_with_oauth2_redirect.go) — это механика её JS-клиента с всплывающим окном, а не серверный вход.

Отсюда объём: инициировать вход, принять возврат и завести куку — наш код. Разбор ответа провайдера, заведение учётной записи и связь с внешним провайдером остаются за хранилищем, как и решено. Решение 2026-08-11 не пересматривается.

Goals / Non-Goals

Goals:

  • запрос к приёму записи и к опросу готовности без сессии получает отказ и ничего не заводит;
  • вход идёт у Authelia по OIDC, учётные записи заводятся сами;
  • выход закрывает доступ немедленно, а не по истечении срока;
  • сессия переживает выкладку;
  • проба здоровья и метрики остаются открытыми.

Non-Goals:

  • владелец у записи и сужение выборки по нему — задача record-ownership;
  • вход для программ по личным токенам — отдельная цель роадмапа. Внешняя программа, ходившая в API анонимно, этим изменением ломается намеренно, и замены ей здесь не появляется;
  • белый список Telegram — живёт до telegram-account-link;
  • панель администратора — в неё провайдер не пускает, наружу её закрывает обратный прокси;
  • своя страница входа со скриптом: у сервиса нет фронтенда, и заводить его ради входа незачем.

Decisions

Сессия предъявляется кукой, а заголовок остаётся внутренним

Выбрано: наш обработчик возврата ставит куку HttpOnly, Secure, SameSite=Lax со значением, выданным хранилищем. Промежуточный слой перед проверкой перекладывает значение куки в заголовок Authorization, если заголовка нет. Дальше работает штатная проверка библиотеки.

Человек увидит обычный вход: перешёл, авторизовался у Authelia, вернулся — работает. Ни строки скрипта на его стороне.

Отвергнуто:

  • заголовок Authorization как единственный способ. Это механика библиотеки и путь наименьшего кода, но браузер такой заголовок сам не шлёт: понадобился бы свой фронтенд, который держит значение и подставляет его. Фронтенда у сервиса нет, а заводить его ради входа — работа шире задачи. Критерий приёмки задачи вдобавок написан про куку;
  • своя таблица сессий. Даёт полный контроль над выходом и сроком, но заводит второй способ делать то, что хранилище уже делает, — и второй дом для факта «кто вошёл». Отвергнуто по концептуальной целостности.

Заголовок при этом остаётся рабочим: его требуют собственные адреса аутентификации хранилища, и глушить их значит ломать библиотеку изнутри. Это осознанно оставленная вторая дверь, и она названа в спеке.

Форма решения выбрана из трёх, а не из одной

Прежде трёх решений ниже — выбор самой формы. Рассматривались три.

A — свой тонкий слой входа поверх хранилища. Выбрана. Наш код ведёт флоу и ставит куку, обмен кода и заведение учётной записи остаются за хранилищем. Цена: сверка состояния и установка куки — наша ответственность, то есть ошибки в чувствительном месте наши.

B — вход целиком на обратном прокси. Authelia стоит перед сервисом и не пускает неузнанные запросы, приложение доверяет заголовку от прокси. Нашего кода почти ноль. Отвергнуто по трём причинам сразу: при прямом обращении к порту заголовок подделывает кто угодно в той же сети, а сервис не имеет способа отличить прокси от постороннего; учётные записи в панели не появляются вовсе, а решение 2026-08-11 требует обратного; вход для программ по личным токенам из этой формы не вырастает — его пришлось бы делать заново и мимо.

C — фронтенд и штатный клиент хранилища. Своя страница, всплывающее окно, подписка, значение сессии в хранилище браузера. Всё штатно для библиотеки. Отвергнуто: у сервиса нет фронтенда, и заводить его ради входа — работа шире задачи; значение сессии становится доступно скриптам страницы, то есть XSS уносит сессию целиком, тогда как кука с запретом чтения скриптом этого не даёт.

Вход и возврат ведёт наш код, разбор ответа — хранилище

Выбрано: три своих адреса — начало входа, возврат от провайдера, выход. Начало входа заводит state и PKCE-verifier, кладёт их во временную куку и уводит человека на authURL провайдера. Возврат сверяет state, а код отдаёт хранилищу вызовом его же обмена — тем, что стоит за auth-with-oauth2.

Обмен кода библиотека наружу не отдаёт — он живёт неэкспортированной функцией за собственным адресом хранилища. Решением владельца от 2026-08-12 обработчик возврата зовёт этот адрес внутри процесса, через роутер хранилища, а не по сети.

Цена названа и принята: получается петля «наш обработчик → наш роутер → наш обработчик», ответ разбирается текстом, а типизированная ошибка теряется. Взамен решение 2026-08-11 соблюдается дословно — разбор ответа провайдера остаётся за хранилищем, и учётные записи видны в панели.

Отвергнуто:

  • собрать обмен своими руками из кусков, которые библиотека всё же отдаёт. Прямой код без петли, но разбор ответа провайдера переезжает к нам — это пересмотр решения 2026-08-11 отдельным ADR, и владелец его не выбрал;
  • всплывающее окно и realtime-подписка, как делает JS-клиент библиотеки. Работает без нашего кода вовсе, но требует того самого фронтенда и держит открытым realtime-соединение ради одного входа.

Кого пускать, решает провайдер, а не сервис

Решением владельца от 2026-08-12 сервис своей проверки допуска не делает: кто допущен, определяет правило Authelia на этого клиента. Всякий, кого провайдер пропустил, получает учётную запись и доступ.

Цена принята и обязана быть записанной: правило живёт вне репозитория, в настройках выкладки, и сервис на него полагается так же, как полагается на обратный прокси в части панели администратора. Настроенный слишком широко клиент открывает сервис всем, у кого есть учётная запись в общей Authelia, — и проверить это по коду нельзя. Строка об этом идёт в docs/security.md, раздел «Что разграничивает доступ».

Отвергнуто: проверка группы своим кодом — защита стояла бы в сервисе и не зависела от настройки контура, но владелец выбрал не заводить второе место, где решается допуск.

Выход обесценивает выданные сессии, а не только убирает куку

Выбрано: выход обновляет ключ токенов учётной записи (Record.RefreshTokenKey() плюс сохранение) и убирает куку. Подпись сессии считается от этого ключа, поэтому все прежние значения перестают проходить разом.

Отвергнуто:

  • только уборка куки. Унесённое значение продолжало бы открывать доступ до истечения срока — то есть выход не закрывал бы доступ, а делал вид;
  • чёрный список выданных значений. Даёт точечный выход одной сессии, но требует своей таблицы и её чистки; при одном человеке и одном браузере это цена без покупателя.

Цена выбранного названа прямо: выход закрывает все сессии учётной записи, а не только текущую. При сегодняшнем числе пользователей это незаметно, и переделка, когда станет заметно, — чёрный список из отвергнутого варианта.

Сессия переживает перезапуск сама

Проверено по исходникам: подпись считается от секрета коллекции (Collection().AuthToken.Secret) и ключа записи, оба лежат в базе (core/record_query.go, FindAuthRecordByToken). Значит требование выполняется устройством хранилища, и нашей работы здесь нет — есть проверка тестом.

Настройки провайдера приводятся к конфигу при каждом запуске

Выбрано: шаг схемы включает провайдера с пустыми значениями, а адреса, идентификатор клиента и секрет проставляются при подъёме сервиса из конфига.

Причина в инварианте: применённый шаг схемы не переписывается. Проставь секрет однажды шагом — и ротация секрета в конфиге до хранилища не доедет вовсе, вход сломается после смены ключа, а починить это можно будет только руками в панели.

Отвергнуто:

  • секрет в шаге схемы. Разбито инвариантом выше;
  • настройка руками в панели. Работает, но не воспроизводится: поднятый с нуля сервис оказывается без входа, и знание живёт в голове владельца.

Что изменило ревью кода

Три решения приняты владельцем 2026-08-12 уже после того, как код был написан: ревью нашло, что заявленное поведение не работает.

Продление сессии выключено. Хранилище выдаёт сессию продлеваемой, и предъявитель менял своё значение на новое бессрочно, никуда не входя. При живом продлении семисуточный срок не значил ничего — а он объявлен единственным каналом, которым отзыв доступа у провайдера доходит до сервиса. Цена: вход раз в семь суток. Отвергнуто: сверяться с провайдером по расписанию (новая связь с Authelia и обработка её недоступности — работа шире задачи) и принять как есть (тогда паспорт теряет способ остановить того, кто тратит слишком много).

Файл записи открыт вошедшим. Пометка поля защищённым сама по себе закрыла файл вообще для всех, кроме владельца панели: защищённый файл судится ещё и правилом просмотра коллекции, а незаданное правило означает «только суперпользователь». Назначено правило для всякого узнанного. Отвергнуто: оставить файл только панели — тогда задача про прослушивание записи начинается с того же вопроса.

Форма адреса провайдера проверяется на старте. Непустая, но негодная строка проходила проверку конфига и отвергалась хранилищем позже — из хука подъёма, до регистрации пробы здоровья. Сервис падал целиком, вместе с ботом и воркерами, а у владельца не было даже кода состояния. Отвергнуто: поднимать пробу здоровья раньше настройки провайдера — это завело бы состояние «сервис жив, вход сломан», которого спека не описывает.

Risks / Trade-offs

  • Секрет клиента появляется в новом месте — в базе. → Инвариант проекта запрещает секрету попадать в git, в лог, в ответ и в error_text; база в этом перечне не значится, и запрета не нарушает. Но место новое, и модель угроз обязана его назвать: чтение файла базы теперь равносильно чтению секрета клиента. Пишется в docs/security.md этой же задачей.
  • Ломается внешняя программа, ходившая в API анонимно. → Ломка намеренная и объявлена в предложении: это и есть предмет задачи. Замены для программ (личные токены) в этом изменении нет — она отдельной целью.
  • Вторая дверь: заголовок Authorization остаётся принимаемым. → Он предъявляет ту же сессию и той же проверке, поэтому обхода не даёт. Но это второй способ войти, и в спеке он назван, чтобы не был обнаружен ревью как находка.
  • Выход закрывает все сессии учётной записи. → Названо решением выше, цена принята.
  • PKCE-verifier и state живут во временной куке. → Кука ставится на время входа, HttpOnly и SameSite=Lax, и убирается на возврате. Хранить их в памяти процесса нельзя: выкладка посреди входа роняла бы вход.
  • Признак Secure закрывает локальный запуск. → Браузер не сохранит такую куку по http://localhost, и вход перестанет работать у того, кто поднимает сервис командой из раздела команд. Признак берётся из конфига с умолчанием «включено», и расхождение образца называется строкой в docs/conventions/config.md.
  • Коллекция пользователей остаётся умолчательной users. → Своя коллекция означала бы задание правил и способов входа с нуля вместо подчистки умолчаний, а переезд позже — перевязку связей с провайдером и обесценивание всех выданных сессий. Цена умолчательной: её заводит системный шаг библиотеки с открытым созданием записи, и закрывать это приходится нам.
  • Проверить вход целиком без живой Authelia нельзя. → Тесты закрывают сверку state, отказ без сессии, выход и сохранность сессии; живой вход у провайдера остаётся ручной проверкой владельца на выкладке. Это граница покрытия, и она называется в докладе.

Migration Plan

Шаг схемы включает провайдера у коллекции пользователей и накатывается при подъёме, как и прежние шаги. Данных он не трогает: ни одной записи не переписывается, учётные записи заводятся сами при первом входе.

Откат — прежний образ: шаг схемы обратим своим down, а до первого входа в коллекции пользователей пусто.

Порядок выкладки: сперва завести клиента в Authelia и получить секрет, потом положить его в конфиг на сервере, потом выкладывать. Обратный порядок поднимает сервис с провайдером без секрета — вход не работает, а API уже закрыт.

Open Questions

  • Адрес возврата должен совпадать с тем, что записан клиенту в Authelia. Значение выбирается при заведении клиента и попадает в конфиг; здесь оно не фиксируется.