1use std::{collections::HashMap, fmt, sync::Arc};
2
3use chrono::Utc;
4use http::StatusCode;
5use securitydept_oidc_client::{
6 OidcClient, OidcCodeCallbackResult, OidcCodeCallbackSearchParams,
7 OidcCodeFlowAuthorizationRequest, OidcRefreshTokenResult, PendingOauthStore,
8 auth_state::{
9 OidcExtractedPrincipal, extract_issuer_from_refresh_result,
10 extract_principal_from_code_callback, extract_principal_from_refresh_result,
11 },
12};
13use securitydept_utils::{
14 error::{ErrorPresentation, ToErrorPresentation, UserRecovery},
15 http::ToHttpStatus,
16};
17use serde::Deserialize;
18use serde_json::{Value, json};
19use snafu::Snafu;
20use url::Url;
21
22use super::{
23 capabilities::{
24 MetadataDelivery, MetadataDeliveryKind, PostAuthRedirectPolicy, RefreshMaterialProtection,
25 },
26 metadata_redemption::{
27 MetadataRedemptionId, PendingAuthStateMetadataRedemptionConfig,
28 PendingAuthStateMetadataRedemptionPayload, PendingAuthStateMetadataRedemptionStore,
29 },
30 redirect::BackendOidcModeRedirectUriResolver,
31 refresh_material::{
32 AeadRefreshMaterialProtector, PassthroughRefreshMaterialProtector,
33 RefreshMaterialProtector, SealedRefreshMaterial,
34 },
35 transport::{
36 BackendOidcModeCallbackReturns, BackendOidcModeRefreshPayload,
37 BackendOidcModeRefreshReturns,
38 },
39};
40use crate::{
41 backend_oidc_mode::config::ResolvedBackendOidcModeConfig,
42 models::{
43 AuthStateDelta, AuthStateMetadataDelta, AuthStateMetadataSnapshot, AuthStateSnapshot,
44 AuthTokenDelta, AuthTokenSnapshot, AuthenticatedPrincipal, AuthenticationSource,
45 AuthenticationSourceKind, CurrentAuthStateMetadataSnapshotPartial,
46 },
47};
48
49const PENDING_POST_AUTH_REDIRECT_URI_KEY: &str = "post_auth_redirect_uri";
50
51#[derive(Debug, Clone)]
57pub struct BackendOidcModeCodeCallbackResult {
58 pub post_auth_redirect_uri: Option<Url>,
60 pub auth_state_snapshot: AuthStateSnapshot,
61 pub response_body: BackendOidcModeCallbackReturns,
62}
63
64#[derive(Debug, Clone)]
66pub struct BackendOidcModeTokenRefreshResult {
67 pub post_auth_redirect_uri: Option<Url>,
69 pub auth_state_delta: AuthStateDelta,
70 pub response_body: BackendOidcModeRefreshReturns,
71}
72
73#[derive(Debug, Clone, Default)]
79pub struct BackendOidcModeAuthStateOptions {
80 pub source_provider_id: Option<String>,
81 pub source_attributes: HashMap<String, Value>,
82 pub metadata_attributes: HashMap<String, Value>,
83}
84
85#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
105#[cfg_attr(
106 feature = "config-schema",
107 schemars(bound = "MC: schemars::JsonSchema")
108)]
109#[derive(Debug, Clone, Deserialize)]
110pub struct BackendOidcModeRuntimeConfig<MC>
111where
112 MC: PendingAuthStateMetadataRedemptionConfig,
113{
114 #[serde(default, bound(deserialize = ""))]
115 pub refresh_material_protection: RefreshMaterialProtection,
116
117 #[serde(
118 default,
119 bound(deserialize = "MC: PendingAuthStateMetadataRedemptionConfig")
120 )]
121 pub metadata_delivery: MetadataDelivery<MC>,
122
123 #[serde(default)]
124 pub post_auth_redirect: PostAuthRedirectPolicy,
125}
126
127impl<MC> Default for BackendOidcModeRuntimeConfig<MC>
128where
129 MC: PendingAuthStateMetadataRedemptionConfig,
130{
131 fn default() -> Self {
132 Self {
133 refresh_material_protection: RefreshMaterialProtection::default(),
134 metadata_delivery: MetadataDelivery::default(),
135 post_auth_redirect: PostAuthRedirectPolicy::default(),
136 }
137 }
138}
139
140#[derive(Clone)]
150pub struct BackendOidcModeRuntime<MS>
151where
152 MS: PendingAuthStateMetadataRedemptionStore,
153{
154 refresh_material_protector: Arc<dyn RefreshMaterialProtector>,
155 redirect_uri_resolver: Option<BackendOidcModeRedirectUriResolver>,
156 metadata_redemption_store: Option<MS>,
157 metadata_delivery_kind: MetadataDeliveryKind,
158}
159
160impl<MS> fmt::Debug for BackendOidcModeRuntime<MS>
161where
162 MS: PendingAuthStateMetadataRedemptionStore,
163{
164 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165 f.write_str("BackendOidcModeRuntime { ... }")
166 }
167}
168
169#[derive(Debug, Snafu)]
175pub enum BackendOidcModeRuntimeError {
176 #[snafu(display("oidc: {source}"), context(false))]
177 Oidc {
178 source: securitydept_oidc_client::OidcError,
179 },
180
181 #[snafu(display("refresh_material: {source}"), context(false))]
182 RefreshMaterial {
183 source: super::refresh_material::RefreshMaterialError,
184 },
185
186 #[snafu(display("redirect_uri: {source}"), context(false))]
187 RedirectUri {
188 source: super::redirect::BackendOidcModeRedirectUriError,
189 },
190
191 #[snafu(display("metadata_store: {source}"), context(false))]
192 MetadataStore {
193 source: super::metadata_redemption::PendingAuthStateMetadataRedemptionStoreError,
194 },
195
196 #[snafu(display("config: {message}"))]
197 Config { message: String },
198}
199
200pub type BackendOidcModeRuntimeResult<T> = Result<T, BackendOidcModeRuntimeError>;
201
202impl ToErrorPresentation for BackendOidcModeRuntimeError {
203 fn to_error_presentation(&self) -> ErrorPresentation {
204 match self {
205 Self::Oidc { source } => source.to_error_presentation(),
206 Self::Config { .. } => ErrorPresentation::new(
207 "backend_oidc_mode_config_invalid",
208 "Backend-oidc mode runtime is misconfigured.",
209 UserRecovery::ContactSupport,
210 ),
211 Self::RefreshMaterial { .. } => ErrorPresentation::new(
212 "backend_oidc_mode_refresh_material_invalid",
213 "The sign-in state is no longer valid. Sign in again.",
214 UserRecovery::Reauthenticate,
215 ),
216 Self::RedirectUri { .. } => ErrorPresentation::new(
217 "backend_oidc_mode_redirect_uri_invalid",
218 "The redirect URL is invalid.",
219 UserRecovery::RestartFlow,
220 ),
221 Self::MetadataStore { .. } => ErrorPresentation::new(
222 "backend_oidc_mode_metadata_unavailable",
223 "Authentication metadata is temporarily unavailable.",
224 UserRecovery::Retry,
225 ),
226 }
227 }
228}
229
230impl ToHttpStatus for BackendOidcModeRuntimeError {
231 fn to_http_status(&self) -> StatusCode {
232 match self {
233 Self::Oidc { source } => source.to_http_status(),
234 Self::Config { .. }
235 | Self::RefreshMaterial { .. }
236 | Self::RedirectUri { .. }
237 | Self::MetadataStore { .. } => StatusCode::INTERNAL_SERVER_ERROR,
238 }
239 }
240}
241
242impl<MC> BackendOidcModeRuntimeConfig<MC>
247where
248 MC: PendingAuthStateMetadataRedemptionConfig,
249{
250 pub fn validate(&self) -> BackendOidcModeRuntimeResult<()> {
256 if let PostAuthRedirectPolicy::Resolved { ref config } = self.post_auth_redirect {
257 config.validate_as_uri_reference()?;
258 }
259
260 Ok(())
261 }
262}
263
264impl<MS> BackendOidcModeRuntime<MS>
269where
270 MS: PendingAuthStateMetadataRedemptionStore,
271{
272 pub fn from_config(
274 config: BackendOidcModeRuntimeConfig<MS::Config>,
275 ) -> BackendOidcModeRuntimeResult<Self> {
276 let refresh_material_protector: Arc<dyn RefreshMaterialProtector> = match &config
279 .refresh_material_protection
280 {
281 RefreshMaterialProtection::Sealed { master_key } => Arc::new(
282 AeadRefreshMaterialProtector::from_master_key(master_key.expose_secret())?,
283 ),
284 RefreshMaterialProtection::Passthrough => Arc::new(PassthroughRefreshMaterialProtector),
285 };
286
287 let metadata_delivery_kind = config.metadata_delivery.kind();
290 let metadata_redemption_store = match &config.metadata_delivery {
291 MetadataDelivery::Redemption { config } => Some(MS::from_config(config)?),
292 MetadataDelivery::None => None,
293 };
294
295 let redirect_uri_resolver = match config.post_auth_redirect {
298 PostAuthRedirectPolicy::Resolved { ref config } => {
299 config.validate_as_uri_reference()?;
300 Some(BackendOidcModeRedirectUriResolver::from_config(
301 config.clone(),
302 ))
303 }
304 PostAuthRedirectPolicy::CallerValidated => None,
305 };
306
307 Ok(Self {
308 refresh_material_protector,
309 redirect_uri_resolver,
310 metadata_redemption_store,
311 metadata_delivery_kind,
312 })
313 }
314
315 pub async fn from_resolved_config<PS>(
336 resolved: Option<&ResolvedBackendOidcModeConfig<PS::Config, MS::Config>>,
337 ) -> BackendOidcModeRuntimeResult<(Self, Option<Arc<OidcClient<PS>>>)>
338 where
339 PS: PendingOauthStore,
340 PS::Config: Clone,
341 MS::Config: Clone,
342 {
343 let (runtime_config, oidc_client) = match resolved {
344 Some(r) => {
345 let client = OidcClient::<PS>::from_config(r.oidc_client.clone()).await?;
346 (r.runtime.clone(), Some(Arc::new(client)))
347 }
348 None => (BackendOidcModeRuntimeConfig::default(), None),
349 };
350
351 let runtime = Self::from_config(runtime_config)?;
352 Ok((runtime, oidc_client))
353 }
354
355 pub fn seal_refresh_token(
360 &self,
361 refresh_token: &str,
362 ) -> BackendOidcModeRuntimeResult<SealedRefreshMaterial> {
363 self.refresh_material_protector
364 .seal(refresh_token)
365 .map_err(Into::into)
366 }
367
368 pub fn unseal_refresh_token(
369 &self,
370 material: &SealedRefreshMaterial,
371 ) -> BackendOidcModeRuntimeResult<String> {
372 self.refresh_material_protector
373 .unseal(material)
374 .map_err(Into::into)
375 }
376
377 fn resolve_post_auth_redirect_uri(
378 &self,
379 requested: Option<&str>,
380 external_base_url: &Url,
381 ) -> BackendOidcModeRuntimeResult<Option<Url>> {
382 match &self.redirect_uri_resolver {
383 Some(resolver) => resolver
384 .resolve_redirect_uri(requested, external_base_url)
385 .map(Some)
386 .map_err(Into::into),
387 None => Ok(None), }
389 }
390
391 pub fn auth_state_snapshot_from_code_callback(
396 &self,
397 result: &OidcCodeCallbackResult,
398 options: &BackendOidcModeAuthStateOptions,
399 ) -> BackendOidcModeRuntimeResult<AuthStateSnapshot> {
400 let mut kind_history = Vec::new();
401 push_kind_history(
402 &mut kind_history,
403 &AuthenticationSourceKind::OidcAuthorizationCode,
404 );
405
406 let extracted = extract_principal_from_code_callback(result);
407
408 Ok(AuthStateSnapshot {
409 tokens: AuthTokenSnapshot {
410 access_token: result.access_token.clone(),
411 id_token: result.id_token.clone(),
412 refresh_material: seal_optional_refresh_material(
413 self,
414 result.refresh_token.as_deref(),
415 )?,
416 access_token_expires_at: result.access_token_expiration,
417 },
418 metadata: AuthStateMetadataSnapshot {
419 principal: Some(into_authenticated_principal(extracted)),
420 source: AuthenticationSource {
421 kind: AuthenticationSourceKind::OidcAuthorizationCode,
422 provider_id: options.source_provider_id.clone(),
423 issuer: Some(result.id_token_claims.issuer().url().to_string()),
424 kind_history,
425 attributes: options.source_attributes.clone(),
426 },
427 attributes: options.metadata_attributes.clone(),
428 },
429 })
430 }
431
432 pub fn auth_state_metadata_delta_from_refresh_result(
433 current_metadata: Option<&CurrentAuthStateMetadataSnapshotPartial>,
434 result: &OidcRefreshTokenResult,
435 ) -> AuthStateMetadataDelta {
436 let principal =
437 extract_principal_from_refresh_result(result).map(into_authenticated_principal);
438
439 AuthStateMetadataDelta {
440 principal,
441 source: Some(refreshed_source(
442 current_metadata.and_then(|m| m.source.as_ref()),
443 result,
444 )),
445 ..Default::default()
446 }
447 }
448
449 pub async fn authorize_code_flow<PS>(
460 &self,
461 oidc_client: &OidcClient<PS>,
462 external_base_url: &Url,
463 requested_post_auth_redirect_uri: Option<&str>,
464 redirect_url_override: Option<&str>,
465 ) -> BackendOidcModeRuntimeResult<OidcCodeFlowAuthorizationRequest>
466 where
467 PS: PendingOauthStore,
468 {
469 if requested_post_auth_redirect_uri.is_some() {
475 let _ = self.resolve_post_auth_redirect_uri(
476 requested_post_auth_redirect_uri,
477 external_base_url,
478 )?;
479 }
480
481 let extra_data = requested_post_auth_redirect_uri.map(|uri| {
482 json!({
483 PENDING_POST_AUTH_REDIRECT_URI_KEY: uri,
484 })
485 });
486
487 let request = oidc_client
488 .handle_code_authorize_with_redirect_override_and_extra_data(
489 external_base_url,
490 redirect_url_override,
491 extra_data,
492 )
493 .await?;
494
495 Ok(request)
496 }
497
498 pub async fn handle_code_callback<PS>(
500 &self,
501 oidc_client: &OidcClient<PS>,
502 search_params: OidcCodeCallbackSearchParams,
503 external_base_url: &Url,
504 auth_state_options: &BackendOidcModeAuthStateOptions,
505 redirect_url_override: Option<&str>,
506 ) -> BackendOidcModeRuntimeResult<BackendOidcModeCodeCallbackResult>
507 where
508 PS: PendingOauthStore,
509 {
510 let result = oidc_client
511 .handle_code_callback_with_redirect_override(
512 search_params,
513 external_base_url,
514 redirect_url_override,
515 )
516 .await?;
517
518 let post_auth_redirect_uri = if self.redirect_uri_resolver.is_some() {
520 self.resolve_post_auth_redirect_uri(
521 callback_post_auth_redirect_uri(&result).as_deref(),
522 external_base_url,
523 )?
524 } else {
525 None
526 };
527
528 let auth_state_snapshot =
529 self.auth_state_snapshot_from_code_callback(&result, auth_state_options)?;
530
531 let metadata_redemption_id = self.issue_metadata_snapshot(&auth_state_snapshot)?;
533
534 let response_body = BackendOidcModeCallbackReturns::from_snapshot(
535 &auth_state_snapshot.tokens,
536 metadata_redemption_id,
537 );
538
539 Ok(BackendOidcModeCodeCallbackResult {
540 post_auth_redirect_uri,
541 auth_state_snapshot,
542 response_body,
543 })
544 }
545
546 pub async fn handle_token_refresh<PS>(
548 &self,
549 oidc_client: &OidcClient<PS>,
550 payload: &BackendOidcModeRefreshPayload,
551 external_base_url: &Url,
552 ) -> BackendOidcModeRuntimeResult<BackendOidcModeTokenRefreshResult>
553 where
554 PS: PendingOauthStore,
555 {
556 let post_auth_redirect_uri = if self.redirect_uri_resolver.is_some() {
558 self.resolve_post_auth_redirect_uri(
559 payload.post_auth_redirect_uri.as_deref(),
560 external_base_url,
561 )?
562 } else {
563 None
564 };
565
566 let refresh_token = self.unseal_refresh_token(&payload.refresh_material)?;
568
569 let refresh_result = oidc_client
570 .handle_token_refresh(refresh_token, payload.id_token.clone())
571 .await?;
572
573 let refresh_material_delta = refresh_result
575 .refresh_token
576 .as_deref()
577 .map(|value| self.seal_refresh_token(value))
578 .transpose()?;
579
580 let token_delta = AuthTokenDelta {
581 access_token: refresh_result.access_token.clone(),
582 id_token: refresh_result.id_token.clone(),
583 refresh_material: refresh_material_delta,
584 access_token_expires_at: refresh_result.access_token_expiration,
585 };
586
587 let metadata_delta = Self::auth_state_metadata_delta_from_refresh_result(
588 payload.current_metadata_snapshot.as_ref(),
589 &refresh_result,
590 );
591
592 let metadata_redemption_id = self.issue_metadata_delta(&metadata_delta)?;
594
595 let response_body =
596 BackendOidcModeRefreshReturns::from_delta(&token_delta, metadata_redemption_id);
597
598 Ok(BackendOidcModeTokenRefreshResult {
599 post_auth_redirect_uri,
600 auth_state_delta: AuthStateDelta {
601 tokens: token_delta,
602 metadata: metadata_delta,
603 },
604 response_body,
605 })
606 }
607
608 pub async fn handle_code_callback_inline<PS>(
621 &self,
622 oidc_client: &OidcClient<PS>,
623 search_params: OidcCodeCallbackSearchParams,
624 external_base_url: &Url,
625 auth_state_options: &BackendOidcModeAuthStateOptions,
626 redirect_url_override: Option<&str>,
627 ) -> BackendOidcModeRuntimeResult<BackendOidcModeCodeCallbackResult>
628 where
629 PS: PendingOauthStore,
630 {
631 let result = oidc_client
632 .handle_code_callback_with_redirect_override(
633 search_params,
634 external_base_url,
635 redirect_url_override,
636 )
637 .await?;
638
639 let auth_state_snapshot =
640 self.auth_state_snapshot_from_code_callback(&result, auth_state_options)?;
641
642 let response_body = BackendOidcModeCallbackReturns::from_snapshot_with_inline_metadata(
644 &auth_state_snapshot.tokens,
645 auth_state_snapshot.metadata.clone(),
646 );
647
648 Ok(BackendOidcModeCodeCallbackResult {
649 post_auth_redirect_uri: None,
650 auth_state_snapshot,
651 response_body,
652 })
653 }
654
655 pub async fn handle_token_refresh_inline<PS>(
668 &self,
669 oidc_client: &OidcClient<PS>,
670 payload: &BackendOidcModeRefreshPayload,
671 ) -> BackendOidcModeRuntimeResult<BackendOidcModeTokenRefreshResult>
672 where
673 PS: PendingOauthStore,
674 {
675 let refresh_token = self.unseal_refresh_token(&payload.refresh_material)?;
677
678 let refresh_result = oidc_client
679 .handle_token_refresh(refresh_token, payload.id_token.clone())
680 .await?;
681
682 let refresh_material_delta = refresh_result
684 .refresh_token
685 .as_deref()
686 .map(|value| self.seal_refresh_token(value))
687 .transpose()?;
688
689 let token_delta = AuthTokenDelta {
690 access_token: refresh_result.access_token.clone(),
691 id_token: refresh_result.id_token.clone(),
692 refresh_material: refresh_material_delta,
693 access_token_expires_at: refresh_result.access_token_expiration,
694 };
695
696 let metadata_delta = Self::auth_state_metadata_delta_from_refresh_result(
697 payload.current_metadata_snapshot.as_ref(),
698 &refresh_result,
699 );
700
701 let response_body = BackendOidcModeRefreshReturns::from_delta_with_inline_metadata(
703 &token_delta,
704 metadata_delta.clone(),
705 );
706
707 Ok(BackendOidcModeTokenRefreshResult {
708 post_auth_redirect_uri: None,
709 auth_state_delta: AuthStateDelta {
710 tokens: token_delta,
711 metadata: metadata_delta,
712 },
713 response_body,
714 })
715 }
716
717 pub async fn redeem_metadata(
719 &self,
720 payload: &super::transport::BackendOidcModeMetadataRedemptionRequest,
721 ) -> BackendOidcModeRuntimeResult<
722 Option<super::transport::BackendOidcModeMetadataRedemptionResponse>,
723 > {
724 let store = self.metadata_redemption_store.as_ref().ok_or_else(|| {
725 BackendOidcModeRuntimeError::Config {
726 message: "metadata redemption is not enabled in this configuration".to_string(),
727 }
728 })?;
729
730 let metadata = store.redeem(&payload.metadata_redemption_id, Utc::now())?;
731
732 Ok(metadata
733 .map(|m| super::transport::BackendOidcModeMetadataRedemptionResponse { metadata: m }))
734 }
735
736 fn issue_metadata_snapshot(
741 &self,
742 snapshot: &AuthStateSnapshot,
743 ) -> BackendOidcModeRuntimeResult<Option<MetadataRedemptionId>> {
744 match (
745 &self.metadata_delivery_kind,
746 &self.metadata_redemption_store,
747 ) {
748 (MetadataDeliveryKind::Redemption, Some(store)) => {
749 let ticket = store.issue(
750 PendingAuthStateMetadataRedemptionPayload::Snapshot(snapshot.metadata.clone()),
751 Utc::now(),
752 )?;
753 Ok(Some(ticket.id))
754 }
755 _ => Ok(None),
756 }
757 }
758
759 fn issue_metadata_delta(
760 &self,
761 delta: &AuthStateMetadataDelta,
762 ) -> BackendOidcModeRuntimeResult<Option<MetadataRedemptionId>> {
763 match (
764 &self.metadata_delivery_kind,
765 &self.metadata_redemption_store,
766 ) {
767 (MetadataDeliveryKind::Redemption, Some(store)) if delta.is_empty() => {
768 let ticket = store.issue(
769 PendingAuthStateMetadataRedemptionPayload::Delta(delta.clone()),
770 Utc::now(),
771 )?;
772 Ok(Some(ticket.id))
773 }
774 _ => Ok(None),
775 }
776 }
777}
778
779fn refreshed_source(
784 current_source: Option<&crate::models::CurrentAuthenticationSourcePartial>,
785 result: &OidcRefreshTokenResult,
786) -> AuthenticationSource {
787 let mut source = AuthenticationSource {
788 kind: AuthenticationSourceKind::RefreshToken,
789 provider_id: current_source.and_then(|s| s.provider_id.clone()),
790 issuer: current_source.and_then(|s| s.issuer.clone()),
791 kind_history: current_source
792 .and_then(|s| s.kind_history.as_ref())
793 .cloned()
794 .unwrap_or_default(),
795 attributes: current_source
796 .map(|s| s.attributes.clone())
797 .unwrap_or_default(),
798 };
799 push_kind_history(
800 &mut source.kind_history,
801 &AuthenticationSourceKind::RefreshToken,
802 );
803
804 if let Some(issuer) = extract_issuer_from_refresh_result(result) {
805 source.issuer = Some(issuer);
806 }
807
808 source
809}
810
811fn into_authenticated_principal(extracted: OidcExtractedPrincipal) -> AuthenticatedPrincipal {
812 extracted
813}
814
815fn push_kind_history(history: &mut Vec<AuthenticationSourceKind>, kind: &AuthenticationSourceKind) {
816 if history.last() != Some(kind) {
817 history.push(kind.clone());
818 }
819}
820
821fn seal_optional_refresh_material<MS>(
822 runtime: &BackendOidcModeRuntime<MS>,
823 refresh_token: Option<&str>,
824) -> Result<Option<SealedRefreshMaterial>, BackendOidcModeRuntimeError>
825where
826 MS: PendingAuthStateMetadataRedemptionStore,
827{
828 refresh_token
829 .map(|value| runtime.seal_refresh_token(value))
830 .transpose()
831}
832
833fn callback_post_auth_redirect_uri(result: &OidcCodeCallbackResult) -> Option<String> {
834 result
835 .pending_extra_data
836 .as_ref()
837 .and_then(|value| value.get(PENDING_POST_AUTH_REDIRECT_URI_KEY))
838 .and_then(|value| value.as_str())
839 .map(ToOwned::to_owned)
840}
841
842#[cfg(test)]
843mod tests {
844 use super::{push_kind_history, refreshed_source};
845 use crate::models::{
846 AuthStateMetadataDelta, AuthenticationSourceKind, CurrentAuthenticationSourcePartial,
847 };
848
849 #[test]
850 fn kind_history_appends_new_kinds() {
851 let mut history = Vec::new();
852
853 push_kind_history(
854 &mut history,
855 &AuthenticationSourceKind::OidcAuthorizationCode,
856 );
857 push_kind_history(&mut history, &AuthenticationSourceKind::RefreshToken);
858
859 assert_eq!(
860 history,
861 vec![
862 AuthenticationSourceKind::OidcAuthorizationCode,
863 AuthenticationSourceKind::RefreshToken
864 ]
865 );
866 }
867
868 #[test]
869 fn kind_history_merges_same_top_kind() {
870 let mut history = vec![AuthenticationSourceKind::RefreshToken];
871
872 push_kind_history(&mut history, &AuthenticationSourceKind::RefreshToken);
873
874 assert_eq!(history, vec![AuthenticationSourceKind::RefreshToken]);
875 }
876
877 #[test]
878 fn metadata_delta_is_generated_without_previous_snapshot() {
879 let delta: AuthStateMetadataDelta = AuthStateMetadataDelta {
880 source: Some(refreshed_source(None, &mock_refresh_result())),
881 ..Default::default()
882 };
883
884 assert_eq!(
885 delta.source.as_ref().map(|source| &source.kind),
886 Some(&AuthenticationSourceKind::RefreshToken)
887 );
888 assert_eq!(
889 delta.source.as_ref().map(|source| &source.kind_history),
890 Some(&vec![AuthenticationSourceKind::RefreshToken])
891 );
892 }
893
894 #[test]
895 fn refreshed_source_preserves_partial_source_fields() {
896 let source = refreshed_source(
897 Some(&CurrentAuthenticationSourcePartial {
898 provider_id: Some("primary".to_string()),
899 issuer: Some("https://issuer.example.com".to_string()),
900 kind_history: Some(vec![AuthenticationSourceKind::OidcAuthorizationCode]),
901 ..Default::default()
902 }),
903 &mock_refresh_result(),
904 );
905
906 assert_eq!(source.provider_id.as_deref(), Some("primary"));
907 assert_eq!(source.issuer.as_deref(), Some("https://issuer.example.com"));
908 assert_eq!(
909 source.kind_history,
910 vec![
911 AuthenticationSourceKind::OidcAuthorizationCode,
912 AuthenticationSourceKind::RefreshToken
913 ]
914 );
915 }
916
917 fn mock_refresh_result() -> securitydept_oidc_client::OidcRefreshTokenResult {
918 securitydept_oidc_client::OidcRefreshTokenResult {
919 access_token: "access-token".to_string(),
920 access_token_expiration: None,
921 id_token: None,
922 refresh_token: None,
923 id_token_claims: None,
924 user_info_claims: None,
925 claims_check_result: None,
926 }
927 }
928}