entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! Shared Base64url → UTF-8 → JSON decode pipeline for JWT segments.
//!
//! Both the header and claims parsers perform the same three-step decode
//! pipeline. This module extracts that logic to a single `inline` helper
//! so changes to the pipeline (e.g. adding size limits) need only happen
//! in one place.

use crate::encoding::base64url_decode;
use crate::json::JsonValue;

// ---------------------------------------------------------------------------
// Size bound
// ---------------------------------------------------------------------------

/// Maximum accepted length, in bytes, of a single Base64url JWT segment.
///
/// Decoding allocates roughly `len * 3 / 4` bytes *before* the JSON parser
/// applies its own 10 MB input ceiling, so an unbounded segment is a
/// resource-amplification surface for a server that runs verification on
/// untrusted tokens. This ceiling caps the pre-JSON allocation. At 16 MiB it
/// is comfortably larger than any segment the JSON layer would accept
/// (`16 MiB` Base64url decodes to ~12 MiB, above the JSON parser's 10 MB
/// limit), so it never rejects a token the pipeline would otherwise have
/// parsed — it only bounds the pathological case.
const MAX_SEGMENT_LEN: usize = 16 * 1024 * 1024;

/// Ceiling on the Base64url **signature** segment.
///
/// The signature is raw bytes, not JSON, so it never reaches
/// [`decode_segment`] and was previously decoded with no bound at all — an
/// attacker could hand a verifier an arbitrarily long third segment and force
/// the allocation before any signature comparison. Every algorithm this crate
/// supports produces at most 512 bytes (RSA-4096), ≈683 Base64url characters;
/// 8 KiB is far above any legitimate value and far below a useful
/// amplification.
pub(super) const MAX_SIGNATURE_SEGMENT_LEN: usize = 8 * 1024;

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

/// Segment decode failure category, mapped to caller-specific error types.
pub(super) enum SegmentDecodeError {
    /// The segment is not valid Base64url.
    InvalidBase64,
    /// The decoded bytes are not valid UTF-8.
    InvalidUtf8,
    /// The decoded UTF-8 is not valid JSON.
    InvalidJson,
}

// ---------------------------------------------------------------------------
// Decode helper
// ---------------------------------------------------------------------------

/// Decodes a Base64url JWT segment into a parsed [`JsonValue`].
///
/// Performs the standard three-step pipeline: length bound → Base64url
/// decode → UTF-8 validation → JSON parsing. A segment longer than
/// [`MAX_SEGMENT_LEN`] is rejected as [`SegmentDecodeError::InvalidBase64`]
/// before any decode allocation occurs.
#[inline]
pub(super) fn decode_segment(segment: &str) -> Result<JsonValue, SegmentDecodeError> {
    if segment.len() > MAX_SEGMENT_LEN {
        return Err(SegmentDecodeError::InvalidBase64);
    }
    let bytes = base64url_decode(segment).map_err(|_| SegmentDecodeError::InvalidBase64)?;
    let json_str = std::str::from_utf8(&bytes).map_err(|_| SegmentDecodeError::InvalidUtf8)?;
    JsonValue::parse(json_str).map_err(|_| SegmentDecodeError::InvalidJson)
}

#[cfg(test)]
mod tests {
    use super::{MAX_SEGMENT_LEN, SegmentDecodeError, decode_segment};

    #[test]
    fn oversized_segment_rejected_before_decode() {
        // One byte over the ceiling is refused as InvalidBase64 without
        // attempting (and allocating for) a Base64url decode.
        let huge = "A".repeat(MAX_SEGMENT_LEN + 1);
        assert!(matches!(
            decode_segment(&huge),
            Err(SegmentDecodeError::InvalidBase64)
        ));
    }

    #[test]
    fn segment_at_limit_is_not_size_rejected() {
        // A segment exactly at the ceiling passes the length gate; it then
        // fails later in the pipeline (invalid JSON), proving the bound did
        // not reject it for size.
        let at_limit = "A".repeat(MAX_SEGMENT_LEN);
        assert!(matches!(
            decode_segment(&at_limit),
            Err(SegmentDecodeError::InvalidUtf8 | SegmentDecodeError::InvalidJson)
        ));
    }
}