1use 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
20const REFRESH_COOKIE: &str = "koan_refresh";
23const REFRESH_COOKIE_PATH: &str = "/auth/refresh";
24
25const LOGIN_WINDOW_SECS: u64 = 60;
32const LOGIN_MAX_PER_WINDOW: u32 = 10;
33const 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 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#[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 pub cookie_secure: bool,
75 pub login_limiter: Arc<LoginRateLimiter>,
76}
77
78impl AuthRouteState {
79 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
100fn 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
118async 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#[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
208pub 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 .layer(tower::limit::ConcurrencyLimitLayer::new(2))
227 .with_state(state)
228}
229
230async 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 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 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 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 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 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 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 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 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 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 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#[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 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 let refresh = state(false).refresh_cookie("tok");
536 assert!(refresh.contains("Path=/auth/refresh"));
537 assert!(refresh.contains("HttpOnly"));
538 }
539}