livekit_token_source/
response.rs1use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17use base64::Engine as _;
18
19use crate::error::TokenSourceError;
20
21const DEFAULT_VALIDITY_TOLERANCE: Duration = Duration::from_secs(60);
22
23#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
26pub struct TokenSourceResponse {
27 pub server_url: String,
28 pub participant_token: String,
29}
30
31impl TokenSourceResponse {
32 pub fn has_valid_token(&self) -> bool {
37 self.has_valid_token_with_tolerance(DEFAULT_VALIDITY_TOLERANCE)
38 }
39
40 pub fn has_valid_token_with_tolerance(&self, tolerance: Duration) -> bool {
52 let Some(claims) = decode_claims(&self.participant_token) else {
53 return false;
54 };
55 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
56 return false;
57 };
58 let now = now.as_secs();
59
60 let active = claims.nbf.is_none_or(|nbf| now >= nbf);
61 let not_expired =
62 claims.exp.is_some_and(|exp| now.saturating_add(tolerance.as_secs()) < exp);
63 active && not_expired
64 }
65}
66
67#[derive(serde::Deserialize)]
70struct Claims {
71 exp: Option<u64>,
72 nbf: Option<u64>,
73}
74
75fn decode_claims(token: &str) -> Option<Claims> {
77 let mut segments = token.split('.');
78 let payload = match (segments.next(), segments.next(), segments.next(), segments.next()) {
79 (Some(_), Some(payload), Some(_), None) => payload,
80 _ => return None,
81 };
82 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()?;
83 serde_json::from_slice(&payload).ok()
84}
85
86pub type TokenSourceResult<T> = Result<T, TokenSourceError>;
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 fn now_secs() -> u64 {
94 SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()
95 }
96
97 fn jwt(payload: &str) -> String {
98 let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
99 format!(
100 "{}.{}.{}",
101 engine.encode(r#"{"alg":"HS256","typ":"JWT"}"#),
102 engine.encode(payload),
103 engine.encode("signature")
104 )
105 }
106
107 fn response_with_token(participant_token: String) -> TokenSourceResponse {
108 TokenSourceResponse { server_url: "wss://example.livekit.cloud".into(), participant_token }
109 }
110
111 #[test]
112 fn future_exp_is_valid() {
113 let response = response_with_token(jwt(&format!(r#"{{"exp":{}}}"#, now_secs() + 3600)));
114 assert!(response.has_valid_token());
115 }
116
117 #[test]
118 fn past_exp_is_invalid() {
119 let response = response_with_token(jwt(&format!(r#"{{"exp":{}}}"#, now_secs() - 3600)));
120 assert!(!response.has_valid_token());
121 }
122
123 #[test]
124 fn exp_within_tolerance_is_invalid() {
125 let response = response_with_token(jwt(&format!(r#"{{"exp":{}}}"#, now_secs() + 30)));
126 assert!(!response.has_valid_token());
127 assert!(response.has_valid_token_with_tolerance(Duration::ZERO));
128 }
129
130 #[test]
131 fn future_nbf_is_invalid() {
132 let response = response_with_token(jwt(&format!(
133 r#"{{"nbf":{},"exp":{}}}"#,
134 now_secs() + 600,
135 now_secs() + 3600
136 )));
137 assert!(!response.has_valid_token());
138 }
139
140 #[test]
141 fn past_nbf_with_future_exp_is_valid() {
142 let response = response_with_token(jwt(&format!(
143 r#"{{"nbf":{},"exp":{}}}"#,
144 now_secs() - 600,
145 now_secs() + 3600
146 )));
147 assert!(response.has_valid_token());
148 }
149
150 #[test]
151 fn missing_exp_is_invalid() {
152 let response = response_with_token(jwt(r#"{"sub":"identity"}"#));
153 assert!(!response.has_valid_token());
154 }
155
156 #[test]
157 fn malformed_tokens_are_invalid() {
158 for token in ["", "opaque-token", "one.two", "one.two.three.four", "a.!!!not-base64.c"] {
159 let response = response_with_token(token.into());
160 assert!(!response.has_valid_token(), "expected {token:?} to be invalid");
161 }
162 let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
163 let bad_json = format!("{0}.{1}.{0}", engine.encode("header"), engine.encode("not json"));
164 assert!(!response_with_token(bad_json).has_valid_token());
165 }
166}