1use 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 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 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 pub metadata_document_fetched_at: Option<DateTime<Utc>>,
76 pub client_uri: Option<String>,
77}
78
79impl OAuthClient {
80 pub fn is_metadata_document_client(&self) -> bool {
82 self.metadata_document_fetched_at.is_some()
83 }
84
85 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 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 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 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 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 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 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#[derive(Debug, Clone)]
308pub struct MetadataDocumentClient<'a> {
309 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 pub scopes: &'a [String],
316}
317
318impl OAuthClient {
319 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}