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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
19#[non_exhaustive]
20pub struct AuthLoginResult {
21 pub provider: String,
23 pub env: String,
25 pub identity: String,
27 pub expires_at: String,
29 #[serde(default)]
34 pub scopes: Vec<String>,
35 #[serde(default)]
39 pub refreshable: bool,
40}
41
42#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
50#[non_exhaustive]
51pub struct AuthStatusEntry {
52 pub provider: String,
54 pub env: String,
56 pub identity: String,
58 pub expires_at: String,
60 #[serde(default)]
63 pub scopes: Vec<String>,
64 pub expired: bool,
66 #[serde(default)]
75 pub refreshable: bool,
76}
77
78#[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 .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
182fn string_vec_arg(args: &serde_json::Map<String, Value>, name: &str) -> Vec<String> {
185 match args.get(name) {
186 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
212pub 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
221pub 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
242pub 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
273pub 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#[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}