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#[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").required(true))
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 = 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
122fn string_vec_arg(args: &serde_json::Map<String, Value>, name: &str) -> Vec<String> {
125 match args.get(name) {
126 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
152pub 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
161pub 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
180pub 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
209pub 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#[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}