From 355fb330dc7317a62f51a06d1ccef4b7b8f1baa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Wed, 9 Sep 2026 12:33:03 -0700 Subject: [PATCH 1/2] livekit-token: the agent grant, the kind claim, and no default claims `VideoGrants` gains `agent` and `Claims` gains `kind` (with `AccessToken::with_kind`), both of which the Go, Python and JS SDKs already carry. An agent worker authenticates to `/agent` with `VideoGrants { agent: true }` and a simulated job's participant token is `kind: "agent"`; neither could be minted from Rust before, so livekit/agents-rust was about to hand-roll the claims over jsonwebtoken instead of using this crate. The four grants the server infers when absent -- canPublish, canSubscribe, canPublishData, canUpdateOwnMetadata (protocol/auth/grants.go) -- become `Option`, as in the Go and JS SDKs: `None` leaves the decision to the server, and getters read a token the way the server does, `canPublishData` falling back to `canPublish` included. With that, `VideoGrants` derives `Default` and nothing at its default is written into the token: every claim and grant carries `skip_serializing_if = "is_default"`, the one predicate, and the token holds only what was set -- as the server's own `omitempty` grants are. Verification of existing tokens is unchanged; a round-trip test pins that `{}` and the defaults are the same thing. `livekit-uniffi` follows: its `VideoGrants` record mirrors the struct field for field, and `kind` is exposed on `TokenOptions` and `Claims`. --- .changeset/token_agent_grant_and_kind.md | 30 ++++ livekit-token/Cargo.toml | 3 + livekit-token/src/access_token.rs | 179 +++++++++++++++++------ livekit-uniffi/src/access_token.rs | 16 +- 4 files changed, 180 insertions(+), 48 deletions(-) create mode 100644 .changeset/token_agent_grant_and_kind.md diff --git a/.changeset/token_agent_grant_and_kind.md b/.changeset/token_agent_grant_and_kind.md new file mode 100644 index 000000000..f77865c03 --- /dev/null +++ b/.changeset/token_agent_grant_and_kind.md @@ -0,0 +1,30 @@ +--- +livekit-token: major +livekit-uniffi: major +livekit-api: major +livekit: patch +livekit-ffi: patch +livekit-signaling: patch +--- + +`VideoGrants` gains the `agent` grant and `Claims` the `kind` claim (with +`AccessToken::with_kind`), which the Go, Python and JS SDKs already carry. An +agent worker's token is `VideoGrants { agent: true }` and a simulated job's +participant token is `kind: "agent"`; neither could be minted from Rust before. +`livekit-uniffi` exposes both: `TokenOptions.kind`, `Claims.kind`, and `agent` +on its `VideoGrants` record. + +**Breaking:** the four grants the server infers when absent -- `can_publish`, +`can_subscribe`, `can_publish_data`, `can_update_own_metadata` -- are now +`Option`, as in the Go and JS SDKs. `None` leaves the decision to the +server, and the new getters (`can_publish()`, `can_subscribe()`, +`can_publish_data()`, `can_update_own_metadata()`) read a token the way the +server does, `can_publish_data` falling back to `can_publish` included. Code +that set these fields writes `Some(..)`; code that read them uses the getters. +The same fields are optional on the `livekit-uniffi` record, and +`livekit-api` re-exports the crate as `livekit_api::access_token`, so both +carry the change. + +Nothing at its default is written into the token any more: unset claims and +grants are omitted, as the server's own `omitempty` grants are. Verification +of existing tokens is unchanged. diff --git a/livekit-token/Cargo.toml b/livekit-token/Cargo.toml index bb1cf8aa1..9406d274e 100644 --- a/livekit-token/Cargo.toml +++ b/livekit-token/Cargo.toml @@ -19,6 +19,9 @@ jsonwebtoken = { version = "10", default-features = false } hmac = "0.12" signature = "2" +[dev-dependencies] +serde_json = { workspace = true } + # How CI checks this crate's features, read by # `.github/workflows/feature-combinations-curated.yml` via `cargo metadata`. [package.metadata.feature-combinations] diff --git a/livekit-token/src/access_token.rs b/livekit-token/src/access_token.rs index e5963d98f..39ebdc6f5 100644 --- a/livekit-token/src/access_token.rs +++ b/livekit-token/src/access_token.rs @@ -40,81 +40,80 @@ pub enum AccessTokenError { Encoding(#[from] jsonwebtoken::errors::Error), } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(default, rename_all = "camelCase")] pub struct VideoGrants { // actions on rooms - #[serde(default)] + #[serde(skip_serializing_if = "is_default")] pub room_create: bool, - #[serde(default)] + #[serde(skip_serializing_if = "is_default")] pub room_list: bool, - #[serde(default)] + #[serde(skip_serializing_if = "is_default")] pub room_record: bool, // actions on a particular room - #[serde(default)] + #[serde(skip_serializing_if = "is_default")] pub room_admin: bool, - #[serde(default)] + #[serde(skip_serializing_if = "is_default")] pub room_join: bool, - #[serde(default)] + #[serde(skip_serializing_if = "is_default")] pub room: String, - #[serde(default)] + #[serde(skip_serializing_if = "is_default")] pub destination_room: String, // permissions within a room - #[serde(default = "default_true")] - pub can_publish: bool, - #[serde(default = "default_true")] - pub can_subscribe: bool, - #[serde(default = "default_true")] - pub can_publish_data: bool, + #[serde(skip_serializing_if = "is_default")] + pub can_publish: Option, + #[serde(skip_serializing_if = "is_default")] + pub can_subscribe: Option, + #[serde(skip_serializing_if = "is_default")] + pub can_publish_data: Option, // TrackSource types that a participant may publish. // When set, it supercedes CanPublish. Only sources explicitly set here can be published - #[serde(default)] + #[serde(skip_serializing_if = "is_default")] pub can_publish_sources: Vec, // keys keep track of each source // by default, a participant is not allowed to update its own metadata - #[serde(default)] - pub can_update_own_metadata: bool, + #[serde(skip_serializing_if = "is_default")] + pub can_update_own_metadata: Option, // actions on ingresses - #[serde(default)] + #[serde(skip_serializing_if = "is_default")] pub ingress_admin: bool, // applies to all ingress // participant is not visible to other participants (useful when making bots) - #[serde(default)] + #[serde(skip_serializing_if = "is_default")] pub hidden: bool, // indicates to the room that current participant is a recorder - #[serde(default)] + #[serde(skip_serializing_if = "is_default")] pub recorder: bool, + + // indicates to the room that current participant is an agent + #[serde(skip_serializing_if = "is_default")] + pub agent: bool, } -/// Used for fields that default to true instead of using the `Default` trait. -fn default_true() -> bool { - true +fn is_default(v: &T) -> bool { + *v == T::default() } -impl Default for VideoGrants { - fn default() -> Self { - Self { - room_create: false, - room_list: false, - room_record: false, - room_admin: false, - room_join: false, - room: "".to_string(), - destination_room: "".to_string(), - can_publish: true, - can_subscribe: true, - can_publish_data: true, - can_publish_sources: Vec::default(), - can_update_own_metadata: false, - ingress_admin: false, - hidden: false, - recorder: false, - } +impl VideoGrants { + pub fn can_publish(&self) -> bool { + self.can_publish.unwrap_or(true) + } + + pub fn can_subscribe(&self) -> bool { + self.can_subscribe.unwrap_or(true) + } + + pub fn can_publish_data(&self) -> bool { + self.can_publish_data.unwrap_or_else(|| self.can_publish()) + } + + pub fn can_update_own_metadata(&self) -> bool { + self.can_update_own_metadata.unwrap_or(false) } } @@ -122,8 +121,10 @@ impl Default for VideoGrants { #[serde(rename_all = "camelCase")] pub struct SIPGrants { // manage sip resources + #[serde(default, skip_serializing_if = "is_default")] pub admin: bool, // make outbound calls + #[serde(default, skip_serializing_if = "is_default")] pub call: bool, } @@ -140,14 +141,24 @@ pub struct Claims { pub exp: usize, // Expiration pub iss: String, // ApiKey pub nbf: usize, + #[serde(skip_serializing_if = "is_default")] pub sub: String, // Identity + #[serde(skip_serializing_if = "is_default")] pub name: String, + #[serde(skip_serializing_if = "is_default")] + pub kind: String, + #[serde(skip_serializing_if = "is_default")] pub video: VideoGrants, + #[serde(skip_serializing_if = "is_default")] pub sip: SIPGrants, + #[serde(skip_serializing_if = "is_default")] pub sha256: String, // Used to verify the integrity of the message body + #[serde(skip_serializing_if = "is_default")] pub metadata: String, + #[serde(skip_serializing_if = "is_default")] pub attributes: HashMap, + #[serde(skip_serializing_if = "is_default")] pub room_config: Option, } @@ -188,6 +199,7 @@ impl AccessToken { nbf: now.as_secs() as usize, sub: Default::default(), name: Default::default(), + kind: Default::default(), video: VideoGrants::default(), sip: SIPGrants::default(), sha256: Default::default(), @@ -235,6 +247,11 @@ impl AccessToken { self } + pub fn with_kind(mut self, kind: &str) -> Self { + self.claims.kind = kind.to_owned(); + self + } + pub fn with_metadata(mut self, metadata: &str) -> Self { self.claims.metadata = metadata.to_owned(); self @@ -326,7 +343,7 @@ impl TokenVerifier { mod tests { use std::time::Duration; - use super::{AccessToken, Claims, TokenVerifier, VideoGrants}; + use super::{AccessToken, Claims, SIPGrants, TokenVerifier, VideoGrants}; const TEST_API_KEY: &str = "myapikey"; const TEST_API_SECRET: &str = "thiskeyistotallyunsafe"; @@ -437,4 +454,78 @@ mod tests { assert_eq!(claims.sub, "test"); assert_eq!(claims.name, "test"); } + + #[test] + fn test_agent_grant_and_kind() { + let token = AccessToken::with_api_key(TEST_API_KEY, TEST_API_SECRET) + .with_ttl(Duration::from_secs(60)) + .with_identity("agent-1") + .with_kind("agent") + .with_grants(VideoGrants { + room_join: true, + room: "test-room".to_string(), + agent: true, + ..Default::default() + }) + .to_jwt() + .expect("Failed to create token"); + + let verifier = TokenVerifier::with_api_key(TEST_API_KEY, TEST_API_SECRET); + let claims = verifier.verify(&token).expect("Failed to verify token."); + assert_eq!(claims.kind, "agent"); + assert!(claims.video.agent); + + let payload = |token: &str| { + let _ = Claims::from_unverified(token).expect("Failed to parse token"); + jsonwebtoken::dangerous::insecure_decode::(token) + .expect("Failed to decode token") + .claims + }; + let bare = AccessToken::with_api_key(TEST_API_KEY, TEST_API_SECRET) + .with_ttl(Duration::from_secs(60)) + .with_grants(VideoGrants::default()) + .to_jwt() + .expect("Failed to create token"); + let p = payload(&bare); + let mut keys: Vec<&str> = p.as_object().unwrap().keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!(keys, ["exp", "iss", "nbf"], "{p}"); + + let agent = AccessToken::with_api_key(TEST_API_KEY, TEST_API_SECRET) + .with_ttl(Duration::from_secs(60)) + .with_grants(VideoGrants { agent: true, can_publish: Some(false), ..Default::default() }) + .to_jwt() + .expect("Failed to create token"); + let p = payload(&agent); + assert_eq!(p["video"], serde_json::json!({"agent": true, "canPublish": false}), "{p}"); + let claims = Claims::from_unverified(&agent).expect("Failed to parse token"); + assert!(claims.video.agent && !claims.video.can_publish() && claims.video.can_subscribe()); + assert!(!claims.video.can_publish_data(), "absent canPublishData follows canPublish"); + } + + #[test] + fn test_defaults_are_not_serialized() { + assert_eq!(serde_json::to_string(&VideoGrants::default()).unwrap(), "{}"); + let parsed: VideoGrants = serde_json::from_str("{}").unwrap(); + assert_eq!(parsed, VideoGrants::default()); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "{}"); + let explicit = VideoGrants { can_publish: Some(false), can_publish_data: Some(true), ..Default::default() }; + assert_eq!( + serde_json::to_string(&explicit).unwrap(), + r#"{"canPublish":false,"canPublishData":true}"# + ); + assert!(explicit.can_publish_data()); + + assert_eq!(serde_json::to_string(&SIPGrants::default()).unwrap(), "{}"); + let parsed: SIPGrants = serde_json::from_str("{}").unwrap(); + assert_eq!(parsed, SIPGrants::default()); + assert_eq!(serde_json::to_string(&parsed).unwrap(), "{}"); + + let claims = Claims { exp: 1, iss: "k".to_string(), nbf: 0, ..Default::default() }; + let json = r#"{"exp":1,"iss":"k","nbf":0}"#; + assert_eq!(serde_json::to_string(&claims).unwrap(), json); + let parsed: Claims = serde_json::from_str(json).unwrap(); + assert_eq!(parsed, claims); + assert_eq!(serde_json::to_string(&parsed).unwrap(), json); + } } diff --git a/livekit-uniffi/src/access_token.rs b/livekit-uniffi/src/access_token.rs index d86ce4f57..b0ed6b133 100644 --- a/livekit-uniffi/src/access_token.rs +++ b/livekit-uniffi/src/access_token.rs @@ -39,14 +39,15 @@ pub struct VideoGrants { pub room_join: bool, pub room: String, pub destination_room: String, - pub can_publish: bool, - pub can_subscribe: bool, - pub can_publish_data: bool, + pub can_publish: Option, + pub can_subscribe: Option, + pub can_publish_data: Option, pub can_publish_sources: Vec, - pub can_update_own_metadata: bool, + pub can_update_own_metadata: Option, pub ingress_admin: bool, pub hidden: bool, pub recorder: bool, + pub agent: bool, } /// SIP grants @@ -113,6 +114,7 @@ pub struct Claims { pub nbf: u64, pub sub: String, pub name: String, + pub kind: String, pub video: VideoGrants, pub sip: SIPGrants, pub sha256: String, @@ -129,6 +131,7 @@ impl From for Claims { nbf: claims.nbf as u64, sub: claims.sub, name: claims.name, + kind: claims.kind, video: claims.video, sip: claims.sip, sha256: claims.sha256, @@ -163,6 +166,8 @@ pub struct TokenOptions { #[uniffi(default)] name: Option, #[uniffi(default)] + kind: Option, + #[uniffi(default)] metadata: Option, #[uniffi(default)] attributes: Option>, @@ -203,6 +208,9 @@ pub fn token_generate( if let Some(name) = options.name { token = token.with_name(&name); } + if let Some(kind) = options.kind { + token = token.with_kind(&kind); + } if let Some(metadata) = options.metadata { token = token.with_metadata(&metadata); } From 3e27d5a6d1d216ce112c7e0868d629e15d7c24e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Wed, 9 Sep 2026 14:25:03 -0700 Subject: [PATCH 2/2] livekit-token: follow the Option grants in the tests and examples The livekit integration tests and the data_track_benchmark and local_video examples still set canPublish / canSubscribe / canPublishData as plain bools; they are Some(..) now. Cargo.lock picks up the serde_json dev-dependency and the two new unit tests are rustfmt'd. --- Cargo.lock | 1 + examples/data_track_benchmark/src/main.rs | 6 +++--- examples/local_video/src/publisher.rs | 4 ++-- examples/local_video/src/subscriber.rs | 2 +- livekit-token/src/access_token.rs | 12 ++++++++++-- livekit/tests/data_track_test.rs | 6 +++++- livekit/tests/peer_connection_signaling_test.rs | 8 ++++---- 7 files changed, 26 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4595e7cbe..47d62c367 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3818,6 +3818,7 @@ dependencies = [ "jsonwebtoken", "livekit-protocol", "serde", + "serde_json", "sha2", "signature", "thiserror 2.0.19", diff --git a/examples/data_track_benchmark/src/main.rs b/examples/data_track_benchmark/src/main.rs index f5f6d109f..dc838f65e 100644 --- a/examples/data_track_benchmark/src/main.rs +++ b/examples/data_track_benchmark/src/main.rs @@ -113,9 +113,9 @@ fn create_token(api_key: &str, api_secret: &str, room: &str, identity: &str) -> .with_grants(VideoGrants { room_join: true, room: room.to_string(), - can_publish: true, - can_publish_data: true, - can_subscribe: true, + can_publish: Some(true), + can_publish_data: Some(true), + can_subscribe: Some(true), ..Default::default() }) .to_jwt()?; diff --git a/examples/local_video/src/publisher.rs b/examples/local_video/src/publisher.rs index ce2ff17be..5f111e5a7 100644 --- a/examples/local_video/src/publisher.rs +++ b/examples/local_video/src/publisher.rs @@ -930,8 +930,8 @@ async fn run(args: Args, ctrl_c_received: Arc) -> Result<()> { .with_grants(access_token::VideoGrants { room_join: true, room: args.room_name.clone(), - can_publish: true, - can_subscribe: false, + can_publish: Some(true), + can_subscribe: Some(false), ..Default::default() }) .to_jwt()?; diff --git a/examples/local_video/src/subscriber.rs b/examples/local_video/src/subscriber.rs index 1be86a641..8e28ae2a0 100644 --- a/examples/local_video/src/subscriber.rs +++ b/examples/local_video/src/subscriber.rs @@ -1699,7 +1699,7 @@ async fn run(args: Args, ctrl_c_received: Arc) -> Result<()> { .with_grants(access_token::VideoGrants { room_join: true, room: args.room_name.clone(), - can_subscribe: true, + can_subscribe: Some(true), ..Default::default() }) .to_jwt()?; diff --git a/livekit-token/src/access_token.rs b/livekit-token/src/access_token.rs index 39ebdc6f5..df64b3b94 100644 --- a/livekit-token/src/access_token.rs +++ b/livekit-token/src/access_token.rs @@ -493,7 +493,11 @@ mod tests { let agent = AccessToken::with_api_key(TEST_API_KEY, TEST_API_SECRET) .with_ttl(Duration::from_secs(60)) - .with_grants(VideoGrants { agent: true, can_publish: Some(false), ..Default::default() }) + .with_grants(VideoGrants { + agent: true, + can_publish: Some(false), + ..Default::default() + }) .to_jwt() .expect("Failed to create token"); let p = payload(&agent); @@ -509,7 +513,11 @@ mod tests { let parsed: VideoGrants = serde_json::from_str("{}").unwrap(); assert_eq!(parsed, VideoGrants::default()); assert_eq!(serde_json::to_string(&parsed).unwrap(), "{}"); - let explicit = VideoGrants { can_publish: Some(false), can_publish_data: Some(true), ..Default::default() }; + let explicit = VideoGrants { + can_publish: Some(false), + can_publish_data: Some(true), + ..Default::default() + }; assert_eq!( serde_json::to_string(&explicit).unwrap(), r#"{"canPublish":false,"canPublishData":true}"# diff --git a/livekit/tests/data_track_test.rs b/livekit/tests/data_track_test.rs index 12883143e..05d58d3e0 100644 --- a/livekit/tests/data_track_test.rs +++ b/livekit/tests/data_track_test.rs @@ -137,7 +137,11 @@ async fn test_publish_many_tracks() -> Result<()> { #[test_log::test(tokio::test)] async fn test_publish_unauthorized() -> Result<()> { let (room, _) = test_rooms_with_options([TestRoomOptions { - grants: VideoGrants { room_join: true, can_publish_data: false, ..Default::default() }, + grants: VideoGrants { + room_join: true, + can_publish_data: Some(false), + ..Default::default() + }, ..Default::default() }]) .await? diff --git a/livekit/tests/peer_connection_signaling_test.rs b/livekit/tests/peer_connection_signaling_test.rs index cf2a4f7c1..ff4cfadaf 100644 --- a/livekit/tests/peer_connection_signaling_test.rs +++ b/livekit/tests/peer_connection_signaling_test.rs @@ -150,8 +150,8 @@ fn create_token( let grants = VideoGrants { room_join: true, room: room_name.to_string(), - can_publish: true, - can_subscribe: true, + can_publish: Some(true), + can_subscribe: Some(true), ..Default::default() }; AccessToken::with_api_key(api_key, api_secret) @@ -868,8 +868,8 @@ async fn test_connect_can_subscribe_false_impl(mode: SignalingMode) -> Result<() let grants = VideoGrants { room_join: true, room: room_name.clone(), - can_publish: true, - can_subscribe: false, + can_publish: Some(true), + can_subscribe: Some(false), ..Default::default() }; let token = AccessToken::with_api_key(&api_key, &api_secret)