use crate::model::UnixMillis;
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SsoProtocol {
Oidc,
Saml,
}
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OidcSettings {
pub issuer: String,
pub client_id: String,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default)]
pub username_claim: String,
#[serde(default)]
pub display_name_claim: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub idp_ca: Vec<String>,
}
pub fn idp_ca_stored(ders: &[Vec<u8>]) -> Vec<String> {
use base64ct::{Base64, Encoding as _};
ders.iter().map(|d| Base64::encode_string(d)).collect()
}
pub fn idp_ca_from_stored(stored: &[String]) -> Vec<Vec<u8>> {
use base64ct::{Base64, Encoding as _};
stored.iter().filter_map(|s| Base64::decode_vec(s.trim()).ok()).collect()
}
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SamlSettings {
pub idp_entity_id: String,
pub idp_sso_url: String,
pub idp_certificates: Vec<String>,
#[serde(default)]
pub metadata_url: String,
#[serde(default)]
pub username_attribute: String,
#[serde(default)]
pub display_name_attribute: String,
}
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SsoProvider {
pub account: String,
pub protocol: SsoProtocol,
pub display_name: String,
#[serde(default)]
pub domains: Vec<String>,
#[serde(default)]
pub oidc: Option<OidcSettings>,
#[serde(default)]
pub saml: Option<SamlSettings>,
#[serde(default)]
pub require_sso: bool,
#[serde(default)]
pub require_passkey: bool,
pub console_origin: String,
#[serde(default)]
pub updated: UnixMillis,
#[serde(default)]
pub updated_by: String,
}
impl SsoProvider {
pub fn redirect_uri(&self) -> String {
redirect_uri(&self.console_origin)
}
pub fn sp_entity_id(&self) -> String {
sp_entity_id(&self.console_origin, &self.account)
}
pub fn acs_url(&self) -> String {
acs_url(&self.console_origin)
}
pub fn issuer(&self) -> &str {
match self.protocol {
SsoProtocol::Oidc => self.oidc.as_ref().map_or("", |o| o.issuer.as_str()),
SsoProtocol::Saml => self.saml.as_ref().map_or("", |s| s.idp_entity_id.as_str()),
}
}
}
pub fn redirect_uri(origin: &str) -> String {
format!("{}/auth/sso/oidc/callback", origin.trim_end_matches('/'))
}
pub fn sp_entity_id(origin: &str, account: &str) -> String {
format!("{}/auth/sso/saml/{account}", origin.trim_end_matches('/'))
}
pub fn acs_url(origin: &str) -> String {
format!("{}/auth/sso/saml/acs", origin.trim_end_matches('/'))
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SsoSource {
Jit,
Scim,
Csv,
}
impl SsoSource {
pub fn as_str(self) -> &'static str {
match self {
SsoSource::Jit => "jit",
SsoSource::Scim => "scim",
SsoSource::Csv => "csv",
}
}
}
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SsoUser {
pub principal_id: String,
pub account: String,
pub user_name: String,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub email: String,
#[serde(default)]
pub external_id: String,
#[serde(default)]
pub subject: Option<String>,
pub active: bool,
pub source: SsoSource,
pub created: UnixMillis,
#[serde(default)]
pub last_login: Option<UnixMillis>,
#[serde(default)]
pub sessions: Vec<String>,
#[serde(default)]
pub deleted: bool,
}
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScimToken {
pub id: String,
pub account: String,
pub secret_hash: String,
#[serde(default)]
pub label: String,
pub created: UnixMillis,
pub created_by: String,
#[serde(default)]
pub last_used: Option<UnixMillis>,
}
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SsoGroup {
pub id: String,
pub account: String,
pub display_name: String,
#[serde(default)]
pub external_id: String,
#[serde(default)]
pub members: Vec<String>,
pub created: UnixMillis,
}
pub fn client_secret_key(account: &str) -> String {
format!("sso/{account}/oidc-client-secret")
}
pub fn gate_holder(account: &str) -> String {
format!("sso:{account}")
}
pub fn subject_key(issuer: &str, subject: &str) -> String {
let mut text = issuer.trim().trim_end_matches('/').to_owned();
text.push('\u{1f}');
text.push_str(subject);
crate::auth::token_hash(&text)
}
pub const MAX_ID_LEN: usize = 64;
pub fn principal_id_candidates(account: &str, user_name: &str) -> Vec<String> {
let local = user_name.split('@').next().unwrap_or(user_name);
let mut cleaned = String::new();
for c in local.chars() {
let c = c.to_ascii_lowercase();
let keep =
if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' || c == '.' {
c
} else {
'-'
};
if (keep == '-' || keep == '.') && cleaned.ends_with(['-', '.']) {
continue;
}
cleaned.push(keep);
}
let cleaned = cleaned.trim_matches(['-', '.']).to_owned();
let cleaned = if cleaned.is_empty() {
"user".to_owned()
} else {
cleaned
};
let account = account.to_ascii_lowercase();
let mut out = Vec::new();
for n in 1..=9 {
let suffix = if n == 1 {
String::new()
} else {
format!("-{n}")
};
let room = MAX_ID_LEN.saturating_sub(account.len() + 1 + suffix.len());
if room == 0 {
break;
}
let body: String = cleaned.chars().take(room).collect();
let body = body.trim_end_matches(['-', '.']);
let id = format!("{account}.{body}{suffix}");
if crate::sandbox::check_component(&id, &id).is_ok() && id.len() <= MAX_ID_LEN {
out.push(id);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn candidates_are_the_accounts_and_valid_ids() {
let ids = principal_id_candidates("acme", "Bob.Smith@ACME.example");
assert_eq!(ids[0], "acme.bob.smith");
assert_eq!(ids[1], "acme.bob.smith-2");
assert_eq!(ids.len(), 9);
let odd = principal_id_candidates("acme", " ..--åsa ö'connor--..");
assert_eq!(odd[0], "acme.sa-connor");
let empty = principal_id_candidates("acme", "@@@");
assert_eq!(empty[0], "acme.user");
let long = principal_id_candidates("acme", &"x".repeat(200));
assert!(long.iter().all(|id| id.len() <= MAX_ID_LEN));
}
#[test]
fn a_provider_without_a_ca_is_written_as_before() {
let o = OidcSettings { issuer: "https://idp.example".into(), client_id: "c".into(), ..Default::default() };
let text = serde_json::to_string(&o).expect("json");
assert!(!text.contains("idp_ca"), "{text}");
let with = OidcSettings { idp_ca: vec!["AAAA".into()], ..o };
assert!(serde_json::to_string(&with).expect("json").contains("idp_ca"));
}
#[test]
fn subject_keys_bind_the_issuer() {
assert_ne!(
subject_key("https://a.example", "1"),
subject_key("https://b.example", "1")
);
assert_eq!(
subject_key("https://a.example/", "1"),
subject_key("https://a.example", "1")
);
assert_eq!(subject_key("i", "s").len(), 64);
}
}