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::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
19/// Name of the cookie carrying the refresh token. Scoped to `/auth` so it
20/// reaches refresh and logout but is never attached to an API call, and
21/// `HttpOnly` so script cannot read it.
22pub(crate) const REFRESH_COOKIE: &str = "koan_refresh";
23const REFRESH_COOKIE_PATH: &str = "/auth";
24/// A cookie left at this narrower path is sent ahead of the one at
25/// `REFRESH_COOKIE_PATH` and shadows it, so every response that sets or clears
26/// the refresh cookie clears this one too.
27const STALE_REFRESH_COOKIE_PATH: &str = "/auth/refresh";
28
29/// Fixed-window per-IP cap on requests. By default, the cap on login attempts:
30///
31/// Argon2 is tuned to cost ~19MiB and real CPU per verification, which is
32/// correct for resisting cracking and ruinous when anyone may trigger it at
33/// will: a few hundred concurrent logins exhaust memory and starve every other
34/// request. The window is coarse on purpose — it bounds cost, it is not a quota.
35const LOGIN_WINDOW_SECS: u64 = 60;
36const LOGIN_MAX_PER_WINDOW: u32 = 10;
37/// Above this many tracked IPs, drop stale windows before inserting more.
38const 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    /// Returns false when `ip`'s network (see [`network`]) has spent its
62    /// allowance for the current window.
63    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
80/// The address a request came from.
81///
82/// Behind a reverse proxy the TCP peer is the proxy, for every client, so a
83/// limit keyed on it is one bucket for everyone. When the peer is on a private
84/// or loopback address — a proxy in the cluster or on the host — the last
85/// `X-Forwarded-For` entry is the one that proxy appended, and is the client.
86/// Earlier entries are whatever the client sent, and are ignored. A public
87/// peer is the client, and its header is not believed. Nor is it when the
88/// peer is unknown: a listener served without its connection info would
89/// otherwise let every client pick its own address.
90///
91/// IPv4-mapped IPv6 addresses come back as IPv4, so one client is one address
92/// whichever way a dual-stack listener or a proxy spelled it.
93pub(crate) fn client_ip(request: &axum::extract::Request) -> IpAddr {
94    address(request.extensions(), request.headers())
95}
96
97/// [`client_ip`] as an extractor, for handlers that also read the body.
98pub(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/// Whose `X-Forwarded-For` a server believes, set on each request as an
112/// extension. Without one, `Private`.
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub enum ForwardedFrom {
115    /// Any private address: a server behind an ingress, where only the proxy
116    /// shares its private network.
117    Private,
118    /// Loopback alone: a server on a home network, where every device has a
119    /// private address and only a tunnel client on the same machine forwards.
120    /// A loopback connection that names no client comes from nowhere known,
121    /// so it neither counts as this network nor shares a limit with it.
122    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
157/// The network an address counts against a per-address limit as: itself, or
158/// for IPv6 its /64, which one subscriber is usually given whole. Keyed on the
159/// full address, a client would have 2^64 fresh allowances.
160pub(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// ---------------------------------------------------------------------------
175// Shared state
176// ---------------------------------------------------------------------------
177
178#[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    /// Mark cookies `Secure`. Only when clients reach koan over HTTPS —
186    /// a browser discards a `Secure` cookie delivered over plain `http://`, so
187    /// setting this on a LAN deployment silently breaks cookie auth entirely.
188    pub cookie_secure: bool,
189    pub login_limiter: Arc<RateLimiter>,
190    /// The server's one password verifier; see `super::password`.
191    pub users: Arc<super::password::PasswordVerifier>,
192}
193
194impl AuthRouteState {
195    /// `SameSite=Lax` keeps the cookie off cross-site requests, which is what
196    /// takes the WebSocket and safelisted-content-type CSRF paths off the table.
197    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    /// The cookies that open a session. Appended rather than inserted: a
220    /// header array as a response part keeps only the last value per name.
221    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    /// The one cookie a session vouched for by an authenticating proxy has:
234    /// an access token, and any refresh cookie the browser held cleared. Such
235    /// a session is derived again from the proxy's header on each page load,
236    /// so it never outlives what the proxy says.
237    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    /// Clears the access cookie and both refresh cookies: what signing out sets.
252    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
264/// A hash with the same parameters as a real one, to verify unknown usernames
265/// against.
266pub(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
271/// Read the refresh token from the request body, falling back to the cookie so a
272/// browser client never has to keep one in script-reachable storage.
273pub(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
292/// Reject login attempts once an IP has spent its window.
293///
294/// A middleware rather than an extractor so it runs before the request body is
295/// read and before the database is touched.
296pub(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
315/// `RateLimiter` as a middleware, for routes other than sign-in.
316pub(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// ---------------------------------------------------------------------------
340// Request/response types
341// ---------------------------------------------------------------------------
342
343#[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
390// ---------------------------------------------------------------------------
391// Router
392// ---------------------------------------------------------------------------
393
394pub 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
408/// How long a sign-in, refresh or sign-out may take once its body has arrived.
409const AUTH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
410
411/// How long a client has to send the body of a sign-in, refresh or sign-out.
412const AUTH_BODY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
413
414/// The largest body these routes read. Each is a small JSON object.
415const AUTH_BODY_LIMIT: usize = 16 * 1024;
416
417/// These routes are unauthenticated by definition and the work behind them is
418/// deliberately expensive, so they get their own ceiling rather than sharing
419/// the GraphQL one. It sheds rather than queues, and each request has a
420/// deadline. The body is read whole, under its own deadline, before a permit
421/// is taken: a client that never finishes sending one holds nothing but its
422/// own connection.
423fn 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
451/// Read a request's body into memory within `deadline`, then pass it on.
452async 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
481// ---------------------------------------------------------------------------
482// Handlers
483// ---------------------------------------------------------------------------
484
485/// Check a username and password and open a session: a fresh access token and
486/// a stored refresh token. The error is the response to send. Shared by the
487/// JSON login and the web UI's sign-in form, so both are one implementation.
488///
489/// On the blocking pool as a whole: the queries and argon2 both block, and on a runtime worker they stall every other request the server
490/// is handling. The password goes through the server's one verifier, so the
491/// ceiling on argon2 and the per-username budget on failures hold here as on
492/// every other door.
493pub(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    // Clear out expired tokens; a failure here does not fail the sign-in.
591    let _ = auth_queries::cleanup_expired_tokens(&db.conn);
592
593    Ok((user, access_token, refresh_token_id))
594}
595
596/// An access token for the account an authenticating proxy names, and no
597/// refresh token: see `proxied_cookies`. No password is checked; the caller
598/// has established that the request came through that proxy. `None` for a
599/// name the server has no account for.
600pub(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        // Also in the body: the CLI and other non-browser clients have no cookie
637        // jar and store this in config.local.toml.
638        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
651/// How long after its refresh a spent OAuth refresh token may come back as a
652/// retry rather than a replay.
653const REPLAY_GRACE_SECS: i64 = 30;
654
655/// Spend a refresh token for a new access token and a new refresh token. The
656/// error is the response to send. Shared by the JSON refresh, the web UI's
657/// session resume and OAuth.
658///
659/// A refresh is the account signing in from `from` as surely as a password
660/// is, so it marks that network as the account's (see
661/// `PasswordVerifier::signed_in`): a browser that keeps its session is how an
662/// account's own network stays known. `None` for an OAuth client, whose
663/// address is a service's, not the account's.
664pub(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    // Atomically consume (validate + revoke) the refresh token in a single
675    // statement to prevent TOCTOU races during token rotation.
676    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    // A grant's tokens stay as narrow as the grant.
724    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// ---------------------------------------------------------------------------
847// Tests
848// ---------------------------------------------------------------------------
849
850#[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        // Other callers are unaffected.
864        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        // A mapped private address is a proxy like any other.
882        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        // Behind the cluster's proxy: the entry it appended, not the client's.
902        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        // A public peer is the client, whatever it claims.
905        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        // An internal peer with no header is itself.
908        let r = request_from("10.42.0.7", None);
909        assert_eq!(client_ip(&r), "10.42.0.7".parse::<IpAddr>().unwrap());
910        // No peer known: the header is the client's own claim.
911        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        // Another device on the network is itself, whatever it claims.
925        assert_eq!(
926            from("192.168.1.30", Some("203.0.113.9")),
927            "192.168.1.30".parse::<IpAddr>().unwrap()
928        );
929        // A tunnel client here: the client it names.
930        assert_eq!(
931            from("127.0.0.1", Some("203.0.113.9")),
932            "203.0.113.9".parse::<IpAddr>().unwrap()
933        );
934        // A tunnel client that names nobody is not this network.
935        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        // With its state, as `auth_router` does: that is what builds each
948        // route's layers once, rather than per request.
949        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        // More stalled bodies than permits, and a whole request still gets in.
967        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        // Every cookie reaches the browser, not only the last one set.
1082        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        // The refresh cookie never rides along on an API call.
1096        let refresh = state(false).refresh_cookie("tok");
1097        assert!(refresh.contains("Path=/auth;"));
1098        assert!(refresh.contains("HttpOnly"));
1099    }
1100}