rskit-auth 0.2.0-alpha.1

JWT, OIDC, password hashing, and request-context auth helpers
Documentation
#![allow(missing_docs)]

use std::time::Duration;

use chrono::{Duration as ChronoDuration, Utc};
use rskit_auth::apikey::{Hasher, HashingConfig, Key, KeyValidationError, split_key, validate};
use rskit_auth::{AuthClaims, AuthOutcome, MissingCredentialPolicy, ResetTokenGenerator};

fn key(
    is_active: bool,
    expires_at: Option<chrono::DateTime<Utc>>,
    grace_ends_at: Option<chrono::DateTime<Utc>>,
) -> Key {
    Key {
        id: "key-1".into(),
        owner_id: "owner".into(),
        name: "test".into(),
        key_prefix: "pk".into(),
        key_digest: "digest".into(),
        scopes: vec!["read".into()],
        is_active,
        expires_at,
        grace_ends_at,
        rotated_by_id: None,
        last_used_at: None,
        created_at: Utc::now(),
    }
}

#[test]
fn api_key_hashing_validates_config_generates_split_and_compares_digest() {
    assert!(Hasher::new(HashingConfig::new("short")).is_err());
    assert!(
        Hasher::new(HashingConfig {
            pepper: "p".repeat(32),
            entropy_bytes: 8
        })
        .is_err()
    );

    let hasher = Hasher::new(HashingConfig {
        pepper: "p".repeat(32),
        entropy_bytes: 0,
    })
    .unwrap();
    assert_eq!(hasher.config().entropy_bytes, 32);
    let issued = hasher.generate_key("pk_test").unwrap();
    let (prefix, secret) = split_key(&issued.plain_key).unwrap();
    assert_eq!(prefix, "pk_test");
    assert!(!secret.is_empty());
    assert_eq!(issued.key_prefix, "pk_test");
    assert!(hasher.compare(&issued.plain_key, &issued.key_digest));
    assert!(!hasher.compare("pk_test.wrong", &issued.key_digest));
    assert!(hasher.generate_key("bad prefix").is_err());
    assert!(split_key("missing-separator").is_err());
    assert!(split_key(".secret").is_err());
}

#[test]
fn key_validation_distinguishes_revoked_expired_and_grace_period() {
    assert!(validate(&key(true, None, None)).is_ok());
    assert_eq!(
        validate(&key(false, None, None)).unwrap_err().to_string(),
        KeyValidationError::Revoked.to_string()
    );
    assert!(key(true, Some(Utc::now() - ChronoDuration::minutes(5)), None).is_expired_past_grace());
    assert!(matches!(
        validate(&key(
            true,
            Some(Utc::now() - ChronoDuration::minutes(5)),
            None
        )),
        Err(KeyValidationError::Expired)
    ));
    assert!(
        !key(
            true,
            Some(Utc::now() - ChronoDuration::minutes(5)),
            Some(Utc::now() + ChronoDuration::minutes(5))
        )
        .is_expired_past_grace()
    );
    assert!(key(true, None, Some(Utc::now() - ChronoDuration::minutes(1))).is_expired_past_grace());
}

#[test]
fn auth_outcome_claims_and_missing_policy_are_typed() {
    assert_eq!(
        MissingCredentialPolicy::default(),
        MissingCredentialPolicy::RejectMissing
    );
    let claims = AuthClaims("subject");
    assert_eq!(claims.0, "subject");
    let authenticated = AuthOutcome::Authenticated(claims.0);
    assert_eq!(authenticated.claims(), Some(&"subject"));
    assert!(!authenticated.is_missing());
    let missing = AuthOutcome::<&str>::Missing;
    assert!(missing.is_missing());
    assert_eq!(missing.claims(), None);
}

#[test]
fn reset_tokens_are_url_safe_and_expire_after_ttl() {
    let generator = ResetTokenGenerator::new(Duration::from_mins(1));
    let (token, expires_at) = generator.generate();
    assert_eq!(token.len(), 43);
    assert!(
        token
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
    );
    assert!(expires_at > Utc::now());
}