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::{
11    AccentInk, ApplicationId, ClientId, ClientSecret, ClientType, Mode, PasswordHash,
12    SplashPrimitive, UserId,
13};
14
15/// An OIDC client application registered with allowthem.
16///
17/// `client_secret_hash` is skipped during serialization — the raw secret
18/// is returned once at creation and is never retrievable again.
19#[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, // JSON array, parsed at the call site
28    pub logo_url: Option<String>,
29    pub primary_color: Option<String>,
30    // Wave Funk branding fields (all optional).
31    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/// Branding configuration for an application's hosted auth pages.
49///
50/// Extracted from `Application` — contains only the fields needed to
51/// theme login, register, consent, and other OIDC-flow pages.
52///
53/// Derives `sqlx::FromRow` for use with `query_as` in
54/// `get_branding_by_client_id`. The SQL query aliases `name` to
55/// `application_name` to match the struct field.
56#[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
73/// Generate a new `client_id`: `ath_` + 24 random bytes base64url-encoded.
74///
75/// Produces a 36-character string (`ath_` + 32 base64url chars). 192 bits of
76/// entropy from `OsRng` makes collision effectively impossible.
77pub 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
86/// Generate a new client secret and its Argon2 hash.
87///
88/// Returns `(raw_secret, hash)`. The raw secret is shown once to the admin
89/// and must never be stored. The hash is stored as `client_secret_hash`.
90/// Reuses `password::hash_password` — a client secret is functionally a
91/// high-entropy password and the security requirements are identical.
92pub 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    /// Parse the stored JSON `redirect_uris` string into a `Vec<String>`.
104    ///
105    /// Returns `AuthError::Database` if the stored value is malformed JSON.
106    /// This indicates a data integrity error — the core layer always validates
107    /// and serializes URIs correctly on write.
108    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    /// Extract the branding configuration for use in themed auth pages.
114    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    /// Construct an all-defaults `BrandingConfig` with only the required
135    /// `application_name` set. Embedders use this as a starting point:
136    /// `BrandingConfig::new("Transfer These Files").with_accent("#ff7a1a", AccentInk::Black)`.
137    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
202/// Opaque keyset cursor for paginating `list_applications_paginated`.
203///
204/// Encodes `(created_at, id)` as a base64url-encoded JSON blob. Callers
205/// treat the encoded string as opaque and pass it back verbatim.
206pub 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
249/// Parameters for registering a new OIDC application via [`Db::create_application`].
250pub 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
270/// Parameters for updating an application's mutable fields.
271///
272/// All fields are required. Fetch the current application first
273/// to populate fields that should remain unchanged.
274pub 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
293/// Validate a list of redirect URIs for registration (create or update).
294///
295/// Rules (per RFC 6749 and RFC 8252):
296/// - List must not be empty.
297/// - Each URI must parse as an absolute URL (has a scheme).
298/// - No fragment component — prohibited by RFC 6749 Section 3.1.2.
299/// - HTTPS required, except `http://localhost` and `http://127.0.0.1`
300///   (loopback URIs permitted per RFC 8252 Section 8.3).
301///
302/// Returns `AuthError::InvalidRedirectUri` with the offending URI on first failure.
303pub 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
329/// Validate that `redirect_uri` exactly matches one of the registered URIs.
330///
331/// Used by the authorization endpoint (M39) to reject unregistered redirect targets.
332/// Exact string match — no normalization, no wildcard expansion.
333///
334/// Returns `AuthError::InvalidRedirectUri` if `redirect_uri` is not in `registered`.
335pub 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
343/// Validate a logo URL for branding.
344///
345/// Must be an absolute URL with HTTPS scheme. HTTP is permitted for
346/// localhost and 127.0.0.1 (development loopback exception, matching
347/// redirect URI validation).
348pub 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
366/// Validate a font CSS URL. Must be an HTTPS URL (no loopback exception —
367/// production asset URL).
368pub fn validate_font_css_url(url: &str) -> Result<(), AuthError> {
369    validate_https_url(url, "font_css_url")
370}
371
372/// Validate a splash image URL. Must be an HTTPS URL (no loopback exception).
373pub fn validate_splash_image_url(url: &str) -> Result<(), AuthError> {
374    validate_https_url(url, "splash_image_url")
375}
376
377/// Validate a splash URL. Must be an HTTPS URL (no loopback exception).
378pub fn validate_splash_url(url: &str) -> Result<(), AuthError> {
379    validate_https_url(url, "splash_url")
380}
381
382/// Shared HTTPS-only URL validator used by branding asset URL fields.
383///
384/// Unlike `validate_logo_url`, no loopback exception — these fields are
385/// intended for production assets only.
386fn 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
397/// Shared hex color validator used by branding color fields.
398///
399/// Accepts `#RRGGBB` (7 chars: `#` + 6 hex digits). Safe for injection
400/// into CSS `color` / `background` declarations without escaping.
401fn 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
411/// Validate a primary color for Wave Funk branding.
412///
413/// Must be a 7-character CSS hex color: `#` followed by exactly 6 hex
414/// digits (e.g., `#3B82F6`). This format is safe for injection into
415/// HTML `style` attributes without escaping.
416pub fn validate_primary_color(color: &str) -> Result<(), AuthError> {
417    validate_hex_color(color, "primary_color")
418}
419
420/// Validate an accent color for Wave Funk branding.
421///
422/// Same format as `validate_primary_color` — `#RRGGBB` (7 chars, `#` + 6 hex
423/// digits). Safe for injection into CSS `color` / `background` declarations
424/// without escaping.
425pub fn validate_accent_hex(color: &str) -> Result<(), AuthError> {
426    validate_hex_color(color, "accent_hex")
427}
428
429impl Db {
430    /// Register a new OIDC application.
431    ///
432    /// Generates a `client_id` and `client_secret`, hashes the secret, and inserts
433    /// the row. Returns the persisted `Application` and the raw `ClientSecret`.
434    /// The raw secret is shown once and is not recoverable — the caller must present
435    /// it to the admin immediately.
436    ///
437    /// Validates `redirect_uris` before inserting. Returns `AuthError::InvalidRedirectUri`
438    /// if any URI fails validation.
439    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    /// Get an application by internal ID.
537    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    /// Get an application by its public client_id.
553    ///
554    /// Used by OAuth endpoints that receive client_id in request parameters.
555    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    /// Get branding configuration for an application by client_id.
574    ///
575    /// Returns `None` if no application with the given `client_id` exists
576    /// or if the application is inactive. Branded pages fall back to
577    /// default allowthem styling when this returns `None`.
578    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    /// List all applications ordered by `created_at ASC`.
596    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    /// Paginated list of applications using a `(created_at, id)` keyset cursor.
611    ///
612    /// Limits are capped at 200. Pass `None` for cursor to start from the beginning.
613    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                // Bind created_at as TEXT matching the schema format so that
635                // lexicographic comparison produces the correct ordering.
636                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    /// Update an application's mutable fields.
658    ///
659    /// Validates `redirect_uris`, serializes them to JSON, and writes all
660    /// mutable fields atomically. Caller is responsible for fetching the current
661    /// application and populating unchanged fields.
662    ///
663    /// Returns `AuthError::NotFound` if no application with `id` exists.
664    /// Returns `AuthError::InvalidRedirectUri` if any URI fails validation.
665    pub async fn update_application(
666        &self,
667        id: ApplicationId,
668        params: UpdateApplication,
669    ) -> Result<(), AuthError> {
670        validate_redirect_uris(&params.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(&params.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(&params.name)
705        .bind(&redirect_uris_json)
706        .bind(params.is_trusted)
707        .bind(params.is_active)
708        .bind(&params.logo_url)
709        .bind(&params.primary_color)
710        .bind(&params.accent_hex)
711        .bind(params.accent_ink.map(|v| v.as_str()))
712        .bind(params.forced_mode.map(|v| v.as_str()))
713        .bind(&params.font_css_url)
714        .bind(&params.font_family)
715        .bind(&params.splash_text)
716        .bind(&params.splash_image_url)
717        .bind(params.splash_primitive.map(|v| v.as_str()))
718        .bind(&params.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    /// Generate a new client secret, invalidating the previous one.
732    ///
733    /// Returns the updated `Application` and the raw `ClientSecret`.
734    /// The new secret is the only opportunity to retrieve it — the old secret
735    /// is irrecoverably invalidated on success.
736    ///
737    /// Returns `AuthError::NotFound` if no application with `id` exists.
738    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    /// Permanently delete an application and all associated grants.
771    ///
772    /// Cascade deletes: authorization_codes, refresh_tokens, consents.
773    /// Returns `AuthError::NotFound` if no application with `id` exists.
774    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        // base64url uses A-Z, a-z, 0-9, -, _ only (no +, /, =)
812        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    // validate_redirect_uris tests
850
851    #[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    // validate_redirect_uri tests
903
904    #[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", &registered).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", &registered).unwrap_err();
914        assert!(matches!(err, AuthError::InvalidRedirectUri(_)));
915    }
916
917    // Application::redirect_uri_list tests
918
919    #[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    // validate_logo_url tests
993
994    #[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    // validate_primary_color tests
1028
1029    #[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    // BrandingConfig extraction test
1070
1071    #[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    // validate_https_url tests (via public wrappers)
1106
1107    #[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    // validate_accent_hex tests
1141
1142    #[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}