Skip to main content

koan_server/auth/
routes.rs

1//! Auth HTTP routes: login, refresh, logout.
2
3use std::collections::HashMap;
4use std::net::{IpAddr, SocketAddr};
5use std::path::PathBuf;
6use std::sync::{Arc, Mutex};
7
8use axum::Json;
9use axum::extract::{ConnectInfo, State};
10use axum::http::StatusCode;
11use axum::http::header::{COOKIE, SET_COOKIE};
12use axum::response::{IntoResponse, Response};
13use axum::routing::post;
14use serde::{Deserialize, Serialize};
15
16use koan_core::auth;
17use koan_core::db::connection::Database;
18use koan_core::db::queries::auth as auth_queries;
19
20/// Name of the cookie carrying the refresh token. Scoped to `/auth/refresh` so
21/// it is never attached to an API call, and `HttpOnly` so script cannot read it.
22const REFRESH_COOKIE: &str = "koan_refresh";
23const REFRESH_COOKIE_PATH: &str = "/auth/refresh";
24
25/// Fixed-window per-IP cap on login attempts.
26///
27/// Argon2 is tuned to cost ~19MiB and real CPU per verification, which is
28/// correct for resisting cracking and ruinous when anyone may trigger it at
29/// will: a few hundred concurrent logins exhaust memory and starve every other
30/// request. The window is coarse on purpose — it bounds cost, it is not a quota.
31const LOGIN_WINDOW_SECS: u64 = 60;
32const LOGIN_MAX_PER_WINDOW: u32 = 10;
33/// Above this many tracked IPs, drop stale windows before inserting more.
34const LOGIN_TRACKED_IPS_MAX: usize = 4096;
35
36#[derive(Default)]
37pub struct LoginRateLimiter {
38    windows: Mutex<HashMap<IpAddr, (u64, u32)>>,
39}
40
41impl LoginRateLimiter {
42    /// Returns false when `ip` has spent its allowance for the current window.
43    fn allow(&self, ip: IpAddr) -> bool {
44        let now = auth::now_unix();
45        let mut windows = self.windows.lock().unwrap_or_else(|e| e.into_inner());
46
47        if windows.len() > LOGIN_TRACKED_IPS_MAX {
48            windows.retain(|_, (start, _)| now.saturating_sub(*start) < LOGIN_WINDOW_SECS);
49        }
50
51        let entry = windows.entry(ip).or_insert((now, 0));
52        if now.saturating_sub(entry.0) >= LOGIN_WINDOW_SECS {
53            *entry = (now, 0);
54        }
55        entry.1 += 1;
56        entry.1 <= LOGIN_MAX_PER_WINDOW
57    }
58}
59
60// ---------------------------------------------------------------------------
61// Shared state
62// ---------------------------------------------------------------------------
63
64#[derive(Clone)]
65pub struct AuthRouteState {
66    pub db_path: PathBuf,
67    pub private_pem: Arc<Vec<u8>>,
68    pub public_pem: Arc<Vec<u8>>,
69    pub access_ttl_secs: u64,
70    pub refresh_ttl_secs: u64,
71    /// Mark cookies `Secure`. Only when clients actually reach koan over HTTPS —
72    /// a browser discards a `Secure` cookie delivered over plain `http://`, so
73    /// setting this on a LAN deployment silently breaks cookie auth entirely.
74    pub cookie_secure: bool,
75    pub login_limiter: Arc<LoginRateLimiter>,
76}
77
78impl AuthRouteState {
79    /// `SameSite=Lax` keeps the cookie off cross-site requests, which is what
80    /// takes the WebSocket and safelisted-content-type CSRF paths off the table.
81    fn cookie(&self, name: &str, value: &str, path: &str, max_age: u64) -> String {
82        let secure = if self.cookie_secure { "; Secure" } else { "" };
83        format!("{name}={value}; HttpOnly; SameSite=Lax; Path={path}; Max-Age={max_age}{secure}")
84    }
85
86    fn access_cookie(&self, token: &str) -> String {
87        self.cookie("koan_access", token, "/", self.access_ttl_secs)
88    }
89
90    fn refresh_cookie(&self, token: &str) -> String {
91        self.cookie(
92            REFRESH_COOKIE,
93            token,
94            REFRESH_COOKIE_PATH,
95            self.refresh_ttl_secs,
96        )
97    }
98}
99
100/// Read the refresh token from the request body, falling back to the cookie so a
101/// browser client never has to keep one in script-reachable storage.
102fn refresh_token_from(body: Option<&str>, headers: &axum::http::HeaderMap) -> Option<String> {
103    if let Some(t) = body.filter(|t| !t.is_empty()) {
104        return Some(t.to_owned());
105    }
106    headers
107        .get(COOKIE)
108        .and_then(|v| v.to_str().ok())
109        .and_then(|cookies| {
110            cookies.split(';').find_map(|c| {
111                c.trim()
112                    .strip_prefix(&format!("{REFRESH_COOKIE}="))
113                    .map(str::to_owned)
114            })
115        })
116}
117
118/// Reject login attempts once an IP has spent its window.
119///
120/// A middleware rather than an extractor so it runs before the request body is
121/// read and before the database is touched.
122async fn login_rate_limit(
123    State(state): State<AuthRouteState>,
124    request: axum::extract::Request,
125    next: axum::middleware::Next,
126) -> Response {
127    let ip = request
128        .extensions()
129        .get::<ConnectInfo<SocketAddr>>()
130        .map(|ConnectInfo(addr)| addr.ip())
131        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
132
133    if !state.login_limiter.allow(ip) {
134        return (
135            StatusCode::TOO_MANY_REQUESTS,
136            Json(MessageResponse {
137                message: "too many login attempts".into(),
138            }),
139        )
140            .into_response();
141    }
142    next.run(request).await
143}
144
145impl AuthRouteState {
146    fn open_db(&self) -> Result<Database, (StatusCode, String)> {
147        Database::open(&self.db_path).map_err(|e| {
148            log::error!("auth db open error: {}", e);
149            (
150                StatusCode::INTERNAL_SERVER_ERROR,
151                "internal error".to_string(),
152            )
153        })
154    }
155}
156
157// ---------------------------------------------------------------------------
158// Request/response types
159// ---------------------------------------------------------------------------
160
161#[derive(Deserialize)]
162pub struct LoginRequest {
163    pub username: String,
164    pub password: String,
165}
166
167#[derive(Serialize)]
168pub struct LoginResponse {
169    pub access_token: String,
170    pub refresh_token: String,
171    pub token_type: String,
172    pub expires_in: u64,
173    pub user: UserInfo,
174}
175
176#[derive(Serialize)]
177pub struct UserInfo {
178    pub id: i64,
179    pub username: String,
180    pub role: String,
181}
182
183#[derive(Deserialize, Default)]
184#[serde(default)]
185pub struct RefreshRequest {
186    pub refresh_token: Option<String>,
187}
188
189#[derive(Serialize)]
190pub struct RefreshResponse {
191    pub access_token: String,
192    pub refresh_token: String,
193    pub token_type: String,
194    pub expires_in: u64,
195}
196
197#[derive(Deserialize, Default)]
198#[serde(default)]
199pub struct LogoutRequest {
200    pub refresh_token: Option<String>,
201}
202
203#[derive(Serialize)]
204pub struct MessageResponse {
205    pub message: String,
206}
207
208// ---------------------------------------------------------------------------
209// Router
210// ---------------------------------------------------------------------------
211
212pub fn auth_router(state: AuthRouteState) -> axum::Router {
213    axum::Router::new()
214        .route(
215            "/auth/login",
216            post(login).layer(axum::middleware::from_fn_with_state(
217                state.clone(),
218                login_rate_limit,
219            )),
220        )
221        .route("/auth/refresh", post(refresh))
222        .route("/auth/logout", post(logout))
223        // These routes are unauthenticated by definition and the work behind
224        // them is deliberately expensive, so they get their own ceiling rather
225        // than sharing the GraphQL one.
226        .layer(tower::limit::ConcurrencyLimitLayer::new(2))
227        .with_state(state)
228}
229
230// ---------------------------------------------------------------------------
231// Handlers
232// ---------------------------------------------------------------------------
233
234async fn login(State(state): State<AuthRouteState>, Json(req): Json<LoginRequest>) -> Response {
235    let db = match state.open_db() {
236        Ok(db) => db,
237        Err((status, msg)) => return (status, msg).into_response(),
238    };
239
240    // Look up user.
241    let user = match auth_queries::get_user_by_username(&db.conn, &req.username) {
242        Ok(Some(u)) => u,
243        Ok(None) => {
244            return (
245                StatusCode::UNAUTHORIZED,
246                Json(MessageResponse {
247                    message: "invalid username or password".into(),
248                }),
249            )
250                .into_response();
251        }
252        Err(e) => {
253            log::error!("auth login db error: {}", e);
254            return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
255        }
256    };
257
258    // Argon2 blocks for milliseconds at a time; on the async workers that stalls
259    // every other request the server is handling.
260    let hash = user.password_hash.clone();
261    let password = req.password.clone();
262    let verified = tokio::task::spawn_blocking(move || auth::verify_password(&password, &hash))
263        .await
264        .map(|r| r.is_ok())
265        .unwrap_or(false);
266
267    if !verified {
268        return (
269            StatusCode::UNAUTHORIZED,
270            Json(MessageResponse {
271                message: "invalid username or password".into(),
272            }),
273        )
274            .into_response();
275    }
276
277    // Mint access token.
278    let access_token = match auth::mint_access_token(
279        &state.private_pem,
280        user.id,
281        &user.username,
282        user.role,
283        state.access_ttl_secs,
284    ) {
285        Ok(t) => t,
286        Err(e) => {
287            log::error!("auth mint token error: {}", e);
288            return (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response();
289        }
290    };
291
292    // Create refresh token.
293    let refresh_token_id = match auth::random_token() {
294        Ok(t) => t,
295        Err(e) => {
296            log::error!("auth refresh token generation error: {}", e);
297            return (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response();
298        }
299    };
300    let refresh_expires = auth::now_unix() as i64 + state.refresh_ttl_secs as i64;
301    if let Err(e) =
302        auth_queries::store_refresh_token(&db.conn, &refresh_token_id, user.id, refresh_expires)
303    {
304        log::error!("auth store refresh token error: {}", e);
305        return (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response();
306    }
307
308    // Housekeeping: clean up expired tokens on login (non-blocking).
309    let _ = auth_queries::cleanup_expired_tokens(&db.conn);
310
311    let cookies = [
312        (SET_COOKIE, state.access_cookie(&access_token)),
313        (SET_COOKIE, state.refresh_cookie(&refresh_token_id)),
314    ];
315
316    let resp = LoginResponse {
317        access_token,
318        // Also in the body: the CLI and other non-browser clients have no cookie
319        // jar and store this in the keychain.
320        refresh_token: refresh_token_id,
321        token_type: "Bearer".into(),
322        expires_in: state.access_ttl_secs,
323        user: UserInfo {
324            id: user.id,
325            username: user.username,
326            role: user.role.as_str().into(),
327        },
328    };
329
330    (StatusCode::OK, cookies, Json(resp)).into_response()
331}
332
333async fn refresh(
334    State(state): State<AuthRouteState>,
335    headers: axum::http::HeaderMap,
336    body: Option<Json<RefreshRequest>>,
337) -> Response {
338    let supplied = body.and_then(|Json(req)| req.refresh_token);
339    let Some(supplied) = refresh_token_from(supplied.as_deref(), &headers) else {
340        return (
341            StatusCode::UNAUTHORIZED,
342            Json(MessageResponse {
343                message: "missing refresh token".into(),
344            }),
345        )
346            .into_response();
347    };
348
349    let db = match state.open_db() {
350        Ok(db) => db,
351        Err((status, msg)) => return (status, msg).into_response(),
352    };
353
354    // Atomically consume (validate + revoke) the refresh token in a single
355    // statement to prevent TOCTOU races during token rotation.
356    let token = match auth_queries::consume_refresh_token(&db.conn, &supplied) {
357        Ok(Some(t)) => t,
358        Ok(None) => {
359            return (
360                StatusCode::UNAUTHORIZED,
361                Json(MessageResponse {
362                    message: "invalid or expired refresh token".into(),
363                }),
364            )
365                .into_response();
366        }
367        Err(e) => {
368            log::error!("auth refresh db error: {}", e);
369            return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
370        }
371    };
372
373    // Look up the user.
374    let user = match auth_queries::get_user_by_id(&db.conn, token.user_id) {
375        Ok(Some(u)) => u,
376        Ok(None) => {
377            return (
378                StatusCode::UNAUTHORIZED,
379                Json(MessageResponse {
380                    message: "user not found".into(),
381                }),
382            )
383                .into_response();
384        }
385        Err(e) => {
386            log::error!("auth refresh user lookup error: {}", e);
387            return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
388        }
389    };
390
391    // Mint new access token.
392    let access_token = match auth::mint_access_token(
393        &state.private_pem,
394        user.id,
395        &user.username,
396        user.role,
397        state.access_ttl_secs,
398    ) {
399        Ok(t) => t,
400        Err(e) => {
401            log::error!("auth mint token error: {}", e);
402            return (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response();
403        }
404    };
405
406    // Issue new refresh token.
407    let new_refresh_id = match auth::random_token() {
408        Ok(t) => t,
409        Err(e) => {
410            log::error!("auth refresh token generation error: {}", e);
411            return (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response();
412        }
413    };
414    let refresh_expires = auth::now_unix() as i64 + state.refresh_ttl_secs as i64;
415    if let Err(e) =
416        auth_queries::store_refresh_token(&db.conn, &new_refresh_id, user.id, refresh_expires)
417    {
418        log::error!("auth store refresh token error: {}", e);
419        return (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response();
420    }
421
422    let cookies = [
423        (SET_COOKIE, state.access_cookie(&access_token)),
424        (SET_COOKIE, state.refresh_cookie(&new_refresh_id)),
425    ];
426
427    let resp = RefreshResponse {
428        access_token,
429        refresh_token: new_refresh_id,
430        token_type: "Bearer".into(),
431        expires_in: state.access_ttl_secs,
432    };
433
434    (StatusCode::OK, cookies, Json(resp)).into_response()
435}
436
437async fn logout(
438    State(state): State<AuthRouteState>,
439    headers: axum::http::HeaderMap,
440    body: Option<Json<LogoutRequest>>,
441) -> Response {
442    let db = match state.open_db() {
443        Ok(db) => db,
444        Err((status, msg)) => return (status, msg).into_response(),
445    };
446
447    let supplied = body.and_then(|Json(req)| req.refresh_token);
448    if let Some(token) = refresh_token_from(supplied.as_deref(), &headers) {
449        let _ = auth_queries::revoke_refresh_token(&db.conn, &token);
450    }
451
452    let cookies = [
453        (SET_COOKIE, state.cookie("koan_access", "", "/", 0)),
454        (
455            SET_COOKIE,
456            state.cookie(REFRESH_COOKIE, "", REFRESH_COOKIE_PATH, 0),
457        ),
458    ];
459
460    (
461        StatusCode::OK,
462        cookies,
463        Json(MessageResponse {
464            message: "logged out".into(),
465        }),
466    )
467        .into_response()
468}
469
470// ---------------------------------------------------------------------------
471// Tests
472// ---------------------------------------------------------------------------
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn login_limiter_caps_a_single_ip() {
480        let limiter = LoginRateLimiter::default();
481        let ip: IpAddr = "10.0.0.5".parse().unwrap();
482        for _ in 0..LOGIN_MAX_PER_WINDOW {
483            assert!(limiter.allow(ip));
484        }
485        assert!(!limiter.allow(ip));
486
487        // Other callers are unaffected.
488        assert!(limiter.allow("10.0.0.6".parse().unwrap()));
489    }
490
491    #[test]
492    fn refresh_token_falls_back_to_the_cookie() {
493        let mut headers = axum::http::HeaderMap::new();
494        headers.insert(
495            COOKIE,
496            format!("a=1; {REFRESH_COOKIE}=from-cookie; b=2")
497                .parse()
498                .unwrap(),
499        );
500
501        assert_eq!(
502            refresh_token_from(None, &headers).as_deref(),
503            Some("from-cookie")
504        );
505        assert_eq!(
506            refresh_token_from(Some("from-body"), &headers).as_deref(),
507            Some("from-body")
508        );
509        assert_eq!(
510            refresh_token_from(None, &axum::http::HeaderMap::new()),
511            None
512        );
513    }
514
515    #[test]
516    fn cookies_are_lax_and_only_secure_when_tls_is_in_play() {
517        let state = |cookie_secure| AuthRouteState {
518            db_path: PathBuf::from("/nonexistent"),
519            private_pem: Arc::new(Vec::new()),
520            public_pem: Arc::new(Vec::new()),
521            access_ttl_secs: 900,
522            refresh_ttl_secs: 60,
523            cookie_secure,
524            login_limiter: Arc::new(LoginRateLimiter::default()),
525        };
526
527        let plain = state(false).access_cookie("tok");
528        assert!(plain.contains("SameSite=Lax"));
529        assert!(plain.contains("HttpOnly"));
530        assert!(!plain.contains("Secure"));
531
532        assert!(state(true).access_cookie("tok").contains("; Secure"));
533
534        // The refresh cookie never rides along on an API call.
535        let refresh = state(false).refresh_cookie("tok");
536        assert!(refresh.contains("Path=/auth/refresh"));
537        assert!(refresh.contains("HttpOnly"));
538    }
539}