1use crate::{
2 domain::{authentication, error::as_controller_error},
3 prelude::*,
4};
5use actix_web::{FromRequest, http::header};
6use chrono::{DateTime, Utc};
7use futures_util::{FutureExt, future::LocalBoxFuture};
8use headless_lms_utils::cache::Cache;
9use models::{
10 library::oauth::{
11 Digest, EXERCISE_SERVICES_SCOPE,
12 bearer::{BearerTokenError, bearer_token, resolve_bearer_token},
13 token_digest_sha256,
14 },
15 users::User,
16};
17use secrecy::{ExposeSecret, SecretString};
18use sqlx::PgConnection;
19use std::ops::{Deref, DerefMut};
20use std::time::Duration;
21
22#[derive(Debug, Clone)]
37pub struct UserFromOAuthToken(User);
38
39impl Deref for UserFromOAuthToken {
40 type Target = User;
41 fn deref(&self) -> &Self::Target {
42 &self.0
43 }
44}
45
46impl DerefMut for UserFromOAuthToken {
47 fn deref_mut(&mut self) -> &mut Self::Target {
48 &mut self.0
49 }
50}
51
52fn unauthorized(message: &str) -> ControllerError {
54 controller_err!(Unauthorized, message.to_string())
55}
56
57fn forbidden(message: &str) -> ControllerError {
59 controller_err!(Forbidden, message.to_string())
60}
61
62fn bearer_error(err: BearerTokenError) -> ControllerError {
68 match err {
69 BearerTokenError::Invalid(message) => unauthorized(message),
70 BearerTokenError::Database(err) => {
71 let source: anyhow::Error = err.into();
72 controller_err!(
73 InternalServerError,
74 "A database error occurred while validating the access token.".to_string(),
75 source
76 )
77 }
78 }
79}
80
81async fn resolve_oauth_user(
87 conn: &mut PgConnection,
88 token: &SecretString,
89 token_hmac_key: &SecretString,
90) -> Result<(User, DateTime<Utc>), ControllerError> {
91 let token = resolve_bearer_token(conn, token.expose_secret(), token_hmac_key)
92 .await
93 .map_err(bearer_error)?;
94 if !token
95 .scopes
96 .iter()
97 .any(|scope| scope == EXERCISE_SERVICES_SCOPE)
98 {
99 return Err(forbidden(
100 "The access token does not grant the required exercise-services scope.",
101 ));
102 }
103 Ok((token.user, token.expires_at))
104}
105
106impl FromRequest for UserFromOAuthToken {
107 type Error = ControllerError;
108 type Future = LocalBoxFuture<'static, Result<Self, ControllerError>>;
109
110 fn from_request(req: &HttpRequest, _payload: &mut actix_http::Payload) -> Self::Future {
111 let app_data = (|| -> Result<_, ControllerError> {
112 let pool = req
113 .app_data::<web::Data<PgPool>>()
114 .ok_or_else(|| {
115 controller_err!(InternalServerError, "Missing database pool".to_string())
116 })?
117 .clone();
118 let app_conf = req
119 .app_data::<web::Data<ApplicationConfiguration>>()
120 .ok_or_else(|| {
121 controller_err!(
122 InternalServerError,
123 "Missing application configuration".to_string()
124 )
125 })?
126 .clone();
127 let cache = req
128 .app_data::<web::Data<Cache>>()
129 .ok_or_else(|| controller_err!(InternalServerError, "Missing cache".to_string()))?
130 .clone();
131 Ok((pool, app_conf, cache))
132 })();
133
134 let auth_header = req
135 .headers()
136 .get(header::AUTHORIZATION)
137 .and_then(|value| value.to_str().ok())
138 .and_then(bearer_token)
139 .map(|token| SecretString::new(token.into()));
140
141 async move {
142 let (pool, app_conf, cache) = app_data?;
143 let Some(token) = auth_header else {
144 return Err(unauthorized("Missing bearer token"));
145 };
146 let mut conn = pool.acquire().await?;
147
148 if app_conf.test_mode {
152 warn!("Test mode is on: fixed test tokens map directly to seeded users.");
153 if let Some(user) =
154 authentication::authenticate_test_token(&mut conn, &token, &app_conf)
155 .await
156 .map_err(as_controller_error(
157 ControllerErrorType::Unauthorized,
158 "Could not find user for test token".to_string(),
159 ))?
160 {
161 return Ok(Self(user));
162 }
163 }
164
165 let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
166 let digest = token_digest_sha256(token.expose_secret(), token_hmac_key);
167
168 let user = match load_user(&cache, &digest, token_hmac_key).await {
174 Some(user) => user,
175 None => {
176 let (user, expires_at) =
177 resolve_oauth_user(&mut conn, &token, token_hmac_key).await?;
178 let ttl = cache_ttl_for_token(expires_at, Utc::now());
179 cache_user(&cache, &digest, token_hmac_key, &user, ttl).await;
180 user
181 }
182 };
183
184 Ok(Self(user))
185 }
186 .boxed_local()
187 }
188}
189
190const CACHE_KEY_CONTEXT: &str = "headless-lms exercise-services token cache v1";
192
193fn digest_to_cache_key(digest: &Digest, token_hmac_key: &SecretString) -> String {
198 let subkey = blake3::derive_key(CACHE_KEY_CONTEXT, token_hmac_key.expose_secret().as_bytes());
199 format!(
200 "user:{}",
201 blake3::keyed_hash(&subkey, digest.as_slice()).to_hex()
202 )
203}
204
205const MAX_CACHE_TTL: Duration = Duration::from_secs(15 * 60);
218
219fn cache_ttl_for_token(expires_at: DateTime<Utc>, now: DateTime<Utc>) -> Duration {
222 let until_expiry = (expires_at - now).to_std().unwrap_or(Duration::ZERO);
223 until_expiry.min(MAX_CACHE_TTL)
224}
225
226pub async fn cache_user(
227 cache: &Cache,
228 digest: &Digest,
229 token_hmac_key: &SecretString,
230 user: &User,
231 ttl: Duration,
232) {
233 cache
234 .cache_json(digest_to_cache_key(digest, token_hmac_key), user, ttl)
235 .await;
236}
237
238pub async fn load_user(
239 cache: &Cache,
240 digest: &Digest,
241 token_hmac_key: &SecretString,
242) -> Option<User> {
243 cache
244 .get_json(digest_to_cache_key(digest, token_hmac_key))
245 .await
246}
247
248pub(crate) async fn invalidate_cached_user(
254 cache: &Cache,
255 digest: &Digest,
256 token_hmac_key: &SecretString,
257) {
258 cache
259 .invalidate(digest_to_cache_key(digest, token_hmac_key))
260 .await;
261}
262
263pub(crate) async fn invalidate_cached_users(
267 cache: &Cache,
268 digests: &[Digest],
269 token_hmac_key: &SecretString,
270) {
271 for digest in digests {
272 invalidate_cached_user(cache, digest, token_hmac_key).await;
273 }
274}
275
276pub async fn delete_user_and_invalidate_cached_tokens(
283 conn: &mut PgConnection,
284 cache: &Cache,
285 token_hmac_key: &SecretString,
286 user_id: uuid::Uuid,
287) -> Result<(), models::ModelError> {
288 let revoked = models::users::delete_user(conn, user_id).await?;
289 invalidate_cached_users(cache, &revoked, token_hmac_key).await;
290 Ok(())
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use crate::test_helper::*;
297 use actix_web::ResponseError;
298 use actix_web::http::StatusCode;
299 use chrono::{Duration as ChronoDuration, Utc};
300 use headless_lms_models::library::oauth::pkce::PkceMethod;
301 use headless_lms_models::library::oauth::{GrantTypeName, generate_access_token};
302 use headless_lms_models::oauth_access_token::{
303 NewAccessTokenParams, OAuthAccessToken, TokenType,
304 };
305 use headless_lms_models::oauth_client::{
306 ApplicationType, NewClientParams, OAuthClient, TokenEndpointAuthMethod,
307 };
308 use headless_lms_models::oauth_user_client_scopes::OAuthUserClientScopes;
309
310 fn hmac_key() -> SecretString {
311 SecretString::new("test-exercise-services-hmac-key".to_string().into())
312 }
313
314 async fn insert_client(
315 conn: &mut PgConnection,
316 scopes: &[String],
317 bearer_allowed: bool,
318 ) -> OAuthClient {
319 let client_id = format!("cli-{}", &generate_access_token()[..12]);
320 OAuthClient::insert(
321 conn,
322 NewClientParams {
323 client_id: &client_id,
324 client_name: "Exercise services token test client",
325 application_type: ApplicationType::Native,
326 token_endpoint_auth_method: TokenEndpointAuthMethod::None,
327 client_secret: None,
328 client_secret_expires_at: None,
329 redirect_uris: &["urn:ietf:wg:oauth:2.0:oob".to_string()],
330 post_logout_redirect_uris: None,
331 allowed_grant_types: &[GrantTypeName::DeviceCode, GrantTypeName::RefreshToken],
332 scopes,
333 require_pkce: true,
334 pkce_methods_allowed: &[PkceMethod::S256],
335 allowed_origins: None,
336 bearer_allowed,
337 },
338 )
339 .await
340 .unwrap()
341 }
342
343 async fn insert_token(
346 conn: &mut PgConnection,
347 client: &OAuthClient,
348 user_id: uuid::Uuid,
349 scopes: &[String],
350 token_type: TokenType,
351 expires_at: chrono::DateTime<Utc>,
352 ) -> String {
353 let plaintext = generate_access_token();
354 let digest = token_digest_sha256(&plaintext, &hmac_key());
355 let dpop_jkt = match token_type {
356 TokenType::Bearer => None,
357 TokenType::DPoP => Some("0123456789abcdefghijklmnopqrstuvwxyzABCDEFG"),
359 };
360 OAuthAccessToken::insert(
361 conn,
362 NewAccessTokenParams {
363 digest: &digest,
364 user_id: Some(user_id),
365 client_id: client.id,
366 scopes,
367 audience: None,
368 token_type,
369 dpop_jkt,
370 metadata: serde_json::Map::new(),
371 expires_at,
372 },
373 )
374 .await
375 .unwrap();
376 OAuthUserClientScopes::insert(conn, user_id, client.id, scopes)
377 .await
378 .unwrap();
379 plaintext
380 }
381
382 fn secret(s: &str) -> SecretString {
383 SecretString::new(s.to_string().into())
384 }
385
386 #[actix_web::test]
387 async fn happy_path_returns_the_token_owner() {
388 insert_data!(:tx, :user);
389 let client = insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], true).await;
390 let token = insert_token(
391 tx.as_mut(),
392 &client,
393 user,
394 &[EXERCISE_SERVICES_SCOPE.to_string()],
395 TokenType::Bearer,
396 Utc::now() + ChronoDuration::hours(1),
397 )
398 .await;
399
400 let (resolved, _expires_at) = resolve_oauth_user(tx.as_mut(), &secret(&token), &hmac_key())
401 .await
402 .expect("valid token should resolve");
403 assert_eq!(resolved.id, user);
404 }
405
406 #[test]
407 fn cache_ttl_is_clamped_for_long_lived_tokens() {
408 let now = Utc::now();
409 let ttl = cache_ttl_for_token(now + ChronoDuration::hours(24), now);
411 assert_eq!(ttl, MAX_CACHE_TTL);
412 }
413
414 #[test]
416 fn the_cache_ttl_cap_stays_in_minutes() {
417 assert!(MAX_CACHE_TTL <= Duration::from_secs(15 * 60));
418 }
419
420 #[test]
421 fn cache_ttl_tracks_tokens_shorter_lived_than_the_cap() {
422 let now = Utc::now();
423 let ttl = cache_ttl_for_token(now + ChronoDuration::seconds(30), now);
425 assert!(ttl <= Duration::from_secs(30));
426 assert!(ttl > Duration::from_secs(25));
427 }
428
429 #[test]
430 fn cache_ttl_is_zero_for_already_expired_tokens() {
431 let now = Utc::now();
432 let ttl = cache_ttl_for_token(now - ChronoDuration::minutes(1), now);
434 assert_eq!(ttl, Duration::ZERO);
435 }
436
437 #[actix_web::test]
438 async fn unknown_token_is_unauthorized() {
439 insert_data!(:tx);
440 let err = resolve_oauth_user(tx.as_mut(), &secret("not-a-real-token"), &hmac_key())
441 .await
442 .expect_err("unknown token should be rejected");
443 assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
444 assert_unauthorized_body(err);
445 }
446
447 #[actix_web::test]
448 async fn expired_token_is_unauthorized() {
449 insert_data!(:tx, :user);
450 let client = insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], true).await;
451 let token = insert_token(
452 tx.as_mut(),
453 &client,
454 user,
455 &[EXERCISE_SERVICES_SCOPE.to_string()],
456 TokenType::Bearer,
457 Utc::now() - ChronoDuration::minutes(1),
458 )
459 .await;
460
461 let err = resolve_oauth_user(tx.as_mut(), &secret(&token), &hmac_key())
462 .await
463 .expect_err("expired token should be rejected");
464 assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
465 }
466
467 #[actix_web::test]
468 async fn missing_scope_is_forbidden() {
469 insert_data!(:tx, :user);
470 let client = insert_client(tx.as_mut(), &["openid".to_string()], true).await;
472 let token = insert_token(
473 tx.as_mut(),
474 &client,
475 user,
476 &["openid".to_string()],
477 TokenType::Bearer,
478 Utc::now() + ChronoDuration::hours(1),
479 )
480 .await;
481
482 let err = resolve_oauth_user(tx.as_mut(), &secret(&token), &hmac_key())
483 .await
484 .expect_err("token without the scope should be forbidden");
485 assert_eq!(err.status_code(), StatusCode::FORBIDDEN);
486 assert_forbidden_body(err);
487 }
488
489 #[actix_web::test]
490 async fn client_without_bearer_allowed_is_unauthorized() {
491 insert_data!(:tx, :user);
492 let client =
493 insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], false).await;
494 let token = insert_token(
495 tx.as_mut(),
496 &client,
497 user,
498 &[EXERCISE_SERVICES_SCOPE.to_string()],
499 TokenType::Bearer,
500 Utc::now() + ChronoDuration::hours(1),
501 )
502 .await;
503
504 let err = resolve_oauth_user(tx.as_mut(), &secret(&token), &hmac_key())
505 .await
506 .expect_err("bearer_allowed=false client should be rejected");
507 assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
508 }
509
510 #[actix_web::test]
511 async fn dpop_bound_token_is_rejected() {
512 insert_data!(:tx, :user);
513 let client = insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], true).await;
514 let token = insert_token(
515 tx.as_mut(),
516 &client,
517 user,
518 &[EXERCISE_SERVICES_SCOPE.to_string()],
519 TokenType::DPoP,
520 Utc::now() + ChronoDuration::hours(1),
521 )
522 .await;
523
524 let err = resolve_oauth_user(tx.as_mut(), &secret(&token), &hmac_key())
525 .await
526 .expect_err("DPoP-bound token should be rejected on this Bearer-only API");
527 assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
528 }
529
530 #[actix_web::test]
531 async fn soft_deleted_user_is_unauthorized() {
532 insert_data!(:tx, :user);
533 let client = insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], true).await;
534 let token = insert_token(
535 tx.as_mut(),
536 &client,
537 user,
538 &[EXERCISE_SERVICES_SCOPE.to_string()],
539 TokenType::Bearer,
540 Utc::now() + ChronoDuration::hours(1),
541 )
542 .await;
543
544 headless_lms_models::users::delete_user(tx.as_mut(), user)
547 .await
548 .unwrap();
549
550 let err = resolve_oauth_user(tx.as_mut(), &secret(&token), &hmac_key())
551 .await
552 .expect_err("a soft-deleted user's token must be rejected");
553 assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
554 assert_unauthorized_body(err);
555 }
556
557 #[actix_web::test]
561 async fn deleting_a_user_revokes_their_tokens_and_reports_the_digests() {
562 insert_data!(:tx, :user);
563 let client = insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], true).await;
564 let token = insert_token(
565 tx.as_mut(),
566 &client,
567 user,
568 &[EXERCISE_SERVICES_SCOPE.to_string()],
569 TokenType::Bearer,
570 Utc::now() + ChronoDuration::hours(1),
571 )
572 .await;
573 let digest = token_digest_sha256(&token, &hmac_key());
574
575 let revoked = models::users::delete_user(tx.as_mut(), user)
576 .await
577 .expect("delete user");
578 assert_eq!(
579 revoked.len(),
580 1,
581 "the deleted access token's digest must be reported for cache eviction"
582 );
583
584 assert!(
585 OAuthAccessToken::find_valid(tx.as_mut(), digest)
586 .await
587 .is_err(),
588 "the access token row must be gone, not just orphaned"
589 );
590 let err = resolve_oauth_user(tx.as_mut(), &secret(&token), &hmac_key())
591 .await
592 .expect_err("a deleted user's token must be rejected");
593 assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
594 }
595
596 #[actix_web::test]
597 async fn bearer_error_maps_invalid_to_401_and_db_errors_to_500() {
598 use headless_lms_models::{ModelError, ModelErrorType};
599
600 let invalid = bearer_error(BearerTokenError::Invalid("missing"));
602 assert_eq!(invalid.status_code(), StatusCode::UNAUTHORIZED);
603 assert_unauthorized_body(invalid);
604
605 let db_error = bearer_error(BearerTokenError::Database(ModelError::new(
609 ModelErrorType::Database,
610 "connection reset".to_string(),
611 None::<anyhow::Error>,
612 )));
613 assert_eq!(
614 db_error.status_code(),
615 StatusCode::INTERNAL_SERVER_ERROR,
616 "transient DB errors must not collapse into 401"
617 );
618 }
619
620 fn assert_unauthorized_body(err: ControllerError) {
622 let response = err.error_response();
623 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
624 let bytes = actix_web::body::to_bytes(response.into_body())
625 .now_or_never()
626 .expect("body resolves immediately")
627 .expect("body bytes");
628 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
629 assert_eq!(value["type"], "unauthorized");
630 assert_eq!(value["message_key"], "unauthorized");
631 }
632
633 fn assert_forbidden_body(err: ControllerError) {
635 let response = err.error_response();
636 assert_eq!(response.status(), StatusCode::FORBIDDEN);
637 let bytes = actix_web::body::to_bytes(response.into_body())
638 .now_or_never()
639 .expect("body resolves immediately")
640 .expect("body bytes");
641 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
642 assert_eq!(value["type"], "forbidden");
643 assert_eq!(value["message_key"], "forbidden");
644 }
645
646 #[actix_web::test]
654 async fn revoking_an_access_token_evicts_its_cached_user() {
655 let Some(cache) = connected_test_cache().await else {
656 return;
657 };
658
659 insert_data!(:tx, :user);
660 let client = insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], true).await;
661 let plaintext = insert_token(
662 tx.as_mut(),
663 &client,
664 user,
665 &[EXERCISE_SERVICES_SCOPE.to_string()],
666 TokenType::Bearer,
667 Utc::now() + ChronoDuration::hours(1),
668 )
669 .await;
670 let token = secret(&plaintext);
671
672 let (resolved, expires_at) = resolve_oauth_user(tx.as_mut(), &token, &hmac_key())
674 .await
675 .expect("token should resolve before revocation");
676 let ttl = cache_ttl_for_token(expires_at, Utc::now());
677 let digest = token_digest_sha256(&plaintext, &hmac_key());
678 cache_user(&cache, &digest, &hmac_key(), &resolved, ttl).await;
679 assert!(
680 load_user(&cache, &digest, &hmac_key()).await.is_some(),
681 "user should be cached after the first successful resolution"
682 );
683
684 OAuthAccessToken::revoke_by_digest(
687 tx.as_mut(),
688 token_digest_sha256(&plaintext, &hmac_key()),
689 )
690 .await
691 .expect("revoke_by_digest should succeed");
692 invalidate_cached_user(&cache, &digest, &hmac_key()).await;
693
694 assert!(
697 load_user(&cache, &digest, &hmac_key()).await.is_none(),
698 "a revoked token's cache entry must not survive revocation"
699 );
700
701 let err = resolve_oauth_user(tx.as_mut(), &token, &hmac_key())
704 .await
705 .expect_err("a revoked token must not resolve from the DB either");
706 assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
707 }
708
709 #[actix_web::test]
715 async fn bulk_revoking_a_clients_tokens_evicts_their_cached_users() {
716 let Some(cache) = connected_test_cache().await else {
717 return;
718 };
719
720 insert_data!(:tx, :user);
721 let client = insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], true).await;
722 let plaintext = insert_token(
723 tx.as_mut(),
724 &client,
725 user,
726 &[EXERCISE_SERVICES_SCOPE.to_string()],
727 TokenType::Bearer,
728 Utc::now() + ChronoDuration::hours(1),
729 )
730 .await;
731
732 let (resolved, expires_at) =
733 resolve_oauth_user(tx.as_mut(), &secret(&plaintext), &hmac_key())
734 .await
735 .expect("token should resolve before revocation");
736 let digest = token_digest_sha256(&plaintext, &hmac_key());
737 cache_user(
738 &cache,
739 &digest,
740 &hmac_key(),
741 &resolved,
742 cache_ttl_for_token(expires_at, Utc::now()),
743 )
744 .await;
745 assert!(load_user(&cache, &digest, &hmac_key()).await.is_some());
746
747 let revoked =
749 models::oauth_user_client_scopes::OAuthUserClientScopes::revoke_user_client_everything(
750 tx.as_mut(),
751 user,
752 client.id,
753 )
754 .await
755 .expect("bulk revoke should succeed");
756 assert_eq!(
757 revoked.len(),
758 1,
759 "bulk revoke must return the deleted digest"
760 );
761 for revoked_digest in &revoked {
762 invalidate_cached_user(&cache, revoked_digest, &hmac_key()).await;
763 }
764
765 assert!(
766 load_user(&cache, &digest, &hmac_key()).await.is_none(),
767 "a bulk-revoked token's cache entry must not survive revocation"
768 );
769 }
770
771 #[actix_web::test]
777 async fn deleting_a_user_evicts_their_cached_tokens() {
778 let Some(cache) = connected_test_cache().await else {
779 return;
780 };
781
782 insert_data!(:tx, :user);
783 let client = insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], true).await;
784 let mut digests = Vec::new();
786 for _ in 0..2 {
787 let plaintext = insert_token(
788 tx.as_mut(),
789 &client,
790 user,
791 &[EXERCISE_SERVICES_SCOPE.to_string()],
792 TokenType::Bearer,
793 Utc::now() + ChronoDuration::hours(1),
794 )
795 .await;
796 let (resolved, expires_at) =
797 resolve_oauth_user(tx.as_mut(), &secret(&plaintext), &hmac_key())
798 .await
799 .expect("token should resolve before deletion");
800 let digest = token_digest_sha256(&plaintext, &hmac_key());
801 cache_user(
802 &cache,
803 &digest,
804 &hmac_key(),
805 &resolved,
806 cache_ttl_for_token(expires_at, Utc::now()),
807 )
808 .await;
809 assert!(load_user(&cache, &digest, &hmac_key()).await.is_some());
810 digests.push(digest);
811 }
812
813 delete_user_and_invalidate_cached_tokens(tx.as_mut(), &cache, &hmac_key(), user)
814 .await
815 .expect("deletion should succeed");
816
817 for digest in &digests {
818 assert!(
819 load_user(&cache, digest, &hmac_key()).await.is_none(),
820 "a deleted user's cache entries must not survive the deletion"
821 );
822 }
823 }
824
825 #[test]
826 fn cache_key_is_not_the_db_digest() {
827 let digest = token_digest_sha256("some-token", &hmac_key());
828 let key = digest_to_cache_key(&digest, &hmac_key());
829 let digest_hex: String = digest
831 .as_slice()
832 .iter()
833 .map(|byte| format!("{byte:02x}"))
834 .collect();
835 assert!(!key.contains(&digest_hex));
836 }
837
838 #[test]
839 fn cache_key_is_stable_per_digest() {
840 let a = token_digest_sha256("token-a", &hmac_key());
841 let b = token_digest_sha256("token-b", &hmac_key());
842 assert_eq!(
843 digest_to_cache_key(&a, &hmac_key()),
844 digest_to_cache_key(&a, &hmac_key())
845 );
846 assert_ne!(
847 digest_to_cache_key(&a, &hmac_key()),
848 digest_to_cache_key(&b, &hmac_key())
849 );
850 }
851
852 #[test]
853 fn cache_key_depends_on_the_hmac_key() {
854 let digest = token_digest_sha256("token", &hmac_key());
855 let other = secret("a-different-hmac-key");
856 assert_ne!(
857 digest_to_cache_key(&digest, &hmac_key()),
858 digest_to_cache_key(&digest, &other)
859 );
860 }
861
862 async fn connected_test_cache() -> Option<Cache> {
865 let redis_url = test_redis_url()?;
866 let cache = Cache::new(&redis_url).expect("failed to construct Redis cache client");
867 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
871 while !cache.initial_connection_successful() {
872 assert!(
873 tokio::time::Instant::now() < deadline,
874 "failed to connect to Redis within timeout"
875 );
876 tokio::time::sleep(Duration::from_millis(100)).await;
877 }
878 Some(cache)
879 }
880}