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