Перейти к основному содержимому

Jitsi: минтинг JWT входа в .NET

Как messenger минтит токен доступа в медиа-комнату, который абстрактно называется MediaJoinModel.AccessToken (нейтрально в API/домене), а конкретно — это Jitsi JWT (mod_auth_token prosody). Токен короткоживущий, минтится в момент доставки инвайта, в БД не хранится и не логируется (INV-CALL-6, INV-CONF-7).

Два токена, два домена доверия (identity → media)

Ключевое: платформенный access-token и media-токен Jitsi — это разные токены разных доменов доверия, их нельзя смешивать.

Платформенный access-tokenMedia-токен (Jitsi JWT)
Кто выпускаетidentity-сервис PIN / KeyCloak (OIDC)messenger (минтер) — см. стратегии ниже
Кто проверяетAPI PIN (messenger и др.)prosody (mod_auth_token)
Назначениеаутентификация пользователя к APIвход в одну media-комнату
Scope / TTLплатформа + permissions, длинный TTLroom-scoped, короткий TTL (~4ч)
Claimssub, roles/permissions, стандартные OIDCroom, context.user/features, moderator

Правило: платформенный токен НЕ отдаётся в Jitsi. Клиент предъявляет access-token сервису messenger (обычная API-аутентификация), а messenger в ответ минтит отдельный media-токен — это обмен токена (token exchange), а не проброс существующего. Причина: prosody — отдельный домен доверия; media-токен обязан быть room-scoped и нести context.features/moderator, которых в платформенном токене нет и быть не может (комната неизвестна на момент логина, а платформенный credential нельзя раздавать во внешний домен).

Стратегии выпуска media-токена

  1. messenger минтит HS256 общим секретом (текущая, MVP). messenger держит app_secret (vault, общий с prosody), проверяет платформенный токен пользователя и минтит room-scoped media-токен из его данных: context.user (id/имя/аватар) — из claim-ов identity; features/moderator — из RecordingPolicy и ролей. «Self-signed» симметрично: PIN подписывает своим секретом, без внешнего CA. Просто (ADR-0056).
  2. Переиспользовать платформенный токен напрямую в Jitsi — НЕЛЬЗЯ. Он не room-scoped, без room/features/moderator, длинный TTL, и это платформенный credential. Единственное легитимное «переиспользование» — как вход для шага минтинга (стратегия 1): из него берутся личность и роль, но сам он в prosody не уходит.
  3. identity-сервис PIN как key authority (self-signed RS256 / ASAP). Асимметрично: подпись приватным ключом PIN, prosody валидирует публичным ключом по kid через asap_key_server/cache_keys_url (JWKS-эндпоинт identity). «Self-signed» = собственный ключ PIN, без внешнего CA. Два варианта разделения:
    • 3a — ключ владеет identity, claims строит и подписывает messenger (рекомендуется для асимметрии): identity отдаёт JWKS для prosody и выдаёт messenger приватный ключ подписи (kid); messenger остаётся минтером (у него session-контекст room/features/moderator). prosody держит только публичный ключ — общего секрета нет.
    • 3b — минтит сам identity (централизованная подпись): messenger вызывает mint-эндпоинт identity, передавая room/features/moderator; identity подписывает и возвращает JWT. Централизует подпись, но добавляет синхронный вызов и требует, чтобы identity доверял claim-ам messenger.

Выбор для PIN. MVP — стратегия 1 (HS256, минтит messenger, identity аутентифицирует пользователя). Когда нужен асимметричный trust / zero-downtime-ротация / «identity владеет ключами» — стратегия 3a (RS256, identity = JWKS-authority, messenger подписывает выданным ключом). Миграция additive: kid в header уже ставится (см. JitsiTokenService ниже), меняются только alg и способ раздачи ключа. Стратегию 2 не применять.

Поток обмена (token exchange)

Конфигурация по стратегиям

СторонаСтратегия 1 (HS256)Стратегия 3 (RS256 + identity JWKS)
messenger (минтер)JWT_APP_SECRET (vault), alg=HS256приватный ключ подписи от identity + kid, alg=RS256; логика claim-ов (JitsiTokenService) та же
prosody (валидатор)app_id, app_secretasap_key_server/cache_keys_url = JWKS identity; asap_accepted_issuers, asap_accepted_audiences; signature_algorithm=RS256
identity PINтолько аутентификация пользователя (токен, проверяемый messenger-ом)+ JWKS-эндпоинт (/.well-known/jwks.json) и ротация ключей (3a: выдаёт ключ messenger; 3b: свой mint-эндпоинт)

Алгоритм подписи

HS256 + общий app_secret — простейший вариант для саморазмещённого Jitsi на нашем масштабе (стратегия 1 выше). Prosody проверяет подпись симметричным секретом (app_secret), совпадающим у минтера (messenger) и prosody (token/util.lib.luaself.appSecret, signature_algorithm=HS256).

  • RS256/ASAP (асимметричный key-server) Jitsi поддерживает, но на целевой нагрузке (ADR-0056) он избыточен: один сервис минтит, один prosody проверяет, ротация секрета — через vault. Оставляем HS256; переход на RS256 — additive (см. «Ротация ключа»).
  • Секрет JWT_APP_SECRET хранится в vault, инжектится и в messenger, и в prosody (jitsi-deployment.md). В коде/логах/событиях не фигурирует.

Структура claim-ов

mod_auth_token через token/util валидирует подпись, iss/aud, nbf/exp и раскладывает context.* в XMPP-сессию (session.jitsi_meet_context_user/group/features/room, jitsi_meet_str_tenant), откуда их читают mod_token_affiliation (роль модератора) и jibri (метаданные записи).

ПолеЗначение
algHS256
typJWT
kidидентификатор ключа (ставится всегда для ротации; при HS256 prosody его не выбираетutil.lib.lua чтит kid только для RS-семейства)

Payload

ClaimЗначениеЗачем
isspin_messengerapp_id; сверяется prosody (app_id/acceptedIssuers)
audpin_messengerаудитория токена
sub<xmpp domain> (домен развёртывания)целевой домен prosody; сверяется в verify_room
roomcall-{CallSessionId} / conf-{ConferenceSessionId}привязка к одной комнате (не *)
nbfnow − 30sзащита от рассинхрона часов
expnow + ~4hверхняя граница жизни; клиент обновляет заранее
context.user{ id, name, avatar, moderator: bool }идентификация occupant + роль
context.features{ recording, screen-sharing, livestreaming:false, transcription:false, sip:false }какие возможности разрешены
context.group<DealId> / <ContactId> (корреляция)группировка/метаданные
context.tenant<DealTenantId> (для конференций сделки)scope маршрутизации (jitsi_meet_str_tenant)
  • room ограничивает токен ровно одной комнатой (не wildcard) — токен для conf-A не пускает в conf-B. Совместно с fail-closed сверкой имени в admission-вебхуке (jitsi-rooms.md) это двойной гейт. mod_auth_token при asap_require_room_claim=true отвергает токен без room.
  • context.user.moderator = инициатор сессии или сотрудник ПИН. mod_token_affiliation при moderator == true (или affiliation == owner) выдаёт occupant-у affiliation owner → роль moderator (может завершить конференцию, стартовать запись, удалять участников; INV-CONF-6). Остальные аутентифицированные — member.
  • context.features.recording/screen-sharing проставляются из RecordingPolicy и прав: клиент стартует запись/демонстрацию только при соответствующем флаге (jitsi-client.md). livestreaming/transcription/sip = false (не поддерживаем в MVP).

Минтинг в .NET (JitsiTokenService)

Иллюстративный SKETCH; полная реализация — JitsiTokenService (backend-implementation.md). context — типизированная модель (строгая типизация; без Dictionary<string,object>/JsonDocument), сериализуется в вложенный JSON-объект claim-а через JsonClaimValueTypes.Json. Ключ screen-sharing — через JsonPropertyName (дефис).

/// <summary>Минтит короткоживущий Jitsi JWT входа в одну media-комнату (HS256 + app_secret).</summary>
public sealed class JitsiTokenService
{
private readonly JitsiOptions _options; // AppId, MediaDomain, TokenTtl (~4h), SecretRef
private readonly IJwtSecretProvider _secrets; // vault: текущий (+ предыдущий на окне ротации) секрет
private readonly TimeProvider _clock;

public JitsiTokenService(JitsiOptions options, IJwtSecretProvider secrets, TimeProvider clock)
=> (_options, _secrets, _clock) = (options, secrets, clock);

/// <summary>Свежий JWT для комнаты. Токен не хранится — минтится на доставку инвайта / по media-token.</summary>
public MintedMediaToken Mint(MediaTokenRequest request)
{
var now = _clock.GetUtcNow();

var context = new JitsiTokenContext(
User: new JitsiUser(
request.UserId.ToString("N"), request.DisplayName, request.AvatarUrl, request.IsModerator),
Features: new JitsiFeatures(
Recording: request.AllowRecording,
ScreenSharing: request.AllowScreenSharing,
LiveStreaming: false, Transcription: false, Sip: false),
Group: request.CorrelationId, // DealId / ContactId
Tenant: request.TenantId?.ToString("N"));

var contextJson = JsonSerializer.Serialize(context, JitsiJson.Options);

var secret = _secrets.Current(_options.SecretRef); // (Kid, byte[] Bytes)
var key = new SymmetricSecurityKey(secret.Bytes) { KeyId = secret.Kid };
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

var jwt = new JwtSecurityToken(
issuer: _options.AppId, // iss
audience: _options.AppId, // aud
claims: new[]
{
new Claim("sub", _options.MediaDomain),
new Claim("room", request.RoomName),
new Claim("context", contextJson, JsonClaimValueTypes.Json),
},
notBefore: now.AddSeconds(-30).UtcDateTime, // nbf
expires: now.Add(_options.TokenTtl).UtcDateTime, // exp
signingCredentials: creds);
jwt.Header["kid"] = secret.Kid; // forward-compat (RS256); HS256 prosody его игнорирует

var encoded = new JwtSecurityTokenHandler().WriteToken(jwt);
return new MintedMediaToken(encoded, jwt.ValidTo); // Sensitive — не логировать, не персистить
}
}

Типизированный context (сериализационные DTO — sealed, record в PIN зарезервирован под доменные события, поэтому обычные sealed class/sealed record-DTO без доменной семантики; ключи Jitsi — через JsonPropertyName):

/// <summary>Корень claim-а <c>context</c> Jitsi JWT.</summary>
public sealed class JitsiTokenContext(JitsiUser User, JitsiFeatures Features, string? Group, string? Tenant)
{
[JsonPropertyName("user")] public JitsiUser User { get; } = User;
[JsonPropertyName("features")] public JitsiFeatures Features { get; } = Features;
[JsonPropertyName("group")] public string? Group { get; } = Group;
[JsonPropertyName("tenant")] public string? Tenant { get; } = Tenant;
}

public sealed class JitsiFeatures(bool Recording, bool ScreenSharing, bool LiveStreaming, bool Transcription, bool Sip)
{
[JsonPropertyName("recording")] public bool Recording { get; } = Recording;
[JsonPropertyName("screen-sharing")] public bool ScreenSharing { get; } = ScreenSharing;
[JsonPropertyName("livestreaming")] public bool LiveStreaming { get; } = LiveStreaming;
[JsonPropertyName("transcription")] public bool Transcription { get; } = Transcription;
[JsonPropertyName("sip")] public bool Sip { get; } = Sip;
}

Вариант RS256 (стратегия 3 — identity как key authority). Тот же Mint, меняется только подпись: вместо SymmetricSecurityKey(app_secret)RsaSecurityKey с приватным ключом, выданным identity: new SigningCredentials(new RsaSecurityKey(privateKey) { KeyId = kid }, SecurityAlgorithms.RsaSha256). prosody валидирует публичным ключом из JWKS identity по kid (asap_key_server/cache_keys_url). При этом iss — идентификатор, принятый prosody в asap_accepted_issuers (напр. pin_identity); aud — в asap_accepted_audiences; остальные claim-ы (room/context.*) не меняются. Так messenger остаётся минтером, а identity владеет ключом и его ротацией (общего секрета с prosody нет).

Пример payload — модератор конференции (запись + демонстрация экрана)

{
"iss": "pin_messenger",
"aud": "pin_messenger",
"sub": "meet.pin.internal",
"room": "conf-3fa85f6457174562b3fc2c963f66afa6",
"nbf": 1752573772,
"exp": 1752588172,
"context": {
"user": {
"id": "b1a7c9de00004000800000000001",
"name": "Анна Риелтор",
"avatar": "https://cdn.pin.internal/u/b1a7c9.png",
"moderator": true
},
"features": {
"recording": true,
"screen-sharing": true,
"livestreaming": false,
"transcription": false,
"sip": false
},
"group": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"tenant": "5f2a1c0044ee4a2fb1b7c9de00000abc"
}
}

mod_token_affiliation увидит context.user.moderator == true → affiliation owner → роль moderator.

Пример payload — обычный спикер (без записи/демонстрации)

{
"iss": "pin_messenger",
"aud": "pin_messenger",
"sub": "meet.pin.internal",
"room": "conf-3fa85f6457174562b3fc2c963f66afa6",
"nbf": 1752573780,
"exp": 1752588180,
"context": {
"user": {
"id": "c3d4e5f600004000800000000002",
"name": "Иван Инвестор",
"avatar": "https://cdn.pin.internal/u/c3d4e5.png",
"moderator": false
},
"features": {
"recording": false,
"screen-sharing": false,
"livestreaming": false,
"transcription": false,
"sip": false
},
"group": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"tenant": "5f2a1c0044ee4a2fb1b7c9de00000abc"
}
}

Та же комната (room, group), но moderator=falsemember; features выключены — клиент не покажет кнопки записи/демонстрации.

Маппинг на нейтральную модель

MediaJoinModel (нейтрально)Jitsi-конкретика
AccessTokenэтот JWT (HS256, room-scoped, TTL ~4ч)
RoomNameroom-claim (call-{id}/conf-{id})
SignalingUrlprosody /xmpp-websocket (wss)
MediaDomainsub/домен развёртывания (MediaRoom.Domain)
IceServerscoturn (TURN-креды с static-auth-secret, короткоживущие)
ExpiresAtexp-claim (jwt.ValidTo)

AccessToken и TURN-CredentialSensitive: минтятся в realtime-dispatch в момент доставки инвайта (CallInvite/ConferenceInvite) или ответа REST (start/answer/join/media-token), в realtime_outbox и БД не пишутся (messenger-hub.md).

Обновление токена (reconnect)

В Jitsi нет встроенного refresh-flow: токен статичен до exp. Клиент обновляет до истечения, запрашивая новый через REST POST /calls/{callId}/media-token или POST /conferences/{conferenceId}/media-token (area Query) — сервер минтит свежий JWT для той же комнаты и возвращает новый MediaJoinModel. Это же покрывает переподключение после разрыва: клиент добирает актуальный дескриптор входа и переустанавливает JitsiConnection.

Ротация ключа

  • kid в header ставится всегда, но под HS256 prosody проверяет единственным app_secret и kid при выборе ключа не использует (util.lib.lua: «kid claim only support with RS family»). kid здесь — журналирование/подготовка к RS256.
  • Ротация HS256 — координированная замена секрета: выкатываем новый JWT_APP_SECRET в prosody и messenger близко по времени. Поскольку токены живут ≤ TTL (~4ч), «in-flight» токены, подписанные старым секретом, после переключения prosody не пройдут проверку — клиент при неудаче входа переминтит через media-token (переподключение). Окно приемлемо: конференции редки, TTL короткий (ADR-0056).
  • Zero-downtime (dual-accept) — путь RS256: переключить signature_algorithm=RS256, отдавать JWKS через cache_keys_url (prosody читает keys[].kid — несколько активных ключей одновременно); messenger подписывает приватным ключом с kid. Ротация: опубликовать новый kid в JWKS → переключить подписанта на новый ключ → снять старый после TTL-окна. Миграция additive (сменить alg, раздать публичный ключ).

Безопасность

  • Секрет — только в vault; ротация — как выше (HS256 swap или RS256 kid).
  • Токен room-scoped и короткоживущий — компрометация ограничена одной комнатой на ≤ TTL.
  • В логи/трейсы/события попадают только id (CallId/ConferenceId/RoomName), не токен и не TURN-креды.

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы

  • Дескриптор входа: MediaJoinModel; комнаты и admission: jitsi-rooms.md; минтинг в .NET — JitsiTokenService (backend-implementation.md); клиент: jitsi-client.md; развёртывание/секреты: jitsi-deployment.md.
  • Изменения prosody (app_id/app_secret, token-модули): jitsi-modifications.md.
  • identity PIN (аутентификация пользователя / источник context.user; при стратегии 3 — JWKS key authority): identity; валидация платформенного токена в API — общий паттерн auth PIN.
  • Источник: thirdparty/jitsi/jitsi-meet/resources/prosody-plugins/token/util.lib.lua (process_and_verify_token, verify_room, kid только для RS, jitsi_meet_context_*); .../mod_token_affiliation.lua (moderator/affiliationowner).
  • Решение: ADR-0062.