Skip to main content

web/
handler.rs

1use std::sync::Arc;
2
3use argon2::{Argon2, PasswordHasher};
4use axum::{
5    Extension, Json,
6    extract::State,
7    http::{HeaderMap, Response, StatusCode, header},
8    response::IntoResponse,
9};
10use axum_extra::extract::{
11    CookieJar,
12    cookie::{Cookie, SameSite},
13};
14use serde_json::json;
15
16use crate::{
17    AppState,
18    jwt_auth::JWTAuthMiddleware,
19    model::{LoginUserSchema, RegisterUserSchema, User},
20    response::FilteredUser,
21    token::TokenDetails,
22};
23use rust_i18n::t;
24
25use redis::AsyncCommands;
26use server::command::CmdResult;
27
28pub async fn register_user_handler(
29    State(_data): State<Arc<AppState>>,
30    Json(body): Json<RegisterUserSchema>,
31) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
32    let user_exists = server::command::user::email_taken(&body.email)
33        .await
34        .map_err(|e| {
35            let msg = format!("{} {}", t!("Database error:"), e);
36            log::error!("{msg}");
37            let error_response = serde_json::json!({
38                "status": "fail",
39                "message": msg,
40            });
41            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
42        })?;
43
44    if user_exists {
45        let msg = format!("{}", t!("User with that email already exists"));
46        log::error!("{msg}");
47        let error_response = serde_json::json!({
48            "status": "fail",
49            "message": msg,
50        });
51        return Err((StatusCode::CONFLICT, Json(error_response)));
52    }
53
54    let hashed_password = Argon2::default()
55        .hash_password(body.password.as_bytes())
56        .map_err(|e| {
57            let msg = format!("{} {}", t!("Error while hashing password:"), e);
58            log::error!("{msg}");
59            let error_response = serde_json::json!({
60                "status": "fail",
61                "message": msg,
62            });
63            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
64        })
65        .map(|hash| hash.to_string())?;
66    let internal_error = |e: &dyn std::fmt::Display| {
67        let msg = format!("{} {}", t!("Database error:"), e);
68        log::error!("{msg}");
69        let error_response = serde_json::json!({ "status": "fail", "message": msg });
70        (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
71    };
72
73    let user_id = uuid::Uuid::new_v4();
74    // Generate the user's signing keypair first (off the request thread); the
75    // private half goes into their per-user DB, the public half into the global
76    // users row.
77    let keypair = server::auth_keys::generate()
78        .await
79        .map_err(|e| internal_error(&e))?;
80    let provisioned = server::provision::provision_user_database(user_id)
81        .await
82        .map_err(|e| internal_error(&e))?;
83    let database_name = provisioned.database_name;
84
85    // Only what this request created may be cleaned up on failure below: if a
86    // retry reused an existing database (42P04), its contents are somebody's
87    // ledger and dropping it would destroy data we did not make.
88    let drop_orphan = |user_id| async move {
89        if !provisioned.created {
90            return;
91        }
92        if let Err(drop_err) = server::provision::drop_user_database(user_id).await {
93            log::error!(
94                "{} {}",
95                t!("Failed to clean up provisioned database:"),
96                drop_err
97            );
98        }
99    };
100
101    if let Err(e) =
102        server::provision::store_user_private_key(&database_name, &keypair.private_pem_b64).await
103    {
104        // The DB exists but key storage failed; drop the orphan so a retry
105        // starts clean.
106        drop_orphan(user_id).await;
107        return Err(internal_error(&e));
108    }
109
110    // `verified` is set here, not left to the column default. Registration is
111    // admin-gated (see `create_admin_pages_router`), so the admin creating the
112    // account has already vouched for it — and nothing in the codebase ever
113    // sets the flag afterwards: there is no verification endpoint and no email
114    // flow. Left FALSE, the account authenticates and then fails every
115    // authenticated request in `jwt_auth`, which is what happened to the first
116    // real registration on 2026-09-06.
117    //
118    let insert = server::command::user::create_user(server::command::user::NewUser {
119        id: user_id,
120        name: &body.name,
121        email: &body.email,
122        password_hash: &hashed_password,
123        database_name: &database_name,
124        jwt_public_key: &keypair.public_pem_b64,
125    })
126    .await;
127
128    let user = match insert {
129        Ok(account) => User {
130            id: account.id,
131            name: account.name,
132            email: account.email,
133            role: account.role,
134            photo: account.photo,
135            verified: account.verified,
136            created_at: account.created_at,
137            updated_at: account.updated_at,
138        },
139        Err(e) => {
140            // The per-user database was already created; the user row failed
141            // (e.g. a concurrent registration won the UNIQUE(email) race).
142            // Drop the orphan so failed registrations don't leak databases.
143            drop_orphan(user_id).await;
144            let msg = format!("{} {}", t!("Database error:"), e);
145            log::error!("{msg}");
146            let error_response = serde_json::json!({
147                "status": "fail",
148                "message": msg,
149            });
150            return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)));
151        }
152    };
153
154    let user_response = serde_json::json!({"status": "success","data": serde_json::json!({
155        "user": filter_user_record(&user)
156    })});
157
158    Ok(Json(user_response))
159}
160
161struct LoginCookies {
162    access: Cookie<'static>,
163    refresh: Cookie<'static>,
164    logged_in: Cookie<'static>,
165    access_token: String,
166}
167
168async fn authenticate_user(
169    data: &Arc<AppState>,
170    email: &str,
171    password: &str,
172) -> Result<LoginCookies, (StatusCode, Json<serde_json::Value>)> {
173    let verified = server::command::user::verify_user_password(email, password)
174        .await
175        .map_err(|e| {
176            let msg = format!("{} {}", t!("Database error:"), e);
177            log::error!("{msg}");
178            let error_response = serde_json::json!({
179                "status": "error",
180                "message": msg,
181            });
182            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
183        })?
184        .ok_or_else(|| {
185            let msg = format!("{}", t!("Invalid email or password"));
186            log::error!("{msg}");
187            let error_response = serde_json::json!({
188                "status": "fail",
189                "message": msg,
190            });
191            (StatusCode::BAD_REQUEST, Json(error_response))
192        })?;
193
194    let access_token_details = mint_token(
195        verified.user_id,
196        data.conf.access_token_max_age,
197        crate::token::TokenType::Access,
198        verified.generation,
199    )
200    .await?;
201    let refresh_token_details = mint_token(
202        verified.user_id,
203        data.conf.refresh_token_max_age,
204        crate::token::TokenType::Refresh,
205        verified.generation,
206    )
207    .await?;
208
209    save_token_data_to_redis(data, &access_token_details, data.conf.access_token_max_age).await?;
210    save_token_data_to_redis(
211        data,
212        &refresh_token_details,
213        data.conf.refresh_token_max_age,
214    )
215    .await?;
216
217    let access_cookie = Cookie::build((
218        "access_token",
219        access_token_details.token.clone().unwrap_or_default(),
220    ))
221    .path("/")
222    .max_age(time::Duration::minutes(data.conf.access_token_max_age))
223    .same_site(SameSite::Lax)
224    .secure(crate::auth_keys::secure_cookies())
225    .http_only(true)
226    .build();
227
228    let refresh_cookie = Cookie::build((
229        "refresh_token",
230        refresh_token_details.token.unwrap_or_default(),
231    ))
232    .path("/")
233    .max_age(time::Duration::minutes(data.conf.refresh_token_max_age))
234    .same_site(SameSite::Lax)
235    .secure(crate::auth_keys::secure_cookies())
236    .http_only(true)
237    .build();
238
239    let logged_in_cookie = Cookie::build(("logged_in", "true"))
240        .path("/")
241        .max_age(time::Duration::minutes(data.conf.access_token_max_age))
242        .same_site(SameSite::Lax)
243        .http_only(false)
244        .build();
245
246    Ok(LoginCookies {
247        access: access_cookie,
248        refresh: refresh_cookie,
249        logged_in: logged_in_cookie,
250        access_token: access_token_details.token.unwrap_or_default(),
251    })
252}
253
254/// Appends one `Set-Cookie` per rendered cookie. A cookie we built ourselves
255/// always parses, so a value that somehow does not is dropped rather than
256/// panicking the request.
257pub(crate) fn append_set_cookies(
258    headers: &mut HeaderMap,
259    cookies: impl IntoIterator<Item = String>,
260) {
261    for cookie in cookies {
262        if let Ok(value) = cookie.parse() {
263            headers.append(header::SET_COOKIE, value);
264        }
265    }
266}
267
268fn build_cookie_headers(cookies: &LoginCookies) -> HeaderMap {
269    let mut headers = HeaderMap::new();
270    append_set_cookies(
271        &mut headers,
272        [
273            cookies.access.to_string(),
274            cookies.refresh.to_string(),
275            cookies.logged_in.to_string(),
276        ],
277    );
278    headers
279}
280
281pub async fn login_user_handler(
282    State(data): State<Arc<AppState>>,
283    Json(body): Json<LoginUserSchema>,
284) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
285    let cookies = authenticate_user(&data, &body.email, &body.password).await?;
286
287    let mut response = Response::new(
288        json!({"status": "success", "access_token": cookies.access_token}).to_string(),
289    );
290    let mut headers = build_cookie_headers(&cookies);
291    headers.append("HX-Redirect", header::HeaderValue::from_static("/"));
292    response.headers_mut().extend(headers);
293    Ok(response)
294}
295
296pub async fn login_form_handler(
297    State(data): State<Arc<AppState>>,
298    axum::Form(body): axum::Form<LoginUserSchema>,
299) -> impl IntoResponse {
300    match authenticate_user(&data, &body.email, &body.password).await {
301        Ok(cookies) => {
302            let mut headers = build_cookie_headers(&cookies);
303            headers.insert(header::LOCATION, header::HeaderValue::from_static("/"));
304            (StatusCode::SEE_OTHER, headers, "").into_response()
305        }
306        Err((status, json_err)) => {
307            let msg = json_err
308                .get("message")
309                .and_then(|v| v.as_str())
310                .unwrap_or("Login failed");
311            (status, msg.to_string()).into_response()
312        }
313    }
314}
315
316pub async fn refresh_access_token_handler(
317    cookie_jar: CookieJar,
318    Extension(jwt_auth): Extension<crate::jwt_auth::JWTAuthMiddleware>,
319    State(data): State<Arc<AppState>>,
320) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
321    // The middleware ahead of this handler refreshes a dead access token on the
322    // way in, consuming the cookie this request still carries: report that
323    // refresh instead of redeeming a token that is already gone.
324    if jwt_auth.refreshed {
325        return Ok(Response::new(
326            json!({"status": "success", "refreshed_by": "session"}).to_string(),
327        ));
328    }
329
330    let message = t!("could not refresh access token");
331
332    let refresh_token = cookie_jar
333        .get("refresh_token")
334        .map(|cookie| cookie.value().to_string())
335        .ok_or_else(|| {
336            let error_response = serde_json::json!({
337                "status": "fail",
338                "message": message
339            });
340            (StatusCode::FORBIDDEN, Json(error_response))
341        })?;
342
343    let refresh_token_details =
344        match crate::auth_keys::verify(&refresh_token, crate::token::TokenType::Refresh).await {
345            Ok(Some(token_details)) => token_details,
346            // An unreadable directory is not an expired session.
347            Err(e) => {
348                let error_response = serde_json::json!({
349                    "status": "error",
350                    "message": format!("{} {e}", t!("Database error:")),
351                });
352                return Err((StatusCode::SERVICE_UNAVAILABLE, Json(error_response)));
353            }
354            Ok(None) => {
355                let error_response = serde_json::json!({
356                    "status": "fail",
357                    "message": t!("Token is invalid or session has expired")
358                });
359                return Err((StatusCode::UNAUTHORIZED, Json(error_response)));
360            }
361        };
362
363    let mut redis_client = data
364        .redis_client
365        .get_multiplexed_async_connection()
366        .await
367        .map_err(|e| {
368            let msg = format!("{} {}", t!("Redis error:"), e);
369            log::error!("{msg}");
370            let error_response = serde_json::json!({
371                "status": "error",
372                "message": msg,
373            });
374            (StatusCode::SERVICE_UNAVAILABLE, Json(error_response))
375        })?;
376
377    let user_id_uuid = refresh_token_details.user_id;
378
379    // Bind the refresh token to the authenticated principal, as logout does:
380    // the rotation deletes the caller's current access session, so a refresh
381    // token belonging to someone else must not get that far.
382    if user_id_uuid != jwt_auth.user.id {
383        let error_response = serde_json::json!({
384            "status": "fail",
385            "message": t!("Token is invalid or session has expired")
386        });
387        return Err((StatusCode::UNAUTHORIZED, Json(error_response)));
388    }
389
390    // Minted BEFORE the rotation: minting touches the tenant database, and a
391    // failure after the old session is consumed would log the user out. A
392    // token minted and never registered is inert.
393    let access_token_details = mint_token(
394        user_id_uuid,
395        data.conf.access_token_max_age,
396        crate::token::TokenType::Access,
397        refresh_token_details.generation,
398    )
399    .await?;
400    let new_refresh_details = mint_token(
401        user_id_uuid,
402        data.conf.refresh_token_max_age,
403        crate::token::TokenType::Refresh,
404        refresh_token_details.generation,
405    )
406    .await?;
407
408    let rotated = crate::jwt_auth::rotate_refresh(
409        &mut redis_client,
410        &crate::jwt_auth::Rotation {
411            token_uuid: refresh_token_details.token_uuid,
412            owner: user_id_uuid,
413            successor: new_refresh_details.token_uuid,
414            access_uuid: access_token_details.token_uuid,
415            superseded: Some(jwt_auth.access_token_uuid),
416            refresh_ttl: (data.conf.refresh_token_max_age * 60) as u64,
417            access_ttl: (data.conf.access_token_max_age * 60) as u64,
418        },
419    )
420    .await
421    .map_err(|(status, body)| {
422        (
423            status,
424            Json(serde_json::json!({"status": "error", "message": body.0.message})),
425        )
426    })?;
427    if !rotated {
428        let msg = format!("{}", t!("Token is invalid or session has expired"));
429        log::error!("{msg}");
430        return Err((
431            StatusCode::UNAUTHORIZED,
432            Json(serde_json::json!({"status": "error", "message": msg})),
433        ));
434    }
435
436    let access_cookie = Cookie::build((
437        "access_token",
438        access_token_details.token.clone().unwrap_or_default(),
439    ))
440    .path("/")
441    .max_age(time::Duration::minutes(data.conf.access_token_max_age))
442    .same_site(SameSite::Lax)
443    .secure(crate::auth_keys::secure_cookies())
444    .http_only(true);
445
446    let refresh_cookie = Cookie::build((
447        "refresh_token",
448        new_refresh_details.token.unwrap_or_default(),
449    ))
450    .path("/")
451    .max_age(time::Duration::minutes(data.conf.refresh_token_max_age))
452    .same_site(SameSite::Lax)
453    .secure(crate::auth_keys::secure_cookies())
454    .http_only(true);
455
456    let logged_in_cookie = Cookie::build(("logged_in", "true"))
457        .path("/")
458        .max_age(time::Duration::minutes(data.conf.access_token_max_age))
459        .same_site(SameSite::Lax)
460        .http_only(false);
461
462    let mut response = Response::new(
463        json!({
464            "status": "success",
465            "access_token": access_token_details.token.clone().unwrap_or_default(),
466        })
467        .to_string(),
468    );
469    let mut headers = HeaderMap::new();
470    append_set_cookies(
471        &mut headers,
472        [
473            access_cookie.to_string(),
474            refresh_cookie.to_string(),
475            logged_in_cookie.to_string(),
476        ],
477    );
478
479    response.headers_mut().extend(headers);
480    Ok(response)
481}
482
483/// A session-store failure during logout.
484fn redis_failed(e: redis::RedisError) -> (StatusCode, Json<serde_json::Value>) {
485    let error_response = serde_json::json!({
486        "status": "error",
487        "message": format!("{e:?}"),
488    });
489    (StatusCode::SERVICE_UNAVAILABLE, Json(error_response))
490}
491
492pub async fn logout_handler(
493    cookie_jar: CookieJar,
494    Extension(auth_guard): Extension<JWTAuthMiddleware>,
495    State(data): State<Arc<AppState>>,
496) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
497    let message = t!("Token is invalid or session has expired");
498
499    let refresh_token = cookie_jar
500        .get("refresh_token")
501        .map(|cookie| cookie.value().to_string())
502        .ok_or_else(|| {
503            let error_response = serde_json::json!({
504                "status": "fail",
505                "message": message
506            });
507            (StatusCode::FORBIDDEN, Json(error_response))
508        })?;
509
510    let refresh_token_details =
511        match crate::auth_keys::verify(&refresh_token, crate::token::TokenType::Refresh).await {
512            Ok(Some(token_details)) => token_details,
513            // An unreadable directory is not an expired session.
514            Err(e) => {
515                let error_response = serde_json::json!({
516                    "status": "error",
517                    "message": format!("{} {e}", t!("Database error:")),
518                });
519                return Err((StatusCode::SERVICE_UNAVAILABLE, Json(error_response)));
520            }
521            Ok(None) => {
522                let error_response = serde_json::json!({
523                    "status": "fail",
524                    "message": t!("Token is invalid or session has expired")
525                });
526                return Err((StatusCode::UNAUTHORIZED, Json(error_response)));
527            }
528        };
529
530    let mut redis_client = data
531        .redis_client
532        .get_multiplexed_async_connection()
533        .await
534        .map_err(|e| {
535            let msg = format!("{} {}", t!("Redis error:"), e);
536            log::error!("{msg}");
537            let error_response = serde_json::json!({
538                "status": "error",
539                "message": msg,
540            });
541            (StatusCode::SERVICE_UNAVAILABLE, Json(error_response))
542        })?;
543
544    // Bind the refresh token to the authenticated principal: the access token
545    // (auth_guard) and the refresh token must belong to the same user, so a
546    // client can't present user A's access token with user B's refresh token to
547    // invalidate B's session.
548    if refresh_token_details.user_id != auth_guard.user.id {
549        let error_response = serde_json::json!({
550            "status": "fail",
551            "message": t!("Token is invalid or session has expired")
552        });
553        return Err((StatusCode::UNAUTHORIZED, Json(error_response)));
554    }
555
556    let _: () = redis_client
557        .del(&[
558            refresh_token_details.token_uuid.to_string(),
559            auth_guard.access_token_uuid.to_string(),
560        ])
561        .await
562        .map_err(redis_failed)?;
563
564    let headers = session_end_headers("/");
565
566    let mut response = Response::new(json!({"status": "success"}).to_string());
567    response.headers_mut().extend(headers);
568    Ok(response)
569}
570
571pub async fn get_me_handler(
572    Extension(jwtauth): Extension<JWTAuthMiddleware>,
573) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
574    let json_response = serde_json::json!({
575        "status":  "success",
576        "data": serde_json::json!({
577            "user": filter_user_record(&jwtauth.user)
578        })
579    });
580
581    Ok(Json(json_response))
582}
583
584/// Ends the session: expires all three cookies and redirects. Shared by logout
585/// and the password change, which revokes the very token that made its request
586/// and so must not leave the caller on a page whose every fetch now 401s.
587pub fn session_end_headers(target: &'static str) -> HeaderMap {
588    let expire = |name: &'static str, value: &'static str, http_only: bool| {
589        Cookie::build((name, value))
590            .path("/")
591            .max_age(time::Duration::minutes(-1))
592            .same_site(SameSite::Lax)
593            .secure(crate::auth_keys::secure_cookies())
594            .http_only(http_only)
595            .to_string()
596    };
597
598    let mut headers = HeaderMap::new();
599    append_set_cookies(
600        &mut headers,
601        [
602            expire("access_token", "", true),
603            expire("refresh_token", "", true),
604            expire("logged_in", "true", false),
605        ],
606    );
607    headers.append("HX-Redirect", header::HeaderValue::from_static(target));
608    headers
609}
610
611#[derive(serde::Deserialize)]
612pub struct ChangePasswordSchema {
613    pub current_password: String,
614    pub new_password: String,
615    pub confirm_password: String,
616}
617
618/// Replaces the caller's password and kills every session including this one.
619///
620/// The bump invalidates the token that made this request, so the response
621/// clears the cookies and the client is expected to log in again — leaving the
622/// caller on a page whose every request has just started failing is worse than
623/// sending them to `/login`.
624pub async fn change_password_handler(
625    Extension(jwtauth): Extension<JWTAuthMiddleware>,
626    Json(body): Json<ChangePasswordSchema>,
627) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
628    // Confirmed before anything is written: the change revokes every session
629    // and there is no self-service recovery, so a single typo would lock the
630    // account out until an operator resets it by hand.
631    if body.new_password != body.confirm_password {
632        return Err((
633            StatusCode::BAD_REQUEST,
634            Json(serde_json::json!({
635                "status": "fail",
636                "message": t!("The new passwords do not match").to_string(),
637            })),
638        ));
639    }
640
641    server::command::user::change_password(
642        jwtauth.user.id,
643        &body.current_password,
644        &body.new_password,
645    )
646    .await
647    .map_err(|e| {
648        log::warn!(
649            "password change refused for user_id={}: {e}",
650            jwtauth.user.id
651        );
652        match e {
653            server::command::CmdError::Args(_) => (
654                StatusCode::BAD_REQUEST,
655                Json(serde_json::json!({
656                    "status": "fail",
657                    "message": t!("Invalid email or password").to_string(),
658                })),
659            ),
660            // A database outage is not a wrong password; saying so sends the
661            // user off retyping a password that was correct.
662            _ => (
663                StatusCode::INTERNAL_SERVER_ERROR,
664                Json(serde_json::json!({
665                    "status": "error",
666                    "message": t!("Database error:").to_string(),
667                })),
668            ),
669        }
670    })?;
671
672    Ok((
673        session_end_headers("/login"),
674        Json(serde_json::json!({ "status": "success" })),
675    ))
676}
677
678pub async fn get_version() -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
679    if let Some(CmdResult::String(version)) = server::command::config::GetVersion::new()
680        .run()
681        .await
682        .map_err(|_| {
683            (
684                StatusCode::INTERNAL_SERVER_ERROR,
685                Json(json!("Can't get version")),
686            )
687        })?
688        && let Some(CmdResult::String(build_date)) = server::command::config::GetBuildDate::new()
689            .run()
690            .await
691            .map_err(|_| {
692                (
693                    StatusCode::INTERNAL_SERVER_ERROR,
694                    Json(json!("Can't get version")),
695                )
696            })?
697    {
698        Ok(Response::new(format!(
699            "<span class=\"version\">{version}</span><span class=\"build_date\" data-iso=\"{build_date}\"><script>
700    const el = document.currentScript.parentElement;
701    el.textContent = new Date(el.dataset.iso.trim()).toLocaleString();
702  </script></span>"
703        )))
704    } else {
705        Ok(Response::new("Unversioned".to_string()))
706    }
707}
708
709pub async fn get_logout_link(
710    Extension(jwtauth): Extension<JWTAuthMiddleware>,
711) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
712    // `/register` is admin-gated server-side; showing the link to everyone
713    // would only advertise a 401.
714    let admin_link = match jwtauth.user.role.as_str() {
715        "admin" => "<a href=\"/register\">Register user</a> ",
716        _ => "",
717    };
718    Ok(Response::new(format!(
719        "<a href=\"/account/ssh-key\">Account</a> {admin_link}\
720         <a hx-get=\"/api/auth/logout\">Logout</a>"
721    )))
722}
723
724pub async fn get_home_link() -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
725    Ok(Response::new("<a href='/'>Back to main</a>".to_string()))
726}
727
728fn filter_user_record(user: &User) -> FilteredUser {
729    FilteredUser {
730        id: user.id.to_string(),
731        email: user.email.clone(),
732        name: user.name.clone(),
733        photo: user.photo.clone(),
734        role: user.role.clone(),
735        verified: user.verified,
736        createdAt: user.created_at.unwrap_or_default(),
737        updatedAt: user.updated_at.unwrap_or_default(),
738    }
739}
740
741async fn mint_token(
742    user_id: uuid::Uuid,
743    max_age: i64,
744    token_type: crate::token::TokenType,
745    generation: i32,
746) -> Result<TokenDetails, (StatusCode, Json<serde_json::Value>)> {
747    crate::auth_keys::mint(user_id, max_age, token_type, generation)
748        .await
749        .map_err(|e| {
750            let msg = format!("{} {}", t!("error generating token:"), e);
751            log::error!("{msg}");
752            let error_response = serde_json::json!({
753                "status": "error",
754                "message": msg,
755            });
756            (e.status(), Json(error_response))
757        })
758}
759
760async fn save_token_data_to_redis(
761    data: &Arc<AppState>,
762    token_details: &TokenDetails,
763    max_age: i64,
764) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
765    let mut redis_client = data
766        .redis_client
767        .get_multiplexed_async_connection()
768        .await
769        .map_err(|e| {
770            let msg = format!("{} {}", t!("Redis error:"), e);
771            log::error!("{msg}");
772            let error_response = serde_json::json!({
773                "status": "error",
774                "message": msg,
775            });
776            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
777        })?;
778    let _: bool = redis_client
779        .set_ex(
780            token_details.token_uuid.to_string(),
781            token_details.user_id.to_string(),
782            (max_age * 60) as u64,
783        )
784        .await
785        .map_err(|e| {
786            let error_response = serde_json::json!({
787                "status": "error",
788                "message": format_args!("{}", e),
789            });
790            (StatusCode::UNPROCESSABLE_ENTITY, Json(error_response))
791        })?;
792    Ok(())
793}