1use std::{fmt, sync::OnceLock, time::Duration};
19
20use anapaya_aa_client::{ApiKeyTokenRefresher, CrpcAaAuthClient};
21use async_trait::async_trait;
22use scion_stack::{
23 reqwest,
24 reqwest_connect_rpc::token_source::{
25 TokenSource, TokenSourceError, TokenSourceWatch, refresh::RefreshTokenSource,
26 },
27};
28use tokio::sync::watch;
29use url::Url;
30
31const SOURCE_NAME: &str = "aa-api-key";
33const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
36
37#[derive(Clone)]
40pub struct ApiKeyAuth {
41 pub key: String,
43 pub aa_url: Url,
45 pub device_id: String,
49 trust_anchors: Option<Vec<reqwest::Certificate>>,
50 allow_insecure_http: bool,
51}
52
53impl ApiKeyAuth {
54 #[must_use]
56 pub fn new(key: impl Into<String>, aa_url: Url, device_id: impl Into<String>) -> Self {
57 ApiKeyAuth {
58 key: key.into(),
59 aa_url,
60 device_id: device_id.into(),
61 trust_anchors: None,
62 allow_insecure_http: false,
63 }
64 }
65
66 #[must_use]
69 pub fn with_trust_anchors(mut self, anchors: Vec<reqwest::Certificate>) -> Self {
70 self.trust_anchors = Some(anchors);
71 self
72 }
73
74 #[must_use]
77 pub fn allow_insecure_http(mut self) -> Self {
78 self.allow_insecure_http = true;
79 self
80 }
81}
82
83impl fmt::Debug for ApiKeyAuth {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 f.debug_struct("ApiKeyAuth")
86 .field("key", &"<redacted>")
87 .field("aa_url", &self.aa_url)
88 .field("device_id", &self.device_id)
89 .finish_non_exhaustive()
90 }
91}
92
93pub(crate) struct AaTokenSource {
101 auth: ApiKeyAuth,
102 source: OnceLock<Result<RefreshTokenSource, TokenSourceError>>,
103}
104
105impl AaTokenSource {
106 pub(crate) fn new(auth: ApiKeyAuth) -> Self {
107 AaTokenSource {
108 auth,
109 source: OnceLock::new(),
110 }
111 }
112
113 fn source(&self) -> Result<&RefreshTokenSource, TokenSourceError> {
115 self.source
116 .get_or_init(|| build(&self.auth))
117 .as_ref()
118 .map_err(Clone::clone)
119 }
120}
121
122#[async_trait]
123impl TokenSource for AaTokenSource {
124 fn watch(&self) -> TokenSourceWatch {
125 match self.source() {
126 Ok(source) => source.watch(),
127 Err(error) => watch::channel(Some(Err(error))).1,
130 }
131 }
132
133 async fn get_token(&self) -> Result<String, TokenSourceError> {
134 self.source()?.get_token().await
135 }
136}
137
138fn build(auth: &ApiKeyAuth) -> Result<RefreshTokenSource, TokenSourceError> {
143 if auth.aa_url.scheme() != "https" && !auth.allow_insecure_http {
144 return Err(TokenSourceError::broken(format!(
145 "the AA URL `{}` is not https, so the API key would cross the network in cleartext; call `ApiKeyAuth::allow_insecure_http` to permit it",
146 auth.aa_url
147 )));
148 }
149 let mut http = reqwest::Client::builder()
152 .timeout(REQUEST_TIMEOUT)
153 .redirect(reqwest::redirect::Policy::none());
154 if let Some(anchors) = auth.trust_anchors.as_deref() {
155 http = http.tls_certs_only(anchors.to_vec());
156 }
157 let http = http.build().map_err(TokenSourceError::broken)?;
158 let client = CrpcAaAuthClient::new_with_client(&auth.aa_url, http).map_err(|e| {
159 TokenSourceError::broken(format!("cannot reach the AA at `{}`: {e}", auth.aa_url))
160 })?;
161 let refresher = ApiKeyTokenRefresher::new(client, auth.key.clone(), auth.device_id.clone());
162 Ok(RefreshTokenSource::builder(SOURCE_NAME, refresher).build())
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 fn auth(aa_url: &str) -> ApiKeyAuth {
170 ApiKeyAuth::new("super-secret-key", aa_url.parse().unwrap(), "device")
171 }
172
173 #[test]
174 fn construction_needs_no_runtime() {
175 let source = AaTokenSource::new(auth("https://aa.example.org"));
176 assert!(source.source.get().is_none());
177 }
178
179 #[tokio::test]
180 async fn an_unreachable_authority_is_transient() {
181 scion_sdk_utils::rustls::select_ring_crypto_provider();
182 let source = AaTokenSource::new(auth("http://127.0.0.1:1").allow_insecure_http());
183 let error = tokio::time::timeout(REQUEST_TIMEOUT, source.get_token())
184 .await
185 .expect("the source answers")
186 .expect_err("nothing listens on port 1");
187 assert!(error.is_transient(), "{error}");
188 }
189
190 #[tokio::test]
191 async fn an_http_authority_is_refused() {
192 let source = AaTokenSource::new(auth("http://aa.example.org"));
193 let error = source
194 .get_token()
195 .await
196 .expect_err("http would send the key in cleartext");
197 assert!(!error.is_transient(), "{error}");
198 assert!(error.to_string().contains("https"), "{error}");
199 }
200
201 #[tokio::test]
202 async fn the_flag_allows_an_http_authority() {
203 scion_sdk_utils::rustls::select_ring_crypto_provider();
204 let auth = auth("http://aa.example.org").allow_insecure_http();
205 assert!(build(&auth).is_ok());
206 }
207
208 #[test]
209 fn debug_redacts_the_key() {
210 let debug = format!("{:?}", auth("https://aa.example.org"));
211 assert!(!debug.contains("super-secret-key"));
212 assert!(debug.contains("aa.example.org"));
213 }
214}