use axum::Extension;
use axum::extract::FromRequestParts;
use axum::http::{StatusCode, header, request::Parts};
use axum::response::{IntoResponse, Response};
use super::store::ApiTokens;
use super::token::ApiToken;
const SCHEME: &str = "bearer";
#[derive(Clone, Debug)]
pub struct ApiAuth(pub ApiToken);
impl ApiAuth {
#[must_use]
pub fn token(&self) -> &ApiToken {
&self.0
}
#[must_use]
pub fn can(&self, ability: &str) -> bool {
self.0.can(ability)
}
}
impl<S> FromRequestParts<S> for ApiAuth
where
S: Send + Sync,
{
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let Extension(tokens) = Extension::<ApiTokens>::from_request_parts(parts, state)
.await
.map_err(|_| misconfigured())?;
let presented = bearer(parts).ok_or_else(unauthenticated)?;
match tokens.authenticate(&presented).await {
Ok(Some(token)) => Ok(Self(token)),
Ok(None) => Err(unauthenticated()),
Err(_) => Err(StatusCode::SERVICE_UNAVAILABLE.into_response()),
}
}
}
fn bearer(parts: &Parts) -> Option<String> {
let value = parts.headers.get(header::AUTHORIZATION)?.to_str().ok()?;
let (scheme, credential) = value.split_once(' ')?;
if !scheme.eq_ignore_ascii_case(SCHEME) {
return None;
}
let credential = credential.trim_start();
if credential.is_empty() {
return None;
}
Some(credential.to_owned())
}
fn unauthenticated() -> Response {
(
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, "Bearer")],
"Authentication required",
)
.into_response()
}
fn misconfigured() -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
"API tokens are not configured",
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::Request;
fn parts_with(value: &str) -> Parts {
Request::builder()
.header(header::AUTHORIZATION, value)
.body(())
.expect("a header value the test wrote is valid")
.into_parts()
.0
}
#[test]
fn a_bearer_credential_is_read() {
assert_eq!(
bearer(&parts_with("Bearer abc123")).as_deref(),
Some("abc123")
);
}
#[test]
fn the_scheme_is_matched_case_insensitively() {
for spelling in ["Bearer", "bearer", "BEARER", "BeArEr"] {
let header = format!("{spelling} abc123");
assert_eq!(
bearer(&parts_with(&header)).as_deref(),
Some("abc123"),
"{spelling} should be accepted"
);
}
}
#[test]
fn another_scheme_is_not_a_bearer_token() {
assert!(bearer(&parts_with("Basic dXNlcjpwYXNz")).is_none());
}
#[test]
fn a_scheme_with_no_credential_is_rejected() {
for value in ["Bearer", "Bearer ", "Bearer "] {
assert!(
bearer(&parts_with(value)).is_none(),
"{value:?} carries no credential"
);
}
}
#[test]
fn a_header_that_is_not_utf8_is_rejected_rather_than_lossily_decoded() {
let parts = Request::builder()
.header(
header::AUTHORIZATION,
axum::http::HeaderValue::from_bytes(b"Bearer \xff\xfe")
.expect("bytes are a valid header value even when not UTF-8"),
)
.body(())
.expect("request builds")
.into_parts()
.0;
assert!(bearer(&parts).is_none());
}
#[test]
fn the_rejection_names_the_scheme_the_client_should_use() {
let response = unauthenticated();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
response
.headers()
.get(header::WWW_AUTHENTICATE)
.and_then(|value| value.to_str().ok()),
Some("Bearer")
);
}
#[test]
fn a_missing_store_is_a_server_error_and_not_an_authentication_failure() {
assert_eq!(misconfigured().status(), StatusCode::INTERNAL_SERVER_ERROR);
}
}