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}