headless_lms_server/domain/oauth/
dpop.rs

1use actix_web::HttpRequest;
2use async_trait::async_trait;
3use dpop_verifier::{
4    DpopError, DpopVerifier,
5    actix_helpers::{dpop_header_str, expected_htu_from_actix},
6    replay::{ReplayContext, ReplayStore},
7};
8use headless_lms_models::library::oauth::Digest as TokenDigest;
9use headless_lms_models::oauth_dpop_proofs::OAuthDpopProof;
10use secrecy::{ExposeSecret, SecretBox};
11use sqlx::PgConnection;
12
13pub struct SqlxReplayStore<'c> {
14    pub conn: &'c mut PgConnection,
15}
16
17#[async_trait]
18impl<'c> ReplayStore for SqlxReplayStore<'c> {
19    async fn insert_once(
20        &mut self,
21        jti_hash: [u8; 32],
22        ctx: ReplayContext<'_>,
23    ) -> Result<bool, DpopError> {
24        let digest = TokenDigest::from(jti_hash);
25        let first_time = OAuthDpopProof::insert_once(
26            self.conn,
27            digest,
28            ctx.client_id,
29            ctx.jkt,       // jkt: Option<&str>
30            ctx.htm,       // htm: Option<&str>
31            ctx.htu,       // htu: Option<&str>
32            Some(ctx.iat), // iat: Option<i64>
33        )
34        .await
35        .map_err(|e| DpopError::Store(e.into()))?;
36
37        Ok(first_time)
38    }
39}
40
41/// Buffered replay context for deferred persistence (token endpoint only).
42struct BufferedReplayEntry {
43    jti_hash: [u8; 32],
44    client_id: Option<String>,
45    jkt: Option<String>,
46    htm: Option<String>,
47    htu: Option<String>,
48    iat: Option<i64>,
49}
50
51/// Replay store that defers persisting the proof until flush is called.
52/// Used at the token endpoint so that when we return UseDpopNonce we do not
53/// record the JTI, allowing the client to retry with the nonce without hitting replay.
54pub struct DeferredReplayStore {
55    buffer: Option<BufferedReplayEntry>,
56}
57
58impl Default for DeferredReplayStore {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl DeferredReplayStore {
65    pub fn new() -> Self {
66        Self { buffer: None }
67    }
68
69    /// Persist any buffered proof into the database. Call only after verification succeeded.
70    /// Returns `Err(DpopError::Replay)` if the JTI was already seen (replay).
71    pub async fn flush(&mut self, conn: &mut PgConnection) -> Result<(), DpopError> {
72        if let Some(entry) = self.buffer.take() {
73            let digest = TokenDigest::from(entry.jti_hash);
74            let first_time = OAuthDpopProof::insert_once(
75                conn,
76                digest,
77                entry.client_id.as_deref(),
78                entry.jkt.as_deref(),
79                entry.htm.as_deref(),
80                entry.htu.as_deref(),
81                entry.iat,
82            )
83            .await
84            .map_err(|e| DpopError::Store(e.into()))?;
85            if !first_time {
86                return Err(DpopError::Replay);
87            }
88        }
89        Ok(())
90    }
91}
92
93#[async_trait]
94impl ReplayStore for DeferredReplayStore {
95    async fn insert_once(
96        &mut self,
97        jti_hash: [u8; 32],
98        ctx: ReplayContext<'_>,
99    ) -> Result<bool, DpopError> {
100        self.buffer = Some(BufferedReplayEntry {
101            jti_hash,
102            client_id: ctx.client_id.map(String::from),
103            jkt: ctx.jkt.map(String::from),
104            htm: ctx.htm.map(String::from),
105            htu: ctx.htu.map(String::from),
106            iat: Some(ctx.iat),
107        });
108        // Report first-time so the verifier continues; we persist only on flush (after Ok).
109        Ok(true)
110    }
111}
112
113pub async fn verify_dpop_from_actix(
114    conn: &mut PgConnection,
115    req: &HttpRequest,
116    method: &str, // "POST", "GET", ...
117    dpop_nonce_key: &SecretBox<String>,
118    access_token: Option<&str>, // Some(at) at resource endpoints; None at /token
119) -> Result<String, DpopError> {
120    let hdr = dpop_header_str(req)?;
121
122    let htu = expected_htu_from_actix(req, true);
123
124    let mut store = SqlxReplayStore { conn };
125    let verifier = DpopVerifier::new()
126        .with_max_age_seconds(300)
127        .with_future_skew_seconds(5)
128        .with_nonce_mode(dpop_verifier::NonceMode::Hmac(
129            dpop_verifier::HmacConfig::new(
130                dpop_nonce_key.expose_secret().as_bytes(),
131                300,
132                true,
133                true,
134                true,
135            ),
136        ));
137    let verified = verifier
138        .verify(&mut store, hdr, &htu, method, access_token)
139        .await?;
140
141    Ok(verified.jkt)
142}
143
144/// DPoP verification for the token endpoint only. Uses a deferred replay store so that
145/// when the server returns UseDpopNonce the proof is not persisted; the client can retry
146/// with the nonce without the auth code being effectively revoked (replay rejection).
147pub async fn verify_dpop_from_actix_for_token(
148    conn: &mut PgConnection,
149    req: &HttpRequest,
150    dpop_nonce_key: &SecretBox<String>,
151) -> Result<String, DpopError> {
152    let hdr = dpop_header_str(req)?;
153    let htu = expected_htu_from_actix(req, true);
154
155    let mut store = DeferredReplayStore::new();
156    let verifier = DpopVerifier::new()
157        .with_max_age_seconds(300)
158        .with_future_skew_seconds(5)
159        .with_nonce_mode(dpop_verifier::NonceMode::Hmac(
160            dpop_verifier::HmacConfig::new(
161                dpop_nonce_key.expose_secret().as_bytes(),
162                300,
163                true,
164                true,
165                true,
166            ),
167        ));
168    let result = verifier
169        .verify(&mut store, hdr, &htu, "POST", None::<&str>)
170        .await;
171
172    match result {
173        Ok(verified) => {
174            store.flush(conn).await?;
175            Ok(verified.jkt)
176        }
177        Err(e) => Err(e),
178    }
179}