entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! JWT header (JOSE header) parsing per RFC 7515 §4.
//!
//! Extracts the `alg`, `typ`, and `kid` fields from a Base64url-encoded
//! JWT header segment. Supported `alg` values are the HMAC family (HS256
//! and HS512) and, behind the `asym-jwt` feature, the asymmetric `EdDSA`,
//! `ES256`, `RS256`, and `RS512` algorithms. `alg: none` is never accepted.
//!
//! A header carrying the `crit` (Critical) parameter (RFC 7515 §4.1.11) is
//! rejected: that field lists header parameters the recipient MUST
//! understand and process, and this crate understands no `crit` extensions,
//! so per the spec (and RFC 8725 §3.10) the only correct response is to
//! reject the token rather than silently ignore the marked parameters.

use core::fmt;

use super::decode::{SegmentDecodeError, decode_segment};

// ---------------------------------------------------------------------------
// Algorithm enum
// ---------------------------------------------------------------------------

/// JWT signature algorithm identifier.
///
/// HMAC algorithms (`HS256`/`HS512`) are always available. With the
/// `asym-jwt` feature enabled, the asymmetric algorithms `EdDSA`, `ES256`,
/// and the verify-only `RS256`/`RS512` are also recognised. The enum is
/// `#[non_exhaustive]` so further variants can be added without breaking
/// downstream code.
#[doc(alias = "algorithm")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum JwtAlgorithm {
    /// HMAC using SHA-256 (RFC 7518 §3.2).
    HS256,
    /// HMAC using SHA-512 (RFC 7518 §3.2).
    HS512,
    /// `EdDSA` using Ed25519 (RFC 8037 §3.1). Requires the `asym-jwt` feature.
    #[cfg(feature = "asym-jwt")]
    EdDSA,
    /// ECDSA using P-256 and SHA-256 (RFC 7518 §3.4). Requires the
    /// `asym-jwt` feature.
    #[cfg(feature = "asym-jwt")]
    ES256,
    /// RSASSA-PKCS1-v1_5 using SHA-256 (RFC 7518 §3.3). Requires the
    /// `asym-jwt` feature. **Verify-only** — used to validate ID tokens
    /// from external OIDC providers (Google, Microsoft, Okta). This crate
    /// does not mint RS256 tokens.
    #[cfg(feature = "asym-jwt")]
    RS256,
    /// RSASSA-PKCS1-v1_5 using SHA-512 (RFC 7518 §3.3). Requires the
    /// `asym-jwt` feature. Verify-only, as for [`RS256`](Self::RS256).
    #[cfg(feature = "asym-jwt")]
    RS512,
    /// No digital signature or MAC (RFC 7518 §3.6).
    ///
    /// # Security
    ///
    /// This variant is recognised for parsing but **always rejected**
    /// during signature verification to prevent bypass attacks.
    None,
}

impl std::str::FromStr for JwtAlgorithm {
    type Err = JwtHeaderError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "HS256" => Ok(Self::HS256),
            "HS512" => Ok(Self::HS512),
            #[cfg(feature = "asym-jwt")]
            "EdDSA" => Ok(Self::EdDSA),
            #[cfg(feature = "asym-jwt")]
            "ES256" => Ok(Self::ES256),
            #[cfg(feature = "asym-jwt")]
            "RS256" => Ok(Self::RS256),
            #[cfg(feature = "asym-jwt")]
            "RS512" => Ok(Self::RS512),
            "none" => Ok(Self::None),
            _ => Err(JwtHeaderError {
                kind: JwtHeaderErrorKind::UnsupportedAlgorithm,
            }),
        }
    }
}

impl fmt::Display for JwtAlgorithm {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::HS256 => write!(f, "HS256"),
            Self::HS512 => write!(f, "HS512"),
            #[cfg(feature = "asym-jwt")]
            Self::EdDSA => write!(f, "EdDSA"),
            #[cfg(feature = "asym-jwt")]
            Self::ES256 => write!(f, "ES256"),
            #[cfg(feature = "asym-jwt")]
            Self::RS256 => write!(f, "RS256"),
            #[cfg(feature = "asym-jwt")]
            Self::RS512 => write!(f, "RS512"),
            Self::None => write!(f, "none"),
        }
    }
}

// ---------------------------------------------------------------------------
// Header struct
// ---------------------------------------------------------------------------

/// Parsed JWT header (JOSE header).
///
/// Contains the algorithm identifier and optional `typ` and `kid` fields.
/// Additional header parameters are ignored (forward-compatible).
#[doc(alias = "jose_header")]
#[derive(Debug, Clone)]
pub struct JwtHeader {
    /// The signature algorithm.
    alg: JwtAlgorithm,
    /// The token type (typically `"JWT"`).
    typ: Option<String>,
    /// The key identifier, used to select the verification key from a JWKS.
    kid: Option<String>,
}

impl JwtHeader {
    /// Returns the signature algorithm.
    #[must_use]
    #[inline]
    pub fn alg(&self) -> JwtAlgorithm {
        self.alg
    }

    /// Returns the token type (typically `"JWT"`), if present.
    #[must_use]
    #[inline]
    pub fn typ(&self) -> Option<&str> {
        self.typ.as_deref()
    }

    /// Returns the key identifier, if present.
    #[must_use]
    #[inline]
    pub fn kid(&self) -> Option<&str> {
        self.kid.as_deref()
    }
}

impl JwtHeader {
    /// Parses a JWT header from its Base64url-encoded representation.
    ///
    /// # Errors
    ///
    /// Returns [`JwtHeaderError`] if Base64url decoding fails, the decoded
    /// bytes are not valid JSON, the `alg` field is missing or unsupported,
    /// or the header carries an unsupported `crit` parameter.
    #[must_use = "parsing may fail; handle the Result"]
    pub fn parse(header_b64: &str) -> Result<Self, JwtHeaderError> {
        let value = decode_segment(header_b64).map_err(|e| JwtHeaderError {
            kind: match e {
                SegmentDecodeError::InvalidBase64 => JwtHeaderErrorKind::InvalidBase64,
                SegmentDecodeError::InvalidUtf8 => JwtHeaderErrorKind::InvalidUtf8,
                SegmentDecodeError::InvalidJson => JwtHeaderErrorKind::InvalidJson,
            },
        })?;

        // RFC 7515 §4.1.11 / RFC 8725 §3.10: `crit` lists header parameters
        // the recipient MUST understand. We support no `crit` extensions, so
        // any `crit` member (even an empty one, which is itself malformed)
        // means the token cannot be safely processed — reject it.
        if value.get("crit").is_some() {
            return Err(JwtHeaderError {
                kind: JwtHeaderErrorKind::CriticalHeaderUnsupported,
            });
        }

        let alg_str = value.get_str("alg").ok_or(JwtHeaderError {
            kind: JwtHeaderErrorKind::MissingAlgorithm,
        })?;

        let alg = alg_str.parse::<JwtAlgorithm>()?;
        let typ = value.get_str("typ").map(String::from);
        let kid = value.get_str("kid").map(String::from);

        Ok(Self { alg, typ, kid })
    }
}

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

/// The category of JWT header parse failure.
#[derive(Debug, Clone, PartialEq, Eq)]
enum JwtHeaderErrorKind {
    /// The header segment is not valid Base64url.
    InvalidBase64,
    /// The decoded header is not valid UTF-8.
    InvalidUtf8,
    /// The decoded header is not valid JSON.
    InvalidJson,
    /// The `alg` field is missing from the header, or present but not a
    /// JSON string (e.g. a number or array — treated as missing).
    MissingAlgorithm,
    /// The `alg` field contains an unsupported algorithm.
    UnsupportedAlgorithm,
    /// The header carries a `crit` (Critical) parameter, which this crate
    /// does not support (RFC 7515 §4.1.11).
    CriticalHeaderUnsupported,
}

/// Error returned when JWT header parsing fails.
///
/// Error messages never include the raw header content to avoid leaking
/// potentially sensitive data.
#[doc(alias = "header_error")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JwtHeaderError {
    kind: JwtHeaderErrorKind,
}

impl fmt::Display for JwtHeaderError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self.kind {
            JwtHeaderErrorKind::InvalidBase64 => {
                write!(f, "jwt header: invalid base64url encoding")
            }
            JwtHeaderErrorKind::InvalidUtf8 => {
                write!(f, "jwt header: decoded bytes are not valid UTF-8")
            }
            JwtHeaderErrorKind::InvalidJson => {
                write!(f, "jwt header: decoded content is not valid JSON")
            }
            JwtHeaderErrorKind::MissingAlgorithm => {
                write!(f, "jwt header: missing 'alg' field")
            }
            JwtHeaderErrorKind::UnsupportedAlgorithm => {
                write!(f, "jwt header: unsupported algorithm")
            }
            JwtHeaderErrorKind::CriticalHeaderUnsupported => {
                write!(f, "jwt header: unsupported 'crit' header parameter")
            }
        }
    }
}

impl std::error::Error for JwtHeaderError {}

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

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

    // --- Valid headers ---

    #[test]
    fn parse_hs256_header() {
        let json = r#"{"alg":"HS256","typ":"JWT"}"#;
        let b64 = base64url_encode(json.as_bytes());
        let header = JwtHeader::parse(&b64).unwrap();
        assert_eq!(header.alg(), JwtAlgorithm::HS256);
        assert_eq!(header.typ(), Some("JWT"));
        assert_eq!(header.kid(), None);
    }

    #[test]
    fn parse_hs512_header() {
        let json = r#"{"alg":"HS512"}"#;
        let b64 = base64url_encode(json.as_bytes());
        let header = JwtHeader::parse(&b64).unwrap();
        assert_eq!(header.alg(), JwtAlgorithm::HS512);
        assert_eq!(header.typ(), None);
    }

    #[test]
    fn parse_none_algorithm() {
        let json = r#"{"alg":"none"}"#;
        let b64 = base64url_encode(json.as_bytes());
        let header = JwtHeader::parse(&b64).unwrap();
        assert_eq!(header.alg(), JwtAlgorithm::None);
    }

    #[test]
    fn parse_header_with_kid() {
        let json = r#"{"alg":"HS256","typ":"JWT","kid":"key-1"}"#;
        let b64 = base64url_encode(json.as_bytes());
        let header = JwtHeader::parse(&b64).unwrap();
        assert_eq!(header.kid(), Some("key-1"));
    }

    // --- Error cases ---

    #[test]
    fn reject_invalid_base64() {
        let err = JwtHeader::parse("!!!invalid!!!").unwrap_err();
        assert!(err.to_string().contains("base64url"));
    }

    #[test]
    fn reject_invalid_json() {
        let b64 = base64url_encode(b"not json");
        let err = JwtHeader::parse(&b64).unwrap_err();
        assert!(err.to_string().contains("JSON"));
    }

    #[test]
    fn reject_missing_alg() {
        let json = r#"{"typ":"JWT"}"#;
        let b64 = base64url_encode(json.as_bytes());
        let err = JwtHeader::parse(&b64).unwrap_err();
        assert!(err.to_string().contains("alg"));
    }

    #[test]
    fn reject_unsupported_algorithm() {
        // PS256 (RSASSA-PSS) is not implemented under any feature set, so
        // this stays an error whether or not `asym-jwt` is enabled.
        let json = r#"{"alg":"PS256"}"#;
        let b64 = base64url_encode(json.as_bytes());
        let err = JwtHeader::parse(&b64).unwrap_err();
        assert!(err.to_string().contains("unsupported"));
    }

    #[test]
    fn reject_crit_header() {
        // RFC 7515 §4.1.11: a `crit` parameter the recipient does not
        // understand makes the JWS invalid. We support no `crit` extensions.
        let json = r#"{"alg":"HS256","crit":["b64"],"b64":false}"#;
        let b64 = base64url_encode(json.as_bytes());
        let err = JwtHeader::parse(&b64).unwrap_err();
        assert!(err.to_string().contains("crit"));
    }

    #[test]
    fn reject_empty_crit_header() {
        // An empty `crit` array is itself malformed (RFC 7515 §4.1.11) and is
        // rejected before the `alg` is even inspected.
        let json = r#"{"alg":"HS256","crit":[]}"#;
        let b64 = base64url_encode(json.as_bytes());
        let err = JwtHeader::parse(&b64).unwrap_err();
        assert!(err.to_string().contains("crit"));
    }

    #[cfg(feature = "asym-jwt")]
    #[test]
    fn parse_rs256_header() {
        let json = r#"{"alg":"RS256","typ":"JWT","kid":"g-1"}"#;
        let b64 = base64url_encode(json.as_bytes());
        let header = JwtHeader::parse(&b64).unwrap();
        assert_eq!(header.alg(), JwtAlgorithm::RS256);
        assert_eq!(header.kid(), Some("g-1"));
        assert_eq!(JwtAlgorithm::RS256.to_string(), "RS256");
        assert_eq!(JwtAlgorithm::RS512.to_string(), "RS512");
    }

    // --- Display ---

    #[test]
    fn algorithm_display() {
        assert_eq!(JwtAlgorithm::HS256.to_string(), "HS256");
        assert_eq!(JwtAlgorithm::HS512.to_string(), "HS512");
        assert_eq!(JwtAlgorithm::None.to_string(), "none");
    }

    #[test]
    fn error_implements_std_error() {
        let err: Box<dyn std::error::Error> = Box::new(JwtHeaderError {
            kind: JwtHeaderErrorKind::MissingAlgorithm,
        });
        let _ = err.to_string();
    }
}