Skip to main content

securitydept_token_set_context/frontend_oidc_mode/
config.rs

1//! Configuration types for `frontend-oidc` mode.
2//!
3//! This module mirrors the formal config pattern established by
4//! [`backend_oidc_mode::config`](crate::backend_oidc_mode::config):
5//!
6//! - [`FrontendOidcModeConfig`] — raw input (deserializable from TOML/env)
7//! - [`ResolvedFrontendOidcModeConfig`] — validated bundle
8//! - [`FrontendOidcModeConfigSource`] — trait for config providers
9//!
10//! # Relationship with `OidcClientRawConfig`
11//!
12//! Both `backend-oidc` and `frontend-oidc` modes reuse
13//! `OidcClientRawConfig<PC>` as the OIDC client config component. The key
14//! difference is:
15//!
16//! - `backend-oidc` uses a real `PendingOauthStoreConfig` implementation (e.g.
17//!   moka) because the backend runs the full OIDC flow.
18//! - `frontend-oidc` uses [`NoPendingStoreConfig`] — a no-op implementation —
19//!   because the browser owns the OIDC flow. The `OidcClientRawConfig` is used
20//!   only to project config to the frontend and to share `[oidc]` defaults.
21
22use securitydept_oauth_provider::OidcSharedConfig;
23use serde::Deserialize;
24
25use super::{
26    capabilities::FrontendOidcModeCapabilities,
27    contracts::{FrontendOidcModeClaimsCheckScript, FrontendOidcModeConfigProjection},
28};
29use crate::orchestration::{BackendConfigError, OidcClientConfig, OidcClientRawConfig};
30
31// ---------------------------------------------------------------------------
32// No-op pending store config (frontend-oidc never runs OIDC flows)
33// ---------------------------------------------------------------------------
34
35/// No-op pending store config for `frontend-oidc` mode.
36///
37/// In `frontend-oidc` mode the browser owns the OIDC flow, so the backend
38/// never stores pending OAuth state (nonce, PKCE verifier). This type
39/// satisfies the `PendingOauthStoreConfig` bound on `OidcClientRawConfig`
40/// without adding any configuration surface.
41#[derive(Debug, Clone, Default, Deserialize)]
42pub struct NoPendingStoreConfig;
43
44impl securitydept_oidc_client::PendingOauthStoreConfig for NoPendingStoreConfig {}
45
46// ---------------------------------------------------------------------------
47// Config source trait
48// ---------------------------------------------------------------------------
49
50/// Trait for types that supply `frontend-oidc` configuration.
51///
52/// Follows the same pattern as
53/// [`BackendOidcModeConfigSource`](crate::backend_oidc_mode::BackendOidcModeConfigSource):
54/// implementors expose component-config accessors and gain default `resolve_*`
55/// helper methods that apply `[oidc]` shared defaults.
56pub trait FrontendOidcModeConfigSource {
57    /// Access the OIDC client raw config (public-client subset).
58    fn oidc_client_raw_config(&self) -> &OidcClientRawConfig<NoPendingStoreConfig>;
59
60    /// Access the capability axes.
61    fn capabilities(&self) -> &FrontendOidcModeCapabilities;
62
63    /// Resolve OIDC client config by applying shared defaults.
64    fn resolve_oidc_client(
65        &self,
66        shared: &OidcSharedConfig,
67    ) -> Result<OidcClientConfig<NoPendingStoreConfig>, BackendConfigError> {
68        self.oidc_client_raw_config()
69            .clone()
70            .resolve_config(shared)
71            .map_err(Into::into)
72    }
73
74    /// **Recommended entry point.** Resolve all frontend-oidc sub-configs
75    /// in one step.
76    fn resolve_all(
77        &self,
78        shared: &OidcSharedConfig,
79    ) -> Result<ResolvedFrontendOidcModeConfig, BackendConfigError> {
80        Ok(ResolvedFrontendOidcModeConfig {
81            oidc_client: self.resolve_oidc_client(shared)?,
82            capabilities: self.capabilities().clone(),
83        })
84    }
85}
86
87// ---------------------------------------------------------------------------
88// Raw config (TOML / env deserializable)
89// ---------------------------------------------------------------------------
90
91/// Raw configuration for a `frontend-oidc` deployment.
92///
93/// Reuses `OidcClientRawConfig` (the same OIDC client config vocabulary as
94/// `backend-oidc`) with [`NoPendingStoreConfig`] since the frontend owns
95/// the OIDC flow. Inherits `[oidc]` shared defaults via the same
96/// `resolve_config()` mechanism.
97///
98/// Capability axes are **flattened** at the top level so they can be
99/// configured inline in the same section as OIDC client settings.
100///
101/// ```text
102/// [oidc]
103/// well_known_url = "https://auth.example.com/.well-known/openid-configuration"
104/// client_id      = "shared-app"
105///
106/// [frontend_oidc]
107/// # OIDC client (inherited from [oidc] if absent)
108/// scopes       = ["openid", "profile", "email"]
109/// redirect_url = "https://app.example.com/callback"
110///
111/// # Capability axes
112/// unsafe_frontend_client_secret = "disabled"   # default; use "enabled" only as last resort
113/// ```
114///
115/// Note: `client_secret` is accepted by `OidcClientRawConfig` but exposing it
116/// to the browser is a security anti-pattern; enable
117/// `unsafe_frontend_client_secret` only for broken providers.
118#[derive(Debug, Clone, Deserialize, Default)]
119pub struct FrontendOidcModeConfig {
120    /// OIDC client raw config (public-client subset, no pending-store).
121    #[serde(default, flatten)]
122    pub oidc_client: OidcClientRawConfig<NoPendingStoreConfig>,
123
124    /// Capability axes controlling opt-in unsafe behaviours.
125    #[serde(default, flatten)]
126    pub capabilities: FrontendOidcModeCapabilities,
127}
128
129impl FrontendOidcModeConfigSource for FrontendOidcModeConfig {
130    fn oidc_client_raw_config(&self) -> &OidcClientRawConfig<NoPendingStoreConfig> {
131        &self.oidc_client
132    }
133
134    fn capabilities(&self) -> &FrontendOidcModeCapabilities {
135        &self.capabilities
136    }
137}
138
139// ---------------------------------------------------------------------------
140// Resolved (validated) config
141// ---------------------------------------------------------------------------
142
143/// Validated configuration bundle for `frontend-oidc` mode.
144///
145/// Produced by [`FrontendOidcModeConfigSource::resolve_all`]. The
146/// `oidc_client` has had `[oidc]` shared defaults applied and passed
147/// validation. Capabilities are carried through from the raw config so
148/// that `to_config_projection` can apply them without external parameters.
149#[derive(Debug, Clone)]
150pub struct ResolvedFrontendOidcModeConfig {
151    /// Resolved OIDC client config with shared defaults applied.
152    pub oidc_client: OidcClientConfig<NoPendingStoreConfig>,
153    /// Resolved capability axes.
154    pub capabilities: FrontendOidcModeCapabilities,
155}
156
157impl ResolvedFrontendOidcModeConfig {
158    /// Build a config projection for the frontend.
159    ///
160    /// - `client_secret` is only included when `UnsafeFrontendClientSecret` is
161    ///   enabled in `self.capabilities`.
162    /// - `claims_check_script`, if configured, is read from the filesystem and
163    ///   embedded inline as [`FrontendOidcModeClaimsCheckScript::Inline`].
164    ///
165    /// # Errors
166    ///
167    /// Returns an `io::Error` if the claims check script path is configured but
168    /// the file cannot be read or transpiled.
169    pub async fn to_config_projection(&self) -> std::io::Result<FrontendOidcModeConfigProjection> {
170        let client_secret = if self.capabilities.unsafe_frontend_client_secret.is_enabled() {
171            self.oidc_client.client_secret.clone()
172        } else {
173            None
174        };
175
176        // Read the script file from the filesystem and inline it.
177        let claims_check_script = match self.oidc_client.claims_check_script.as_deref() {
178            Some(path) => Some(FrontendOidcModeClaimsCheckScript::from_path(path).await?),
179            None => None,
180        };
181
182        Ok(FrontendOidcModeConfigProjection {
183            // Provider connectivity
184            well_known_url: self.oidc_client.remote.well_known_url.clone(),
185            issuer_url: self.oidc_client.remote.issuer_url.clone(),
186            jwks_uri: self.oidc_client.remote.jwks_uri.clone(),
187            metadata_refresh_interval: self.oidc_client.remote.metadata_refresh_interval,
188            jwks_refresh_interval: self.oidc_client.remote.jwks_refresh_interval,
189            // Provider OIDC endpoints
190            authorization_endpoint: self
191                .oidc_client
192                .provider_oidc
193                .authorization_endpoint
194                .clone(),
195            token_endpoint: self.oidc_client.provider_oidc.token_endpoint.clone(),
196            userinfo_endpoint: self.oidc_client.provider_oidc.userinfo_endpoint.clone(),
197            revocation_endpoint: self.oidc_client.provider_oidc.revocation_endpoint.clone(),
198            token_endpoint_auth_methods_supported: self
199                .oidc_client
200                .provider_oidc
201                .token_endpoint_auth_methods_supported
202                .as_ref()
203                .map(|v| {
204                    v.iter()
205                        .filter_map(|a| serde_json::to_value(a).ok())
206                        .filter_map(|v| v.as_str().map(|s| s.to_owned()))
207                        .collect()
208                }),
209            id_token_signing_alg_values_supported: self
210                .oidc_client
211                .provider_oidc
212                .id_token_signing_alg_values_supported
213                .as_ref()
214                .map(|v| {
215                    v.iter()
216                        .filter_map(|a| serde_json::to_value(a).ok())
217                        .filter_map(|v| v.as_str().map(|s| s.to_owned()))
218                        .collect()
219                }),
220            userinfo_signing_alg_values_supported: self
221                .oidc_client
222                .provider_oidc
223                .userinfo_signing_alg_values_supported
224                .as_ref()
225                .map(|v| {
226                    v.iter()
227                        .filter_map(|a| serde_json::to_value(a).ok())
228                        .filter_map(|v| v.as_str().map(|s| s.to_owned()))
229                        .collect()
230                }),
231
232            // Client settings
233            client_id: self.oidc_client.client_id.clone(),
234            client_secret,
235            scopes: self.oidc_client.scopes.clone(),
236            required_scopes: self.oidc_client.required_scopes.clone(),
237            redirect_url: self.oidc_client.redirect_url.clone(),
238            pkce_enabled: self.oidc_client.pkce_enabled,
239            claims_check_script,
240            generated_at: std::time::SystemTime::now()
241                .duration_since(std::time::UNIX_EPOCH)
242                .unwrap_or_default()
243                .as_millis() as u64,
244        })
245    }
246}
247
248// ---------------------------------------------------------------------------
249// Tests
250// ---------------------------------------------------------------------------
251
252#[cfg(test)]
253mod tests {
254    use securitydept_oauth_provider::{OAuthProviderRemoteConfig, OidcSharedConfig};
255
256    use super::*;
257
258    fn shared_config() -> OidcSharedConfig {
259        OidcSharedConfig {
260            remote: OAuthProviderRemoteConfig {
261                well_known_url: Some(
262                    "https://auth.example.com/.well-known/openid-configuration".to_string(),
263                ),
264                ..Default::default()
265            },
266            client_id: Some("shared-app".to_string()),
267            client_secret: Some("shared-secret".to_string()),
268            ..Default::default()
269        }
270    }
271
272    #[test]
273    fn resolve_inherits_client_id_from_shared() {
274        let raw = FrontendOidcModeConfig::default();
275        let resolved = raw.resolve_all(&shared_config()).expect("should resolve");
276        assert_eq!(resolved.oidc_client.client_id, "shared-app");
277    }
278
279    #[test]
280    fn local_client_id_overrides_shared() {
281        let raw = FrontendOidcModeConfig {
282            oidc_client: OidcClientRawConfig {
283                client_id: Some("local-spa".to_string()),
284                ..Default::default()
285            },
286            capabilities: Default::default(),
287        };
288        let resolved = raw.resolve_all(&shared_config()).expect("should resolve");
289        assert_eq!(resolved.oidc_client.client_id, "local-spa");
290    }
291
292    #[test]
293    fn resolve_inherits_well_known_url_from_shared() {
294        let raw = FrontendOidcModeConfig::default();
295        let resolved = raw.resolve_all(&shared_config()).expect("should resolve");
296        assert_eq!(
297            resolved.oidc_client.remote.well_known_url.as_deref(),
298            Some("https://auth.example.com/.well-known/openid-configuration"),
299        );
300    }
301
302    #[test]
303    fn resolve_fails_without_client_id() {
304        let shared = OidcSharedConfig::default();
305        let raw = FrontendOidcModeConfig::default();
306        let err = raw.resolve_all(&shared).unwrap_err();
307        assert!(err.to_string().contains("client_id must be set"));
308    }
309
310    #[tokio::test]
311    async fn projection_reflects_resolved_config() {
312        let raw = FrontendOidcModeConfig {
313            oidc_client: OidcClientRawConfig {
314                redirect_url: Some("https://app.example.com/callback".to_string()),
315                pkce_enabled: true,
316                ..Default::default()
317            },
318            capabilities: Default::default(),
319        };
320        let resolved = raw.resolve_all(&shared_config()).expect("should resolve");
321        let projection = resolved
322            .to_config_projection()
323            .await
324            .expect("projection should succeed");
325
326        assert_eq!(
327            projection.well_known_url.as_deref(),
328            Some("https://auth.example.com/.well-known/openid-configuration")
329        );
330        assert_eq!(projection.client_id, "shared-app");
331        assert_eq!(projection.redirect_url, "https://app.example.com/callback");
332        assert!(projection.pkce_enabled);
333        // client_secret NOT exposed by default
334        assert!(projection.client_secret.is_none());
335        // Endpoint overrides default to None (derived from discovery)
336        assert!(projection.authorization_endpoint.is_none());
337        assert!(projection.token_endpoint.is_none());
338        assert!(projection.userinfo_endpoint.is_none());
339    }
340
341    #[test]
342    fn default_scopes_applied() {
343        let raw = FrontendOidcModeConfig::default();
344        let resolved = raw.resolve_all(&shared_config()).expect("should resolve");
345        assert_eq!(
346            resolved.oidc_client.scopes,
347            vec![
348                "openid".to_string(),
349                "profile".to_string(),
350                "email".to_string()
351            ]
352        );
353    }
354}