use std::sync::Mutex;
use std::time::{Duration, Instant};
use dynamic_config::Error;
const REFRESH_WITHIN: Duration = Duration::from_secs(60);
pub const SERVICE_ACCOUNT_TOKEN: &str = "/var/run/secrets/kubernetes.io/serviceaccount/token";
#[derive(Clone)]
#[non_exhaustive]
pub enum Auth {
Anonymous,
Token(String),
Login {
method: String,
bearer: Bearer,
meta: Vec<(String, String)>,
},
}
#[derive(Clone)]
#[non_exhaustive]
pub enum Bearer {
Literal(String),
File(String),
}
impl Auth {
pub fn token(token: impl Into<String>) -> Self {
Self::Token(token.into())
}
#[must_use]
pub fn from_environment() -> Self {
match std::env::var("CONSUL_HTTP_TOKEN") {
Ok(token) if !token.is_empty() => Self::Token(token),
_ => Self::Anonymous,
}
}
pub fn kubernetes(method: impl Into<String>) -> Self {
Self::Login {
method: method.into(),
bearer: Bearer::File(SERVICE_ACCOUNT_TOKEN.to_owned()),
meta: Vec::new(),
}
}
pub fn jwt(method: impl Into<String>, token: impl Into<String>) -> Self {
Self::Login {
method: method.into(),
bearer: Bearer::Literal(token.into()),
meta: Vec::new(),
}
}
#[must_use]
pub fn with_bearer_file(mut self, path: impl Into<String>) -> Self {
if let Self::Login { bearer, .. } = &mut self {
*bearer = Bearer::File(path.into());
}
self
}
#[must_use]
pub fn with_meta(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
if let Self::Login { meta, .. } = &mut self {
meta.push((name.into(), value.into()));
}
self
}
pub(crate) fn login_body(&self) -> Result<Option<serde_json::Value>, Error> {
let Self::Login {
method,
bearer,
meta,
} = self
else {
return Ok(None);
};
let token = match bearer {
Bearer::Literal(token) => token.clone(),
Bearer::File(path) => std::fs::read_to_string(path)
.map_err(|error| {
Error::remote(format!(
"consul: cannot read the bearer token at {path}: {error}"
))
})?
.trim()
.to_owned(),
};
let meta: serde_json::Map<String, serde_json::Value> = meta
.iter()
.map(|(name, value)| (name.clone(), serde_json::Value::from(value.clone())))
.collect();
Ok(Some(serde_json::json!({
"AuthMethod": method,
"BearerToken": token,
"Meta": meta,
})))
}
pub(crate) fn describe(&self) -> String {
match self {
Self::Anonymous => "no token".to_owned(),
Self::Token(_) => "a supplied token".to_owned(),
Self::Login { method, .. } => format!("auth method `{method}`"),
}
}
}
#[derive(Clone)]
pub(crate) struct Token {
pub(crate) secret: String,
expires: Option<Instant>,
}
impl Token {
pub(crate) fn new(secret: String, ttl: Option<Duration>) -> Self {
Self {
secret,
expires: ttl.and_then(|ttl| Instant::now().checked_add(ttl)),
}
}
fn is_stale(&self) -> bool {
self.expires.is_some_and(|expires| {
expires.saturating_duration_since(Instant::now()) < REFRESH_WITHIN
})
}
}
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::Token(_) => f.write_str("Token(***)"),
Self::Login { method, meta, .. } => f
.debug_struct("Login")
.field("method", method)
.field("meta", meta)
.finish_non_exhaustive(),
}
}
}
impl std::fmt::Debug for Bearer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Literal(_) => f.write_str("Literal(***)"),
Self::File(path) => f.debug_tuple("File").field(path).finish(),
}
}
}
impl std::fmt::Debug for Token {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Token")
.field("secret", &"***")
.field("expires", &self.expires)
.finish()
}
}
#[derive(Debug, Default)]
pub(crate) struct Session {
token: Mutex<Option<Token>>,
}
impl Session {
pub(crate) const fn new() -> Self {
Self {
token: Mutex::new(None),
}
}
pub(crate) fn token(&self, login: impl Fn() -> Result<Token, Error>) -> Result<String, Error> {
let mut slot = self.lock();
if let Some(token) = slot.as_ref() {
if !token.is_stale() {
return Ok(token.secret.clone());
}
}
let fresh = login()?;
let secret = fresh.secret.clone();
*slot = Some(fresh);
Ok(secret)
}
pub(crate) fn invalidate(&self) {
*self.lock() = None;
}
fn lock(&self) -> std::sync::MutexGuard<'_, Option<Token>> {
self.token
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_supplied_token_needs_no_login() {
assert!(Auth::token("t").login_body().unwrap().is_none());
assert!(Auth::Anonymous.login_body().unwrap().is_none());
}
#[test]
fn a_login_presents_its_bearer_token_to_a_named_method() {
let body = Auth::jwt("kubernetes", "a.b.c")
.login_body()
.unwrap()
.expect("this one logs in");
assert_eq!(body["AuthMethod"], "kubernetes");
assert_eq!(body["BearerToken"], "a.b.c");
}
#[test]
fn meta_is_carried_through_for_the_audit_log() {
let body = Auth::jwt("kubernetes", "a.b.c")
.with_meta("pod", "myapp-7f9")
.login_body()
.unwrap()
.unwrap();
assert_eq!(body["Meta"]["pod"], "myapp-7f9");
}
#[test]
fn a_missing_bearer_file_says_where_it_looked() {
let error = Auth::kubernetes("kubernetes")
.with_bearer_file("/no/such/token")
.login_body()
.expect_err("there is no token there");
assert!(error.to_string().contains("/no/such/token"), "{error}");
}
#[test]
fn an_unset_environment_variable_is_anonymous_rather_than_an_error() {
std::env::remove_var("CONSUL_HTTP_TOKEN");
assert!(matches!(Auth::from_environment(), Auth::Anonymous));
}
#[test]
fn a_ttl_too_large_to_represent_is_treated_as_no_expiry() {
assert!(!Token::new("t".to_owned(), Some(Duration::from_nanos(u64::MAX))).is_stale());
}
#[test]
fn a_token_with_no_expiry_is_never_stale() {
assert!(!Token::new("t".to_owned(), None).is_stale());
}
#[test]
fn a_token_near_its_expiry_is_stale() {
assert!(!Token::new("t".to_owned(), Some(Duration::from_secs(3600))).is_stale());
assert!(Token::new("t".to_owned(), Some(REFRESH_WITHIN / 2)).is_stale());
}
#[test]
fn a_session_logs_in_once_and_then_reuses_the_token() {
use std::sync::atomic::{AtomicUsize, Ordering};
let logins = AtomicUsize::new(0);
let session = Session::new();
let login = || {
logins.fetch_add(1, Ordering::SeqCst);
Ok(Token::new(
"token".to_owned(),
Some(Duration::from_secs(3600)),
))
};
assert_eq!(session.token(login).unwrap(), "token");
assert_eq!(session.token(login).unwrap(), "token");
assert_eq!(logins.load(Ordering::SeqCst), 1);
session.invalidate();
assert_eq!(session.token(login).unwrap(), "token");
assert_eq!(
logins.load(Ordering::SeqCst),
2,
"a 403 must be able to force a fresh login"
);
}
}