use std::fmt;
use std::sync::Arc;
use serde::Deserialize;
pub const MIN_TOKEN_LEN: usize = 32;
#[derive(Clone, Deserialize)]
#[serde(transparent)]
pub struct Token(String);
impl Token {
#[must_use]
pub fn new(token: impl Into<String>) -> Self {
Self(token.into())
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn matches(&self, presented: &str) -> bool {
let (configured, presented) = (self.0.as_bytes(), presented.as_bytes());
let mut difference = u8::from(configured.len() != presented.len());
for (left, right) in configured.iter().zip(presented) {
difference |= left ^ right;
}
difference == 0
}
pub(crate) fn same_as(&self, other: &Self) -> bool {
self.0 == other.0
}
}
impl fmt::Debug for Token {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Token(***)")
}
}
#[derive(Clone, Debug)]
pub struct Principal(Arc<Inner>);
#[derive(Debug)]
struct Inner {
name: String,
applications: Vec<String>,
}
impl Principal {
#[must_use]
pub fn new(
name: impl Into<String>,
applications: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self(Arc::new(Inner {
name: name.into(),
applications: applications.into_iter().map(Into::into).collect(),
}))
}
#[must_use]
pub fn name(&self) -> &str {
&self.0.name
}
#[must_use]
pub fn may_read(&self, application: &str) -> bool {
self.0
.applications
.iter()
.any(|granted| granted == application)
}
#[must_use]
pub fn applications(&self) -> &[String] {
&self.0.applications
}
}
#[derive(Debug)]
pub struct Authenticator {
clients: Vec<(Token, Principal)>,
anonymous: Option<Principal>,
}
impl Authenticator {
#[must_use]
pub fn new(
clients: impl IntoIterator<Item = (Token, Principal)>,
anonymous: Option<Principal>,
) -> Self {
Self {
clients: clients.into_iter().collect(),
anonymous,
}
}
#[must_use]
pub fn allows_anonymous(&self) -> bool {
self.anonymous.is_some()
}
#[must_use]
pub fn authenticate(&self, authorization: Option<&str>) -> Option<Principal> {
let Some(header) = authorization else {
return self.anonymous.clone();
};
let presented = bearer(header)?;
let mut found = None;
for (token, principal) in &self.clients {
let hit = token.matches(presented);
if hit && found.is_none() {
found = Some(principal.clone());
}
}
found
}
}
fn bearer(header: &str) -> Option<&str> {
let (scheme, token) = header.split_once(' ')?;
if !scheme.eq_ignore_ascii_case("bearer") {
return None;
}
let token = token.trim_start();
(!token.is_empty()).then_some(token)
}
#[cfg(test)]
mod tests {
use super::*;
fn authenticator() -> Authenticator {
Authenticator::new(
[(
Token::new("0123456789abcdef0123456789abcdef"),
Principal::new("billing-pod", ["billing"]),
)],
None,
)
}
#[test]
fn a_configured_token_authenticates_its_client() {
let principal = authenticator()
.authenticate(Some("Bearer 0123456789abcdef0123456789abcdef"))
.expect("the configured token");
assert_eq!(principal.name(), "billing-pod");
assert!(principal.may_read("billing"));
}
#[test]
fn the_scheme_is_case_insensitive_and_the_token_is_not() {
let authenticator = authenticator();
assert!(authenticator
.authenticate(Some("bearer 0123456789abcdef0123456789abcdef"))
.is_some());
assert!(authenticator
.authenticate(Some("Bearer 0123456789ABCDEF0123456789ABCDEF"))
.is_none());
}
#[test]
fn an_unusable_header_is_nobody_even_when_anonymous_is_configured() {
let authenticator = Authenticator::new(
[(
Token::new("0123456789abcdef0123456789abcdef"),
Principal::new("billing-pod", ["billing"]),
)],
Some(Principal::new("anonymous", ["demo"])),
);
assert_eq!(
authenticator
.authenticate(None)
.map(|who| who.name().to_owned()),
Some("anonymous".to_owned())
);
assert!(authenticator.authenticate(Some("Bearer wrong")).is_none());
assert!(authenticator.authenticate(Some("Basic abc")).is_none());
assert!(authenticator.authenticate(Some("Bearer ")).is_none());
assert!(authenticator.authenticate(Some("garbage")).is_none());
}
#[test]
fn a_grant_is_exact() {
let principal = Principal::new("who", ["billing"]);
assert!(principal.may_read("billing"));
assert!(!principal.may_read("bill"));
assert!(!principal.may_read("billing-staging"));
assert!(!principal.may_read("*"));
}
#[test]
fn token_comparison_is_by_bytes_and_length() {
let token = Token::new("0123456789abcdef0123456789abcdef");
assert!(token.matches("0123456789abcdef0123456789abcdef"));
assert!(!token.matches("0123456789abcdef0123456789abcdeg"));
assert!(!token.matches("0123456789abcdef0123456789abcde"));
assert!(!token.matches("0123456789abcdef0123456789abcdef0"));
assert!(!token.matches(""));
}
#[test]
fn debug_never_prints_a_token() {
let token = Token::new("planted-token-value-0123456789ab");
let authenticator =
Authenticator::new([(token.clone(), Principal::new("who", ["billing"]))], None);
for rendered in [format!("{token:?}"), format!("{authenticator:?}")] {
assert!(
!rendered.contains("planted-token-value"),
"a credential escaped through Debug: {rendered}"
);
}
}
}