Skip to main content

videosdk/
token.rs

1//! Token minting, decoding and verification.
2//!
3//! The VideoSDK API authenticates server requests with a JWT that is HS256-signed
4//! with the project secret and carries the public API key in its payload. Tokens
5//! are signed locally — there is no network call — and are sent as
6//! `Authorization: <token>`, a **raw JWT with no `Bearer` prefix**.
7
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10use base64::engine::general_purpose::URL_SAFE_NO_PAD;
11use base64::Engine as _;
12use hmac::{Hmac, Mac};
13use serde_json::{json, Map, Value};
14use sha2::Sha256;
15
16use crate::error::{Error, Result};
17
18/// The token lifetime used when none is specified.
19pub const DEFAULT_TOKEN_TTL: Duration = Duration::from_secs(24 * 60 * 60);
20
21/// The fixed JWT header. Written literally so the encoded bytes match the other
22/// VideoSDK SDKs exactly.
23const JWT_HEADER: &str = r#"{"alg":"HS256","typ":"JWT"}"#;
24
25type HmacSha256 = Hmac<Sha256>;
26
27/// Configures [`generate_token`].
28#[derive(Debug, Clone, Default)]
29pub struct GenerateTokenParams {
30    /// The project API key (public). Embedded in the payload as `apikey`. Required.
31    pub api_key: String,
32    /// The project secret (private). HS256-signs the token; never transmitted. Required.
33    pub secret_key: String,
34    /// Permission claims. Defaults to `["allow_join", "allow_mod"]`.
35    pub permissions: Option<Vec<String>>,
36    /// Role claims. Defaults to `["crawler"]`, which the v2 REST APIs require for
37    /// management requests.
38    pub roles: Option<Vec<String>>,
39    /// The token version. Defaults to `2`, which the v2 REST APIs require.
40    pub version: Option<i64>,
41    /// The token lifetime. Defaults to [`DEFAULT_TOKEN_TTL`].
42    pub expires_in: Option<Duration>,
43    /// Extra custom claims merged into the payload, e.g. `participantId`, `roomId`.
44    ///
45    /// Custom claims may shadow `apikey`, `permissions`, `version` and `roles`,
46    /// but never `iat` or `exp`.
47    pub claims: Map<String, Value>,
48}
49
50/// A decoded view of a token payload.
51#[derive(Debug, Clone, Default, PartialEq)]
52#[non_exhaustive]
53pub struct TokenClaims {
54    /// The `apikey` claim.
55    pub api_key: Option<String>,
56    /// The `permissions` claim.
57    pub permissions: Vec<String>,
58    /// The `roles` claim.
59    pub roles: Vec<String>,
60    /// The `version` claim.
61    pub version: Option<i64>,
62    /// The `iat` claim, in Unix seconds.
63    pub issued_at: Option<i64>,
64    /// The `exp` claim, in Unix seconds.
65    pub expires_at: Option<i64>,
66    /// Every claim as decoded from JSON, including custom ones.
67    pub raw: Map<String, Value>,
68}
69
70/// A participant permission claim — an entry in the token's `permissions`.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub enum Grant {
73    /// Lets the participant join the meeting directly.
74    AllowJoin,
75    /// Requires the participant to request to join; a host admits them.
76    AskJoin,
77    /// Lets the participant moderate: remove participants, control recordings, and so on.
78    AllowMod,
79}
80
81impl Grant {
82    /// The wire representation of this grant.
83    pub fn as_str(self) -> &'static str {
84        match self {
85            Grant::AllowJoin => "allow_join",
86            Grant::AskJoin => "ask_join",
87            Grant::AllowMod => "allow_mod",
88        }
89    }
90}
91
92fn unix_now() -> i64 {
93    SystemTime::now()
94        .duration_since(UNIX_EPOCH)
95        .map(|d| d.as_secs() as i64)
96        .unwrap_or(0)
97}
98
99fn invalid_expires_in(value: &str) -> Error {
100    Error::validation(format!(
101        "invalid expiresIn {value:?}: use a number of seconds or a string like \"24h\", \"7d\""
102    ))
103}
104
105/// Converts a duration string such as `"30s"`, `"10m"`, `"24h"`, `"7d"`, `"2w"`
106/// or `"1y"` into a [`Duration`]. A bare number is interpreted as seconds.
107pub fn parse_expires_in(value: &str) -> Result<Duration> {
108    let s = value.trim();
109    let digits_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
110    if digits_end == 0 {
111        return Err(invalid_expires_in(value));
112    }
113    let amount: u64 = s[..digits_end]
114        .parse()
115        .map_err(|_| invalid_expires_in(value))?;
116    let seconds_per_unit = match s[digits_end..].trim_start() {
117        "" | "s" => 1,
118        "m" => 60,
119        "h" => 3_600,
120        "d" => 86_400,
121        "w" => 604_800,
122        "y" => 31_536_000,
123        _ => return Err(invalid_expires_in(value)),
124    };
125    amount
126        .checked_mul(seconds_per_unit)
127        .map(Duration::from_secs)
128        .ok_or_else(|| invalid_expires_in(value))
129}
130
131fn new_mac(secret: &str) -> HmacSha256 {
132    <HmacSha256 as Mac>::new_from_slice(secret.as_bytes()).expect("HMAC accepts keys of any length")
133}
134
135fn sign_hs256(signing_input: &str, secret: &str) -> String {
136    let mut mac = new_mac(secret);
137    mac.update(signing_input.as_bytes());
138    URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
139}
140
141/// Decodes a base64url JWT segment, tolerating padding and the standard base64
142/// alphabet that some producers emit.
143fn decode_segment(segment: &str) -> std::result::Result<Vec<u8>, base64::DecodeError> {
144    let normalized: String = segment
145        .trim_end_matches('=')
146        .chars()
147        .map(|c| match c {
148            '+' => '-',
149            '/' => '_',
150            other => other,
151        })
152        .collect();
153    URL_SAFE_NO_PAD.decode(normalized)
154}
155
156/// Signs a JWT for authenticating against the VideoSDK v2 REST APIs.
157///
158/// Send it as `Authorization: <token>` — a raw JWT, with no `Bearer` prefix.
159pub fn generate_token(params: &GenerateTokenParams) -> Result<String> {
160    if params.api_key.is_empty() {
161        return Err(Error::config("generate_token: api_key is required"));
162    }
163    if params.secret_key.is_empty() {
164        return Err(Error::config("generate_token: secret_key is required"));
165    }
166
167    let ttl = params
168        .expires_in
169        .filter(|d| !d.is_zero())
170        .unwrap_or(DEFAULT_TOKEN_TTL);
171    let iat = unix_now();
172    let exp = iat + ttl.as_secs() as i64;
173
174    let permissions = params
175        .permissions
176        .clone()
177        .unwrap_or_else(|| vec!["allow_join".to_string(), "allow_mod".to_string()]);
178    let roles = params
179        .roles
180        .clone()
181        .unwrap_or_else(|| vec!["crawler".to_string()]);
182
183    let mut payload = Map::new();
184    payload.insert("apikey".into(), json!(params.api_key));
185    payload.insert("permissions".into(), json!(permissions));
186    payload.insert("version".into(), json!(params.version.unwrap_or(2)));
187    payload.insert("roles".into(), json!(roles));
188    // Custom claims may shadow the four claims above...
189    for (key, value) in &params.claims {
190        payload.insert(key.clone(), value.clone());
191    }
192    // ...but never `iat`/`exp`, which are written last.
193    payload.insert("iat".into(), json!(iat));
194    payload.insert("exp".into(), json!(exp));
195
196    let payload_json = serde_json::to_vec(&payload).map_err(|e| Error::Encode { source: e })?;
197    let signing_input = format!(
198        "{}.{}",
199        URL_SAFE_NO_PAD.encode(JWT_HEADER),
200        URL_SAFE_NO_PAD.encode(payload_json)
201    );
202    let signature = sign_hs256(&signing_input, &params.secret_key);
203    Ok(format!("{signing_input}.{signature}"))
204}
205
206fn string_vec(value: Option<&Value>) -> Vec<String> {
207    value
208        .and_then(Value::as_array)
209        .map(|items| {
210            items
211                .iter()
212                .filter_map(Value::as_str)
213                .map(str::to_string)
214                .collect()
215        })
216        .unwrap_or_default()
217}
218
219fn claims_from_map(map: Map<String, Value>) -> TokenClaims {
220    TokenClaims {
221        api_key: map
222            .get("apikey")
223            .and_then(Value::as_str)
224            .map(str::to_string),
225        permissions: string_vec(map.get("permissions")),
226        roles: string_vec(map.get("roles")),
227        version: map.get("version").and_then(Value::as_i64),
228        issued_at: map.get("iat").and_then(Value::as_i64),
229        expires_at: map.get("exp").and_then(Value::as_i64),
230        raw: map,
231    }
232}
233
234/// Decodes a token's payload **without verifying its signature**.
235///
236/// Useful for reading `exp` to decide whether a cached token needs regenerating.
237pub fn decode_token(token: &str) -> Result<TokenClaims> {
238    let mut parts = token.split('.');
239    let (_header, payload) = (parts.next(), parts.next());
240    let payload = payload
241        .filter(|p| !p.is_empty())
242        .ok_or_else(|| Error::validation("malformed token: expected at least a 2-part JWT"))?;
243    let raw = decode_segment(payload)
244        .map_err(|e| Error::validation(format!("malformed token payload: {e}")))?;
245    let map: Map<String, Value> = serde_json::from_slice(&raw)
246        .map_err(|e| Error::validation(format!("malformed token payload: {e}")))?;
247    Ok(claims_from_map(map))
248}
249
250/// Verifies an HS256 JWT against `secret` and returns its claims.
251///
252/// Fails if the token is malformed, the signature doesn't match, or it has expired.
253pub fn verify_token(token: &str, secret: &str) -> Result<TokenClaims> {
254    let parts: Vec<&str> = token.split('.').collect();
255    if parts.len() != 3 || parts.iter().any(|p| p.is_empty()) {
256        return Err(Error::auth("malformed token: expected a 3-part JWT"));
257    }
258    let signature = decode_segment(parts[2]).map_err(|_| Error::auth("invalid token signature"))?;
259
260    let mut mac = new_mac(secret);
261    mac.update(format!("{}.{}", parts[0], parts[1]).as_bytes());
262    // Constant-time comparison.
263    mac.verify_slice(&signature)
264        .map_err(|_| Error::auth("invalid token signature"))?;
265
266    let claims = decode_token(token).map_err(|_| Error::auth("malformed token payload"))?;
267    if let Some(exp) = claims.expires_at {
268        if unix_now() >= exp {
269            return Err(Error::auth("token has expired"));
270        }
271    }
272    Ok(claims)
273}
274
275/// Fluently builds a VideoSDK access token.
276///
277/// Defaults to a **participant** token (`roles: ["rtc"]`, `version: 2`). Call
278/// [`AccessTokenBuilder::for_api`] for a management token (`roles: ["crawler"]`).
279/// Obtain one from [`Client::access_token`](crate::Client::access_token).
280///
281/// ```no_run
282/// # use videosdk::{AccessTokenBuilder, Grant};
283/// # use std::time::Duration;
284/// let jwt = AccessTokenBuilder::new("key", "secret")
285///     .set_participant("participant-1")
286///     .grant(Grant::AllowJoin)
287///     .grant(Grant::AllowMod)
288///     .for_room("room-1")
289///     .expires_in(Duration::from_secs(2 * 60 * 60))
290///     .to_jwt()?;
291/// # Ok::<(), videosdk::Error>(())
292/// ```
293#[derive(Debug, Clone)]
294pub struct AccessTokenBuilder {
295    api_key: String,
296    secret: String,
297    participant_id: Option<String>,
298    room_id: Option<String>,
299    grants: Vec<Grant>,
300    roles: Vec<String>,
301    ttl: Option<Duration>,
302    claims: Map<String, Value>,
303}
304
305impl AccessTokenBuilder {
306    /// Starts a builder for a participant token.
307    pub fn new(api_key: impl Into<String>, secret: impl Into<String>) -> Self {
308        Self {
309            api_key: api_key.into(),
310            secret: secret.into(),
311            participant_id: None,
312            room_id: None,
313            grants: Vec::new(),
314            roles: vec!["rtc".to_string()],
315            ttl: None,
316            claims: Map::new(),
317        }
318    }
319
320    /// Sets the `participantId` claim.
321    pub fn set_participant(mut self, participant_id: impl Into<String>) -> Self {
322        self.participant_id = Some(participant_id.into());
323        self
324    }
325
326    /// Adds a permission grant. Duplicates are ignored; insertion order is kept.
327    pub fn grant(mut self, grant: Grant) -> Self {
328        if !self.grants.contains(&grant) {
329            self.grants.push(grant);
330        }
331        self
332    }
333
334    /// Adds several permission grants at once.
335    pub fn grants(mut self, grants: impl IntoIterator<Item = Grant>) -> Self {
336        for grant in grants {
337            self = self.grant(grant);
338        }
339        self
340    }
341
342    /// Scopes the token to a specific room, via the `roomId` claim.
343    pub fn for_room(mut self, room_id: impl Into<String>) -> Self {
344        self.room_id = Some(room_id.into());
345        self
346    }
347
348    /// Builds a management token (`roles: ["crawler"]`) instead of a participant token.
349    pub fn for_api(mut self) -> Self {
350        self.roles = vec!["crawler".to_string()];
351        self
352    }
353
354    /// Sets the token lifetime.
355    pub fn expires_in(mut self, ttl: Duration) -> Self {
356        self.ttl = Some(ttl);
357        self
358    }
359
360    /// Attaches an arbitrary custom claim.
361    pub fn set_claim(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
362        self.claims.insert(key.into(), value.into());
363        self
364    }
365
366    /// Signs and returns the token.
367    pub fn to_jwt(&self) -> Result<String> {
368        if self.api_key.is_empty() || self.secret.is_empty() {
369            return Err(Error::config("access_token requires an api_key and secret"));
370        }
371        let mut claims = self.claims.clone();
372        if let Some(participant_id) = &self.participant_id {
373            claims.insert("participantId".into(), json!(participant_id));
374        }
375        if let Some(room_id) = &self.room_id {
376            claims.insert("roomId".into(), json!(room_id));
377        }
378        // No grants means no `permissions` claim, which lets `generate_token`
379        // apply its own default of ["allow_join", "allow_mod"].
380        let permissions = (!self.grants.is_empty())
381            .then(|| self.grants.iter().map(|g| g.as_str().to_string()).collect());
382
383        generate_token(&GenerateTokenParams {
384            api_key: self.api_key.clone(),
385            secret_key: self.secret.clone(),
386            permissions,
387            roles: Some(self.roles.clone()),
388            version: Some(2),
389            expires_in: self.ttl,
390            claims,
391        })
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    fn params() -> GenerateTokenParams {
400        GenerateTokenParams {
401            api_key: "test-key".into(),
402            secret_key: "test-secret".into(),
403            ..Default::default()
404        }
405    }
406
407    #[test]
408    fn parses_duration_strings() {
409        assert_eq!(parse_expires_in("30").unwrap(), Duration::from_secs(30));
410        assert_eq!(parse_expires_in("30s").unwrap(), Duration::from_secs(30));
411        assert_eq!(parse_expires_in("10m").unwrap(), Duration::from_secs(600));
412        assert_eq!(
413            parse_expires_in("24h").unwrap(),
414            Duration::from_secs(86_400)
415        );
416        assert_eq!(
417            parse_expires_in("7d").unwrap(),
418            Duration::from_secs(604_800)
419        );
420        assert_eq!(
421            parse_expires_in("2w").unwrap(),
422            Duration::from_secs(1_209_600)
423        );
424        assert_eq!(
425            parse_expires_in("1y").unwrap(),
426            Duration::from_secs(31_536_000)
427        );
428        // The reference SDKs allow whitespace between the amount and the unit.
429        assert_eq!(
430            parse_expires_in(" 15 m ").unwrap(),
431            Duration::from_secs(900)
432        );
433    }
434
435    #[test]
436    fn rejects_bad_duration_strings() {
437        for bad in ["", "abc", "h", "-5s", "10x", "1.5h"] {
438            assert!(parse_expires_in(bad).is_err(), "expected {bad:?} to fail");
439        }
440    }
441
442    #[test]
443    fn generated_token_has_three_segments_and_expected_header() {
444        let token = generate_token(&params()).unwrap();
445        let parts: Vec<&str> = token.split('.').collect();
446        assert_eq!(parts.len(), 3);
447        let header = decode_segment(parts[0]).unwrap();
448        assert_eq!(String::from_utf8(header).unwrap(), JWT_HEADER);
449    }
450
451    #[test]
452    fn applies_documented_defaults() {
453        let claims = decode_token(&generate_token(&params()).unwrap()).unwrap();
454        assert_eq!(claims.api_key.as_deref(), Some("test-key"));
455        assert_eq!(claims.permissions, ["allow_join", "allow_mod"]);
456        assert_eq!(claims.roles, ["crawler"]);
457        assert_eq!(claims.version, Some(2));
458        let (iat, exp) = (claims.issued_at.unwrap(), claims.expires_at.unwrap());
459        assert_eq!(exp - iat, DEFAULT_TOKEN_TTL.as_secs() as i64);
460    }
461
462    #[test]
463    fn custom_claims_may_shadow_roles_but_never_iat_or_exp() {
464        let mut p = params();
465        p.claims.insert("roles".into(), json!(["custom"]));
466        p.claims.insert("exp".into(), json!(1));
467        p.claims.insert("iat".into(), json!(1));
468        p.claims.insert("participantId".into(), json!("p-1"));
469
470        let claims = decode_token(&generate_token(&p).unwrap()).unwrap();
471        assert_eq!(claims.roles, ["custom"], "custom claims shadow roles");
472        assert_eq!(
473            claims.raw.get("participantId").unwrap().as_str(),
474            Some("p-1")
475        );
476        assert_ne!(claims.expires_at, Some(1), "exp must not be overridable");
477        assert_ne!(claims.issued_at, Some(1), "iat must not be overridable");
478    }
479
480    #[test]
481    fn verify_round_trips_a_generated_token() {
482        let token = generate_token(&params()).unwrap();
483        let claims = verify_token(&token, "test-secret").unwrap();
484        assert_eq!(claims.api_key.as_deref(), Some("test-key"));
485    }
486
487    #[test]
488    fn verify_rejects_a_wrong_secret() {
489        let token = generate_token(&params()).unwrap();
490        let err = verify_token(&token, "not-the-secret").unwrap_err();
491        assert!(err.is_authentication());
492        assert!(err.to_string().contains("invalid token signature"));
493    }
494
495    #[test]
496    fn verify_rejects_a_tampered_payload() {
497        let token = generate_token(&params()).unwrap();
498        let mut parts: Vec<&str> = token.split('.').collect();
499        let forged = URL_SAFE_NO_PAD.encode(br#"{"apikey":"attacker"}"#);
500        parts[1] = &forged;
501        let tampered = parts.join(".");
502        assert!(verify_token(&tampered, "test-secret").is_err());
503    }
504
505    #[test]
506    fn verify_rejects_malformed_and_expired_tokens() {
507        assert!(verify_token("a.b", "s").unwrap_err().is_authentication());
508        assert!(verify_token("a..c", "s").unwrap_err().is_authentication());
509
510        let mut p = params();
511        p.expires_in = Some(Duration::from_secs(1));
512        let mut token_params = p.clone();
513        // Mint a token that expired an hour ago by back-dating via a custom claim
514        // is impossible (exp always wins), so sign one by hand instead.
515        token_params.claims.clear();
516        let payload = json!({"apikey": "k", "exp": unix_now() - 3600});
517        let signing_input = format!(
518            "{}.{}",
519            URL_SAFE_NO_PAD.encode(JWT_HEADER),
520            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap())
521        );
522        let expired = format!("{signing_input}.{}", sign_hs256(&signing_input, "s"));
523        let err = verify_token(&expired, "s").unwrap_err();
524        assert!(err.to_string().contains("token has expired"));
525    }
526
527    #[test]
528    fn decode_does_not_verify() {
529        let signing_input = format!(
530            "{}.{}",
531            URL_SAFE_NO_PAD.encode(JWT_HEADER),
532            URL_SAFE_NO_PAD.encode(br#"{"apikey":"k"}"#)
533        );
534        let unsigned = format!("{signing_input}.garbage");
535        assert_eq!(
536            decode_token(&unsigned).unwrap().api_key.as_deref(),
537            Some("k")
538        );
539        assert!(verify_token(&unsigned, "s").is_err());
540    }
541
542    #[test]
543    fn decode_rejects_malformed_tokens() {
544        assert!(decode_token("only-one-part").is_err());
545        assert!(decode_token("header.").is_err());
546        assert!(decode_token("header.!!!not-base64!!!").is_err());
547    }
548
549    #[test]
550    fn access_token_defaults_to_a_participant_token() {
551        let jwt = AccessTokenBuilder::new("k", "s").to_jwt().unwrap();
552        let claims = decode_token(&jwt).unwrap();
553        assert_eq!(claims.roles, ["rtc"]);
554        // No grants set, so `generate_token` supplies the default permissions.
555        assert_eq!(claims.permissions, ["allow_join", "allow_mod"]);
556        assert_eq!(claims.version, Some(2));
557    }
558
559    #[test]
560    fn for_api_switches_to_the_management_role() {
561        let jwt = AccessTokenBuilder::new("k", "s")
562            .for_api()
563            .to_jwt()
564            .unwrap();
565        assert_eq!(decode_token(&jwt).unwrap().roles, ["crawler"]);
566    }
567
568    #[test]
569    fn grants_are_deduplicated_and_ordered() {
570        let jwt = AccessTokenBuilder::new("k", "s")
571            .grant(Grant::AllowMod)
572            .grant(Grant::AllowJoin)
573            .grant(Grant::AllowMod)
574            .to_jwt()
575            .unwrap();
576        assert_eq!(
577            decode_token(&jwt).unwrap().permissions,
578            ["allow_mod", "allow_join"]
579        );
580    }
581
582    #[test]
583    fn participant_and_room_become_claims() {
584        let jwt = AccessTokenBuilder::new("k", "s")
585            .set_participant("p-1")
586            .for_room("r-1")
587            .set_claim("custom", "v")
588            .to_jwt()
589            .unwrap();
590        let raw = decode_token(&jwt).unwrap().raw;
591        assert_eq!(raw.get("participantId").unwrap().as_str(), Some("p-1"));
592        assert_eq!(raw.get("roomId").unwrap().as_str(), Some("r-1"));
593        assert_eq!(raw.get("custom").unwrap().as_str(), Some("v"));
594    }
595
596    #[test]
597    fn generate_token_requires_credentials() {
598        let mut p = params();
599        p.api_key = String::new();
600        assert!(generate_token(&p)
601            .unwrap_err()
602            .to_string()
603            .contains("api_key"));
604
605        let mut p = params();
606        p.secret_key = String::new();
607        assert!(generate_token(&p)
608            .unwrap_err()
609            .to_string()
610            .contains("secret_key"));
611    }
612
613    #[test]
614    fn decode_segment_tolerates_standard_base64_alphabet() {
615        // `+` and `/` should be normalized to `-` and `_`.
616        let raw = [0xfb_u8, 0xff, 0xbf];
617        let standard = base64::engine::general_purpose::STANDARD.encode(raw);
618        assert!(standard.contains('+') || standard.contains('/'));
619        assert_eq!(decode_segment(&standard).unwrap(), raw);
620    }
621}