securitydept_oidc_client/
config.rs1use openidconnect::core::CoreJwsSigningAlgorithm;
2use securitydept_oauth_provider::{
3 OAuthProviderConfig, OAuthProviderOidcConfig, OAuthProviderRemoteConfig, OidcSharedConfig,
4};
5use securitydept_utils::{
6 secret::{SecretString, deserialize_optional_secret_string},
7 ser::CommaOrSpaceSeparated,
8};
9use serde::Deserialize;
10use serde_with::{NoneAsEmptyString, PickFirst, serde_as};
11
12use crate::{OidcError, OidcResult, PendingOauthStoreConfig};
13
14#[serde_as]
24#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
25#[cfg_attr(
26 feature = "config-schema",
27 schemars(bound = "PC: schemars::JsonSchema")
28)]
29#[derive(Debug, Clone, Deserialize)]
30pub struct OidcClientConfig<PC>
31where
32 PC: PendingOauthStoreConfig,
33{
34 pub client_id: String,
35 #[serde(default)]
36 pub client_secret: Option<SecretString>,
37 #[serde(flatten)]
39 pub remote: OAuthProviderRemoteConfig,
40 #[serde(flatten)]
42 pub provider_oidc: OAuthProviderOidcConfig,
43 #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
44 #[serde(default = "default_scopes")]
45 #[cfg_attr(
46 feature = "config-schema",
47 schemars(with = "securitydept_utils::schema::StringOrVecString")
48 )]
49 pub scopes: Vec<String>,
50 #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
57 #[serde(default)]
58 #[cfg_attr(
59 feature = "config-schema",
60 schemars(with = "securitydept_utils::schema::StringOrVecString")
61 )]
62 pub required_scopes: Vec<String>,
63 #[serde(default)]
64 pub claims_check_script: Option<String>,
65 #[serde(default)]
68 pub pkce_enabled: bool,
69 #[serde(default = "default_redirect_url")]
70 pub redirect_url: String,
71 #[serde(default, bound = "PC: PendingOauthStoreConfig")]
73 pub pending_store: Option<PC>,
74 #[serde(default = "default_device_poll_interval", with = "humantime_serde")]
77 #[cfg_attr(feature = "config-schema", schemars(with = "String"))]
78 pub device_poll_interval: std::time::Duration,
79}
80
81impl<PC> OidcClientConfig<PC>
82where
83 PC: PendingOauthStoreConfig,
84{
85 pub fn validate(&self) -> OidcResult<()> {
86 if self.claims_check_script.is_some() && cfg!(not(feature = "claims-script")) {
87 return Err(OidcError::InvalidConfig {
88 message: "Claims check script is enabled but the claims-script feature is disabled"
89 .to_string(),
90 });
91 }
92 if self.remote.well_known_url.is_none() {
93 let missing: Vec<&str> = [
94 ("issuer_url", self.remote.issuer_url.as_deref()),
95 (
96 "authorization_endpoint",
97 self.provider_oidc.authorization_endpoint.as_deref(),
98 ),
99 (
100 "token_endpoint",
101 self.provider_oidc.token_endpoint.as_deref(),
102 ),
103 ("jwks_uri", self.remote.jwks_uri.as_deref()),
104 (
105 "userinfo_endpoint",
106 self.provider_oidc.userinfo_endpoint.as_deref(),
107 ),
108 ]
109 .into_iter()
110 .filter_map(|(name, v)| match v {
111 None | Some("") => Some(name),
112 Some(s) if s.trim().is_empty() => Some(name),
113 _ => None,
114 })
115 .collect();
116 if missing.len() > 1 || (missing.len() == 1 && missing[0] != "userinfo_endpoint") {
117 return Err(OidcError::InvalidConfig {
118 message: format!(
119 "When well_known_url is not set, all of issuer_url, \
120 authorization_endpoint, token_endpoint, and jwks_uri must be set; \
121 userinfo_endpoint is recommended and only enables user_info_claims \
122 fetch; missing: {}",
123 missing.join(", ")
124 ),
125 });
126 }
127 }
128 Ok(())
129 }
130
131 pub fn provider_config(&self) -> OAuthProviderConfig {
132 OAuthProviderConfig {
133 remote: self.remote.clone(),
134 oidc: self.provider_oidc.clone(),
135 }
136 }
137}
138
139#[serde_as]
159#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
160#[cfg_attr(
161 feature = "config-schema",
162 schemars(bound = "PC: schemars::JsonSchema")
163)]
164#[derive(Debug, Clone, Deserialize)]
165pub struct OidcClientRawConfig<PC>
166where
167 PC: PendingOauthStoreConfig,
168{
169 #[serde(default)]
171 #[serde_as(as = "NoneAsEmptyString")]
172 #[cfg_attr(feature = "config-schema", schemars(with = "Option<String>"))]
173 pub client_id: Option<String>,
174 #[serde(default, deserialize_with = "deserialize_optional_secret_string")]
176 pub client_secret: Option<SecretString>,
177 #[serde(flatten)]
179 pub remote: OAuthProviderRemoteConfig,
180 #[serde(flatten)]
182 pub provider_oidc: OAuthProviderOidcConfig,
183 #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
184 #[serde(default = "default_scopes")]
185 #[cfg_attr(
186 feature = "config-schema",
187 schemars(with = "securitydept_utils::schema::StringOrVecString")
188 )]
189 pub scopes: Vec<String>,
190 #[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
193 #[serde(default)]
194 #[cfg_attr(
195 feature = "config-schema",
196 schemars(with = "securitydept_utils::schema::StringOrVecString")
197 )]
198 pub required_scopes: Vec<String>,
199 #[serde(default)]
200 pub claims_check_script: Option<String>,
201 #[serde(default)]
202 pub pkce_enabled: bool,
203 #[serde(default)]
208 pub redirect_url: Option<String>,
209 #[serde(default, bound = "PC: PendingOauthStoreConfig")]
210 pub pending_store: Option<PC>,
211 #[serde(default = "default_device_poll_interval", with = "humantime_serde")]
212 #[cfg_attr(feature = "config-schema", schemars(with = "String"))]
213 pub device_poll_interval: std::time::Duration,
214}
215
216impl<PC> OidcClientRawConfig<PC>
217where
218 PC: PendingOauthStoreConfig,
219{
220 pub fn apply_shared_defaults(
224 self,
225 shared: &OidcSharedConfig,
226 ) -> OidcResult<OidcClientConfig<PC>> {
227 let resolved_client_id = shared
228 .resolve_client_id(self.client_id.as_deref())
229 .ok_or_else(|| OidcError::InvalidConfig {
230 message: "client_id must be set in either [oidc_client] or [oidc]".to_string(),
231 })?;
232
233 Ok(OidcClientConfig {
234 client_id: resolved_client_id,
235 client_secret: shared.resolve_client_secret(self.client_secret.as_ref()),
236 remote: shared.resolve_remote(&self.remote),
237 provider_oidc: self.provider_oidc,
238 scopes: self.scopes,
239 required_scopes: shared.resolve_required_scopes(&self.required_scopes),
240 claims_check_script: self.claims_check_script,
241 pkce_enabled: self.pkce_enabled,
242 redirect_url: self
243 .redirect_url
244 .as_deref()
245 .unwrap_or(&default_redirect_url())
246 .to_owned(),
247
248 pending_store: self.pending_store,
249 device_poll_interval: self.device_poll_interval,
250 })
251 }
252
253 pub fn resolve_config(self, shared: &OidcSharedConfig) -> OidcResult<OidcClientConfig<PC>> {
265 let config = self.apply_shared_defaults(shared)?;
266 config.validate()?;
267 Ok(config)
268 }
269}
270
271impl<PC> Default for OidcClientRawConfig<PC>
272where
273 PC: PendingOauthStoreConfig,
274{
275 fn default() -> Self {
276 Self {
277 client_id: None,
278 client_secret: None,
279 remote: OAuthProviderRemoteConfig::default(),
280 provider_oidc: OAuthProviderOidcConfig::default(),
281 scopes: default_scopes(),
282 required_scopes: vec![],
283 claims_check_script: None,
284 pkce_enabled: false,
285 redirect_url: None,
286
287 pending_store: None,
288 device_poll_interval: default_device_poll_interval(),
289 }
290 }
291}
292
293pub fn default_scopes() -> Vec<String> {
294 vec![
295 "openid".to_string(),
296 "profile".to_string(),
297 "email".to_string(),
298 ]
299}
300
301pub fn default_id_token_signing_alg_values_supported() -> Vec<CoreJwsSigningAlgorithm> {
302 vec![CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256]
303}
304
305pub fn default_redirect_url() -> String {
306 "/auth/callback".to_string()
307}
308
309pub fn default_device_poll_interval() -> std::time::Duration {
310 std::time::Duration::from_secs(5)
311}
312
313#[cfg(test)]
314mod tests {
315 use securitydept_oauth_provider::{OAuthProviderRemoteConfig, OidcSharedConfig};
316 use securitydept_utils::secret::SecretString;
317 use serde::Deserialize;
318
319 use super::{OidcClientRawConfig, default_scopes};
320 use crate::pending_store::base::PendingOauthStoreConfig;
321
322 #[derive(Debug, Clone, Default, Deserialize)]
324 struct TestPendingStoreConfig;
325 impl PendingOauthStoreConfig for TestPendingStoreConfig {}
326
327 type RawConfig = OidcClientRawConfig<TestPendingStoreConfig>;
328
329 #[test]
330 fn apply_shared_defaults_inherits_well_known_url_from_oidc_block() {
331 let shared = OidcSharedConfig {
332 remote: OAuthProviderRemoteConfig {
333 well_known_url: Some(
334 "https://auth.example.com/.well-known/openid-configuration".to_string(),
335 ),
336 ..Default::default()
337 },
338 client_id: Some("shared-app".to_string()),
339 client_secret: Some(SecretString::from("shared-secret")),
340 ..Default::default()
341 };
342
343 let raw = RawConfig::default();
344 let config = raw
345 .apply_shared_defaults(&shared)
346 .expect("should resolve with shared defaults");
347
348 assert_eq!(
349 config.remote.well_known_url.as_deref(),
350 Some("https://auth.example.com/.well-known/openid-configuration"),
351 "well_known_url should be inherited from [oidc]"
352 );
353 assert_eq!(
354 config.client_id, "shared-app",
355 "client_id should be inherited from [oidc]"
356 );
357 assert_eq!(
358 config
359 .client_secret
360 .as_ref()
361 .map(SecretString::expose_secret),
362 Some("shared-secret")
363 );
364 }
365
366 #[test]
367 fn local_client_id_overrides_shared_client_id() {
368 let shared = OidcSharedConfig {
369 client_id: Some("shared-app".to_string()),
370 ..Default::default()
371 };
372
373 let raw = RawConfig {
374 client_id: Some("local-app".to_string()),
375 remote: OAuthProviderRemoteConfig {
376 well_known_url: Some("https://auth.example.com/.well-known".to_string()),
377 ..Default::default()
378 },
379 ..Default::default()
380 };
381 let config = raw.apply_shared_defaults(&shared).expect("should resolve");
382
383 assert_eq!(config.client_id, "local-app", "local client_id must win");
384 }
385
386 #[test]
387 fn missing_client_id_everywhere_returns_error() {
388 let shared = OidcSharedConfig::default();
389 let raw = RawConfig::default();
390
391 let result = raw.apply_shared_defaults(&shared);
392 assert!(result.is_err(), "should fail when client_id is absent");
393 assert!(
394 result
395 .unwrap_err()
396 .to_string()
397 .contains("client_id must be set")
398 );
399 }
400
401 #[test]
402 fn default_scopes_are_applied_when_not_overridden() {
403 let shared = OidcSharedConfig {
404 client_id: Some("app".to_string()),
405 remote: OAuthProviderRemoteConfig {
406 well_known_url: Some("https://auth.example.com/.well-known".to_string()),
407 ..Default::default()
408 },
409 ..Default::default()
410 };
411 let raw = RawConfig::default();
412 let config = raw.apply_shared_defaults(&shared).expect("should resolve");
413
414 assert_eq!(config.scopes, default_scopes());
415 }
416
417 #[test]
422 fn resolve_config_applies_shared_defaults_and_validates() {
423 let shared = OidcSharedConfig {
424 client_id: Some("app".to_string()),
425 remote: OAuthProviderRemoteConfig {
426 well_known_url: Some("https://auth.example.com/.well-known".to_string()),
427 ..Default::default()
428 },
429 ..Default::default()
430 };
431 let raw = RawConfig::default();
432
433 let config = raw
435 .resolve_config(&shared)
436 .expect("should resolve and validate");
437 assert_eq!(config.client_id, "app");
438 assert_eq!(
439 config.remote.well_known_url.as_deref(),
440 Some("https://auth.example.com/.well-known"),
441 );
442 }
443
444 #[test]
445 fn resolve_config_propagates_validation_failure() {
446 let shared = OidcSharedConfig {
447 client_id: Some("app".to_string()),
448 ..Default::default()
450 };
451 let raw = RawConfig::default();
452
453 let result = raw.resolve_config(&shared);
454 assert!(
455 result.is_err(),
456 "should fail validation without well_known_url or manual endpoints"
457 );
458 }
459}