Skip to main content

scion_http3/
aa.rs

1// Copyright 2026 Anapaya Systems
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Exchanging an Anapaya AA API key for tokens, see
16//! [`Config::with_api_key`](crate::Config::with_api_key).
17
18use 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
31/// The name the refresh task logs under.
32const SOURCE_NAME: &str = "aa-api-key";
33/// How long one exchange may take. `CrpcClient::new` sets the same timeout on
34/// the client it builds itself.
35const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
36
37/// An Anapaya AA API key and where to exchange it, see
38/// [`Config::with_api_key`](crate::Config::with_api_key).
39#[derive(Clone)]
40pub struct ApiKeyAuth {
41    /// The API key.
42    pub key: String,
43    /// The Anapaya AA service that exchanges the key for tokens.
44    pub aa_url: Url,
45    /// Identifies this device to the AA. The AA puts it in the token and
46    /// refuses a device on its blocklist, so the same device has to present
47    /// the same value every time.
48    pub device_id: String,
49    trust_anchors: Option<Vec<reqwest::Certificate>>,
50    allow_insecure_http: bool,
51}
52
53impl ApiKeyAuth {
54    /// Creates the credential.
55    #[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    /// Trusts `anchors` instead of the default roots when reaching the AA, for
67    /// a deployment that signs its own certificate.
68    #[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    /// Allows an `http` AA URL. The API key then crosses the network in
75    /// cleartext. Use it only for local testing.
76    #[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
93/// A [`TokenSource`] that exchanges an API key at the AA and renews the token
94/// in the background.
95///
96/// The refreshing source underneath spawns its task when it is built, which
97/// needs a Tokio runtime. It is therefore built on the first `watch` or
98/// `get_token`, which the stack calls from async code, and never at
99/// construction. The task then lives on the runtime of that first call.
100pub(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    /// The refreshing source, or the failure that kept it from being built.
114    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            // A receiver keeps the value it was created with after the sender goes, and every
128            // caller reads that value before it waits for a change.
129            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
138/// Builds the refreshing source, which spawns the task that renews the token.
139///
140/// A client that cannot be built fails for good, so the failure is returned and
141/// no task is spawned for it. The first request reports it.
142fn 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    // The request body holds the API key, and a 307 or 308 redirect resends the
150    // body to the new location.
151    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}