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#[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// ---------------------------------------------------------------------------
141// Runtime
142// ---------------------------------------------------------------------------
143
144/// Unified backend-oidc runtime.
145///
146/// Parameterized by a metadata-redemption store (which may be a no-op for
147/// the pure preset). Provides the single implementation of authorize,
148/// callback, refresh, and metadata redemption.
149#[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// ---------------------------------------------------------------------------
170// Error
171// ---------------------------------------------------------------------------
172
173/// Error type for the unified backend-oidc runtime.
174#[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
242// ---------------------------------------------------------------------------
243// Config validation
244// ---------------------------------------------------------------------------
245
246impl<MC> BackendOidcModeRuntimeConfig<MC>
247where
248    MC: PendingAuthStateMetadataRedemptionConfig,
249{
250    /// Validate the configuration without constructing the runtime.
251    ///
252    /// Most invariants are enforced by the structured enum types.
253    /// This method validates things the type system cannot
254    /// (e.g. redirect URI format).
255    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
264// ---------------------------------------------------------------------------
265// Runtime impl — construction
266// ---------------------------------------------------------------------------
267
268impl<MS> BackendOidcModeRuntime<MS>
269where
270    MS: PendingAuthStateMetadataRedemptionStore,
271{
272    /// Build the unified runtime from its config.
273    pub fn from_config(
274        config: BackendOidcModeRuntimeConfig<MS::Config>,
275    ) -> BackendOidcModeRuntimeResult<Self> {
276        // Build refresh material protector — the `Sealed` variant carries
277        // master_key by type, so no Option check needed.
278        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        // Build metadata redemption store — the `Redemption` variant carries
288        // the store config by type.
289        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        // Build redirect URI resolver — the `Resolved` variant carries the
296        // redirect config by type.
297        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    /// **Recommended entry point.** Build both the runtime and the optional
316    /// OIDC client from a resolved backend-oidc config.
317    ///
318    /// Mirrors [`AccessTokenSubstrateRuntime::from_resolved_config`]:
319    ///
320    /// ```text
321    /// BackendOidcModeRuntime::from_resolved_config(resolved_oidc.as_ref()).await?
322    ///   ──▸ (BackendOidcModeRuntime<MS>, Option<Arc<OidcClient<PS>>>)
323    ///
324    /// AccessTokenSubstrateRuntime::from_resolved_config(&resolved_substrate).await?
325    ///   ──▸ (AccessTokenSubstrateRuntime, Option<Arc<OAuthResourceServerVerifier>>)
326    /// ```
327    ///
328    /// Pass `None` when OIDC is disabled — the runtime is built from a default
329    /// config and `oidc_client` will be `None`.
330    ///
331    /// # Type parameters
332    /// - `PS` — pending OAuth store (Store type, e.g. `MokaPendingOauthStore`)
333    /// - `MS` — pending auth-state metadata redemption store (inferred from
334    ///   `Self`)
335    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    // -----------------------------------------------------------------------
356    // Low-level helpers
357    // -----------------------------------------------------------------------
358
359    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), // caller_validated — no resolution here
388        }
389    }
390
391    // -----------------------------------------------------------------------
392    // Auth-state construction (unified from pure + mediated)
393    // -----------------------------------------------------------------------
394
395    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    // -----------------------------------------------------------------------
450    // OIDC flow orchestration
451    // -----------------------------------------------------------------------
452
453    /// Build an authorization URL for the OIDC code flow.
454    ///
455    /// When `post_auth_redirect_policy = resolved`, the
456    /// `post_auth_redirect_uri` is resolved against the allowlist and
457    /// encoded into the OIDC state. When `caller_validated`, no redirect
458    /// URI is embedded.
459    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        // Validate the requested redirect URI against the allowlist (if the
470        // Resolved policy is active). We discard the resolved Url and store
471        // the *original* requested string — the callback will re-resolve it.
472        // Storing the original avoids a double-resolve mismatch where the
473        // allowlist contains relative paths but the stored value is absolute.
474        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    /// Handle the OIDC code callback.
499    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        // Resolve post_auth_redirect_uri.
519        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        // Issue metadata redemption if active.
532        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    /// Handle a token refresh.
547    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        // Resolve post_auth_redirect_uri.
557        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        // Unseal or passthrough refresh token.
567        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        // Re-seal or passthrough the new refresh token.
574        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        // Issue metadata redemption if active AND metadata is non-empty.
593        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    /// Handle the OIDC code callback for a JSON body response, embedding
609    /// metadata inline.
610    ///
611    /// Compared to [`handle_code_callback`](Self::handle_code_callback) this
612    /// method:
613    ///
614    /// - Skips `post_auth_redirect_uri` resolution (irrelevant for body flows)
615    /// - Skips `issue_metadata_snapshot` and the associated store write
616    /// - Embeds `AuthStateMetadataSnapshot` directly in the response body
617    ///
618    /// This removes one store write and one client redemption round-trip,
619    /// making it the preferred implementation for `callback_body_return`.
620    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        // Embed metadata inline — no store write, no redemption ID.
643        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    /// Handle a token refresh for a JSON body response, embedding metadata
656    /// inline.
657    ///
658    /// Compared to [`handle_token_refresh`](Self::handle_token_refresh) this
659    /// method:
660    ///
661    /// - Skips `post_auth_redirect_uri` resolution (irrelevant for body flows)
662    /// - Skips `issue_metadata_delta` and the associated store write
663    /// - Embeds `AuthStateMetadataDelta` directly in the response body
664    ///
665    /// This removes one store write and one client redemption round-trip,
666    /// making it the preferred implementation for `refresh_body_return`.
667    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        // Unseal or passthrough refresh token.
676        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        // Re-seal or passthrough the new refresh token.
683        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        // Embed metadata inline — no store write, no redemption ID.
702        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    /// Redeem metadata by one-time redemption id.
718    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    // -----------------------------------------------------------------------
737    // Internal — metadata issuance helpers
738    // -----------------------------------------------------------------------
739
740    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
779// ---------------------------------------------------------------------------
780// Private helpers — shared auth-state construction
781// ---------------------------------------------------------------------------
782
783fn 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}