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; } pub struct SystemUnixTimestampSource; impl UnixTimestampSource for SystemUnixTimestampSource { fn unix_timestamp(&self) -> Result { 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>, pub iat: u64, pub exp: u64, pub nbf: u64, pub jwtid: Option>, pub video: VideoGrant<'a>, } #[derive(Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VideoGrant<'a> { pub room_create: Option, pub room_join: Option, pub room_list: Option, pub room_record: Option, pub room_admin: Option, pub room: Option>, pub can_publish: Option, pub can_subscribe: Option, pub can_publish_data: Option, pub hidden: Option, pub recorder: Option, } 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 { 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 { 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 { 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> { 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> { let mut validation = Validation::default(); validation.validate_exp = false; validation.validate_nbf = false; let token: jsonwebtoken::TokenData> = 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 { 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> { 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) } }