Skip to main content

headless_lms_models/
oauth_client.rs

1//! OAuth 2.1 / OIDC Client model
2//!
3//! Mirrors the `oauth_clients` table and PostgreSQL enums.
4//! Includes small policy helpers (public/confidential, PKCE, grants).
5
6use crate::{
7    library::oauth::{Digest, GrantTypeName, pkce::PkceMethod},
8    prelude::*,
9};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use sqlx::{FromRow, PgConnection, Type};
13use uuid::Uuid;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
16#[sqlx(type_name = "token_endpoint_auth_method", rename_all = "snake_case")]
17#[serde(rename_all = "snake_case")]
18pub enum TokenEndpointAuthMethod {
19    None,
20    ClientSecretPost,
21}
22
23impl TokenEndpointAuthMethod {
24    pub fn is_public(self) -> bool {
25        matches!(self, Self::None)
26    }
27
28    pub fn is_confidential(self) -> bool {
29        !self.is_public()
30    }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
34#[sqlx(type_name = "application_type", rename_all = "snake_case")]
35#[serde(rename_all = "snake_case")]
36pub enum ApplicationType {
37    Web,
38    Native,
39    Spa,
40    Service,
41}
42
43#[derive(Debug, Serialize, Deserialize, FromRow)]
44pub struct OAuthClient {
45    pub id: Uuid,
46    pub client_id: String,
47    pub client_name: String,
48    pub application_type: ApplicationType,
49
50    pub token_endpoint_auth_method: TokenEndpointAuthMethod,
51
52    /// Hashed/HMACed secret for confidential clients (`BYTEA`); `None` for public clients.
53    pub client_secret: Option<Digest>,
54    pub client_secret_expires_at: Option<DateTime<Utc>>,
55
56    pub redirect_uris: Vec<String>,
57    pub post_logout_redirect_uris: Option<Vec<String>>,
58
59    pub allowed_grant_types: Vec<GrantTypeName>,
60    pub scopes: Vec<String>,
61
62    pub require_pkce: bool,
63    pub pkce_methods_allowed: Vec<PkceMethod>,
64
65    /// Optional list of allowed origins (same validation as redirect URIs). If None or empty, origin check is not enforced.
66    pub allowed_origins: Option<Vec<String>>,
67    pub bearer_allowed: bool,
68
69    pub created_at: DateTime<Utc>,
70    pub updated_at: DateTime<Utc>,
71    pub deleted_at: Option<DateTime<Utc>>,
72
73    /// Set for a client registered through a Client ID Metadata Document, whose `client_id` is
74    /// the document's URL: when the document was last fetched.
75    pub metadata_document_fetched_at: Option<DateTime<Utc>>,
76    pub client_uri: Option<String>,
77}
78
79impl OAuthClient {
80    /// Whether the client was registered through a Client ID Metadata Document.
81    pub fn is_metadata_document_client(&self) -> bool {
82        self.metadata_document_fetched_at.is_some()
83    }
84
85    /// How long this client's access tokens are valid: shorter for clients registered through a
86    /// metadata document.
87    pub fn access_token_ttl(&self) -> chrono::Duration {
88        if self.is_metadata_document_client() {
89            chrono::Duration::minutes(15)
90        } else {
91            chrono::Duration::hours(1)
92        }
93    }
94
95    pub fn is_public(&self) -> bool {
96        self.token_endpoint_auth_method.is_public()
97    }
98
99    pub fn is_confidential(&self) -> bool {
100        self.token_endpoint_auth_method.is_confidential()
101    }
102
103    pub fn allows_bearer(&self) -> bool {
104        self.bearer_allowed
105    }
106
107    pub fn requires_pkce(&self) -> bool {
108        self.require_pkce || self.is_public()
109    }
110
111    pub fn allows_pkce_method(&self, m: PkceMethod) -> bool {
112        self.pkce_methods_allowed.contains(&m)
113    }
114
115    pub fn allows_grant(&self, g: GrantTypeName) -> bool {
116        self.allowed_grant_types.contains(&g)
117    }
118}
119
120#[derive(Debug, Clone)]
121pub struct NewClientParams<'a> {
122    pub client_id: &'a str,
123    pub client_name: &'a str,
124    pub application_type: ApplicationType,
125    pub token_endpoint_auth_method: TokenEndpointAuthMethod,
126
127    pub client_secret: Option<&'a Digest>,
128    pub client_secret_expires_at: Option<DateTime<Utc>>,
129
130    pub redirect_uris: &'a [String],
131    pub post_logout_redirect_uris: Option<&'a [String]>,
132
133    pub allowed_grant_types: &'a [GrantTypeName],
134    pub scopes: &'a [String],
135
136    pub require_pkce: bool,
137    pub pkce_methods_allowed: &'a [PkceMethod],
138
139    pub allowed_origins: Option<&'a [String]>,
140    pub bearer_allowed: bool,
141}
142
143impl<'a> NewClientParams<'a> {
144    /// Lightweight pre-DB checks (DB constraints still enforce policy).
145    pub fn validate(&self) -> ModelResult<()> {
146        if self.client_id.trim().is_empty() {
147            return Err(ModelError::new(
148                ModelErrorType::InvalidRequest,
149                "client_id cannot be empty",
150                None::<anyhow::Error>,
151            ));
152        }
153
154        if self.client_name.trim().is_empty() {
155            return Err(ModelError::new(
156                ModelErrorType::InvalidRequest,
157                "client_name cannot be empty",
158                None::<anyhow::Error>,
159            ));
160        }
161
162        if self.redirect_uris.is_empty() {
163            return Err(ModelError::new(
164                ModelErrorType::InvalidRequest,
165                "redirect_uris must not be empty",
166                None::<anyhow::Error>,
167            ));
168        }
169
170        if self.token_endpoint_auth_method.is_public() && self.client_secret.is_some() {
171            return Err(ModelError::new(
172                ModelErrorType::PreconditionFailed,
173                "public clients must not include client_secret",
174                None::<anyhow::Error>,
175            ));
176        }
177
178        if self.token_endpoint_auth_method.is_confidential() && self.client_secret.is_none() {
179            return Err(ModelError::new(
180                ModelErrorType::PreconditionFailed,
181                "confidential clients must include client_secret",
182                None::<anyhow::Error>,
183            ));
184        }
185
186        if !self.require_pkce && self.token_endpoint_auth_method.is_public() {
187            return Err(ModelError::new(
188                ModelErrorType::PreconditionFailed,
189                "public clients must require PKCE",
190                None::<anyhow::Error>,
191            ));
192        }
193
194        Ok(())
195    }
196}
197
198impl OAuthClient {
199    /// Find an **active** (non-soft-deleted) client by DB `id` (UUID).
200    pub async fn find_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Self> {
201        let client = sqlx::query_as!(
202            OAuthClient,
203            r#"
204        SELECT *
205        FROM oauth_clients
206        WHERE id = $1
207          AND deleted_at IS NULL
208        "#,
209            id
210        )
211        .fetch_one(conn)
212        .await?;
213
214        Ok(client)
215    }
216
217    /// Same as `find_by_id`, but returns `Ok(None)` when not found.
218    pub async fn find_by_id_optional(
219        conn: &mut PgConnection,
220        id: Uuid,
221    ) -> Result<Option<Self>, ModelError> {
222        Self::find_by_id(conn, id).await.optional()
223    }
224
225    /// Find an **active** (non-soft-deleted) client by `client_id`.
226    pub async fn find_by_client_id(conn: &mut PgConnection, client_id: &str) -> ModelResult<Self> {
227        let client = sqlx::query_as!(
228            OAuthClient,
229            r#"
230    SELECT *
231    FROM oauth_clients
232    WHERE client_id = $1
233      AND deleted_at IS NULL
234    "#,
235            client_id
236        )
237        .fetch_one(conn)
238        .await?;
239
240        Ok(client)
241    }
242
243    /// Same as `find_by_client_id`, but returns `Ok(None)` when not found.
244    pub async fn find_by_client_id_optional(
245        conn: &mut PgConnection,
246        client_id: &str,
247    ) -> Result<Option<Self>, ModelError> {
248        Self::find_by_client_id(conn, client_id).await.optional()
249    }
250
251    /// Insert a new client and return the full hydrated row.
252    pub async fn insert(conn: &mut PgConnection, p: NewClientParams<'_>) -> ModelResult<Self> {
253        p.validate()?;
254        let row = sqlx::query_as!(
255            OAuthClient,
256            r#"
257    INSERT INTO oauth_clients (
258        client_id,
259        client_name,
260        application_type,
261        token_endpoint_auth_method,
262        client_secret,
263        client_secret_expires_at,
264        redirect_uris,
265        post_logout_redirect_uris,
266        allowed_grant_types,
267        scopes,
268        require_pkce,
269        pkce_methods_allowed,
270        allowed_origins,
271        bearer_allowed
272    )
273    VALUES (
274        $1, $2, $3, $4,
275        $5, $6,
276        $7, COALESCE($8, '{}'::text[]),     -- << cast needed for text[]
277        $9, $10,
278        $11, $12,
279        $13, $14
280    )
281    RETURNING
282      *
283    "#,
284            p.client_id,
285            p.client_name,
286            p.application_type as ApplicationType,
287            p.token_endpoint_auth_method as TokenEndpointAuthMethod,
288            p.client_secret.map(|d| d.as_bytes() as &[u8]),
289            p.client_secret_expires_at,
290            p.redirect_uris,
291            p.post_logout_redirect_uris,
292            p.allowed_grant_types as &[GrantTypeName],
293            p.scopes,
294            p.require_pkce,
295            p.pkce_methods_allowed as &[PkceMethod],
296            p.allowed_origins,
297            p.bearer_allowed
298        )
299        .fetch_one(conn)
300        .await?;
301
302        Ok(row)
303    }
304}
305
306/// A client as its Client ID Metadata Document describes it, already validated.
307#[derive(Debug, Clone)]
308pub struct MetadataDocumentClient<'a> {
309    /// The URL the document was fetched from, which is also the client id.
310    pub client_id: &'a str,
311    pub client_name: &'a str,
312    pub redirect_uris: &'a [String],
313    pub client_uri: Option<&'a str>,
314    /// The scopes the client may request.
315    pub scopes: &'a [String],
316}
317
318impl OAuthClient {
319    /// Stores or refreshes the cached registration of a Client ID Metadata Document client: a
320    /// public client that must use PKCE with S256 and gets Bearer tokens through the
321    /// authorization code and refresh token grants.
322    pub async fn upsert_metadata_document_client(
323        conn: &mut PgConnection,
324        client: MetadataDocumentClient<'_>,
325    ) -> ModelResult<Self> {
326        let p = NewClientParams {
327            client_id: client.client_id,
328            client_name: client.client_name,
329            application_type: ApplicationType::Native,
330            token_endpoint_auth_method: TokenEndpointAuthMethod::None,
331            client_secret: None,
332            client_secret_expires_at: None,
333            redirect_uris: client.redirect_uris,
334            post_logout_redirect_uris: None,
335            allowed_grant_types: &[
336                GrantTypeName::AuthorizationCode,
337                GrantTypeName::RefreshToken,
338            ],
339            scopes: client.scopes,
340            require_pkce: true,
341            pkce_methods_allowed: &[PkceMethod::S256],
342            allowed_origins: None,
343            bearer_allowed: true,
344        };
345        p.validate()?;
346        let row = sqlx::query_as!(
347            OAuthClient,
348            r#"
349INSERT INTO oauth_clients (
350    client_id,
351    client_name,
352    application_type,
353    token_endpoint_auth_method,
354    redirect_uris,
355    allowed_grant_types,
356    scopes,
357    require_pkce,
358    pkce_methods_allowed,
359    bearer_allowed,
360    metadata_document_fetched_at,
361    client_uri
362  )
363VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now(), $11) ON CONFLICT (client_id)
364WHERE deleted_at IS NULL DO
365UPDATE
366SET client_name = EXCLUDED.client_name,
367  redirect_uris = EXCLUDED.redirect_uris,
368  scopes = EXCLUDED.scopes,
369  metadata_document_fetched_at = EXCLUDED.metadata_document_fetched_at,
370  client_uri = EXCLUDED.client_uri
371WHERE oauth_clients.metadata_document_fetched_at IS NOT NULL
372RETURNING *
373            "#,
374            p.client_id,
375            p.client_name,
376            p.application_type as ApplicationType,
377            p.token_endpoint_auth_method as TokenEndpointAuthMethod,
378            p.redirect_uris,
379            p.allowed_grant_types as &[GrantTypeName],
380            p.scopes,
381            p.require_pkce,
382            p.pkce_methods_allowed as &[PkceMethod],
383            p.bearer_allowed,
384            client.client_uri,
385        )
386        .fetch_one(conn)
387        .await?;
388        Ok(row)
389    }
390}