use openidconnect::core::CoreJwsSigningAlgorithm;
use securitydept_oauth_provider::{
OAuthProviderConfig, OAuthProviderOidcConfig, OAuthProviderRemoteConfig, OidcSharedConfig,
};
use securitydept_utils::{
secret::{SecretString, deserialize_optional_secret_string},
ser::CommaOrSpaceSeparated,
};
use serde::Deserialize;
use serde_with::{NoneAsEmptyString, PickFirst, serde_as};
use crate::{OidcError, OidcResult, PendingOauthStoreConfig};
#[serde_as]
#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
#[cfg_attr(
feature = "config-schema",
schemars(bound = "PC: schemars::JsonSchema")
)]
#[derive(Debug, Clone, Deserialize)]
pub struct OidcClientConfig<PC>
where
PC: PendingOauthStoreConfig,
{
pub client_id: String,
#[serde(default)]
pub client_secret: Option<SecretString>,
#[serde(flatten)]
pub remote: OAuthProviderRemoteConfig,
#[serde(flatten)]
pub provider_oidc: OAuthProviderOidcConfig,
#[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
#[serde(default = "default_scopes")]
#[cfg_attr(
feature = "config-schema",
schemars(with = "securitydept_utils::schema::StringOrVecString")
)]
pub scopes: Vec<String>,
#[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
#[serde(default)]
#[cfg_attr(
feature = "config-schema",
schemars(with = "securitydept_utils::schema::StringOrVecString")
)]
pub required_scopes: Vec<String>,
#[serde(default)]
pub claims_check_script: Option<String>,
#[serde(default)]
pub pkce_enabled: bool,
#[serde(default = "default_redirect_url")]
pub redirect_url: String,
#[serde(default, bound = "PC: PendingOauthStoreConfig")]
pub pending_store: Option<PC>,
#[serde(default = "default_device_poll_interval", with = "humantime_serde")]
#[cfg_attr(feature = "config-schema", schemars(with = "String"))]
pub device_poll_interval: std::time::Duration,
}
impl<PC> OidcClientConfig<PC>
where
PC: PendingOauthStoreConfig,
{
pub fn validate(&self) -> OidcResult<()> {
if self.claims_check_script.is_some() && cfg!(not(feature = "claims-script")) {
return Err(OidcError::InvalidConfig {
message: "Claims check script is enabled but the claims-script feature is disabled"
.to_string(),
});
}
if self.remote.well_known_url.is_none() {
let missing: Vec<&str> = [
("issuer_url", self.remote.issuer_url.as_deref()),
(
"authorization_endpoint",
self.provider_oidc.authorization_endpoint.as_deref(),
),
(
"token_endpoint",
self.provider_oidc.token_endpoint.as_deref(),
),
("jwks_uri", self.remote.jwks_uri.as_deref()),
(
"userinfo_endpoint",
self.provider_oidc.userinfo_endpoint.as_deref(),
),
]
.into_iter()
.filter_map(|(name, v)| match v {
None | Some("") => Some(name),
Some(s) if s.trim().is_empty() => Some(name),
_ => None,
})
.collect();
if missing.len() > 1 || (missing.len() == 1 && missing[0] != "userinfo_endpoint") {
return Err(OidcError::InvalidConfig {
message: format!(
"When well_known_url is not set, all of issuer_url, \
authorization_endpoint, token_endpoint, and jwks_uri must be set; \
userinfo_endpoint is recommended and only enables user_info_claims \
fetch; missing: {}",
missing.join(", ")
),
});
}
}
Ok(())
}
pub fn provider_config(&self) -> OAuthProviderConfig {
OAuthProviderConfig {
remote: self.remote.clone(),
oidc: self.provider_oidc.clone(),
}
}
}
#[serde_as]
#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
#[cfg_attr(
feature = "config-schema",
schemars(bound = "PC: schemars::JsonSchema")
)]
#[derive(Debug, Clone, Deserialize)]
pub struct OidcClientRawConfig<PC>
where
PC: PendingOauthStoreConfig,
{
#[serde(default)]
#[serde_as(as = "NoneAsEmptyString")]
#[cfg_attr(feature = "config-schema", schemars(with = "Option<String>"))]
pub client_id: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_secret_string")]
pub client_secret: Option<SecretString>,
#[serde(flatten)]
pub remote: OAuthProviderRemoteConfig,
#[serde(flatten)]
pub provider_oidc: OAuthProviderOidcConfig,
#[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
#[serde(default = "default_scopes")]
#[cfg_attr(
feature = "config-schema",
schemars(with = "securitydept_utils::schema::StringOrVecString")
)]
pub scopes: Vec<String>,
#[serde_as(as = "PickFirst<(CommaOrSpaceSeparated<String>, _)>")]
#[serde(default)]
#[cfg_attr(
feature = "config-schema",
schemars(with = "securitydept_utils::schema::StringOrVecString")
)]
pub required_scopes: Vec<String>,
#[serde(default)]
pub claims_check_script: Option<String>,
#[serde(default)]
pub pkce_enabled: bool,
#[serde(default)]
pub redirect_url: Option<String>,
#[serde(default, bound = "PC: PendingOauthStoreConfig")]
pub pending_store: Option<PC>,
#[serde(default = "default_device_poll_interval", with = "humantime_serde")]
#[cfg_attr(feature = "config-schema", schemars(with = "String"))]
pub device_poll_interval: std::time::Duration,
}
impl<PC> OidcClientRawConfig<PC>
where
PC: PendingOauthStoreConfig,
{
pub fn apply_shared_defaults(
self,
shared: &OidcSharedConfig,
) -> OidcResult<OidcClientConfig<PC>> {
let resolved_client_id = shared
.resolve_client_id(self.client_id.as_deref())
.ok_or_else(|| OidcError::InvalidConfig {
message: "client_id must be set in either [oidc_client] or [oidc]".to_string(),
})?;
Ok(OidcClientConfig {
client_id: resolved_client_id,
client_secret: shared.resolve_client_secret(self.client_secret.as_ref()),
remote: shared.resolve_remote(&self.remote),
provider_oidc: self.provider_oidc,
scopes: self.scopes,
required_scopes: shared.resolve_required_scopes(&self.required_scopes),
claims_check_script: self.claims_check_script,
pkce_enabled: self.pkce_enabled,
redirect_url: self
.redirect_url
.as_deref()
.unwrap_or(&default_redirect_url())
.to_owned(),
pending_store: self.pending_store,
device_poll_interval: self.device_poll_interval,
})
}
pub fn resolve_config(self, shared: &OidcSharedConfig) -> OidcResult<OidcClientConfig<PC>> {
let config = self.apply_shared_defaults(shared)?;
config.validate()?;
Ok(config)
}
}
impl<PC> Default for OidcClientRawConfig<PC>
where
PC: PendingOauthStoreConfig,
{
fn default() -> Self {
Self {
client_id: None,
client_secret: None,
remote: OAuthProviderRemoteConfig::default(),
provider_oidc: OAuthProviderOidcConfig::default(),
scopes: default_scopes(),
required_scopes: vec![],
claims_check_script: None,
pkce_enabled: false,
redirect_url: None,
pending_store: None,
device_poll_interval: default_device_poll_interval(),
}
}
}
pub fn default_scopes() -> Vec<String> {
vec![
"openid".to_string(),
"profile".to_string(),
"email".to_string(),
]
}
pub fn default_id_token_signing_alg_values_supported() -> Vec<CoreJwsSigningAlgorithm> {
vec![CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256]
}
pub fn default_redirect_url() -> String {
"/auth/callback".to_string()
}
pub fn default_device_poll_interval() -> std::time::Duration {
std::time::Duration::from_secs(5)
}
#[cfg(test)]
mod tests {
use securitydept_oauth_provider::{OAuthProviderRemoteConfig, OidcSharedConfig};
use securitydept_utils::secret::SecretString;
use serde::Deserialize;
use super::{OidcClientRawConfig, default_scopes};
use crate::pending_store::base::PendingOauthStoreConfig;
#[derive(Debug, Clone, Default, Deserialize)]
struct TestPendingStoreConfig;
impl PendingOauthStoreConfig for TestPendingStoreConfig {}
type RawConfig = OidcClientRawConfig<TestPendingStoreConfig>;
#[test]
fn apply_shared_defaults_inherits_well_known_url_from_oidc_block() {
let shared = OidcSharedConfig {
remote: OAuthProviderRemoteConfig {
well_known_url: Some(
"https://auth.example.com/.well-known/openid-configuration".to_string(),
),
..Default::default()
},
client_id: Some("shared-app".to_string()),
client_secret: Some(SecretString::from("shared-secret")),
..Default::default()
};
let raw = RawConfig::default();
let config = raw
.apply_shared_defaults(&shared)
.expect("should resolve with shared defaults");
assert_eq!(
config.remote.well_known_url.as_deref(),
Some("https://auth.example.com/.well-known/openid-configuration"),
"well_known_url should be inherited from [oidc]"
);
assert_eq!(
config.client_id, "shared-app",
"client_id should be inherited from [oidc]"
);
assert_eq!(
config
.client_secret
.as_ref()
.map(SecretString::expose_secret),
Some("shared-secret")
);
}
#[test]
fn local_client_id_overrides_shared_client_id() {
let shared = OidcSharedConfig {
client_id: Some("shared-app".to_string()),
..Default::default()
};
let raw = RawConfig {
client_id: Some("local-app".to_string()),
remote: OAuthProviderRemoteConfig {
well_known_url: Some("https://auth.example.com/.well-known".to_string()),
..Default::default()
},
..Default::default()
};
let config = raw.apply_shared_defaults(&shared).expect("should resolve");
assert_eq!(config.client_id, "local-app", "local client_id must win");
}
#[test]
fn missing_client_id_everywhere_returns_error() {
let shared = OidcSharedConfig::default();
let raw = RawConfig::default();
let result = raw.apply_shared_defaults(&shared);
assert!(result.is_err(), "should fail when client_id is absent");
assert!(
result
.unwrap_err()
.to_string()
.contains("client_id must be set")
);
}
#[test]
fn default_scopes_are_applied_when_not_overridden() {
let shared = OidcSharedConfig {
client_id: Some("app".to_string()),
remote: OAuthProviderRemoteConfig {
well_known_url: Some("https://auth.example.com/.well-known".to_string()),
..Default::default()
},
..Default::default()
};
let raw = RawConfig::default();
let config = raw.apply_shared_defaults(&shared).expect("should resolve");
assert_eq!(config.scopes, default_scopes());
}
#[test]
fn resolve_config_applies_shared_defaults_and_validates() {
let shared = OidcSharedConfig {
client_id: Some("app".to_string()),
remote: OAuthProviderRemoteConfig {
well_known_url: Some("https://auth.example.com/.well-known".to_string()),
..Default::default()
},
..Default::default()
};
let raw = RawConfig::default();
let config = raw
.resolve_config(&shared)
.expect("should resolve and validate");
assert_eq!(config.client_id, "app");
assert_eq!(
config.remote.well_known_url.as_deref(),
Some("https://auth.example.com/.well-known"),
);
}
#[test]
fn resolve_config_propagates_validation_failure() {
let shared = OidcSharedConfig {
client_id: Some("app".to_string()),
..Default::default()
};
let raw = RawConfig::default();
let result = raw.resolve_config(&shared);
assert!(
result.is_err(),
"should fail validation without well_known_url or manual endpoints"
);
}
}