headless_lms_models/
oauth_dpop_proofs.rs

1use crate::library::oauth::Digest;
2use crate::prelude::*;
3use chrono::{DateTime, TimeZone, Utc};
4use sqlx::{FromRow, PgConnection};
5
6/// **INTERNAL/DATABASE-ONLY MODEL - DO NOT EXPOSE TO CLIENTS**
7///
8/// This struct is a database model that contains a `Digest` field, which contains raw bytes
9/// and uses custom (de)serialization. This model must **never** be serialized into external
10/// API payloads or returned directly to clients.
11///
12/// For external-facing responses, use DTOs such as `TokenResponse`, `UserInfoResponse`, or
13/// an explicit redacting wrapper that strips or converts `Digest` fields to safe types (e.g., strings).
14///
15/// **Rationale**: The `Digest` type contains sensitive raw bytes and uses custom serialization
16/// that is not suitable for external APIs. Exposing this model directly could leak internal
17/// implementation details or cause serialization issues.
18#[derive(Debug, Serialize, Deserialize, FromRow)]
19pub struct OAuthDpopProof {
20    pub jti_hash: Digest,           // SHA-256(jti)
21    pub seen_at: DateTime<Utc>,     // when first observed (DB default now())
22    pub client_id: Option<String>,  // optional audit fields
23    pub jkt: Option<String>,        // RFC 7638 thumbprint
24    pub htm: Option<String>,        // HTTP method
25    pub htu: Option<String>,        // HTTP URL (no query)
26    pub iat: Option<DateTime<Utc>>, // issued-at
27}
28
29impl OAuthDpopProof {
30    /// Atomically record this DPoP proof exactly once.
31    /// Returns:
32    ///   - Ok(true)  => first time seen (ACCEPT)
33    ///   - Ok(false) => already seen (REPLAY -> REJECT)
34    pub async fn insert_once(
35        conn: &mut PgConnection,
36        jti_hash: Digest,
37        client_id: Option<&str>,
38        jkt: Option<&str>,
39        htm: Option<&str>,
40        htu: Option<&str>,
41        iat_epoch: Option<i64>,
42    ) -> ModelResult<bool> {
43        let mut tx = conn.begin().await?;
44
45        let iat_ts: Option<DateTime<Utc>> =
46            iat_epoch.and_then(|s| Utc.timestamp_opt(s, 0).single());
47
48        let rows = sqlx::query!(
49            r#"
50            INSERT INTO oauth_dpop_proofs (jti_hash, client_id, jkt, htm, htu, iat)
51            VALUES ($1, $2, $3, $4, $5, $6)
52            ON CONFLICT DO NOTHING
53            "#,
54            jti_hash.as_bytes(),
55            client_id,
56            jkt,
57            htm,
58            htu,
59            iat_ts,
60        )
61        .execute(&mut *tx)
62        .await?
63        .rows_affected();
64
65        tx.commit().await?;
66        Ok(rows == 1)
67    }
68
69    /// Fetch a stored proof row (for audits/debug).
70    pub async fn find_by_jti_hash(
71        conn: &mut PgConnection,
72        jti_hash: Digest,
73    ) -> ModelResult<Option<OAuthDpopProof>> {
74        let mut tx = conn.begin().await?;
75        let row = sqlx::query_as!(
76            OAuthDpopProof,
77            r#"
78            SELECT
79              jti_hash  AS "jti_hash: _",
80              seen_at   AS "seen_at: _",
81              client_id AS "client_id?",
82              jkt       AS "jkt?",
83              htm       AS "htm?",
84              htu       AS "htu?",
85              iat       AS "iat?"
86            FROM oauth_dpop_proofs
87            WHERE jti_hash = $1
88            "#,
89            jti_hash.as_bytes(),
90        )
91        .fetch_optional(&mut *tx)
92        .await?;
93        tx.commit().await?;
94        Ok(row)
95    }
96
97    /// Delete old entries (call from a periodic task).
98    /// Returns number of rows removed.
99    pub async fn prune_older_than(conn: &mut PgConnection, keep_seconds: i64) -> ModelResult<u64> {
100        if keep_seconds < 0 {
101            return Err(ModelError::new(
102                ModelErrorType::Generic,
103                "keep_seconds must be >= 0",
104                None,
105            ));
106        }
107        let mut tx = conn.begin().await?;
108        let res = sqlx::query!(
109            r#"
110            DELETE FROM oauth_dpop_proofs
111            WHERE seen_at < now() - ($1::bigint * interval '1 second')
112            "#,
113            keep_seconds,
114        )
115        .execute(&mut *tx)
116        .await?;
117        tx.commit().await?;
118        Ok(res.rows_affected())
119    }
120}