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::{Digest, EXERCISE_SERVICES_SCOPE, token_digest_sha256},
11 oauth_access_token::{OAuthAccessToken, TokenType},
12 oauth_client::OAuthClient,
13 users::User,
14};
15use secrecy::{ExposeSecret, SecretString};
16use sqlx::PgConnection;
17use std::ops::{Deref, DerefMut};
18use std::time::Duration;
19
20#[derive(Debug, Clone)]
35pub struct UserFromOAuthToken(User);
36
37impl Deref for UserFromOAuthToken {
38 type Target = User;
39 fn deref(&self) -> &Self::Target {
40 &self.0
41 }
42}
43
44impl DerefMut for UserFromOAuthToken {
45 fn deref_mut(&mut self) -> &mut Self::Target {
46 &mut self.0
47 }
48}
49
50fn unauthorized(message: &str) -> ControllerError {
52 controller_err!(Unauthorized, message.to_string())
53}
54
55fn forbidden(message: &str) -> ControllerError {
57 controller_err!(Forbidden, message.to_string())
58}
59
60fn lookup_error(err: models::ModelError, unauthorized_message: &str) -> ControllerError {
68 use headless_lms_base::error::backend_error::BackendError;
69 match err.error_type() {
70 models::ModelErrorType::RecordNotFound | models::ModelErrorType::NotFound => {
71 unauthorized(unauthorized_message)
72 }
73 _ => {
74 let source: anyhow::Error = err.into();
75 controller_err!(
76 InternalServerError,
77 "A database error occurred while validating the access token.".to_string(),
78 source
79 )
80 }
81 }
82}
83
84async fn resolve_oauth_user(
95 conn: &mut PgConnection,
96 token: &SecretString,
97 token_hmac_key: &SecretString,
98) -> Result<(User, DateTime<Utc>), ControllerError> {
99 let digest = token_digest_sha256(token.expose_secret(), token_hmac_key);
100 let access_token = OAuthAccessToken::find_valid(conn, digest)
101 .await
102 .map_err(|e| lookup_error(e, "The access token is missing, invalid, or expired."))?;
103
104 if access_token.token_type != TokenType::Bearer {
108 return Err(unauthorized(
109 "This API accepts only Bearer tokens; the presented token is sender-constrained.",
110 ));
111 }
112
113 let client = OAuthClient::find_by_id(conn, access_token.client_id)
114 .await
115 .map_err(|e| lookup_error(e, "The access token's client could not be found."))?;
116 if !client.allows_bearer() {
117 return Err(unauthorized(
118 "The access token's client is not permitted to use Bearer tokens.",
119 ));
120 }
121
122 if !access_token
123 .scopes
124 .iter()
125 .any(|scope| scope == EXERCISE_SERVICES_SCOPE)
126 {
127 return Err(forbidden(
128 "The access token does not grant the required exercise-services scope.",
129 ));
130 }
131
132 let user_id = access_token
133 .user_id
134 .ok_or_else(|| unauthorized("The access token is not associated with a user."))?;
135
136 let user = models::users::get_active_by_id(conn, user_id)
140 .await
141 .map_err(|e| lookup_error(e, "The access token's user could not be found."))?;
142
143 Ok((user, access_token.expires_at))
144}
145
146impl FromRequest for UserFromOAuthToken {
147 type Error = ControllerError;
148 type Future = LocalBoxFuture<'static, Result<Self, ControllerError>>;
149
150 fn from_request(req: &HttpRequest, _payload: &mut actix_http::Payload) -> Self::Future {
151 let app_data = (|| -> Result<_, ControllerError> {
152 let pool = req
153 .app_data::<web::Data<PgPool>>()
154 .ok_or_else(|| {
155 controller_err!(InternalServerError, "Missing database pool".to_string())
156 })?
157 .clone();
158 let app_conf = req
159 .app_data::<web::Data<ApplicationConfiguration>>()
160 .ok_or_else(|| {
161 controller_err!(
162 InternalServerError,
163 "Missing application configuration".to_string()
164 )
165 })?
166 .clone();
167 let cache = req
168 .app_data::<web::Data<Cache>>()
169 .ok_or_else(|| controller_err!(InternalServerError, "Missing cache".to_string()))?
170 .clone();
171 Ok((pool, app_conf, cache))
172 })();
173
174 let auth_header = req
175 .headers()
176 .get(header::AUTHORIZATION)
177 .map(|hv| String::from_utf8_lossy(hv.as_bytes()))
178 .and_then(|h| h.strip_prefix("Bearer ").map(str::to_string))
179 .map(|o| SecretString::new(o.into()));
180
181 async move {
182 let (pool, app_conf, cache) = app_data?;
183 let Some(token) = auth_header else {
184 return Err(unauthorized("Missing bearer token"));
185 };
186 let mut conn = pool.acquire().await?;
187
188 if app_conf.test_mode {
192 warn!("Test mode is on: fixed test tokens map directly to seeded users.");
193 if let Some(user) =
194 authentication::authenticate_test_token(&mut conn, &token, &app_conf)
195 .await
196 .map_err(as_controller_error(
197 ControllerErrorType::Unauthorized,
198 "Could not find user for test token".to_string(),
199 ))?
200 {
201 return Ok(Self(user));
202 }
203 }
204
205 let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
206 let digest = token_digest_sha256(token.expose_secret(), token_hmac_key);
207
208 let user = match load_user(&cache, &digest, token_hmac_key).await {
214 Some(user) => user,
215 None => {
216 let (user, expires_at) =
217 resolve_oauth_user(&mut conn, &token, token_hmac_key).await?;
218 let ttl = cache_ttl_for_token(expires_at, Utc::now());
219 cache_user(&cache, &digest, token_hmac_key, &user, ttl).await;
220 user
221 }
222 };
223
224 Ok(Self(user))
225 }
226 .boxed_local()
227 }
228}
229
230const CACHE_KEY_CONTEXT: &str = "headless-lms exercise-services token cache v1";
232
233fn digest_to_cache_key(digest: &Digest, token_hmac_key: &SecretString) -> String {
238 let subkey = blake3::derive_key(CACHE_KEY_CONTEXT, token_hmac_key.expose_secret().as_bytes());
239 format!(
240 "user:{}",
241 blake3::keyed_hash(&subkey, digest.as_slice()).to_hex()
242 )
243}
244
245const MAX_CACHE_TTL: Duration = Duration::from_secs(15 * 60);
262
263fn cache_ttl_for_token(expires_at: DateTime<Utc>, now: DateTime<Utc>) -> Duration {
266 let until_expiry = (expires_at - now).to_std().unwrap_or(Duration::ZERO);
267 until_expiry.min(MAX_CACHE_TTL)
268}
269
270pub async fn cache_user(
271 cache: &Cache,
272 digest: &Digest,
273 token_hmac_key: &SecretString,
274 user: &User,
275 ttl: Duration,
276) {
277 cache
278 .cache_json(digest_to_cache_key(digest, token_hmac_key), user, ttl)
279 .await;
280}
281
282pub async fn load_user(
283 cache: &Cache,
284 digest: &Digest,
285 token_hmac_key: &SecretString,
286) -> Option<User> {
287 cache
288 .get_json(digest_to_cache_key(digest, token_hmac_key))
289 .await
290}
291
292pub(crate) async fn invalidate_cached_user(
298 cache: &Cache,
299 digest: &Digest,
300 token_hmac_key: &SecretString,
301) {
302 cache
303 .invalidate(digest_to_cache_key(digest, token_hmac_key))
304 .await;
305}
306
307pub(crate) async fn invalidate_cached_users(
311 cache: &Cache,
312 digests: &[Digest],
313 token_hmac_key: &SecretString,
314) {
315 for digest in digests {
316 invalidate_cached_user(cache, digest, token_hmac_key).await;
317 }
318}
319
320pub async fn delete_user_and_invalidate_cached_tokens(
327 conn: &mut PgConnection,
328 cache: &Cache,
329 token_hmac_key: &SecretString,
330 user_id: uuid::Uuid,
331) -> Result<(), models::ModelError> {
332 let revoked = models::users::delete_user(conn, user_id).await?;
333 invalidate_cached_users(cache, &revoked, token_hmac_key).await;
334 Ok(())
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use crate::test_helper::*;
341 use actix_web::ResponseError;
342 use actix_web::http::StatusCode;
343 use chrono::{Duration as ChronoDuration, Utc};
344 use headless_lms_models::library::oauth::pkce::PkceMethod;
345 use headless_lms_models::library::oauth::{GrantTypeName, generate_access_token};
346 use headless_lms_models::oauth_access_token::NewAccessTokenParams;
347 use headless_lms_models::oauth_client::{
348 ApplicationType, NewClientParams, OAuthClient, TokenEndpointAuthMethod,
349 };
350
351 fn hmac_key() -> SecretString {
352 SecretString::new("test-exercise-services-hmac-key".to_string().into())
353 }
354
355 async fn insert_client(
356 conn: &mut PgConnection,
357 scopes: &[String],
358 bearer_allowed: bool,
359 ) -> OAuthClient {
360 let client_id = format!("cli-{}", &generate_access_token()[..12]);
361 OAuthClient::insert(
362 conn,
363 NewClientParams {
364 client_id: &client_id,
365 client_name: "Exercise services token test client",
366 application_type: ApplicationType::Native,
367 token_endpoint_auth_method: TokenEndpointAuthMethod::None,
368 client_secret: None,
369 client_secret_expires_at: None,
370 redirect_uris: &["urn:ietf:wg:oauth:2.0:oob".to_string()],
371 post_logout_redirect_uris: None,
372 allowed_grant_types: &[GrantTypeName::DeviceCode, GrantTypeName::RefreshToken],
373 scopes,
374 require_pkce: true,
375 pkce_methods_allowed: &[PkceMethod::S256],
376 allowed_origins: None,
377 bearer_allowed,
378 },
379 )
380 .await
381 .unwrap()
382 }
383
384 async fn insert_token(
386 conn: &mut PgConnection,
387 client: &OAuthClient,
388 user_id: uuid::Uuid,
389 scopes: &[String],
390 token_type: TokenType,
391 expires_at: chrono::DateTime<Utc>,
392 ) -> String {
393 let plaintext = generate_access_token();
394 let digest = token_digest_sha256(&plaintext, &hmac_key());
395 let dpop_jkt = match token_type {
396 TokenType::Bearer => None,
397 TokenType::DPoP => Some("0123456789abcdefghijklmnopqrstuvwxyzABCDEFG"),
399 };
400 OAuthAccessToken::insert(
401 conn,
402 NewAccessTokenParams {
403 digest: &digest,
404 user_id: Some(user_id),
405 client_id: client.id,
406 scopes,
407 audience: None,
408 token_type,
409 dpop_jkt,
410 metadata: serde_json::Map::new(),
411 expires_at,
412 },
413 )
414 .await
415 .unwrap();
416 plaintext
417 }
418
419 fn secret(s: &str) -> SecretString {
420 SecretString::new(s.to_string().into())
421 }
422
423 #[actix_web::test]
424 async fn happy_path_returns_the_token_owner() {
425 insert_data!(:tx, :user);
426 let client = insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], true).await;
427 let token = insert_token(
428 tx.as_mut(),
429 &client,
430 user,
431 &[EXERCISE_SERVICES_SCOPE.to_string()],
432 TokenType::Bearer,
433 Utc::now() + ChronoDuration::hours(1),
434 )
435 .await;
436
437 let (resolved, _expires_at) = resolve_oauth_user(tx.as_mut(), &secret(&token), &hmac_key())
438 .await
439 .expect("valid token should resolve");
440 assert_eq!(resolved.id, user);
441 }
442
443 #[test]
444 fn cache_ttl_is_clamped_for_long_lived_tokens() {
445 let now = Utc::now();
446 let ttl = cache_ttl_for_token(now + ChronoDuration::hours(24), now);
448 assert_eq!(ttl, MAX_CACHE_TTL);
449 }
450
451 #[test]
453 fn the_cache_ttl_cap_stays_in_minutes() {
454 assert!(MAX_CACHE_TTL <= Duration::from_secs(15 * 60));
455 }
456
457 #[test]
458 fn cache_ttl_tracks_tokens_shorter_lived_than_the_cap() {
459 let now = Utc::now();
460 let ttl = cache_ttl_for_token(now + ChronoDuration::seconds(30), now);
462 assert!(ttl <= Duration::from_secs(30));
463 assert!(ttl > Duration::from_secs(25));
464 }
465
466 #[test]
467 fn cache_ttl_is_zero_for_already_expired_tokens() {
468 let now = Utc::now();
469 let ttl = cache_ttl_for_token(now - ChronoDuration::minutes(1), now);
471 assert_eq!(ttl, Duration::ZERO);
472 }
473
474 #[actix_web::test]
475 async fn unknown_token_is_unauthorized() {
476 insert_data!(:tx);
477 let err = resolve_oauth_user(tx.as_mut(), &secret("not-a-real-token"), &hmac_key())
478 .await
479 .expect_err("unknown token should be rejected");
480 assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
481 assert_unauthorized_body(err);
482 }
483
484 #[actix_web::test]
485 async fn expired_token_is_unauthorized() {
486 insert_data!(:tx, :user);
487 let client = insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], true).await;
488 let token = insert_token(
489 tx.as_mut(),
490 &client,
491 user,
492 &[EXERCISE_SERVICES_SCOPE.to_string()],
493 TokenType::Bearer,
494 Utc::now() - ChronoDuration::minutes(1),
495 )
496 .await;
497
498 let err = resolve_oauth_user(tx.as_mut(), &secret(&token), &hmac_key())
499 .await
500 .expect_err("expired token should be rejected");
501 assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
502 }
503
504 #[actix_web::test]
505 async fn missing_scope_is_forbidden() {
506 insert_data!(:tx, :user);
507 let client = insert_client(tx.as_mut(), &["openid".to_string()], true).await;
509 let token = insert_token(
510 tx.as_mut(),
511 &client,
512 user,
513 &["openid".to_string()],
514 TokenType::Bearer,
515 Utc::now() + ChronoDuration::hours(1),
516 )
517 .await;
518
519 let err = resolve_oauth_user(tx.as_mut(), &secret(&token), &hmac_key())
520 .await
521 .expect_err("token without the scope should be forbidden");
522 assert_eq!(err.status_code(), StatusCode::FORBIDDEN);
523 assert_forbidden_body(err);
524 }
525
526 #[actix_web::test]
527 async fn client_without_bearer_allowed_is_unauthorized() {
528 insert_data!(:tx, :user);
529 let client =
530 insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], false).await;
531 let token = insert_token(
532 tx.as_mut(),
533 &client,
534 user,
535 &[EXERCISE_SERVICES_SCOPE.to_string()],
536 TokenType::Bearer,
537 Utc::now() + ChronoDuration::hours(1),
538 )
539 .await;
540
541 let err = resolve_oauth_user(tx.as_mut(), &secret(&token), &hmac_key())
542 .await
543 .expect_err("bearer_allowed=false client should be rejected");
544 assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
545 }
546
547 #[actix_web::test]
548 async fn dpop_bound_token_is_rejected() {
549 insert_data!(:tx, :user);
550 let client = insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], true).await;
551 let token = insert_token(
552 tx.as_mut(),
553 &client,
554 user,
555 &[EXERCISE_SERVICES_SCOPE.to_string()],
556 TokenType::DPoP,
557 Utc::now() + ChronoDuration::hours(1),
558 )
559 .await;
560
561 let err = resolve_oauth_user(tx.as_mut(), &secret(&token), &hmac_key())
562 .await
563 .expect_err("DPoP-bound token should be rejected on this Bearer-only API");
564 assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
565 }
566
567 #[actix_web::test]
568 async fn soft_deleted_user_is_unauthorized() {
569 insert_data!(:tx, :user);
570 let client = insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], true).await;
571 let token = insert_token(
572 tx.as_mut(),
573 &client,
574 user,
575 &[EXERCISE_SERVICES_SCOPE.to_string()],
576 TokenType::Bearer,
577 Utc::now() + ChronoDuration::hours(1),
578 )
579 .await;
580
581 sqlx::query("UPDATE users SET deleted_at = now() WHERE id = $1")
584 .bind(user)
585 .execute(&mut **tx.as_mut())
586 .await
587 .unwrap();
588
589 let err = resolve_oauth_user(tx.as_mut(), &secret(&token), &hmac_key())
590 .await
591 .expect_err("a soft-deleted user's token must be rejected");
592 assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
593 assert_unauthorized_body(err);
594 }
595
596 #[actix_web::test]
600 async fn deleting_a_user_revokes_their_tokens_and_reports_the_digests() {
601 insert_data!(:tx, :user);
602 let client = insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], true).await;
603 let token = insert_token(
604 tx.as_mut(),
605 &client,
606 user,
607 &[EXERCISE_SERVICES_SCOPE.to_string()],
608 TokenType::Bearer,
609 Utc::now() + ChronoDuration::hours(1),
610 )
611 .await;
612 let digest = token_digest_sha256(&token, &hmac_key());
613
614 let revoked = models::users::delete_user(tx.as_mut(), user)
615 .await
616 .expect("delete user");
617 assert_eq!(
618 revoked.len(),
619 1,
620 "the deleted access token's digest must be reported for cache eviction"
621 );
622
623 assert!(
624 OAuthAccessToken::find_valid(tx.as_mut(), digest)
625 .await
626 .is_err(),
627 "the access token row must be gone, not just orphaned"
628 );
629 let err = resolve_oauth_user(tx.as_mut(), &secret(&token), &hmac_key())
630 .await
631 .expect_err("a deleted user's token must be rejected");
632 assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
633 }
634
635 #[actix_web::test]
636 async fn lookup_error_maps_not_found_to_401_and_db_errors_to_500() {
637 use headless_lms_models::{ModelError, ModelErrorType};
638
639 let not_found = lookup_error(
642 ModelError::new(
643 ModelErrorType::RecordNotFound,
644 "no such row".to_string(),
645 None::<anyhow::Error>,
646 ),
647 "missing",
648 );
649 assert_eq!(not_found.status_code(), StatusCode::UNAUTHORIZED);
650 assert_unauthorized_body(not_found);
651
652 let db_error = lookup_error(
656 ModelError::new(
657 ModelErrorType::Database,
658 "connection reset".to_string(),
659 None::<anyhow::Error>,
660 ),
661 "missing",
662 );
663 assert_eq!(
664 db_error.status_code(),
665 StatusCode::INTERNAL_SERVER_ERROR,
666 "transient DB errors must not collapse into 401"
667 );
668 }
669
670 fn assert_unauthorized_body(err: ControllerError) {
672 let response = err.error_response();
673 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
674 let bytes = actix_web::body::to_bytes(response.into_body())
675 .now_or_never()
676 .expect("body resolves immediately")
677 .expect("body bytes");
678 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
679 assert_eq!(value["type"], "unauthorized");
680 assert_eq!(value["message_key"], "unauthorized");
681 }
682
683 fn assert_forbidden_body(err: ControllerError) {
685 let response = err.error_response();
686 assert_eq!(response.status(), StatusCode::FORBIDDEN);
687 let bytes = actix_web::body::to_bytes(response.into_body())
688 .now_or_never()
689 .expect("body resolves immediately")
690 .expect("body bytes");
691 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
692 assert_eq!(value["type"], "forbidden");
693 assert_eq!(value["message_key"], "forbidden");
694 }
695
696 #[actix_web::test]
704 async fn revoking_an_access_token_evicts_its_cached_user() {
705 let Some(cache) = connected_test_cache().await else {
706 return;
707 };
708
709 insert_data!(:tx, :user);
710 let client = insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], true).await;
711 let plaintext = insert_token(
712 tx.as_mut(),
713 &client,
714 user,
715 &[EXERCISE_SERVICES_SCOPE.to_string()],
716 TokenType::Bearer,
717 Utc::now() + ChronoDuration::hours(1),
718 )
719 .await;
720 let token = secret(&plaintext);
721
722 let (resolved, expires_at) = resolve_oauth_user(tx.as_mut(), &token, &hmac_key())
724 .await
725 .expect("token should resolve before revocation");
726 let ttl = cache_ttl_for_token(expires_at, Utc::now());
727 let digest = token_digest_sha256(&plaintext, &hmac_key());
728 cache_user(&cache, &digest, &hmac_key(), &resolved, ttl).await;
729 assert!(
730 load_user(&cache, &digest, &hmac_key()).await.is_some(),
731 "user should be cached after the first successful resolution"
732 );
733
734 OAuthAccessToken::revoke_by_digest(
737 tx.as_mut(),
738 token_digest_sha256(&plaintext, &hmac_key()),
739 )
740 .await
741 .expect("revoke_by_digest should succeed");
742 invalidate_cached_user(&cache, &digest, &hmac_key()).await;
743
744 assert!(
747 load_user(&cache, &digest, &hmac_key()).await.is_none(),
748 "a revoked token's cache entry must not survive revocation"
749 );
750
751 let err = resolve_oauth_user(tx.as_mut(), &token, &hmac_key())
754 .await
755 .expect_err("a revoked token must not resolve from the DB either");
756 assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
757 }
758
759 #[actix_web::test]
765 async fn bulk_revoking_a_clients_tokens_evicts_their_cached_users() {
766 let Some(cache) = connected_test_cache().await else {
767 return;
768 };
769
770 insert_data!(:tx, :user);
771 let client = insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], true).await;
772 let plaintext = insert_token(
773 tx.as_mut(),
774 &client,
775 user,
776 &[EXERCISE_SERVICES_SCOPE.to_string()],
777 TokenType::Bearer,
778 Utc::now() + ChronoDuration::hours(1),
779 )
780 .await;
781
782 let (resolved, expires_at) =
783 resolve_oauth_user(tx.as_mut(), &secret(&plaintext), &hmac_key())
784 .await
785 .expect("token should resolve before revocation");
786 let digest = token_digest_sha256(&plaintext, &hmac_key());
787 cache_user(
788 &cache,
789 &digest,
790 &hmac_key(),
791 &resolved,
792 cache_ttl_for_token(expires_at, Utc::now()),
793 )
794 .await;
795 assert!(load_user(&cache, &digest, &hmac_key()).await.is_some());
796
797 let revoked =
799 models::oauth_user_client_scopes::OAuthUserClientScopes::revoke_user_client_everything(
800 tx.as_mut(),
801 user,
802 client.id,
803 )
804 .await
805 .expect("bulk revoke should succeed");
806 assert_eq!(
807 revoked.len(),
808 1,
809 "bulk revoke must return the deleted digest"
810 );
811 for revoked_digest in &revoked {
812 invalidate_cached_user(&cache, revoked_digest, &hmac_key()).await;
813 }
814
815 assert!(
816 load_user(&cache, &digest, &hmac_key()).await.is_none(),
817 "a bulk-revoked token's cache entry must not survive revocation"
818 );
819 }
820
821 #[actix_web::test]
827 async fn deleting_a_user_evicts_their_cached_tokens() {
828 let Some(cache) = connected_test_cache().await else {
829 return;
830 };
831
832 insert_data!(:tx, :user);
833 let client = insert_client(tx.as_mut(), &[EXERCISE_SERVICES_SCOPE.to_string()], true).await;
834 let mut digests = Vec::new();
836 for _ in 0..2 {
837 let plaintext = insert_token(
838 tx.as_mut(),
839 &client,
840 user,
841 &[EXERCISE_SERVICES_SCOPE.to_string()],
842 TokenType::Bearer,
843 Utc::now() + ChronoDuration::hours(1),
844 )
845 .await;
846 let (resolved, expires_at) =
847 resolve_oauth_user(tx.as_mut(), &secret(&plaintext), &hmac_key())
848 .await
849 .expect("token should resolve before deletion");
850 let digest = token_digest_sha256(&plaintext, &hmac_key());
851 cache_user(
852 &cache,
853 &digest,
854 &hmac_key(),
855 &resolved,
856 cache_ttl_for_token(expires_at, Utc::now()),
857 )
858 .await;
859 assert!(load_user(&cache, &digest, &hmac_key()).await.is_some());
860 digests.push(digest);
861 }
862
863 delete_user_and_invalidate_cached_tokens(tx.as_mut(), &cache, &hmac_key(), user)
864 .await
865 .expect("deletion should succeed");
866
867 for digest in &digests {
868 assert!(
869 load_user(&cache, digest, &hmac_key()).await.is_none(),
870 "a deleted user's cache entries must not survive the deletion"
871 );
872 }
873 }
874
875 #[test]
876 fn cache_key_is_not_the_db_digest() {
877 let digest = token_digest_sha256("some-token", &hmac_key());
878 let key = digest_to_cache_key(&digest, &hmac_key());
879 let digest_hex: String = digest
881 .as_slice()
882 .iter()
883 .map(|byte| format!("{byte:02x}"))
884 .collect();
885 assert!(!key.contains(&digest_hex));
886 }
887
888 #[test]
889 fn cache_key_is_stable_per_digest() {
890 let a = token_digest_sha256("token-a", &hmac_key());
891 let b = token_digest_sha256("token-b", &hmac_key());
892 assert_eq!(
893 digest_to_cache_key(&a, &hmac_key()),
894 digest_to_cache_key(&a, &hmac_key())
895 );
896 assert_ne!(
897 digest_to_cache_key(&a, &hmac_key()),
898 digest_to_cache_key(&b, &hmac_key())
899 );
900 }
901
902 #[test]
903 fn cache_key_depends_on_the_hmac_key() {
904 let digest = token_digest_sha256("token", &hmac_key());
905 let other = secret("a-different-hmac-key");
906 assert_ne!(
907 digest_to_cache_key(&digest, &hmac_key()),
908 digest_to_cache_key(&digest, &other)
909 );
910 }
911
912 async fn connected_test_cache() -> Option<Cache> {
915 let redis_url = test_redis_url()?;
916 let cache = Cache::new(&redis_url).expect("failed to construct Redis cache client");
917 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
921 while !cache.initial_connection_successful() {
922 assert!(
923 tokio::time::Instant::now() < deadline,
924 "failed to connect to Redis within timeout"
925 );
926 tokio::time::sleep(Duration::from_millis(100)).await;
927 }
928 Some(cache)
929 }
930}