Skip to main content

dynamic_config_git/
auth.rs

1//! What to present to a git host, and where it comes from.
2//!
3//! Every host in this crate's remit speaks git, and git has exactly two places
4//! a credential can go: the HTTP `Authorization` header, or the `ssh` process
5//! that carries the stream. [`Auth`] is those two, plus the absence of both.
6//!
7//! # The credential is a callable, not a string
8//!
9//! A store that takes `token: String` at construction works in a demo and
10//! fails at three in the morning on the first refresh. A GitHub App
11//! installation token lives one hour; a workload-identity token exchanged for
12//! a provider token lives minutes; a watcher lives for the life of the
13//! process. So [`Credential`] is a *function*, called per fetch, and the three
14//! shapes it comes in are the three lifetimes a real credential has:
15//!
16//! | Constructor | Called | For |
17//! |---|---|---|
18//! | [`Credential::token`], [`basic`](Credential::basic), [`ssh_agent`](Credential::ssh_agent), [`ssh_key`](Credential::ssh_key), [`anonymous`](Credential::anonymous) | once | a value that cannot change |
19//! | [`Credential::from_fn`] | every fetch | a value read from somewhere that can change — an environment variable, a file a sidecar rewrites |
20//! | [`Credential::expiring`] | when it is about to expire | a value the issuer stamped a lifetime on |
21//!
22//! [`Credential::expiring`] is the one the item exists for. It is handed to
23//! [`Cached`], the same machinery Vault, Consul and Firestore use: obtained
24//! once, reused until it is within
25//! [`REFRESH_WITHIN`](dynamic_config_store_core::credential::REFRESH_WITHIN) of
26//! expiry, refreshed under one lock so eight threads produce one exchange, and
27//! thrown away the moment the host refuses it so the next fetch obtains a new
28//! one. None of that is re-derived here.
29//!
30//! # What is deliberately not here
31//!
32//! **The GitHub App JWT-to-installation-token exchange.** It is two steps — sign
33//! an RS256 JWT with the app's private key, `POST
34//! /app/installations/{id}/access_tokens` — and both belong in the caller's
35//! closure. Signing needs an RSA implementation, and the pure-Rust one carries
36//! an unpatched timing-sidechannel advisory that this workspace's `cargo deny`
37//! gate rejects; a program that already talks to GitHub almost certainly has a
38//! client that does the exchange. What this crate owes that flow is the
39//! *refresh*, and that is [`Credential::expiring`].
40//!
41//! **An SSH key passphrase.** `ssh` has no way to accept one that does not put
42//! it on a command line, where `ps` can read it, or in a file this crate would
43//! have to write. A passphrase-protected key is therefore used through an
44//! agent — `ssh-add` it once — which is what an agent is for. Taking a
45//! passphrase parameter and then leaking it would be worse than not taking one.
46
47use std::path::{Path, PathBuf};
48use std::sync::Arc;
49use std::time::Duration;
50
51use dynamic_config::Error;
52use dynamic_config_store_core::credential::{Cached, Issued};
53
54/// The user name GitHub, GitLab and Azure DevOps all accept beside a token.
55///
56/// HTTP basic authentication has two halves and a token is one value, so every
57/// host picks a filler for the other. GitHub documents `x-access-token` for App
58/// installation tokens and ignores the user name entirely for a personal access
59/// token; GitLab and Azure DevOps ignore it too. One constant is therefore
60/// correct everywhere, and [`Credential::basic`] is there for the host that
61/// turns out not to be.
62const TOKEN_USERNAME: &str = "x-access-token";
63
64/// What to present to a git host.
65///
66/// Not `Debug`-derived: the HTTPS password is a token and the SSH command can
67/// carry anything the caller put in it.
68#[derive(Clone)]
69#[non_exhaustive]
70pub enum Auth {
71    /// Nothing at all — a public repository over HTTPS.
72    Anonymous,
73
74    /// HTTP basic authentication, which is how every host takes a token.
75    ///
76    /// The token goes in `password`. The `username` half is filler that every
77    /// host in this crate's remit ignores — `x-access-token` is what
78    /// [`Credential::token`] puts there.
79    Https {
80        /// The user half, usually `x-access-token`.
81        username: String,
82        /// The secret half: a personal access token, an installation token, a
83        /// deploy token, an OAuth bearer.
84        password: String,
85    },
86
87    /// SSH, carried by the `ssh` program.
88    Ssh(SshAuth),
89}
90
91impl Auth {
92    /// How to name this method in an error, without naming its secret.
93    #[must_use]
94    pub fn describe(&self) -> &'static str {
95        match self {
96            Self::Anonymous => "anonymously",
97            Self::Https { .. } => "an https token",
98            Self::Ssh(SshAuth::Agent) => "an ssh agent",
99            Self::Ssh(SshAuth::Key(_)) => "an ssh key",
100            Self::Ssh(SshAuth::Command(_)) => "a custom ssh command",
101        }
102    }
103
104    /// The `core.sshCommand` this method wants, if it is an SSH one.
105    pub(crate) fn ssh_command(&self) -> Option<String> {
106        match self {
107            Self::Anonymous | Self::Https { .. } => None,
108            Self::Ssh(ssh) => ssh.command(),
109        }
110    }
111}
112
113// Hand-written, never derived: a derive prints the password, and `{:?}`
114// reaching a log is an ordinary accident — a `dbg!`, a
115// `tracing::debug!(?source)`. The user name stays printable, because it is the
116// half worth seeing when a host rejects the pair.
117impl std::fmt::Debug for Auth {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        match self {
120            Self::Anonymous => f.write_str("Anonymous"),
121            Self::Https { username, .. } => f
122                .debug_struct("Https")
123                .field("username", username)
124                .field("password", &"***")
125                .finish(),
126            Self::Ssh(ssh) => f.debug_tuple("Ssh").field(ssh).finish(),
127        }
128    }
129}
130
131/// How the `ssh` program should be run.
132///
133/// SSH is not a Rust library here. `gix` carries an SSH stream by spawning the
134/// system `ssh`, exactly as `git` does, so **the `ssh` binary must be on the
135/// host** for any of these — and in exchange, everything already configured for
136/// it works: `~/.ssh/config`, `known_hosts`, a `ProxyJump`, a hardware key.
137#[derive(Clone)]
138#[non_exhaustive]
139pub enum SshAuth {
140    /// Whatever `ssh` would do unaided: the agent in `SSH_AUTH_SOCK`, the keys
141    /// `~/.ssh/config` names, the defaults.
142    ///
143    /// The right choice for a passphrase-protected key, because the agent is
144    /// the only place a passphrase can be entered once and used many times.
145    Agent,
146
147    /// One private key file, and only that one.
148    ///
149    /// Adds `-o IdentitiesOnly=yes`, so an agent holding other keys cannot
150    /// quietly offer them first and exhaust the server's `MaxAuthTries` before
151    /// the intended key is tried. The path is a path; the key's *contents* are
152    /// never read by this crate and never printed.
153    Key(PathBuf),
154
155    /// Run this instead of `ssh`.
156    ///
157    /// The escape hatch, and an explicit one — never a silent fallback. For a
158    /// jump host, a vendored client, `-o` options this crate has no opinion
159    /// about, or a test double. It becomes `core.sshCommand` for one fetch and
160    /// is never written to the working directory's config.
161    Command(String),
162}
163
164impl SshAuth {
165    /// The `core.sshCommand` value for this method, if it needs one.
166    fn command(&self) -> Option<String> {
167        match self {
168            // Nothing to say: `ssh` unaided is what git does anyway.
169            Self::Agent => None,
170            Self::Key(path) => Some(format!(
171                "ssh -i {} -o IdentitiesOnly=yes",
172                quoted(path.as_path())
173            )),
174            Self::Command(command) => Some(command.clone()),
175        }
176    }
177}
178
179/// A path as one shell word.
180///
181/// `core.sshCommand` is split by a shell-like parser, so a key under
182/// `/home/my user/.ssh/id_ed25519` would otherwise become two arguments. Single
183/// quotes with the POSIX `'\''` escape, because there is no other character a
184/// single-quoted shell word treats specially.
185fn quoted(path: &Path) -> String {
186    format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
187}
188
189impl std::fmt::Debug for SshAuth {
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        match self {
192            Self::Agent => f.write_str("Agent"),
193            // The path, not the key: a path is what a person debugging needs
194            // and a key file's contents are never in this type to begin with.
195            Self::Key(path) => f.debug_tuple("Key").field(path).finish(),
196            // Redacted whole: a caller reaching for this hatch may well have
197            // put `sshpass -p ...` in it, and this crate cannot tell.
198            Self::Command(_) => f.write_str("Command(***)"),
199        }
200    }
201}
202
203/// Where an [`Auth`] comes from, and how long it lasts.
204///
205/// See the [module documentation](self) for which constructor to reach for.
206#[derive(Clone)]
207pub struct Credential(Kind);
208
209#[derive(Clone)]
210enum Kind {
211    /// A value that cannot change; obtained once and kept.
212    Constant(Auth),
213    /// A value that may have changed since the last fetch.
214    #[allow(clippy::type_complexity)]
215    PerFetch(Arc<dyn Fn() -> Result<Auth, Error> + Send + Sync>),
216    /// A value with a lifetime the issuer stamped on it.
217    #[allow(clippy::type_complexity)]
218    Expiring(Arc<dyn Fn(Option<&Auth>) -> Result<Issued<Auth>, Error> + Send + Sync>),
219}
220
221impl Credential {
222    /// No credential at all — a public repository.
223    #[must_use]
224    pub fn anonymous() -> Self {
225        Self(Kind::Constant(Auth::Anonymous))
226    }
227
228    /// A token that does not expire, over HTTPS.
229    ///
230    /// A classic personal access token, a GitLab deploy token, an Azure DevOps
231    /// PAT. For one that *does* expire, see [`expiring`](Self::expiring) — a
232    /// token pasted in here is presented unchanged forever, because there is
233    /// nothing here to obtain another one with.
234    #[must_use]
235    pub fn token(token: impl Into<String>) -> Self {
236        Self::basic(TOKEN_USERNAME, token)
237    }
238
239    /// A user name and password (or token) of the caller's choosing.
240    ///
241    /// For the host that does look at the user half — a GitLab deploy token is
242    /// a real user name and a real token, and a CI job token is
243    /// `gitlab-ci-token` plus `CI_JOB_TOKEN`.
244    #[must_use]
245    pub fn basic(username: impl Into<String>, password: impl Into<String>) -> Self {
246        Self(Kind::Constant(Auth::Https {
247            username: username.into(),
248            password: password.into(),
249        }))
250    }
251
252    /// SSH through the agent in `SSH_AUTH_SOCK`, and whatever `~/.ssh/config`
253    /// says.
254    #[must_use]
255    pub fn ssh_agent() -> Self {
256        Self(Kind::Constant(Auth::Ssh(SshAuth::Agent)))
257    }
258
259    /// SSH with one named private key and no other.
260    #[must_use]
261    pub fn ssh_key(path: impl Into<PathBuf>) -> Self {
262        Self(Kind::Constant(Auth::Ssh(SshAuth::Key(path.into()))))
263    }
264
265    /// SSH through a command of the caller's own.
266    #[must_use]
267    pub fn ssh_command(command: impl Into<String>) -> Self {
268        Self(Kind::Constant(Auth::Ssh(SshAuth::Command(command.into()))))
269    }
270
271    /// A credential read afresh **on every fetch**.
272    ///
273    /// For a value that lives somewhere that can change without telling anyone
274    /// — an environment variable a supervisor rewrites, a file a sidecar drops
275    /// a new token into. Cheap, because a fetch is already a network round
276    /// trip.
277    ///
278    /// ```
279    /// # use dynamic_config_git::Credential;
280    /// # use dynamic_config::Error;
281    /// let credential = Credential::from_fn(|| {
282    ///     let token = std::fs::read_to_string("/var/run/secrets/git-token")
283    ///         .map_err(|error| Error::auth(format!("no git token: {error}")))?;
284    ///
285    ///     Ok(dynamic_config_git::Auth::Https {
286    ///         username: "x-access-token".to_owned(),
287    ///         password: token.trim().to_owned(),
288    ///     })
289    /// });
290    /// ```
291    #[must_use]
292    pub fn from_fn(obtain: impl Fn() -> Result<Auth, Error> + Send + Sync + 'static) -> Self {
293        Self(Kind::PerFetch(Arc::new(obtain)))
294    }
295
296    /// A credential the issuer stamped a lifetime on.
297    ///
298    /// The closure is handed the credential it is replacing — `None` on the
299    /// first call and after a refusal — and returns the new one with the
300    /// lifetime the issuer reported. It is called when there is nothing held,
301    /// when what is held is within a minute of expiring, and immediately after
302    /// the host refuses what was presented. It is *not* called per fetch: a
303    /// GitHub App token exchange is a rate-limited API call, and one per poll
304    /// tick would be a bill.
305    ///
306    /// ```no_run
307    /// # use dynamic_config_git::{Auth, Credential};
308    /// # use dynamic_config::Error;
309    /// # use dynamic_config_store_core::credential::Issued;
310    /// # use std::time::Duration;
311    /// # fn installation_token() -> Result<(String, Duration), Error> { unimplemented!() }
312    /// let credential = Credential::expiring(|_previous| {
313    ///     // Sign the app JWT and exchange it for an installation token —
314    ///     // whatever your GitHub client already does.
315    ///     let (token, lives_for) = installation_token()?;
316    ///
317    ///     Ok(Issued {
318    ///         value: Auth::Https {
319    ///             username: "x-access-token".to_owned(),
320    ///             password: token,
321    ///         },
322    ///         ttl: Some(lives_for),
323    ///     })
324    /// });
325    /// ```
326    #[must_use]
327    pub fn expiring(
328        obtain: impl Fn(Option<&Auth>) -> Result<Issued<Auth>, Error> + Send + Sync + 'static,
329    ) -> Self {
330        Self(Kind::Expiring(Arc::new(obtain)))
331    }
332
333    /// Whether a refused credential can be traded for a different one.
334    ///
335    /// A constant cannot: invalidating it would retry the identical string,
336    /// which is one wasted round trip per fetch against a token that is simply
337    /// wrong.
338    fn is_replaceable(&self) -> bool {
339        !matches!(self.0, Kind::Constant(_))
340    }
341
342    fn obtain(&self, previous: Option<&Auth>) -> Result<Issued<Auth>, Error> {
343        match &self.0 {
344            Kind::Constant(auth) => Ok(Issued {
345                value: auth.clone(),
346                ttl: None,
347            }),
348            // A zero lifetime is how "ask again next time" is spelled to
349            // `Cached`: it is always inside the refresh margin, so every
350            // `get` obtains. Cheaper than a second code path, and it keeps
351            // the reactive `invalidate` working the same way for all three.
352            Kind::PerFetch(obtain) => Ok(Issued {
353                value: obtain()?,
354                ttl: Some(Duration::ZERO),
355            }),
356            Kind::Expiring(obtain) => obtain(previous),
357        }
358    }
359}
360
361impl std::fmt::Debug for Credential {
362    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363        match &self.0 {
364            Kind::Constant(auth) => f.debug_tuple("Constant").field(auth).finish(),
365            Kind::PerFetch(_) => f.write_str("PerFetch(..)"),
366            Kind::Expiring(_) => f.write_str("Expiring(..)"),
367        }
368    }
369}
370
371impl Default for Credential {
372    fn default() -> Self {
373        Self::anonymous()
374    }
375}
376
377/// The credential currently held for one source, and when to get another.
378///
379/// The *when* is [`Cached`]'s, shared with the Vault, Consul and Firestore
380/// crates. What is left here is the one thing git decides differently: there is
381/// no renewal endpoint, so a stale credential is replaced rather than extended
382/// and the `previous` argument exists only for a caller whose issuer can do
383/// something with it.
384#[derive(Debug)]
385pub(crate) struct Session {
386    credential: Credential,
387    held: Cached<Auth>,
388}
389
390impl Session {
391    pub(crate) fn new(credential: Credential) -> Self {
392        Self {
393            credential,
394            held: Cached::new(),
395        }
396    }
397
398    /// The credential to present, obtaining or refreshing it if it is time.
399    pub(crate) fn current(&self) -> Result<Auth, Error> {
400        self.held.get(|previous| self.credential.obtain(previous))
401    }
402
403    /// Throws away what is held, so the next [`current`](Self::current)
404    /// obtains.
405    pub(crate) fn invalidate(&self) {
406        self.held.invalidate();
407    }
408
409    /// Whether trying again could present anything different.
410    pub(crate) fn is_replaceable(&self) -> bool {
411        self.credential.is_replaceable()
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use std::sync::atomic::{AtomicUsize, Ordering};
418
419    use dynamic_config_store_core::credential::REFRESH_WITHIN;
420
421    use super::*;
422
423    #[test]
424    fn a_constant_credential_is_obtained_once() {
425        let session = Session::new(Credential::token("hunter2-token"));
426
427        for _ in 0..3 {
428            assert!(matches!(
429                session.current().unwrap(),
430                Auth::Https { password, .. } if password == "hunter2-token"
431            ));
432        }
433
434        assert!(
435            !session.is_replaceable(),
436            "retrying the identical string is a wasted round trip"
437        );
438    }
439
440    #[test]
441    fn a_per_fetch_credential_is_read_every_time() {
442        let calls = AtomicUsize::new(0);
443
444        let session = Session::new(Credential::from_fn(move || {
445            let count = calls.fetch_add(1, Ordering::SeqCst);
446
447            Ok(Auth::Https {
448                username: "x-access-token".to_owned(),
449                password: format!("token-{count}"),
450            })
451        }));
452
453        let seen: Vec<_> = (0..3)
454            .map(|_| match session.current().unwrap() {
455                Auth::Https { password, .. } => password,
456                other => panic!("{other:?}"),
457            })
458            .collect();
459
460        assert_eq!(seen, ["token-0", "token-1", "token-2"]);
461    }
462
463    /// The test the item exists for, at the credential seam: a token with an
464    /// hour on it is reused, and one about to expire is replaced without the
465    /// caller doing anything.
466    #[test]
467    fn an_expiring_credential_is_refreshed_before_it_dies_and_not_before() {
468        let calls = AtomicUsize::new(0);
469        let lifetimes = [REFRESH_WITHIN / 2, Duration::from_secs(3600)];
470
471        let session = Session::new(Credential::expiring(move |_previous| {
472            let count = calls.fetch_add(1, Ordering::SeqCst);
473
474            Ok(Issued {
475                value: Auth::Https {
476                    username: "x-access-token".to_owned(),
477                    password: format!("ghs_{count}"),
478                },
479                ttl: Some(lifetimes[count.min(1)]),
480            })
481        }));
482
483        let password = |auth| match auth {
484            Auth::Https { password, .. } => password,
485            other => panic!("{other:?}"),
486        };
487
488        // Issued with half the margin left, so the next fetch replaces it...
489        assert_eq!(password(session.current().unwrap()), "ghs_0");
490        assert_eq!(password(session.current().unwrap()), "ghs_1");
491        // ...and this one has an hour, so it is not replaced again.
492        assert_eq!(password(session.current().unwrap()), "ghs_1");
493        assert!(session.is_replaceable());
494    }
495
496    #[test]
497    fn a_refused_credential_is_thrown_away_so_the_next_one_is_fresh() {
498        let calls = AtomicUsize::new(0);
499
500        let session = Session::new(Credential::expiring(move |previous| {
501            assert!(
502                previous.is_none(),
503                "a credential the host refused must not be offered back for renewal"
504            );
505
506            Ok(Issued {
507                value: Auth::Https {
508                    username: "x-access-token".to_owned(),
509                    password: format!("ghs_{}", calls.fetch_add(1, Ordering::SeqCst)),
510                },
511                ttl: Some(Duration::from_secs(3600)),
512            })
513        }));
514
515        let password = |auth| match auth {
516            Auth::Https { password, .. } => password,
517            other => panic!("{other:?}"),
518        };
519
520        assert_eq!(password(session.current().unwrap()), "ghs_0");
521        session.invalidate();
522        assert_eq!(password(session.current().unwrap()), "ghs_1");
523    }
524
525    #[test]
526    fn a_named_key_is_the_only_one_offered() {
527        let command = SshAuth::Key(PathBuf::from("/home/app/.ssh/id_ed25519"))
528            .command()
529            .expect("a named key needs a command");
530
531        assert_eq!(
532            command,
533            "ssh -i '/home/app/.ssh/id_ed25519' -o IdentitiesOnly=yes"
534        );
535        assert_eq!(
536            SshAuth::Agent.command(),
537            None,
538            "the agent is what ssh does unaided"
539        );
540    }
541
542    #[test]
543    fn a_key_path_with_a_space_stays_one_argument() {
544        let command = SshAuth::Key(PathBuf::from("/home/my user/.ssh/id_rsa"))
545            .command()
546            .unwrap();
547
548        assert!(command.contains("'/home/my user/.ssh/id_rsa'"), "{command}");
549
550        let command = SshAuth::Key(PathBuf::from("/home/o'brien/.ssh/id_rsa"))
551            .command()
552            .unwrap();
553
554        assert!(
555            command.contains(r"'/home/o'\''brien/.ssh/id_rsa'"),
556            "{command}"
557        );
558    }
559
560    #[test]
561    fn debug_never_prints_a_credential() {
562        let printed = format!(
563            "{:?} {:?} {:?} {:?} {:?}",
564            Credential::token("hunter2-token"),
565            Credential::basic("gitlab-ci-token", "hunter2-job-token"),
566            Credential::ssh_command("sshpass -p hunter2-passphrase ssh"),
567            Credential::ssh_key("/home/app/.ssh/id_ed25519"),
568            Credential::from_fn(|| Ok(Auth::Anonymous)),
569        );
570
571        assert!(!printed.contains("hunter2"), "{printed}");
572        // The halves worth seeing survive: a user name and a key path are what
573        // a person debugging a refused login actually needs.
574        assert!(printed.contains("gitlab-ci-token"), "{printed}");
575        assert!(printed.contains("id_ed25519"), "{printed}");
576    }
577}