Skip to main content

securitydept_oauth_provider/
shared.rs

1use serde::Deserialize;
2use serde_with::{NoneAsEmptyString, serde_as};
3
4use crate::{OAuthProviderRemoteConfig, default_jwks_refresh_interval};
5
6/// Shared OIDC alias configuration block — provider remote fallback skeleton.
7///
8/// When present in the application config (typically `[oidc]`), provides
9/// fallback values for `OAuthProviderRemoteConfig` fields that both
10/// `oidc-client` and `oauth-resource-server` need. Also holds optional
11/// confidential-client defaults (`client_id`, `client_secret`) that are
12/// commonly shared in single-provider deployments with introspection.
13///
14/// # Current scope (supported fields)
15///
16/// - `well_known_url`, `issuer_url`, `jwks_uri` — URL fields with true
17///   presence-aware fallback (local `Some` > shared `Some` > `None`)
18/// - `client_id`, `client_secret` — optional confidential-client defaults; not
19///   pure provider connectivity, but commonly shared between `oidc_client`
20///   (full client) and `oauth_resource_server.introspection`
21/// - `required_scopes` — scopes that MUST appear in token endpoint responses;
22///   presence-aware (`Vec::is_empty` sentinel: local non-empty wins, else
23///   shared)
24///
25/// # Known limitations
26///
27/// Duration fields (`metadata_refresh_interval`, `jwks_refresh_interval`) are
28/// non-optional in `OAuthProviderRemoteConfig` and use serde defaults. The
29/// current implementation uses sentinel heuristics and **cannot distinguish**
30/// "local explicitly set to the default" from "local not configured". A future
31/// iteration should migrate these to `Option<Duration>`.
32///
33/// # Shared but not provider connectivity
34///
35/// `client_id` and `client_secret` can be shared via `[oidc]`, but they must
36/// be resolved separately from `OAuthProviderRemoteConfig`. They are exposed
37/// on this struct as optional fields and resolved through dedicated helpers.
38#[serde_as]
39#[derive(Debug, Clone, Deserialize, Default)]
40pub struct OidcSharedConfig {
41    /// Shared provider connectivity settings (URL + interval fields).
42    #[serde(flatten)]
43    pub remote: OAuthProviderRemoteConfig,
44
45    /// Optional confidential-client default. Not pure provider connectivity;
46    /// shared when both oidc-client and resource-server introspection use the
47    /// same client identity against a single provider.
48    #[serde(default)]
49    #[serde_as(as = "NoneAsEmptyString")]
50    pub client_id: Option<String>,
51
52    /// Optional confidential-client secret default. See `client_id`.
53    #[serde(default)]
54    #[serde_as(as = "NoneAsEmptyString")]
55    pub client_secret: Option<String>,
56
57    /// Shared required-scopes list. Applied when the local client config does
58    /// not specify its own `required_scopes`.
59    #[serde_as(as = "securitydept_utils::ser::CommaOrSpaceSeparated<String>")]
60    #[serde(default)]
61    pub required_scopes: Vec<String>,
62}
63
64impl OidcSharedConfig {
65    /// Resolve a local `OAuthProviderRemoteConfig` against this shared
66    /// fallback. For `Option<String>` URL fields, local `Some` takes
67    /// priority. For duration fields, see the struct-level doc on known
68    /// limitations.
69    pub fn resolve_remote(&self, local: &OAuthProviderRemoteConfig) -> OAuthProviderRemoteConfig {
70        OAuthProviderRemoteConfig {
71            well_known_url: local
72                .well_known_url
73                .clone()
74                .or_else(|| self.remote.well_known_url.clone()),
75            issuer_url: local
76                .issuer_url
77                .clone()
78                .or_else(|| self.remote.issuer_url.clone()),
79            jwks_uri: local
80                .jwks_uri
81                .clone()
82                .or_else(|| self.remote.jwks_uri.clone()),
83            metadata_refresh_interval: if local.metadata_refresh_interval.is_zero() {
84                self.remote.metadata_refresh_interval
85            } else {
86                local.metadata_refresh_interval
87            },
88            jwks_refresh_interval: if local.jwks_refresh_interval == default_jwks_refresh_interval()
89            {
90                self.remote.jwks_refresh_interval
91            } else {
92                local.jwks_refresh_interval
93            },
94        }
95    }
96
97    /// Resolve a local optional `client_id` String against the shared
98    /// `client_id` default.
99    ///
100    /// Returns `local` if it is `Some`; otherwise falls back to the shared
101    /// default. `None` means neither local nor shared has a value.
102    pub fn resolve_client_id(&self, local: Option<&str>) -> Option<String> {
103        local
104            .map(ToOwned::to_owned)
105            .or_else(|| self.client_id.clone())
106    }
107
108    /// Resolve a local optional `client_secret` against the shared default.
109    pub fn resolve_client_secret(&self, local: Option<&str>) -> Option<String> {
110        local
111            .map(ToOwned::to_owned)
112            .or_else(|| self.client_secret.clone())
113    }
114
115    /// Resolve a local `required_scopes` list against the shared default.
116    ///
117    /// Resolution: local non-empty wins; when local is empty the shared list is
118    /// used instead. This allows partial overrides while still using
119    /// `Vec::is_empty` as the "not set" sentinel (no `Option` wrapper needed).
120    pub fn resolve_required_scopes(&self, local: &[String]) -> Vec<String> {
121        if !local.is_empty() {
122            local.to_vec()
123        } else {
124            self.required_scopes.clone()
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use std::time::Duration;
132
133    use super::OidcSharedConfig;
134    use crate::OAuthProviderRemoteConfig;
135
136    // ---------------------------------------------------------------------------
137    // Remote URL fallback tests
138    // ---------------------------------------------------------------------------
139
140    #[test]
141    fn local_url_values_take_priority_over_shared() {
142        let shared = OidcSharedConfig {
143            remote: OAuthProviderRemoteConfig {
144                well_known_url: Some("https://shared.example.com/.well-known".to_string()),
145                issuer_url: Some("https://shared.example.com".to_string()),
146                jwks_uri: Some("https://shared.example.com/jwks".to_string()),
147                ..Default::default()
148            },
149            ..Default::default()
150        };
151        let local = OAuthProviderRemoteConfig {
152            well_known_url: Some("https://local.example.com/.well-known".to_string()),
153            ..Default::default()
154        };
155        let resolved = shared.resolve_remote(&local);
156
157        assert_eq!(
158            resolved.well_known_url.as_deref(),
159            Some("https://local.example.com/.well-known"),
160            "local well_known_url should take priority"
161        );
162        assert_eq!(
163            resolved.issuer_url.as_deref(),
164            Some("https://shared.example.com"),
165            "shared issuer_url should fill the gap"
166        );
167        assert_eq!(
168            resolved.jwks_uri.as_deref(),
169            Some("https://shared.example.com/jwks"),
170            "shared jwks_uri should fill the gap"
171        );
172    }
173
174    #[test]
175    fn empty_shared_returns_local_remote_unchanged() {
176        let shared = OidcSharedConfig::default();
177        let local = OAuthProviderRemoteConfig {
178            well_known_url: Some("https://local.example.com/.well-known".to_string()),
179            ..Default::default()
180        };
181        let resolved = shared.resolve_remote(&local);
182
183        assert_eq!(resolved.well_known_url, local.well_known_url);
184        assert!(resolved.issuer_url.is_none());
185    }
186
187    #[test]
188    fn local_interval_overrides_shared_interval() {
189        let shared = OidcSharedConfig {
190            remote: OAuthProviderRemoteConfig {
191                metadata_refresh_interval: Duration::from_secs(600),
192                ..Default::default()
193            },
194            ..Default::default()
195        };
196        let local = OAuthProviderRemoteConfig {
197            metadata_refresh_interval: Duration::from_secs(120),
198            ..Default::default()
199        };
200        let resolved = shared.resolve_remote(&local);
201
202        assert_eq!(
203            resolved.metadata_refresh_interval,
204            Duration::from_secs(120),
205            "non-zero local interval should take priority"
206        );
207    }
208
209    #[test]
210    fn zero_local_interval_falls_back_to_shared_interval() {
211        let shared = OidcSharedConfig {
212            remote: OAuthProviderRemoteConfig {
213                metadata_refresh_interval: Duration::from_secs(600),
214                ..Default::default()
215            },
216            ..Default::default()
217        };
218        let local = OAuthProviderRemoteConfig {
219            metadata_refresh_interval: Duration::ZERO,
220            ..Default::default()
221        };
222        let resolved = shared.resolve_remote(&local);
223
224        assert_eq!(
225            resolved.metadata_refresh_interval,
226            Duration::from_secs(600),
227            "zero local interval should fall back to shared"
228        );
229    }
230
231    // ---------------------------------------------------------------------------
232    // client_id / client_secret shared-defaults tests
233    // ---------------------------------------------------------------------------
234
235    #[test]
236    fn local_client_id_takes_priority_over_shared() {
237        let shared = OidcSharedConfig {
238            client_id: Some("shared-client".to_string()),
239            ..Default::default()
240        };
241
242        let resolved = shared.resolve_client_id(Some("local-client"));
243        assert_eq!(resolved.as_deref(), Some("local-client"));
244    }
245
246    #[test]
247    fn shared_client_id_fills_gap_when_local_is_absent() {
248        let shared = OidcSharedConfig {
249            client_id: Some("shared-client".to_string()),
250            ..Default::default()
251        };
252
253        let resolved = shared.resolve_client_id(None);
254        assert_eq!(resolved.as_deref(), Some("shared-client"));
255    }
256
257    #[test]
258    fn no_client_id_anywhere_returns_none() {
259        let shared = OidcSharedConfig::default();
260        let resolved = shared.resolve_client_id(None);
261        assert!(resolved.is_none());
262    }
263
264    #[test]
265    fn local_client_secret_takes_priority_over_shared() {
266        let shared = OidcSharedConfig {
267            client_secret: Some("shared-secret".to_string()),
268            ..Default::default()
269        };
270
271        let resolved = shared.resolve_client_secret(Some("local-secret"));
272        assert_eq!(resolved.as_deref(), Some("local-secret"));
273    }
274
275    #[test]
276    fn shared_client_secret_fills_gap_when_local_is_absent() {
277        let shared = OidcSharedConfig {
278            client_secret: Some("shared-secret".to_string()),
279            ..Default::default()
280        };
281
282        let resolved = shared.resolve_client_secret(None);
283        assert_eq!(resolved.as_deref(), Some("shared-secret"));
284    }
285
286    #[test]
287    fn deserialization_of_shared_config_from_flat_json() {
288        // Simulates what a TOML [oidc] block deserialises into when loaded via figment.
289        let json = serde_json::json!({
290            "well_known_url": "https://auth.example.com/.well-known/openid-configuration",
291            "client_id": "shared-app",
292            "client_secret": "s3cr3t"
293        });
294        let config: OidcSharedConfig =
295            serde_json::from_value(json).expect("shared config should deserialize");
296
297        assert_eq!(
298            config.remote.well_known_url.as_deref(),
299            Some("https://auth.example.com/.well-known/openid-configuration")
300        );
301        assert_eq!(config.client_id.as_deref(), Some("shared-app"));
302        assert_eq!(config.client_secret.as_deref(), Some("s3cr3t"));
303    }
304}