use super::entity_id_getters::{EntityIdSrc, get_first_valid_entity_id};
use super::{
BuildEntityError, BuiltEntities, DEFAULT_ENTITY_TYPE_NAME, EntityBuilder, EntityData,
default_tkn_entity_name,
};
use crate::authz::AuthorizeEntitiesData;
use crate::common::issuer_utils::IssClaim;
use crate::common::policy_store::token_entity_metadata::DEFAULT_TKN_ID;
use crate::entity_builder::{BuildAttrsErrorVec, schema};
use crate::jwt::Token;
use crate::log::interface::LogWriter;
use crate::log::{BaseLogEntry, LogEntry, LogLevel};
use cedar_policy::{Entity, EntityId, EntityTypeName, EntityUid, RestrictedExpression};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::Arc;
#[derive(Debug, thiserror::Error)]
pub enum MultiIssuerEntityError {
#[error("Missing issuer claim in JWT")]
MissingIssuer,
#[error("Missing exp claim in JWT")]
MissingExpClaim,
#[error("Invalid entity UID: {0}")]
InvalidEntityUid(String),
#[error("Entity creation failed: {0}")]
EntityCreationFailed(String),
#[error("No valid tokens found")]
NoValidTokens,
#[error("Could not create cedar uid for trusted issuer: {0}")]
BuildTrustedIssuerUid(#[from] BuildEntityError),
#[error("Could not build entity attributes: {0}")]
BuildAttrs(#[from] BuildAttrsErrorVec),
}
fn sanitize_issuer_name(name: &str) -> String {
name.replace(['.', ' ', '-'], "_").to_lowercase()
}
fn simplify_token_type(mapping: &str) -> String {
mapping.split("::").last().unwrap_or(mapping).to_lowercase()
}
fn create_string_set(value: &str) -> RestrictedExpression {
RestrictedExpression::new_set(vec![RestrictedExpression::new_string(value.to_string())])
}
fn create_string_set_array(values: &[String]) -> RestrictedExpression {
RestrictedExpression::new_set(
values
.iter()
.map(|s| RestrictedExpression::new_string(s.clone()))
.collect::<Vec<_>>(),
)
}
const RESERVED_CLAIMS: [&str; 3] = ["iss", "jti", "exp"];
fn add_reserved_claims(
attrs: &mut HashMap<String, RestrictedExpression>,
token: &Token,
entity_id: &str,
attrs_shape_opt: Option<&HashMap<smol_str::SmolStr, schema::AttrsShape>>,
validated_at_ts: i64,
) -> Result<(), MultiIssuerEntityError> {
const TOKEN_TYPE: &str = "token_type";
const JTI_CLAIM: &str = "jti";
const ISS_CLAIM: &str = "iss";
const EXP_CLAIM: &str = "exp";
const VALIDATED_AT_CLAIM: &str = "validated_at";
if let Some(attrs_shape) = attrs_shape_opt {
if attrs_shape.contains_key(TOKEN_TYPE) {
attrs.insert(
TOKEN_TYPE.to_string(),
RestrictedExpression::new_string(token.name.clone()),
);
}
if attrs_shape.contains_key(JTI_CLAIM) {
attrs.insert(
JTI_CLAIM.to_string(),
RestrictedExpression::new_string(entity_id.to_string()),
);
}
if let Some(shape) = attrs_shape.get(ISS_CLAIM) {
const UNDEFINED_ISSUER: &str = "undefined";
if let Some(token_iss) = &token.iss {
let issuer = token.extract_normalized_issuer()
.unwrap_or_else(|| IssClaim::new(UNDEFINED_ISSUER));
attrs.insert(
ISS_CLAIM.to_string(),
RestrictedExpression::new_entity_uid(EntityBuilder::trusted_issuer_cedar_uid(
&token_iss.name,
&issuer,
)?),
);
} else if shape.is_required() {
attrs.insert(
"iss".to_string(),
RestrictedExpression::new_string(
token
.get_claim(ISS_CLAIM)
.and_then(|v| v.value().as_str().map(str::to_string))
.unwrap_or_else(|| UNDEFINED_ISSUER.to_string()),
),
);
}
}
if let Some(shape) = attrs_shape.get(EXP_CLAIM) {
if let Some(exp) = token
.get_claim_val(EXP_CLAIM)
.and_then(serde_json::Value::as_i64)
{
attrs.insert(EXP_CLAIM.to_string(), RestrictedExpression::new_long(exp));
} else if shape.is_required() {
return Err(MultiIssuerEntityError::MissingExpClaim);
}
}
if attrs_shape.contains_key(VALIDATED_AT_CLAIM) {
attrs.insert(
VALIDATED_AT_CLAIM.to_string(),
RestrictedExpression::new_long(validated_at_ts),
);
}
} else {
attrs.insert(
TOKEN_TYPE.to_string(),
RestrictedExpression::new_string(token.name.clone()),
);
attrs.insert(
JTI_CLAIM.to_string(),
RestrictedExpression::new_string(entity_id.to_string()),
);
if let Some(token_iss) = &token.iss {
let issuer = token
.extract_normalized_issuer()
.ok_or(MultiIssuerEntityError::MissingIssuer)?;
attrs.insert(
ISS_CLAIM.to_string(),
RestrictedExpression::new_entity_uid(EntityBuilder::trusted_issuer_cedar_uid(
&token_iss.name,
&issuer,
)?),
);
}
if let Some(exp) = token
.get_claim_val(EXP_CLAIM)
.and_then(serde_json::Value::as_i64)
{
attrs.insert(EXP_CLAIM.to_string(), RestrictedExpression::new_long(exp));
}
attrs.insert(
VALIDATED_AT_CLAIM.to_string(),
RestrictedExpression::new_long(validated_at_ts),
);
}
Ok(())
}
fn convert_claim_to_string_set(value: &Value) -> RestrictedExpression {
match value {
Value::String(s) => create_string_set(s),
Value::Number(n) => create_string_set(&n.to_string()),
Value::Bool(b) => create_string_set(&b.to_string()),
Value::Array(arr) => {
let string_values: Vec<String> = arr
.iter()
.map(|v| match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => v.to_string(),
})
.collect();
create_string_set_array(&string_values)
},
_ => create_string_set(&value.to_string()),
}
}
fn determine_token_entity_type(token: &Token) -> String {
if let Some(issuer) = token.iss.as_ref()
&& let Some(metadata) = issuer.token_metadata.get(&token.name)
{
return metadata.entity_type_name.clone();
}
if token.name.contains("::") {
return token.name.clone();
}
if let Some(default_type) = default_tkn_entity_name(&token.name) {
return default_type.to_string();
}
DEFAULT_ENTITY_TYPE_NAME.to_string()
}
impl EntityBuilder {
pub(crate) fn build_multi_issuer_entities(
&self,
tokens: &HashMap<String, Arc<Token>>,
resource: &EntityData,
log_service: &impl LogWriter,
) -> Result<AuthorizeEntitiesData, MultiIssuerEntityError> {
let mut built_entities = BuiltEntities::from(&self.iss_entities);
let mut token_entities = HashMap::new();
for (token_name, token) in tokens {
match self.build_single_token_entity(token, &built_entities) {
Ok(entity) => match self.generate_entity_key(token_name, token) {
Ok(entity_key) => {
built_entities.insert(&entity.uid());
token_entities.insert(entity_key, entity);
},
Err(e) => {
log_service.log_any(
LogEntry::new(BaseLogEntry::new_system_opt_request_id(
LogLevel::ERROR,
None,
))
.set_message(format!(
"Failed to generate entity key for token '{token_name}'"
))
.set_error(e.to_string()),
);
},
},
Err(e) => {
log_service.log_any(
LogEntry::new(BaseLogEntry::new_system_opt_request_id(
LogLevel::ERROR,
None,
))
.set_message(format!(
"Failed to build token entity for token '{token_name}'"
))
.set_error(e.to_string()),
);
},
}
}
if token_entities.is_empty() {
log_service.log_any(
LogEntry::new(BaseLogEntry::new_system_opt_request_id(
LogLevel::ERROR,
None,
))
.set_message("No valid tokens found for multi-issuer authorization".to_string())
.set_error("All tokens failed validation or entity building".to_string()),
);
return Err(MultiIssuerEntityError::NoValidTokens);
}
let resource = self
.build_resource_entity(resource)
.inspect_err(|e| {
log_service.log_any(
LogEntry::new(BaseLogEntry::new_system_opt_request_id(
LogLevel::ERROR,
None,
))
.set_message(
"Failed to build resource entity for multi-issuer authorization"
.to_string(),
)
.set_error(e.to_string()),
);
})
.map_err(|e| MultiIssuerEntityError::EntityCreationFailed(e.to_string()))?;
let issuers = self.iss_entities.values().cloned().collect();
Ok(AuthorizeEntitiesData {
issuers,
tokens: token_entities,
resource,
default_entities: self.default_entities.clone(),
})
}
fn build_single_token_entity(
&self,
token: &Token,
built_entities: &BuiltEntities,
) -> Result<Entity, MultiIssuerEntityError> {
let entity_type = determine_token_entity_type(token);
let token_id_claim: &str = token
.iss
.as_deref()
.and_then(|iss| iss.token_metadata.get(&token.name))
.map_or(DEFAULT_TKN_ID, |m| m.token_id.as_str());
let entity_id_srcs = [EntityIdSrc::Token {
token,
claim: token_id_claim,
}];
let entity_id = get_first_valid_entity_id(&entity_id_srcs)
.map_err(|e| MultiIssuerEntityError::InvalidEntityUid(e.to_string()))?
.to_string();
let attrs_shape = self
.schema
.as_ref()
.and_then(|schema| schema.get_entity_shape(&entity_type));
let claims = token.claims_value();
let validated_at_ts = chrono::Utc::now().timestamp();
let mut attrs = if let Some(shape) = attrs_shape {
let token_type_val = Value::String(token.name.clone());
let validated_at_val = Value::Number(validated_at_ts.into());
let jti_val = Value::String(entity_id.clone());
super::build_entity_attrs::build_entity_attrs_with_shape_lookup(
|name| match name {
"token_type" => Some(&token_type_val),
"validated_at" => Some(&validated_at_val),
"jti" => claims.get("jti").or(Some(&jti_val)),
other => claims.get(other),
},
built_entities,
shape,
)?
} else {
let mut all_claims = HashMap::with_capacity(claims.len() + 2);
all_claims.extend(claims.iter().map(|(k, v)| (k.clone(), v.clone())));
all_claims.insert("token_type".to_string(), Value::String(token.name.clone()));
all_claims.insert(
"validated_at".to_string(),
Value::Number(validated_at_ts.into()),
);
super::build_entity_attrs::build_entity_attrs(&all_claims, built_entities, None)?
};
add_reserved_claims(&mut attrs, token, &entity_id, attrs_shape, validated_at_ts)?;
let mut tags = HashMap::new();
for (claim_key, claim_value) in token.claims_value() {
if RESERVED_CLAIMS.contains(&claim_key.as_str()) {
continue;
}
let value = convert_claim_to_string_set(claim_value);
tags.insert(claim_key.clone(), value);
}
let entity_type_name = EntityTypeName::from_str(&entity_type)
.map_err(|e| MultiIssuerEntityError::InvalidEntityUid(e.to_string()))?;
let entity_id = EntityId::from_str(&entity_id).unwrap_or_else(|e| match e {});
let uid = EntityUid::from_type_name_and_id(entity_type_name, entity_id);
let entity = Entity::new_with_tags(uid, attrs, HashSet::new(), tags)
.map_err(|e| MultiIssuerEntityError::EntityCreationFailed(e.to_string()))?;
Ok(entity)
}
fn generate_entity_key(
&self,
token_name: &str,
token: &Token,
) -> Result<String, MultiIssuerEntityError> {
let issuer = token
.extract_normalized_issuer()
.ok_or(MultiIssuerEntityError::MissingIssuer)?;
let issuer_simplified = self.resolve_issuer_name(&issuer);
let token_type_simplified = simplify_token_type(token_name);
Ok(format!("{issuer_simplified}_{token_type_simplified}"))
}
fn resolve_issuer_name(&self, issuer: &IssClaim) -> String {
if let Some(trusted_issuer) = self.issuers_index.find(issuer) {
return sanitize_issuer_name(&trusted_issuer.name);
}
let issuer_str = issuer.as_str();
let hostname = issuer_str
.replace("https://", "")
.replace("http://", "")
.split('/')
.next()
.unwrap_or(issuer_str)
.split(':')
.next()
.unwrap_or(issuer_str)
.to_string();
sanitize_issuer_name(&hostname)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::authz::request::CedarEntityMapping;
use crate::common::default_entities::DefaultEntities;
use crate::common::policy_store::TrustedIssuer;
use crate::common::policy_store::token_entity_metadata::TokenEntityMetadata;
use crate::entity_builder::TrustedIssuerIndex;
use crate::jwt::{Token, TokenClaims};
use crate::log::NopLogger;
use cedar_policy::EvalResult;
use serde_json::json;
use std::collections::HashMap;
use url::Url;
fn create_test_entity_builder() -> EntityBuilder {
let mut trusted_issuers = HashMap::new();
let acme_issuer = TrustedIssuer::new(
"Acme".to_string(),
"Acme Corporation".to_string(),
Url::parse("https://idp.acme.com/auth").unwrap(),
HashMap::new(),
);
trusted_issuers.insert("acme".to_string(), acme_issuer);
let dolphin_issuer = TrustedIssuer::new(
"Dolphin".to_string(),
"Dolphin Sea Services".to_string(),
Url::parse("https://idp.dolphin.sea/auth").unwrap(),
HashMap::new(),
);
trusted_issuers.insert("dolphin".to_string(), dolphin_issuer);
let microsoft_issuer = TrustedIssuer::new(
"Microsoft".to_string(),
"Microsoft Azure AD".to_string(),
Url::parse("https://login.microsoftonline.com/tenant").unwrap(),
HashMap::new(),
);
trusted_issuers.insert("microsoft".to_string(), microsoft_issuer);
let company_issuer = TrustedIssuer::new(
"Company".to_string(),
"Company Internal Auth".to_string(),
Url::parse("https://auth.company.internal:8443/oauth").unwrap(),
HashMap::new(),
);
trusted_issuers.insert("company".to_string(), company_issuer);
EntityBuilder::new(
TrustedIssuerIndex::new(&trusted_issuers, None),
None,
DefaultEntities::default(),
)
.unwrap()
}
fn create_test_token(
issuer: &str,
jti: &str,
claims: HashMap<String, Value>,
builder: &EntityBuilder,
) -> Token {
let mut all_claims = claims;
all_claims.insert("iss".to_string(), json!(issuer));
all_claims.insert("jti".to_string(), json!(jti));
all_claims.insert(
"exp".to_string(),
json!(chrono::Utc::now().timestamp() + 3600),
);
let trusted_issuer = builder.find_trusted_issuer_by_iss(issuer);
let token_claims = TokenClaims::from(all_claims);
Token::new("Jans::Access_Token", token_claims, trusted_issuer)
}
fn create_test_resource() -> EntityData {
EntityData {
cedar_mapping: CedarEntityMapping {
entity_type: "Jans::Resource".to_string(),
id: "test_resource".to_string(),
},
attributes: HashMap::new(),
}
}
#[test]
fn test_issuer_name_resolution_with_trusted_metadata() {
let builder = create_test_entity_builder();
let result = builder.resolve_issuer_name(&IssClaim::new("https://idp.acme.com/auth"));
assert_eq!(result, "acme");
let result = builder.resolve_issuer_name(&IssClaim::new("https://idp.dolphin.sea/auth"));
assert_eq!(result, "dolphin");
let result =
builder.resolve_issuer_name(&IssClaim::new("https://login.microsoftonline.com/tenant"));
assert_eq!(result, "microsoft");
}
#[test]
fn test_issuer_name_resolution_fallback_to_hostname() {
let builder = create_test_entity_builder();
let result = builder.resolve_issuer_name(&IssClaim::new("https://unknown.issuer.com/auth"));
assert_eq!(result, "unknown_issuer_com");
}
#[test]
fn test_token_type_simplification() {
assert_eq!(simplify_token_type("Jans::Access_Token"), "access_token");
assert_eq!(simplify_token_type("Jans::Id_Token"), "id_token");
assert_eq!(simplify_token_type("Acme::DolphinToken"), "dolphintoken");
assert_eq!(
simplify_token_type("Custom::Employee_Token"),
"employee_token"
);
assert_eq!(simplify_token_type("SimpleToken"), "simpletoken");
}
#[test]
fn test_entity_key_generation() {
let builder = create_test_entity_builder();
let mut claims = HashMap::new();
claims.insert("sub".to_string(), json!("user123"));
let token = create_test_token("https://idp.acme.com/auth", "token123", claims, &builder);
let key = builder
.generate_entity_key("Jans::Access_Token", &token)
.unwrap();
assert_eq!(key, "acme_access_token");
}
#[test]
fn test_entity_key_generation_with_unknown_issuer() {
let builder = create_test_entity_builder();
let mut claims = HashMap::new();
claims.insert("sub".to_string(), json!("user123"));
let token = create_test_token(
"https://unknown.issuer.com/auth",
"token123",
claims,
&builder,
);
let key = builder
.generate_entity_key("Custom::Employee_Token", &token)
.unwrap();
assert_eq!(key, "unknown_issuer_com_employee_token");
}
#[test]
fn test_multiple_tokens_same_issuer_different_names() {
let builder = create_test_entity_builder();
let mut tokens = HashMap::new();
let mut claims1 = HashMap::new();
claims1.insert("sub".to_string(), json!("user1"));
let token_one = create_test_token("https://idp.acme.com/auth", "token1", claims1, &builder);
tokens.insert("Jans::Access_Token".to_string(), token_one);
let mut claims2 = HashMap::new();
claims2.insert("sub".to_string(), json!("user2"));
let token_two = create_test_token("https://idp.acme.com/auth", "token2", claims2, &builder);
tokens.insert("Jans::Access_Token2".to_string(), token_two);
let tokens: HashMap<String, Arc<Token>> =
tokens.into_iter().map(|(k, v)| (k, Arc::new(v))).collect();
let result =
builder.build_multi_issuer_entities(&tokens, &create_test_resource(), &NopLogger);
assert!(result.is_ok());
let entities_data = result.unwrap();
assert_eq!(entities_data.tokens.len(), 2);
assert!(entities_data.tokens.contains_key("acme_access_token"));
assert!(entities_data.tokens.contains_key("acme_access_token2"));
}
#[test]
fn test_multiple_tokens_different_issuers_and_types() {
let builder = create_test_entity_builder();
let mut tokens = HashMap::new();
let mut claims1 = HashMap::new();
claims1.insert("sub".to_string(), json!("user1"));
let token_one = create_test_token("https://idp.acme.com/auth", "token1", claims1, &builder);
tokens.insert("Jans::Access_Token".to_string(), token_one);
let mut claims2 = HashMap::new();
claims2.insert("sub".to_string(), json!("user2"));
let token_two =
create_test_token("https://idp.dolphin.sea/auth", "token2", claims2, &builder);
tokens.insert("Acme::DolphinToken".to_string(), token_two);
let mut claims3 = HashMap::new();
claims3.insert("sub".to_string(), json!("user3"));
claims3.insert("iss".to_string(), json!("https://idp.acme.com/auth"));
claims3.insert("jti".to_string(), json!("token3"));
claims3.insert(
"exp".to_string(),
json!(chrono::Utc::now().timestamp() + 3600),
);
let token_claims3 = TokenClaims::from(claims3);
let token_three = Token::new("Jans::Id_Token", token_claims3, None);
tokens.insert("Jans::Id_Token".to_string(), token_three);
let tokens: HashMap<String, Arc<Token>> =
tokens.into_iter().map(|(k, v)| (k, Arc::new(v))).collect();
let result =
builder.build_multi_issuer_entities(&tokens, &create_test_resource(), &NopLogger);
assert!(result.is_ok());
let entities_data = result.unwrap();
assert_eq!(entities_data.tokens.len(), 3);
assert!(entities_data.tokens.contains_key("acme_access_token"));
assert!(entities_data.tokens.contains_key("dolphin_dolphintoken"));
assert!(entities_data.tokens.contains_key("acme_id_token"));
}
#[test]
fn test_token_entity_structure() {
let builder = create_test_entity_builder();
let mut claims = HashMap::new();
claims.insert("sub".to_string(), json!("user123"));
claims.insert("scope".to_string(), json!(["read:profile", "write:data"]));
claims.insert("aud".to_string(), json!("my-client"));
let token = create_test_token("https://idp.acme.com/auth", "token123", claims, &builder);
let built_entities = BuiltEntities::from(&builder.iss_entities);
let entity = builder
.build_single_token_entity(&token, &built_entities)
.unwrap();
assert_eq!(entity.uid().type_name().to_string(), "Jans::Access_Token");
assert!(entity.attr("token_type").is_some());
assert!(entity.attr("jti").is_some());
assert!(entity.attr("iss").is_some());
assert!(entity.attr("validated_at").is_some());
assert!(entity.attr("exp").is_some());
assert!(entity.tag("sub").is_some());
assert!(entity.tag("scope").is_some());
assert!(entity.tag("aud").is_some());
}
#[test]
fn test_missing_jti_claim_error() {
let builder = create_test_entity_builder();
let mut claims = HashMap::new();
claims.insert("iss".to_string(), json!("https://test.issuer.com"));
claims.insert("sub".to_string(), json!("user123"));
let token_claims = TokenClaims::from(claims);
let token = Token::new("Jans::Access_Token", token_claims, None);
let built_entities = BuiltEntities::from(&builder.iss_entities);
let result = builder.build_single_token_entity(&token, &built_entities);
assert!(
matches!(
result.unwrap_err(),
MultiIssuerEntityError::InvalidEntityUid(_)
),
"Should get InvalidEntityUid error due to missing jti claim"
);
}
#[test]
fn test_schema_based_processing() {
let schema_src = r#"
namespace Jans {
entity Token = {
sub: String,
scope: Set<String>,
aud: String,
custom_claim: Long
};
type Url = {"host": String, "path": String, "protocol": String};
entity TrustedIssuer = {"issuer_entity_id": Url};
}
"#;
let validator_schema = cedar_policy_core::validator::ValidatorSchema::from_str(schema_src)
.expect("should parse schema");
let ti = TrustedIssuer::new(
"Jans".to_string(),
String::new(),
Url::parse("https://test.issuer.com").unwrap(),
HashMap::default(),
);
let trusted_issuers = HashMap::from_iter(vec![("Jans".to_string(), ti)]);
let builder = EntityBuilder::new(
TrustedIssuerIndex::new(&trusted_issuers, None),
Some(&validator_schema),
DefaultEntities::default(),
)
.expect("could not build EntityBuilder");
let iss = "https://test.issuer.com";
let mut claims = HashMap::new();
claims.insert("iss".to_string(), json!(iss));
claims.insert("jti".to_string(), json!("test-jti-123"));
claims.insert("sub".to_string(), json!("user123"));
claims.insert("scope".to_string(), json!(["read:profile", "write:data"]));
claims.insert("aud".to_string(), json!("my-client"));
claims.insert("custom_claim".to_string(), json!(42));
claims.insert(
"exp".to_string(),
json!(chrono::Utc::now().timestamp() + 3600),
);
let token_claims = TokenClaims::from(claims);
let token = Token::new(
"Jans::Access_Token",
token_claims,
builder.find_trusted_issuer_by_iss(iss),
);
let built_entities = BuiltEntities::from(&builder.iss_entities);
let entity = builder
.build_single_token_entity(&token, &built_entities)
.unwrap();
assert!(entity.tag("sub").is_some());
assert!(entity.tag("scope").is_some());
assert!(entity.tag("aud").is_some());
assert!(entity.tag("custom_claim").is_some());
assert!(matches!(
entity.attr("token_type").expect("token_type attribute should exist").expect("should be a valid value"),
EvalResult::String(ref val) if *val == "Jans::Access_Token"
));
assert!(matches!(
entity.attr("jti").expect("jti attribute should exist").expect("should be a valid value"),
EvalResult::String(ref val) if *val == "test-jti-123"
));
let current_time = chrono::Utc::now().timestamp();
assert!(matches!(
entity.attr("exp").expect("exp attribute should exist").expect("should be a valid value"),
EvalResult::Long(exp) if exp > current_time
));
let iss_value = entity
.attr("iss")
.expect("iss attribute should exist")
.expect("should be a valid value");
assert!(
matches!(iss_value, EvalResult::EntityUid(_)),
"iss should be an entity reference (EntityUid), got {iss_value:?}"
);
assert!(entity.tag("iss").is_none());
assert!(entity.tag("jti").is_none());
assert!(entity.tag("exp").is_none());
}
#[test]
fn test_invalid_tokens_are_skipped() {
let builder = create_test_entity_builder();
let mut tokens = HashMap::new();
let mut claims1 = HashMap::new();
claims1.insert("sub".to_string(), json!("user1"));
let token_one = create_test_token("https://idp.acme.com/auth", "token1", claims1, &builder);
tokens.insert("Jans::Access_Token".to_string(), token_one);
let mut claims2 = HashMap::new();
claims2.insert("sub".to_string(), json!("user2"));
let token_claims2 = TokenClaims::from(claims2);
let token_two = Token::new("Jans::Id_Token", token_claims2, None);
tokens.insert("Jans::Id_Token".to_string(), token_two);
let mut claims3 = HashMap::new();
claims3.insert("sub".to_string(), json!("user3"));
let token_three =
create_test_token("https://idp.dolphin.sea/auth", "token3", claims3, &builder);
tokens.insert("Acme::DolphinToken".to_string(), token_three);
let tokens: HashMap<String, Arc<Token>> =
tokens.into_iter().map(|(k, v)| (k, Arc::new(v))).collect();
let result =
builder.build_multi_issuer_entities(&tokens, &create_test_resource(), &NopLogger);
assert!(result.is_ok());
let entities_data = result.unwrap();
assert_eq!(entities_data.tokens.len(), 2);
assert!(entities_data.tokens.contains_key("acme_access_token"));
assert!(entities_data.tokens.contains_key("dolphin_dolphintoken"));
}
#[test]
fn test_all_invalid_tokens_returns_error() {
let builder = create_test_entity_builder();
let mut tokens = HashMap::new();
let mut claims1 = HashMap::new();
claims1.insert("sub".to_string(), json!("user1"));
let token_claims1 = TokenClaims::from(claims1);
let token_one = Token::new("Jans::Access_Token", token_claims1, None);
tokens.insert("Jans::Access_Token".to_string(), token_one);
let mut claims2 = HashMap::new();
claims2.insert("sub".to_string(), json!("user2"));
let token_claims2 = TokenClaims::from(claims2);
let token_two = Token::new("Jans::Id_Token", token_claims2, None);
tokens.insert("Jans::Id_Token".to_string(), token_two);
let tokens: HashMap<String, Arc<Token>> =
tokens.into_iter().map(|(k, v)| (k, Arc::new(v))).collect();
let result =
builder.build_multi_issuer_entities(&tokens, &create_test_resource(), &NopLogger);
assert!(
matches!(result.unwrap_err(), MultiIssuerEntityError::NoValidTokens),
"Should return NoValidTokens error when all tokens are invalid"
);
}
#[test]
fn test_schema_fallback_processing() {
let schema_src = r#"
namespace Jans {
entity Token = {
sub: String,
scope: Set<String>
};
type Url = {"host": String, "path": String, "protocol": String};
entity TrustedIssuer = {"issuer_entity_id": Url};
}
"#;
let validator_schema = cedar_policy_core::validator::ValidatorSchema::from_str(schema_src)
.expect("should parse schema");
let ti = TrustedIssuer::new(
"Jans".to_string(),
String::new(),
Url::parse("https://test.issuer.com/.well-known/openid-configuration")
.expect("url should be parsed"),
HashMap::new(),
);
let trusted_issuers = HashMap::from([("Jans".to_string(), ti)]);
let builder = EntityBuilder::new(
TrustedIssuerIndex::new(&trusted_issuers, None),
Some(&validator_schema),
DefaultEntities::default(),
)
.unwrap();
let mut claims = HashMap::new();
claims.insert("sub".to_string(), json!("user123"));
claims.insert("scope".to_string(), json!(["read:profile", "write:data"]));
claims.insert("unknown_claim".to_string(), json!("some_value")); claims.insert("another_unknown".to_string(), json!(123));
let token = create_test_token("https://test.issuer.com", "test-jti-123", claims, &builder);
let built_entities = BuiltEntities::from(&builder.iss_entities);
let entity = builder
.build_single_token_entity(&token, &built_entities)
.unwrap();
assert!(entity.tag("sub").is_some());
assert!(entity.tag("scope").is_some());
assert!(entity.tag("unknown_claim").is_some());
assert!(entity.tag("another_unknown").is_some());
assert!(entity.tag("iss").is_none());
assert!(entity.tag("jti").is_none());
assert!(entity.tag("exp").is_none());
assert!(matches!(
entity.attr("token_type").expect("token_type attribute should exist").expect("should be a valid value"),
EvalResult::String(ref val) if *val == "Jans::Access_Token"
));
assert!(matches!(
entity.attr("jti").expect("jti attribute should exist").expect("should be a valid value"),
EvalResult::String(ref val) if *val == "test-jti-123"
));
let current_time = chrono::Utc::now().timestamp();
assert!(matches!(
entity.attr("exp").expect("exp attribute should exist").expect("should be a valid value"),
EvalResult::Long(exp) if exp > current_time
));
let iss_value = entity
.attr("iss")
.expect("iss attribute should exist")
.expect("should be a valid value");
assert!(
matches!(iss_value, EvalResult::EntityUid(_)),
"iss should be an entity reference (EntityUid), got {iss_value:?}"
);
}
#[test]
fn test_jti_attr_uses_entity_id_not_raw_claim_without_schema() {
let tkn_meta = TokenEntityMetadata::builder()
.entity_type_name("Jans::Access_Token".into())
.token_id("sub".into())
.build();
let mut token_metadata = HashMap::new();
token_metadata.insert("Jans::Access_Token".into(), tkn_meta);
let ti = TrustedIssuer::new(
"TestIssuer".to_string(),
String::new(),
Url::parse("https://test.issuer.com")
.expect("should parse test issuer URL"),
token_metadata,
);
let trusted_issuers = HashMap::from([("TestIssuer".to_string(), ti)]);
let builder = EntityBuilder::new(
TrustedIssuerIndex::new(&trusted_issuers, None),
None, DefaultEntities::default(),
)
.expect("should create entity builder without schema");
let iss = "https://test.issuer.com";
let mut claims = HashMap::new();
claims.insert("iss".to_string(), json!(iss));
claims.insert("jti".to_string(), json!("raw-jti-from-jwt"));
claims.insert("sub".to_string(), json!("entity-sub-uid"));
claims.insert(
"exp".to_string(),
json!(chrono::Utc::now().timestamp() + 3600),
);
let token = Token::new(
"Jans::Access_Token",
TokenClaims::from(claims),
builder.find_trusted_issuer_by_iss(iss),
);
let built_entities = BuiltEntities::from(&builder.iss_entities);
let entity = builder
.build_single_token_entity(&token, &built_entities)
.expect("should build token entity without schema");
let jti_attr = entity
.attr("jti")
.expect("jti attribute should exist")
.expect("jti should be a valid value");
assert!(
matches!(jti_attr, EvalResult::String(ref val) if val == "entity-sub-uid"),
"Expected jti = entity_id ('entity-sub-uid'), proving \
add_reserved_claims (not the data path) sets jti. \
Got {jti_attr:?}"
);
assert!(
entity.tag("iss").is_none(),
"iss should not be a tag (reserved)"
);
assert!(
entity.tag("jti").is_none(),
"jti should not be a tag (reserved)"
);
assert!(
entity.tag("exp").is_none(),
"exp should not be a tag (reserved)"
);
assert!(
entity.tag("sub").is_some(),
"sub should be a tag (non-reserved)"
);
}
#[test]
fn test_reserved_claims_overwrite_schema_path() {
let schema_src = r#"
namespace Jans {
entity Access_Token = {
jti: String,
sub: String,
iss: String,
exp: Long
};
type Url = {"host": String, "path": String, "protocol": String};
entity TrustedIssuer = {"issuer_entity_id": Url};
}
"#;
let validator_schema = cedar_policy_core::validator::ValidatorSchema::from_str(schema_src)
.expect("should parse schema");
let tkn_meta = TokenEntityMetadata::builder()
.entity_type_name("Jans::Access_Token".into())
.token_id("sub".into())
.build();
let mut token_metadata = HashMap::new();
token_metadata.insert("Jans::Access_Token".into(), tkn_meta);
let ti = TrustedIssuer::new(
"Jans".to_string(),
String::new(),
Url::parse("https://test.issuer.com")
.expect("should parse test issuer URL"),
token_metadata,
);
let trusted_issuers = HashMap::from([("Jans".to_string(), ti)]);
let builder = EntityBuilder::new(
TrustedIssuerIndex::new(&trusted_issuers, None),
Some(&validator_schema),
DefaultEntities::default(),
)
.expect("should create entity builder with schema");
let iss = "https://test.issuer.com";
let mut claims = HashMap::new();
claims.insert("iss".to_string(), json!(iss));
claims.insert("jti".to_string(), json!("raw-jti-from-jwt"));
claims.insert("sub".to_string(), json!("entity-sub-uid"));
claims.insert(
"exp".to_string(),
json!(chrono::Utc::now().timestamp() + 3600),
);
claims.insert("extra_test_claim".to_string(), json!("should-not-be-attr"));
let token = Token::new(
"Jans::Access_Token",
TokenClaims::from(claims),
builder.find_trusted_issuer_by_iss(iss),
);
let built_entities = BuiltEntities::from(&builder.iss_entities);
let entity = builder
.build_single_token_entity(&token, &built_entities)
.expect("should build token entity with schema path");
assert!(
entity.attr("extra_test_claim").is_none(),
"extra_test_claim should not be an attribute when the schema \
path is active (it is not in the schema shape)"
);
let jti_attr = entity
.attr("jti")
.expect("jti attribute should exist")
.expect("jti should be a valid value");
assert!(
matches!(jti_attr, EvalResult::String(ref val) if val == "entity-sub-uid"),
"Expected jti = entity_id ('entity-sub-uid'), proving \
add_reserved_claims overwrites the schema-built value. \
Got {jti_attr:?}"
);
let iss_attr = entity
.attr("iss")
.expect("iss attribute should exist")
.expect("iss should be a valid value");
assert!(
matches!(iss_attr, EvalResult::EntityUid(_)),
"iss should be an EntityUid (overwritten by add_reserved_claims), \
not a plain String. Got {iss_attr:?}"
);
let current_time = chrono::Utc::now().timestamp();
let exp_attr = entity
.attr("exp")
.expect("exp attribute should exist")
.expect("exp should be a valid value");
assert!(
matches!(exp_attr, EvalResult::Long(exp) if exp > current_time),
"exp should be a future Long (overwritten by add_reserved_claims). \
Got {exp_attr:?}"
);
assert!(entity.tag("iss").is_none(), "iss should not be a tag");
assert!(entity.tag("jti").is_none(), "jti should not be a tag");
assert!(entity.tag("exp").is_none(), "exp should not be a tag");
assert!(entity.tag("sub").is_some(), "sub should be a tag");
}
#[test]
fn test_schema_path_fallback_to_entity_id_when_jti_missing() {
let schema_src = r#"
namespace Jans {
entity Access_Token = {
jti: String,
sub: String,
iss: String,
exp: Long
};
type Url = {"host": String, "path": String, "protocol": String};
entity TrustedIssuer = {"issuer_entity_id": Url};
}
"#;
let validator_schema = cedar_policy_core::validator::ValidatorSchema::from_str(schema_src)
.expect("should parse schema");
let tkn_meta = TokenEntityMetadata::builder()
.entity_type_name("Jans::Access_Token".into())
.token_id("sub".into())
.build();
let mut token_metadata = HashMap::new();
token_metadata.insert("Jans::Access_Token".into(), tkn_meta);
let ti = TrustedIssuer::new(
"Jans".to_string(),
String::new(),
Url::parse("https://test.issuer.com")
.expect("should parse test issuer URL"),
token_metadata,
);
let trusted_issuers = HashMap::from([("Jans".to_string(), ti)]);
let builder = EntityBuilder::new(
TrustedIssuerIndex::new(&trusted_issuers, None),
Some(&validator_schema),
DefaultEntities::default(),
)
.expect("should create entity builder with schema for missing jti test");
let iss = "https://test.issuer.com";
let mut claims = HashMap::new();
claims.insert("iss".to_string(), json!(iss));
claims.insert("sub".to_string(), json!("entity-sub-uid"));
claims.insert(
"exp".to_string(),
json!(chrono::Utc::now().timestamp() + 3600),
);
let token = Token::new(
"Jans::Access_Token",
TokenClaims::from(claims),
builder.find_trusted_issuer_by_iss(iss),
);
let built_entities = BuiltEntities::from(&builder.iss_entities);
let entity = builder
.build_single_token_entity(&token, &built_entities)
.expect("should succeed even without jti claim — closure falls back to entity_id");
let jti_attr = entity
.attr("jti")
.expect("jti attribute should exist")
.expect("jti should be a valid value");
assert!(
matches!(jti_attr, EvalResult::String(ref val) if val == "entity-sub-uid"),
"Expected jti = entity_id ('entity-sub-uid') when raw jti claim \
is absent. Got {jti_attr:?}"
);
assert!(entity.tag("jti").is_none(), "jti should not be a tag");
}
}