securitydept_token_set_context/frontend_oidc_mode/
config.rs1use 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#[derive(Debug, Clone, Default, Deserialize)]
42pub struct NoPendingStoreConfig;
43
44impl securitydept_oidc_client::PendingOauthStoreConfig for NoPendingStoreConfig {}
45
46pub trait FrontendOidcModeConfigSource {
57 fn oidc_client_raw_config(&self) -> &OidcClientRawConfig<NoPendingStoreConfig>;
59
60 fn capabilities(&self) -> &FrontendOidcModeCapabilities;
62
63 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 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#[derive(Debug, Clone, Deserialize, Default)]
119pub struct FrontendOidcModeConfig {
120 #[serde(default, flatten)]
122 pub oidc_client: OidcClientRawConfig<NoPendingStoreConfig>,
123
124 #[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#[derive(Debug, Clone)]
150pub struct ResolvedFrontendOidcModeConfig {
151 pub oidc_client: OidcClientConfig<NoPendingStoreConfig>,
153 pub capabilities: FrontendOidcModeCapabilities,
155}
156
157impl ResolvedFrontendOidcModeConfig {
158 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 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 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 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_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#[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 assert!(projection.client_secret.is_none());
335 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}