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