1use std::collections::{HashMap, HashSet};
60use std::sync::{Arc, Mutex};
61
62use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
63
64use super::Role;
65
66pub const MIN_SECRET_BYTES: usize = 32;
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum SameSite {
80 Strict,
81 Lax,
82 None,
83}
84
85impl SameSite {
86 pub fn as_str(&self) -> &'static str {
87 match self {
88 SameSite::Strict => "Strict",
89 SameSite::Lax => "Lax",
90 SameSite::None => "None",
91 }
92 }
93}
94
95#[derive(Debug, Clone)]
99pub struct BrowserTokenConfig {
100 pub secret: Vec<u8>,
102 pub issuer: String,
104 pub audience: String,
106 pub access_ttl_secs: i64,
109 pub refresh_ttl_secs: i64,
113 pub cookie_secure: bool,
117 pub same_site: SameSite,
119 pub cookie_name: String,
121 pub cookie_path: String,
125}
126
127impl BrowserTokenConfig {
128 pub fn new(secret: impl Into<Vec<u8>>) -> Self {
130 Self {
131 secret: secret.into(),
132 issuer: "reddb-browser".to_string(),
133 audience: "reddb-redwire".to_string(),
134 access_ttl_secs: 15 * 60,
135 refresh_ttl_secs: 30 * 24 * 60 * 60,
136 cookie_secure: true,
137 same_site: SameSite::Strict,
138 cookie_name: "reddb_refresh".to_string(),
139 cookie_path: "/auth/browser".to_string(),
140 }
141 }
142
143 fn sanitised(mut self) -> Self {
147 if self.same_site == SameSite::None {
148 self.cookie_secure = true;
149 }
150 self
151 }
152}
153
154fn new_token_id() -> String {
158 let mut bytes = [0u8; 16];
159 crate::crypto::os_random::fill_bytes(&mut bytes)
160 .expect("OS CSPRNG unavailable; refusing to mint a refresh token id");
161 bytes.iter().map(|b| format!("{b:02x}")).collect()
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct BrowserIdentity {
167 pub username: String,
168 pub tenant: Option<String>,
169 pub role: Role,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum BrowserTokenError {
176 Decode(String),
179 WrongType { expected: TokenType, got: String },
183 Expired,
185 NotYetValid,
187 BadRole(String),
189 Revoked,
194}
195
196impl std::fmt::Display for BrowserTokenError {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 match self {
199 BrowserTokenError::Decode(m) => write!(f, "token decode failed: {m}"),
200 BrowserTokenError::WrongType { expected, got } => {
201 write!(
202 f,
203 "wrong token type: expected {}, got {got:?}",
204 expected.as_str()
205 )
206 }
207 BrowserTokenError::Expired => write!(f, "token expired"),
208 BrowserTokenError::NotYetValid => write!(f, "token not yet valid"),
209 BrowserTokenError::BadRole(r) => write!(f, "token carries unknown role {r:?}"),
210 BrowserTokenError::Revoked => write!(f, "refresh token has been revoked"),
211 }
212 }
213}
214
215impl std::error::Error for BrowserTokenError {}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum TokenType {
223 Access,
224 Refresh,
225}
226
227impl TokenType {
228 pub fn as_str(&self) -> &'static str {
229 match self {
230 TokenType::Access => "access",
231 TokenType::Refresh => "refresh",
232 }
233 }
234}
235
236#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
240struct Claims {
241 iss: String,
242 aud: String,
243 sub: String,
244 exp: i64,
245 iat: i64,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 nbf: Option<i64>,
248 typ: String,
249 role: String,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 tenant: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
255 jti: Option<String>,
256}
257
258#[derive(Debug, Clone)]
261pub struct IssuedTokens {
262 pub access_token: String,
263 pub access_expires_in: i64,
266 pub refresh_token: String,
267}
268
269pub struct BrowserTokenAuthority {
273 config: BrowserTokenConfig,
274 encoding: EncodingKey,
275 decoding: DecodingKey,
276 live_refresh: Arc<Mutex<HashMap<String, RefreshOwner>>>,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq)]
285struct RefreshOwner {
286 username: String,
287 tenant: Option<String>,
288}
289
290impl std::fmt::Debug for BrowserTokenAuthority {
291 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 f.debug_struct("BrowserTokenAuthority")
294 .field("issuer", &self.config.issuer)
295 .field("audience", &self.config.audience)
296 .field("access_ttl_secs", &self.config.access_ttl_secs)
297 .field("refresh_ttl_secs", &self.config.refresh_ttl_secs)
298 .finish_non_exhaustive()
299 }
300}
301
302impl BrowserTokenAuthority {
303 pub fn new(config: BrowserTokenConfig) -> Result<Self, String> {
307 if config.secret.len() < MIN_SECRET_BYTES {
308 return Err(format!(
309 "browser-token secret must be at least {MIN_SECRET_BYTES} bytes, got {}",
310 config.secret.len()
311 ));
312 }
313 let config = config.sanitised();
314 let encoding = EncodingKey::from_secret(&config.secret);
315 let decoding = DecodingKey::from_secret(&config.secret);
316 Ok(Self {
317 config,
318 encoding,
319 decoding,
320 live_refresh: Arc::new(Mutex::new(HashMap::new())),
321 })
322 }
323
324 pub fn access_ttl_secs(&self) -> i64 {
325 self.config.access_ttl_secs
326 }
327
328 pub fn cookie_name(&self) -> &str {
329 &self.config.cookie_name
330 }
331
332 pub fn issue(&self, identity: &BrowserIdentity, now: i64) -> Result<IssuedTokens, String> {
335 let access_token = self.encode(
336 identity,
337 TokenType::Access,
338 now,
339 self.config.access_ttl_secs,
340 )?;
341 let refresh_token = self.encode(
342 identity,
343 TokenType::Refresh,
344 now,
345 self.config.refresh_ttl_secs,
346 )?;
347 Ok(IssuedTokens {
348 access_token,
349 access_expires_in: self.config.access_ttl_secs,
350 refresh_token,
351 })
352 }
353
354 pub fn issue_access(&self, identity: &BrowserIdentity, now: i64) -> Result<String, String> {
357 self.encode(
358 identity,
359 TokenType::Access,
360 now,
361 self.config.access_ttl_secs,
362 )
363 }
364
365 fn encode(
366 &self,
367 identity: &BrowserIdentity,
368 typ: TokenType,
369 now: i64,
370 ttl: i64,
371 ) -> Result<String, String> {
372 let jti = match typ {
375 TokenType::Refresh => Some(new_token_id()),
376 TokenType::Access => None,
377 };
378 let claims = Claims {
379 iss: self.config.issuer.clone(),
380 aud: self.config.audience.clone(),
381 sub: identity.username.clone(),
382 exp: now + ttl,
383 iat: now,
384 nbf: Some(now),
385 typ: typ.as_str().to_string(),
386 role: identity.role.as_str().to_string(),
387 tenant: identity.tenant.clone(),
388 jti: jti.clone(),
389 };
390 let token = encode(&Header::new(Algorithm::HS256), &claims, &self.encoding)
391 .map_err(|e| format!("encode browser token: {e}"))?;
392 if let Some(jti) = jti {
393 self.register_refresh(jti, identity);
394 }
395 Ok(token)
396 }
397
398 fn register_refresh(&self, jti: String, identity: &BrowserIdentity) {
400 if let Ok(mut live) = self.live_refresh.lock() {
401 live.insert(
402 jti,
403 RefreshOwner {
404 username: identity.username.clone(),
405 tenant: identity.tenant.clone(),
406 },
407 );
408 }
409 }
410
411 pub fn revoke_refresh_token(&self, token: &str) {
415 if let Some(jti) = self.refresh_token_id(token) {
416 if let Ok(mut live) = self.live_refresh.lock() {
417 live.remove(&jti);
418 }
419 }
420 }
421
422 pub fn revoke_all_for(&self, username: &str, tenant: Option<&str>) {
426 if let Ok(mut live) = self.live_refresh.lock() {
427 live.retain(|_, owner| {
428 !(owner.username == username && owner.tenant.as_deref() == tenant)
429 });
430 }
431 }
432
433 pub fn live_refresh_count(&self) -> usize {
435 self.live_refresh.lock().map(|live| live.len()).unwrap_or(0)
436 }
437
438 fn refresh_token_id(&self, token: &str) -> Option<String> {
441 let mut validation = Validation::new(Algorithm::HS256);
442 validation.set_issuer(&[self.config.issuer.as_str()]);
443 validation.set_audience(&[self.config.audience.as_str()]);
444 validation.validate_exp = false;
445 validation.validate_nbf = false;
446 validation.required_spec_claims = HashSet::new();
447 decode::<Claims>(token, &self.decoding, &validation)
448 .ok()
449 .and_then(|data| data.claims.jti)
450 }
451
452 pub fn validate_access(
454 &self,
455 token: &str,
456 now: i64,
457 ) -> Result<BrowserIdentity, BrowserTokenError> {
458 self.validate(token, TokenType::Access, now)
459 }
460
461 pub fn validate_refresh(
464 &self,
465 token: &str,
466 now: i64,
467 ) -> Result<BrowserIdentity, BrowserTokenError> {
468 self.validate(token, TokenType::Refresh, now)
469 }
470
471 fn validate(
476 &self,
477 token: &str,
478 expected: TokenType,
479 now: i64,
480 ) -> Result<BrowserIdentity, BrowserTokenError> {
481 let mut validation = Validation::new(Algorithm::HS256);
482 validation.set_issuer(&[self.config.issuer.as_str()]);
483 validation.set_audience(&[self.config.audience.as_str()]);
484 validation.validate_exp = false;
489 validation.validate_nbf = false;
490 validation.required_spec_claims = HashSet::new();
491
492 let data = decode::<Claims>(token, &self.decoding, &validation)
493 .map_err(|e| BrowserTokenError::Decode(e.to_string()))?;
494 let claims = data.claims;
495
496 if claims.typ != expected.as_str() {
497 return Err(BrowserTokenError::WrongType {
498 expected,
499 got: claims.typ,
500 });
501 }
502 if now >= claims.exp {
503 return Err(BrowserTokenError::Expired);
504 }
505 if let Some(nbf) = claims.nbf {
506 if now < nbf {
507 return Err(BrowserTokenError::NotYetValid);
508 }
509 }
510 let role = Role::from_str(&claims.role).ok_or(BrowserTokenError::BadRole(claims.role))?;
511 if expected == TokenType::Refresh {
512 let live = claims
517 .jti
518 .as_ref()
519 .and_then(|jti| {
520 self.live_refresh
521 .lock()
522 .ok()
523 .map(|live| live.contains_key(jti))
524 })
525 .unwrap_or(false);
526 if !live {
527 return Err(BrowserTokenError::Revoked);
528 }
529 }
530 Ok(BrowserIdentity {
531 username: claims.sub,
532 tenant: claims.tenant,
533 role,
534 })
535 }
536
537 pub fn refresh_cookie(&self, refresh_token: &str) -> String {
541 self.build_cookie(refresh_token, self.config.refresh_ttl_secs)
542 }
543
544 pub fn clear_cookie(&self) -> String {
548 self.build_cookie("", 0)
549 }
550
551 fn build_cookie(&self, value: &str, max_age: i64) -> String {
552 let mut cookie = format!(
553 "{}={}; HttpOnly; Path={}; Max-Age={}; SameSite={}",
554 self.config.cookie_name,
555 value,
556 self.config.cookie_path,
557 max_age,
558 self.config.same_site.as_str()
559 );
560 if self.config.cookie_secure {
561 cookie.push_str("; Secure");
562 }
563 cookie
564 }
565}
566
567pub fn cookie_value<'a>(cookie_header: &'a str, name: &str) -> Option<&'a str> {
571 cookie_header.split(';').find_map(|pair| {
572 let pair = pair.trim();
573 let (k, v) = pair.split_once('=')?;
574 if k.trim() == name {
575 Some(v.trim())
576 } else {
577 None
578 }
579 })
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585
586 const NOW: i64 = 1_750_000_000;
587
588 fn authority() -> BrowserTokenAuthority {
589 let secret = b"0123456789abcdef0123456789abcdef".to_vec();
590 BrowserTokenAuthority::new(BrowserTokenConfig::new(secret)).unwrap()
591 }
592
593 fn identity() -> BrowserIdentity {
594 BrowserIdentity {
595 username: "alice".to_string(),
596 tenant: Some("acme".to_string()),
597 role: Role::Write,
598 }
599 }
600
601 #[test]
602 fn rejects_short_secret() {
603 let err = BrowserTokenAuthority::new(BrowserTokenConfig::new(b"too-short".to_vec()));
604 assert!(err.is_err());
605 }
606
607 #[test]
608 fn issue_then_validate_access_roundtrip() {
609 let auth = authority();
610 let tokens = auth.issue(&identity(), NOW).unwrap();
611 let id = auth
612 .validate_access(&tokens.access_token, NOW + 60)
613 .unwrap();
614 assert_eq!(id, identity());
615 assert_eq!(tokens.access_expires_in, 15 * 60);
616 }
617
618 #[test]
619 fn platform_scoped_identity_has_no_tenant() {
620 let auth = authority();
621 let id = BrowserIdentity {
622 username: "root".to_string(),
623 tenant: None,
624 role: Role::Admin,
625 };
626 let tokens = auth.issue(&id, NOW).unwrap();
627 let got = auth.validate_access(&tokens.access_token, NOW + 1).unwrap();
628 assert_eq!(got.tenant, None);
629 assert_eq!(got.role, Role::Admin);
630 }
631
632 #[test]
633 fn expired_access_token_rejected() {
634 let auth = authority();
635 let tokens = auth.issue(&identity(), NOW).unwrap();
636 let err = auth
638 .validate_access(&tokens.access_token, NOW + 16 * 60)
639 .unwrap_err();
640 assert_eq!(err, BrowserTokenError::Expired);
641 }
642
643 #[test]
644 fn not_yet_valid_token_rejected() {
645 let auth = authority();
646 let tokens = auth.issue(&identity(), NOW).unwrap();
647 let err = auth
649 .validate_access(&tokens.access_token, NOW - 10)
650 .unwrap_err();
651 assert_eq!(err, BrowserTokenError::NotYetValid);
652 }
653
654 #[test]
655 fn refresh_token_cannot_authenticate_a_session() {
656 let auth = authority();
660 let tokens = auth.issue(&identity(), NOW).unwrap();
661 let err = auth
662 .validate_access(&tokens.refresh_token, NOW + 60)
663 .unwrap_err();
664 assert!(matches!(err, BrowserTokenError::WrongType { .. }));
665 }
666
667 #[test]
668 fn access_token_cannot_be_used_at_refresh_endpoint() {
669 let auth = authority();
670 let tokens = auth.issue(&identity(), NOW).unwrap();
671 let err = auth
672 .validate_refresh(&tokens.access_token, NOW + 60)
673 .unwrap_err();
674 assert!(matches!(err, BrowserTokenError::WrongType { .. }));
675 }
676
677 #[test]
678 fn refresh_validates_and_mints_new_access() {
679 let auth = authority();
680 let tokens = auth.issue(&identity(), NOW).unwrap();
681 let later = NOW + 10 * 60;
683 let id = auth.validate_refresh(&tokens.refresh_token, later).unwrap();
684 let new_access = auth.issue_access(&id, later).unwrap();
685 let got = auth.validate_access(&new_access, NOW + 20 * 60).unwrap();
688 assert_eq!(got, identity());
689 }
690
691 #[test]
692 fn token_signed_by_a_different_secret_is_rejected() {
693 let auth = authority();
694 let other = BrowserTokenAuthority::new(BrowserTokenConfig::new(
695 b"FEDCBA9876543210FEDCBA9876543210".to_vec(),
696 ))
697 .unwrap();
698 let tokens = other.issue(&identity(), NOW).unwrap();
699 let err = auth
700 .validate_access(&tokens.access_token, NOW + 60)
701 .unwrap_err();
702 assert!(matches!(err, BrowserTokenError::Decode(_)));
703 }
704
705 #[test]
706 fn wrong_audience_rejected() {
707 let auth = authority();
708 let mut cfg = BrowserTokenConfig::new(b"0123456789abcdef0123456789abcdef".to_vec());
709 cfg.audience = "someone-else".to_string();
710 let other = BrowserTokenAuthority::new(cfg).unwrap();
711 let tokens = other.issue(&identity(), NOW).unwrap();
712 let err = auth
713 .validate_access(&tokens.access_token, NOW + 60)
714 .unwrap_err();
715 assert!(matches!(err, BrowserTokenError::Decode(_)));
716 }
717
718 #[test]
719 fn refresh_cookie_carries_security_attributes() {
720 let auth = authority();
721 let cookie = auth.refresh_cookie("the.jwt.value");
722 assert!(cookie.contains("reddb_refresh=the.jwt.value"));
723 assert!(cookie.contains("HttpOnly"));
724 assert!(cookie.contains("Secure"));
725 assert!(cookie.contains("SameSite=Strict"));
726 assert!(cookie.contains("Path=/auth/browser"));
727 assert!(cookie.contains("Max-Age=2592000"));
728 }
729
730 #[test]
731 fn clear_cookie_expires_immediately() {
732 let auth = authority();
733 let cookie = auth.clear_cookie();
734 assert!(cookie.contains("reddb_refresh=;"));
735 assert!(cookie.contains("Max-Age=0"));
736 assert!(cookie.contains("HttpOnly"));
737 }
738
739 #[test]
740 fn samesite_none_forces_secure() {
741 let mut cfg = BrowserTokenConfig::new(b"0123456789abcdef0123456789abcdef".to_vec());
742 cfg.same_site = SameSite::None;
743 cfg.cookie_secure = false; let auth = BrowserTokenAuthority::new(cfg).unwrap();
745 let cookie = auth.refresh_cookie("x");
746 assert!(cookie.contains("SameSite=None"));
747 assert!(cookie.contains("Secure"));
748 }
749
750 #[test]
751 fn cookie_value_extracts_named_cookie() {
752 let header = "other=1; reddb_refresh=abc.def.ghi; theme=dark";
753 assert_eq!(cookie_value(header, "reddb_refresh"), Some("abc.def.ghi"));
754 assert_eq!(cookie_value(header, "missing"), None);
755 }
756
757 #[test]
758 fn cookie_value_handles_single_cookie() {
759 assert_eq!(
760 cookie_value("reddb_refresh=solo", "reddb_refresh"),
761 Some("solo")
762 );
763 }
764}