1use std::collections::HashSet;
44
45use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
46
47use super::Role;
48
49pub const MIN_SECRET_BYTES: usize = 32;
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum SameSite {
63 Strict,
64 Lax,
65 None,
66}
67
68impl SameSite {
69 pub fn as_str(&self) -> &'static str {
70 match self {
71 SameSite::Strict => "Strict",
72 SameSite::Lax => "Lax",
73 SameSite::None => "None",
74 }
75 }
76}
77
78#[derive(Debug, Clone)]
82pub struct BrowserTokenConfig {
83 pub secret: Vec<u8>,
85 pub issuer: String,
87 pub audience: String,
89 pub access_ttl_secs: i64,
92 pub refresh_ttl_secs: i64,
96 pub cookie_secure: bool,
100 pub same_site: SameSite,
102 pub cookie_name: String,
104 pub cookie_path: String,
108}
109
110impl BrowserTokenConfig {
111 pub fn new(secret: impl Into<Vec<u8>>) -> Self {
113 Self {
114 secret: secret.into(),
115 issuer: "reddb-browser".to_string(),
116 audience: "reddb-redwire".to_string(),
117 access_ttl_secs: 15 * 60,
118 refresh_ttl_secs: 30 * 24 * 60 * 60,
119 cookie_secure: true,
120 same_site: SameSite::Strict,
121 cookie_name: "reddb_refresh".to_string(),
122 cookie_path: "/auth/browser".to_string(),
123 }
124 }
125
126 fn sanitised(mut self) -> Self {
130 if self.same_site == SameSite::None {
131 self.cookie_secure = true;
132 }
133 self
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct BrowserIdentity {
140 pub username: String,
141 pub tenant: Option<String>,
142 pub role: Role,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum BrowserTokenError {
149 Decode(String),
152 WrongType { expected: TokenType, got: String },
156 Expired,
158 NotYetValid,
160 BadRole(String),
162}
163
164impl std::fmt::Display for BrowserTokenError {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 match self {
167 BrowserTokenError::Decode(m) => write!(f, "token decode failed: {m}"),
168 BrowserTokenError::WrongType { expected, got } => {
169 write!(
170 f,
171 "wrong token type: expected {}, got {got:?}",
172 expected.as_str()
173 )
174 }
175 BrowserTokenError::Expired => write!(f, "token expired"),
176 BrowserTokenError::NotYetValid => write!(f, "token not yet valid"),
177 BrowserTokenError::BadRole(r) => write!(f, "token carries unknown role {r:?}"),
178 }
179 }
180}
181
182impl std::error::Error for BrowserTokenError {}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum TokenType {
190 Access,
191 Refresh,
192}
193
194impl TokenType {
195 pub fn as_str(&self) -> &'static str {
196 match self {
197 TokenType::Access => "access",
198 TokenType::Refresh => "refresh",
199 }
200 }
201}
202
203#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
207struct Claims {
208 iss: String,
209 aud: String,
210 sub: String,
211 exp: i64,
212 iat: i64,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 nbf: Option<i64>,
215 typ: String,
216 role: String,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 tenant: Option<String>,
219}
220
221#[derive(Debug, Clone)]
224pub struct IssuedTokens {
225 pub access_token: String,
226 pub access_expires_in: i64,
229 pub refresh_token: String,
230}
231
232pub struct BrowserTokenAuthority {
236 config: BrowserTokenConfig,
237 encoding: EncodingKey,
238 decoding: DecodingKey,
239}
240
241impl std::fmt::Debug for BrowserTokenAuthority {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 f.debug_struct("BrowserTokenAuthority")
245 .field("issuer", &self.config.issuer)
246 .field("audience", &self.config.audience)
247 .field("access_ttl_secs", &self.config.access_ttl_secs)
248 .field("refresh_ttl_secs", &self.config.refresh_ttl_secs)
249 .finish_non_exhaustive()
250 }
251}
252
253impl BrowserTokenAuthority {
254 pub fn new(config: BrowserTokenConfig) -> Result<Self, String> {
258 if config.secret.len() < MIN_SECRET_BYTES {
259 return Err(format!(
260 "browser-token secret must be at least {MIN_SECRET_BYTES} bytes, got {}",
261 config.secret.len()
262 ));
263 }
264 let config = config.sanitised();
265 let encoding = EncodingKey::from_secret(&config.secret);
266 let decoding = DecodingKey::from_secret(&config.secret);
267 Ok(Self {
268 config,
269 encoding,
270 decoding,
271 })
272 }
273
274 pub fn access_ttl_secs(&self) -> i64 {
275 self.config.access_ttl_secs
276 }
277
278 pub fn cookie_name(&self) -> &str {
279 &self.config.cookie_name
280 }
281
282 pub fn issue(&self, identity: &BrowserIdentity, now: i64) -> Result<IssuedTokens, String> {
285 let access_token = self.encode(
286 identity,
287 TokenType::Access,
288 now,
289 self.config.access_ttl_secs,
290 )?;
291 let refresh_token = self.encode(
292 identity,
293 TokenType::Refresh,
294 now,
295 self.config.refresh_ttl_secs,
296 )?;
297 Ok(IssuedTokens {
298 access_token,
299 access_expires_in: self.config.access_ttl_secs,
300 refresh_token,
301 })
302 }
303
304 pub fn issue_access(&self, identity: &BrowserIdentity, now: i64) -> Result<String, String> {
307 self.encode(
308 identity,
309 TokenType::Access,
310 now,
311 self.config.access_ttl_secs,
312 )
313 }
314
315 fn encode(
316 &self,
317 identity: &BrowserIdentity,
318 typ: TokenType,
319 now: i64,
320 ttl: i64,
321 ) -> Result<String, String> {
322 let claims = Claims {
323 iss: self.config.issuer.clone(),
324 aud: self.config.audience.clone(),
325 sub: identity.username.clone(),
326 exp: now + ttl,
327 iat: now,
328 nbf: Some(now),
329 typ: typ.as_str().to_string(),
330 role: identity.role.as_str().to_string(),
331 tenant: identity.tenant.clone(),
332 };
333 encode(&Header::new(Algorithm::HS256), &claims, &self.encoding)
334 .map_err(|e| format!("encode browser token: {e}"))
335 }
336
337 pub fn validate_access(
339 &self,
340 token: &str,
341 now: i64,
342 ) -> Result<BrowserIdentity, BrowserTokenError> {
343 self.validate(token, TokenType::Access, now)
344 }
345
346 pub fn validate_refresh(
349 &self,
350 token: &str,
351 now: i64,
352 ) -> Result<BrowserIdentity, BrowserTokenError> {
353 self.validate(token, TokenType::Refresh, now)
354 }
355
356 fn validate(
361 &self,
362 token: &str,
363 expected: TokenType,
364 now: i64,
365 ) -> Result<BrowserIdentity, BrowserTokenError> {
366 let mut validation = Validation::new(Algorithm::HS256);
367 validation.set_issuer(&[self.config.issuer.as_str()]);
368 validation.set_audience(&[self.config.audience.as_str()]);
369 validation.validate_exp = false;
374 validation.validate_nbf = false;
375 validation.required_spec_claims = HashSet::new();
376
377 let data = decode::<Claims>(token, &self.decoding, &validation)
378 .map_err(|e| BrowserTokenError::Decode(e.to_string()))?;
379 let claims = data.claims;
380
381 if claims.typ != expected.as_str() {
382 return Err(BrowserTokenError::WrongType {
383 expected,
384 got: claims.typ,
385 });
386 }
387 if now >= claims.exp {
388 return Err(BrowserTokenError::Expired);
389 }
390 if let Some(nbf) = claims.nbf {
391 if now < nbf {
392 return Err(BrowserTokenError::NotYetValid);
393 }
394 }
395 let role = Role::from_str(&claims.role).ok_or(BrowserTokenError::BadRole(claims.role))?;
396 Ok(BrowserIdentity {
397 username: claims.sub,
398 tenant: claims.tenant,
399 role,
400 })
401 }
402
403 pub fn refresh_cookie(&self, refresh_token: &str) -> String {
407 self.build_cookie(refresh_token, self.config.refresh_ttl_secs)
408 }
409
410 pub fn clear_cookie(&self) -> String {
414 self.build_cookie("", 0)
415 }
416
417 fn build_cookie(&self, value: &str, max_age: i64) -> String {
418 let mut cookie = format!(
419 "{}={}; HttpOnly; Path={}; Max-Age={}; SameSite={}",
420 self.config.cookie_name,
421 value,
422 self.config.cookie_path,
423 max_age,
424 self.config.same_site.as_str()
425 );
426 if self.config.cookie_secure {
427 cookie.push_str("; Secure");
428 }
429 cookie
430 }
431}
432
433pub fn cookie_value<'a>(cookie_header: &'a str, name: &str) -> Option<&'a str> {
437 cookie_header.split(';').find_map(|pair| {
438 let pair = pair.trim();
439 let (k, v) = pair.split_once('=')?;
440 if k.trim() == name {
441 Some(v.trim())
442 } else {
443 None
444 }
445 })
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 const NOW: i64 = 1_750_000_000;
453
454 fn authority() -> BrowserTokenAuthority {
455 let secret = b"0123456789abcdef0123456789abcdef".to_vec();
456 BrowserTokenAuthority::new(BrowserTokenConfig::new(secret)).unwrap()
457 }
458
459 fn identity() -> BrowserIdentity {
460 BrowserIdentity {
461 username: "alice".to_string(),
462 tenant: Some("acme".to_string()),
463 role: Role::Write,
464 }
465 }
466
467 #[test]
468 fn rejects_short_secret() {
469 let err = BrowserTokenAuthority::new(BrowserTokenConfig::new(b"too-short".to_vec()));
470 assert!(err.is_err());
471 }
472
473 #[test]
474 fn issue_then_validate_access_roundtrip() {
475 let auth = authority();
476 let tokens = auth.issue(&identity(), NOW).unwrap();
477 let id = auth
478 .validate_access(&tokens.access_token, NOW + 60)
479 .unwrap();
480 assert_eq!(id, identity());
481 assert_eq!(tokens.access_expires_in, 15 * 60);
482 }
483
484 #[test]
485 fn platform_scoped_identity_has_no_tenant() {
486 let auth = authority();
487 let id = BrowserIdentity {
488 username: "root".to_string(),
489 tenant: None,
490 role: Role::Admin,
491 };
492 let tokens = auth.issue(&id, NOW).unwrap();
493 let got = auth.validate_access(&tokens.access_token, NOW + 1).unwrap();
494 assert_eq!(got.tenant, None);
495 assert_eq!(got.role, Role::Admin);
496 }
497
498 #[test]
499 fn expired_access_token_rejected() {
500 let auth = authority();
501 let tokens = auth.issue(&identity(), NOW).unwrap();
502 let err = auth
504 .validate_access(&tokens.access_token, NOW + 16 * 60)
505 .unwrap_err();
506 assert_eq!(err, BrowserTokenError::Expired);
507 }
508
509 #[test]
510 fn not_yet_valid_token_rejected() {
511 let auth = authority();
512 let tokens = auth.issue(&identity(), NOW).unwrap();
513 let err = auth
515 .validate_access(&tokens.access_token, NOW - 10)
516 .unwrap_err();
517 assert_eq!(err, BrowserTokenError::NotYetValid);
518 }
519
520 #[test]
521 fn refresh_token_cannot_authenticate_a_session() {
522 let auth = authority();
526 let tokens = auth.issue(&identity(), NOW).unwrap();
527 let err = auth
528 .validate_access(&tokens.refresh_token, NOW + 60)
529 .unwrap_err();
530 assert!(matches!(err, BrowserTokenError::WrongType { .. }));
531 }
532
533 #[test]
534 fn access_token_cannot_be_used_at_refresh_endpoint() {
535 let auth = authority();
536 let tokens = auth.issue(&identity(), NOW).unwrap();
537 let err = auth
538 .validate_refresh(&tokens.access_token, NOW + 60)
539 .unwrap_err();
540 assert!(matches!(err, BrowserTokenError::WrongType { .. }));
541 }
542
543 #[test]
544 fn refresh_validates_and_mints_new_access() {
545 let auth = authority();
546 let tokens = auth.issue(&identity(), NOW).unwrap();
547 let later = NOW + 10 * 60;
549 let id = auth.validate_refresh(&tokens.refresh_token, later).unwrap();
550 let new_access = auth.issue_access(&id, later).unwrap();
551 let got = auth.validate_access(&new_access, NOW + 20 * 60).unwrap();
554 assert_eq!(got, identity());
555 }
556
557 #[test]
558 fn token_signed_by_a_different_secret_is_rejected() {
559 let auth = authority();
560 let other = BrowserTokenAuthority::new(BrowserTokenConfig::new(
561 b"FEDCBA9876543210FEDCBA9876543210".to_vec(),
562 ))
563 .unwrap();
564 let tokens = other.issue(&identity(), NOW).unwrap();
565 let err = auth
566 .validate_access(&tokens.access_token, NOW + 60)
567 .unwrap_err();
568 assert!(matches!(err, BrowserTokenError::Decode(_)));
569 }
570
571 #[test]
572 fn wrong_audience_rejected() {
573 let auth = authority();
574 let mut cfg = BrowserTokenConfig::new(b"0123456789abcdef0123456789abcdef".to_vec());
575 cfg.audience = "someone-else".to_string();
576 let other = BrowserTokenAuthority::new(cfg).unwrap();
577 let tokens = other.issue(&identity(), NOW).unwrap();
578 let err = auth
579 .validate_access(&tokens.access_token, NOW + 60)
580 .unwrap_err();
581 assert!(matches!(err, BrowserTokenError::Decode(_)));
582 }
583
584 #[test]
585 fn refresh_cookie_carries_security_attributes() {
586 let auth = authority();
587 let cookie = auth.refresh_cookie("the.jwt.value");
588 assert!(cookie.contains("reddb_refresh=the.jwt.value"));
589 assert!(cookie.contains("HttpOnly"));
590 assert!(cookie.contains("Secure"));
591 assert!(cookie.contains("SameSite=Strict"));
592 assert!(cookie.contains("Path=/auth/browser"));
593 assert!(cookie.contains("Max-Age=2592000"));
594 }
595
596 #[test]
597 fn clear_cookie_expires_immediately() {
598 let auth = authority();
599 let cookie = auth.clear_cookie();
600 assert!(cookie.contains("reddb_refresh=;"));
601 assert!(cookie.contains("Max-Age=0"));
602 assert!(cookie.contains("HttpOnly"));
603 }
604
605 #[test]
606 fn samesite_none_forces_secure() {
607 let mut cfg = BrowserTokenConfig::new(b"0123456789abcdef0123456789abcdef".to_vec());
608 cfg.same_site = SameSite::None;
609 cfg.cookie_secure = false; let auth = BrowserTokenAuthority::new(cfg).unwrap();
611 let cookie = auth.refresh_cookie("x");
612 assert!(cookie.contains("SameSite=None"));
613 assert!(cookie.contains("Secure"));
614 }
615
616 #[test]
617 fn cookie_value_extracts_named_cookie() {
618 let header = "other=1; reddb_refresh=abc.def.ghi; theme=dark";
619 assert_eq!(cookie_value(header, "reddb_refresh"), Some("abc.def.ghi"));
620 assert_eq!(cookie_value(header, "missing"), None);
621 }
622
623 #[test]
624 fn cookie_value_handles_single_cookie() {
625 assert_eq!(
626 cookie_value("reddb_refresh=solo", "reddb_refresh"),
627 Some("solo")
628 );
629 }
630}