Skip to main content

cli_engine/auth/
commands.rs

1use clap::{Arg, ArgAction};
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4
5use super::Dispatcher;
6use crate::{
7    CliCoreError, CommandContext, CommandResult, CommandSpec, Credential, GroupSpec, Result,
8    RuntimeCommandSpec, RuntimeGroupSpec, Tier,
9};
10
11/// Data rendered after a successful `auth login`.
12///
13/// Built by [`login_and_build`]/[`login_and_build_with_scopes`] from the
14/// dispatcher's [`Credential`]. Consumer code receives it as a command
15/// result and should not construct it directly — `#[non_exhaustive]` lets
16/// this framework add fields (as it did for [`refreshable`](Self::refreshable))
17/// without another breaking release.
18#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
19#[non_exhaustive]
20pub struct AuthLoginResult {
21    /// Provider used for login.
22    pub provider: String,
23    /// Environment used for login.
24    pub env: String,
25    /// Authenticated identity.
26    pub identity: String,
27    /// Credential expiration timestamp.
28    pub expires_at: String,
29    /// OAuth scopes granted to the new credential, empty when the provider
30    /// doesn't expose scope data (e.g. PATs). Mirrors
31    /// [`AuthStatusEntry::scopes`], so `auth login`'s output is consistent
32    /// with a subsequent `auth status`.
33    #[serde(default)]
34    pub scopes: Vec<String>,
35    /// Whether a renewal mechanism was issued for the new credential (e.g.
36    /// an OAuth refresh token) — reflects presence, not verified validity.
37    /// Mirrors [`AuthStatusEntry::refreshable`].
38    #[serde(default)]
39    pub refreshable: bool,
40}
41
42/// Data rendered by `auth status`.
43///
44/// Built by [`status_result`]/[`to_status_entry`] from the dispatcher's
45/// [`Credential`] (or its absence). Consumer code receives it as a command
46/// result and should not construct it directly — `#[non_exhaustive]` lets
47/// this framework add fields (as it did for [`refreshable`](Self::refreshable))
48/// without another breaking release.
49#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
50#[non_exhaustive]
51pub struct AuthStatusEntry {
52    /// Provider name.
53    pub provider: String,
54    /// Environment name.
55    pub env: String,
56    /// Cached identity, empty when missing or unavailable.
57    pub identity: String,
58    /// Credential expiration timestamp, empty when missing or unavailable.
59    pub expires_at: String,
60    /// OAuth scopes granted to the cached credential, empty when missing,
61    /// unavailable, or the provider doesn't expose scope data (e.g. PATs).
62    #[serde(default)]
63    pub scopes: Vec<String>,
64    /// Whether the cached credential is expired or unavailable.
65    pub expired: bool,
66    /// Whether a renewal mechanism (e.g. an OAuth refresh token) is on file
67    /// for this credential — reflects presence, not verified validity, since
68    /// that's only discoverable by attempting a renewal. Always `false` when
69    /// there is no cached credential, or the provider has no such mechanism
70    /// (e.g. PATs) — `expired && !refreshable` means the next command
71    /// definitely needs `auth login`, while `expired && refreshable` means it
72    /// will attempt a silent renewal first and only fall back to an
73    /// interactive login if that fails.
74    #[serde(default)]
75    pub refreshable: bool,
76}
77
78/// Builds the built-in runtime `auth` command group.
79#[must_use]
80pub fn auth_command_group(default_provider: &str, registered_names: &[String]) -> RuntimeGroupSpec {
81    let effective_default = effective_default_provider(default_provider, registered_names);
82    RuntimeGroupSpec::new(GroupSpec::new("auth", "Manage authentication credentials"))
83        .with_command(RuntimeCommandSpec::new_with_context(
84            CommandSpec::new("login", "Authenticate and cache credentials")
85                .with_system("auth")
86                .with_tier(Tier::Mutate)
87                .mutates(true)
88                .no_auth(true)
89                .with_arg(provider_arg(&effective_default, registered_names))
90                .with_arg(Arg::new("env").long("env").value_name("ENV"))
91                .with_arg(
92                    Arg::new("scope")
93                        .long("scope")
94                        .short('s')
95                        .value_name("SCOPE")
96                        // One scope per occurrence, repeatable: `--scope a --scope b`.
97                        // `ArgAction::Append` requires a value, so a bare `--scope`
98                        // is rejected rather than silently doing nothing.
99                        .action(ArgAction::Append)
100                        .help("Additional OAuth scope to request (repeatable, one per flag)"),
101                ),
102            async |context| {
103                let provider = string_arg(&context.args, "provider");
104                let env = env_arg(&context)?;
105                let scopes = string_vec_arg(&context.args, "scope");
106                serde_json::to_value(
107                    login_and_build_with_scopes(&context.middleware.auth, &provider, &env, &scopes)
108                        .await?,
109                )
110                .map(CommandResult::new)
111                .map_err(Into::into)
112            },
113        ))
114        .with_command(RuntimeCommandSpec::new_with_context(
115            CommandSpec::new("status", "Show cached credential status")
116                .with_system("auth")
117                .no_auth(true)
118                .with_arg(provider_arg(&effective_default, registered_names))
119                .with_arg(Arg::new("env").long("env").value_name("ENV")),
120            async |context| {
121                let provider = string_arg(&context.args, "provider");
122                let env = string_arg(&context.args, "env");
123                status_result(&context.middleware.auth, &provider, &env)
124                    .await
125                    .map(CommandResult::new)
126            },
127        ))
128        .with_command(RuntimeCommandSpec::new_with_context(
129            CommandSpec::new("logout", "Clear cached credentials")
130                .with_system("auth")
131                .with_tier(Tier::Mutate)
132                .mutates(true)
133                .no_auth(true)
134                .with_arg(provider_arg(&effective_default, registered_names))
135                .with_arg(Arg::new("env").long("env").value_name("ENV")),
136            async |context| {
137                let provider = string_arg(&context.args, "provider");
138                let env = env_arg(&context)?;
139                logout_result(&context.middleware.auth, &provider, &env)
140                    .await
141                    .map(CommandResult::new)
142            },
143        ))
144}
145
146fn effective_default_provider(default_provider: &str, registered_names: &[String]) -> String {
147    if default_provider.is_empty() {
148        registered_names.first().cloned().unwrap_or_default()
149    } else {
150        default_provider.to_owned()
151    }
152}
153
154fn string_arg(args: &serde_json::Map<String, Value>, name: &str) -> String {
155    args.get(name)
156        .and_then(Value::as_str)
157        .unwrap_or_default()
158        .to_owned()
159}
160
161fn env_arg(context: &CommandContext) -> Result<String> {
162    if let Some(env) = context.user_args.get("env").and_then(Value::as_str) {
163        if env.is_empty() {
164            return Err(missing_env_error());
165        }
166        return Ok(env.to_owned());
167    }
168
169    if !context.middleware.env.is_empty() {
170        return Ok(context.middleware.env.clone());
171    }
172
173    Err(missing_env_error())
174}
175
176fn missing_env_error() -> CliCoreError {
177    CliCoreError::message(
178        "auth: missing environment; pass --env or configure a default environment",
179    )
180}
181
182/// Reads a repeatable string argument as a `Vec<String>`, accepting either a
183/// JSON array (multiple values) or a single string.
184fn string_vec_arg(args: &serde_json::Map<String, Value>, name: &str) -> Vec<String> {
185    match args.get(name) {
186        // Drop empty strings: an empty scope token is never valid and only
187        // produces confusing auth-server errors.
188        Some(Value::Array(items)) => items
189            .iter()
190            .filter_map(Value::as_str)
191            .filter(|value| !value.is_empty())
192            .map(str::to_owned)
193            .collect(),
194        Some(Value::String(value)) if !value.is_empty() => vec![value.clone()],
195        _ => Vec::new(),
196    }
197}
198
199fn provider_arg(default_provider: &str, registered_names: &[String]) -> Arg {
200    let names = registered_names.join(", ");
201    let help = format!("Auth provider name (one of: [{names}])");
202    let mut arg = Arg::new("provider")
203        .long("provider")
204        .value_name("NAME")
205        .help(help);
206    if !default_provider.is_empty() {
207        arg = arg.default_value(default_provider.to_owned());
208    }
209    arg
210}
211
212/// Runs dispatcher login and converts the credential to renderable output.
213pub async fn login_and_build(
214    dispatcher: &Dispatcher,
215    provider: &str,
216    env: &str,
217) -> Result<AuthLoginResult> {
218    login_and_build_with_scopes(dispatcher, provider, env, &[]).await
219}
220
221/// Like [`login_and_build`], but requests `additional_scopes` on top of the
222/// provider's defaults (used by `auth login --scope`).
223pub async fn login_and_build_with_scopes(
224    dispatcher: &Dispatcher,
225    provider: &str,
226    env: &str,
227    additional_scopes: &[String],
228) -> Result<AuthLoginResult> {
229    let credential = dispatcher
230        .login_with_scopes(provider, env, additional_scopes)
231        .await?;
232    Ok(AuthLoginResult {
233        provider: provider.to_owned(),
234        env: env.to_owned(),
235        identity: credential.identity,
236        expires_at: credential.expires_at,
237        scopes: credential.scopes,
238        refreshable: credential.refreshable,
239    })
240}
241
242/// Builds the JSON value rendered by `auth status`.
243pub async fn status_result(dispatcher: &Dispatcher, provider: &str, env: &str) -> Result<Value> {
244    if !provider.is_empty() && !env.is_empty() {
245        let credential = dispatcher.status(provider, env).await?;
246        return serde_json::to_value(to_status_entry(provider, env, Some(&credential)))
247            .map_err(Into::into);
248    }
249
250    let out = dispatcher
251        .all_statuses()
252        .await
253        .iter()
254        .map(|entry| {
255            if entry.error.is_some() {
256                AuthStatusEntry {
257                    provider: entry.provider.clone(),
258                    env: entry.env.clone(),
259                    identity: String::new(),
260                    expires_at: String::new(),
261                    scopes: Vec::new(),
262                    expired: true,
263                    refreshable: false,
264                }
265            } else {
266                to_status_entry(&entry.provider, &entry.env, entry.credential.as_ref())
267            }
268        })
269        .collect::<Vec<_>>();
270    serde_json::to_value(out).map_err(Into::into)
271}
272
273/// Runs dispatcher logout and builds the renderable result.
274pub async fn logout_result(dispatcher: &Dispatcher, provider: &str, env: &str) -> Result<Value> {
275    dispatcher.logout(provider, env).await?;
276    Ok(json!({
277        "provider": provider,
278        "env": env,
279        "status": "logged out",
280    }))
281}
282
283/// Converts an optional credential into an auth status row.
284#[must_use]
285pub fn to_status_entry(
286    provider: &str,
287    env: &str,
288    credential: Option<&Credential>,
289) -> AuthStatusEntry {
290    credential.map_or_else(
291        || AuthStatusEntry {
292            provider: provider.to_owned(),
293            env: env.to_owned(),
294            identity: String::new(),
295            expires_at: String::new(),
296            scopes: Vec::new(),
297            expired: true,
298            refreshable: false,
299        },
300        |credential| AuthStatusEntry {
301            provider: provider.to_owned(),
302            env: env.to_owned(),
303            identity: credential.identity.clone(),
304            expires_at: credential.expires_at.clone(),
305            scopes: credential.scopes.clone(),
306            expired: credential.is_expired(),
307            refreshable: credential.refreshable,
308        },
309    )
310}