securitydept_oauth_resource_server/config/
mod.rs1pub 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 #[serde(flatten)]
25 pub remote: OAuthProviderRemoteConfig,
26 #[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 #[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 #[serde(default = "default_clock_skew", with = "humantime_serde")]
44 #[cfg_attr(feature = "config-schema", schemars(with = "String"))]
45 pub clock_skew: Duration,
46 #[serde(default)]
63 pub introspection: Option<OAuthResourceServerIntrospectionConfig>,
64 #[cfg(feature = "jwe")]
65 #[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 pub fn apply_shared_defaults(&mut self, shared: &OidcSharedConfig) {
126 self.remote = shared.resolve_remote(&self.remote);
127
128 if self.required_scopes.is_empty() {
130 self.required_scopes = shared.required_scopes.clone();
131 }
132
133 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 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 #[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 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 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 #[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(); let mut config = OAuthResourceServerConfig::default(); let result = config.resolve_config(&shared);
534 assert!(result.is_err(), "should fail validation");
535 }
536}