1use 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
18pub const DEFAULT_TOKEN_TTL: Duration = Duration::from_secs(24 * 60 * 60);
20
21const JWT_HEADER: &str = r#"{"alg":"HS256","typ":"JWT"}"#;
24
25type HmacSha256 = Hmac<Sha256>;
26
27#[derive(Debug, Clone, Default)]
29pub struct GenerateTokenParams {
30 pub api_key: String,
32 pub secret_key: String,
34 pub permissions: Option<Vec<String>>,
36 pub roles: Option<Vec<String>>,
39 pub version: Option<i64>,
41 pub expires_in: Option<Duration>,
43 pub claims: Map<String, Value>,
48}
49
50#[derive(Debug, Clone, Default, PartialEq)]
52#[non_exhaustive]
53pub struct TokenClaims {
54 pub api_key: Option<String>,
56 pub permissions: Vec<String>,
58 pub roles: Vec<String>,
60 pub version: Option<i64>,
62 pub issued_at: Option<i64>,
64 pub expires_at: Option<i64>,
66 pub raw: Map<String, Value>,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub enum Grant {
73 AllowJoin,
75 AskJoin,
77 AllowMod,
79}
80
81impl Grant {
82 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
105pub 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
141fn 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
156pub 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 for (key, value) in ¶ms.claims {
190 payload.insert(key.clone(), value.clone());
191 }
192 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, ¶ms.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
234pub 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
250pub 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 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#[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 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 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 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 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 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 pub fn for_api(mut self) -> Self {
350 self.roles = vec!["crawler".to_string()];
351 self
352 }
353
354 pub fn expires_in(mut self, ttl: Duration) -> Self {
356 self.ttl = Some(ttl);
357 self
358 }
359
360 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 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 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 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(¶ms()).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(¶ms()).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(¶ms()).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(¶ms()).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(¶ms()).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 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 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 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}