Skip to main content

securitydept_token_set_context/
cross_mode_config.rs

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}