Skip to main content

allowthem_core/
applications.rs

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/// An OIDC client application registered with allowthem.
13///
14/// `client_secret_hash` is skipped during serialization — the raw secret
15/// is returned once at creation and is never retrievable again.
16#[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, // JSON array, parsed at the call site
24    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/// Branding configuration for an application's hosted auth pages.
34///
35/// Extracted from `Application` — contains only the fields needed to
36/// theme login, register, consent, and other OIDC-flow pages.
37///
38/// Derives `sqlx::FromRow` for use with `query_as` in
39/// `get_branding_by_client_id`. The SQL query aliases `name` to
40/// `application_name` to match the struct field.
41#[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
48/// Generate a new `client_id`: `ath_` + 24 random bytes base64url-encoded.
49///
50/// Produces a 36-character string (`ath_` + 32 base64url chars). 192 bits of
51/// entropy from `OsRng` makes collision effectively impossible.
52pub 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
61/// Generate a new client secret and its Argon2 hash.
62///
63/// Returns `(raw_secret, hash)`. The raw secret is shown once to the admin
64/// and must never be stored. The hash is stored as `client_secret_hash`.
65/// Reuses `password::hash_password` — a client secret is functionally a
66/// high-entropy password and the security requirements are identical.
67pub 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    /// Parse the stored JSON `redirect_uris` string into a `Vec<String>`.
79    ///
80    /// Returns `AuthError::Database` if the stored value is malformed JSON.
81    /// This indicates a data integrity error — the core layer always validates
82    /// and serializes URIs correctly on write.
83    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    /// Extract the branding configuration for use in themed auth pages.
89    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
108/// Parameters for updating an application's mutable fields.
109///
110/// All fields are required. Fetch the current application first
111/// to populate fields that should remain unchanged.
112pub 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
121/// Validate a list of redirect URIs for registration (create or update).
122///
123/// Rules (per RFC 6749 and RFC 8252):
124/// - List must not be empty.
125/// - Each URI must parse as an absolute URL (has a scheme).
126/// - No fragment component — prohibited by RFC 6749 Section 3.1.2.
127/// - HTTPS required, except `http://localhost` and `http://127.0.0.1`
128///   (loopback URIs permitted per RFC 8252 Section 8.3).
129///
130/// Returns `AuthError::InvalidRedirectUri` with the offending URI on first failure.
131pub 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
157/// Validate that `redirect_uri` exactly matches one of the registered URIs.
158///
159/// Used by the authorization endpoint (M39) to reject unregistered redirect targets.
160/// Exact string match — no normalization, no wildcard expansion.
161///
162/// Returns `AuthError::InvalidRedirectUri` if `redirect_uri` is not in `registered`.
163pub 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
171/// Validate a logo URL for branding.
172///
173/// Must be an absolute URL with HTTPS scheme. HTTP is permitted for
174/// localhost and 127.0.0.1 (development loopback exception, matching
175/// redirect URI validation).
176pub 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
194/// Validate a primary color for branding.
195///
196/// Must be a 7-character CSS hex color: `#` followed by exactly 6 hex
197/// digits (e.g., `#3B82F6`). This format is safe for injection into
198/// HTML `style` attributes without escaping.
199pub 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    /// Register a new OIDC application.
216    ///
217    /// Generates a `client_id` and `client_secret`, hashes the secret, and inserts
218    /// the row. Returns the persisted `Application` and the raw `ClientSecret`.
219    /// The raw secret is shown once and is not recoverable — the caller must present
220    /// it to the admin immediately.
221    ///
222    /// Validates `redirect_uris` before inserting. Returns `AuthError::InvalidRedirectUri`
223    /// if any URI fails validation.
224    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    /// Get an application by internal ID.
272    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    /// Get an application by its public client_id.
286    ///
287    /// Used by OAuth endpoints that receive client_id in request parameters.
288    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    /// Get branding configuration for an application by client_id.
305    ///
306    /// Returns `None` if no application with the given `client_id` exists
307    /// or if the application is inactive. Branded pages fall back to
308    /// default allowthem styling when this returns `None`.
309    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    /// List all applications ordered by `created_at ASC`.
325    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    /// Update an application's mutable fields.
338    ///
339    /// Validates `redirect_uris`, serializes them to JSON, and writes all six
340    /// mutable fields atomically. Caller is responsible for fetching the current
341    /// application and populating unchanged fields.
342    ///
343    /// Returns `AuthError::NotFound` if no application with `id` exists.
344    /// Returns `AuthError::InvalidRedirectUri` if any URI fails validation.
345    pub async fn update_application(
346        &self,
347        id: ApplicationId,
348        params: UpdateApplication,
349    ) -> Result<(), AuthError> {
350        validate_redirect_uris(&params.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(&params.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(&params.name)
368        .bind(&redirect_uris_json)
369        .bind(params.is_trusted)
370        .bind(params.is_active)
371        .bind(&params.logo_url)
372        .bind(&params.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    /// Generate a new client secret, invalidating the previous one.
385    ///
386    /// Returns the updated `Application` and the raw `ClientSecret`.
387    /// The new secret is the only opportunity to retrieve it — the old secret
388    /// is irrecoverably invalidated on success.
389    ///
390    /// Returns `AuthError::NotFound` if no application with `id` exists.
391    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    /// Permanently delete an application and all associated grants.
418    ///
419    /// Cascade deletes: authorization_codes, refresh_tokens, consents.
420    /// Returns `AuthError::NotFound` if no application with `id` exists.
421    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        // base64url uses A-Z, a-z, 0-9, -, _ only (no +, /, =)
459        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    // validate_redirect_uris tests
497
498    #[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    // validate_redirect_uri tests
550
551    #[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", &registered).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", &registered).unwrap_err();
561        assert!(matches!(err, AuthError::InvalidRedirectUri(_)));
562    }
563
564    // Application::redirect_uri_list tests
565
566    #[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    // validate_logo_url tests
618
619    #[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    // validate_primary_color tests
653
654    #[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    // BrandingConfig extraction test
695
696    #[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}