use std::{fmt, sync::OnceLock, time::Duration};
use anapaya_aa_client::{ApiKeyTokenRefresher, CrpcAaAuthClient};
use async_trait::async_trait;
use scion_stack::{
reqwest,
reqwest_connect_rpc::token_source::{
TokenSource, TokenSourceError, TokenSourceWatch, refresh::RefreshTokenSource,
},
};
use tokio::sync::watch;
use url::Url;
const SOURCE_NAME: &str = "aa-api-key";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone)]
pub struct ApiKeyAuth {
pub key: String,
pub aa_url: Url,
pub device_id: String,
trust_anchors: Option<Vec<reqwest::Certificate>>,
allow_insecure_http: bool,
}
impl ApiKeyAuth {
#[must_use]
pub fn new(key: impl Into<String>, aa_url: Url, device_id: impl Into<String>) -> Self {
ApiKeyAuth {
key: key.into(),
aa_url,
device_id: device_id.into(),
trust_anchors: None,
allow_insecure_http: false,
}
}
#[must_use]
pub fn with_trust_anchors(mut self, anchors: Vec<reqwest::Certificate>) -> Self {
self.trust_anchors = Some(anchors);
self
}
#[must_use]
pub fn allow_insecure_http(mut self) -> Self {
self.allow_insecure_http = true;
self
}
}
impl fmt::Debug for ApiKeyAuth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ApiKeyAuth")
.field("key", &"<redacted>")
.field("aa_url", &self.aa_url)
.field("device_id", &self.device_id)
.finish_non_exhaustive()
}
}
pub(crate) struct AaTokenSource {
auth: ApiKeyAuth,
source: OnceLock<Result<RefreshTokenSource, TokenSourceError>>,
}
impl AaTokenSource {
pub(crate) fn new(auth: ApiKeyAuth) -> Self {
AaTokenSource {
auth,
source: OnceLock::new(),
}
}
fn source(&self) -> Result<&RefreshTokenSource, TokenSourceError> {
self.source
.get_or_init(|| build(&self.auth))
.as_ref()
.map_err(Clone::clone)
}
}
#[async_trait]
impl TokenSource for AaTokenSource {
fn watch(&self) -> TokenSourceWatch {
match self.source() {
Ok(source) => source.watch(),
Err(error) => watch::channel(Some(Err(error))).1,
}
}
async fn get_token(&self) -> Result<String, TokenSourceError> {
self.source()?.get_token().await
}
}
fn build(auth: &ApiKeyAuth) -> Result<RefreshTokenSource, TokenSourceError> {
if auth.aa_url.scheme() != "https" && !auth.allow_insecure_http {
return Err(TokenSourceError::broken(format!(
"the AA URL `{}` is not https, so the API key would cross the network in cleartext; call `ApiKeyAuth::allow_insecure_http` to permit it",
auth.aa_url
)));
}
let mut http = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.redirect(reqwest::redirect::Policy::none());
if let Some(anchors) = auth.trust_anchors.as_deref() {
http = http.tls_certs_only(anchors.to_vec());
}
let http = http.build().map_err(TokenSourceError::broken)?;
let client = CrpcAaAuthClient::new_with_client(&auth.aa_url, http).map_err(|e| {
TokenSourceError::broken(format!("cannot reach the AA at `{}`: {e}", auth.aa_url))
})?;
let refresher = ApiKeyTokenRefresher::new(client, auth.key.clone(), auth.device_id.clone());
Ok(RefreshTokenSource::builder(SOURCE_NAME, refresher).build())
}
#[cfg(test)]
mod tests {
use super::*;
fn auth(aa_url: &str) -> ApiKeyAuth {
ApiKeyAuth::new("super-secret-key", aa_url.parse().unwrap(), "device")
}
#[test]
fn construction_needs_no_runtime() {
let source = AaTokenSource::new(auth("https://aa.example.org"));
assert!(source.source.get().is_none());
}
#[tokio::test]
async fn an_unreachable_authority_is_transient() {
scion_sdk_utils::rustls::select_ring_crypto_provider();
let source = AaTokenSource::new(auth("http://127.0.0.1:1").allow_insecure_http());
let error = tokio::time::timeout(REQUEST_TIMEOUT, source.get_token())
.await
.expect("the source answers")
.expect_err("nothing listens on port 1");
assert!(error.is_transient(), "{error}");
}
#[tokio::test]
async fn an_http_authority_is_refused() {
let source = AaTokenSource::new(auth("http://aa.example.org"));
let error = source
.get_token()
.await
.expect_err("http would send the key in cleartext");
assert!(!error.is_transient(), "{error}");
assert!(error.to_string().contains("https"), "{error}");
}
#[tokio::test]
async fn the_flag_allows_an_http_authority() {
scion_sdk_utils::rustls::select_ring_crypto_provider();
let auth = auth("http://aa.example.org").allow_insecure_http();
assert!(build(&auth).is_ok());
}
#[test]
fn debug_redacts_the_key() {
let debug = format!("{:?}", auth("https://aa.example.org"));
assert!(!debug.contains("super-secret-key"));
assert!(debug.contains("aa.example.org"));
}
}