use std::fmt;
use std::time::{Duration, Instant};
const DEFAULT_TTL: Duration = Duration::from_secs(60);
const EXPIRY_SKEW: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Challenge {
pub realm: String,
pub service: Option<String>,
pub scope: Option<String>,
}
pub fn parse_www_authenticate(header: &str) -> Option<Challenge> {
let trimmed = header.trim();
let rest = trimmed
.get(..6)
.filter(|s| s.eq_ignore_ascii_case("Bearer"))?;
let rest = &trimmed[rest.len()..];
if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
return None; }
let mut realm = None;
let mut service = None;
let mut scope = None;
for part in rest.split(',') {
let Some((key, value)) = part.split_once('=') else {
continue;
};
let value = value.trim().trim_matches('"').to_string();
match key.trim().to_ascii_lowercase().as_str() {
"realm" => realm = Some(value),
"service" => service = Some(value),
"scope" => scope = Some(value),
_ => {}
}
}
Some(Challenge {
realm: realm?,
service,
scope,
})
}
#[derive(Clone)]
pub struct CachedToken {
token: String,
expires_at: Instant,
}
impl fmt::Debug for CachedToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CachedToken")
.field("token", &"[REDACTED]")
.field("expires_at", &self.expires_at)
.finish()
}
}
impl CachedToken {
pub fn new(token: String, expires_in_secs: Option<u64>, now: Instant) -> Self {
let ttl = expires_in_secs.map_or(DEFAULT_TTL, Duration::from_secs);
let effective = ttl.saturating_sub(EXPIRY_SKEW).max(Duration::from_secs(1));
let expires_at = now
.checked_add(effective)
.unwrap_or_else(|| now + DEFAULT_TTL);
Self { token, expires_at }
}
pub fn valid_token(&self, now: Instant) -> Option<&str> {
(now < self.expires_at).then_some(self.token.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_full_challenge() {
let c = parse_www_authenticate(
r#"Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/alpine:pull""#,
)
.unwrap();
assert_eq!(c.realm, "https://auth.docker.io/token");
assert_eq!(c.service.as_deref(), Some("registry.docker.io"));
assert_eq!(c.scope.as_deref(), Some("repository:library/alpine:pull"));
}
#[test]
fn parses_challenge_without_scope() {
let c = parse_www_authenticate(r#"Bearer realm="https://ghcr.io/token",service="ghcr.io""#)
.unwrap();
assert_eq!(c.realm, "https://ghcr.io/token");
assert_eq!(c.service.as_deref(), Some("ghcr.io"));
assert!(c.scope.is_none());
}
#[test]
fn scheme_is_case_insensitive_and_tolerates_spacing() {
let c =
parse_www_authenticate(r#"bearer realm = "https://r/token" , service="s""#).unwrap();
assert_eq!(c.realm, "https://r/token");
assert_eq!(c.service.as_deref(), Some("s"));
}
#[test]
fn rejects_non_bearer_and_missing_realm() {
assert!(parse_www_authenticate(r#"Basic realm="x""#).is_none());
assert!(parse_www_authenticate(r#"Bearer service="s""#).is_none());
assert!(parse_www_authenticate("Bearerish realm=\"x\"").is_none());
assert!(parse_www_authenticate("").is_none());
}
#[test]
fn token_validity_respects_expiry() {
let now = Instant::now();
let t = CachedToken::new("tok".into(), Some(300), now);
assert_eq!(t.valid_token(now), Some("tok"));
assert_eq!(t.valid_token(now + Duration::from_secs(600)), None);
}
#[test]
fn zero_expires_in_does_not_panic_and_floors_to_one_second() {
let now = Instant::now();
let t = CachedToken::new("tok".into(), Some(0), now);
assert_eq!(t.valid_token(now), Some("tok"));
}
#[test]
fn cached_token_debug_redacts_the_token() {
let t = CachedToken::new("supersecret".into(), Some(60), Instant::now());
assert!(
!format!("{t:?}").contains("supersecret"),
"token leaked via CachedToken Debug"
);
}
}