use std::collections::HashSet;
use axum::http::HeaderMap;
use super::{AuthError, Authenticator, Caller};
use crate::core::{Delegation, Principal, Scope, Secret, TenantId, Timestamp};
#[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 },
#[error(
"entry {index} declares an empty scope, which permits nothing — a caller who may \
start no run is a caller the file should not list"
)]
EmptyScope { index: usize },
}
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 tenant = match entry.tenant {
Some(tenant) => TenantId::new(tenant).map_err(|e| TokenFileError::Tenant {
index,
detail: e.to_string(),
})?,
None => TenantId::default(),
};
let mut caller =
Caller::new(entry.actor.clone(), entry.roles).in_tenant(tenant.clone());
if entry.scope.is_some() || entry.not_after.is_some() {
let scope = match entry.scope {
Some(patterns) if patterns.is_empty() => {
return Err(TokenFileError::EmptyScope { index });
}
Some(patterns) => Scope::of(patterns),
None => Scope::root(),
};
let mut link = Principal::new(entry.actor, scope).for_audience(tenant.as_str());
if let Some(not_after) = entry.not_after {
link = link.until(not_after);
}
caller = caller.acting_as(Delegation::root(link));
}
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>,
#[serde(default)]
pub scope: Option<Vec<String>>,
#[serde(default, with = "time::serde::rfc3339::option")]
pub not_after: Option<Timestamp>,
}
#[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_scoped_entry_is_the_callers_chain() {
let auth = TokenAuthenticator::from_yaml(
"- token: t\n actor: peer-a\n roles: [peer]\n tenant: acme\n \
scope: [support.*]\n not_after: 2027-01-01T00:00:00Z\n\
- token: u\n actor: peer-b\n roles: [peer]\n",
)
.expect("two callers");
let scoped = auth.authenticate(&presenting("Bearer t")).await.unwrap();
let chain = scoped.acting_as.expect("a scoped entry carries a chain");
assert_eq!(chain.subject().id, "peer-a");
assert_eq!(chain.depth(), 0, "rooted at the actor");
assert!(
chain
.effective_scope()
.permits(&crate::core::Capability::new("support.x"))
);
assert!(
!chain
.effective_scope()
.permits(&crate::core::Capability::new("billing.x"))
);
assert_eq!(
chain.audience(),
Some("acme"),
"the audience is the entry's tenant, so the token is not spendable on another plane"
);
assert_eq!(
chain.not_after().map(time::OffsetDateTime::unix_timestamp),
Some(1_798_761_600),
"2027-01-01T00:00:00Z"
);
let bare = auth.authenticate(&presenting("Bearer u")).await.unwrap();
assert!(
bare.acting_as.is_none(),
"an entry declaring neither bound carries no chain, so its runs act \
under whatever the plane was built with"
);
}
#[test]
fn an_empty_scope_is_refused_at_load() {
let err = TokenAuthenticator::from_yaml("- token: t\n actor: peer-a\n scope: []\n")
.expect_err("permits nothing");
assert!(
matches!(err, TokenFileError::EmptyScope { index: 0 }),
"{err:?}"
);
}
#[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"
);
}
}
}