entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! `private_key_jwt` client-authentication assertion verification
//! (RFC 7521 / RFC 7523 §3, OIDC Core §9).
//!
//! A confidential client can authenticate at the token endpoint by signing a
//! short-lived JWT ("client assertion") with its own private key instead of
//! presenting a shared secret. The server holds only the client's *public*
//! key(s) and verifies the assertion — no shared secret ever crosses the
//! wire, and key rotation is a public-key swap.
//!
//! [`verify_client_assertion`] is storage-free: the caller loads the client's
//! registered public keys and passes them in; this function verifies the
//! signature against any one of them and checks the required claims. Replay
//! defence via the optional `jti` is the caller's concern (it needs storage);
//! the parsed claims are returned so the caller can enforce it.
//!
//! # Checks (in order)
//!
//! 1. **Signature** — the assertion verifies against at least one of the
//!    client's registered public keys (rejecting `alg: none`, enforced by
//!    [`verify_jwt_asymmetric`](crate::jwt::verify_jwt_asymmetric)).
//! 2. **Issuer / subject** — `iss` and `sub` both equal the `client_id`
//!    (RFC 7523 §3: the assertion is issued by, and about, the client).
//! 3. **Audience** — `aud` contains the token endpoint (the server's
//!    identifier), so an assertion minted for one server can't be replayed at
//!    another.
//! 4. **Expiry** — `exp` is present and in the future.
//! 5. **Not-before** — if `nbf` is present, it is not in the future.
//!
//! Checks 4 and 5 allow [`CLOCK_SKEW_LEEWAY_SECS`] of drift, matching the
//! ID-token validator's default; without it ordinary client/IdP clock drift
//! intermittently rejects valid authentication as `invalid_client`.

use core::fmt;

use crate::jwt::{AsymmetricVerifyingKey, JwtClaims, verify_jwt_asymmetric};
use crate::util::timestamp::Timestamp;

/// Clock-skew tolerance applied to the assertion's `exp` / `nbf`.
///
/// Matches `IdTokenValidator`'s default so both JWT-verification paths in the
/// crate treat drift identically. It only ever *widens* the accepted window;
/// the caller's separate forward-lifetime cap is what bounds replay.
pub const CLOCK_SKEW_LEEWAY_SECS: u64 = 60;

/// The reason a client assertion is rejected.
#[doc(alias = "client_assertion_error")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ClientAssertionDenied {
    /// No candidate key was supplied to verify against.
    NoKeys,
    /// The assertion did not verify against any registered key (bad
    /// signature, wrong/`none` algorithm, or malformed JWT).
    SignatureInvalid,
    /// `iss` or `sub` is absent or is not the `client_id`.
    IssuerMismatch,
    /// `aud` does not contain the expected token-endpoint audience.
    AudienceMismatch,
    /// `exp` is absent.
    MissingExpiry,
    /// `exp` is in the past.
    Expired,
    /// `nbf` is in the future.
    NotYetValid,
}

impl ClientAssertionDenied {
    /// A short, stable diagnostic string.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::NoKeys => "no registered keys to verify against",
            Self::SignatureInvalid => "assertion signature is invalid",
            Self::IssuerMismatch => "iss/sub does not match client_id",
            Self::AudienceMismatch => "aud does not match the token endpoint",
            Self::MissingExpiry => "assertion is missing exp",
            Self::Expired => "assertion has expired",
            Self::NotYetValid => "assertion is not yet valid (nbf in the future)",
        }
    }
}

impl fmt::Display for ClientAssertionDenied {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "client assertion rejected: {}", self.as_str())
    }
}

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

/// A `private_key_jwt` client assertion to verify.
#[derive(Debug, Clone, Copy)]
pub struct ClientAssertion<'a> {
    /// The compact JWT assertion (`client_assertion` form parameter).
    pub jwt: &'a str,
    /// The `client_id` the assertion must be issued by and about.
    pub client_id: &'a str,
    /// The expected audience — the token endpoint URL (or the issuer
    /// identifier the deployment publishes for it).
    pub audience: &'a str,
}

/// Verifies a `private_key_jwt` client assertion against the client's
/// registered public keys as of `now`, returning the parsed claims on
/// success so the caller can enforce `jti` replay protection.
///
/// # Errors
///
/// Returns [`ClientAssertionDenied`] for the first failing check.
pub fn verify_client_assertion(
    assertion: &ClientAssertion<'_>,
    keys: &[AsymmetricVerifyingKey],
    now: Timestamp,
) -> Result<JwtClaims, ClientAssertionDenied> {
    use ClientAssertionDenied as D;

    if keys.is_empty() {
        return Err(D::NoKeys);
    }

    // 1. Signature: accept if ANY registered key verifies it. A key whose
    //    algorithm doesn't match the header (or that simply didn't sign this
    //    assertion) fails and we try the next — so a client may hold several
    //    keys (e.g. across a rotation) and any live one authenticates.
    let claims = keys
        .iter()
        .find_map(|key| verify_jwt_asymmetric(assertion.jwt, key).ok())
        .map(|(_, claims)| claims)
        .ok_or(D::SignatureInvalid)?;

    // 2. iss == sub == client_id (RFC 7523 §3).
    if claims.iss() != Some(assertion.client_id) || claims.sub() != Some(assertion.client_id) {
        return Err(D::IssuerMismatch);
    }

    // 3. Audience binds the assertion to THIS token endpoint.
    if !claims.validate_aud(assertion.audience) {
        return Err(D::AudienceMismatch);
    }

    // 4. Expiry is mandatory and must be in the future, with the standard
    //    clock-skew allowance. Without leeway, ordinary drift between the
    //    client's clock and the IdP's intermittently rejects a perfectly valid
    //    assertion as `invalid_client` — the same leeway ID-token validation
    //    already applies (`IdTokenValidator`'s 60s default).
    //
    //    Compared as EPOCH SECONDS, never by constructing a `Timestamp` from a
    //    claim value: `JwtClaims` deliberately maps an unusable exp/nbf to the
    //    fail-closed sentinels 0 / u64::MAX, and feeding u64::MAX through
    //    `Timestamp::from_unix_secs` trips `civil_from_days`' year assertion
    //    and panics in debug builds. The sentinels still reject here — they
    //    just do so by comparison instead of by calendar conversion.
    let now_secs = now.unix_epoch_secs();
    let exp = claims.exp().ok_or(D::MissingExpiry)?;
    if exp.saturating_add(CLOCK_SKEW_LEEWAY_SECS) <= now_secs {
        return Err(D::Expired);
    }

    // 5. Not-before, when present, must not be in the future (same leeway).
    if let Some(nbf) = claims.nbf() {
        if nbf.saturating_sub(CLOCK_SKEW_LEEWAY_SECS) > now_secs {
            return Err(D::NotYetValid);
        }
    }

    Ok(claims)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::jwt::{AsymmetricAlgorithm, AsymmetricSigningKey, JwtEncoder};

    const CLIENT_ID: &str = "svc_app";
    const AUD: &str = "https://auth.example.com/t/entropy/api/oidc/token";
    const NOW: u64 = 1_700_000_000;

    fn key() -> AsymmetricSigningKey {
        AsymmetricSigningKey::generate(AsymmetricAlgorithm::EdDsa).unwrap()
    }

    fn assertion_jwt(
        key: &AsymmetricSigningKey,
        iss: &str,
        sub: &str,
        aud: &str,
        exp: u64,
        nbf: Option<u64>,
    ) -> String {
        let mut enc = JwtEncoder::new()
            .issuer(iss)
            .subject(sub)
            .audience(aud)
            .expiration(exp)
            .issued_at(NOW);
        if let Some(nbf) = nbf {
            enc = enc.custom_claim("nbf", nbf);
        }
        enc.key_id(key.kid()).sign_asymmetric(key)
    }

    /// Build an assertion with RAW claim JSON, so a test can inject a
    /// malformed (non-numeric) exp/nbf that the typed encoder cannot express.
    fn assertion_jwt_raw(key: &AsymmetricSigningKey, extra_claims: &str) -> String {
        use crate::encoding::base64url_encode;
        let header = base64url_encode(
            format!(
                r#"{{"alg":"{}","typ":"JWT","kid":"{}"}}"#,
                key.algorithm().as_str(),
                key.kid()
            )
            .as_bytes(),
        );
        let payload = base64url_encode(
            format!(r#"{{"iss":"{CLIENT_ID}","sub":"{CLIENT_ID}","aud":"{AUD}",{extra_claims}}}"#)
                .as_bytes(),
        );
        let signing_input = format!("{header}.{payload}");
        let sig = base64url_encode(&key.sign(signing_input.as_bytes()));
        format!("{signing_input}.{sig}")
    }

    fn verify(
        jwt: &str,
        keys: &[AsymmetricVerifyingKey],
    ) -> Result<JwtClaims, ClientAssertionDenied> {
        verify_client_assertion(
            &ClientAssertion {
                jwt,
                client_id: CLIENT_ID,
                audience: AUD,
            },
            keys,
            Timestamp::from_unix_secs(NOW),
        )
    }

    #[test]
    fn accepts_valid_assertion() {
        let k = key();
        let jwt = assertion_jwt(&k, CLIENT_ID, CLIENT_ID, AUD, NOW + 300, None);
        assert!(verify(&jwt, &[k.verifying_key().clone()]).is_ok());
    }

    #[test]
    fn accepts_when_one_of_several_keys_matches() {
        let signing = key();
        let other = key();
        let jwt = assertion_jwt(&signing, CLIENT_ID, CLIENT_ID, AUD, NOW + 300, None);
        let keys = [
            other.verifying_key().clone(),
            signing.verifying_key().clone(),
        ];
        assert!(verify(&jwt, &keys).is_ok());
    }

    #[test]
    fn rejects_no_keys() {
        let k = key();
        let jwt = assertion_jwt(&k, CLIENT_ID, CLIENT_ID, AUD, NOW + 300, None);
        assert_eq!(
            verify(&jwt, &[]).unwrap_err(),
            ClientAssertionDenied::NoKeys
        );
    }

    #[test]
    fn rejects_wrong_key() {
        let signing = key();
        let wrong = key();
        let jwt = assertion_jwt(&signing, CLIENT_ID, CLIENT_ID, AUD, NOW + 300, None);
        assert_eq!(
            verify(&jwt, &[wrong.verifying_key().clone()]).unwrap_err(),
            ClientAssertionDenied::SignatureInvalid
        );
    }

    #[test]
    fn rejects_issuer_mismatch() {
        let k = key();
        let jwt = assertion_jwt(&k, "someone_else", "someone_else", AUD, NOW + 300, None);
        assert_eq!(
            verify(&jwt, &[k.verifying_key().clone()]).unwrap_err(),
            ClientAssertionDenied::IssuerMismatch
        );
    }

    #[test]
    fn rejects_audience_mismatch() {
        let k = key();
        let jwt = assertion_jwt(
            &k,
            CLIENT_ID,
            CLIENT_ID,
            "https://evil.example",
            NOW + 300,
            None,
        );
        assert_eq!(
            verify(&jwt, &[k.verifying_key().clone()]).unwrap_err(),
            ClientAssertionDenied::AudienceMismatch
        );
    }

    #[test]
    fn rejects_expired() {
        // Past the clock-skew leeway, not merely one second stale.
        let k = key();
        let jwt = assertion_jwt(
            &k,
            CLIENT_ID,
            CLIENT_ID,
            AUD,
            NOW - CLOCK_SKEW_LEEWAY_SECS - 1,
            None,
        );
        assert_eq!(
            verify(&jwt, &[k.verifying_key().clone()]).unwrap_err(),
            ClientAssertionDenied::Expired
        );
    }

    /// A malformed exp/nbf becomes a fail-closed sentinel (0 / `u64::MAX`).
    /// Those must REJECT, not panic — converting `u64::MAX` to a calendar date
    /// trips a debug assertion.
    #[test]
    fn sentinel_timestamps_reject_without_panicking() {
        let k = key();
        // Non-numeric exp -> sentinel 0 -> already expired.
        let jwt = assertion_jwt_raw(&k, r#""exp":"not-a-number""#);
        assert_eq!(
            verify(&jwt, &[k.verifying_key().clone()]).unwrap_err(),
            ClientAssertionDenied::Expired
        );
        // Non-numeric nbf -> sentinel u64::MAX -> not yet valid.
        let jwt = assertion_jwt_raw(&k, &format!(r#""exp":{},"nbf":true"#, NOW + 300));
        assert_eq!(
            verify(&jwt, &[k.verifying_key().clone()]).unwrap_err(),
            ClientAssertionDenied::NotYetValid
        );
    }

    #[test]
    fn accepts_within_clock_skew_leeway() {
        // Ordinary client/IdP drift must not read as `invalid_client`.
        let k = key();
        let jwt = assertion_jwt(&k, CLIENT_ID, CLIENT_ID, AUD, NOW - 1, None);
        assert!(verify(&jwt, &[k.verifying_key().clone()]).is_ok());
        // …and an `nbf` a few seconds ahead is likewise tolerated.
        let jwt = assertion_jwt(&k, CLIENT_ID, CLIENT_ID, AUD, NOW + 300, Some(NOW + 5));
        assert!(verify(&jwt, &[k.verifying_key().clone()]).is_ok());
    }

    #[test]
    fn rejects_not_yet_valid() {
        let k = key();
        let jwt = assertion_jwt(&k, CLIENT_ID, CLIENT_ID, AUD, NOW + 300, Some(NOW + 100));
        assert_eq!(
            verify(&jwt, &[k.verifying_key().clone()]).unwrap_err(),
            ClientAssertionDenied::NotYetValid
        );
    }
}