use serde::Deserialize;
use serde_with::{NoneAsEmptyString, serde_as};
use crate::{OAuthProviderRemoteConfig, default_jwks_refresh_interval};
#[serde_as]
#[derive(Debug, Clone, Deserialize, Default)]
pub struct OidcSharedConfig {
#[serde(flatten)]
pub remote: OAuthProviderRemoteConfig,
#[serde(default)]
#[serde_as(as = "NoneAsEmptyString")]
pub client_id: Option<String>,
#[serde(default)]
#[serde_as(as = "NoneAsEmptyString")]
pub client_secret: Option<String>,
#[serde_as(as = "securitydept_utils::ser::CommaOrSpaceSeparated<String>")]
#[serde(default)]
pub required_scopes: Vec<String>,
}
impl OidcSharedConfig {
pub fn resolve_remote(&self, local: &OAuthProviderRemoteConfig) -> OAuthProviderRemoteConfig {
OAuthProviderRemoteConfig {
well_known_url: local
.well_known_url
.clone()
.or_else(|| self.remote.well_known_url.clone()),
issuer_url: local
.issuer_url
.clone()
.or_else(|| self.remote.issuer_url.clone()),
jwks_uri: local
.jwks_uri
.clone()
.or_else(|| self.remote.jwks_uri.clone()),
metadata_refresh_interval: if local.metadata_refresh_interval.is_zero() {
self.remote.metadata_refresh_interval
} else {
local.metadata_refresh_interval
},
jwks_refresh_interval: if local.jwks_refresh_interval == default_jwks_refresh_interval()
{
self.remote.jwks_refresh_interval
} else {
local.jwks_refresh_interval
},
}
}
pub fn resolve_client_id(&self, local: Option<&str>) -> Option<String> {
local
.map(ToOwned::to_owned)
.or_else(|| self.client_id.clone())
}
pub fn resolve_client_secret(&self, local: Option<&str>) -> Option<String> {
local
.map(ToOwned::to_owned)
.or_else(|| self.client_secret.clone())
}
pub fn resolve_required_scopes(&self, local: &[String]) -> Vec<String> {
if !local.is_empty() {
local.to_vec()
} else {
self.required_scopes.clone()
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::OidcSharedConfig;
use crate::OAuthProviderRemoteConfig;
#[test]
fn local_url_values_take_priority_over_shared() {
let shared = OidcSharedConfig {
remote: OAuthProviderRemoteConfig {
well_known_url: Some("https://shared.example.com/.well-known".to_string()),
issuer_url: Some("https://shared.example.com".to_string()),
jwks_uri: Some("https://shared.example.com/jwks".to_string()),
..Default::default()
},
..Default::default()
};
let local = OAuthProviderRemoteConfig {
well_known_url: Some("https://local.example.com/.well-known".to_string()),
..Default::default()
};
let resolved = shared.resolve_remote(&local);
assert_eq!(
resolved.well_known_url.as_deref(),
Some("https://local.example.com/.well-known"),
"local well_known_url should take priority"
);
assert_eq!(
resolved.issuer_url.as_deref(),
Some("https://shared.example.com"),
"shared issuer_url should fill the gap"
);
assert_eq!(
resolved.jwks_uri.as_deref(),
Some("https://shared.example.com/jwks"),
"shared jwks_uri should fill the gap"
);
}
#[test]
fn empty_shared_returns_local_remote_unchanged() {
let shared = OidcSharedConfig::default();
let local = OAuthProviderRemoteConfig {
well_known_url: Some("https://local.example.com/.well-known".to_string()),
..Default::default()
};
let resolved = shared.resolve_remote(&local);
assert_eq!(resolved.well_known_url, local.well_known_url);
assert!(resolved.issuer_url.is_none());
}
#[test]
fn local_interval_overrides_shared_interval() {
let shared = OidcSharedConfig {
remote: OAuthProviderRemoteConfig {
metadata_refresh_interval: Duration::from_secs(600),
..Default::default()
},
..Default::default()
};
let local = OAuthProviderRemoteConfig {
metadata_refresh_interval: Duration::from_secs(120),
..Default::default()
};
let resolved = shared.resolve_remote(&local);
assert_eq!(
resolved.metadata_refresh_interval,
Duration::from_secs(120),
"non-zero local interval should take priority"
);
}
#[test]
fn zero_local_interval_falls_back_to_shared_interval() {
let shared = OidcSharedConfig {
remote: OAuthProviderRemoteConfig {
metadata_refresh_interval: Duration::from_secs(600),
..Default::default()
},
..Default::default()
};
let local = OAuthProviderRemoteConfig {
metadata_refresh_interval: Duration::ZERO,
..Default::default()
};
let resolved = shared.resolve_remote(&local);
assert_eq!(
resolved.metadata_refresh_interval,
Duration::from_secs(600),
"zero local interval should fall back to shared"
);
}
#[test]
fn local_client_id_takes_priority_over_shared() {
let shared = OidcSharedConfig {
client_id: Some("shared-client".to_string()),
..Default::default()
};
let resolved = shared.resolve_client_id(Some("local-client"));
assert_eq!(resolved.as_deref(), Some("local-client"));
}
#[test]
fn shared_client_id_fills_gap_when_local_is_absent() {
let shared = OidcSharedConfig {
client_id: Some("shared-client".to_string()),
..Default::default()
};
let resolved = shared.resolve_client_id(None);
assert_eq!(resolved.as_deref(), Some("shared-client"));
}
#[test]
fn no_client_id_anywhere_returns_none() {
let shared = OidcSharedConfig::default();
let resolved = shared.resolve_client_id(None);
assert!(resolved.is_none());
}
#[test]
fn local_client_secret_takes_priority_over_shared() {
let shared = OidcSharedConfig {
client_secret: Some("shared-secret".to_string()),
..Default::default()
};
let resolved = shared.resolve_client_secret(Some("local-secret"));
assert_eq!(resolved.as_deref(), Some("local-secret"));
}
#[test]
fn shared_client_secret_fills_gap_when_local_is_absent() {
let shared = OidcSharedConfig {
client_secret: Some("shared-secret".to_string()),
..Default::default()
};
let resolved = shared.resolve_client_secret(None);
assert_eq!(resolved.as_deref(), Some("shared-secret"));
}
#[test]
fn deserialization_of_shared_config_from_flat_json() {
let json = serde_json::json!({
"well_known_url": "https://auth.example.com/.well-known/openid-configuration",
"client_id": "shared-app",
"client_secret": "s3cr3t"
});
let config: OidcSharedConfig =
serde_json::from_value(json).expect("shared config should deserialize");
assert_eq!(
config.remote.well_known_url.as_deref(),
Some("https://auth.example.com/.well-known/openid-configuration")
);
assert_eq!(config.client_id.as_deref(), Some("shared-app"));
assert_eq!(config.client_secret.as_deref(), Some("s3cr3t"));
}
}