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