use serde::{Deserialize, Serialize};
use uuid::Uuid;
use chrono::{DateTime, Utc};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SsoProvider {
Saml,
OAuth2,
Oidc,
Ldap,
}
impl std::fmt::Display for SsoProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SsoProvider::Saml => write!(f, "saml"),
SsoProvider::OAuth2 => write!(f, "oauth2"),
SsoProvider::Oidc => write!(f, "oidc"),
SsoProvider::Ldap => write!(f, "ldap"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SsoConnection {
pub id: Uuid,
pub organization_id: Uuid,
pub provider: SsoProvider,
pub name: String,
pub config: SsoConfig,
pub is_enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum SsoConfig {
Saml(SamlConfig),
OAuth2(OAuth2Config),
Oidc(OidcConfig),
Ldap(LdapConfig),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SamlConfig {
pub idp_metadata_url: String,
pub sp_entity_id: String,
pub acs_url: String,
pub slo_url: Option<String>,
pub signing_cert: Option<String>,
pub attribute_mapping: HashMap<String, String>,
pub want_assertions_signed: bool,
pub want_response_signed: bool,
}
impl Default for SamlConfig {
fn default() -> Self {
let mut attribute_mapping = HashMap::new();
attribute_mapping.insert("email".to_string(), "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress".to_string());
attribute_mapping.insert("name".to_string(), "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name".to_string());
Self {
idp_metadata_url: String::new(),
sp_entity_id: "https://portalis.dev/saml/metadata".to_string(),
acs_url: "https://portalis.dev/saml/acs".to_string(),
slo_url: Some("https://portalis.dev/saml/slo".to_string()),
signing_cert: None,
attribute_mapping,
want_assertions_signed: true,
want_response_signed: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuth2Config {
pub client_id: String,
pub client_secret: String,
pub authorization_url: String,
pub token_url: String,
pub userinfo_url: String,
pub scopes: Vec<String>,
pub redirect_uri: String,
}
impl Default for OAuth2Config {
fn default() -> Self {
Self {
client_id: String::new(),
client_secret: String::new(),
authorization_url: String::new(),
token_url: String::new(),
userinfo_url: String::new(),
scopes: vec!["openid".to_string(), "email".to_string(), "profile".to_string()],
redirect_uri: "https://portalis.dev/oauth2/callback".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcConfig {
pub client_id: String,
pub client_secret: String,
pub discovery_url: String,
pub scopes: Vec<String>,
pub redirect_uri: String,
pub use_pkce: bool,
}
impl Default for OidcConfig {
fn default() -> Self {
Self {
client_id: String::new(),
client_secret: String::new(),
discovery_url: String::new(),
scopes: vec!["openid".to_string(), "email".to_string(), "profile".to_string()],
redirect_uri: "https://portalis.dev/oidc/callback".to_string(),
use_pkce: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LdapConfig {
pub server_url: String,
pub bind_dn: String,
pub bind_password: String,
pub user_base_dn: String,
pub user_search_filter: String,
pub attribute_mapping: HashMap<String, String>,
pub use_tls: bool,
}
impl Default for LdapConfig {
fn default() -> Self {
let mut attribute_mapping = HashMap::new();
attribute_mapping.insert("email".to_string(), "mail".to_string());
attribute_mapping.insert("name".to_string(), "cn".to_string());
Self {
server_url: "ldap://localhost:389".to_string(),
bind_dn: String::new(),
bind_password: String::new(),
user_base_dn: "ou=users,dc=example,dc=com".to_string(),
user_search_filter: "(uid={username})".to_string(),
attribute_mapping,
use_tls: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SsoAuthRequest {
pub connection_id: Uuid,
pub return_url: Option<String>,
pub state: String, }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SsoAuthResponse {
pub user_id: Uuid,
pub email: String,
pub name: Option<String>,
pub provider: SsoProvider,
pub external_id: String,
pub attributes: HashMap<String, String>,
}
pub struct SsoService {
}
impl SsoService {
pub fn new() -> Self {
Self {}
}
pub fn get_authorization_url(
&self,
connection: &SsoConnection,
state: &str,
) -> Result<String, SsoError> {
match &connection.config {
SsoConfig::Saml(config) => {
Ok(format!(
"{}?SAMLRequest=<encoded>&RelayState={}",
config.idp_metadata_url, state
))
}
SsoConfig::OAuth2(config) => {
let scope = config.scopes.join(" ");
Ok(format!(
"{}?client_id={}&redirect_uri={}&scope={}&state={}&response_type=code",
config.authorization_url,
config.client_id, config.redirect_uri, scope, state
))
}
SsoConfig::Oidc(config) => {
let scope = config.scopes.join(" ");
let mut url = format!(
"{}?client_id={}&redirect_uri={}&scope={}&state={}&response_type=code",
config.discovery_url.replace("/.well-known/openid-configuration", "/authorize"),
config.client_id, config.redirect_uri, scope, state
);
if config.use_pkce {
url.push_str("&code_challenge=<challenge>&code_challenge_method=S256");
}
Ok(url)
}
SsoConfig::Ldap(_) => {
Err(SsoError::UnsupportedProvider("LDAP does not use authorization URLs".to_string()))
}
}
}
pub async fn handle_callback(
&self,
connection: &SsoConnection,
code: &str,
state: &str,
) -> Result<SsoAuthResponse, SsoError> {
Err(SsoError::NotImplemented("SSO callback handling not yet implemented".to_string()))
}
pub async fn authenticate_ldap(
&self,
connection: &SsoConnection,
username: &str,
password: &str,
) -> Result<SsoAuthResponse, SsoError> {
Err(SsoError::NotImplemented("LDAP authentication not yet implemented".to_string()))
}
}
impl Default for SsoService {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, thiserror::Error)]
pub enum SsoError {
#[error("SSO provider not supported: {0}")]
UnsupportedProvider(String),
#[error("Invalid SSO configuration: {0}")]
InvalidConfig(String),
#[error("SSO authentication failed: {0}")]
AuthenticationFailed(String),
#[error("Not implemented: {0}")]
NotImplemented(String),
#[error("Invalid state parameter (CSRF check failed)")]
InvalidState,
#[error("Token exchange failed: {0}")]
TokenExchangeFailed(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sso_provider_display() {
assert_eq!(SsoProvider::Saml.to_string(), "saml");
assert_eq!(SsoProvider::OAuth2.to_string(), "oauth2");
assert_eq!(SsoProvider::Oidc.to_string(), "oidc");
assert_eq!(SsoProvider::Ldap.to_string(), "ldap");
}
#[test]
fn test_saml_config_default() {
let config = SamlConfig::default();
assert_eq!(config.sp_entity_id, "https://portalis.dev/saml/metadata");
assert!(config.want_assertions_signed);
assert!(config.attribute_mapping.contains_key("email"));
}
#[test]
fn test_oauth2_config_default() {
let config = OAuth2Config::default();
assert!(config.scopes.contains(&"openid".to_string()));
assert!(config.scopes.contains(&"email".to_string()));
assert_eq!(config.redirect_uri, "https://portalis.dev/oauth2/callback");
}
#[test]
fn test_oidc_config_default() {
let config = OidcConfig::default();
assert!(config.use_pkce);
assert!(config.scopes.contains(&"profile".to_string()));
}
#[test]
fn test_ldap_config_default() {
let config = LdapConfig::default();
assert_eq!(config.server_url, "ldap://localhost:389");
assert!(config.use_tls);
assert!(config.attribute_mapping.contains_key("email"));
}
#[test]
fn test_sso_service_new() {
let service = SsoService::new();
}
#[test]
fn test_get_authorization_url_oauth2() {
let service = SsoService::new();
let connection = SsoConnection {
id: Uuid::new_v4(),
organization_id: Uuid::new_v4(),
provider: SsoProvider::OAuth2,
name: "Google".to_string(),
config: SsoConfig::OAuth2(OAuth2Config {
client_id: "test-client".to_string(),
client_secret: "secret".to_string(),
authorization_url: "https://accounts.google.com/o/oauth2/auth".to_string(),
token_url: "https://oauth2.googleapis.com/token".to_string(),
userinfo_url: "https://www.googleapis.com/oauth2/v1/userinfo".to_string(),
scopes: vec!["email".to_string(), "profile".to_string()],
redirect_uri: "https://portalis.dev/oauth2/callback".to_string(),
}),
is_enabled: true,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let url = service.get_authorization_url(&connection, "test-state").unwrap();
assert!(url.contains("https://accounts.google.com/o/oauth2/auth"));
assert!(url.contains("client_id=test-client"));
assert!(url.contains("state=test-state"));
assert!(url.contains("response_type=code"));
}
#[test]
fn test_get_authorization_url_ldap_fails() {
let service = SsoService::new();
let connection = SsoConnection {
id: Uuid::new_v4(),
organization_id: Uuid::new_v4(),
provider: SsoProvider::Ldap,
name: "Active Directory".to_string(),
config: SsoConfig::Ldap(LdapConfig::default()),
is_enabled: true,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let result = service.get_authorization_url(&connection, "test-state");
assert!(result.is_err());
}
}