LiveKit Cloud rejects room-join tokens as revoked when their `nbf` predates a participant revocation. Zed generated those LiveKit JWTs with `nbf: 0`, so a fresh participant token minted after stale connection cleanup could still appear older than the cleanup and leave a user joined at the collab layer without audio or screen sharing. This sets `nbf` to the issuance time for room-join tokens while leaving admin/API tokens unchanged, and adds regression coverage at the token, mock LiveKit, and channel rejoin layers. Closes FR-83 Release Notes: - Fixed calls getting stuck without audio or screen sharing after restarting Zed and rejoining a channel.
279 lines
7.7 KiB
Rust
279 lines
7.7 KiB
Rust
use anyhow::{Context as _, Result};
|
|
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
borrow::Cow,
|
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
const DEFAULT_TTL: Duration = Duration::from_secs(6 * 60 * 60); // 6 hours
|
|
|
|
pub trait UnixTimestampSource: Send + Sync {
|
|
fn unix_timestamp(&self) -> Result<u64>;
|
|
}
|
|
|
|
pub struct SystemUnixTimestampSource;
|
|
|
|
impl UnixTimestampSource for SystemUnixTimestampSource {
|
|
fn unix_timestamp(&self) -> Result<u64> {
|
|
Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ClaimGrants<'a> {
|
|
pub iss: Cow<'a, str>,
|
|
pub sub: Option<Cow<'a, str>>,
|
|
pub iat: u64,
|
|
pub exp: u64,
|
|
pub nbf: u64,
|
|
pub jwtid: Option<Cow<'a, str>>,
|
|
pub video: VideoGrant<'a>,
|
|
}
|
|
|
|
#[derive(Default, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct VideoGrant<'a> {
|
|
pub room_create: Option<bool>,
|
|
pub room_join: Option<bool>,
|
|
pub room_list: Option<bool>,
|
|
pub room_record: Option<bool>,
|
|
pub room_admin: Option<bool>,
|
|
pub room: Option<Cow<'a, str>>,
|
|
pub can_publish: Option<bool>,
|
|
pub can_subscribe: Option<bool>,
|
|
pub can_publish_data: Option<bool>,
|
|
pub hidden: Option<bool>,
|
|
pub recorder: Option<bool>,
|
|
}
|
|
|
|
impl<'a> VideoGrant<'a> {
|
|
pub fn to_admin(room: &'a str) -> Self {
|
|
Self {
|
|
room_admin: Some(true),
|
|
room: Some(Cow::Borrowed(room)),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub fn to_join(room: &'a str) -> Self {
|
|
Self {
|
|
room: Some(Cow::Borrowed(room)),
|
|
room_join: Some(true),
|
|
can_publish: Some(true),
|
|
can_subscribe: Some(true),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub fn for_guest(room: &'a str) -> Self {
|
|
Self {
|
|
room: Some(Cow::Borrowed(room)),
|
|
room_join: Some(true),
|
|
can_publish: Some(false),
|
|
can_subscribe: Some(true),
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn create(
|
|
api_key: &str,
|
|
secret_key: &str,
|
|
identity: Option<&str>,
|
|
video_grant: VideoGrant,
|
|
) -> Result<String> {
|
|
create_with_timestamp_source(
|
|
api_key,
|
|
secret_key,
|
|
identity,
|
|
video_grant,
|
|
&SystemUnixTimestampSource,
|
|
)
|
|
}
|
|
|
|
pub fn create_with_timestamp_source(
|
|
api_key: &str,
|
|
secret_key: &str,
|
|
identity: Option<&str>,
|
|
video_grant: VideoGrant,
|
|
timestamp_source: &dyn UnixTimestampSource,
|
|
) -> Result<String> {
|
|
let issued_at = timestamp_source.unix_timestamp()?;
|
|
create_with_issued_at(api_key, secret_key, identity, video_grant, issued_at)
|
|
}
|
|
|
|
fn create_with_issued_at(
|
|
api_key: &str,
|
|
secret_key: &str,
|
|
identity: Option<&str>,
|
|
video_grant: VideoGrant,
|
|
issued_at: u64,
|
|
) -> Result<String> {
|
|
let room_join = video_grant.room_join.unwrap_or(false);
|
|
if room_join && identity.is_none() {
|
|
anyhow::bail!("identity is required for room_join grant, but it is none");
|
|
}
|
|
|
|
let expires_at = issued_at
|
|
.checked_add(DEFAULT_TTL.as_secs())
|
|
.context("token expiration overflow")?;
|
|
let not_before = if room_join {
|
|
// LiveKit Cloud applies participant revocations by comparing the
|
|
// revocation timestamp to room-join token `nbf`.
|
|
issued_at
|
|
} else {
|
|
0
|
|
};
|
|
|
|
let claims = ClaimGrants {
|
|
iss: Cow::Borrowed(api_key),
|
|
sub: identity.map(Cow::Borrowed),
|
|
iat: issued_at,
|
|
exp: expires_at,
|
|
nbf: not_before,
|
|
jwtid: identity.map(Cow::Borrowed),
|
|
video: video_grant,
|
|
};
|
|
Ok(jsonwebtoken::encode(
|
|
&Header::default(),
|
|
&claims,
|
|
&EncodingKey::from_secret(secret_key.as_ref()),
|
|
)?)
|
|
}
|
|
|
|
pub fn validate<'a>(token: &'a str, secret_key: &str) -> Result<ClaimGrants<'a>> {
|
|
let token = jsonwebtoken::decode(
|
|
token,
|
|
&DecodingKey::from_secret(secret_key.as_ref()),
|
|
&Validation::default(),
|
|
)?;
|
|
|
|
Ok(token.claims)
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn validate_with_timestamp_source<'a>(
|
|
token: &'a str,
|
|
secret_key: &str,
|
|
timestamp_source: &dyn UnixTimestampSource,
|
|
) -> Result<ClaimGrants<'a>> {
|
|
let mut validation = Validation::default();
|
|
validation.validate_exp = false;
|
|
validation.validate_nbf = false;
|
|
let token: jsonwebtoken::TokenData<ClaimGrants<'_>> = jsonwebtoken::decode(
|
|
token,
|
|
&DecodingKey::from_secret(secret_key.as_ref()),
|
|
&validation,
|
|
)?;
|
|
let claims = token.claims;
|
|
let timestamp = timestamp_source.unix_timestamp()?;
|
|
|
|
anyhow::ensure!(claims.nbf <= timestamp, "token is not yet valid");
|
|
anyhow::ensure!(claims.exp > timestamp, "token has expired");
|
|
|
|
Ok(claims)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{Client as _, LiveKitClient};
|
|
use std::sync::Arc;
|
|
|
|
const ISSUED_AT: u64 = 1_234_567;
|
|
|
|
struct FixedUnixTimestampSource(u64);
|
|
|
|
impl UnixTimestampSource for FixedUnixTimestampSource {
|
|
fn unix_timestamp(&self) -> Result<u64> {
|
|
Ok(self.0)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn token_not_before_matches_issue_time() -> Result<()> {
|
|
let token = create_with_timestamp_source(
|
|
"api-key",
|
|
"secret-key",
|
|
Some("participant"),
|
|
VideoGrant::to_join("room"),
|
|
&FixedUnixTimestampSource(ISSUED_AT),
|
|
)?;
|
|
|
|
assert_claims_timestamp(&token, ISSUED_AT, ISSUED_AT)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn room_token_not_before_matches_issue_time() -> Result<()> {
|
|
let client = LiveKitClient::new_with_timestamp_source(
|
|
"http://livekit.test".into(),
|
|
"api-key".into(),
|
|
"secret-key".into(),
|
|
Arc::new(FixedUnixTimestampSource(ISSUED_AT)),
|
|
);
|
|
let token = client.room_token("room", "participant")?;
|
|
|
|
let claims = assert_claims_timestamp(&token, ISSUED_AT, ISSUED_AT)?;
|
|
assert_eq!(claims.video.room_join, Some(true));
|
|
assert_eq!(claims.video.can_publish, Some(true));
|
|
assert_eq!(claims.video.can_subscribe, Some(true));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn guest_token_not_before_matches_issue_time() -> Result<()> {
|
|
let client = LiveKitClient::new_with_timestamp_source(
|
|
"http://livekit.test".into(),
|
|
"api-key".into(),
|
|
"secret-key".into(),
|
|
Arc::new(FixedUnixTimestampSource(ISSUED_AT)),
|
|
);
|
|
let token = client.guest_token("room", "participant")?;
|
|
|
|
let claims = assert_claims_timestamp(&token, ISSUED_AT, ISSUED_AT)?;
|
|
assert_eq!(claims.video.room_join, Some(true));
|
|
assert_eq!(claims.video.can_publish, Some(false));
|
|
assert_eq!(claims.video.can_subscribe, Some(true));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn admin_token_not_before_remains_unset() -> Result<()> {
|
|
let token = create_with_timestamp_source(
|
|
"api-key",
|
|
"secret-key",
|
|
None,
|
|
VideoGrant::to_admin("room"),
|
|
&FixedUnixTimestampSource(ISSUED_AT),
|
|
)?;
|
|
|
|
let claims = assert_claims_timestamp(&token, ISSUED_AT, 0)?;
|
|
assert_eq!(claims.video.room_admin, Some(true));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn assert_claims_timestamp(
|
|
token: &str,
|
|
issued_at: u64,
|
|
expected_not_before: u64,
|
|
) -> Result<ClaimGrants<'_>> {
|
|
let claims = validate_with_timestamp_source(
|
|
token,
|
|
"secret-key",
|
|
&FixedUnixTimestampSource(issued_at),
|
|
)?;
|
|
|
|
assert_eq!(claims.iat, issued_at);
|
|
assert_eq!(claims.nbf, expected_not_before);
|
|
assert_eq!(claims.exp, issued_at + DEFAULT_TTL.as_secs());
|
|
|
|
Ok(claims)
|
|
}
|
|
}
|