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 clap::{Args, Parser, Subcommand};
use serde::{Deserialize, Serialize};

/// The main CLI entry point for `ez-token`.
///
/// Parses command-line arguments and dispatches to the appropriate
/// subcommand. If no subcommand is given, the default profile is used
/// to run an interactive login via the PKCE flow.
#[derive(Parser, PartialEq, Debug)]
#[command(author, version, about, long_about = None)]
pub struct Cli {
    /// The configuration profile to use.
    ///
    /// Profiles store Tenant ID, Client ID, and Scopes so you don't
    /// have to re-enter them every time. Defaults to `"default"`.
    ///
    /// Example: `ez-token --profile prod login`
    #[arg(long, global = true, default_value = "default")]
    pub profile: String,

    /// The subcommand to execute.
    #[command(subcommand)]
    pub command: Option<Commands>,
}

/// The identity provider to authenticate against.
#[derive(Clone, Default, Serialize, PartialEq, Deserialize, Debug, clap::ValueEnum)]
pub enum ProviderKind {
    /// Microsoft Entra ID (Azure AD). Requires `--tenant-id`.
    #[default]
    Microsoft,
    /// Auth0. Requires `--domain` (e.g. `my-org.eu.auth0.com`).
    Auth0,
}

/// Shared authentication arguments used across multiple subcommands.
///
/// These fields are optional — any value not provided via the command line
/// will be resolved from the active profile, or prompted interactively.
#[derive(Args, Default, PartialEq, Debug)]
pub struct AuthArgs {
    /// The identity provider to authenticate against.
    ///
    /// If not provided, resolved from the active profile or prompted interactively.
    /// Accepted values: `microsoft`, `auth0`
    #[arg(long)]
    pub provider: Option<ProviderKind>,

    /// Tenant ID — required for Microsoft (GUID, domain, or `"common"`).
    #[arg(long)]
    pub tenant_id: Option<String>,

    /// Domain — required for Auth0 (e.g. `my-org.eu.auth0.com`).
    #[arg(long)]
    pub domain: Option<String>,

    /// Audience — required for Auth0 (e.g. `api://ez-token`).
    ///
    /// Tells Auth0 which API the token is intended for.
    /// Ignored for Microsoft flows.
    #[arg(long)]
    pub audience: Option<String>,

    /// The Application (Client) ID.
    #[arg(long)]
    pub client_id: Option<String>,

    /// Space-separated list of OAuth2 scopes to request.
    ///
    /// Example: `"read:ez write:ez"`
    /// For Microsoft M2M flows use: `"api://YOUR_API/.default"`
    #[arg(long)]
    pub scopes: Option<String>,
}

/// Available subcommands for `ez-token`.
#[derive(Subcommand, PartialEq, Debug)]
pub enum Commands {
    /// Authenticate interactively via the browser using the PKCE flow.
    ///
    /// Opens your default browser to complete the OAuth2 Authorization
    /// Code flow with PKCE. The resulting access token is copied to
    /// your clipboard automatically.
    ///
    /// Example: `ez-token login --tenant-id common --client-id YOUR_ID`
    Login {
        /// The authentication parameters.
        #[command(flatten)]
        auth: AuthArgs,

        /// The local port to use for the OAuth2 callback server.
        #[arg(long, default_value = "3000")]
        port: u16,
    },

    /// Fetch a token using the Client Credentials (machine-to-machine) flow.
    ///
    /// Does not open a browser. Authenticates using a client secret,
    /// suitable for services, scripts, and CI/CD pipelines.
    ///
    /// Example: `ez-token m2m --client-secret YOUR_SECRET`
    ///
    /// Note: The client secret is never saved to disk. If not provided
    /// via `--client-secret`, it will be prompted securely.
    M2m {
        /// The authentication parameters.
        #[command(flatten)]
        auth: AuthArgs,

        /// The client secret for the registered application.
        ///
        /// If omitted, the value will be prompted interactively
        /// and is never persisted to disk.
        #[arg(long)]
        client_secret: Option<String>,
    },

    /// Manage configuration profiles.
    ///
    /// Profiles store Tenant ID, Client ID, and Scopes so you don't
    /// have to re-enter them on every invocation.
    ///
    /// Example: `ez-token config set --tenant-id common --client-id YOUR_ID`
    Config {
        /// The specific configuration action to perform.
        #[command(subcommand)]
        action: ConfigAction,
    },
}

/// Subcommands for managing configuration profiles.
#[derive(Subcommand, PartialEq, Debug)]
pub enum ConfigAction {
    /// Save or update the active profile's configuration.
    ///
    /// Only the provided fields are updated — omitted fields retain
    /// their existing values.
    ///
    /// Example: `ez-token --profile prod config set --tenant-id YOUR_TENANT`
    Set {
        /// The configuration parameters to update.
        #[command(flatten)]
        auth: AuthArgs,
    },

    /// Display the active profile's current configuration.
    ///
    /// Example: `ez-token --profile prod config show`
    Show,

    /// List all saved profiles.
    ///
    /// The active profile is highlighted with an asterisk (*).
    List,
}

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

    #[test]
    fn verify_cli() {
        Cli::command().debug_assert();
    }

    #[test]
    fn test_no_command_uses_default_profile() {
        let cli = Cli::try_parse_from(["ez-token"]).unwrap();

        assert_eq!(
            cli,
            Cli {
                profile: "default".to_string(),
                command: None,
            }
        );
    }

    #[test]
    fn test_global_profile_flag() {
        let cli = Cli::try_parse_from(["ez-token", "--profile", "prod", "config", "show"]).unwrap();

        assert_eq!(
            cli,
            Cli {
                profile: "prod".to_string(),
                command: Some(Commands::Config {
                    action: ConfigAction::Show,
                }),
            }
        );
    }

    #[test]
    fn test_login_command_with_defaults() {
        let cli = Cli::try_parse_from(["ez-token", "login"]).unwrap();

        assert_eq!(
            cli.command,
            Some(Commands::Login {
                auth: AuthArgs::default(),
                port: 3000,
            })
        );
    }

    #[test]
    fn test_login_command_with_auth_args_and_custom_port() {
        let cli = Cli::try_parse_from([
            "ez-token",
            "login",
            "--provider",
            "auth0",
            "--domain",
            "my-org.eu.auth0.com",
            "--client-id",
            "12345",
            "--port",
            "8080",
        ])
        .unwrap();

        assert_eq!(
            cli.command,
            Some(Commands::Login {
                auth: AuthArgs {
                    provider: Some(ProviderKind::Auth0),
                    domain: Some("my-org.eu.auth0.com".to_string()),
                    client_id: Some("12345".to_string()),
                    ..Default::default()
                },
                port: 8080,
            })
        );
    }

    #[test]
    fn test_m2m_command() {
        let cli = Cli::try_parse_from([
            "ez-token",
            "m2m",
            "--tenant-id",
            "common",
            "--client-secret",
            "super-secret",
        ])
        .unwrap();

        assert_eq!(
            cli.command,
            Some(Commands::M2m {
                auth: AuthArgs {
                    tenant_id: Some("common".to_string()),
                    ..Default::default()
                },
                client_secret: Some("super-secret".to_string()),
            })
        );
    }

    #[test]
    fn test_config_set_command() {
        let cli = Cli::try_parse_from([
            "ez-token",
            "--profile",
            "dev",
            "config",
            "set",
            "--scopes",
            "api://ez/.default",
        ])
        .unwrap();

        assert_eq!(cli.profile, "dev");
        assert_eq!(
            cli.command,
            Some(Commands::Config {
                action: ConfigAction::Set {
                    auth: AuthArgs {
                        scopes: Some("api://ez/.default".to_string()),
                        ..Default::default()
                    }
                }
            })
        );
    }

    #[test]
    fn test_config_list_command() {
        let cli = Cli::try_parse_from(["ez-token", "config", "list"]).unwrap();

        assert_eq!(
            cli.command,
            Some(Commands::Config {
                action: ConfigAction::List,
            })
        );
    }
}