rustauth-core 0.2.0

Core types and primitives for RustAuth.
Documentation
use rustauth_core::context::{AuthEnvironment, SecretMaterial};
use rustauth_core::crypto::{SecretConfig, SecretEntry};
use rustauth_core::env::{allows_development_defaults, is_production, is_production_posture};
use rustauth_core::options::{
    DeploymentMode, EmailPasswordOptions, ExperimentalOptions, IpAddressOptions, RustAuthOptions,
};
use rustauth_core::plugin::AuthPlugin;
use std::sync::{Mutex, MutexGuard, OnceLock};

struct EnvRestore(Vec<(&'static str, Option<String>)>);

impl EnvRestore {
    fn unset(keys: &[&'static str]) -> Self {
        let saved = keys
            .iter()
            .map(|key| (*key, std::env::var(key).ok()))
            .collect::<Vec<_>>();
        for key in keys {
            std::env::remove_var(key);
        }
        Self(saved)
    }
}

impl Drop for EnvRestore {
    fn drop(&mut self) {
        for (key, value) in &self.0 {
            match value {
                Some(value) => std::env::set_var(key, value),
                None => std::env::remove_var(key),
            }
        }
    }
}

fn env_lock() -> &'static Mutex<()> {
    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(()))
}

fn lock_env() -> MutexGuard<'static, ()> {
    env_lock()
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner())
}

#[test]
fn rustauth_options_debug_redacts_secret_material() {
    let options = RustAuthOptions {
        secret: Some("legacy-secret-should-not-appear".to_owned()),
        secrets: vec![SecretEntry {
            version: 1,
            value: "rotating-secret-should-not-appear".to_owned(),
        }],
        ..RustAuthOptions::default()
    };

    let output = format!("{options:?}");

    assert!(output.contains("<redacted>"));
    assert!(!output.contains("legacy-secret-should-not-appear"));
    assert!(!output.contains("rotating-secret-should-not-appear"));
}

#[test]
fn secret_rotation_debug_redacts_secret_material() {
    let entry = SecretEntry {
        version: 1,
        value: "entry-secret-should-not-appear".to_owned(),
    };
    let config = SecretConfig::new([(1, "config-secret-should-not-appear")])
        .with_legacy_secret("legacy-rotation-secret-should-not-appear");
    let material = SecretMaterial::Rotating(config.clone());

    let entry_output = format!("{entry:?}");
    let config_output = format!("{config:?}");
    let material_output = format!("{material:?}");

    assert!(!entry_output.contains("entry-secret-should-not-appear"));
    assert!(!config_output.contains("config-secret-should-not-appear"));
    assert!(!config_output.contains("legacy-rotation-secret-should-not-appear"));
    assert!(!material_output.contains("config-secret-should-not-appear"));
    assert!(!material_output.contains("legacy-rotation-secret-should-not-appear"));
}

#[test]
fn auth_environment_debug_redacts_secret_material() {
    let environment = AuthEnvironment {
        rustauth_secret: Some("rustauth-secret-should-not-appear".to_owned()),
        rustauth_secrets: Some("1:rotating-env-secret-should-not-appear".to_owned()),
        rustauth_trusted_origins: Some("https://trusted.example.com".to_owned()),
    };

    let output = format!("{environment:?}");

    assert!(!output.contains("rustauth-secret-should-not-appear"));
    assert!(!output.contains("rotating-env-secret-should-not-appear"));
}

#[test]
fn auth_environment_from_process_is_empty_when_secret_env_is_unset() {
    let _guard = lock_env();
    let _restore = EnvRestore::unset(&[
        "RUSTAUTH_SECRET",
        "RUSTAUTH_SECRETS",
        "RUSTAUTH_TRUSTED_ORIGINS",
    ]);

    assert_eq!(AuthEnvironment::from_process(), AuthEnvironment::default());
}

#[test]
fn auth_environment_from_process_reads_mocked_secret_env() {
    let _guard = lock_env();
    let _restore = EnvRestore::unset(&[
        "RUSTAUTH_SECRET",
        "RUSTAUTH_SECRETS",
        "RUSTAUTH_TRUSTED_ORIGINS",
    ]);
    std::env::set_var("RUSTAUTH_SECRET", "rustauth-secret");
    std::env::set_var("RUSTAUTH_SECRETS", "2:next,1:prev");
    std::env::set_var("RUSTAUTH_TRUSTED_ORIGINS", "https://trusted.example.com");

    assert_eq!(
        AuthEnvironment::from_process(),
        AuthEnvironment {
            rustauth_secret: Some("rustauth-secret".to_owned()),
            rustauth_secrets: Some("2:next,1:prev".to_owned()),
            rustauth_trusted_origins: Some("https://trusted.example.com".to_owned()),
        }
    );
}

#[test]
fn is_production_is_false_when_rust_env_is_unset() {
    let _guard = lock_env();
    let _restore = EnvRestore::unset(&["RUST_ENV"]);

    assert!(!is_production());
}

#[test]
fn is_production_only_accepts_exact_production_rust_env() {
    let _guard = lock_env();
    let _restore = EnvRestore::unset(&["RUST_ENV", "RUST_TEST_THREADS", "TEST", "NEXTEST"]);

    std::env::set_var("RUST_ENV", "development");
    assert!(!is_production());

    std::env::set_var("RUST_ENV", "production");
    assert!(is_production());
}

#[test]
fn ambiguous_deployment_fails_closed_without_explicit_development() {
    let _guard = lock_env();
    let _restore = EnvRestore::unset(&["RUST_ENV", "RUST_TEST_THREADS", "TEST", "NEXTEST"]);

    let options = RustAuthOptions::default();
    assert!(is_production_posture(&options));
    assert!(!allows_development_defaults(&options));
}

#[test]
fn explicit_development_option_allows_development_defaults() {
    let _guard = lock_env();
    let _restore = EnvRestore::unset(&["RUST_ENV", "RUST_TEST_THREADS", "TEST", "NEXTEST"]);

    let options = RustAuthOptions::default().development(true);
    assert!(!is_production_posture(&options));
    assert!(allows_development_defaults(&options));
}

#[test]
fn production_option_overrides_development_flag() {
    let _guard = lock_env();
    let _restore = EnvRestore::unset(&["RUST_ENV", "RUST_TEST_THREADS", "TEST", "NEXTEST"]);

    let options = RustAuthOptions::default()
        .development(true)
        .production(true);
    assert!(is_production_posture(&options));
    assert!(!allows_development_defaults(&options));
}

#[test]
fn email_password_is_disabled_by_default_until_explicitly_opted_in() {
    assert!(!EmailPasswordOptions::default().enabled);
    assert!(!RustAuthOptions::default().email_password.enabled);
}

#[test]
fn rustauth_options_plugins_appends_without_replacing() {
    let options = RustAuthOptions::new()
        .plugin(AuthPlugin::new("first"))
        .plugins(vec![AuthPlugin::new("second"), AuthPlugin::new("third")]);

    let ids: Vec<_> = options
        .plugins
        .iter()
        .map(|plugin| plugin.id.as_str())
        .collect();
    assert_eq!(ids, ["first", "second", "third"]);
}

#[test]
fn rustauth_options_set_plugins_replaces_list() {
    let options = RustAuthOptions::new()
        .plugin(AuthPlugin::new("first"))
        .set_plugins(vec![AuthPlugin::new("only")]);

    let ids: Vec<_> = options
        .plugins
        .iter()
        .map(|plugin| plugin.id.as_str())
        .collect();
    assert_eq!(ids, ["only"]);
}

#[test]
fn deployment_mode_auto_honors_rust_env_matrix() {
    let _guard = lock_env();
    let _restore = EnvRestore::unset(&["RUST_ENV", "RUST_TEST_THREADS", "TEST", "NEXTEST"]);

    let auto = RustAuthOptions::default();
    assert_eq!(auto.mode, DeploymentMode::Auto);
    assert!(is_production_posture(&auto));
    assert!(!allows_development_defaults(&auto));

    std::env::set_var("RUST_ENV", "development");
    assert!(allows_development_defaults(&RustAuthOptions::default()));

    let explicit = RustAuthOptions::default().deployment_mode(DeploymentMode::Development);
    assert!(allows_development_defaults(&explicit));

    std::env::set_var("RUST_ENV", "production");
    assert!(!allows_development_defaults(&explicit));
    assert!(!allows_development_defaults(&RustAuthOptions::default()));
    assert!(!allows_development_defaults(
        &RustAuthOptions::default().deployment_mode(DeploymentMode::Production)
    ));
}

#[test]
fn collection_aliases_match_primary_methods() {
    let options = RustAuthOptions::new()
        .push_plugin(AuthPlugin::new("first"))
        .extend_plugins(vec![AuthPlugin::new("second")])
        .push_disabled_path("/one")
        .set_disabled_paths(["/two", "/three"]);

    let ids: Vec<_> = options
        .plugins
        .iter()
        .map(|plugin| plugin.id.as_str())
        .collect();
    assert_eq!(ids, ["first", "second"]);
    assert_eq!(
        options.disabled_paths,
        ["/two".to_owned(), "/three".to_owned()]
    );

    let ip = IpAddressOptions::new()
        .header("X-Forwarded-For")
        .headers(["X-Real-Ip"]);
    assert_eq!(ip.headers, ["X-Real-Ip".to_owned()]);
}

#[cfg(feature = "oauth")]
#[test]
fn social_providers_batch_appends_in_order() {
    use rustauth_oauth::oauth2::{
        OAuth2Tokens, OAuth2UserInfo, ProviderOptions, SocialAuthorizationCodeRequest,
        SocialAuthorizationUrlRequest, SocialOAuthProvider, SocialProviderFuture,
    };
    use url::Url;

    struct StubProvider(&'static str);

    impl SocialOAuthProvider for StubProvider {
        fn id(&self) -> &str {
            self.0
        }

        fn name(&self) -> &str {
            self.0
        }

        fn provider_options(&self) -> ProviderOptions {
            ProviderOptions::default()
        }

        fn create_authorization_url(
            &self,
            _input: SocialAuthorizationUrlRequest,
        ) -> Result<Url, rustauth_oauth::oauth2::OAuthError> {
            Url::parse("https://example.com/oauth")
                .map_err(rustauth_oauth::oauth2::OAuthError::InvalidUrl)
        }

        fn validate_authorization_code(
            &self,
            _input: SocialAuthorizationCodeRequest,
        ) -> SocialProviderFuture<'_, OAuth2Tokens> {
            Box::pin(async { Ok(OAuth2Tokens::default()) })
        }

        fn get_user_info(
            &self,
            _tokens: OAuth2Tokens,
            _provider_user: Option<serde_json::Value>,
        ) -> SocialProviderFuture<'_, Option<OAuth2UserInfo>> {
            Box::pin(async { Ok(None) })
        }
    }

    let options = RustAuthOptions::new()
        .social_provider(StubProvider("github"))
        .social_providers([StubProvider("google"), StubProvider("apple")]);

    let ids: Vec<_> = options
        .social_providers
        .iter()
        .map(|provider| provider.id())
        .collect();
    assert_eq!(ids, ["github", "google", "apple"]);

    let options = RustAuthOptions::new()
        .try_social_providers::<_, _, std::convert::Infallible>([
            Ok(StubProvider("one")),
            Ok(StubProvider("two")),
        ])
        .expect("providers should construct");
    let ids: Vec<_> = options
        .social_providers
        .iter()
        .map(|provider| provider.id())
        .collect();
    assert_eq!(ids, ["one", "two"]);
}

#[test]
fn experimental_joins_default_to_enabled_and_can_be_disabled() {
    assert!(RustAuthOptions::default().experimental.joins);

    let options = RustAuthOptions {
        experimental: ExperimentalOptions { joins: false },
        ..RustAuthOptions::default()
    };

    assert!(!options.experimental.joins);
    assert!(format!("{options:?}").contains("experimental"));
}