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_TOKEN_PREFIX: &str = "acss_";
pub const EPOCH_HEADER: &str = "x-crew-epoch";
pub const SESSION_TTL_SECS: i64 = 24 * 3600;
pub const MAX_SESSION_TTL_SECS: i64 = 24 * 3600;
pub const ADMIN_TOKEN_PREFIX: &str = "acsa_";
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 session_id: Option<Uuid>,
pub session_epoch: Option<i64>,
pub token_id: Option<Uuid>,
}
impl AuthCtx {
pub fn session_is_authenticated(&self) -> bool {
self.session_id.is_some()
}
}
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 epoch_from_headers(headers: &axum::http::HeaderMap) -> Result<Option<i64>, AuthError> {
let Some(value) = headers.get(EPOCH_HEADER) else {
return Ok(None);
};
let raw = value
.to_str()
.ok()
.map(str::trim)
.filter(|v| !v.is_empty())
.ok_or_else(|| AuthError::BadSession(format!("{EPOCH_HEADER} must be ASCII")))?;
let epoch: i64 = raw
.parse()
.map_err(|_| AuthError::BadSession(format!("{EPOCH_HEADER} must be a positive integer")))?;
if epoch <= 0 {
return Err(AuthError::BadSession(format!(
"{EPOCH_HEADER} must be a positive integer"
)));
}
Ok(Some(epoch))
}
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 generate_session_token() -> String {
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
format!("{SESSION_TOKEN_PREFIX}{}", hex::encode(bytes))
}
pub fn generate_admin_token() -> String {
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
format!("{ADMIN_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(SESSION_TOKEN_PREFIX) {
return resolve_session_token(pool, raw).await;
}
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(),
session_id: None,
session_epoch: None,
token_id: Some(row.token_id),
})
}
async fn resolve_session_token(pool: &PgPool, raw: &str) -> Result<AuthCtx, AuthError> {
let row: Option<(
Uuid,
String,
i64,
Uuid,
String,
Option<chrono::DateTime<chrono::Utc>>,
Uuid,
String,
bool,
bool,
bool,
)> = sqlx::query_as(
r#"
SELECT s.id,
s.label,
s.epoch,
a.id,
a.name,
a.disabled_at,
tm.id,
tm.slug,
(s.revoked_at IS NOT NULL) AS session_revoked,
(s.expires_at <= now()) AS session_expired,
(t.revoked_at IS NOT NULL) AS parent_revoked
FROM agent_sessions s
JOIN api_tokens t ON t.id = s.parent_token
JOIN agents a ON a.id = s.agent_id
JOIN teams tm ON tm.id = a.team_id
WHERE s.token_hash = $1
"#,
)
.bind(hash_token(raw))
.fetch_optional(pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "session lookup failed");
AuthError::Internal
})?;
let Some((
session_id,
label,
epoch,
agent_id,
agent_name,
agent_disabled,
team_id,
team_slug,
session_revoked,
session_expired,
parent_revoked,
)) = row
else {
return Err(AuthError::Invalid);
};
if agent_disabled.is_some() {
return Err(AuthError::Disabled);
}
if parent_revoked || session_revoked {
return Err(AuthError::Invalid);
}
if session_expired {
return Err(AuthError::SessionExpired);
}
let _ = sqlx::query(
"UPDATE agent_sessions SET last_used_at = now()
WHERE id IN (
SELECT id FROM agent_sessions
WHERE id = $1
AND (last_used_at IS NULL OR last_used_at < now() - interval '60 seconds')
FOR UPDATE SKIP LOCKED
)",
)
.bind(session_id)
.execute(pool)
.await;
Ok(AuthCtx {
agent_id,
agent_name,
team_id,
team_slug,
session: label,
session_id: Some(session_id),
session_epoch: Some(epoch),
token_id: None,
})
}
#[derive(Debug)]
pub enum AuthError {
Missing,
Invalid,
Disabled,
Internal,
Throttled(u64),
BadSession(String),
SessionExpired,
SessionMismatch {
proven: String,
claimed: String,
},
StaleEpoch {
current: i64,
sent: i64,
},
}
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::SessionExpired => (
StatusCode::UNAUTHORIZED,
"this session credential has expired. Register a new session with \
register_session using your agent token; your session label, and \
everything filed under it, is unchanged."
.to_owned(),
),
AuthError::SessionMismatch { proven, claimed } => (
StatusCode::FORBIDDEN,
format!(
"the {SESSION_HEADER} header says '{claimed}' but this credential \
authenticates session '{proven}'. A session credential proves which \
window it is; drop the header, or send the one you hold."
),
),
AuthError::StaleEpoch { current, sent } => (
StatusCode::CONFLICT,
format!(
"this connection is stale: it carries epoch {sent} and the session is \
at {current}, so another process resumed this window after you. Stop \
writing as it — resume the session to take over, or exit."
),
),
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 epoch = epoch_from_headers(req.headers())?;
let mut ctx = resolve_token(&state.pool, &raw).await?;
match ctx.session_id {
None => ctx.session = session,
Some(_) => {
if !session.is_empty() && session != ctx.session {
return Err(AuthError::SessionMismatch {
proven: ctx.session,
claimed: session,
});
}
if let (Some(current), Some(sent)) = (ctx.session_epoch, epoch)
&& sent < current
{
return Err(AuthError::StaleEpoch { current, sent });
}
}
}
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:?}"),
}
}
}