1use std::error::Error;
10use std::fmt;
11use std::sync::{Arc, Mutex};
12use std::time::{Duration, Instant};
13
14use reqwest::{Client, ClientBuilder, Url};
15use serde::Serialize;
16use serde_json::Value;
17use tokio::sync::Notify;
18use tokio::task::JoinHandle;
19
20use super::RejectedTokenRefreshFuture;
21use crate::{Credential, CredentialError};
22
23const DEFAULT_EXCHANGE_TIMEOUT: Duration = Duration::from_secs(10);
24const FAILED_REFRESH_DELAY: Duration = Duration::from_secs(5);
25
26pub struct ApiKeyCredential {
63 shared: Arc<SharedState>,
64 refresh_task: JoinHandle<()>,
65}
66
67struct SharedState {
68 token_url: Url,
69 server_id: String,
70 api_key: String,
71 http_client: Client,
72 exchange_timeout: Duration,
73 state: Mutex<TokenState>,
74 changed: Notify,
75 refresh_requested: Notify,
76}
77
78struct TokenState {
79 cached: Option<CachedToken>,
80 known_tokens: Vec<String>,
81 last_error: Option<ApiKeyCredentialError>,
82 exchange_generation: u64,
83}
84
85struct CachedToken {
86 value: String,
87 refresh_at: Instant,
88 expires_at: Instant,
89}
90
91impl fmt::Debug for ApiKeyCredential {
92 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93 formatter.write_str("ApiKeyCredential { secrets: <redacted> }")
94 }
95}
96
97impl Drop for ApiKeyCredential {
98 fn drop(&mut self) {
99 self.refresh_task.abort();
100 }
101}
102
103impl ApiKeyCredential {
104 pub fn builder(
110 token_url: impl Into<String>,
111 server_id: impl Into<String>,
112 api_key: impl Into<String>,
113 ) -> ApiKeyCredentialBuilder {
114 ApiKeyCredentialBuilder {
115 token_url: token_url.into(),
116 server_id: server_id.into(),
117 api_key: api_key.into(),
118 exchange_timeout: DEFAULT_EXCHANGE_TIMEOUT,
119 http_client: None,
120 }
121 }
122}
123
124impl Credential for ApiKeyCredential {
125 fn token(&self) -> Result<String, CredentialError> {
126 let state = self
127 .shared
128 .state
129 .lock()
130 .expect("API-key credential state lock");
131 if let Some(cached) = &state.cached
132 && Instant::now() < cached.expires_at
133 {
134 return Ok(cached.value.clone());
135 }
136 let last_error = state.last_error.clone();
137 drop(state);
138 Err(CredentialError::new(last_error.as_ref().map_or_else(
139 || {
140 format!(
141 "token exchange at {} (HTTP status unavailable): access token unavailable",
142 self.shared.redacted_url()
143 )
144 },
145 ToString::to_string,
146 )))
147 }
148
149 fn discard_rejected_token(&self, rejected_token: &str) {
150 let mut state = self
151 .shared
152 .state
153 .lock()
154 .expect("API-key credential state lock");
155 if state
156 .cached
157 .as_ref()
158 .is_some_and(|cached| cached.value == rejected_token)
159 {
160 state.cached = None;
161 self.shared.changed.notify_waiters();
162 self.shared.refresh_requested.notify_waiters();
163 }
164 }
165
166 fn token_after_rejection(&self) -> Option<RejectedTokenRefreshFuture<'_>> {
167 Some(Box::pin(async move {
168 let original_generation = self
169 .shared
170 .state
171 .lock()
172 .expect("API-key credential state lock")
173 .exchange_generation;
174 let await_exchange = async {
175 loop {
176 let notified = self.shared.changed.notified();
177 tokio::pin!(notified);
178 notified.as_mut().enable();
179 let has_result = {
180 let state = self
181 .shared
182 .state
183 .lock()
184 .expect("API-key credential state lock");
185 state
186 .cached
187 .as_ref()
188 .is_some_and(|cached| Instant::now() < cached.expires_at)
189 || state.exchange_generation != original_generation
190 };
191 if has_result {
192 return self.token();
193 }
194 notified.await;
195 }
196 };
197 tokio::time::timeout(self.shared.exchange_timeout, await_exchange)
198 .await
199 .unwrap_or_else(|_| {
200 Err(CredentialError::new(format!(
201 "token exchange at {} (HTTP status unavailable): timeout",
202 self.shared.redacted_url()
203 )))
204 })
205 }))
206 }
207}
208
209pub struct ApiKeyCredentialBuilder {
215 token_url: String,
216 server_id: String,
217 api_key: String,
218 exchange_timeout: Duration,
219 http_client: Option<ClientBuilder>,
220}
221
222impl fmt::Debug for ApiKeyCredentialBuilder {
223 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
224 formatter.write_str("ApiKeyCredentialBuilder { secrets: <redacted> }")
225 }
226}
227
228impl ApiKeyCredentialBuilder {
229 pub fn exchange_timeout(mut self, exchange_timeout: Duration) -> Self {
234 self.exchange_timeout = exchange_timeout;
235 self
236 }
237
238 pub fn http_client(mut self, http_client: ClientBuilder) -> Self {
243 self.http_client = Some(http_client);
244 self
245 }
246
247 pub async fn build(self) -> Result<ApiKeyCredential, ApiKeyCredentialError> {
260 if self.token_url.is_empty() || self.server_id.is_empty() || self.api_key.is_empty() {
261 return Err(
262 self.configuration_error("token URL, server ID, and API key must be non-empty")
263 );
264 }
265 let token_url = Url::parse(&self.token_url)
266 .map_err(|_| self.configuration_error("invalid token URL"))?;
267 let is_loopback = token_url.host_str().is_some_and(|host| {
268 host.eq_ignore_ascii_case("localhost")
269 || host
270 .trim_start_matches('[')
271 .trim_end_matches(']')
272 .parse::<std::net::IpAddr>()
273 .is_ok_and(|address| address.is_loopback())
274 });
275 if token_url.scheme() != "https" && !(token_url.scheme() == "http" && is_loopback) {
276 return Err(self.configuration_error("token URL requires HTTPS except on loopback"));
277 }
278 if self.exchange_timeout.is_zero() {
279 return Err(self.configuration_error("exchange timeout must be positive"));
280 }
281 let http_client = self
282 .http_client
283 .unwrap_or_else(Client::builder)
284 .redirect(reqwest::redirect::Policy::none())
285 .build()
286 .map_err(|_| {
287 ApiKeyCredentialError::configuration(
288 &self.token_url,
289 &self.api_key,
290 "HTTP client initialization failed",
291 )
292 })?;
293 let shared = Arc::new(SharedState {
294 token_url,
295 server_id: self.server_id,
296 api_key: self.api_key,
297 http_client,
298 exchange_timeout: self.exchange_timeout,
299 state: Mutex::new(TokenState {
300 cached: None,
301 known_tokens: Vec::new(),
302 last_error: None,
303 exchange_generation: 0,
304 }),
305 changed: Notify::new(),
306 refresh_requested: Notify::new(),
307 });
308 let cached = shared.validate_exchange_result(shared.exchange().await?)?;
309 let mut state = shared.state.lock().expect("API-key credential state lock");
310 state.known_tokens.push(cached.value.clone());
311 state.cached = Some(cached);
312 drop(state);
313 let refresh_task = tokio::spawn(SharedState::refresh_loop(Arc::clone(&shared)));
314 Ok(ApiKeyCredential {
315 shared,
316 refresh_task,
317 })
318 }
319
320 fn configuration_error(&self, detail: &str) -> ApiKeyCredentialError {
321 ApiKeyCredentialError::configuration(&self.token_url, &self.api_key, detail)
322 }
323}
324
325#[derive(Clone)]
331pub struct ApiKeyCredentialError {
332 message: String,
333}
334
335impl ApiKeyCredentialError {
336 fn configuration(token_url: &str, api_key: &str, detail: &str) -> Self {
337 let safe_url = if api_key.is_empty() {
338 redact_jwt_substrings(token_url)
339 } else {
340 redact_jwt_substrings(token_url).replace(api_key, "<redacted>")
341 };
342 Self {
343 message: format!("token exchange at {safe_url} (HTTP status unavailable): {detail}"),
344 }
345 }
346}
347
348impl fmt::Debug for ApiKeyCredentialError {
349 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
350 formatter
351 .debug_tuple("ApiKeyCredentialError")
352 .field(&self.message)
353 .finish()
354 }
355}
356
357impl fmt::Display for ApiKeyCredentialError {
358 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
359 formatter.write_str(&self.message)
360 }
361}
362
363impl Error for ApiKeyCredentialError {}
364
365#[derive(Serialize)]
366struct ExchangeRequest<'a> {
367 server_id: &'a str,
368 api_key: &'a str,
369}
370
371impl SharedState {
372 fn validate_exchange_result(
373 &self,
374 mut cached: CachedToken,
375 ) -> Result<CachedToken, ApiKeyCredentialError> {
376 let now = Instant::now();
377 if now >= cached.expires_at {
378 return Err(self.error("200", "token expired before exchange completed"));
379 }
380 if now >= cached.refresh_at {
381 cached.refresh_at = now + FAILED_REFRESH_DELAY;
382 }
383 Ok(cached)
384 }
385
386 fn redacted_url(&self) -> String {
387 self.redact(self.token_url.as_str())
388 }
389
390 async fn exchange(&self) -> Result<CachedToken, ApiKeyCredentialError> {
391 let started_at = Instant::now();
392 let response = self
393 .http_client
394 .post(self.token_url.clone())
395 .header(reqwest::header::ACCEPT, "application/json")
396 .json(&ExchangeRequest {
397 server_id: &self.server_id,
398 api_key: &self.api_key,
399 })
400 .timeout(self.exchange_timeout)
401 .send()
402 .await
403 .map_err(|error| {
404 self.error(
405 "status unavailable",
406 if error.is_timeout() {
407 "timeout"
408 } else if error.is_connect() {
409 "connection failed"
410 } else if error.is_body() {
411 "request body failed"
412 } else {
413 "transport failure"
414 },
415 )
416 })?;
417 let status = response.status();
418 if status.is_redirection() {
419 return Err(self.error(&status.as_u16().to_string(), "redirect rejected"));
420 }
421 let response_bytes = response.bytes().await.map_err(|error| {
422 self.error(
423 &status.as_u16().to_string(),
424 if error.is_timeout() {
425 "timeout"
426 } else {
427 "response read failed"
428 },
429 )
430 })?;
431 let response_json: Value = serde_json::from_slice(&response_bytes)
432 .map_err(|_| self.error(&status.as_u16().to_string(), "malformed JSON"))?;
433 let response_token = response_json
434 .get("access_token")
435 .and_then(Value::as_str)
436 .filter(|token| !token.is_empty());
437 let details = response_json
438 .as_object()
439 .map(|object| {
440 ["error", "error_description"]
441 .into_iter()
442 .filter_map(|name| {
443 object.get(name).and_then(Value::as_str).map(|value| {
444 let redacted = self.redact(value);
445 let redacted = if let Some(token) = response_token {
446 redacted.replace(token, "<redacted>")
447 } else {
448 redacted
449 };
450 format!("{name}: {}", redacted.chars().take(200).collect::<String>())
451 })
452 })
453 .collect::<Vec<_>>()
454 .join("; ")
455 })
456 .unwrap_or_default();
457 if status != reqwest::StatusCode::OK {
458 return Err(self.error(&status.as_u16().to_string(), &details));
459 }
460 let object = response_json
461 .as_object()
462 .ok_or_else(|| self.response_error("JSON object required", &details))?;
463 let token = object
464 .get("access_token")
465 .and_then(Value::as_str)
466 .filter(|value| !value.is_empty())
467 .ok_or_else(|| self.response_error("invalid access_token", &details))?;
468 let token_type = object
469 .get("token_type")
470 .and_then(Value::as_str)
471 .ok_or_else(|| self.response_error("invalid token_type", &details))?;
472 if !token_type.eq_ignore_ascii_case("Bearer") {
473 return Err(self.response_error("invalid token_type", &details));
474 }
475 let expires_in = object
476 .get("expires_in")
477 .and_then(Value::as_u64)
478 .filter(|seconds| *seconds > 0)
479 .ok_or_else(|| self.response_error("invalid expires_in", &details))?;
480 let lifetime = Duration::from_secs(expires_in);
481 let expires_at = started_at
482 .checked_add(lifetime)
483 .ok_or_else(|| self.response_error("invalid expires_in", &details))?;
484 let refresh_at = started_at
485 .checked_add(lifetime.mul_f64(0.8))
486 .ok_or_else(|| self.response_error("invalid expires_in", &details))?;
487 Ok(CachedToken {
488 value: token.to_string(),
489 refresh_at,
490 expires_at,
491 })
492 }
493
494 fn redact(&self, value: &str) -> String {
495 let mut value = redact_jwt_substrings(value).replace(&self.api_key, "<redacted>");
496 let state = self.state.lock().expect("API-key credential state lock");
497 for token in &state.known_tokens {
498 value = value.replace(token, "<redacted>");
499 }
500 value
501 }
502
503 fn error(&self, status: &str, detail: &str) -> ApiKeyCredentialError {
504 let status = self.redact(status);
505 let detail = self.redact(detail);
506 ApiKeyCredentialError {
507 message: format!(
508 "token exchange at {} (HTTP {status}): {detail}",
509 self.redacted_url()
510 ),
511 }
512 }
513
514 fn response_error(&self, detail: &str, response_details: &str) -> ApiKeyCredentialError {
515 if response_details.is_empty() {
516 self.error("200", detail)
517 } else {
518 self.error("200", &format!("{detail}; {response_details}"))
519 }
520 }
521
522 async fn refresh_loop(shared: Arc<Self>) {
523 loop {
524 let notified = shared.refresh_requested.notified();
525 tokio::pin!(notified);
526 notified.as_mut().enable();
527 let refresh_at = shared
528 .state
529 .lock()
530 .expect("API-key credential state lock")
531 .cached
532 .as_ref()
533 .map(|cached| cached.refresh_at);
534 if let Some(refresh_at) = refresh_at {
535 tokio::select! {
536 () = tokio::time::sleep_until(refresh_at.into()) => {},
537 () = &mut notified => {},
538 }
539 }
540 let result = shared
541 .exchange()
542 .await
543 .and_then(|cached| shared.validate_exchange_result(cached));
544 let failed_error = result.as_ref().err().cloned();
545 {
546 let mut state = shared.state.lock().expect("API-key credential state lock");
547 match result {
548 Ok(cached) => {
549 if !state.known_tokens.contains(&cached.value) {
550 state.known_tokens.push(cached.value.clone());
551 if state.known_tokens.len() > 2 {
552 state.known_tokens.remove(0);
553 }
554 }
555 state.cached = Some(cached);
556 state.last_error = None;
557 }
558 Err(error) => {
559 state.last_error = Some(error);
560 }
561 }
562 state.exchange_generation += 1;
563 }
564 shared.changed.notify_waiters();
565 if let Some(error) = failed_error {
566 tracing::warn!(error = %error, "API-key token refresh failed");
567 tokio::time::sleep(FAILED_REFRESH_DELAY).await;
568 }
569 }
570 }
571}
572
573fn redact_jwt_substrings(value: &str) -> String {
574 let bytes = value.as_bytes();
575 let mut redacted = String::with_capacity(value.len());
576 let mut index = 0;
577 let mut unchanged_start = 0;
578 while index < bytes.len() {
579 if index > 0 && is_base64url(bytes[index - 1]) {
580 index += 1;
581 continue;
582 }
583 let first_end = base64url_segment_end(bytes, index);
584 if first_end - index < 10 || bytes.get(first_end) != Some(&b'.') {
585 index += 1;
586 continue;
587 }
588 let second_start = first_end + 1;
589 let second_end = base64url_segment_end(bytes, second_start);
590 if second_end - second_start < 10 || bytes.get(second_end) != Some(&b'.') {
591 index += 1;
592 continue;
593 }
594 let third_start = second_end + 1;
595 let third_end = base64url_segment_end(bytes, third_start);
596 if third_end - third_start < 10 {
597 index += 1;
598 continue;
599 }
600 redacted.push_str(&value[unchanged_start..index]);
601 redacted.push_str("<redacted>");
602 index = third_end;
603 unchanged_start = index;
604 }
605 redacted.push_str(&value[unchanged_start..]);
606 redacted
607}
608
609fn base64url_segment_end(bytes: &[u8], start: usize) -> usize {
610 let mut end = start;
611 while end < bytes.len() && is_base64url(bytes[end]) {
612 end += 1;
613 }
614 end
615}
616
617fn is_base64url(byte: u8) -> bool {
618 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')
619}
620
621#[cfg(test)]
622#[path = "api_key_credential_tests.rs"]
623mod tests;