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    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
25/// Hex-encoded SHA-256 of a token, which is what `agents.token_hash` holds.
26pub fn hash_token(token: &str) -> String {
27    let digest = Sha256::digest(token.as_bytes());
28    // Hex rather than base64: it survives copying through logs, shells and
29    // psql without an encoding argument on either side.
30    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/// An authenticated agent and the person it reports for.
38///
39/// Handlers take this as an argument, which makes the check impossible to
40/// forget: a route without it simply has no user to write rows for.
41#[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
63/// Extracts the credentials from `Authorization: Bearer <token>`.
64fn bearer_token(parts: &Parts) -> Option<String> {
65    let header = parts.headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
66    // The scheme is case-insensitive per RFC 7235, and clients differ.
67    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
75/// Resolves a token to its agent, refusing revoked agents and inactive users.
76///
77/// Returns `Ok(None)` when nothing matches: an unknown token and a revoked one
78/// are the same answer to whoever is asking, which is the point.
79async 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    // Best-effort liveness stamp: the dashboards use it to spot agents that
94    // went quiet. A failure here must not cost the upload its data.
95    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/// What an agent is told about its own token: `GET /api/v1/agent/whoami`.
107///
108/// The only route that answers an agent a question about itself. Without it a
109/// token is opaque to the machine holding it: `kasl server connect` could
110/// confirm that a token works, but not whose it is, and a token pasted from
111/// the wrong chat window would file this machine's days under a colleague's
112/// name without a word. The check belongs at connect time, where a person is
113/// watching.
114#[derive(Debug, serde::Serialize)]
115pub struct Whoami {
116    /// The employee this agent reports for, as the server displays them.
117    pub user_name: String,
118    /// The label the administrator gave this agent, typically the machine.
119    pub agent_name: String,
120    /// The API version this path belongs to. Sent so a client can say "this
121    /// server speaks v1" without parsing the URL it just called.
122    pub api_version: &'static str,
123    /// The server's own version, for the connect summary and for support
124    /// questions. Already public on `/health`; repeated here so a connecting
125    /// agent needs one round trip, not two.
126    pub server_version: &'static str,
127}
128
129/// Answers whose token this is.
130pub 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        // Pinned against an external implementation: were the encoding to
171        // change, every stored hash would silently stop matching.
172        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}