use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::Engine as _;
use crate::error::TokenSourceError;
const DEFAULT_VALIDITY_TOLERANCE: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
pub struct TokenSourceResponse {
pub server_url: String,
pub participant_token: String,
}
impl TokenSourceResponse {
pub fn has_valid_token(&self) -> bool {
self.has_valid_token_with_tolerance(DEFAULT_VALIDITY_TOLERANCE)
}
pub fn has_valid_token_with_tolerance(&self, tolerance: Duration) -> bool {
let Some(claims) = decode_claims(&self.participant_token) else {
return false;
};
let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
return false;
};
let now = now.as_secs();
let active = claims.nbf.is_none_or(|nbf| now >= nbf);
let not_expired =
claims.exp.is_some_and(|exp| now.saturating_add(tolerance.as_secs()) < exp);
active && not_expired
}
}
#[derive(serde::Deserialize)]
struct Claims {
exp: Option<u64>,
nbf: Option<u64>,
}
fn decode_claims(token: &str) -> Option<Claims> {
let mut segments = token.split('.');
let payload = match (segments.next(), segments.next(), segments.next(), segments.next()) {
(Some(_), Some(payload), Some(_), None) => payload,
_ => return None,
};
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()?;
serde_json::from_slice(&payload).ok()
}
pub type TokenSourceResult<T> = Result<T, TokenSourceError>;
#[cfg(test)]
mod tests {
use super::*;
fn now_secs() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()
}
fn jwt(payload: &str) -> String {
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
format!(
"{}.{}.{}",
engine.encode(r#"{"alg":"HS256","typ":"JWT"}"#),
engine.encode(payload),
engine.encode("signature")
)
}
fn response_with_token(participant_token: String) -> TokenSourceResponse {
TokenSourceResponse { server_url: "wss://example.livekit.cloud".into(), participant_token }
}
#[test]
fn future_exp_is_valid() {
let response = response_with_token(jwt(&format!(r#"{{"exp":{}}}"#, now_secs() + 3600)));
assert!(response.has_valid_token());
}
#[test]
fn past_exp_is_invalid() {
let response = response_with_token(jwt(&format!(r#"{{"exp":{}}}"#, now_secs() - 3600)));
assert!(!response.has_valid_token());
}
#[test]
fn exp_within_tolerance_is_invalid() {
let response = response_with_token(jwt(&format!(r#"{{"exp":{}}}"#, now_secs() + 30)));
assert!(!response.has_valid_token());
assert!(response.has_valid_token_with_tolerance(Duration::ZERO));
}
#[test]
fn future_nbf_is_invalid() {
let response = response_with_token(jwt(&format!(
r#"{{"nbf":{},"exp":{}}}"#,
now_secs() + 600,
now_secs() + 3600
)));
assert!(!response.has_valid_token());
}
#[test]
fn past_nbf_with_future_exp_is_valid() {
let response = response_with_token(jwt(&format!(
r#"{{"nbf":{},"exp":{}}}"#,
now_secs() - 600,
now_secs() + 3600
)));
assert!(response.has_valid_token());
}
#[test]
fn missing_exp_is_invalid() {
let response = response_with_token(jwt(r#"{"sub":"identity"}"#));
assert!(!response.has_valid_token());
}
#[test]
fn malformed_tokens_are_invalid() {
for token in ["", "opaque-token", "one.two", "one.two.three.four", "a.!!!not-base64.c"] {
let response = response_with_token(token.into());
assert!(!response.has_valid_token(), "expected {token:?} to be invalid");
}
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let bad_json = format!("{0}.{1}.{0}", engine.encode("header"), engine.encode("not json"));
assert!(!response_with_token(bad_json).has_valid_token());
}
}