use core::fmt;
use crate::crypto::constant_time::constant_time_eq;
use crate::json::JsonValue;
use crate::util::log::{info, warn};
use super::standard_claims::StandardClaims;
#[doc(alias = "userinfo")]
#[derive(Debug, Clone)]
pub struct UserInfo {
sub: String,
standard: StandardClaims,
}
impl UserInfo {
#[must_use]
#[inline]
pub fn sub(&self) -> &str {
&self.sub
}
pub fn verify_sub(&self, id_token_sub: &str) -> Result<(), UserInfoError> {
if constant_time_eq(self.sub.as_bytes(), id_token_sub.as_bytes()) {
Ok(())
} else {
warn!("oidc: userinfo sub does not match id token sub");
Err(UserInfoError {
kind: UserInfoErrorKind::SubMismatch,
})
}
}
#[must_use]
#[inline]
pub fn name(&self) -> Option<&str> {
self.standard.name()
}
#[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 preferred_username(&self) -> Option<&str> {
self.standard.preferred_username()
}
#[must_use]
#[inline]
pub fn picture(&self) -> Option<&str> {
self.standard.picture()
}
}
impl UserInfo {
pub fn parse(json: &str) -> Result<Self, UserInfoError> {
let value = JsonValue::parse(json).map_err(|_| {
warn!("oidc: userinfo parse failed (invalid JSON)");
UserInfoError {
kind: UserInfoErrorKind::InvalidJson,
}
})?;
let sub = value
.get_str("sub")
.filter(|s| !s.is_empty())
.map(String::from)
.ok_or_else(|| {
warn!("oidc: userinfo parse failed (missing sub)");
UserInfoError {
kind: UserInfoErrorKind::MissingSub,
}
})?;
let standard = StandardClaims::new(
value.get_str("name").map(String::from),
value.get_str("email").map(String::from),
value
.get("email_verified")
.and_then(super::claims::coerce_bool_claim),
value.get_str("preferred_username").map(String::from),
value.get_str("picture").map(String::from),
);
info!("oidc: userinfo parsed");
Ok(Self { sub, standard })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum UserInfoErrorKind {
InvalidJson,
MissingSub,
SubMismatch,
}
#[doc(alias = "userinfo_error")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserInfoError {
kind: UserInfoErrorKind,
}
impl UserInfoError {
#[must_use]
#[inline]
pub fn is_sub_mismatch(&self) -> bool {
self.kind == UserInfoErrorKind::SubMismatch
}
}
impl fmt::Display for UserInfoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind {
UserInfoErrorKind::InvalidJson => {
write!(f, "oidc userinfo: invalid JSON")
}
UserInfoErrorKind::MissingSub => {
write!(f, "oidc userinfo: missing required 'sub' claim")
}
UserInfoErrorKind::SubMismatch => {
write!(f, "oidc userinfo: 'sub' does not match the ID token")
}
}
}
}
impl std::error::Error for UserInfoError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_full_userinfo() {
let json = r#"{
"sub": "user-123",
"name": "Test User",
"email": "user@example.com",
"email_verified": true,
"preferred_username": "testuser",
"picture": "https://example.com/photo.jpg"
}"#;
let info = UserInfo::parse(json).unwrap();
assert_eq!(info.sub(), "user-123");
assert_eq!(info.name(), Some("Test User"));
assert_eq!(info.email(), Some("user@example.com"));
assert_eq!(info.email_verified(), Some(true));
assert_eq!(info.preferred_username(), Some("testuser"));
assert_eq!(info.picture(), Some("https://example.com/photo.jpg"));
}
#[test]
fn parse_minimal_userinfo() {
let json = r#"{"sub": "user-456"}"#;
let info = UserInfo::parse(json).unwrap();
assert_eq!(info.sub(), "user-456");
assert_eq!(info.name(), None::<&str>);
assert_eq!(info.email(), None::<&str>);
assert_eq!(info.email_verified(), None);
assert_eq!(info.preferred_username(), None::<&str>);
assert_eq!(info.picture(), None::<&str>);
}
#[test]
fn parse_email_verified_false() {
let json = r#"{"sub": "user-1", "email_verified": false}"#;
let info = UserInfo::parse(json).unwrap();
assert_eq!(info.email_verified(), Some(false));
}
#[test]
fn missing_sub() {
let json = r#"{"name": "Test User", "email": "user@example.com"}"#;
let err = UserInfo::parse(json).unwrap_err();
assert!(err.to_string().contains("sub"), "got: {err}");
}
#[test]
fn invalid_json() {
let err = UserInfo::parse("not json").unwrap_err();
assert!(err.to_string().contains("JSON"), "got: {err}");
}
#[test]
fn empty_object_missing_sub() {
let err = UserInfo::parse("{}").unwrap_err();
assert!(err.to_string().contains("sub"), "got: {err}");
}
#[test]
fn empty_sub_is_rejected() {
let err = UserInfo::parse(r#"{"sub": ""}"#).unwrap_err();
assert!(err.to_string().contains("sub"), "got: {err}");
}
#[test]
fn verify_sub_accepts_match() {
let info = UserInfo::parse(r#"{"sub": "user-123"}"#).unwrap();
assert!(info.verify_sub("user-123").is_ok());
}
#[test]
fn verify_sub_rejects_mismatch() {
let info = UserInfo::parse(r#"{"sub": "victim", "email": "victim@example.com"}"#).unwrap();
let err = info.verify_sub("attacker").unwrap_err();
assert!(err.is_sub_mismatch());
}
#[test]
fn error_implements_std_error() {
let err: Box<dyn std::error::Error> = Box::new(UserInfoError {
kind: UserInfoErrorKind::InvalidJson,
});
let _ = err.to_string();
}
}