Skip to main content

headless_lms_models/
oauth_refresh_tokens.rs

1use crate::oauth_access_token::{NewAccessTokenParams, OAuthAccessToken, TokenType};
2use crate::{library::oauth::Digest, prelude::*};
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use sqlx::{FromRow, PgConnection};
6use uuid::Uuid;
7
8/// **INTERNAL/DATABASE-ONLY MODEL - DO NOT EXPOSE TO CLIENTS**
9///
10/// This struct is a database model that contains a `Digest` field, which contains raw bytes
11/// and uses custom (de)serialization. This model must **never** be serialized into external
12/// API payloads or returned directly to clients.
13///
14/// For external-facing responses, use DTOs such as `TokenResponse`, `UserInfoResponse`, or
15/// an explicit redacting wrapper that strips or converts `Digest` fields to safe types (e.g., strings).
16///
17/// **Rationale**: The `Digest` type contains sensitive raw bytes and uses custom serialization
18/// that is not suitable for external APIs. Exposing this model directly could leak internal
19/// implementation details or cause serialization issues.
20#[derive(Debug, Serialize, Deserialize, FromRow)]
21pub struct OAuthRefreshTokens {
22    pub digest: Digest,
23    pub user_id: Uuid,
24    pub client_id: Uuid,
25    pub expires_at: DateTime<Utc>,
26    pub scopes: Vec<String>,
27    pub audience: Option<Vec<String>>,
28    pub jti: Uuid,
29    /// Optional DPoP sender constraint
30    pub dpop_jkt: Option<String>,
31    pub metadata: serde_json::Value,
32    pub revoked: bool,
33    pub rotated_from: Option<Digest>,
34    pub created_at: DateTime<Utc>,
35    pub updated_at: DateTime<Utc>,
36}
37
38#[derive(Debug, Clone)]
39pub struct NewRefreshTokenParams<'a> {
40    pub digest: &'a Digest,
41    pub user_id: Uuid,
42    pub client_id: Uuid,
43    pub scopes: &'a [String],
44    pub audience: Option<&'a [String]>,
45    pub expires_at: DateTime<Utc>,
46    pub rotated_from: Option<&'a Digest>,
47    pub metadata: serde_json::Map<String, serde_json::Value>,
48    /// Provide Some(jkt) to sender-constrain this RT; None for unconstrained
49    pub dpop_jkt: Option<&'a str>,
50}
51
52/// Parameters for rotating a refresh token (refresh token grant flow).
53#[derive(Debug)]
54pub struct RotateRefreshTokenParams<'a> {
55    pub new_refresh_token_digest: &'a Digest,
56    pub new_access_token_digest: &'a Digest,
57    pub access_token_expires_at: DateTime<Utc>,
58    pub refresh_token_expires_at: DateTime<Utc>,
59    pub access_token_type: TokenType,
60    pub access_token_dpop_jkt: Option<&'a str>,
61    pub refresh_token_dpop_jkt: Option<&'a str>,
62}
63
64/// Parameters for issuing tokens from an authorization code.
65#[derive(Debug, Clone)]
66pub struct IssueTokensFromAuthCodeParams<'a> {
67    pub user_id: Uuid,
68    pub client_id: Uuid,
69    pub scopes: &'a [String],
70    pub access_token_digest: &'a Digest,
71    pub refresh_token_digest: &'a Digest,
72    pub access_token_expires_at: DateTime<Utc>,
73    pub refresh_token_expires_at: DateTime<Utc>,
74    pub access_token_type: TokenType,
75    pub access_token_dpop_jkt: Option<&'a str>,
76    pub refresh_token_dpop_jkt: Option<&'a str>,
77}
78
79impl OAuthRefreshTokens {
80    pub async fn insert(
81        conn: &mut PgConnection,
82        params: NewRefreshTokenParams<'_>,
83    ) -> ModelResult<()> {
84        sqlx::query!(
85            r#"
86            INSERT INTO oauth_refresh_tokens
87              (digest, user_id, client_id, scopes, audience, jti, expires_at, revoked, rotated_from, metadata, dpop_jkt)
88            VALUES
89              ($1,    $2,     $3,        $4,     $5,       gen_random_uuid(), $6,       false,   $7,          $8,      $9)
90            "#,
91            params.digest.as_bytes(),
92            params.user_id,
93            params.client_id,
94            params.scopes,
95            params.audience,
96            params.expires_at,
97            params.rotated_from.map(|d| d.as_bytes() as &[u8]),
98            serde_json::Value::Object(params.metadata),
99            params.dpop_jkt
100        )
101        .execute(conn)
102        .await?;
103        Ok(())
104    }
105
106    pub async fn find_valid(
107        conn: &mut PgConnection,
108        digest: Digest,
109    ) -> ModelResult<OAuthRefreshTokens> {
110        let mut tx = conn.begin().await?;
111        let token = sqlx::query_as!(
112            OAuthRefreshTokens,
113            r#"
114            SELECT *
115            FROM oauth_refresh_tokens
116            WHERE digest = $1
117              AND expires_at > now()
118              AND revoked = false
119            "#,
120            digest.as_bytes()
121        )
122        .fetch_one(&mut *tx)
123        .await?;
124        tx.commit().await?;
125        Ok(token)
126    }
127
128    /// Optional stricter variant: if the RT is sender-constrained (has `dpop_jkt`), require a matching `presented_jkt`.
129    pub async fn find_valid_for_sender(
130        conn: &mut PgConnection,
131        digest: Digest,
132        presented_jkt: Option<&str>,
133    ) -> ModelResult<OAuthRefreshTokens> {
134        let t = Self::find_valid(conn, digest).await?;
135        if let Some(expected) = t.dpop_jkt.as_deref() {
136            let Some(presented) = presented_jkt else {
137                return Err(ModelError::new(
138                    ModelErrorType::PreconditionFailed,
139                    "refresh token requires DPoP but no JKT presented",
140                    None::<anyhow::Error>,
141                ));
142            };
143            if expected != presented {
144                return Err(ModelError::new(
145                    ModelErrorType::PreconditionFailed,
146                    "DPoP JKT mismatch for refresh token",
147                    None::<anyhow::Error>,
148                ));
149            }
150        }
151        Ok(t)
152    }
153
154    pub async fn revoke_by_digest(conn: &mut PgConnection, digest: Digest) -> ModelResult<()> {
155        let mut tx = conn.begin().await?;
156        sqlx::query!(
157            r#"
158            UPDATE oauth_refresh_tokens
159               SET revoked = true
160             WHERE digest = $1
161            "#,
162            digest.as_bytes()
163        )
164        .execute(&mut *tx)
165        .await?;
166        tx.commit().await?;
167        Ok(())
168    }
169
170    pub async fn revoke_all_by_user_client(
171        conn: &mut PgConnection,
172        user_id: Uuid,
173        client_id: Uuid,
174    ) -> ModelResult<()> {
175        let mut tx = conn.begin().await?;
176        sqlx::query!(
177            r#"
178            UPDATE oauth_refresh_tokens
179               SET revoked = true
180             WHERE user_id = $1 AND client_id = $2
181            "#,
182            user_id,
183            client_id
184        )
185        .execute(&mut *tx)
186        .await?;
187        tx.commit().await?;
188        Ok(())
189    }
190
191    /// Consume a refresh token within an existing transaction.
192    ///
193    /// # Transaction Requirements
194    /// This method must be called within an existing database transaction.
195    /// The caller is responsible for managing the transaction (begin, commit, rollback).
196    ///
197    /// Returns the consumed token data.
198    pub async fn consume_in_transaction(
199        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
200        digest: Digest,
201        client_id: Uuid,
202    ) -> ModelResult<OAuthRefreshTokens> {
203        let row = sqlx::query_as!(
204            OAuthRefreshTokens,
205            r#"
206            UPDATE oauth_refresh_tokens
207               SET revoked = true
208             WHERE digest = $1
209               AND client_id = $2
210               AND revoked = false
211               AND expires_at > now()
212            RETURNING *
213            "#,
214            digest.as_bytes(),
215            client_id
216        )
217        .fetch_one(&mut **tx)
218        .await?;
219        Ok(row)
220    }
221
222    /// Complete refresh token rotation within an existing transaction after token has been consumed.
223    ///
224    /// # Transaction Requirements
225    /// This method must be called within an existing database transaction.
226    /// The caller is responsible for managing the transaction (begin, commit, rollback).
227    ///
228    /// Revokes all tokens for user/client, inserts new refresh token, and inserts new access token.
229    pub async fn complete_refresh_token_rotation_in_transaction(
230        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
231        old_token: &OAuthRefreshTokens,
232        params: RotateRefreshTokenParams<'_>,
233    ) -> ModelResult<()> {
234        // Revoke all tokens for user/client
235        sqlx::query!(
236            r#"
237            UPDATE oauth_refresh_tokens
238               SET revoked = true
239             WHERE user_id = $1 AND client_id = $2
240            "#,
241            old_token.user_id,
242            old_token.client_id
243        )
244        .execute(&mut **tx)
245        .await?;
246
247        // Insert new refresh token
248        sqlx::query!(
249            r#"
250            INSERT INTO oauth_refresh_tokens
251              (digest, user_id, client_id, scopes, audience, jti, expires_at, revoked, rotated_from, metadata, dpop_jkt)
252            VALUES
253              ($1,    $2,     $3,        $4,     $5,       gen_random_uuid(), $6,       false,   $7,          $8,      $9)
254            "#,
255            params.new_refresh_token_digest.as_bytes(),
256            old_token.user_id,
257            old_token.client_id,
258            &old_token.scopes,
259            old_token.audience.as_deref(),
260            params.refresh_token_expires_at,
261            old_token.digest.as_bytes(),
262            serde_json::Value::Object(serde_json::Map::new()),
263            params.refresh_token_dpop_jkt
264        )
265        .execute(&mut **tx)
266        .await?;
267
268        // Insert new access token
269        OAuthAccessToken::insert(
270            tx,
271            NewAccessTokenParams {
272                digest: params.new_access_token_digest,
273                user_id: Some(old_token.user_id),
274                client_id: old_token.client_id,
275                scopes: &old_token.scopes,
276                audience: old_token.audience.as_deref(),
277                token_type: params.access_token_type,
278                dpop_jkt: params.access_token_dpop_jkt,
279                metadata: serde_json::Map::new(),
280                expires_at: params.access_token_expires_at,
281            },
282        )
283        .await?;
284
285        Ok(())
286    }
287
288    /// Issue tokens from authorization code within an existing transaction.
289    ///
290    /// # Transaction Requirements
291    /// This method must be called within an existing database transaction.
292    /// The caller is responsible for managing the transaction (begin, commit, rollback).
293    ///
294    /// Inserts access token, revokes all refresh tokens for user/client, and inserts new refresh token.
295    pub async fn issue_tokens_from_auth_code_in_transaction(
296        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
297        params: IssueTokensFromAuthCodeParams<'_>,
298    ) -> ModelResult<()> {
299        // Insert access token
300        OAuthAccessToken::insert(
301            tx,
302            NewAccessTokenParams {
303                digest: params.access_token_digest,
304                user_id: Some(params.user_id),
305                client_id: params.client_id,
306                scopes: params.scopes,
307                audience: None,
308                token_type: params.access_token_type,
309                dpop_jkt: params.access_token_dpop_jkt,
310                metadata: serde_json::Map::new(),
311                expires_at: params.access_token_expires_at,
312            },
313        )
314        .await?;
315
316        // Revoke all refresh tokens for user/client
317        sqlx::query!(
318            r#"
319            UPDATE oauth_refresh_tokens
320               SET revoked = true
321             WHERE user_id = $1 AND client_id = $2
322            "#,
323            params.user_id,
324            params.client_id
325        )
326        .execute(&mut **tx)
327        .await?;
328
329        // Insert new refresh token
330        sqlx::query!(
331            r#"
332            INSERT INTO oauth_refresh_tokens
333              (digest, user_id, client_id, scopes, audience, jti, expires_at, revoked, rotated_from, metadata, dpop_jkt)
334            VALUES
335              ($1,    $2,     $3,        $4,     $5,       gen_random_uuid(), $6,       false,   NULL,          $7,      $8)
336            "#,
337            params.refresh_token_digest.as_bytes(),
338            params.user_id,
339            params.client_id,
340            params.scopes,
341            Option::<Vec<String>>::None as Option<Vec<String>>,
342            params.refresh_token_expires_at,
343            serde_json::Value::Object(serde_json::Map::new()),
344            params.refresh_token_dpop_jkt
345        )
346        .execute(&mut **tx)
347        .await?;
348
349        Ok(())
350    }
351}