Skip to main content

web/
jwt_auth.rs

1use std::sync::Arc;
2
3use axum::{
4    Extension, Json,
5    body::Body,
6    extract::State,
7    http::{HeaderValue, Request, StatusCode, header},
8    middleware::Next,
9    response::{IntoResponse, Response},
10};
11
12use axum_extra::extract::{
13    CookieJar,
14    cookie::{Cookie, SameSite},
15};
16use serde::{Deserialize, Serialize};
17
18use crate::{AppState, model::User};
19use redis::AsyncCommands;
20use server::command::user::{Account, Refusal};
21
22#[derive(Debug, Serialize)]
23pub struct ErrorResponse {
24    pub status: &'static str,
25    pub message: String,
26}
27
28#[derive(Debug, Serialize, Deserialize, Clone)]
29pub struct JWTAuthMiddleware {
30    pub user: User,
31    pub access_token_uuid: uuid::Uuid,
32    /// This request arrived with a dead access token and the middleware
33    /// refreshed the session itself, consuming the refresh token in the jar.
34    pub refreshed: bool,
35}
36
37type AuthError = (StatusCode, Json<ErrorResponse>);
38
39fn unauthorized(message: impl Into<String>) -> AuthError {
40    (
41        StatusCode::UNAUTHORIZED,
42        Json(ErrorResponse {
43            status: "fail",
44            message: message.into(),
45        }),
46    )
47}
48
49/// A tenant whose database is not at the current schema.
50///
51/// 503 rather than 500: the account is intact and will come back, and a
52/// retryable status is what a caller (and an operator reading logs) needs to
53/// see. Checked here because this middleware already re-reads the user row on
54/// every authenticated request, so it costs one column rather than a query,
55/// and it covers the whole authenticated surface instead of each handler
56/// remembering to.
57fn unavailable(message: impl Into<String>) -> AuthError {
58    (
59        StatusCode::SERVICE_UNAVAILABLE,
60        Json(ErrorResponse {
61            status: "error",
62            message: message.into(),
63        }),
64    )
65}
66
67fn internal(message: impl Into<String>) -> AuthError {
68    (
69        StatusCode::INTERNAL_SERVER_ERROR,
70        Json(ErrorResponse {
71            status: "error",
72            message: message.into(),
73        }),
74    )
75}
76
77fn extract_access_token(cookie_jar: &CookieJar, req: &Request<Body>) -> Option<String> {
78    cookie_jar
79        .get("access_token")
80        .map(|cookie| cookie.value().to_string())
81        .or_else(|| {
82            req.headers()
83                .get(header::AUTHORIZATION)
84                .and_then(|auth_header| auth_header.to_str().ok())
85                .and_then(|auth_value| {
86                    auth_value
87                        .strip_prefix("Bearer ")
88                        .map(std::borrow::ToOwned::to_owned)
89                })
90        })
91}
92
93/// Why an access token did or did not resolve to a session.
94enum Access {
95    Valid(uuid::Uuid, uuid::Uuid),
96    /// No usable token. The caller may still refresh.
97    Absent,
98    /// The session store could not be reached. Distinguished from `Absent`
99    /// because falling through to the refresh path told the user their session
100    /// had expired — and consumed their refresh token — over an outage that
101    /// had nothing to do with them.
102    Unavailable,
103}
104
105async fn resolve_access(data: &Arc<AppState>, access_token: Option<&str>) -> Access {
106    let Some(token_str) = access_token else {
107        return Access::Absent;
108    };
109    let details = match crate::auth_keys::verify(token_str, crate::token::TokenType::Access).await {
110        Ok(Some(details)) => details,
111        Ok(None) => return Access::Absent,
112        Err(_) => return Access::Unavailable,
113    };
114    let Ok(mut redis) = data.redis_client.get_multiplexed_async_connection().await else {
115        return Access::Unavailable;
116    };
117    // `Option<String>`, so a missing session reads as `Ok(None)` rather than
118    // sharing an error arm with the store being down.
119    let Ok(user_id_str) = redis
120        .get::<_, Option<String>>(details.token_uuid.to_string())
121        .await
122    else {
123        return Access::Unavailable;
124    };
125    let Some(user_id) = user_id_str.and_then(|id| uuid::Uuid::parse_str(&id).ok()) else {
126        return Access::Absent;
127    };
128    // Bind the verified JWT subject to the Redis session owner: the token was
129    // signed by `details.user_id`'s key, so a mismatch means the token_uuid was
130    // paired with a different user's session — reject it.
131    if user_id != details.user_id {
132        return Access::Absent;
133    }
134    Access::Valid(user_id, details.token_uuid)
135}
136
137struct RefreshOutcome {
138    account: Account,
139    access_token_uuid: uuid::Uuid,
140    set_cookies: Vec<HeaderValue>,
141}
142
143/// What one refresh-token rotation writes.
144pub(crate) struct Rotation {
145    /// The session being redeemed.
146    pub token_uuid: uuid::Uuid,
147    /// The user the caller's signed token claims to be.
148    pub owner: uuid::Uuid,
149    /// The refresh session replacing it.
150    pub successor: uuid::Uuid,
151    /// The access session issued with it.
152    pub access_uuid: uuid::Uuid,
153    /// The access session the caller held before rotating, if it still has
154    /// one: superseded now, and deleted so a later logout, which only sees the
155    /// newest tokens, does not leave it valid.
156    pub superseded: Option<uuid::Uuid>,
157    pub refresh_ttl: u64,
158    pub access_ttl: u64,
159}
160
161/// Rotates a refresh session in one step, returning whether it was consumed.
162///
163/// The replacements are written before the old session is deleted, and a
164/// failing `redis.call` aborts the script, so either the new sessions are live
165/// or the old one still is. A token whose subject is not the session's owner
166/// consumes nothing.
167pub(crate) async fn rotate_refresh(
168    redis: &mut redis::aio::MultiplexedConnection,
169    rotation: &Rotation,
170) -> Result<bool, AuthError> {
171    let script = redis::Script::new(
172        r"if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
173          redis.call('SETEX', KEYS[2], ARGV[2], ARGV[1])
174          redis.call('SETEX', KEYS[3], ARGV[3], ARGV[1])
175          redis.call('DEL', KEYS[1])
176          if KEYS[4] then redis.call('DEL', KEYS[4]) end
177          return 1",
178    );
179    let mut invocation = script.prepare_invoke();
180    invocation
181        .key(rotation.token_uuid.to_string())
182        .key(rotation.successor.to_string())
183        .key(rotation.access_uuid.to_string());
184    if let Some(superseded) = rotation.superseded {
185        invocation.key(superseded.to_string());
186    }
187    invocation
188        .arg(rotation.owner.to_string())
189        .arg(rotation.refresh_ttl)
190        .arg(rotation.access_ttl)
191        .invoke_async::<i64>(redis)
192        .await
193        .map(|rotated| rotated == 1)
194        .map_err(|e| unavailable_err(&e))
195}
196
197/// Fetches the account a token names, or refuses the request.
198///
199/// Read through `server`, which owns every database connection and releases
200/// this one before the handler runs: held across `next.run` it would tie up one
201/// of the admin pool's five connections for the whole request.
202async fn fetch_account(user_id: uuid::Uuid) -> Result<Account, AuthError> {
203    server::command::user::fetch_account(user_id)
204        .await
205        .map_err(|e| internal(format!("Error fetching user from database: {e}")))?
206        .ok_or_else(|| unauthorized("The user belonging to this token no longer exists"))
207}
208
209/// Refuses an account that may not be served.
210fn refuse(refusal: Option<Refusal>) -> Result<(), AuthError> {
211    match refusal {
212        Some(Refusal::Disabled) => Err(unauthorized("The user is not verified yet")),
213        Some(Refusal::Quarantined) => Err(unavailable(
214            "This account is undergoing maintenance and is temporarily unavailable",
215        )),
216        None => Ok(()),
217    }
218}
219
220/// A mint that failed: quarantined and unreachable tenants are retryable.
221fn mint_failed(e: crate::auth_keys::MintError) -> AuthError {
222    (
223        e.status(),
224        Json(ErrorResponse {
225            status: "error",
226            message: format!("error generating token: {e}"),
227        }),
228    )
229}
230
231/// The refusal for a dependency that is merely unreachable.
232fn unavailable_err(e: &dyn std::fmt::Display) -> AuthError {
233    unavailable(format!("session store error: {e}"))
234}
235
236async fn try_refresh_session(
237    data: &Arc<AppState>,
238    cookie_jar: &CookieJar,
239) -> Result<RefreshOutcome, AuthError> {
240    let refresh_token = cookie_jar
241        .get("refresh_token")
242        .map(|c| c.value().to_string())
243        .ok_or_else(|| unauthorized("You are not logged in, please provide token"))?;
244
245    let refresh_details =
246        match crate::auth_keys::verify(&refresh_token, crate::token::TokenType::Refresh).await {
247            Ok(Some(details)) => details,
248            Ok(None) => return Err(unauthorized("Token is invalid or session has expired")),
249            Err(e) => return Err(unavailable_err(&e)),
250        };
251
252    let mut redis_client = data
253        .redis_client
254        .get_multiplexed_async_connection()
255        .await
256        .map_err(|e| unavailable_err(&e))?;
257
258    // Everything that can fail runs BEFORE the rotation, which consumes the
259    // session: the account read and its refusal, and both mints, which touch
260    // the tenant database. A failure after the rotation would log the user
261    // out; a token minted here and never registered is inert.
262    let user_id = refresh_details.user_id;
263    let account = fetch_account(user_id).await?;
264    refuse(account.refusal)?;
265
266    let access_details = crate::auth_keys::mint(
267        user_id,
268        data.conf.access_token_max_age,
269        crate::token::TokenType::Access,
270        refresh_details.generation,
271    )
272    .await
273    .map_err(mint_failed)?;
274    let new_refresh_details = crate::auth_keys::mint(
275        user_id,
276        data.conf.refresh_token_max_age,
277        crate::token::TokenType::Refresh,
278        refresh_details.generation,
279    )
280    .await
281    .map_err(mint_failed)?;
282
283    let rotated = rotate_refresh(
284        &mut redis_client,
285        &Rotation {
286            token_uuid: refresh_details.token_uuid,
287            owner: user_id,
288            successor: new_refresh_details.token_uuid,
289            access_uuid: access_details.token_uuid,
290            superseded: None,
291            refresh_ttl: (data.conf.refresh_token_max_age * 60) as u64,
292            access_ttl: (data.conf.access_token_max_age * 60) as u64,
293        },
294    )
295    .await?;
296    if !rotated {
297        return Err(unauthorized("Token is invalid or session has expired"));
298    }
299
300    let access_cookie = Cookie::build((
301        "access_token",
302        access_details.token.clone().unwrap_or_default(),
303    ))
304    .path("/")
305    .max_age(time::Duration::minutes(data.conf.access_token_max_age))
306    .same_site(SameSite::Lax)
307    .secure(crate::auth_keys::secure_cookies())
308    .http_only(true)
309    .build();
310
311    let refresh_cookie = Cookie::build((
312        "refresh_token",
313        new_refresh_details.token.unwrap_or_default(),
314    ))
315    .path("/")
316    .max_age(time::Duration::minutes(data.conf.refresh_token_max_age))
317    .same_site(SameSite::Lax)
318    .secure(crate::auth_keys::secure_cookies())
319    .http_only(true)
320    .build();
321
322    let logged_in_cookie = Cookie::build(("logged_in", "true"))
323        .path("/")
324        .max_age(time::Duration::minutes(data.conf.access_token_max_age))
325        .same_site(SameSite::Lax)
326        .http_only(false)
327        .build();
328
329    let mut cookie_headers = axum::http::HeaderMap::new();
330    crate::handler::append_set_cookies(
331        &mut cookie_headers,
332        [access_cookie, refresh_cookie, logged_in_cookie].map(|c| c.to_string()),
333    );
334    let set_cookies = cookie_headers
335        .get_all(header::SET_COOKIE)
336        .iter()
337        .cloned()
338        .collect();
339
340    Ok(RefreshOutcome {
341        account,
342        access_token_uuid: access_details.token_uuid,
343        set_cookies,
344    })
345}
346
347pub async fn auth(
348    cookie_jar: CookieJar,
349    State(data): State<Arc<AppState>>,
350    mut req: Request<Body>,
351    next: Next,
352) -> Result<Response, AuthError> {
353    let access_token = extract_access_token(&cookie_jar, &req);
354
355    let (row, access_token_uuid, refreshed_cookies) =
356        match resolve_access(&data, access_token.as_deref()).await {
357            Access::Valid(uid, atid) => {
358                let account = fetch_account(uid).await?;
359                refuse(account.refusal)?;
360                (account, atid, Vec::new())
361            }
362            Access::Unavailable => {
363                return Err(unavailable("The service is temporarily unavailable"));
364            }
365            // The refresh read the account before rotating, so nothing that can
366            // fail is left between consuming the session and answering.
367            Access::Absent => {
368                let outcome = try_refresh_session(&data, &cookie_jar).await?;
369                (
370                    outcome.account,
371                    outcome.access_token_uuid,
372                    outcome.set_cookies,
373                )
374            }
375        };
376
377    let user = User {
378        id: row.id,
379        name: row.name,
380        email: row.email,
381        role: row.role,
382        photo: row.photo,
383        verified: row.verified,
384        created_at: row.created_at,
385        updated_at: row.updated_at,
386    };
387
388    req.extensions_mut().insert(JWTAuthMiddleware {
389        user,
390        access_token_uuid,
391        refreshed: !refreshed_cookies.is_empty(),
392    });
393
394    let mut response = next.run(req).await;
395    // A handler that ends the session sets its own expiry cookies, and the
396    // browser keeps whichever `Set-Cookie` for a name comes last. Appending a
397    // refresh minted on the way in would hand the client a live session back:
398    // logout does not bump `session_generation`, so unlike the password change
399    // that token would still verify, and the middleware's rotated refresh UUID
400    // is one the logout handler never saw and so cannot delete from Redis.
401    if !ends_session(&response) {
402        for cookie in refreshed_cookies {
403            response.headers_mut().append(header::SET_COOKIE, cookie);
404        }
405    }
406    Ok(response)
407}
408
409/// Whether the handler already expired the access cookie.
410pub fn ends_session<B>(response: &Response<B>) -> bool {
411    response
412        .headers()
413        .get_all(header::SET_COOKIE)
414        .iter()
415        .filter_map(|value| value.to_str().ok())
416        .filter_map(|raw| Cookie::parse(raw.to_owned()).ok())
417        .any(|cookie| cookie.name() == "access_token" && cookie.value().is_empty())
418}
419
420pub async fn admin(
421    Extension(jwtauth): Extension<JWTAuthMiddleware>,
422    req: Request<Body>,
423    next: Next,
424) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
425    if jwtauth.user.role != "admin" {
426        let error_response = ErrorResponse {
427            status: "fail",
428            message: "The user does not have admin permissions".to_string(),
429        };
430        return Err((StatusCode::UNAUTHORIZED, Json(error_response)));
431    }
432
433    Ok(next.run(req).await)
434}
435
436/// The rotation script against a real session store: it is all that stands
437/// between a refresh and a logged-out user, so it is proved rather than argued.
438#[cfg(test)]
439mod rotation_tests {
440    use super::{Rotation, rotate_refresh};
441    use redis::AsyncCommands;
442
443    async fn store() -> redis::aio::MultiplexedConnection {
444        let url =
445            std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
446        redis::Client::open(url)
447            .expect("a valid session-store URL")
448            .get_multiplexed_async_connection()
449            .await
450            .expect("the session store must be running for these tests")
451    }
452
453    async fn get(redis: &mut redis::aio::MultiplexedConnection, key: uuid::Uuid) -> Option<String> {
454        redis.get(key.to_string()).await.expect("get")
455    }
456
457    /// A live session owned by `owner`, and the rotation that would replace it.
458    async fn seeded(redis: &mut redis::aio::MultiplexedConnection, owner: uuid::Uuid) -> Rotation {
459        let rotation = Rotation {
460            token_uuid: uuid::Uuid::new_v4(),
461            owner,
462            successor: uuid::Uuid::new_v4(),
463            access_uuid: uuid::Uuid::new_v4(),
464            superseded: None,
465            refresh_ttl: 60,
466            access_ttl: 60,
467        };
468        let _: () = redis
469            .set_ex(rotation.token_uuid.to_string(), owner.to_string(), 60)
470            .await
471            .expect("seed");
472        rotation
473    }
474
475    #[tokio::test]
476    async fn a_rotation_replaces_the_session_once() {
477        let mut redis = store().await;
478        let owner = uuid::Uuid::new_v4();
479        let rotation = seeded(&mut redis, owner).await;
480
481        assert!(rotate_refresh(&mut redis, &rotation).await.expect("rotate"));
482        assert_eq!(get(&mut redis, rotation.token_uuid).await, None);
483        assert_eq!(
484            get(&mut redis, rotation.successor).await,
485            Some(owner.to_string())
486        );
487        assert_eq!(
488            get(&mut redis, rotation.access_uuid).await,
489            Some(owner.to_string())
490        );
491
492        assert!(
493            !rotate_refresh(&mut redis, &rotation).await.expect("rotate"),
494            "a refresh token is single-use"
495        );
496    }
497
498    /// Refreshing while still holding an access token supersedes it. Logout
499    /// only ever sees the newest pair, so a superseded token left alive would
500    /// outlive the session.
501    #[tokio::test]
502    async fn a_rotation_ends_the_access_token_it_supersedes() {
503        let mut redis = store().await;
504        let owner = uuid::Uuid::new_v4();
505        let previous = uuid::Uuid::new_v4();
506        let _: () = redis
507            .set_ex(previous.to_string(), owner.to_string(), 60)
508            .await
509            .expect("seed previous access");
510        let rotation = Rotation {
511            superseded: Some(previous),
512            ..seeded(&mut redis, owner).await
513        };
514
515        assert!(rotate_refresh(&mut redis, &rotation).await.expect("rotate"));
516        assert_eq!(get(&mut redis, previous).await, None);
517        assert_eq!(
518            get(&mut redis, rotation.access_uuid).await,
519            Some(owner.to_string())
520        );
521    }
522
523    #[tokio::test]
524    async fn a_foreign_owner_consumes_nothing() {
525        let mut redis = store().await;
526        let owner = uuid::Uuid::new_v4();
527        let rotation = seeded(&mut redis, owner).await;
528        let (session, successor) = (rotation.token_uuid, rotation.successor);
529
530        let impostor = Rotation {
531            owner: uuid::Uuid::new_v4(),
532            ..rotation
533        };
534        assert!(!rotate_refresh(&mut redis, &impostor).await.expect("rotate"));
535        assert_eq!(get(&mut redis, session).await, Some(owner.to_string()));
536        assert_eq!(get(&mut redis, successor).await, None);
537    }
538
539    /// A failing `redis.call` aborts the script with earlier writes applied, so
540    /// the delete must come last or a bad TTL consumes the session.
541    #[tokio::test]
542    async fn a_rotation_that_cannot_write_leaves_the_session_intact() {
543        let mut redis = store().await;
544        let owner = uuid::Uuid::new_v4();
545        let rotation = seeded(&mut redis, owner).await;
546        let session = rotation.token_uuid;
547
548        let broken = Rotation {
549            refresh_ttl: 0,
550            ..rotation
551        };
552        assert!(rotate_refresh(&mut redis, &broken).await.is_err());
553        assert_eq!(get(&mut redis, session).await, Some(owner.to_string()));
554    }
555}