pdk-jwt-lib 1.10.0

PDK JWT Library
Documentation
// Copyright (c) 2026, Salesforce, Inc.,
// All rights reserved.
// For full license text, see the LICENSE.txt file

//! JWT claims parsing
//!
//! This module provides functionality to parse JWT tokens and extract claims
//! without signature validation. It handles JWT token structure validation
//! and extracts both header and payload claims.
//!
//! ## Primary types
//!
//! - [`JWTClaimsParser`]: parser for extracting claims from JWT tokens
//!
//! ## Usage
//!
//! This parser should be used after signature validation with [`SignatureValidator`]
//! to ensure the token is trusted before parsing claims.
//!

use std::collections::HashMap;

use base64::{decode_config, URL_SAFE_NO_PAD};
use serde_json::Value;

use crate::{
    error::jwt_error::JWTError,
    model::{
        claims::{JWTClaims, RawClaims, TryFromRawClaims},
        untrusted_token::untrusted_jwt_raw::UntrustedJWT,
    },
};

/// This parser receives an untrusted JWT token (validation should be done previously using
/// a [SignatureValidator](crate::api::SignatureValidator)) and results in the content of the JWT
/// extracted into a [`JWTClaims`]. If parsing fails, it results in a [`JWTError`].
pub struct JWTClaimsParser {}

impl JWTClaimsParser {
    /// Parse a JWT token and return the claims.
    pub fn parse(token: String) -> Result<JWTClaims, JWTError> {
        UntrustedJWT::new(token.clone().as_str()).and_then(|_| {
            let token_parts: Vec<_> = token.splitn(4, '.').collect();

            match &token_parts[..] {
                [_, claims, _] => {
                    let serialized_claims = decode_config(claims, URL_SAFE_NO_PAD)?;
                    let claims = RawClaims::from_json_slice(&serialized_claims)?;

                    JWTClaims::try_from_raw_claims(claims, token)
                }
                _ => Err(JWTError::TokenParseFailed(String::from(
                    "A JWT should have xxxxx.yyyyy.zzzzz or xxxxx.yyyyy. format",
                ))),
            }
        })
    }

    /// Parse the headers of a JWT token and return them as a HashMap.
    pub fn parse_headers(token: String) -> Result<HashMap<String, Value>, JWTError> {
        UntrustedJWT::new(token.as_str()).and_then(|_| {
            let mut parts = token.split('.');
            let jwt_header_b64 = parts.next().ok_or(JWTError::TokenParseFailed(String::from(
                "Error parsing token header",
            )))?;

            Self::decode_base64_to_hashmap(jwt_header_b64)
        })
    }

    fn decode_base64_to_hashmap(base64: &str) -> Result<HashMap<String, Value>, JWTError> {
        let jwt_header_vec = decode_config(base64, URL_SAFE_NO_PAD)
            .map_err(|_| JWTError::TokenParseFailed(String::from("Error parsing token header")))?;

        let headers: HashMap<String, Value> = serde_json::from_slice(&jwt_header_vec)?;

        Ok(headers)
    }
}

#[cfg(test)]
mod tests {
    use crate::error::jwt_error::JWTErrorEnumKind;
    use crate::validator::signature_validator::tests::assert_error_kind;

    use super::JWTClaimsParser;

    #[test]
    fn parse_fails_when_token_not_sent() {
        let token = "";

        let jwt_claims = JWTClaimsParser::parse(token.to_string());

        assert_error_kind(&jwt_claims, JWTErrorEnumKind::TokenParseFailed);
    }

    #[test]
    fn unparseable_token_is_rejected() {
        let token = "WhatAmI?AnInvalidToken!";

        let jwt_claims = JWTClaimsParser::parse(token.to_string());

        assert_error_kind(&jwt_claims, JWTErrorEnumKind::TokenParseFailed);
    }

    #[test]
    fn parse_doesnt_fail_on_invalid_signature() {
        let token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.e30.Ba3m9DcgHHeLKAh7KthRASIDNm7G_nZDkVJ_gh1wXQ3IJFS5moG_qohHl8ZoJI5WZOSSklnFWzEYPTbFtgeUzg";

        let jwt_claims = JWTClaimsParser::parse(token.to_string());

        assert!(jwt_claims.is_ok());
    }

    #[test]
    fn parse_doesnt_fail_with_exp_in_float_and_result_is_correct() {
        let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3JlZGFjdGVkLmFuei5jb20iLCJhdWQiOiJyZWRhY3RlZC5hbnouY29tL2VtYWlsQWRkcmVzcy1yZWRhY3RlZEBhbnouY29tIiwic3ViIjoicmVkYWN0ZWQuYW56LmNvbS9lbWFpbEFkZHJlc3MtcmVkYWN0ZWRAYW56LmNvbSIsImV4cCI6MTY5Mzk4ODY1Ni41OTcsInNjb3BlcyI6WyJyZWRhY3RlZDEiLCJyZWRhY3RlZCJdLCJhbXIiOlsicmVkYWN0ZWQiXSwiYWNyIjoicmVkYWN0ZWQifQ.EswBSyD9976PVC6o4tWwsT5rGTf2RJcvL7hdpcTwXUo";

        let jwt_claims = JWTClaimsParser::parse(token.to_string()).unwrap();
        assert_eq!(jwt_claims.get_header("alg").unwrap(), "HS256");
        assert_eq!(
            jwt_claims.expiration().unwrap().to_string(),
            "2023-09-06 08:24:16 UTC"
        );
    }

    #[test]
    fn parse_rejects_string_typed_exp() {
        // exp emitted as a JSON string (non-conformant with RFC 7519, which
        // requires a NumericDate). jwt-compact rejected this outright; the
        // FIPS-safe RawClaims path must too, rather than silently dropping the
        // claim and treating the token as non-expiring.
        // Payload: {"iss":"https://issuer.example","exp":"1693988656","sub":"s"}
        let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiZXhwIjoiMTY5Mzk4ODY1NiIsInN1YiI6InMifQ.sig";

        let jwt_claims = JWTClaimsParser::parse(token.to_string());

        assert_error_kind(&jwt_claims, JWTErrorEnumKind::TokenParseFailed);
    }

    #[test]
    fn parse_rejects_string_typed_nbf() {
        // nbf as a JSON string must be rejected, not silently dropped (which
        // would remove the not-before constraint).
        // Payload: {"iss":"https://issuer.example","nbf":"1693988656"}
        let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwibmJmIjoiMTY5Mzk4ODY1NiJ9.sig";

        let jwt_claims = JWTClaimsParser::parse(token.to_string());

        assert_error_kind(&jwt_claims, JWTErrorEnumKind::TokenParseFailed);
    }

    #[test]
    fn parse_rejects_negative_exp() {
        // exp as a negative NumericDate is non-conformant with RFC 7519 (which
        // requires a non-negative number of seconds). Must reject, not truncate.
        // Payload: {"iss":"https://issuer.example","exp":-1,"sub":"s"}
        let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiZXhwIjotMSwic3ViIjoicyJ9.sig";

        let jwt_claims = JWTClaimsParser::parse(token.to_string());

        assert_error_kind(&jwt_claims, JWTErrorEnumKind::TokenParseFailed);
    }

    #[test]
    fn parse_rejects_negative_nbf() {
        // A negative nbf is always "in the past", so silently accepting it would
        // disable the not-before constraint. Must reject.
        // Payload: {"iss":"https://issuer.example","nbf":-1}
        let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwibmJmIjotMX0.sig";

        let jwt_claims = JWTClaimsParser::parse(token.to_string());

        assert_error_kind(&jwt_claims, JWTErrorEnumKind::TokenParseFailed);
    }

    #[test]
    fn parse_accepts_fractional_nbf() {
        // A fractional (but non-negative) NumericDate is valid JSON per RFC 7519;
        // the sub-second part is truncated to whole seconds.
        // Payload: {"iss":"https://issuer.example","nbf":1693988656.597}
        let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwibmJmIjoxNjkzOTg4NjU2LjU5N30.sig";

        let jwt_claims = JWTClaimsParser::parse(token.to_string()).unwrap();
        assert_eq!(
            jwt_claims.not_before().unwrap().to_string(),
            "2023-09-06 08:24:16 UTC"
        );
    }

    #[test]
    fn parse_doesnt_fail_on_missing_signature() {
        let token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.e30.";

        let jwt_claims = JWTClaimsParser::parse(token.to_string());

        assert!(jwt_claims.is_ok());
    }

    #[test]
    fn parse_fails_on_missing_signature_with_no_ending_point() {
        let token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.e30";

        let jwt_claims = JWTClaimsParser::parse(token.to_string());

        assert_error_kind(&jwt_claims, JWTErrorEnumKind::TokenParseFailed);
    }

    #[test]
    fn headers_unparseable_token_is_rejected() {
        let token = "WhatAmI?AnInvalidToken!";
        let headers = JWTClaimsParser::parse_headers(token.to_string());

        assert_error_kind(&headers, JWTErrorEnumKind::TokenParseFailed);
    }

    #[test]
    fn headers_parse_fails_when_token_not_sent() {
        let token = "";
        let headers = JWTClaimsParser::parse_headers(token.to_string());

        assert_error_kind(&headers, JWTErrorEnumKind::TokenParseFailed);
    }

    #[test]
    fn headers_parse_successful() {
        let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3JlZGFjdGVkLmFuei5jb20iLCJhdWQiOiJyZWRhY3RlZC5hbnouY29tL2VtYWlsQWRkcmVzcy1yZWRhY3RlZEBhbnouY29tIiwic3ViIjoicmVkYWN0ZWQuYW56LmNvbS9lbWFpbEFkZHJlc3MtcmVkYWN0ZWRAYW56LmNvbSIsImV4cCI6MTY5Mzk4ODY1Ni41OTcsInNjb3BlcyI6WyJyZWRhY3RlZDEiLCJyZWRhY3RlZCJdLCJhbXIiOlsicmVkYWN0ZWQiXSwiYWNyIjoicmVkYWN0ZWQifQ.EswBSyD9976PVC6o4tWwsT5rGTf2RJcvL7hdpcTwXUo";
        let headers = JWTClaimsParser::parse_headers(token.to_string());

        assert!(headers.is_ok());
        assert_eq!(
            headers.unwrap().get("alg").unwrap().as_str().unwrap(),
            "HS256"
        );
    }
}