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)]
13pub struct AuthLoginResult {
14 pub provider: String,
16 pub env: String,
18 pub identity: String,
20 pub expires_at: String,
22}
23
24#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
26pub struct AuthStatusEntry {
27 pub provider: String,
29 pub env: String,
31 pub identity: String,
33 pub expires_at: String,
35 pub expired: bool,
37}
38
39#[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"))
52 .with_arg(
53 Arg::new("scope")
54 .long("scope")
55 .short('s')
56 .value_name("SCOPE")
57 .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 = env_arg(&context)?;
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")),
97 async |context| {
98 let provider = string_arg(&context.args, "provider");
99 let env = env_arg(&context)?;
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
122fn env_arg(context: &CommandContext) -> Result<String> {
123 if let Some(env) = context.user_args.get("env").and_then(Value::as_str) {
124 if env.is_empty() {
125 return Err(missing_env_error());
126 }
127 return Ok(env.to_owned());
128 }
129
130 if !context.middleware.env.is_empty() {
131 return Ok(context.middleware.env.clone());
132 }
133
134 Err(missing_env_error())
135}
136
137fn missing_env_error() -> CliCoreError {
138 CliCoreError::message(
139 "auth: missing environment; pass --env or configure a default environment",
140 )
141}
142
143fn string_vec_arg(args: &serde_json::Map<String, Value>, name: &str) -> Vec<String> {
146 match args.get(name) {
147 Some(Value::Array(items)) => items
150 .iter()
151 .filter_map(Value::as_str)
152 .filter(|value| !value.is_empty())
153 .map(str::to_owned)
154 .collect(),
155 Some(Value::String(value)) if !value.is_empty() => vec![value.clone()],
156 _ => Vec::new(),
157 }
158}
159
160fn provider_arg(default_provider: &str, registered_names: &[String]) -> Arg {
161 let names = registered_names.join(", ");
162 let help = format!("Auth provider name (one of: [{names}])");
163 let mut arg = Arg::new("provider")
164 .long("provider")
165 .value_name("NAME")
166 .help(help);
167 if !default_provider.is_empty() {
168 arg = arg.default_value(default_provider.to_owned());
169 }
170 arg
171}
172
173pub async fn login_and_build(
175 dispatcher: &Dispatcher,
176 provider: &str,
177 env: &str,
178) -> Result<AuthLoginResult> {
179 login_and_build_with_scopes(dispatcher, provider, env, &[]).await
180}
181
182pub async fn login_and_build_with_scopes(
185 dispatcher: &Dispatcher,
186 provider: &str,
187 env: &str,
188 additional_scopes: &[String],
189) -> Result<AuthLoginResult> {
190 let credential = dispatcher
191 .login_with_scopes(provider, env, additional_scopes)
192 .await?;
193 Ok(AuthLoginResult {
194 provider: provider.to_owned(),
195 env: env.to_owned(),
196 identity: credential.identity,
197 expires_at: credential.expires_at,
198 })
199}
200
201pub async fn status_result(dispatcher: &Dispatcher, provider: &str, env: &str) -> Result<Value> {
203 if !provider.is_empty() && !env.is_empty() {
204 let credential = dispatcher.status(provider, env).await?;
205 return serde_json::to_value(to_status_entry(provider, env, Some(&credential)))
206 .map_err(Into::into);
207 }
208
209 let out = dispatcher
210 .all_statuses()
211 .await
212 .iter()
213 .map(|entry| {
214 if entry.error.is_some() {
215 AuthStatusEntry {
216 provider: entry.provider.clone(),
217 env: entry.env.clone(),
218 identity: String::new(),
219 expires_at: String::new(),
220 expired: true,
221 }
222 } else {
223 to_status_entry(&entry.provider, &entry.env, entry.credential.as_ref())
224 }
225 })
226 .collect::<Vec<_>>();
227 serde_json::to_value(out).map_err(Into::into)
228}
229
230pub async fn logout_result(dispatcher: &Dispatcher, provider: &str, env: &str) -> Result<Value> {
232 dispatcher.logout(provider, env).await?;
233 Ok(json!({
234 "provider": provider,
235 "env": env,
236 "status": "logged out",
237 }))
238}
239
240#[must_use]
242pub fn to_status_entry(
243 provider: &str,
244 env: &str,
245 credential: Option<&Credential>,
246) -> AuthStatusEntry {
247 credential.map_or_else(
248 || AuthStatusEntry {
249 provider: provider.to_owned(),
250 env: env.to_owned(),
251 identity: String::new(),
252 expires_at: String::new(),
253 expired: true,
254 },
255 |credential| AuthStatusEntry {
256 provider: provider.to_owned(),
257 env: env.to_owned(),
258 identity: credential.identity.clone(),
259 expires_at: credential.expires_at.clone(),
260 expired: credential.is_expired(),
261 },
262 )
263}