1use std::collections::HashMap;
4use std::net::{IpAddr, SocketAddr};
5use std::sync::{Arc, Mutex};
6
7use axum::Json;
8use axum::extract::{ConnectInfo, State};
9use axum::http::StatusCode;
10use axum::http::header::{COOKIE, SET_COOKIE};
11use axum::response::{AppendHeaders, IntoResponse, Response};
12use axum::routing::post;
13use serde::{Deserialize, Serialize};
14
15use koan_core::auth;
16use koan_core::db::pool::{Handle, Pool};
17use koan_core::db::queries::auth as auth_queries;
18
19pub(crate) const REFRESH_COOKIE: &str = "koan_refresh";
23const REFRESH_COOKIE_PATH: &str = "/auth";
24const STALE_REFRESH_COOKIE_PATH: &str = "/auth/refresh";
28
29const LOGIN_WINDOW_SECS: u64 = 60;
36const LOGIN_MAX_PER_WINDOW: u32 = 10;
37const TRACKED_IPS_MAX: usize = 4096;
39
40pub struct RateLimiter {
41 windows: Mutex<HashMap<IpAddr, (u64, u32)>>,
42 window_secs: u64,
43 max: u32,
44}
45
46impl Default for RateLimiter {
47 fn default() -> Self {
48 Self::new(LOGIN_WINDOW_SECS, LOGIN_MAX_PER_WINDOW)
49 }
50}
51
52impl RateLimiter {
53 pub fn new(window_secs: u64, max: u32) -> Self {
54 Self {
55 windows: Mutex::default(),
56 window_secs,
57 max,
58 }
59 }
60
61 pub(crate) fn allow(&self, ip: IpAddr) -> bool {
64 let now = koan_core::clock::unix_secs() as u64;
65 let mut windows = self.windows.lock().unwrap_or_else(|e| e.into_inner());
66
67 if windows.len() > TRACKED_IPS_MAX {
68 windows.retain(|_, (start, _)| now.saturating_sub(*start) < self.window_secs);
69 }
70
71 let entry = windows.entry(network(ip)).or_insert((now, 0));
72 if now.saturating_sub(entry.0) >= self.window_secs {
73 *entry = (now, 0);
74 }
75 entry.1 += 1;
76 entry.1 <= self.max
77 }
78}
79
80pub(crate) fn client_ip(request: &axum::extract::Request) -> IpAddr {
94 address(request.extensions(), request.headers())
95}
96
97pub(crate) struct ClientIp(pub IpAddr);
99
100impl<S: Send + Sync> axum::extract::FromRequestParts<S> for ClientIp {
101 type Rejection = std::convert::Infallible;
102
103 async fn from_request_parts(
104 parts: &mut axum::http::request::Parts,
105 _: &S,
106 ) -> Result<Self, Self::Rejection> {
107 Ok(Self(address(&parts.extensions, &parts.headers)))
108 }
109}
110
111#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub enum ForwardedFrom {
115 Private,
118 Loopback,
123}
124
125fn address(extensions: &axum::http::Extensions, headers: &axum::http::HeaderMap) -> IpAddr {
126 let unknown = IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED);
127 let Some(ConnectInfo(peer)) = extensions.get::<ConnectInfo<SocketAddr>>() else {
128 return unknown;
129 };
130 let peer = peer.ip().to_canonical();
131 let trust = extensions
132 .get::<ForwardedFrom>()
133 .copied()
134 .unwrap_or(ForwardedFrom::Private);
135 let believed = match trust {
136 ForwardedFrom::Private => is_internal(peer),
137 ForwardedFrom::Loopback => peer.is_loopback(),
138 };
139 if !believed {
140 return peer;
141 }
142 let forwarded = headers
143 .get_all("x-forwarded-for")
144 .iter()
145 .filter_map(|v| v.to_str().ok())
146 .flat_map(|v| v.split(','))
147 .filter_map(|ip| ip.trim().parse::<IpAddr>().ok())
148 .next_back()
149 .map(|ip| ip.to_canonical());
150 match (forwarded, trust) {
151 (Some(ip), _) => ip,
152 (None, ForwardedFrom::Private) => peer,
153 (None, ForwardedFrom::Loopback) => unknown,
154 }
155}
156
157pub(crate) fn network(ip: IpAddr) -> IpAddr {
161 match ip.to_canonical() {
162 IpAddr::V4(v4) => IpAddr::V4(v4),
163 IpAddr::V6(v6) => IpAddr::V6((u128::from(v6) & !((1u128 << 64) - 1)).into()),
164 }
165}
166
167fn is_internal(ip: IpAddr) -> bool {
168 match ip.to_canonical() {
169 IpAddr::V4(v4) => v4.is_private() || v4.is_loopback(),
170 IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xfe00) == 0xfc00,
171 }
172}
173
174#[derive(Clone)]
179pub struct AuthRouteState {
180 pub pool: Arc<Pool>,
181 pub private_pem: Arc<Vec<u8>>,
182 pub public_pem: Arc<Vec<u8>>,
183 pub access_ttl_secs: u64,
184 pub refresh_ttl_secs: u64,
185 pub cookie_secure: bool,
189 pub login_limiter: Arc<RateLimiter>,
190 pub users: Arc<super::password::PasswordVerifier>,
192}
193
194impl AuthRouteState {
195 fn cookie(&self, name: &str, value: &str, path: &str, max_age: u64) -> String {
198 let secure = if self.cookie_secure { "; Secure" } else { "" };
199 format!("{name}={value}; HttpOnly; SameSite=Lax; Path={path}; Max-Age={max_age}{secure}")
200 }
201
202 fn access_cookie(&self, token: &str) -> String {
203 self.cookie("koan_access", token, "/", self.access_ttl_secs)
204 }
205
206 fn refresh_cookie(&self, token: &str) -> String {
207 self.cookie(
208 REFRESH_COOKIE,
209 token,
210 REFRESH_COOKIE_PATH,
211 self.refresh_ttl_secs,
212 )
213 }
214
215 fn stale_refresh_cookie(&self) -> String {
216 self.cookie(REFRESH_COOKIE, "", STALE_REFRESH_COOKIE_PATH, 0)
217 }
218
219 pub(crate) fn session_cookies(
222 &self,
223 access_token: &str,
224 refresh_token: &str,
225 ) -> AppendHeaders<[(axum::http::HeaderName, String); 3]> {
226 AppendHeaders([
227 (SET_COOKIE, self.access_cookie(access_token)),
228 (SET_COOKIE, self.refresh_cookie(refresh_token)),
229 (SET_COOKIE, self.stale_refresh_cookie()),
230 ])
231 }
232
233 pub(crate) fn proxied_cookies(
238 &self,
239 access_token: &str,
240 ) -> AppendHeaders<[(axum::http::HeaderName, String); 3]> {
241 AppendHeaders([
242 (SET_COOKIE, self.access_cookie(access_token)),
243 (
244 SET_COOKIE,
245 self.cookie(REFRESH_COOKIE, "", REFRESH_COOKIE_PATH, 0),
246 ),
247 (SET_COOKIE, self.stale_refresh_cookie()),
248 ])
249 }
250
251 pub(crate) fn cleared_cookies(&self) -> AppendHeaders<[(axum::http::HeaderName, String); 3]> {
253 AppendHeaders([
254 (SET_COOKIE, self.cookie("koan_access", "", "/", 0)),
255 (
256 SET_COOKIE,
257 self.cookie(REFRESH_COOKIE, "", REFRESH_COOKIE_PATH, 0),
258 ),
259 (SET_COOKIE, self.stale_refresh_cookie()),
260 ])
261 }
262}
263
264pub(crate) fn dummy_password_hash() -> &'static str {
267 static HASH: std::sync::OnceLock<String> = std::sync::OnceLock::new();
268 HASH.get_or_init(|| auth::hash_password("koan-dummy-password").unwrap_or_default())
269}
270
271pub(crate) fn refresh_token_from(
274 body: Option<&str>,
275 headers: &axum::http::HeaderMap,
276) -> Option<String> {
277 if let Some(t) = body.filter(|t| !t.is_empty()) {
278 return Some(t.to_owned());
279 }
280 headers
281 .get(COOKIE)
282 .and_then(|v| v.to_str().ok())
283 .and_then(|cookies| {
284 cookies.split(';').find_map(|c| {
285 c.trim()
286 .strip_prefix(&format!("{REFRESH_COOKIE}="))
287 .map(str::to_owned)
288 })
289 })
290}
291
292pub(crate) async fn login_rate_limit(
297 State(state): State<AuthRouteState>,
298 request: axum::extract::Request,
299 next: axum::middleware::Next,
300) -> Response {
301 let ip = client_ip(&request);
302
303 if !state.login_limiter.allow(ip) {
304 return (
305 StatusCode::TOO_MANY_REQUESTS,
306 Json(MessageResponse {
307 message: "too many login attempts".into(),
308 }),
309 )
310 .into_response();
311 }
312 next.run(request).await
313}
314
315pub(crate) async fn rate_limit(
317 State(limiter): State<Arc<RateLimiter>>,
318 request: axum::extract::Request,
319 next: axum::middleware::Next,
320) -> Response {
321 if !limiter.allow(client_ip(&request)) {
322 return (StatusCode::TOO_MANY_REQUESTS, "too many requests").into_response();
323 }
324 next.run(request).await
325}
326
327impl AuthRouteState {
328 fn open_db(&self) -> Result<Handle<'_>, (StatusCode, String)> {
329 self.pool.get().map_err(|e| {
330 log::error!("auth db open error: {}", e);
331 (
332 StatusCode::INTERNAL_SERVER_ERROR,
333 "internal error".to_string(),
334 )
335 })
336 }
337}
338
339#[derive(Deserialize)]
344pub struct LoginRequest {
345 pub username: String,
346 pub password: String,
347}
348
349#[derive(Serialize)]
350pub struct LoginResponse {
351 pub access_token: String,
352 pub refresh_token: String,
353 pub token_type: String,
354 pub expires_in: u64,
355 pub user: UserInfo,
356}
357
358#[derive(Serialize)]
359pub struct UserInfo {
360 pub id: i64,
361 pub username: String,
362 pub role: String,
363}
364
365#[derive(Deserialize, Default)]
366#[serde(default)]
367pub struct RefreshRequest {
368 pub refresh_token: Option<String>,
369}
370
371#[derive(Serialize)]
372pub struct RefreshResponse {
373 pub access_token: String,
374 pub refresh_token: String,
375 pub token_type: String,
376 pub expires_in: u64,
377}
378
379#[derive(Deserialize, Default)]
380#[serde(default)]
381pub struct LogoutRequest {
382 pub refresh_token: Option<String>,
383}
384
385#[derive(Serialize)]
386pub struct MessageResponse {
387 pub message: String,
388}
389
390pub fn auth_router(state: AuthRouteState) -> axum::Router {
395 let router = axum::Router::new()
396 .route(
397 "/auth/login",
398 post(login).layer(axum::middleware::from_fn_with_state(
399 state.clone(),
400 login_rate_limit,
401 )),
402 )
403 .route("/auth/refresh", post(refresh))
404 .route("/auth/logout", post(logout));
405 auth_perimeter(router, AUTH_TIMEOUT, AUTH_BODY_TIMEOUT).with_state(state)
406}
407
408const AUTH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
410
411const AUTH_BODY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
413
414const AUTH_BODY_LIMIT: usize = 16 * 1024;
416
417fn auth_perimeter<S>(
424 router: axum::Router<S>,
425 timeout: std::time::Duration,
426 body_timeout: std::time::Duration,
427) -> axum::Router<S>
428where
429 S: Clone + Send + Sync + 'static,
430{
431 router
432 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
433 StatusCode::REQUEST_TIMEOUT,
434 timeout,
435 ))
436 .layer(
437 tower::ServiceBuilder::new()
438 .layer(axum::error_handling::HandleErrorLayer::new(
439 |_: tower::BoxError| async { (StatusCode::SERVICE_UNAVAILABLE, "busy") },
440 ))
441 .load_shed()
442 .concurrency_limit(2),
443 )
444 .layer(axum::middleware::from_fn(
445 move |request: axum::extract::Request, next: axum::middleware::Next| {
446 buffer_body(request, next, body_timeout)
447 },
448 ))
449}
450
451async fn buffer_body(
453 request: axum::extract::Request,
454 next: axum::middleware::Next,
455 deadline: std::time::Duration,
456) -> Response {
457 use futures_util::StreamExt as _;
458 let (parts, body) = request.into_parts();
459 let read = async {
460 let mut stream = body.into_data_stream();
461 let mut bytes = Vec::new();
462 while let Some(chunk) = stream.next().await {
463 let chunk = chunk.map_err(|_| StatusCode::BAD_REQUEST)?;
464 if bytes.len() + chunk.len() > AUTH_BODY_LIMIT {
465 return Err(StatusCode::PAYLOAD_TOO_LARGE);
466 }
467 bytes.extend_from_slice(&chunk);
468 }
469 Ok(bytes)
470 };
471 match tokio::time::timeout(deadline, read).await {
472 Ok(Ok(bytes)) => {
473 next.run(axum::extract::Request::from_parts(parts, bytes.into()))
474 .await
475 }
476 Ok(Err(status)) => status.into_response(),
477 Err(_) => StatusCode::REQUEST_TIMEOUT.into_response(),
478 }
479}
480
481pub(crate) async fn authenticate(
494 state: &AuthRouteState,
495 username: &str,
496 password: &str,
497 from: IpAddr,
498) -> Result<(auth_queries::UserRow, String, String), Box<Response>> {
499 let state = state.clone();
500 let (username, password) = (username.to_owned(), password.to_owned());
501 tokio::task::spawn_blocking(move || authenticate_blocking(&state, &username, &password, from))
502 .await
503 .unwrap_or_else(|_| Err(Box::new(StatusCode::INTERNAL_SERVER_ERROR.into_response())))
504}
505
506fn authenticate_blocking(
507 state: &AuthRouteState,
508 username: &str,
509 password: &str,
510 from: IpAddr,
511) -> Result<(auth_queries::UserRow, String, String), Box<Response>> {
512 use super::password::Refused;
513 let refused = |status, message: &str| {
514 Box::new(
515 (
516 status,
517 Json(MessageResponse {
518 message: message.into(),
519 }),
520 )
521 .into_response(),
522 )
523 };
524 if state.users.spent(username, from) {
525 return Err(refused(
526 StatusCode::TOO_MANY_REQUESTS,
527 "too many failed sign-ins for this account; try again in a minute",
528 ));
529 }
530 let user = match state.users.verify(username, password) {
531 Ok(user) => {
532 state.users.signed_in(username, from);
533 user
534 }
535 Err(Refused::Wrong) => {
536 state.users.failed(username);
537 return Err(refused(
538 StatusCode::UNAUTHORIZED,
539 "invalid username or password",
540 ));
541 }
542 Err(Refused::Busy) => {
543 return Err(refused(
544 StatusCode::SERVICE_UNAVAILABLE,
545 "busy; try again in a moment",
546 ));
547 }
548 };
549
550 let db = match state.open_db() {
551 Ok(db) => db,
552 Err((status, msg)) => return Err(Box::new((status, msg).into_response())),
553 };
554
555 let access_token = match auth::mint_access_token(
556 &state.private_pem,
557 user.id,
558 &user.username,
559 user.role,
560 state.access_ttl_secs,
561 ) {
562 Ok(t) => t,
563 Err(e) => {
564 log::error!("auth mint token error: {}", e);
565 return Err(Box::new(
566 (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response(),
567 ));
568 }
569 };
570
571 let refresh_token_id = match auth::random_token() {
572 Ok(t) => t,
573 Err(e) => {
574 log::error!("auth refresh token generation error: {}", e);
575 return Err(Box::new(
576 (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response(),
577 ));
578 }
579 };
580 let refresh_expires = koan_core::clock::unix_secs() + state.refresh_ttl_secs as i64;
581 if let Err(e) =
582 auth_queries::store_refresh_token(&db.conn, &refresh_token_id, user.id, refresh_expires)
583 {
584 log::error!("auth store refresh token error: {}", e);
585 return Err(Box::new(
586 (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response(),
587 ));
588 }
589
590 let _ = auth_queries::cleanup_expired_tokens(&db.conn);
592
593 Ok((user, access_token, refresh_token_id))
594}
595
596pub(crate) async fn proxied_access(state: &AuthRouteState, username: &str) -> Option<String> {
601 let state = state.clone();
602 let username = username.to_owned();
603 tokio::task::spawn_blocking(move || {
604 let db = state.open_db().ok()?;
605 let user = auth_queries::get_user_by_username(&db.conn, &username).ok()??;
606 auth::mint_access_token(
607 &state.private_pem,
608 user.id,
609 &user.username,
610 user.role,
611 state.access_ttl_secs,
612 )
613 .map_err(|e| log::error!("auth mint token error: {e}"))
614 .ok()
615 })
616 .await
617 .ok()
618 .flatten()
619}
620
621async fn login(
622 State(state): State<AuthRouteState>,
623 ClientIp(from): ClientIp,
624 Json(req): Json<LoginRequest>,
625) -> Response {
626 let (user, access_token, refresh_token_id) =
627 match authenticate(&state, &req.username, &req.password, from).await {
628 Ok(session) => session,
629 Err(resp) => return *resp,
630 };
631
632 let cookies = state.session_cookies(&access_token, &refresh_token_id);
633
634 let resp = LoginResponse {
635 access_token,
636 refresh_token: refresh_token_id,
639 token_type: "Bearer".into(),
640 expires_in: state.access_ttl_secs,
641 user: UserInfo {
642 id: user.id,
643 username: user.username,
644 role: user.role.as_str().into(),
645 },
646 };
647
648 (StatusCode::OK, cookies, Json(resp)).into_response()
649}
650
651const REPLAY_GRACE_SECS: i64 = 30;
654
655pub(crate) fn rotate(
665 state: &AuthRouteState,
666 supplied: &str,
667 from: Option<IpAddr>,
668) -> Result<(String, String), Box<Response>> {
669 let db = match state.open_db() {
670 Ok(db) => db,
671 Err((status, msg)) => return Err(Box::new((status, msg).into_response())),
672 };
673
674 let token = match auth_queries::consume_refresh_token(&db.conn, supplied) {
677 Ok(Some(t)) => t,
678 Ok(None) => {
679 match auth_queries::revoke_replayed_grant(&db.conn, supplied, REPLAY_GRACE_SECS) {
680 Ok(0) => {}
681 Ok(n) => log::warn!("a spent OAuth refresh token came back: revoked {n} tokens"),
682 Err(e) => log::error!("auth replay check error: {e}"),
683 }
684 return Err(Box::new(
685 (
686 StatusCode::UNAUTHORIZED,
687 Json(MessageResponse {
688 message: "invalid or expired refresh token".into(),
689 }),
690 )
691 .into_response(),
692 ));
693 }
694 Err(e) => {
695 log::error!("auth refresh db error: {}", e);
696 return Err(Box::new(
697 (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response(),
698 ));
699 }
700 };
701
702 let user = match auth_queries::get_user_by_id(&db.conn, token.user_id) {
703 Ok(Some(u)) => u,
704 Ok(None) => {
705 return Err(Box::new(
706 (
707 StatusCode::UNAUTHORIZED,
708 Json(MessageResponse {
709 message: "user not found".into(),
710 }),
711 )
712 .into_response(),
713 ));
714 }
715 Err(e) => {
716 log::error!("auth refresh user lookup error: {}", e);
717 return Err(Box::new(
718 (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response(),
719 ));
720 }
721 };
722
723 let access_token = match auth::mint_scoped_token(
725 &state.private_pem,
726 user.id,
727 &user.username,
728 user.role,
729 state.access_ttl_secs,
730 token.grant.as_ref().map(|_| auth::MCP_SCOPE),
731 ) {
732 Ok(t) => t,
733 Err(e) => {
734 log::error!("auth mint token error: {}", e);
735 return Err(Box::new(
736 (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response(),
737 ));
738 }
739 };
740
741 let new_refresh_id = match auth::random_token() {
742 Ok(t) => t,
743 Err(e) => {
744 log::error!("auth refresh token generation error: {}", e);
745 return Err(Box::new(
746 (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response(),
747 ));
748 }
749 };
750 let refresh_expires = koan_core::clock::unix_secs() + state.refresh_ttl_secs as i64;
751 if let Err(e) = auth_queries::store_grant_token(
752 &db.conn,
753 &new_refresh_id,
754 user.id,
755 refresh_expires,
756 token.grant.as_ref(),
757 ) {
758 log::error!("auth store refresh token error: {}", e);
759 return Err(Box::new(
760 (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response(),
761 ));
762 }
763
764 if let Some(from) = from {
765 state.users.signed_in(&user.username, from);
766 }
767 Ok((access_token, new_refresh_id))
768}
769
770async fn refresh(
771 State(state): State<AuthRouteState>,
772 ClientIp(from): ClientIp,
773 headers: axum::http::HeaderMap,
774 body: Option<Json<RefreshRequest>>,
775) -> Response {
776 let supplied = body.and_then(|Json(req)| req.refresh_token);
777 let Some(supplied) = refresh_token_from(supplied.as_deref(), &headers) else {
778 return (
779 StatusCode::UNAUTHORIZED,
780 Json(MessageResponse {
781 message: "missing refresh token".into(),
782 }),
783 )
784 .into_response();
785 };
786
787 let rotating = state.clone();
788 let rotated = tokio::task::spawn_blocking(move || rotate(&rotating, &supplied, Some(from)))
789 .await
790 .unwrap_or_else(|_| Err(Box::new(StatusCode::INTERNAL_SERVER_ERROR.into_response())));
791 let (access_token, new_refresh_id) = match rotated {
792 Ok(pair) => pair,
793 Err(resp) => return *resp,
794 };
795
796 let cookies = state.session_cookies(&access_token, &new_refresh_id);
797
798 let resp = RefreshResponse {
799 access_token,
800 refresh_token: new_refresh_id,
801 token_type: "Bearer".into(),
802 expires_in: state.access_ttl_secs,
803 };
804
805 (StatusCode::OK, cookies, Json(resp)).into_response()
806}
807
808async fn logout(
809 State(state): State<AuthRouteState>,
810 headers: axum::http::HeaderMap,
811 body: Option<Json<LogoutRequest>>,
812) -> Response {
813 let supplied = body.and_then(|Json(req)| req.refresh_token);
814 let token = refresh_token_from(supplied.as_deref(), &headers);
815 let revoking = state.clone();
816 let revoked = tokio::task::spawn_blocking(move || {
817 let db = revoking.open_db()?;
818 if let Some(token) = token {
819 let _ = auth_queries::revoke_refresh_token(&db.conn, &token);
820 }
821 Ok(())
822 })
823 .await
824 .unwrap_or_else(|_| {
825 Err((
826 StatusCode::INTERNAL_SERVER_ERROR,
827 "internal error".to_string(),
828 ))
829 });
830 if let Err((status, msg)) = revoked {
831 return (status, msg).into_response();
832 }
833
834 let cookies = state.cleared_cookies();
835
836 (
837 StatusCode::OK,
838 cookies,
839 Json(MessageResponse {
840 message: "logged out".into(),
841 }),
842 )
843 .into_response()
844}
845
846#[cfg(test)]
851mod tests {
852 use super::*;
853
854 #[test]
855 fn login_limiter_caps_a_single_ip() {
856 let limiter = RateLimiter::default();
857 let ip: IpAddr = "10.0.0.5".parse().unwrap();
858 for _ in 0..LOGIN_MAX_PER_WINDOW {
859 assert!(limiter.allow(ip));
860 }
861 assert!(!limiter.allow(ip));
862
863 assert!(limiter.allow("10.0.0.6".parse().unwrap()));
865 }
866
867 #[test]
868 fn an_ipv6_client_is_limited_by_its_64() {
869 let limiter = RateLimiter::default();
870 for n in 0..LOGIN_MAX_PER_WINDOW {
871 assert!(limiter.allow(format!("2001:db8:1:2::{n:x}").parse().unwrap()));
872 }
873 assert!(!limiter.allow("2001:db8:1:2:ffff::1".parse().unwrap()));
874 assert!(limiter.allow("2001:db8:1:3::1".parse().unwrap()));
875 }
876
877 #[test]
878 fn a_mapped_ipv4_address_is_the_ipv4_address() {
879 let r = request_from("[::ffff:198.51.100.4]", None);
880 assert_eq!(client_ip(&r), "198.51.100.4".parse::<IpAddr>().unwrap());
881 let r = request_from("[::ffff:10.42.0.7]", Some("::ffff:203.0.113.9"));
883 assert_eq!(client_ip(&r), "203.0.113.9".parse::<IpAddr>().unwrap());
884 }
885
886 fn request_from(peer: &str, forwarded: Option<&str>) -> axum::extract::Request {
887 let mut request = axum::http::Request::new(axum::body::Body::empty());
888 request.extensions_mut().insert(ConnectInfo(
889 format!("{peer}:1234").parse::<SocketAddr>().unwrap(),
890 ));
891 if let Some(f) = forwarded {
892 request
893 .headers_mut()
894 .insert("x-forwarded-for", f.parse().unwrap());
895 }
896 request
897 }
898
899 #[test]
900 fn client_ip_believes_only_an_internal_proxy() {
901 let r = request_from("10.42.0.7", Some("6.6.6.6, 203.0.113.9"));
903 assert_eq!(client_ip(&r), "203.0.113.9".parse::<IpAddr>().unwrap());
904 let r = request_from("198.51.100.4", Some("10.0.0.1"));
906 assert_eq!(client_ip(&r), "198.51.100.4".parse::<IpAddr>().unwrap());
907 let r = request_from("10.42.0.7", None);
909 assert_eq!(client_ip(&r), "10.42.0.7".parse::<IpAddr>().unwrap());
910 let mut r = axum::http::Request::new(axum::body::Body::empty());
912 r.headers_mut()
913 .insert("x-forwarded-for", "203.0.113.9".parse().unwrap());
914 assert_eq!(client_ip(&r), IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
915 }
916
917 #[test]
918 fn a_home_server_believes_only_a_tunnel_on_this_machine() {
919 let from = |peer, forwarded| {
920 let mut r = request_from(peer, forwarded);
921 r.extensions_mut().insert(ForwardedFrom::Loopback);
922 client_ip(&r)
923 };
924 assert_eq!(
926 from("192.168.1.30", Some("203.0.113.9")),
927 "192.168.1.30".parse::<IpAddr>().unwrap()
928 );
929 assert_eq!(
931 from("127.0.0.1", Some("203.0.113.9")),
932 "203.0.113.9".parse::<IpAddr>().unwrap()
933 );
934 assert_eq!(
936 from("127.0.0.1", None),
937 IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
938 );
939 }
940
941 #[tokio::test]
942 async fn stalled_bodies_time_out_without_holding_a_permit() {
943 use tower::ServiceExt as _;
944 async fn read(_: axum::body::Bytes) -> StatusCode {
945 StatusCode::OK
946 }
947 let app = auth_perimeter(
950 axum::Router::new().route("/auth/login", post(read)),
951 std::time::Duration::from_secs(10),
952 std::time::Duration::from_millis(200),
953 )
954 .with_state(());
955 let stalled = || {
956 axum::http::Request::post("/auth/login")
957 .body(axum::body::Body::from_stream(tokio_stream::pending::<
958 Result<axum::body::Bytes, std::io::Error>,
959 >()))
960 .unwrap()
961 };
962 let held: Vec<_> = (0..4)
963 .map(|_| tokio::spawn(app.clone().oneshot(stalled())))
964 .collect();
965 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
966 let ok = axum::http::Request::post("/auth/login")
968 .body(axum::body::Body::from("{}"))
969 .unwrap();
970 assert_eq!(
971 app.clone().oneshot(ok).await.unwrap().status(),
972 StatusCode::OK
973 );
974 for h in held {
975 assert_eq!(
976 h.await.unwrap().unwrap().status(),
977 StatusCode::REQUEST_TIMEOUT
978 );
979 }
980 let big = axum::http::Request::post("/auth/login")
981 .body(axum::body::Body::from(vec![b' '; AUTH_BODY_LIMIT + 1]))
982 .unwrap();
983 assert_eq!(
984 app.clone().oneshot(big).await.unwrap().status(),
985 StatusCode::PAYLOAD_TOO_LARGE
986 );
987 let broken = axum::http::Request::post("/auth/login")
988 .body(axum::body::Body::from_stream(tokio_stream::once(Err::<
989 axum::body::Bytes,
990 _,
991 >(
992 std::io::Error::other("reset"),
993 ))))
994 .unwrap();
995 assert_eq!(
996 app.oneshot(broken).await.unwrap().status(),
997 StatusCode::BAD_REQUEST
998 );
999 }
1000
1001 #[tokio::test]
1002 async fn slow_work_is_shed_beyond_the_permits() {
1003 use tower::ServiceExt as _;
1004 async fn slow(_: axum::body::Bytes) -> StatusCode {
1005 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1006 StatusCode::OK
1007 }
1008 let app = auth_perimeter(
1009 axum::Router::new().route("/auth/login", post(slow)),
1010 std::time::Duration::from_millis(100),
1011 std::time::Duration::from_secs(5),
1012 )
1013 .with_state(());
1014 let req = || {
1015 axum::http::Request::post("/auth/login")
1016 .body(axum::body::Body::from("{}"))
1017 .unwrap()
1018 };
1019 let held: Vec<_> = (0..2)
1020 .map(|_| tokio::spawn(app.clone().oneshot(req())))
1021 .collect();
1022 tokio::time::sleep(std::time::Duration::from_millis(30)).await;
1023 assert_eq!(
1024 app.clone().oneshot(req()).await.unwrap().status(),
1025 StatusCode::SERVICE_UNAVAILABLE
1026 );
1027 for h in held {
1028 assert_eq!(
1029 h.await.unwrap().unwrap().status(),
1030 StatusCode::REQUEST_TIMEOUT
1031 );
1032 }
1033 }
1034
1035 #[test]
1036 fn refresh_token_falls_back_to_the_cookie() {
1037 let mut headers = axum::http::HeaderMap::new();
1038 headers.insert(
1039 COOKIE,
1040 format!("a=1; {REFRESH_COOKIE}=from-cookie; b=2")
1041 .parse()
1042 .unwrap(),
1043 );
1044
1045 assert_eq!(
1046 refresh_token_from(None, &headers).as_deref(),
1047 Some("from-cookie")
1048 );
1049 assert_eq!(
1050 refresh_token_from(Some("from-body"), &headers).as_deref(),
1051 Some("from-body")
1052 );
1053 assert_eq!(
1054 refresh_token_from(None, &axum::http::HeaderMap::new()),
1055 None
1056 );
1057 }
1058
1059 #[test]
1060 fn cookies_are_lax_and_only_secure_when_tls_is_in_play() {
1061 let state = |cookie_secure| AuthRouteState {
1062 pool: Arc::new(Pool::new("/nonexistent".into())),
1063 private_pem: Arc::new(Vec::new()),
1064 public_pem: Arc::new(Vec::new()),
1065 access_ttl_secs: 900,
1066 refresh_ttl_secs: 60,
1067 cookie_secure,
1068 login_limiter: Arc::new(RateLimiter::default()),
1069 users: Arc::new(crate::auth::password::PasswordVerifier::new(Arc::new(
1070 Pool::new("/nonexistent/koan.db".into()),
1071 ))),
1072 };
1073
1074 let plain = state(false).access_cookie("tok");
1075 assert!(plain.contains("SameSite=Lax"));
1076 assert!(plain.contains("HttpOnly"));
1077 assert!(!plain.contains("Secure"));
1078
1079 assert!(state(true).access_cookie("tok").contains("; Secure"));
1080
1081 let resp = (StatusCode::OK, state(false).session_cookies("a", "r")).into_response();
1083 let set: Vec<_> = resp.headers().get_all(SET_COOKIE).iter().collect();
1084 assert_eq!(set.len(), 3);
1085 assert_eq!(
1086 (StatusCode::OK, state(false).cleared_cookies())
1087 .into_response()
1088 .headers()
1089 .get_all(SET_COOKIE)
1090 .iter()
1091 .count(),
1092 3
1093 );
1094
1095 let refresh = state(false).refresh_cookie("tok");
1097 assert!(refresh.contains("Path=/auth;"));
1098 assert!(refresh.contains("HttpOnly"));
1099 }
1100}