use std::path::PathBuf;
use std::sync::Arc;
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use axum::http::header::SET_COOKIE;
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use koan_core::auth;
use koan_core::db::connection::Database;
use koan_core::db::queries::auth as auth_queries;
#[derive(Clone)]
pub struct AuthRouteState {
pub db_path: PathBuf,
pub private_pem: Arc<Vec<u8>>,
pub public_pem: Arc<Vec<u8>>,
pub access_ttl_secs: u64,
pub refresh_ttl_secs: u64,
}
impl AuthRouteState {
fn open_db(&self) -> Result<Database, (StatusCode, String)> {
Database::open(&self.db_path)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("db error: {e}")))
}
}
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Serialize)]
pub struct LoginResponse {
pub access_token: String,
pub refresh_token: String,
pub token_type: String,
pub expires_in: u64,
pub user: UserInfo,
}
#[derive(Serialize)]
pub struct UserInfo {
pub id: i64,
pub username: String,
pub role: String,
}
#[derive(Deserialize)]
pub struct RefreshRequest {
pub refresh_token: String,
}
#[derive(Serialize)]
pub struct RefreshResponse {
pub access_token: String,
pub refresh_token: String,
pub token_type: String,
pub expires_in: u64,
}
#[derive(Deserialize)]
pub struct LogoutRequest {
pub refresh_token: String,
}
#[derive(Serialize)]
pub struct MessageResponse {
pub message: String,
}
pub fn auth_router(state: AuthRouteState) -> axum::Router {
axum::Router::new()
.route("/auth/login", post(login))
.route("/auth/refresh", post(refresh))
.route("/auth/logout", post(logout))
.with_state(state)
}
async fn login(State(state): State<AuthRouteState>, Json(req): Json<LoginRequest>) -> Response {
let db = match state.open_db() {
Ok(db) => db,
Err((status, msg)) => return (status, msg).into_response(),
};
let user = match auth_queries::get_user_by_username(&db.conn, &req.username) {
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::UNAUTHORIZED,
Json(MessageResponse {
message: "invalid username or password".into(),
}),
)
.into_response();
}
Err(e) => {
log::error!("auth login db error: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
}
};
if auth::verify_password(&req.password, &user.password_hash).is_err() {
return (
StatusCode::UNAUTHORIZED,
Json(MessageResponse {
message: "invalid username or password".into(),
}),
)
.into_response();
}
let access_token = match auth::mint_access_token(
&state.private_pem,
user.id,
&user.username,
user.role,
state.access_ttl_secs,
) {
Ok(t) => t,
Err(e) => {
log::error!("auth mint token error: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response();
}
};
let refresh_token_id = Uuid::now_v7().to_string();
let refresh_expires = auth::now_unix() as i64 + state.refresh_ttl_secs as i64;
if let Err(e) =
auth_queries::store_refresh_token(&db.conn, &refresh_token_id, user.id, refresh_expires)
{
log::error!("auth store refresh token error: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response();
}
let _ = auth_queries::cleanup_expired_tokens(&db.conn);
let cookie = format!(
"koan_access={}; HttpOnly; Secure; SameSite=None; Path=/; Max-Age={}",
access_token, state.access_ttl_secs
);
let resp = LoginResponse {
access_token,
refresh_token: refresh_token_id,
token_type: "Bearer".into(),
expires_in: state.access_ttl_secs,
user: UserInfo {
id: user.id,
username: user.username,
role: user.role.as_str().into(),
},
};
(StatusCode::OK, [(SET_COOKIE, cookie)], Json(resp)).into_response()
}
async fn refresh(State(state): State<AuthRouteState>, Json(req): Json<RefreshRequest>) -> Response {
let db = match state.open_db() {
Ok(db) => db,
Err((status, msg)) => return (status, msg).into_response(),
};
let token = match auth_queries::consume_refresh_token(&db.conn, &req.refresh_token) {
Ok(Some(t)) => t,
Ok(None) => {
return (
StatusCode::UNAUTHORIZED,
Json(MessageResponse {
message: "invalid or expired refresh token".into(),
}),
)
.into_response();
}
Err(e) => {
log::error!("auth refresh db error: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
}
};
let user = match auth_queries::get_user_by_id(&db.conn, token.user_id) {
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::UNAUTHORIZED,
Json(MessageResponse {
message: "user not found".into(),
}),
)
.into_response();
}
Err(e) => {
log::error!("auth refresh user lookup error: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
}
};
let access_token = match auth::mint_access_token(
&state.private_pem,
user.id,
&user.username,
user.role,
state.access_ttl_secs,
) {
Ok(t) => t,
Err(e) => {
log::error!("auth mint token error: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response();
}
};
let new_refresh_id = Uuid::now_v7().to_string();
let refresh_expires = auth::now_unix() as i64 + state.refresh_ttl_secs as i64;
if let Err(e) =
auth_queries::store_refresh_token(&db.conn, &new_refresh_id, user.id, refresh_expires)
{
log::error!("auth store refresh token error: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response();
}
let cookie = format!(
"koan_access={}; HttpOnly; Secure; SameSite=None; Path=/; Max-Age={}",
access_token, state.access_ttl_secs
);
let resp = RefreshResponse {
access_token,
refresh_token: new_refresh_id,
token_type: "Bearer".into(),
expires_in: state.access_ttl_secs,
};
(StatusCode::OK, [(SET_COOKIE, cookie)], Json(resp)).into_response()
}
async fn logout(State(state): State<AuthRouteState>, Json(req): Json<LogoutRequest>) -> Response {
let db = match state.open_db() {
Ok(db) => db,
Err((status, msg)) => return (status, msg).into_response(),
};
let _ = auth_queries::revoke_refresh_token(&db.conn, &req.refresh_token);
let cookie = "koan_access=; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=0";
(
StatusCode::OK,
[(SET_COOKIE, cookie.to_owned())],
Json(MessageResponse {
message: "logged out".into(),
}),
)
.into_response()
}