entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! OIDC `UserInfo` endpoint response parsing.
//!
//! Parses the JSON response from the `OpenID` Connect `UserInfo` endpoint
//! per OIDC Core 1.0 §5.3. The `sub` claim is required; all other
//! claims are optional.

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;

// ---------------------------------------------------------------------------
// UserInfo struct
// ---------------------------------------------------------------------------

/// Parsed OIDC `UserInfo` endpoint response.
///
/// Contains the standard claims returned by the `UserInfo` endpoint.
/// The `sub` claim is required per the OIDC specification; all other
/// fields are optional.
#[doc(alias = "userinfo")]
#[derive(Debug, Clone)]
pub struct UserInfo {
    /// Subject identifier (must match the `sub` claim in the ID token).
    sub: String,
    /// Standard profile claims shared with `IdTokenClaims`.
    standard: StandardClaims,
}

impl UserInfo {
    /// Returns the subject identifier.
    #[must_use]
    #[inline]
    pub fn sub(&self) -> &str {
        &self.sub
    }

    /// Verifies that this `UserInfo`'s `sub` matches the `sub` of the
    /// validated ID token.
    ///
    /// # Security
    ///
    /// OIDC Core §5.3.4 **requires** that the `sub` of a `UserInfo` response
    /// be verified to exactly equal the `sub` of the ID token before any of
    /// its claims are used; otherwise an attacker who can substitute a
    /// `UserInfo` response (or a confused-deputy token) can serve another
    /// user's profile. [`parse`](Self::parse) does **not** perform this check
    /// — it cannot, having no ID token — so the caller must call this (or
    /// compare `sub` themselves) before trusting `email`/`name`/etc. The
    /// comparison is constant time.
    ///
    /// # Errors
    ///
    /// Returns [`UserInfoError`] (with [`is_sub_mismatch`](UserInfoError::is_sub_mismatch)
    /// true) if the subjects differ.
    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,
            })
        }
    }

    /// Returns the user's full name, if present.
    #[must_use]
    #[inline]
    pub fn name(&self) -> Option<&str> {
        self.standard.name()
    }

    /// Returns the user's email address, if present.
    #[must_use]
    #[inline]
    pub fn email(&self) -> Option<&str> {
        self.standard.email()
    }

    /// Returns whether the user's email has been verified, if present.
    #[must_use]
    #[inline]
    pub fn email_verified(&self) -> Option<bool> {
        self.standard.email_verified()
    }

    /// Returns the user's preferred username, if present.
    #[must_use]
    #[inline]
    pub fn preferred_username(&self) -> Option<&str> {
        self.standard.preferred_username()
    }

    /// Returns the URL of the user's profile picture, if present.
    #[must_use]
    #[inline]
    pub fn picture(&self) -> Option<&str> {
        self.standard.picture()
    }
}

impl UserInfo {
    /// Parses a `UserInfo` response from its JSON representation.
    ///
    /// This does **not** verify the `sub` against an ID token; see
    /// [`verify_sub`](Self::verify_sub), which a caller MUST use (OIDC Core
    /// §5.3.4) before trusting any returned claim.
    ///
    /// # Errors
    ///
    /// Returns [`UserInfoError`] if the JSON is malformed or the required
    /// `sub` claim is missing or empty.
    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,
            }
        })?;

        // An empty `sub` is malformed: treat it as missing rather than a valid
        // (and trivially-collidable) identifier.
        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),
        );

        // SECURITY: do not log `sub` (or any claim value) — PII.
        info!("oidc: userinfo parsed");

        Ok(Self { sub, standard })
    }
}

// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------

/// The category of `UserInfo` parse failure.
#[derive(Debug, Clone, PartialEq, Eq)]
enum UserInfoErrorKind {
    /// The input is not valid JSON.
    InvalidJson,
    /// The required `sub` claim is missing (or empty).
    MissingSub,
    /// The `sub` does not match the ID token's `sub` (OIDC Core §5.3.4).
    SubMismatch,
}

/// Error returned when OIDC `UserInfo` response parsing fails.
#[doc(alias = "userinfo_error")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserInfoError {
    kind: UserInfoErrorKind,
}

impl UserInfoError {
    /// Returns `true` if the failure is a `sub` mismatch against the ID
    /// token (OIDC Core §5.3.4).
    #[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 {}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[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() {
        // The UserInfo substitution attack (OIDC Core §5.3.4): a response for
        // a different subject must be rejected before its claims are trusted.
        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();
    }
}