use provcfg::{ClapArgs, Configurable};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
fn default_scopes() -> Vec<String> {
vec![
"openid".to_string(),
"profile".to_string(),
"email".to_string(),
]
}
fn default_button_text() -> String {
"Log in with SSO".to_string()
}
#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone, Configurable, ClapArgs, ToSchema)]
#[serde(default)]
#[configurable(clap_prefix = "oauth2")]
#[allow(clippy::struct_excessive_bools)]
pub struct OAuth2 {
pub enabled: bool,
pub issuer_url: Option<String>,
pub client_id: Option<String>,
#[serde(skip_serializing)]
#[configurable(secret)]
pub client_secret: Option<String>,
#[configurable(env_list)]
#[arg(value_delimiter = ',')]
pub additional_audiences: Vec<String>,
#[configurable(env_list)]
#[arg(value_delimiter = ',')]
pub scopes: Vec<String>,
pub auto_provision_users: bool,
pub admin_group_claim: Option<String>,
pub admin_group_value: Option<String>,
pub read_only_group_claim: Option<String>,
pub read_only_group_value: Option<String>,
pub button_text: String,
pub enforced: bool,
pub auto_redirect: bool,
}
impl Default for OAuth2 {
fn default() -> Self {
Self {
enabled: false,
issuer_url: None,
client_id: None,
client_secret: None,
additional_audiences: Vec::new(),
scopes: default_scopes(),
auto_provision_users: true,
admin_group_claim: None,
admin_group_value: None,
read_only_group_claim: None,
read_only_group_value: None,
button_text: default_button_text(),
enforced: false,
auto_redirect: false,
}
}
}
impl OAuth2 {
pub fn validate(&self) -> Result<(), String> {
if self.enforced && !self.enabled {
return Err("OAuth2 enforced is set but OAuth2 is not enabled; \
this would disable all login. Set oauth2.enabled = true"
.to_string());
}
if self.auto_redirect && !self.enforced {
return Err("OAuth2 auto_redirect is set but OAuth2 is not enforced; \
set oauth2.enforced = true to redirect straight to SSO"
.to_string());
}
if !self.enabled {
return Ok(());
}
if is_unset(self.issuer_url.as_ref()) {
return Err("OAuth2 is enabled but issuer_url is not set".to_string());
}
if is_unset(self.client_id.as_ref()) {
return Err("OAuth2 is enabled but client_id is not set".to_string());
}
if self.client_secret.is_none() {
return Err("OAuth2 is enabled but client_secret is not set. \
Set it via KELLNR_OAUTH2__CLIENT_SECRET environment variable"
.to_string());
}
if self.scopes.is_empty() {
return Err("OAuth2 scopes cannot be empty".to_string());
}
if !self.scopes.contains(&"openid".to_string()) {
return Err("OAuth2 scopes must contain 'openid' for OIDC".to_string());
}
Ok(())
}
pub fn admin_claim(&self) -> Option<ClaimMatch<'_>> {
ClaimMatch::new(
self.admin_group_claim.as_deref(),
self.admin_group_value.as_deref(),
)
}
pub fn read_only_claim(&self) -> Option<ClaimMatch<'_>> {
ClaimMatch::new(
self.read_only_group_claim.as_deref(),
self.read_only_group_value.as_deref(),
)
}
pub fn warnings(&self) -> Vec<String> {
let mut warnings = Vec::new();
if !self.enabled {
return warnings;
}
if self.admin_group_claim.is_some() != self.admin_group_value.is_some() {
warnings.push(
"OAuth2 admin_group_claim and admin_group_value must be set together, \
admin status is not synced from the provider"
.to_string(),
);
}
if self.read_only_group_claim.is_some() != self.read_only_group_value.is_some() {
warnings.push(
"OAuth2 read_only_group_claim and read_only_group_value must be set \
together, read-only status is not synced from the provider"
.to_string(),
);
}
warnings
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClaimMatch<'a> {
pub claim: &'a str,
pub value: &'a str,
}
impl<'a> ClaimMatch<'a> {
fn new(claim: Option<&'a str>, value: Option<&'a str>) -> Option<Self> {
Some(Self {
claim: claim?,
value: value?,
})
}
}
fn is_unset(value: Option<&String>) -> bool {
value.is_none_or(|v| v.trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_oauth2() {
let oauth2 = OAuth2::default();
assert!(!oauth2.enabled);
assert!(oauth2.issuer_url.is_none());
assert!(oauth2.client_id.is_none());
assert!(oauth2.client_secret.is_none());
assert!(oauth2.additional_audiences.is_empty());
assert_eq!(oauth2.scopes, vec!["openid", "profile", "email"]);
assert!(oauth2.auto_provision_users);
assert_eq!(oauth2.button_text, "Log in with SSO");
assert!(!oauth2.enforced);
assert!(!oauth2.auto_redirect);
}
#[test]
fn test_validate_disabled() {
let oauth2 = OAuth2::default();
assert!(oauth2.validate().is_ok());
}
#[test]
fn test_validate_enabled_missing_issuer() {
let oauth2 = OAuth2 {
enabled: true,
..Default::default()
};
let result = oauth2.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("issuer_url"));
}
#[test]
fn test_validate_enabled_missing_client_id() {
let oauth2 = OAuth2 {
enabled: true,
issuer_url: Some("https://example.com".to_string()),
..Default::default()
};
let result = oauth2.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("client_id"));
}
#[test]
fn test_validate_enabled_missing_client_secret() {
let oauth2 = OAuth2 {
enabled: true,
issuer_url: Some("https://example.com".to_string()),
client_id: Some("client-id".to_string()),
..Default::default()
};
let result = oauth2.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("client_secret"));
}
#[test]
fn test_validate_enabled_valid() {
let oauth2 = OAuth2 {
enabled: true,
issuer_url: Some("https://example.com".to_string()),
client_id: Some("client-id".to_string()),
client_secret: Some("client-secret".to_string()),
..Default::default()
};
assert!(oauth2.validate().is_ok());
}
fn enabled() -> OAuth2 {
OAuth2 {
enabled: true,
issuer_url: Some("https://example.com".to_string()),
client_id: Some("client-id".to_string()),
client_secret: Some("client-secret".to_string()),
..Default::default()
}
}
#[test]
fn test_validate_empty_required_values() {
for oauth2 in [
OAuth2 {
issuer_url: Some(String::new()),
..enabled()
},
OAuth2 {
client_id: Some(" ".to_string()),
..enabled()
},
] {
assert!(oauth2.validate().unwrap_err().contains("not set"));
}
}
#[test]
fn test_validate_accepts_empty_client_secret() {
let oauth2 = OAuth2 {
client_secret: Some(String::new()),
..enabled()
};
assert!(oauth2.validate().is_ok());
}
#[test]
fn test_warnings_for_half_configured_group_claims() {
for oauth2 in [
OAuth2 {
admin_group_claim: Some("groups".to_string()),
..enabled()
},
OAuth2 {
admin_group_value: Some("admins".to_string()),
..enabled()
},
OAuth2 {
read_only_group_claim: Some("groups".to_string()),
..enabled()
},
OAuth2 {
read_only_group_value: Some("readers".to_string()),
..enabled()
},
] {
assert!(oauth2.validate().is_ok());
let warnings = oauth2.warnings();
assert_eq!(1, warnings.len());
assert!(warnings[0].contains("set together"));
}
}
#[test]
fn claim_matches_need_claim_and_value() {
let oauth2 = OAuth2 {
admin_group_claim: Some("groups".to_string()),
admin_group_value: Some("admins".to_string()),
read_only_group_claim: Some("roles".to_string()),
..OAuth2::default()
};
assert_eq!(
oauth2.admin_claim(),
Some(ClaimMatch {
claim: "groups",
value: "admins"
})
);
assert_eq!(oauth2.read_only_claim(), None);
let oauth2 = OAuth2 {
read_only_group_value: Some("readers".to_string()),
..OAuth2::default()
};
assert_eq!(oauth2.admin_claim(), None);
assert_eq!(oauth2.read_only_claim(), None);
}
#[test]
fn test_validate_complete_group_claims() {
let oauth2 = OAuth2 {
admin_group_claim: Some("groups".to_string()),
admin_group_value: Some("admins".to_string()),
read_only_group_claim: Some("groups".to_string()),
read_only_group_value: Some("readers".to_string()),
..enabled()
};
assert!(oauth2.validate().is_ok());
assert!(oauth2.warnings().is_empty());
}
#[test]
fn test_validate_empty_scopes() {
let oauth2 = OAuth2 {
enabled: true,
issuer_url: Some("https://example.com".to_string()),
client_id: Some("client-id".to_string()),
client_secret: Some("client-secret".to_string()),
scopes: vec![],
..Default::default()
};
let result = oauth2.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("empty"));
}
#[test]
fn test_validate_enforced_without_enabled() {
let oauth2 = OAuth2 {
enforced: true,
..Default::default()
};
let result = oauth2.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("enforced"));
}
#[test]
fn test_validate_enforced_with_valid_enabled() {
let oauth2 = OAuth2 {
enabled: true,
enforced: true,
issuer_url: Some("https://example.com".to_string()),
client_id: Some("client-id".to_string()),
client_secret: Some("client-secret".to_string()),
..Default::default()
};
assert!(oauth2.validate().is_ok());
}
#[test]
fn test_validate_auto_redirect_without_enforced() {
let oauth2 = OAuth2 {
enabled: true,
auto_redirect: true,
issuer_url: Some("https://example.com".to_string()),
client_id: Some("client-id".to_string()),
client_secret: Some("client-secret".to_string()),
..Default::default()
};
let result = oauth2.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("auto_redirect"));
}
#[test]
fn test_validate_auto_redirect_with_enforced() {
let oauth2 = OAuth2 {
enabled: true,
enforced: true,
auto_redirect: true,
issuer_url: Some("https://example.com".to_string()),
client_id: Some("client-id".to_string()),
client_secret: Some("client-secret".to_string()),
..Default::default()
};
assert!(oauth2.validate().is_ok());
}
#[test]
fn test_validate_missing_openid_scope() {
let oauth2 = OAuth2 {
enabled: true,
issuer_url: Some("https://example.com".to_string()),
client_id: Some("client-id".to_string()),
client_secret: Some("client-secret".to_string()),
scopes: vec!["profile".to_string(), "email".to_string()],
..Default::default()
};
let result = oauth2.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("openid"));
}
}