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 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
49fn 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
93enum Access {
95 Valid(uuid::Uuid, uuid::Uuid),
96 Absent,
98 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 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 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
143pub(crate) struct Rotation {
145 pub token_uuid: uuid::Uuid,
147 pub owner: uuid::Uuid,
149 pub successor: uuid::Uuid,
151 pub access_uuid: uuid::Uuid,
153 pub superseded: Option<uuid::Uuid>,
157 pub refresh_ttl: u64,
158 pub access_ttl: u64,
159}
160
161pub(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
197async 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
209fn 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
220fn 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
231fn 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 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 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 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
409pub 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#[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 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 #[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 #[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}