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::{
11 AccentInk, ApplicationId, ClientId, ClientSecret, ClientType, Mode, PasswordHash,
12 SplashPrimitive, UserId,
13};
14
15#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
20pub struct Application {
21 pub id: ApplicationId,
22 pub name: String,
23 pub client_id: ClientId,
24 pub client_type: ClientType,
25 #[serde(skip_serializing)]
26 pub client_secret_hash: Option<PasswordHash>,
27 pub redirect_uris: String, pub logo_url: Option<String>,
29 pub primary_color: Option<String>,
30 pub accent_hex: Option<String>,
32 pub accent_ink: Option<AccentInk>,
33 pub forced_mode: Option<Mode>,
34 pub font_css_url: Option<String>,
35 pub font_family: Option<String>,
36 pub splash_text: Option<String>,
37 pub splash_image_url: Option<String>,
38 pub splash_primitive: Option<SplashPrimitive>,
39 pub splash_url: Option<String>,
40 pub shader_cell_scale: Option<i64>,
41 pub is_trusted: bool,
42 pub created_by: Option<UserId>,
43 pub is_active: bool,
44 pub created_at: DateTime<Utc>,
45 pub updated_at: DateTime<Utc>,
46}
47
48#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
57pub struct BrandingConfig {
58 pub application_name: String,
59 pub logo_url: Option<String>,
60 pub primary_color: Option<String>,
61 pub accent_hex: Option<String>,
62 pub accent_ink: Option<AccentInk>,
63 pub forced_mode: Option<Mode>,
64 pub font_css_url: Option<String>,
65 pub font_family: Option<String>,
66 pub splash_text: Option<String>,
67 pub splash_image_url: Option<String>,
68 pub splash_primitive: Option<SplashPrimitive>,
69 pub splash_url: Option<String>,
70 pub shader_cell_scale: Option<i64>,
71}
72
73pub fn generate_client_id() -> ClientId {
78 let mut bytes = [0u8; 24];
79 OsRng
80 .try_fill_bytes(&mut bytes)
81 .expect("OS RNG unavailable");
82 let encoded = Base64UrlUnpadded::encode_string(&bytes);
83 ClientId::new_unchecked(format!("ath_{encoded}"))
84}
85
86pub fn generate_client_secret() -> Result<(ClientSecret, PasswordHash), AuthError> {
93 let mut bytes = [0u8; 32];
94 OsRng
95 .try_fill_bytes(&mut bytes)
96 .expect("OS RNG unavailable");
97 let raw = Base64UrlUnpadded::encode_string(&bytes);
98 let hash = crate::password::hash_password(&raw)?;
99 Ok((ClientSecret::new_unchecked(raw), hash))
100}
101
102impl Application {
103 pub fn redirect_uri_list(&self) -> Result<Vec<String>, AuthError> {
109 serde_json::from_str(&self.redirect_uris)
110 .map_err(|e| AuthError::Database(sqlx::Error::Decode(Box::new(e))))
111 }
112
113 pub fn branding(&self) -> BrandingConfig {
115 BrandingConfig {
116 application_name: self.name.clone(),
117 logo_url: self.logo_url.clone(),
118 primary_color: self.primary_color.clone(),
119 accent_hex: self.accent_hex.clone(),
120 accent_ink: self.accent_ink,
121 forced_mode: self.forced_mode,
122 font_css_url: self.font_css_url.clone(),
123 font_family: self.font_family.clone(),
124 splash_text: self.splash_text.clone(),
125 splash_image_url: self.splash_image_url.clone(),
126 splash_primitive: self.splash_primitive,
127 splash_url: self.splash_url.clone(),
128 shader_cell_scale: self.shader_cell_scale,
129 }
130 }
131}
132
133impl BrandingConfig {
134 pub fn new(application_name: impl Into<String>) -> Self {
138 Self {
139 application_name: application_name.into(),
140 logo_url: None,
141 primary_color: None,
142 accent_hex: None,
143 accent_ink: None,
144 forced_mode: None,
145 font_css_url: None,
146 font_family: None,
147 splash_text: None,
148 splash_image_url: None,
149 splash_primitive: None,
150 splash_url: None,
151 shader_cell_scale: None,
152 }
153 }
154
155 pub fn with_accent(mut self, hex: impl Into<String>, ink: AccentInk) -> Self {
156 self.accent_hex = Some(hex.into());
157 self.accent_ink = Some(ink);
158 self
159 }
160
161 pub fn with_primary_color(mut self, hex: impl Into<String>) -> Self {
162 self.primary_color = Some(hex.into());
163 self
164 }
165
166 pub fn with_logo_url(mut self, url: impl Into<String>) -> Self {
167 self.logo_url = Some(url.into());
168 self
169 }
170
171 pub fn with_splash_text(mut self, text: impl Into<String>) -> Self {
172 self.splash_text = Some(text.into());
173 self
174 }
175
176 pub fn with_splash_image_url(mut self, url: impl Into<String>) -> Self {
177 self.splash_image_url = Some(url.into());
178 self
179 }
180
181 pub fn with_splash_primitive(mut self, primitive: SplashPrimitive) -> Self {
182 self.splash_primitive = Some(primitive);
183 self
184 }
185
186 pub fn with_shader_cell_scale(mut self, scale: i64) -> Self {
187 self.shader_cell_scale = Some(scale);
188 self
189 }
190}
191
192fn map_unique_violation(err: sqlx::Error) -> AuthError {
193 if let sqlx::Error::Database(ref db_err) = err {
194 let msg = db_err.message();
195 if msg.contains("UNIQUE constraint failed") && msg.contains("client_id") {
196 return AuthError::Conflict("client_id already exists".into());
197 }
198 }
199 AuthError::Database(err)
200}
201
202pub struct ApplicationCursor {
207 pub created_at: DateTime<Utc>,
208 pub id: ApplicationId,
209}
210
211#[derive(serde::Serialize, serde::Deserialize)]
212struct RawCursor {
213 ca: String,
214 id: String,
215}
216
217impl ApplicationCursor {
218 pub fn from_app(app: &Application) -> Self {
219 Self {
220 created_at: app.created_at,
221 id: app.id,
222 }
223 }
224
225 pub fn encode(&self) -> String {
226 let raw = RawCursor {
227 ca: self.created_at.to_rfc3339(),
228 id: self.id.to_string(),
229 };
230 let json = serde_json::to_string(&raw).expect("RawCursor serializes");
231 Base64UrlUnpadded::encode_string(json.as_bytes())
232 }
233
234 pub fn decode(s: &str) -> Option<Self> {
235 let bytes = Base64UrlUnpadded::decode_vec(s).ok()?;
236 let raw: RawCursor = serde_json::from_slice(&bytes).ok()?;
237 let created_at = chrono::DateTime::parse_from_rfc3339(&raw.ca)
238 .ok()?
239 .with_timezone(&Utc);
240 let id = raw
241 .id
242 .parse::<uuid::Uuid>()
243 .ok()
244 .map(ApplicationId::from_uuid)?;
245 Some(Self { created_at, id })
246 }
247}
248
249pub struct CreateApplicationParams {
251 pub name: String,
252 pub client_type: ClientType,
253 pub redirect_uris: Vec<String>,
254 pub is_trusted: bool,
255 pub created_by: Option<UserId>,
256 pub logo_url: Option<String>,
257 pub primary_color: Option<String>,
258 pub accent_hex: Option<String>,
259 pub accent_ink: Option<AccentInk>,
260 pub forced_mode: Option<Mode>,
261 pub font_css_url: Option<String>,
262 pub font_family: Option<String>,
263 pub splash_text: Option<String>,
264 pub splash_image_url: Option<String>,
265 pub splash_primitive: Option<SplashPrimitive>,
266 pub splash_url: Option<String>,
267 pub shader_cell_scale: Option<i64>,
268}
269
270pub struct UpdateApplication {
275 pub name: String,
276 pub redirect_uris: Vec<String>,
277 pub is_trusted: bool,
278 pub is_active: bool,
279 pub logo_url: Option<String>,
280 pub primary_color: Option<String>,
281 pub accent_hex: Option<String>,
282 pub accent_ink: Option<AccentInk>,
283 pub forced_mode: Option<Mode>,
284 pub font_css_url: Option<String>,
285 pub font_family: Option<String>,
286 pub splash_text: Option<String>,
287 pub splash_image_url: Option<String>,
288 pub splash_primitive: Option<SplashPrimitive>,
289 pub splash_url: Option<String>,
290 pub shader_cell_scale: Option<i64>,
291}
292
293pub fn validate_redirect_uris(uris: &[String]) -> Result<(), AuthError> {
304 if uris.is_empty() {
305 return Err(AuthError::InvalidRedirectUri(
306 "redirect_uris must not be empty".into(),
307 ));
308 }
309 for uri in uris {
310 let parsed = Url::parse(uri).map_err(|_| AuthError::InvalidRedirectUri(uri.clone()))?;
311 if parsed.fragment().is_some() {
312 return Err(AuthError::InvalidRedirectUri(uri.clone()));
313 }
314 let scheme = parsed.scheme();
315 if scheme == "https" {
316 continue;
317 }
318 if scheme == "http" {
319 let host = parsed.host_str().unwrap_or("");
320 if host == "localhost" || host == "127.0.0.1" {
321 continue;
322 }
323 }
324 return Err(AuthError::InvalidRedirectUri(uri.clone()));
325 }
326 Ok(())
327}
328
329pub fn validate_redirect_uri(redirect_uri: &str, registered: &[String]) -> Result<(), AuthError> {
336 if registered.iter().any(|r| r == redirect_uri) {
337 Ok(())
338 } else {
339 Err(AuthError::InvalidRedirectUri(redirect_uri.to_owned()))
340 }
341}
342
343pub fn validate_logo_url(url: &str) -> Result<(), AuthError> {
349 let parsed = Url::parse(url)
350 .map_err(|_| AuthError::Validation("logo_url must be a valid absolute URL".into()))?;
351 let scheme = parsed.scheme();
352 if scheme == "https" {
353 return Ok(());
354 }
355 if scheme == "http" {
356 let host = parsed.host_str().unwrap_or("");
357 if host == "localhost" || host == "127.0.0.1" {
358 return Ok(());
359 }
360 }
361 Err(AuthError::Validation(
362 "logo_url must be an HTTPS URL".into(),
363 ))
364}
365
366pub fn validate_font_css_url(url: &str) -> Result<(), AuthError> {
369 validate_https_url(url, "font_css_url")
370}
371
372pub fn validate_splash_image_url(url: &str) -> Result<(), AuthError> {
374 validate_https_url(url, "splash_image_url")
375}
376
377pub fn validate_splash_url(url: &str) -> Result<(), AuthError> {
379 validate_https_url(url, "splash_url")
380}
381
382fn validate_https_url(url: &str, field: &str) -> Result<(), AuthError> {
387 let parsed = Url::parse(url)
388 .map_err(|_| AuthError::Validation(format!("{field} must be a valid absolute URL")))?;
389 if parsed.scheme() != "https" {
390 return Err(AuthError::Validation(format!(
391 "{field} must be an HTTPS URL"
392 )));
393 }
394 Ok(())
395}
396
397fn validate_hex_color(color: &str, field: &str) -> Result<(), AuthError> {
402 let bytes = color.as_bytes();
403 if bytes.len() != 7 || bytes[0] != b'#' || !bytes[1..].iter().all(|b| b.is_ascii_hexdigit()) {
404 return Err(AuthError::Validation(format!(
405 "{field} must be a hex color (#RRGGBB)"
406 )));
407 }
408 Ok(())
409}
410
411pub fn validate_primary_color(color: &str) -> Result<(), AuthError> {
417 validate_hex_color(color, "primary_color")
418}
419
420pub fn validate_accent_hex(color: &str) -> Result<(), AuthError> {
426 validate_hex_color(color, "accent_hex")
427}
428
429impl Db {
430 pub async fn create_application(
440 &self,
441 params: CreateApplicationParams,
442 ) -> Result<(Application, Option<ClientSecret>), AuthError> {
443 let CreateApplicationParams {
444 name,
445 client_type,
446 redirect_uris,
447 is_trusted,
448 created_by,
449 logo_url,
450 primary_color,
451 accent_hex,
452 accent_ink,
453 forced_mode,
454 font_css_url,
455 font_family,
456 splash_text,
457 splash_image_url,
458 splash_primitive,
459 splash_url,
460 shader_cell_scale,
461 } = params;
462 validate_redirect_uris(&redirect_uris)?;
463 if let Some(ref url) = logo_url {
464 validate_logo_url(url)?;
465 }
466 if let Some(ref color) = primary_color {
467 validate_primary_color(color)?;
468 }
469 if let Some(ref hex) = accent_hex {
470 validate_accent_hex(hex)?;
471 }
472 if let Some(ref url) = font_css_url {
473 validate_font_css_url(url)?;
474 }
475 if let Some(ref url) = splash_image_url {
476 validate_splash_image_url(url)?;
477 }
478 if let Some(ref url) = splash_url {
479 validate_splash_url(url)?;
480 }
481 let id = ApplicationId::new();
482 let client_id = generate_client_id();
483 let (raw_secret, hash) = match client_type {
484 ClientType::Confidential => {
485 let (secret, hash) = generate_client_secret()?;
486 (Some(secret), Some(hash))
487 }
488 ClientType::Public => (None, None),
489 };
490 let redirect_uris_json =
491 serde_json::to_string(&redirect_uris).expect("Vec<String> serializes to JSON");
492 let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
493
494 sqlx::query(
495 "INSERT INTO allowthem_applications \
496 (id, name, client_id, client_type, client_secret_hash, redirect_uris, logo_url, \
497 primary_color, \
498 accent_hex, accent_ink, forced_mode, font_css_url, font_family, \
499 splash_text, splash_image_url, splash_primitive, splash_url, shader_cell_scale, \
500 is_trusted, created_by, is_active, created_at, updated_at) \
501 VALUES \
502 (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, \
503 ?9, ?10, ?11, ?12, ?13, \
504 ?14, ?15, ?16, ?17, ?18, \
505 ?19, ?20, 1, ?21, ?21)",
506 )
507 .bind(id)
508 .bind(&name)
509 .bind(&client_id)
510 .bind(client_type)
511 .bind(&hash)
512 .bind(&redirect_uris_json)
513 .bind(&logo_url)
514 .bind(&primary_color)
515 .bind(&accent_hex)
516 .bind(accent_ink.map(|v| v.as_str()))
517 .bind(forced_mode.map(|v| v.as_str()))
518 .bind(&font_css_url)
519 .bind(&font_family)
520 .bind(&splash_text)
521 .bind(&splash_image_url)
522 .bind(splash_primitive.map(|v| v.as_str()))
523 .bind(&splash_url)
524 .bind(shader_cell_scale)
525 .bind(is_trusted)
526 .bind(created_by)
527 .bind(&now)
528 .execute(self.pool())
529 .await
530 .map_err(map_unique_violation)?;
531
532 let app = self.get_application(id).await?;
533 Ok((app, raw_secret))
534 }
535
536 pub async fn get_application(&self, id: ApplicationId) -> Result<Application, AuthError> {
538 sqlx::query_as::<_, Application>(
539 "SELECT id, name, client_id, client_type, client_secret_hash, redirect_uris, \
540 logo_url, primary_color, \
541 accent_hex, accent_ink, forced_mode, font_css_url, font_family, \
542 splash_text, splash_image_url, splash_primitive, splash_url, shader_cell_scale, \
543 is_trusted, created_by, is_active, created_at, updated_at \
544 FROM allowthem_applications WHERE id = ?",
545 )
546 .bind(id)
547 .fetch_optional(self.pool())
548 .await?
549 .ok_or(AuthError::NotFound)
550 }
551
552 pub async fn get_application_by_client_id(
556 &self,
557 client_id: &ClientId,
558 ) -> Result<Application, AuthError> {
559 sqlx::query_as::<_, Application>(
560 "SELECT id, name, client_id, client_type, client_secret_hash, redirect_uris, \
561 logo_url, primary_color, \
562 accent_hex, accent_ink, forced_mode, font_css_url, font_family, \
563 splash_text, splash_image_url, splash_primitive, splash_url, shader_cell_scale, \
564 is_trusted, created_by, is_active, created_at, updated_at \
565 FROM allowthem_applications WHERE client_id = ?",
566 )
567 .bind(client_id)
568 .fetch_optional(self.pool())
569 .await?
570 .ok_or(AuthError::NotFound)
571 }
572
573 pub async fn get_branding_by_client_id(
579 &self,
580 client_id: &ClientId,
581 ) -> Result<Option<BrandingConfig>, AuthError> {
582 sqlx::query_as::<_, BrandingConfig>(
583 "SELECT name AS application_name, logo_url, primary_color, \
584 accent_hex, accent_ink, forced_mode, font_css_url, font_family, \
585 splash_text, splash_image_url, splash_primitive, splash_url, shader_cell_scale \
586 FROM allowthem_applications \
587 WHERE client_id = ? AND is_active = 1",
588 )
589 .bind(client_id)
590 .fetch_optional(self.pool())
591 .await
592 .map_err(AuthError::Database)
593 }
594
595 pub async fn list_applications(&self) -> Result<Vec<Application>, AuthError> {
597 sqlx::query_as::<_, Application>(
598 "SELECT id, name, client_id, client_type, client_secret_hash, redirect_uris, \
599 logo_url, primary_color, \
600 accent_hex, accent_ink, forced_mode, font_css_url, font_family, \
601 splash_text, splash_image_url, splash_primitive, splash_url, shader_cell_scale, \
602 is_trusted, created_by, is_active, created_at, updated_at \
603 FROM allowthem_applications ORDER BY created_at ASC",
604 )
605 .fetch_all(self.pool())
606 .await
607 .map_err(AuthError::Database)
608 }
609
610 pub async fn list_applications_paginated(
614 &self,
615 limit: u32,
616 cursor: Option<&ApplicationCursor>,
617 ) -> Result<Vec<Application>, AuthError> {
618 let limit = (limit as i64).min(200);
619 match cursor {
620 None => sqlx::query_as::<_, Application>(
621 "SELECT id, name, client_id, client_type, client_secret_hash, \
622 redirect_uris, logo_url, primary_color, \
623 accent_hex, accent_ink, forced_mode, font_css_url, font_family, \
624 splash_text, splash_image_url, splash_primitive, splash_url, shader_cell_scale, \
625 is_trusted, created_by, is_active, created_at, updated_at \
626 FROM allowthem_applications \
627 ORDER BY created_at ASC, id ASC LIMIT ?1",
628 )
629 .bind(limit)
630 .fetch_all(self.pool())
631 .await
632 .map_err(AuthError::Database),
633 Some(cur) => {
634 let ca = cur.created_at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
637 sqlx::query_as::<_, Application>(
638 "SELECT id, name, client_id, client_type, client_secret_hash, \
639 redirect_uris, logo_url, primary_color, \
640 accent_hex, accent_ink, forced_mode, font_css_url, font_family, \
641 splash_text, splash_image_url, splash_primitive, splash_url, shader_cell_scale, \
642 is_trusted, created_by, is_active, created_at, updated_at \
643 FROM allowthem_applications \
644 WHERE (created_at > ?1 OR (created_at = ?1 AND id > ?2)) \
645 ORDER BY created_at ASC, id ASC LIMIT ?3",
646 )
647 .bind(&ca)
648 .bind(cur.id)
649 .bind(limit)
650 .fetch_all(self.pool())
651 .await
652 .map_err(AuthError::Database)
653 }
654 }
655 }
656
657 pub async fn update_application(
666 &self,
667 id: ApplicationId,
668 params: UpdateApplication,
669 ) -> Result<(), AuthError> {
670 validate_redirect_uris(¶ms.redirect_uris)?;
671 if let Some(ref url) = params.logo_url {
672 validate_logo_url(url)?;
673 }
674 if let Some(ref color) = params.primary_color {
675 validate_primary_color(color)?;
676 }
677 if let Some(ref hex) = params.accent_hex {
678 validate_accent_hex(hex)?;
679 }
680 if let Some(ref url) = params.font_css_url {
681 validate_font_css_url(url)?;
682 }
683 if let Some(ref url) = params.splash_image_url {
684 validate_splash_image_url(url)?;
685 }
686 if let Some(ref url) = params.splash_url {
687 validate_splash_url(url)?;
688 }
689 let redirect_uris_json =
690 serde_json::to_string(¶ms.redirect_uris).expect("Vec<String> serializes to JSON");
691 let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
692
693 let result = sqlx::query(
694 "UPDATE allowthem_applications \
695 SET name = ?1, redirect_uris = ?2, is_trusted = ?3, is_active = ?4, \
696 logo_url = ?5, primary_color = ?6, \
697 accent_hex = ?7, accent_ink = ?8, forced_mode = ?9, \
698 font_css_url = ?10, font_family = ?11, \
699 splash_text = ?12, splash_image_url = ?13, splash_primitive = ?14, \
700 splash_url = ?15, shader_cell_scale = ?16, \
701 updated_at = ?17 \
702 WHERE id = ?18",
703 )
704 .bind(¶ms.name)
705 .bind(&redirect_uris_json)
706 .bind(params.is_trusted)
707 .bind(params.is_active)
708 .bind(¶ms.logo_url)
709 .bind(¶ms.primary_color)
710 .bind(¶ms.accent_hex)
711 .bind(params.accent_ink.map(|v| v.as_str()))
712 .bind(params.forced_mode.map(|v| v.as_str()))
713 .bind(¶ms.font_css_url)
714 .bind(¶ms.font_family)
715 .bind(¶ms.splash_text)
716 .bind(¶ms.splash_image_url)
717 .bind(params.splash_primitive.map(|v| v.as_str()))
718 .bind(¶ms.splash_url)
719 .bind(params.shader_cell_scale)
720 .bind(&now)
721 .bind(id)
722 .execute(self.pool())
723 .await?;
724
725 if result.rows_affected() == 0 {
726 return Err(AuthError::NotFound);
727 }
728 Ok(())
729 }
730
731 pub async fn regenerate_client_secret(
739 &self,
740 id: ApplicationId,
741 ) -> Result<(Application, ClientSecret), AuthError> {
742 let application = self.get_application(id).await?;
743 if application.client_type == ClientType::Public {
744 return Err(AuthError::InvalidRequest(
745 "public clients have no client secret".into(),
746 ));
747 }
748 let (raw_secret, hash) = generate_client_secret()?;
749 let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
750
751 let result = sqlx::query(
752 "UPDATE allowthem_applications \
753 SET client_secret_hash = ?1, updated_at = ?2 \
754 WHERE id = ?3",
755 )
756 .bind(&hash)
757 .bind(&now)
758 .bind(id)
759 .execute(self.pool())
760 .await?;
761
762 if result.rows_affected() == 0 {
763 return Err(AuthError::NotFound);
764 }
765
766 let app = self.get_application(id).await?;
767 Ok((app, raw_secret))
768 }
769
770 pub async fn delete_application(&self, id: ApplicationId) -> Result<(), AuthError> {
775 let result = sqlx::query("DELETE FROM allowthem_applications WHERE id = ?")
776 .bind(id)
777 .execute(self.pool())
778 .await?;
779
780 if result.rows_affected() == 0 {
781 return Err(AuthError::NotFound);
782 }
783 Ok(())
784 }
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790 use crate::password::verify_password;
791 use crate::types::ApplicationId;
792
793 #[test]
794 fn client_id_has_ath_prefix() {
795 let id = generate_client_id();
796 assert!(
797 id.as_str().starts_with("ath_"),
798 "client_id must start with ath_"
799 );
800 }
801
802 #[test]
803 fn client_id_length_is_36() {
804 let id = generate_client_id();
805 assert_eq!(id.as_str().len(), 36, "ath_(4) + 32 base64url chars = 36");
806 }
807
808 #[test]
809 fn client_id_chars_are_url_safe() {
810 let id = generate_client_id();
811 let suffix = &id.as_str()[4..];
813 assert!(
814 suffix
815 .chars()
816 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
817 "client_id suffix must be URL-safe base64url: got {suffix}"
818 );
819 }
820
821 #[test]
822 fn two_client_ids_differ() {
823 let a = generate_client_id();
824 let b = generate_client_id();
825 assert_ne!(a, b, "each client_id must be unique");
826 }
827
828 #[test]
829 fn client_secret_verifies_round_trip() {
830 let (secret, hash) = generate_client_secret().expect("generate_client_secret");
831 let valid = verify_password(secret.as_str(), &hash).expect("verify_password");
832 assert!(valid, "generated secret must verify against its own hash");
833 }
834
835 #[test]
836 fn two_client_secrets_differ() {
837 let (s1, _) = generate_client_secret().expect("secret 1");
838 let (s2, _) = generate_client_secret().expect("secret 2");
839 assert_ne!(s1.as_str(), s2.as_str(), "each secret must be unique");
840 }
841
842 #[test]
843 fn wrong_secret_does_not_verify() {
844 let (_, hash) = generate_client_secret().expect("generate_client_secret");
845 let valid = verify_password("wrong-secret", &hash).expect("verify_password");
846 assert!(!valid, "wrong secret must not verify");
847 }
848
849 #[test]
852 fn redirect_uri_empty_list_is_rejected() {
853 let err = validate_redirect_uris(&[]).unwrap_err();
854 assert!(matches!(err, AuthError::InvalidRedirectUri(_)));
855 }
856
857 #[test]
858 fn redirect_uri_https_is_valid() {
859 let uris = vec!["https://example.com/callback".to_string()];
860 assert!(validate_redirect_uris(&uris).is_ok());
861 }
862
863 #[test]
864 fn redirect_uri_http_localhost_is_valid() {
865 let uris = vec!["http://localhost/callback".to_string()];
866 assert!(validate_redirect_uris(&uris).is_ok());
867 }
868
869 #[test]
870 fn redirect_uri_http_localhost_with_port_is_valid() {
871 let uris = vec!["http://localhost:3000/callback".to_string()];
872 assert!(validate_redirect_uris(&uris).is_ok());
873 }
874
875 #[test]
876 fn redirect_uri_http_127_0_0_1_is_valid() {
877 let uris = vec!["http://127.0.0.1:8080/callback".to_string()];
878 assert!(validate_redirect_uris(&uris).is_ok());
879 }
880
881 #[test]
882 fn redirect_uri_http_non_localhost_is_rejected() {
883 let uris = vec!["http://example.com/callback".to_string()];
884 let err = validate_redirect_uris(&uris).unwrap_err();
885 assert!(matches!(err, AuthError::InvalidRedirectUri(_)));
886 }
887
888 #[test]
889 fn redirect_uri_with_fragment_is_rejected() {
890 let uris = vec!["https://example.com/callback#section".to_string()];
891 let err = validate_redirect_uris(&uris).unwrap_err();
892 assert!(matches!(err, AuthError::InvalidRedirectUri(_)));
893 }
894
895 #[test]
896 fn redirect_uri_relative_is_rejected() {
897 let uris = vec!["/callback".to_string()];
898 let err = validate_redirect_uris(&uris).unwrap_err();
899 assert!(matches!(err, AuthError::InvalidRedirectUri(_)));
900 }
901
902 #[test]
905 fn redirect_uri_exact_match_passes() {
906 let registered = vec!["https://example.com/callback".to_string()];
907 assert!(validate_redirect_uri("https://example.com/callback", ®istered).is_ok());
908 }
909
910 #[test]
911 fn redirect_uri_not_in_registered_is_rejected() {
912 let registered = vec!["https://example.com/callback".to_string()];
913 let err = validate_redirect_uri("https://example.com/other", ®istered).unwrap_err();
914 assert!(matches!(err, AuthError::InvalidRedirectUri(_)));
915 }
916
917 #[test]
920 fn redirect_uri_list_parses_valid_json() {
921 let (_, hash) = generate_client_secret().expect("generate_client_secret");
922 let app = Application {
923 id: ApplicationId::new(),
924 name: "Test".to_string(),
925 client_id: generate_client_id(),
926 client_type: ClientType::Confidential,
927 client_secret_hash: Some(hash),
928 redirect_uris: r#"["https://example.com/callback","https://example.com/other"]"#
929 .to_string(),
930 logo_url: None,
931 primary_color: None,
932 accent_hex: None,
933 accent_ink: None,
934 forced_mode: None,
935 font_css_url: None,
936 font_family: None,
937 splash_text: None,
938 splash_image_url: None,
939 splash_primitive: None,
940 splash_url: None,
941 shader_cell_scale: None,
942 is_trusted: false,
943 created_by: None,
944 is_active: true,
945 created_at: chrono::Utc::now(),
946 updated_at: chrono::Utc::now(),
947 };
948 let list = app.redirect_uri_list().expect("redirect_uri_list");
949 assert_eq!(
950 list,
951 vec![
952 "https://example.com/callback".to_string(),
953 "https://example.com/other".to_string(),
954 ]
955 );
956 }
957
958 #[test]
959 fn redirect_uri_list_returns_error_on_malformed_json() {
960 let (_, hash) = generate_client_secret().expect("generate_client_secret");
961 let app = Application {
962 id: ApplicationId::new(),
963 name: "Test".to_string(),
964 client_id: generate_client_id(),
965 client_type: ClientType::Confidential,
966 client_secret_hash: Some(hash),
967 redirect_uris: "not valid json".to_string(),
968 logo_url: None,
969 primary_color: None,
970 accent_hex: None,
971 accent_ink: None,
972 forced_mode: None,
973 font_css_url: None,
974 font_family: None,
975 splash_text: None,
976 splash_image_url: None,
977 splash_primitive: None,
978 splash_url: None,
979 shader_cell_scale: None,
980 is_trusted: false,
981 created_by: None,
982 is_active: true,
983 created_at: chrono::Utc::now(),
984 updated_at: chrono::Utc::now(),
985 };
986 assert!(matches!(
987 app.redirect_uri_list(),
988 Err(AuthError::Database(_))
989 ));
990 }
991
992 #[test]
995 fn logo_url_https_is_valid() {
996 assert!(validate_logo_url("https://example.com/logo.png").is_ok());
997 }
998
999 #[test]
1000 fn logo_url_http_localhost_is_valid() {
1001 assert!(validate_logo_url("http://localhost:3000/logo.png").is_ok());
1002 }
1003
1004 #[test]
1005 fn logo_url_http_127_is_valid() {
1006 assert!(validate_logo_url("http://127.0.0.1:8080/logo.png").is_ok());
1007 }
1008
1009 #[test]
1010 fn logo_url_http_non_localhost_is_rejected() {
1011 let err = validate_logo_url("http://example.com/logo.png").unwrap_err();
1012 assert!(matches!(err, AuthError::Validation(_)));
1013 }
1014
1015 #[test]
1016 fn logo_url_relative_is_rejected() {
1017 let err = validate_logo_url("/logo.png").unwrap_err();
1018 assert!(matches!(err, AuthError::Validation(_)));
1019 }
1020
1021 #[test]
1022 fn logo_url_not_a_url_is_rejected() {
1023 let err = validate_logo_url("not a url").unwrap_err();
1024 assert!(matches!(err, AuthError::Validation(_)));
1025 }
1026
1027 #[test]
1030 fn primary_color_valid_hex() {
1031 assert!(validate_primary_color("#3B82F6").is_ok());
1032 }
1033
1034 #[test]
1035 fn primary_color_lowercase_hex() {
1036 assert!(validate_primary_color("#3b82f6").is_ok());
1037 }
1038
1039 #[test]
1040 fn primary_color_missing_hash() {
1041 let err = validate_primary_color("3B82F6").unwrap_err();
1042 assert!(matches!(err, AuthError::Validation(_)));
1043 }
1044
1045 #[test]
1046 fn primary_color_too_short() {
1047 let err = validate_primary_color("#FFF").unwrap_err();
1048 assert!(matches!(err, AuthError::Validation(_)));
1049 }
1050
1051 #[test]
1052 fn primary_color_too_long() {
1053 let err = validate_primary_color("#3B82F6FF").unwrap_err();
1054 assert!(matches!(err, AuthError::Validation(_)));
1055 }
1056
1057 #[test]
1058 fn primary_color_non_hex_chars() {
1059 let err = validate_primary_color("#ZZZZZZ").unwrap_err();
1060 assert!(matches!(err, AuthError::Validation(_)));
1061 }
1062
1063 #[test]
1064 fn primary_color_named_color_rejected() {
1065 let err = validate_primary_color("red").unwrap_err();
1066 assert!(matches!(err, AuthError::Validation(_)));
1067 }
1068
1069 #[test]
1072 fn branding_extracts_correct_fields() {
1073 let (_, hash) = generate_client_secret().expect("generate");
1074 let app = Application {
1075 id: ApplicationId::new(),
1076 name: "My App".to_string(),
1077 client_id: generate_client_id(),
1078 client_type: ClientType::Confidential,
1079 client_secret_hash: Some(hash),
1080 redirect_uris: r#"["https://example.com/cb"]"#.to_string(),
1081 logo_url: Some("https://example.com/logo.png".to_string()),
1082 primary_color: Some("#3B82F6".to_string()),
1083 accent_hex: None,
1084 accent_ink: None,
1085 forced_mode: None,
1086 font_css_url: None,
1087 font_family: None,
1088 splash_text: None,
1089 splash_image_url: None,
1090 splash_primitive: None,
1091 splash_url: None,
1092 shader_cell_scale: None,
1093 is_trusted: false,
1094 created_by: None,
1095 is_active: true,
1096 created_at: chrono::Utc::now(),
1097 updated_at: chrono::Utc::now(),
1098 };
1099 let b = app.branding();
1100 assert_eq!(b.application_name, "My App");
1101 assert_eq!(b.logo_url.as_deref(), Some("https://example.com/logo.png"));
1102 assert_eq!(b.primary_color.as_deref(), Some("#3B82F6"));
1103 }
1104
1105 #[test]
1108 fn https_url_accepts_https() {
1109 assert!(validate_font_css_url("https://example.com/x.css").is_ok());
1110 }
1111
1112 #[test]
1113 fn https_url_rejects_http() {
1114 let err = validate_font_css_url("http://example.com/x.css").unwrap_err();
1115 assert!(matches!(err, AuthError::Validation(_)));
1116 }
1117
1118 #[test]
1119 fn https_url_rejects_invalid() {
1120 let err = validate_font_css_url("not a url").unwrap_err();
1121 assert!(matches!(err, AuthError::Validation(_)));
1122 }
1123
1124 #[test]
1125 fn logo_url_loopback_hostname_accepted() {
1126 assert!(validate_logo_url("http://localhost/logo.png").is_ok());
1127 }
1128
1129 #[test]
1130 fn logo_url_loopback_ip_accepted() {
1131 assert!(validate_logo_url("http://127.0.0.1/logo.png").is_ok());
1132 }
1133
1134 #[test]
1135 fn font_css_url_rejects_localhost() {
1136 let err = validate_font_css_url("http://localhost/font.css").unwrap_err();
1137 assert!(matches!(err, AuthError::Validation(_)));
1138 }
1139
1140 #[test]
1143 fn accent_hex_valid() {
1144 assert!(validate_accent_hex("#ff6b35").is_ok());
1145 }
1146
1147 #[test]
1148 fn accent_hex_rejects_named_color() {
1149 let err = validate_accent_hex("red").unwrap_err();
1150 assert!(matches!(err, AuthError::Validation(_)));
1151 }
1152
1153 #[test]
1154 fn accent_hex_rejects_shorthand() {
1155 let err = validate_accent_hex("#fff").unwrap_err();
1156 assert!(matches!(err, AuthError::Validation(_)));
1157 }
1158
1159 #[test]
1160 fn accent_hex_rejects_non_hex_chars() {
1161 let err = validate_accent_hex("#gggggg").unwrap_err();
1162 assert!(matches!(err, AuthError::Validation(_)));
1163 }
1164
1165 #[test]
1166 fn primary_color_still_valid_after_refactor() {
1167 assert!(validate_primary_color("#3B82F6").is_ok());
1168 }
1169
1170 #[test]
1171 fn application_serialization_omits_secret() {
1172 let (_, hash) = generate_client_secret().expect("generate_client_secret");
1173 let app = Application {
1174 id: ApplicationId::new(),
1175 name: "Test App".to_string(),
1176 client_id: generate_client_id(),
1177 client_type: ClientType::Confidential,
1178 client_secret_hash: Some(hash),
1179 redirect_uris: r#"["https://example.com/callback"]"#.to_string(),
1180 logo_url: None,
1181 primary_color: None,
1182 accent_hex: None,
1183 accent_ink: None,
1184 forced_mode: None,
1185 font_css_url: None,
1186 font_family: None,
1187 splash_text: None,
1188 splash_image_url: None,
1189 splash_primitive: None,
1190 splash_url: None,
1191 shader_cell_scale: None,
1192 is_trusted: false,
1193 created_by: None,
1194 is_active: true,
1195 created_at: chrono::Utc::now(),
1196 updated_at: chrono::Utc::now(),
1197 };
1198 let value = serde_json::to_value(&app).expect("serialize Application");
1199 assert!(
1200 value.get("client_secret_hash").is_none(),
1201 "client_secret_hash must not appear in serialized output"
1202 );
1203 assert!(
1204 value.get("client_id").is_some(),
1205 "client_id must appear in serialized output"
1206 );
1207 }
1208
1209 #[cfg(test)]
1210 mod branding_config_builder_tests {
1211 use super::*;
1212 use crate::types::{AccentInk, SplashPrimitive};
1213
1214 #[test]
1215 fn new_sets_application_name_leaves_rest_none() {
1216 let b = BrandingConfig::new("Fixture Co");
1217 assert_eq!(b.application_name, "Fixture Co");
1218 assert!(b.logo_url.is_none());
1219 assert!(b.primary_color.is_none());
1220 assert!(b.accent_hex.is_none());
1221 assert!(b.accent_ink.is_none());
1222 assert!(b.forced_mode.is_none());
1223 assert!(b.font_css_url.is_none());
1224 assert!(b.font_family.is_none());
1225 assert!(b.splash_text.is_none());
1226 assert!(b.splash_image_url.is_none());
1227 assert!(b.splash_primitive.is_none());
1228 assert!(b.splash_url.is_none());
1229 assert!(b.shader_cell_scale.is_none());
1230 }
1231
1232 #[test]
1233 fn with_accent_sets_hex_and_ink() {
1234 let b = BrandingConfig::new("Co").with_accent("#ff7a1a", AccentInk::Black);
1235 assert_eq!(b.accent_hex.as_deref(), Some("#ff7a1a"));
1236 assert_eq!(b.accent_ink, Some(AccentInk::Black));
1237 }
1238
1239 #[test]
1240 fn with_splash_text_sets_field() {
1241 let b = BrandingConfig::new("Co").with_splash_text("TRANSFER");
1242 assert_eq!(b.splash_text.as_deref(), Some("TRANSFER"));
1243 }
1244
1245 #[test]
1246 fn with_shader_cell_scale_sets_field() {
1247 let b = BrandingConfig::new("Co").with_shader_cell_scale(18);
1248 assert_eq!(b.shader_cell_scale, Some(18));
1249 }
1250
1251 #[test]
1252 fn with_splash_primitive_sets_field() {
1253 let b = BrandingConfig::new("Co").with_splash_primitive(SplashPrimitive::Wave);
1254 assert_eq!(b.splash_primitive, Some(SplashPrimitive::Wave));
1255 }
1256
1257 #[test]
1258 fn with_logo_url_sets_field() {
1259 let b = BrandingConfig::new("Co").with_logo_url("https://cdn.example/logo.svg");
1260 assert_eq!(b.logo_url.as_deref(), Some("https://cdn.example/logo.svg"));
1261 }
1262
1263 #[test]
1264 fn with_primary_color_sets_field() {
1265 let b = BrandingConfig::new("Co").with_primary_color("#0066ff");
1266 assert_eq!(b.primary_color.as_deref(), Some("#0066ff"));
1267 }
1268
1269 #[test]
1270 fn with_splash_image_url_sets_field() {
1271 let b =
1272 BrandingConfig::new("Co").with_splash_image_url("https://cdn.example/splash.png");
1273 assert_eq!(
1274 b.splash_image_url.as_deref(),
1275 Some("https://cdn.example/splash.png")
1276 );
1277 }
1278 }
1279}