Skip to main content

headless_lms_models/
oauth_device_codes.rs

1use crate::{library::oauth::Digest, prelude::*};
2use chrono::{DateTime, Duration, Utc};
3use serde::{Deserialize, Serialize};
4use sqlx::{FromRow, PgConnection, Type};
5use uuid::Uuid;
6
7/// Approval lifecycle of a device authorization grant.
8///
9/// Maps 1:1 to the PostgreSQL `device_code_status` enum.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
11#[sqlx(type_name = "device_code_status", rename_all = "snake_case")]
12#[serde(rename_all = "snake_case")]
13pub enum DeviceCodeStatus {
14    Pending,
15    Approved,
16    Denied,
17}
18
19/// **INTERNAL/DATABASE-ONLY MODEL - DO NOT EXPOSE TO CLIENTS**
20///
21/// Contains a `Digest` field with raw bytes and custom serialization; never serialize this
22/// directly into external API payloads. Use DTOs that strip or convert `Digest` fields instead.
23#[derive(Debug, Serialize, Deserialize, FromRow)]
24pub struct OAuthDeviceCode {
25    pub device_code_digest: Digest,
26    pub user_code: String,
27    pub client_id: Uuid,
28    /// `None` until the user approves the grant on the verification page.
29    pub user_id: Option<Uuid>,
30    pub scopes: Vec<String>,
31    pub status: DeviceCodeStatus,
32    pub jti: Uuid,
33    pub interval_seconds: i32,
34    pub last_polled_at: Option<DateTime<Utc>>,
35    pub expires_at: DateTime<Utc>,
36    pub created_at: DateTime<Utc>,
37    pub updated_at: DateTime<Utc>,
38    pub metadata: serde_json::Value,
39}
40
41#[derive(Debug, Clone)]
42pub struct NewDeviceCodeParams<'a> {
43    pub device_code_digest: &'a Digest,
44    pub user_code: &'a str,
45    pub client_id: Uuid,
46    pub scopes: &'a [String],
47    pub interval_seconds: i32,
48    pub expires_at: DateTime<Utc>,
49    pub metadata: serde_json::Map<String, serde_json::Value>,
50}
51
52/// Result of recording a poll against a device code.
53///
54/// Carries just enough state for the token endpoint to decide between
55/// `slow_down`, `authorization_pending`, `expired_token`, `access_denied`, or
56/// proceeding to token issuance. `previous_polled_at` is the value of
57/// `last_polled_at` *before* this poll updated it, so the caller can detect a
58/// client polling faster than `interval_seconds`.
59#[derive(Debug, Clone)]
60pub struct DeviceCodePoll {
61    pub status: DeviceCodeStatus,
62    pub expires_at: DateTime<Utc>,
63    pub interval_seconds: i32,
64    pub previous_polled_at: Option<DateTime<Utc>>,
65    pub user_id: Option<Uuid>,
66    pub client_id: Uuid,
67    pub scopes: Vec<String>,
68}
69
70impl OAuthDeviceCode {
71    /// Insert a new pending device authorization grant.
72    pub async fn insert(
73        conn: &mut PgConnection,
74        params: NewDeviceCodeParams<'_>,
75    ) -> ModelResult<()> {
76        sqlx::query!(
77            r#"
78            INSERT INTO oauth_device_codes (
79                device_code_digest,
80                user_code,
81                client_id,
82                scopes,
83                interval_seconds,
84                expires_at,
85                metadata
86            )
87            VALUES (
88                $1,$2,$3,$4,$5,$6,$7
89            )
90            "#,
91            params.device_code_digest.as_bytes(),
92            params.user_code,
93            params.client_id,
94            params.scopes,
95            params.interval_seconds,
96            params.expires_at,
97            serde_json::Value::Object(params.metadata)
98        )
99        .execute(conn)
100        .await?;
101
102        Ok(())
103    }
104
105    /// How long an expired device code is kept before it is prunable.
106    ///
107    /// Deleting on the stroke of expiry would turn a device still polling into `invalid_grant`
108    /// instead of the `expired_token` RFC 8628 §3.5 prescribes; the grace period keeps that answer
109    /// truthful.
110    pub const EXPIRED_RETENTION: Duration = Duration::hours(1);
111
112    /// Deletes device codes that expired more than [`EXPIRED_RETENTION`] ago, whatever their
113    /// status.
114    ///
115    /// Only the approved-and-redeemed path deletes its own row, so without this every abandoned
116    /// login leaves a permanent row whose `user_code` is unusable — excluded by
117    /// `expires_at > now()` — yet unreclaimable, since the partial unique index still holds it
118    /// while `status = 'pending'`. Called opportunistically from the device-authorization
119    /// endpoint: the table only grows when that endpoint is used, so that is also where it can
120    /// shrink, with no CronJob to forget to deploy.
121    pub async fn delete_expired(conn: &mut PgConnection) -> ModelResult<u64> {
122        let deleted = sqlx::query!(
123            r#"DELETE FROM oauth_device_codes WHERE expires_at < now() - $1::interval"#,
124            Self::EXPIRED_RETENTION as Duration
125        )
126        .execute(conn)
127        .await?
128        .rows_affected();
129        Ok(deleted)
130    }
131
132    /// Find the still-valid, pending grant for a given `user_code`.
133    ///
134    /// Used by the verification page to render the pending consent request.
135    /// The partial unique index guarantees at most one pending row per code.
136    pub async fn find_pending_by_user_code(
137        conn: &mut PgConnection,
138        user_code: &str,
139    ) -> ModelResult<OAuthDeviceCode> {
140        let row = sqlx::query_as!(
141            OAuthDeviceCode,
142            r#"
143            SELECT *
144            FROM oauth_device_codes
145            WHERE user_code = $1
146              AND status = 'pending'
147              AND expires_at > now()
148            "#,
149            user_code
150        )
151        .fetch_one(conn)
152        .await?;
153
154        Ok(row)
155    }
156
157    /// Approve a pending grant, attaching the approving user in a single statement.
158    ///
159    /// Only affects a row that is still pending and unexpired.
160    pub async fn approve(
161        conn: &mut PgConnection,
162        user_code: &str,
163        user_id: Uuid,
164    ) -> ModelResult<OAuthDeviceCode> {
165        let row = sqlx::query_as!(
166            OAuthDeviceCode,
167            r#"
168            UPDATE oauth_device_codes
169               SET user_id = $2,
170                   status = 'approved'
171             WHERE user_code = $1
172               AND status = 'pending'
173               AND expires_at > now()
174            RETURNING *
175            "#,
176            user_code,
177            user_id
178        )
179        .fetch_one(conn)
180        .await?;
181
182        Ok(row)
183    }
184
185    /// Deny a pending grant.
186    ///
187    /// Only affects a row that is still pending and unexpired.
188    pub async fn deny(conn: &mut PgConnection, user_code: &str) -> ModelResult<OAuthDeviceCode> {
189        let row = sqlx::query_as!(
190            OAuthDeviceCode,
191            r#"
192            UPDATE oauth_device_codes
193               SET status = 'denied'
194             WHERE user_code = $1
195               AND status = 'pending'
196               AND expires_at > now()
197            RETURNING *
198            "#,
199            user_code
200        )
201        .fetch_one(conn)
202        .await?;
203
204        Ok(row)
205    }
206
207    /// Record a poll from the token endpoint and return the state needed to
208    /// decide the response.
209    ///
210    /// Atomically reads the previous `last_polled_at` and advances it to now.
211    /// The returned `previous_polled_at` lets the caller detect a client
212    /// polling faster than `interval_seconds` (=> `slow_down`). Returns a
213    /// record-not-found error if the digest is unknown.
214    pub async fn record_poll(
215        conn: &mut PgConnection,
216        device_code_digest: &Digest,
217    ) -> ModelResult<DeviceCodePoll> {
218        let row = sqlx::query!(
219            r#"
220            WITH current AS (
221                SELECT device_code_digest, last_polled_at
222                FROM oauth_device_codes
223                WHERE device_code_digest = $1
224                FOR UPDATE
225            )
226            UPDATE oauth_device_codes d
227               SET last_polled_at = now()
228              FROM current c
229             WHERE d.device_code_digest = c.device_code_digest
230            RETURNING
231                c.last_polled_at AS "previous_polled_at",
232                d.status,
233                d.expires_at AS "expires_at!",
234                d.interval_seconds AS "interval_seconds!",
235                d.user_id,
236                d.client_id AS "client_id!",
237                d.scopes AS "scopes!"
238            "#,
239            device_code_digest.as_bytes()
240        )
241        .fetch_one(conn)
242        .await?;
243
244        Ok(DeviceCodePoll {
245            status: row.status,
246            expires_at: row.expires_at,
247            interval_seconds: row.interval_seconds,
248            previous_polled_at: row.previous_polled_at,
249            user_id: row.user_id,
250            client_id: row.client_id,
251            scopes: row.scopes,
252        })
253    }
254
255    /// Single-use redemption of an approved device code within an existing transaction.
256    ///
257    /// Deletes the approved, unexpired row and returns it, so a second
258    /// redemption of the same code finds nothing (single-use). The returned row
259    /// always has `user_id = Some(..)` (enforced by the `approve` statement and
260    /// the DB check constraint).
261    pub async fn consume_approved_in_transaction(
262        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
263        device_code_digest: &Digest,
264    ) -> ModelResult<OAuthDeviceCode> {
265        let row = sqlx::query_as!(
266            OAuthDeviceCode,
267            r#"
268            DELETE FROM oauth_device_codes
269             WHERE device_code_digest = $1
270               AND status = 'approved'
271               AND expires_at > now()
272            RETURNING *
273            "#,
274            device_code_digest.as_bytes()
275        )
276        .fetch_one(&mut **tx)
277        .await?;
278
279        Ok(row)
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::{
287        library::oauth::{
288            GrantTypeName, generate_user_code,
289            pkce::PkceMethod,
290            tokens::{generate_access_token, token_digest_sha256},
291        },
292        oauth_client::{ApplicationType, NewClientParams, OAuthClient, TokenEndpointAuthMethod},
293        test_helper::*,
294    };
295    use chrono::Duration;
296    use secrecy::SecretString;
297
298    fn hmac_key() -> SecretString {
299        SecretString::new("test-device-code-hmac-key".to_string().into())
300    }
301
302    async fn insert_public_client(conn: &mut PgConnection) -> OAuthClient {
303        let client_id = format!("cli-{}", &generate_access_token()[..12]);
304        OAuthClient::insert(
305            conn,
306            NewClientParams {
307                client_id: &client_id,
308                client_name: "Device flow test client",
309                application_type: ApplicationType::Native,
310                token_endpoint_auth_method: TokenEndpointAuthMethod::None,
311                client_secret: None,
312                client_secret_expires_at: None,
313                redirect_uris: &["urn:ietf:wg:oauth:2.0:oob".to_string()],
314                post_logout_redirect_uris: None,
315                allowed_grant_types: &[GrantTypeName::DeviceCode, GrantTypeName::RefreshToken],
316                scopes: &["exercise-services".to_string()],
317                require_pkce: true,
318                pkce_methods_allowed: &[PkceMethod::S256],
319                allowed_origins: None,
320                bearer_allowed: true,
321            },
322        )
323        .await
324        .unwrap()
325    }
326
327    fn new_params<'a>(
328        digest: &'a Digest,
329        user_code: &'a str,
330        client_id: Uuid,
331        scopes: &'a [String],
332    ) -> NewDeviceCodeParams<'a> {
333        NewDeviceCodeParams {
334            device_code_digest: digest,
335            user_code,
336            client_id,
337            scopes,
338            interval_seconds: 5,
339            expires_at: Utc::now() + Duration::minutes(15),
340            metadata: serde_json::Map::new(),
341        }
342    }
343
344    #[tokio::test]
345    async fn insert_find_approve_and_consume() {
346        insert_data!(:tx, :user);
347        let client = insert_public_client(tx.as_mut()).await;
348
349        let user_code = generate_user_code();
350        let device_code = generate_access_token();
351        let digest = token_digest_sha256(&device_code, &hmac_key());
352        let scopes = vec!["exercise-services".to_string()];
353
354        OAuthDeviceCode::insert(
355            tx.as_mut(),
356            new_params(&digest, &user_code, client.id, &scopes),
357        )
358        .await
359        .unwrap();
360
361        let pending = OAuthDeviceCode::find_pending_by_user_code(tx.as_mut(), &user_code)
362            .await
363            .unwrap();
364        assert_eq!(pending.status, DeviceCodeStatus::Pending);
365        assert_eq!(pending.user_id, None);
366        assert_eq!(pending.scopes, scopes);
367
368        let approved = OAuthDeviceCode::approve(tx.as_mut(), &user_code, user)
369            .await
370            .unwrap();
371        assert_eq!(approved.status, DeviceCodeStatus::Approved);
372        assert_eq!(approved.user_id, Some(user));
373
374        // No longer pending once approved.
375        assert!(
376            OAuthDeviceCode::find_pending_by_user_code(tx.as_mut(), &user_code)
377                .await
378                .is_err()
379        );
380
381        // Single-use redemption succeeds once...
382        let mut inner = tx.begin().await;
383        let consumed = OAuthDeviceCode::consume_approved_in_transaction(inner.as_mut(), &digest)
384            .await
385            .unwrap();
386        assert_eq!(consumed.user_id, Some(user));
387        assert_eq!(consumed.client_id, client.id);
388        // ...and not a second time (row deleted).
389        assert!(
390            OAuthDeviceCode::consume_approved_in_transaction(inner.as_mut(), &digest)
391                .await
392                .is_err()
393        );
394        inner.rollback().await;
395    }
396
397    #[tokio::test]
398    async fn deny_marks_denied() {
399        insert_data!(:tx, :user);
400        let _ = user;
401        let client = insert_public_client(tx.as_mut()).await;
402
403        let user_code = generate_user_code();
404        let digest = token_digest_sha256(&generate_access_token(), &hmac_key());
405        let scopes = vec!["exercise-services".to_string()];
406
407        OAuthDeviceCode::insert(
408            tx.as_mut(),
409            new_params(&digest, &user_code, client.id, &scopes),
410        )
411        .await
412        .unwrap();
413
414        let denied = OAuthDeviceCode::deny(tx.as_mut(), &user_code)
415            .await
416            .unwrap();
417        assert_eq!(denied.status, DeviceCodeStatus::Denied);
418
419        // A denied code cannot be consumed.
420        let mut inner = tx.begin().await;
421        assert!(
422            OAuthDeviceCode::consume_approved_in_transaction(inner.as_mut(), &digest)
423                .await
424                .is_err()
425        );
426        inner.rollback().await;
427    }
428
429    #[tokio::test]
430    async fn record_poll_reports_previous_poll_time() {
431        insert_data!(:tx, :user);
432        let _ = user;
433        let client = insert_public_client(tx.as_mut()).await;
434
435        let user_code = generate_user_code();
436        let digest = token_digest_sha256(&generate_access_token(), &hmac_key());
437        let scopes = vec!["exercise-services".to_string()];
438
439        OAuthDeviceCode::insert(
440            tx.as_mut(),
441            new_params(&digest, &user_code, client.id, &scopes),
442        )
443        .await
444        .unwrap();
445
446        // First poll: no previous timestamp (=> not too fast).
447        let first = OAuthDeviceCode::record_poll(tx.as_mut(), &digest)
448            .await
449            .unwrap();
450        assert_eq!(first.status, DeviceCodeStatus::Pending);
451        assert!(first.previous_polled_at.is_none());
452        assert_eq!(first.interval_seconds, 5);
453
454        // Second poll: previous timestamp is now populated.
455        let second = OAuthDeviceCode::record_poll(tx.as_mut(), &digest)
456            .await
457            .unwrap();
458        assert!(second.previous_polled_at.is_some());
459    }
460}