use provcfg::{ClapArgs, Configurable};
use serde::{Deserialize, Serialize};
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)]
#[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 self.issuer_url.is_none() {
return Err("OAuth2 is enabled but issuer_url is not set".to_string());
}
if self.client_id.is_none() {
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(())
}
}
#[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());
}
#[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"));
}
}