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