lys-pass 0.3.4

Lys pass verification, live permission checks and product draft execution
Documentation
#![cfg(test)]
//! Signed passes are trusted only for their issuer, audience and lifetime.

use lys_pass::{Decision, KeySet, VerifiedPass};

type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;

/// The token a fixture file holds: its one line, without the file's newline.
fn fixture(name: &str) -> Result<String> {
    Ok(std::fs::read_to_string(format!(
        "{}/fixtures/passes/{name}.jwt",
        env!("CARGO_MANIFEST_DIR")
    ))?
    .trim_end()
    .to_owned())
}

fn keys() -> Result<KeySet> {
    Ok(KeySet::from_json(include_str!(
        "../fixtures/passes/keys.json"
    ))?)
}

#[test]
fn parent_reach_is_explicit_and_restricted_child_is_absent() -> Result {
    let token = fixture("valid")?;
    let keys = keys()?;
    let pass = VerifiedPass::verify(&token, &keys, "https://issuer.example", "sample", 100)?;
    assert!(matches!(
        pass.evaluate("sample.file", "child", "read", 100)?,
        Decision::Allowed {
            grant: "parent-grant"
        }
    ));
    assert_eq!(
        pass.evaluate("sample.file", "restricted", "read", 100)?,
        Decision::Refused
    );
    assert_eq!(
        pass.evaluate("sample.file", "child/unplaced", "read", 100)?,
        Decision::Refused
    );
    assert_eq!(pass.rights_in_prefix("sample.file").count(), 2);
    assert_eq!(pass.rights_in_prefix("sample.fi").count(), 0);
    Ok(())
}

#[test]
fn held_modes_are_never_hot_authority() -> Result {
    let token = fixture("valid")?;
    let keys = keys()?;
    let pass = VerifiedPass::verify(&token, &keys, "https://issuer.example", "sample", 100)?;
    assert!(matches!(
        pass.evaluate("sample.file", "held", "write", 100)?,
        Decision::Held { .. }
    ));
    assert!(!pass.hot_allowed("sample.file", "held", "write", 100)?);
    assert!(pass.hot_allowed("sample.file", "child", "read", 100)?);
    Ok(())
}

#[test]
fn hostile_passes_are_refused_by_name() -> Result {
    let keys = keys()?;
    for (name, expected) in [
        ("wrong-audience", "wrong_audience"),
        ("expired", "pass_expired"),
        ("unpublished-key", "unpublished_key"),
        ("tampered", "signature_refused"),
        ("future", "pass_not_yet_valid"),
        ("other-app", "rights_outside_audience"),
    ] {
        let token = fixture(name)?;
        let error = VerifiedPass::verify(&token, &keys, "https://issuer.example", "sample", 100)
            .err()
            .ok_or("hostile pass was accepted")?;
        assert_eq!(error.name(), expected, "{name}");
    }
    Ok(())
}

#[test]
fn previously_verified_pass_is_refused_at_expiry() -> Result {
    let token = fixture("valid")?;
    let keys = keys()?;
    let pass = VerifiedPass::verify(&token, &keys, "https://issuer.example", "sample", 100)?;
    assert_eq!(
        pass.evaluate("sample.file", "child", "read", 200)
            .err()
            .ok_or("expired pass accepted")?
            .name(),
        "pass_expired"
    );
    Ok(())
}

#[test]
fn truncated_rights_are_never_partial_authority() -> Result {
    let token = fixture("truncated")?;
    let keys = keys()?;
    let pass = VerifiedPass::verify(&token, &keys, "https://issuer.example", "sample", 100)?;
    assert_eq!(
        pass.evaluate("sample.file", "child", "read", 100)
            .err()
            .ok_or("truncated pass accepted")?
            .name(),
        "rights_truncated"
    );
    Ok(())
}

#[test]
fn connector_keeps_responsible_person_and_removed_key_refuses() -> Result {
    let token = fixture("connector")?;
    let keys = keys()?;
    let pass = VerifiedPass::verify(&token, &keys, "https://issuer.example", "sample", 100)?;
    assert_eq!(pass.claims().holder.responsible.as_deref(), Some("person"));
    let retired = KeySet::from_json("{\"keys\":[]}")?;
    assert_eq!(
        VerifiedPass::verify(&token, &retired, "https://issuer.example", "sample", 100)
            .err()
            .ok_or("retired key accepted")?
            .name(),
        "unpublished_key"
    );
    Ok(())
}

#[test]
fn malformed_keysets_and_unsigned_headers_are_refused() -> Result {
    assert!(
        KeySet::from_json(
            "{\"keys\":[{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"kid\":\"fixture\",\"x\":\"AA\"}]}"
        )
        .is_err()
    );
    let keys = keys()?;
    assert!(
        VerifiedPass::verify(
            "eyJhbGciOiJub25lIiwidHlwIjoiSldUIiwia2lkIjoiZml4dHVyZSJ9.e30.AA",
            &keys,
            "https://issuer.example",
            "sample",
            100
        )
        .is_err()
    );
    assert!(
        VerifiedPass::verify(
            "one.two.three.four",
            &keys,
            "https://issuer.example",
            "sample",
            100
        )
        .is_err()
    );
    Ok(())
}

#[test]
fn wrong_issuer_never_trusts_a_pass_from_another_authority() -> Result {
    let token = fixture("valid")?;
    let keys = keys()?;
    assert_eq!(
        VerifiedPass::verify(&token, &keys, "https://other.example", "sample", 100)
            .err()
            .ok_or("wrong issuer accepted")?
            .name(),
        "wrong_issuer"
    );
    Ok(())
}

#[test]
fn older_passes_omit_agent_keys_and_new_claims_round_trip() -> Result {
    let pass = VerifiedPass::verify(
        &fixture("valid")?,
        &keys()?,
        "https://issuer.example",
        "sample",
        100,
    )?;
    assert!(pass.claims().agent_key_thumbprints.is_empty());
    let mut value = serde_json::to_value(pass.claims())?;
    assert!(value.get("agent_key_thumbprints").is_none());
    value["agent_key_thumbprints"] = serde_json::json!(["signed-key-thumbprint"]);
    let claims: lys_pass::Claims = serde_json::from_value(value)?;
    assert_eq!(claims.agent_key_thumbprints, ["signed-key-thumbprint"]);
    assert_eq!(
        serde_json::to_value(claims)?["agent_key_thumbprints"],
        serde_json::json!(["signed-key-thumbprint"])
    );
    Ok(())
}