orbit-auth 0.5.2

Fleet-backed encrypted tokens, cached principals, sessions and atomic refresh/replay.
Documentation
use std::collections::{BTreeSet, HashSet};
use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex};
use std::time::Duration;

use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use orbit_auth::*;

fn authority(
    realm: &str,
    issuer: &str,
    active: u32,
    keys: &[(u32, u8)]
) -> Authority {
    Authority::new(
        realm,
        issuer,
        Keyring::new(
            active,
            keys.iter().map(|(id, byte)| (*id, SecretKey::from_bytes([*byte; 32])))
        )
        .unwrap()
    )
    .unwrap()
}
fn standard() -> Authority {
    authority("prod", "login", 1, &[(1, 7)])
}
fn policy(
    audience: &str,
    purpose: Purpose
) -> Validation {
    Validation {
        audience: Audience::new(audience).unwrap(),
        purpose,
        required_capabilities: BTreeSet::new()
    }
}
fn claims() -> Claims {
    Claims {
        subject: "user:42".into(),
        audience: Audience::new("api").unwrap(),
        purpose: Purpose::Access,
        capabilities: BTreeSet::from([Capability::new("read").unwrap()]),
        issued_at: 100,
        not_before: 110,
        expires_at: 200
    }
}
fn cache() -> MemoryCache {
    MemoryCache::new(NonZeroUsize::new(8).unwrap())
}

#[test]
fn roundtrip_all_purposes_and_randomness() {
    let issuer = standard();
    let peer = standard();
    for purpose in
        [Purpose::Session, Purpose::Refresh, Purpose::Access, Purpose::WebSocket, Purpose::Internal]
    {
        let mut claims = claims();
        claims.purpose = purpose;
        let first = issuer.issue(claims.clone()).unwrap();
        let second = issuer.issue(claims.clone()).unwrap();
        assert_ne!(first.expose(), second.expose());
        let validator = peer.validator(policy("api", purpose));
        let principal = validator.validate(first.expose(), 110, &AllowReusable).unwrap();
        assert_eq!(principal.claims(), &claims);
        assert_eq!(principal.subject(), "user:42");
        assert_eq!(principal.realm(), "prod");
        assert_eq!(principal.issuer(), "login");
        assert!(principal.has_capability(&Capability::new("read").unwrap()));
        assert_ne!(
            principal.jti(),
            validator.validate(second.expose(), 110, &AllowReusable).unwrap().jti()
        );
        assert!(!first.expose().contains("user:42"));
        assert_eq!(format!("{first:?}"), "Token([REDACTED])");
    }
    assert_eq!(format!("{:?}", SecretKey::from_bytes([7; 32])), "SecretKey([REDACTED])");
}

#[test]
fn time_and_capabilities_are_enforced() {
    let authority = standard();
    let token = authority.issue(claims()).unwrap();
    let validator = authority.validator(policy("api", Purpose::Access));
    assert_eq!(validator.validate(token.expose(), 109, &AllowReusable), Err(Error::NotYetValid));
    assert!(validator.validate(token.expose(), 110, &AllowReusable).is_ok());
    assert!(validator.validate(token.expose(), 199, &AllowReusable).is_ok());
    assert_eq!(validator.validate(token.expose(), 200, &AllowReusable), Err(Error::Expired));
    let mut required = policy("api", Purpose::Access);
    required.required_capabilities.insert(Capability::new("write").unwrap());
    assert_eq!(
        authority.validator(required).validate(token.expose(), 110, &AllowReusable),
        Err(Error::MissingCapability)
    );
}

#[test]
fn tampering_every_wire_byte_and_malformed_tokens_fail() {
    let authority = standard();
    let token = authority.issue(claims()).unwrap();
    let validator = authority.validator(policy("api", Purpose::Access));
    let wire = URL_SAFE_NO_PAD.decode(token.expose().strip_prefix("oa1.").unwrap()).unwrap();
    for index in 0..wire.len() {
        let mut corrupted = wire.clone();
        corrupted[index] ^= 1;
        let changed = format!("oa1.{}", URL_SAFE_NO_PAD.encode(corrupted));
        assert_eq!(validator.validate(&changed, 110, &AllowReusable), Err(Error::InvalidToken));
    }
    for malformed in ["", "oa2.AAAA", "oa1.", "oa1.***", "oa1.AAA=", "oa1.AAAA", "bearer abc"] {
        assert_eq!(validator.validate(malformed, 110, &AllowReusable), Err(Error::InvalidToken));
    }
    for length in 0..wire.len() {
        let truncated = format!("oa1.{}", URL_SAFE_NO_PAD.encode(&wire[..length]));
        assert!(validator.validate(&truncated, 110, &AllowReusable).is_err());
    }
    assert_eq!(
        validator.validate(&"a".repeat(MAX_TOKEN_LEN + 1), 110, &AllowReusable),
        Err(Error::TokenTooLarge)
    );
}

#[test]
fn trust_contexts_cannot_share_tokens_or_warm_cache_entries() {
    let original = standard();
    let token = original.issue(claims()).unwrap();
    let cache = cache();
    let validator = original.validator(policy("api", Purpose::Access));
    let ttl = Duration::from_secs(90);
    validator.validate_cached(token.expose(), 110, &cache, ttl, &AllowReusable).unwrap();
    let original_key = validator.cache_key(token.expose()).unwrap();
    for other in [
        authority("stage", "login", 1, &[(1, 7)]),
        authority("prod", "other-issuer", 1, &[(1, 7)]),
        authority("prod", "login", 1, &[(1, 8)])
    ] {
        let v = other.validator(policy("api", Purpose::Access));
        assert_ne!(original_key, v.cache_key(token.expose()).unwrap());
        assert_eq!(
            v.validate_cached(token.expose(), 110, &cache, ttl, &AllowReusable),
            Err(Error::InvalidToken)
        );
    }
    for different in [policy("other-api", Purpose::Access), policy("api", Purpose::Internal)] {
        let v = original.validator(different);
        assert_ne!(original_key, v.cache_key(token.expose()).unwrap());
        assert_eq!(
            v.validate_cached(token.expose(), 110, &cache, ttl, &AllowReusable),
            Err(Error::InvalidToken)
        );
    }
    let mut different = policy("api", Purpose::Access);
    different.required_capabilities.insert(Capability::new("write").unwrap());
    let v = original.validator(different);
    assert_ne!(original_key, v.cache_key(token.expose()).unwrap());
    assert_eq!(
        v.validate_cached(token.expose(), 110, &cache, ttl, &AllowReusable),
        Err(Error::MissingCapability)
    );
}

#[test]
fn cache_reuses_decoded_principal_and_obeys_ttl_expiry_and_clock_rollback() {
    let authority = standard();
    let token = authority.issue(claims()).unwrap();
    let validator = authority.validator(policy("api", Purpose::Access));
    let cache = cache();
    let ttl = Duration::from_secs(30);
    let first =
        validator.validate_cached(token.expose(), 110, &cache, ttl, &AllowReusable).unwrap();
    let second =
        validator.validate_cached(token.expose(), 139, &cache, ttl, &AllowReusable).unwrap();
    assert!(Arc::ptr_eq(&first, &second));
    let after_ttl =
        validator.validate_cached(token.expose(), 140, &cache, ttl, &AllowReusable).unwrap();
    assert!(!Arc::ptr_eq(&first, &after_ttl));
    let shorter = validator
        .validate_cached(token.expose(), 145, &cache, Duration::from_secs(5), &AllowReusable)
        .unwrap();
    assert!(!Arc::ptr_eq(&after_ttl, &shorter));
    let rollback =
        validator.validate_cached(token.expose(), 144, &cache, ttl, &AllowReusable).unwrap();
    assert!(!Arc::ptr_eq(&shorter, &rollback));
    assert_eq!(
        validator.validate_cached(token.expose(), 109, &cache, ttl, &AllowReusable),
        Err(Error::NotYetValid)
    );
    cache.clear();
    validator.validate_cached(token.expose(), 190, &cache, Duration::MAX, &AllowReusable).unwrap();
    let entry = cache.get(&validator.cache_key(token.expose()).unwrap()).unwrap();
    assert_eq!(entry.valid_until(), 200);
    assert_eq!(
        validator.validate_cached(token.expose(), 200, &cache, ttl, &AllowReusable),
        Err(Error::Expired)
    );
}

#[test]
fn rotation_retains_old_tokens_but_removal_invalidates_warm_cache() {
    let old = standard();
    let rotated = authority("prod", "login", 2, &[(1, 7), (2, 9)]);
    let removed = authority("prod", "login", 2, &[(2, 9)]);
    let token = old.issue(claims()).unwrap();
    let rotated_validator = rotated.validator(policy("api", Purpose::Access));
    let cache = cache();
    let ttl = Duration::from_secs(80);
    rotated_validator.validate_cached(token.expose(), 110, &cache, ttl, &AllowReusable).unwrap();
    let removed_validator = removed.validator(policy("api", Purpose::Access));
    assert_ne!(
        rotated_validator.cache_key(token.expose()).unwrap(),
        removed_validator.cache_key(token.expose()).unwrap()
    );
    assert_eq!(
        removed_validator.validate_cached(token.expose(), 110, &cache, ttl, &AllowReusable),
        Err(Error::InvalidToken)
    );
    let fresh = rotated.issue(claims()).unwrap();
    assert!(removed_validator.validate(fresh.expose(), 110, &AllowReusable).is_ok());
    assert_eq!(
        old.validator(policy("api", Purpose::Access)).validate(fresh.expose(), 110, &AllowReusable),
        Err(Error::InvalidToken)
    );
}

struct Once(Mutex<HashSet<(String, String, TokenId)>>);
impl ValidationHook for Once {
    fn check(
        &self,
        p: &Principal,
        _: u64
    ) -> Result<()> {
        if self.0.lock().unwrap().insert((p.realm().into(), p.issuer().into(), p.jti())) {
            Ok(())
        } else {
            Err(Error::Replayed)
        }
    }
}
struct Reject(Error);
impl ValidationHook for Reject {
    fn check(
        &self,
        _: &Principal,
        _: u64
    ) -> Result<()> {
        match self.0 {
            Error::Revoked => Err(Error::Revoked),
            _ => Err(Error::PolicyUnavailable)
        }
    }
}

#[test]
fn replay_revocation_and_store_failures_run_on_cache_hits() {
    let authority = standard();
    let validator = authority.validator(policy("api", Purpose::Access));
    let token = authority.issue(claims()).unwrap();
    let cache = cache();
    let ttl = Duration::from_secs(90);
    let once = Once(Mutex::new(HashSet::new()));
    validator.validate_cached(token.expose(), 110, &cache, ttl, &once).unwrap();
    assert!(cache.get(&validator.cache_key(token.expose()).unwrap()).is_some());
    assert_eq!(
        validator.validate_cached(token.expose(), 110, &cache, ttl, &once),
        Err(Error::Replayed)
    );
    assert_eq!(
        validator.validate_cached(token.expose(), 110, &cache, ttl, &Reject(Error::Revoked)),
        Err(Error::Revoked)
    );
    assert_eq!(
        validator.validate_cached(
            token.expose(),
            110,
            &cache,
            ttl,
            &Reject(Error::PolicyUnavailable)
        ),
        Err(Error::PolicyUnavailable)
    );
    cache.clear();
    assert!(
        validator
            .validate_cached(token.expose(), 110, &cache, ttl, &Reject(Error::Revoked))
            .is_err()
    );
    assert!(cache.get(&validator.cache_key(token.expose()).unwrap()).is_none());
}

#[test]
fn atomic_hook_allows_only_one_concurrent_authentication() {
    let authority = standard();
    let validator = authority.validator(policy("api", Purpose::Access));
    let token = authority.issue(claims()).unwrap();
    let cache = cache();
    let once = Once(Mutex::new(HashSet::new()));
    std::thread::scope(|scope| {
        let handles: Vec<_> = (0..8)
            .map(|_| {
                scope.spawn(|| {
                    validator.validate_cached(
                        token.expose(),
                        110,
                        &cache,
                        Duration::from_secs(30),
                        &once
                    )
                })
            })
            .collect();
        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
        assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1);
        assert_eq!(results.iter().filter(|r| **r == Err(Error::Replayed)).count(), 7);
    });
}

#[test]
fn bounds_bad_configuration_and_invalid_claims() {
    assert!(matches!(Keyring::new(1, []), Err(Error::MissingActiveKey)));
    assert!(matches!(
        Keyring::new(1, [(1, SecretKey::from_bytes([0; 32])), (1, SecretKey::from_bytes([1; 32]))]),
        Err(Error::DuplicateKey)
    ));
    for label in ["", "bad\nlabel", &"x".repeat(MAX_LABEL_LEN + 1)] {
        assert!(Audience::new(label).is_err());
        assert!(Capability::new(label).is_err());
        assert!(
            Authority::new(
                label,
                "issuer",
                Keyring::new(1, [(1, SecretKey::from_bytes([1; 32]))]).unwrap()
            )
            .is_err()
        );
    }
    let authority = standard();
    let mut invalid = claims();
    invalid.expires_at = invalid.not_before;
    assert!(matches!(authority.issue(invalid), Err(Error::InvalidInput)));
    let mut invalid = claims();
    invalid.issued_at = invalid.not_before + 1;
    assert!(matches!(authority.issue(invalid), Err(Error::InvalidInput)));
    let mut invalid = claims();
    invalid.capabilities =
        (0..=MAX_CAPABILITIES).map(|n| Capability::new(n.to_string()).unwrap()).collect();
    assert!(matches!(authority.issue(invalid), Err(Error::InvalidInput)));
    let mut huge = claims();
    huge.capabilities = (0..MAX_CAPABILITIES)
        .map(|n| Capability::new(format!("{n:03}{}", "x".repeat(250))).unwrap())
        .collect();
    assert!(matches!(authority.issue(huge), Err(Error::TokenTooLarge)));
}

#[test]
fn bounded_eviction_zero_ttl_and_wrong_cached_entry_are_safe() {
    let authority = standard();
    let validator = authority.validator(policy("api", Purpose::Access));
    let first = authority.issue(claims()).unwrap();
    let second = authority.issue(claims()).unwrap();
    let cache = MemoryCache::new(NonZeroUsize::new(1).unwrap());
    let ttl = Duration::from_secs(30);
    let principal =
        validator.validate_cached(first.expose(), 110, &cache, ttl, &AllowReusable).unwrap();
    let key = validator.cache_key(first.expose()).unwrap();
    let entry = cache.get(&key).unwrap();
    validator.validate_cached(second.expose(), 110, &cache, ttl, &AllowReusable).unwrap();
    assert!(cache.get(&key).is_none());
    let second_key = validator.cache_key(second.expose()).unwrap();
    cache.insert(second_key, entry);
    let correct =
        validator.validate_cached(second.expose(), 110, &cache, ttl, &AllowReusable).unwrap();
    assert_ne!(principal.jti(), correct.jti());
    let uncached = validator
        .validate_cached(second.expose(), 110, &cache, Duration::ZERO, &AllowReusable)
        .unwrap();
    assert!(!Arc::ptr_eq(&correct, &uncached));
}