use std::future::Future;
use std::pin::Pin;
use axum::http::HeaderMap;
use crate::domain::registry::{AuthContext, ScopeSet};
#[derive(Clone, Copy)]
pub struct Credentials<'a> {
authorization: Option<&'a str>,
}
impl core::fmt::Debug for Credentials<'_> {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter
.debug_struct("Credentials")
.field("authorization", &self.authorization.map(Fingerprint))
.finish()
}
}
struct Fingerprint<'a>(&'a str);
impl core::fmt::Debug for Fingerprint<'_> {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let token = self
.0
.split_once(char::is_whitespace)
.map_or(self.0, |(_, rest)| rest.trim());
let head: String = token.chars().take(4).collect();
write!(formatter, "#{head}…{}", token.chars().count())
}
}
impl<'a> Credentials<'a> {
#[must_use]
pub fn from_headers(headers: &'a HeaderMap) -> Self {
Self {
authorization: headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok()),
}
}
#[must_use]
pub const fn authorization(&self) -> Option<&'a str> {
self.authorization
}
}
pub trait Authenticator: Send + Sync {
fn authenticate<'a>(
&'a self,
presented: &'a Credentials<'_>,
) -> Pin<Box<dyn Future<Output = AuthContext> + Send + 'a>>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Anonymous;
impl Authenticator for Anonymous {
fn authenticate<'a>(
&'a self,
_presented: &'a Credentials<'_>,
) -> Pin<Box<dyn Future<Output = AuthContext> + Send + 'a>> {
Box::pin(std::future::ready(AuthContext::anonymous()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AuthPolicy {
#[default]
Public,
Require(ScopeSet),
}
impl AuthPolicy {
#[must_use]
pub const fn required(self) -> ScopeSet {
match self {
Self::Public => ScopeSet::empty(),
Self::Require(scopes) => scopes,
}
}
#[must_use]
pub const fn refuse(&self, context: &AuthContext) -> Option<super::Refusal> {
let required = self.required();
if required.is_empty() {
return None;
}
if context.scopes().is_empty() {
return Some(super::Refusal::Unauthenticated);
}
if !context.holds(required) {
return Some(super::Refusal::Forbidden);
}
None
}
}
pub(super) mod oauth;
pub use oauth::{OAuthAuthenticator, SystemClock, WallClock};
#[cfg(test)]
mod tests {
const SECRET: &str = "super-secret-bearer-token-value-0123456789";
#[test]
fn the_debug_form_redacts_but_the_value_does_not() -> Result<(), TestError> {
let header = format!("Bearer {SECRET}");
let headers = header_map_with(Some(header.as_str()))?;
let credentials = Credentials::from_headers(&headers);
let rendered = format!("{credentials:?}");
assert!(!rendered.contains(SECRET), "{rendered}");
assert!(!rendered.contains("secret"), "{rendered}");
assert!(rendered.contains("#supe…42"), "{rendered}");
assert_eq!(credentials.authorization(), Some(header.as_str()));
Ok(())
}
#[test]
fn the_fingerprint_covers_the_token_and_not_the_scheme() -> Result<(), TestError> {
let header = format!("Bearer {SECRET}");
let headers = header_map_with(Some(header.as_str()))?;
let rendered = format!("{:?}", Credentials::from_headers(&headers));
assert!(rendered.contains("#supe…"), "{rendered}");
assert!(!rendered.contains("Bear"), "{rendered}");
Ok(())
}
#[test]
fn a_missing_credential_says_so() {
let headers = HeaderMap::new();
let rendered = format!("{:?}", Credentials::from_headers(&headers));
assert!(rendered.contains("None"), "{rendered}");
}
use axum::http::{HeaderMap, HeaderValue, header};
use crate::domain::registry::{AuthContext, Scope, ScopeSet};
use super::{Anonymous, AuthPolicy, Authenticator, Credentials};
use crate::adapters::primary::transport::http::Refusal;
type TestError = Box<dyn std::error::Error>;
fn header_map_with(authorization: Option<&str>) -> Result<HeaderMap, TestError> {
let mut headers = HeaderMap::new();
if let Some(value) = authorization {
headers.insert(header::AUTHORIZATION, HeaderValue::from_str(value)?);
}
Ok(headers)
}
#[tokio::test]
async fn the_default_authenticator_holds_no_scopes_for_anything() -> Result<(), TestError> {
for presented in [None, Some("Bearer test-token"), Some("Basic abc"), Some("")] {
let headers = header_map_with(presented)?;
let context = Anonymous
.authenticate(&Credentials::from_headers(&headers))
.await;
assert_eq!(context.scopes(), ScopeSet::empty());
assert_eq!(context, AuthContext::anonymous());
}
Ok(())
}
#[tokio::test]
async fn a_malformed_credential_is_refused_under_a_required_policy() -> Result<(), TestError> {
let policy = AuthPolicy::Require(ScopeSet::empty().with(Scope::Read));
for malformed in [Some(""), Some("Bearer"), Some("not-a-scheme x"), Some("??")] {
let headers = header_map_with(malformed)?;
let context = Anonymous
.authenticate(&Credentials::from_headers(&headers))
.await;
assert_eq!(
policy.refuse(&context),
Some(Refusal::Unauthenticated),
"malformed credential {malformed:?} was not refused"
);
}
Ok(())
}
#[test]
fn a_credential_that_granted_nothing_is_unauthenticated() {
let policy = AuthPolicy::Require(ScopeSet::empty().with(Scope::Read));
let granted_nothing = AuthContext::new("alice", ScopeSet::empty());
assert_eq!(
policy.refuse(&granted_nothing),
Some(Refusal::Unauthenticated),
);
}
#[test]
fn an_empty_requirement_is_public_and_says_so() {
let policy = AuthPolicy::Require(ScopeSet::empty());
assert!(policy.required().is_empty());
assert_eq!(policy.refuse(&AuthContext::anonymous()), None);
assert_eq!(policy.required(), AuthPolicy::Public.required());
assert_eq!(
policy.refuse(&AuthContext::anonymous()),
AuthPolicy::Public.refuse(&AuthContext::anonymous()),
);
}
#[test]
fn the_credential_is_read_off_the_request() -> Result<(), TestError> {
let with = header_map_with(Some("Bearer abc"))?;
let without = header_map_with(None)?;
assert_eq!(
Credentials::from_headers(&with).authorization(),
Some("Bearer abc"),
);
assert_eq!(Credentials::from_headers(&without).authorization(), None);
Ok(())
}
#[test]
fn a_requiring_policy_refuses_an_anonymous_caller() {
let policy = AuthPolicy::Require(ScopeSet::empty().with(Scope::Read));
let context = AuthContext::anonymous();
assert_eq!(policy.refuse(&context), Some(Refusal::Unauthenticated));
}
#[test]
fn an_insufficient_scope_is_403_and_not_401() {
let policy = AuthPolicy::Require(ScopeSet::empty().with(Scope::Write));
let read_only = AuthContext::new("alice", ScopeSet::empty().with(Scope::Read));
assert_eq!(policy.refuse(&read_only), Some(Refusal::Forbidden));
let writer = AuthContext::new("alice", ScopeSet::empty().with(Scope::Write));
assert_eq!(policy.refuse(&writer), None);
}
#[test]
fn the_public_policy_serves_everyone() {
let policy = AuthPolicy::Public;
assert!(policy.required().is_empty());
assert_eq!(policy.refuse(&AuthContext::anonymous()), None);
assert_eq!(policy.required(), AuthPolicy::default().required());
}
}