1use std::{
2 env,
3 sync::{LazyLock, OnceLock},
4};
5
6use crate::config::O2P_ROUTE_PREFIX;
7use crate::oauth2::discovery::{OidcDiscoveryDocument, OidcDiscoveryError, fetch_oidc_discovery};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub struct ProviderName(&'static str);
26
27impl ProviderName {
28 pub(crate) const fn from_static(s: &'static str) -> Self {
31 Self(s)
32 }
33
34 pub(crate) fn from_env_leaked(raw: String) -> Self {
41 Self(leak_static(raw))
42 }
43
44 pub const fn as_str(&self) -> &'static str {
46 self.0
47 }
48
49 pub fn from_registered(s: &str) -> Option<Self> {
59 ProviderKind::from_provider_name(s)
60 .and_then(provider_for)
61 .map(|cfg| cfg.provider_name)
62 }
63}
64
65impl std::fmt::Display for ProviderName {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 f.write_str(self.0)
68 }
69}
70
71impl AsRef<str> for ProviderName {
72 fn as_ref(&self) -> &str {
73 self.0
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
93pub(crate) enum ProviderKind {
94 Google,
95 Custom(CustomSlot),
96}
97
98pub(crate) struct ProviderPreset {
110 pub(crate) display_name: &'static str,
111 pub(crate) provider_name: ProviderName,
112 pub(crate) icon_slug: &'static str,
113 pub(crate) button_color: &'static str,
114 pub(crate) button_hover_color: &'static str,
115 pub(crate) additional_allowed_origins: &'static [&'static str],
116}
117
118pub(crate) const AUTH0_PRESET: ProviderPreset = ProviderPreset {
119 display_name: "Auth0",
120 provider_name: ProviderName::from_static("auth0"),
121 icon_slug: "auth0",
122 button_color: "#eb5424",
123 button_hover_color: "#c94419",
124 additional_allowed_origins: &[],
125};
126
127pub(crate) const KEYCLOAK_PRESET: ProviderPreset = ProviderPreset {
128 display_name: "Keycloak",
129 provider_name: ProviderName::from_static("keycloak"),
130 icon_slug: "keycloak",
131 button_color: "#4d4d4d",
132 button_hover_color: "#333333",
133 additional_allowed_origins: &[],
134};
135
136pub(crate) const ENTRA_PRESET: ProviderPreset = ProviderPreset {
137 display_name: "Microsoft",
138 provider_name: ProviderName::from_static("entra"),
139 icon_slug: "entra",
140 button_color: "#0078D4",
141 button_hover_color: "#005A9E",
142 additional_allowed_origins: &["https://login.live.com"],
146};
147
148pub(crate) const ZITADEL_PRESET: ProviderPreset = ProviderPreset {
149 display_name: "Zitadel",
150 provider_name: ProviderName::from_static("zitadel"),
151 icon_slug: "zitadel",
152 button_color: "#333333",
153 button_hover_color: "#1a1a1a",
154 additional_allowed_origins: &[],
155};
156
157pub(crate) const OKTA_PRESET: ProviderPreset = ProviderPreset {
158 display_name: "Okta",
159 provider_name: ProviderName::from_static("okta"),
160 icon_slug: "okta",
161 button_color: "#007dc1",
162 button_hover_color: "#005e93",
163 additional_allowed_origins: &[],
164};
165
166pub(crate) const AUTHENTIK_PRESET: ProviderPreset = ProviderPreset {
167 display_name: "Authentik",
168 provider_name: ProviderName::from_static("authentik"),
169 icon_slug: "authentik",
170 button_color: "#fd4b2d",
171 button_hover_color: "#e03d1f",
172 additional_allowed_origins: &[],
173};
174
175pub(crate) const LINE_PRESET: ProviderPreset = ProviderPreset {
176 display_name: "LINE",
177 provider_name: ProviderName::from_static("line"),
178 icon_slug: "line",
179 button_color: "#06C755",
180 button_hover_color: "#05A647",
181 additional_allowed_origins: &[],
182};
183
184pub(crate) const APPLE_PRESET: ProviderPreset = ProviderPreset {
185 display_name: "Apple",
186 provider_name: ProviderName::from_static("apple"),
187 icon_slug: "apple",
188 button_color: "#000000",
189 button_hover_color: "#333333",
190 additional_allowed_origins: &[],
191};
192
193fn resolve_preset(key: &str) -> Result<&'static ProviderPreset, String> {
197 match key {
198 "auth0" => Ok(&AUTH0_PRESET),
199 "keycloak" => Ok(&KEYCLOAK_PRESET),
200 "entra" => Ok(&ENTRA_PRESET),
201 "zitadel" => Ok(&ZITADEL_PRESET),
202 "okta" => Ok(&OKTA_PRESET),
203 "authentik" => Ok(&AUTHENTIK_PRESET),
204 "line" => Ok(&LINE_PRESET),
205 "apple" => Ok(&APPLE_PRESET),
206 other => Err(format!(
207 "unknown PRESET '{other}' (expected one of: \
208 auth0, keycloak, entra, zitadel, okta, authentik, line, apple)"
209 )),
210 }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
219pub(crate) enum CustomSlot {
220 Slot1,
221 Slot2,
222 Slot3,
223 Slot4,
224 Slot5,
225 Slot6,
226 Slot7,
227 Slot8,
228}
229
230impl CustomSlot {
231 pub(crate) const ALL: &'static [Self] = &[
232 Self::Slot1,
233 Self::Slot2,
234 Self::Slot3,
235 Self::Slot4,
236 Self::Slot5,
237 Self::Slot6,
238 Self::Slot7,
239 Self::Slot8,
240 ];
241
242 pub(crate) const fn label(self) -> &'static str {
246 match self {
247 Self::Slot1 => "custom1",
248 Self::Slot2 => "custom2",
249 Self::Slot3 => "custom3",
250 Self::Slot4 => "custom4",
251 Self::Slot5 => "custom5",
252 Self::Slot6 => "custom6",
253 Self::Slot7 => "custom7",
254 Self::Slot8 => "custom8",
255 }
256 }
257
258 pub(crate) const fn env_prefix(self) -> &'static str {
260 match self {
261 Self::Slot1 => "OAUTH2_CUSTOM1",
262 Self::Slot2 => "OAUTH2_CUSTOM2",
263 Self::Slot3 => "OAUTH2_CUSTOM3",
264 Self::Slot4 => "OAUTH2_CUSTOM4",
265 Self::Slot5 => "OAUTH2_CUSTOM5",
266 Self::Slot6 => "OAUTH2_CUSTOM6",
267 Self::Slot7 => "OAUTH2_CUSTOM7",
268 Self::Slot8 => "OAUTH2_CUSTOM8",
269 }
270 }
271
272 pub(crate) const fn button_class(self) -> &'static str {
277 match self {
278 Self::Slot1 => "btn-oauth2 btn-custom1",
279 Self::Slot2 => "btn-oauth2 btn-custom2",
280 Self::Slot3 => "btn-oauth2 btn-custom3",
281 Self::Slot4 => "btn-oauth2 btn-custom4",
282 Self::Slot5 => "btn-oauth2 btn-custom5",
283 Self::Slot6 => "btn-oauth2 btn-custom6",
284 Self::Slot7 => "btn-oauth2 btn-custom7",
285 Self::Slot8 => "btn-oauth2 btn-custom8",
286 }
287 }
288}
289
290impl ProviderKind {
291 pub(crate) const ALL: &'static [Self] = &[
294 Self::Google,
295 Self::Custom(CustomSlot::Slot1),
296 Self::Custom(CustomSlot::Slot2),
297 Self::Custom(CustomSlot::Slot3),
298 Self::Custom(CustomSlot::Slot4),
299 Self::Custom(CustomSlot::Slot5),
300 Self::Custom(CustomSlot::Slot6),
301 Self::Custom(CustomSlot::Slot7),
302 Self::Custom(CustomSlot::Slot8),
303 ];
304
305 pub(crate) fn optional_env_contract(&self) -> Option<(&'static str, &'static [&'static str])> {
319 match self {
320 Self::Google => None,
321 Self::Custom(CustomSlot::Slot1) => Some((
322 "OAUTH2_CUSTOM1_CLIENT_ID",
323 &["OAUTH2_CUSTOM1_CLIENT_SECRET", "OAUTH2_CUSTOM1_ISSUER_URL"],
324 )),
325 Self::Custom(CustomSlot::Slot2) => Some((
326 "OAUTH2_CUSTOM2_CLIENT_ID",
327 &["OAUTH2_CUSTOM2_CLIENT_SECRET", "OAUTH2_CUSTOM2_ISSUER_URL"],
328 )),
329 Self::Custom(CustomSlot::Slot3) => Some((
330 "OAUTH2_CUSTOM3_CLIENT_ID",
331 &["OAUTH2_CUSTOM3_CLIENT_SECRET", "OAUTH2_CUSTOM3_ISSUER_URL"],
332 )),
333 Self::Custom(CustomSlot::Slot4) => Some((
334 "OAUTH2_CUSTOM4_CLIENT_ID",
335 &["OAUTH2_CUSTOM4_CLIENT_SECRET", "OAUTH2_CUSTOM4_ISSUER_URL"],
336 )),
337 Self::Custom(CustomSlot::Slot5) => Some((
338 "OAUTH2_CUSTOM5_CLIENT_ID",
339 &["OAUTH2_CUSTOM5_CLIENT_SECRET", "OAUTH2_CUSTOM5_ISSUER_URL"],
340 )),
341 Self::Custom(CustomSlot::Slot6) => Some((
342 "OAUTH2_CUSTOM6_CLIENT_ID",
343 &["OAUTH2_CUSTOM6_CLIENT_SECRET", "OAUTH2_CUSTOM6_ISSUER_URL"],
344 )),
345 Self::Custom(CustomSlot::Slot7) => Some((
346 "OAUTH2_CUSTOM7_CLIENT_ID",
347 &["OAUTH2_CUSTOM7_CLIENT_SECRET", "OAUTH2_CUSTOM7_ISSUER_URL"],
348 )),
349 Self::Custom(CustomSlot::Slot8) => Some((
350 "OAUTH2_CUSTOM8_CLIENT_ID",
351 &["OAUTH2_CUSTOM8_CLIENT_SECRET", "OAUTH2_CUSTOM8_ISSUER_URL"],
352 )),
353 }
354 }
355
356 pub(crate) const fn as_str(&self) -> &'static str {
361 match self {
362 Self::Google => "google",
363 Self::Custom(slot) => slot.label(),
364 }
365 }
366
367 pub(crate) fn from_provider_name(s: &str) -> Option<Self> {
375 match s {
376 "google" => Some(Self::Google),
377 _ => CustomSlot::ALL
378 .iter()
379 .copied()
380 .find(|&slot| {
381 provider_for(Self::Custom(slot))
382 .is_some_and(|cfg| cfg.provider_name.as_str() == s)
383 })
384 .map(Self::Custom),
385 }
386 }
387}
388
389#[derive(Debug, Clone)]
400#[non_exhaustive]
401pub struct ProviderInfo {
402 pub provider_name: ProviderName,
407 pub display_name: &'static str,
409 pub button_class: &'static str,
411 pub icon_slug: &'static str,
415 pub button_color: Option<&'static str>,
420 pub button_hover_color: Option<&'static str>,
423 pub css_var_suffix: Option<&'static str>,
430}
431
432impl std::fmt::Display for ProviderKind {
433 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
434 f.write_str(self.as_str())
435 }
436}
437
438pub(crate) struct ProviderConfig {
453 pub(crate) kind: ProviderKind,
454 pub(crate) client_id: String,
455 pub(crate) client_secret: String,
456 pub(crate) issuer_url: String,
458 pub(crate) redirect_uri: String,
461 pub(crate) response_mode: String,
462 pub(crate) query_string: String,
465 pub(crate) discovery: OnceLock<OidcDiscoveryDocument>,
467 pub(crate) additional_allowed_origins: Vec<String>,
475 pub(crate) provider_name: ProviderName,
480 pub(crate) display_name: &'static str,
482 pub(crate) button_class: &'static str,
484 pub(crate) icon_slug: &'static str,
487 pub(crate) button_color: Option<&'static str>,
492 pub(crate) button_hover_color: Option<&'static str>,
495 pub(crate) css_var_suffix: Option<&'static str>,
500 pub(crate) strict_display_claims: bool,
509}
510
511impl ProviderConfig {
512 async fn get_or_fetch_discovery(&self) -> Result<&OidcDiscoveryDocument, OidcDiscoveryError> {
513 if let Some(cached) = self.discovery.get() {
514 return Ok(cached);
515 }
516
517 tracing::debug!(
518 provider = %self.kind,
519 "Fetching OIDC discovery for issuer: {}",
520 self.issuer_url
521 );
522 let document = fetch_oidc_discovery(&self.issuer_url).await?;
523
524 let _ = self.discovery.set(document);
526
527 self.discovery.get().ok_or_else(|| {
528 OidcDiscoveryError::CacheError("Failed to cache discovery document".to_string())
529 })
530 }
531
532 pub(crate) async fn auth_url(&self) -> Result<String, OidcDiscoveryError> {
533 let doc = self.get_or_fetch_discovery().await?;
534 Ok(doc.authorization_endpoint.clone())
535 }
536
537 pub(crate) async fn token_url(&self) -> Result<String, OidcDiscoveryError> {
538 let doc = self.get_or_fetch_discovery().await?;
539 Ok(doc.token_endpoint.clone())
540 }
541
542 pub(crate) async fn jwks_url(&self) -> Result<String, OidcDiscoveryError> {
543 let doc = self.get_or_fetch_discovery().await?;
544 Ok(doc.jwks_uri.clone())
545 }
546
547 pub(crate) async fn userinfo_url(&self) -> Result<String, OidcDiscoveryError> {
548 let doc = self.get_or_fetch_discovery().await?;
549 Ok(doc.userinfo_endpoint.clone())
550 }
551
552 pub(crate) async fn expected_issuer(&self) -> Result<String, OidcDiscoveryError> {
553 let doc = self.get_or_fetch_discovery().await?;
554 Ok(doc.issuer.clone())
555 }
556}
557
558pub(crate) static GOOGLE_PROVIDER: LazyLock<ProviderConfig> = LazyLock::new(|| {
561 let client_id =
562 env::var("OAUTH2_GOOGLE_CLIENT_ID").expect("OAUTH2_GOOGLE_CLIENT_ID must be set");
563 let client_secret =
564 env::var("OAUTH2_GOOGLE_CLIENT_SECRET").expect("OAUTH2_GOOGLE_CLIENT_SECRET must be set");
565 let issuer_url =
566 env::var("OAUTH2_ISSUER_URL").unwrap_or_else(|_| "https://accounts.google.com".to_string());
567 let origin = env::var("ORIGIN").expect("Missing ORIGIN!");
568 let redirect_uri = format!(
569 "{}{}/oauth2/google/authorized",
570 origin,
571 O2P_ROUTE_PREFIX.as_str()
572 );
573 let response_mode = {
574 let mode = env::var("OAUTH2_RESPONSE_MODE").unwrap_or_else(|_| "form_post".to_string());
575 match mode.to_lowercase().as_str() {
576 "form_post" => "form_post".to_string(),
577 "query" => "query".to_string(),
578 _ => panic!("Invalid OAUTH2_RESPONSE_MODE '{mode}'. Must be 'form_post' or 'query'."),
579 }
580 };
581 let scope = env::var("OAUTH2_SCOPE").unwrap_or_else(|_| "openid+email+profile".to_string());
582 let response_type = env::var("OAUTH2_RESPONSE_TYPE").unwrap_or_else(|_| "code".to_string());
583 let prompt = parse_prompt("OAUTH2_GOOGLE_PROMPT").unwrap_or_else(|msg| panic!("{msg}"));
584 let prompt_segment = prompt.map(|p| format!("&prompt={p}")).unwrap_or_default();
585 let query_string = format!(
586 "&response_type={}&scope={}&response_mode={}&access_type=online{}",
587 response_type, scope, response_mode, prompt_segment
588 );
589 let strict_display_claims = read_strict_display_claims("OAUTH2_GOOGLE_STRICT_DISPLAY_CLAIMS");
590 ProviderConfig {
591 kind: ProviderKind::Google,
592 client_id,
593 client_secret,
594 issuer_url,
595 redirect_uri,
596 response_mode,
597 query_string,
598 discovery: OnceLock::new(),
599 additional_allowed_origins: Vec::new(),
600 provider_name: ProviderName::from_static("google"),
601 display_name: "Google",
602 button_class: "btn-oauth2 btn-google",
603 icon_slug: "google",
604 button_color: None,
605 button_hover_color: None,
606 css_var_suffix: None,
607 strict_display_claims,
608 }
609});
610
611const CUSTOM_DEFAULT_BUTTON_COLOR: &str = "#6b7280";
614
615const CUSTOM_DEFAULT_BUTTON_HOVER_COLOR: &str = "#4b5563";
618
619fn leak_static(s: String) -> &'static str {
624 Box::leak(s.into_boxed_str())
625}
626
627fn parse_strict_display_claims(env_var: &str) -> Result<bool, String> {
632 match env::var(env_var).ok().as_deref() {
633 None | Some("true") => Ok(true),
634 Some("false") => Ok(false),
635 Some(other) => Err(format!(
636 "Invalid {env_var} '{other}'. Must be 'true' or 'false'."
637 )),
638 }
639}
640
641fn read_strict_display_claims(env_var: &str) -> bool {
644 parse_strict_display_claims(env_var).unwrap_or_else(|msg| panic!("{msg}"))
645}
646
647pub(crate) fn validate_named_provider_strict_display_claims() -> Result<(), String> {
656 parse_strict_display_claims("OAUTH2_GOOGLE_STRICT_DISPLAY_CLAIMS")?;
657 Ok(())
658}
659
660fn parse_prompt(env_var: &str) -> Result<Option<&'static str>, String> {
666 match env::var(env_var).ok().as_deref() {
667 None => Ok(Some("consent")),
668 Some("") => Ok(None),
669 Some("none") => Ok(Some("none")),
670 Some("login") => Ok(Some("login")),
671 Some("consent") => Ok(Some("consent")),
672 Some("select_account") => Ok(Some("select_account")),
673 Some(other) => Err(format!(
674 "Invalid {env_var} '{other}'. \
675 Must be one of: none, login, consent, select_account \
676 (or empty to omit the parameter)."
677 )),
678 }
679}
680
681pub(crate) fn validate_named_provider_prompt() -> Result<(), String> {
684 parse_prompt("OAUTH2_GOOGLE_PROMPT")?;
685 Ok(())
686}
687
688fn build_custom_provider(slot: CustomSlot) -> Option<ProviderConfig> {
702 let prefix = slot.env_prefix();
703 let client_id = env::var(format!("{prefix}_CLIENT_ID")).ok()?;
704 let client_secret = env::var(format!("{prefix}_CLIENT_SECRET"))
705 .unwrap_or_else(|_| panic!("{prefix}_CLIENT_ID set but {prefix}_CLIENT_SECRET missing"));
706 let issuer_url = env::var(format!("{prefix}_ISSUER_URL"))
707 .unwrap_or_else(|_| panic!("{prefix}_CLIENT_ID set but {prefix}_ISSUER_URL missing"));
708
709 let preset: Option<&'static ProviderPreset> =
712 match env::var(format!("{prefix}_PRESET")).ok().as_deref() {
713 None => None,
714 Some(key) => {
715 Some(resolve_preset(key).unwrap_or_else(|msg| panic!("{prefix}_PRESET: {msg}")))
716 }
717 };
718
719 let display_name: &'static str = env::var(format!("{prefix}_DISPLAY_NAME"))
720 .ok()
721 .map(leak_static)
722 .or(preset.map(|p| p.display_name))
723 .unwrap_or_else(|| {
724 panic!("{prefix}_CLIENT_ID set but {prefix}_DISPLAY_NAME missing (no PRESET to supply a default)")
725 });
726 let provider_name: ProviderName = env::var(format!("{prefix}_NAME"))
727 .ok()
728 .map(ProviderName::from_env_leaked)
729 .or(preset.map(|p| p.provider_name))
730 .unwrap_or_else(|| {
731 panic!(
732 "{prefix}_CLIENT_ID set but {prefix}_NAME missing (no PRESET to supply a default)"
733 )
734 });
735
736 let origin = env::var("ORIGIN").expect("Missing ORIGIN!");
737
738 let response_mode =
739 env::var(format!("{prefix}_RESPONSE_MODE")).unwrap_or_else(|_| "form_post".to_string());
740 let scope =
741 env::var(format!("{prefix}_SCOPE")).unwrap_or_else(|_| "openid+email+profile".to_string());
742
743 let button_color: &'static str = env::var(format!("{prefix}_BUTTON_COLOR"))
744 .ok()
745 .map(leak_static)
746 .or(preset.map(|p| p.button_color))
747 .unwrap_or(CUSTOM_DEFAULT_BUTTON_COLOR);
748 let button_hover_color: &'static str = env::var(format!("{prefix}_BUTTON_HOVER_COLOR"))
749 .ok()
750 .map(leak_static)
751 .or(preset.map(|p| p.button_hover_color))
752 .unwrap_or(CUSTOM_DEFAULT_BUTTON_HOVER_COLOR);
753 let icon_slug: &'static str = env::var(format!("{prefix}_ICON_SLUG"))
754 .ok()
755 .map(leak_static)
756 .or(preset.map(|p| p.icon_slug))
757 .unwrap_or("openid");
758
759 let additional_allowed_origins: Vec<String> = preset
760 .map(|p| {
761 p.additional_allowed_origins
762 .iter()
763 .map(|s| s.to_string())
764 .collect()
765 })
766 .unwrap_or_default();
767
768 let strict_display_claims =
769 read_strict_display_claims(&format!("{prefix}_STRICT_DISPLAY_CLAIMS"));
770
771 let redirect_uri = format!(
772 "{}{}/oauth2/{}/authorized",
773 origin,
774 O2P_ROUTE_PREFIX.as_str(),
775 provider_name
776 );
777 let prompt = parse_prompt(&format!("{prefix}_PROMPT")).unwrap_or_else(|msg| panic!("{msg}"));
778 let prompt_segment = prompt.map(|p| format!("&prompt={p}")).unwrap_or_default();
779 let query_string = format!(
780 "&response_type=code&scope={}&response_mode={}{}",
781 scope, response_mode, prompt_segment
782 );
783
784 Some(ProviderConfig {
785 kind: ProviderKind::Custom(slot),
786 client_id,
787 client_secret,
788 issuer_url,
789 redirect_uri,
790 response_mode,
791 query_string,
792 discovery: OnceLock::new(),
793 additional_allowed_origins,
794 provider_name,
795 display_name,
796 button_class: slot.button_class(),
797 icon_slug,
798 button_color: Some(button_color),
799 button_hover_color: Some(button_hover_color),
800 css_var_suffix: Some(slot.label()),
801 strict_display_claims,
802 })
803}
804
805pub(crate) static CUSTOM1_PROVIDER: LazyLock<Option<ProviderConfig>> =
807 LazyLock::new(|| build_custom_provider(CustomSlot::Slot1));
808
809pub(crate) static CUSTOM2_PROVIDER: LazyLock<Option<ProviderConfig>> =
811 LazyLock::new(|| build_custom_provider(CustomSlot::Slot2));
812
813pub(crate) static CUSTOM3_PROVIDER: LazyLock<Option<ProviderConfig>> =
815 LazyLock::new(|| build_custom_provider(CustomSlot::Slot3));
816
817pub(crate) static CUSTOM4_PROVIDER: LazyLock<Option<ProviderConfig>> =
819 LazyLock::new(|| build_custom_provider(CustomSlot::Slot4));
820
821pub(crate) static CUSTOM5_PROVIDER: LazyLock<Option<ProviderConfig>> =
823 LazyLock::new(|| build_custom_provider(CustomSlot::Slot5));
824
825pub(crate) static CUSTOM6_PROVIDER: LazyLock<Option<ProviderConfig>> =
827 LazyLock::new(|| build_custom_provider(CustomSlot::Slot6));
828
829pub(crate) static CUSTOM7_PROVIDER: LazyLock<Option<ProviderConfig>> =
831 LazyLock::new(|| build_custom_provider(CustomSlot::Slot7));
832
833pub(crate) static CUSTOM8_PROVIDER: LazyLock<Option<ProviderConfig>> =
835 LazyLock::new(|| build_custom_provider(CustomSlot::Slot8));
836
837pub(crate) const RESERVED_PROVIDER_NAMES: &[&str] = &[
843 "google",
844 "authorized",
845 "accounts",
846 "fedcm",
847 "popup_close",
848 "oauth2.js",
849 "select",
850];
851
852fn is_valid_custom_provider_name(s: &str) -> bool {
855 !s.is_empty()
856 && s.chars()
857 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
858}
859
860fn is_valid_css_color(s: &str) -> bool {
865 match s.strip_prefix('#') {
866 Some(hex) => {
867 matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|c| c.is_ascii_hexdigit())
868 }
869 None => {
870 let len = s.len();
871 (3..=30).contains(&len) && s.chars().all(|c| c.is_ascii_lowercase())
872 }
873 }
874}
875
876pub(crate) fn validate_custom_slots() -> Result<(), String> {
890 let mut enabled_segments: Vec<(CustomSlot, ProviderName)> = Vec::new();
891 for &slot in CustomSlot::ALL {
892 let Some(cfg) = provider_for(ProviderKind::Custom(slot)) else {
893 continue;
894 };
895 let seg = cfg.provider_name;
896
897 if !is_valid_custom_provider_name(seg.as_str()) {
898 return Err(format!(
899 "{}_NAME='{}' is invalid: must match [a-z0-9_-]+",
900 slot.env_prefix(),
901 seg
902 ));
903 }
904 if RESERVED_PROVIDER_NAMES.contains(&seg.as_str()) {
905 return Err(format!(
906 "{}_NAME='{}' collides with a reserved name",
907 slot.env_prefix(),
908 seg
909 ));
910 }
911 if let Some((other_slot, _)) = enabled_segments.iter().find(|(_, s)| *s == seg) {
912 return Err(format!(
913 "{}_NAME='{}' collides with {}_NAME",
914 slot.env_prefix(),
915 seg,
916 other_slot.env_prefix()
917 ));
918 }
919 enabled_segments.push((slot, seg));
920
921 if let Some(color) = cfg.button_color
922 && !is_valid_css_color(color)
923 {
924 return Err(format!(
925 "{}_BUTTON_COLOR='{}' is invalid: expected '#rgb[a]', '#rrggbb[aa]', or a CSS color keyword (3-30 lowercase letters)",
926 slot.env_prefix(),
927 color
928 ));
929 }
930 if let Some(color) = cfg.button_hover_color
931 && !is_valid_css_color(color)
932 {
933 return Err(format!(
934 "{}_BUTTON_HOVER_COLOR='{}' is invalid: expected '#rgb[a]', '#rrggbb[aa]', or a CSS color keyword (3-30 lowercase letters)",
935 slot.env_prefix(),
936 color
937 ));
938 }
939 if !is_valid_custom_provider_name(cfg.icon_slug) {
943 return Err(format!(
944 "{}_ICON_SLUG='{}' is invalid: must match [a-z0-9_-]+",
945 slot.env_prefix(),
946 cfg.icon_slug
947 ));
948 }
949 }
950 Ok(())
951}
952
953pub(crate) fn validate_custom_slot_preset_shape() -> Result<(), String> {
961 for &slot in CustomSlot::ALL {
962 let prefix = slot.env_prefix();
963 if env::var(format!("{prefix}_CLIENT_ID")).is_err() {
964 continue;
965 }
966 let preset_key = env::var(format!("{prefix}_PRESET")).ok();
967 let has_preset = match preset_key.as_deref() {
968 None => false,
969 Some(key) => {
970 resolve_preset(key).map_err(|msg| format!("{prefix}_PRESET: {msg}"))?;
971 true
972 }
973 };
974 if !has_preset {
975 if env::var(format!("{prefix}_DISPLAY_NAME")).is_err() {
976 return Err(format!(
977 "{prefix}_CLIENT_ID is set without {prefix}_PRESET; {prefix}_DISPLAY_NAME is required"
978 ));
979 }
980 if env::var(format!("{prefix}_NAME")).is_err() {
981 return Err(format!(
982 "{prefix}_CLIENT_ID is set without {prefix}_PRESET; {prefix}_NAME is required"
983 ));
984 }
985 }
986 parse_prompt(&format!("{prefix}_PROMPT"))?;
987 }
988 Ok(())
989}
990
991pub(crate) fn provider_for(kind: ProviderKind) -> Option<&'static ProviderConfig> {
996 match kind {
997 ProviderKind::Google => Some(&GOOGLE_PROVIDER),
998 ProviderKind::Custom(slot) => match slot {
999 CustomSlot::Slot1 => CUSTOM1_PROVIDER.as_ref(),
1000 CustomSlot::Slot2 => CUSTOM2_PROVIDER.as_ref(),
1001 CustomSlot::Slot3 => CUSTOM3_PROVIDER.as_ref(),
1002 CustomSlot::Slot4 => CUSTOM4_PROVIDER.as_ref(),
1003 CustomSlot::Slot5 => CUSTOM5_PROVIDER.as_ref(),
1004 CustomSlot::Slot6 => CUSTOM6_PROVIDER.as_ref(),
1005 CustomSlot::Slot7 => CUSTOM7_PROVIDER.as_ref(),
1006 CustomSlot::Slot8 => CUSTOM8_PROVIDER.as_ref(),
1007 },
1008 }
1009}
1010
1011#[cfg(test)]
1012mod tests;