Skip to main content

securitydept_token_set_context/backend_oidc_mode/
runtime.rs

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// ---------------------------------------------------------------------------
52// Result types
53// ---------------------------------------------------------------------------
54
55/// Result of a backend-oidc code callback flow.
56#[derive(Debug, Clone)]
57pub struct BackendOidcModeCodeCallbackResult {
58    /// Present only when `post_auth_redirect_policy = resolved`.
59    pub post_auth_redirect_uri: Option<Url>,
60    pub auth_state_snapshot: AuthStateSnapshot,
61    pub response_body: BackendOidcModeCallbackReturns,
62}
63
64/// Result of a backend-oidc token refresh flow.
65#[derive(Debug, Clone)]
66pub struct BackendOidcModeTokenRefreshResult {
67    /// Present only when `post_auth_redirect_policy = resolved`.
68    pub post_auth_redirect_uri: Option<Url>,
69    pub auth_state_delta: AuthStateDelta,
70    pub response_body: BackendOidcModeRefreshReturns,
71}
72
73// ---------------------------------------------------------------------------
74// Auth state options
75// ---------------------------------------------------------------------------
76
77/// Options for OIDC auth-state construction.
78#[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// ---------------------------------------------------------------------------
86// Runtime config
87// ---------------------------------------------------------------------------
88
89/// Configuration for the unified backend-oidc runtime.
90///
91/// Each capability axis is a structured enum that carries its associated
92/// configuration. This eliminates scattered sibling fields and lets the type
93/// system enforce invariants (e.g. `Sealed` always has a `master_key`).
94///
95/// ```text
96/// refresh_material_protection: Sealed { master_key }  | Passthrough
97/// metadata_delivery:           Redemption { config }  | None
98/// post_auth_redirect:          Resolved { config }    | CallerValidated
99/// ```
100///
101/// Note: `token_propagation` has been moved to
102/// [`AccessTokenSubstrateConfig`](crate::access_token_substrate::AccessTokenSubstrateConfig)
103/// as a substrate-level capability axis.
104#[derive(Debug, Clone, Deserialize)]
105pub struct BackendOidcModeRuntimeConfig<MC>
106where
107    MC: PendingAuthStateMetadataRedemptionConfig,
108{
109    #[serde(default, bound(deserialize = ""))]
110    pub refresh_material_protection: RefreshMaterialProtection,
111
112    #[serde(
113        default,
114        bound(deserialize = "MC: PendingAuthStateMetadataRedemptionConfig")
115    )]
116    pub metadata_delivery: MetadataDelivery<MC>,
117
118    #[serde(default)]
119    pub post_auth_redirect: PostAuthRedirectPolicy,
120}
121
122impl<MC> Default for BackendOidcModeRuntimeConfig<MC>
123where
124    MC: PendingAuthStateMetadataRedemptionConfig,
125{
126    fn default() -> Self {
127        Self {
128            refresh_material_protection: RefreshMaterialProtection::default(),
129            metadata_delivery: MetadataDelivery::default(),
130            post_auth_redirect: PostAuthRedirectPolicy::default(),
131        }
132    }
133}
134
135// ---------------------------------------------------------------------------
136// Runtime
137// ---------------------------------------------------------------------------
138
139/// Unified backend-oidc runtime.
140///
141/// Parameterized by a metadata-redemption store (which may be a no-op for
142/// the pure preset). Provides the single implementation of authorize,
143/// callback, refresh, and metadata redemption.
144#[derive(Clone)]
145pub struct BackendOidcModeRuntime<MS>
146where
147    MS: PendingAuthStateMetadataRedemptionStore,
148{
149    refresh_material_protector: Arc<dyn RefreshMaterialProtector>,
150    redirect_uri_resolver: Option<BackendOidcModeRedirectUriResolver>,
151    metadata_redemption_store: Option<MS>,
152    metadata_delivery_kind: MetadataDeliveryKind,
153}
154
155impl<MS> fmt::Debug for BackendOidcModeRuntime<MS>
156where
157    MS: PendingAuthStateMetadataRedemptionStore,
158{
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        f.write_str("BackendOidcModeRuntime { ... }")
161    }
162}
163
164// ---------------------------------------------------------------------------
165// Error
166// ---------------------------------------------------------------------------
167
168/// Error type for the unified backend-oidc runtime.
169#[derive(Debug, Snafu)]
170pub enum BackendOidcModeRuntimeError {
171    #[snafu(display("oidc: {source}"), context(false))]
172    Oidc {
173        source: securitydept_oidc_client::OidcError,
174    },
175
176    #[snafu(display("refresh_material: {source}"), context(false))]
177    RefreshMaterial {
178        source: super::refresh_material::RefreshMaterialError,
179    },
180
181    #[snafu(display("redirect_uri: {source}"), context(false))]
182    RedirectUri {
183        source: super::redirect::BackendOidcModeRedirectUriError,
184    },
185
186    #[snafu(display("metadata_store: {source}"), context(false))]
187    MetadataStore {
188        source: super::metadata_redemption::PendingAuthStateMetadataRedemptionStoreError,
189    },
190
191    #[snafu(display("config: {message}"))]
192    Config { message: String },
193}
194
195pub type BackendOidcModeRuntimeResult<T> = Result<T, BackendOidcModeRuntimeError>;
196
197impl ToErrorPresentation for BackendOidcModeRuntimeError {
198    fn to_error_presentation(&self) -> ErrorPresentation {
199        match self {
200            Self::Oidc { source } => source.to_error_presentation(),
201            Self::Config { .. } => ErrorPresentation::new(
202                "backend_oidc_mode_config_invalid",
203                "Backend-oidc mode runtime is misconfigured.",
204                UserRecovery::ContactSupport,
205            ),
206            Self::RefreshMaterial { .. } => ErrorPresentation::new(
207                "backend_oidc_mode_refresh_material_invalid",
208                "The sign-in state is no longer valid. Sign in again.",
209                UserRecovery::Reauthenticate,
210            ),
211            Self::RedirectUri { .. } => ErrorPresentation::new(
212                "backend_oidc_mode_redirect_uri_invalid",
213                "The redirect URL is invalid.",
214                UserRecovery::RestartFlow,
215            ),
216            Self::MetadataStore { .. } => ErrorPresentation::new(
217                "backend_oidc_mode_metadata_unavailable",
218                "Authentication metadata is temporarily unavailable.",
219                UserRecovery::Retry,
220            ),
221        }
222    }
223}
224
225impl ToHttpStatus for BackendOidcModeRuntimeError {
226    fn to_http_status(&self) -> StatusCode {
227        match self {
228            Self::Oidc { source } => source.to_http_status(),
229            Self::Config { .. }
230            | Self::RefreshMaterial { .. }
231            | Self::RedirectUri { .. }
232            | Self::MetadataStore { .. } => StatusCode::INTERNAL_SERVER_ERROR,
233        }
234    }
235}
236
237// ---------------------------------------------------------------------------
238// Config validation
239// ---------------------------------------------------------------------------
240
241impl<MC> BackendOidcModeRuntimeConfig<MC>
242where
243    MC: PendingAuthStateMetadataRedemptionConfig,
244{
245    /// Validate the configuration without constructing the runtime.
246    ///
247    /// Most invariants are enforced by the structured enum types.
248    /// This method validates things the type system cannot
249    /// (e.g. redirect URI format).
250    pub fn validate(&self) -> BackendOidcModeRuntimeResult<()> {
251        if let PostAuthRedirectPolicy::Resolved { ref config } = self.post_auth_redirect {
252            config.validate_as_uri_reference()?;
253        }
254
255        Ok(())
256    }
257}
258
259// ---------------------------------------------------------------------------
260// Runtime impl — construction
261// ---------------------------------------------------------------------------
262
263impl<MS> BackendOidcModeRuntime<MS>
264where
265    MS: PendingAuthStateMetadataRedemptionStore,
266{
267    /// Build the unified runtime from its config.
268    pub fn from_config(
269        config: BackendOidcModeRuntimeConfig<MS::Config>,
270    ) -> BackendOidcModeRuntimeResult<Self> {
271        // Build refresh material protector — the `Sealed` variant carries
272        // master_key by type, so no Option check needed.
273        let refresh_material_protector: Arc<dyn RefreshMaterialProtector> = match &config
274            .refresh_material_protection
275        {
276            RefreshMaterialProtection::Sealed { master_key } => {
277                Arc::new(AeadRefreshMaterialProtector::from_master_key(master_key)?)
278            }
279            RefreshMaterialProtection::Passthrough => Arc::new(PassthroughRefreshMaterialProtector),
280        };
281
282        // Build metadata redemption store — the `Redemption` variant carries
283        // the store config by type.
284        let metadata_delivery_kind = config.metadata_delivery.kind();
285        let metadata_redemption_store = match &config.metadata_delivery {
286            MetadataDelivery::Redemption { config } => Some(MS::from_config(config)?),
287            MetadataDelivery::None => None,
288        };
289
290        // Build redirect URI resolver — the `Resolved` variant carries the
291        // redirect config by type.
292        let redirect_uri_resolver = match config.post_auth_redirect {
293            PostAuthRedirectPolicy::Resolved { ref config } => {
294                config.validate_as_uri_reference()?;
295                Some(BackendOidcModeRedirectUriResolver::from_config(
296                    config.clone(),
297                ))
298            }
299            PostAuthRedirectPolicy::CallerValidated => None,
300        };
301
302        Ok(Self {
303            refresh_material_protector,
304            redirect_uri_resolver,
305            metadata_redemption_store,
306            metadata_delivery_kind,
307        })
308    }
309
310    /// **Recommended entry point.** Build both the runtime and the optional
311    /// OIDC client from a resolved backend-oidc config.
312    ///
313    /// Mirrors [`AccessTokenSubstrateRuntime::from_resolved_config`]:
314    ///
315    /// ```text
316    /// BackendOidcModeRuntime::from_resolved_config(resolved_oidc.as_ref()).await?
317    ///   ──▸ (BackendOidcModeRuntime<MS>, Option<Arc<OidcClient<PS>>>)
318    ///
319    /// AccessTokenSubstrateRuntime::from_resolved_config(&resolved_substrate).await?
320    ///   ──▸ (AccessTokenSubstrateRuntime, Option<Arc<OAuthResourceServerVerifier>>)
321    /// ```
322    ///
323    /// Pass `None` when OIDC is disabled — the runtime is built from a default
324    /// config and `oidc_client` will be `None`.
325    ///
326    /// # Type parameters
327    /// - `PS` — pending OAuth store (Store type, e.g. `MokaPendingOauthStore`)
328    /// - `MS` — pending auth-state metadata redemption store (inferred from
329    ///   `Self`)
330    pub async fn from_resolved_config<PS>(
331        resolved: Option<&ResolvedBackendOidcModeConfig<PS::Config, MS::Config>>,
332    ) -> BackendOidcModeRuntimeResult<(Self, Option<Arc<OidcClient<PS>>>)>
333    where
334        PS: PendingOauthStore,
335        PS::Config: Clone,
336        MS::Config: Clone,
337    {
338        let (runtime_config, oidc_client) = match resolved {
339            Some(r) => {
340                let client = OidcClient::<PS>::from_config(r.oidc_client.clone()).await?;
341                (r.runtime.clone(), Some(Arc::new(client)))
342            }
343            None => (BackendOidcModeRuntimeConfig::default(), None),
344        };
345
346        let runtime = Self::from_config(runtime_config)?;
347        Ok((runtime, oidc_client))
348    }
349
350    // -----------------------------------------------------------------------
351    // Low-level helpers
352    // -----------------------------------------------------------------------
353
354    pub fn seal_refresh_token(
355        &self,
356        refresh_token: &str,
357    ) -> BackendOidcModeRuntimeResult<SealedRefreshMaterial> {
358        self.refresh_material_protector
359            .seal(refresh_token)
360            .map_err(Into::into)
361    }
362
363    pub fn unseal_refresh_token(
364        &self,
365        material: &SealedRefreshMaterial,
366    ) -> BackendOidcModeRuntimeResult<String> {
367        self.refresh_material_protector
368            .unseal(material)
369            .map_err(Into::into)
370    }
371
372    fn resolve_post_auth_redirect_uri(
373        &self,
374        requested: Option<&str>,
375        external_base_url: &Url,
376    ) -> BackendOidcModeRuntimeResult<Option<Url>> {
377        match &self.redirect_uri_resolver {
378            Some(resolver) => resolver
379                .resolve_redirect_uri(requested, external_base_url)
380                .map(Some)
381                .map_err(Into::into),
382            None => Ok(None), // caller_validated — no resolution here
383        }
384    }
385
386    // -----------------------------------------------------------------------
387    // Auth-state construction (unified from pure + mediated)
388    // -----------------------------------------------------------------------
389
390    pub fn auth_state_snapshot_from_code_callback(
391        &self,
392        result: &OidcCodeCallbackResult,
393        options: &BackendOidcModeAuthStateOptions,
394    ) -> BackendOidcModeRuntimeResult<AuthStateSnapshot> {
395        let mut kind_history = Vec::new();
396        push_kind_history(
397            &mut kind_history,
398            &AuthenticationSourceKind::OidcAuthorizationCode,
399        );
400
401        let extracted = extract_principal_from_code_callback(result);
402
403        Ok(AuthStateSnapshot {
404            tokens: AuthTokenSnapshot {
405                access_token: result.access_token.clone(),
406                id_token: result.id_token.clone(),
407                refresh_material: seal_optional_refresh_material(
408                    self,
409                    result.refresh_token.as_deref(),
410                )?,
411                access_token_expires_at: result.access_token_expiration,
412            },
413            metadata: AuthStateMetadataSnapshot {
414                principal: Some(into_authenticated_principal(extracted)),
415                source: AuthenticationSource {
416                    kind: AuthenticationSourceKind::OidcAuthorizationCode,
417                    provider_id: options.source_provider_id.clone(),
418                    issuer: Some(result.id_token_claims.issuer().url().to_string()),
419                    kind_history,
420                    attributes: options.source_attributes.clone(),
421                },
422                attributes: options.metadata_attributes.clone(),
423            },
424        })
425    }
426
427    pub fn auth_state_metadata_delta_from_refresh_result(
428        current_metadata: Option<&CurrentAuthStateMetadataSnapshotPartial>,
429        result: &OidcRefreshTokenResult,
430    ) -> AuthStateMetadataDelta {
431        let principal =
432            extract_principal_from_refresh_result(result).map(into_authenticated_principal);
433
434        AuthStateMetadataDelta {
435            principal,
436            source: Some(refreshed_source(
437                current_metadata.and_then(|m| m.source.as_ref()),
438                result,
439            )),
440            ..Default::default()
441        }
442    }
443
444    // -----------------------------------------------------------------------
445    // OIDC flow orchestration
446    // -----------------------------------------------------------------------
447
448    /// Build an authorization URL for the OIDC code flow.
449    ///
450    /// When `post_auth_redirect_policy = resolved`, the
451    /// `post_auth_redirect_uri` is resolved against the allowlist and
452    /// encoded into the OIDC state. When `caller_validated`, no redirect
453    /// URI is embedded.
454    pub async fn authorize_code_flow<PS>(
455        &self,
456        oidc_client: &OidcClient<PS>,
457        external_base_url: &Url,
458        requested_post_auth_redirect_uri: Option<&str>,
459        redirect_url_override: Option<&str>,
460    ) -> BackendOidcModeRuntimeResult<OidcCodeFlowAuthorizationRequest>
461    where
462        PS: PendingOauthStore,
463    {
464        // Validate the requested redirect URI against the allowlist (if the
465        // Resolved policy is active). We discard the resolved Url and store
466        // the *original* requested string — the callback will re-resolve it.
467        // Storing the original avoids a double-resolve mismatch where the
468        // allowlist contains relative paths but the stored value is absolute.
469        if requested_post_auth_redirect_uri.is_some() {
470            let _ = self.resolve_post_auth_redirect_uri(
471                requested_post_auth_redirect_uri,
472                external_base_url,
473            )?;
474        }
475
476        let extra_data = requested_post_auth_redirect_uri.map(|uri| {
477            json!({
478                PENDING_POST_AUTH_REDIRECT_URI_KEY: uri,
479            })
480        });
481
482        let request = oidc_client
483            .handle_code_authorize_with_redirect_override_and_extra_data(
484                external_base_url,
485                redirect_url_override,
486                extra_data,
487            )
488            .await?;
489
490        Ok(request)
491    }
492
493    /// Handle the OIDC code callback.
494    pub async fn handle_code_callback<PS>(
495        &self,
496        oidc_client: &OidcClient<PS>,
497        search_params: OidcCodeCallbackSearchParams,
498        external_base_url: &Url,
499        auth_state_options: &BackendOidcModeAuthStateOptions,
500        redirect_url_override: Option<&str>,
501    ) -> BackendOidcModeRuntimeResult<BackendOidcModeCodeCallbackResult>
502    where
503        PS: PendingOauthStore,
504    {
505        let result = oidc_client
506            .handle_code_callback_with_redirect_override(
507                search_params,
508                external_base_url,
509                redirect_url_override,
510            )
511            .await?;
512
513        // Resolve post_auth_redirect_uri.
514        let post_auth_redirect_uri = if self.redirect_uri_resolver.is_some() {
515            self.resolve_post_auth_redirect_uri(
516                callback_post_auth_redirect_uri(&result).as_deref(),
517                external_base_url,
518            )?
519        } else {
520            None
521        };
522
523        let auth_state_snapshot =
524            self.auth_state_snapshot_from_code_callback(&result, auth_state_options)?;
525
526        // Issue metadata redemption if active.
527        let metadata_redemption_id = self.issue_metadata_snapshot(&auth_state_snapshot)?;
528
529        let response_body = BackendOidcModeCallbackReturns::from_snapshot(
530            &auth_state_snapshot.tokens,
531            metadata_redemption_id,
532        );
533
534        Ok(BackendOidcModeCodeCallbackResult {
535            post_auth_redirect_uri,
536            auth_state_snapshot,
537            response_body,
538        })
539    }
540
541    /// Handle a token refresh.
542    pub async fn handle_token_refresh<PS>(
543        &self,
544        oidc_client: &OidcClient<PS>,
545        payload: &BackendOidcModeRefreshPayload,
546        external_base_url: &Url,
547    ) -> BackendOidcModeRuntimeResult<BackendOidcModeTokenRefreshResult>
548    where
549        PS: PendingOauthStore,
550    {
551        // Resolve post_auth_redirect_uri.
552        let post_auth_redirect_uri = if self.redirect_uri_resolver.is_some() {
553            self.resolve_post_auth_redirect_uri(
554                payload.post_auth_redirect_uri.as_deref(),
555                external_base_url,
556            )?
557        } else {
558            None
559        };
560
561        // Unseal or passthrough refresh token.
562        let refresh_token = self.unseal_refresh_token(&payload.refresh_material)?;
563
564        let refresh_result = oidc_client
565            .handle_token_refresh(refresh_token, payload.id_token.clone())
566            .await?;
567
568        // Re-seal or passthrough the new refresh token.
569        let refresh_material_delta = refresh_result
570            .refresh_token
571            .as_deref()
572            .map(|value| self.seal_refresh_token(value))
573            .transpose()?;
574
575        let token_delta = AuthTokenDelta {
576            access_token: refresh_result.access_token.clone(),
577            id_token: refresh_result.id_token.clone(),
578            refresh_material: refresh_material_delta,
579            access_token_expires_at: refresh_result.access_token_expiration,
580        };
581
582        let metadata_delta = Self::auth_state_metadata_delta_from_refresh_result(
583            payload.current_metadata_snapshot.as_ref(),
584            &refresh_result,
585        );
586
587        // Issue metadata redemption if active AND metadata is non-empty.
588        let metadata_redemption_id = self.issue_metadata_delta(&metadata_delta)?;
589
590        let response_body =
591            BackendOidcModeRefreshReturns::from_delta(&token_delta, metadata_redemption_id);
592
593        Ok(BackendOidcModeTokenRefreshResult {
594            post_auth_redirect_uri,
595            auth_state_delta: AuthStateDelta {
596                tokens: token_delta,
597                metadata: metadata_delta,
598            },
599            response_body,
600        })
601    }
602
603    /// Handle the OIDC code callback for a JSON body response, embedding
604    /// metadata inline.
605    ///
606    /// Compared to [`handle_code_callback`](Self::handle_code_callback) this
607    /// method:
608    ///
609    /// - Skips `post_auth_redirect_uri` resolution (irrelevant for body flows)
610    /// - Skips `issue_metadata_snapshot` and the associated store write
611    /// - Embeds `AuthStateMetadataSnapshot` directly in the response body
612    ///
613    /// This removes one store write and one client redemption round-trip,
614    /// making it the preferred implementation for `callback_body_return`.
615    pub async fn handle_code_callback_inline<PS>(
616        &self,
617        oidc_client: &OidcClient<PS>,
618        search_params: OidcCodeCallbackSearchParams,
619        external_base_url: &Url,
620        auth_state_options: &BackendOidcModeAuthStateOptions,
621        redirect_url_override: Option<&str>,
622    ) -> BackendOidcModeRuntimeResult<BackendOidcModeCodeCallbackResult>
623    where
624        PS: PendingOauthStore,
625    {
626        let result = oidc_client
627            .handle_code_callback_with_redirect_override(
628                search_params,
629                external_base_url,
630                redirect_url_override,
631            )
632            .await?;
633
634        let auth_state_snapshot =
635            self.auth_state_snapshot_from_code_callback(&result, auth_state_options)?;
636
637        // Embed metadata inline — no store write, no redemption ID.
638        let response_body = BackendOidcModeCallbackReturns::from_snapshot_with_inline_metadata(
639            &auth_state_snapshot.tokens,
640            auth_state_snapshot.metadata.clone(),
641        );
642
643        Ok(BackendOidcModeCodeCallbackResult {
644            post_auth_redirect_uri: None,
645            auth_state_snapshot,
646            response_body,
647        })
648    }
649
650    /// Handle a token refresh for a JSON body response, embedding metadata
651    /// inline.
652    ///
653    /// Compared to [`handle_token_refresh`](Self::handle_token_refresh) this
654    /// method:
655    ///
656    /// - Skips `post_auth_redirect_uri` resolution (irrelevant for body flows)
657    /// - Skips `issue_metadata_delta` and the associated store write
658    /// - Embeds `AuthStateMetadataDelta` directly in the response body
659    ///
660    /// This removes one store write and one client redemption round-trip,
661    /// making it the preferred implementation for `refresh_body_return`.
662    pub async fn handle_token_refresh_inline<PS>(
663        &self,
664        oidc_client: &OidcClient<PS>,
665        payload: &BackendOidcModeRefreshPayload,
666    ) -> BackendOidcModeRuntimeResult<BackendOidcModeTokenRefreshResult>
667    where
668        PS: PendingOauthStore,
669    {
670        // Unseal or passthrough refresh token.
671        let refresh_token = self.unseal_refresh_token(&payload.refresh_material)?;
672
673        let refresh_result = oidc_client
674            .handle_token_refresh(refresh_token, payload.id_token.clone())
675            .await?;
676
677        // Re-seal or passthrough the new refresh token.
678        let refresh_material_delta = refresh_result
679            .refresh_token
680            .as_deref()
681            .map(|value| self.seal_refresh_token(value))
682            .transpose()?;
683
684        let token_delta = AuthTokenDelta {
685            access_token: refresh_result.access_token.clone(),
686            id_token: refresh_result.id_token.clone(),
687            refresh_material: refresh_material_delta,
688            access_token_expires_at: refresh_result.access_token_expiration,
689        };
690
691        let metadata_delta = Self::auth_state_metadata_delta_from_refresh_result(
692            payload.current_metadata_snapshot.as_ref(),
693            &refresh_result,
694        );
695
696        // Embed metadata inline — no store write, no redemption ID.
697        let response_body = BackendOidcModeRefreshReturns::from_delta_with_inline_metadata(
698            &token_delta,
699            metadata_delta.clone(),
700        );
701
702        Ok(BackendOidcModeTokenRefreshResult {
703            post_auth_redirect_uri: None,
704            auth_state_delta: AuthStateDelta {
705                tokens: token_delta,
706                metadata: metadata_delta,
707            },
708            response_body,
709        })
710    }
711
712    /// Redeem metadata by one-time redemption id.
713    pub async fn redeem_metadata(
714        &self,
715        payload: &super::transport::BackendOidcModeMetadataRedemptionRequest,
716    ) -> BackendOidcModeRuntimeResult<
717        Option<super::transport::BackendOidcModeMetadataRedemptionResponse>,
718    > {
719        let store = self.metadata_redemption_store.as_ref().ok_or_else(|| {
720            BackendOidcModeRuntimeError::Config {
721                message: "metadata redemption is not enabled in this configuration".to_string(),
722            }
723        })?;
724
725        let metadata = store.redeem(&payload.metadata_redemption_id, Utc::now())?;
726
727        Ok(metadata
728            .map(|m| super::transport::BackendOidcModeMetadataRedemptionResponse { metadata: m }))
729    }
730
731    // -----------------------------------------------------------------------
732    // Internal — metadata issuance helpers
733    // -----------------------------------------------------------------------
734
735    fn issue_metadata_snapshot(
736        &self,
737        snapshot: &AuthStateSnapshot,
738    ) -> BackendOidcModeRuntimeResult<Option<MetadataRedemptionId>> {
739        match (
740            &self.metadata_delivery_kind,
741            &self.metadata_redemption_store,
742        ) {
743            (MetadataDeliveryKind::Redemption, Some(store)) => {
744                let ticket = store.issue(
745                    PendingAuthStateMetadataRedemptionPayload::Snapshot(snapshot.metadata.clone()),
746                    Utc::now(),
747                )?;
748                Ok(Some(ticket.id))
749            }
750            _ => Ok(None),
751        }
752    }
753
754    fn issue_metadata_delta(
755        &self,
756        delta: &AuthStateMetadataDelta,
757    ) -> BackendOidcModeRuntimeResult<Option<MetadataRedemptionId>> {
758        match (
759            &self.metadata_delivery_kind,
760            &self.metadata_redemption_store,
761        ) {
762            (MetadataDeliveryKind::Redemption, Some(store)) if delta.is_empty() => {
763                let ticket = store.issue(
764                    PendingAuthStateMetadataRedemptionPayload::Delta(delta.clone()),
765                    Utc::now(),
766                )?;
767                Ok(Some(ticket.id))
768            }
769            _ => Ok(None),
770        }
771    }
772}
773
774// ---------------------------------------------------------------------------
775// Private helpers — shared auth-state construction
776// ---------------------------------------------------------------------------
777
778fn refreshed_source(
779    current_source: Option<&crate::models::CurrentAuthenticationSourcePartial>,
780    result: &OidcRefreshTokenResult,
781) -> AuthenticationSource {
782    let mut source = AuthenticationSource {
783        kind: AuthenticationSourceKind::RefreshToken,
784        provider_id: current_source.and_then(|s| s.provider_id.clone()),
785        issuer: current_source.and_then(|s| s.issuer.clone()),
786        kind_history: current_source
787            .and_then(|s| s.kind_history.as_ref())
788            .cloned()
789            .unwrap_or_default(),
790        attributes: current_source
791            .map(|s| s.attributes.clone())
792            .unwrap_or_default(),
793    };
794    push_kind_history(
795        &mut source.kind_history,
796        &AuthenticationSourceKind::RefreshToken,
797    );
798
799    if let Some(issuer) = extract_issuer_from_refresh_result(result) {
800        source.issuer = Some(issuer);
801    }
802
803    source
804}
805
806fn into_authenticated_principal(extracted: OidcExtractedPrincipal) -> AuthenticatedPrincipal {
807    extracted
808}
809
810fn push_kind_history(history: &mut Vec<AuthenticationSourceKind>, kind: &AuthenticationSourceKind) {
811    if history.last() != Some(kind) {
812        history.push(kind.clone());
813    }
814}
815
816fn seal_optional_refresh_material<MS>(
817    runtime: &BackendOidcModeRuntime<MS>,
818    refresh_token: Option<&str>,
819) -> Result<Option<SealedRefreshMaterial>, BackendOidcModeRuntimeError>
820where
821    MS: PendingAuthStateMetadataRedemptionStore,
822{
823    refresh_token
824        .map(|value| runtime.seal_refresh_token(value))
825        .transpose()
826}
827
828fn callback_post_auth_redirect_uri(result: &OidcCodeCallbackResult) -> Option<String> {
829    result
830        .pending_extra_data
831        .as_ref()
832        .and_then(|value| value.get(PENDING_POST_AUTH_REDIRECT_URI_KEY))
833        .and_then(|value| value.as_str())
834        .map(ToOwned::to_owned)
835}
836
837#[cfg(test)]
838mod tests {
839    use super::{push_kind_history, refreshed_source};
840    use crate::models::{
841        AuthStateMetadataDelta, AuthenticationSourceKind, CurrentAuthenticationSourcePartial,
842    };
843
844    #[test]
845    fn kind_history_appends_new_kinds() {
846        let mut history = Vec::new();
847
848        push_kind_history(
849            &mut history,
850            &AuthenticationSourceKind::OidcAuthorizationCode,
851        );
852        push_kind_history(&mut history, &AuthenticationSourceKind::RefreshToken);
853
854        assert_eq!(
855            history,
856            vec![
857                AuthenticationSourceKind::OidcAuthorizationCode,
858                AuthenticationSourceKind::RefreshToken
859            ]
860        );
861    }
862
863    #[test]
864    fn kind_history_merges_same_top_kind() {
865        let mut history = vec![AuthenticationSourceKind::RefreshToken];
866
867        push_kind_history(&mut history, &AuthenticationSourceKind::RefreshToken);
868
869        assert_eq!(history, vec![AuthenticationSourceKind::RefreshToken]);
870    }
871
872    #[test]
873    fn metadata_delta_is_generated_without_previous_snapshot() {
874        let delta: AuthStateMetadataDelta = AuthStateMetadataDelta {
875            source: Some(refreshed_source(None, &mock_refresh_result())),
876            ..Default::default()
877        };
878
879        assert_eq!(
880            delta.source.as_ref().map(|source| &source.kind),
881            Some(&AuthenticationSourceKind::RefreshToken)
882        );
883        assert_eq!(
884            delta.source.as_ref().map(|source| &source.kind_history),
885            Some(&vec![AuthenticationSourceKind::RefreshToken])
886        );
887    }
888
889    #[test]
890    fn refreshed_source_preserves_partial_source_fields() {
891        let source = refreshed_source(
892            Some(&CurrentAuthenticationSourcePartial {
893                provider_id: Some("primary".to_string()),
894                issuer: Some("https://issuer.example.com".to_string()),
895                kind_history: Some(vec![AuthenticationSourceKind::OidcAuthorizationCode]),
896                ..Default::default()
897            }),
898            &mock_refresh_result(),
899        );
900
901        assert_eq!(source.provider_id.as_deref(), Some("primary"));
902        assert_eq!(source.issuer.as_deref(), Some("https://issuer.example.com"));
903        assert_eq!(
904            source.kind_history,
905            vec![
906                AuthenticationSourceKind::OidcAuthorizationCode,
907                AuthenticationSourceKind::RefreshToken
908            ]
909        );
910    }
911
912    fn mock_refresh_result() -> securitydept_oidc_client::OidcRefreshTokenResult {
913        securitydept_oidc_client::OidcRefreshTokenResult {
914            access_token: "access-token".to_string(),
915            access_token_expiration: None,
916            id_token: None,
917            refresh_token: None,
918            id_token_claims: None,
919            user_info_claims: None,
920            claims_check_result: None,
921        }
922    }
923}