1use axum::{
2 extract::{Request, State},
3 http::StatusCode,
4 middleware::Next,
5 response::{IntoResponse, Response},
6};
7use rand::Rng;
8use sha2::{Digest, Sha256};
9use sqlx::PgPool;
10use uuid::Uuid;
11
12pub const TOKEN_PREFIX: &str = "acs_";
13
14pub const SESSION_HEADER: &str = "x-crew-session";
19
20pub const MAX_SESSION_BYTES: usize = 64;
22
23#[derive(Clone, Debug)]
26pub struct AuthCtx {
27 pub agent_id: Uuid,
28 pub agent_name: String,
29 pub team_id: Uuid,
30 pub team_slug: String,
31 pub session: String,
40}
41
42pub fn normalize_session(raw: &str) -> Result<String, String> {
59 let label = raw.trim().to_lowercase();
60 if label.is_empty() {
61 return Ok(String::new());
62 }
63 if label.len() > MAX_SESSION_BYTES {
64 return Err(format!(
65 "is {} bytes; the limit is {MAX_SESSION_BYTES}. Use a short label, \
66 such as the repository name",
67 label.len()
68 ));
69 }
70 if !label.is_ascii() {
71 return Err("must be ASCII".to_owned());
72 }
73 if label.chars().any(char::is_control) {
74 return Err("must not contain control characters".to_owned());
75 }
76 if label.contains(['$', '{', '}']) {
81 return Err(
82 "looks like an unexpanded variable. Set the variable, or use a form \
83 with a fallback such as ${BUS_SESSION:-} so an unset value sends \
84 nothing at all"
85 .to_owned(),
86 );
87 }
88 if label.contains('/') {
92 return Err(
93 "must not contain '/', which separates agent from session when \
94 addressing a message"
95 .to_owned(),
96 );
97 }
98 Ok(label)
99}
100
101fn session_from_headers(headers: &axum::http::HeaderMap) -> Result<String, AuthError> {
103 let Some(value) = headers.get(SESSION_HEADER) else {
104 return Ok(String::new());
105 };
106 let raw = value
107 .to_str()
108 .map_err(|_| AuthError::BadSession("must be ASCII".to_owned()))?;
109 normalize_session(raw).map_err(AuthError::BadSession)
110}
111
112pub fn generate_token() -> String {
114 let mut bytes = [0u8; 32];
115 rand::rng().fill_bytes(&mut bytes);
116 format!("{TOKEN_PREFIX}{}", hex::encode(bytes))
117}
118
119pub fn hash_token(raw: &str) -> Vec<u8> {
120 Sha256::digest(raw.trim().as_bytes()).to_vec()
121}
122
123pub fn token_prefix(raw: &str) -> String {
126 raw.chars().take(12).collect()
127}
128
129struct AuthRow {
130 token_id: Uuid,
131 agent_id: Uuid,
132 agent_name: String,
133 agent_disabled: bool,
134 team_id: Uuid,
135 team_slug: String,
136}
137
138pub async fn resolve_token(pool: &PgPool, raw: &str) -> Result<AuthCtx, AuthError> {
139 if !raw.starts_with(TOKEN_PREFIX) {
140 return Err(AuthError::Invalid);
141 }
142 let hash = hash_token(raw);
143
144 let row = sqlx::query_as::<
145 _,
146 (
147 Uuid,
148 Uuid,
149 String,
150 Option<chrono::DateTime<chrono::Utc>>,
151 Uuid,
152 String,
153 ),
154 >(
155 r#"
156 SELECT t.id, a.id, a.name, a.disabled_at, tm.id, tm.slug
157 FROM api_tokens t
158 JOIN agents a ON a.id = t.agent_id
159 JOIN teams tm ON tm.id = a.team_id
160 WHERE t.token_hash = $1 AND t.revoked_at IS NULL
161 "#,
162 )
163 .bind(&hash)
164 .fetch_optional(pool)
165 .await
166 .map_err(|e| {
167 tracing::error!(error = %e, "token lookup failed");
168 AuthError::Internal
169 })?;
170
171 let Some((token_id, agent_id, agent_name, disabled_at, team_id, team_slug)) = row else {
172 return Err(AuthError::Invalid);
173 };
174 let row = AuthRow {
175 token_id,
176 agent_id,
177 agent_name,
178 agent_disabled: disabled_at.is_some(),
179 team_id,
180 team_slug,
181 };
182
183 if row.agent_disabled {
184 return Err(AuthError::Disabled);
185 }
186
187 let _ = sqlx::query("UPDATE api_tokens SET last_used_at = now() WHERE id = $1")
189 .bind(row.token_id)
190 .execute(pool)
191 .await;
192
193 Ok(AuthCtx {
194 agent_id: row.agent_id,
195 agent_name: row.agent_name,
196 team_id: row.team_id,
197 team_slug: row.team_slug,
198 session: String::new(),
201 })
202}
203
204#[derive(Debug)]
205pub enum AuthError {
206 Missing,
207 Invalid,
208 Disabled,
209 Internal,
210 Throttled(u64),
212 BadSession(String),
215}
216
217impl IntoResponse for AuthError {
218 fn into_response(self) -> Response {
219 let retry_after = match self {
220 AuthError::Throttled(secs) => Some(secs),
221 _ => None,
222 };
223 let is_auth_challenge = matches!(self, AuthError::Missing | AuthError::Invalid);
225 let (status, msg) = match self {
228 AuthError::Missing => (StatusCode::UNAUTHORIZED, "missing bearer token".to_owned()),
229 AuthError::Invalid => (
230 StatusCode::UNAUTHORIZED,
231 "invalid or revoked token".to_owned(),
232 ),
233 AuthError::Disabled => (StatusCode::FORBIDDEN, "agent is disabled".to_owned()),
234 AuthError::Internal => (
235 StatusCode::INTERNAL_SERVER_ERROR,
236 "internal error".to_owned(),
237 ),
238 AuthError::Throttled(secs) => (
239 StatusCode::TOO_MANY_REQUESTS,
240 format!(
241 "rate limit exceeded for this token; retry in {secs}s. \
242 If you are polling, use wait_for_updates (it blocks until \
243 something happens) instead of calling in a loop."
244 ),
245 ),
246 AuthError::BadSession(why) => (
247 StatusCode::BAD_REQUEST,
248 format!(
249 "the {SESSION_HEADER} header {why}. It labels which of your \
250 concurrent working contexts is calling — one per repository \
251 is the usual choice. Omit it entirely to use the shared session."
252 ),
253 ),
254 };
255 let body = serde_json::json!({ "error": msg });
256 let mut resp = (status, axum::Json(body)).into_response();
257 if is_auth_challenge {
258 resp.headers_mut().insert(
259 axum::http::header::WWW_AUTHENTICATE,
260 axum::http::HeaderValue::from_static("Bearer"),
261 );
262 }
263 if let Some(secs) = retry_after
264 && let Ok(value) = axum::http::HeaderValue::from_str(&secs.to_string())
265 {
266 resp.headers_mut()
267 .insert(axum::http::header::RETRY_AFTER, value);
268 }
269 resp
270 }
271}
272
273#[derive(Clone)]
275pub struct AuthState {
276 pub pool: PgPool,
277 pub limiter: Option<crate::ratelimit::RateLimiter>,
278}
279
280pub async fn require_bearer(
284 State(state): State<AuthState>,
285 mut req: Request,
286 next: Next,
287) -> Result<Response, AuthError> {
288 let raw = req
289 .headers()
290 .get(axum::http::header::AUTHORIZATION)
291 .and_then(|v| v.to_str().ok())
292 .and_then(|v| {
293 v.strip_prefix("Bearer ")
294 .or_else(|| v.strip_prefix("bearer "))
295 })
296 .map(str::trim)
297 .filter(|v| !v.is_empty())
298 .ok_or(AuthError::Missing)?
299 .to_owned();
300
301 if let Some(limiter) = &state.limiter
304 && let Err(throttled) = limiter.check(&hex::encode(hash_token(&raw)))
305 {
306 return Err(AuthError::Throttled(throttled.retry_after_secs));
307 }
308
309 let session = session_from_headers(req.headers())?;
312
313 let mut ctx = resolve_token(&state.pool, &raw).await?;
314 ctx.session = session;
315 tracing::debug!(
316 agent = %ctx.agent_name,
317 team = %ctx.team_slug,
318 session = %ctx.session,
319 "authenticated"
320 );
321 req.extensions_mut().insert(ctx);
322 Ok(next.run(req).await)
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use axum::http::{HeaderMap, HeaderValue};
329
330 fn headers(value: &str) -> HeaderMap {
331 let mut h = HeaderMap::new();
332 h.insert(SESSION_HEADER, HeaderValue::from_str(value).unwrap());
333 h
334 }
335
336 fn err(value: &str) -> String {
337 match session_from_headers(&headers(value)) {
338 Err(AuthError::BadSession(why)) => why,
339 other => panic!("expected BadSession, got {other:?}"),
340 }
341 }
342
343 #[test]
344 fn absent_header_is_the_shared_session() {
345 assert_eq!(session_from_headers(&HeaderMap::new()).unwrap(), "");
346 }
347
348 #[test]
349 fn blank_header_is_the_shared_session() {
350 assert_eq!(session_from_headers(&headers(" ")).unwrap(), "");
353 }
354
355 #[test]
356 fn label_is_normalised_like_a_channel_name() {
357 assert_eq!(
360 session_from_headers(&headers(" Market-Data ")).unwrap(),
361 "market-data"
362 );
363 }
364
365 #[test]
366 fn over_long_label_is_rejected_with_the_limit() {
367 let why = err(&"a".repeat(MAX_SESSION_BYTES + 1));
368 assert!(why.contains(&MAX_SESSION_BYTES.to_string()), "{why}");
369 }
370
371 #[test]
372 fn label_at_the_limit_is_accepted() {
373 let label = "a".repeat(MAX_SESSION_BYTES);
374 assert_eq!(session_from_headers(&headers(&label)).unwrap(), label);
375 }
376
377 #[test]
378 fn an_unexpanded_template_is_rejected_rather_than_becoming_a_session() {
379 let why = err("${BUS_SESSION}");
380 assert!(why.contains("unexpanded"), "{why}");
381 }
382
383 #[test]
384 fn slash_is_rejected_because_it_separates_agent_from_session() {
385 assert!(err("joaquin/market-data").contains('/'));
386 }
387
388 #[test]
389 fn internal_control_character_is_rejected() {
390 assert!(err("market\tdata").contains("control"));
393 }
394
395 #[test]
396 fn non_ascii_header_is_rejected() {
397 let mut h = HeaderMap::new();
398 h.insert(
399 SESSION_HEADER,
400 HeaderValue::from_bytes(&[0xff, 0xfe]).unwrap(),
401 );
402 match session_from_headers(&h) {
403 Err(AuthError::BadSession(_)) => {}
404 other => panic!("expected BadSession, got {other:?}"),
405 }
406 }
407}