Skip to main content

qcs_api_client_common/configuration/
external_command.rs

1//! Support for credentials whose access tokens are produced by an external program.
2
3use std::{path::PathBuf, process::Stdio, time::Duration};
4
5use serde::{Deserialize, Serialize};
6use tokio::io::AsyncReadExt as _;
7
8use crate::configuration::tokens::ExternallyManaged;
9
10use super::secret_string::SecretAccessToken;
11
12/// The most stdout (tokens) or stderr (error messages) retained.
13const MAX_PIPE_BYTES: usize = 4 * 1024;
14
15/// How long the command is given to produce an access token when `timeout_seconds` is unset.
16const DEFAULT_TIMEOUT_SECONDS: u64 = 30;
17
18const fn default_timeout_seconds() -> u64 {
19    DEFAULT_TIMEOUT_SECONDS
20}
21
22#[allow(clippy::trivially_copy_pass_by_ref, reason = "serde needs a reference")]
23fn is_default_timeout_seconds(timeout_seconds: &u64) -> bool {
24    timeout_seconds == &DEFAULT_TIMEOUT_SECONDS
25}
26
27/// A credential whose access tokens come from running an external program, rather than from
28/// tokens stored in `secrets.toml`.
29///
30/// This is how you integrate QCS clients with a credential helper in e.g. hosted environments.
31/// Valid tokens are cached in-process so the subcommand is only called when a new token is needed.
32///
33/// With this method, tokens are never written back to `secrets.toml`.
34///
35/// # Schema
36///
37/// ```toml
38/// [credentials.coder.externally_managed]
39/// # Required.
40/// command = "/usr/bin/coder"
41/// # Optional. Passed to the program verbatim; no shell is involved, so no quoting or
42/// # escaping is applied or required.
43/// args = ["external-auth", "access-token", "qcs"]
44/// # Optional, defaults to 30. How long the program may run before it is killed.
45/// timeout_seconds = 30
46/// ```
47///
48/// All [`super::settings::Profile`]s still reference an `auth_server`, although this credential
49/// method does not use the auth server when fetching credential.
50///
51/// # Security
52///
53/// This turns `secrets.toml` into a file that causes code to run: anyone who can write to it, or
54/// to the program it names, can run arbitrary code as you. The program runs with this process's
55/// environment, so it sees the same `PATH` and variables the client does.
56///
57/// The usual precautions apply if that matters for your environment: keep `secrets.toml` writable
58/// only by your own user, and give `command` an absolute path so it can't be resolved through an
59/// attacker-controlled `PATH`.
60///
61/// The subcommand's stderr is included in error messages, so avoid emitting sensitive values there.
62#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
63pub struct ExternallyManagedCredential {
64    /// Path to the program that produces an access token on stdout.
65    pub command: PathBuf,
66    /// Arguments passed to the program verbatim, without shell interpretation.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub args: Vec<String>,
69    /// How long the program may run before it is killed. Defaults to 30 seconds.
70    #[serde(
71        default = "default_timeout_seconds",
72        skip_serializing_if = "is_default_timeout_seconds"
73    )]
74    pub timeout_seconds: u64,
75}
76
77impl From<ExternallyManagedCredential> for ExternallyManaged {
78    fn from(credential: ExternallyManagedCredential) -> Self {
79        Self::from_async(move |_auth_server| {
80            let credential = credential.clone();
81            async move {
82                credential
83                    .request_access_token()
84                    .await
85                    .map(|token| token.secret().to_string())
86                    .map_err(Into::into)
87            }
88        })
89    }
90}
91
92impl ExternallyManagedCredential {
93    /// Run the program and return the access token it prints to stdout.
94    ///
95    /// # Errors
96    ///
97    /// See [`ExternalCommandError`].
98    pub async fn request_access_token(&self) -> Result<SecretAccessToken, ExternalCommandError> {
99        // Boxed because `run`'s state holds a `Command`, which is large enough that leaving it
100        // inline bloats the future of every caller that awaits a token.
101        let output = Box::pin(self.run()).await?;
102
103        let token = String::from_utf8(output)
104            .map_err(|_| ExternalCommandError::InvalidUtf8 {
105                program: self.command.clone(),
106            })?
107            .trim()
108            .to_string();
109
110        if token.is_empty() {
111            return Err(ExternalCommandError::EmptyOutput {
112                program: self.command.clone(),
113            });
114        }
115
116        Ok(SecretAccessToken::from(token))
117    }
118
119    /// Spawn the program and collect its (capped) stdout, enforcing [`Self::timeout_seconds`].
120    async fn run(&self) -> Result<Vec<u8>, ExternalCommandError> {
121        let mut command = tokio::process::Command::new(&self.command);
122
123        command
124            .args(&self.args)
125            .stdin(Stdio::null())
126            .stdout(Stdio::piped())
127            .stderr(Stdio::piped())
128            .kill_on_drop(true);
129
130        let mut child = command
131            .spawn()
132            .map_err(|source| ExternalCommandError::Spawn {
133                program: self.command.clone(),
134                source,
135            })?;
136
137        let stdout = child.stdout.take().expect("stdout is piped");
138        let stderr = child.stderr.take().expect("stderr is piped");
139
140        let timeout = Duration::from_secs(self.timeout_seconds);
141        let (status, stdout_buf, stderr_buf, stdout_truncated) =
142            tokio::time::timeout(timeout, async {
143                let (stdout_result, stderr_result) = futures::future::join(
144                    read_capped(stdout, MAX_PIPE_BYTES),
145                    read_capped(stderr, MAX_PIPE_BYTES),
146                )
147                .await;
148
149                let (stdout_buf, stdout_truncated) =
150                    stdout_result.map_err(|source| ExternalCommandError::Read {
151                        program: self.command.clone(),
152                        source,
153                    })?;
154
155                // Don't fail just because we couldn't capture the error message
156                let (stderr_buf, _) = stderr_result.unwrap_or_default();
157
158                let status = child
159                    .wait()
160                    .await
161                    .map_err(|source| ExternalCommandError::Read {
162                        program: self.command.clone(),
163                        source,
164                    })?;
165
166                Ok::<_, ExternalCommandError>((status, stdout_buf, stderr_buf, stdout_truncated))
167            })
168            .await
169            .map_err(|_| ExternalCommandError::Timeout {
170                program: self.command.clone(),
171                timeout,
172            })??;
173
174        if !status.success() {
175            return Err(ExternalCommandError::ExitStatus {
176                program: self.command.clone(),
177                status: status.to_string(),
178                stderr: String::from_utf8_lossy(&stderr_buf).trim().to_string(),
179            });
180        }
181
182        if stdout_truncated {
183            return Err(ExternalCommandError::OutputTooLarge {
184                program: self.command.clone(),
185                limit: MAX_PIPE_BYTES,
186            });
187        }
188
189        Ok(stdout_buf)
190    }
191}
192
193/// Returns at most `cap` bytes and whether anything was discarded.
194async fn read_capped(
195    mut reader: impl tokio::io::AsyncRead + Unpin,
196    cap: usize,
197) -> std::io::Result<(Vec<u8>, bool)> {
198    let mut retained = Vec::new();
199
200    // Heap-allocated rather than a stack array: this buffer lives across an await point, so an
201    // inline one would be carried in the future of everything that requests a token.
202    let mut chunk = vec![0_u8; 1024];
203    let mut truncated = false;
204
205    loop {
206        let read = reader.read(&mut chunk).await?;
207        if read == 0 {
208            return Ok((retained, truncated));
209        }
210
211        let room = cap - retained.len();
212        if read > room {
213            truncated = true;
214        }
215
216        retained.extend_from_slice(&chunk[..read.min(room)]);
217    }
218}
219
220/// Errors that can occur while getting an access token from an [`ExternallyManagedCredential`].
221#[derive(Debug, thiserror::Error)]
222#[non_exhaustive]
223pub enum ExternalCommandError {
224    /// The program could not be started.
225    #[error("failed to run {program:?}: {source}")]
226    Spawn {
227        /// The program that could not be started.
228        program: PathBuf,
229        /// The underlying error.
230        source: std::io::Error,
231    },
232    /// The program's output could not be read.
233    #[error("failed to read the output of {program:?}: {source}")]
234    Read {
235        /// The program whose output could not be read.
236        program: PathBuf,
237        /// The underlying error.
238        source: std::io::Error,
239    },
240    /// The program did not finish within its timeout and was killed.
241    #[error("{program:?} did not produce an access token within {timeout:?}")]
242    Timeout {
243        /// The program that timed out.
244        program: PathBuf,
245        /// The timeout that elapsed.
246        timeout: Duration,
247    },
248    /// The program exited unsuccessfully.
249    #[error("{program:?} failed with {status}: {stderr}")]
250    ExitStatus {
251        /// The program that failed.
252        program: PathBuf,
253        /// The exit status it reported.
254        status: String,
255        /// What it wrote to stderr, truncated.
256        stderr: String,
257    },
258    /// The program wrote more to stdout than an access token could plausibly need.
259    #[error("{program:?} wrote more than {limit} bytes to stdout")]
260    OutputTooLarge {
261        /// The program that wrote too much.
262        program: PathBuf,
263        /// The limit it exceeded.
264        limit: usize,
265    },
266    /// The program's output was not valid UTF-8.
267    #[error("{program:?} did not write a valid UTF-8 access token to stdout")]
268    InvalidUtf8 {
269        /// The program with invalid output.
270        program: PathBuf,
271    },
272    /// The program wrote nothing to stdout.
273    #[error("{program:?} did not write an access token to stdout")]
274    EmptyOutput {
275        /// The program that wrote nothing.
276        program: PathBuf,
277    },
278}
279
280#[cfg(test)]
281pub(super) use tests::shell;
282
283#[cfg(test)]
284mod tests {
285    use std::path::PathBuf;
286
287    use super::{DEFAULT_TIMEOUT_SECONDS, ExternalCommandError, ExternallyManagedCredential};
288
289    /// An [`ExternallyManagedCredential`] that runs `command` with no arguments and default
290    /// settings.
291    fn credential(command: impl Into<PathBuf>) -> ExternallyManagedCredential {
292        ExternallyManagedCredential {
293            command: command.into(),
294            args: Vec::new(),
295            timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
296        }
297    }
298
299    /// The absolute path of a program that can run a one-line script, plus the flag that
300    /// introduces it. Tests avoid depending on any tool that isn't part of a base install.
301    pub(in super::super) fn shell() -> (PathBuf, &'static str) {
302        #[cfg(windows)]
303        {
304            let comspec = std::env::var_os("COMSPEC")
305                .unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into());
306            (PathBuf::from(comspec), "/C")
307        }
308        #[cfg(not(windows))]
309        {
310            (PathBuf::from("/bin/sh"), "-c")
311        }
312    }
313
314    /// A credential whose program runs `script` through the platform shell.
315    fn script_credential(script: &str) -> ExternallyManagedCredential {
316        let (program, flag) = shell();
317        ExternallyManagedCredential {
318            args: vec![flag.to_string(), script.to_string()],
319            ..credential(program)
320        }
321    }
322
323    /// A credential that prints `token` and exits successfully.
324    fn echo_credential(token: &str) -> ExternallyManagedCredential {
325        #[cfg(windows)]
326        let script = format!("echo {token}");
327        #[cfg(not(windows))]
328        let script = format!("printf '%s\\n' '{token}'");
329        script_credential(&script)
330    }
331
332    #[tokio::test]
333    async fn returns_trimmed_stdout_as_the_access_token() {
334        let token = echo_credential("an-access-token")
335            .request_access_token()
336            .await
337            .expect("the command should produce a token");
338
339        assert_eq!(token.secret(), "an-access-token");
340    }
341
342    #[tokio::test]
343    async fn reports_stderr_when_the_command_fails() {
344        #[cfg(windows)]
345        let script = "echo something went wrong 1>&2 && exit 3";
346        #[cfg(not(windows))]
347        let script = "echo 'something went wrong' >&2; exit 3";
348
349        let error = script_credential(script)
350            .request_access_token()
351            .await
352            .expect_err("a failing command should be an error");
353
354        let message = error.to_string();
355        assert!(
356            message.contains("something went wrong"),
357            "stderr should be reported: {message}"
358        );
359    }
360
361    #[tokio::test]
362    async fn times_out_a_command_that_hangs() {
363        #[cfg(windows)]
364        let script = "ping -n 30 127.0.0.1 > nul";
365        #[cfg(not(windows))]
366        let script = "sleep 30";
367
368        let error = ExternallyManagedCredential {
369            timeout_seconds: 1,
370            ..script_credential(script)
371        }
372        .request_access_token()
373        .await
374        .expect_err("a hanging command should time out");
375
376        assert!(
377            matches!(error, ExternalCommandError::Timeout { .. }),
378            "unexpected error: {error}"
379        );
380    }
381
382    #[tokio::test]
383    async fn rejects_empty_output() {
384        let error = script_credential("exit 0")
385            .request_access_token()
386            .await
387            .expect_err("a command that prints nothing should be an error");
388
389        assert!(
390            matches!(error, ExternalCommandError::EmptyOutput { .. }),
391            "unexpected error: {error}"
392        );
393    }
394
395    #[tokio::test]
396    async fn rejects_output_that_is_too_large() {
397        #[cfg(windows)]
398        let script = "for /L %i in (1,1,20000) do @echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
399        #[cfg(not(windows))]
400        let script = "yes aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | head -c 200000";
401
402        let error = script_credential(script)
403            .request_access_token()
404            .await
405            .expect_err("an oversized output should be an error");
406
407        assert!(
408            matches!(error, ExternalCommandError::OutputTooLarge { .. }),
409            "unexpected error: {error}"
410        );
411    }
412}