1use axum::{
14 extract::FromRequestParts,
15 http::{StatusCode, request::Parts},
16};
17use sha2::{Digest, Sha256};
18use sqlx::PgPool;
19use uuid::Uuid;
20
21use crate::{app::AppState, error::ApiError};
22
23pub fn hash_token(token: &str) -> String {
25 let digest = Sha256::digest(token.as_bytes());
26 digest.iter().fold(String::with_capacity(64), |mut acc, byte| {
29 use std::fmt::Write;
30 let _ = write!(acc, "{byte:02x}");
31 acc
32 })
33}
34
35#[derive(Debug, Clone, Copy)]
40pub struct AuthenticatedAgent {
41 pub agent_id: Uuid,
42 pub user_id: Uuid,
43}
44
45impl FromRequestParts<AppState> for AuthenticatedAgent {
46 type Rejection = ApiError;
47
48 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
49 let token = bearer_token(parts)
50 .ok_or_else(|| ApiError::new(StatusCode::UNAUTHORIZED, "missing or malformed Authorization header; expected `Bearer <token>`"))?;
51
52 authenticate(&state.pool, &token).await?.ok_or_else(|| {
53 ApiError::new(
54 StatusCode::UNAUTHORIZED,
55 "the token is not recognized, has been revoked, or its user is deactivated",
56 )
57 })
58 }
59}
60
61fn bearer_token(parts: &Parts) -> Option<String> {
63 let header = parts.headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
64 let (scheme, token) = header.split_once(' ')?;
66 if !scheme.eq_ignore_ascii_case("bearer") {
67 return None;
68 }
69 let token = token.trim();
70 (!token.is_empty()).then(|| token.to_string())
71}
72
73async fn authenticate(pool: &PgPool, token: &str) -> Result<Option<AuthenticatedAgent>, ApiError> {
78 let hash = hash_token(token);
79
80 let row: Option<(Uuid, Uuid)> = sqlx::query_as(
81 "SELECT a.id, a.user_id FROM agents a
82 JOIN users u ON u.id = a.user_id
83 WHERE a.token_hash = $1 AND a.revoked_at IS NULL AND u.active",
84 )
85 .bind(&hash)
86 .fetch_optional(pool)
87 .await?;
88
89 let Some((agent_id, user_id)) = row else { return Ok(None) };
90
91 if let Err(error) = sqlx::query("UPDATE agents SET last_seen_at = now() WHERE id = $1")
94 .bind(agent_id)
95 .execute(pool)
96 .await
97 {
98 tracing::warn!(%error, %agent_id, "failed to record the agent's last-seen time");
99 }
100
101 Ok(Some(AuthenticatedAgent { agent_id, user_id }))
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use axum::http::{HeaderValue, Request, header::AUTHORIZATION};
108
109 fn parts_with(header: &str) -> Parts {
110 let mut request = Request::new(());
111 request.headers_mut().insert(AUTHORIZATION, HeaderValue::from_str(header).unwrap());
112 request.into_parts().0
113 }
114
115 #[test]
116 fn hashing_is_stable_and_hides_the_token() {
117 let hash = hash_token("kasl_agent_secret");
118 assert_eq!(hash, hash_token("kasl_agent_secret"), "the same token must hash the same way");
119 assert_ne!(hash, hash_token("kasl_agent_secre"), "a different token must hash differently");
120 assert_eq!(hash.len(), 64, "SHA-256 is 32 bytes, 64 hex characters");
121 assert!(!hash.contains("kasl_agent_secret"), "the stored form must not contain the token");
122 }
123
124 #[test]
125 fn hashing_matches_the_known_sha256_of_a_fixed_input() {
126 assert_eq!(hash_token("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
129 }
130
131 #[test]
132 fn reads_the_bearer_scheme_in_any_case() {
133 assert_eq!(bearer_token(&parts_with("Bearer tok")).as_deref(), Some("tok"));
134 assert_eq!(bearer_token(&parts_with("bearer tok")).as_deref(), Some("tok"));
135 assert_eq!(bearer_token(&parts_with("BEARER tok")).as_deref(), Some("tok"));
136 }
137
138 #[test]
139 fn rejects_headers_that_are_not_a_bearer_token() {
140 assert!(
141 bearer_token(&parts_with("Basic dXNlcjpwYXNz")).is_none(),
142 "another scheme is not ours to interpret"
143 );
144 assert!(bearer_token(&parts_with("Bearer ")).is_none(), "an empty token is not a token");
145 assert!(bearer_token(&parts_with("tok")).is_none(), "a bare value carries no scheme");
146 }
147}