securitydept_oidc_client/
config.rs1use openidconnect::core::CoreJwsSigningAlgorithm;
2use securitydept_oauth_provider::{
3 OAuthProviderConfig, OAuthProviderOidcConfig, OAuthProviderRemoteConfig, OidcSharedConfig,
4};
5use securitydept_utils::ser::CommaOrSpaceSeparated;
6use serde::Deserialize;
7use serde_with::{NoneAsEmptyString, PickFirst, serde_as};
8
9use crate::{OidcError, OidcResult, PendingOauthStoreConfig};
10
11#[serde_as]
21#[derive(Debug, Clone, Deserialize)]
22pub struct OidcClientConfig<PC>
23where
24 PC: PendingOauthStoreConfig,
25{
26 pub client_id: String,
27 #[serde(default)]
28 pub client_secret: Option<String>,
29 #[serde(flatten)]
31 pub remote: OAuthProviderRemoteConfig,
32 #[serde(flatten)]
34 pub provider_oidc: OAuthProviderOidcConfig,
35 #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
36 #[serde(default = "default_scopes")]
37 pub scopes: Vec<String>,
38 #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
45 #[serde(default)]
46 pub required_scopes: Vec<String>,
47 #[serde(default)]
48 pub claims_check_script: Option<String>,
49 #[serde(default)]
52 pub pkce_enabled: bool,
53 #[serde(default = "default_redirect_url")]
54 pub redirect_url: String,
55 #[serde(default, bound = "PC: PendingOauthStoreConfig")]
57 pub pending_store: Option<PC>,
58 #[serde(default = "default_device_poll_interval", with = "humantime_serde")]
61 pub device_poll_interval: std::time::Duration,
62}
63
64impl<PC> OidcClientConfig<PC>
65where
66 PC: PendingOauthStoreConfig,
67{
68 pub fn validate(&self) -> OidcResult<()> {
69 if self.claims_check_script.is_some() && cfg!(not(feature = "claims-script")) {
70 return Err(OidcError::InvalidConfig {
71 message: "Claims check script is enabled but the claims-script feature is disabled"
72 .to_string(),
73 });
74 }
75 if self.remote.well_known_url.is_none() {
76 let missing: Vec<&str> = [
77 ("issuer_url", self.remote.issuer_url.as_deref()),
78 (
79 "authorization_endpoint",
80 self.provider_oidc.authorization_endpoint.as_deref(),
81 ),
82 (
83 "token_endpoint",
84 self.provider_oidc.token_endpoint.as_deref(),
85 ),
86 ("jwks_uri", self.remote.jwks_uri.as_deref()),
87 (
88 "userinfo_endpoint",
89 self.provider_oidc.userinfo_endpoint.as_deref(),
90 ),
91 ]
92 .into_iter()
93 .filter_map(|(name, v)| match v {
94 None | Some("") => Some(name),
95 Some(s) if s.trim().is_empty() => Some(name),
96 _ => None,
97 })
98 .collect();
99 if missing.len() > 1 || (missing.len() == 1 && missing[0] != "userinfo_endpoint") {
100 return Err(OidcError::InvalidConfig {
101 message: format!(
102 "When well_known_url is not set, all of issuer_url, \
103 authorization_endpoint, token_endpoint, and jwks_uri must be set; \
104 userinfo_endpoint is recommended and only enables user_info_claims \
105 fetch; missing: {}",
106 missing.join(", ")
107 ),
108 });
109 }
110 }
111 Ok(())
112 }
113
114 pub fn provider_config(&self) -> OAuthProviderConfig {
115 OAuthProviderConfig {
116 remote: self.remote.clone(),
117 oidc: self.provider_oidc.clone(),
118 }
119 }
120}
121
122#[serde_as]
142#[derive(Debug, Clone, Deserialize)]
143pub struct OidcClientRawConfig<PC>
144where
145 PC: PendingOauthStoreConfig,
146{
147 #[serde(default)]
149 #[serde_as(as = "NoneAsEmptyString")]
150 pub client_id: Option<String>,
151 #[serde(default)]
153 #[serde_as(as = "NoneAsEmptyString")]
154 pub client_secret: Option<String>,
155 #[serde(flatten)]
157 pub remote: OAuthProviderRemoteConfig,
158 #[serde(flatten)]
160 pub provider_oidc: OAuthProviderOidcConfig,
161 #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
162 #[serde(default = "default_scopes")]
163 pub scopes: Vec<String>,
164 #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
167 #[serde(default)]
168 pub required_scopes: Vec<String>,
169 #[serde(default)]
170 pub claims_check_script: Option<String>,
171 #[serde(default)]
172 pub pkce_enabled: bool,
173 #[serde(default)]
178 pub redirect_url: Option<String>,
179 #[serde(default, bound = "PC: PendingOauthStoreConfig")]
180 pub pending_store: Option<PC>,
181 #[serde(default = "default_device_poll_interval", with = "humantime_serde")]
182 pub device_poll_interval: std::time::Duration,
183}
184
185impl<PC> OidcClientRawConfig<PC>
186where
187 PC: PendingOauthStoreConfig,
188{
189 pub fn apply_shared_defaults(
193 self,
194 shared: &OidcSharedConfig,
195 ) -> OidcResult<OidcClientConfig<PC>> {
196 let resolved_client_id = shared
197 .resolve_client_id(self.client_id.as_deref())
198 .ok_or_else(|| OidcError::InvalidConfig {
199 message: "client_id must be set in either [oidc_client] or [oidc]".to_string(),
200 })?;
201
202 Ok(OidcClientConfig {
203 client_id: resolved_client_id,
204 client_secret: shared.resolve_client_secret(self.client_secret.as_deref()),
205 remote: shared.resolve_remote(&self.remote),
206 provider_oidc: self.provider_oidc,
207 scopes: self.scopes,
208 required_scopes: shared.resolve_required_scopes(&self.required_scopes),
209 claims_check_script: self.claims_check_script,
210 pkce_enabled: self.pkce_enabled,
211 redirect_url: self
212 .redirect_url
213 .as_deref()
214 .unwrap_or(&default_redirect_url())
215 .to_owned(),
216
217 pending_store: self.pending_store,
218 device_poll_interval: self.device_poll_interval,
219 })
220 }
221
222 pub fn resolve_config(self, shared: &OidcSharedConfig) -> OidcResult<OidcClientConfig<PC>> {
234 let config = self.apply_shared_defaults(shared)?;
235 config.validate()?;
236 Ok(config)
237 }
238}
239
240impl<PC> Default for OidcClientRawConfig<PC>
241where
242 PC: PendingOauthStoreConfig,
243{
244 fn default() -> Self {
245 Self {
246 client_id: None,
247 client_secret: None,
248 remote: OAuthProviderRemoteConfig::default(),
249 provider_oidc: OAuthProviderOidcConfig::default(),
250 scopes: default_scopes(),
251 required_scopes: vec![],
252 claims_check_script: None,
253 pkce_enabled: false,
254 redirect_url: None,
255
256 pending_store: None,
257 device_poll_interval: default_device_poll_interval(),
258 }
259 }
260}
261
262pub fn default_scopes() -> Vec<String> {
263 vec![
264 "openid".to_string(),
265 "profile".to_string(),
266 "email".to_string(),
267 ]
268}
269
270pub fn default_id_token_signing_alg_values_supported() -> Vec<CoreJwsSigningAlgorithm> {
271 vec![CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256]
272}
273
274pub fn default_redirect_url() -> String {
275 "/auth/callback".to_string()
276}
277
278pub fn default_device_poll_interval() -> std::time::Duration {
279 std::time::Duration::from_secs(5)
280}
281
282#[cfg(test)]
283mod tests {
284 use securitydept_oauth_provider::{OAuthProviderRemoteConfig, OidcSharedConfig};
285 use serde::Deserialize;
286
287 use super::{OidcClientRawConfig, default_scopes};
288 use crate::pending_store::base::PendingOauthStoreConfig;
289
290 #[derive(Debug, Clone, Default, Deserialize)]
292 struct TestPendingStoreConfig;
293 impl PendingOauthStoreConfig for TestPendingStoreConfig {}
294
295 type RawConfig = OidcClientRawConfig<TestPendingStoreConfig>;
296
297 #[test]
298 fn apply_shared_defaults_inherits_well_known_url_from_oidc_block() {
299 let shared = OidcSharedConfig {
300 remote: OAuthProviderRemoteConfig {
301 well_known_url: Some(
302 "https://auth.example.com/.well-known/openid-configuration".to_string(),
303 ),
304 ..Default::default()
305 },
306 client_id: Some("shared-app".to_string()),
307 ..Default::default()
308 };
309
310 let raw = RawConfig::default();
311 let config = raw
312 .apply_shared_defaults(&shared)
313 .expect("should resolve with shared defaults");
314
315 assert_eq!(
316 config.remote.well_known_url.as_deref(),
317 Some("https://auth.example.com/.well-known/openid-configuration"),
318 "well_known_url should be inherited from [oidc]"
319 );
320 assert_eq!(
321 config.client_id, "shared-app",
322 "client_id should be inherited from [oidc]"
323 );
324 assert!(config.client_secret.is_none());
325 }
326
327 #[test]
328 fn local_client_id_overrides_shared_client_id() {
329 let shared = OidcSharedConfig {
330 client_id: Some("shared-app".to_string()),
331 ..Default::default()
332 };
333
334 let raw = RawConfig {
335 client_id: Some("local-app".to_string()),
336 remote: OAuthProviderRemoteConfig {
337 well_known_url: Some("https://auth.example.com/.well-known".to_string()),
338 ..Default::default()
339 },
340 ..Default::default()
341 };
342 let config = raw.apply_shared_defaults(&shared).expect("should resolve");
343
344 assert_eq!(config.client_id, "local-app", "local client_id must win");
345 }
346
347 #[test]
348 fn missing_client_id_everywhere_returns_error() {
349 let shared = OidcSharedConfig::default();
350 let raw = RawConfig::default();
351
352 let result = raw.apply_shared_defaults(&shared);
353 assert!(result.is_err(), "should fail when client_id is absent");
354 assert!(
355 result
356 .unwrap_err()
357 .to_string()
358 .contains("client_id must be set")
359 );
360 }
361
362 #[test]
363 fn default_scopes_are_applied_when_not_overridden() {
364 let shared = OidcSharedConfig {
365 client_id: Some("app".to_string()),
366 remote: OAuthProviderRemoteConfig {
367 well_known_url: Some("https://auth.example.com/.well-known".to_string()),
368 ..Default::default()
369 },
370 ..Default::default()
371 };
372 let raw = RawConfig::default();
373 let config = raw.apply_shared_defaults(&shared).expect("should resolve");
374
375 assert_eq!(config.scopes, default_scopes());
376 }
377
378 #[test]
383 fn resolve_config_applies_shared_defaults_and_validates() {
384 let shared = OidcSharedConfig {
385 client_id: Some("app".to_string()),
386 remote: OAuthProviderRemoteConfig {
387 well_known_url: Some("https://auth.example.com/.well-known".to_string()),
388 ..Default::default()
389 },
390 ..Default::default()
391 };
392 let raw = RawConfig::default();
393
394 let config = raw
396 .resolve_config(&shared)
397 .expect("should resolve and validate");
398 assert_eq!(config.client_id, "app");
399 assert_eq!(
400 config.remote.well_known_url.as_deref(),
401 Some("https://auth.example.com/.well-known"),
402 );
403 }
404
405 #[test]
406 fn resolve_config_propagates_validation_failure() {
407 let shared = OidcSharedConfig {
408 client_id: Some("app".to_string()),
409 ..Default::default()
411 };
412 let raw = RawConfig::default();
413
414 let result = raw.resolve_config(&shared);
415 assert!(
416 result.is_err(),
417 "should fail validation without well_known_url or manual endpoints"
418 );
419 }
420}