Skip to main content

koan_server/auth/
middleware.rs

1//! Axum middleware for JWT authentication.
2//!
3//! Extracts the `Authorization: Bearer <token>` header, validates the JWT,
4//! and injects `AuthUser` into request extensions.
5
6use std::sync::Arc;
7
8use axum::extract::Request;
9use axum::http::{StatusCode, header};
10use axum::middleware::Next;
11use axum::response::{IntoResponse, Response};
12
13use koan_core::auth::{self, Role};
14
15use super::AuthUser;
16
17/// Shared state for the auth middleware.
18#[derive(Clone)]
19pub struct AuthState {
20    /// Ed25519 public key PEM for JWT verification.
21    pub public_pem: Arc<Vec<u8>>,
22    /// Whether auth is enforced.
23    pub auth_enabled: bool,
24    /// Process-scoped introspection key. Bypasses auth when matched.
25    /// Generated randomly on server start, dies with the process.
26    pub introspection_key: Option<Arc<String>>,
27}
28
29/// Axum middleware: validate JWT and inject `AuthUser`.
30///
31/// When `auth_enabled = false`, injects anonymous admin and passes through.
32/// When `auth_enabled = true`, requires valid Bearer token.
33pub async fn auth_middleware(
34    axum::extract::State(state): axum::extract::State<AuthState>,
35    mut request: Request,
36    next: Next,
37) -> Response {
38    if !state.auth_enabled {
39        request.extensions_mut().insert(AuthUser::anonymous_admin());
40        return next.run(request).await;
41    }
42
43    // Check for introspection key (playground bypass).
44    if let Some(ref expected_key) = state.introspection_key
45        && let Some(provided) = request
46            .headers()
47            .get("X-Introspection-Key")
48            .and_then(|v| v.to_str().ok())
49        && provided == expected_key.as_str()
50    {
51        request.extensions_mut().insert(AuthUser::anonymous_admin());
52        return next.run(request).await;
53    }
54
55    // Priority: 1. koan_access cookie  2. Authorization: Bearer
56    //           3. ?token= query param
57    let token = request
58        .headers()
59        .get(axum::http::header::COOKIE)
60        .and_then(|v| v.to_str().ok())
61        .and_then(|cookies| {
62            cookies
63                .split(';')
64                .find_map(|c| c.trim().strip_prefix("koan_access=").map(String::from))
65        })
66        .or_else(|| {
67            request
68                .headers()
69                .get(header::AUTHORIZATION)
70                .and_then(|v| v.to_str().ok())
71                .and_then(|v| v.strip_prefix("Bearer "))
72                .map(String::from)
73        })
74        .or_else(|| {
75            // Fall back to ?token= query parameter (for playground URLs).
76            request.uri().query().and_then(|q| {
77                q.split('&')
78                    .find_map(|pair| pair.strip_prefix("token=").map(String::from))
79            })
80        });
81
82    let Some(token) = token else {
83        return (
84            StatusCode::UNAUTHORIZED,
85            [("WWW-Authenticate", "Bearer")],
86            "missing or invalid Authorization header",
87        )
88            .into_response();
89    };
90
91    match auth::validate_access_token(&state.public_pem, &token) {
92        Ok(claims) => {
93            let role = claims.role.parse().unwrap_or(Role::Readonly);
94            let user = AuthUser {
95                user_id: claims.sub,
96                username: claims.username,
97                role,
98            };
99            request.extensions_mut().insert(user);
100            next.run(request).await
101        }
102        Err(_) => (
103            StatusCode::UNAUTHORIZED,
104            [("WWW-Authenticate", "Bearer")],
105            "invalid or expired token",
106        )
107            .into_response(),
108    }
109}