entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! OAuth 2.0 token introspection response (RFC 7662 §2.2).
//!
//! [`IntrospectionResponse`] is the JSON body an authorization server
//! returns from its `/introspect` endpoint so resource servers can validate
//! opaque tokens. This type builds and serialises the response; it performs
//! no token lookup (the caller does that) and no transport.
//!
//! # JSON emission
//!
//! The crate's [`JsonValue`](crate::json::JsonValue) parses but does not
//! serialise, so the response is emitted by building the RFC 8259 JSON
//! object directly with escaping — the same manual-construction approach
//! used by `saml::generate_sp_metadata`.
//!
//! # Security
//!
//! Per RFC 7662 §2.2, an inactive token MUST serialise to exactly
//! `{"active":false}` with no other members — disclosing `exp`, `sub`, or
//! `scope` for an invalid token would leak information.
//! [`IntrospectionResponse::inactive`] and the serializer enforce this:
//! when `active` is `false`, all other fields are omitted regardless of
//! what was set.

use crate::json::escape_json_string;

// ---------------------------------------------------------------------------
// Response type
// ---------------------------------------------------------------------------

/// An RFC 7662 token-introspection response.
///
/// Construct an active response with [`IntrospectionResponse::active`] and
/// chain the optional setters, or an inactive one with
/// [`IntrospectionResponse::inactive`]. Serialise with
/// [`to_json`](IntrospectionResponse::to_json).
///
/// # Example
///
/// ```
/// use entropy_auth::oauth::server::IntrospectionResponse;
///
/// let json = IntrospectionResponse::active()
///     .scope("openid profile")
///     .client_id("entropy_website")
///     .username("frodo")
///     .subject("user-123")
///     .token_type("Bearer")
///     .expiration(1_700_003_600)
///     .issued_at(1_700_000_000)
///     .to_json();
/// assert!(json.contains(r#""active":true"#));
///
/// // An inactive token discloses nothing else (RFC 7662 §2.2).
/// assert_eq!(IntrospectionResponse::inactive().to_json(), r#"{"active":false}"#);
/// ```
#[doc(alias = "introspection")]
#[derive(Debug, Clone, Default)]
pub struct IntrospectionResponse {
    active: bool,
    scope: Option<String>,
    client_id: Option<String>,
    username: Option<String>,
    sub: Option<String>,
    token_type: Option<String>,
    exp: Option<u64>,
    iat: Option<u64>,
    aud: Option<String>,
    iss: Option<String>,
}

impl IntrospectionResponse {
    /// Creates an `active: true` response with no other fields set.
    #[must_use]
    pub fn active() -> Self {
        Self {
            active: true,
            ..Self::default()
        }
    }

    /// Creates the canonical inactive response.
    ///
    /// Serialises to exactly `{"active":false}` regardless of any other
    /// state, per RFC 7662 §2.2.
    #[must_use]
    pub fn inactive() -> Self {
        Self {
            active: false,
            ..Self::default()
        }
    }

    /// Returns whether this response reports the token as active.
    #[must_use]
    #[inline]
    pub fn is_active(&self) -> bool {
        self.active
    }

    /// Sets the `scope` member (space-delimited scopes).
    #[must_use]
    pub fn scope(mut self, scope: impl Into<String>) -> Self {
        self.scope = Some(scope.into());
        self
    }

    /// Sets the `client_id` member.
    #[must_use]
    pub fn client_id(mut self, client_id: impl Into<String>) -> Self {
        self.client_id = Some(client_id.into());
        self
    }

    /// Sets the `username` member (human-readable identifier).
    #[must_use]
    pub fn username(mut self, username: impl Into<String>) -> Self {
        self.username = Some(username.into());
        self
    }

    /// Sets the `sub` (subject) member.
    #[must_use]
    pub fn subject(mut self, sub: impl Into<String>) -> Self {
        self.sub = Some(sub.into());
        self
    }

    /// Sets the `token_type` member (e.g. `"Bearer"`).
    #[must_use]
    pub fn token_type(mut self, token_type: impl Into<String>) -> Self {
        self.token_type = Some(token_type.into());
        self
    }

    /// Sets the `exp` (expiry) member as seconds since the Unix epoch.
    #[must_use]
    pub fn expiration(mut self, exp: u64) -> Self {
        self.exp = Some(exp);
        self
    }

    /// Sets the `iat` (issued-at) member as seconds since the Unix epoch.
    #[must_use]
    pub fn issued_at(mut self, iat: u64) -> Self {
        self.iat = Some(iat);
        self
    }

    /// Sets the `aud` (audience) member.
    #[must_use]
    pub fn audience(mut self, aud: impl Into<String>) -> Self {
        self.aud = Some(aud.into());
        self
    }

    /// Sets the `iss` (issuer) member.
    #[must_use]
    pub fn issuer(mut self, iss: impl Into<String>) -> Self {
        self.iss = Some(iss.into());
        self
    }

    /// Serialises this response to its RFC 7662 JSON body.
    ///
    /// # Security
    ///
    /// When `active` is `false`, the output is exactly `{"active":false}`
    /// and every other field is suppressed, even if set — an inactive
    /// token MUST NOT leak metadata (RFC 7662 §2.2).
    #[must_use]
    pub fn to_json(&self) -> String {
        if !self.active {
            return r#"{"active":false}"#.to_owned();
        }

        let mut parts: Vec<String> = vec![r#""active":true"#.to_owned()];

        let mut push_str_member = |key: &str, value: &Option<String>| {
            if let Some(v) = value {
                parts.push(format!(
                    "{}:{}",
                    escape_json_string(key),
                    escape_json_string(v)
                ));
            }
        };
        push_str_member("scope", &self.scope);
        push_str_member("client_id", &self.client_id);
        push_str_member("username", &self.username);
        push_str_member("token_type", &self.token_type);
        push_str_member("sub", &self.sub);
        push_str_member("aud", &self.aud);
        push_str_member("iss", &self.iss);

        if let Some(exp) = self.exp {
            parts.push(format!(r#""exp":{exp}"#));
        }
        if let Some(iat) = self.iat {
            parts.push(format!(r#""iat":{iat}"#));
        }

        format!("{{{}}}", parts.join(","))
    }
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use crate::json::JsonValue;

    #[test]
    fn inactive_is_canonical() {
        assert_eq!(
            IntrospectionResponse::inactive().to_json(),
            r#"{"active":false}"#,
        );
    }

    #[test]
    fn inactive_suppresses_all_other_fields() {
        // Even with fields set, an inactive response leaks nothing.
        let mut resp = IntrospectionResponse::inactive();
        resp.scope = Some("openid".into());
        resp.sub = Some("user-1".into());
        resp.exp = Some(123);
        assert_eq!(resp.to_json(), r#"{"active":false}"#);
    }

    #[test]
    fn active_full_round_trips_through_parser() {
        let json = IntrospectionResponse::active()
            .scope("openid profile email")
            .client_id("entropy_website")
            .username("frodo")
            .subject("user-123")
            .token_type("Bearer")
            .audience("api.example.com")
            .issuer("https://auth.example.com")
            .expiration(1_700_003_600)
            .issued_at(1_700_000_000)
            .to_json();

        let v = JsonValue::parse(&json).unwrap();
        assert_eq!(v.get_bool("active"), Some(true));
        assert_eq!(v.get_str("scope"), Some("openid profile email"));
        assert_eq!(v.get_str("client_id"), Some("entropy_website"));
        assert_eq!(v.get_str("username"), Some("frodo"));
        assert_eq!(v.get_str("sub"), Some("user-123"));
        assert_eq!(v.get_str("token_type"), Some("Bearer"));
        assert_eq!(v.get_str("aud"), Some("api.example.com"));
        assert_eq!(v.get_str("iss"), Some("https://auth.example.com"));
        assert_eq!(v.get_i64("exp"), Some(1_700_003_600));
        assert_eq!(v.get_i64("iat"), Some(1_700_000_000));
    }

    #[test]
    fn active_minimal_only_has_active() {
        let json = IntrospectionResponse::active().to_json();
        assert_eq!(json, r#"{"active":true}"#);
    }

    #[test]
    fn omitted_fields_are_absent() {
        let json = IntrospectionResponse::active().scope("openid").to_json();
        let v = JsonValue::parse(&json).unwrap();
        assert_eq!(v.get_str("scope"), Some("openid"));
        assert_eq!(v.get("client_id"), None);
        assert_eq!(v.get("sub"), None);
        assert_eq!(v.get("exp"), None);
    }

    #[test]
    fn escapes_special_characters() {
        let json = IntrospectionResponse::active()
            .username("a\"b\\c")
            .to_json();
        let v = JsonValue::parse(&json).unwrap();
        assert_eq!(v.get_str("username"), Some("a\"b\\c"));
    }

    #[test]
    fn is_active_accessor() {
        assert!(IntrospectionResponse::active().is_active());
        assert!(!IntrospectionResponse::inactive().is_active());
    }
}