From 6ab8ffa7bda56ded47bb3dd1d64fc888541296cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sat, 29 Aug 2026 13:27:25 -0700 Subject: [PATCH 1/2] livekit-api: add the kind and inference claims to AccessToken Claims carries exp, iss, nbf, sub, name, video, sip, sha256, metadata, attributes and room_config. The Python SDK has two more that Rust cannot express (livekit/api/access_token.py:84,96,161,169): - kind, the participant kind. An agent that joins a room without kind: "agent" is counted as a user, which makes a caller-room disconnect handler treat a transferring agent as the caller. - inference, the grant the LiveKit Inference gateway checks. Callers that need either mint the JWT themselves today, with their own claim struct and their own jsonwebtoken dependency. Both new fields skip when unset. Claims has no other skip_serializing_if -- it already emits name:"", metadata:"", attributes:{} and roomConfig:null on every token -- so without the skip every token minted through this SDK would silently grow two claims. That is what the second test pins. The verify path needs no change: Claims is #[serde(default)]. --- livekit-token/Cargo.toml | 6 +++ livekit-token/src/access_token.rs | 83 ++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/livekit-token/Cargo.toml b/livekit-token/Cargo.toml index faf786fff..4c641e45a 100644 --- a/livekit-token/Cargo.toml +++ b/livekit-token/Cargo.toml @@ -18,3 +18,9 @@ sha2 = "0.10" jsonwebtoken = { version = "10", default-features = false } hmac = "0.12" signature = "2" + +[dev-dependencies] +# The tests assert that `kind` and `inference` are *absent* from the payload when unset, which +# needs to look at the serialized claims. A dev-dependency does not propagate, so this does not +# widen what a consumer of this crate builds. +serde_json = { workspace = true } diff --git a/livekit-token/src/access_token.rs b/livekit-token/src/access_token.rs index e5963d98f..795212ee9 100644 --- a/livekit-token/src/access_token.rs +++ b/livekit-token/src/access_token.rs @@ -133,6 +133,15 @@ impl Default for SIPGrants { } } +/// Grants for the LiveKit Inference gateway. `perform` is the only capability today; the struct +/// exists so a second one can be added without changing the claim's shape. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct InferenceGrants { + #[serde(default)] + pub perform: bool, +} + #[derive(Debug, Clone, Serialize, Default, Deserialize, PartialEq)] #[serde(default)] #[serde(rename_all = "camelCase")] @@ -149,6 +158,14 @@ pub struct Claims { pub metadata: String, pub attributes: HashMap, pub room_config: Option, + + // `kind` and `inference` are the only two claims that skip when unset. Every other field here + // is serialized unconditionally, so adding these without the skip would silently grow every + // token minted by every user of this SDK by two claims. + #[serde(skip_serializing_if = "String::is_empty")] + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub inference: Option, } impl Claims { @@ -194,6 +211,8 @@ impl AccessToken { metadata: Default::default(), attributes: HashMap::new(), room_config: Default::default(), + kind: Default::default(), + inference: Default::default(), }, } } @@ -261,6 +280,18 @@ impl AccessToken { self } + /// The participant kind the server should record for this identity, e.g. `"agent"`. A + /// participant that joins without it is counted as a user by the room. + pub fn with_kind(mut self, kind: &str) -> Self { + self.claims.kind = kind.to_owned(); + self + } + + pub fn with_inference_grants(mut self, grants: InferenceGrants) -> Self { + self.claims.inference = Some(grants); + self + } + pub fn to_jwt(self) -> Result { crate::jwt_provider::ensure_installed(); if self.api_key.is_empty() || self.api_secret.is_empty() { @@ -326,7 +357,7 @@ impl TokenVerifier { mod tests { use std::time::Duration; - use super::{AccessToken, Claims, TokenVerifier, VideoGrants}; + use super::{AccessToken, Claims, InferenceGrants, TokenVerifier, VideoGrants}; const TEST_API_KEY: &str = "myapikey"; const TEST_API_SECRET: &str = "thiskeyistotallyunsafe"; @@ -392,6 +423,56 @@ mod tests { ); } + #[test] + fn test_kind_and_inference_round_trip() { + 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_inference_grants(InferenceGrants { perform: true }) + .to_jwt() + .unwrap(); + + let verifier = TokenVerifier::with_api_key(TEST_API_KEY, TEST_API_SECRET); + let claims = verifier.verify(&token).unwrap(); + + assert_eq!(claims.kind, "agent"); + assert_eq!(claims.inference, Some(InferenceGrants { perform: true })); + } + + /// The constraint that decides the design: `Claims` has no other `skip_serializing_if`, so + /// without one on these two fields every token minted by every user of this SDK would grow + /// `kind: ""` and `inference: null`. + #[test] + fn test_kind_and_inference_are_absent_from_a_token_that_does_not_set_them() { + let token = AccessToken::with_api_key(TEST_API_KEY, TEST_API_SECRET) + .with_ttl(Duration::from_secs(60)) + .with_identity("test") + .to_jwt() + .unwrap(); + + let payload = serde_json::to_value( + &TokenVerifier::with_api_key(TEST_API_KEY, TEST_API_SECRET).verify(&token).unwrap(), + ) + .unwrap(); + let payload = payload.as_object().unwrap(); + + assert!(!payload.contains_key("kind"), "unset `kind` must not be serialized"); + assert!(!payload.contains_key("inference"), "unset `inference` must not be serialized"); + // The fields that already serialize unconditionally still do -- this is additive only. + assert!(payload.contains_key("name")); + assert!(payload.contains_key("metadata")); + } + + /// `Claims` is `#[serde(default)]`, so the verify path reads a token minted before these two + /// claims existed without change. + #[test] + fn test_a_token_without_the_new_claims_still_deserializes() { + let claims = Claims::from_unverified(TEST_TOKEN).expect("Failed to parse token"); + assert_eq!(claims.kind, ""); + assert_eq!(claims.inference, None); + } + #[test] fn test_unverified_token() { let claims = Claims::from_unverified(TEST_TOKEN).expect("Failed to parse token"); From 7903ef9e3ea8cc03a1fbc2a9bfa9b9a8d9796d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 30 Aug 2026 22:28:58 -0700 Subject: [PATCH 2/2] Create livekit_api_add_the_kind_and_inference_claims_to_accesstoken.md --- ...add_the_kind_and_inference_claims_to_accesstoken.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/livekit_api_add_the_kind_and_inference_claims_to_accesstoken.md diff --git a/.changeset/livekit_api_add_the_kind_and_inference_claims_to_accesstoken.md b/.changeset/livekit_api_add_the_kind_and_inference_claims_to_accesstoken.md new file mode 100644 index 000000000..94d458b69 --- /dev/null +++ b/.changeset/livekit_api_add_the_kind_and_inference_claims_to_accesstoken.md @@ -0,0 +1,10 @@ +--- +livekit: patch +livekit-api: patch +livekit-ffi: patch +livekit-signaling: patch +livekit-token: patch +livekit-uniffi: patch +--- + +livekit-api: add the kind and inference claims to AccessToken - #1380 (@theomonnom)