Skip to main content

securitydept_oauth_resource_server/config/
mod.rs

1pub mod introspection;
2#[cfg(feature = "jwe")]
3pub mod jwe;
4
5use std::time::Duration;
6
7pub use introspection::OAuthResourceServerIntrospectionConfig;
8#[cfg(feature = "jwe")]
9pub use jwe::OAuthResourceServerJweConfig;
10use securitydept_oauth_provider::{
11    OAuthProviderConfig, OAuthProviderOidcConfig, OAuthProviderRemoteConfig, OidcSharedConfig,
12};
13use securitydept_utils::ser::CommaOrSpaceSeparated;
14use serde::Deserialize;
15use serde_with::{PickFirst, serde_as};
16
17use crate::{OAuthResourceServerError, OAuthResourceServerResult};
18
19#[serde_as]
20#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
21#[derive(Debug, Clone, Deserialize)]
22pub struct OAuthResourceServerConfig {
23    /// Shared remote-provider connectivity settings.
24    #[serde(flatten)]
25    pub remote: OAuthProviderRemoteConfig,
26    /// Accepted `aud` values. Empty means audience validation is disabled.
27    #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
28    #[serde(default)]
29    #[cfg_attr(
30        feature = "config-schema",
31        schemars(with = "securitydept_utils::schema::StringOrVecString")
32    )]
33    pub audiences: Vec<String>,
34    /// Required scopes. Empty means no scope requirement is enforced.
35    #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
36    #[serde(default)]
37    #[cfg_attr(
38        feature = "config-schema",
39        schemars(with = "securitydept_utils::schema::StringOrVecString")
40    )]
41    pub required_scopes: Vec<String>,
42    /// Allowed clock skew when validating `exp` and `nbf`.
43    #[serde(default = "default_clock_skew", with = "humantime_serde")]
44    #[cfg_attr(feature = "config-schema", schemars(with = "String"))]
45    pub clock_skew: Duration,
46    /// Optional opaque-token introspection configuration.
47    ///
48    /// Example TOML:
49    /// ```toml
50    /// [oauth_resource_server]
51    /// well_known_url = "https://issuer.example.com/.well-known/openid-configuration"
52    /// audiences = ["api://securitydept"]
53    /// required_scopes = ["entries.read", "entries.write"]
54    ///
55    /// [oauth_resource_server.introspection]
56    /// client_id = "resource-server"
57    /// client_secret = "secret"
58    /// token_type_hint = "access_token"
59    /// # optional override:
60    /// # introspection_url = "https://issuer.example.com/oauth2/introspect"
61    /// ```
62    #[serde(default)]
63    pub introspection: Option<OAuthResourceServerIntrospectionConfig>,
64    #[cfg(feature = "jwe")]
65    /// Optional JWE resource-server configuration.
66    ///
67    /// Example TOML:
68    /// ```toml
69    /// [oauth_resource_server.jwe]
70    /// jwe_jwks_path = "config/jwe-private.jwks"
71    /// # or jwe_jwk_path = "config/jwe-private.jwk"
72    /// # or jwe_pem_path = "config/jwe-private.pem"
73    /// watch_interval = "30s"
74    /// jwe_pem_key_id = "enc-key-1"
75    /// jwe_pem_algorithm = "RSA-OAEP-256"
76    /// jwe_pem_key_use = "enc"
77    /// ```
78    #[serde(default)]
79    pub jwe: Option<OAuthResourceServerJweConfig>,
80}
81
82impl OAuthResourceServerConfig {
83    pub fn validate(&self) -> OAuthResourceServerResult<()> {
84        self.remote.validate()?;
85
86        if let Some(introspection) = self.introspection.as_ref()
87            && introspection
88                .client_id
89                .as_deref()
90                .is_none_or(|value| value.trim().is_empty())
91        {
92            return Err(OAuthResourceServerError::InvalidConfig {
93                message: "introspection.client_id must be set when introspection is enabled"
94                    .to_string(),
95            });
96        }
97
98        if let Some(introspection) = self.introspection.as_ref()
99            && self.remote.well_known_url.is_none()
100            && introspection
101                .introspection_url
102                .as_deref()
103                .is_none_or(|value| value.trim().is_empty())
104        {
105            return Err(OAuthResourceServerError::InvalidConfig {
106                message: "introspection.introspection_url must be set when introspection is \
107                          enabled without well_known_url discovery"
108                    .to_string(),
109            });
110        }
111
112        Ok(())
113    }
114
115    /// Apply shared defaults from an `[oidc]` block in-place.
116    ///
117    /// Resolution order for supported fields:
118    /// - `well_known_url`, `issuer_url`, `jwks_uri` — local > shared > None
119    /// - `introspection.client_id`, `introspection.client_secret` — local >
120    ///   shared > None (only when `introspection` is already `Some`)
121    /// - `required_scopes` — local non-empty wins; else inherited from shared
122    ///
123    /// Duration fields are resolved with sentinel heuristics; see
124    /// [`OidcSharedConfig`] for the known limitation.
125    pub fn apply_shared_defaults(&mut self, shared: &OidcSharedConfig) {
126        self.remote = shared.resolve_remote(&self.remote);
127
128        // Inherit required_scopes from [oidc] when local list is empty.
129        if self.required_scopes.is_empty() {
130            self.required_scopes = shared.required_scopes.clone();
131        }
132
133        // Apply shared credential defaults into the introspection block when
134        // present so that a single confidential client identity can serve both
135        // oidc-client and introspection without repeating the secret.
136        if let Some(introspection) = self.introspection.as_mut() {
137            if introspection.client_id.is_none() {
138                introspection.client_id = shared.resolve_client_id(None);
139            }
140            if introspection.client_secret.is_none() {
141                introspection.client_secret = shared.resolve_client_secret(None);
142            }
143        }
144    }
145
146    /// **Recommended entry point.** Apply shared defaults and validate in one
147    /// step.
148    ///
149    /// Equivalent to `self.apply_shared_defaults(shared); self.validate()`
150    /// but eliminates manual glue.
151    ///
152    /// ```text
153    /// [oidc]                      ──┐
154    ///                               ├──▸ resolve_config() ──▸ validated &mut self
155    /// [oauth_resource_server]     ──┘
156    /// ```
157    pub fn resolve_config(&mut self, shared: &OidcSharedConfig) -> OAuthResourceServerResult<()> {
158        self.apply_shared_defaults(shared);
159        self.validate()
160    }
161
162    pub fn provider_config(&self) -> OAuthProviderConfig {
163        OAuthProviderConfig {
164            remote: self.remote.clone(),
165            oidc: OAuthProviderOidcConfig {
166                introspection_endpoint: self
167                    .introspection
168                    .as_ref()
169                    .and_then(|value| value.introspection_url.clone()),
170                ..Default::default()
171            },
172        }
173    }
174}
175
176impl Default for OAuthResourceServerConfig {
177    fn default() -> Self {
178        Self {
179            remote: OAuthProviderRemoteConfig::default(),
180            audiences: Vec::new(),
181            required_scopes: Vec::new(),
182            clock_skew: default_clock_skew(),
183            introspection: None,
184            #[cfg(feature = "jwe")]
185            jwe: None,
186        }
187    }
188}
189
190fn default_clock_skew() -> Duration {
191    Duration::from_secs(60)
192}
193
194#[cfg(test)]
195mod tests {
196    use securitydept_oauth_provider::{OAuthProviderRemoteConfig, OidcSharedConfig};
197    use securitydept_utils::secret::SecretString;
198
199    #[cfg(feature = "jwe")]
200    use super::OAuthResourceServerJweConfig;
201    use super::{OAuthResourceServerConfig, OAuthResourceServerIntrospectionConfig};
202
203    #[test]
204    fn validate_accepts_well_known_only() {
205        let config = OAuthResourceServerConfig {
206            remote: OAuthProviderRemoteConfig {
207                well_known_url: Some(
208                    "https://issuer.example.com/.well-known/openid-configuration".to_string(),
209                ),
210                ..Default::default()
211            },
212            ..Default::default()
213        };
214
215        assert!(config.validate().is_ok());
216    }
217
218    #[test]
219    fn validate_rejects_missing_manual_fields() {
220        let config = OAuthResourceServerConfig::default();
221
222        assert!(config.validate().is_err());
223    }
224
225    #[test]
226    fn validate_rejects_introspection_without_client_id() {
227        let config = OAuthResourceServerConfig {
228            remote: OAuthProviderRemoteConfig {
229                well_known_url: Some(
230                    "https://issuer.example.com/.well-known/openid-configuration".to_string(),
231                ),
232                ..Default::default()
233            },
234            introspection: Some(OAuthResourceServerIntrospectionConfig::default()),
235            ..Default::default()
236        };
237
238        assert!(config.validate().is_err());
239    }
240
241    #[test]
242    fn validate_rejects_manual_introspection_without_endpoint() {
243        let config = OAuthResourceServerConfig {
244            remote: OAuthProviderRemoteConfig {
245                issuer_url: Some("https://issuer.example.com".to_string()),
246                jwks_uri: Some("https://issuer.example.com/jwks".to_string()),
247                ..Default::default()
248            },
249            introspection: Some(OAuthResourceServerIntrospectionConfig {
250                client_id: Some("resource-server".to_string()),
251                ..Default::default()
252            }),
253            ..Default::default()
254        };
255
256        assert!(config.validate().is_err());
257    }
258
259    #[test]
260    fn validate_accepts_discovered_introspection_without_endpoint_override() {
261        let config = OAuthResourceServerConfig {
262            remote: OAuthProviderRemoteConfig {
263                well_known_url: Some(
264                    "https://issuer.example.com/.well-known/openid-configuration".to_string(),
265                ),
266                ..Default::default()
267            },
268            introspection: Some(OAuthResourceServerIntrospectionConfig {
269                client_id: Some("resource-server".to_string()),
270                ..Default::default()
271            }),
272            ..Default::default()
273        };
274
275        assert!(config.validate().is_ok());
276    }
277
278    #[cfg(feature = "jwe")]
279    #[test]
280    fn validate_accepts_manual_jwe_jwks_path() {
281        let config = OAuthResourceServerConfig {
282            remote: OAuthProviderRemoteConfig {
283                issuer_url: Some("https://issuer.example.com".to_string()),
284                jwks_uri: Some("https://issuer.example.com/jwks".to_string()),
285                ..Default::default()
286            },
287            jwe: Some(OAuthResourceServerJweConfig {
288                jwe_jwks_path: Some("data/jwe-private.jwks".to_string()),
289                ..Default::default()
290            }),
291            ..Default::default()
292        };
293
294        assert!(config.validate().is_ok());
295    }
296
297    // ---------------------------------------------------------------------------
298    // Shared-defaults resolution tests
299    // ---------------------------------------------------------------------------
300
301    #[test]
302    fn apply_shared_defaults_inherits_well_known_url_from_oidc_block() {
303        let shared = OidcSharedConfig {
304            remote: OAuthProviderRemoteConfig {
305                well_known_url: Some(
306                    "https://auth.example.com/.well-known/openid-configuration".to_string(),
307                ),
308                ..Default::default()
309            },
310            ..Default::default()
311        };
312
313        let mut config = OAuthResourceServerConfig::default();
314        config.apply_shared_defaults(&shared);
315
316        assert_eq!(
317            config.remote.well_known_url.as_deref(),
318            Some("https://auth.example.com/.well-known/openid-configuration"),
319            "well_known_url should be inherited from [oidc]"
320        );
321    }
322
323    #[test]
324    fn local_well_known_url_takes_priority_over_shared() {
325        let shared = OidcSharedConfig {
326            remote: OAuthProviderRemoteConfig {
327                well_known_url: Some("https://shared.example.com/.well-known".to_string()),
328                ..Default::default()
329            },
330            ..Default::default()
331        };
332
333        let mut config = OAuthResourceServerConfig {
334            remote: OAuthProviderRemoteConfig {
335                well_known_url: Some("https://local.example.com/.well-known".to_string()),
336                ..Default::default()
337            },
338            ..Default::default()
339        };
340        config.apply_shared_defaults(&shared);
341
342        assert_eq!(
343            config.remote.well_known_url.as_deref(),
344            Some("https://local.example.com/.well-known"),
345            "local well_known_url should take priority over shared"
346        );
347    }
348
349    #[test]
350    fn apply_shared_defaults_fills_introspection_client_id_from_oidc_block() {
351        let shared = OidcSharedConfig {
352            remote: OAuthProviderRemoteConfig {
353                well_known_url: Some(
354                    "https://auth.example.com/.well-known/openid-configuration".to_string(),
355                ),
356                ..Default::default()
357            },
358            client_id: Some("shared-app".to_string()),
359            client_secret: Some(SecretString::from("shared-secret")),
360            ..Default::default()
361        };
362
363        let mut config = OAuthResourceServerConfig {
364            introspection: Some(OAuthResourceServerIntrospectionConfig::default()),
365            ..Default::default()
366        };
367        config.apply_shared_defaults(&shared);
368
369        let introspection = config.introspection.as_ref().unwrap();
370        assert_eq!(
371            introspection.client_id.as_deref(),
372            Some("shared-app"),
373            "introspection.client_id should be inherited from [oidc]"
374        );
375        assert_eq!(
376            introspection
377                .client_secret
378                .as_ref()
379                .map(SecretString::expose_secret),
380            Some("shared-secret"),
381            "introspection.client_secret should be inherited from [oidc]"
382        );
383        // validate() should now pass
384        assert!(
385            config.validate().is_ok(),
386            "config should be valid after shared defaults applied"
387        );
388    }
389
390    #[test]
391    fn local_introspection_client_id_not_overwritten_by_shared() {
392        let shared = OidcSharedConfig {
393            client_id: Some("shared-app".to_string()),
394            ..Default::default()
395        };
396
397        let mut config = OAuthResourceServerConfig {
398            remote: OAuthProviderRemoteConfig {
399                well_known_url: Some(
400                    "https://auth.example.com/.well-known/openid-configuration".to_string(),
401                ),
402                ..Default::default()
403            },
404            introspection: Some(OAuthResourceServerIntrospectionConfig {
405                client_id: Some("local-rs".to_string()),
406                ..Default::default()
407            }),
408            ..Default::default()
409        };
410        config.apply_shared_defaults(&shared);
411
412        assert_eq!(
413            config.introspection.unwrap().client_id.as_deref(),
414            Some("local-rs"),
415            "local introspection.client_id must take priority over shared"
416        );
417    }
418
419    #[test]
420    fn shared_defaults_not_applied_when_no_introspection_block() {
421        let shared = OidcSharedConfig {
422            client_id: Some("shared-app".to_string()),
423            remote: OAuthProviderRemoteConfig {
424                well_known_url: Some(
425                    "https://auth.example.com/.well-known/openid-configuration".to_string(),
426                ),
427                ..Default::default()
428            },
429            ..Default::default()
430        };
431
432        let mut config = OAuthResourceServerConfig::default();
433        config.apply_shared_defaults(&shared);
434
435        // No introspection block — should not create one from shared defaults
436        assert!(
437            config.introspection.is_none(),
438            "should not create introspection block from shared defaults alone"
439        );
440    }
441
442    #[test]
443    fn shared_required_scopes_inherited_when_local_is_empty() {
444        let shared = OidcSharedConfig {
445            remote: OAuthProviderRemoteConfig {
446                well_known_url: Some(
447                    "https://auth.example.com/.well-known/openid-configuration".to_string(),
448                ),
449                ..Default::default()
450            },
451            required_scopes: vec!["openid".to_string(), "read:data".to_string()],
452            ..Default::default()
453        };
454
455        let mut config = OAuthResourceServerConfig::default();
456        config.apply_shared_defaults(&shared);
457
458        assert_eq!(
459            config.required_scopes,
460            vec!["openid".to_string(), "read:data".to_string()],
461            "required_scopes should be inherited from [oidc]"
462        );
463    }
464
465    #[test]
466    fn local_required_scopes_win_over_shared() {
467        let shared = OidcSharedConfig {
468            remote: OAuthProviderRemoteConfig {
469                well_known_url: Some(
470                    "https://auth.example.com/.well-known/openid-configuration".to_string(),
471                ),
472                ..Default::default()
473            },
474            required_scopes: vec!["openid".to_string()],
475            ..Default::default()
476        };
477
478        let mut config = OAuthResourceServerConfig {
479            required_scopes: vec!["entries.read".to_string(), "entries.write".to_string()],
480            ..Default::default()
481        };
482        config.apply_shared_defaults(&shared);
483
484        assert_eq!(
485            config.required_scopes,
486            vec!["entries.read".to_string(), "entries.write".to_string()],
487            "local required_scopes must take priority over shared"
488        );
489    }
490
491    // ---------------------------------------------------------------------------
492    // resolve_config (unified entry) tests
493    // ---------------------------------------------------------------------------
494
495    #[test]
496    fn resolve_config_applies_defaults_and_validates_in_one_step() {
497        let shared = OidcSharedConfig {
498            remote: OAuthProviderRemoteConfig {
499                well_known_url: Some(
500                    "https://auth.example.com/.well-known/openid-configuration".to_string(),
501                ),
502                ..Default::default()
503            },
504            client_id: Some("shared-app".to_string()),
505            client_secret: Some(SecretString::from("shared-secret")),
506            ..Default::default()
507        };
508
509        let mut config = OAuthResourceServerConfig {
510            introspection: Some(OAuthResourceServerIntrospectionConfig::default()),
511            ..Default::default()
512        };
513
514        config
515            .resolve_config(&shared)
516            .expect("should resolve and validate");
517
518        assert_eq!(
519            config.remote.well_known_url.as_deref(),
520            Some("https://auth.example.com/.well-known/openid-configuration"),
521        );
522        assert_eq!(
523            config.introspection.as_ref().unwrap().client_id.as_deref(),
524            Some("shared-app"),
525        );
526    }
527
528    #[test]
529    fn resolve_config_propagates_validation_error() {
530        let shared = OidcSharedConfig::default(); // no well_known_url
531        let mut config = OAuthResourceServerConfig::default(); // no manual fields
532
533        let result = config.resolve_config(&shared);
534        assert!(result.is_err(), "should fail validation");
535    }
536}