use std::collections::HashSet;
use axum::http::HeaderMap;
use super::{AuthError, Authenticator, Caller};
use crate::core::{Secret, TenantId};
#[derive(Debug)]
pub struct TokenAuthenticator {
entries: Vec<(Secret, Caller)>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TokenFileError {
#[error("the token file is not valid YAML: {0}")]
Syntax(String),
#[error(
"the token file lists no callers — a server with no accepted credential \
accepts nobody, which is an outage that reads like a configuration"
)]
Empty,
#[error(
"entry {index} has an empty token; a blank credential would be presented \
by anyone who presented none"
)]
BlankToken { index: usize },
#[error(
"entry {index} has no actor; a decision recorded against an unnamed \
caller is not a decision anybody can answer for"
)]
BlankActor { index: usize },
#[error(
"two entries share one token, so one of them silently never applies and \
which depends on the order of the file"
)]
DuplicateToken,
#[error("entry {index} names an invalid tenant: {detail}")]
Tenant { index: usize, detail: String },
}
impl TokenAuthenticator {
pub fn new(entries: Vec<TokenEntry>) -> Result<Self, TokenFileError> {
if entries.is_empty() {
return Err(TokenFileError::Empty);
}
let mut seen: HashSet<String> = HashSet::new();
let mut built = Vec::with_capacity(entries.len());
for (index, entry) in entries.into_iter().enumerate() {
if entry.token.trim().is_empty() {
return Err(TokenFileError::BlankToken { index });
}
if entry.actor.trim().is_empty() {
return Err(TokenFileError::BlankActor { index });
}
if !seen.insert(entry.token.clone()) {
return Err(TokenFileError::DuplicateToken);
}
let mut caller = Caller::new(entry.actor, entry.roles);
if let Some(tenant) = entry.tenant {
let tenant = TenantId::new(tenant).map_err(|e| TokenFileError::Tenant {
index,
detail: e.to_string(),
})?;
caller = caller.in_tenant(tenant);
}
built.push((Secret::new(entry.token), caller));
}
Ok(Self { entries: built })
}
#[cfg(feature = "manifest")]
pub fn from_yaml(source: &str) -> Result<Self, TokenFileError> {
let entries: Vec<TokenEntry> =
serde_yaml_ng::from_str(source).map_err(|e| TokenFileError::Syntax(e.to_string()))?;
Self::new(entries)
}
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TokenEntry {
pub token: String,
pub actor: String,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default)]
pub tenant: Option<String>,
}
#[async_trait::async_trait]
impl Authenticator for TokenAuthenticator {
async fn authenticate(&self, headers: &HeaderMap) -> Result<Caller, AuthError> {
let raw = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or(AuthError::Missing)?;
let (scheme, token) = raw.split_once(' ').ok_or(AuthError::Missing)?;
if !scheme.eq_ignore_ascii_case("Bearer") {
return Err(AuthError::Missing);
}
let presented = Secret::new(token);
let mut found: Option<&Caller> = None;
for (token, caller) in &self.entries {
if *token == presented {
found = Some(caller);
}
}
found.cloned().ok_or(AuthError::Rejected)
}
}
#[cfg(all(test, feature = "manifest"))]
mod scheme_tests {
use super::*;
fn ring() -> TokenAuthenticator {
TokenAuthenticator::from_yaml(
"- token: a-long-random-string\n actor: peer-a\n roles: [peer]\n",
)
.expect("one caller")
}
fn presenting(value: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::AUTHORIZATION,
value.parse().expect("a header value"),
);
headers
}
#[tokio::test]
async fn the_scheme_is_case_insensitive_and_the_token_is_not() {
let auth = ring();
for spelling in ["Bearer", "bearer", "BEARER", "BeArEr"] {
let caller = auth
.authenticate(&presenting(&format!("{spelling} a-long-random-string")))
.await
.unwrap_or_else(|e| panic!("'{spelling}' is a legal auth-scheme spelling: {e}"));
assert_eq!(caller.actor, "peer-a");
}
assert!(
matches!(
auth.authenticate(&presenting("Bearer A-LONG-RANDOM-STRING"))
.await,
Err(AuthError::Rejected)
),
"the token was matched case-insensitively, which silently shrinks \
the space an attacker has to search"
);
}
#[tokio::test]
async fn a_credential_that_is_not_a_bearer_token_names_nobody() {
let auth = ring();
for raw in [
"Basic a-long-random-string",
"Bearer",
"a-long-random-string",
"",
] {
assert!(
matches!(
auth.authenticate(&presenting(raw)).await,
Err(AuthError::Missing)
),
"'{raw}' was read as a presented bearer credential"
);
}
}
}