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 crate::{
    cli::{
        args::{AuthArgs, ProviderKind},
        history::{HistoryKey, HistoryManager, file::FileHistoryManager},
    },
    config::cli_config::Profile,
    services::authentication::urls::IdentityProvider,
};
use dialoguer::{Input, Select, theme::ColorfulTheme};
use miette::{IntoDiagnostic, Result};

/// Resolved authentication parameters required to initiate an OAuth2 flow.
///
/// `AuthParams` is constructed by merging three layers of input in order
/// of priority:
///
/// 1. **CLI arguments** — highest priority, passed directly by the user.
/// 2. **Profile** — values saved via the `config set` subcommand.
/// 3. **Interactive prompt** — used as a fallback if neither of the above provides a value.
#[derive(Debug, PartialEq)]
pub struct AuthParams {
    /// The resolved identity provider with all required endpoint data.
    pub provider: IdentityProvider,

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

    /// The list of OAuth2 scopes to request.
    ///
    /// Parsed from a whitespace-separated string (e.g., `"User.Read Mail.Read"`
    /// becomes `["User.Read", "Mail.Read"]`).
    pub scopes: Vec<String>,
}

impl AuthParams {
    /// Resolves authentication parameters from CLI args, a saved profile, and
    /// interactive prompts.
    ///
    /// # Arguments
    ///
    /// * `profile` - The active configuration profile, loaded from disk.
    /// * `args` - Parsed CLI arguments from [`AuthArgs`], any of which may be `None`.
    /// * `default_scope_prompt` - A hint shown in the scopes prompt and used
    ///   as the default value if the user presses Enter without typing.
    ///
    /// # Errors
    ///
    /// Returns an error if an interactive prompt fails (e.g., due to a non-interactive terminal).
    pub fn new(profile: &Profile, args: AuthArgs, default_scope_prompt: &str) -> Result<Self> {
        let theme = ColorfulTheme::default();
        let manager = FileHistoryManager;

        let provider_kind = match args.provider.or(profile.provider.clone()) {
            Some(p) => p,
            None => Self::prompt_provider(&theme)?,
        };

        let provider = match provider_kind {
            ProviderKind::Microsoft => {
                let tenant_id = match args.tenant_id.or(profile.tenant_id.clone()) {
                    Some(t) => t,
                    None => Self::prompt_or_history(
                        &theme,
                        "Enter Tenant ID",
                        HistoryKey::Tenant,
                        None,
                        &manager,
                    )?,
                };
                IdentityProvider::Microsoft { tenant_id }
            }
            ProviderKind::Auth0 => {
                let domain = match args.domain.or(profile.domain.clone()) {
                    Some(d) => d,
                    None => Self::prompt_or_history(
                        &theme,
                        "Enter Auth0 Domain (e.g. my-org.eu.auth0.com)",
                        HistoryKey::Domain,
                        None,
                        &manager,
                    )?,
                };
                let audience = match args.audience.or(profile.audience.clone()) {
                    Some(a) => a,
                    None => Self::prompt_or_history(
                        &theme,
                        "Enter Auth0 Audience (e.g. api://ez-token)",
                        HistoryKey::Audience,
                        None,
                        &manager,
                    )?,
                };
                IdentityProvider::Auth0 { domain, audience }
            }
        };

        let client_id = match args.client_id.or(profile.client_id.clone()) {
            Some(c) => c,
            None => Self::prompt_or_history(
                &theme,
                "Enter Client ID",
                HistoryKey::Client,
                None,
                &manager,
            )?,
        };

        let scopes_str = match args.scopes.or(profile.default_scopes.clone()) {
            Some(s) => s,
            None => Self::prompt_or_history(
                &theme,
                &format!("Enter Scopes (e.g. {})", default_scope_prompt),
                HistoryKey::Scopes,
                Some(default_scope_prompt),
                &manager,
            )?,
        };

        let scopes = scopes_str
            .split_whitespace()
            .map(|s| s.to_string())
            .collect();

        Ok(Self {
            provider,
            client_id,
            scopes,
        })
    }

    /// Displays an interactive text prompt with history-based completion.
    ///
    /// Previous inputs are loaded from disk via the [`HistoryManager`] and
    /// saved back after the user confirms their input.
    ///
    /// # Errors
    ///
    /// Returns an error if the prompt cannot be displayed or the user input
    /// cannot be read.
    fn prompt_or_history(
        theme: &ColorfulTheme,
        prompt: &str,
        key: HistoryKey,
        default: Option<&str>,
        manager: &dyn HistoryManager,
    ) -> Result<String> {
        let mut history = manager.load(key);
        let mut input = Input::with_theme(theme)
            .with_prompt(prompt)
            .history_with(&mut history);

        if let Some(d) = default {
            input = input.default(d.to_string());
        }

        let result = input.interact_text().into_diagnostic()?;
        manager.save(key, &history);
        Ok(result)
    }

    /// Displays an interactive selection prompt for the identity provider.
    ///
    /// Renders a list the user navigates with arrow keys and confirms with Enter.
    /// No typing required.
    fn prompt_provider(theme: &ColorfulTheme) -> Result<ProviderKind> {
        let options = vec!["Microsoft Entra ID (Azure AD)", "Auth0"];
        let selection = Select::with_theme(theme)
            .with_prompt("Select Identity Provider")
            .items(&options)
            .default(0)
            .interact()
            .into_diagnostic()?;

        match selection {
            0 => Ok(ProviderKind::Microsoft),
            _ => Ok(ProviderKind::Auth0),
        }
    }
}

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

    // A helper to create an empty profile for testing
    fn empty_profile() -> Profile {
        Profile {
            provider: None,
            tenant_id: None,
            domain: None,
            audience: None,
            client_id: None,
            default_scopes: None,
        }
    }

    #[test]
    fn test_cli_args_override_profile_microsoft() {
        let profile = Profile {
            provider: Some(ProviderKind::Auth0),
            tenant_id: Some("profile-tenant".to_string()),
            client_id: Some("profile-client".to_string()),
            default_scopes: Some("profile.read".to_string()),
            ..empty_profile()
        };

        let args = AuthArgs {
            provider: Some(ProviderKind::Microsoft),
            tenant_id: Some("cli-tenant".to_string()),
            client_id: Some("cli-client".to_string()),
            scopes: Some("cli.read cli.write".to_string()),
            ..AuthArgs::default()
        };

        let params = AuthParams::new(&profile, args, "default.scope").unwrap();

        assert_eq!(
            params,
            AuthParams {
                provider: IdentityProvider::Microsoft {
                    tenant_id: "cli-tenant".to_string(),
                },
                client_id: "cli-client".to_string(),
                scopes: vec!["cli.read".to_string(), "cli.write".to_string()],
            }
        );
    }

    #[test]
    fn test_profile_fallback_when_cli_args_missing_auth0() {
        let profile = Profile {
            provider: Some(ProviderKind::Auth0),
            domain: Some("my-org.auth0.com".to_string()),
            audience: Some("api://my-api".to_string()),
            client_id: Some("profile-client".to_string()),
            default_scopes: Some("openid profile".to_string()),
            ..empty_profile()
        };

        let args = AuthArgs::default();

        let params = AuthParams::new(&profile, args, "default.scope").unwrap();

        assert_eq!(
            params,
            AuthParams {
                provider: IdentityProvider::Auth0 {
                    domain: "my-org.auth0.com".to_string(),
                    audience: "api://my-api".to_string(),
                },
                client_id: "profile-client".to_string(),
                scopes: vec!["openid".to_string(), "profile".to_string()],
            }
        );
    }

    #[test]
    fn test_mixed_resolution() {
        let profile = Profile {
            provider: Some(ProviderKind::Microsoft),
            tenant_id: Some("common".to_string()),
            ..empty_profile()
        };

        let args = AuthArgs {
            client_id: Some("cli-client".to_string()),
            scopes: Some("api://ez/.default".to_string()),
            ..AuthArgs::default()
        };

        let params = AuthParams::new(&profile, args, "default").unwrap();

        assert_eq!(
            params,
            AuthParams {
                provider: IdentityProvider::Microsoft {
                    tenant_id: "common".to_string(),
                },
                client_id: "cli-client".to_string(),
                scopes: vec!["api://ez/.default".to_string()],
            }
        );
    }

    #[test]
    fn test_scopes_whitespace_splitting() {
        let profile = empty_profile();

        let args = AuthArgs {
            provider: Some(ProviderKind::Microsoft),
            tenant_id: Some("common".to_string()),
            client_id: Some("12345".to_string()),

            scopes: Some("scope1   scope2\tscope3".to_string()),
            ..AuthArgs::default()
        };

        let params = AuthParams::new(&profile, args, "default").unwrap();

        assert_eq!(
            params.scopes,
            vec![
                "scope1".to_string(),
                "scope2".to_string(),
                "scope3".to_string()
            ]
        );
    }
}