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_TOKEN_PREFIX: &str = "acss_";
20
21pub const EPOCH_HEADER: &str = "x-crew-epoch";
25
26pub const SESSION_TTL_SECS: i64 = 24 * 3600;
29pub const MAX_SESSION_TTL_SECS: i64 = 24 * 3600;
30
31pub const ADMIN_TOKEN_PREFIX: &str = "acsa_";
37
38pub const SESSION_HEADER: &str = "x-crew-session";
43
44pub const MAX_SESSION_BYTES: usize = 64;
46
47#[derive(Clone, Debug)]
50pub struct AuthCtx {
51 pub agent_id: Uuid,
52 pub agent_name: String,
53 pub team_id: Uuid,
54 pub team_slug: String,
55 pub session: String,
68 pub session_id: Option<Uuid>,
72 pub session_epoch: Option<i64>,
75 pub token_id: Option<Uuid>,
81}
82
83impl AuthCtx {
84 pub fn session_is_authenticated(&self) -> bool {
89 self.session_id.is_some()
90 }
91}
92
93pub fn normalize_session(raw: &str) -> Result<String, String> {
110 let label = raw.trim().to_lowercase();
111 if label.is_empty() {
112 return Ok(String::new());
113 }
114 if label.len() > MAX_SESSION_BYTES {
115 return Err(format!(
116 "is {} bytes; the limit is {MAX_SESSION_BYTES}. Use a short label, \
117 such as the repository name",
118 label.len()
119 ));
120 }
121 if !label.is_ascii() {
122 return Err("must be ASCII".to_owned());
123 }
124 if label.chars().any(char::is_control) {
125 return Err("must not contain control characters".to_owned());
126 }
127 if label.contains(['$', '{', '}']) {
132 return Err(
133 "looks like an unexpanded variable. Set the variable, or use a form \
134 with a fallback such as ${BUS_SESSION:-} so an unset value sends \
135 nothing at all"
136 .to_owned(),
137 );
138 }
139 if label.contains('/') {
143 return Err(
144 "must not contain '/', which separates agent from session when \
145 addressing a message"
146 .to_owned(),
147 );
148 }
149 Ok(label)
150}
151
152fn epoch_from_headers(headers: &axum::http::HeaderMap) -> Result<Option<i64>, AuthError> {
155 let Some(value) = headers.get(EPOCH_HEADER) else {
156 return Ok(None);
157 };
158 let raw = value
159 .to_str()
160 .ok()
161 .map(str::trim)
162 .filter(|v| !v.is_empty())
163 .ok_or_else(|| AuthError::BadSession(format!("{EPOCH_HEADER} must be ASCII")))?;
164 let epoch: i64 = raw
165 .parse()
166 .map_err(|_| AuthError::BadSession(format!("{EPOCH_HEADER} must be a positive integer")))?;
167 if epoch <= 0 {
168 return Err(AuthError::BadSession(format!(
169 "{EPOCH_HEADER} must be a positive integer"
170 )));
171 }
172 Ok(Some(epoch))
173}
174
175fn session_from_headers(headers: &axum::http::HeaderMap) -> Result<String, AuthError> {
177 let Some(value) = headers.get(SESSION_HEADER) else {
178 return Ok(String::new());
179 };
180 let raw = value
181 .to_str()
182 .map_err(|_| AuthError::BadSession("must be ASCII".to_owned()))?;
183 normalize_session(raw).map_err(AuthError::BadSession)
184}
185
186pub fn generate_token() -> String {
188 let mut bytes = [0u8; 32];
189 rand::rng().fill_bytes(&mut bytes);
190 format!("{TOKEN_PREFIX}{}", hex::encode(bytes))
191}
192
193pub fn generate_session_token() -> String {
195 let mut bytes = [0u8; 32];
196 rand::rng().fill_bytes(&mut bytes);
197 format!("{SESSION_TOKEN_PREFIX}{}", hex::encode(bytes))
198}
199
200pub fn generate_admin_token() -> String {
203 let mut bytes = [0u8; 32];
204 rand::rng().fill_bytes(&mut bytes);
205 format!("{ADMIN_TOKEN_PREFIX}{}", hex::encode(bytes))
206}
207
208pub fn hash_token(raw: &str) -> Vec<u8> {
209 Sha256::digest(raw.trim().as_bytes()).to_vec()
210}
211
212pub fn token_prefix(raw: &str) -> String {
215 raw.chars().take(12).collect()
216}
217
218struct AuthRow {
219 token_id: Uuid,
220 agent_id: Uuid,
221 agent_name: String,
222 agent_disabled: bool,
223 team_id: Uuid,
224 team_slug: String,
225}
226
227pub async fn resolve_token(pool: &PgPool, raw: &str) -> Result<AuthCtx, AuthError> {
228 if raw.starts_with(SESSION_TOKEN_PREFIX) {
229 return resolve_session_token(pool, raw).await;
230 }
231 if !raw.starts_with(TOKEN_PREFIX) {
232 return Err(AuthError::Invalid);
233 }
234 let hash = hash_token(raw);
235
236 let row = sqlx::query_as::<
237 _,
238 (
239 Uuid,
240 Uuid,
241 String,
242 Option<chrono::DateTime<chrono::Utc>>,
243 Uuid,
244 String,
245 ),
246 >(
247 r#"
248 SELECT t.id, a.id, a.name, a.disabled_at, tm.id, tm.slug
249 FROM api_tokens t
250 JOIN agents a ON a.id = t.agent_id
251 JOIN teams tm ON tm.id = a.team_id
252 WHERE t.token_hash = $1 AND t.revoked_at IS NULL
253 "#,
254 )
255 .bind(&hash)
256 .fetch_optional(pool)
257 .await
258 .map_err(|e| {
259 tracing::error!(error = %e, "token lookup failed");
260 AuthError::Internal
261 })?;
262
263 let Some((token_id, agent_id, agent_name, disabled_at, team_id, team_slug)) = row else {
264 return Err(AuthError::Invalid);
265 };
266 let row = AuthRow {
267 token_id,
268 agent_id,
269 agent_name,
270 agent_disabled: disabled_at.is_some(),
271 team_id,
272 team_slug,
273 };
274
275 if row.agent_disabled {
276 return Err(AuthError::Disabled);
277 }
278
279 let _ = sqlx::query("UPDATE api_tokens SET last_used_at = now() WHERE id = $1")
281 .bind(row.token_id)
282 .execute(pool)
283 .await;
284
285 Ok(AuthCtx {
286 agent_id: row.agent_id,
287 agent_name: row.agent_name,
288 team_id: row.team_id,
289 team_slug: row.team_slug,
290 session: String::new(),
293 session_id: None,
294 session_epoch: None,
295 token_id: Some(row.token_id),
296 })
297}
298
299async fn resolve_session_token(pool: &PgPool, raw: &str) -> Result<AuthCtx, AuthError> {
303 let row: Option<(
304 Uuid,
305 String,
306 i64,
307 Uuid,
308 String,
309 Option<chrono::DateTime<chrono::Utc>>,
310 Uuid,
311 String,
312 bool,
313 bool,
314 bool,
315 )> = sqlx::query_as(
316 r#"
317 SELECT s.id,
318 s.label,
319 s.epoch,
320 a.id,
321 a.name,
322 a.disabled_at,
323 tm.id,
324 tm.slug,
325 (s.revoked_at IS NOT NULL) AS session_revoked,
326 (s.expires_at <= now()) AS session_expired,
327 (t.revoked_at IS NOT NULL) AS parent_revoked
328 FROM agent_sessions s
329 JOIN api_tokens t ON t.id = s.parent_token
330 JOIN agents a ON a.id = s.agent_id
331 JOIN teams tm ON tm.id = a.team_id
332 WHERE s.token_hash = $1
333 "#,
334 )
335 .bind(hash_token(raw))
336 .fetch_optional(pool)
337 .await
338 .map_err(|e| {
339 tracing::error!(error = %e, "session lookup failed");
340 AuthError::Internal
341 })?;
342
343 let Some((
344 session_id,
345 label,
346 epoch,
347 agent_id,
348 agent_name,
349 agent_disabled,
350 team_id,
351 team_slug,
352 session_revoked,
353 session_expired,
354 parent_revoked,
355 )) = row
356 else {
357 return Err(AuthError::Invalid);
358 };
359 if agent_disabled.is_some() {
360 return Err(AuthError::Disabled);
361 }
362 if parent_revoked || session_revoked {
365 return Err(AuthError::Invalid);
366 }
367 if session_expired {
368 return Err(AuthError::SessionExpired);
369 }
370
371 let _ = sqlx::query(
378 "UPDATE agent_sessions SET last_used_at = now()
379 WHERE id IN (
380 SELECT id FROM agent_sessions
381 WHERE id = $1
382 AND (last_used_at IS NULL OR last_used_at < now() - interval '60 seconds')
383 FOR UPDATE SKIP LOCKED
384 )",
385 )
386 .bind(session_id)
387 .execute(pool)
388 .await;
389
390 Ok(AuthCtx {
391 agent_id,
392 agent_name,
393 team_id,
394 team_slug,
395 session: label,
398 session_id: Some(session_id),
399 session_epoch: Some(epoch),
400 token_id: None,
402 })
403}
404
405#[derive(Debug)]
406pub enum AuthError {
407 Missing,
408 Invalid,
409 Disabled,
410 Internal,
411 Throttled(u64),
413 BadSession(String),
416 SessionExpired,
418 SessionMismatch {
420 proven: String,
421 claimed: String,
422 },
423 StaleEpoch {
426 current: i64,
427 sent: i64,
428 },
429}
430
431impl IntoResponse for AuthError {
432 fn into_response(self) -> Response {
433 let retry_after = match self {
434 AuthError::Throttled(secs) => Some(secs),
435 _ => None,
436 };
437 let is_auth_challenge = matches!(self, AuthError::Missing | AuthError::Invalid);
439 let (status, msg) = match self {
442 AuthError::Missing => (StatusCode::UNAUTHORIZED, "missing bearer token".to_owned()),
443 AuthError::Invalid => (
444 StatusCode::UNAUTHORIZED,
445 "invalid or revoked token".to_owned(),
446 ),
447 AuthError::Disabled => (StatusCode::FORBIDDEN, "agent is disabled".to_owned()),
448 AuthError::Internal => (
449 StatusCode::INTERNAL_SERVER_ERROR,
450 "internal error".to_owned(),
451 ),
452 AuthError::Throttled(secs) => (
453 StatusCode::TOO_MANY_REQUESTS,
454 format!(
455 "rate limit exceeded for this token; retry in {secs}s. \
456 If you are polling, use wait_for_updates (it blocks until \
457 something happens) instead of calling in a loop."
458 ),
459 ),
460 AuthError::SessionExpired => (
461 StatusCode::UNAUTHORIZED,
462 "this session credential has expired. Register a new session with \
463 register_session using your agent token; your session label, and \
464 everything filed under it, is unchanged."
465 .to_owned(),
466 ),
467 AuthError::SessionMismatch { proven, claimed } => (
468 StatusCode::FORBIDDEN,
469 format!(
470 "the {SESSION_HEADER} header says '{claimed}' but this credential \
471 authenticates session '{proven}'. A session credential proves which \
472 window it is; drop the header, or send the one you hold."
473 ),
474 ),
475 AuthError::StaleEpoch { current, sent } => (
476 StatusCode::CONFLICT,
477 format!(
478 "this connection is stale: it carries epoch {sent} and the session is \
479 at {current}, so another process resumed this window after you. Stop \
480 writing as it — resume the session to take over, or exit."
481 ),
482 ),
483 AuthError::BadSession(why) => (
484 StatusCode::BAD_REQUEST,
485 format!(
486 "the {SESSION_HEADER} header {why}. It labels which of your \
487 concurrent working contexts is calling — one per repository \
488 is the usual choice. Omit it entirely to use the shared session."
489 ),
490 ),
491 };
492 let body = serde_json::json!({ "error": msg });
493 let mut resp = (status, axum::Json(body)).into_response();
494 if is_auth_challenge {
495 resp.headers_mut().insert(
496 axum::http::header::WWW_AUTHENTICATE,
497 axum::http::HeaderValue::from_static("Bearer"),
498 );
499 }
500 if let Some(secs) = retry_after
501 && let Ok(value) = axum::http::HeaderValue::from_str(&secs.to_string())
502 {
503 resp.headers_mut()
504 .insert(axum::http::header::RETRY_AFTER, value);
505 }
506 resp
507 }
508}
509
510#[derive(Clone)]
512pub struct AuthState {
513 pub pool: PgPool,
514 pub limiter: Option<crate::ratelimit::RateLimiter>,
515}
516
517pub async fn require_bearer(
521 State(state): State<AuthState>,
522 mut req: Request,
523 next: Next,
524) -> Result<Response, AuthError> {
525 let raw = req
526 .headers()
527 .get(axum::http::header::AUTHORIZATION)
528 .and_then(|v| v.to_str().ok())
529 .and_then(|v| {
530 v.strip_prefix("Bearer ")
531 .or_else(|| v.strip_prefix("bearer "))
532 })
533 .map(str::trim)
534 .filter(|v| !v.is_empty())
535 .ok_or(AuthError::Missing)?
536 .to_owned();
537
538 if let Some(limiter) = &state.limiter
541 && let Err(throttled) = limiter.check(&hex::encode(hash_token(&raw)))
542 {
543 return Err(AuthError::Throttled(throttled.retry_after_secs));
544 }
545
546 let session = session_from_headers(req.headers())?;
549 let epoch = epoch_from_headers(req.headers())?;
550
551 let mut ctx = resolve_token(&state.pool, &raw).await?;
552 match ctx.session_id {
553 None => ctx.session = session,
555 Some(_) => {
559 if !session.is_empty() && session != ctx.session {
560 return Err(AuthError::SessionMismatch {
561 proven: ctx.session,
562 claimed: session,
563 });
564 }
565 if let (Some(current), Some(sent)) = (ctx.session_epoch, epoch)
566 && sent < current
567 {
568 return Err(AuthError::StaleEpoch { current, sent });
569 }
570 }
571 }
572 tracing::debug!(
573 agent = %ctx.agent_name,
574 team = %ctx.team_slug,
575 session = %ctx.session,
576 "authenticated"
577 );
578 req.extensions_mut().insert(ctx);
579 Ok(next.run(req).await)
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use axum::http::{HeaderMap, HeaderValue};
586
587 fn headers(value: &str) -> HeaderMap {
588 let mut h = HeaderMap::new();
589 h.insert(SESSION_HEADER, HeaderValue::from_str(value).unwrap());
590 h
591 }
592
593 fn err(value: &str) -> String {
594 match session_from_headers(&headers(value)) {
595 Err(AuthError::BadSession(why)) => why,
596 other => panic!("expected BadSession, got {other:?}"),
597 }
598 }
599
600 #[test]
601 fn absent_header_is_the_shared_session() {
602 assert_eq!(session_from_headers(&HeaderMap::new()).unwrap(), "");
603 }
604
605 #[test]
606 fn blank_header_is_the_shared_session() {
607 assert_eq!(session_from_headers(&headers(" ")).unwrap(), "");
610 }
611
612 #[test]
613 fn label_is_normalised_like_a_channel_name() {
614 assert_eq!(
617 session_from_headers(&headers(" Market-Data ")).unwrap(),
618 "market-data"
619 );
620 }
621
622 #[test]
623 fn over_long_label_is_rejected_with_the_limit() {
624 let why = err(&"a".repeat(MAX_SESSION_BYTES + 1));
625 assert!(why.contains(&MAX_SESSION_BYTES.to_string()), "{why}");
626 }
627
628 #[test]
629 fn label_at_the_limit_is_accepted() {
630 let label = "a".repeat(MAX_SESSION_BYTES);
631 assert_eq!(session_from_headers(&headers(&label)).unwrap(), label);
632 }
633
634 #[test]
635 fn an_unexpanded_template_is_rejected_rather_than_becoming_a_session() {
636 let why = err("${BUS_SESSION}");
637 assert!(why.contains("unexpanded"), "{why}");
638 }
639
640 #[test]
641 fn slash_is_rejected_because_it_separates_agent_from_session() {
642 assert!(err("joaquin/market-data").contains('/'));
643 }
644
645 #[test]
646 fn internal_control_character_is_rejected() {
647 assert!(err("market\tdata").contains("control"));
650 }
651
652 #[test]
653 fn non_ascii_header_is_rejected() {
654 let mut h = HeaderMap::new();
655 h.insert(
656 SESSION_HEADER,
657 HeaderValue::from_bytes(&[0xff, 0xfe]).unwrap(),
658 );
659 match session_from_headers(&h) {
660 Err(AuthError::BadSession(_)) => {}
661 other => panic!("expected BadSession, got {other:?}"),
662 }
663 }
664}