Skip to main content

securitydept_token_set_context/frontend_oidc_mode/
runtime.rs

1//! `frontend-oidc` mode runtime.
2//!
3//! In `frontend-oidc` mode the browser runs the full OIDC flow. The Rust
4//! backend provides configuration projection but does not host a
5//! client/callback/refresh runtime.
6//!
7//! `FrontendOidcModeRuntime` exists as the formal runtime landing point so
8//! that future capabilities (browser-callback policy enforcement, frontend
9//! token handoff validation, address validation, etc.) have a proper owner.
10
11use securitydept_utils::observability::{
12    AuthFlowDiagnosis, AuthFlowDiagnosisField, AuthFlowOperation, DiagnosedResult,
13};
14
15use super::{
16    capabilities::FrontendOidcModeCapabilities, config::ResolvedFrontendOidcModeConfig,
17    contracts::FrontendOidcModeConfigProjection,
18};
19
20// ---------------------------------------------------------------------------
21// Runtime
22// ---------------------------------------------------------------------------
23
24/// Runtime for `frontend-oidc` mode.
25///
26/// Wraps the resolved config (which already embeds capabilities) and provides
27/// helpers for config projection generation. Capabilities are accessed via
28/// `self.config.capabilities` — they are carried through from the raw config
29/// by [`FrontendOidcModeConfigSource::resolve_all`].
30#[derive(Debug, Clone)]
31pub struct FrontendOidcModeRuntime {
32    config: ResolvedFrontendOidcModeConfig,
33}
34
35impl FrontendOidcModeRuntime {
36    /// Create a new runtime from a resolved config.
37    ///
38    /// Emits startup warnings for any unsafe capabilities that are enabled.
39    pub fn new(config: ResolvedFrontendOidcModeConfig) -> Self {
40        config.capabilities.warn_unsafe();
41        Self { config }
42    }
43
44    /// Access the resolved config (including capabilities).
45    pub fn config(&self) -> &ResolvedFrontendOidcModeConfig {
46        &self.config
47    }
48
49    /// Access the capability axes.
50    pub fn capabilities(&self) -> &FrontendOidcModeCapabilities {
51        &self.config.capabilities
52    }
53
54    /// Build a config projection for the frontend.
55    ///
56    /// Capability settings are read from `self.config.capabilities` — e.g.
57    /// `client_secret` is only included when `UnsafeFrontendClientSecret` is
58    /// enabled.
59    ///
60    /// # Errors
61    ///
62    /// Returns an `io::Error` if the claims check script file cannot be read.
63    pub async fn config_projection(&self) -> std::io::Result<FrontendOidcModeConfigProjection> {
64        self.config_projection_with_diagnosis().await.into_result()
65    }
66
67    /// Build a config projection and return a machine-readable diagnosis.
68    pub async fn config_projection_with_diagnosis(
69        &self,
70    ) -> DiagnosedResult<FrontendOidcModeConfigProjection, std::io::Error> {
71        let base_diagnosis = AuthFlowDiagnosis::started(AuthFlowOperation::PROJECTION_CONFIG_FETCH)
72            .field(AuthFlowDiagnosisField::MODE, "frontend_oidc")
73            .field("client_id", self.config.oidc_client.client_id.clone())
74            .field("pkce_enabled", self.config.oidc_client.pkce_enabled)
75            .field(
76                "claims_check_script_configured",
77                self.config.oidc_client.claims_check_script.is_some(),
78            );
79
80        match self.config.to_config_projection().await {
81            Ok(projection) => DiagnosedResult::success(
82                base_diagnosis
83                    .with_outcome(
84                        securitydept_utils::observability::AuthFlowDiagnosisOutcome::Succeeded,
85                    )
86                    .field("has_client_secret", projection.client_secret.is_some())
87                    .field(
88                        "has_claims_check_script",
89                        projection.claims_check_script.is_some(),
90                    ),
91                projection,
92            ),
93            Err(error) => DiagnosedResult::failure(
94                base_diagnosis
95                    .with_outcome(
96                        securitydept_utils::observability::AuthFlowDiagnosisOutcome::Failed,
97                    )
98                    .field(
99                        AuthFlowDiagnosisField::FAILURE_STAGE,
100                        "projection_generation",
101                    ),
102                error,
103            ),
104        }
105    }
106}
107
108// ---------------------------------------------------------------------------
109// Tests
110// ---------------------------------------------------------------------------
111
112#[cfg(test)]
113mod tests {
114    use securitydept_oauth_provider::{OAuthProviderRemoteConfig, OidcSharedConfig};
115
116    use super::*;
117    use crate::frontend_oidc_mode::{
118        capabilities::UnsafeFrontendClientSecret,
119        config::{FrontendOidcModeConfig, FrontendOidcModeConfigSource},
120    };
121
122    fn test_runtime() -> FrontendOidcModeRuntime {
123        let shared = OidcSharedConfig {
124            remote: OAuthProviderRemoteConfig {
125                well_known_url: Some(
126                    "https://auth.example.com/.well-known/openid-configuration".to_string(),
127                ),
128                ..Default::default()
129            },
130            client_id: Some("spa-client".to_string()),
131            ..Default::default()
132        };
133
134        let config = FrontendOidcModeConfig::default()
135            .resolve_all(&shared)
136            .expect("should resolve");
137        FrontendOidcModeRuntime::new(config)
138    }
139
140    #[tokio::test]
141    async fn runtime_produces_config_projection() {
142        let runtime = test_runtime();
143        let projection = runtime
144            .config_projection()
145            .await
146            .expect("projection should succeed");
147        assert_eq!(projection.client_id, "spa-client");
148        assert_eq!(
149            projection.well_known_url.as_deref(),
150            Some("https://auth.example.com/.well-known/openid-configuration")
151        );
152        // Default capabilities: client_secret should NOT be exposed
153        assert!(projection.client_secret.is_none());
154    }
155
156    #[tokio::test]
157    async fn runtime_exposes_client_secret_when_capability_enabled() {
158        let shared = OidcSharedConfig {
159            remote: OAuthProviderRemoteConfig {
160                well_known_url: Some(
161                    "https://auth.example.com/.well-known/openid-configuration".to_string(),
162                ),
163                ..Default::default()
164            },
165            client_id: Some("spa-client".to_string()),
166            ..Default::default()
167        };
168
169        let config = FrontendOidcModeConfig {
170            oidc_client: securitydept_oidc_client::OidcClientRawConfig {
171                client_secret: Some("test-secret".to_string()),
172                ..Default::default()
173            },
174            capabilities: FrontendOidcModeCapabilities {
175                unsafe_frontend_client_secret: UnsafeFrontendClientSecret::Enabled,
176            },
177        };
178        let resolved = config.resolve_all(&shared).expect("should resolve");
179        let runtime = FrontendOidcModeRuntime::new(resolved);
180        let projection = runtime
181            .config_projection()
182            .await
183            .expect("projection should succeed");
184        assert_eq!(projection.client_secret.as_deref(), Some("test-secret"));
185    }
186
187    #[tokio::test]
188    async fn runtime_reports_projection_diagnosis() {
189        let runtime = test_runtime();
190        let diagnosed = runtime.config_projection_with_diagnosis().await;
191
192        assert!(diagnosed.result().is_ok());
193        assert_eq!(
194            diagnosed.diagnosis().operation,
195            AuthFlowOperation::PROJECTION_CONFIG_FETCH
196        );
197        assert_eq!(diagnosed.diagnosis().outcome.as_str(), "succeeded");
198        assert_eq!(
199            diagnosed.diagnosis().fields[AuthFlowDiagnosisField::MODE],
200            "frontend_oidc"
201        );
202        assert_eq!(diagnosed.diagnosis().fields["client_id"], "spa-client");
203    }
204}