use axum::{
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
};
use rand::Rng;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use uuid::Uuid;
pub const TOKEN_PREFIX: &str = "acs_";
pub const SESSION_HEADER: &str = "x-crew-session";
pub const MAX_SESSION_BYTES: usize = 64;
#[derive(Clone, Debug)]
pub struct AuthCtx {
pub agent_id: Uuid,
pub agent_name: String,
pub team_id: Uuid,
pub team_slug: String,
pub session: String,
}
pub fn normalize_session(raw: &str) -> Result<String, String> {
let label = raw.trim().to_lowercase();
if label.is_empty() {
return Ok(String::new());
}
if label.len() > MAX_SESSION_BYTES {
return Err(format!(
"is {} bytes; the limit is {MAX_SESSION_BYTES}. Use a short label, \
such as the repository name",
label.len()
));
}
if !label.is_ascii() {
return Err("must be ASCII".to_owned());
}
if label.chars().any(char::is_control) {
return Err("must not contain control characters".to_owned());
}
if label.contains(['$', '{', '}']) {
return Err(
"looks like an unexpanded variable. Set the variable, or use a form \
with a fallback such as ${BUS_SESSION:-} so an unset value sends \
nothing at all"
.to_owned(),
);
}
if label.contains('/') {
return Err(
"must not contain '/', which separates agent from session when \
addressing a message"
.to_owned(),
);
}
Ok(label)
}
fn session_from_headers(headers: &axum::http::HeaderMap) -> Result<String, AuthError> {
let Some(value) = headers.get(SESSION_HEADER) else {
return Ok(String::new());
};
let raw = value
.to_str()
.map_err(|_| AuthError::BadSession("must be ASCII".to_owned()))?;
normalize_session(raw).map_err(AuthError::BadSession)
}
pub fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
format!("{TOKEN_PREFIX}{}", hex::encode(bytes))
}
pub fn hash_token(raw: &str) -> Vec<u8> {
Sha256::digest(raw.trim().as_bytes()).to_vec()
}
pub fn token_prefix(raw: &str) -> String {
raw.chars().take(12).collect()
}
struct AuthRow {
token_id: Uuid,
agent_id: Uuid,
agent_name: String,
agent_disabled: bool,
team_id: Uuid,
team_slug: String,
}
pub async fn resolve_token(pool: &PgPool, raw: &str) -> Result<AuthCtx, AuthError> {
if !raw.starts_with(TOKEN_PREFIX) {
return Err(AuthError::Invalid);
}
let hash = hash_token(raw);
let row = sqlx::query_as::<
_,
(
Uuid,
Uuid,
String,
Option<chrono::DateTime<chrono::Utc>>,
Uuid,
String,
),
>(
r#"
SELECT t.id, a.id, a.name, a.disabled_at, tm.id, tm.slug
FROM api_tokens t
JOIN agents a ON a.id = t.agent_id
JOIN teams tm ON tm.id = a.team_id
WHERE t.token_hash = $1 AND t.revoked_at IS NULL
"#,
)
.bind(&hash)
.fetch_optional(pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "token lookup failed");
AuthError::Internal
})?;
let Some((token_id, agent_id, agent_name, disabled_at, team_id, team_slug)) = row else {
return Err(AuthError::Invalid);
};
let row = AuthRow {
token_id,
agent_id,
agent_name,
agent_disabled: disabled_at.is_some(),
team_id,
team_slug,
};
if row.agent_disabled {
return Err(AuthError::Disabled);
}
let _ = sqlx::query("UPDATE api_tokens SET last_used_at = now() WHERE id = $1")
.bind(row.token_id)
.execute(pool)
.await;
Ok(AuthCtx {
agent_id: row.agent_id,
agent_name: row.agent_name,
team_id: row.team_id,
team_slug: row.team_slug,
session: String::new(),
})
}
#[derive(Debug)]
pub enum AuthError {
Missing,
Invalid,
Disabled,
Internal,
Throttled(u64),
BadSession(String),
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let retry_after = match self {
AuthError::Throttled(secs) => Some(secs),
_ => None,
};
let is_auth_challenge = matches!(self, AuthError::Missing | AuthError::Invalid);
let (status, msg) = match self {
AuthError::Missing => (StatusCode::UNAUTHORIZED, "missing bearer token".to_owned()),
AuthError::Invalid => (
StatusCode::UNAUTHORIZED,
"invalid or revoked token".to_owned(),
),
AuthError::Disabled => (StatusCode::FORBIDDEN, "agent is disabled".to_owned()),
AuthError::Internal => (
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_owned(),
),
AuthError::Throttled(secs) => (
StatusCode::TOO_MANY_REQUESTS,
format!(
"rate limit exceeded for this token; retry in {secs}s. \
If you are polling, use wait_for_updates (it blocks until \
something happens) instead of calling in a loop."
),
),
AuthError::BadSession(why) => (
StatusCode::BAD_REQUEST,
format!(
"the {SESSION_HEADER} header {why}. It labels which of your \
concurrent working contexts is calling — one per repository \
is the usual choice. Omit it entirely to use the shared session."
),
),
};
let body = serde_json::json!({ "error": msg });
let mut resp = (status, axum::Json(body)).into_response();
if is_auth_challenge {
resp.headers_mut().insert(
axum::http::header::WWW_AUTHENTICATE,
axum::http::HeaderValue::from_static("Bearer"),
);
}
if let Some(secs) = retry_after
&& let Ok(value) = axum::http::HeaderValue::from_str(&secs.to_string())
{
resp.headers_mut()
.insert(axum::http::header::RETRY_AFTER, value);
}
resp
}
}
#[derive(Clone)]
pub struct AuthState {
pub pool: PgPool,
pub limiter: Option<crate::ratelimit::RateLimiter>,
}
pub async fn require_bearer(
State(state): State<AuthState>,
mut req: Request,
next: Next,
) -> Result<Response, AuthError> {
let raw = req
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| {
v.strip_prefix("Bearer ")
.or_else(|| v.strip_prefix("bearer "))
})
.map(str::trim)
.filter(|v| !v.is_empty())
.ok_or(AuthError::Missing)?
.to_owned();
if let Some(limiter) = &state.limiter
&& let Err(throttled) = limiter.check(&hex::encode(hash_token(&raw)))
{
return Err(AuthError::Throttled(throttled.retry_after_secs));
}
let session = session_from_headers(req.headers())?;
let mut ctx = resolve_token(&state.pool, &raw).await?;
ctx.session = session;
tracing::debug!(
agent = %ctx.agent_name,
team = %ctx.team_slug,
session = %ctx.session,
"authenticated"
);
req.extensions_mut().insert(ctx);
Ok(next.run(req).await)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{HeaderMap, HeaderValue};
fn headers(value: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(SESSION_HEADER, HeaderValue::from_str(value).unwrap());
h
}
fn err(value: &str) -> String {
match session_from_headers(&headers(value)) {
Err(AuthError::BadSession(why)) => why,
other => panic!("expected BadSession, got {other:?}"),
}
}
#[test]
fn absent_header_is_the_shared_session() {
assert_eq!(session_from_headers(&HeaderMap::new()).unwrap(), "");
}
#[test]
fn blank_header_is_the_shared_session() {
assert_eq!(session_from_headers(&headers(" ")).unwrap(), "");
}
#[test]
fn label_is_normalised_like_a_channel_name() {
assert_eq!(
session_from_headers(&headers(" Market-Data ")).unwrap(),
"market-data"
);
}
#[test]
fn over_long_label_is_rejected_with_the_limit() {
let why = err(&"a".repeat(MAX_SESSION_BYTES + 1));
assert!(why.contains(&MAX_SESSION_BYTES.to_string()), "{why}");
}
#[test]
fn label_at_the_limit_is_accepted() {
let label = "a".repeat(MAX_SESSION_BYTES);
assert_eq!(session_from_headers(&headers(&label)).unwrap(), label);
}
#[test]
fn an_unexpanded_template_is_rejected_rather_than_becoming_a_session() {
let why = err("${BUS_SESSION}");
assert!(why.contains("unexpanded"), "{why}");
}
#[test]
fn slash_is_rejected_because_it_separates_agent_from_session() {
assert!(err("joaquin/market-data").contains('/'));
}
#[test]
fn internal_control_character_is_rejected() {
assert!(err("market\tdata").contains("control"));
}
#[test]
fn non_ascii_header_is_rejected() {
let mut h = HeaderMap::new();
h.insert(
SESSION_HEADER,
HeaderValue::from_bytes(&[0xff, 0xfe]).unwrap(),
);
match session_from_headers(&h) {
Err(AuthError::BadSession(_)) => {}
other => panic!("expected BadSession, got {other:?}"),
}
}
}