Skip to main content

livekit_token_source/
response.rs

1// Copyright 2026 LiveKit, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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/// The credentials returned by a token source: the server to connect to and
24/// the participant token to authenticate with.
25#[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    /// Reports whether the participant token is currently usable, with the
33    /// default tolerance of 60 seconds.
34    ///
35    /// See [`TokenSourceResponse::has_valid_token_with_tolerance`].
36    pub fn has_valid_token(&self) -> bool {
37        self.has_valid_token_with_tolerance(DEFAULT_VALIDITY_TOLERANCE)
38    }
39
40    /// Reports whether the participant token is currently usable: it decodes
41    /// the token as a JWT **without verifying its signature** and checks the
42    /// `nbf`/`exp` claims against the current time. The token is considered
43    /// valid when it is already active (`now >= nbf`, if present) and does not
44    /// expire within the given tolerance (`now + tolerance < exp`).
45    ///
46    /// A token that does not parse as a JWT or has no `exp` claim is treated
47    /// as invalid.
48    ///
49    /// This is a client-side freshness heuristic only — since the signature is
50    /// never verified, it must not be used as an authorization check.
51    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/// The JWT claims relevant for validity checking. LiveKit tokens use integer
68/// `NumericDate` values, so fractional timestamps are rejected as unparseable.
69#[derive(serde::Deserialize)]
70struct Claims {
71    exp: Option<u64>,
72    nbf: Option<u64>,
73}
74
75/// Decodes the payload of a JWT without verifying its signature.
76fn 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
86/// Result alias used by all token source operations.
87pub 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}