1use axum::{
14 Json,
15 extract::{FromRequestParts, State},
16 http::{StatusCode, request::Parts},
17 response::IntoResponse,
18};
19use sha2::{Digest, Sha256};
20use sqlx::PgPool;
21use uuid::Uuid;
22
23use crate::{app::AppState, error::ApiError};
24
25pub fn hash_token(token: &str) -> String {
27 let digest = Sha256::digest(token.as_bytes());
28 digest.iter().fold(String::with_capacity(64), |mut acc, byte| {
31 use std::fmt::Write;
32 let _ = write!(acc, "{byte:02x}");
33 acc
34 })
35}
36
37#[derive(Debug, Clone, Copy)]
42pub struct AuthenticatedAgent {
43 pub agent_id: Uuid,
44 pub user_id: Uuid,
45}
46
47impl FromRequestParts<AppState> for AuthenticatedAgent {
48 type Rejection = ApiError;
49
50 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
51 let token = bearer_token(parts)
52 .ok_or_else(|| ApiError::new(StatusCode::UNAUTHORIZED, "missing or malformed Authorization header; expected `Bearer <token>`"))?;
53
54 authenticate(&state.pool, &token).await?.ok_or_else(|| {
55 ApiError::new(
56 StatusCode::UNAUTHORIZED,
57 "the token is not recognized, has been revoked, or its user is deactivated",
58 )
59 })
60 }
61}
62
63fn bearer_token(parts: &Parts) -> Option<String> {
65 let header = parts.headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
66 let (scheme, token) = header.split_once(' ')?;
68 if !scheme.eq_ignore_ascii_case("bearer") {
69 return None;
70 }
71 let token = token.trim();
72 (!token.is_empty()).then(|| token.to_string())
73}
74
75async fn authenticate(pool: &PgPool, token: &str) -> Result<Option<AuthenticatedAgent>, ApiError> {
80 let hash = hash_token(token);
81
82 let row: Option<(Uuid, Uuid)> = sqlx::query_as(
83 "SELECT a.id, a.user_id FROM agents a
84 JOIN users u ON u.id = a.user_id
85 WHERE a.token_hash = $1 AND a.revoked_at IS NULL AND u.active",
86 )
87 .bind(&hash)
88 .fetch_optional(pool)
89 .await?;
90
91 let Some((agent_id, user_id)) = row else { return Ok(None) };
92
93 if let Err(error) = sqlx::query("UPDATE agents SET last_seen_at = now() WHERE id = $1")
96 .bind(agent_id)
97 .execute(pool)
98 .await
99 {
100 tracing::warn!(%error, %agent_id, "failed to record the agent's last-seen time");
101 }
102
103 Ok(Some(AuthenticatedAgent { agent_id, user_id }))
104}
105
106#[derive(Debug, serde::Serialize)]
115pub struct Whoami {
116 pub user_name: String,
118 pub agent_name: String,
120 pub api_version: &'static str,
123 pub server_version: &'static str,
127}
128
129pub async fn whoami(State(state): State<AppState>, agent: AuthenticatedAgent) -> Result<impl IntoResponse, ApiError> {
131 let (user_name, agent_name): (String, String) = sqlx::query_as(
132 "SELECT u.display_name, a.name FROM agents a
133 JOIN users u ON u.id = a.user_id
134 WHERE a.id = $1",
135 )
136 .bind(agent.agent_id)
137 .fetch_one(&state.pool)
138 .await?;
139
140 Ok(Json(Whoami {
141 user_name,
142 agent_name,
143 api_version: "v1",
144 server_version: env!("CARGO_PKG_VERSION"),
145 }))
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use axum::http::{HeaderValue, Request, header::AUTHORIZATION};
152
153 fn parts_with(header: &str) -> Parts {
154 let mut request = Request::new(());
155 request.headers_mut().insert(AUTHORIZATION, HeaderValue::from_str(header).unwrap());
156 request.into_parts().0
157 }
158
159 #[test]
160 fn hashing_is_stable_and_hides_the_token() {
161 let hash = hash_token("kasl_agent_secret");
162 assert_eq!(hash, hash_token("kasl_agent_secret"), "the same token must hash the same way");
163 assert_ne!(hash, hash_token("kasl_agent_secre"), "a different token must hash differently");
164 assert_eq!(hash.len(), 64, "SHA-256 is 32 bytes, 64 hex characters");
165 assert!(!hash.contains("kasl_agent_secret"), "the stored form must not contain the token");
166 }
167
168 #[test]
169 fn hashing_matches_the_known_sha256_of_a_fixed_input() {
170 assert_eq!(hash_token("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
173 }
174
175 #[test]
176 fn reads_the_bearer_scheme_in_any_case() {
177 assert_eq!(bearer_token(&parts_with("Bearer tok")).as_deref(), Some("tok"));
178 assert_eq!(bearer_token(&parts_with("bearer tok")).as_deref(), Some("tok"));
179 assert_eq!(bearer_token(&parts_with("BEARER tok")).as_deref(), Some("tok"));
180 }
181
182 #[test]
183 fn rejects_headers_that_are_not_a_bearer_token() {
184 assert!(
185 bearer_token(&parts_with("Basic dXNlcjpwYXNz")).is_none(),
186 "another scheme is not ours to interpret"
187 );
188 assert!(bearer_token(&parts_with("Bearer ")).is_none(), "an empty token is not a token");
189 assert!(bearer_token(&parts_with("tok")).is_none(), "a bare value carries no scheme");
190 }
191}