use std::collections::HashSet;
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use super::Role;
pub const MIN_SECRET_BYTES: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SameSite {
Strict,
Lax,
None,
}
impl SameSite {
pub fn as_str(&self) -> &'static str {
match self {
SameSite::Strict => "Strict",
SameSite::Lax => "Lax",
SameSite::None => "None",
}
}
}
#[derive(Debug, Clone)]
pub struct BrowserTokenConfig {
pub secret: Vec<u8>,
pub issuer: String,
pub audience: String,
pub access_ttl_secs: i64,
pub refresh_ttl_secs: i64,
pub cookie_secure: bool,
pub same_site: SameSite,
pub cookie_name: String,
pub cookie_path: String,
}
impl BrowserTokenConfig {
pub fn new(secret: impl Into<Vec<u8>>) -> Self {
Self {
secret: secret.into(),
issuer: "reddb-browser".to_string(),
audience: "reddb-redwire".to_string(),
access_ttl_secs: 15 * 60,
refresh_ttl_secs: 30 * 24 * 60 * 60,
cookie_secure: true,
same_site: SameSite::Strict,
cookie_name: "reddb_refresh".to_string(),
cookie_path: "/auth/browser".to_string(),
}
}
fn sanitised(mut self) -> Self {
if self.same_site == SameSite::None {
self.cookie_secure = true;
}
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BrowserIdentity {
pub username: String,
pub tenant: Option<String>,
pub role: Role,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BrowserTokenError {
Decode(String),
WrongType { expected: TokenType, got: String },
Expired,
NotYetValid,
BadRole(String),
}
impl std::fmt::Display for BrowserTokenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BrowserTokenError::Decode(m) => write!(f, "token decode failed: {m}"),
BrowserTokenError::WrongType { expected, got } => {
write!(
f,
"wrong token type: expected {}, got {got:?}",
expected.as_str()
)
}
BrowserTokenError::Expired => write!(f, "token expired"),
BrowserTokenError::NotYetValid => write!(f, "token not yet valid"),
BrowserTokenError::BadRole(r) => write!(f, "token carries unknown role {r:?}"),
}
}
}
impl std::error::Error for BrowserTokenError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenType {
Access,
Refresh,
}
impl TokenType {
pub fn as_str(&self) -> &'static str {
match self {
TokenType::Access => "access",
TokenType::Refresh => "refresh",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct Claims {
iss: String,
aud: String,
sub: String,
exp: i64,
iat: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
nbf: Option<i64>,
typ: String,
role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
tenant: Option<String>,
}
#[derive(Debug, Clone)]
pub struct IssuedTokens {
pub access_token: String,
pub access_expires_in: i64,
pub refresh_token: String,
}
pub struct BrowserTokenAuthority {
config: BrowserTokenConfig,
encoding: EncodingKey,
decoding: DecodingKey,
}
impl std::fmt::Debug for BrowserTokenAuthority {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BrowserTokenAuthority")
.field("issuer", &self.config.issuer)
.field("audience", &self.config.audience)
.field("access_ttl_secs", &self.config.access_ttl_secs)
.field("refresh_ttl_secs", &self.config.refresh_ttl_secs)
.finish_non_exhaustive()
}
}
impl BrowserTokenAuthority {
pub fn new(config: BrowserTokenConfig) -> Result<Self, String> {
if config.secret.len() < MIN_SECRET_BYTES {
return Err(format!(
"browser-token secret must be at least {MIN_SECRET_BYTES} bytes, got {}",
config.secret.len()
));
}
let config = config.sanitised();
let encoding = EncodingKey::from_secret(&config.secret);
let decoding = DecodingKey::from_secret(&config.secret);
Ok(Self {
config,
encoding,
decoding,
})
}
pub fn access_ttl_secs(&self) -> i64 {
self.config.access_ttl_secs
}
pub fn cookie_name(&self) -> &str {
&self.config.cookie_name
}
pub fn issue(&self, identity: &BrowserIdentity, now: i64) -> Result<IssuedTokens, String> {
let access_token = self.encode(
identity,
TokenType::Access,
now,
self.config.access_ttl_secs,
)?;
let refresh_token = self.encode(
identity,
TokenType::Refresh,
now,
self.config.refresh_ttl_secs,
)?;
Ok(IssuedTokens {
access_token,
access_expires_in: self.config.access_ttl_secs,
refresh_token,
})
}
pub fn issue_access(&self, identity: &BrowserIdentity, now: i64) -> Result<String, String> {
self.encode(
identity,
TokenType::Access,
now,
self.config.access_ttl_secs,
)
}
fn encode(
&self,
identity: &BrowserIdentity,
typ: TokenType,
now: i64,
ttl: i64,
) -> Result<String, String> {
let claims = Claims {
iss: self.config.issuer.clone(),
aud: self.config.audience.clone(),
sub: identity.username.clone(),
exp: now + ttl,
iat: now,
nbf: Some(now),
typ: typ.as_str().to_string(),
role: identity.role.as_str().to_string(),
tenant: identity.tenant.clone(),
};
encode(&Header::new(Algorithm::HS256), &claims, &self.encoding)
.map_err(|e| format!("encode browser token: {e}"))
}
pub fn validate_access(
&self,
token: &str,
now: i64,
) -> Result<BrowserIdentity, BrowserTokenError> {
self.validate(token, TokenType::Access, now)
}
pub fn validate_refresh(
&self,
token: &str,
now: i64,
) -> Result<BrowserIdentity, BrowserTokenError> {
self.validate(token, TokenType::Refresh, now)
}
fn validate(
&self,
token: &str,
expected: TokenType,
now: i64,
) -> Result<BrowserIdentity, BrowserTokenError> {
let mut validation = Validation::new(Algorithm::HS256);
validation.set_issuer(&[self.config.issuer.as_str()]);
validation.set_audience(&[self.config.audience.as_str()]);
validation.validate_exp = false;
validation.validate_nbf = false;
validation.required_spec_claims = HashSet::new();
let data = decode::<Claims>(token, &self.decoding, &validation)
.map_err(|e| BrowserTokenError::Decode(e.to_string()))?;
let claims = data.claims;
if claims.typ != expected.as_str() {
return Err(BrowserTokenError::WrongType {
expected,
got: claims.typ,
});
}
if now >= claims.exp {
return Err(BrowserTokenError::Expired);
}
if let Some(nbf) = claims.nbf {
if now < nbf {
return Err(BrowserTokenError::NotYetValid);
}
}
let role = Role::from_str(&claims.role).ok_or(BrowserTokenError::BadRole(claims.role))?;
Ok(BrowserIdentity {
username: claims.sub,
tenant: claims.tenant,
role,
})
}
pub fn refresh_cookie(&self, refresh_token: &str) -> String {
self.build_cookie(refresh_token, self.config.refresh_ttl_secs)
}
pub fn clear_cookie(&self) -> String {
self.build_cookie("", 0)
}
fn build_cookie(&self, value: &str, max_age: i64) -> String {
let mut cookie = format!(
"{}={}; HttpOnly; Path={}; Max-Age={}; SameSite={}",
self.config.cookie_name,
value,
self.config.cookie_path,
max_age,
self.config.same_site.as_str()
);
if self.config.cookie_secure {
cookie.push_str("; Secure");
}
cookie
}
}
pub fn cookie_value<'a>(cookie_header: &'a str, name: &str) -> Option<&'a str> {
cookie_header.split(';').find_map(|pair| {
let pair = pair.trim();
let (k, v) = pair.split_once('=')?;
if k.trim() == name {
Some(v.trim())
} else {
None
}
})
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: i64 = 1_750_000_000;
fn authority() -> BrowserTokenAuthority {
let secret = b"0123456789abcdef0123456789abcdef".to_vec();
BrowserTokenAuthority::new(BrowserTokenConfig::new(secret)).unwrap()
}
fn identity() -> BrowserIdentity {
BrowserIdentity {
username: "alice".to_string(),
tenant: Some("acme".to_string()),
role: Role::Write,
}
}
#[test]
fn rejects_short_secret() {
let err = BrowserTokenAuthority::new(BrowserTokenConfig::new(b"too-short".to_vec()));
assert!(err.is_err());
}
#[test]
fn issue_then_validate_access_roundtrip() {
let auth = authority();
let tokens = auth.issue(&identity(), NOW).unwrap();
let id = auth
.validate_access(&tokens.access_token, NOW + 60)
.unwrap();
assert_eq!(id, identity());
assert_eq!(tokens.access_expires_in, 15 * 60);
}
#[test]
fn platform_scoped_identity_has_no_tenant() {
let auth = authority();
let id = BrowserIdentity {
username: "root".to_string(),
tenant: None,
role: Role::Admin,
};
let tokens = auth.issue(&id, NOW).unwrap();
let got = auth.validate_access(&tokens.access_token, NOW + 1).unwrap();
assert_eq!(got.tenant, None);
assert_eq!(got.role, Role::Admin);
}
#[test]
fn expired_access_token_rejected() {
let auth = authority();
let tokens = auth.issue(&identity(), NOW).unwrap();
let err = auth
.validate_access(&tokens.access_token, NOW + 16 * 60)
.unwrap_err();
assert_eq!(err, BrowserTokenError::Expired);
}
#[test]
fn not_yet_valid_token_rejected() {
let auth = authority();
let tokens = auth.issue(&identity(), NOW).unwrap();
let err = auth
.validate_access(&tokens.access_token, NOW - 10)
.unwrap_err();
assert_eq!(err, BrowserTokenError::NotYetValid);
}
#[test]
fn refresh_token_cannot_authenticate_a_session() {
let auth = authority();
let tokens = auth.issue(&identity(), NOW).unwrap();
let err = auth
.validate_access(&tokens.refresh_token, NOW + 60)
.unwrap_err();
assert!(matches!(err, BrowserTokenError::WrongType { .. }));
}
#[test]
fn access_token_cannot_be_used_at_refresh_endpoint() {
let auth = authority();
let tokens = auth.issue(&identity(), NOW).unwrap();
let err = auth
.validate_refresh(&tokens.access_token, NOW + 60)
.unwrap_err();
assert!(matches!(err, BrowserTokenError::WrongType { .. }));
}
#[test]
fn refresh_validates_and_mints_new_access() {
let auth = authority();
let tokens = auth.issue(&identity(), NOW).unwrap();
let later = NOW + 10 * 60;
let id = auth.validate_refresh(&tokens.refresh_token, later).unwrap();
let new_access = auth.issue_access(&id, later).unwrap();
let got = auth.validate_access(&new_access, NOW + 20 * 60).unwrap();
assert_eq!(got, identity());
}
#[test]
fn token_signed_by_a_different_secret_is_rejected() {
let auth = authority();
let other = BrowserTokenAuthority::new(BrowserTokenConfig::new(
b"FEDCBA9876543210FEDCBA9876543210".to_vec(),
))
.unwrap();
let tokens = other.issue(&identity(), NOW).unwrap();
let err = auth
.validate_access(&tokens.access_token, NOW + 60)
.unwrap_err();
assert!(matches!(err, BrowserTokenError::Decode(_)));
}
#[test]
fn wrong_audience_rejected() {
let auth = authority();
let mut cfg = BrowserTokenConfig::new(b"0123456789abcdef0123456789abcdef".to_vec());
cfg.audience = "someone-else".to_string();
let other = BrowserTokenAuthority::new(cfg).unwrap();
let tokens = other.issue(&identity(), NOW).unwrap();
let err = auth
.validate_access(&tokens.access_token, NOW + 60)
.unwrap_err();
assert!(matches!(err, BrowserTokenError::Decode(_)));
}
#[test]
fn refresh_cookie_carries_security_attributes() {
let auth = authority();
let cookie = auth.refresh_cookie("the.jwt.value");
assert!(cookie.contains("reddb_refresh=the.jwt.value"));
assert!(cookie.contains("HttpOnly"));
assert!(cookie.contains("Secure"));
assert!(cookie.contains("SameSite=Strict"));
assert!(cookie.contains("Path=/auth/browser"));
assert!(cookie.contains("Max-Age=2592000"));
}
#[test]
fn clear_cookie_expires_immediately() {
let auth = authority();
let cookie = auth.clear_cookie();
assert!(cookie.contains("reddb_refresh=;"));
assert!(cookie.contains("Max-Age=0"));
assert!(cookie.contains("HttpOnly"));
}
#[test]
fn samesite_none_forces_secure() {
let mut cfg = BrowserTokenConfig::new(b"0123456789abcdef0123456789abcdef".to_vec());
cfg.same_site = SameSite::None;
cfg.cookie_secure = false; let auth = BrowserTokenAuthority::new(cfg).unwrap();
let cookie = auth.refresh_cookie("x");
assert!(cookie.contains("SameSite=None"));
assert!(cookie.contains("Secure"));
}
#[test]
fn cookie_value_extracts_named_cookie() {
let header = "other=1; reddb_refresh=abc.def.ghi; theme=dark";
assert_eq!(cookie_value(header, "reddb_refresh"), Some("abc.def.ghi"));
assert_eq!(cookie_value(header, "missing"), None);
}
#[test]
fn cookie_value_handles_single_cookie() {
assert_eq!(
cookie_value("reddb_refresh=solo", "reddb_refresh"),
Some("solo")
);
}
}