Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions livekit-token/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
83 changes: 82 additions & 1 deletion livekit-token/src/access_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -149,6 +158,14 @@ pub struct Claims {
pub metadata: String,
pub attributes: HashMap<String, String>,
pub room_config: Option<livekit_protocol::RoomConfiguration>,

// `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<InferenceGrants>,
}

impl Claims {
Expand Down Expand Up @@ -194,6 +211,8 @@ impl AccessToken {
metadata: Default::default(),
attributes: HashMap::new(),
room_config: Default::default(),
kind: Default::default(),
inference: Default::default(),
},
}
}
Expand Down Expand Up @@ -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<String, AccessTokenError> {
crate::jwt_provider::ensure_installed();
if self.api_key.is_empty() || self.api_secret.is_empty() {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
Loading