Skip to main content

headless_lms_server/domain/exercise_services/
token.rs

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/// Authenticated user extracted from a courses.mooc.fi OAuth 2.0 access token.
23///
24/// The client sends an opaque access token issued by this backend's own OAuth
25/// provider (via the device-authorization flow) as `Authorization: Bearer <token>`.
26/// The token is hashed to a digest, looked up in `oauth_access_tokens`, gated on
27/// the `exercise-services` scope, and mapped to the local user that owns it.
28///
29/// The exact error bodies and status codes are a contract with the tmc-langs client,
30/// which keys its error mapping on them:
31///  - a missing / invalid / expired / revoked token, a token whose client may
32///    not use Bearer tokens, a sender-constrained (DPoP) token, or a token whose
33///    user no longer exists all yield `401` with an `unauthorized` body;
34///  - a valid token that lacks the `exercise-services` scope yields `403` with a
35///    `forbidden` body.
36#[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
52/// Builds the `401 unauthorized` error the langs client expects for any rejected token.
53fn unauthorized(message: &str) -> ControllerError {
54    controller_err!(Unauthorized, message.to_string())
55}
56
57/// Builds the `403 forbidden` error the langs client expects for a token missing the scope.
58fn forbidden(message: &str) -> ControllerError {
59    controller_err!(Forbidden, message.to_string())
60}
61
62/// The response to a token `resolve_bearer_token` rejected.
63///
64/// A database failure is a `500`, never a `401`: langs turns a `401` into
65/// refresh-then-DELETE-credentials, so collapsing a transient DB blip into `401` would force-log-out
66/// every user for the duration of the outage.
67fn 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
81/// Resolve an opaque Bearer access token to the local user that owns it, requiring the
82/// `exercise-services` scope.
83///
84/// Pure DB work (no actix, no cache) so it can be unit-tested directly. Also returns the token's
85/// `expires_at`, so the caller can cap cache TTL to it.
86async 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            // In test/dev mode a small set of fixed tokens map straight to seeded users so
149            // tests can skip the device flow. Anything else falls through to the real
150            // OAuth-token path below.
151            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            // A cache hit skips `resolve_oauth_user`, and with it the token-validity,
169            // bearer_allowed, scope and soft-delete checks, for the cache TTL — see
170            // `MAX_CACHE_TTL` for why that bound is the security-relevant number. The TTL is
171            // additionally clamped to the token's own remaining lifetime so a cached mapping
172            // never outlives the token.
173            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
190/// Domain separator for the cache-key KDF. Changing it invalidates every cache entry.
191const CACHE_KEY_CONTEXT: &str = "headless-lms exercise-services token cache v1";
192
193/// Cache key for a token, derived from its `oauth_access_tokens.digest` rather than the token
194/// plaintext: bulk revocation only ever holds digests, so a plaintext-derived key could not
195/// be evicted. Keyed (not a bare hash) so a leaked Redis dump is inert and a guessed digest
196/// cannot be confirmed offline.
197fn 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
205/// Upper bound on cache TTL, independent of the token's own expiry.
206///
207/// A cache hit skips every authorization check in `resolve_oauth_user`, so this bounds how long a
208/// change goes unnoticed when nothing evicts for it. Revocation, refresh-family reuse, consent
209/// withdrawal and user deletion all evict; these do not:
210///  - a grant narrowed by a new consent, which keeps its tokens and only caps their scopes;
211///  - a hard `DELETE FROM users`/`oauth_clients`, whose `ON DELETE CASCADE` into
212///    `oauth_access_tokens` runs no Rust;
213///  - `oauth_clients` changes (`bearer_allowed = false`, soft delete, narrowed `scopes`), which
214///    have no Rust mutator to evict from.
215///
216/// For those this bound is the only guard, which is why it is minutes and not an hour.
217const MAX_CACHE_TTL: Duration = Duration::from_secs(15 * 60);
218
219/// `min(MAX_CACHE_TTL, expires_at - now)`, floored at zero — a flat TTL could otherwise
220/// outlive a short-lived token.
221fn 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
248/// Evicts a cached user mapping. Call this right after revoking a token so it can't
249/// keep authenticating from a stale cache hit for the rest of its TTL.
250///
251/// Entries written before this keying scheme shipped are unreachable and only age out
252/// within `MAX_CACHE_TTL`.
253pub(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
263/// Evicts every mapping a batch revocation invalidated — a refresh family, a withdrawn consent, a
264/// deleted user. Same contract as `invalidate_cached_user`, for the callers that revoke more than
265/// one token at a time.
266pub(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
276/// Soft-deletes a user and evicts every cached mapping their access tokens had.
277///
278/// The only supported way to delete a user: `models::users::delete_user` returns the digests of the
279/// tokens it hard-deleted, and a caller that drops them leaves a banned account authenticating from
280/// a cache hit for the rest of `MAX_CACHE_TTL`. Both callers — self-service account deletion and the
281/// `sync_tmc_users` batch — go through here so neither can forget.
282pub 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    /// Insert an access token, and the grant it comes from, for the given user/client and return
344    /// its plaintext.
345    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            // A JWK SHA-256 thumbprint is 43 base64url chars (DB CHECK requires 43..=128).
358            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        // Token good for another 24h: the cap, not the token, decides the TTL.
410        let ttl = cache_ttl_for_token(now + ChronoDuration::hours(24), now);
411        assert_eq!(ttl, MAX_CACHE_TTL);
412    }
413
414    /// Guards the bound argued for in `MAX_CACHE_TTL`'s doc: minutes, never an hour.
415    #[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        // Token expiring in 30 seconds: the mapping must not outlive it.
424        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        // A token at/after expiry yields a zero TTL rather than a negative or flat 1h.
433        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        // Client and token both carry a non-exercise-services scope.
471        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        // Soft-delete the token owner. Their still-valid access token must stop
545        // authenticating rather than working until it expires.
546        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    /// Deleting the account must take its credentials down with it, not merely make the
558    /// `users` join fail: the returned digests are what lets the caller evict the cache, and a
559    /// deleted access-token row is what makes the ban survive a cache that was never evicted.
560    #[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        // A rejected token -> 401 with the exact unauthorized body the langs client keys on.
601        let invalid = bearer_error(BearerTokenError::Invalid("missing"));
602        assert_eq!(invalid.status_code(), StatusCode::UNAUTHORIZED);
603        assert_unauthorized_body(invalid);
604
605        // An infrastructure error (sqlx surfaces connection/statement failures as
606        // ModelErrorType::Database) -> 500, so a DB blip never masquerades as an
607        // invalid token and forces the client to drop its credentials.
608        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    /// Assert the exact 401 body shape the langs client depends on.
621    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    /// Assert the exact 403 body shape the langs client depends on.
634    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    /// A token that authenticated once (populating the cache) must not keep
647    /// authenticating from that entry after it's revoked. Drives the underlying
648    /// functions directly rather than the actix extractor or `/revoke` handler,
649    /// since those need a `PgPool`/running `App` this crate's tests don't set up.
650    ///
651    /// Needs a real Redis; a no-op (not a failure) when `REDIS_URL` isn't set,
652    /// matching `headless_lms_utils::cache`'s own test convention.
653    #[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        // Populate the cache the way the extractor would on a first request.
673        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        // Revoke it the way the /revoke controller's access-token path does:
685        // hard-delete the DB row, then evict the cache entry.
686        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        // The very next lookup must miss the cache (not just eventually,
695        // once the TTL naturally expires).
696        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        // And re-resolving from the DB must now fail too, confirming this
702        // isn't passing only because the cache was never populated.
703        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    /// The bug this keying scheme exists for: bulk revocation holds no token plaintext, so
710    /// with a plaintext-derived cache key it could not evict and the token kept
711    /// authenticating for up to `MAX_CACHE_TTL`.
712    ///
713    /// Needs a real Redis; a no-op (not a failure) when `REDIS_URL` isn't set.
714    #[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        // The bulk path never sees plaintext — it must be able to evict from the digests alone.
748        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    /// Deleting a user must take effect on the next request, not after `MAX_CACHE_TTL`. This is the
772    /// path both the self-service account deletion and the `sync_tmc_users` batch take; the batch
773    /// used to drop the digests and rely on the TTL alone.
774    ///
775    /// Needs a real Redis; a no-op (not a failure) when `REDIS_URL` isn't set.
776    #[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        // Two tokens, so a hook that only evicted the first would fail here.
785        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        // A key that merely re-encoded the digest would leak it into Redis.
830        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    /// A Redis-backed `Cache` that has completed its initial connection, or `None` when
863    /// `REDIS_URL` isn't set (matching `headless_lms_utils::cache`'s own test convention).
864    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        // `Cache::wait_for_initial_connection` is `#[cfg(test)]` *inside the utils crate*, so
868        // it isn't visible here (cfg(test) doesn't cross a crate boundary into a downstream
869        // dependent's tests) — poll the public `initial_connection_successful` instead.
870        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}