Skip to main content

koan_server/auth/
routes.rs

1//! Auth HTTP routes: login, refresh, logout.
2
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use axum::Json;
7use axum::extract::State;
8use axum::http::StatusCode;
9use axum::http::header::SET_COOKIE;
10use axum::response::{IntoResponse, Response};
11use axum::routing::post;
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15use koan_core::auth;
16use koan_core::db::connection::Database;
17use koan_core::db::queries::auth as auth_queries;
18
19// ---------------------------------------------------------------------------
20// Shared state
21// ---------------------------------------------------------------------------
22
23#[derive(Clone)]
24pub struct AuthRouteState {
25    pub db_path: PathBuf,
26    pub private_pem: Arc<Vec<u8>>,
27    pub public_pem: Arc<Vec<u8>>,
28    pub access_ttl_secs: u64,
29    pub refresh_ttl_secs: u64,
30}
31
32impl AuthRouteState {
33    fn open_db(&self) -> Result<Database, (StatusCode, String)> {
34        Database::open(&self.db_path)
35            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("db error: {e}")))
36    }
37}
38
39// ---------------------------------------------------------------------------
40// Request/response types
41// ---------------------------------------------------------------------------
42
43#[derive(Deserialize)]
44pub struct LoginRequest {
45    pub username: String,
46    pub password: String,
47}
48
49#[derive(Serialize)]
50pub struct LoginResponse {
51    pub access_token: String,
52    pub refresh_token: String,
53    pub token_type: String,
54    pub expires_in: u64,
55    pub user: UserInfo,
56}
57
58#[derive(Serialize)]
59pub struct UserInfo {
60    pub id: i64,
61    pub username: String,
62    pub role: String,
63}
64
65#[derive(Deserialize)]
66pub struct RefreshRequest {
67    pub refresh_token: String,
68}
69
70#[derive(Serialize)]
71pub struct RefreshResponse {
72    pub access_token: String,
73    pub refresh_token: String,
74    pub token_type: String,
75    pub expires_in: u64,
76}
77
78#[derive(Deserialize)]
79pub struct LogoutRequest {
80    pub refresh_token: String,
81}
82
83#[derive(Serialize)]
84pub struct MessageResponse {
85    pub message: String,
86}
87
88// ---------------------------------------------------------------------------
89// Router
90// ---------------------------------------------------------------------------
91
92pub fn auth_router(state: AuthRouteState) -> axum::Router {
93    axum::Router::new()
94        .route("/auth/login", post(login))
95        .route("/auth/refresh", post(refresh))
96        .route("/auth/logout", post(logout))
97        .with_state(state)
98}
99
100// ---------------------------------------------------------------------------
101// Handlers
102// ---------------------------------------------------------------------------
103
104async fn login(State(state): State<AuthRouteState>, Json(req): Json<LoginRequest>) -> Response {
105    let db = match state.open_db() {
106        Ok(db) => db,
107        Err((status, msg)) => return (status, msg).into_response(),
108    };
109
110    // Look up user.
111    let user = match auth_queries::get_user_by_username(&db.conn, &req.username) {
112        Ok(Some(u)) => u,
113        Ok(None) => {
114            return (
115                StatusCode::UNAUTHORIZED,
116                Json(MessageResponse {
117                    message: "invalid username or password".into(),
118                }),
119            )
120                .into_response();
121        }
122        Err(e) => {
123            log::error!("auth login db error: {}", e);
124            return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
125        }
126    };
127
128    // Verify password.
129    if auth::verify_password(&req.password, &user.password_hash).is_err() {
130        return (
131            StatusCode::UNAUTHORIZED,
132            Json(MessageResponse {
133                message: "invalid username or password".into(),
134            }),
135        )
136            .into_response();
137    }
138
139    // Mint access token.
140    let access_token = match auth::mint_access_token(
141        &state.private_pem,
142        user.id,
143        &user.username,
144        user.role,
145        state.access_ttl_secs,
146    ) {
147        Ok(t) => t,
148        Err(e) => {
149            log::error!("auth mint token error: {}", e);
150            return (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response();
151        }
152    };
153
154    // Create refresh token.
155    let refresh_token_id = Uuid::now_v7().to_string();
156    let refresh_expires = auth::now_unix() as i64 + state.refresh_ttl_secs as i64;
157    if let Err(e) =
158        auth_queries::store_refresh_token(&db.conn, &refresh_token_id, user.id, refresh_expires)
159    {
160        log::error!("auth store refresh token error: {}", e);
161        return (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response();
162    }
163
164    // Housekeeping: clean up expired tokens on login (non-blocking).
165    let _ = auth_queries::cleanup_expired_tokens(&db.conn);
166
167    let cookie = format!(
168        "koan_access={}; HttpOnly; Secure; SameSite=None; Path=/; Max-Age={}",
169        access_token, state.access_ttl_secs
170    );
171
172    let resp = LoginResponse {
173        access_token,
174        refresh_token: refresh_token_id,
175        token_type: "Bearer".into(),
176        expires_in: state.access_ttl_secs,
177        user: UserInfo {
178            id: user.id,
179            username: user.username,
180            role: user.role.as_str().into(),
181        },
182    };
183
184    (StatusCode::OK, [(SET_COOKIE, cookie)], Json(resp)).into_response()
185}
186
187async fn refresh(State(state): State<AuthRouteState>, Json(req): Json<RefreshRequest>) -> Response {
188    let db = match state.open_db() {
189        Ok(db) => db,
190        Err((status, msg)) => return (status, msg).into_response(),
191    };
192
193    // Atomically consume (validate + revoke) the refresh token in a single
194    // statement to prevent TOCTOU races during token rotation.
195    let token = match auth_queries::consume_refresh_token(&db.conn, &req.refresh_token) {
196        Ok(Some(t)) => t,
197        Ok(None) => {
198            return (
199                StatusCode::UNAUTHORIZED,
200                Json(MessageResponse {
201                    message: "invalid or expired refresh token".into(),
202                }),
203            )
204                .into_response();
205        }
206        Err(e) => {
207            log::error!("auth refresh db error: {}", e);
208            return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
209        }
210    };
211
212    // Look up the user.
213    let user = match auth_queries::get_user_by_id(&db.conn, token.user_id) {
214        Ok(Some(u)) => u,
215        Ok(None) => {
216            return (
217                StatusCode::UNAUTHORIZED,
218                Json(MessageResponse {
219                    message: "user not found".into(),
220                }),
221            )
222                .into_response();
223        }
224        Err(e) => {
225            log::error!("auth refresh user lookup error: {}", e);
226            return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
227        }
228    };
229
230    // Mint new access token.
231    let access_token = match auth::mint_access_token(
232        &state.private_pem,
233        user.id,
234        &user.username,
235        user.role,
236        state.access_ttl_secs,
237    ) {
238        Ok(t) => t,
239        Err(e) => {
240            log::error!("auth mint token error: {}", e);
241            return (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response();
242        }
243    };
244
245    // Issue new refresh token.
246    let new_refresh_id = Uuid::now_v7().to_string();
247    let refresh_expires = auth::now_unix() as i64 + state.refresh_ttl_secs as i64;
248    if let Err(e) =
249        auth_queries::store_refresh_token(&db.conn, &new_refresh_id, user.id, refresh_expires)
250    {
251        log::error!("auth store refresh token error: {}", e);
252        return (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response();
253    }
254
255    let cookie = format!(
256        "koan_access={}; HttpOnly; Secure; SameSite=None; Path=/; Max-Age={}",
257        access_token, state.access_ttl_secs
258    );
259
260    let resp = RefreshResponse {
261        access_token,
262        refresh_token: new_refresh_id,
263        token_type: "Bearer".into(),
264        expires_in: state.access_ttl_secs,
265    };
266
267    (StatusCode::OK, [(SET_COOKIE, cookie)], Json(resp)).into_response()
268}
269
270async fn logout(State(state): State<AuthRouteState>, Json(req): Json<LogoutRequest>) -> Response {
271    let db = match state.open_db() {
272        Ok(db) => db,
273        Err((status, msg)) => return (status, msg).into_response(),
274    };
275
276    let _ = auth_queries::revoke_refresh_token(&db.conn, &req.refresh_token);
277
278    let cookie = "koan_access=; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=0";
279
280    (
281        StatusCode::OK,
282        [(SET_COOKIE, cookie.to_owned())],
283        Json(MessageResponse {
284            message: "logged out".into(),
285        }),
286    )
287        .into_response()
288}