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}