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#[derive(Debug, Clone, Deserialize)]
21pub struct OAuthResourceServerConfig {
22 #[serde(flatten)]
24 pub remote: OAuthProviderRemoteConfig,
25 #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
27 #[serde(default)]
28 pub audiences: Vec<String>,
29 #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
31 #[serde(default)]
32 pub required_scopes: Vec<String>,
33 #[serde(default = "default_clock_skew", with = "humantime_serde")]
35 pub clock_skew: Duration,
36 #[serde(default)]
53 pub introspection: Option<OAuthResourceServerIntrospectionConfig>,
54 #[cfg(feature = "jwe")]
55 #[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 pub fn apply_shared_defaults(&mut self, shared: &OidcSharedConfig) {
116 self.remote = shared.resolve_remote(&self.remote);
117
118 if self.required_scopes.is_empty() {
120 self.required_scopes = shared.required_scopes.clone();
121 }
122
123 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 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 #[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 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 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 #[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(); let mut config = OAuthResourceServerConfig::default(); let result = config.resolve_config(&shared);
520 assert!(result.is_err(), "should fail validation");
521 }
522}