headless_lms_models/
oauth_auth_code.rs

1use crate::{library::oauth::Digest, prelude::*};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use sqlx::{FromRow, PgConnection};
5use uuid::Uuid;
6
7use crate::library::oauth::pkce::PkceMethod;
8
9/// **INTERNAL/DATABASE-ONLY MODEL - DO NOT EXPOSE TO CLIENTS**
10///
11/// This struct is a database model that contains a `Digest` field, which contains raw bytes
12/// and uses custom (de)serialization. This model must **never** be serialized into external
13/// API payloads or returned directly to clients.
14///
15/// For external-facing responses, use DTOs such as `TokenResponse`, `UserInfoResponse`, or
16/// an explicit redacting wrapper that strips or converts `Digest` fields to safe types (e.g., strings).
17///
18/// **Rationale**: The `Digest` type contains sensitive raw bytes and uses custom serialization
19/// that is not suitable for external APIs. Exposing this model directly could leak internal
20/// implementation details or cause serialization issues.
21#[derive(Debug, Serialize, Deserialize, FromRow)]
22pub struct OAuthAuthCode {
23    pub digest: Digest,
24    pub user_id: Uuid,
25    pub client_id: Uuid,
26    pub redirect_uri: String,
27    pub scopes: Vec<String>,
28    pub jti: Uuid,
29    pub nonce: Option<String>,
30
31    pub code_challenge: Option<String>,
32    pub code_challenge_method: Option<PkceMethod>,
33
34    pub dpop_jkt: Option<String>,
35
36    pub used: bool,
37    pub expires_at: DateTime<Utc>,
38    pub metadata: serde_json::Value,
39}
40
41#[derive(Debug, Clone)]
42pub struct NewAuthCodeParams<'a> {
43    pub digest: &'a Digest,
44    pub user_id: Uuid,
45    pub client_id: Uuid,
46    pub redirect_uri: &'a str,
47    pub scopes: &'a [String],
48    pub nonce: Option<&'a str>,
49
50    pub code_challenge: Option<&'a str>,
51    pub code_challenge_method: Option<PkceMethod>,
52
53    pub dpop_jkt: Option<&'a str>,
54
55    pub expires_at: DateTime<Utc>,
56    pub metadata: serde_json::Map<String, serde_json::Value>,
57}
58
59impl<'a> NewAuthCodeParams<'a> {
60    pub fn validate(&self) -> ModelResult<()> {
61        // If one PKCE field is set, the other must also be set (mirrors DB check)
62        match (self.code_challenge, self.code_challenge_method) {
63            (Some(_), Some(_)) | (None, None) => {}
64            _ => {
65                return Err(ModelError::new(
66                    ModelErrorType::InvalidRequest,
67                    "PKCE: code_challenge and code_challenge_method must be provided together",
68                    None::<anyhow::Error>,
69                ));
70            }
71        }
72        Ok(())
73    }
74}
75
76impl OAuthAuthCode {
77    pub async fn insert(conn: &mut PgConnection, params: NewAuthCodeParams<'_>) -> ModelResult<()> {
78        params.validate()?;
79
80        sqlx::query!(
81            r#"
82            INSERT INTO oauth_auth_codes (
83                digest,
84                user_id,
85                client_id,
86                redirect_uri,
87                scopes,
88                nonce,
89                code_challenge,
90                code_challenge_method,
91                dpop_jkt,
92                expires_at,
93                metadata
94            )
95            VALUES (
96                $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11
97            )
98            "#,
99            params.digest.as_bytes(),
100            params.user_id,
101            params.client_id,
102            params.redirect_uri,
103            params.scopes,
104            params.nonce,
105            params.code_challenge,
106            // Cast enum for sqlx macro
107            params.code_challenge_method as Option<PkceMethod>,
108            params.dpop_jkt,
109            params.expires_at,
110            serde_json::Value::Object(params.metadata)
111        )
112        .execute(conn)
113        .await?;
114
115        Ok(())
116    }
117
118    /// Consume an authorization code within an existing transaction.
119    ///
120    /// # Transaction Requirements
121    /// This method must be called within an existing database transaction.
122    /// The caller is responsible for managing the transaction (begin, commit, rollback).
123    ///
124    /// Returns the consumed code data.
125    pub async fn consume_in_transaction(
126        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
127        digest: Digest,
128        client_id: Uuid,
129    ) -> ModelResult<OAuthAuthCode> {
130        let auth_code = sqlx::query_as!(
131            OAuthAuthCode,
132            r#"
133            UPDATE oauth_auth_codes
134               SET used = true
135             WHERE digest = $1
136               AND client_id = $2
137               AND used = false
138               AND expires_at > now()
139            RETURNING
140              digest                   as "digest: _",
141              user_id,
142              client_id,
143              redirect_uri,
144              scopes,
145              jti,
146              nonce,
147              code_challenge,
148              code_challenge_method    as "code_challenge_method: PkceMethod",
149              dpop_jkt,
150              used,
151              expires_at,
152              metadata
153            "#,
154            digest.as_bytes(),
155            client_id
156        )
157        .fetch_one(&mut **tx)
158        .await?;
159
160        Ok(auth_code)
161    }
162
163    /// Consume an authorization code with redirect URI check within an existing transaction.
164    ///
165    /// # Transaction Requirements
166    /// This method must be called within an existing database transaction.
167    /// The caller is responsible for managing the transaction (begin, commit, rollback).
168    ///
169    /// Returns the consumed code data.
170    pub async fn consume_with_redirect_in_transaction(
171        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
172        digest: Digest,
173        client_id: Uuid,
174        redirect_uri: &str,
175    ) -> ModelResult<OAuthAuthCode> {
176        let auth_code = sqlx::query_as!(
177            OAuthAuthCode,
178            r#"
179            UPDATE oauth_auth_codes
180               SET used = true
181             WHERE digest = $1
182               AND client_id = $2
183               AND redirect_uri = $3
184               AND used = false
185               AND expires_at > now()
186            RETURNING
187              digest                   as "digest: _",
188              user_id,
189              client_id,
190              redirect_uri,
191              scopes,
192              jti,
193              nonce,
194              code_challenge,
195              code_challenge_method    as "code_challenge_method: PkceMethod",
196              dpop_jkt,
197              used,
198              expires_at,
199              metadata
200            "#,
201            digest.as_bytes(),
202            client_id,
203            redirect_uri
204        )
205        .fetch_one(&mut **tx)
206        .await?;
207
208        Ok(auth_code)
209    }
210}