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    CommandResult, CommandSpec, Credential, GroupSpec, Result, RuntimeCommandSpec,
8    RuntimeGroupSpec, Tier,
9};
10
11/// Data rendered after a successful `auth login`.
12#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
13pub struct AuthLoginResult {
14    /// Provider used for login.
15    pub provider: String,
16    /// Environment used for login.
17    pub env: String,
18    /// Authenticated identity.
19    pub identity: String,
20    /// Credential expiration timestamp.
21    pub expires_at: String,
22}
23
24/// Data rendered by `auth status`.
25#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
26pub struct AuthStatusEntry {
27    /// Provider name.
28    pub provider: String,
29    /// Environment name.
30    pub env: String,
31    /// Cached identity, empty when missing or unavailable.
32    pub identity: String,
33    /// Credential expiration timestamp, empty when missing or unavailable.
34    pub expires_at: String,
35    /// Whether the cached credential is expired or unavailable.
36    pub expired: bool,
37}
38
39/// Builds the built-in runtime `auth` command group.
40#[must_use]
41pub fn auth_command_group(default_provider: &str, registered_names: &[String]) -> RuntimeGroupSpec {
42    let effective_default = effective_default_provider(default_provider, registered_names);
43    RuntimeGroupSpec::new(GroupSpec::new("auth", "Manage authentication credentials"))
44        .with_command(RuntimeCommandSpec::new_with_context(
45            CommandSpec::new("login", "Authenticate and cache credentials")
46                .with_system("auth")
47                .with_tier(Tier::Mutate)
48                .mutates(true)
49                .no_auth(true)
50                .with_arg(provider_arg(&effective_default, registered_names))
51                .with_arg(Arg::new("env").long("env").value_name("ENV").required(true))
52                .with_arg(
53                    Arg::new("scope")
54                        .long("scope")
55                        .short('s')
56                        .value_name("SCOPE")
57                        // One scope per occurrence, repeatable: `--scope a --scope b`.
58                        // `ArgAction::Append` requires a value, so a bare `--scope`
59                        // is rejected rather than silently doing nothing.
60                        .action(ArgAction::Append)
61                        .help("Additional OAuth scope to request (repeatable, one per flag)"),
62                ),
63            async |context| {
64                let provider = string_arg(&context.args, "provider");
65                let env = string_arg(&context.args, "env");
66                let scopes = string_vec_arg(&context.args, "scope");
67                serde_json::to_value(
68                    login_and_build_with_scopes(&context.middleware.auth, &provider, &env, &scopes)
69                        .await?,
70                )
71                .map(CommandResult::new)
72                .map_err(Into::into)
73            },
74        ))
75        .with_command(RuntimeCommandSpec::new_with_context(
76            CommandSpec::new("status", "Show cached credential status")
77                .with_system("auth")
78                .no_auth(true)
79                .with_arg(provider_arg(&effective_default, registered_names))
80                .with_arg(Arg::new("env").long("env").value_name("ENV")),
81            async |context| {
82                let provider = string_arg(&context.args, "provider");
83                let env = string_arg(&context.args, "env");
84                status_result(&context.middleware.auth, &provider, &env)
85                    .await
86                    .map(CommandResult::new)
87            },
88        ))
89        .with_command(RuntimeCommandSpec::new_with_context(
90            CommandSpec::new("logout", "Clear cached credentials")
91                .with_system("auth")
92                .with_tier(Tier::Mutate)
93                .mutates(true)
94                .no_auth(true)
95                .with_arg(provider_arg(&effective_default, registered_names))
96                .with_arg(Arg::new("env").long("env").value_name("ENV").required(true)),
97            async |context| {
98                let provider = string_arg(&context.args, "provider");
99                let env = string_arg(&context.args, "env");
100                logout_result(&context.middleware.auth, &provider, &env)
101                    .await
102                    .map(CommandResult::new)
103            },
104        ))
105}
106
107fn effective_default_provider(default_provider: &str, registered_names: &[String]) -> String {
108    if default_provider.is_empty() {
109        registered_names.first().cloned().unwrap_or_default()
110    } else {
111        default_provider.to_owned()
112    }
113}
114
115fn string_arg(args: &serde_json::Map<String, Value>, name: &str) -> String {
116    args.get(name)
117        .and_then(Value::as_str)
118        .unwrap_or_default()
119        .to_owned()
120}
121
122/// Reads a repeatable string argument as a `Vec<String>`, accepting either a
123/// JSON array (multiple values) or a single string.
124fn string_vec_arg(args: &serde_json::Map<String, Value>, name: &str) -> Vec<String> {
125    match args.get(name) {
126        // Drop empty strings: an empty scope token is never valid and only
127        // produces confusing auth-server errors.
128        Some(Value::Array(items)) => items
129            .iter()
130            .filter_map(Value::as_str)
131            .filter(|value| !value.is_empty())
132            .map(str::to_owned)
133            .collect(),
134        Some(Value::String(value)) if !value.is_empty() => vec![value.clone()],
135        _ => Vec::new(),
136    }
137}
138
139fn provider_arg(default_provider: &str, registered_names: &[String]) -> Arg {
140    let names = registered_names.join(", ");
141    let help = format!("Auth provider name (one of: [{names}])");
142    let mut arg = Arg::new("provider")
143        .long("provider")
144        .value_name("NAME")
145        .help(help);
146    if !default_provider.is_empty() {
147        arg = arg.default_value(default_provider.to_owned());
148    }
149    arg
150}
151
152/// Runs dispatcher login and converts the credential to renderable output.
153pub async fn login_and_build(
154    dispatcher: &Dispatcher,
155    provider: &str,
156    env: &str,
157) -> Result<AuthLoginResult> {
158    login_and_build_with_scopes(dispatcher, provider, env, &[]).await
159}
160
161/// Like [`login_and_build`], but requests `additional_scopes` on top of the
162/// provider's defaults (used by `auth login --scope`).
163pub async fn login_and_build_with_scopes(
164    dispatcher: &Dispatcher,
165    provider: &str,
166    env: &str,
167    additional_scopes: &[String],
168) -> Result<AuthLoginResult> {
169    let credential = dispatcher
170        .login_with_scopes(provider, env, additional_scopes)
171        .await?;
172    Ok(AuthLoginResult {
173        provider: provider.to_owned(),
174        env: env.to_owned(),
175        identity: credential.identity,
176        expires_at: credential.expires_at,
177    })
178}
179
180/// Builds the JSON value rendered by `auth status`.
181pub async fn status_result(dispatcher: &Dispatcher, provider: &str, env: &str) -> Result<Value> {
182    if !provider.is_empty() && !env.is_empty() {
183        let credential = dispatcher.status(provider, env).await?;
184        return serde_json::to_value(to_status_entry(provider, env, Some(&credential)))
185            .map_err(Into::into);
186    }
187
188    let out = dispatcher
189        .all_statuses()
190        .await
191        .iter()
192        .map(|entry| {
193            if entry.error.is_some() {
194                AuthStatusEntry {
195                    provider: entry.provider.clone(),
196                    env: entry.env.clone(),
197                    identity: String::new(),
198                    expires_at: String::new(),
199                    expired: true,
200                }
201            } else {
202                to_status_entry(&entry.provider, &entry.env, entry.credential.as_ref())
203            }
204        })
205        .collect::<Vec<_>>();
206    serde_json::to_value(out).map_err(Into::into)
207}
208
209/// Runs dispatcher logout and builds the renderable result.
210pub async fn logout_result(dispatcher: &Dispatcher, provider: &str, env: &str) -> Result<Value> {
211    dispatcher.logout(provider, env).await?;
212    Ok(json!({
213        "provider": provider,
214        "env": env,
215        "status": "logged out",
216    }))
217}
218
219/// Converts an optional credential into an auth status row.
220#[must_use]
221pub fn to_status_entry(
222    provider: &str,
223    env: &str,
224    credential: Option<&Credential>,
225) -> AuthStatusEntry {
226    credential.map_or_else(
227        || AuthStatusEntry {
228            provider: provider.to_owned(),
229            env: env.to_owned(),
230            identity: String::new(),
231            expires_at: String::new(),
232            expired: true,
233        },
234        |credential| AuthStatusEntry {
235            provider: provider.to_owned(),
236            env: env.to_owned(),
237            identity: credential.identity.clone(),
238            expires_at: credential.expires_at.clone(),
239            expired: credential.is_expired(),
240        },
241    )
242}