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-token | Media-токен (Jitsi JWT) | |
|---|---|---|
| Кто выпускает | identity-сервис PIN / KeyCloak (OIDC) | messenger (минтер) — см. стратегии ниже |
| Кто проверяет | API PIN (messenger и др.) | prosody (mod_auth_token) |
| Назначение | аутентификация пользователя к API | вход в одну media-комнату |
| Scope / TTL | платформа + permissions, длинный TTL | room-scoped, короткий TTL (~4ч) |
| Claims | sub, roles/permissions, стандартные OIDC | room, context.user/features, moderator |
Правило: платформенный токен НЕ отдаётся в Jitsi. Клиент предъявляет access-token сервису messenger
(обычная API-аутентификация), а messenger в ответ минтит отдельный media-токен — это обмен токена
(token exchange), а не проброс существующего. Причина: prosody — отдельный домен доверия; media-токен обязан
быть room-scoped и нести context.features/moderator, которых в платформенном токене нет и быть не может
(комната неизвестна на момент логина, а платформенный credential нельзя раздавать во внешний домен).
Стратегии выпуска media-токена
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).- Переиспользовать платформенный токен напрямую в Jitsi — НЕЛЬЗЯ. Он не room-scoped, без
room/features/moderator, длинный TTL, и это платформенный credential. Единственное легитимное «переиспользование» — как вход для шага минтинга (стратегия 1): из него берутся личность и роль, но сам он в prosody не уходит. - 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.
- 3a — ключ владеет identity, claims строит и подписывает
Выбор для 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_secret | asap_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.lua — self.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 (метаданные
записи).
Header
| Поле | Значение |
|---|---|
alg | HS256 |
typ | JWT |
kid | идентификатор ключа (ставится всегда для ротации; при HS256 prosody его не выбирает — util.lib.lua чтит kid только для RS-семейства) |
Payload
| Claim | Значение | Зачем |
|---|---|---|
iss | pin_messenger | app_id; сверяется prosody (app_id/acceptedIssuers) |
aud | pin_messenger | аудитория токена |
sub | <xmpp domain> (домен развёртывания) | целевой домен prosody; сверяется в verify_room |
room | call-{CallSessionId} / conf-{ConferenceSessionId} | привязка к одной комнате (не *) |
nbf | now − 30s | защита от рассинхрона часов |
exp | now + ~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-у affiliationowner→ роль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=false → member; features выключены — клиент не покажет
кнопки записи/демонстрации.
Маппинг на нейтральную модель
MediaJoinModel (нейтрально) | Jitsi-конкретика |
|---|---|
AccessToken | этот JWT (HS256, room-scoped, TTL ~4ч) |
RoomName | room-claim (call-{id}/conf-{id}) |
SignalingUrl | prosody /xmpp-websocket (wss) |
MediaDomain | sub/домен развёртывания (MediaRoom.Domain) |
IceServers | coturn (TURN-креды с static-auth-secret, короткоживущие) |
ExpiresAt | exp-claim (jwt.ValidTo) |
AccessToken и TURN-Credential — Sensitive: минтятся в 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: «kidclaim 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-креды.
Обратные ссылки
Автоссылки: где используется этот документ.
- calls (8): Медиа-звонки и запись на Jitsi — интеграционный гид (messenger), Медиа-звонки на Jitsi: план поставки (playbook аналитик → разработчик), Backend-реализация медиа-звонков: спека → код (messenger), Jitsi-звонки: план верификации и приёмки, Jitsi: именование комнат, admission и presence, Jitsi: инвентарь изменений в thirdparty/jitsi, Jitsi: нативный клиент lib-jitsi-meet на Next.js 16, Jitsi: k8s-топология и взаимодействие с бэкендом
Связанные документы
- Дескриптор входа: 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/affiliation→owner). - Решение: ADR-0062.