Skip to main content

ez_token/cli/
args.rs

1use clap::{Args, Parser, Subcommand};
2use serde::{Deserialize, Serialize};
3
4/// The main CLI entry point for `ez-token`.
5///
6/// Parses command-line arguments and dispatches to the appropriate
7/// subcommand. If no subcommand is given, the default profile is used
8/// to run an interactive login via the PKCE flow.
9#[derive(Parser, PartialEq, Debug)]
10#[command(author, version, about, long_about = None)]
11pub struct Cli {
12    /// The configuration profile to use.
13    ///
14    /// Profiles store Tenant ID, Client ID, and Scopes so you don't
15    /// have to re-enter them every time. Defaults to `"default"`.
16    ///
17    /// Example: `ez-token --profile prod login`
18    #[arg(long, global = true, default_value = "default")]
19    pub profile: String,
20
21    /// The subcommand to execute.
22    #[command(subcommand)]
23    pub command: Option<Commands>,
24}
25
26/// The identity provider to authenticate against.
27#[derive(Clone, Default, Serialize, PartialEq, Deserialize, Debug, clap::ValueEnum)]
28pub enum ProviderKind {
29    /// Microsoft Entra ID (Azure AD). Requires `--tenant-id`.
30    #[default]
31    Microsoft,
32    /// Auth0. Requires `--domain` (e.g. `my-org.eu.auth0.com`).
33    Auth0,
34}
35
36/// Shared authentication arguments used across multiple subcommands.
37///
38/// These fields are optional — any value not provided via the command line
39/// will be resolved from the active profile, or prompted interactively.
40#[derive(Args, Default, PartialEq, Debug)]
41pub struct AuthArgs {
42    /// The identity provider to authenticate against.
43    ///
44    /// If not provided, resolved from the active profile or prompted interactively.
45    /// Accepted values: `microsoft`, `auth0`
46    #[arg(long)]
47    pub provider: Option<ProviderKind>,
48
49    /// Tenant ID — required for Microsoft (GUID, domain, or `"common"`).
50    #[arg(long)]
51    pub tenant_id: Option<String>,
52
53    /// Domain — required for Auth0 (e.g. `my-org.eu.auth0.com`).
54    #[arg(long)]
55    pub domain: Option<String>,
56
57    /// Audience — required for Auth0 (e.g. `api://ez-token`).
58    ///
59    /// Tells Auth0 which API the token is intended for.
60    /// Ignored for Microsoft flows.
61    #[arg(long)]
62    pub audience: Option<String>,
63
64    /// The Application (Client) ID.
65    #[arg(long)]
66    pub client_id: Option<String>,
67
68    /// Space-separated list of OAuth2 scopes to request.
69    ///
70    /// Example: `"read:ez write:ez"`
71    /// For Microsoft M2M flows use: `"api://YOUR_API/.default"`
72    #[arg(long)]
73    pub scopes: Option<String>,
74}
75
76/// Available subcommands for `ez-token`.
77#[derive(Subcommand, PartialEq, Debug)]
78pub enum Commands {
79    /// Authenticate interactively via the browser using the PKCE flow.
80    ///
81    /// Opens your default browser to complete the OAuth2 Authorization
82    /// Code flow with PKCE. The resulting access token is copied to
83    /// your clipboard automatically.
84    ///
85    /// Example: `ez-token login --tenant-id common --client-id YOUR_ID`
86    Login {
87        /// The authentication parameters.
88        #[command(flatten)]
89        auth: AuthArgs,
90
91        /// The local port to use for the OAuth2 callback server.
92        #[arg(long, default_value = "3000")]
93        port: u16,
94    },
95
96    /// Fetch a token using the Client Credentials (machine-to-machine) flow.
97    ///
98    /// Does not open a browser. Authenticates using a client secret,
99    /// suitable for services, scripts, and CI/CD pipelines.
100    ///
101    /// Example: `ez-token m2m --client-secret YOUR_SECRET`
102    ///
103    /// Note: The client secret is never saved to disk. If not provided
104    /// via `--client-secret`, it will be prompted securely.
105    M2m {
106        /// The authentication parameters.
107        #[command(flatten)]
108        auth: AuthArgs,
109
110        /// The client secret for the registered application.
111        ///
112        /// If omitted, the value will be prompted interactively
113        /// and is never persisted to disk.
114        #[arg(long)]
115        client_secret: Option<String>,
116    },
117
118    /// Manage configuration profiles.
119    ///
120    /// Profiles store Tenant ID, Client ID, and Scopes so you don't
121    /// have to re-enter them on every invocation.
122    ///
123    /// Example: `ez-token config set --tenant-id common --client-id YOUR_ID`
124    Config {
125        /// The specific configuration action to perform.
126        #[command(subcommand)]
127        action: ConfigAction,
128    },
129}
130
131/// Subcommands for managing configuration profiles.
132#[derive(Subcommand, PartialEq, Debug)]
133pub enum ConfigAction {
134    /// Save or update the active profile's configuration.
135    ///
136    /// Only the provided fields are updated — omitted fields retain
137    /// their existing values.
138    ///
139    /// Example: `ez-token --profile prod config set --tenant-id YOUR_TENANT`
140    Set {
141        /// The configuration parameters to update.
142        #[command(flatten)]
143        auth: AuthArgs,
144    },
145
146    /// Display the active profile's current configuration.
147    ///
148    /// Example: `ez-token --profile prod config show`
149    Show,
150
151    /// List all saved profiles.
152    ///
153    /// The active profile is highlighted with an asterisk (*).
154    List,
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use clap::CommandFactory;
161
162    #[test]
163    fn verify_cli() {
164        Cli::command().debug_assert();
165    }
166
167    #[test]
168    fn test_no_command_uses_default_profile() {
169        let cli = Cli::try_parse_from(["ez-token"]).unwrap();
170
171        assert_eq!(
172            cli,
173            Cli {
174                profile: "default".to_string(),
175                command: None,
176            }
177        );
178    }
179
180    #[test]
181    fn test_global_profile_flag() {
182        let cli = Cli::try_parse_from(["ez-token", "--profile", "prod", "config", "show"]).unwrap();
183
184        assert_eq!(
185            cli,
186            Cli {
187                profile: "prod".to_string(),
188                command: Some(Commands::Config {
189                    action: ConfigAction::Show,
190                }),
191            }
192        );
193    }
194
195    #[test]
196    fn test_login_command_with_defaults() {
197        let cli = Cli::try_parse_from(["ez-token", "login"]).unwrap();
198
199        assert_eq!(
200            cli.command,
201            Some(Commands::Login {
202                auth: AuthArgs::default(),
203                port: 3000,
204            })
205        );
206    }
207
208    #[test]
209    fn test_login_command_with_auth_args_and_custom_port() {
210        let cli = Cli::try_parse_from([
211            "ez-token",
212            "login",
213            "--provider",
214            "auth0",
215            "--domain",
216            "my-org.eu.auth0.com",
217            "--client-id",
218            "12345",
219            "--port",
220            "8080",
221        ])
222        .unwrap();
223
224        assert_eq!(
225            cli.command,
226            Some(Commands::Login {
227                auth: AuthArgs {
228                    provider: Some(ProviderKind::Auth0),
229                    domain: Some("my-org.eu.auth0.com".to_string()),
230                    client_id: Some("12345".to_string()),
231                    ..Default::default()
232                },
233                port: 8080,
234            })
235        );
236    }
237
238    #[test]
239    fn test_m2m_command() {
240        let cli = Cli::try_parse_from([
241            "ez-token",
242            "m2m",
243            "--tenant-id",
244            "common",
245            "--client-secret",
246            "super-secret",
247        ])
248        .unwrap();
249
250        assert_eq!(
251            cli.command,
252            Some(Commands::M2m {
253                auth: AuthArgs {
254                    tenant_id: Some("common".to_string()),
255                    ..Default::default()
256                },
257                client_secret: Some("super-secret".to_string()),
258            })
259        );
260    }
261
262    #[test]
263    fn test_config_set_command() {
264        let cli = Cli::try_parse_from([
265            "ez-token",
266            "--profile",
267            "dev",
268            "config",
269            "set",
270            "--scopes",
271            "api://ez/.default",
272        ])
273        .unwrap();
274
275        assert_eq!(cli.profile, "dev");
276        assert_eq!(
277            cli.command,
278            Some(Commands::Config {
279                action: ConfigAction::Set {
280                    auth: AuthArgs {
281                        scopes: Some("api://ez/.default".to_string()),
282                        ..Default::default()
283                    }
284                }
285            })
286        );
287    }
288
289    #[test]
290    fn test_config_list_command() {
291        let cli = Cli::try_parse_from(["ez-token", "config", "list"]).unwrap();
292
293        assert_eq!(
294            cli.command,
295            Some(Commands::Config {
296                action: ConfigAction::List,
297            })
298        );
299    }
300}