ez-token 0.1.0

CLI tool for generating OAuth2 access tokens via PKCE and Client Credentials for Microsoft Entra ID and Auth0
Documentation
use confy::{get_configuration_file_path, load, store};
use miette::{Context, IntoDiagnostic, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;

use crate::cli::args::ProviderKind;

/// The application name, derived from `Cargo.toml` at compile time.
///
/// Used by `confy` to determine the platform-specific configuration directory.
const APP_NAME: &str = env!("CARGO_PKG_NAME");

/// The configuration file name (without extension).
const CONFIG_NAME: &str = "config";

/// A named configuration profile storing reusable authentication parameters.
///
/// All fields are optional — any field not set will be resolved from CLI
/// arguments or prompted interactively at runtime.
///
/// Profiles are stored in the `ez-token` configuration file and can be
/// managed via the `ez-token config` subcommands.
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct Profile {
    /// The identity provider (e.g. `"microsoft"`, `"auth0"`).
    pub provider: Option<ProviderKind>,

    /// The Microsoft Entra ID Tenant ID (Microsoft only).
    pub tenant_id: Option<String>,

    /// The Auth0 domain (Auth0 only, e.g. `my-org.eu.auth0.com`).
    pub domain: Option<String>,

    /// The Auth0 audience (Auth0 only, e.g. `api://ez-token`).
    pub audience: Option<String>,

    /// The Application (Client) ID registered in Entra ID.
    pub client_id: Option<String>,

    /// Space-separated list of default OAuth2 scopes for this profile.
    ///
    /// # Examples
    /// - `"User.Read Mail.Read"`
    /// - `"api://my-api/.default"`
    pub default_scopes: Option<String>,
}

/// The top-level configuration file structure for `ez-token`.
///
/// Stores a map of named [`Profile`]s, persisted to disk via `confy`.
#[derive(Serialize, Deserialize, Default, Debug)]
pub struct CliConfig {
    /// Named profiles keyed by profile name (e.g., `"default"`, `"prod"`).
    #[serde(default)]
    pub profiles: HashMap<String, Profile>,
}

impl CliConfig {
    /// Loads the configuration from disk.
    ///
    /// If no configuration file exists, returns a default empty [`CliConfig`].
    ///
    /// # Errors
    ///
    /// Returns an error if the configuration file exists but cannot be
    /// parsed or read due to invalid format or insufficient permissions.
    pub fn load() -> Result<Self> {
        let cfg: CliConfig = load(APP_NAME, CONFIG_NAME)
            .into_diagnostic()
            .wrap_err("Failed to load configuration")?;
        Ok(cfg)
    }

    /// Persists the current configuration to disk.
    ///
    /// Creates the configuration file and any parent directories if they
    /// do not already exist.
    ///
    /// # Errors
    ///
    /// Returns an error if the configuration cannot be serialized or written
    /// to the filesystem.
    pub fn save(&self) -> Result<()> {
        store(APP_NAME, CONFIG_NAME, self)
            .into_diagnostic()
            .wrap_err("Failed to save configuration")?;
        Ok(())
    }

    /// Returns the path to the configuration file on disk.
    ///
    /// Useful for displaying the configuration location to the user via
    /// `ez-token config show` or determining the directory for history files.
    ///
    /// # Errors
    ///
    /// Returns an error if the platform-specific configuration directory
    /// cannot be determined.
    pub fn get_path() -> Result<PathBuf> {
        let path = get_configuration_file_path(APP_NAME, CONFIG_NAME)
            .into_diagnostic()
            .wrap_err("Could not determine config path")?;
        Ok(path)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_default_config_is_empty() {
        let config = CliConfig::default();
        assert!(config.profiles.is_empty());
    }

    #[test]
    fn test_config_serialization_deserialization() {
        let mut original_config = CliConfig::default();

        original_config.profiles.insert(
            "prod".to_string(),
            Profile {
                provider: Some(ProviderKind::Microsoft),
                tenant_id: Some("common".to_string()),
                client_id: Some("12345".to_string()),
                ..Default::default()
            },
        );

        let serialized = serde_json::to_string(&original_config).expect("Should serialize");

        assert!(serialized.contains("prod"));
        assert!(serialized.contains("Microsoft"));
        assert!(serialized.contains("common"));
        assert!(serialized.contains("12345"));

        let deserialized: CliConfig =
            serde_json::from_str(&serialized).expect("Should deserialize");

        let prod_profile = deserialized.profiles.get("prod").unwrap();
        assert_eq!(prod_profile.tenant_id.as_deref(), Some("common"));
    }
}