use std::sync::Mutex;
use std::time::{Duration, Instant};
use dynamic_config::Error;
const REFRESH_WITHIN: Duration = Duration::from_secs(60);
const METADATA_TOKEN_URL: &str =
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token";
#[derive(Clone)]
#[non_exhaustive]
pub enum Auth {
Emulator,
AccessToken(String),
MetadataServer {
url: String,
},
}
impl Auth {
pub fn access_token(token: impl Into<String>) -> Self {
Self::AccessToken(token.into())
}
#[must_use]
pub fn metadata_server() -> Self {
Self::MetadataServer {
url: METADATA_TOKEN_URL.to_owned(),
}
}
#[must_use]
pub fn with_url(mut self, url: impl Into<String>) -> Self {
if let Self::MetadataServer { url: existing } = &mut self {
*existing = url.into();
}
self
}
}
impl std::fmt::Debug for Auth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Emulator => f.write_str("Emulator"),
Self::AccessToken(_) => f.write_str("AccessToken(***)"),
Self::MetadataServer { url } => {
f.debug_struct("MetadataServer").field("url", url).finish()
}
}
}
}
struct Token {
secret: String,
expires: Option<Instant>,
}
impl Token {
fn is_stale(&self) -> bool {
self.expires.is_some_and(|expires| {
expires.saturating_duration_since(Instant::now()) < REFRESH_WITHIN
})
}
}
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, auth: &Auth, agent: &ureq::Agent) -> Result<Option<String>, Error> {
match auth {
Auth::Emulator => Ok(None),
Auth::AccessToken(token) => Ok(Some(token.clone())),
Auth::MetadataServer { url } => self.metadata_token(url, agent).map(Some),
}
}
fn metadata_token(&self, url: &str, agent: &ureq::Agent) -> 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 response: serde_json::Value = agent
.get(url)
.header("Metadata-Flavor", "Google")
.call()
.map_err(|error| Error::remote(format!("firestore: the metadata server: {error}")))?
.body_mut()
.read_json()
.map_err(|error| {
Error::remote(format!(
"firestore: the metadata server's response was not JSON: {error}"
))
})?;
let secret = response
.get("access_token")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
Error::remote("firestore: the metadata server returned no `access_token`")
})?
.to_owned();
let expires = response
.get("expires_in")
.and_then(serde_json::Value::as_u64)
.filter(|seconds| *seconds > 0)
.and_then(|seconds| Instant::now().checked_add(Duration::from_secs(seconds)));
*slot = Some(Token {
secret: secret.clone(),
expires,
});
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 the_emulator_presents_nothing() {
let agent = ureq::Agent::new_with_defaults();
assert!(Session::new()
.token(&Auth::Emulator, &agent)
.unwrap()
.is_none());
}
#[test]
fn a_supplied_token_is_presented_as_it_is() {
let agent = ureq::Agent::new_with_defaults();
assert_eq!(
Session::new()
.token(&Auth::access_token("ya29.abc"), &agent)
.unwrap()
.as_deref(),
Some("ya29.abc")
);
}
#[test]
fn the_metadata_url_can_be_moved_for_a_sidecar() {
let auth = Auth::metadata_server().with_url("http://127.0.0.1:8081/token");
let Auth::MetadataServer { url } = auth else {
panic!("still a metadata auth");
};
assert_eq!(url, "http://127.0.0.1:8081/token");
}
#[test]
fn a_token_near_its_expiry_is_stale() {
let fresh = Token {
secret: "t".to_owned(),
expires: Instant::now().checked_add(Duration::from_secs(3600)),
};
let expiring = Token {
secret: "t".to_owned(),
expires: Instant::now().checked_add(REFRESH_WITHIN / 2),
};
assert!(!fresh.is_stale());
assert!(expiring.is_stale());
}
#[test]
fn a_lifetime_too_large_to_represent_is_treated_as_no_expiry() {
let token = Token {
secret: "t".to_owned(),
expires: Instant::now().checked_add(Duration::from_secs(u64::MAX)),
};
assert!(!token.is_stale());
}
}