mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 03:09:50 +08:00
767 lines
27 KiB
Rust
767 lines
27 KiB
Rust
use async_trait::async_trait;
|
|||
use std::net::IpAddr;
|
|||
|
|
use url::{Host, Url};
|
||
|
|||
pub fn validate_oauth_redirect_uri(value: &str) -> Result<(), String> {
|
|||
|
|
let parsed =
|
||
|
|
Url::parse(value).map_err(|_| "redirect_uri must be an absolute URL".to_string())?;
|
||
|
|
let Some(host) = parsed.host() else {
|
||
|
|
return Err("redirect_uri must be an absolute URL".to_string());
|
||
|
|
};
|
||
|
|
let is_loopback = match host {
|
||
|
|
Host::Domain(domain) => domain.eq_ignore_ascii_case("localhost"),
|
||
|
|
Host::Ipv4(address) => address.is_loopback(),
|
||
|
|
Host::Ipv6(address) => address.is_loopback(),
|
||
|
|
};
|
||
|
|
if parsed.scheme() != "https" && !(parsed.scheme() == "http" && is_loopback) {
|
||
|
|
return Err(
|
||
|
|
"redirect_uri must use https, except for localhost or loopback IPs".to_string(),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||
|
|
return Err("redirect_uri must not contain URL credentials".to_string());
|
||
|
|
}
|
||
|
|
if parsed.fragment().is_some() {
|
||
|
|
return Err("redirect_uri must not contain a fragment".to_string());
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn validate_oauth_frontend_callback_url(value: &str) -> Result<(), String> {
|
||
|
|
let parsed = Url::parse(value)
|
||
|
|
.map_err(|_| "frontend_callback_url must be an absolute URL".to_string())?;
|
||
|
|
let Some(host) = parsed.host() else {
|
||
|
|
return Err("frontend_callback_url must be an absolute URL".to_string());
|
||
|
|
};
|
||
|
|
let is_loopback = match host {
|
||
|
|
Host::Domain(domain) => domain.eq_ignore_ascii_case("localhost"),
|
||
|
|
Host::Ipv4(address) => address.is_loopback(),
|
||
|
|
Host::Ipv6(address) => address.is_loopback(),
|
||
|
|
};
|
||
|
|
if parsed.scheme() != "https" && !(parsed.scheme() == "http" && is_loopback) {
|
||
|
|
return Err(
|
||
|
|
"frontend_callback_url must use https, except for localhost or loopback IPs"
|
||
|
|
.to_string(),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||
|
|
return Err("frontend_callback_url must not contain URL credentials".to_string());
|
||
|
|
}
|
||
|
|
if parsed.query().is_some() || parsed.fragment().is_some() {
|
||
|
|
return Err("frontend_callback_url must not contain a query or fragment".to_string());
|
||
|
|
}
|
||
|
|
if !parsed
|
||
|
|
.path()
|
||
|
|
.trim_end_matches('/')
|
||
|
|
.ends_with("/auth/callback")
|
||
|
|
{
|
||
|
|
return Err("frontend_callback_url path must end with /auth/callback".to_string());
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn validate_oauth_provider_endpoint_config(
|
||
|
|
provider_type: &str,
|
||
|
|
authorization_url_override: Option<&str>,
|
||
|
|
token_url_override: Option<&str>,
|
||
|
|
userinfo_url_override: Option<&str>,
|
||
|
|
extra_config: Option<&serde_json::Value>,
|
||
|
|
) -> Result<(), String> {
|
||
|
|
let provider_type = provider_type.trim().to_ascii_lowercase();
|
||
|
|
let mut provider_chars = provider_type.chars();
|
||
|
|
if !(3..=64).contains(&provider_type.len())
|
||
|
|
|| !provider_chars
|
||
|
|
.next()
|
||
|
|
.is_some_and(|character| character.is_ascii_lowercase())
|
||
|
|
|| !provider_chars.all(|character| {
|
||
|
|
character.is_ascii_lowercase()
|
||
|
|
|| character.is_ascii_digit()
|
||
|
|
|| matches!(character, '_' | '-')
|
||
|
|
})
|
||
|
|
{
|
||
|
|
return Err("provider_type contains invalid characters".to_string());
|
||
|
|
}
|
||
|
|
let is_custom = provider_type == "custom_oidc"
|
||
|
|
|| provider_type.starts_with("custom_oidc_")
|
||
|
|
|| provider_type.starts_with("custom_")
|
||
|
|
|| provider_type.starts_with("oidc_");
|
||
|
|
let allowed_domains = if provider_type == "linuxdo" {
|
||
|
|
vec![
|
||
|
|
"linux.do".to_string(),
|
||
|
|
"connect.linux.do".to_string(),
|
||
|
|
"connect.linuxdo.org".to_string(),
|
||
|
|
]
|
||
|
|
} else if is_custom {
|
||
|
|
oauth_custom_allowed_domains(extra_config)?
|
||
|
|
} else {
|
||
|
|
return Err("unsupported identity OAuth provider_type".to_string());
|
||
|
|
};
|
||
|
|
|
||
|
|
for (field, value) in [
|
||
|
|
("authorization_url_override", authorization_url_override),
|
||
|
|
("token_url_override", token_url_override),
|
||
|
|
("userinfo_url_override", userinfo_url_override),
|
||
|
|
] {
|
||
|
|
let value = value.map(str::trim).filter(|value| !value.is_empty());
|
||
|
|
if is_custom && value.is_none() {
|
||
|
|
return Err(format!("custom OIDC providers must configure {field}"));
|
||
|
|
}
|
||
|
|
if let Some(value) = value {
|
||
|
|
validate_oauth_endpoint_url(field, value, &allowed_domains)?;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn oauth_custom_allowed_domains(
|
||
|
|
extra_config: Option<&serde_json::Value>,
|
||
|
|
) -> Result<Vec<String>, String> {
|
||
|
|
let values = extra_config
|
||
|
|
.and_then(serde_json::Value::as_object)
|
||
|
|
.and_then(|object| {
|
||
|
|
object
|
||
|
|
.get("allowed_domains")
|
||
|
|
.or_else(|| object.get("oauth_allowed_domains"))
|
||
|
|
})
|
||
|
|
.and_then(serde_json::Value::as_array)
|
||
|
|
.ok_or_else(|| {
|
||
|
|
"custom OIDC providers must configure extra_config.allowed_domains".to_string()
|
||
|
|
})?;
|
||
|
|
let mut domains = Vec::with_capacity(values.len());
|
||
|
|
for value in values {
|
||
|
|
let domain = value
|
||
|
|
.as_str()
|
||
|
|
.map(str::trim)
|
||
|
|
.map(|value| value.trim_end_matches('.'))
|
||
|
|
.filter(|value| !value.is_empty())
|
||
|
|
.ok_or_else(|| "OAuth allowed_domains must contain only host names".to_string())?;
|
||
|
|
if domain.contains('/')
|
||
|
|
|| domain.contains('\\')
|
||
|
|
|| domain.contains('@')
|
||
|
|
|| domain.contains(':')
|
||
|
|
|| domain.contains(char::is_whitespace)
|
||
|
|
|| domain.parse::<IpAddr>().is_ok()
|
||
|
|
{
|
||
|
|
return Err(
|
||
|
|
"OAuth allowed_domains must contain DNS host names, not IP literals".to_string(),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
domains.push(domain.to_ascii_lowercase());
|
||
|
|
}
|
||
|
|
if domains.is_empty() {
|
||
|
|
return Err("custom OIDC providers must configure allowed domains".to_string());
|
||
|
|
}
|
||
|
|
Ok(domains)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn validate_oauth_endpoint_url(
|
||
|
|
field: &str,
|
||
|
|
value: &str,
|
||
|
|
allowed_domains: &[String],
|
||
|
|
) -> Result<(), String> {
|
||
|
|
let parsed = Url::parse(value).map_err(|_| format!("{field} must be an absolute URL"))?;
|
||
|
|
if parsed.scheme() != "https" || parsed.host_str().is_none() {
|
||
|
|
return Err(format!("{field} must be an absolute https URL"));
|
||
|
|
}
|
||
|
|
if matches!(parsed.host(), Some(Host::Ipv4(_)) | Some(Host::Ipv6(_))) {
|
||
|
|
return Err(format!(
|
||
|
|
"{field} must use a DNS host name, not an IP literal"
|
||
|
|
));
|
||
|
|
}
|
||
|
|
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||
|
|
return Err(format!("{field} must not contain URL credentials"));
|
||
|
|
}
|
||
|
|
if parsed.fragment().is_some() {
|
||
|
|
return Err(format!("{field} must not contain a fragment"));
|
||
|
|
}
|
||
|
|
if field == "authorization_url_override" {
|
||
|
|
for (name, _) in parsed.query_pairs() {
|
||
|
|
if matches!(
|
||
|
|
name.to_ascii_lowercase().as_str(),
|
||
|
|
"response_type"
|
||
|
|
| "client_id"
|
||
|
|
| "redirect_uri"
|
||
|
|
| "state"
|
||
|
|
| "scope"
|
||
|
|
| "code_challenge"
|
||
|
|
| "code_challenge_method"
|
||
|
|
) {
|
||
|
|
return Err(format!(
|
||
|
|
"{field} must not predefine OAuth authorization parameters"
|
||
|
|
));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !allowed_domains.is_empty() {
|
||
|
|
let host = parsed
|
||
|
|
.host_str()
|
||
|
|
.map(|value| value.trim_end_matches('.').to_ascii_lowercase())
|
||
|
|
.unwrap_or_default();
|
||
|
|
if !allowed_domains
|
||
|
|
.iter()
|
||
|
|
.any(|domain| host == *domain || host.ends_with(&format!(".{domain}")))
|
||
|
|
{
|
||
|
|
return Err(format!("{field} host is not in the provider allowlist"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct StoredOAuthProviderConfig {
|
|||
|
|
pub provider_type: String,
|
||
|
|
pub display_name: String,
|
||
|
|
pub client_id: String,
|
||
|
|
pub client_secret_encrypted: Option<String>,
|
||
|
|
pub authorization_url_override: Option<String>,
|
||
|
|
pub token_url_override: Option<String>,
|
||
|
|
pub userinfo_url_override: Option<String>,
|
||
|
|
pub scopes: Option<Vec<String>>,
|
||
|
|
pub redirect_uri: String,
|
||
|
|
pub frontend_callback_url: String,
|
||
|
|
pub attribute_mapping: Option<serde_json::Value>,
|
||
|
|
pub extra_config: Option<serde_json::Value>,
|
||
pub icon_url: Option<String>,
|
|||
pub is_enabled: bool,
|
|||
pub created_at_unix_ms: Option<u64>,
|
|||
pub updated_at_unix_secs: Option<u64>,
|
|||
|
|
}
|
||
|
|
|
||
impl std::fmt::Debug for StoredOAuthProviderConfig {
|
|||
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
|
|
formatter
|
||
|
|
.debug_struct("StoredOAuthProviderConfig")
|
||
|
|
.field("provider_type", &self.provider_type)
|
||
|
|
.field("display_name", &self.display_name)
|
||
|
|
.field("client_id", &self.client_id)
|
||
|
|
.field(
|
||
|
|
"client_secret_encrypted",
|
||
|
|
&self.client_secret_encrypted.as_ref().map(|_| "[REDACTED]"),
|
||
|
|
)
|
||
|
|
.field(
|
||
|
|
"authorization_url_override",
|
||
|
|
&self
|
||
|
|
.authorization_url_override
|
||
|
|
.as_ref()
|
||
|
|
.map(|_| "[REDACTED]"),
|
||
|
|
)
|
||
|
|
.field(
|
||
|
|
"token_url_override",
|
||
|
|
&self.token_url_override.as_ref().map(|_| "[REDACTED]"),
|
||
|
|
)
|
||
|
|
.field(
|
||
|
|
"userinfo_url_override",
|
||
|
|
&self.userinfo_url_override.as_ref().map(|_| "[REDACTED]"),
|
||
|
|
)
|
||
|
|
.field("scopes", &self.scopes)
|
||
|
|
.field("redirect_uri", &self.redirect_uri)
|
||
|
|
.field("frontend_callback_url", &self.frontend_callback_url)
|
||
|
|
.field(
|
||
|
|
"attribute_mapping",
|
||
|
|
&self.attribute_mapping.as_ref().map(|_| "[REDACTED]"),
|
||
|
|
)
|
||
|
|
.field(
|
||
|
|
"extra_config",
|
||
|
|
&self.extra_config.as_ref().map(|_| "[REDACTED]"),
|
||
|
|
)
|
||
|
|
.field("icon_url", &self.icon_url)
|
||
|
|
.field("is_enabled", &self.is_enabled)
|
||
|
|
.field("created_at_unix_ms", &self.created_at_unix_ms)
|
||
|
|
.field("updated_at_unix_secs", &self.updated_at_unix_secs)
|
||
|
|
.finish()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
impl StoredOAuthProviderConfig {
|
|||
|
|
pub fn new(
|
||
|
|
provider_type: String,
|
||
|
|
display_name: String,
|
||
|
|
client_id: String,
|
||
|
|
redirect_uri: String,
|
||
|
|
frontend_callback_url: String,
|
||
|
|
) -> Result<Self, crate::DataLayerError> {
|
||
|
|
if provider_type.trim().is_empty() {
|
||
|
|
return Err(crate::DataLayerError::UnexpectedValue(
|
||
|
|
"oauth_providers.provider_type is empty".to_string(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
if display_name.trim().is_empty() {
|
||
|
|
return Err(crate::DataLayerError::UnexpectedValue(
|
||
|
|
"oauth_providers.display_name is empty".to_string(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
if client_id.trim().is_empty() {
|
||
|
|
return Err(crate::DataLayerError::UnexpectedValue(
|
||
|
|
"oauth_providers.client_id is empty".to_string(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
if redirect_uri.trim().is_empty() {
|
||
|
|
return Err(crate::DataLayerError::UnexpectedValue(
|
||
|
|
"oauth_providers.redirect_uri is empty".to_string(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
if frontend_callback_url.trim().is_empty() {
|
||
|
|
return Err(crate::DataLayerError::UnexpectedValue(
|
||
|
|
"oauth_providers.frontend_callback_url is empty".to_string(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(Self {
|
||
|
|
provider_type,
|
||
|
|
display_name,
|
||
|
|
client_id,
|
||
|
|
client_secret_encrypted: None,
|
||
|
|
authorization_url_override: None,
|
||
|
|
token_url_override: None,
|
||
|
|
userinfo_url_override: None,
|
||
|
|
scopes: None,
|
||
|
|
redirect_uri,
|
||
|
|
frontend_callback_url,
|
||
|
|
attribute_mapping: None,
|
||
|
|
extra_config: None,
|
||
icon_url: None,
|
|||
is_enabled: false,
|
|||
created_at_unix_ms: None,
|
|||
updated_at_unix_secs: None,
|
|||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
#[allow(clippy::too_many_arguments)]
|
||
|
|
pub fn with_config_fields(
|
||
|
|
mut self,
|
||
|
|
client_secret_encrypted: Option<String>,
|
||
|
|
authorization_url_override: Option<String>,
|
||
|
|
token_url_override: Option<String>,
|
||
|
|
userinfo_url_override: Option<String>,
|
||
|
|
scopes: Option<Vec<String>>,
|
||
|
|
attribute_mapping: Option<serde_json::Value>,
|
||
|
|
extra_config: Option<serde_json::Value>,
|
||
icon_url: Option<String>,
|
|||
is_enabled: bool,
|
|||
|
|
) -> Self {
|
||
|
|
self.client_secret_encrypted = client_secret_encrypted;
|
||
|
|
self.authorization_url_override = authorization_url_override;
|
||
|
|
self.token_url_override = token_url_override;
|
||
|
|
self.userinfo_url_override = userinfo_url_override;
|
||
|
|
self.scopes = scopes;
|
||
|
|
self.attribute_mapping = attribute_mapping;
|
||
|
|
self.extra_config = extra_config;
|
||
self.icon_url = icon_url;
|
|||
self.is_enabled = is_enabled;
|
|||
|
|
self
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn with_timestamps(
|
||
|
|
mut self,
|
||
created_at_unix_ms: Option<u64>,
|
|||
updated_at_unix_secs: Option<u64>,
|
|||
|
|
) -> Self {
|
||
self.created_at_unix_ms = created_at_unix_ms;
|
|||
self.updated_at_unix_secs = updated_at_unix_secs;
|
|||
|
|
self
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
|
|||
pub enum EncryptedSecretUpdate {
|
|||
|
|
#[default]
|
||
|
|
Preserve,
|
||
|
|
Clear,
|
||
|
|
Set(String),
|
||
|
|
}
|
||
|
|
|
||
impl std::fmt::Debug for EncryptedSecretUpdate {
|
|||
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
|
|
match self {
|
||
|
|
Self::Preserve => formatter.write_str("Preserve"),
|
||
|
|
Self::Clear => formatter.write_str("Clear"),
|
||
|
|
Self::Set(_) => formatter.write_str("Set([REDACTED])"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
impl EncryptedSecretUpdate {
|
|||
|
|
pub fn mode_name(&self) -> &'static str {
|
||
|
|
match self {
|
||
|
|
Self::Preserve => "preserve",
|
||
|
|
Self::Clear => "clear",
|
||
|
|
Self::Set(_) => "set",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn value(&self) -> Option<&str> {
|
||
|
|
match self {
|
||
|
|
Self::Set(value) => Some(value.as_str()),
|
||
|
|
Self::Preserve | Self::Clear => None,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
|||
pub struct UpsertOAuthProviderConfigRecord {
|
|||
|
|
pub provider_type: String,
|
||
|
|
pub display_name: String,
|
||
|
|
pub client_id: String,
|
||
|
|
pub client_secret_encrypted: EncryptedSecretUpdate,
|
||
|
|
pub authorization_url_override: Option<String>,
|
||
|
|
pub token_url_override: Option<String>,
|
||
|
|
pub userinfo_url_override: Option<String>,
|
||
|
|
pub scopes: Option<Vec<String>>,
|
||
|
|
pub redirect_uri: String,
|
||
|
|
pub frontend_callback_url: String,
|
||
|
|
pub attribute_mapping: Option<serde_json::Value>,
|
||
|
|
pub extra_config: Option<serde_json::Value>,
|
||
pub icon_url: Option<String>,
|
|||
pub is_enabled: bool,
|
|||
|
|
}
|
||
|
|
|
||
impl std::fmt::Debug for UpsertOAuthProviderConfigRecord {
|
|||
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
|
|
formatter
|
||
|
|
.debug_struct("UpsertOAuthProviderConfigRecord")
|
||
|
|
.field("provider_type", &self.provider_type)
|
||
|
|
.field("display_name", &self.display_name)
|
||
|
|
.field("client_id", &self.client_id)
|
||
|
|
.field("client_secret_encrypted", &self.client_secret_encrypted)
|
||
|
|
.field(
|
||
|
|
"authorization_url_override",
|
||
|
|
&self
|
||
|
|
.authorization_url_override
|
||
|
|
.as_ref()
|
||
|
|
.map(|_| "[REDACTED]"),
|
||
|
|
)
|
||
|
|
.field(
|
||
|
|
"token_url_override",
|
||
|
|
&self.token_url_override.as_ref().map(|_| "[REDACTED]"),
|
||
|
|
)
|
||
|
|
.field(
|
||
|
|
"userinfo_url_override",
|
||
|
|
&self.userinfo_url_override.as_ref().map(|_| "[REDACTED]"),
|
||
|
|
)
|
||
|
|
.field("scopes", &self.scopes)
|
||
|
|
.field("redirect_uri", &"[REDACTED]")
|
||
|
|
.field("frontend_callback_url", &"[REDACTED]")
|
||
|
|
.field(
|
||
|
|
"attribute_mapping",
|
||
|
|
&self.attribute_mapping.as_ref().map(|_| "[REDACTED]"),
|
||
|
|
)
|
||
|
|
.field(
|
||
|
|
"extra_config",
|
||
|
|
&self.extra_config.as_ref().map(|_| "[REDACTED]"),
|
||
|
|
)
|
||
|
|
.field("icon_url", &self.icon_url)
|
||
|
|
.field("is_enabled", &self.is_enabled)
|
||
|
|
.finish()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, PartialEq)]
|
||
|
|
pub enum UpsertOAuthProviderConfigOutcome {
|
||
|
|
Upserted(StoredOAuthProviderConfig),
|
||
|
|
DisableRequiresConfirmation { affected_count: usize },
|
||
|
|
}
|
||
|
|
|
||
impl UpsertOAuthProviderConfigRecord {
|
|||
|
|
pub fn validate(&self) -> Result<(), crate::DataLayerError> {
|
||
|
|
if self.provider_type.trim().is_empty() {
|
||
|
|
return Err(crate::DataLayerError::InvalidInput(
|
||
|
|
"provider_type is required".to_string(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
if self.display_name.trim().is_empty() {
|
||
|
|
return Err(crate::DataLayerError::InvalidInput(
|
||
|
|
"display_name is required".to_string(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
if self.client_id.trim().is_empty() {
|
||
|
|
return Err(crate::DataLayerError::InvalidInput(
|
||
|
|
"client_id is required".to_string(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
if self.redirect_uri.trim().is_empty() {
|
||
|
|
return Err(crate::DataLayerError::InvalidInput(
|
||
|
|
"redirect_uri is required".to_string(),
|
||
|
|
));
|
||
|
|
}
|
||
validate_oauth_redirect_uri(self.redirect_uri.trim())
|
|||
|
|
.map_err(crate::DataLayerError::InvalidInput)?;
|
||
if self.frontend_callback_url.trim().is_empty() {
|
|||
|
|
return Err(crate::DataLayerError::InvalidInput(
|
||
|
|
"frontend_callback_url is required".to_string(),
|
||
|
|
));
|
||
|
|
}
|
||
validate_oauth_frontend_callback_url(self.frontend_callback_url.trim())
|
|||
|
|
.map_err(crate::DataLayerError::InvalidInput)?;
|
||
|
|
validate_oauth_provider_endpoint_config(
|
||
|
|
&self.provider_type,
|
||
|
|
self.authorization_url_override.as_deref(),
|
||
|
|
self.token_url_override.as_deref(),
|
||
|
|
self.userinfo_url_override.as_deref(),
|
||
|
|
self.extra_config.as_ref(),
|
||
|
|
)
|
||
|
|
.map_err(crate::DataLayerError::InvalidInput)?;
|
||
if let Some(scopes) = &self.scopes {
|
|||
|
|
for scope in scopes {
|
||
|
|
if scope.trim().is_empty() {
|
||
|
|
return Err(crate::DataLayerError::InvalidInput(
|
||
|
|
"scopes must not contain empty values".to_string(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
#[cfg(test)]
|
|||
|
|
mod tests {
|
||
|
|
use super::{
|
||
|
|
validate_oauth_frontend_callback_url, validate_oauth_provider_endpoint_config,
|
||
|
|
validate_oauth_redirect_uri, EncryptedSecretUpdate, StoredOAuthProviderConfig,
|
||
|
|
};
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn oauth_provider_debug_output_redacts_encrypted_client_secrets() {
|
||
|
|
let secret = "debug-secret-oauth-provider-ciphertext";
|
||
|
|
let provider = StoredOAuthProviderConfig::new(
|
||
|
|
"linuxdo".to_string(),
|
||
|
|
"Linux.do".to_string(),
|
||
|
|
"client-id".to_string(),
|
||
|
|
"https://gateway.example/api/oauth/linuxdo/callback".to_string(),
|
||
|
|
"https://frontend.example/auth/callback".to_string(),
|
||
|
|
)
|
||
|
|
.expect("provider should build")
|
||
|
|
.with_config_fields(
|
||
|
|
Some(secret.to_string()),
|
||
|
|
None,
|
||
|
|
None,
|
||
|
|
None,
|
||
|
|
None,
|
||
|
|
None,
|
||
|
|
None,
|
||
|
|
None,
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
|
||
|
|
for rendered in [
|
||
|
|
format!("{provider:?}"),
|
||
|
|
format!("{:?}", EncryptedSecretUpdate::Set(secret.to_string())),
|
||
|
|
] {
|
||
|
|
assert!(!rendered.contains(secret));
|
||
|
|
assert!(rendered.contains("[REDACTED]"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn oauth_redirect_uri_requires_absolute_http_url_without_credentials() {
|
||
|
|
assert!(
|
||
|
|
validate_oauth_redirect_uri("https://gateway.example/api/oauth/custom/callback")
|
||
|
|
.is_ok()
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
validate_oauth_redirect_uri("http://localhost:8080/api/oauth/custom/callback").is_ok()
|
||
|
|
);
|
||
|
|
for value in [
|
||
|
|
"http://gateway.example/api/oauth/custom/callback",
|
||
|
|
"/api/oauth/custom/callback",
|
||
|
|
"javascript:alert(1)",
|
||
|
|
"https://user:[email protected]/api/oauth/custom/callback",
|
||
|
|
] {
|
||
|
|
assert!(
|
||
|
|
validate_oauth_redirect_uri(value).is_err(),
|
||
|
|
"accepted {value}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn oauth_provider_endpoints_require_https_and_the_configured_domain() {
|
||
|
|
let extra = serde_json::json!({"allowed_domains": ["idp.example"]});
|
||
|
|
assert!(validate_oauth_provider_endpoint_config(
|
||
|
|
"custom_oidc_work",
|
||
|
|
Some("https://idp.example/oauth/authorize"),
|
||
|
|
Some("https://idp.example/oauth/token"),
|
||
|
|
Some("https://accounts.idp.example/oauth/userinfo"),
|
||
|
|
Some(&extra),
|
||
|
|
)
|
||
|
|
.is_ok());
|
||
|
|
assert!(validate_oauth_provider_endpoint_config(
|
||
|
|
"custom_oidc_work",
|
||
|
|
Some("https://idp.example/oauth/authorize"),
|
||
|
|
Some("https://attacker.example/oauth/token"),
|
||
|
|
Some("https://idp.example/oauth/userinfo"),
|
||
|
|
Some(&extra),
|
||
|
|
)
|
||
|
|
.is_err());
|
||
|
|
assert!(validate_oauth_provider_endpoint_config(
|
||
|
|
"custom_oidc_work",
|
||
|
|
Some("https://127.0.0.1/oauth/authorize"),
|
||
|
|
Some("https://idp.example/oauth/token"),
|
||
|
|
Some("https://idp.example/oauth/userinfo"),
|
||
|
|
Some(&serde_json::json!({"allowed_domains": ["127.0.0.1"]})),
|
||
|
|
)
|
||
|
|
.is_err());
|
||
|
|
assert!(validate_oauth_provider_endpoint_config(
|
||
|
|
"custom_oidc_work",
|
||
|
|
Some("https://idp.example/oauth/authorize?client_id=attacker"),
|
||
|
|
Some("https://idp.example/oauth/token"),
|
||
|
|
Some("https://idp.example/oauth/userinfo"),
|
||
|
|
Some(&extra),
|
||
|
|
)
|
||
|
|
.is_err());
|
||
|
|
assert!(validate_oauth_provider_endpoint_config(
|
||
|
|
"custom_oidc_work",
|
||
|
|
Some("https://idp.example/oauth/authorize"),
|
||
|
|
Some("http://idp.example/oauth/token"),
|
||
|
|
Some("https://idp.example/oauth/userinfo"),
|
||
|
|
Some(&extra),
|
||
|
|
)
|
||
|
|
.is_err());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn oauth_provider_endpoints_reject_ip_literals_and_predefined_authorization_parameters() {
|
||
|
|
for host in ["127.0.0.1", "[::1]"] {
|
||
|
|
let extra = serde_json::json!({"allowed_domains": [host]});
|
||
|
|
assert!(validate_oauth_provider_endpoint_config(
|
||
|
|
"custom_oidc_work",
|
||
|
|
Some(&format!("https://{host}/oauth/authorize")),
|
||
|
|
Some(&format!("https://{host}/oauth/token")),
|
||
|
|
Some(&format!("https://{host}/oauth/userinfo")),
|
||
|
|
Some(&extra),
|
||
|
|
)
|
||
|
|
.is_err());
|
||
|
|
}
|
||
|
|
|
||
|
|
let extra = serde_json::json!({"allowed_domains": ["idp.example"]});
|
||
|
|
for name in [
|
||
|
|
"response_type",
|
||
|
|
"client_id",
|
||
|
|
"redirect_uri",
|
||
|
|
"state",
|
||
|
|
"scope",
|
||
|
|
"code_challenge",
|
||
|
|
"code_challenge_method",
|
||
|
|
] {
|
||
|
|
assert!(validate_oauth_provider_endpoint_config(
|
||
|
|
"custom_oidc_work",
|
||
|
|
Some(&format!(
|
||
|
|
"https://idp.example/oauth/authorize?{name}=attacker"
|
||
|
|
)),
|
||
|
|
Some("https://idp.example/oauth/token?tenant=workforce"),
|
||
|
|
Some("https://idp.example/oauth/userinfo?schema=current"),
|
||
|
|
Some(&extra),
|
||
|
|
)
|
||
|
|
.is_err());
|
||
|
|
}
|
||
|
|
|
||
|
|
assert!(validate_oauth_provider_endpoint_config(
|
||
|
|
"custom_oidc_work",
|
||
|
|
Some("https://idp.example/oauth/authorize?tenant=workforce"),
|
||
|
|
Some("https://idp.example/oauth/token?tenant=workforce"),
|
||
|
|
Some("https://idp.example/oauth/userinfo?schema=current"),
|
||
|
|
Some(&extra),
|
||
|
|
)
|
||
|
|
.is_ok());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn oauth_frontend_callback_rejects_token_exfiltration_targets() {
|
||
|
|
for value in [
|
||
|
|
"https://frontend.example/auth/callback",
|
||
|
|
"http://localhost:5173/auth/callback",
|
||
|
|
"http://127.0.0.1:5173/auth/callback",
|
||
|
|
"http://[::1]:5173/auth/callback",
|
||
|
|
] {
|
||
|
|
assert!(
|
||
|
|
validate_oauth_frontend_callback_url(value).is_ok(),
|
||
|
|
"rejected {value}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
for value in [
|
||
|
|
"http://attacker.example/auth/callback",
|
||
|
|
"https://user:[email protected]/auth/callback",
|
||
|
|
"https://frontend.example/auth/callback?next=https://attacker.example",
|
||
|
|
"https://frontend.example/auth/callback#access_token=stolen",
|
||
|
|
"https://frontend.example/not-the-callback",
|
||
|
|
] {
|
||
|
|
assert!(
|
||
|
|
validate_oauth_frontend_callback_url(value).is_err(),
|
||
|
|
"accepted {value}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
#[async_trait]
|
|||
|
|
pub trait OAuthProviderReadRepository: Send + Sync {
|
||
|
|
async fn list_oauth_provider_configs(
|
||
|
|
&self,
|
||
|
|
) -> Result<Vec<StoredOAuthProviderConfig>, crate::DataLayerError>;
|
||
|
|
|
||
|
|
async fn get_oauth_provider_config(
|
||
|
|
&self,
|
||
|
|
provider_type: &str,
|
||
|
|
) -> Result<Option<StoredOAuthProviderConfig>, crate::DataLayerError>;
|
||
|
|
|
||
|
|
async fn count_locked_users_if_provider_disabled(
|
||
|
|
&self,
|
||
|
|
provider_type: &str,
|
||
|
|
ldap_exclusive: bool,
|
||
|
|
) -> Result<usize, crate::DataLayerError>;
|
||
|
|
}
|
||
|
|
|
||
|
|
#[async_trait]
|
||
|
|
pub trait OAuthProviderWriteRepository: Send + Sync {
|
||
|
|
async fn upsert_oauth_provider_config(
|
||
|
|
&self,
|
||
|
|
record: &UpsertOAuthProviderConfigRecord,
|
||
) -> Result<StoredOAuthProviderConfig, crate::DataLayerError> {
|
|||
|
|
match self
|
||
|
|
.upsert_oauth_provider_config_guarded(record, false, false, 0)
|
||
|
|
.await?
|
||
|
|
{
|
||
|
|
UpsertOAuthProviderConfigOutcome::Upserted(provider) => Ok(provider),
|
||
|
|
UpsertOAuthProviderConfigOutcome::DisableRequiresConfirmation { affected_count } => {
|
||
|
|
Err(crate::DataLayerError::InvalidInput(format!(
|
||
|
|
"disabling OAuth provider requires confirmation for {affected_count} affected users"
|
||
|
|
)))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|||
async fn upsert_oauth_provider_config_guarded(
|
|||
|
|
&self,
|
||
|
|
record: &UpsertOAuthProviderConfigRecord,
|
||
|
|
ldap_exclusive: bool,
|
||
|
|
force_disable: bool,
|
||
|
|
locked_users_snapshot: usize,
|
||
|
|
) -> Result<UpsertOAuthProviderConfigOutcome, crate::DataLayerError>;
|
||
|
|
|
||
|
|
/// Replace only the stored client secret when the provider and exact previously observed
|
||
|
|
/// ciphertext still match. Implementations must not modify `updated_at` or any non-secret
|
||
|
|
/// provider field; this is used by lazy record-bound ciphertext migration.
|
||
|
|
async fn compare_and_swap_oauth_provider_client_secret(
|
||
&self,
|
|||
|
|
provider_type: &str,
|
||
expected: &str,
|
|||
|
|
replacement: &str,
|
||
|
|
) -> Result<bool, crate::DataLayerError>;
|
||
|
|
|
||
|
|
async fn delete_oauth_provider_config_if_unlinked(
|
||
|
|
&self,
|
||
|
|
provider_type: &str,
|
||
|
|
has_links_snapshot: bool,
|
||
) -> Result<bool, crate::DataLayerError>;
|
|||
|
|
}
|
||
|
|
|
||
|
|
pub trait OAuthProviderRepository:
|
||
|
|
OAuthProviderReadRepository + OAuthProviderWriteRepository + Send + Sync
|
||
|
|
{
|
||
|
|
}
|
||
|
|
|
||
|
|
impl<T> OAuthProviderRepository for T where
|
||
|
|
T: OAuthProviderReadRepository + OAuthProviderWriteRepository + Send + Sync
|
||
|
|
{
|
||
|
|
}
|