use core::fmt;
use crate::jwt::JwtClaims;
use super::standard_claims::StandardClaims;
#[doc(alias = "id_token_claims")]
#[derive(Debug, Clone)]
pub struct IdTokenClaims {
iss: String,
sub: String,
aud: Vec<String>,
exp: u64,
iat: u64,
nonce: Option<String>,
azp: Option<String>,
standard: StandardClaims,
}
impl IdTokenClaims {
#[must_use]
#[inline]
pub fn iss(&self) -> &str {
&self.iss
}
#[must_use]
#[inline]
pub fn sub(&self) -> &str {
&self.sub
}
#[must_use]
#[inline]
pub fn aud(&self) -> &[String] {
&self.aud
}
#[must_use]
#[inline]
pub fn exp(&self) -> u64 {
self.exp
}
#[must_use]
#[inline]
pub fn iat(&self) -> u64 {
self.iat
}
#[must_use]
#[inline]
pub fn nonce(&self) -> Option<&str> {
self.nonce.as_deref()
}
#[must_use]
#[inline]
pub fn azp(&self) -> Option<&str> {
self.azp.as_deref()
}
#[must_use]
#[inline]
pub fn email(&self) -> Option<&str> {
self.standard.email()
}
#[must_use]
#[inline]
pub fn email_verified(&self) -> Option<bool> {
self.standard.email_verified()
}
#[must_use]
#[inline]
pub fn name(&self) -> Option<&str> {
self.standard.name()
}
#[must_use]
#[inline]
pub fn preferred_username(&self) -> Option<&str> {
self.standard.preferred_username()
}
#[must_use]
#[inline]
pub fn picture(&self) -> Option<&str> {
self.standard.picture()
}
}
fn optional_string_claim(claims: &JwtClaims, key: &str) -> Option<String> {
claims
.get_claim(key)
.and_then(|v| v.as_str())
.map(String::from)
}
pub(super) fn coerce_bool_claim(value: &crate::json::JsonValue) -> Option<bool> {
if let Some(b) = value.as_bool() {
return Some(b);
}
match value.as_str()? {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
impl IdTokenClaims {
pub fn from_jwt_claims(claims: &JwtClaims) -> Result<Self, IdTokenClaimsError> {
let iss = claims
.iss()
.filter(|s| !s.is_empty())
.map(String::from)
.ok_or_else(|| IdTokenClaimsError::missing("iss"))?;
let sub = claims
.sub()
.filter(|s| !s.is_empty())
.map(String::from)
.ok_or_else(|| IdTokenClaimsError::missing("sub"))?;
if claims.aud().is_empty() {
return Err(IdTokenClaimsError::missing("aud"));
}
let aud = claims.aud().to_vec();
let exp = claims
.exp()
.ok_or_else(|| IdTokenClaimsError::missing("exp"))?;
let iat = claims
.iat()
.ok_or_else(|| IdTokenClaimsError::missing("iat"))?;
let nonce = optional_string_claim(claims, "nonce");
let azp = optional_string_claim(claims, "azp");
let standard = StandardClaims::new(
optional_string_claim(claims, "name"),
optional_string_claim(claims, "email"),
claims
.get_claim("email_verified")
.and_then(coerce_bool_claim),
optional_string_claim(claims, "preferred_username"),
optional_string_claim(claims, "picture"),
);
Ok(Self {
iss,
sub,
aud,
exp,
iat,
nonce,
azp,
standard,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum IdTokenClaimsErrorKind {
MissingClaim(String),
}
#[doc(alias = "id_claims_error")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdTokenClaimsError {
kind: IdTokenClaimsErrorKind,
}
impl IdTokenClaimsError {
fn missing(claim: &str) -> Self {
Self {
kind: IdTokenClaimsErrorKind::MissingClaim(claim.to_string()),
}
}
#[must_use]
pub(crate) fn claim_name(&self) -> &str {
match &self.kind {
IdTokenClaimsErrorKind::MissingClaim(name) => name,
}
}
}
impl fmt::Display for IdTokenClaimsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.kind {
IdTokenClaimsErrorKind::MissingClaim(claim) => {
write!(f, "oidc id token: missing required claim '{claim}'")
}
}
}
}
impl std::error::Error for IdTokenClaimsError {}
#[cfg(test)]
mod tests {
use super::*;
use crate::encoding::base64url_encode;
use crate::jwt::JwtClaims;
fn parse_claims(json: &str) -> JwtClaims {
let b64 = base64url_encode(json.as_bytes());
JwtClaims::parse(&b64).unwrap()
}
#[test]
fn extract_full_claims() {
let jwt_claims = parse_claims(
r#"{
"iss": "https://accounts.example.com",
"sub": "user-123",
"aud": "my-client",
"exp": 1700000000,
"iat": 1699999000,
"nonce": "abc123",
"email": "user@example.com",
"email_verified": true,
"name": "Test User",
"preferred_username": "testuser",
"picture": "https://example.com/photo.jpg"
}"#,
);
let claims = IdTokenClaims::from_jwt_claims(&jwt_claims).unwrap();
assert_eq!(claims.iss(), "https://accounts.example.com");
assert_eq!(claims.sub(), "user-123");
assert_eq!(claims.aud(), ["my-client"]);
assert_eq!(claims.exp(), 1_700_000_000);
assert_eq!(claims.iat(), 1_699_999_000);
assert_eq!(claims.nonce(), Some("abc123"));
assert_eq!(claims.email(), Some("user@example.com"));
assert_eq!(claims.email_verified(), Some(true));
assert_eq!(claims.name(), Some("Test User"));
assert_eq!(claims.preferred_username(), Some("testuser"));
assert_eq!(claims.picture(), Some("https://example.com/photo.jpg"));
}
#[test]
fn email_verified_accepts_string_boolean() {
let claims = IdTokenClaims::from_jwt_claims(&parse_claims(
r#"{
"iss": "https://example.com",
"sub": "user-1",
"aud": "client-id",
"exp": 9999999999,
"iat": 1000,
"email_verified": "true"
}"#,
))
.unwrap();
assert_eq!(claims.email_verified(), Some(true));
let claims = IdTokenClaims::from_jwt_claims(&parse_claims(
r#"{
"iss": "https://example.com",
"sub": "user-1",
"aud": "client-id",
"exp": 9999999999,
"iat": 1000,
"email_verified": "false"
}"#,
))
.unwrap();
assert_eq!(claims.email_verified(), Some(false));
}
#[test]
fn extract_minimal_required_claims() {
let jwt_claims = parse_claims(
r#"{
"iss": "https://example.com",
"sub": "user-1",
"aud": "client-id",
"exp": 9999999999,
"iat": 1000
}"#,
);
let claims = IdTokenClaims::from_jwt_claims(&jwt_claims).unwrap();
assert_eq!(claims.iss(), "https://example.com");
assert_eq!(claims.sub(), "user-1");
assert_eq!(claims.aud(), ["client-id"]);
assert_eq!(claims.nonce(), None::<&str>);
assert_eq!(claims.email(), None::<&str>);
assert_eq!(claims.email_verified(), None);
assert_eq!(claims.name(), None::<&str>);
assert_eq!(claims.preferred_username(), None::<&str>);
assert_eq!(claims.picture(), None::<&str>);
}
#[test]
fn missing_iss() {
let jwt_claims = parse_claims(r#"{"sub": "user-1", "aud": "c", "exp": 1000, "iat": 1000}"#);
let err = IdTokenClaims::from_jwt_claims(&jwt_claims).unwrap_err();
assert!(err.to_string().contains("iss"), "got: {err}");
}
#[test]
fn missing_sub() {
let jwt_claims =
parse_claims(r#"{"iss": "https://example.com", "aud": "c", "exp": 1000, "iat": 1000}"#);
let err = IdTokenClaims::from_jwt_claims(&jwt_claims).unwrap_err();
assert!(err.to_string().contains("sub"), "got: {err}");
}
#[test]
fn empty_sub_is_rejected_as_missing() {
let jwt_claims = parse_claims(
r#"{"iss": "https://example.com", "sub": "", "aud": "c", "exp": 1000, "iat": 1000}"#,
);
let err = IdTokenClaims::from_jwt_claims(&jwt_claims).unwrap_err();
assert!(err.to_string().contains("sub"), "got: {err}");
}
#[test]
fn empty_iss_is_rejected_as_missing() {
let jwt_claims =
parse_claims(r#"{"iss": "", "sub": "u", "aud": "c", "exp": 1000, "iat": 1000}"#);
let err = IdTokenClaims::from_jwt_claims(&jwt_claims).unwrap_err();
assert!(err.to_string().contains("iss"), "got: {err}");
}
#[test]
fn missing_aud() {
let jwt_claims =
parse_claims(r#"{"iss": "https://example.com", "sub": "u", "exp": 1000, "iat": 1000}"#);
let err = IdTokenClaims::from_jwt_claims(&jwt_claims).unwrap_err();
assert!(err.to_string().contains("aud"), "got: {err}");
}
#[test]
fn missing_exp() {
let jwt_claims =
parse_claims(r#"{"iss": "https://example.com", "sub": "u", "aud": "c", "iat": 1000}"#);
let err = IdTokenClaims::from_jwt_claims(&jwt_claims).unwrap_err();
assert!(err.to_string().contains("exp"), "got: {err}");
}
#[test]
fn missing_iat() {
let jwt_claims =
parse_claims(r#"{"iss": "https://example.com", "sub": "u", "aud": "c", "exp": 1000}"#);
let err = IdTokenClaims::from_jwt_claims(&jwt_claims).unwrap_err();
assert!(err.to_string().contains("iat"), "got: {err}");
}
#[test]
fn error_implements_std_error() {
let err: Box<dyn std::error::Error> = Box::new(IdTokenClaimsError::missing("test"));
let _ = err.to_string();
}
}