Skip to main content

oauth2_passkey/oauth2/
provider.rs

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/// URL-facing identifier of a **registered** OAuth2/OIDC provider.
10///
11/// Wraps a `&'static str` that is either a compile-time literal (named
12/// providers, preset defaults) or a `Box::leak`-ed value produced from an
13/// operator-supplied env var at `LazyLock` init (custom slots). The value
14/// has been validated for shape via `is_valid_custom_provider_name` (see
15/// `validate_custom_slots`) before any `ProviderName` constructed from env
16/// input reaches callers.
17///
18/// The newtype is not a parser — it does not re-validate its input at
19/// construction. It exists so function signatures can carry
20/// "provider-identifier" as a distinct type from other `&str` parameters
21/// (display name, client secret, etc.), letting the compiler catch
22/// accidental argument swaps. Construction paths stay `pub(crate)` so the
23/// invariant holds at the crate boundary.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub struct ProviderName(&'static str);
26
27impl ProviderName {
28    /// Wrap a compile-time literal. Caller is responsible for shape
29    /// (only used at authored LazyLock / preset initializers).
30    pub(crate) const fn from_static(s: &'static str) -> Self {
31        Self(s)
32    }
33
34    /// Leak an operator-supplied `String` (from `OAUTH2_CUSTOM{N}_NAME`)
35    /// to `'static`. The leak is bounded by the number of custom slots —
36    /// eight at most, each initialized at most once per process by
37    /// `LazyLock`. Shape validation runs in `validate_custom_slots`
38    /// against the resulting `ProviderConfig`, so the `Err` path there
39    /// is the single source of "invalid provider name" diagnostics.
40    pub(crate) fn from_env_leaked(raw: String) -> Self {
41        Self(leak_static(raw))
42    }
43
44    /// Borrow the underlying `&'static str`.
45    pub const fn as_str(&self) -> &'static str {
46        self.0
47    }
48
49    /// Resolve a runtime string (URL path segment, DB-stored value) against
50    /// the currently-enabled provider registry. Returns `Some(ProviderName)`
51    /// only when the string matches an enabled provider's name, so the
52    /// `ProviderName` invariant is preserved — callers can treat the result
53    /// as "validated, registered identifier" without further checks.
54    ///
55    /// Returns `None` when the string is empty, does not match any enabled
56    /// provider, or matches a named provider whose optional env vars are not
57    /// configured.
58    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/// Identifies a supported OAuth2/OIDC provider.
78///
79/// `Google` is the only **named** variant — it has library-side features
80/// (FedCM, `access_type=online`, `hd` claim handling) that cannot be
81/// expressed as a generic OIDC preset. All other providers are routed
82/// through `Custom(CustomSlot)` and configured via `OAUTH2_CUSTOM{N}_*`
83/// env vars, optionally pre-populated by a [`ProviderPreset`] selected
84/// via `OAUTH2_CUSTOM{N}_PRESET`.
85///
86/// Reserved names that must never become path-segment values (they collide
87/// with literal routes under `/oauth2/*`):
88/// "authorized", "accounts", "fedcm", "popup_close", "oauth2.js", "select",
89/// plus the named-provider segment "google". Operators using a preset
90/// (`PRESET=auth0` etc.) take the preset's default name ("auth0") which is
91/// allowed because no library route shadows it.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
93pub(crate) enum ProviderKind {
94    Google,
95    Custom(CustomSlot),
96}
97
98/// Vendor-specific defaults that pre-populate a Custom OIDC slot when the
99/// operator sets `OAUTH2_CUSTOM{N}_PRESET=<key>`. Each preset provides the
100/// defaults for display name, URL-facing provider name, icon, brand colors,
101/// and any library-side quirks (additional allowed origins) that would
102/// otherwise require a bespoke named-provider code path.
103///
104/// Operators can override individual fields via the usual `OAUTH2_CUSTOM{N}_*`
105/// env vars; the env var wins. `additional_allowed_origins` has no env-var
106/// override surface — it is a library-level invariant tied to the IdP's
107/// login UI (e.g. Entra personal routes credentials through login.live.com
108/// while its OIDC endpoints live on login.microsoftonline.com).
109pub(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    // Microsoft routes personal MS account (B2C / consumers tenant) login
143    // through `login.live.com`; its Referer on the form_post callback is
144    // that host rather than `login.microsoftonline.com`.
145    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
193/// Resolve a preset key (from `OAUTH2_CUSTOM{N}_PRESET`) to its
194/// [`ProviderPreset`]. Returns `Err` with an operator-facing message on
195/// unknown keys so `init()` can fail fast.
196fn 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/// Identifies one of the eight generic OIDC provider slots.
214///
215/// Each slot is independently configured via `OAUTH2_CUSTOM{N}_*` env vars.
216/// A fixed-slot design was chosen over a dynamic registry to preserve
217/// compile-time `match` exhaustiveness (see issue `20260420-1511`).
218#[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    /// Stable internal label for this slot — `"custom1".."custom8"`.
243    /// Used for diagnostics; NOT the URL path segment (that comes from
244    /// `OAUTH2_CUSTOM{N}_NAME` and lives on `ProviderConfig`).
245    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    /// Env-var prefix for this slot — `"OAUTH2_CUSTOM1".."OAUTH2_CUSTOM8"`.
259    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    /// CSS button class for this slot — `"btn-oauth2 btn-custom1".."btn-custom8"`.
273    /// The `.btn-custom{N}` rules are defined in `o2p-base.css` and consume
274    /// the `--o2p-custom{N}` / `--o2p-custom{N}-hover` CSS variables injected
275    /// by the template for enabled slots.
276    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    /// All supported provider kinds in stable display order.
292    /// Google first, then the eight generic OIDC slots.
293    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    /// Env-var validation contract for optional providers.
306    ///
307    /// Returns `Some((trigger, dependents))` for providers activated by one
308    /// env var that require additional env vars when that trigger is set.
309    /// Returns `None` for unconditional providers (validated directly in `init`).
310    ///
311    /// Used by `init` to fail fast at startup instead of panicking mid-request
312    /// via the `LazyLock.expect()` inside the matching static.
313    ///
314    /// `DISPLAY_NAME` / `NAME` are intentionally NOT in the required list:
315    /// when `OAUTH2_CUSTOM{N}_PRESET` is set, the preset supplies those
316    /// defaults. `validate_custom_slot_preset_shape` handles the
317    /// preset-aware requirement check.
318    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    /// Stable internal identifier. For `Custom(slot)` returns
357    /// `"custom1".."custom8"` — the **slot label**, not the configurable
358    /// URL path segment. Use `ProviderConfig::provider_name` when you need
359    /// the URL-facing identifier.
360    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    /// Parse a URL path segment (e.g. `"google"`, or a custom slot's
368    /// configured segment) into a `ProviderKind`.
369    ///
370    /// Google is the compile-time literal; custom-slot segments are read
371    /// from `ProviderConfig::provider_name` of each enabled slot.
372    ///
373    /// Returns `None` if the segment does not match any configured provider.
374    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/// Public information about a single enabled OAuth2 provider.
390///
391/// Returned by [`enabled_providers`](crate::oauth2::enabled_providers). The
392/// framework-integration crate (`oauth2_passkey_axum`) uses these fields to
393/// render provider buttons without maintaining its own presentation table —
394/// the source of truth lives on `ProviderConfig` inside this crate.
395///
396/// For generic OIDC slots (`Custom{1..8}`), `display_name` / `button_class` /
397/// `button_color` / `button_hover_color` come from `OAUTH2_CUSTOM{N}_*` env
398/// vars. For named providers they are compile-time literals.
399#[derive(Debug, Clone)]
400#[non_exhaustive]
401pub struct ProviderInfo {
402    /// Provider identifier used in URL routing (`/oauth2/{provider_name}/`),
403    /// DB rows (`oauth2_accounts.provider`), OAuth2 state (`StateParams.provider`),
404    /// and templates. E.g. `"google"`, `"auth0"`, or an operator-configured
405    /// value like `"my-sso"` for a custom slot.
406    pub provider_name: ProviderName,
407    /// Human-readable label for login buttons (e.g. `"Google"`, `"Microsoft"`).
408    pub display_name: &'static str,
409    /// CSS classes for the login button (e.g. `"btn-oauth2 btn-google"`).
410    pub button_class: &'static str,
411    /// SVG basename served under `{O2P_ROUTE_PREFIX}/icons/{icon_slug}.svg`.
412    /// Named providers use their own slug; custom slots use `"openid"` (the
413    /// neutral OpenID Connect mark).
414    pub icon_slug: &'static str,
415    /// Inline CSS `background-color` for the button. `None` for named
416    /// providers (colored via `--o2p-<name>` variables in the base CSS /
417    /// theme files). `Some(color)` for custom slots, injected via a
418    /// `:root { --o2p-customN: ...; }` block in the login template.
419    pub button_color: Option<&'static str>,
420    /// Inline CSS `background-color` for the `:hover` state. `None` for
421    /// named providers; `Some(color)` for custom slots.
422    pub button_hover_color: Option<&'static str>,
423    /// Suffix used in the `--o2p-{suffix}` / `--o2p-{suffix}-hover` CSS
424    /// variables the login template injects for this provider. `None` for
425    /// named providers (styled via `.btn-<name>` rules + theme variables);
426    /// for custom slots this is `"custom1".."custom8"`. The framework crate
427    /// uses this to build the `:root { ... }` block without re-parsing
428    /// `button_class`.
429    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
438/// Per-provider OAuth2/OIDC configuration.
439///
440/// Each instance holds all configuration needed for one provider:
441/// credentials, computed endpoint URLs (via per-instance OIDC discovery),
442/// and response-mode settings.
443///
444/// The `discovery` field uses `OnceLock` with a "first write wins" strategy,
445/// matching the existing global `OIDC_DISCOVERY_CACHE` behaviour.  Concurrent
446/// first-access races may cause redundant fetches but always result in
447/// correct state.
448///
449/// **`ProviderConfig` is `pub(crate)`**: it never appears in the public API of
450/// `oauth2_passkey`.  The axum-crate boundary uses `&str`; parsing and config
451/// resolution happen inside this crate.
452pub(crate) struct ProviderConfig {
453    pub(crate) kind: ProviderKind,
454    pub(crate) client_id: String,
455    pub(crate) client_secret: String,
456    /// Base issuer URL used for OIDC discovery (trailing slash stripped).
457    pub(crate) issuer_url: String,
458    /// Redirect URI registered in the IdP console.
459    /// Built as `{ORIGIN}{O2P_ROUTE_PREFIX}/oauth2/{provider}/authorized`.
460    pub(crate) redirect_uri: String,
461    pub(crate) response_mode: String,
462    /// Precomputed query-string fragment appended to the authorization URL.
463    /// Starts with `&` to match the existing format string in core.rs.
464    pub(crate) query_string: String,
465    /// Per-provider OIDC discovery document cache.
466    pub(crate) discovery: OnceLock<OidcDiscoveryDocument>,
467    /// Extra origins that `validate_origin` should accept in addition to the
468    /// authorization endpoint's origin. Needed for providers whose login UI
469    /// is hosted on a different host than the OIDC endpoints — notably
470    /// Microsoft Entra B2C (personal MS accounts) routes credential entry
471    /// through `login.live.com` while the OIDC endpoints remain on
472    /// `login.microsoftonline.com`. Empty for providers where the login UI
473    /// and the authorization endpoint share an origin.
474    pub(crate) additional_allowed_origins: Vec<String>,
475    /// URL path segment used to route `/oauth2/{segment}/*` to this provider.
476    /// For named providers this equals `kind.as_str()`. For `Custom(slot)`
477    /// it is the operator-supplied `OAUTH2_CUSTOM{N}_NAME`,
478    /// `Box::leak`ed to `'static` at `LazyLock` init.
479    pub(crate) provider_name: ProviderName,
480    /// Human-readable label for login buttons (e.g. `"Google"`, `"My SSO"`).
481    pub(crate) display_name: &'static str,
482    /// CSS classes for the login button (e.g. `"btn-oauth2 btn-google"`).
483    pub(crate) button_class: &'static str,
484    /// SVG basename served under `{O2P_ROUTE_PREFIX}/icons/{slug}.svg`.
485    /// Named providers use their own slug; custom slots use `"openid"`.
486    pub(crate) icon_slug: &'static str,
487    /// Inline CSS `background-color` value for the button. `None` for named
488    /// providers (they rely on `.btn-<name>` rules + theme variables in
489    /// `o2p-base.css` / `theme-*.css`). `Some(color)` for custom slots,
490    /// injected via a `:root { --o2p-customN: ...; }` block in the template.
491    pub(crate) button_color: Option<&'static str>,
492    /// Inline CSS `background-color` for the `:hover` state of the button.
493    /// `None` for named providers; `Some(color)` for custom slots.
494    pub(crate) button_hover_color: Option<&'static str>,
495    /// Suffix used in the `--o2p-{suffix}` / `--o2p-{suffix}-hover` CSS
496    /// variables the template injects for this provider. `None` for named
497    /// providers (they rely on `.btn-<name>` rules + theme variables); for
498    /// custom slots this is `"custom1".."custom8"` (i.e. `CustomSlot::label`).
499    pub(crate) css_var_suffix: Option<&'static str>,
500    /// When `true` (default), any divergence of display-tier claims
501    /// (`name`, `picture`, `family_name`, `given_name`) between the
502    /// verified ID token and the `/userinfo` response is rejected.
503    /// When `false`, such divergence is logged as a warning and the
504    /// id_token value is used (Option B merge priority preserved).
505    /// Identity-tier claims (`email`, `email_verified`,
506    /// `preferred_username`, `hd`) are always hardcoded-strict; this
507    /// flag does not affect them.
508    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        // First write wins in case of concurrent access
525        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
558/// Google provider — unconditional (panics at first access if env vars are missing,
559/// matching the previous `LazyLock<String>` behaviour).
560pub(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
611/// Default button background color for a custom slot when
612/// `OAUTH2_CUSTOM{N}_BUTTON_COLOR` is not set. Neutral gray.
613const CUSTOM_DEFAULT_BUTTON_COLOR: &str = "#6b7280";
614
615/// Default button `:hover` background color for a custom slot when
616/// `OAUTH2_CUSTOM{N}_BUTTON_HOVER_COLOR` is not set. Slightly darker gray.
617const CUSTOM_DEFAULT_BUTTON_HOVER_COLOR: &str = "#4b5563";
618
619/// Leak a `String` to `&'static str`. Used at `LazyLock` init to move
620/// env-var values (which are dynamic) into the `ProviderConfig` fields
621/// that require `'static` lifetimes. The leak happens at most once per
622/// slot per process, as `LazyLock` initializes once.
623fn leak_static(s: String) -> &'static str {
624    Box::leak(s.into_boxed_str())
625}
626
627/// Parse an `OAUTH2_*_STRICT_DISPLAY_CLAIMS` env var. Unset or `"true"`
628/// yields `Ok(true)`; `"false"` yields `Ok(false)`; any other value yields
629/// `Err(message)` so callers can choose between startup-time rejection
630/// (pure validator) and `LazyLock`-time panic.
631fn 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
641/// `LazyLock`-time wrapper around `parse_strict_display_claims`. Matches the
642/// on-invalid strict-parse style of `OAUTH2_RESPONSE_MODE`.
643fn read_strict_display_claims(env_var: &str) -> bool {
644    parse_strict_display_claims(env_var).unwrap_or_else(|msg| panic!("{msg}"))
645}
646
647/// Validate `OAUTH2_GOOGLE_STRICT_DISPLAY_CLAIMS` at startup without
648/// forcing `GOOGLE_PROVIDER`'s `LazyLock` to initialize.
649///
650/// Custom slots go through `LazyLock` init in `validate_custom_slots`, so
651/// their STRICT_DISPLAY_CLAIMS gets checked there. `GOOGLE_PROVIDER` is
652/// intentionally lazy so tests can override other env vars after
653/// `init()`; without this function a bad value would survive startup and
654/// crash the first login request instead.
655pub(crate) fn validate_named_provider_strict_display_claims() -> Result<(), String> {
656    parse_strict_display_claims("OAUTH2_GOOGLE_STRICT_DISPLAY_CLAIMS")?;
657    Ok(())
658}
659
660/// Parse an `OAUTH2_*_PROMPT` env var.
661/// - Unset -> `Ok(Some("consent"))` (preserves current behavior)
662/// - `""` -> `Ok(None)` (operator explicitly omits `&prompt=` from the URL)
663/// - `"none" | "login" | "consent" | "select_account"` -> `Ok(Some(value))`
664/// - Any other value -> `Err(message)` for startup-time rejection
665fn 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
681/// Validate `OAUTH2_GOOGLE_PROMPT` at startup without forcing `GOOGLE_PROVIDER`'s
682/// `LazyLock` to initialize. Custom slots are caught via `validate_custom_slots`.
683pub(crate) fn validate_named_provider_prompt() -> Result<(), String> {
684    parse_prompt("OAUTH2_GOOGLE_PROMPT")?;
685    Ok(())
686}
687
688/// Build a `ProviderConfig` for a custom OIDC slot from its env vars.
689///
690/// Returns `None` if the slot's `CLIENT_ID` trigger env var is not set
691/// (meaning the slot is disabled). Panics at first access if `CLIENT_ID`
692/// is set but any required dependent var is missing — `init()` calls
693/// `optional_env_contract` validation + `validate_custom_slot_preset_shape`
694/// first, so this panic is a defense against direct static access before
695/// `init()` ran.
696///
697/// When `OAUTH2_CUSTOM{N}_PRESET` is set (and known), the preset supplies
698/// defaults for display_name / provider_name / icon_slug / button colors /
699/// additional_allowed_origins. Each preset field is overridable via the
700/// usual `OAUTH2_CUSTOM{N}_*` env var — env wins.
701fn 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    // Resolve optional preset. Invalid value → panic; matches the
710    // strict-parse style used for `OAUTH2_RESPONSE_MODE`.
711    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
805/// Generic OIDC provider slot 1 — enabled by `OAUTH2_CUSTOM1_CLIENT_ID`.
806pub(crate) static CUSTOM1_PROVIDER: LazyLock<Option<ProviderConfig>> =
807    LazyLock::new(|| build_custom_provider(CustomSlot::Slot1));
808
809/// Generic OIDC provider slot 2 — enabled by `OAUTH2_CUSTOM2_CLIENT_ID`.
810pub(crate) static CUSTOM2_PROVIDER: LazyLock<Option<ProviderConfig>> =
811    LazyLock::new(|| build_custom_provider(CustomSlot::Slot2));
812
813/// Generic OIDC provider slot 3 — enabled by `OAUTH2_CUSTOM3_CLIENT_ID`.
814pub(crate) static CUSTOM3_PROVIDER: LazyLock<Option<ProviderConfig>> =
815    LazyLock::new(|| build_custom_provider(CustomSlot::Slot3));
816
817/// Generic OIDC provider slot 4 — enabled by `OAUTH2_CUSTOM4_CLIENT_ID`.
818pub(crate) static CUSTOM4_PROVIDER: LazyLock<Option<ProviderConfig>> =
819    LazyLock::new(|| build_custom_provider(CustomSlot::Slot4));
820
821/// Generic OIDC provider slot 5 — enabled by `OAUTH2_CUSTOM5_CLIENT_ID`.
822pub(crate) static CUSTOM5_PROVIDER: LazyLock<Option<ProviderConfig>> =
823    LazyLock::new(|| build_custom_provider(CustomSlot::Slot5));
824
825/// Generic OIDC provider slot 6 — enabled by `OAUTH2_CUSTOM6_CLIENT_ID`.
826pub(crate) static CUSTOM6_PROVIDER: LazyLock<Option<ProviderConfig>> =
827    LazyLock::new(|| build_custom_provider(CustomSlot::Slot6));
828
829/// Generic OIDC provider slot 7 — enabled by `OAUTH2_CUSTOM7_CLIENT_ID`.
830pub(crate) static CUSTOM7_PROVIDER: LazyLock<Option<ProviderConfig>> =
831    LazyLock::new(|| build_custom_provider(CustomSlot::Slot7));
832
833/// Generic OIDC provider slot 8 — enabled by `OAUTH2_CUSTOM8_CLIENT_ID`.
834pub(crate) static CUSTOM8_PROVIDER: LazyLock<Option<ProviderConfig>> =
835    LazyLock::new(|| build_custom_provider(CustomSlot::Slot8));
836
837/// Path segments under `/oauth2/*` that a custom-slot `NAME` must not
838/// collide with: the `Google` named-provider segment plus the literal
839/// routes mounted by the axum crate. `"auth0"`, `"keycloak"`, `"entra"`
840/// are NOT reserved — they are the natural default names selected by
841/// `OAUTH2_CUSTOM{N}_PRESET` and operators may use them directly.
842pub(crate) const RESERVED_PROVIDER_NAMES: &[&str] = &[
843    "google",
844    "authorized",
845    "accounts",
846    "fedcm",
847    "popup_close",
848    "oauth2.js",
849    "select",
850];
851
852/// Returns `true` iff `s` is non-empty and every character is in the
853/// allowed path-segment alphabet (`[a-z0-9_-]+`).
854fn 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
860/// Returns `true` iff `s` is a hex (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`) or
861/// a lowercase alphabetic CSS color keyword (3-30 letters). The value is
862/// emitted into an inline `<style>` block, so this guard rejects syntax that
863/// could break out of the CSS var declaration.
864fn 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
876/// Validates value-level constraints on enabled custom OIDC slots.
877///
878/// Env-presence (trigger → dependents) is already covered by
879/// `optional_env_contract`. This function covers the checks that operate
880/// on resolved `ProviderConfig` values:
881///
882/// - `provider_name` matches `[a-z0-9_-]+` (non-empty)
883/// - `provider_name` does not collide with named providers or reserved routes
884///   (see `RESERVED_PROVIDER_NAMES`)
885/// - No two enabled custom slots share the same `provider_name`
886///
887/// Returns an `Err` with a descriptive message on the first violation so
888/// `init()` can fail fast before any request is served.
889pub(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        // Icon slug shares the `[a-z0-9_-]+` grammar with provider_name —
940        // both are URL path segments served under `/icons/{slug}.svg` and
941        // `/oauth2/{name}` respectively.
942        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
953/// Pre-`LazyLock` validation for the preset contract on every enabled
954/// custom slot: reject invalid `OAUTH2_CUSTOM{N}_PRESET` values and, when
955/// no preset is declared, require `DISPLAY_NAME` + `NAME` explicitly.
956///
957/// Kept separate from `validate_custom_slots` so operators get the
958/// preset-shape error at startup without triggering `build_custom_provider`
959/// (which panics via `unwrap_or_else` if a required field is missing).
960pub(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
991/// Resolve a `ProviderKind` to its `&'static ProviderConfig`.
992///
993/// Returns `None` if the provider is optional and not configured (its env vars
994/// are absent).  Google is unconditional, so this always returns `Some` for it.
995pub(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;