1use 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#[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#[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
88pub 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
100async 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 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 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 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 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 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 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 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 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 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}