Skip to main content

kasl_server/
auth.rs

1//! Agent authentication: who is allowed to upload, and on whose behalf.
2//!
3//! An agent presents a bearer token. The server stores only its SHA-256, so a
4//! database dump - or a stray log line containing a row - hands out nothing
5//! usable. Verification is a hash and a lookup, cheap enough to run on every
6//! upload.
7//!
8//! SHA-256 rather than a password hash on purpose: these tokens are long
9//! random strings the server itself issues, not human-chosen secrets, so there
10//! is no dictionary to slow an attacker down with. Passwords, when they arrive
11//! with the login milestone, need a different treatment.
12
13use 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
23/// Hex-encoded SHA-256 of a token, which is what `agents.token_hash` holds.
24pub fn hash_token(token: &str) -> String {
25    let digest = Sha256::digest(token.as_bytes());
26    // Hex rather than base64: it survives copying through logs, shells and
27    // psql without an encoding argument on either side.
28    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/// An authenticated agent and the person it reports for.
36///
37/// Handlers take this as an argument, which makes the check impossible to
38/// forget: a route without it simply has no user to write rows for.
39#[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
61/// Extracts the credentials from `Authorization: Bearer <token>`.
62fn bearer_token(parts: &Parts) -> Option<String> {
63    let header = parts.headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
64    // The scheme is case-insensitive per RFC 7235, and clients differ.
65    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
73/// Resolves a token to its agent, refusing revoked agents and inactive users.
74///
75/// Returns `Ok(None)` when nothing matches: an unknown token and a revoked one
76/// are the same answer to whoever is asking, which is the point.
77async 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    // Best-effort liveness stamp: the dashboards use it to spot agents that
92    // went quiet. A failure here must not cost the upload its data.
93    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        // Pinned against an external implementation: were the encoding to
127        // change, every stored hash would silently stop matching.
128        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}