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    /// Scopes for the newly-issued pair, already resolved by the caller against any
63    /// down-scope request (RFC 6749 §6) rather than defaulting to `old_token.scopes` here.
64    pub scopes: &'a [String],
65}
66
67/// Parameters for issuing tokens from an authorization code.
68#[derive(Debug, Clone)]
69pub struct IssueTokensFromAuthCodeParams<'a> {
70    pub user_id: Uuid,
71    pub client_id: Uuid,
72    pub scopes: &'a [String],
73    pub access_token_digest: &'a Digest,
74    pub refresh_token_digest: &'a Digest,
75    pub access_token_expires_at: DateTime<Utc>,
76    pub refresh_token_expires_at: DateTime<Utc>,
77    pub access_token_type: TokenType,
78    pub access_token_dpop_jkt: Option<&'a str>,
79    pub refresh_token_dpop_jkt: Option<&'a str>,
80}
81
82impl OAuthRefreshTokens {
83    pub async fn insert(
84        conn: &mut PgConnection,
85        params: NewRefreshTokenParams<'_>,
86    ) -> ModelResult<()> {
87        sqlx::query!(
88            r#"
89            INSERT INTO oauth_refresh_tokens
90              (digest, user_id, client_id, scopes, audience, jti, expires_at, revoked, rotated_from, metadata, dpop_jkt)
91            VALUES
92              ($1,    $2,     $3,        $4,     $5,       gen_random_uuid(), $6,       false,   $7,          $8,      $9)
93            "#,
94            params.digest.as_bytes(),
95            params.user_id,
96            params.client_id,
97            params.scopes,
98            params.audience,
99            params.expires_at,
100            params.rotated_from.map(|d| d.as_bytes() as &[u8]),
101            serde_json::Value::Object(params.metadata),
102            params.dpop_jkt
103        )
104        .execute(conn)
105        .await?;
106        Ok(())
107    }
108
109    pub async fn find_valid(
110        conn: &mut PgConnection,
111        digest: Digest,
112    ) -> ModelResult<OAuthRefreshTokens> {
113        let mut tx = conn.begin().await?;
114        let token = sqlx::query_as!(
115            OAuthRefreshTokens,
116            r#"
117            SELECT *
118            FROM oauth_refresh_tokens
119            WHERE digest = $1
120              AND expires_at > now()
121              AND revoked = false
122            "#,
123            digest.as_bytes()
124        )
125        .fetch_one(&mut *tx)
126        .await?;
127        tx.commit().await?;
128        Ok(token)
129    }
130
131    /// Optional stricter variant: if the RT is sender-constrained (has `dpop_jkt`), require a matching `presented_jkt`.
132    pub async fn find_valid_for_sender(
133        conn: &mut PgConnection,
134        digest: Digest,
135        presented_jkt: Option<&str>,
136    ) -> ModelResult<OAuthRefreshTokens> {
137        let t = Self::find_valid(conn, digest).await?;
138        if let Some(expected) = t.dpop_jkt.as_deref() {
139            let Some(presented) = presented_jkt else {
140                return Err(ModelError::new(
141                    ModelErrorType::PreconditionFailed,
142                    "refresh token requires DPoP but no JKT presented",
143                    None::<anyhow::Error>,
144                ));
145            };
146            if expected != presented {
147                return Err(ModelError::new(
148                    ModelErrorType::PreconditionFailed,
149                    "DPoP JKT mismatch for refresh token",
150                    None::<anyhow::Error>,
151                ));
152            }
153        }
154        Ok(t)
155    }
156
157    pub async fn revoke_by_digest(conn: &mut PgConnection, digest: Digest) -> ModelResult<()> {
158        let mut tx = conn.begin().await?;
159        sqlx::query!(
160            r#"
161            UPDATE oauth_refresh_tokens
162               SET revoked = true
163             WHERE digest = $1
164            "#,
165            digest.as_bytes()
166        )
167        .execute(&mut *tx)
168        .await?;
169        tx.commit().await?;
170        Ok(())
171    }
172
173    pub async fn revoke_all_by_user_client(
174        conn: &mut PgConnection,
175        user_id: Uuid,
176        client_id: Uuid,
177    ) -> ModelResult<()> {
178        let mut tx = conn.begin().await?;
179        sqlx::query!(
180            r#"
181            UPDATE oauth_refresh_tokens
182               SET revoked = true
183             WHERE user_id = $1 AND client_id = $2
184            "#,
185            user_id,
186            client_id
187        )
188        .execute(&mut *tx)
189        .await?;
190        tx.commit().await?;
191        Ok(())
192    }
193
194    /// Looks up a refresh token whether or not it is still usable, so a presented token can be
195    /// told apart from one that never existed. Reuse detection needs the owning (user, client)
196    /// of a token `consume_in_transaction` has already rejected.
197    pub async fn find_any_by_digest(
198        conn: &mut PgConnection,
199        digest: Digest,
200        client_id: Uuid,
201    ) -> ModelResult<Option<OAuthRefreshTokens>> {
202        let row = sqlx::query_as!(
203            OAuthRefreshTokens,
204            r#"
205            SELECT *
206            FROM oauth_refresh_tokens
207            WHERE digest = $1
208              AND client_id = $2
209            "#,
210            digest.as_bytes(),
211            client_id
212        )
213        .fetch_optional(conn)
214        .await?;
215        Ok(row)
216    }
217
218    /// Revokes everything issued from a (user, client) grant: every refresh token, every access
219    /// token, and any outstanding authorization code.
220    ///
221    /// Consent (`oauth_user_client_scopes`) is deliberately left alone — neither a logout nor a
222    /// refresh-reuse takedown is a withdrawal of consent. `revoke_user_client_everything` layers
223    /// that on top.
224    ///
225    /// Returns the digests of the deleted access tokens so the caller can evict their cached user
226    /// mappings; without that they keep authenticating until the cache TTL expires.
227    pub async fn revoke_grant(
228        conn: &mut PgConnection,
229        user_id: Uuid,
230        client_id: Uuid,
231    ) -> ModelResult<Vec<Digest>> {
232        let mut tx = conn.begin().await?;
233        let digests = Self::revoke_grant_in_transaction(&mut tx, user_id, client_id).await?;
234        tx.commit().await?;
235        Ok(digests)
236    }
237
238    /// `revoke_grant` across every client the user ever authorized, for use when the account
239    /// itself goes away. Returns the deleted access-token digests for cache eviction.
240    pub async fn revoke_all_grants_of_user_in_transaction(
241        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
242        user_id: Uuid,
243    ) -> ModelResult<Vec<Digest>> {
244        sqlx::query!(
245            r#"UPDATE oauth_refresh_tokens SET revoked = true WHERE user_id = $1"#,
246            user_id
247        )
248        .execute(&mut **tx)
249        .await?;
250
251        let deleted_digests = sqlx::query_scalar!(
252            r#"DELETE FROM oauth_access_tokens WHERE user_id = $1 RETURNING digest"#,
253            user_id
254        )
255        .fetch_all(&mut **tx)
256        .await?;
257
258        sqlx::query!(
259            r#"DELETE FROM oauth_auth_codes WHERE user_id = $1"#,
260            user_id
261        )
262        .execute(&mut **tx)
263        .await?;
264
265        Ok(deleted_digests)
266    }
267
268    /// `revoke_grant` for a caller that owns the transaction.
269    pub async fn revoke_grant_in_transaction(
270        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
271        user_id: Uuid,
272        client_id: Uuid,
273    ) -> ModelResult<Vec<Digest>> {
274        sqlx::query!(
275            r#"
276            UPDATE oauth_refresh_tokens
277               SET revoked = true
278             WHERE user_id = $1 AND client_id = $2
279            "#,
280            user_id,
281            client_id
282        )
283        .execute(&mut **tx)
284        .await?;
285
286        let deleted_digests = sqlx::query_scalar!(
287            r#"DELETE FROM oauth_access_tokens WHERE user_id = $1 AND client_id = $2 RETURNING digest"#,
288            user_id,
289            client_id
290        )
291        .fetch_all(&mut **tx)
292        .await?;
293
294        sqlx::query!(
295            r#"DELETE FROM oauth_auth_codes WHERE user_id = $1 AND client_id = $2"#,
296            user_id,
297            client_id
298        )
299        .execute(&mut **tx)
300        .await?;
301
302        Ok(deleted_digests)
303    }
304
305    /// Consume a refresh token within an existing transaction.
306    ///
307    /// # Transaction Requirements
308    /// This method must be called within an existing database transaction.
309    /// The caller is responsible for managing the transaction (begin, commit, rollback).
310    ///
311    /// Returns the consumed token data.
312    pub async fn consume_in_transaction(
313        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
314        digest: Digest,
315        client_id: Uuid,
316    ) -> ModelResult<OAuthRefreshTokens> {
317        let row = sqlx::query_as!(
318            OAuthRefreshTokens,
319            r#"
320            UPDATE oauth_refresh_tokens
321               SET revoked = true
322             WHERE digest = $1
323               AND client_id = $2
324               AND revoked = false
325               AND expires_at > now()
326            RETURNING *
327            "#,
328            digest.as_bytes(),
329            client_id
330        )
331        .fetch_one(&mut **tx)
332        .await?;
333        Ok(row)
334    }
335
336    /// Complete refresh token rotation within an existing transaction after token has been consumed.
337    ///
338    /// # Transaction Requirements
339    /// This method must be called within an existing database transaction.
340    /// The caller is responsible for managing the transaction (begin, commit, rollback).
341    ///
342    /// Revokes all tokens for user/client, inserts new refresh token, and inserts new access token.
343    pub async fn complete_refresh_token_rotation_in_transaction(
344        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
345        old_token: &OAuthRefreshTokens,
346        params: RotateRefreshTokenParams<'_>,
347    ) -> ModelResult<()> {
348        // Revoke all tokens for user/client
349        sqlx::query!(
350            r#"
351            UPDATE oauth_refresh_tokens
352               SET revoked = true
353             WHERE user_id = $1 AND client_id = $2
354            "#,
355            old_token.user_id,
356            old_token.client_id
357        )
358        .execute(&mut **tx)
359        .await?;
360
361        // Insert new refresh token
362        sqlx::query!(
363            r#"
364            INSERT INTO oauth_refresh_tokens
365              (digest, user_id, client_id, scopes, audience, jti, expires_at, revoked, rotated_from, metadata, dpop_jkt)
366            VALUES
367              ($1,    $2,     $3,        $4,     $5,       gen_random_uuid(), $6,       false,   $7,          $8,      $9)
368            "#,
369            params.new_refresh_token_digest.as_bytes(),
370            old_token.user_id,
371            old_token.client_id,
372            params.scopes,
373            old_token.audience.as_deref(),
374            params.refresh_token_expires_at,
375            old_token.digest.as_bytes(),
376            serde_json::Value::Object(serde_json::Map::new()),
377            params.refresh_token_dpop_jkt
378        )
379        .execute(&mut **tx)
380        .await?;
381
382        // Insert new access token
383        OAuthAccessToken::insert(
384            tx,
385            NewAccessTokenParams {
386                digest: params.new_access_token_digest,
387                user_id: Some(old_token.user_id),
388                client_id: old_token.client_id,
389                scopes: params.scopes,
390                audience: old_token.audience.as_deref(),
391                token_type: params.access_token_type,
392                dpop_jkt: params.access_token_dpop_jkt,
393                metadata: serde_json::Map::new(),
394                expires_at: params.access_token_expires_at,
395            },
396        )
397        .await?;
398
399        Ok(())
400    }
401
402    /// Issue tokens from authorization code within an existing transaction.
403    ///
404    /// # Transaction Requirements
405    /// This method must be called within an existing database transaction.
406    /// The caller is responsible for managing the transaction (begin, commit, rollback).
407    ///
408    /// Inserts access token, revokes all refresh tokens for user/client, and inserts new refresh token.
409    pub async fn issue_tokens_from_auth_code_in_transaction(
410        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
411        params: IssueTokensFromAuthCodeParams<'_>,
412    ) -> ModelResult<()> {
413        // Insert access token
414        OAuthAccessToken::insert(
415            tx,
416            NewAccessTokenParams {
417                digest: params.access_token_digest,
418                user_id: Some(params.user_id),
419                client_id: params.client_id,
420                scopes: params.scopes,
421                audience: None,
422                token_type: params.access_token_type,
423                dpop_jkt: params.access_token_dpop_jkt,
424                metadata: serde_json::Map::new(),
425                expires_at: params.access_token_expires_at,
426            },
427        )
428        .await?;
429
430        // Revoke all refresh tokens for user/client
431        sqlx::query!(
432            r#"
433            UPDATE oauth_refresh_tokens
434               SET revoked = true
435             WHERE user_id = $1 AND client_id = $2
436            "#,
437            params.user_id,
438            params.client_id
439        )
440        .execute(&mut **tx)
441        .await?;
442
443        // Insert new refresh token
444        sqlx::query!(
445            r#"
446            INSERT INTO oauth_refresh_tokens
447              (digest, user_id, client_id, scopes, audience, jti, expires_at, revoked, rotated_from, metadata, dpop_jkt)
448            VALUES
449              ($1,    $2,     $3,        $4,     $5,       gen_random_uuid(), $6,       false,   NULL,          $7,      $8)
450            "#,
451            params.refresh_token_digest.as_bytes(),
452            params.user_id,
453            params.client_id,
454            params.scopes,
455            Option::<Vec<String>>::None as Option<Vec<String>>,
456            params.refresh_token_expires_at,
457            serde_json::Value::Object(serde_json::Map::new()),
458            params.refresh_token_dpop_jkt
459        )
460        .execute(&mut **tx)
461        .await?;
462
463        Ok(())
464    }
465}