Files
ds-galo/openspec/changes/metadata-managed-jwt-secret/design.md
T
iu cb272398ce feat(auth): store the token signing secret in the metadata layer
- app_settings table (PostgreSQL migration 000010, SQLite 000002)
- internal/settings repository (Get/Set/Ensure) wired into store.Repos
- secret generated with crypto/rand on first start and persisted; reused on restart
- drop required JWT_SECRET from env/config; app.New takes a TokenManager and
  storage.Open takes the resolved secret (URL_SECRET still overrides)
- docs updated
2026-10-11 10:18:31 +03:00

5.0 KiB

Design

Context

См. proposal.md — Why. Текущее состояние:

  • JWT_SECRET обязателен в env (config.Load), используется в auth.NewTokenManager (HS256-подпись access-токенов) и как fallback для URL_SECRET в storage.Open.
  • Refresh-токены не подписываются секретом (случайные, хранятся хэшем).
  • Есть два бэкенда метаданных (postgres, sqlite) за интерфейсом Repository и фабрикой store.NewPostgres/NewSQLite; миграции раздельные (pg-набор и migrations/sqlite).
  • Порядок старта в cmd/server: миграции → метаданные → storage → app.

Goals / Non-Goals

Goals:

  • Хранить секрет приложения в слое метаданных; генерировать при первом использовании.
  • Убрать обязательный JWT_SECRET из окружения.
  • Единый стабильный секрет на инсталляцию; корректное поведение при конкурентном старте.

Non-Goals:

  • Шифрование секрета в БД (БД доверенная).
  • UI/команда ротации секрета.
  • Миграция существующих env-секретов в БД.
  • Изменение формата токенов или наблюдаемого поведения аутентификации для клиента.

Decisions

D1. Таблица app_settings

app_settings(key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at, updated_at) в обоих бэкендах. Ключ jwt_secret. Отдельные миграции: PostgreSQL 000010_app_settings, SQLite 000002_app_settings (консолидированная 000001 уже применена на существующих БД).

D2. Репозиторий internal/settings

Интерфейс Repository { Get(ctx,key) (value string, ok bool, err error); Set(ctx,key,value) error; Ensure(ctx,key, generate func() (string,error)) (string, error) }. Реализации pg и sqlite. Ensure: SELECT → если есть, вернуть; иначе сгенерировать, INSERT ... ON CONFLICT(key) DO NOTHING, затем SELECT (единый секрет при гонке). Добавляется в store.Repos.

D3. Генерация секрета

crypto/rand 32 байта → base64url (без padding). Значение хранится как строка; в TokenManager и Signer передаётся как есть.

D4. Порядок старта и проводка

cmd/server: миграции → метаданные → secret := repos.Settings.Ensure("jwt_secret", generate) → tm := auth.NewTokenManager(secret, accessTTL, refreshTTL) → storage.Open(ctx, cfg, secret) (где URL_SECRET, если задан, имеет приоритет, иначе используется secret) → app.New. app.New перестаёт строить TokenManager из конфига и принимает готовый *auth.TokenManager (или секрет).

D5. Конфиг

Поле Auth.JWTSecret и проверка обязательности удаляются; JWT_ACCESS_TTL/JWT_REFRESH_TTL остаются. URL_SECRET остаётся необязательным. storage.Open получает секрет параметром вместо чтения из конфига.

Risks / Trade-offs

  • [Смена источника секрета у существующих деплоев] → access-токены инвалидируются, но refresh-токены остаются валидными (не подписаны секретом), клиент молча обновляет access; в худшем случае — перелогин. Отмечаем в доках.
  • [Конкурентный первый старт на общей БД] → INSERT ... ON CONFLICT DO NOTHING + повторный SELECT (D2).
  • [Секрет в открытом виде в БД] → БД доверенная; шифрование — вне рамок.
  • [Тихая смена поведения] → при URL_SECRET не задан подпись URL зависит от секрета из БД; явный URL_SECRET снимает зависимость.

Migration Plan

  1. Добавить таблицу и репозиторий секретов; сид отсутствует — генерируется при старте.
  2. Переключить cmd/server/app/storage на секрет из метаданных; убрать JWT_SECRET.
  3. Обновить доки и .env.example.
  4. Откат: вернуть чтение JWT_SECRET из env (код), данные не меняются.

Open Questions

  • Нужна ли операция ротации секрета (сброс значения и инвалидация access-токенов).