use serde_json::Value;
use crate::proto::udb::core::idp::entity::v1 as idp_entity_pb;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MappedClaims {
pub subject: String,
pub email: String,
pub email_verified: bool,
pub display_name: String,
pub groups: Vec<String>,
pub assurance: idp_entity_pb::AssuranceLevel,
}
fn json_path<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
let mut cur = root;
for seg in path.split('.') {
cur = cur.get(seg)?;
}
Some(cur)
}
fn as_claim_string(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn as_claim_bool(value: &Value) -> bool {
match value {
Value::Bool(b) => *b,
Value::String(s) => matches!(
s.trim().to_ascii_lowercase().as_str(),
"true" | "1" | "yes" | "on"
),
Value::Number(n) => n.as_i64().map(|v| v != 0).unwrap_or(false),
_ => false,
}
}
fn as_claim_groups(value: &Value) -> Vec<String> {
match value {
Value::Array(items) => items.iter().filter_map(as_claim_string).collect(),
Value::String(s) => s
.split([',', ' '])
.map(str::trim)
.filter(|g| !g.is_empty())
.map(ToString::to_string)
.collect(),
_ => Vec::new(),
}
}
fn mapped_claim_name<'a>(mapping: &'a Value, logical: &str, default: &'a str) -> &'a str {
mapping
.get(logical)
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.unwrap_or(default)
}
pub fn apply_claim_mapping(mapping_json: &str, claims: &Value) -> MappedClaims {
let mapping: Value = serde_json::from_str(mapping_json.trim())
.ok()
.filter(Value::is_object)
.unwrap_or_else(|| Value::Object(Default::default()));
let subject = json_path(claims, mapped_claim_name(&mapping, "subject", "sub"))
.and_then(as_claim_string)
.unwrap_or_default();
let email = json_path(claims, mapped_claim_name(&mapping, "email", "email"))
.and_then(as_claim_string)
.unwrap_or_default();
let email_verified = json_path(
claims,
mapped_claim_name(&mapping, "email_verified", "email_verified"),
)
.map(as_claim_bool)
.unwrap_or(false);
let display_name = json_path(claims, mapped_claim_name(&mapping, "display_name", "name"))
.and_then(as_claim_string)
.unwrap_or_default();
let groups = json_path(claims, mapped_claim_name(&mapping, "groups", "groups"))
.map(as_claim_groups)
.unwrap_or_default();
let assurance = derive_assurance(claims);
MappedClaims {
subject,
email,
email_verified,
display_name,
groups,
assurance,
}
}
pub fn derive_assurance(claims: &Value) -> idp_entity_pb::AssuranceLevel {
use idp_entity_pb::AssuranceLevel as A;
let amr: Vec<String> = claims
.get("amr")
.map(as_claim_groups)
.unwrap_or_default()
.into_iter()
.map(|s| s.to_ascii_lowercase())
.collect();
let acr = claims
.get("acr")
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
const HARDWARE: &[&str] = &["hwk", "swk", "fido", "webauthn", "phr", "phrh", "hardware"];
const MFA: &[&str] = &["mfa", "otp", "sms", "totp", "hotp", "tel"];
if amr.iter().any(|m| HARDWARE.contains(&m.as_str()))
|| acr.contains("phr")
|| acr.contains("aal3")
{
return A::Hardware;
}
if amr.iter().any(|m| MFA.contains(&m.as_str()))
|| (amr.len() >= 2 && amr.iter().any(|m| m == "pwd" || m == "mca"))
|| acr.contains("mfa")
|| acr.contains("aal2")
{
return A::MultiFactor;
}
let has_subject = claims
.get("sub")
.and_then(Value::as_str)
.map(|s| !s.is_empty())
.unwrap_or(false);
if !amr.is_empty() || has_subject {
A::SingleFactor
} else {
A::None
}
}
pub fn map_groups_to_roles(
group_mapping_json: &str,
groups: &[String],
) -> (Vec<String>, Vec<String>) {
let mapping: Value = serde_json::from_str(group_mapping_json.trim())
.ok()
.filter(Value::is_object)
.unwrap_or_else(|| Value::Object(Default::default()));
let obj = mapping.as_object();
let mut roles: Vec<String> = Vec::new();
let mut unmapped: Vec<String> = Vec::new();
for group in groups {
let entry = obj.and_then(|m| m.get(group));
match entry {
Some(Value::String(role)) if !role.trim().is_empty() => {
roles.push(role.trim().to_string());
}
Some(Value::Array(items)) => {
let mut any = false;
for item in items {
if let Some(role) = item.as_str().filter(|s| !s.trim().is_empty()) {
roles.push(role.trim().to_string());
any = true;
}
}
if !any {
unmapped.push(group.clone());
}
}
_ => unmapped.push(group.clone()),
}
}
roles.sort();
roles.dedup();
(roles, unmapped)
}
#[derive(Debug, Clone, Default)]
pub struct JitPolicy {
pub allowed_domains: Vec<String>,
pub require_verified_email: bool,
pub enabled: bool,
pub default_project: String,
pub default_roles: Vec<String>,
}
impl JitPolicy {
pub fn from_json(jit_policy_json: &str) -> Self {
let v: Value = serde_json::from_str(jit_policy_json.trim())
.ok()
.filter(Value::is_object)
.unwrap_or_else(|| Value::Object(Default::default()));
let allowed_domains = v
.get("allowed_domains")
.map(as_claim_groups)
.unwrap_or_default()
.into_iter()
.map(|d| d.trim().trim_start_matches('@').to_ascii_lowercase())
.filter(|d| !d.is_empty())
.collect();
let require_verified_email = v
.get("require_verified_email")
.map(as_claim_bool)
.unwrap_or(true);
let enabled = v.get("enabled").map(as_claim_bool).unwrap_or(true);
let default_project = v
.get("default_project")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let default_roles = v
.get("default_roles")
.map(as_claim_groups)
.unwrap_or_default();
Self {
allowed_domains,
require_verified_email,
enabled,
default_project,
default_roles,
}
}
pub fn domain_allowed(&self, email: &str) -> bool {
if self.allowed_domains.is_empty() {
return true;
}
let domain = email
.rsplit_once('@')
.map(|(_, d)| d.trim().to_ascii_lowercase())
.unwrap_or_default();
!domain.is_empty() && self.allowed_domains.iter().any(|d| d == &domain)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JitDecision {
Provision,
Reject(String),
}
pub fn evaluate_jit(policy: &JitPolicy, claims: &MappedClaims) -> JitDecision {
if claims.subject.trim().is_empty() {
return JitDecision::Reject("missing subject claim".to_string());
}
if !policy.enabled {
return JitDecision::Reject("JIT provisioning is disabled for this provider".to_string());
}
if policy.require_verified_email && !claims.email_verified {
return JitDecision::Reject("email is not verified".to_string());
}
if claims.email.trim().is_empty() && !policy.allowed_domains.is_empty() {
return JitDecision::Reject(
"email is required to check the allowed-domain policy".to_string(),
);
}
if !policy.domain_allowed(&claims.email) {
return JitDecision::Reject(format!(
"email domain is not in the provider allowed-domains list: {}",
claims.email
));
}
JitDecision::Provision
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountLinkDecision {
Deny,
LinkExisting,
RequireExplicit,
}
pub fn evaluate_account_linking(policy: &str, email_verified: bool) -> AccountLinkDecision {
match policy.trim().to_ascii_lowercase().as_str() {
"deny" => AccountLinkDecision::Deny,
"auto_verified" if email_verified => AccountLinkDecision::LinkExisting,
"auto_verified" | "explicit" | "" => AccountLinkDecision::RequireExplicit,
_ => AccountLinkDecision::RequireExplicit,
}
}
#[cfg(test)]
mod tests {
use super::*;
use idp_entity_pb::AssuranceLevel as A;
use serde_json::json;
#[test]
fn claim_mapping_defaults_to_standard_oidc_names() {
let claims = json!({
"sub": "abc-123",
"email": "a@b.com",
"email_verified": true,
"name": "Ada",
"groups": ["eng", "admins"],
});
let m = apply_claim_mapping("{}", &claims);
assert_eq!(m.subject, "abc-123");
assert_eq!(m.email, "a@b.com");
assert!(m.email_verified);
assert_eq!(m.display_name, "Ada");
assert_eq!(m.groups, vec!["eng", "admins"]);
}
#[test]
fn claim_mapping_honors_custom_and_nested_paths() {
let claims = json!({
"oid": "xyz",
"upn": "u@corp.com",
"profile": { "verified": "true" },
"roles": "r1,r2 r3",
});
let mapping = json!({
"subject": "oid",
"email": "upn",
"email_verified": "profile.verified",
"groups": "roles"
})
.to_string();
let m = apply_claim_mapping(&mapping, &claims);
assert_eq!(m.subject, "xyz");
assert_eq!(m.email, "u@corp.com");
assert!(m.email_verified);
assert_eq!(m.groups, vec!["r1", "r2", "r3"]);
}
#[test]
fn assurance_from_amr_and_acr() {
assert_eq!(
derive_assurance(&json!({"sub":"s","amr":["pwd"]})),
A::SingleFactor
);
assert_eq!(
derive_assurance(&json!({"sub":"s","amr":["pwd","otp"]})),
A::MultiFactor
);
assert_eq!(
derive_assurance(&json!({"sub":"s","amr":["mfa"]})),
A::MultiFactor
);
assert_eq!(
derive_assurance(&json!({"sub":"s","amr":["fido"]})),
A::Hardware
);
assert_eq!(
derive_assurance(&json!({"sub":"s","acr":"phr"})),
A::Hardware
);
assert_eq!(
derive_assurance(&json!({"acr":"urn:mace:aal2"})),
A::MultiFactor
);
assert_eq!(derive_assurance(&json!({})), A::None);
assert_eq!(derive_assurance(&json!({"sub":"s"})), A::SingleFactor);
}
#[test]
fn group_mapping_never_grants_unconfigured_roles() {
let mapping = json!({
"eng": "role:developer",
"admins": ["role:admin", "role:auditor"]
})
.to_string();
let groups = vec![
"eng".to_string(),
"admins".to_string(),
"interns".to_string(), ];
let (roles, unmapped) = map_groups_to_roles(&mapping, &groups);
assert_eq!(roles, vec!["role:admin", "role:auditor", "role:developer"]);
assert_eq!(unmapped, vec!["interns"]);
let (none, _) = map_groups_to_roles(&mapping, &["nobody".to_string()]);
assert!(none.is_empty());
let (empty, un) = map_groups_to_roles("{}", &["eng".to_string()]);
assert!(empty.is_empty());
assert_eq!(un, vec!["eng"]);
}
#[test]
fn jit_rejects_unverified_and_disallowed_domains() {
let policy = JitPolicy::from_json(
&json!({
"allowed_domains": ["corp.com"],
"require_verified_email": true,
"default_roles": ["role:member"]
})
.to_string(),
);
let unverified = MappedClaims {
subject: "s".into(),
email: "u@corp.com".into(),
email_verified: false,
..Default::default()
};
assert!(matches!(
evaluate_jit(&policy, &unverified),
JitDecision::Reject(_)
));
let wrong_domain = MappedClaims {
subject: "s".into(),
email: "u@evil.com".into(),
email_verified: true,
..Default::default()
};
assert!(matches!(
evaluate_jit(&policy, &wrong_domain),
JitDecision::Reject(_)
));
let ok = MappedClaims {
subject: "s".into(),
email: "u@corp.com".into(),
email_verified: true,
..Default::default()
};
assert_eq!(evaluate_jit(&policy, &ok), JitDecision::Provision);
let nosub = MappedClaims {
email: "u@corp.com".into(),
email_verified: true,
..Default::default()
};
assert!(matches!(
evaluate_jit(&policy, &nosub),
JitDecision::Reject(_)
));
}
#[test]
fn jit_empty_allowlist_permits_any_domain() {
let policy = JitPolicy::from_json(&json!({"require_verified_email": false}).to_string());
let claims = MappedClaims {
subject: "s".into(),
email: "x@anywhere.io".into(),
..Default::default()
};
assert_eq!(evaluate_jit(&policy, &claims), JitDecision::Provision);
}
#[test]
fn account_linking_never_silently_links_without_verified_auto_policy() {
assert_eq!(
evaluate_account_linking("auto_verified", true),
AccountLinkDecision::LinkExisting
);
assert_eq!(
evaluate_account_linking("auto_verified", false),
AccountLinkDecision::RequireExplicit
);
assert_eq!(
evaluate_account_linking("explicit", true),
AccountLinkDecision::RequireExplicit
);
assert_eq!(
evaluate_account_linking("", true),
AccountLinkDecision::RequireExplicit
);
assert_eq!(
evaluate_account_linking("unknown-new-mode", true),
AccountLinkDecision::RequireExplicit
);
assert_eq!(
evaluate_account_linking("deny", true),
AccountLinkDecision::Deny
);
}
}