use crate::authenticator::SCOPE_CLAIM;
use crate::introspect::IntrospectionResult;
use crate::{
Authentication, Authenticator, PrincipalType, Subject,
error::{Error, Result},
};
use jsonwebtoken::{Algorithm, DecodingKey, Header, Validation};
use jwks_client_rs::source::WebSource;
use jwks_client_rs::{JsonWebKey, JwksClient};
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
const SUBJECT_CLAIM: &str = "sub";
const IDTYP_CLAIM: &str = "idtyp";
const APP_DISPLAYNAME_CLAIM: &str = "app_displayname";
const NAME_CLAIM: &str = "name";
const AUD_CLAIM: &str = "aud";
#[derive(Clone)]
pub struct JWKSWebAuthenticator {
idp_id: Option<String>,
audiences: Vec<String>,
client: JwksClient<WebSource>,
issuers: Vec<String>,
scope: Option<String>,
config_url: url::Url,
subject_claim: Vec<String>,
role_claims: Option<Vec<String>>,
display_name_template: Option<DisplayNameTemplate>,
}
impl JWKSWebAuthenticator {
const WELL_KNOWN_CONFIG: &'static str = ".well-known/openid-configuration";
pub async fn new(issuer_url: &str, ttl: Option<Duration>) -> Result<Self> {
let (client, issuer, config_url) =
JWKSWebAuthenticator::initialize_client(issuer_url, ttl).await?;
Ok(Self {
idp_id: None,
client,
issuers: vec![issuer],
audiences: Vec::new(),
scope: None,
config_url,
subject_claim: vec![SUBJECT_CLAIM.to_string()],
role_claims: None,
display_name_template: None,
})
}
#[must_use]
pub fn set_idp_id(mut self, idp_id: &str) -> Self {
if !idp_id.is_empty() {
self.idp_id = Some(idp_id.to_string());
}
self
}
#[must_use]
pub fn set_accepted_audiences(mut self, audiences: Vec<String>) -> Self {
self.audiences = audiences;
self
}
#[must_use]
pub fn add_additional_issuers(mut self, additional_issuers: Vec<String>) -> Self {
let additional_issuers: Vec<_> = additional_issuers
.into_iter()
.filter(|issuer| !self.issuers.contains(issuer))
.collect();
self.issuers.extend(additional_issuers);
self
}
#[must_use]
pub fn set_scope(mut self, scope: String) -> Self {
self.scope = Some(scope);
self
}
#[must_use]
pub fn with_subject_claim(mut self, subject_claim: String) -> Self {
self.subject_claim = vec![subject_claim];
self
}
#[must_use]
pub fn with_subject_claims(mut self, subject_claims: Vec<String>) -> Self {
if !subject_claims.is_empty() {
self.subject_claim = subject_claims;
}
self
}
#[must_use]
pub fn with_role_claim(self, role_claim: String) -> Self {
self.with_role_claims(vec![role_claim])
}
#[must_use]
pub fn with_role_claims(mut self, role_claims: Vec<String>) -> Self {
let filtered: Vec<String> = role_claims.into_iter().filter(|s| !s.is_empty()).collect();
if filtered.is_empty() {
self.role_claims = None;
} else {
self.role_claims = Some(filtered);
}
self
}
#[must_use]
pub fn with_display_name_template(mut self, template: DisplayNameTemplate) -> Self {
self.display_name_template = Some(template);
self
}
async fn initialize_client(
issuer_url: &str,
ttl: Option<Duration>,
) -> Result<(JwksClient<WebSource>, String, url::Url)> {
let mut url = url::Url::parse(issuer_url)
.inspect_err(|e| tracing::debug!("Failed to parse issuer url: {e}"))?;
if !url.path().ends_with('/') {
url.set_path(&format!("{}/", url.path()));
}
url = if url.path().ends_with(Self::WELL_KNOWN_CONFIG) {
url
} else {
url.join(Self::WELL_KNOWN_CONFIG).inspect_err(|e| {
tracing::debug!(
"Failed to join well-known configuration '{}' to issuer url '{}': {e}",
Self::WELL_KNOWN_CONFIG,
url
);
})?
};
let config = Arc::new(
reqwest::get(url.clone())
.await
.map_err(|e| Error::FetchOpenIDWellKnownConfigError {
url: url.to_string(),
source: e,
})?
.json::<WellKnownConfig>()
.await
.map_err(|e| {
tracing::debug!("Failed to parse openid configuration: {e}");
Error::InvalidWellKnownConfig {
expected_fields: &["jwks_uri", "issuer"],
source: e,
}
})?,
);
let issuer = config.issuer.clone();
let source = WebSource::builder()
.build(config.jwks_uri.clone())
.map_err(|e| {
tracing::debug!("Failed to fetch openid configuration from '{url}': {e}");
Error::FetchOpenIDWellKnownConfigError {
url: url.to_string(),
source: e,
}
})?;
let client = JwksClient::builder();
let client = if let Some(ttl) = ttl {
client.time_to_live(ttl)
} else {
client
};
let client = client.build(source);
Ok((client, issuer, url))
}
}
impl Authenticator for JWKSWebAuthenticator {
async fn authenticate(
&self,
token: &str,
introspection: &IntrospectionResult,
) -> Result<Authentication> {
let IntrospectionResult::JWTBearer { header, .. } = introspection else {
return Err(Error::JWTDecodeError {
reason: "Token is not a JWT bearer token.".to_string(),
});
};
let key_id = header.kid.as_deref().ok_or_else(|| Error::JWTDecodeError {
reason: "Token does not contain a key id".to_string(),
})?;
let key = self
.client
.get_opt(key_id)
.await
.map_err(|e| Error::RefreshOpenIDWellKnownConfigError {
url: self.config_url.to_string(),
reason: e.to_string(),
})?
.ok_or_else(|| {
Error::unauthenticated(format!("Key id `{key_id}` not found in JWKS."))
})?;
let token_data = authenticate_jwt(
token,
header,
&key,
&self.audiences,
&self.issuers,
self.scope.as_deref(),
)?;
extract_authentication(
self.idp_id().map(String::as_str),
token_data,
&self.subject_claim,
self.role_claims.as_deref(),
self.display_name_template.as_ref(),
)
}
fn idp_id(&self) -> Option<&String> {
self.idp_id.as_ref()
}
fn can_handle_token(&self, token: &str, introspection_result: &IntrospectionResult) -> bool {
if token.is_empty() {
return false;
}
match introspection_result {
IntrospectionResult::JWTBearer {
iss,
aud,
header: _,
} => {
(self.issuers.is_empty() || self.issuers.iter().any(|i| iss.contains(i)))
&& (self.audiences.is_empty() || self.audiences.iter().any(|a| aud.contains(a)))
}
IntrospectionResult::Unknown => false,
}
}
}
fn authenticate_jwt(
token: &str,
header: &Header,
key: &JsonWebKey,
audiences: &[String],
issuers: &[String],
scope: Option<&str>,
) -> Result<jsonwebtoken::TokenData<serde_json::Value>> {
let mut validation = if let Some(alg) = key.alg() {
Validation::new(Algorithm::from_str(alg).map_err(|e| {
Error::internal(
format!(
"Failed to parse algorithm `{alg}` from key obtained from the jwks endpoint."
),
e,
)
})?)
} else {
Validation::new(header.alg)
};
if audiences.is_empty() {
validation.validate_aud = false;
} else {
validation.set_audience(audiences);
validation.validate_aud = true;
}
validation.set_issuer(issuers);
let decoding_key = match key {
JsonWebKey::Rsa(jwk) => DecodingKey::from_rsa_components(jwk.modulus(), jwk.exponent())
.map_err(|e| {
Error::internal("Failed to create rsa decoding key from key components.", e)
})?,
JsonWebKey::Ec(jwk) => DecodingKey::from_ec_components(jwk.x(), jwk.y()).map_err(|e| {
Error::internal("Failed to create ec decoding key from key components.", e)
})?,
JsonWebKey::Okp(jwk) => DecodingKey::from_ed_components(jwk.x()).map_err(|e| {
Error::internal("Failed to create okp decoding key from key components.", e)
})?,
};
let token_data = jsonwebtoken::decode::<serde_json::Value>(token, &decoding_key, &validation)
.map_err(|e| Error::JWTDecodeError {
reason: format!("Failed to decode JWT token. {e}"),
})?;
if let Some(required_scope) = scope {
let scope_claim = token_data
.claims
.get(SCOPE_CLAIM)
.and_then(serde_json::Value::as_str);
if !scope_claim_contains(scope_claim, required_scope) {
return Err(Error::unauthenticated(format!(
"Token does not contain required scope `{required_scope}`."
)));
}
}
Ok(token_data)
}
fn extract_authentication(
idp_id: Option<&str>,
token_data: jsonwebtoken::TokenData<serde_json::Value>,
subject_claim: &[String],
role_claims: Option<&[String]>,
display_name_template: Option<&DisplayNameTemplate>,
) -> Result<Authentication> {
let subject_in_idp = get_subject(&token_data, subject_claim)?;
let header = token_data.header;
let claims = token_data.claims;
let subject = Subject::new(idp_id.map(ToString::to_string), subject_in_idp);
let human_name = parse_human_name(&claims);
let app_name = claims.get(APP_DISPLAYNAME_CLAIM).and_then(value_as_string);
let principal_type = get_idp_type(&claims)
.or(human_name.as_ref().map(|_t| PrincipalType::Human))
.or(app_name.as_ref().map(|_t| PrincipalType::Application))
.or(claims.get("client_id").map(|_t| PrincipalType::Application));
let name = human_name
.or(app_name)
.or_else(|| claims.get("preferred_username").and_then(value_as_string))
.or_else(|| display_name_template.and_then(|t| t.render(&claims)));
let roles = get_roles(&claims, role_claims);
let audiences = crate::introspect::parse_aud(claims.get(AUD_CLAIM));
let email = get_email(&claims);
Ok(Authentication::builder()
.token_header(Some(header))
.name(name)
.email(email)
.subject(subject)
.principal_type(principal_type)
.roles(roles)
.audiences(audiences)
.claims(claims)
.build())
}
fn get_idp_type(claims: &serde_json::Value) -> Option<PrincipalType> {
match claims.get(IDTYP_CLAIM).and_then(|v| v.as_str()) {
Some("user") => Some(PrincipalType::Human),
Some("app" | "device") => Some(PrincipalType::Application),
_ => None,
}
}
fn get_email(claims: &serde_json::Value) -> Option<String> {
claims
.get("email")
.and_then(value_as_string)
.or(claims
.get("upn")
.and_then(value_as_string)
.filter(|s| s.contains('@')))
.or(claims
.get("preferred_username")
.and_then(value_as_string)
.filter(|s| s.contains('@')))
}
fn get_roles(claims: &serde_json::Value, role_claims: Option<&[String]>) -> Option<Vec<String>> {
let role_claim_paths = role_claims?;
if role_claim_paths.is_empty() {
return None;
}
for claim_path in role_claim_paths {
let Some(current) = navigate(claims, claim_path) else {
continue;
};
if let Some(roles_array) = current.as_array() {
let roles: Vec<String> = roles_array.iter().filter_map(value_as_string).collect();
if !roles.is_empty() {
return Some(roles);
}
}
else if let Some(role) = value_as_string(current) {
return Some(vec![role]);
}
}
tracing::debug!(
"Role claims `{role_claim_paths:?}` were configured but no valid roles were found in the token. \
Configured paths may be missing or contain non-string values."
);
None
}
fn get_subject(
token_data: &jsonwebtoken::TokenData<serde_json::Value>,
subject_claim: &[String],
) -> Result<String> {
for claim in subject_claim {
if let Some(subject) = token_data.claims.get(claim).and_then(value_as_string) {
return Ok(subject);
}
}
Err(Error::unauthenticated(
"Could not find the subject claim in the JWT token.".to_string(),
))
}
fn parse_human_name(claims: &serde_json::Value) -> Option<String> {
let first_name = claims
.get("given_name")
.or(claims.get("first_name"))
.and_then(value_as_string);
let last_name = claims
.get("family_name")
.or(claims.get("last_name"))
.and_then(value_as_string);
claims
.get(NAME_CLAIM)
.and_then(value_as_string)
.or_else(|| match (first_name, last_name) {
(Some(first), Some(last)) => Some(format!("{first} {last}")),
(Some(first), None) => Some(first),
(None, Some(last)) => Some(last),
(None, None) => None,
})
}
#[derive(serde::Deserialize, Clone, Debug)]
struct WellKnownConfig {
pub jwks_uri: url::Url,
pub issuer: String,
}
impl std::fmt::Debug for JWKSWebAuthenticator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut r = f.debug_struct("JWKSWebAuthenticator");
let r = r.field("idp_id", &self.idp_id);
r.field("audiences", &self.audiences)
.field("issuers", &self.issuers)
.field("scope", &self.scope)
.field("config_url", &self.config_url)
.field("subject_claim", &self.subject_claim)
.field("client", &"jwks_client_rs::JwksClient<WebSource>")
.field("role_claims", &self.role_claims)
.field("display_name_template", &self.display_name_template)
.finish()
}
}
fn value_as_string(value: &serde_json::Value) -> Option<String> {
value.as_str().map(std::string::ToString::to_string)
}
fn navigate<'a>(claims: &'a serde_json::Value, dot_path: &str) -> Option<&'a serde_json::Value> {
let mut current = claims;
for part in dot_path.split('.') {
current = current.get(part)?;
}
Some(current)
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DisplayNameTemplateError {
#[error("unmatched `{{` in display-name template (write a literal brace as `{{{{`)")]
UnmatchedOpenBrace,
#[error("unmatched `}}` in display-name template (write a literal brace as `}}}}`)")]
UnmatchedCloseBrace,
#[error("empty `{{}}` placeholder in display-name template (name a claim, e.g. `{{email}}`)")]
EmptyPlaceholder,
}
enum TemplateEvent<'a> {
Literal(&'a str),
Claim(&'a str),
}
fn parse_display_name_template(
template: &str,
mut sink: impl FnMut(TemplateEvent<'_>),
) -> std::result::Result<(), DisplayNameTemplateError> {
let mut rest = template;
loop {
let Some(idx) = rest.find(['{', '}']) else {
sink(TemplateEvent::Literal(rest));
return Ok(());
};
sink(TemplateEvent::Literal(&rest[..idx]));
let brace = rest.as_bytes()[idx];
rest = &rest[idx + 1..];
if brace == b'}' {
let Some(stripped) = rest.strip_prefix('}') else {
return Err(DisplayNameTemplateError::UnmatchedCloseBrace);
};
sink(TemplateEvent::Literal("}"));
rest = stripped;
continue;
}
if let Some(stripped) = rest.strip_prefix('{') {
sink(TemplateEvent::Literal("{"));
rest = stripped;
continue;
}
let Some(close) = rest.find('}') else {
return Err(DisplayNameTemplateError::UnmatchedOpenBrace);
};
let path = &rest[..close];
if path.contains('{') {
return Err(DisplayNameTemplateError::UnmatchedOpenBrace);
}
if path.trim().is_empty() {
return Err(DisplayNameTemplateError::EmptyPlaceholder);
}
sink(TemplateEvent::Claim(path));
rest = &rest[close + 1..];
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DisplayNameTemplate {
source: String,
segments: Vec<Segment>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Segment {
Literal(String),
Claim(String),
}
impl DisplayNameTemplate {
pub fn parse(template: &str) -> std::result::Result<Self, DisplayNameTemplateError> {
let mut segments = Vec::new();
let mut literal = String::new();
parse_display_name_template(template, |event| match event {
TemplateEvent::Literal(text) => literal.push_str(text),
TemplateEvent::Claim(path) => {
if !literal.is_empty() {
segments.push(Segment::Literal(std::mem::take(&mut literal)));
}
segments.push(Segment::Claim(path.to_string()));
}
})?;
if !literal.is_empty() {
segments.push(Segment::Literal(literal));
}
Ok(Self {
source: template.to_string(),
segments,
})
}
fn render(&self, claims: &serde_json::Value) -> Option<String> {
let mut out = String::with_capacity(self.source.len());
for segment in &self.segments {
match segment {
Segment::Literal(text) => out.push_str(text),
Segment::Claim(path) => {
let Some(value) = navigate(claims, path).and_then(value_as_string) else {
tracing::debug!(
"Display name template `{}` not applied: claim `{path}` is missing or not a string.",
self.source
);
return None;
};
out.push_str(&value);
}
}
}
let trimmed = out.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
}
impl std::str::FromStr for DisplayNameTemplate {
type Err = DisplayNameTemplateError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Self::parse(s)
}
}
fn scope_claim_contains(scope_claim: Option<&str>, required: &str) -> bool {
scope_claim.is_some_and(|scopes| scopes.split_whitespace().any(|s| s == required))
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
#[test]
fn test_scope_claim_contains_multi() {
assert!(scope_claim_contains(
Some("openid profile email"),
"profile"
));
assert!(!scope_claim_contains(Some("openid profile email"), "admin"));
}
#[test]
fn test_scope_claim_contains_single() {
assert!(scope_claim_contains(Some("openid"), "openid"));
assert!(!scope_claim_contains(Some("openid"), "open"));
}
#[test]
fn test_scope_claim_contains_absent() {
assert!(!scope_claim_contains(None, "openid"));
assert!(!scope_claim_contains(Some(""), "openid"));
}
#[test]
fn test_payload_entra_application() {
let claims = serde_json::json!({
"aio": "k2BgYGiZGnb+zdtzaReDdlQfWjHBAgA=",
"app_displayname": "ht-testing-lakekeeper-oauth",
"appid": "d53edae2-1b58-4c56-a243-xxxxxxxxxxxx",
"appidacr": "1",
"aud": "00000003-0000-0000-c000-000000000000",
"exp": 1_730_052_519,
"iat": 1_730_048_619,
"idp": "https://sts.windows.net/00000003-1234-0000-c000-000000000000/",
"idtyp": "app",
"iss": "https://sts.windows.net/00000003-1234-0000-c000-000000000000/",
"nbf": 1_730_048_619,
"oid": "f621fc83-4ec9-4bf8-bc8d-xxxxxxxxxxxx",
"rh": "0.AU8A4hqJeoi7wkGOJROkB9ygQAMAAAAAAAAAwAAAAAAAAABPAAA.",
"sub": "f621fc83-4ec9-4bf8-bc8d-xxxxxxxxxxxx",
"tenant_region_scope": "EU",
"tid": "00000003-1234-0000-c000-000000000000",
"uti": "mBOqwjvzLUqboKm591ccAA",
"ver": "1.0",
"wids": ["0997a1d0-0d1d-4acb-b408-xxxxxxxxxxxx"],
"xms_idrel": "7 24",
"xms_tcdt": 1_638_946_153,
"xms_tdbr": "EU"
});
let token_header = jsonwebtoken::Header::new(Algorithm::RS256);
let token_data = jsonwebtoken::TokenData {
header: token_header.clone(),
claims: claims.clone(),
};
let payload = extract_authentication(
Some("idp"),
token_data,
&["oid".to_string(), "sub".to_string()],
None,
None,
)
.unwrap();
let subject = Subject::new(
Some("idp".to_string()),
"f621fc83-4ec9-4bf8-bc8d-xxxxxxxxxxxx".to_string(),
);
let expected_payload = Authentication::builder()
.token_header(Some(token_header))
.claims(claims.clone())
.name(Some("ht-testing-lakekeeper-oauth".to_string()))
.email(None)
.subject(subject)
.principal_type(Some(PrincipalType::Application))
.audiences(HashSet::from([
"00000003-0000-0000-c000-000000000000".to_string()
]))
.build();
assert_eq!(payload, expected_payload);
}
#[test]
fn test_payload_entra_human_1() {
let claims = serde_json::json!({
"aud": "api://xyz",
"iss": "https://sts.windows.net/my-tenant-id/",
"iat": 1_733_673_952,
"nbf": 1_733_673_952,
"exp": 1_733_679_587,
"acr": "1",
"aio": "...",
"amr": [
"pwd",
"mfa"
],
"appid": "xyz",
"appidacr": "0",
"family_name": "Peter",
"given_name": "Cold",
"ipaddr": "192.168.5.1",
"name": "Peter Cold",
"oid": "user-oid",
"pwd_exp": "49828",
"pwd_url": "https://portal.microsoftonline.com/ChangePassword.aspx",
"scp": "lakekeeper",
"sub": "user-sub",
"tid": "my-tenant-id",
"unique_name": "peter@example.com",
"upn": "peter@example.com",
"uti": "...",
"ver": "1.0"
});
let token_header = jsonwebtoken::Header::new(Algorithm::RS256);
let token_data = jsonwebtoken::TokenData {
header: token_header.clone(),
claims: claims.clone(),
};
let payload =
extract_authentication(Some("idp"), token_data, &["oid".to_string()], None, None)
.unwrap();
let subject = Subject::new(Some("idp".to_string()), "user-oid".to_string());
let expected_payload = Authentication::builder()
.token_header(Some(token_header))
.claims(claims.clone())
.name(Some("Peter Cold".to_string()))
.email(Some("peter@example.com".to_string()))
.subject(subject)
.principal_type(Some(PrincipalType::Human))
.audiences(HashSet::from(["api://xyz".to_string()]))
.build();
assert_eq!(payload, expected_payload);
}
#[test]
fn test_payload_entra_human_2() {
let claims = serde_json::json!({
"acct": 0,
"acr": "1",
"aio": "...",
"amr": ["pwd", "mfa"],
"app_displayname": "ht-testing-lakekeeper-oauth",
"appid": "d53edae2-1b58-4c56-a243-xxxxxxxxxxxx",
"appidacr": "0",
"aud": "00000003-0000-0000-c000-000000000000",
"exp": 1_730_054_207,
"family_name": "Frost",
"given_name": "Jack",
"iat": 1_730_049_088,
"idtyp": "user",
"ipaddr": "12.206.221.219",
"iss": "https://sts.windows.net/00000003-1234-0000-c000-000000000000/",
"name": "Jack Frost",
"nbf": 1_730_049_088,
"oid": "eb54b4f5-0d20-46eb-b703-b1c910262e89",
"platf": "14",
"puid": "100320025A52FAC4",
"rh": "0.AU8A4hqJeoi7wkGOJROkB9ygQAMAAAAAAAAAwAAAAAAAAABPAJo.",
"scp": "openid profile User.Read email",
"signin_state": ["kmsi"],
"sub": "SFUpMUKjypW6q3w3Vc9u8N3LNAGlZmIrmGdvQVN53AI",
"tenant_region_scope": "EU",
"tid": "00000003-1234-0000-c000-000000000000",
"unique_name": "jack@example.com",
"upn": "jack@example.com",
"uti": "FXRr3wnAA0e8YADs1adQAA",
"ver": "1.0",
"wids": ["62e90394-69f5-4237-9190-xxxxxxxxxxxx",
"b79fbf4d-3ef9-4689-8143-xxxxxxxxxxxx"],
"xms_idrel": "1 8",
"xms_st": {"sub": "VZ5XLBqhasu6qISBjalO9e45lQjr_EyLLtKzCFcWw-8"},
"xms_tcdt": 1_638_946_153,
"xms_tdbr": "EU"
});
let token_header = jsonwebtoken::Header::new(Algorithm::RS256);
let token_data = jsonwebtoken::TokenData {
header: token_header.clone(),
claims: claims.clone(),
};
let payload =
extract_authentication(Some("idp"), token_data, &["oid".to_string()], None, None)
.unwrap();
let subject = Subject::new(
Some("idp".to_string()),
"eb54b4f5-0d20-46eb-b703-b1c910262e89".to_string(),
);
let expected_payload = Authentication::builder()
.token_header(Some(token_header))
.claims(claims.clone())
.name(Some("Jack Frost".to_string()))
.email(Some("jack@example.com".to_string()))
.subject(subject)
.principal_type(Some(PrincipalType::Human))
.audiences(HashSet::from([
"00000003-0000-0000-c000-000000000000".to_string()
]))
.build();
assert_eq!(payload, expected_payload);
}
#[test]
fn test_get_roles_simple_claim() {
let claims = serde_json::json!({
"roles": ["admin", "user", "editor"]
});
let roles = get_roles(&claims, Some(&["roles".to_string()]));
assert_eq!(
roles,
Some(vec![
"admin".to_string(),
"user".to_string(),
"editor".to_string()
])
);
}
#[test]
fn test_get_roles_nested_claim() {
let claims = serde_json::json!({
"resource_access": {
"account": {
"roles": ["manage-account", "view-profile"]
}
}
});
let roles = get_roles(
&claims,
Some(&["resource_access.account.roles".to_string()]),
);
assert_eq!(
roles,
Some(vec![
"manage-account".to_string(),
"view-profile".to_string()
])
);
}
#[test]
fn test_get_roles_multiple_paths_first_match() {
let claims = serde_json::json!({
"realm_access": {
"roles": ["realm-role"]
},
"resource_access": {
"account": {
"roles": ["account-role"]
}
}
});
let roles = get_roles(
&claims,
Some(&[
"realm_access.roles".to_string(),
"resource_access.account.roles".to_string(),
]),
);
assert_eq!(roles, Some(vec!["realm-role".to_string()]));
}
#[test]
fn test_get_roles_fallback_to_second_path() {
let claims = serde_json::json!({
"resource_access": {
"account": {
"roles": ["account-role"]
}
}
});
let roles = get_roles(
&claims,
Some(&[
"realm_access.roles".to_string(),
"resource_access.account.roles".to_string(),
]),
);
assert_eq!(roles, Some(vec!["account-role".to_string()]));
}
#[test]
fn test_get_roles_no_match() {
let claims = serde_json::json!({
"other_field": "value"
});
let roles = get_roles(&claims, Some(&["roles".to_string()]));
assert_eq!(roles, None);
}
#[test]
fn test_get_roles_single_string() {
let claims = serde_json::json!({
"role": "admin"
});
let roles = get_roles(&claims, Some(&["role".to_string()]));
assert_eq!(roles, Some(vec!["admin".to_string()]));
}
#[test]
fn test_get_roles_array_with_non_strings() {
let claims = serde_json::json!({
"roles": [42, {"name": "admin"}, null, true]
});
let roles = get_roles(&claims, Some(&["roles".to_string()]));
assert_eq!(roles, None);
}
#[test]
fn test_get_roles_empty_array() {
let claims = serde_json::json!({
"roles": []
});
let roles = get_roles(&claims, Some(&["roles".to_string()]));
assert_eq!(roles, None);
}
#[test]
fn test_get_roles_mixed_array() {
let claims = serde_json::json!({
"roles": ["admin", 42, "user", null, "editor", {"name": "invalid"}]
});
let roles = get_roles(&claims, Some(&["roles".to_string()]));
assert_eq!(
roles,
Some(vec![
"admin".to_string(),
"user".to_string(),
"editor".to_string()
])
);
}
#[test]
fn test_get_roles_fallback_from_non_strings() {
let claims = serde_json::json!({
"invalid_roles": [42, null, {"role": "admin"}],
"valid_roles": ["user", "viewer"]
});
let roles = get_roles(
&claims,
Some(&["invalid_roles".to_string(), "valid_roles".to_string()]),
);
assert_eq!(roles, Some(vec!["user".to_string(), "viewer".to_string()]));
}
#[test]
fn test_payload_keycloak_human() {
let claims = serde_json::json!({
"exp": 1_729_990_458,
"iat": 1_729_990_158,
"jti": "97cdc5d9-8717-4826-a425-30c6682342b4",
"iss": "http://localhost:30080/realms/iceberg",
"aud": "account",
"sub": "f1616ed0-18d8-48ea-9fb3-832f42db0b1b",
"typ": "Bearer",
"azp": "iceberg-catalog",
"sid": "6f2ca33d-2513-43fe-ab53-4a945c78a66d",
"acr": "1",
"allowed-origins": [
"*"
],
"realm_access": {
"roles": [
"offline_access",
"uma_authorization",
"default-roles-iceberg"
]
},
"resource_access": {
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
},
"scope": "openid email profile",
"email_verified": true,
"name": "Peter Cold",
"preferred_username": "peter",
"given_name": "Peter",
"family_name": "Cold",
"email": "peter@example.com"
});
let token_header = jsonwebtoken::Header::new(Algorithm::RS256);
let token_data = jsonwebtoken::TokenData {
header: token_header.clone(),
claims: claims.clone(),
};
let payload = extract_authentication(
Some("idp"),
token_data.clone(),
&["sub".to_string()],
None,
None,
)
.unwrap();
let subject = Subject::new(
Some("idp".to_string()),
"f1616ed0-18d8-48ea-9fb3-832f42db0b1b".to_string(),
);
let expected_payload = Authentication::builder()
.token_header(Some(token_header.clone()))
.claims(claims.clone())
.name(Some("Peter Cold".to_string()))
.email(Some("peter@example.com".to_string()))
.subject(subject.clone())
.principal_type(Some(PrincipalType::Human))
.audiences(HashSet::from(["account".to_string()]))
.build();
assert_eq!(payload, expected_payload);
let payload_with_roles = extract_authentication(
Some("idp"),
token_data,
&["sub".to_string()],
Some(&["realm_access.roles".to_string()]),
None,
)
.unwrap();
let expected_with_roles = Authentication::builder()
.token_header(Some(token_header))
.claims(claims.clone())
.name(Some("Peter Cold".to_string()))
.email(Some("peter@example.com".to_string()))
.subject(subject)
.principal_type(Some(PrincipalType::Human))
.roles(Some(vec![
"offline_access".to_string(),
"uma_authorization".to_string(),
"default-roles-iceberg".to_string(),
]))
.audiences(HashSet::from(["account".to_string()]))
.build();
assert_eq!(payload_with_roles, expected_with_roles);
}
#[test]
fn test_payload_keycloak_machine() {
let claims = serde_json::json!({
"exp": 1_730_050_563,
"iat": 1_730_050_563,
"jti": "b1e96701-b718-4714-88a2-d25d985c38ed",
"iss": "http://keycloak:8080/realms/iceberg",
"aud": [
"iceberg-catalog",
"account"
],
"sub": "b6cc7aa0-1af0-460e-9174-e05c881fb6d4",
"typ": "Bearer",
"azp": "iceberg-machine-client",
"acr": "1",
"allowed-origins": [
"/*"
],
"realm_access": {
"roles": [
"offline_access",
"uma_authorization",
"default-roles-iceberg"
]
},
"resource_access": {
"iceberg-machine-client": {
"roles": [
"uma_protection"
]
},
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
},
"scope": "email profile",
"clientHost": "10.89.0.2",
"email_verified": false,
"preferred_username": "service-account-iceberg-machine-client",
"clientAddress": "10.89.0.2",
"client_id": "iceberg-machine-client"
});
let token_header = jsonwebtoken::Header::new(Algorithm::RS256);
let token_data = jsonwebtoken::TokenData {
header: token_header.clone(),
claims: claims.clone(),
};
let payload = extract_authentication(
Some("idp"),
token_data.clone(),
&["sub".to_string()],
None,
None,
)
.unwrap();
let subject = Subject::new(
Some("idp".to_string()),
"b6cc7aa0-1af0-460e-9174-e05c881fb6d4".to_string(),
);
let expected_payload = Authentication::builder()
.token_header(Some(token_header.clone()))
.claims(claims.clone())
.name(Some("service-account-iceberg-machine-client".to_string()))
.email(None)
.subject(subject.clone())
.principal_type(Some(PrincipalType::Application))
.audiences(HashSet::from([
"iceberg-catalog".to_string(),
"account".to_string(),
]))
.build();
assert_eq!(payload, expected_payload);
let payload_with_roles = extract_authentication(
Some("idp"),
token_data,
&["sub".to_string()],
Some(&["resource_access.account.roles".to_string()]),
None,
)
.unwrap();
let expected_with_roles = Authentication::builder()
.token_header(Some(token_header))
.claims(claims.clone())
.name(Some("service-account-iceberg-machine-client".to_string()))
.email(None)
.subject(subject)
.principal_type(Some(PrincipalType::Application))
.roles(Some(vec![
"manage-account".to_string(),
"manage-account-links".to_string(),
"view-profile".to_string(),
]))
.audiences(HashSet::from([
"iceberg-catalog".to_string(),
"account".to_string(),
]))
.build();
assert_eq!(payload_with_roles, expected_with_roles);
}
#[test]
fn test_payload_missing_aud_claim_yields_empty_audiences() {
let claims = serde_json::json!({
"sub": "some-subject",
"iss": "https://example.com/",
"iat": 1_730_048_619,
"exp": 1_730_052_519,
"name": "Test User"
});
let token_header = jsonwebtoken::Header::new(Algorithm::RS256);
let token_data = jsonwebtoken::TokenData {
header: token_header.clone(),
claims: claims.clone(),
};
let payload =
extract_authentication(Some("idp"), token_data, &["sub".to_string()], None, None)
.unwrap();
assert_eq!(payload.audiences(), &HashSet::new());
}
fn parse_ok(template: &str) -> DisplayNameTemplate {
DisplayNameTemplate::parse(template).expect("template should be valid")
}
#[test]
fn test_display_name_template_parse_accepts_well_formed() {
assert!(DisplayNameTemplate::parse("Service Account {email}").is_ok());
assert!(DisplayNameTemplate::parse("{sub}").is_ok());
assert!(DisplayNameTemplate::parse("literal, no placeholders").is_ok());
assert!(DisplayNameTemplate::parse("{{escaped}} {sub}").is_ok());
assert!(DisplayNameTemplate::parse("{a.b.c}").is_ok());
}
#[test]
fn test_display_name_template_parse_rejects_structural_errors() {
assert_eq!(
DisplayNameTemplate::parse("Team {Alpha"),
Err(DisplayNameTemplateError::UnmatchedOpenBrace)
);
assert_eq!(
DisplayNameTemplate::parse("Team Alpha}"),
Err(DisplayNameTemplateError::UnmatchedCloseBrace)
);
assert_eq!(
DisplayNameTemplate::parse("{}"),
Err(DisplayNameTemplateError::EmptyPlaceholder)
);
assert_eq!(
DisplayNameTemplate::parse("{a{b}"),
Err(DisplayNameTemplateError::UnmatchedOpenBrace)
);
assert_eq!(
DisplayNameTemplate::parse("{ }"),
Err(DisplayNameTemplateError::EmptyPlaceholder)
);
assert_eq!(
DisplayNameTemplate::parse("{ }"),
Err(DisplayNameTemplateError::EmptyPlaceholder)
);
}
#[test]
fn test_display_name_template_from_str() {
let template: DisplayNameTemplate = "Service Account {email}".parse().unwrap();
let claims = serde_json::json!({ "email": "sa@example.com" });
assert_eq!(
template.render(&claims),
Some("Service Account sa@example.com".to_string())
);
}
#[test]
fn test_render_substitutes_email() {
let claims = serde_json::json!({ "email": "sa@example.com", "sub": "abc" });
assert_eq!(
parse_ok("Service Account {email}").render(&claims),
Some("Service Account sa@example.com".to_string())
);
}
#[test]
fn test_render_substitutes_sub() {
let claims = serde_json::json!({ "sub": "abc-123" });
assert_eq!(
parse_ok("{sub}").render(&claims),
Some("abc-123".to_string())
);
}
#[test]
fn test_render_navigates_nested_claim() {
let claims = serde_json::json!({ "a": { "b": { "c": "deep" } } });
assert_eq!(
parse_ok("name={a.b.c}").render(&claims),
Some("name=deep".to_string())
);
}
#[test]
fn test_render_escaped_braces() {
let claims = serde_json::json!({ "sub": "abc" });
assert_eq!(parse_ok("{{}}").render(&claims), Some("{}".to_string()));
assert_eq!(
parse_ok("{{{sub}}}").render(&claims),
Some("{abc}".to_string())
);
assert_eq!(
parse_ok("Team {{Alpha}}").render(&claims),
Some("Team {Alpha}".to_string())
);
}
#[test]
fn test_render_literal_only() {
let claims = serde_json::json!({ "sub": "abc" });
assert_eq!(
parse_ok("Machine Account").render(&claims),
Some("Machine Account".to_string())
);
}
#[test]
fn test_render_missing_claim_yields_none() {
let claims = serde_json::json!({ "sub": "abc" });
assert_eq!(parse_ok("Service Account {email}").render(&claims), None);
}
#[test]
fn test_render_non_string_claim_yields_none() {
let claims = serde_json::json!({ "organizations": ["org-1"] });
assert_eq!(parse_ok("{organizations}").render(&claims), None);
}
#[test]
fn test_render_does_not_rescan_substituted_value() {
let claims = serde_json::json!({ "org": "Team {X}" });
assert_eq!(
parse_ok("{org}").render(&claims),
Some("Team {X}".to_string())
);
}
#[test]
fn test_render_trims_surrounding_whitespace() {
let claims = serde_json::json!({ "sub": "abc" });
assert_eq!(
parse_ok(" {sub} ").render(&claims),
Some("abc".to_string())
);
}
#[test]
fn test_display_name_template_applied_when_no_name_claim() {
let claims = serde_json::json!({
"sub": "d0f26f85-a4fd-4dba-9534-9f69ba2b17ac",
"email": "sa@sa.stackit.cloud",
"iss": "https://accounts.qa.stackit.cloud",
});
let token_data = jsonwebtoken::TokenData {
header: jsonwebtoken::Header::new(Algorithm::RS256),
claims,
};
let template = parse_ok("Service Account {email}");
let payload = extract_authentication(
Some("oidc"),
token_data,
&["sub".to_string()],
None,
Some(&template),
)
.unwrap();
assert_eq!(
payload.full_name(),
Some("Service Account sa@sa.stackit.cloud")
);
}
#[test]
fn test_display_name_template_ignored_when_name_claim_present() {
let claims = serde_json::json!({
"sub": "x",
"name": "Jane Doe",
"email": "jane@example.com",
});
let token_data = jsonwebtoken::TokenData {
header: jsonwebtoken::Header::new(Algorithm::RS256),
claims,
};
let template = parse_ok("Service Account {email}");
let payload = extract_authentication(
Some("oidc"),
token_data,
&["sub".to_string()],
None,
Some(&template),
)
.unwrap();
assert_eq!(payload.full_name(), Some("Jane Doe"));
}
}