use std::path::Path;
use axum::extract::{Request, State};
use axum::http::{StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use crate::a2a::server::AGENT_CARD_PATH;
use crate::a2a::state::A2aState;
pub(crate) fn load_or_create_token(path: &Path) -> std::io::Result<String> {
if path.exists() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(path)?.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"auth token file '{}' is group/other accessible (mode {mode:o}); expected 0600",
path.display()
),
));
}
}
let token = std::fs::read_to_string(path)?;
let trimmed = token.trim().to_owned();
if trimmed.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("auth token file '{}' is empty", path.display()),
));
}
return Ok(trimmed);
}
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let token = uuid::Uuid::new_v4().to_string();
write_token_with_owner_only_perms(path, &token)?;
Ok(token)
}
#[cfg(unix)]
fn write_token_with_owner_only_perms(path: &Path, token: &str) -> std::io::Result<()> {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
file.write_all(token.as_bytes())?;
file.write_all(b"\n")
}
#[cfg(not(unix))]
fn write_token_with_owner_only_perms(path: &Path, token: &str) -> std::io::Result<()> {
std::fs::write(path, token)
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
use subtle::ConstantTimeEq as _;
if a.len() != b.len() {
return false;
}
a.ct_eq(b).into()
}
fn parse_bearer(value: &str) -> Option<&str> {
let (scheme, token) = value.trim_start().split_once(' ')?;
scheme
.eq_ignore_ascii_case("bearer")
.then(|| token.trim_start())
}
fn unauthorized() -> Response {
(
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, "Bearer")],
"unauthorized: missing or invalid bearer token\n",
)
.into_response()
}
pub(crate) async fn require_bearer(
State(state): State<A2aState>,
request: Request,
next: Next,
) -> Response {
let Some(expected) = state.auth_token.as_deref() else {
return next.run(request).await;
};
if request.uri().path() == AGENT_CARD_PATH {
return next.run(request).await;
}
let provided = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(parse_bearer);
match provided {
Some(token) if constant_time_eq(token.as_bytes(), expected.as_bytes()) => {
next.run(request).await
}
_ => unauthorized(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_or_create_token_generates_uuid_when_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.token");
let token = load_or_create_token(&path).expect("token must be generated");
assert_eq!(token.len(), 36, "UUID v4 token must be 36 chars: {token}");
assert!(path.exists(), "token file must be created");
}
#[test]
fn load_or_create_token_round_trips() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.token");
let first = load_or_create_token(&path).expect("first call generates");
let second = load_or_create_token(&path).expect("second call reads");
assert_eq!(first, second, "token must persist across calls");
}
#[cfg(unix)]
#[test]
fn token_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.token");
load_or_create_token(&path).expect("token must be generated");
let mode = std::fs::metadata(&path)
.expect("metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "token file must be 0600");
}
#[test]
fn empty_token_file_is_rejected() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.token");
std::fs::write(&path, " \n").expect("write empty");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.expect("chmod 0600");
}
let err = load_or_create_token(&path).expect_err("empty token must be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn constant_time_eq_matches_only_identical_slices() {
assert!(constant_time_eq(b"secret-token", b"secret-token"));
assert!(!constant_time_eq(b"secret-token", b"secret-toker"));
assert!(!constant_time_eq(b"secret", b"secret-token"));
assert!(!constant_time_eq(b"", b"x"));
assert!(constant_time_eq(b"", b""));
}
#[test]
fn parse_bearer_is_case_insensitive_and_trims() {
assert_eq!(parse_bearer("Bearer tok"), Some("tok"));
assert_eq!(parse_bearer("bearer tok"), Some("tok"));
assert_eq!(parse_bearer("BEARER tok"), Some("tok"));
assert_eq!(parse_bearer(" Bearer tok"), Some("tok"));
assert_eq!(parse_bearer("Basic tok"), None);
assert_eq!(parse_bearer("tok"), None);
}
#[cfg(unix)]
#[test]
fn group_readable_token_file_is_rejected() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.token");
std::fs::write(&path, "tok\n").expect("write token");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640))
.expect("chmod 0640");
let err = load_or_create_token(&path).expect_err("group-readable file must be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
}
}