use secrecy::{ExposeSecret, SecretString};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
#[derive(Clone, Debug)]
pub struct BearerToken {
secret: Arc<SecretString>,
expires_on: Option<Instant>,
}
impl BearerToken {
#[must_use]
pub fn without_expiry(secret: impl Into<SecretString>) -> Self {
Self {
secret: Arc::new(secret.into()),
expires_on: None,
}
}
#[must_use]
pub fn with_expiry(secret: impl Into<SecretString>, expires_on: Option<Instant>) -> Self {
Self {
secret: Arc::new(secret.into()),
expires_on,
}
}
#[must_use]
pub fn from_absolute_expiry(secret: impl Into<SecretString>, expires_on: SystemTime) -> Self {
Self::with_expiry(secret, absolute_to_monotonic(expires_on))
}
#[must_use]
pub fn from_relative_expiry(secret: impl Into<SecretString>, expires_in: Duration) -> Self {
Self::with_expiry(secret, Instant::now().checked_add(expires_in))
}
#[must_use]
pub fn from_header_value(header_value: &str) -> Option<Self> {
let token = extract_bearer_token(header_value)?;
Some(Self::without_expiry(token.to_owned()))
}
#[must_use]
pub fn expose_token(&self) -> &str {
self.secret.expose_secret()
}
#[must_use]
pub const fn expires_on(&self) -> Option<Instant> {
self.expires_on
}
}
fn extract_bearer_token(header_value: &str) -> Option<&str> {
let trimmed = header_value.trim_matches([' ', '\t']);
let (scheme, rest) = trimmed.split_once(' ')?;
if !scheme.eq_ignore_ascii_case("Bearer") {
return None;
}
let token = rest.trim_matches(' ');
is_valid_b64token(token).then_some(token)
}
fn is_valid_b64token(token: &str) -> bool {
let mut saw_token_byte = false;
let mut saw_padding = false;
for byte in token.bytes() {
if byte == b'=' {
saw_padding = true;
} else if saw_padding
|| !(byte.is_ascii_alphanumeric()
|| matches!(byte, b'-' | b'.' | b'_' | b'~' | b'+' | b'/'))
{
return false;
} else {
saw_token_byte = true;
}
}
saw_token_byte
}
fn absolute_to_monotonic(expires_on: SystemTime) -> Option<Instant> {
let remaining = expires_on
.duration_since(SystemTime::now())
.unwrap_or(Duration::ZERO);
Instant::now().checked_add(remaining)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn without_expiry_holds_opaque_token() {
let token = BearerToken::without_expiry("opaque-secret-token".to_owned());
assert_eq!(token.expose_token(), "opaque-secret-token");
assert_eq!(token.expires_on(), None);
}
#[test]
fn with_expiry_round_trips_accessors() {
let now = Instant::now();
let token = BearerToken::with_expiry("super-secret".to_owned(), Some(now));
assert_eq!(token.expose_token(), "super-secret");
assert_eq!(token.expires_on(), Some(now));
let non_expiring = BearerToken::with_expiry("s".to_owned(), None);
assert_eq!(non_expiring.expires_on(), None);
}
#[test]
fn from_absolute_expiry_converts_future_wall_clock_to_instant() {
let before = Instant::now();
let token = BearerToken::from_absolute_expiry(
"s".to_owned(),
SystemTime::now() + Duration::from_secs(60),
);
let after = Instant::now();
let expiry = token.expires_on().expect("future expiry is set");
assert!(expiry >= before + Duration::from_secs(59));
assert!(expiry <= after + Duration::from_secs(61));
}
#[test]
fn from_absolute_expiry_clamps_past_wall_clock_to_now() {
let before = Instant::now();
let token = BearerToken::from_absolute_expiry(
"s".to_owned(),
SystemTime::now() - Duration::from_secs(60),
);
let after = Instant::now();
let expiry = token.expires_on().expect("expiry is set");
assert!(expiry >= before);
assert!(expiry <= after);
}
#[test]
fn from_absolute_expiry_does_not_panic_on_far_future() {
let far = SystemTime::now() + Duration::from_secs(60 * 60 * 24 * 365 * 1000);
let token = BearerToken::from_absolute_expiry("s".to_owned(), far);
if let Some(expiry) = token.expires_on() {
assert!(expiry > Instant::now());
}
}
#[test]
fn from_relative_expiry_offsets_from_now() {
let before = Instant::now();
let token = BearerToken::from_relative_expiry("s".to_owned(), Duration::from_secs(60));
let after = Instant::now();
let expiry = token.expires_on().expect("future expiry is set");
assert!(expiry >= before + Duration::from_secs(60));
assert!(expiry <= after + Duration::from_secs(60));
}
#[test]
fn from_relative_expiry_does_not_panic_on_absurd_lifetime() {
let token = BearerToken::from_relative_expiry("s".to_owned(), Duration::MAX);
assert_eq!(token.expires_on(), None);
assert_eq!(token.expose_token(), "s");
}
#[test]
fn from_relative_expiry_zero_expires_immediately() {
let before = Instant::now();
let token = BearerToken::from_relative_expiry("s".to_owned(), Duration::ZERO);
let after = Instant::now();
let expiry = token
.expires_on()
.expect("zero lifetime still sets an expiry");
assert!(expiry >= before);
assert!(expiry <= after);
}
#[test]
fn clone_shares_the_same_secret_allocation() {
let token = BearerToken::without_expiry("super-secret".to_owned());
let cloned = token.clone();
assert_eq!(token.expose_token(), cloned.expose_token());
assert!(std::ptr::eq(token.expose_token(), cloned.expose_token()));
}
#[test]
fn debug_never_leaks_the_secret() {
let token = BearerToken::without_expiry("super-secret-token".to_owned());
let rendered = format!("{token:?}");
assert!(
!rendered.contains("super-secret-token"),
"secret leaked: {rendered}"
);
}
#[test]
fn from_header_value_strips_bearer_prefix() {
assert_eq!(
BearerToken::from_header_value("Bearer eyJ.abc.sig")
.expect("valid bearer header")
.expose_token(),
"eyJ.abc.sig"
);
assert_eq!(
BearerToken::from_header_value(" bEaReR eyJ.abc.sig ")
.expect("valid case-insensitive bearer header")
.expose_token(),
"eyJ.abc.sig"
);
}
#[test]
fn from_header_value_rejects_missing_scheme() {
assert!(BearerToken::from_header_value(" eyJ.abc.sig ").is_none());
}
#[test]
fn from_header_value_rejects_non_bearer_scheme() {
assert!(BearerToken::from_header_value("Basic dXNlcjpwYXNz").is_none());
}
#[test]
fn from_header_value_requires_whitespace_separator() {
assert!(BearerToken::from_header_value("BearerToken").is_none());
assert!(BearerToken::from_header_value("Bearer-eyJ.abc.sig").is_none());
}
#[test]
fn from_header_value_rejects_scheme_only() {
assert!(BearerToken::from_header_value("Bearer").is_none());
assert!(BearerToken::from_header_value("Bearer ").is_none());
}
#[test]
fn from_header_value_accepts_multiple_spaces() {
assert_eq!(
BearerToken::from_header_value("Bearer eyJ.abc.sig")
.expect("valid bearer header")
.expose_token(),
"eyJ.abc.sig"
);
}
#[test]
fn from_header_value_rejects_tab_separator() {
assert!(BearerToken::from_header_value("Bearer\teyJ.abc.sig").is_none());
}
#[test]
fn from_header_value_accepts_b64token_characters() {
let token = "AZaz09-._~+/==";
assert_eq!(
BearerToken::from_header_value(&format!("Bearer {token}"))
.expect("valid b64token")
.expose_token(),
token
);
}
#[test]
fn from_header_value_rejects_invalid_b64token_characters() {
assert!(BearerToken::from_header_value("Bearer abc:def").is_none());
assert!(BearerToken::from_header_value("Bearer abc=def").is_none());
}
#[test]
fn from_header_value_rejects_whitespace_in_token() {
assert!(BearerToken::from_header_value("Bearer first second").is_none());
}
#[test]
fn from_header_value_rejects_empty_or_whitespace() {
assert!(BearerToken::from_header_value("").is_none());
assert!(BearerToken::from_header_value(" ").is_none());
}
#[test]
fn without_expiry_does_not_strip_scheme() {
assert_eq!(
BearerToken::without_expiry("Bearer eyJ.abc.sig".to_owned()).expose_token(),
"Bearer eyJ.abc.sig"
);
}
}