Skip to main content

securitydept_oidc_client/
config.rs

1use openidconnect::core::CoreJwsSigningAlgorithm;
2use securitydept_oauth_provider::{
3    OAuthProviderConfig, OAuthProviderOidcConfig, OAuthProviderRemoteConfig, OidcSharedConfig,
4};
5use securitydept_utils::{
6    secret::{SecretString, deserialize_optional_secret_string},
7    ser::CommaOrSpaceSeparated,
8};
9use serde::Deserialize;
10use serde_with::{NoneAsEmptyString, PickFirst, serde_as};
11
12use crate::{OidcError, OidcResult, PendingOauthStoreConfig};
13
14/// Input configuration for building the OIDC client.
15///
16/// When `well_known_url` is set, discovery is fetched from it and optional
17/// fields override. When not set, `issuer_url`, `authorization_endpoint`,
18/// `token_endpoint`, and `jwks_uri` must be set. `userinfo_endpoint` is
19/// recommended, and userinfo claims are fetched only when it is set.
20///
21/// Use [`OidcClientRawConfig::apply_shared_defaults`] when loading from a
22/// config source that also provides an `[oidc]` shared-defaults block.
23#[serde_as]
24#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
25#[cfg_attr(
26    feature = "config-schema",
27    schemars(bound = "PC: schemars::JsonSchema")
28)]
29#[derive(Debug, Clone, Deserialize)]
30pub struct OidcClientConfig<PC>
31where
32    PC: PendingOauthStoreConfig,
33{
34    pub client_id: String,
35    #[serde(default)]
36    pub client_secret: Option<SecretString>,
37    /// Shared remote-provider connectivity settings.
38    #[serde(flatten)]
39    pub remote: OAuthProviderRemoteConfig,
40    /// OIDC-specific provider metadata overrides.
41    #[serde(flatten)]
42    pub provider_oidc: OAuthProviderOidcConfig,
43    #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
44    #[serde(default = "default_scopes")]
45    #[cfg_attr(
46        feature = "config-schema",
47        schemars(with = "securitydept_utils::schema::StringOrVecString")
48    )]
49    pub scopes: Vec<String>,
50    /// Scopes that MUST be present in the token endpoint response.
51    ///
52    /// When non-empty, `exchange_code` and `handle_token_refresh` will verify
53    /// that the returned `scope` field covers all entries. An empty list (the
54    /// default) disables the check. Can be shared from
55    /// `[oidc].required_scopes`.
56    #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
57    #[serde(default)]
58    #[cfg_attr(
59        feature = "config-schema",
60        schemars(with = "securitydept_utils::schema::StringOrVecString")
61    )]
62    pub required_scopes: Vec<String>,
63    #[serde(default)]
64    pub claims_check_script: Option<String>,
65    /// When true, use PKCE (code_challenge / code_verifier) for the
66    /// authorization code flow.
67    #[serde(default)]
68    pub pkce_enabled: bool,
69    #[serde(default = "default_redirect_url")]
70    pub redirect_url: String,
71    /// Configuration for the pending OAuth store.
72    #[serde(default, bound = "PC: PendingOauthStoreConfig")]
73    pub pending_store: Option<PC>,
74    /// Default interval to poll the device token endpoint if the provider
75    /// doesn't specify one.
76    #[serde(default = "default_device_poll_interval", with = "humantime_serde")]
77    #[cfg_attr(feature = "config-schema", schemars(with = "String"))]
78    pub device_poll_interval: std::time::Duration,
79}
80
81impl<PC> OidcClientConfig<PC>
82where
83    PC: PendingOauthStoreConfig,
84{
85    pub fn validate(&self) -> OidcResult<()> {
86        if self.claims_check_script.is_some() && cfg!(not(feature = "claims-script")) {
87            return Err(OidcError::InvalidConfig {
88                message: "Claims check script is enabled but the claims-script feature is disabled"
89                    .to_string(),
90            });
91        }
92        if self.remote.well_known_url.is_none() {
93            let missing: Vec<&str> = [
94                ("issuer_url", self.remote.issuer_url.as_deref()),
95                (
96                    "authorization_endpoint",
97                    self.provider_oidc.authorization_endpoint.as_deref(),
98                ),
99                (
100                    "token_endpoint",
101                    self.provider_oidc.token_endpoint.as_deref(),
102                ),
103                ("jwks_uri", self.remote.jwks_uri.as_deref()),
104                (
105                    "userinfo_endpoint",
106                    self.provider_oidc.userinfo_endpoint.as_deref(),
107                ),
108            ]
109            .into_iter()
110            .filter_map(|(name, v)| match v {
111                None | Some("") => Some(name),
112                Some(s) if s.trim().is_empty() => Some(name),
113                _ => None,
114            })
115            .collect();
116            if missing.len() > 1 || (missing.len() == 1 && missing[0] != "userinfo_endpoint") {
117                return Err(OidcError::InvalidConfig {
118                    message: format!(
119                        "When well_known_url is not set, all of issuer_url, \
120                         authorization_endpoint, token_endpoint, and jwks_uri must be set; \
121                         userinfo_endpoint is recommended and only enables user_info_claims \
122                         fetch; missing: {}",
123                        missing.join(", ")
124                    ),
125                });
126            }
127        }
128        Ok(())
129    }
130
131    pub fn provider_config(&self) -> OAuthProviderConfig {
132        OAuthProviderConfig {
133            remote: self.remote.clone(),
134            oidc: self.provider_oidc.clone(),
135        }
136    }
137}
138
139/// Raw (pre-resolution) OIDC client configuration that allows optional fields
140/// to be filled from an `[oidc]` shared-defaults block.
141///
142/// Unlike [`OidcClientConfig`], `client_id` here is optional so that it can
143/// be omitted from `[oidc_client]` and inherited from `[oidc]` instead.
144/// Call [`OidcClientRawConfig::apply_shared_defaults`] to resolve into a
145/// validated [`OidcClientConfig`].
146///
147/// # Resolution order: local > [oidc] shared > hardcoded default
148///
149/// Supported shared fields (from `[oidc]`):
150/// - `well_known_url`, `issuer_url`, `jwks_uri` — true presence-aware
151/// - `client_id`, `client_secret` — presence-aware optional credentials
152///
153/// Shareable from `[oidc]`:
154/// - `required_scopes` — presence-aware (local non-empty wins; else shared)
155///
156/// Not shared (must stay in `[oidc_client]`):
157/// - `scopes`, `redirect_url`, `pkce_enabled`, `claims_check_script`
158#[serde_as]
159#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
160#[cfg_attr(
161    feature = "config-schema",
162    schemars(bound = "PC: schemars::JsonSchema")
163)]
164#[derive(Debug, Clone, Deserialize)]
165pub struct OidcClientRawConfig<PC>
166where
167    PC: PendingOauthStoreConfig,
168{
169    /// Local `client_id`. If absent, falls back to `[oidc].client_id`.
170    #[serde(default)]
171    #[serde_as(as = "NoneAsEmptyString")]
172    #[cfg_attr(feature = "config-schema", schemars(with = "Option<String>"))]
173    pub client_id: Option<String>,
174    /// Local `client_secret`. If absent, falls back to `[oidc].client_secret`.
175    #[serde(default, deserialize_with = "deserialize_optional_secret_string")]
176    pub client_secret: Option<SecretString>,
177    /// Local provider connectivity. URL fields fall back to `[oidc]` if absent.
178    #[serde(flatten)]
179    pub remote: OAuthProviderRemoteConfig,
180    /// OIDC-specific overrides (never shared).
181    #[serde(flatten)]
182    pub provider_oidc: OAuthProviderOidcConfig,
183    #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
184    #[serde(default = "default_scopes")]
185    #[cfg_attr(
186        feature = "config-schema",
187        schemars(with = "securitydept_utils::schema::StringOrVecString")
188    )]
189    pub scopes: Vec<String>,
190    /// Scopes that MUST be present in the token endpoint response.
191    /// Falls back to `[oidc].required_scopes` when local is empty.
192    #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
193    #[serde(default)]
194    #[cfg_attr(
195        feature = "config-schema",
196        schemars(with = "securitydept_utils::schema::StringOrVecString")
197    )]
198    pub required_scopes: Vec<String>,
199    #[serde(default)]
200    pub claims_check_script: Option<String>,
201    #[serde(default)]
202    pub pkce_enabled: bool,
203    /// Explicit redirect URL. When `None` (the default), each auth context
204    /// uses its own hardcoded callback path at resolution time. In the
205    /// combined `apps/webui` + `apps/server` deployment this field has no
206    /// effect and will produce a startup warning if set.
207    #[serde(default)]
208    pub redirect_url: Option<String>,
209    #[serde(default, bound = "PC: PendingOauthStoreConfig")]
210    pub pending_store: Option<PC>,
211    #[serde(default = "default_device_poll_interval", with = "humantime_serde")]
212    #[cfg_attr(feature = "config-schema", schemars(with = "String"))]
213    pub device_poll_interval: std::time::Duration,
214}
215
216impl<PC> OidcClientRawConfig<PC>
217where
218    PC: PendingOauthStoreConfig,
219{
220    /// Apply shared defaults from an `[oidc]` block and produce the final
221    /// [`OidcClientConfig`]. Returns an error if `client_id` cannot be
222    /// resolved (neither local nor shared has a value).
223    pub fn apply_shared_defaults(
224        self,
225        shared: &OidcSharedConfig,
226    ) -> OidcResult<OidcClientConfig<PC>> {
227        let resolved_client_id = shared
228            .resolve_client_id(self.client_id.as_deref())
229            .ok_or_else(|| OidcError::InvalidConfig {
230                message: "client_id must be set in either [oidc_client] or [oidc]".to_string(),
231            })?;
232
233        Ok(OidcClientConfig {
234            client_id: resolved_client_id,
235            client_secret: shared.resolve_client_secret(self.client_secret.as_ref()),
236            remote: shared.resolve_remote(&self.remote),
237            provider_oidc: self.provider_oidc,
238            scopes: self.scopes,
239            required_scopes: shared.resolve_required_scopes(&self.required_scopes),
240            claims_check_script: self.claims_check_script,
241            pkce_enabled: self.pkce_enabled,
242            redirect_url: self
243                .redirect_url
244                .as_deref()
245                .unwrap_or(&default_redirect_url())
246                .to_owned(),
247
248            pending_store: self.pending_store,
249            device_poll_interval: self.device_poll_interval,
250        })
251    }
252
253    /// **Recommended entry point.** Resolve shared defaults and validate in
254    /// one step.
255    ///
256    /// Equivalent to `self.apply_shared_defaults(shared)?.validate()` but
257    /// returns the validated config directly, eliminating manual glue.
258    ///
259    /// ```text
260    /// [oidc]          ──┐
261    ///                   ├──▸ resolve_config() ──▸ validated OidcClientConfig
262    /// [oidc_client]   ──┘
263    /// ```
264    pub fn resolve_config(self, shared: &OidcSharedConfig) -> OidcResult<OidcClientConfig<PC>> {
265        let config = self.apply_shared_defaults(shared)?;
266        config.validate()?;
267        Ok(config)
268    }
269}
270
271impl<PC> Default for OidcClientRawConfig<PC>
272where
273    PC: PendingOauthStoreConfig,
274{
275    fn default() -> Self {
276        Self {
277            client_id: None,
278            client_secret: None,
279            remote: OAuthProviderRemoteConfig::default(),
280            provider_oidc: OAuthProviderOidcConfig::default(),
281            scopes: default_scopes(),
282            required_scopes: vec![],
283            claims_check_script: None,
284            pkce_enabled: false,
285            redirect_url: None,
286
287            pending_store: None,
288            device_poll_interval: default_device_poll_interval(),
289        }
290    }
291}
292
293pub fn default_scopes() -> Vec<String> {
294    vec![
295        "openid".to_string(),
296        "profile".to_string(),
297        "email".to_string(),
298    ]
299}
300
301pub fn default_id_token_signing_alg_values_supported() -> Vec<CoreJwsSigningAlgorithm> {
302    vec![CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256]
303}
304
305pub fn default_redirect_url() -> String {
306    "/auth/callback".to_string()
307}
308
309pub fn default_device_poll_interval() -> std::time::Duration {
310    std::time::Duration::from_secs(5)
311}
312
313#[cfg(test)]
314mod tests {
315    use securitydept_oauth_provider::{OAuthProviderRemoteConfig, OidcSharedConfig};
316    use securitydept_utils::secret::SecretString;
317    use serde::Deserialize;
318
319    use super::{OidcClientRawConfig, default_scopes};
320    use crate::pending_store::base::PendingOauthStoreConfig;
321
322    // Minimal no-op config for tests — avoids feature-gated moka dependency.
323    #[derive(Debug, Clone, Default, Deserialize)]
324    struct TestPendingStoreConfig;
325    impl PendingOauthStoreConfig for TestPendingStoreConfig {}
326
327    type RawConfig = OidcClientRawConfig<TestPendingStoreConfig>;
328
329    #[test]
330    fn apply_shared_defaults_inherits_well_known_url_from_oidc_block() {
331        let shared = OidcSharedConfig {
332            remote: OAuthProviderRemoteConfig {
333                well_known_url: Some(
334                    "https://auth.example.com/.well-known/openid-configuration".to_string(),
335                ),
336                ..Default::default()
337            },
338            client_id: Some("shared-app".to_string()),
339            client_secret: Some(SecretString::from("shared-secret")),
340            ..Default::default()
341        };
342
343        let raw = RawConfig::default();
344        let config = raw
345            .apply_shared_defaults(&shared)
346            .expect("should resolve with shared defaults");
347
348        assert_eq!(
349            config.remote.well_known_url.as_deref(),
350            Some("https://auth.example.com/.well-known/openid-configuration"),
351            "well_known_url should be inherited from [oidc]"
352        );
353        assert_eq!(
354            config.client_id, "shared-app",
355            "client_id should be inherited from [oidc]"
356        );
357        assert_eq!(
358            config
359                .client_secret
360                .as_ref()
361                .map(SecretString::expose_secret),
362            Some("shared-secret")
363        );
364    }
365
366    #[test]
367    fn local_client_id_overrides_shared_client_id() {
368        let shared = OidcSharedConfig {
369            client_id: Some("shared-app".to_string()),
370            ..Default::default()
371        };
372
373        let raw = RawConfig {
374            client_id: Some("local-app".to_string()),
375            remote: OAuthProviderRemoteConfig {
376                well_known_url: Some("https://auth.example.com/.well-known".to_string()),
377                ..Default::default()
378            },
379            ..Default::default()
380        };
381        let config = raw.apply_shared_defaults(&shared).expect("should resolve");
382
383        assert_eq!(config.client_id, "local-app", "local client_id must win");
384    }
385
386    #[test]
387    fn missing_client_id_everywhere_returns_error() {
388        let shared = OidcSharedConfig::default();
389        let raw = RawConfig::default();
390
391        let result = raw.apply_shared_defaults(&shared);
392        assert!(result.is_err(), "should fail when client_id is absent");
393        assert!(
394            result
395                .unwrap_err()
396                .to_string()
397                .contains("client_id must be set")
398        );
399    }
400
401    #[test]
402    fn default_scopes_are_applied_when_not_overridden() {
403        let shared = OidcSharedConfig {
404            client_id: Some("app".to_string()),
405            remote: OAuthProviderRemoteConfig {
406                well_known_url: Some("https://auth.example.com/.well-known".to_string()),
407                ..Default::default()
408            },
409            ..Default::default()
410        };
411        let raw = RawConfig::default();
412        let config = raw.apply_shared_defaults(&shared).expect("should resolve");
413
414        assert_eq!(config.scopes, default_scopes());
415    }
416
417    // ---------------------------------------------------------------------------
418    // resolve_config (unified entry) tests
419    // ---------------------------------------------------------------------------
420
421    #[test]
422    fn resolve_config_applies_shared_defaults_and_validates() {
423        let shared = OidcSharedConfig {
424            client_id: Some("app".to_string()),
425            remote: OAuthProviderRemoteConfig {
426                well_known_url: Some("https://auth.example.com/.well-known".to_string()),
427                ..Default::default()
428            },
429            ..Default::default()
430        };
431        let raw = RawConfig::default();
432
433        // resolve_config = apply_shared_defaults + validate in one call
434        let config = raw
435            .resolve_config(&shared)
436            .expect("should resolve and validate");
437        assert_eq!(config.client_id, "app");
438        assert_eq!(
439            config.remote.well_known_url.as_deref(),
440            Some("https://auth.example.com/.well-known"),
441        );
442    }
443
444    #[test]
445    fn resolve_config_propagates_validation_failure() {
446        let shared = OidcSharedConfig {
447            client_id: Some("app".to_string()),
448            // No well_known_url and no manual endpoints → validation should fail
449            ..Default::default()
450        };
451        let raw = RawConfig::default();
452
453        let result = raw.resolve_config(&shared);
454        assert!(
455            result.is_err(),
456            "should fail validation without well_known_url or manual endpoints"
457        );
458    }
459}