diff --git a/backend/migrations/20260126235947_lowercase_emails.down.sql b/backend/migrations/20260126235947_lowercase_emails.down.sql new file mode 100644 index 0000000000..10766a8a0d --- /dev/null +++ b/backend/migrations/20260126235947_lowercase_emails.down.sql @@ -0,0 +1,3 @@ +-- This migration cannot be reversed as the original email casing is not preserved +-- Emails will remain lowercased +SELECT 1; diff --git a/backend/migrations/20260126235947_lowercase_emails.up.sql b/backend/migrations/20260126235947_lowercase_emails.up.sql new file mode 100644 index 0000000000..e4d74dc1bc --- /dev/null +++ b/backend/migrations/20260126235947_lowercase_emails.up.sql @@ -0,0 +1,14 @@ +-- Normalize emails to lowercase for consistency with external login normalization +-- This migration lowercases email columns in critical authentication tables + +-- Lowercase emails in password table (primary user identity) +UPDATE password SET email = LOWER(email) WHERE email != LOWER(email); + +-- Lowercase emails in usr table (workspace users) +UPDATE usr SET email = LOWER(email) WHERE email != LOWER(email); + +-- Lowercase emails in email_to_igroup table (instance group memberships) +UPDATE email_to_igroup SET email = LOWER(email) WHERE email != LOWER(email); + +-- Lowercase emails in token table (active sessions) +UPDATE token SET email = LOWER(email) WHERE email != LOWER(email); diff --git a/backend/windmill-common/src/oauth2.rs b/backend/windmill-common/src/oauth2.rs index ed78514cdf..faa1f1b942 100644 --- a/backend/windmill-common/src/oauth2.rs +++ b/backend/windmill-common/src/oauth2.rs @@ -28,6 +28,24 @@ lazy_static::lazy_static! { .and_then(|x| x.parse::().ok()) .unwrap_or(false)); + /// Domain to append to emails missing a domain during external login (OAuth/SAML). + /// If set, emails without '@' will have '@{LOGIN_DOMAIN}' appended. + /// Example: LOGIN_DOMAIN=example.com transforms "john" to "john@example.com" + pub static ref LOGIN_DOMAIN: Option = std::env::var("LOGIN_DOMAIN").ok(); + +} + +/// Normalize an email from external login by appending LOGIN_DOMAIN if the email is missing a domain. +/// Returns the email lowercased. +pub fn normalize_external_email(email: &str) -> String { + let email = email.trim(); + if email.contains('@') { + email.to_lowercase() + } else if let Some(domain) = LOGIN_DOMAIN.as_ref() { + format!("{}@{}", email, domain).to_lowercase() + } else { + email.to_lowercase() + } } #[derive(Serialize)] @@ -41,3 +59,41 @@ pub enum InstanceEvent { UserInvitedWorkspace { workspace: String, email: String }, UserJoinedWorkspace { workspace: String, email: String, username: String }, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_external_email_with_domain() { + // Email already has domain - should just lowercase + assert_eq!( + normalize_external_email("John.Doe@Example.COM"), + "john.doe@example.com" + ); + assert_eq!( + normalize_external_email("user@domain.org"), + "user@domain.org" + ); + } + + #[test] + fn test_normalize_external_email_trims_whitespace() { + assert_eq!( + normalize_external_email(" user@example.com "), + "user@example.com" + ); + assert_eq!(normalize_external_email(" john "), "john"); + } + + #[test] + fn test_normalize_external_email_without_domain_no_env() { + // When LOGIN_DOMAIN is not set, email without @ stays as-is (lowercased) + // Note: This test's behavior depends on whether LOGIN_DOMAIN env var is set + // In the test environment, it's typically not set + let result = normalize_external_email("JohnDoe"); + // Result will either be "johndoe" or "johndoe@{LOGIN_DOMAIN}" depending on env + assert!(result.starts_with("johndoe")); + assert_eq!(result, result.to_lowercase()); + } +}