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