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::{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/// Authenticated user extracted from a courses.mooc.fi OAuth 2.0 access token.
21///
22/// The client sends an opaque access token issued by this backend's own OAuth
23/// provider (via the device-authorization flow) as `Authorization: Bearer <token>`.
24/// The token is hashed to a digest, looked up in `oauth_access_tokens`, gated on
25/// the `exercise-services` scope, and mapped to the local user that owns it.
26///
27/// The exact error bodies and status codes are a contract with the tmc-langs client,
28/// which keys its error mapping on them:
29///  - a missing / invalid / expired / revoked token, a token whose client may
30///    not use Bearer tokens, a sender-constrained (DPoP) token, or a token whose
31///    user no longer exists all yield `401` with an `unauthorized` body;
32///  - a valid token that lacks the `exercise-services` scope yields `403` with a
33///    `forbidden` body.
34#[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
50/// Builds the `401 unauthorized` error the langs client expects for any rejected token.
51fn unauthorized(message: &str) -> ControllerError {
52    controller_err!(Unauthorized, message.to_string())
53}
54
55/// Builds the `403 forbidden` error the langs client expects for a token missing the scope.
56fn forbidden(message: &str) -> ControllerError {
57    controller_err!(Forbidden, message.to_string())
58}
59
60/// Classify a model lookup error raised while resolving a Bearer token.
61///
62/// An absent row (token, client or user missing, or the user soft-deleted) is an
63/// authentication failure and maps to `401`; anything else is infrastructure and must
64/// propagate as `500`. The distinction is load-bearing: langs turns a `401` into
65/// refresh-then-DELETE-credentials, so collapsing a transient DB blip into `401` would
66/// force-log-out every user for the duration of the outage.
67fn 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
84/// Resolve an opaque Bearer access token to the local user that owns it.
85///
86/// Pure DB work (no actix, no cache) so it can be unit-tested directly. Flow:
87/// digest the token → look up a still-valid `oauth_access_tokens` row → reject
88/// sender-constrained (DPoP) tokens (this API is Bearer-only, so
89/// `find_valid_for_sender` is deliberately not used) → require the issuing
90/// client to allow Bearer tokens → require the `exercise-services` scope → load
91/// the user.
92///
93/// Also returns the token's `expires_at`, so the caller can cap cache TTL to it.
94async 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    // Bearer-only: a DPoP (sender-constrained) token must be presented with a
105    // proof, which this API does not verify, so reject it rather than accept it
106    // unbound.
107    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    // `get_active_by_id` filters `deleted_at IS NULL`, so a soft-deleted
137    // (banned/removed) user resolves to RecordNotFound -> 401, rather than
138    // continuing to authenticate until the token expires.
139    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            // In test/dev mode a small set of fixed tokens map straight to seeded users so
189            // tests can skip the device flow. Anything else falls through to the real
190            // OAuth-token path below.
191            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            // A cache hit skips `resolve_oauth_user`, and with it the token-validity,
209            // bearer_allowed, scope and soft-delete checks, for the cache TTL — see
210            // `MAX_CACHE_TTL` for why that bound is the security-relevant number. The TTL is
211            // additionally clamped to the token's own remaining lifetime so a cached mapping
212            // never outlives the token.
213            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
230/// Domain separator for the cache-key KDF. Changing it invalidates every cache entry.
231const CACHE_KEY_CONTEXT: &str = "headless-lms exercise-services token cache v1";
232
233/// Cache key for a token, derived from its `oauth_access_tokens.digest` rather than the token
234/// plaintext: bulk revocation only ever holds digests, so a plaintext-derived key could not
235/// be evicted. Keyed (not a bare hash) so a leaked Redis dump is inert and a guessed digest
236/// cannot be confirmed offline.
237fn 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
245/// Upper bound on cache TTL, independent of the token's own expiry.
246///
247/// A cache hit skips every authorization check in `resolve_oauth_user`, so this is the window in
248/// which a change that nothing evicts for goes unnoticed. Every mutation this application performs
249/// does evict: token revocation (`/revoke`), refresh-family revocation on reuse, consent withdrawal
250/// (`authorized_clients`), and user deletion — both self-service and the `sync_tmc_users` batch —
251/// all go through code that holds the affected digests.
252///
253/// What remains is only out-of-band SQL, and it cannot be hooked from here:
254///  - a hard `DELETE FROM users`/`oauth_clients`, whose `ON DELETE CASCADE` into
255///    `oauth_access_tokens` holds no digests and runs no Rust;
256///  - `oauth_clients` changes — `bearer_allowed = false`, a soft delete, a narrowed `scopes` — for
257///    which no Rust mutator exists at all, so there is no call site to evict from.
258///
259/// For those paths this bound is still the only guard, which is why it is minutes and not an hour:
260/// a change made by hand has to take effect without also flushing Redis.
261const MAX_CACHE_TTL: Duration = Duration::from_secs(15 * 60);
262
263/// `min(MAX_CACHE_TTL, expires_at - now)`, floored at zero — a flat TTL could otherwise
264/// outlive a short-lived token.
265fn 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
292/// Evicts a cached user mapping. Call this right after revoking a token so it can't
293/// keep authenticating from a stale cache hit for the rest of its TTL.
294///
295/// Entries written before this keying scheme shipped are unreachable and only age out
296/// within `MAX_CACHE_TTL`.
297pub(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
307/// Evicts every mapping a batch revocation invalidated — a refresh family, a withdrawn consent, a
308/// deleted user. Same contract as `invalidate_cached_user`, for the callers that revoke more than
309/// one token at a time.
310pub(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
320/// Soft-deletes a user and evicts every cached mapping their access tokens had.
321///
322/// The only supported way to delete a user: `models::users::delete_user` returns the digests of the
323/// tokens it hard-deleted, and a caller that drops them leaves a banned account authenticating from
324/// a cache hit for the rest of `MAX_CACHE_TTL`. Both callers — self-service account deletion and the
325/// `sync_tmc_users` batch — go through here so neither can forget.
326pub 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    /// Insert an access token for the given user/client and return its plaintext.
385    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            // A JWK SHA-256 thumbprint is 43 base64url chars (DB CHECK requires 43..=128).
398            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        // Token good for another 24h: the cap, not the token, decides the TTL.
447        let ttl = cache_ttl_for_token(now + ChronoDuration::hours(24), now);
448        assert_eq!(ttl, MAX_CACHE_TTL);
449    }
450
451    /// Guards the bound argued for in `MAX_CACHE_TTL`'s doc: minutes, never an hour.
452    #[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        // Token expiring in 30 seconds: the mapping must not outlive it.
461        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        // A token at/after expiry yields a zero TTL rather than a negative or flat 1h.
470        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        // Client and token both carry a non-exercise-services scope.
508        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        // Soft-delete the token owner. Their still-valid access token must stop
582        // authenticating rather than working until it expires.
583        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    /// Deleting the account must take its credentials down with it, not merely make the
597    /// `users` join fail: the returned digests are what lets the caller evict the cache, and a
598    /// deleted access-token row is what makes the ban survive a cache that was never evicted.
599    #[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        // A missing row (token/client/user absent or soft-deleted) -> 401 with the
640        // exact unauthorized body the langs client keys on.
641        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        // An infrastructure error (sqlx surfaces connection/statement failures as
653        // ModelErrorType::Database) -> 500, so a DB blip never masquerades as an
654        // invalid token and forces the client to drop its credentials.
655        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    /// Assert the exact 401 body shape the langs client depends on.
671    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    /// Assert the exact 403 body shape the langs client depends on.
684    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    /// A token that authenticated once (populating the cache) must not keep
697    /// authenticating from that entry after it's revoked. Drives the underlying
698    /// functions directly rather than the actix extractor or `/revoke` handler,
699    /// since those need a `PgPool`/running `App` this crate's tests don't set up.
700    ///
701    /// Needs a real Redis; a no-op (not a failure) when `REDIS_URL` isn't set,
702    /// matching `headless_lms_utils::cache`'s own test convention.
703    #[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        // Populate the cache the way the extractor would on a first request.
723        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        // Revoke it the way the /revoke controller's access-token path does:
735        // hard-delete the DB row, then evict the cache entry.
736        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        // The very next lookup must miss the cache (not just eventually,
745        // once the TTL naturally expires).
746        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        // And re-resolving from the DB must now fail too, confirming this
752        // isn't passing only because the cache was never populated.
753        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    /// The bug this keying scheme exists for: bulk revocation holds no token plaintext, so
760    /// with a plaintext-derived cache key it could not evict and the token kept
761    /// authenticating for up to `MAX_CACHE_TTL`.
762    ///
763    /// Needs a real Redis; a no-op (not a failure) when `REDIS_URL` isn't set.
764    #[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        // The bulk path never sees plaintext — it must be able to evict from the digests alone.
798        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    /// Deleting a user must take effect on the next request, not after `MAX_CACHE_TTL`. This is the
822    /// path both the self-service account deletion and the `sync_tmc_users` batch take; the batch
823    /// used to drop the digests and rely on the TTL alone.
824    ///
825    /// Needs a real Redis; a no-op (not a failure) when `REDIS_URL` isn't set.
826    #[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        // Two tokens, so a hook that only evicted the first would fail here.
835        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        // A key that merely re-encoded the digest would leak it into Redis.
880        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    /// A Redis-backed `Cache` that has completed its initial connection, or `None` when
913    /// `REDIS_URL` isn't set (matching `headless_lms_utils::cache`'s own test convention).
914    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        // `Cache::wait_for_initial_connection` is `#[cfg(test)]` *inside the utils crate*, so
918        // it isn't visible here (cfg(test) doesn't cross a crate boundary into a downstream
919        // dependent's tests) — poll the public `initial_connection_successful` instead.
920        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}