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