Skip to main content

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 created_at: DateTime<Utc>,
39    pub updated_at: DateTime<Utc>,
40    pub metadata: serde_json::Value,
41    /// RFC 8707 resource indicator the tokens are bound to.
42    pub resource: Option<String>,
43}
44
45#[derive(Debug, Clone)]
46pub struct NewAuthCodeParams<'a> {
47    pub digest: &'a Digest,
48    pub user_id: Uuid,
49    pub client_id: Uuid,
50    pub redirect_uri: &'a str,
51    pub scopes: &'a [String],
52    pub nonce: Option<&'a str>,
53
54    pub code_challenge: Option<&'a str>,
55    pub code_challenge_method: Option<PkceMethod>,
56
57    pub dpop_jkt: Option<&'a str>,
58
59    pub expires_at: DateTime<Utc>,
60    pub metadata: serde_json::Map<String, serde_json::Value>,
61    pub resource: Option<&'a str>,
62}
63
64impl<'a> NewAuthCodeParams<'a> {
65    pub fn validate(&self) -> ModelResult<()> {
66        // If one PKCE field is set, the other must also be set (mirrors DB check)
67        match (self.code_challenge, self.code_challenge_method) {
68            (Some(_), Some(_)) | (None, None) => {}
69            _ => {
70                return Err(ModelError::new(
71                    ModelErrorType::InvalidRequest,
72                    "PKCE: code_challenge and code_challenge_method must be provided together",
73                    None::<anyhow::Error>,
74                ));
75            }
76        }
77        Ok(())
78    }
79}
80
81impl OAuthAuthCode {
82    pub async fn insert(conn: &mut PgConnection, params: NewAuthCodeParams<'_>) -> ModelResult<()> {
83        params.validate()?;
84
85        sqlx::query!(
86            r#"
87            INSERT INTO oauth_auth_codes (
88                digest,
89                user_id,
90                client_id,
91                redirect_uri,
92                scopes,
93                nonce,
94                code_challenge,
95                code_challenge_method,
96                dpop_jkt,
97                expires_at,
98                metadata,
99                resource
100            )
101            VALUES (
102                $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12
103            )
104            "#,
105            params.digest.as_bytes(),
106            params.user_id,
107            params.client_id,
108            params.redirect_uri,
109            params.scopes,
110            params.nonce,
111            params.code_challenge,
112            // Cast enum for sqlx macro
113            params.code_challenge_method as Option<PkceMethod>,
114            params.dpop_jkt,
115            params.expires_at,
116            serde_json::Value::Object(params.metadata),
117            params.resource
118        )
119        .execute(conn)
120        .await?;
121
122        Ok(())
123    }
124
125    /// Consume an authorization code within an existing transaction.
126    ///
127    /// # Transaction Requirements
128    /// This method must be called within an existing database transaction.
129    /// The caller is responsible for managing the transaction (begin, commit, rollback).
130    ///
131    /// Returns the consumed code data.
132    pub async fn consume_in_transaction(
133        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
134        digest: Digest,
135        client_id: Uuid,
136    ) -> ModelResult<OAuthAuthCode> {
137        let auth_code = sqlx::query_as!(
138            OAuthAuthCode,
139            r#"
140            UPDATE oauth_auth_codes
141               SET used = true
142             WHERE digest = $1
143               AND client_id = $2
144               AND used = false
145               AND expires_at > now()
146            RETURNING *
147            "#,
148            digest.as_bytes(),
149            client_id
150        )
151        .fetch_one(&mut **tx)
152        .await?;
153
154        Ok(auth_code)
155    }
156
157    /// Consume an authorization code with redirect URI check within an existing transaction.
158    ///
159    /// # Transaction Requirements
160    /// This method must be called within an existing database transaction.
161    /// The caller is responsible for managing the transaction (begin, commit, rollback).
162    ///
163    /// Returns the consumed code data.
164    pub async fn consume_with_redirect_in_transaction(
165        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
166        digest: Digest,
167        client_id: Uuid,
168        redirect_uri: &str,
169    ) -> ModelResult<OAuthAuthCode> {
170        let auth_code = sqlx::query_as!(
171            OAuthAuthCode,
172            r#"
173            UPDATE oauth_auth_codes
174               SET used = true
175             WHERE digest = $1
176               AND client_id = $2
177               AND redirect_uri = $3
178               AND used = false
179               AND expires_at > now()
180            RETURNING *
181            "#,
182            digest.as_bytes(),
183            client_id,
184            redirect_uri
185        )
186        .fetch_one(&mut **tx)
187        .await?;
188
189        Ok(auth_code)
190    }
191}