use std::fmt;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use base64::Engine as _;
use ferrohealth_secret::file::{SecretError, read_secret, require_secret};
use ferrohealth_secret::secret::Secret;
use ferrohealth_smart::client::{Reach, Tls, TlsVersion};
use ferrohealth_smart::metadata::{DiscoveryError, Http, ServerMetadata};
use ferrohealth_smart::oidc::{OFFLINE_ACCESS, OPENID, OidcError, Provider, RelyingParty};
use secrecy::SecretSlice;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::lifetime::Lifetimes;
pub const KEY_BYTES: usize = 64;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error(transparent)]
Secret(#[from] SecretError),
#[error("{key} is not set")]
Missing {
key: &'static str,
},
#[error("{key} is not a URL")]
Url {
key: &'static str,
#[source]
source: url::ParseError,
},
#[error("{key} {reason}")]
UrlShape {
key: &'static str,
reason: &'static str,
},
#[error("oidc.scopes must hold openid")]
NoOpenId,
#[error("oidc.scopes must not list offline_access; set oidc.offline_access = true")]
OfflineAccessScope,
#[error("{key} must be greater than zero")]
Zero {
key: &'static str,
},
#[error("session.absolute_timeout_s must be at least session.idle_timeout_s")]
AbsoluteBeforeIdle,
#[error("session.store = \"sealed\" needs session.key or session.key_file")]
NoKey,
#[error("session.key is set, but session.store = \"memory\" seals nothing")]
KeyUnused,
#[error("session.key is not base64")]
KeyBase64 {
#[source]
source: base64::DecodeError,
},
#[error("session.key decodes to {found} bytes; it needs at least {KEY_BYTES}")]
KeyShort {
found: usize,
},
#[error("oidc.offline_access cannot be used with session.store = \"sealed\"")]
OfflineAccessSealed,
#[error("oidc.ca_file names {}, which could not be read", path.display())]
CaFile {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("oidc.resolve entry {index} must be host=ip:port")]
Resolve {
index: usize,
},
#[error("oidc.tls.client_certificate and oidc.tls.client_key must be set together")]
ClientIdentityHalf,
#[error("oidc.tls.client_certificate names {}, which could not be read", path.display())]
ClientCertificate {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("the OpenID Provider's client could not be built")]
Http(#[source] DiscoveryError),
#[error("the OpenID Provider could not be read")]
Provider(#[source] OidcError),
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct OidcSection {
pub issuer: String,
pub authorization_endpoint: Option<String>,
pub token_endpoint: Option<String>,
pub jwks_uri: Option<String>,
pub end_session_endpoint: Option<String>,
pub revocation_endpoint: Option<String>,
pub client_id: String,
pub client_secret: Option<Secret>,
pub client_secret_file: Option<PathBuf>,
pub redirect_uri: String,
pub post_logout_redirect_uri: Option<String>,
pub scopes: Vec<String>,
pub offline_access: bool,
pub ca_file: Option<PathBuf>,
pub resolve: Vec<String>,
pub tls: OidcTlsSection,
pub timeout_s: u64,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct OidcTlsSection {
pub client_certificate: Option<PathBuf>,
pub client_key: Option<Secret>,
pub client_key_file: Option<PathBuf>,
pub min_version: TlsVersion,
}
impl Default for OidcSection {
fn default() -> Self {
Self {
issuer: String::new(),
authorization_endpoint: None,
token_endpoint: None,
jwks_uri: None,
end_session_endpoint: None,
revocation_endpoint: None,
client_id: String::new(),
client_secret: None,
client_secret_file: None,
redirect_uri: String::new(),
post_logout_redirect_uri: None,
scopes: vec![String::from(OPENID)],
offline_access: false,
ca_file: None,
resolve: Vec::new(),
tls: OidcTlsSection::default(),
timeout_s: 10,
}
}
}
#[derive(Clone)]
pub struct OidcSettings {
issuer: String,
endpoints: ServerMetadata,
client_id: String,
client_secret: Option<Secret>,
redirect_uri: Url,
post_logout_redirect_uri: Option<Url>,
scopes: Vec<String>,
offline_access: bool,
roots: Option<Vec<u8>>,
identity: Option<Arc<SecretSlice<u8>>>,
reach: Reach,
timeout: Duration,
}
impl fmt::Debug for OidcSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OidcSettings")
.field("issuer", &self.issuer)
.field("client_id", &self.client_id)
.field("redirect_uri", &self.redirect_uri.as_str())
.field("scopes", &self.scopes)
.field("offline_access", &self.offline_access)
.finish_non_exhaustive()
}
}
impl OidcSection {
pub fn check(&self) -> Result<OidcSettings, ConfigError> {
let issuer = required("oidc.issuer", &self.issuer)?;
provider_url("oidc.issuer", issuer)?;
let client_id = required("oidc.client_id", &self.client_id)?;
let redirect_uri = web_url(
"oidc.redirect_uri",
required("oidc.redirect_uri", &self.redirect_uri)?,
)?;
if redirect_uri.fragment().is_some() {
return Err(ConfigError::UrlShape {
key: "oidc.redirect_uri",
reason: "must not carry a fragment",
});
}
if !self.scopes.iter().any(|scope| scope == OPENID) {
return Err(ConfigError::NoOpenId);
}
if self.scopes.iter().any(|scope| scope == OFFLINE_ACCESS) {
return Err(ConfigError::OfflineAccessScope);
}
let endpoints = ServerMetadata {
issuer: issuer.to_owned(),
authorization_endpoint: endpoint(
"oidc.authorization_endpoint",
self.authorization_endpoint.as_deref(),
)?,
token_endpoint: endpoint("oidc.token_endpoint", self.token_endpoint.as_deref())?,
jwks_uri: endpoint("oidc.jwks_uri", self.jwks_uri.as_deref())?,
end_session_endpoint: endpoint(
"oidc.end_session_endpoint",
self.end_session_endpoint.as_deref(),
)?,
revocation_endpoint: endpoint(
"oidc.revocation_endpoint",
self.revocation_endpoint.as_deref(),
)?,
..ServerMetadata::default()
};
let post_logout_redirect_uri = self
.post_logout_redirect_uri
.as_deref()
.map(|value| web_url("oidc.post_logout_redirect_uri", value))
.transpose()?;
if self.timeout_s == 0 {
return Err(ConfigError::Zero {
key: "oidc.timeout_s",
});
}
let client_secret = read_secret(
"oidc.client_secret",
self.client_secret.clone(),
self.client_secret_file.as_deref(),
)?;
let roots = self.ca_file.as_deref().map(read_roots).transpose()?;
let identity = self.tls.identity()?;
let mut reach = Reach::default().with_min_tls_version(self.tls.min_version);
for (index, entry) in self.resolve.iter().enumerate() {
let (host, address) = resolve_entry(entry).ok_or(ConfigError::Resolve { index })?;
reach = reach.with_resolve(host, address);
}
Ok(OidcSettings {
issuer: issuer.to_owned(),
endpoints,
client_id: client_id.to_owned(),
client_secret,
redirect_uri,
post_logout_redirect_uri,
scopes: self.scopes.clone(),
offline_access: self.offline_access,
roots,
identity,
reach,
timeout: Duration::from_secs(self.timeout_s),
})
}
}
impl OidcSettings {
#[must_use]
pub const fn offline_access(&self) -> bool {
self.offline_access
}
pub async fn relying_party(&self) -> Result<RelyingParty, ConfigError> {
let tls = Tls {
root_certificates: self.roots.clone(),
identity: self.identity.clone(),
};
let http = Http::reaching(self.timeout, &tls, &self.reach).map_err(ConfigError::Http)?;
let given = &self.endpoints;
let complete = given.authorization_endpoint.is_some()
&& given.token_endpoint.is_some()
&& given.jwks_uri.is_some();
let metadata = if complete {
given.clone()
} else {
let found = http
.discover(&self.issuer)
.await
.map_err(|error| ConfigError::Provider(OidcError::Discovery(error)))?;
ServerMetadata {
authorization_endpoint: given
.authorization_endpoint
.clone()
.or(found.authorization_endpoint.clone()),
token_endpoint: given
.token_endpoint
.clone()
.or(found.token_endpoint.clone()),
jwks_uri: given.jwks_uri.clone().or(found.jwks_uri.clone()),
end_session_endpoint: given
.end_session_endpoint
.clone()
.or(found.end_session_endpoint.clone()),
revocation_endpoint: given
.revocation_endpoint
.clone()
.or(found.revocation_endpoint.clone()),
..found
}
};
let provider = Provider::from_metadata(&metadata).map_err(ConfigError::Provider)?;
let mut party = RelyingParty::new(
http,
provider,
self.client_id.clone(),
self.redirect_uri.clone(),
)
.with_scopes(self.scopes.iter().cloned());
if let Some(secret) = &self.client_secret {
party = party.with_client_secret(secret.to_secret_string());
}
if self.offline_access {
party = party.with_offline_access();
}
if let Some(redirect) = &self.post_logout_redirect_uri {
party = party.with_post_logout_redirect_uri(redirect.clone());
}
Ok(party)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum StoreKind {
#[default]
Memory,
Sealed,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct SessionSection {
pub store: StoreKind,
pub secure_cookie: bool,
pub sign_in_timeout_s: u64,
pub max_sign_ins: usize,
pub idle_timeout_s: u64,
pub absolute_timeout_s: u64,
pub max_sessions: usize,
pub key: Option<Secret>,
pub key_file: Option<PathBuf>,
}
impl Default for SessionSection {
fn default() -> Self {
Self {
store: StoreKind::Memory,
secure_cookie: true,
sign_in_timeout_s: 300,
max_sign_ins: 1000,
idle_timeout_s: 1800,
absolute_timeout_s: 43_200,
max_sessions: 10_000,
key: None,
key_file: None,
}
}
}
#[derive(Clone)]
pub(crate) enum Storage {
Memory {
max_sign_ins: usize,
max_sessions: usize,
},
Sealed(cookie::Key),
}
#[derive(Clone)]
pub struct SessionSettings {
pub(crate) storage: Storage,
pub(crate) secure_cookie: bool,
pub(crate) sign_in_timeout: Duration,
pub(crate) lifetimes: Lifetimes,
}
impl fmt::Debug for SessionSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let store = match self.storage {
Storage::Memory { .. } => StoreKind::Memory,
Storage::Sealed(_) => StoreKind::Sealed,
};
f.debug_struct("SessionSettings")
.field("store", &store)
.field("secure_cookie", &self.secure_cookie)
.field("sign_in_timeout", &self.sign_in_timeout)
.field("lifetimes", &self.lifetimes)
.finish_non_exhaustive()
}
}
impl SessionSettings {
#[must_use]
pub const fn lifetimes(&self) -> Lifetimes {
self.lifetimes
}
#[must_use]
pub const fn secure_cookie(&self) -> bool {
self.secure_cookie
}
}
impl SessionSection {
pub fn check(&self, oidc: &OidcSettings) -> Result<SessionSettings, ConfigError> {
self.check_with(oidc.offline_access())
}
pub fn check_with(&self, offline_access: bool) -> Result<SessionSettings, ConfigError> {
for (key, value) in [
("session.sign_in_timeout_s", self.sign_in_timeout_s),
("session.idle_timeout_s", self.idle_timeout_s),
("session.absolute_timeout_s", self.absolute_timeout_s),
] {
if value == 0 {
return Err(ConfigError::Zero { key });
}
}
if self.absolute_timeout_s < self.idle_timeout_s {
return Err(ConfigError::AbsoluteBeforeIdle);
}
let storage = match self.store {
StoreKind::Memory => {
if self.key.is_some() || self.key_file.is_some() {
return Err(ConfigError::KeyUnused);
}
for (key, value) in [
("session.max_sign_ins", self.max_sign_ins),
("session.max_sessions", self.max_sessions),
] {
if value == 0 {
return Err(ConfigError::Zero { key });
}
}
Storage::Memory {
max_sign_ins: self.max_sign_ins,
max_sessions: self.max_sessions,
}
}
StoreKind::Sealed => {
if offline_access {
return Err(ConfigError::OfflineAccessSealed);
}
let secret: Secret =
match require_secret("session.key", self.key.clone(), self.key_file.as_deref())
{
Ok(secret) => secret,
Err(SecretError::Missing { .. }) => return Err(ConfigError::NoKey),
Err(error) => return Err(error.into()),
};
Storage::Sealed(sealing_key(&secret)?)
}
};
Ok(SessionSettings {
storage,
secure_cookie: self.secure_cookie,
sign_in_timeout: Duration::from_secs(self.sign_in_timeout_s),
lifetimes: Lifetimes {
idle: Duration::from_secs(self.idle_timeout_s),
absolute: Duration::from_secs(self.absolute_timeout_s),
},
})
}
}
fn sealing_key(secret: &Secret) -> Result<cookie::Key, ConfigError> {
let bytes = base64::engine::general_purpose::STANDARD
.decode(secret.expose().trim())
.map_err(|source| ConfigError::KeyBase64 { source })?;
cookie::Key::try_from(bytes.as_slice())
.map_err(|_short| ConfigError::KeyShort { found: bytes.len() })
}
fn required<'a>(key: &'static str, value: &'a str) -> Result<&'a str, ConfigError> {
let value = value.trim();
if value.is_empty() {
Err(ConfigError::Missing { key })
} else {
Ok(value)
}
}
fn url(key: &'static str, value: &str) -> Result<Url, ConfigError> {
Url::parse(value).map_err(|source| ConfigError::Url { key, source })
}
fn endpoint(key: &'static str, value: Option<&str>) -> Result<Option<String>, ConfigError> {
value
.map(|text| provider_url(key, text).map(|_| text.to_owned()))
.transpose()
}
fn web_url(key: &'static str, value: &str) -> Result<Url, ConfigError> {
let parsed = url(key, value)?;
let shape = |reason| Err(ConfigError::UrlShape { key, reason });
if !matches!(parsed.scheme(), "http" | "https") {
return shape("must be an http or https URL");
}
if parsed.host().is_none() {
return shape("must name a host");
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return shape("must not carry userinfo");
}
Ok(parsed)
}
fn provider_url(key: &'static str, value: &str) -> Result<Url, ConfigError> {
let parsed = web_url(key, value)?;
if parsed.scheme() == "https" || is_loopback(&parsed) {
Ok(parsed)
} else {
Err(ConfigError::UrlShape {
key,
reason: "must be an https URL unless its host is loopback",
})
}
}
fn is_loopback(url: &Url) -> bool {
match url.host() {
Some(url::Host::Domain(name)) => name == "localhost",
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
None => false,
}
}
impl OidcTlsSection {
fn identity(&self) -> Result<Option<Arc<SecretSlice<u8>>>, ConfigError> {
let key = read_secret(
"oidc.tls.client_key",
self.client_key.clone(),
self.client_key_file.as_deref(),
)?;
match (&self.client_certificate, key) {
(None, None) => Ok(None),
(Some(path), Some(key)) => {
let mut pem =
std::fs::read(path).map_err(|source| ConfigError::ClientCertificate {
path: path.clone(),
source,
})?;
pem.push(b'\n');
pem.extend_from_slice(key.expose().as_bytes());
Ok(Some(Arc::new(SecretSlice::from(pem))))
}
_ => Err(ConfigError::ClientIdentityHalf),
}
}
}
fn resolve_entry(entry: &str) -> Option<(String, SocketAddr)> {
let (host, address) = entry.split_once('=')?;
let host = host.trim();
if host.is_empty() {
return None;
}
let address = address.trim().parse().ok()?;
Some((host.to_ascii_lowercase(), address))
}
fn read_roots(path: &Path) -> Result<Vec<u8>, ConfigError> {
std::fs::read(path).map_err(|source| ConfigError::CaFile {
path: path.to_path_buf(),
source,
})
}
#[cfg(test)]
mod tests {
use base64::Engine as _;
use ferrohealth_secret::secret::Secret;
use ferrohealth_smart::oidc::{OFFLINE_ACCESS, OPENID};
use super::{ConfigError, OidcSection, OidcTlsSection, SessionSection, StoreKind};
fn key(bytes: usize) -> Secret {
let mut raw = vec![0_u8; bytes];
aws_lc_rs::rand::fill(&mut raw).expect("the random source works");
Secret::new(base64::engine::general_purpose::STANDARD.encode(raw))
}
fn sealed() -> SessionSection {
SessionSection {
store: StoreKind::Sealed,
key: Some(key(64)),
..SessionSection::default()
}
}
#[test]
fn the_sections_refuse_an_unknown_key() {
let refused = toml::from_str::<SessionSection>("idle_minutes = 5");
assert!(refused.is_err(), "deny_unknown_fields");
let refused = toml::from_str::<OidcSection>("issuer = \"x\"\nclient = \"y\"");
assert!(refused.is_err(), "deny_unknown_fields");
}
#[test]
fn the_defaults_are_a_memory_store_with_secure_cookies() {
let section: SessionSection = toml::from_str("").expect("all defaults");
let settings = section.check_with(false).expect("valid");
assert!(settings.secure_cookie(), "Secure by default");
assert_eq!(1800, settings.lifetimes().idle.as_secs(), "idle");
assert_eq!(43_200, settings.lifetimes().absolute.as_secs(), "absolute");
}
#[test]
fn an_absolute_lifetime_shorter_than_the_idle_one_is_refused() {
let section = SessionSection {
absolute_timeout_s: 10,
idle_timeout_s: 20,
..SessionSection::default()
};
assert!(matches!(
section.check_with(false),
Err(ConfigError::AbsoluteBeforeIdle)
));
}
#[test]
fn a_sealed_store_refuses_offline_access_when_it_loads() {
assert!(matches!(
sealed().check_with(true),
Err(ConfigError::OfflineAccessSealed)
));
assert!(sealed().check_with(false).is_ok(), "without offline_access");
}
#[test]
fn a_sealed_store_without_a_usable_key_is_refused() {
let none = SessionSection {
key: None,
..sealed()
};
assert!(matches!(none.check_with(false), Err(ConfigError::NoKey)));
let short = SessionSection {
key: Some(key(32)),
..sealed()
};
assert!(matches!(
short.check_with(false),
Err(ConfigError::KeyShort { found: 32 })
));
let garbled = SessionSection {
key: Some(Secret::new("not base64 !")),
..sealed()
};
assert!(matches!(
garbled.check_with(false),
Err(ConfigError::KeyBase64 { .. })
));
}
#[test]
fn a_key_beside_a_memory_store_is_refused() {
let section = SessionSection {
key: Some(key(64)),
..SessionSection::default()
};
assert!(matches!(
section.check_with(false),
Err(ConfigError::KeyUnused)
));
}
#[test]
fn no_secret_reaches_a_debug_rendering_or_an_error() {
let section = sealed();
let shown = format!(
"{section:?} {:?}",
section.check_with(false).expect("valid")
);
let raw = section.key.as_ref().expect("set").expose().to_owned();
assert!(!shown.contains(&raw), "{shown}");
let oidc = OidcSection {
issuer: String::from("https://idp.example.org"),
client_id: String::from("console"),
client_secret: Some(Secret::new("synthetic-client-secret")),
redirect_uri: String::from("https://console.example.org/auth/callback"),
..OidcSection::default()
};
let shown = format!("{oidc:?} {:?}", oidc.check().expect("valid"));
assert!(!shown.contains("synthetic-client-secret"), "{shown}");
}
fn oidc() -> OidcSection {
OidcSection {
issuer: String::from("https://idp.example.org"),
client_id: String::from("console"),
redirect_uri: String::from("https://console.example.org/auth/callback"),
..OidcSection::default()
}
}
#[test]
fn a_provider_url_off_loopback_must_be_https() {
assert!(oidc().check().is_ok(), "https");
for (key, section) in [
(
"oidc.issuer",
OidcSection {
issuer: String::from("http://idp.example.org"),
..oidc()
},
),
(
"oidc.token_endpoint",
OidcSection {
token_endpoint: Some(String::from("http://idp.example.org/token")),
..oidc()
},
),
(
"oidc.revocation_endpoint",
OidcSection {
revocation_endpoint: Some(String::from("http://idp.example.org/revoke")),
..oidc()
},
),
] {
let refused = section.check();
assert!(
matches!(refused, Err(ConfigError::UrlShape { key: named, .. }) if named == key),
"{key}: {refused:?}"
);
}
let loopback = OidcSection {
issuer: String::from("http://127.0.0.1:8081/realm"),
jwks_uri: Some(String::from("http://localhost:8081/jwks")),
..oidc()
};
assert!(loopback.check().is_ok(), "plain http on loopback");
}
#[test]
fn a_redirect_with_a_fragment_or_userinfo_is_refused() {
for redirect in [
"https://console.example.org/auth/callback#x",
"https://u:p@console.example.org/auth/callback",
"ftp://console.example.org/auth/callback",
] {
let refused = OidcSection {
redirect_uri: String::from(redirect),
..oidc()
}
.check();
assert!(
matches!(
refused,
Err(ConfigError::UrlShape {
key: "oidc.redirect_uri",
..
})
),
"{redirect}: {refused:?}"
);
}
}
#[test]
fn the_scopes_hold_openid_and_never_offline_access() {
let refused = OidcSection {
scopes: vec![String::from("profile")],
..oidc()
}
.check();
assert!(matches!(refused, Err(ConfigError::NoOpenId)), "{refused:?}");
let refused = OidcSection {
scopes: vec![String::from(OPENID), String::from(OFFLINE_ACCESS)],
..oidc()
}
.check();
assert!(
matches!(refused, Err(ConfigError::OfflineAccessScope)),
"{refused:?}"
);
let asked = OidcSection {
offline_access: true,
..oidc()
};
assert!(asked.check().is_ok(), "offline_access = true asks for it");
}
#[test]
fn an_oidc_section_needs_an_issuer_a_client_and_a_redirect() {
let refused = OidcSection::default().check();
assert!(
matches!(refused, Err(ConfigError::Missing { key: "oidc.issuer" })),
"{refused:?}"
);
let refused = OidcSection {
issuer: String::from("https://idp.example.org"),
client_id: String::from("console"),
redirect_uri: String::from("not a url"),
..OidcSection::default()
}
.check();
assert!(
matches!(
refused,
Err(ConfigError::Url {
key: "oidc.redirect_uri",
..
})
),
"{refused:?}"
);
}
#[test]
fn resolve_entries_name_a_host_and_its_address() {
let section: OidcSection = toml::from_str(
"issuer = \"https://keycloak:8443/realms/console\"\n\
client_id = \"console\"\n\
redirect_uri = \"https://console.example.org/auth/callback\"\n\
resolve = [\"Keycloak = 127.0.0.1:8443\"]\n\
[tls]\nmin_version = \"1.3\"\n",
)
.expect("the section reads");
let settings = section.check().expect("valid");
assert_eq!(
settings.reach.resolve(),
[(
String::from("keycloak"),
"127.0.0.1:8443".parse().expect("an address")
)]
);
assert_eq!(
settings.reach.min_tls_version(),
ferrohealth_smart::client::TlsVersion::Tls13
);
assert!(
toml::from_str::<OidcSection>("[tls]\nmin_version = \"1.1\"").is_err(),
"no floor below TLS 1.2"
);
for (index, entry) in [
"keycloak",
"=127.0.0.1:8443",
"keycloak=localhost:8443",
"keycloak=127.0.0.1",
]
.into_iter()
.enumerate()
{
let refused = OidcSection {
resolve: vec![String::from("idp.example.org=127.0.0.1:443"); index]
.into_iter()
.chain([entry.to_owned()])
.collect(),
..oidc()
}
.check();
assert!(
matches!(refused, Err(ConfigError::Resolve { index: found }) if found == index),
"{entry}: {refused:?}"
);
}
}
#[test]
fn a_client_certificate_is_set_with_its_key() {
let dir = tempfile::tempdir().expect("a directory");
let certificate = dir.path().join("client.pem");
std::fs::write(&certificate, "synthetic certificate").expect("written");
let key = Secret::new("synthetic-client-key");
let both = OidcSection {
tls: OidcTlsSection {
client_certificate: Some(certificate.clone()),
client_key: Some(key.clone()),
..OidcTlsSection::default()
},
..oidc()
};
let settings = both.check().expect("valid");
assert!(settings.identity.is_some(), "the identity is read");
let shown = format!("{both:?} {settings:?}");
assert!(!shown.contains("synthetic-client-key"), "{shown}");
for tls in [
OidcTlsSection {
client_certificate: Some(certificate.clone()),
..OidcTlsSection::default()
},
OidcTlsSection {
client_key: Some(key.clone()),
..OidcTlsSection::default()
},
] {
let refused = OidcSection { tls, ..oidc() }.check();
assert!(
matches!(refused, Err(ConfigError::ClientIdentityHalf)),
"{refused:?}"
);
}
let refused = OidcSection {
tls: OidcTlsSection {
client_certificate: Some(dir.path().join("absent.pem")),
client_key: Some(key),
..OidcTlsSection::default()
},
..oidc()
}
.check();
assert!(
matches!(refused, Err(ConfigError::ClientCertificate { .. })),
"{refused:?}"
);
}
}