#![allow(clippy::doc_markdown)]
use crate::json::JsonValue;
#[derive(Debug, Clone, PartialEq)]
pub enum ClaimSource {
Literal(JsonValue),
Attribute(String),
Groups,
GroupsFiltered(Vec<String>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct ClaimMapping {
pub name: String,
pub source: ClaimSource,
}
const RESERVED: &[&str] = &[
"iss",
"sub",
"aud",
"exp",
"iat",
"nbf",
"jti",
"nonce",
"at_hash",
"c_hash",
"azp",
"auth_time",
"acr",
"amr",
"sid",
"email",
"email_verified",
"name",
"preferred_username",
"tenant",
];
#[must_use]
pub fn is_reserved(name: &str) -> bool {
RESERVED.contains(&name)
}
#[must_use]
pub fn transform(
mappings: &[ClaimMapping],
profile: &JsonValue,
groups: &[String],
) -> Vec<(String, JsonValue)> {
let mut out: Vec<(String, JsonValue)> = Vec::new();
for m in mappings {
if is_reserved(&m.name) {
continue;
}
let value = match &m.source {
ClaimSource::Literal(v) => Some(v.clone()),
ClaimSource::Attribute(key) => profile.get(key).cloned(),
ClaimSource::Groups => Some(groups_array(groups)),
ClaimSource::GroupsFiltered(allow) => Some(groups_array(
&groups
.iter()
.filter(|g| allow.iter().any(|a| a == *g))
.cloned()
.collect::<Vec<_>>(),
)),
};
let Some(value) = value else { continue };
out.retain(|(n, _)| n != &m.name);
out.push((m.name.clone(), value));
}
out
}
fn groups_array(groups: &[String]) -> JsonValue {
JsonValue::Array(
groups
.iter()
.map(|g| JsonValue::String(g.clone()))
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn profile() -> JsonValue {
JsonValue::parse(r#"{"department":"eng","level":7,"email":"a@b.c"}"#).unwrap()
}
#[test]
fn literal_and_attribute() {
let m = vec![
ClaimMapping {
name: "tier".into(),
source: ClaimSource::Literal(JsonValue::String("gold".into())),
},
ClaimMapping {
name: "dept".into(),
source: ClaimSource::Attribute("department".into()),
},
];
let out = transform(&m, &profile(), &[]);
assert_eq!(out.len(), 2);
assert_eq!(out[0].1.as_str(), Some("gold"));
assert_eq!(out[1].1.as_str(), Some("eng"));
}
#[test]
fn absent_attribute_skipped() {
let m = vec![ClaimMapping {
name: "missing".into(),
source: ClaimSource::Attribute("nope".into()),
}];
assert!(transform(&m, &profile(), &[]).is_empty());
}
#[test]
fn reserved_name_never_emitted() {
let m = vec![ClaimMapping {
name: "sub".into(),
source: ClaimSource::Literal(JsonValue::String("evil".into())),
}];
assert!(transform(&m, &profile(), &[]).is_empty());
}
#[test]
fn groups_and_filtered() {
let groups = vec!["eng".to_string(), "leads".to_string(), "all".to_string()];
let m = vec![
ClaimMapping {
name: "groups".into(),
source: ClaimSource::Groups,
},
ClaimMapping {
name: "roles".into(),
source: ClaimSource::GroupsFiltered(vec!["leads".into(), "x".into()]),
},
];
let out = transform(&m, &profile(), &groups);
assert_eq!(out[0].1.as_array().unwrap().len(), 3);
let filtered = out[1].1.as_array().unwrap();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].as_str(), Some("leads"));
}
#[test]
fn last_write_wins_on_duplicate_name() {
let m = vec![
ClaimMapping {
name: "x".into(),
source: ClaimSource::Literal(JsonValue::String("first".into())),
},
ClaimMapping {
name: "x".into(),
source: ClaimSource::Literal(JsonValue::String("second".into())),
},
];
let out = transform(&m, &profile(), &[]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].1.as_str(), Some("second"));
}
}