headless_lms_models/
oauth_access_token.rs

1use crate::library::oauth::Digest;
2use crate::prelude::*;
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use sqlx::{self, FromRow, PgConnection, Type};
6use uuid::Uuid;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
9#[sqlx(type_name = "token_type")]
10pub enum TokenType {
11    Bearer,
12    DPoP,
13}
14
15/// **INTERNAL/DATABASE-ONLY MODEL - DO NOT EXPOSE TO CLIENTS**
16///
17/// This struct is a database model that contains a `Digest` field, which contains raw bytes
18/// and uses custom (de)serialization. This model must **never** be serialized into external
19/// API payloads or returned directly to clients.
20///
21/// For external-facing responses, use DTOs such as `TokenResponse`, `UserInfoResponse`, or
22/// an explicit redacting wrapper that strips or converts `Digest` fields to safe types (e.g., strings).
23///
24/// **Rationale**: The `Digest` type contains sensitive raw bytes and uses custom serialization
25/// that is not suitable for external APIs. Exposing this model directly could leak internal
26/// implementation details or cause serialization issues.
27#[derive(Debug, Serialize, Deserialize, FromRow)]
28pub struct OAuthAccessToken {
29    pub digest: Digest,
30    pub user_id: Option<Uuid>,
31    pub client_id: Uuid,
32    pub scopes: Vec<String>,
33    pub audience: Option<Vec<String>>,
34    pub jti: Uuid,
35
36    /// Sender constraint: present only when `token_type = DPoP`
37    pub dpop_jkt: Option<String>,
38
39    pub token_type: TokenType,
40
41    pub metadata: serde_json::Value,
42    pub expires_at: DateTime<Utc>,
43    pub created_at: DateTime<Utc>,
44}
45
46#[derive(Debug, Clone)]
47pub struct NewAccessTokenParams<'a> {
48    pub digest: &'a Digest,
49    pub user_id: Option<Uuid>,
50    pub client_id: Uuid,
51    pub scopes: &'a [String],
52    pub audience: Option<&'a [String]>,
53
54    /// Set to `TokenType::Bearer` **and** leave `dpop_jkt` = None for Bearer tokens.
55    /// Set to `TokenType::DPoP` **and** provide `dpop_jkt = Some(...)` for DPoP tokens.
56    pub token_type: TokenType,
57    pub dpop_jkt: Option<&'a str>,
58
59    pub metadata: serde_json::Map<String, serde_json::Value>,
60    pub expires_at: DateTime<Utc>,
61}
62
63impl OAuthAccessToken {
64    /// Insert a new access token (with jti).
65    ///
66    /// DB constraint requires:
67    ///  - Bearer  => dpop_jkt = NULL
68    ///  - DPoP    => dpop_jkt IS NOT NULL
69    pub async fn insert(
70        conn: &mut PgConnection,
71        params: NewAccessTokenParams<'_>,
72    ) -> ModelResult<()> {
73        match (params.token_type, params.dpop_jkt) {
74            (TokenType::Bearer, None) => {}
75            (TokenType::Bearer, Some(_)) => {
76                return Err(ModelError::new(
77                    ModelErrorType::InvalidRequest,
78                    "Bearer tokens must not include dpop_jkt",
79                    None::<anyhow::Error>,
80                ));
81            }
82            (TokenType::DPoP, Some(_)) => {}
83            (TokenType::DPoP, None) => {
84                return Err(ModelError::new(
85                    ModelErrorType::InvalidRequest,
86                    "DPoP tokens must include dpop_jkt",
87                    None::<anyhow::Error>,
88                ));
89            }
90        }
91
92        sqlx::query!(
93            r#"
94            INSERT INTO oauth_access_tokens
95              (digest, user_id, client_id, scopes, audience, token_type, dpop_jkt, metadata, expires_at)
96            VALUES
97              ($1,    $2,     $3,       $4,     $5,       $6,         $7,       $8,       $9)
98            "#,
99            params.digest.as_bytes(),
100            params.user_id,
101            params.client_id,
102            params.scopes,
103            params.audience,
104            params.token_type as TokenType,
105            params.dpop_jkt,
106            serde_json::Value::Object(params.metadata),
107            params.expires_at
108        )
109        .execute(conn)
110        .await?;
111        Ok(())
112    }
113
114    /// Find a still-valid token by digest (no sender enforcement).
115    pub async fn find_valid(
116        conn: &mut PgConnection,
117        digest: Digest,
118    ) -> ModelResult<OAuthAccessToken> {
119        let token = sqlx::query_as!(
120            OAuthAccessToken,
121            r#"
122            SELECT
123              digest        as "digest: _",
124              user_id,
125              client_id,
126              scopes,
127              audience,
128              jti,
129              dpop_jkt,
130              token_type    as "token_type: TokenType",
131              metadata,
132              expires_at,
133              created_at
134            FROM oauth_access_tokens
135            WHERE digest = $1 AND expires_at > now()
136            "#,
137            digest.as_bytes()
138        )
139        .fetch_one(conn)
140        .await?;
141        Ok(token)
142    }
143
144    /// Find a still-valid token by digest and enforce sender:
145    ///  - DPoP => `dpop_jkt` must match `sender_jkt`
146    ///  - Bearer => sender is ignored
147    pub async fn find_valid_for_sender(
148        conn: &mut PgConnection,
149        digest: Digest,
150        sender_jkt: Option<&str>,
151    ) -> ModelResult<OAuthAccessToken> {
152        let t = Self::find_valid(conn, digest).await?;
153
154        match t.token_type {
155            TokenType::Bearer => Ok(t),
156            TokenType::DPoP => {
157                let Some(expected) = t.dpop_jkt.as_deref() else {
158                    return Err(ModelError::new(
159                        ModelErrorType::PreconditionFailed,
160                        "token missing dpop_jkt",
161                        None::<anyhow::Error>,
162                    ));
163                };
164                let Some(presented) = sender_jkt else {
165                    return Err(ModelError::new(
166                        ModelErrorType::PreconditionFailed,
167                        "DPoP proof missing JKT",
168                        None::<anyhow::Error>,
169                    ));
170                };
171                if expected != presented {
172                    return Err(ModelError::new(
173                        ModelErrorType::PreconditionFailed,
174                        "DPoP JKT mismatch",
175                        None::<anyhow::Error>,
176                    ));
177                }
178                Ok(t)
179            }
180        }
181    }
182
183    pub async fn delete_all_by_user_client(
184        conn: &mut PgConnection,
185        user_id: Uuid,
186        client_id: Uuid,
187    ) -> ModelResult<()> {
188        let mut tx = conn.begin().await?;
189        sqlx::query!(
190            r#"
191            DELETE FROM oauth_access_tokens
192            WHERE user_id = $1 AND client_id = $2
193            "#,
194            user_id,
195            client_id
196        )
197        .execute(&mut *tx)
198        .await?;
199        tx.commit().await?;
200        Ok(())
201    }
202
203    /// Revoke (delete) an access token by its digest.
204    ///
205    /// This method is used for the OAuth 2.0 token revocation endpoint (RFC 7009).
206    /// Access tokens are deleted rather than marked as revoked since they are short-lived.
207    pub async fn revoke_by_digest(conn: &mut PgConnection, digest: Digest) -> ModelResult<()> {
208        sqlx::query!(
209            r#"
210            DELETE FROM oauth_access_tokens
211            WHERE digest = $1
212            "#,
213            digest.as_bytes()
214        )
215        .execute(conn)
216        .await?;
217        Ok(())
218    }
219}