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::output::{finish_spinner_error, finish_spinner_success, start_spinner};
use crate::services::authentication::authenticator::Authenticator;
use crate::services::authentication::urls::IdentityProvider;
use crate::services::http_client::client::create_http_client;
use miette::{Context, IntoDiagnostic, Result};
use oauth2::{ClientId, ClientSecret, Scope, TokenResponse, TokenUrl, basic::BasicClient};

/// An OAuth2 Client Credentials flow implementation for supported identity providers.
///
/// This flow is designed for machine-to-machine (M2M) authentication where
/// no user interaction is required. It exchanges a client secret for an
/// access token directly, without opening a browser.
///
/// # Provider Requirements
///
/// ## Microsoft Entra ID
/// - The application must have **Application permissions** (not Delegated)
/// - Admin consent must be granted for all requested scopes
/// - Scopes must use the `.default` suffix (e.g. `api://my-api/.default`)
///
/// ## Auth0
/// - Requires a dedicated **Machine to Machine** application — a Native app
///   used for PKCE cannot use Client Credentials simultaneously
/// - Under **Advanced Settings → Grant Types**, **Client Credentials** must be enabled
/// - Scopes are explicit (e.g. `read:ez`) and must be granted under **APIs → Client Access**
pub struct ClientCredentialsFlow {
    /// 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 client secret for the registered application.
    ///
    /// Never persisted to disk — always sourced from a prompt or CLI argument.
    pub client_secret: String,

    /// The list of OAuth2 scopes to request.
    ///
    /// For Microsoft use `.default` suffix (e.g. `api://my-api/.default`).
    /// For Auth0 use explicit scopes (e.g. `read:ez`).
    pub scopes: Vec<String>,
}

impl Authenticator for ClientCredentialsFlow {
    /// Exchanges the client credentials for an access token.
    ///
    /// Builds an OAuth2 client, adds the requested scopes, and performs
    /// the token exchange against the provider's token endpoint.
    /// For Auth0, an `audience` parameter is included automatically.
    /// Displays a spinner during the request.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - The provider produces an invalid token URL
    /// - The HTTP client cannot be initialized
    /// - The token exchange is rejected by the identity provider
    async fn get_token(&self) -> Result<String> {
        let token_uri = TokenUrl::new(self.provider.token_url())
            .into_diagnostic()
            .wrap_err("Invalid token URL")?;

        let client = BasicClient::new(ClientId::new(self.client_id.clone()))
            .set_client_secret(ClientSecret::new(self.client_secret.clone()))
            .set_token_uri(token_uri);

        let mut token_req = client.exchange_client_credentials();

        if let Some(audience) = self.provider.audience() {
            token_req = token_req.add_extra_param("audience", audience);
        }

        for scope in &self.scopes {
            token_req = token_req.add_scope(Scope::new(scope.clone()));
        }

        let http_client = create_http_client()?;
        let spinner = start_spinner("Authenticating...")?;

        let token_result = token_req
            .request_async(&http_client)
            .await
            .into_diagnostic()
            .wrap_err("Failed to exchange Client Credentials for Access Token");

        match token_result {
            Ok(res) => {
                finish_spinner_success(&spinner, "Authentication successful!");
                Ok(res.access_token().secret().clone())
            }
            Err(e) => {
                finish_spinner_error(&spinner, "Authentication failed!");
                Err(e)
            }
        }
    }
}