Skip to main content

dex_sdk/credential/
api_key_credential.rs

1// Copyright (c) 2026 Super Durable, Inc.
2//
3// Licensed under the Sustainable Use License 1.0.
4// You may not use this file except in compliance with the License.
5// See the LICENSE file in the repository root.
6//
7// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
8
9use 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
26/// Exchanges an application API key for short-lived Dex access tokens.
27///
28/// Use this when an application holds a long-lived API key and Dex requires bearer tokens. Use
29/// [`crate::FileCredential`] when another process writes ready-to-use tokens to a file. Unlike
30/// other [`Credential`] implementations, construction is async: [`ApiKeyCredentialBuilder::build`]
31/// performs the first exchange so an invalid key fails before Client or Worker creation. The
32/// synchronous [`Credential::token`] reads only the cache and never performs network I/O.
33///
34/// A background Tokio task refreshes at 80% of each token's lifetime. Concurrent callers use the
35/// same cached token and only one exchange runs at a time. After a failed refresh, the current
36/// token remains usable until expiry and refresh is retried no more often than every five seconds.
37/// An expired token fails calls until a refresh succeeds. A unary Dex call rejected with gRPC
38/// `Unauthenticated` discards that exact token and retries once after a fresh exchange. Dex rejects
39/// unauthenticated calls before execution, so the retry is safe. Worker poll streams discard
40/// rejected tokens and reconnect once immediately. A second rejection uses the normal backoff.
41///
42/// The credential is safe to share across threads. Its background task is aborted when the last
43/// owner drops it. Client and Worker release their references when dropped; applications holding
44/// another `Arc` should drop that reference to stop refreshing. Keep the Tokio runtime used for
45/// construction alive and driving tasks while the credential is in use. A multi-thread runtime
46/// does this while it remains alive; a current-thread runtime must be continuously driven.
47/// Debug output and exchange errors omit the API key and access tokens.
48///
49/// ```no_run
50/// use std::{sync::Arc, time::Duration};
51/// use dex_sdk::{ApiKeyCredential, ClientOptions, WorkerOptions};
52/// let runtime = tokio::runtime::Runtime::new()?;
53/// let credential = Arc::new(runtime.block_on(ApiKeyCredential::builder(
54///     "https://auth.example.com/token", "my-dex-server", "application-api-key",
55/// ).exchange_timeout(Duration::from_secs(10)).build())?);
56/// let client_options = ClientOptions::new().credential(credential.clone());
57/// let worker_options = WorkerOptions::new().credential(credential);
58/// # let _ = (client_options, worker_options);
59/// # drop(runtime);
60/// # Ok::<(), Box<dyn std::error::Error>>(())
61/// ```
62pub 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    /// Starts configuration of an API-key credential.
105    ///
106    /// `token_url` identifies the exchange endpoint. `server_id` scopes its access token, and
107    /// `api_key` is the application secret. All three values are required. Validation and the
108    /// first exchange occur in [`ApiKeyCredentialBuilder::build`].
109    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
209/// Configures validation, HTTP transport, and the first API-key exchange.
210///
211/// Obtain this from [`ApiKeyCredential::builder`]. The default timeout is ten seconds and covers
212/// connecting and receiving the response. An injected HTTP client can configure proxies or test
213/// transport. Redirects are disabled when the client is built.
214pub 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    /// Sets the maximum duration of each exchange, including connection and response.
230    ///
231    /// The default is ten seconds. A zero timeout is rejected by [`Self::build`]. The timeout
232    /// also bounds waiting for a fresh token after Dex rejects a unary call.
233    pub fn exchange_timeout(mut self, exchange_timeout: Duration) -> Self {
234        self.exchange_timeout = exchange_timeout;
235        self
236    }
237
238    /// Configures the HTTP client for token exchange.
239    ///
240    /// This supports proxy settings and test transport. Redirects are always disabled so the
241    /// API key is never sent to a redirect target. Each request uses the exchange timeout.
242    pub fn http_client(mut self, http_client: ClientBuilder) -> Self {
243        self.http_client = Some(http_client);
244        self
245    }
246
247    /// Validates settings, exchanges the key once, and starts automatic refresh.
248    ///
249    /// The URL must use HTTPS, except `localhost`, IPv4 loopback addresses, and `::1` may use
250    /// HTTP. The server ID is supplied by the application and is never inferred from the URL.
251    /// This must run inside a Tokio runtime. No background task remains if the initial exchange
252    /// fails. See [`ApiKeyCredential`] for a complete example and lifecycle details.
253    ///
254    /// # Errors
255    ///
256    /// Returns [`ApiKeyCredentialError`] for an empty value, invalid or disallowed URL, zero
257    /// timeout, HTTP client setup failure, transport failure, timeout, rejected HTTP status, or
258    /// malformed response. The error names the URL and status without exposing secrets.
259    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/// A validation, transport, timeout, or response failure during API-key exchange.
326///
327/// The message includes the token URL and HTTP status, or says the status is unavailable if no
328/// response arrived. Server `error` and `error_description` strings are each limited to 200
329/// characters. The API key, cached access token, and raw response body are never displayed.
330#[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;