Skip to content

JWT и сервисная авторизация

VideoGrace использует bearer-token модель для всех runtime-каналов. CommandLoop выполняет первичную авторизацию, а затем тот же access_token используется для BlobChannel, WSMedia и REST API.

Runtime flow

sequenceDiagram
    participant Client
    participant Core as VideoGrace Server
    participant DB
    participant Media as Blob/WSMedia/API

    Client->>Core: connect_request(login/password)
    Core->>DB: load user + grants
    Core-->>Client: connect_response(access_token, bootstrap refresh_token)
    Client->>Core: POST auth_refresh(login, cookie_slot, bootstrap token)
    Core-->>Client: access_token + Set-Cookie(HttpOnly refresh) + rotated fallback
    Client->>Media: Authorization/connect_request access_token
    Media->>Core: validate token
    Core-->>Media: client_id + grants
    Client->>Core: POST /api/v1.0?auth_refresh + HttpOnly cookie (preferred)
    Core->>DB: validate refresh token hash
    Core-->>Client: new access_token + rotated Set-Cookie
    Client->>Core: POST /api/v1.0?auth_logout
    Core->>DB: revoke access jti + refresh hash

access_token выпускается как JWT HS256. Штатный установщик создаёт и сохраняет [Auth] JwtSecret, если не задан VG_JWT_SECRET. Сам сервер secret не генерирует: для ручной установки отсутствие настройки остаётся явной ошибкой. Legacy opaque token остаётся только переходным механизмом для уже открытых внутренних каналов; новый код не должен зависеть от него.

Access JWT

JWT содержит только данные, нужные для быстрой авторизации:

Claim Значение
iss VideoGrace.
aud videograce.
typ access.
sub client_id.
login Логин пользователя.
name Отображаемое имя.
grants Битовая маска прав.
scope Каналы и API scopes.
jti ID токена для точечного revoke.
iat / exp Время выпуска и истечения.

Проверка JWT включает:

  • разбор трех частей JWT;
  • проверку alg=HS256;
  • HMAC-SHA256 подпись через серверный secret;
  • проверку iss, aud, typ, обязательных sub, iat, exp, jti, scope;
  • проверку времени выпуска и истечения;
  • проверку требуемого scope для конкретного канала;
  • проверку jti по таблице jwt_revoked;
  • загрузку актуального пользователя из БД только по положительному sub.

Клиентам не нужно читать claims. Для клиента token остается непрозрачной строкой.

Refresh token

refresh_token - случайный одноразовый секрет, не JWT. Сервер хранит в БД только HMAC-хеш refresh token и TTL. Это позволяет:

  • не хранить access JWT в БД;
  • продлевать пользовательскую сессию без повторного пароля;
  • ротировать refresh token при каждом успешном обновлении;
  • отозвать refresh token без хранения его открытого значения;
  • не раскрывать пароль сервисам и браузерным каналам.

При первичном входе по паролю WebSocket возвращает bootstrap refresh_token. Web-client немедленно обменивает его через same-origin HTTP endpoint на отдельную для аккаунта cookie vg_refresh_<cookie_slot> с атрибутами Secure; HttpOnly; SameSite=Strict. Cookie является основным хранилищем сессии.

Последующие обновления используют cookie, которую JavaScript прочитать не может:

POST /api/v1.0?auth_refresh
Content-Type: application/json

{
  "login": "alice",
  "cookie_slot": "stable-non-secret-account-slot",
  "include_refresh_fallback": true
}

Ответ:

{
  "result": 200,
  "access_token": "...",
  "refresh_token": "rotated-revocable-fallback",
  "refresh_cookie": true,
  "expires_in": 604800,
  "token_type": "Bearer"
}

Успешный запрос атомарно отзывает старый refresh token и выпускает новый. По умолчанию он возвращается только в Set-Cookie. Web-client передает include_refresh_fallback=true, поэтому тот же новый token также возвращается в JSON и сохраняется как резерв для Android WebView и других профилей, которые теряют cookie между запусками. При восстановлении клиент сначала пробует HttpOnly cookie и использует fallback только после 401; успешный fallback одновременно заново устанавливает cookie и ротирует token. Повторное использование старого token получает 401. cookie_slot не является секретом: он позволяет держать несколько аккаунтов одного сервера в одном браузере, не смешивая их cookie.

Cookie-auth endpoints принимаются только с того же origin, что и сервер. Credentialed CORS для них намеренно запрещен.

Logout

POST /api/v1.0?auth_logout
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "login": "alice",
  "cookie_slot": "stable-non-secret-account-slot"
}

Сервер отзывает jti access JWT и refresh token cookie одной сессии, затем очищает cookie через Max-Age=0. Если access JWT уже истек, достаточно действующей cookie.

Scope enforcement

Scope Где проверяется
control Вход в CommandLoop.
media Вход в WSMedia.
blob BlobChannel и storage blob/upload API.
api Основной REST API.
integration:rooms Partner Integration API комнат.

Обычный пользовательский token получает control, media, blob, api. Service token получает scopes из service_accounts; отсутствие нужного scope не компенсируется уже открытой control-сессией.

Для CAN service accounts прикладные scopes разворачиваются в транспортные: conference:join и chat:write требуют control, media:render требует control + media, storage:write требует api + blob.

Партнерскому Integration API account для скачивания записей из recording.ready нужны integration:rooms + blob. Scope blob разрешает обращение к storage API, но не открывает чужие файлы: отдельно проверяется ACL записи и владелец комнаты. После изменения scopes надо получить новый access token; старые JWT не меняются.

Browser session

Web-client использует пароль только для первичного обмена на token pair. После успешного входа:

  • localStorage.vg_sessions_v2 содержит компактные несекретные метаданные аккаунтов и отметку о выданной server-side cookie; cookie_slot детерминированно вычисляется из server + login и не сохраняется;
  • sessionStorage.vg_session_tokens_v1 содержит короткий access token и текущую копию refresh token для активной вкладки;
  • localStorage.vg_refresh_tokens_v1 содержит только ротируемый и отзываемый refresh fallback по ключу аккаунта; пароль и access token туда не записываются;
  • sessionStorage.vg_active_session_v2 выбирает активный аккаунт вкладки;
  • access token дублируется в session-cookie t для защищенных blob/media URL, а основной постоянный refresh хранится в Secure; HttpOnly cookie;
  • F5 использует текущий access token, а новая вкладка/PWA получает новый access token по refresh-cookie либо по fallback, если браузер потерял cookie;
  • несколько аккаунтов одного origin используют разные refresh-cookie;
  • старые поля pass, password, accessToken, refreshToken удаляются из localStorage миграцией.

Запись реестра обрабатывает QuotaExceededError: UI не падает при исчерпании общей квоты origin, а текущая вкладка временно использует in-memory копию. История сообщений при этом остается в IndexedDB и не очищается.

Fallback сознательно меняет security trade-off: XSS может прочитать его из localStorage, но не получает пароль, а token ограничен сроком, хранится на сервере только как HMAC-хеш, ротируется после использования и отзывается при logout. Без fallback некоторые Android-устройства требовали пароль при каждом запуске. При удалении аккаунта сервер отвечает AccountNotFound; web-client удаляет соответствующие локальные метаданные, fallback и IndexedDB. Если пользователь пришел по invite и других аккаунтов нет, после очистки запускается гостевая авторегистрация.

Service accounts

Сервисные процессы не должны хранить пользовательские пароли. Для production используется таблица service_accounts: она связывает имя сервиса с обычным client_id, grants которого уже настроены в VideoGrace.

Типовой пример:

insert into service_accounts
  (name, client_id, scopes, enabled, created_at)
values
  ('transcriber', 228800001, '["conference:join","media:render","chat:write"]', 1, strftime('%s','now'));

Когда администратор отправляет CAN job transcriber.start без payload.auth, core выпускает короткий access JWT для service account и добавляет его в job payload. Worker получает задание уже с bearer token и подключается к CommandLoop стандартным путем.

Инварианты безопасности

  • JWT-secret должен быть серверным секретом и не должен попадать в клиентский bundle.
  • Refresh token нельзя передавать в media/blob/CAN worker как access token.
  • Основной browser refresh token должен находиться в Secure; HttpOnly; SameSite=Strict cookie; JS fallback допустим только для web-client, должен ротироваться и удаляться при logout.
  • Пароль пользователя нельзя сохранять в browser storage или повторять при reconnect.
  • CAN service token авторизует подключение worker'а к /can, но не дает пользовательских grants.
  • Service account должен быть отдельным пользователем с минимально нужными правами.
  • Нельзя писать access/refresh token в application logs.
  • Для точечного отзыва access JWT используйте jti и таблицу jwt_revoked; для полного сброса сессий меняйте JwtSecret.