1use base64ct::{Base64UrlUnpadded, Encoding};
2use chrono::{DateTime, Utc};
3use rand::TryRngCore;
4use rand::rngs::OsRng;
5use serde::Serialize;
6use url::Url;
7
8use crate::db::Db;
9use crate::error::AuthError;
10use crate::types::{ApplicationId, ClientId, ClientSecret, PasswordHash, UserId};
11
12#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
17pub struct Application {
18 pub id: ApplicationId,
19 pub name: String,
20 pub client_id: ClientId,
21 #[serde(skip_serializing)]
22 pub client_secret_hash: PasswordHash,
23 pub redirect_uris: String, pub logo_url: Option<String>,
25 pub primary_color: Option<String>,
26 pub is_trusted: bool,
27 pub created_by: Option<UserId>,
28 pub is_active: bool,
29 pub created_at: DateTime<Utc>,
30 pub updated_at: DateTime<Utc>,
31}
32
33#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
42pub struct BrandingConfig {
43 pub application_name: String,
44 pub logo_url: Option<String>,
45 pub primary_color: Option<String>,
46}
47
48pub fn generate_client_id() -> ClientId {
53 let mut bytes = [0u8; 24];
54 OsRng
55 .try_fill_bytes(&mut bytes)
56 .expect("OS RNG unavailable");
57 let encoded = Base64UrlUnpadded::encode_string(&bytes);
58 ClientId::new_unchecked(format!("ath_{encoded}"))
59}
60
61pub fn generate_client_secret() -> Result<(ClientSecret, PasswordHash), AuthError> {
68 let mut bytes = [0u8; 32];
69 OsRng
70 .try_fill_bytes(&mut bytes)
71 .expect("OS RNG unavailable");
72 let raw = Base64UrlUnpadded::encode_string(&bytes);
73 let hash = crate::password::hash_password(&raw)?;
74 Ok((ClientSecret::new_unchecked(raw), hash))
75}
76
77impl Application {
78 pub fn redirect_uri_list(&self) -> Result<Vec<String>, AuthError> {
84 serde_json::from_str(&self.redirect_uris)
85 .map_err(|e| AuthError::Database(sqlx::Error::Decode(Box::new(e))))
86 }
87
88 pub fn branding(&self) -> BrandingConfig {
90 BrandingConfig {
91 application_name: self.name.clone(),
92 logo_url: self.logo_url.clone(),
93 primary_color: self.primary_color.clone(),
94 }
95 }
96}
97
98fn map_unique_violation(err: sqlx::Error) -> AuthError {
99 if let sqlx::Error::Database(ref db_err) = err {
100 let msg = db_err.message();
101 if msg.contains("UNIQUE constraint failed") && msg.contains("client_id") {
102 return AuthError::Conflict("client_id already exists".into());
103 }
104 }
105 AuthError::Database(err)
106}
107
108pub struct UpdateApplication {
113 pub name: String,
114 pub redirect_uris: Vec<String>,
115 pub is_trusted: bool,
116 pub is_active: bool,
117 pub logo_url: Option<String>,
118 pub primary_color: Option<String>,
119}
120
121pub fn validate_redirect_uris(uris: &[String]) -> Result<(), AuthError> {
132 if uris.is_empty() {
133 return Err(AuthError::InvalidRedirectUri(
134 "redirect_uris must not be empty".into(),
135 ));
136 }
137 for uri in uris {
138 let parsed = Url::parse(uri).map_err(|_| AuthError::InvalidRedirectUri(uri.clone()))?;
139 if parsed.fragment().is_some() {
140 return Err(AuthError::InvalidRedirectUri(uri.clone()));
141 }
142 let scheme = parsed.scheme();
143 if scheme == "https" {
144 continue;
145 }
146 if scheme == "http" {
147 let host = parsed.host_str().unwrap_or("");
148 if host == "localhost" || host == "127.0.0.1" {
149 continue;
150 }
151 }
152 return Err(AuthError::InvalidRedirectUri(uri.clone()));
153 }
154 Ok(())
155}
156
157pub fn validate_redirect_uri(redirect_uri: &str, registered: &[String]) -> Result<(), AuthError> {
164 if registered.iter().any(|r| r == redirect_uri) {
165 Ok(())
166 } else {
167 Err(AuthError::InvalidRedirectUri(redirect_uri.to_owned()))
168 }
169}
170
171pub fn validate_logo_url(url: &str) -> Result<(), AuthError> {
177 let parsed = Url::parse(url)
178 .map_err(|_| AuthError::Validation("logo_url must be a valid absolute URL".into()))?;
179 let scheme = parsed.scheme();
180 if scheme == "https" {
181 return Ok(());
182 }
183 if scheme == "http" {
184 let host = parsed.host_str().unwrap_or("");
185 if host == "localhost" || host == "127.0.0.1" {
186 return Ok(());
187 }
188 }
189 Err(AuthError::Validation(
190 "logo_url must be an HTTPS URL".into(),
191 ))
192}
193
194pub fn validate_primary_color(color: &str) -> Result<(), AuthError> {
200 let bytes = color.as_bytes();
201 if bytes.len() != 7 || bytes[0] != b'#' {
202 return Err(AuthError::Validation(
203 "primary_color must be a hex color (#RRGGBB)".into(),
204 ));
205 }
206 if !bytes[1..].iter().all(|b| b.is_ascii_hexdigit()) {
207 return Err(AuthError::Validation(
208 "primary_color must be a hex color (#RRGGBB)".into(),
209 ));
210 }
211 Ok(())
212}
213
214impl Db {
215 pub async fn create_application(
225 &self,
226 name: String,
227 redirect_uris: Vec<String>,
228 is_trusted: bool,
229 created_by: Option<UserId>,
230 logo_url: Option<String>,
231 primary_color: Option<String>,
232 ) -> Result<(Application, ClientSecret), AuthError> {
233 validate_redirect_uris(&redirect_uris)?;
234 if let Some(ref url) = logo_url {
235 validate_logo_url(url)?;
236 }
237 if let Some(ref color) = primary_color {
238 validate_primary_color(color)?;
239 }
240 let id = ApplicationId::new();
241 let client_id = generate_client_id();
242 let (raw_secret, hash) = generate_client_secret()?;
243 let redirect_uris_json =
244 serde_json::to_string(&redirect_uris).expect("Vec<String> serializes to JSON");
245 let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
246
247 sqlx::query(
248 "INSERT INTO allowthem_applications \
249 (id, name, client_id, client_secret_hash, redirect_uris, logo_url, \
250 primary_color, is_trusted, created_by, is_active, created_at, updated_at) \
251 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1, ?10, ?10)",
252 )
253 .bind(id)
254 .bind(&name)
255 .bind(&client_id)
256 .bind(&hash)
257 .bind(&redirect_uris_json)
258 .bind(&logo_url)
259 .bind(&primary_color)
260 .bind(is_trusted)
261 .bind(created_by)
262 .bind(&now)
263 .execute(self.pool())
264 .await
265 .map_err(map_unique_violation)?;
266
267 let app = self.get_application(id).await?;
268 Ok((app, raw_secret))
269 }
270
271 pub async fn get_application(&self, id: ApplicationId) -> Result<Application, AuthError> {
273 sqlx::query_as::<_, Application>(
274 "SELECT id, name, client_id, client_secret_hash, redirect_uris, \
275 logo_url, primary_color, is_trusted, created_by, is_active, \
276 created_at, updated_at \
277 FROM allowthem_applications WHERE id = ?",
278 )
279 .bind(id)
280 .fetch_optional(self.pool())
281 .await?
282 .ok_or(AuthError::NotFound)
283 }
284
285 pub async fn get_application_by_client_id(
289 &self,
290 client_id: &ClientId,
291 ) -> Result<Application, AuthError> {
292 sqlx::query_as::<_, Application>(
293 "SELECT id, name, client_id, client_secret_hash, redirect_uris, \
294 logo_url, primary_color, is_trusted, created_by, is_active, \
295 created_at, updated_at \
296 FROM allowthem_applications WHERE client_id = ?",
297 )
298 .bind(client_id)
299 .fetch_optional(self.pool())
300 .await?
301 .ok_or(AuthError::NotFound)
302 }
303
304 pub async fn get_branding_by_client_id(
310 &self,
311 client_id: &ClientId,
312 ) -> Result<Option<BrandingConfig>, AuthError> {
313 sqlx::query_as::<_, BrandingConfig>(
314 "SELECT name AS application_name, logo_url, primary_color \
315 FROM allowthem_applications \
316 WHERE client_id = ? AND is_active = 1",
317 )
318 .bind(client_id)
319 .fetch_optional(self.pool())
320 .await
321 .map_err(AuthError::Database)
322 }
323
324 pub async fn list_applications(&self) -> Result<Vec<Application>, AuthError> {
326 sqlx::query_as::<_, Application>(
327 "SELECT id, name, client_id, client_secret_hash, redirect_uris, \
328 logo_url, primary_color, is_trusted, created_by, is_active, \
329 created_at, updated_at \
330 FROM allowthem_applications ORDER BY created_at ASC",
331 )
332 .fetch_all(self.pool())
333 .await
334 .map_err(AuthError::Database)
335 }
336
337 pub async fn update_application(
346 &self,
347 id: ApplicationId,
348 params: UpdateApplication,
349 ) -> Result<(), AuthError> {
350 validate_redirect_uris(¶ms.redirect_uris)?;
351 if let Some(ref url) = params.logo_url {
352 validate_logo_url(url)?;
353 }
354 if let Some(ref color) = params.primary_color {
355 validate_primary_color(color)?;
356 }
357 let redirect_uris_json =
358 serde_json::to_string(¶ms.redirect_uris).expect("Vec<String> serializes to JSON");
359 let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
360
361 let result = sqlx::query(
362 "UPDATE allowthem_applications \
363 SET name = ?1, redirect_uris = ?2, is_trusted = ?3, is_active = ?4, \
364 logo_url = ?5, primary_color = ?6, updated_at = ?7 \
365 WHERE id = ?8",
366 )
367 .bind(¶ms.name)
368 .bind(&redirect_uris_json)
369 .bind(params.is_trusted)
370 .bind(params.is_active)
371 .bind(¶ms.logo_url)
372 .bind(¶ms.primary_color)
373 .bind(&now)
374 .bind(id)
375 .execute(self.pool())
376 .await?;
377
378 if result.rows_affected() == 0 {
379 return Err(AuthError::NotFound);
380 }
381 Ok(())
382 }
383
384 pub async fn regenerate_client_secret(
392 &self,
393 id: ApplicationId,
394 ) -> Result<(Application, ClientSecret), AuthError> {
395 let (raw_secret, hash) = generate_client_secret()?;
396 let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
397
398 let result = sqlx::query(
399 "UPDATE allowthem_applications \
400 SET client_secret_hash = ?1, updated_at = ?2 \
401 WHERE id = ?3",
402 )
403 .bind(&hash)
404 .bind(&now)
405 .bind(id)
406 .execute(self.pool())
407 .await?;
408
409 if result.rows_affected() == 0 {
410 return Err(AuthError::NotFound);
411 }
412
413 let app = self.get_application(id).await?;
414 Ok((app, raw_secret))
415 }
416
417 pub async fn delete_application(&self, id: ApplicationId) -> Result<(), AuthError> {
422 let result = sqlx::query("DELETE FROM allowthem_applications WHERE id = ?")
423 .bind(id)
424 .execute(self.pool())
425 .await?;
426
427 if result.rows_affected() == 0 {
428 return Err(AuthError::NotFound);
429 }
430 Ok(())
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use crate::password::verify_password;
438 use crate::types::ApplicationId;
439
440 #[test]
441 fn client_id_has_ath_prefix() {
442 let id = generate_client_id();
443 assert!(
444 id.as_str().starts_with("ath_"),
445 "client_id must start with ath_"
446 );
447 }
448
449 #[test]
450 fn client_id_length_is_36() {
451 let id = generate_client_id();
452 assert_eq!(id.as_str().len(), 36, "ath_(4) + 32 base64url chars = 36");
453 }
454
455 #[test]
456 fn client_id_chars_are_url_safe() {
457 let id = generate_client_id();
458 let suffix = &id.as_str()[4..];
460 assert!(
461 suffix
462 .chars()
463 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
464 "client_id suffix must be URL-safe base64url: got {suffix}"
465 );
466 }
467
468 #[test]
469 fn two_client_ids_differ() {
470 let a = generate_client_id();
471 let b = generate_client_id();
472 assert_ne!(a, b, "each client_id must be unique");
473 }
474
475 #[test]
476 fn client_secret_verifies_round_trip() {
477 let (secret, hash) = generate_client_secret().expect("generate_client_secret");
478 let valid = verify_password(secret.as_str(), &hash).expect("verify_password");
479 assert!(valid, "generated secret must verify against its own hash");
480 }
481
482 #[test]
483 fn two_client_secrets_differ() {
484 let (s1, _) = generate_client_secret().expect("secret 1");
485 let (s2, _) = generate_client_secret().expect("secret 2");
486 assert_ne!(s1.as_str(), s2.as_str(), "each secret must be unique");
487 }
488
489 #[test]
490 fn wrong_secret_does_not_verify() {
491 let (_, hash) = generate_client_secret().expect("generate_client_secret");
492 let valid = verify_password("wrong-secret", &hash).expect("verify_password");
493 assert!(!valid, "wrong secret must not verify");
494 }
495
496 #[test]
499 fn redirect_uri_empty_list_is_rejected() {
500 let err = validate_redirect_uris(&[]).unwrap_err();
501 assert!(matches!(err, AuthError::InvalidRedirectUri(_)));
502 }
503
504 #[test]
505 fn redirect_uri_https_is_valid() {
506 let uris = vec!["https://example.com/callback".to_string()];
507 assert!(validate_redirect_uris(&uris).is_ok());
508 }
509
510 #[test]
511 fn redirect_uri_http_localhost_is_valid() {
512 let uris = vec!["http://localhost/callback".to_string()];
513 assert!(validate_redirect_uris(&uris).is_ok());
514 }
515
516 #[test]
517 fn redirect_uri_http_localhost_with_port_is_valid() {
518 let uris = vec!["http://localhost:3000/callback".to_string()];
519 assert!(validate_redirect_uris(&uris).is_ok());
520 }
521
522 #[test]
523 fn redirect_uri_http_127_0_0_1_is_valid() {
524 let uris = vec!["http://127.0.0.1:8080/callback".to_string()];
525 assert!(validate_redirect_uris(&uris).is_ok());
526 }
527
528 #[test]
529 fn redirect_uri_http_non_localhost_is_rejected() {
530 let uris = vec!["http://example.com/callback".to_string()];
531 let err = validate_redirect_uris(&uris).unwrap_err();
532 assert!(matches!(err, AuthError::InvalidRedirectUri(_)));
533 }
534
535 #[test]
536 fn redirect_uri_with_fragment_is_rejected() {
537 let uris = vec!["https://example.com/callback#section".to_string()];
538 let err = validate_redirect_uris(&uris).unwrap_err();
539 assert!(matches!(err, AuthError::InvalidRedirectUri(_)));
540 }
541
542 #[test]
543 fn redirect_uri_relative_is_rejected() {
544 let uris = vec!["/callback".to_string()];
545 let err = validate_redirect_uris(&uris).unwrap_err();
546 assert!(matches!(err, AuthError::InvalidRedirectUri(_)));
547 }
548
549 #[test]
552 fn redirect_uri_exact_match_passes() {
553 let registered = vec!["https://example.com/callback".to_string()];
554 assert!(validate_redirect_uri("https://example.com/callback", ®istered).is_ok());
555 }
556
557 #[test]
558 fn redirect_uri_not_in_registered_is_rejected() {
559 let registered = vec!["https://example.com/callback".to_string()];
560 let err = validate_redirect_uri("https://example.com/other", ®istered).unwrap_err();
561 assert!(matches!(err, AuthError::InvalidRedirectUri(_)));
562 }
563
564 #[test]
567 fn redirect_uri_list_parses_valid_json() {
568 let (_, hash) = generate_client_secret().expect("generate_client_secret");
569 let app = Application {
570 id: ApplicationId::new(),
571 name: "Test".to_string(),
572 client_id: generate_client_id(),
573 client_secret_hash: hash,
574 redirect_uris: r#"["https://example.com/callback","https://example.com/other"]"#
575 .to_string(),
576 logo_url: None,
577 primary_color: None,
578 is_trusted: false,
579 created_by: None,
580 is_active: true,
581 created_at: chrono::Utc::now(),
582 updated_at: chrono::Utc::now(),
583 };
584 let list = app.redirect_uri_list().expect("redirect_uri_list");
585 assert_eq!(
586 list,
587 vec![
588 "https://example.com/callback".to_string(),
589 "https://example.com/other".to_string(),
590 ]
591 );
592 }
593
594 #[test]
595 fn redirect_uri_list_returns_error_on_malformed_json() {
596 let (_, hash) = generate_client_secret().expect("generate_client_secret");
597 let app = Application {
598 id: ApplicationId::new(),
599 name: "Test".to_string(),
600 client_id: generate_client_id(),
601 client_secret_hash: hash,
602 redirect_uris: "not valid json".to_string(),
603 logo_url: None,
604 primary_color: None,
605 is_trusted: false,
606 created_by: None,
607 is_active: true,
608 created_at: chrono::Utc::now(),
609 updated_at: chrono::Utc::now(),
610 };
611 assert!(matches!(
612 app.redirect_uri_list(),
613 Err(AuthError::Database(_))
614 ));
615 }
616
617 #[test]
620 fn logo_url_https_is_valid() {
621 assert!(validate_logo_url("https://example.com/logo.png").is_ok());
622 }
623
624 #[test]
625 fn logo_url_http_localhost_is_valid() {
626 assert!(validate_logo_url("http://localhost:3000/logo.png").is_ok());
627 }
628
629 #[test]
630 fn logo_url_http_127_is_valid() {
631 assert!(validate_logo_url("http://127.0.0.1:8080/logo.png").is_ok());
632 }
633
634 #[test]
635 fn logo_url_http_non_localhost_is_rejected() {
636 let err = validate_logo_url("http://example.com/logo.png").unwrap_err();
637 assert!(matches!(err, AuthError::Validation(_)));
638 }
639
640 #[test]
641 fn logo_url_relative_is_rejected() {
642 let err = validate_logo_url("/logo.png").unwrap_err();
643 assert!(matches!(err, AuthError::Validation(_)));
644 }
645
646 #[test]
647 fn logo_url_not_a_url_is_rejected() {
648 let err = validate_logo_url("not a url").unwrap_err();
649 assert!(matches!(err, AuthError::Validation(_)));
650 }
651
652 #[test]
655 fn primary_color_valid_hex() {
656 assert!(validate_primary_color("#3B82F6").is_ok());
657 }
658
659 #[test]
660 fn primary_color_lowercase_hex() {
661 assert!(validate_primary_color("#3b82f6").is_ok());
662 }
663
664 #[test]
665 fn primary_color_missing_hash() {
666 let err = validate_primary_color("3B82F6").unwrap_err();
667 assert!(matches!(err, AuthError::Validation(_)));
668 }
669
670 #[test]
671 fn primary_color_too_short() {
672 let err = validate_primary_color("#FFF").unwrap_err();
673 assert!(matches!(err, AuthError::Validation(_)));
674 }
675
676 #[test]
677 fn primary_color_too_long() {
678 let err = validate_primary_color("#3B82F6FF").unwrap_err();
679 assert!(matches!(err, AuthError::Validation(_)));
680 }
681
682 #[test]
683 fn primary_color_non_hex_chars() {
684 let err = validate_primary_color("#ZZZZZZ").unwrap_err();
685 assert!(matches!(err, AuthError::Validation(_)));
686 }
687
688 #[test]
689 fn primary_color_named_color_rejected() {
690 let err = validate_primary_color("red").unwrap_err();
691 assert!(matches!(err, AuthError::Validation(_)));
692 }
693
694 #[test]
697 fn branding_extracts_correct_fields() {
698 let (_, hash) = generate_client_secret().expect("generate");
699 let app = Application {
700 id: ApplicationId::new(),
701 name: "My App".to_string(),
702 client_id: generate_client_id(),
703 client_secret_hash: hash,
704 redirect_uris: r#"["https://example.com/cb"]"#.to_string(),
705 logo_url: Some("https://example.com/logo.png".to_string()),
706 primary_color: Some("#3B82F6".to_string()),
707 is_trusted: false,
708 created_by: None,
709 is_active: true,
710 created_at: chrono::Utc::now(),
711 updated_at: chrono::Utc::now(),
712 };
713 let b = app.branding();
714 assert_eq!(b.application_name, "My App");
715 assert_eq!(b.logo_url.as_deref(), Some("https://example.com/logo.png"));
716 assert_eq!(b.primary_color.as_deref(), Some("#3B82F6"));
717 }
718
719 #[test]
720 fn application_serialization_omits_secret() {
721 let (_, hash) = generate_client_secret().expect("generate_client_secret");
722 let app = Application {
723 id: ApplicationId::new(),
724 name: "Test App".to_string(),
725 client_id: generate_client_id(),
726 client_secret_hash: hash,
727 redirect_uris: r#"["https://example.com/callback"]"#.to_string(),
728 logo_url: None,
729 primary_color: None,
730 is_trusted: false,
731 created_by: None,
732 is_active: true,
733 created_at: chrono::Utc::now(),
734 updated_at: chrono::Utc::now(),
735 };
736 let value = serde_json::to_value(&app).expect("serialize Application");
737 assert!(
738 value.get("client_secret_hash").is_none(),
739 "client_secret_hash must not appear in serialized output"
740 );
741 assert!(
742 value.get("client_id").is_some(),
743 "client_id must appear in serialized output"
744 );
745 }
746}