Skip to main content

headless_lms_server/domain/oauth/
token_service.rs

1use chrono::{DateTime, Duration, Utc};
2use secrecy::{ExposeSecret, SecretString};
3use sqlx::{Connection, PgConnection};
4use uuid::Uuid;
5
6use crate::domain::exercise_services::token::invalidate_cached_users;
7use crate::domain::oauth::errors::TokenGrantError;
8use crate::domain::oauth::helpers::split_and_validate_scopes;
9use crate::domain::oauth::pkce::verify_token_pkce;
10use headless_lms_utils::cache::Cache;
11
12use headless_lms_models::library::oauth::Digest;
13use headless_lms_models::library::oauth::tokens::token_digest_sha256;
14use headless_lms_models::oauth_access_token::TokenType;
15use headless_lms_models::oauth_auth_code::OAuthAuthCode;
16use headless_lms_models::oauth_client::OAuthClient;
17use headless_lms_models::oauth_device_codes::{DeviceCodeStatus, OAuthDeviceCode};
18use headless_lms_models::oauth_refresh_tokens::{
19    IssueTokensFromAuthCodeParams, OAuthRefreshTokens, RotateRefreshTokenParams,
20};
21
22use super::token_query::TokenGrant;
23
24/// A pair of access and refresh tokens with their digests.
25pub struct TokenPair {
26    pub access_token: String,
27    pub refresh_token: String,
28    pub access_digest: Digest,
29    pub refresh_digest: Digest,
30}
31
32/// Resolves a refresh grant's requested scope against the token's original scope,
33/// per RFC 6749 §6: absent `scope`, the original set carries over unchanged; a
34/// space-delimited subset narrows it; anything outside the original set is rejected.
35fn resolve_refresh_scopes(
36    original: &[String],
37    requested: Option<&str>,
38) -> Result<Vec<String>, TokenGrantError> {
39    match requested {
40        None => Ok(original.to_vec()),
41        Some(s) if s.split_whitespace().next().is_none() => Ok(original.to_vec()),
42        Some(s) => split_and_validate_scopes(s, original).map_err(|scope| {
43            TokenGrantError::InvalidScope(format!(
44                "requested scope \"{}\" was not included in the original grant",
45                scope
46            ))
47        }),
48    }
49}
50
51/// Generate a new token pair (access token and refresh token) with their digests.
52pub fn generate_token_pair(key: &SecretString) -> TokenPair {
53    let access_token = headless_lms_models::library::oauth::tokens::generate_access_token();
54    let refresh_token = headless_lms_models::library::oauth::tokens::generate_access_token();
55    TokenPair {
56        access_token: access_token.clone(),
57        refresh_token: refresh_token.clone(),
58        access_digest: token_digest_sha256(&access_token, key),
59        refresh_digest: token_digest_sha256(&refresh_token, key),
60    }
61}
62
63pub struct TokenGrantRequest<'a> {
64    pub grant: &'a TokenGrant,
65    pub client: &'a OAuthClient,
66    pub token_pair: TokenPair,
67    pub access_expires_at: DateTime<Utc>,
68    pub refresh_expires_at: DateTime<Utc>,
69    pub issued_token_type: TokenType,
70    pub dpop_jkt: Option<&'a str>,
71    pub token_hmac_key: &'a SecretString,
72}
73
74#[derive(Debug)]
75pub struct TokenGrantResult {
76    pub user_id: Uuid,
77    pub scopes: Vec<String>,
78    pub nonce: Option<String>,
79    pub access_expires_at: DateTime<Utc>,
80    pub issue_id_token: bool,
81}
82
83/// RFC 9700 §4.14.2: presenting a refresh token that has already been revoked — by a rotation
84/// that superseded it, or by `/revoke` — is evidence the token leaked, so the whole (user, client)
85/// family is taken down instead of only this request failing. Without that, an attacker who
86/// redeems a stolen token first keeps a self-renewing family alive while the victim silently
87/// re-logs-in into a new one.
88///
89/// Runs before the issuance transaction: that transaction is rolled back on error, which would
90/// undo the takedown.
91///
92/// A token still valid here that loses a concurrent race to another rotation is deliberately left
93/// alone — that is one client refreshing twice, and the winning rotation already turned the family
94/// over.
95async fn revoke_family_on_refresh_token_reuse(
96    conn: &mut PgConnection,
97    cache: &Cache,
98    client: &OAuthClient,
99    refresh_token: &SecretString,
100    token_hmac_key: &SecretString,
101) -> Result<(), TokenGrantError> {
102    let presented = token_digest_sha256(refresh_token.expose_secret(), token_hmac_key);
103    let found = OAuthRefreshTokens::find_any_by_digest(conn, presented, client.id)
104        .await
105        .map_err(|e| TokenGrantError::ServerError(format!("{}", e)))?;
106    let Some(token) = found else {
107        return Ok(());
108    };
109    if !token.revoked {
110        return Ok(());
111    }
112
113    tracing::warn!(
114        user_id = %token.user_id,
115        client_id = %client.id,
116        "OAuth token: reuse of a revoked refresh token; revoking the whole (user, client) token family"
117    );
118    let revoked_access_digests = OAuthRefreshTokens::revoke_grant(conn, token.user_id, client.id)
119        .await
120        .map_err(|e| TokenGrantError::ServerError(format!("{}", e)))?;
121    invalidate_cached_users(cache, &revoked_access_digests, token_hmac_key).await;
122
123    Err(TokenGrantError::InvalidGrant(
124        "Given grant is invalid".to_string(),
125    ))
126}
127
128pub async fn process_token_grant(
129    conn: &mut PgConnection,
130    cache: &Cache,
131    request: TokenGrantRequest<'_>,
132) -> Result<TokenGrantResult, TokenGrantError> {
133    // Handled on the bare connection, before the issuance transaction the other grants
134    // roll back on failure, because its poll bookkeeping must persist on error too.
135    // See `process_device_code_grant`.
136    if let TokenGrant::DeviceCode { device_code } = request.grant {
137        return process_device_code_grant(conn, &request, device_code).await;
138    }
139
140    if let TokenGrant::RefreshToken { refresh_token, .. } = request.grant {
141        revoke_family_on_refresh_token_reuse(
142            conn,
143            cache,
144            request.client,
145            refresh_token,
146            request.token_hmac_key,
147        )
148        .await?;
149    }
150
151    let mut tx = conn
152        .begin()
153        .await
154        .map_err(|e| TokenGrantError::ServerError(format!("Failed to start transaction: {}", e)))?;
155
156    let result = match request.grant {
157        TokenGrant::AuthorizationCode {
158            code,
159            redirect_uri,
160            code_verifier,
161        } => {
162            let code_digest = token_digest_sha256(code.expose_secret(), request.token_hmac_key);
163            // Consume with client_id check in WHERE clause to prevent DoS attacks
164            let code_row = if let Some(ref_uri) = redirect_uri {
165                OAuthAuthCode::consume_with_redirect_in_transaction(
166                    &mut tx,
167                    code_digest,
168                    request.client.id,
169                    ref_uri,
170                )
171                .await
172                .map_err(|e| {
173                    tracing::warn!(
174                        err = %e,
175                        "OAuth token: auth code consume failed (redirect_uri check); possible causes: code already used, wrong redirect_uri, expired, or wrong client"
176                    );
177                    TokenGrantError::InvalidGrant("Given grant is invalid".to_string())
178                })?
179            } else {
180                OAuthAuthCode::consume_in_transaction(&mut tx, code_digest, request.client.id)
181                    .await
182                    .map_err(|e| {
183                        tracing::warn!(
184                            err = %e,
185                            "OAuth token: auth code consume failed; possible causes: code already used, expired, or wrong client"
186                        );
187                        TokenGrantError::InvalidGrant("Given grant is invalid".to_string())
188                    })?
189            };
190
191            // PKCE verification happens after client_id check (enforced in SQL)
192            verify_token_pkce(
193                request.client,
194                code_row.code_challenge.as_deref(),
195                code_row.code_challenge_method,
196                code_verifier.as_ref().map(|v| v.expose_secret()),
197            )
198            .map_err(|_| TokenGrantError::PkceVerificationFailed)?;
199
200            OAuthRefreshTokens::issue_tokens_from_auth_code_in_transaction(
201                &mut tx,
202                IssueTokensFromAuthCodeParams {
203                    user_id: code_row.user_id,
204                    client_id: code_row.client_id,
205                    scopes: &code_row.scopes,
206                    access_token_digest: &request.token_pair.access_digest,
207                    refresh_token_digest: &request.token_pair.refresh_digest,
208                    access_token_expires_at: request.access_expires_at,
209                    refresh_token_expires_at: request.refresh_expires_at,
210                    access_token_type: request.issued_token_type,
211                    access_token_dpop_jkt: request.dpop_jkt,
212                    refresh_token_dpop_jkt: request.dpop_jkt,
213                },
214            )
215            .await
216            .map_err(|e| TokenGrantError::ServerError(format!("{}", e)))?;
217
218            // Determine if ID token should be issued based on presence of "openid" scope
219            let has_openid = code_row.scopes.iter().any(|s| s == "openid");
220
221            Ok(TokenGrantResult {
222                user_id: code_row.user_id,
223                scopes: code_row.scopes,
224                nonce: code_row.nonce.clone(),
225                access_expires_at: request.access_expires_at,
226                issue_id_token: has_openid,
227            })
228        }
229        TokenGrant::RefreshToken {
230            refresh_token,
231            scope,
232        } => {
233            let presented =
234                token_digest_sha256(refresh_token.expose_secret(), request.token_hmac_key);
235            // Consume with client_id check in WHERE clause to prevent DoS attacks.
236            // A token that isn't currently valid — unknown, expired, revoked, or already
237            // rotated — fails as invalid_grant here; the revoked case has already been
238            // punished by `revoke_family_on_refresh_token_reuse`.
239            let old =
240                OAuthRefreshTokens::consume_in_transaction(&mut tx, presented, request.client.id)
241                    .await
242                    .map_err(|e| TokenGrantError::InvalidGrant(format!("{}", e)))?;
243
244            // On `Err` the caller rolls back the transaction, so the consumed-token
245            // update above is undone too and the refresh token isn't burned.
246            let effective_scopes = resolve_refresh_scopes(&old.scopes, scope.as_deref())?;
247
248            if let Some(expected_jkt) = old.dpop_jkt.as_deref() {
249                let presented_jkt = request.dpop_jkt.ok_or_else(|| {
250                    TokenGrantError::InvalidClient(
251                        "missing DPoP header for sender-constrained refresh".into(),
252                    )
253                })?;
254                if presented_jkt != expected_jkt {
255                    return Err(TokenGrantError::DpopMismatch);
256                }
257            }
258
259            let refresh_issue_type = if old.dpop_jkt.is_some() {
260                TokenType::DPoP
261            } else {
262                request.issued_token_type
263            };
264            let at_jkt = old.dpop_jkt.as_deref().or(request.dpop_jkt);
265            let refresh_jkt = old.dpop_jkt.as_deref().or(request.dpop_jkt);
266
267            OAuthRefreshTokens::complete_refresh_token_rotation_in_transaction(
268                &mut tx,
269                &old,
270                RotateRefreshTokenParams {
271                    new_refresh_token_digest: &request.token_pair.refresh_digest,
272                    new_access_token_digest: &request.token_pair.access_digest,
273                    access_token_expires_at: request.access_expires_at,
274                    refresh_token_expires_at: request.refresh_expires_at,
275                    access_token_type: refresh_issue_type,
276                    access_token_dpop_jkt: at_jkt,
277                    refresh_token_dpop_jkt: refresh_jkt,
278                    scopes: &effective_scopes,
279                },
280            )
281            .await
282            .map_err(|e| TokenGrantError::ServerError(format!("{}", e)))?;
283
284            Ok(TokenGrantResult {
285                user_id: old.user_id,
286                scopes: effective_scopes,
287                nonce: None,
288                access_expires_at: request.access_expires_at,
289                issue_id_token: false,
290            })
291        }
292        TokenGrant::DeviceCode { .. } => {
293            // Handled before the transaction is opened (see process_token_grant).
294            unreachable!("device_code grant is dispatched before the issuance transaction")
295        }
296        TokenGrant::Unknown => Err(TokenGrantError::UnsupportedGrantType),
297    };
298
299    match result {
300        Ok(res) => {
301            tx.commit().await.map_err(|e| {
302                TokenGrantError::ServerError(format!("Failed to commit transaction: {}", e))
303            })?;
304            Ok(res)
305        }
306        Err(e) => {
307            // Transaction will be rolled back on drop
308            Err(e)
309        }
310    }
311}
312
313/// Handle the RFC 8628 device-code grant at the token endpoint.
314///
315/// The poll is recorded on the bare connection so `last_polled_at` persists
316/// regardless of the outcome (a pending / slow_down response must still advance
317/// the poll clock). Only the approved branch opens a transaction, which
318/// atomically consumes the single-use device code and reuses the
319/// authorization-code issuance path so access/refresh creation and scope
320/// persistence stay identical across grants.
321async fn process_device_code_grant(
322    conn: &mut PgConnection,
323    request: &TokenGrantRequest<'_>,
324    device_code: &SecretString,
325) -> Result<TokenGrantResult, TokenGrantError> {
326    let digest = token_digest_sha256(device_code.expose_secret(), request.token_hmac_key);
327
328    // Record this poll and read back enough state to decide the response.
329    // An unknown device_code surfaces as a record-not-found => invalid grant.
330    let poll = OAuthDeviceCode::record_poll(conn, &digest)
331        .await
332        .map_err(|e| TokenGrantError::InvalidGrant(format!("{}", e)))?;
333
334    // RFC 8628 §3.4: the device_code is bound to the client it was issued to; a
335    // different client must not redeem it even if it, too, is allowed the device
336    // grant. Fail as invalid_grant without revealing whether the code or the
337    // presenting client was the mismatch.
338    if poll.client_id != request.client.id {
339        return Err(TokenGrantError::InvalidGrant(
340            "Given grant is invalid".to_string(),
341        ));
342    }
343
344    let now = Utc::now();
345
346    if poll.status == DeviceCodeStatus::Denied {
347        return Err(TokenGrantError::AccessDenied);
348    }
349    if poll.expires_at <= now {
350        return Err(TokenGrantError::ExpiredToken);
351    }
352    if poll.status == DeviceCodeStatus::Approved {
353        let mut tx = conn.begin().await.map_err(|e| {
354            TokenGrantError::ServerError(format!("Failed to start transaction: {}", e))
355        })?;
356
357        // Single-use redemption: the row is deleted so a replay finds nothing.
358        let device = OAuthDeviceCode::consume_approved_in_transaction(&mut tx, &digest)
359            .await
360            .map_err(|e| TokenGrantError::InvalidGrant(format!("{}", e)))?;
361
362        let user_id = device.user_id.ok_or_else(|| {
363            TokenGrantError::ServerError("approved device code missing user_id".into())
364        })?;
365
366        OAuthRefreshTokens::issue_tokens_from_auth_code_in_transaction(
367            &mut tx,
368            IssueTokensFromAuthCodeParams {
369                user_id,
370                client_id: device.client_id,
371                scopes: &device.scopes,
372                access_token_digest: &request.token_pair.access_digest,
373                refresh_token_digest: &request.token_pair.refresh_digest,
374                access_token_expires_at: request.access_expires_at,
375                refresh_token_expires_at: request.refresh_expires_at,
376                access_token_type: request.issued_token_type,
377                access_token_dpop_jkt: request.dpop_jkt,
378                refresh_token_dpop_jkt: request.dpop_jkt,
379            },
380        )
381        .await
382        .map_err(|e| TokenGrantError::ServerError(format!("{}", e)))?;
383
384        tx.commit().await.map_err(|e| {
385            TokenGrantError::ServerError(format!("Failed to commit transaction: {}", e))
386        })?;
387
388        return Ok(TokenGrantResult {
389            user_id,
390            scopes: device.scopes,
391            nonce: None,
392            access_expires_at: request.access_expires_at,
393            issue_id_token: false,
394        });
395    }
396
397    // Still pending: emit slow_down when the client polled faster than the
398    // advertised interval, otherwise authorization_pending.
399    let polled_too_soon = poll
400        .previous_polled_at
401        .map(|prev| {
402            now.signed_duration_since(prev) < Duration::seconds(poll.interval_seconds as i64)
403        })
404        .unwrap_or(false);
405    if polled_too_soon {
406        Err(TokenGrantError::SlowDown)
407    } else {
408        Err(TokenGrantError::AuthorizationPending)
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::test_helper::*;
416    use headless_lms_models::PKeyPolicy;
417    use headless_lms_models::library::oauth::pkce::PkceMethod;
418    use headless_lms_models::library::oauth::{
419        GrantTypeName, generate_access_token, generate_user_code,
420    };
421    use headless_lms_models::oauth_access_token::OAuthAccessToken;
422    use headless_lms_models::oauth_client::{
423        ApplicationType, NewClientParams, OAuthClient, TokenEndpointAuthMethod,
424    };
425    use headless_lms_models::oauth_device_codes::{NewDeviceCodeParams, OAuthDeviceCode};
426    use headless_lms_models::oauth_refresh_tokens::NewRefreshTokenParams;
427    use headless_lms_models::users;
428
429    fn hmac_key() -> SecretString {
430        SecretString::new("test-token-service-hmac-key".to_string().into())
431    }
432
433    /// A `Cache` pointed at a port nothing listens on. Every operation degrades to a no-op, which
434    /// is all these tests need: they assert on database state, not on cache contents.
435    fn test_cache() -> Cache {
436        Cache::new("redis://127.0.0.1:1").expect("cache")
437    }
438
439    /// Build a request that reuses a caller-supplied token pair (so the test can
440    /// recover the issued refresh-token plaintext).
441    fn build_request_with_pair<'a>(
442        grant: &'a TokenGrant,
443        client: &'a OAuthClient,
444        key: &'a SecretString,
445        token_pair: TokenPair,
446    ) -> TokenGrantRequest<'a> {
447        TokenGrantRequest {
448            grant,
449            client,
450            token_pair,
451            access_expires_at: Utc::now() + Duration::hours(1),
452            refresh_expires_at: Utc::now() + Duration::days(30),
453            issued_token_type: TokenType::Bearer,
454            dpop_jkt: None,
455            token_hmac_key: key,
456        }
457    }
458
459    async fn insert_refresh_token(
460        conn: &mut PgConnection,
461        user_id: Uuid,
462        client_id: Uuid,
463    ) -> String {
464        let plaintext = generate_access_token();
465        let digest = token_digest_sha256(&plaintext, &hmac_key());
466        OAuthRefreshTokens::insert(
467            conn,
468            NewRefreshTokenParams {
469                digest: &digest,
470                user_id,
471                client_id,
472                scopes: &["exercise-services".to_string()],
473                audience: None,
474                expires_at: Utc::now() + Duration::days(30),
475                rotated_from: None,
476                metadata: serde_json::Map::new(),
477                dpop_jkt: None,
478            },
479        )
480        .await
481        .unwrap();
482        plaintext
483    }
484
485    fn refresh_grant(plaintext: &str) -> TokenGrant {
486        TokenGrant::RefreshToken {
487            refresh_token: plaintext.into(),
488            scope: None,
489        }
490    }
491
492    /// Like [`refresh_grant`], but with an explicit (possibly down-scoping)
493    /// `scope` request parameter.
494    fn refresh_grant_with_scope(plaintext: &str, scope: &str) -> TokenGrant {
495        TokenGrant::RefreshToken {
496            refresh_token: plaintext.into(),
497            scope: Some(scope.to_string()),
498        }
499    }
500
501    /// Like [`insert_refresh_token`], but with a caller-chosen scope set (so
502    /// down-scoping tests have more than one scope to narrow from).
503    async fn insert_refresh_token_with_scopes(
504        conn: &mut PgConnection,
505        user_id: Uuid,
506        client_id: Uuid,
507        scopes: &[String],
508    ) -> String {
509        let plaintext = generate_access_token();
510        let digest = token_digest_sha256(&plaintext, &hmac_key());
511        OAuthRefreshTokens::insert(
512            conn,
513            NewRefreshTokenParams {
514                digest: &digest,
515                user_id,
516                client_id,
517                scopes,
518                audience: None,
519                expires_at: Utc::now() + Duration::days(30),
520                rotated_from: None,
521                metadata: serde_json::Map::new(),
522                dpop_jkt: None,
523            },
524        )
525        .await
526        .unwrap();
527        plaintext
528    }
529
530    async fn insert_device_client(conn: &mut PgConnection) -> OAuthClient {
531        let client_id = format!("cli-{}", &generate_access_token()[..12]);
532        OAuthClient::insert(
533            conn,
534            NewClientParams {
535                client_id: &client_id,
536                client_name: "Device flow token test client",
537                application_type: ApplicationType::Native,
538                token_endpoint_auth_method: TokenEndpointAuthMethod::None,
539                client_secret: None,
540                client_secret_expires_at: None,
541                redirect_uris: &["urn:ietf:wg:oauth:2.0:oob".to_string()],
542                post_logout_redirect_uris: None,
543                allowed_grant_types: &[GrantTypeName::DeviceCode, GrantTypeName::RefreshToken],
544                scopes: &["exercise-services".to_string()],
545                require_pkce: true,
546                pkce_methods_allowed: &[PkceMethod::S256],
547                allowed_origins: None,
548                bearer_allowed: true,
549            },
550        )
551        .await
552        .unwrap()
553    }
554
555    /// Inserts a device code and returns its plaintext (the caller hashes it to
556    /// build the grant).
557    async fn insert_device_code(
558        conn: &mut PgConnection,
559        client_id: Uuid,
560        expires_at: DateTime<Utc>,
561    ) -> (String, String) {
562        let device_code = generate_access_token();
563        let user_code = generate_user_code();
564        let digest = token_digest_sha256(&device_code, &hmac_key());
565        OAuthDeviceCode::insert(
566            conn,
567            NewDeviceCodeParams {
568                device_code_digest: &digest,
569                user_code: &user_code,
570                client_id,
571                scopes: &["exercise-services".to_string()],
572                interval_seconds: 5,
573                expires_at,
574                metadata: serde_json::Map::new(),
575            },
576        )
577        .await
578        .unwrap();
579        (device_code, user_code)
580    }
581
582    fn build_request<'a>(
583        grant: &'a TokenGrant,
584        client: &'a OAuthClient,
585        key: &'a SecretString,
586    ) -> TokenGrantRequest<'a> {
587        TokenGrantRequest {
588            grant,
589            client,
590            token_pair: generate_token_pair(key),
591            access_expires_at: Utc::now() + Duration::hours(1),
592            refresh_expires_at: Utc::now() + Duration::days(30),
593            issued_token_type: TokenType::Bearer,
594            dpop_jkt: None,
595            token_hmac_key: key,
596        }
597    }
598
599    #[actix_web::test]
600    async fn device_grant_happy_path_issues_tokens_and_is_single_use() {
601        let mut conn = Conn::init().await;
602        let mut tx = conn.begin().await;
603        let key = hmac_key();
604
605        let user = users::insert(
606            tx.as_mut(),
607            PKeyPolicy::Generate,
608            "device-happy@example.com",
609            None,
610            None,
611        )
612        .await
613        .unwrap();
614        let client = insert_device_client(tx.as_mut()).await;
615        let (device_code, user_code) =
616            insert_device_code(tx.as_mut(), client.id, Utc::now() + Duration::minutes(15)).await;
617        OAuthDeviceCode::approve(tx.as_mut(), &user_code, user)
618            .await
619            .unwrap();
620
621        let grant = TokenGrant::DeviceCode {
622            device_code: device_code.clone().into(),
623        };
624        let result = process_token_grant(
625            tx.as_mut(),
626            &test_cache(),
627            build_request(&grant, &client, &key),
628        )
629        .await
630        .expect("device grant should succeed");
631        assert_eq!(result.user_id, user);
632        assert_eq!(result.scopes, vec!["exercise-services".to_string()]);
633        assert!(!result.issue_id_token);
634
635        // Second redemption of the same device code fails (single-use).
636        let grant2 = TokenGrant::DeviceCode {
637            device_code: device_code.into(),
638        };
639        let err = process_token_grant(
640            tx.as_mut(),
641            &test_cache(),
642            build_request(&grant2, &client, &key),
643        )
644        .await
645        .expect_err("replay should fail");
646        assert!(matches!(err, TokenGrantError::InvalidGrant(_)));
647
648        tx.rollback().await;
649    }
650
651    #[actix_web::test]
652    async fn device_grant_pending_returns_authorization_pending() {
653        let mut conn = Conn::init().await;
654        let mut tx = conn.begin().await;
655        let key = hmac_key();
656
657        let client = insert_device_client(tx.as_mut()).await;
658        let (device_code, _user_code) =
659            insert_device_code(tx.as_mut(), client.id, Utc::now() + Duration::minutes(15)).await;
660
661        let grant = TokenGrant::DeviceCode {
662            device_code: device_code.into(),
663        };
664        let err = process_token_grant(
665            tx.as_mut(),
666            &test_cache(),
667            build_request(&grant, &client, &key),
668        )
669        .await
670        .expect_err("pending grant should not succeed");
671        assert!(matches!(err, TokenGrantError::AuthorizationPending));
672
673        tx.rollback().await;
674    }
675
676    #[actix_web::test]
677    async fn device_grant_denied_returns_access_denied() {
678        let mut conn = Conn::init().await;
679        let mut tx = conn.begin().await;
680        let key = hmac_key();
681
682        let client = insert_device_client(tx.as_mut()).await;
683        let (device_code, user_code) =
684            insert_device_code(tx.as_mut(), client.id, Utc::now() + Duration::minutes(15)).await;
685        OAuthDeviceCode::deny(tx.as_mut(), &user_code)
686            .await
687            .unwrap();
688
689        let grant = TokenGrant::DeviceCode {
690            device_code: device_code.into(),
691        };
692        let err = process_token_grant(
693            tx.as_mut(),
694            &test_cache(),
695            build_request(&grant, &client, &key),
696        )
697        .await
698        .expect_err("denied grant should not succeed");
699        assert!(matches!(err, TokenGrantError::AccessDenied));
700
701        tx.rollback().await;
702    }
703
704    #[actix_web::test]
705    async fn device_grant_expired_returns_expired_token() {
706        let mut conn = Conn::init().await;
707        let mut tx = conn.begin().await;
708        let key = hmac_key();
709
710        let client = insert_device_client(tx.as_mut()).await;
711        // Already expired (still within the 30-minute CHECK ceiling relative to created_at).
712        let (device_code, _user_code) =
713            insert_device_code(tx.as_mut(), client.id, Utc::now() - Duration::minutes(1)).await;
714
715        let grant = TokenGrant::DeviceCode {
716            device_code: device_code.into(),
717        };
718        let err = process_token_grant(
719            tx.as_mut(),
720            &test_cache(),
721            build_request(&grant, &client, &key),
722        )
723        .await
724        .expect_err("expired grant should not succeed");
725        assert!(matches!(err, TokenGrantError::ExpiredToken));
726
727        tx.rollback().await;
728    }
729
730    #[actix_web::test]
731    async fn device_grant_fast_polling_returns_slow_down() {
732        let mut conn = Conn::init().await;
733        let mut tx = conn.begin().await;
734        let key = hmac_key();
735
736        let client = insert_device_client(tx.as_mut()).await;
737        let (device_code, _user_code) =
738            insert_device_code(tx.as_mut(), client.id, Utc::now() + Duration::minutes(15)).await;
739
740        // First poll: authorization_pending (no previous poll recorded).
741        let grant1 = TokenGrant::DeviceCode {
742            device_code: device_code.clone().into(),
743        };
744        let first = process_token_grant(
745            tx.as_mut(),
746            &test_cache(),
747            build_request(&grant1, &client, &key),
748        )
749        .await
750        .expect_err("first poll should be pending");
751        assert!(matches!(first, TokenGrantError::AuthorizationPending));
752
753        // Second immediate poll: slow_down (polled well within the interval).
754        let grant2 = TokenGrant::DeviceCode {
755            device_code: device_code.into(),
756        };
757        let second = process_token_grant(
758            tx.as_mut(),
759            &test_cache(),
760            build_request(&grant2, &client, &key),
761        )
762        .await
763        .expect_err("fast second poll should slow down");
764        assert!(matches!(second, TokenGrantError::SlowDown));
765
766        tx.rollback().await;
767    }
768
769    #[actix_web::test]
770    async fn device_grant_unknown_code_is_invalid_grant() {
771        let mut conn = Conn::init().await;
772        let mut tx = conn.begin().await;
773        let key = hmac_key();
774
775        let client = insert_device_client(tx.as_mut()).await;
776
777        let grant = TokenGrant::DeviceCode {
778            device_code: "does-not-exist".into(),
779        };
780        let err = process_token_grant(
781            tx.as_mut(),
782            &test_cache(),
783            build_request(&grant, &client, &key),
784        )
785        .await
786        .expect_err("unknown device code should fail");
787        assert!(matches!(err, TokenGrantError::InvalidGrant(_)));
788
789        tx.rollback().await;
790    }
791
792    #[actix_web::test]
793    async fn device_grant_rejects_mismatched_client() {
794        let mut conn = Conn::init().await;
795        let mut tx = conn.begin().await;
796        let key = hmac_key();
797
798        let user = users::insert(
799            tx.as_mut(),
800            PKeyPolicy::Generate,
801            "device-mismatch@example.com",
802            None,
803            None,
804        )
805        .await
806        .unwrap();
807        // Two distinct clients, both allowed the device_code grant.
808        let client_a = insert_device_client(tx.as_mut()).await;
809        let client_b = insert_device_client(tx.as_mut()).await;
810
811        // The device code is issued to (and approved for) client A.
812        let (device_code, user_code) =
813            insert_device_code(tx.as_mut(), client_a.id, Utc::now() + Duration::minutes(15)).await;
814        OAuthDeviceCode::approve(tx.as_mut(), &user_code, user)
815            .await
816            .unwrap();
817
818        // Client B presenting A's device code must be rejected (RFC 8628 §3.4).
819        let grant_b = TokenGrant::DeviceCode {
820            device_code: device_code.clone().into(),
821        };
822        let err = process_token_grant(
823            tx.as_mut(),
824            &test_cache(),
825            build_request(&grant_b, &client_b, &key),
826        )
827        .await
828        .expect_err("cross-client device code redemption must fail");
829        assert!(matches!(err, TokenGrantError::InvalidGrant(_)));
830
831        // The binding check does not consume the code, so the original client
832        // (A) can still redeem it successfully.
833        let grant_a = TokenGrant::DeviceCode {
834            device_code: device_code.into(),
835        };
836        let ok = process_token_grant(
837            tx.as_mut(),
838            &test_cache(),
839            build_request(&grant_a, &client_a, &key),
840        )
841        .await
842        .expect("the client the code was issued to should still succeed");
843        assert_eq!(ok.user_id, user);
844
845        tx.rollback().await;
846    }
847
848    /// Refresh tokens are single-use, and reuse is treated as a leak: presenting a superseded
849    /// token fails as `invalid_grant` *and* takes down the whole (user, client) family, including
850    /// the successor the legitimate holder is still using (RFC 9700 §4.14.2). Anything less lets
851    /// an attacker who redeemed the stolen token first keep renewing forever.
852    #[actix_web::test]
853    async fn refresh_reuse_of_a_rotated_token_revokes_the_whole_family() {
854        let mut conn = Conn::init().await;
855        let mut tx = conn.begin().await;
856        let key = hmac_key();
857
858        let user = users::insert(
859            tx.as_mut(),
860            PKeyPolicy::Generate,
861            "refresh-reuse@example.com",
862            None,
863            None,
864        )
865        .await
866        .unwrap();
867        let client = insert_device_client(tx.as_mut()).await;
868
869        // RT1 -> RT2 (normal rotation; revokes the family).
870        let rt1 = insert_refresh_token(tx.as_mut(), user, client.id).await;
871        let pair2 = generate_token_pair(&key);
872        let rt2 = pair2.refresh_token.clone();
873        let at2 = pair2.access_token.clone();
874        let grant1 = refresh_grant(&rt1);
875        process_token_grant(
876            tx.as_mut(),
877            &test_cache(),
878            build_request_with_pair(&grant1, &client, &key, pair2),
879        )
880        .await
881        .expect("initial rotation should succeed");
882
883        // The rotation revoked RT1 along with the rest of the family.
884        assert!(
885            OAuthRefreshTokens::find_valid(tx.as_mut(), token_digest_sha256(&rt1, &key))
886                .await
887                .is_err(),
888            "the rotated token must no longer be valid"
889        );
890
891        // Reusing RT1 fails, even immediately after the rotation.
892        let grant2 = refresh_grant(&rt1);
893        let err = process_token_grant(
894            tx.as_mut(),
895            &test_cache(),
896            build_request_with_pair(&grant2, &client, &key, generate_token_pair(&key)),
897        )
898        .await
899        .expect_err("reusing a rotated refresh token must fail");
900        assert!(matches!(err, TokenGrantError::InvalidGrant(_)));
901
902        // The takedown reaches the successor the legitimate holder still has: the victim is
903        // logged out rather than left sharing a live family with the attacker.
904        assert!(
905            OAuthRefreshTokens::find_valid(tx.as_mut(), token_digest_sha256(&rt2, &key))
906                .await
907                .is_err(),
908            "RT2 must be dead after a reuse of its predecessor was detected"
909        );
910        // The access token issued alongside RT2 goes with it, or the bearer keeps working.
911        assert!(
912            OAuthAccessToken::find_valid(tx.as_mut(), token_digest_sha256(&at2, &key))
913                .await
914                .is_err(),
915            "the access token from the rotated pair must be revoked with the family"
916        );
917
918        tx.rollback().await;
919    }
920
921    #[actix_web::test]
922    async fn refresh_with_subset_scope_narrows_the_issued_tokens() {
923        let mut conn = Conn::init().await;
924        let mut tx = conn.begin().await;
925        let key = hmac_key();
926
927        let user = users::insert(
928            tx.as_mut(),
929            PKeyPolicy::Generate,
930            "refresh-downscope@example.com",
931            None,
932            None,
933        )
934        .await
935        .unwrap();
936        let client = insert_device_client(tx.as_mut()).await;
937
938        let original_scopes = vec!["exercise-services".to_string(), "openid".to_string()];
939        let rt1 =
940            insert_refresh_token_with_scopes(tx.as_mut(), user, client.id, &original_scopes).await;
941
942        let pair2 = generate_token_pair(&key);
943        // `Digest` doesn't implement `Clone` (it wraps a `SecretBox`), so hang
944        // on to the plaintexts instead and re-derive the digests afterwards.
945        let new_refresh_plaintext = pair2.refresh_token.clone();
946        let new_access_plaintext = pair2.access_token.clone();
947        let grant = refresh_grant_with_scope(&rt1, "exercise-services");
948        let result = process_token_grant(
949            tx.as_mut(),
950            &test_cache(),
951            build_request_with_pair(&grant, &client, &key, pair2),
952        )
953        .await
954        .expect("requesting a subset of the original scope should be granted");
955
956        // The response reports the narrowed scope, not the original one.
957        assert_eq!(result.scopes, vec!["exercise-services".to_string()]);
958
959        // The persisted refresh + access token rows are narrowed too, not
960        // just the response body: an old-scope check against the DB row
961        // (e.g. a later exercise-services gate) must see the same narrowed
962        // set the client asked for.
963        let new_refresh_row = OAuthRefreshTokens::find_valid(
964            tx.as_mut(),
965            token_digest_sha256(&new_refresh_plaintext, &key),
966        )
967        .await
968        .expect("new refresh token should be valid");
969        assert_eq!(
970            new_refresh_row.scopes,
971            vec!["exercise-services".to_string()]
972        );
973
974        let new_access_row = OAuthAccessToken::find_valid(
975            tx.as_mut(),
976            token_digest_sha256(&new_access_plaintext, &key),
977        )
978        .await
979        .expect("new access token should be valid");
980        assert_eq!(new_access_row.scopes, vec!["exercise-services".to_string()]);
981
982        tx.rollback().await;
983    }
984
985    #[actix_web::test]
986    async fn refresh_requesting_scope_outside_original_grant_is_rejected_without_consuming_token() {
987        let mut conn = Conn::init().await;
988        let mut tx = conn.begin().await;
989        let key = hmac_key();
990
991        let user = users::insert(
992            tx.as_mut(),
993            PKeyPolicy::Generate,
994            "refresh-widescope@example.com",
995            None,
996            None,
997        )
998        .await
999        .unwrap();
1000        let client = insert_device_client(tx.as_mut()).await;
1001
1002        let original_scopes = vec!["exercise-services".to_string()];
1003        let rt1 =
1004            insert_refresh_token_with_scopes(tx.as_mut(), user, client.id, &original_scopes).await;
1005
1006        // Ask for a scope that was never granted to this refresh token.
1007        let grant = refresh_grant_with_scope(&rt1, "exercise-services admin");
1008        let err = process_token_grant(
1009            tx.as_mut(),
1010            &test_cache(),
1011            build_request_with_pair(&grant, &client, &key, generate_token_pair(&key)),
1012        )
1013        .await
1014        .expect_err("a wider-than-original scope request must be rejected");
1015        assert!(matches!(err, TokenGrantError::InvalidScope(_)));
1016
1017        // The rejected request must not have burned the single-use refresh
1018        // token: it must still be redeemable afterwards.
1019        let rt1_digest = token_digest_sha256(&rt1, &key);
1020        let still_valid = OAuthRefreshTokens::find_valid(tx.as_mut(), rt1_digest).await;
1021        assert!(
1022            still_valid.is_ok(),
1023            "a rejected down-scope request must not consume/rotate the refresh token"
1024        );
1025
1026        // And it must still be genuinely usable for a normal (non-down-scoping) refresh.
1027        let grant2 = refresh_grant(&rt1);
1028        process_token_grant(
1029            tx.as_mut(),
1030            &test_cache(),
1031            build_request_with_pair(&grant2, &client, &key, generate_token_pair(&key)),
1032        )
1033        .await
1034        .expect("the untouched refresh token should still work for a normal refresh");
1035
1036        tx.rollback().await;
1037    }
1038}