1
use std::sync::Arc;
2

            
3
use 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

            
12
use axum_extra::extract::{
13
    CookieJar,
14
    cookie::{Cookie, SameSite},
15
};
16
use serde::{Deserialize, Serialize};
17

            
18
use crate::{AppState, model::User};
19
use redis::AsyncCommands;
20
use server::command::user::{Account, Refusal};
21

            
22
#[derive(Debug, Serialize)]
23
pub struct ErrorResponse {
24
    pub status: &'static str,
25
    pub message: String,
26
}
27

            
28
#[derive(Debug, Serialize, Deserialize, Clone)]
29
pub 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

            
37
type AuthError = (StatusCode, Json<ErrorResponse>);
38

            
39
15
fn unauthorized(message: impl Into<String>) -> AuthError {
40
15
    (
41
15
        StatusCode::UNAUTHORIZED,
42
15
        Json(ErrorResponse {
43
15
            status: "fail",
44
15
            message: message.into(),
45
15
        }),
46
15
    )
47
15
}
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.
57
2
fn unavailable(message: impl Into<String>) -> AuthError {
58
2
    (
59
2
        StatusCode::SERVICE_UNAVAILABLE,
60
2
        Json(ErrorResponse {
61
2
            status: "error",
62
2
            message: message.into(),
63
2
        }),
64
2
    )
65
2
}
66

            
67
fn 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

            
77
15
fn extract_access_token(cookie_jar: &CookieJar, req: &Request<Body>) -> Option<String> {
78
15
    cookie_jar
79
15
        .get("access_token")
80
15
        .map(|cookie| cookie.value().to_string())
81
15
        .or_else(|| {
82
15
            req.headers()
83
15
                .get(header::AUTHORIZATION)
84
15
                .and_then(|auth_header| auth_header.to_str().ok())
85
15
                .and_then(|auth_value| {
86
                    auth_value
87
                        .strip_prefix("Bearer ")
88
                        .map(std::borrow::ToOwned::to_owned)
89
                })
90
15
        })
91
15
}
92

            
93
/// Why an access token did or did not resolve to a session.
94
enum 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

            
105
15
async fn resolve_access(data: &Arc<AppState>, access_token: Option<&str>) -> Access {
106
15
    let Some(token_str) = access_token else {
107
15
        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
15
}
136

            
137
struct RefreshOutcome {
138
    account: Account,
139
    access_token_uuid: uuid::Uuid,
140
    set_cookies: Vec<HeaderValue>,
141
}
142

            
143
/// What one refresh-token rotation writes.
144
pub(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.
167
10
pub(crate) async fn rotate_refresh(
168
10
    redis: &mut redis::aio::MultiplexedConnection,
169
10
    rotation: &Rotation,
170
10
) -> Result<bool, AuthError> {
171
10
    let script = redis::Script::new(
172
10
        r"if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
173
10
          redis.call('SETEX', KEYS[2], ARGV[2], ARGV[1])
174
10
          redis.call('SETEX', KEYS[3], ARGV[3], ARGV[1])
175
10
          redis.call('DEL', KEYS[1])
176
10
          if KEYS[4] then redis.call('DEL', KEYS[4]) end
177
10
          return 1",
178
    );
179
10
    let mut invocation = script.prepare_invoke();
180
10
    invocation
181
10
        .key(rotation.token_uuid.to_string())
182
10
        .key(rotation.successor.to_string())
183
10
        .key(rotation.access_uuid.to_string());
184
10
    if let Some(superseded) = rotation.superseded {
185
2
        invocation.key(superseded.to_string());
186
8
    }
187
10
    invocation
188
10
        .arg(rotation.owner.to_string())
189
10
        .arg(rotation.refresh_ttl)
190
10
        .arg(rotation.access_ttl)
191
10
        .invoke_async::<i64>(redis)
192
10
        .await
193
10
        .map(|rotated| rotated == 1)
194
10
        .map_err(|e| unavailable_err(&e))
195
10
}
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.
202
async 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.
210
fn 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.
221
fn 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.
232
2
fn unavailable_err(e: &dyn std::fmt::Display) -> AuthError {
233
2
    unavailable(format!("session store error: {e}"))
234
2
}
235

            
236
15
async fn try_refresh_session(
237
15
    data: &Arc<AppState>,
238
15
    cookie_jar: &CookieJar,
239
15
) -> Result<RefreshOutcome, AuthError> {
240
15
    let refresh_token = cookie_jar
241
15
        .get("refresh_token")
242
15
        .map(|c| c.value().to_string())
243
15
        .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
15
}
346

            
347
15
pub async fn auth(
348
15
    cookie_jar: CookieJar,
349
15
    State(data): State<Arc<AppState>>,
350
15
    mut req: Request<Body>,
351
15
    next: Next,
352
15
) -> Result<Response, AuthError> {
353
15
    let access_token = extract_access_token(&cookie_jar, &req);
354

            
355
    let (row, access_token_uuid, refreshed_cookies) =
356
15
        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
15
                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
15
}
408

            
409
/// Whether the handler already expired the access cookie.
410
2
pub fn ends_session<B>(response: &Response<B>) -> bool {
411
2
    response
412
2
        .headers()
413
2
        .get_all(header::SET_COOKIE)
414
2
        .iter()
415
2
        .filter_map(|value| value.to_str().ok())
416
2
        .filter_map(|raw| Cookie::parse(raw.to_owned()).ok())
417
2
        .any(|cookie| cookie.name() == "access_token" && cookie.value().is_empty())
418
2
}
419

            
420
pub 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)]
439
mod rotation_tests {
440
    use super::{Rotation, rotate_refresh};
441
    use redis::AsyncCommands;
442

            
443
8
    async fn store() -> redis::aio::MultiplexedConnection {
444
8
        let url =
445
8
            std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
446
8
        redis::Client::open(url)
447
8
            .expect("a valid session-store URL")
448
8
            .get_multiplexed_async_connection()
449
8
            .await
450
8
            .expect("the session store must be running for these tests")
451
8
    }
452

            
453
16
    async fn get(redis: &mut redis::aio::MultiplexedConnection, key: uuid::Uuid) -> Option<String> {
454
16
        redis.get(key.to_string()).await.expect("get")
455
16
    }
456

            
457
    /// A live session owned by `owner`, and the rotation that would replace it.
458
8
    async fn seeded(redis: &mut redis::aio::MultiplexedConnection, owner: uuid::Uuid) -> Rotation {
459
8
        let rotation = Rotation {
460
8
            token_uuid: uuid::Uuid::new_v4(),
461
8
            owner,
462
8
            successor: uuid::Uuid::new_v4(),
463
8
            access_uuid: uuid::Uuid::new_v4(),
464
8
            superseded: None,
465
8
            refresh_ttl: 60,
466
8
            access_ttl: 60,
467
8
        };
468
8
        let _: () = redis
469
8
            .set_ex(rotation.token_uuid.to_string(), owner.to_string(), 60)
470
8
            .await
471
8
            .expect("seed");
472
8
        rotation
473
8
    }
474

            
475
    #[tokio::test]
476
2
    async fn a_rotation_replaces_the_session_once() {
477
2
        let mut redis = store().await;
478
2
        let owner = uuid::Uuid::new_v4();
479
2
        let rotation = seeded(&mut redis, owner).await;
480

            
481
2
        assert!(rotate_refresh(&mut redis, &rotation).await.expect("rotate"));
482
2
        assert_eq!(get(&mut redis, rotation.token_uuid).await, None);
483
2
        assert_eq!(
484
2
            get(&mut redis, rotation.successor).await,
485
2
            Some(owner.to_string())
486
        );
487
2
        assert_eq!(
488
2
            get(&mut redis, rotation.access_uuid).await,
489
2
            Some(owner.to_string())
490
        );
491

            
492
2
        assert!(
493
2
            !rotate_refresh(&mut redis, &rotation).await.expect("rotate"),
494
2
            "a refresh token is single-use"
495
2
        );
496
2
    }
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
2
    async fn a_rotation_ends_the_access_token_it_supersedes() {
503
2
        let mut redis = store().await;
504
2
        let owner = uuid::Uuid::new_v4();
505
2
        let previous = uuid::Uuid::new_v4();
506
2
        let _: () = redis
507
2
            .set_ex(previous.to_string(), owner.to_string(), 60)
508
2
            .await
509
2
            .expect("seed previous access");
510
2
        let rotation = Rotation {
511
2
            superseded: Some(previous),
512
2
            ..seeded(&mut redis, owner).await
513
        };
514

            
515
2
        assert!(rotate_refresh(&mut redis, &rotation).await.expect("rotate"));
516
2
        assert_eq!(get(&mut redis, previous).await, None);
517
2
        assert_eq!(
518
2
            get(&mut redis, rotation.access_uuid).await,
519
2
            Some(owner.to_string())
520
2
        );
521
2
    }
522

            
523
    #[tokio::test]
524
2
    async fn a_foreign_owner_consumes_nothing() {
525
2
        let mut redis = store().await;
526
2
        let owner = uuid::Uuid::new_v4();
527
2
        let rotation = seeded(&mut redis, owner).await;
528
2
        let (session, successor) = (rotation.token_uuid, rotation.successor);
529

            
530
2
        let impostor = Rotation {
531
2
            owner: uuid::Uuid::new_v4(),
532
2
            ..rotation
533
2
        };
534
2
        assert!(!rotate_refresh(&mut redis, &impostor).await.expect("rotate"));
535
2
        assert_eq!(get(&mut redis, session).await, Some(owner.to_string()));
536
2
        assert_eq!(get(&mut redis, successor).await, None);
537
2
    }
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
2
    async fn a_rotation_that_cannot_write_leaves_the_session_intact() {
543
2
        let mut redis = store().await;
544
2
        let owner = uuid::Uuid::new_v4();
545
2
        let rotation = seeded(&mut redis, owner).await;
546
2
        let session = rotation.token_uuid;
547

            
548
2
        let broken = Rotation {
549
2
            refresh_ttl: 0,
550
2
            ..rotation
551
2
        };
552
2
        assert!(rotate_refresh(&mut redis, &broken).await.is_err());
553
2
        assert_eq!(get(&mut redis, session).await, Some(owner.to_string()));
554
2
    }
555
}