use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use dynamic_config::Error;
use dynamic_config_store_core::credential::{Cached, Issued};
const TOKEN_USERNAME: &str = "x-access-token";
#[derive(Clone)]
#[non_exhaustive]
pub enum Auth {
Anonymous,
Https {
username: String,
password: String,
},
Ssh(SshAuth),
}
impl Auth {
#[must_use]
pub fn describe(&self) -> &'static str {
match self {
Self::Anonymous => "anonymously",
Self::Https { .. } => "an https token",
Self::Ssh(SshAuth::Agent) => "an ssh agent",
Self::Ssh(SshAuth::Key(_)) => "an ssh key",
Self::Ssh(SshAuth::Command(_)) => "a custom ssh command",
}
}
pub(crate) fn ssh_command(&self) -> Option<String> {
match self {
Self::Anonymous | Self::Https { .. } => None,
Self::Ssh(ssh) => ssh.command(),
}
}
}
impl std::fmt::Debug for Auth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Anonymous => f.write_str("Anonymous"),
Self::Https { username, .. } => f
.debug_struct("Https")
.field("username", username)
.field("password", &"***")
.finish(),
Self::Ssh(ssh) => f.debug_tuple("Ssh").field(ssh).finish(),
}
}
}
#[derive(Clone)]
#[non_exhaustive]
pub enum SshAuth {
Agent,
Key(PathBuf),
Command(String),
}
impl SshAuth {
fn command(&self) -> Option<String> {
match self {
Self::Agent => None,
Self::Key(path) => Some(format!(
"ssh -i {} -o IdentitiesOnly=yes",
quoted(path.as_path())
)),
Self::Command(command) => Some(command.clone()),
}
}
}
fn quoted(path: &Path) -> String {
format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
}
impl std::fmt::Debug for SshAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Agent => f.write_str("Agent"),
Self::Key(path) => f.debug_tuple("Key").field(path).finish(),
Self::Command(_) => f.write_str("Command(***)"),
}
}
}
#[derive(Clone)]
pub struct Credential(Kind);
#[derive(Clone)]
enum Kind {
Constant(Auth),
#[allow(clippy::type_complexity)]
PerFetch(Arc<dyn Fn() -> Result<Auth, Error> + Send + Sync>),
#[allow(clippy::type_complexity)]
Expiring(Arc<dyn Fn(Option<&Auth>) -> Result<Issued<Auth>, Error> + Send + Sync>),
}
impl Credential {
#[must_use]
pub fn anonymous() -> Self {
Self(Kind::Constant(Auth::Anonymous))
}
#[must_use]
pub fn token(token: impl Into<String>) -> Self {
Self::basic(TOKEN_USERNAME, token)
}
#[must_use]
pub fn basic(username: impl Into<String>, password: impl Into<String>) -> Self {
Self(Kind::Constant(Auth::Https {
username: username.into(),
password: password.into(),
}))
}
#[must_use]
pub fn ssh_agent() -> Self {
Self(Kind::Constant(Auth::Ssh(SshAuth::Agent)))
}
#[must_use]
pub fn ssh_key(path: impl Into<PathBuf>) -> Self {
Self(Kind::Constant(Auth::Ssh(SshAuth::Key(path.into()))))
}
#[must_use]
pub fn ssh_command(command: impl Into<String>) -> Self {
Self(Kind::Constant(Auth::Ssh(SshAuth::Command(command.into()))))
}
#[must_use]
pub fn from_fn(obtain: impl Fn() -> Result<Auth, Error> + Send + Sync + 'static) -> Self {
Self(Kind::PerFetch(Arc::new(obtain)))
}
#[must_use]
pub fn expiring(
obtain: impl Fn(Option<&Auth>) -> Result<Issued<Auth>, Error> + Send + Sync + 'static,
) -> Self {
Self(Kind::Expiring(Arc::new(obtain)))
}
fn is_replaceable(&self) -> bool {
!matches!(self.0, Kind::Constant(_))
}
fn obtain(&self, previous: Option<&Auth>) -> Result<Issued<Auth>, Error> {
match &self.0 {
Kind::Constant(auth) => Ok(Issued {
value: auth.clone(),
ttl: None,
}),
Kind::PerFetch(obtain) => Ok(Issued {
value: obtain()?,
ttl: Some(Duration::ZERO),
}),
Kind::Expiring(obtain) => obtain(previous),
}
}
}
impl std::fmt::Debug for Credential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.0 {
Kind::Constant(auth) => f.debug_tuple("Constant").field(auth).finish(),
Kind::PerFetch(_) => f.write_str("PerFetch(..)"),
Kind::Expiring(_) => f.write_str("Expiring(..)"),
}
}
}
impl Default for Credential {
fn default() -> Self {
Self::anonymous()
}
}
#[derive(Debug)]
pub(crate) struct Session {
credential: Credential,
held: Cached<Auth>,
}
impl Session {
pub(crate) fn new(credential: Credential) -> Self {
Self {
credential,
held: Cached::new(),
}
}
pub(crate) fn current(&self) -> Result<Auth, Error> {
self.held.get(|previous| self.credential.obtain(previous))
}
pub(crate) fn invalidate(&self) {
self.held.invalidate();
}
pub(crate) fn is_replaceable(&self) -> bool {
self.credential.is_replaceable()
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use dynamic_config_store_core::credential::REFRESH_WITHIN;
use super::*;
#[test]
fn a_constant_credential_is_obtained_once() {
let session = Session::new(Credential::token("hunter2-token"));
for _ in 0..3 {
assert!(matches!(
session.current().unwrap(),
Auth::Https { password, .. } if password == "hunter2-token"
));
}
assert!(
!session.is_replaceable(),
"retrying the identical string is a wasted round trip"
);
}
#[test]
fn a_per_fetch_credential_is_read_every_time() {
let calls = AtomicUsize::new(0);
let session = Session::new(Credential::from_fn(move || {
let count = calls.fetch_add(1, Ordering::SeqCst);
Ok(Auth::Https {
username: "x-access-token".to_owned(),
password: format!("token-{count}"),
})
}));
let seen: Vec<_> = (0..3)
.map(|_| match session.current().unwrap() {
Auth::Https { password, .. } => password,
other => panic!("{other:?}"),
})
.collect();
assert_eq!(seen, ["token-0", "token-1", "token-2"]);
}
#[test]
fn an_expiring_credential_is_refreshed_before_it_dies_and_not_before() {
let calls = AtomicUsize::new(0);
let lifetimes = [REFRESH_WITHIN / 2, Duration::from_secs(3600)];
let session = Session::new(Credential::expiring(move |_previous| {
let count = calls.fetch_add(1, Ordering::SeqCst);
Ok(Issued {
value: Auth::Https {
username: "x-access-token".to_owned(),
password: format!("ghs_{count}"),
},
ttl: Some(lifetimes[count.min(1)]),
})
}));
let password = |auth| match auth {
Auth::Https { password, .. } => password,
other => panic!("{other:?}"),
};
assert_eq!(password(session.current().unwrap()), "ghs_0");
assert_eq!(password(session.current().unwrap()), "ghs_1");
assert_eq!(password(session.current().unwrap()), "ghs_1");
assert!(session.is_replaceable());
}
#[test]
fn a_refused_credential_is_thrown_away_so_the_next_one_is_fresh() {
let calls = AtomicUsize::new(0);
let session = Session::new(Credential::expiring(move |previous| {
assert!(
previous.is_none(),
"a credential the host refused must not be offered back for renewal"
);
Ok(Issued {
value: Auth::Https {
username: "x-access-token".to_owned(),
password: format!("ghs_{}", calls.fetch_add(1, Ordering::SeqCst)),
},
ttl: Some(Duration::from_secs(3600)),
})
}));
let password = |auth| match auth {
Auth::Https { password, .. } => password,
other => panic!("{other:?}"),
};
assert_eq!(password(session.current().unwrap()), "ghs_0");
session.invalidate();
assert_eq!(password(session.current().unwrap()), "ghs_1");
}
#[test]
fn a_named_key_is_the_only_one_offered() {
let command = SshAuth::Key(PathBuf::from("/home/app/.ssh/id_ed25519"))
.command()
.expect("a named key needs a command");
assert_eq!(
command,
"ssh -i '/home/app/.ssh/id_ed25519' -o IdentitiesOnly=yes"
);
assert_eq!(
SshAuth::Agent.command(),
None,
"the agent is what ssh does unaided"
);
}
#[test]
fn a_key_path_with_a_space_stays_one_argument() {
let command = SshAuth::Key(PathBuf::from("/home/my user/.ssh/id_rsa"))
.command()
.unwrap();
assert!(command.contains("'/home/my user/.ssh/id_rsa'"), "{command}");
let command = SshAuth::Key(PathBuf::from("/home/o'brien/.ssh/id_rsa"))
.command()
.unwrap();
assert!(
command.contains(r"'/home/o'\''brien/.ssh/id_rsa'"),
"{command}"
);
}
#[test]
fn debug_never_prints_a_credential() {
let printed = format!(
"{:?} {:?} {:?} {:?} {:?}",
Credential::token("hunter2-token"),
Credential::basic("gitlab-ci-token", "hunter2-job-token"),
Credential::ssh_command("sshpass -p hunter2-passphrase ssh"),
Credential::ssh_key("/home/app/.ssh/id_ed25519"),
Credential::from_fn(|| Ok(Auth::Anonymous)),
);
assert!(!printed.contains("hunter2"), "{printed}");
assert!(printed.contains("gitlab-ci-token"), "{printed}");
assert!(printed.contains("id_ed25519"), "{printed}");
}
}