use crate::jwt::{CustomClaimValueOpaque, IntoCustomClaim, JwtEncoder, JwtSigningAlgorithm};
#[doc(alias = "id_token")]
#[derive(Debug, Clone)]
#[must_use = "a builder does nothing until `sign` is called"]
pub struct IdTokenBuilder {
issuer: String,
subject: String,
audience: String,
iat: Option<u64>,
exp: Option<u64>,
nonce: Option<String>,
auth_time: Option<u64>,
email: Option<String>,
email_verified: Option<bool>,
name: Option<String>,
preferred_username: Option<String>,
roles: Option<Vec<String>>,
groups: Option<Vec<String>>,
tenant: Option<String>,
extra: Vec<(String, CustomClaimValueOpaque)>,
}
impl IdTokenBuilder {
pub fn new(
issuer: impl Into<String>,
subject: impl Into<String>,
audience: impl Into<String>,
) -> Self {
Self {
issuer: issuer.into(),
subject: subject.into(),
audience: audience.into(),
iat: None,
exp: None,
nonce: None,
auth_time: None,
email: None,
email_verified: None,
name: None,
preferred_username: None,
roles: None,
groups: None,
tenant: None,
extra: Vec::new(),
}
}
pub fn issued_at(mut self, iat: u64) -> Self {
self.iat = Some(iat);
self
}
pub fn expiration(mut self, exp: u64) -> Self {
self.exp = Some(exp);
self
}
pub fn nonce(mut self, nonce: impl Into<String>) -> Self {
self.nonce = Some(nonce.into());
self
}
pub fn auth_time(mut self, auth_time: u64) -> Self {
self.auth_time = Some(auth_time);
self
}
pub fn email(mut self, email: impl Into<String>, verified: bool) -> Self {
self.email = Some(email.into());
self.email_verified = Some(verified);
self
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn preferred_username(mut self, username: impl Into<String>) -> Self {
self.preferred_username = Some(username.into());
self
}
pub fn roles<I, S>(mut self, roles: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.roles = Some(roles.into_iter().map(Into::into).collect());
self
}
pub fn groups<I, S>(mut self, groups: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.groups = Some(groups.into_iter().map(Into::into).collect());
self
}
pub fn tenant(mut self, tenant: impl Into<String>) -> Self {
self.tenant = Some(tenant.into());
self
}
pub fn extra_claim(mut self, name: impl Into<String>, value: impl IntoCustomClaim) -> Self {
self.extra.push((name.into(), value.into_custom_claim()));
self
}
fn into_encoder(self) -> JwtEncoder {
let mut encoder = JwtEncoder::new()
.issuer(self.issuer)
.subject(self.subject)
.audience(self.audience);
if let Some(iat) = self.iat {
encoder = encoder.issued_at(iat);
}
if let Some(exp) = self.exp {
encoder = encoder.expiration(exp);
}
if let Some(nonce) = self.nonce {
encoder = encoder.nonce(nonce);
}
if let Some(auth_time) = self.auth_time {
encoder = encoder.custom_claim("auth_time", auth_time);
}
if let Some(email) = self.email {
encoder = encoder.custom_claim("email", email);
}
if let Some(verified) = self.email_verified {
encoder = encoder.custom_claim("email_verified", verified);
}
if let Some(name) = self.name {
encoder = encoder.custom_claim("name", name);
}
if let Some(username) = self.preferred_username {
encoder = encoder.custom_claim("preferred_username", username);
}
if let Some(roles) = self.roles {
encoder = encoder.custom_claim("roles", roles);
}
if let Some(groups) = self.groups {
encoder = encoder.custom_claim("groups", groups);
}
if let Some(tenant) = self.tenant {
encoder = encoder.custom_claim("tenant", tenant);
}
for (name, value) in self.extra {
encoder = encoder.custom_claim(name, value);
}
encoder
}
#[must_use = "this returns the signed ID token"]
pub fn sign(self, alg: JwtSigningAlgorithm, key: &[u8]) -> String {
self.into_encoder().sign(alg, key)
}
#[cfg(feature = "asym-jwt")]
#[must_use = "this returns the signed ID token"]
pub fn sign_asymmetric(self, key: &crate::jwt::AsymmetricSigningKey) -> String {
self.into_encoder().key_id(key.kid()).sign_asymmetric(key)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::verify_jwt;
const KEY: &[u8] = b"client-secret-value";
#[test]
fn mandatory_claims_only() {
let token =
IdTokenBuilder::new("iss", "sub-1", "aud-1").sign(JwtSigningAlgorithm::Hs256, KEY);
let (_, claims) = verify_jwt(&token, KEY).unwrap();
assert_eq!(claims.iss(), Some("iss"));
assert_eq!(claims.sub(), Some("sub-1"));
assert_eq!(claims.aud(), &["aud-1"]);
}
#[test]
fn extra_claims_emitted_but_never_override_standard() {
let token = IdTokenBuilder::new("iss", "sub-1", "aud-1")
.roles(["user"])
.extra_claim("department", "engineering")
.extra_claim("employee_id", 4242u64)
.extra_claim("is_contractor", false)
.extra_claim("scopes", vec!["read".to_string(), "write".to_string()])
.extra_claim("roles", vec!["admin".to_string()])
.extra_claim("sub", "attacker")
.sign(JwtSigningAlgorithm::Hs256, KEY);
let (_, c) = verify_jwt(&token, KEY).unwrap();
assert_eq!(c.sub(), Some("sub-1"), "custom sub cannot override");
assert_eq!(
c.get_claim("department").and_then(|v| v.as_str()),
Some("engineering")
);
assert_eq!(
c.get_claim("employee_id")
.and_then(crate::json::JsonValue::as_i64),
Some(4242)
);
assert_eq!(
c.get_claim("is_contractor")
.and_then(crate::json::JsonValue::as_bool),
Some(false)
);
assert_eq!(
c.get_claim("scopes")
.and_then(|v| v.as_array())
.unwrap()
.len(),
2
);
let roles: Vec<&str> = c
.get_claim("roles")
.and_then(|v| v.as_array())
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(roles, ["user"]);
}
#[test]
fn full_id_token_round_trips() {
let token = IdTokenBuilder::new("https://auth.example.com", "user-123", "entropy_website")
.issued_at(1_700_000_000)
.expiration(1_700_003_600)
.nonce("n-abc")
.email("frodo@example.com", true)
.name("Frodo Baggins")
.preferred_username("frodo")
.roles(["user", "developer"])
.groups(["engineering", "admins"])
.tenant("entropy")
.sign(JwtSigningAlgorithm::Hs256, KEY);
let (_, c) = verify_jwt(&token, KEY).unwrap();
assert_eq!(c.iss(), Some("https://auth.example.com"));
assert_eq!(c.sub(), Some("user-123"));
assert!(c.validate_aud("entropy_website"));
assert_eq!(c.iat(), Some(1_700_000_000));
assert_eq!(c.exp(), Some(1_700_003_600));
assert_eq!(c.get_claim("nonce").and_then(|v| v.as_str()), Some("n-abc"));
assert_eq!(
c.get_claim("email").and_then(|v| v.as_str()),
Some("frodo@example.com")
);
assert_eq!(
c.get_claim("email_verified")
.and_then(crate::json::JsonValue::as_bool),
Some(true)
);
assert_eq!(
c.get_claim("name").and_then(|v| v.as_str()),
Some("Frodo Baggins")
);
assert_eq!(
c.get_claim("preferred_username").and_then(|v| v.as_str()),
Some("frodo"),
);
assert_eq!(
c.get_claim("tenant").and_then(|v| v.as_str()),
Some("entropy")
);
let roles: Vec<&str> = c
.get_claim("roles")
.and_then(|v| v.as_array())
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(roles, ["user", "developer"]);
let groups: Vec<&str> = c
.get_claim("groups")
.and_then(|v| v.as_array())
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(groups, ["engineering", "admins"]);
}
#[test]
fn groups_claim_absent_when_not_set() {
let token = IdTokenBuilder::new("iss", "sub", "aud")
.roles(["user"])
.sign(JwtSigningAlgorithm::Hs256, KEY);
let (_, c) = verify_jwt(&token, KEY).unwrap();
assert!(c.get_claim("groups").is_none());
}
#[test]
fn auth_time_emitted_as_number() {
let token = IdTokenBuilder::new("iss", "sub", "aud")
.auth_time(1_700_000_000)
.sign(JwtSigningAlgorithm::Hs256, KEY);
let (_, c) = verify_jwt(&token, KEY).unwrap();
assert_eq!(
c.get_claim("auth_time")
.and_then(crate::json::JsonValue::as_i64),
Some(1_700_000_000)
);
}
#[test]
fn email_unverified() {
let token = IdTokenBuilder::new("iss", "sub", "aud")
.email("x@y.z", false)
.sign(JwtSigningAlgorithm::Hs256, KEY);
let (_, c) = verify_jwt(&token, KEY).unwrap();
assert_eq!(
c.get_claim("email_verified")
.and_then(crate::json::JsonValue::as_bool),
Some(false)
);
}
#[test]
fn hs512_signing() {
let token = IdTokenBuilder::new("iss", "sub", "aud").sign(JwtSigningAlgorithm::Hs512, KEY);
assert!(verify_jwt(&token, KEY).is_ok());
}
#[test]
fn wrong_secret_fails() {
let token = IdTokenBuilder::new("iss", "sub", "aud").sign(JwtSigningAlgorithm::Hs256, KEY);
assert!(verify_jwt(&token, b"different-secret").is_err());
}
}