Skip to main content

dex_sdk/
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::fs;
12use std::future::Future;
13use std::io;
14use std::path::{Path, PathBuf};
15use std::pin::Pin;
16use std::sync::{Arc, Mutex};
17use std::task::{Context, Poll};
18use std::time::{Duration, Instant, SystemTime};
19
20use bytes::Bytes;
21use http_body::{Body as HttpBody, Frame};
22use http_body_util::BodyExt;
23use tonic::body::Body;
24use tonic::codegen::{BoxFuture, Service, http};
25use tonic::metadata::MetadataValue;
26use tonic::service::Interceptor;
27use tonic::service::interceptor::{InterceptedService, ResponseBody};
28use tonic::transport::Channel;
29use tonic::{Request, Status};
30
31type RejectedTokenRefreshFuture<'a> =
32    Pin<Box<dyn Future<Output = Result<String, CredentialError>> + Send + 'a>>;
33
34#[cfg(feature = "api-key-credential")]
35#[path = "credential/api_key_credential.rs"]
36mod api_key_credential;
37#[cfg(feature = "api-key-credential")]
38pub use api_key_credential::{ApiKeyCredential, ApiKeyCredentialBuilder, ApiKeyCredentialError};
39
40/// Supplies the current bearer token before each Dex request.
41///
42/// Return the bare token without a `Bearer ` prefix. Implementations may be called concurrently.
43/// The synchronous call can run on a Tokio worker thread; it must return promptly without network
44/// I/O. A call deadline cannot interrupt a blocked implementation. Sources requiring slow I/O
45/// should refresh in the background and return a cached token. An error fails the request locally
46/// with gRPC `Unauthenticated`. Pull Worker streams evaluate the token when each stream opens; a
47/// failed open normally backs off for about one minute. An API-key credential can reconnect once
48/// immediately after a token rejection. The token travels over Dex's plaintext gRPC connection,
49/// so protect the network path.
50///
51/// ```no_run
52/// use std::sync::{Arc, RwLock};
53/// use dex_sdk::{ClientOptions, Credential, CredentialError};
54/// struct RotatingCredential { current: RwLock<Option<String>> }
55/// impl Credential for RotatingCredential {
56///     fn token(&self) -> Result<String, CredentialError> {
57///         self.current.read()
58///             .map_err(|_| CredentialError::new("credential lock poisoned"))?
59///             .clone()
60///             .ok_or_else(|| CredentialError::new("token unavailable"))
61///     }
62/// }
63/// let credential = Arc::new(RotatingCredential {
64///     current: RwLock::new(Some("example-token".to_string())),
65/// });
66/// let client_options = ClientOptions::new().credential(credential);
67/// ```
68pub trait Credential: Send + Sync {
69    /// Returns the current bare token, or an error that fails the request locally.
70    ///
71    /// Implementations must be safe for concurrent synchronous calls and return promptly. Do not
72    /// include the `Bearer ` prefix. A credential failure maps to gRPC `Unauthenticated` without
73    /// contacting Dex.
74    fn token(&self) -> Result<String, CredentialError>;
75
76    /// Discards a token rejected by Dex if it is still the credential's cached token.
77    ///
78    /// The default does nothing, keeping existing credential implementations compatible.
79    /// Sources that cache tokens can compare `rejected_token` with their current token before
80    /// invalidating it. Worker streams call this on rejection and reconnect once immediately when
81    /// refresh is supported. The SDK never logs this value.
82    fn discard_rejected_token(&self, _rejected_token: &str) {}
83
84    /// Supplies an asynchronous fresh token after rejection when supported.
85    ///
86    /// The default returns `None`, so existing credentials continue handling rejection as before.
87    /// An API-key credential returns a future bounded by its exchange timeout. The SDK uses
88    /// this for one unary retry or one immediate Worker stream reconnect after
89    /// [`Self::discard_rejected_token`]. Implementations must avoid returning the rejected token.
90    /// Errors fail the attempt without exposing token contents.
91    fn token_after_rejection(&self) -> Option<RejectedTokenRefreshFuture<'_>> {
92        None
93    }
94}
95
96impl fmt::Debug for dyn Credential {
97    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98        formatter.write_str("Credential")
99    }
100}
101
102/// An error obtaining a bearer token before a Dex request.
103///
104/// Return this from a custom [`Credential`] when no usable token is available. Avoid including
105/// token contents in the error message.
106///
107/// ```
108/// use dex_sdk::CredentialError;
109/// let error = CredentialError::new("token unavailable");
110/// assert_eq!(error.to_string(), "token unavailable");
111/// ```
112#[derive(Debug, Clone)]
113pub struct CredentialError {
114    message: String,
115}
116
117impl CredentialError {
118    /// Creates an error for a credential source failure.
119    ///
120    /// `message` is shown in the local service error and must not contain a token. See the
121    /// [`CredentialError`] example.
122    pub fn new(message: impl Into<String>) -> Self {
123        Self {
124            message: message.into(),
125        }
126    }
127}
128
129impl fmt::Display for CredentialError {
130    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131        formatter.write_str(&self.message)
132    }
133}
134
135impl Error for CredentialError {}
136
137/// Reads a token file at construction and after its modification time or size changes.
138///
139/// File metadata is checked at most once per second. Surrounding whitespace is removed. If the
140/// file becomes empty or unreadable, requests fail until a later check succeeds; no stale token
141/// is sent. The constructor fails when the initial file is unreadable or empty. Checks and reads
142/// use a mutex, briefly serializing concurrent callers. Use a local filesystem; slow network
143/// filesystems can delay calls. Replace the token atomically by writing a temporary file and
144/// renaming it. An empty or partial token can fail pull stream opens, which back off for about one
145/// minute.
146///
147/// ```no_run
148/// use std::sync::Arc;
149/// use dex_sdk::{ClientOptions, FileCredential, WorkerOptions};
150/// let credential = Arc::new(FileCredential::new("/var/run/secrets/dex/token")?);
151/// let client_options = ClientOptions::new().credential(credential.clone());
152/// let worker_options = WorkerOptions::new().credential(credential);
153/// # Ok::<(), Box<dyn std::error::Error>>(())
154/// ```
155pub struct FileCredential {
156    path: PathBuf,
157    state: Mutex<FileCredentialState>,
158}
159
160struct FileCredentialState {
161    token: Result<String, CredentialError>,
162    modified: SystemTime,
163    size: u64,
164    checked_at: Instant,
165}
166
167impl fmt::Debug for FileCredential {
168    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
169        formatter
170            .debug_struct("FileCredential")
171            .field("path", &self.path)
172            .field("token", &"<redacted>")
173            .finish()
174    }
175}
176
177impl FileCredential {
178    /// Opens `path` and reads its initial nonempty token.
179    ///
180    /// # Errors
181    ///
182    /// Returns an I/O error if the file cannot be inspected or read, or contains only whitespace.
183    /// See the [`FileCredential`] example for sharing the credential with a Client and Worker.
184    pub fn new(path: impl AsRef<Path>) -> io::Result<Self> {
185        let path = path.as_ref().to_path_buf();
186        let metadata = fs::metadata(&path)?;
187        let token = read_token(&path)?;
188        Ok(Self {
189            path,
190            state: Mutex::new(FileCredentialState {
191                token: Ok(token),
192                modified: metadata.modified()?,
193                size: metadata.len(),
194                checked_at: Instant::now(),
195            }),
196        })
197    }
198}
199
200impl Credential for FileCredential {
201    fn token(&self) -> Result<String, CredentialError> {
202        let mut state = self.state.lock().expect("credential file lock poisoned");
203        if state.checked_at.elapsed() >= Duration::from_secs(1) {
204            state.checked_at = Instant::now();
205            state.token = match fs::metadata(&self.path) {
206                Ok(metadata) => match metadata.modified() {
207                    Ok(modified)
208                        if state.token.is_ok()
209                            && modified == state.modified
210                            && metadata.len() == state.size =>
211                    {
212                        state.token.clone()
213                    }
214                    Ok(modified) => {
215                        let token = read_token(&self.path).map_err(credential_file_error);
216                        if token.is_ok() {
217                            state.modified = modified;
218                            state.size = metadata.len();
219                        }
220                        token
221                    }
222                    Err(error) => Err(credential_file_error(error)),
223                },
224                Err(error) => Err(credential_file_error(error)),
225            };
226        }
227        state.token.clone()
228    }
229}
230
231fn read_token(path: &Path) -> io::Result<String> {
232    let token = fs::read_to_string(path)?.trim().to_string();
233    if token.is_empty() {
234        return Err(io::Error::new(
235            io::ErrorKind::InvalidData,
236            format!("credential file {} is empty", path.display()),
237        ));
238    }
239    Ok(token)
240}
241
242fn credential_file_error(error: io::Error) -> CredentialError {
243    CredentialError::new(format!("read credential file: {error}"))
244}
245
246#[derive(Clone)]
247pub(crate) struct CredentialChannel {
248    service: InterceptedService<RetryChannel, CredentialInterceptor>,
249    credential: Option<Arc<dyn Credential>>,
250}
251
252impl CredentialChannel {
253    pub(crate) fn new(channel: Channel, interceptor: CredentialInterceptor) -> Self {
254        let credential = interceptor.credential.clone();
255        let retry_channel = RetryChannel {
256            channel,
257            credential: credential.clone(),
258        };
259        Self {
260            service: InterceptedService::new(retry_channel, interceptor),
261            credential,
262        }
263    }
264
265    pub(crate) fn credential(&self) -> Option<Arc<dyn Credential>> {
266        self.credential.clone()
267    }
268}
269
270impl Service<http::Request<Body>> for CredentialChannel {
271    type Response = http::Response<ResponseBody<Body>>;
272    type Error = tonic::transport::Error;
273    type Future = BoxFuture<Self::Response, Self::Error>;
274
275    fn poll_ready(&mut self, context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
276        self.service.poll_ready(context)
277    }
278
279    fn call(&mut self, request: http::Request<Body>) -> Self::Future {
280        Box::pin(self.service.call(request))
281    }
282}
283
284#[derive(Clone)]
285struct RetryChannel {
286    channel: Channel,
287    credential: Option<Arc<dyn Credential>>,
288}
289
290impl Service<http::Request<Body>> for RetryChannel {
291    type Response = http::Response<Body>;
292    type Error = tonic::transport::Error;
293    type Future = BoxFuture<Self::Response, Self::Error>;
294
295    fn poll_ready(&mut self, _context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
296        Poll::Ready(Ok(()))
297    }
298
299    fn call(&mut self, request: http::Request<Body>) -> Self::Future {
300        let mut channel = self.channel.clone();
301        let credential = self.credential.clone();
302        let deadline = request_deadline(&request);
303        Box::pin(async move {
304            let attempt = async {
305                let mut request = request;
306                let mut rejected_token = match request.headers().get("authorization") {
307                    Some(value) => match value.to_str() {
308                        Ok(value) => value.strip_prefix("Bearer ").map(str::to_owned),
309                        Err(_) => None,
310                    },
311                    None => None,
312                };
313                if rejected_token.is_none()
314                    && let Some(credential) = &credential
315                    && let Some(fresh_token) = credential.token_after_rejection()
316                {
317                    let fresh_token = match fresh_token.await {
318                        Ok(token) => token,
319                        Err(error) => {
320                            return Ok(Status::unauthenticated(error.to_string()).into_http());
321                        }
322                    };
323                    let authorization = match bearer_header(&fresh_token) {
324                        Ok(value) => value,
325                        Err(status) => return Ok(status.into_http()),
326                    };
327                    request.headers_mut().insert("authorization", authorization);
328                    rejected_token = Some(fresh_token);
329                }
330                let is_poll_stream =
331                    request.uri().path() == "/dex.WorkerTaskService/PollWorkerTask";
332                if is_poll_stream || credential.is_none() {
333                    let response = send_channel_request(&mut channel, request).await?;
334                    let Some(credential) = credential else {
335                        return Ok(response);
336                    };
337                    return Ok(observe_stream_rejection(
338                        response,
339                        credential,
340                        rejected_token,
341                    ));
342                }
343                let (parts, body) = request.into_parts();
344                let request_body = match body.collect().await {
345                    Ok(body) => body,
346                    Err(status) => return Ok(status.into_http()),
347                };
348                let request_trailers = request_body.trailers().cloned();
349                let request_bytes = request_body.to_bytes();
350                let retry_method = parts.method.clone();
351                let retry_uri = parts.uri.clone();
352                let retry_version = parts.version;
353                let retry_headers = parts.headers.clone();
354                let request = http::Request::from_parts(
355                    parts,
356                    buffered_body(request_bytes.clone(), request_trailers.clone()),
357                );
358                let response = send_channel_request(&mut channel, request).await?;
359                let (response_parts, response_body) = response.into_parts();
360                let response_body = match response_body.collect().await {
361                    Ok(body) => body,
362                    Err(status) => return Ok(status.into_http()),
363                };
364                let response_trailers = response_body.trailers().cloned();
365                let is_rejected = is_unauthenticated(&response_parts.headers)
366                    || response_trailers.as_ref().is_some_and(is_unauthenticated);
367                if !is_rejected {
368                    return Ok(http::Response::from_parts(
369                        response_parts,
370                        buffered_body(response_body.to_bytes(), response_trailers),
371                    ));
372                }
373                let Some(credential) = credential else {
374                    return Ok(http::Response::from_parts(
375                        response_parts,
376                        buffered_body(response_body.to_bytes(), response_trailers),
377                    ));
378                };
379                let Some(rejected_token) = rejected_token else {
380                    return Ok(http::Response::from_parts(
381                        response_parts,
382                        buffered_body(response_body.to_bytes(), response_trailers),
383                    ));
384                };
385                credential.discard_rejected_token(&rejected_token);
386                let Some(fresh_token) = credential.token_after_rejection() else {
387                    return Ok(http::Response::from_parts(
388                        response_parts,
389                        buffered_body(response_body.to_bytes(), response_trailers),
390                    ));
391                };
392                let fresh_token = match fresh_token.await {
393                    Ok(token) => token,
394                    Err(error) => return Ok(Status::unauthenticated(error.to_string()).into_http()),
395                };
396                let authorization = match bearer_header(&fresh_token) {
397                    Ok(value) => value,
398                    Err(status) => return Ok(status.into_http()),
399                };
400                let mut retry = http::Request::new(buffered_body(request_bytes, request_trailers));
401                *retry.method_mut() = retry_method;
402                *retry.uri_mut() = retry_uri;
403                *retry.version_mut() = retry_version;
404                *retry.headers_mut() = retry_headers;
405                retry.headers_mut().insert("authorization", authorization);
406                if let Some(deadline) = deadline
407                    && !set_remaining_timeout(&mut retry, deadline)
408                {
409                    return Ok(Status::deadline_exceeded("request deadline exceeded").into_http());
410                }
411                let response = send_channel_request(&mut channel, retry).await?;
412                let (parts, body) = response.into_parts();
413                let response = observe_stream_rejection(
414                    http::Response::from_parts(parts, body),
415                    credential,
416                    Some(fresh_token),
417                );
418                Ok(response)
419            };
420            if let Some(deadline) = deadline {
421                match tokio::time::timeout_at(deadline.into(), attempt).await {
422                    Ok(result) => result,
423                    Err(_) => {
424                        Ok(Status::deadline_exceeded("request deadline exceeded").into_http())
425                    }
426                }
427            } else {
428                attempt.await
429            }
430        })
431    }
432}
433
434fn bearer_header(token: &str) -> Result<http::HeaderValue, Status> {
435    http::HeaderValue::from_str(&format!("Bearer {token}"))
436        .map_err(|_| Status::unauthenticated("invalid bearer token metadata"))
437}
438
439fn request_deadline(request: &http::Request<Body>) -> Option<Instant> {
440    let timeout = request.headers().get("grpc-timeout")?.to_str().ok()?;
441    let (amount, unit) = timeout.split_at(timeout.len().checked_sub(1)?);
442    if amount.is_empty() || amount.len() > 8 {
443        return None;
444    }
445    let amount = amount.parse::<u64>().ok()?;
446    let duration = match unit {
447        "H" => Duration::from_secs(amount.checked_mul(3_600)?),
448        "M" => Duration::from_secs(amount.checked_mul(60)?),
449        "S" => Duration::from_secs(amount),
450        "m" => Duration::from_millis(amount),
451        "u" => Duration::from_micros(amount),
452        "n" => Duration::from_nanos(amount),
453        _ => return None,
454    };
455    Instant::now().checked_add(duration)
456}
457
458fn set_remaining_timeout(request: &mut http::Request<Body>, deadline: Instant) -> bool {
459    let remaining = deadline.saturating_duration_since(Instant::now());
460    if remaining.is_zero() {
461        return false;
462    }
463    let nanoseconds = remaining.as_nanos();
464    let timeout = [
465        (1, "n"),
466        (1_000, "u"),
467        (1_000_000, "m"),
468        (1_000_000_000, "S"),
469        (60_000_000_000, "M"),
470        (3_600_000_000_000, "H"),
471    ]
472    .into_iter()
473    .find_map(|(unit, suffix)| {
474        let amount = nanoseconds.div_ceil(unit);
475        (amount <= 99_999_999).then(|| format!("{amount}{suffix}"))
476    })
477    .expect("gRPC timeout fits an eight-digit hour value");
478    request.headers_mut().insert(
479        "grpc-timeout",
480        http::HeaderValue::from_str(&timeout).expect("numeric gRPC timeout header"),
481    );
482    true
483}
484
485async fn send_channel_request(
486    channel: &mut Channel,
487    request: http::Request<Body>,
488) -> Result<http::Response<Body>, tonic::transport::Error> {
489    std::future::poll_fn(|context| channel.poll_ready(context)).await?;
490    channel.call(request).await
491}
492
493fn is_unauthenticated(headers: &http::HeaderMap) -> bool {
494    headers
495        .get("grpc-status")
496        .is_some_and(|status| status == "16")
497}
498
499fn observe_stream_rejection(
500    response: http::Response<Body>,
501    credential: Arc<dyn Credential>,
502    rejected_token: Option<String>,
503) -> http::Response<Body> {
504    let (parts, body) = response.into_parts();
505    if is_unauthenticated(&parts.headers)
506        && let Some(token) = &rejected_token
507    {
508        credential.discard_rejected_token(token);
509    }
510    http::Response::from_parts(
511        parts,
512        Body::new(ObservedBody {
513            body,
514            credential,
515            rejected_token,
516        }),
517    )
518}
519
520struct ObservedBody {
521    body: Body,
522    credential: Arc<dyn Credential>,
523    rejected_token: Option<String>,
524}
525
526impl HttpBody for ObservedBody {
527    type Data = Bytes;
528    type Error = Status;
529
530    fn poll_frame(
531        mut self: Pin<&mut Self>,
532        context: &mut Context<'_>,
533    ) -> Poll<Option<Result<Frame<Bytes>, Status>>> {
534        let frame = std::task::ready!(Pin::new(&mut self.body).poll_frame(context));
535        if let Some(Ok(frame)) = &frame
536            && frame.trailers_ref().is_some_and(is_unauthenticated)
537            && let Some(token) = &self.rejected_token
538        {
539            self.credential.discard_rejected_token(token);
540        }
541        Poll::Ready(frame)
542    }
543}
544
545fn buffered_body(data: Bytes, trailers: Option<http::HeaderMap>) -> Body {
546    Body::new(BufferedBody {
547        data: Some(data),
548        trailers,
549    })
550}
551
552struct BufferedBody {
553    data: Option<Bytes>,
554    trailers: Option<http::HeaderMap>,
555}
556
557impl HttpBody for BufferedBody {
558    type Data = Bytes;
559    type Error = Status;
560
561    fn poll_frame(
562        mut self: Pin<&mut Self>,
563        _context: &mut Context<'_>,
564    ) -> Poll<Option<Result<Frame<Bytes>, Status>>> {
565        if let Some(data) = self.data.take() {
566            return Poll::Ready(Some(Ok(Frame::data(data))));
567        }
568        Poll::Ready(
569            self.trailers
570                .take()
571                .map(|trailers| Ok(Frame::trailers(trailers))),
572        )
573    }
574}
575
576#[derive(Clone)]
577pub(crate) struct CredentialInterceptor {
578    credential: Option<Arc<dyn Credential>>,
579}
580
581impl CredentialInterceptor {
582    pub(crate) fn new(credential: Option<Arc<dyn Credential>>) -> Self {
583        Self { credential }
584    }
585}
586
587impl Interceptor for CredentialInterceptor {
588    fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
589        if let Some(credential) = &self.credential {
590            match credential.token() {
591                Ok(token) => {
592                    let value = MetadataValue::try_from(format!("Bearer {token}"))
593                        .map_err(|_| Status::unauthenticated("invalid bearer token metadata"))?;
594                    request.metadata_mut().insert("authorization", value);
595                }
596                Err(_) if credential.token_after_rejection().is_some() => {}
597                Err(error) => return Err(Status::unauthenticated(error.to_string())),
598            }
599        }
600        Ok(request)
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use std::convert::Infallible;
607    use std::sync::atomic::{AtomicUsize, Ordering};
608    use std::task::{Context, Poll};
609
610    use dex_protocol::dex::worker_task_service_client::WorkerTaskServiceClient;
611    use dex_protocol::dex::worker_task_service_server::{
612        WorkerTaskService, WorkerTaskServiceServer,
613    };
614    use dex_protocol::dex::{
615        HealthInfo, PollWorkerTaskRequest, PollWorkerTaskResponse, ServerInfo,
616    };
617    use tokio::net::TcpListener;
618    use tokio::sync::{mpsc, oneshot};
619    use tokio_stream::wrappers::{ReceiverStream, TcpListenerStream};
620    use tonic::codegen::{BoxFuture, Service, http};
621    use tonic::server::{NamedService, UnaryService};
622    use tonic::transport::{Endpoint, Server};
623    use tonic::{Response, Streaming};
624
625    use super::*;
626    use crate::{
627        BlobCache, BlobCacheConfig, Client, ClientOptions, PullWorkerOptions, Registry, Worker,
628        WorkerOptions,
629    };
630
631    #[derive(Clone)]
632    struct FakeFlowService {
633        headers: Arc<Mutex<Vec<Option<String>>>>,
634    }
635
636    impl NamedService for FakeFlowService {
637        const NAME: &'static str = "dex.FlowService";
638    }
639
640    impl<B> Service<http::Request<B>> for FakeFlowService
641    where
642        B: tonic::codegen::Body + Send + 'static,
643        B::Error: Into<tonic::codegen::StdError> + Send + 'static,
644    {
645        type Response = http::Response<tonic::body::Body>;
646        type Error = Infallible;
647        type Future = BoxFuture<Self::Response, Self::Error>;
648
649        fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
650            Poll::Ready(Ok(()))
651        }
652
653        fn call(&mut self, request: http::Request<B>) -> Self::Future {
654            let is_server_info = request.uri().path() == "/dex.FlowService/GetServerInfo";
655            let header = request
656                .headers()
657                .get("authorization")
658                .map(|value| value.to_str().expect("ASCII bearer header").to_string());
659            self.headers.lock().expect("headers lock").push(header);
660            Box::pin(async move {
661                if is_server_info {
662                    let mut grpc = tonic::server::Grpc::new(tonic_prost::ProstCodec::default());
663                    Ok(grpc.unary(GetServerInfo, request).await)
664                } else {
665                    let mut grpc = tonic::server::Grpc::new(tonic_prost::ProstCodec::default());
666                    Ok(grpc.unary(HealthCheck, request).await)
667                }
668            })
669        }
670    }
671
672    struct HealthCheck;
673
674    impl UnaryService<()> for HealthCheck {
675        type Response = HealthInfo;
676        type Future = BoxFuture<Response<Self::Response>, Status>;
677
678        fn call(&mut self, _: Request<()>) -> Self::Future {
679            Box::pin(async { Ok(Response::new(HealthInfo::default())) })
680        }
681    }
682
683    struct GetServerInfo;
684
685    impl UnaryService<()> for GetServerInfo {
686        type Response = ServerInfo;
687        type Future = BoxFuture<Response<Self::Response>, Status>;
688
689        fn call(&mut self, _: Request<()>) -> Self::Future {
690            Box::pin(async { Ok(Response::new(ServerInfo::default())) })
691        }
692    }
693
694    #[derive(Clone)]
695    struct FakeTaskService {
696        headers: Arc<Mutex<Vec<Option<String>>>>,
697    }
698
699    #[tonic::async_trait]
700    impl WorkerTaskService for FakeTaskService {
701        type PollWorkerTaskStream = ReceiverStream<Result<PollWorkerTaskResponse, Status>>;
702
703        async fn poll_worker_task(
704            &self,
705            request: Request<Streaming<PollWorkerTaskRequest>>,
706        ) -> Result<Response<Self::PollWorkerTaskStream>, Status> {
707            let header = request
708                .metadata()
709                .get("authorization")
710                .map(|value| value.to_str().expect("ASCII bearer header").to_string());
711            self.headers.lock().expect("headers lock").push(header);
712            let (_sender, receiver) = mpsc::channel(1);
713            Ok(Response::new(ReceiverStream::new(receiver)))
714        }
715    }
716
717    struct MutableCredential {
718        token: Mutex<Result<String, CredentialError>>,
719        calls: AtomicUsize,
720    }
721
722    impl Credential for MutableCredential {
723        fn token(&self) -> Result<String, CredentialError> {
724            self.calls.fetch_add(1, Ordering::SeqCst);
725            self.token.lock().expect("token lock").clone()
726        }
727    }
728
729    #[test]
730    fn file_credential_rotates_and_fails_closed() {
731        let directory = tempfile::tempdir().expect("token directory");
732        let path = directory.path().join("token");
733        assert!(FileCredential::new(&path).is_err());
734        fs::write(&path, " \n").expect("write empty token");
735        assert!(FileCredential::new(&path).is_err());
736        fs::write(&path, "  first\n").expect("write initial token");
737        let credential = FileCredential::new(&path).expect("initial token");
738        assert_eq!(credential.token().expect("initial token"), "first");
739        fs::write(&path, " second-token \n").expect("rotate token");
740        assert_eq!(credential.token().expect("cached token"), "first");
741        credential.state.lock().expect("file state lock").checked_at -= Duration::from_secs(1);
742        assert_eq!(credential.token().expect("rotated token"), "second-token");
743        fs::write(&path, " \n").expect("empty token");
744        credential.state.lock().expect("file state lock").checked_at -= Duration::from_secs(1);
745        assert!(credential.token().is_err());
746        fs::remove_file(&path).expect("remove token");
747        credential.state.lock().expect("file state lock").checked_at -= Duration::from_secs(1);
748        assert!(credential.token().is_err());
749        fs::write(&path, "restored").expect("restore token");
750        credential.state.lock().expect("file state lock").checked_at -= Duration::from_secs(1);
751        assert_eq!(credential.token().expect("restored token"), "restored");
752    }
753
754    #[test]
755    fn file_credential_debug_redacts_token() {
756        let directory = tempfile::tempdir().expect("token directory");
757        let path = directory.path().join("token");
758        fs::write(&path, "private-token-value").expect("write token");
759        let credential = Arc::new(FileCredential::new(&path).expect("file credential"));
760        let debug = format!("{credential:?}");
761        assert!(!debug.contains("private-token-value"));
762        assert!(debug.contains("<redacted>"));
763    }
764
765    #[test]
766    fn flow_service_unary_uses_current_credential_and_default_has_no_header() {
767        let runtime = tokio::runtime::Runtime::new().expect("server runtime");
768        let listener = runtime
769            .block_on(TcpListener::bind("127.0.0.1:0"))
770            .expect("bind fake FlowService");
771        let address = listener.local_addr().expect("fake server address");
772        let headers = Arc::new(Mutex::new(Vec::new()));
773        let (shutdown, stopped) = oneshot::channel::<()>();
774        runtime.spawn(
775            Server::builder()
776                .add_service(FakeFlowService {
777                    headers: Arc::clone(&headers),
778                })
779                .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async {
780                    stopped.await.expect("FlowService shutdown signal");
781                }),
782        );
783        let credential = Arc::new(MutableCredential {
784            token: Mutex::new(Ok("first".to_string())),
785            calls: AtomicUsize::new(0),
786        });
787        let directory = tempfile::tempdir().expect("blob cache directory");
788        let cache = Arc::new(
789            BlobCache::open(
790                BlobCacheConfig::new(directory.path(), 1024 * 1024, 0).expect("cache config"),
791            )
792            .expect("open cache"),
793        );
794        let without_credential = Client::try_new(
795            Registry::new(),
796            Arc::clone(&cache),
797            ClientOptions::new().server_address(address.to_string()),
798        )
799        .expect("default Client");
800        without_credential.health_check().expect("default call");
801        let with_credential = Client::try_new(
802            Registry::new(),
803            Arc::clone(&cache),
804            ClientOptions::new()
805                .server_address(address.to_string())
806                .credential(credential.clone()),
807        )
808        .expect("authenticated Client");
809        with_credential.health_check().expect("first call");
810        *credential.token.lock().expect("token lock") = Ok("second".to_string());
811        with_credential.health_check().expect("rotated call");
812        *credential.token.lock().expect("token lock") =
813            Err(CredentialError::new("token unavailable"));
814        let error = with_credential.health_check().expect_err("local error");
815        assert_eq!(
816            error.service_error().expect("service error").code(),
817            tonic::Code::Unauthenticated
818        );
819        *credential.token.lock().expect("token lock") = Ok("worker".to_string());
820        let worker = Worker::try_new(
821            Registry::new(),
822            cache,
823            WorkerOptions::new()
824                .server_address(address.to_string())
825                .credential(credential.clone())
826                .pull(PullWorkerOptions::new()),
827        )
828        .expect("authenticated Worker");
829        worker
830            .start()
831            .expect_err("fake protocol interval is invalid");
832        assert_eq!(credential.calls.load(Ordering::SeqCst), 4);
833        assert_eq!(
834            *headers.lock().expect("headers lock"),
835            vec![
836                None,
837                Some("Bearer first".to_string()),
838                Some("Bearer second".to_string()),
839                Some("Bearer worker".to_string())
840            ]
841        );
842        shutdown.send(()).expect("stop fake FlowService");
843    }
844
845    #[tokio::test(flavor = "multi_thread")]
846    async fn pull_stream_open_reevaluates_credential() {
847        let listener = TcpListener::bind("127.0.0.1:0")
848            .await
849            .expect("bind fake WorkerTaskService");
850        let address = listener.local_addr().expect("fake server address");
851        let headers = Arc::new(Mutex::new(Vec::new()));
852        let (shutdown, stopped) = oneshot::channel::<()>();
853        let server = tokio::spawn(
854            Server::builder()
855                .add_service(WorkerTaskServiceServer::new(FakeTaskService {
856                    headers: Arc::clone(&headers),
857                }))
858                .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async {
859                    stopped.await.expect("WorkerTaskService shutdown signal");
860                }),
861        );
862        let credential = Arc::new(MutableCredential {
863            token: Mutex::new(Ok("first".to_string())),
864            calls: AtomicUsize::new(0),
865        });
866        let channel = Endpoint::from_shared(format!("http://{address}"))
867            .expect("endpoint")
868            .connect()
869            .await
870            .expect("connect fake WorkerTaskService");
871        let mut default_client = WorkerTaskServiceClient::new(CredentialChannel::new(
872            channel.clone(),
873            CredentialInterceptor::new(None),
874        ));
875        default_client
876            .poll_worker_task(tokio_stream::empty::<PollWorkerTaskRequest>())
877            .await
878            .expect("open default task stream");
879        let mut client = WorkerTaskServiceClient::new(CredentialChannel::new(
880            channel,
881            CredentialInterceptor::new(Some(credential.clone())),
882        ));
883        for token in ["first", "second"] {
884            *credential.token.lock().expect("token lock") = Ok(token.to_string());
885            client
886                .poll_worker_task(tokio_stream::empty::<PollWorkerTaskRequest>())
887                .await
888                .expect("open task stream");
889        }
890        *credential.token.lock().expect("token lock") =
891            Err(CredentialError::new("token unavailable"));
892        let error = client
893            .poll_worker_task(tokio_stream::empty::<PollWorkerTaskRequest>())
894            .await
895            .expect_err("local task stream error");
896        assert_eq!(error.code(), tonic::Code::Unauthenticated);
897        assert_eq!(credential.calls.load(Ordering::SeqCst), 3);
898        assert_eq!(
899            *headers.lock().expect("headers lock"),
900            vec![
901                None,
902                Some("Bearer first".to_string()),
903                Some("Bearer second".to_string())
904            ]
905        );
906        shutdown.send(()).expect("stop fake WorkerTaskService");
907        server
908            .await
909            .expect("join fake WorkerTaskService")
910            .expect("serve task service");
911    }
912}