Skip to main content

ez_token/cli/
auth_params.rs

1use crate::{
2    cli::{
3        args::{AuthArgs, ProviderKind},
4        history::{HistoryKey, HistoryManager, file::FileHistoryManager},
5    },
6    config::cli_config::Profile,
7    services::authentication::urls::IdentityProvider,
8};
9use dialoguer::{Input, Select, theme::ColorfulTheme};
10use miette::{IntoDiagnostic, Result};
11
12/// Resolved authentication parameters required to initiate an OAuth2 flow.
13///
14/// `AuthParams` is constructed by merging three layers of input in order
15/// of priority:
16///
17/// 1. **CLI arguments** — highest priority, passed directly by the user.
18/// 2. **Profile** — values saved via the `config set` subcommand.
19/// 3. **Interactive prompt** — used as a fallback if neither of the above provides a value.
20#[derive(Debug, PartialEq)]
21pub struct AuthParams {
22    /// The resolved identity provider with all required endpoint data.
23    pub provider: IdentityProvider,
24
25    /// The Application (Client) ID registered in Entra ID.
26    pub client_id: String,
27
28    /// The list of OAuth2 scopes to request.
29    ///
30    /// Parsed from a whitespace-separated string (e.g., `"User.Read Mail.Read"`
31    /// becomes `["User.Read", "Mail.Read"]`).
32    pub scopes: Vec<String>,
33}
34
35impl AuthParams {
36    /// Resolves authentication parameters from CLI args, a saved profile, and
37    /// interactive prompts.
38    ///
39    /// # Arguments
40    ///
41    /// * `profile` - The active configuration profile, loaded from disk.
42    /// * `args` - Parsed CLI arguments from [`AuthArgs`], any of which may be `None`.
43    /// * `default_scope_prompt` - A hint shown in the scopes prompt and used
44    ///   as the default value if the user presses Enter without typing.
45    ///
46    /// # Errors
47    ///
48    /// Returns an error if an interactive prompt fails (e.g., due to a non-interactive terminal).
49    pub fn new(profile: &Profile, args: AuthArgs, default_scope_prompt: &str) -> Result<Self> {
50        let theme = ColorfulTheme::default();
51        let manager = FileHistoryManager;
52
53        let provider_kind = match args.provider.or(profile.provider.clone()) {
54            Some(p) => p,
55            None => Self::prompt_provider(&theme)?,
56        };
57
58        let provider = match provider_kind {
59            ProviderKind::Microsoft => {
60                let tenant_id = match args.tenant_id.or(profile.tenant_id.clone()) {
61                    Some(t) => t,
62                    None => Self::prompt_or_history(
63                        &theme,
64                        "Enter Tenant ID",
65                        HistoryKey::Tenant,
66                        None,
67                        &manager,
68                    )?,
69                };
70                IdentityProvider::Microsoft { tenant_id }
71            }
72            ProviderKind::Auth0 => {
73                let domain = match args.domain.or(profile.domain.clone()) {
74                    Some(d) => d,
75                    None => Self::prompt_or_history(
76                        &theme,
77                        "Enter Auth0 Domain (e.g. my-org.eu.auth0.com)",
78                        HistoryKey::Domain,
79                        None,
80                        &manager,
81                    )?,
82                };
83                let audience = match args.audience.or(profile.audience.clone()) {
84                    Some(a) => a,
85                    None => Self::prompt_or_history(
86                        &theme,
87                        "Enter Auth0 Audience (e.g. api://ez-token)",
88                        HistoryKey::Audience,
89                        None,
90                        &manager,
91                    )?,
92                };
93                IdentityProvider::Auth0 { domain, audience }
94            }
95        };
96
97        let client_id = match args.client_id.or(profile.client_id.clone()) {
98            Some(c) => c,
99            None => Self::prompt_or_history(
100                &theme,
101                "Enter Client ID",
102                HistoryKey::Client,
103                None,
104                &manager,
105            )?,
106        };
107
108        let scopes_str = match args.scopes.or(profile.default_scopes.clone()) {
109            Some(s) => s,
110            None => Self::prompt_or_history(
111                &theme,
112                &format!("Enter Scopes (e.g. {})", default_scope_prompt),
113                HistoryKey::Scopes,
114                Some(default_scope_prompt),
115                &manager,
116            )?,
117        };
118
119        let scopes = scopes_str
120            .split_whitespace()
121            .map(|s| s.to_string())
122            .collect();
123
124        Ok(Self {
125            provider,
126            client_id,
127            scopes,
128        })
129    }
130
131    /// Displays an interactive text prompt with history-based completion.
132    ///
133    /// Previous inputs are loaded from disk via the [`HistoryManager`] and
134    /// saved back after the user confirms their input.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if the prompt cannot be displayed or the user input
139    /// cannot be read.
140    fn prompt_or_history(
141        theme: &ColorfulTheme,
142        prompt: &str,
143        key: HistoryKey,
144        default: Option<&str>,
145        manager: &dyn HistoryManager,
146    ) -> Result<String> {
147        let mut history = manager.load(key);
148        let mut input = Input::with_theme(theme)
149            .with_prompt(prompt)
150            .history_with(&mut history);
151
152        if let Some(d) = default {
153            input = input.default(d.to_string());
154        }
155
156        let result = input.interact_text().into_diagnostic()?;
157        manager.save(key, &history);
158        Ok(result)
159    }
160
161    /// Displays an interactive selection prompt for the identity provider.
162    ///
163    /// Renders a list the user navigates with arrow keys and confirms with Enter.
164    /// No typing required.
165    fn prompt_provider(theme: &ColorfulTheme) -> Result<ProviderKind> {
166        let options = vec!["Microsoft Entra ID (Azure AD)", "Auth0"];
167        let selection = Select::with_theme(theme)
168            .with_prompt("Select Identity Provider")
169            .items(&options)
170            .default(0)
171            .interact()
172            .into_diagnostic()?;
173
174        match selection {
175            0 => Ok(ProviderKind::Microsoft),
176            _ => Ok(ProviderKind::Auth0),
177        }
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    // A helper to create an empty profile for testing
186    fn empty_profile() -> Profile {
187        Profile {
188            provider: None,
189            tenant_id: None,
190            domain: None,
191            audience: None,
192            client_id: None,
193            default_scopes: None,
194        }
195    }
196
197    #[test]
198    fn test_cli_args_override_profile_microsoft() {
199        let profile = Profile {
200            provider: Some(ProviderKind::Auth0),
201            tenant_id: Some("profile-tenant".to_string()),
202            client_id: Some("profile-client".to_string()),
203            default_scopes: Some("profile.read".to_string()),
204            ..empty_profile()
205        };
206
207        let args = AuthArgs {
208            provider: Some(ProviderKind::Microsoft),
209            tenant_id: Some("cli-tenant".to_string()),
210            client_id: Some("cli-client".to_string()),
211            scopes: Some("cli.read cli.write".to_string()),
212            ..AuthArgs::default()
213        };
214
215        let params = AuthParams::new(&profile, args, "default.scope").unwrap();
216
217        assert_eq!(
218            params,
219            AuthParams {
220                provider: IdentityProvider::Microsoft {
221                    tenant_id: "cli-tenant".to_string(),
222                },
223                client_id: "cli-client".to_string(),
224                scopes: vec!["cli.read".to_string(), "cli.write".to_string()],
225            }
226        );
227    }
228
229    #[test]
230    fn test_profile_fallback_when_cli_args_missing_auth0() {
231        let profile = Profile {
232            provider: Some(ProviderKind::Auth0),
233            domain: Some("my-org.auth0.com".to_string()),
234            audience: Some("api://my-api".to_string()),
235            client_id: Some("profile-client".to_string()),
236            default_scopes: Some("openid profile".to_string()),
237            ..empty_profile()
238        };
239
240        let args = AuthArgs::default();
241
242        let params = AuthParams::new(&profile, args, "default.scope").unwrap();
243
244        assert_eq!(
245            params,
246            AuthParams {
247                provider: IdentityProvider::Auth0 {
248                    domain: "my-org.auth0.com".to_string(),
249                    audience: "api://my-api".to_string(),
250                },
251                client_id: "profile-client".to_string(),
252                scopes: vec!["openid".to_string(), "profile".to_string()],
253            }
254        );
255    }
256
257    #[test]
258    fn test_mixed_resolution() {
259        let profile = Profile {
260            provider: Some(ProviderKind::Microsoft),
261            tenant_id: Some("common".to_string()),
262            ..empty_profile()
263        };
264
265        let args = AuthArgs {
266            client_id: Some("cli-client".to_string()),
267            scopes: Some("api://ez/.default".to_string()),
268            ..AuthArgs::default()
269        };
270
271        let params = AuthParams::new(&profile, args, "default").unwrap();
272
273        assert_eq!(
274            params,
275            AuthParams {
276                provider: IdentityProvider::Microsoft {
277                    tenant_id: "common".to_string(),
278                },
279                client_id: "cli-client".to_string(),
280                scopes: vec!["api://ez/.default".to_string()],
281            }
282        );
283    }
284
285    #[test]
286    fn test_scopes_whitespace_splitting() {
287        let profile = empty_profile();
288
289        let args = AuthArgs {
290            provider: Some(ProviderKind::Microsoft),
291            tenant_id: Some("common".to_string()),
292            client_id: Some("12345".to_string()),
293
294            scopes: Some("scope1   scope2\tscope3".to_string()),
295            ..AuthArgs::default()
296        };
297
298        let params = AuthParams::new(&profile, args, "default").unwrap();
299
300        assert_eq!(
301            params.scopes,
302            vec![
303                "scope1".to_string(),
304                "scope2".to_string(),
305                "scope3".to_string()
306            ]
307        );
308    }
309}