entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! Per-client custom-claims transformation. Pure.
//!
//! An app registration may carry a list of [`ClaimMapping`]s describing extra
//! claims to emit in its ID token / userinfo response beyond the standard set.
//! Each mapping names an output claim and a [`ClaimSource`]: a literal value, a
//! copy of a source attribute (from the user's profile object), the user's
//! group names as an array, or that array filtered to an allow-list.
//!
//! [`transform`] applies the mappings over a source profile [`JsonValue`] and
//! the user's group names, returning the extra `(name, value)` claims for the
//! caller to merge. It never overwrites a protected/standard claim it is handed
//! โ€” see [`transform`]'s reserved-name guard.
#![allow(clippy::doc_markdown)]

use crate::json::JsonValue;

/// Where an output claim's value comes from.
#[derive(Debug, Clone, PartialEq)]
pub enum ClaimSource {
    /// A constant value.
    Literal(JsonValue),
    /// Copy a top-level attribute from the source profile object. Absent
    /// attributes produce no claim (the mapping is skipped).
    Attribute(String),
    /// The user's group names, as a JSON string array.
    Groups,
    /// The user's group names intersected with an allow-list, as an array.
    /// Emitted even when empty (an explicit `[]`), so a relying party can tell
    /// "no matching groups" from "claim not configured".
    GroupsFiltered(Vec<String>),
}

/// One output-claim mapping.
#[derive(Debug, Clone, PartialEq)]
pub struct ClaimMapping {
    /// The output claim name.
    pub name: String,
    /// How to derive its value.
    pub source: ClaimSource,
}

/// Claim names a mapping may never produce (they are owned by the token
/// builder and overwriting them would break verification / identity).
/// Claim names a custom mapping must never emit.
///
/// Covers the JWT registered claims, the OIDC authentication-context members,
/// and the IDENTITY claims the server asserts itself. Guarding only the
/// registered set let a mapping named `email` or `email_verified` collide with
/// โ€” and, depending on merge order, override โ€” a claim the server vouches for,
/// letting a tenant admin restate a server-owned identity assertion as an
/// arbitrary value that relying parties would trust.
///
/// `groups` and `roles` are deliberately NOT reserved: emitting them is the
/// entire purpose of [`ClaimSource::Groups`] / [`ClaimSource::GroupsFiltered`],
/// and unlike `email` they are authorization data the deployment is expected
/// to shape per client.
const RESERVED: &[&str] = &[
    // RFC 7519 ยง4.1 registered claims.
    "iss",
    "sub",
    "aud",
    "exp",
    "iat",
    "nbf",
    "jti",
    // OIDC ID-token / authentication-context members.
    "nonce",
    "at_hash",
    "c_hash",
    "azp",
    "auth_time",
    "acr",
    "amr",
    "sid",
    // Identity claims the server asserts from the directory.
    "email",
    "email_verified",
    "name",
    "preferred_username",
    "tenant",
];

/// Whether `name` is a reserved claim a custom mapping must not emit.
#[must_use]
pub fn is_reserved(name: &str) -> bool {
    RESERVED.contains(&name)
}

/// Apply `mappings` over `profile` (a JSON object of the user's attributes) and
/// `groups` (the user's group names), returning the extra claims to merge into
/// the token. Pure.
///
/// Rules:
/// - A mapping onto a [reserved](is_reserved) name is skipped (never emitted).
/// - A later mapping onto the same output name wins (last-write); earlier
///   duplicates are dropped so the result has one entry per name.
/// - An [`ClaimSource::Attribute`] whose source key is absent is skipped.
#[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 };
        // Last-write-wins: drop any earlier entry with the same name.
        out.retain(|(n, _)| n != &m.name);
        out.push((m.name.clone(), value));
    }
    out
}

/// Build a JSON string array from group names.
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"));
    }
}