Skip to main content

cli_engine/auth/
exec.rs

1use std::{io::ErrorKind, path::PathBuf, process::Stdio, time::Duration};
2
3use serde::{Deserialize, Serialize};
4use tokio::{io::AsyncWriteExt, process::Command, time};
5
6use super::{AuthProvider, Credential};
7use crate::{CliCoreError, Result};
8
9/// Provider action requesting a credential.
10pub const ACTION_AUTHENTICATE: &str = "authenticate";
11/// Provider action requesting cached credential status.
12pub const ACTION_STATUS: &str = "status";
13/// Provider action clearing cached credentials.
14pub const ACTION_LOGOUT: &str = "logout";
15/// Provider action listing cached environments.
16pub const ACTION_LIST_ENVIRONMENTS: &str = "list-environments";
17/// Legacy provider action listing cached realms.
18pub const ACTION_LIST_REALMS: &str = "list-realms";
19
20/// JSON payload sent to an external auth provider.
21#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
22pub struct AuthnRequest {
23    /// Provider action.
24    pub action: String,
25    /// Provider name.
26    pub provider: String,
27    /// Environment name.
28    pub env: String,
29    /// Deprecated alias of `env` kept for older provider binaries.
30    #[serde(skip_serializing_if = "String::is_empty")]
31    pub realm: String,
32    /// Colon-separated command path.
33    #[serde(skip_serializing_if = "String::is_empty")]
34    pub command: String,
35    /// Risk tier.
36    #[serde(skip_serializing_if = "String::is_empty")]
37    pub tier: String,
38}
39
40/// JSON payload returned by providers for `list-environments`.
41#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
42pub struct EnvironmentsResponse {
43    /// Environment names with cached credentials.
44    pub environments: Vec<String>,
45}
46
47/// Auth provider implemented by spawning an external provider command.
48///
49/// The provider receives [`AuthnRequest`] JSON on stdin and returns credential
50/// JSON on stdout. This keeps auth flows language-agnostic and easy to test.
51#[derive(Clone, Debug)]
52pub struct ExecProvider {
53    provider_name: String,
54    command: PathBuf,
55    args: Vec<String>,
56    timeout: Option<Duration>,
57}
58
59impl ExecProvider {
60    /// Creates an exec provider with no extra arguments or timeout.
61    #[must_use]
62    pub fn new(provider_name: impl Into<String>, command: impl Into<PathBuf>) -> Self {
63        Self {
64            provider_name: provider_name.into(),
65            command: command.into(),
66            args: Vec::new(),
67            timeout: None,
68        }
69    }
70
71    /// Adds extra command-line arguments passed to the provider binary.
72    #[must_use]
73    pub fn with_args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
74        self.args = args.into_iter().map(Into::into).collect();
75        self
76    }
77
78    /// Sets a provider process timeout. A zero duration disables the timeout.
79    #[must_use]
80    pub fn with_timeout(mut self, timeout: Duration) -> Self {
81        self.timeout = (!timeout.is_zero()).then_some(timeout);
82        self
83    }
84
85    /// Executes an arbitrary provider request and decodes a credential response.
86    pub async fn exec_with_request(&self, request: &AuthnRequest) -> Result<Credential> {
87        let out = self.exec_raw(request).await?;
88        serde_json::from_slice(&out).map_err(|err| {
89            CliCoreError::message(format!(
90                "auth: parse credential from {}: {err}",
91                self.command.display()
92            ))
93        })
94    }
95
96    async fn exec_action(&self, request: &AuthnRequest) -> Result<Vec<u8>> {
97        self.exec_raw(request).await
98    }
99
100    async fn exec_raw(&self, request: &AuthnRequest) -> Result<Vec<u8>> {
101        let request_json = serde_json::to_vec(request)?;
102        let mut command = Command::new(&self.command);
103        command
104            .args(&self.args)
105            .kill_on_drop(true)
106            .stdin(Stdio::piped())
107            .stdout(Stdio::piped())
108            .stderr(Stdio::piped());
109
110        let mut child = command.spawn().map_err(|err| self.exec_error(err, ""))?;
111        let Some(mut stdin) = child.stdin.take() else {
112            return Err(CliCoreError::message("auth: provider stdin unavailable"));
113        };
114        if let Err(err) = stdin.write_all(&request_json).await
115            && err.kind() != ErrorKind::BrokenPipe
116        {
117            return Err(self.exec_error(err, ""));
118        }
119        drop(stdin);
120
121        let output_fut = child.wait_with_output();
122        let output = if let Some(timeout) = self.timeout {
123            match time::timeout(timeout, output_fut).await {
124                Ok(result) => result.map_err(|err| self.exec_error(err, ""))?,
125                Err(_) => {
126                    return Err(CliCoreError::message(format!(
127                        "auth: exec {}: signal: killed: ",
128                        self.command.display()
129                    )));
130                }
131            }
132        } else {
133            output_fut.await.map_err(|err| self.exec_error(err, ""))?
134        };
135
136        if output.status.success() {
137            return Ok(output.stdout);
138        }
139
140        let stderr = String::from_utf8_lossy(&output.stderr);
141        Err(CliCoreError::message(format!(
142            "auth: exec {}: {}: {stderr}",
143            self.command.display(),
144            compat_exit_status(&output.status)
145        )))
146    }
147
148    fn request(&self, action: &str, env: &str, command: &str, tier: &str) -> AuthnRequest {
149        AuthnRequest {
150            action: action.to_owned(),
151            provider: self.provider_name.clone(),
152            env: env.to_owned(),
153            realm: env.to_owned(),
154            command: command.to_owned(),
155            tier: tier.to_owned(),
156        }
157    }
158
159    async fn list_realms_compat(&self) -> Result<Vec<String>> {
160        let out = self
161            .exec_raw(&AuthnRequest {
162                action: ACTION_LIST_REALMS.to_owned(),
163                provider: String::new(),
164                env: String::new(),
165                realm: String::new(),
166                command: String::new(),
167                tier: String::new(),
168            })
169            .await?;
170        #[derive(Deserialize)]
171        struct RealmsResponse {
172            #[serde(default)]
173            realms: Vec<String>,
174        }
175        let response: RealmsResponse = serde_json::from_slice(&out).map_err(|err| {
176            CliCoreError::message(format!(
177                "auth: parse realms from {}: {err}",
178                self.command.display()
179            ))
180        })?;
181        Ok(response.realms)
182    }
183
184    fn exec_error(&self, err: std::io::Error, stderr: &str) -> CliCoreError {
185        CliCoreError::message(format!(
186            "auth: exec {}: {err}: {stderr}",
187            self.command.display()
188        ))
189    }
190}
191
192#[cfg(unix)]
193fn compat_exit_status(status: &std::process::ExitStatus) -> String {
194    use std::os::unix::process::ExitStatusExt;
195    if let Some(code) = status.code() {
196        return format!("exit status {code}");
197    }
198    if let Some(signal) = status.signal() {
199        return format!("signal: {signal}");
200    }
201    status.to_string()
202}
203
204#[cfg(not(unix))]
205fn compat_exit_status(status: &std::process::ExitStatus) -> String {
206    if let Some(code) = status.code() {
207        return format!("exit status {code}");
208    }
209    status.to_string()
210}
211
212#[async_trait::async_trait]
213impl AuthProvider for ExecProvider {
214    fn name(&self) -> &str {
215        &self.provider_name
216    }
217
218    async fn get_credential(&self, env: &str, command: &str, tier: &str) -> Result<Credential> {
219        self.exec_with_request(&self.request(ACTION_AUTHENTICATE, env, command, tier))
220            .await
221    }
222
223    async fn status(&self, env: &str) -> Result<Credential> {
224        self.exec_with_request(&self.request(ACTION_STATUS, env, "", ""))
225            .await
226    }
227
228    async fn logout(&self, env: &str) -> Result<()> {
229        let _output = self
230            .exec_action(&self.request(ACTION_LOGOUT, env, "", ""))
231            .await?;
232        Ok(())
233    }
234
235    async fn list_environments(&self) -> Result<Vec<String>> {
236        let request = AuthnRequest {
237            action: ACTION_LIST_ENVIRONMENTS.to_owned(),
238            provider: String::new(),
239            env: String::new(),
240            realm: String::new(),
241            command: String::new(),
242            tier: String::new(),
243        };
244        let out = match self.exec_raw(&request).await {
245            Ok(out) => out,
246            Err(_) => return self.list_realms_compat().await,
247        };
248
249        if let Ok(response) = serde_json::from_slice::<EnvironmentsResponse>(&out)
250            && !response.environments.is_empty()
251        {
252            return Ok(response.environments);
253        }
254
255        #[derive(Deserialize, Default)]
256        struct RealmsResponse {
257            #[serde(default)]
258            realms: Vec<String>,
259        }
260        if let Ok(response) = serde_json::from_slice::<RealmsResponse>(&out) {
261            return Ok(response.realms);
262        }
263
264        Err(CliCoreError::message(format!(
265            "auth: parse environments from {}",
266            self.command.display()
267        )))
268    }
269}