1use std::time::Duration;
2
3use openidconnect::core::{CoreClientAuthMethod, CoreJwsSigningAlgorithm};
4use securitydept_oauth_provider::{OAuthProviderOidcConfig, OAuthProviderRemoteConfig};
5use securitydept_oidc_client::{
6 OidcClientRawConfig, PendingOauthStoreConfig,
7 config::{default_device_poll_interval, default_scopes},
8};
9use securitydept_utils::ser::CommaOrSpaceSeparated;
10use serde::Deserialize;
11use serde_with::{NoneAsEmptyString, PickFirst, serde_as};
12
13use crate::{
14 backend_oidc_mode::{
15 BackendOidcModeConfig, MetadataDelivery, PendingAuthStateMetadataRedemptionConfig,
16 PostAuthRedirectPolicy, RefreshMaterialProtection,
17 },
18 frontend_oidc_mode::{
19 FrontendOidcModeCapabilities, FrontendOidcModeConfig, NoPendingStoreConfig,
20 UnsafeFrontendClientSecret,
21 },
22};
23
24#[serde_as]
25#[derive(Debug, Clone, Deserialize)]
26pub struct TokenSetOidcSharedIntersectionConfig {
27 #[serde(default)]
28 #[serde_as(as = "NoneAsEmptyString")]
29 pub client_id: Option<String>,
30
31 #[serde(default)]
32 #[serde_as(as = "NoneAsEmptyString")]
33 pub client_secret: Option<String>,
34
35 #[serde(default, flatten)]
36 pub remote: OAuthProviderRemoteConfig,
37
38 #[serde(default, flatten)]
39 pub provider_oidc: OAuthProviderOidcConfig,
40
41 #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
42 #[serde(default = "default_scopes")]
43 pub scopes: Vec<String>,
44
45 #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
46 #[serde(default)]
47 pub required_scopes: Vec<String>,
48
49 #[serde(default)]
50 pub claims_check_script: Option<String>,
51
52 #[serde(default)]
53 pub pkce_enabled: bool,
54
55 #[serde(default)]
56 pub redirect_url: Option<String>,
57
58 #[serde(default = "default_device_poll_interval", with = "humantime_serde")]
59 pub device_poll_interval: Duration,
60}
61
62impl Default for TokenSetOidcSharedIntersectionConfig {
63 fn default() -> Self {
64 Self {
65 client_id: None,
66 client_secret: None,
67 remote: OAuthProviderRemoteConfig::default(),
68 provider_oidc: OAuthProviderOidcConfig::default(),
69 scopes: default_scopes(),
70 required_scopes: vec![],
71 claims_check_script: None,
72 pkce_enabled: false,
73 redirect_url: None,
74 device_poll_interval: default_device_poll_interval(),
75 }
76 }
77}
78
79impl TokenSetOidcSharedIntersectionConfig {
80 fn apply_override(
81 &self,
82 override_config: &OptionalTokenSetOidcSharedIntersectionConfig,
83 ) -> Self {
84 Self {
85 client_id: override_config
86 .client_id
87 .clone()
88 .or_else(|| self.client_id.clone()),
89 client_secret: override_config
90 .client_secret
91 .clone()
92 .or_else(|| self.client_secret.clone()),
93 remote: override_config.remote.apply_to(&self.remote),
94 provider_oidc: override_config.provider_oidc.apply_to(&self.provider_oidc),
95 scopes: override_config
96 .scopes
97 .clone()
98 .unwrap_or_else(|| self.scopes.clone()),
99 required_scopes: override_config
100 .required_scopes
101 .clone()
102 .unwrap_or_else(|| self.required_scopes.clone()),
103 claims_check_script: override_config
104 .claims_check_script
105 .clone()
106 .or_else(|| self.claims_check_script.clone()),
107 pkce_enabled: override_config.pkce_enabled.unwrap_or(self.pkce_enabled),
108 redirect_url: override_config
109 .redirect_url
110 .clone()
111 .or_else(|| self.redirect_url.clone()),
112 device_poll_interval: override_config
113 .device_poll_interval
114 .unwrap_or(self.device_poll_interval),
115 }
116 }
117
118 fn into_backend_raw_config<PC>(self, pending_store: Option<PC>) -> OidcClientRawConfig<PC>
119 where
120 PC: PendingOauthStoreConfig,
121 {
122 OidcClientRawConfig {
123 client_id: self.client_id,
124 client_secret: self.client_secret,
125 remote: self.remote,
126 provider_oidc: self.provider_oidc,
127 scopes: self.scopes,
128 required_scopes: self.required_scopes,
129 claims_check_script: self.claims_check_script,
130 pkce_enabled: self.pkce_enabled,
131 redirect_url: self.redirect_url,
132 pending_store,
133 device_poll_interval: self.device_poll_interval,
134 }
135 }
136
137 fn into_frontend_raw_config(self) -> OidcClientRawConfig<NoPendingStoreConfig> {
138 OidcClientRawConfig {
139 client_id: self.client_id,
140 client_secret: self.client_secret,
141 remote: self.remote,
142 provider_oidc: self.provider_oidc,
143 scopes: self.scopes,
144 required_scopes: self.required_scopes,
145 claims_check_script: self.claims_check_script,
146 pkce_enabled: self.pkce_enabled,
147 redirect_url: self.redirect_url,
148 pending_store: None,
149 device_poll_interval: self.device_poll_interval,
150 }
151 }
152}
153
154impl<PC> From<&OidcClientRawConfig<PC>> for TokenSetOidcSharedIntersectionConfig
155where
156 PC: PendingOauthStoreConfig,
157{
158 fn from(value: &OidcClientRawConfig<PC>) -> Self {
159 Self {
160 client_id: value.client_id.clone(),
161 client_secret: value.client_secret.clone(),
162 remote: value.remote.clone(),
163 provider_oidc: value.provider_oidc.clone(),
164 scopes: value.scopes.clone(),
165 required_scopes: value.required_scopes.clone(),
166 claims_check_script: value.claims_check_script.clone(),
167 pkce_enabled: value.pkce_enabled,
168 redirect_url: value.redirect_url.clone(),
169 device_poll_interval: value.device_poll_interval,
170 }
171 }
172}
173
174#[derive(Debug, Clone, Deserialize, Default)]
175pub struct TokenSetOidcSharedUnionConfig<PC, MC>
176where
177 PC: PendingOauthStoreConfig,
178 MC: PendingAuthStateMetadataRedemptionConfig,
179{
180 #[serde(default, flatten)]
181 pub oidc_client: TokenSetOidcSharedIntersectionConfig,
182
183 #[serde(default, bound = "PC: PendingOauthStoreConfig")]
184 pub pending_store: Option<PC>,
185
186 #[serde(default, bound(deserialize = ""))]
187 pub refresh_material_protection: RefreshMaterialProtection,
188
189 #[serde(
190 default,
191 bound(deserialize = "MC: PendingAuthStateMetadataRedemptionConfig")
192 )]
193 pub metadata_delivery: MetadataDelivery<MC>,
194
195 #[serde(default)]
196 pub post_auth_redirect: PostAuthRedirectPolicy,
197
198 #[serde(default, flatten)]
199 pub frontend_capabilities: FrontendOidcModeCapabilities,
200}
201
202impl<PC, MC> TokenSetOidcSharedUnionConfig<PC, MC>
203where
204 PC: PendingOauthStoreConfig,
205 MC: PendingAuthStateMetadataRedemptionConfig,
206{
207 pub fn compose_backend_config(
208 &self,
209 override_config: &BackendOidcModeOverrideConfig<PC, MC>,
210 ) -> BackendOidcModeConfig<PC, MC>
211 where
212 PC: Clone,
213 MC: Clone,
214 {
215 let oidc_client = self
216 .oidc_client
217 .apply_override(&override_config.oidc_client)
218 .into_backend_raw_config(
219 override_config
220 .pending_store
221 .clone()
222 .or_else(|| self.pending_store.clone()),
223 );
224
225 BackendOidcModeConfig {
226 oidc_client,
227 refresh_material_protection: override_config
228 .refresh_material_protection
229 .clone()
230 .unwrap_or_else(|| self.refresh_material_protection.clone()),
231 metadata_delivery: override_config
232 .metadata_delivery
233 .clone()
234 .unwrap_or_else(|| self.metadata_delivery.clone()),
235 post_auth_redirect: override_config
236 .post_auth_redirect
237 .clone()
238 .unwrap_or_else(|| self.post_auth_redirect.clone()),
239 }
240 }
241
242 pub fn compose_frontend_config(
243 &self,
244 override_config: &FrontendOidcModeOverrideConfig,
245 ) -> FrontendOidcModeConfig {
246 FrontendOidcModeConfig {
247 oidc_client: self
248 .oidc_client
249 .apply_override(&override_config.oidc_client)
250 .into_frontend_raw_config(),
251 capabilities: FrontendOidcModeCapabilities {
252 unsafe_frontend_client_secret: override_config
253 .unsafe_frontend_client_secret
254 .unwrap_or(self.frontend_capabilities.unsafe_frontend_client_secret),
255 },
256 }
257 }
258}
259
260impl<PC, MC> From<&BackendOidcModeConfig<PC, MC>> for TokenSetOidcSharedUnionConfig<PC, MC>
261where
262 PC: PendingOauthStoreConfig,
263 MC: PendingAuthStateMetadataRedemptionConfig,
264{
265 fn from(value: &BackendOidcModeConfig<PC, MC>) -> Self {
266 Self {
267 oidc_client: TokenSetOidcSharedIntersectionConfig::from(&value.oidc_client),
268 pending_store: value.oidc_client.pending_store.clone(),
269 refresh_material_protection: value.refresh_material_protection.clone(),
270 metadata_delivery: value.metadata_delivery.clone(),
271 post_auth_redirect: value.post_auth_redirect.clone(),
272 frontend_capabilities: FrontendOidcModeCapabilities::default(),
273 }
274 }
275}
276
277#[serde_as]
278#[derive(Debug, Clone, Deserialize, Default)]
279pub struct OptionalOAuthProviderRemoteConfig {
280 #[serde(default)]
281 #[serde_as(as = "NoneAsEmptyString")]
282 pub well_known_url: Option<String>,
283
284 #[serde(default)]
285 #[serde_as(as = "NoneAsEmptyString")]
286 pub issuer_url: Option<String>,
287
288 #[serde(default)]
289 #[serde_as(as = "NoneAsEmptyString")]
290 pub jwks_uri: Option<String>,
291
292 #[serde(default, with = "humantime_serde")]
293 pub metadata_refresh_interval: Option<Duration>,
294
295 #[serde(default, with = "humantime_serde")]
296 pub jwks_refresh_interval: Option<Duration>,
297}
298
299impl OptionalOAuthProviderRemoteConfig {
300 fn apply_to(&self, base: &OAuthProviderRemoteConfig) -> OAuthProviderRemoteConfig {
301 OAuthProviderRemoteConfig {
302 well_known_url: self
303 .well_known_url
304 .clone()
305 .or_else(|| base.well_known_url.clone()),
306 issuer_url: self.issuer_url.clone().or_else(|| base.issuer_url.clone()),
307 jwks_uri: self.jwks_uri.clone().or_else(|| base.jwks_uri.clone()),
308 metadata_refresh_interval: self
309 .metadata_refresh_interval
310 .unwrap_or(base.metadata_refresh_interval),
311 jwks_refresh_interval: self
312 .jwks_refresh_interval
313 .unwrap_or(base.jwks_refresh_interval),
314 }
315 }
316}
317
318impl From<&OAuthProviderRemoteConfig> for OptionalOAuthProviderRemoteConfig {
319 fn from(value: &OAuthProviderRemoteConfig) -> Self {
320 Self {
321 well_known_url: value.well_known_url.clone(),
322 issuer_url: value.issuer_url.clone(),
323 jwks_uri: value.jwks_uri.clone(),
324 metadata_refresh_interval: Some(value.metadata_refresh_interval),
325 jwks_refresh_interval: Some(value.jwks_refresh_interval),
326 }
327 }
328}
329
330#[serde_as]
331#[derive(Debug, Clone, Deserialize, Default)]
332pub struct OptionalOAuthProviderOidcConfig {
333 #[serde(default)]
334 #[serde_as(as = "NoneAsEmptyString")]
335 pub authorization_endpoint: Option<String>,
336
337 #[serde(default)]
338 #[serde_as(as = "NoneAsEmptyString")]
339 pub token_endpoint: Option<String>,
340
341 #[serde(default)]
342 #[serde_as(as = "NoneAsEmptyString")]
343 pub userinfo_endpoint: Option<String>,
344
345 #[serde(default)]
346 #[serde_as(as = "NoneAsEmptyString")]
347 pub introspection_endpoint: Option<String>,
348
349 #[serde(default)]
350 #[serde_as(as = "NoneAsEmptyString")]
351 pub revocation_endpoint: Option<String>,
352
353 #[serde(default)]
354 #[serde_as(as = "NoneAsEmptyString")]
355 pub device_authorization_endpoint: Option<String>,
356
357 #[serde_as(as = "Option<PickFirst<(CommaOrSpaceSeparated<CoreClientAuthMethod>, _)>>")]
358 #[serde(default)]
359 pub token_endpoint_auth_methods_supported: Option<Vec<CoreClientAuthMethod>>,
360
361 #[serde_as(as = "Option<PickFirst<(CommaOrSpaceSeparated<CoreJwsSigningAlgorithm>, _)>>")]
362 #[serde(default)]
363 pub id_token_signing_alg_values_supported: Option<Vec<CoreJwsSigningAlgorithm>>,
364
365 #[serde_as(as = "Option<PickFirst<(CommaOrSpaceSeparated<CoreJwsSigningAlgorithm>, _)>>")]
366 #[serde(default)]
367 pub userinfo_signing_alg_values_supported: Option<Vec<CoreJwsSigningAlgorithm>>,
368}
369
370impl OptionalOAuthProviderOidcConfig {
371 fn apply_to(&self, base: &OAuthProviderOidcConfig) -> OAuthProviderOidcConfig {
372 OAuthProviderOidcConfig {
373 authorization_endpoint: self
374 .authorization_endpoint
375 .clone()
376 .or_else(|| base.authorization_endpoint.clone()),
377 token_endpoint: self
378 .token_endpoint
379 .clone()
380 .or_else(|| base.token_endpoint.clone()),
381 userinfo_endpoint: self
382 .userinfo_endpoint
383 .clone()
384 .or_else(|| base.userinfo_endpoint.clone()),
385 introspection_endpoint: self
386 .introspection_endpoint
387 .clone()
388 .or_else(|| base.introspection_endpoint.clone()),
389 revocation_endpoint: self
390 .revocation_endpoint
391 .clone()
392 .or_else(|| base.revocation_endpoint.clone()),
393 device_authorization_endpoint: self
394 .device_authorization_endpoint
395 .clone()
396 .or_else(|| base.device_authorization_endpoint.clone()),
397 token_endpoint_auth_methods_supported: self
398 .token_endpoint_auth_methods_supported
399 .clone()
400 .or_else(|| base.token_endpoint_auth_methods_supported.clone()),
401 id_token_signing_alg_values_supported: self
402 .id_token_signing_alg_values_supported
403 .clone()
404 .or_else(|| base.id_token_signing_alg_values_supported.clone()),
405 userinfo_signing_alg_values_supported: self
406 .userinfo_signing_alg_values_supported
407 .clone()
408 .or_else(|| base.userinfo_signing_alg_values_supported.clone()),
409 }
410 }
411}
412
413impl From<&OAuthProviderOidcConfig> for OptionalOAuthProviderOidcConfig {
414 fn from(value: &OAuthProviderOidcConfig) -> Self {
415 Self {
416 authorization_endpoint: value.authorization_endpoint.clone(),
417 token_endpoint: value.token_endpoint.clone(),
418 userinfo_endpoint: value.userinfo_endpoint.clone(),
419 introspection_endpoint: value.introspection_endpoint.clone(),
420 revocation_endpoint: value.revocation_endpoint.clone(),
421 device_authorization_endpoint: value.device_authorization_endpoint.clone(),
422 token_endpoint_auth_methods_supported: value
423 .token_endpoint_auth_methods_supported
424 .clone(),
425 id_token_signing_alg_values_supported: value
426 .id_token_signing_alg_values_supported
427 .clone(),
428 userinfo_signing_alg_values_supported: value
429 .userinfo_signing_alg_values_supported
430 .clone(),
431 }
432 }
433}
434
435#[serde_as]
436#[derive(Debug, Clone, Deserialize, Default)]
437pub struct OptionalTokenSetOidcSharedIntersectionConfig {
438 #[serde(default)]
439 #[serde_as(as = "NoneAsEmptyString")]
440 pub client_id: Option<String>,
441
442 #[serde(default)]
443 #[serde_as(as = "NoneAsEmptyString")]
444 pub client_secret: Option<String>,
445
446 #[serde(default, flatten)]
447 pub remote: OptionalOAuthProviderRemoteConfig,
448
449 #[serde(default, flatten)]
450 pub provider_oidc: OptionalOAuthProviderOidcConfig,
451
452 #[serde_as(as = "Option<PickFirst<(CommaOrSpaceSeparated<String>, _)>>")]
453 #[serde(default)]
454 pub scopes: Option<Vec<String>>,
455
456 #[serde_as(as = "Option<PickFirst<(CommaOrSpaceSeparated<String>, _)>>")]
457 #[serde(default)]
458 pub required_scopes: Option<Vec<String>>,
459
460 #[serde(default)]
461 pub claims_check_script: Option<String>,
462
463 #[serde(default)]
464 pub pkce_enabled: Option<bool>,
465
466 #[serde(default)]
467 pub redirect_url: Option<String>,
468
469 #[serde(default, with = "humantime_serde")]
470 pub device_poll_interval: Option<Duration>,
471}
472
473impl<PC> From<&OidcClientRawConfig<PC>> for OptionalTokenSetOidcSharedIntersectionConfig
474where
475 PC: PendingOauthStoreConfig,
476{
477 fn from(value: &OidcClientRawConfig<PC>) -> Self {
478 Self {
479 client_id: value.client_id.clone(),
480 client_secret: value.client_secret.clone(),
481 remote: OptionalOAuthProviderRemoteConfig::from(&value.remote),
482 provider_oidc: OptionalOAuthProviderOidcConfig::from(&value.provider_oidc),
483 scopes: Some(value.scopes.clone()),
484 required_scopes: Some(value.required_scopes.clone()),
485 claims_check_script: value.claims_check_script.clone(),
486 pkce_enabled: Some(value.pkce_enabled),
487 redirect_url: value.redirect_url.clone(),
488 device_poll_interval: Some(value.device_poll_interval),
489 }
490 }
491}
492
493#[derive(Debug, Clone, Deserialize, Default)]
494pub struct BackendOidcModeOverrideConfig<PC, MC>
495where
496 PC: PendingOauthStoreConfig,
497 MC: PendingAuthStateMetadataRedemptionConfig,
498{
499 #[serde(default, flatten)]
500 pub oidc_client: OptionalTokenSetOidcSharedIntersectionConfig,
501
502 #[serde(default, bound = "PC: PendingOauthStoreConfig")]
503 pub pending_store: Option<PC>,
504
505 #[serde(default)]
506 pub refresh_material_protection: Option<RefreshMaterialProtection>,
507
508 #[serde(
509 default,
510 bound(deserialize = "MC: PendingAuthStateMetadataRedemptionConfig")
511 )]
512 pub metadata_delivery: Option<MetadataDelivery<MC>>,
513
514 #[serde(default)]
515 pub post_auth_redirect: Option<PostAuthRedirectPolicy>,
516}
517
518#[derive(Debug, Clone, Deserialize, Default)]
519pub struct FrontendOidcModeOverrideConfig {
520 #[serde(default, flatten)]
521 pub oidc_client: OptionalTokenSetOidcSharedIntersectionConfig,
522
523 #[serde(default)]
524 pub unsafe_frontend_client_secret: Option<UnsafeFrontendClientSecret>,
525}
526
527#[cfg(test)]
528mod tests {
529 use serde::Deserialize;
530
531 use super::*;
532 use crate::backend_oidc_mode::{
533 BackendOidcModeRedirectUriConfig, MetadataDelivery, PostAuthRedirectPolicy,
534 };
535
536 #[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
537 struct TestPendingStoreConfig {
538 label: Option<String>,
539 }
540
541 impl PendingOauthStoreConfig for TestPendingStoreConfig {}
542
543 #[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
544 struct TestMetadataConfig;
545
546 impl PendingAuthStateMetadataRedemptionConfig for TestMetadataConfig {}
547
548 type SharedUnion = TokenSetOidcSharedUnionConfig<TestPendingStoreConfig, TestMetadataConfig>;
549
550 #[test]
551 fn compose_frontend_inherits_backend_oidc_client_fields() {
552 let shared = SharedUnion {
553 oidc_client: TokenSetOidcSharedIntersectionConfig {
554 client_id: Some("shared-app".to_string()),
555 scopes: vec!["openid".to_string(), "offline_access".to_string()],
556 claims_check_script: Some("./custom-claims-check.mts".to_string()),
557 pkce_enabled: true,
558 ..Default::default()
559 },
560 ..Default::default()
561 };
562
563 let frontend = shared.compose_frontend_config(&FrontendOidcModeOverrideConfig::default());
564
565 assert_eq!(
566 frontend.oidc_client.client_id.as_deref(),
567 Some("shared-app")
568 );
569 assert_eq!(
570 frontend.oidc_client.scopes,
571 vec!["openid".to_string(), "offline_access".to_string()]
572 );
573 assert_eq!(
574 frontend.oidc_client.claims_check_script.as_deref(),
575 Some("./custom-claims-check.mts")
576 );
577 assert!(frontend.oidc_client.pkce_enabled);
578 }
579
580 #[test]
581 fn compose_frontend_preserves_explicit_false_and_empty_list_overrides() {
582 let shared = SharedUnion {
583 oidc_client: TokenSetOidcSharedIntersectionConfig {
584 scopes: vec!["openid".to_string(), "offline_access".to_string()],
585 pkce_enabled: true,
586 ..Default::default()
587 },
588 ..Default::default()
589 };
590 let override_config = FrontendOidcModeOverrideConfig {
591 oidc_client: OptionalTokenSetOidcSharedIntersectionConfig {
592 scopes: Some(vec![]),
593 pkce_enabled: Some(false),
594 ..Default::default()
595 },
596 ..Default::default()
597 };
598
599 let frontend = shared.compose_frontend_config(&override_config);
600
601 assert!(frontend.oidc_client.scopes.is_empty());
602 assert!(!frontend.oidc_client.pkce_enabled);
603 }
604
605 #[test]
606 fn compose_backend_replaces_whole_runtime_fields() {
607 let shared = SharedUnion {
608 post_auth_redirect: PostAuthRedirectPolicy::CallerValidated,
609 metadata_delivery: MetadataDelivery::None,
610 ..Default::default()
611 };
612 let override_config = BackendOidcModeOverrideConfig {
613 post_auth_redirect: Some(PostAuthRedirectPolicy::Resolved {
614 config: BackendOidcModeRedirectUriConfig::builder()
615 .dynamic_redirect_target_enabled(false)
616 .build(),
617 }),
618 metadata_delivery: Some(MetadataDelivery::None),
619 ..Default::default()
620 };
621
622 let backend = shared.compose_backend_config(&override_config);
623
624 assert!(matches!(
625 backend.post_auth_redirect,
626 PostAuthRedirectPolicy::Resolved { .. }
627 ));
628 assert!(matches!(backend.metadata_delivery, MetadataDelivery::None));
629 }
630
631 #[test]
632 fn compose_backend_applies_pending_store_override() {
633 let shared = SharedUnion {
634 pending_store: Some(TestPendingStoreConfig {
635 label: Some("shared".to_string()),
636 }),
637 ..Default::default()
638 };
639 let override_config = BackendOidcModeOverrideConfig {
640 pending_store: Some(TestPendingStoreConfig {
641 label: Some("backend".to_string()),
642 }),
643 ..Default::default()
644 };
645
646 let backend = shared.compose_backend_config(&override_config);
647
648 assert_eq!(
649 backend.oidc_client.pending_store,
650 Some(TestPendingStoreConfig {
651 label: Some("backend".to_string()),
652 })
653 );
654 }
655}