use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity};
use crate::retry::RetryConfig;
use crate::{Error, Result};
pub trait TokenSource: Send + Sync + fmt::Debug {
fn fetch_bearer(&self) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + '_>>;
}
#[derive(Clone)]
#[non_exhaustive]
pub enum Auth {
None,
Static(String),
Dynamic(Arc<dyn TokenSource>),
}
impl Auth {
pub async fn bearer(&self) -> Result<Option<String>> {
match self {
Auth::None => Ok(None),
Auth::Static(token) => Ok(Some(token.clone())),
Auth::Dynamic(source) => source.fetch_bearer().await,
}
}
}
impl fmt::Debug for Auth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Auth::None => f.write_str("None"),
Auth::Static(_) => f.write_str("Static(<redacted>)"),
Auth::Dynamic(source) => write!(f, "Dynamic({source:?})"),
}
}
}
#[derive(Clone, Default)]
#[non_exhaustive]
pub struct TlsConfig {
pub ca_certificate_pem: Option<Vec<u8>>,
pub domain_name: Option<String>,
pub client_identity_pem: Option<(Vec<u8>, Vec<u8>)>,
}
impl std::fmt::Debug for TlsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
fn pem(bytes: Option<&Vec<u8>>) -> String {
bytes.map_or_else(|| "None".to_string(), |b| format!("<{} bytes>", b.len()))
}
f.debug_struct("TlsConfig")
.field("ca_certificate_pem", &pem(self.ca_certificate_pem.as_ref()))
.field("domain_name", &self.domain_name)
.field(
"client_identity_pem",
&self.client_identity_pem.as_ref().map_or_else(
|| "None".to_string(),
|(cert, key)| {
format!(
"Some((<{} bytes>, <{} bytes, redacted>))",
cert.len(),
key.len()
)
},
),
)
.finish()
}
}
impl TlsConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_ca_certificate(mut self, ca_pem: impl Into<Vec<u8>>) -> Self {
self.ca_certificate_pem = Some(ca_pem.into());
self
}
#[must_use]
pub fn with_domain_name(mut self, domain: impl Into<String>) -> Self {
self.domain_name = Some(domain.into());
self
}
#[must_use]
pub fn with_client_identity(
mut self,
certificate_pem: impl Into<Vec<u8>>,
private_key_pem: impl Into<Vec<u8>>,
) -> Self {
self.client_identity_pem = Some((certificate_pem.into(), private_key_pem.into()));
self
}
}
fn build_tls(tls: Option<&TlsConfig>) -> ClientTlsConfig {
let mut config = ClientTlsConfig::new();
match tls.and_then(|t| t.ca_certificate_pem.as_ref()) {
Some(ca) => config = config.ca_certificate(Certificate::from_pem(ca.clone())),
None => config = config.with_native_roots(),
}
if let Some(domain) = tls.and_then(|t| t.domain_name.as_ref()) {
config = config.domain_name(domain.clone());
}
if let Some((cert, key)) = tls.and_then(|t| t.client_identity_pem.as_ref()) {
config = config.identity(Identity::from_pem(cert.clone(), key.clone()));
}
config
}
#[must_use]
pub fn redact_url(url: &str) -> std::borrow::Cow<'_, str> {
let Some(scheme_end) = url.find("://") else {
return std::borrow::Cow::Borrowed(url);
};
let authority_start = scheme_end + 3;
let authority_end = url[authority_start..]
.find(['/', '?', '#'])
.map_or(url.len(), |i| authority_start + i);
let authority = &url[authority_start..authority_end];
let Some(at) = authority.rfind('@') else {
return std::borrow::Cow::Borrowed(url);
};
std::borrow::Cow::Owned(format!(
"{}***{}",
&url[..authority_start],
&url[authority_start + at..]
))
}
#[derive(Clone)]
pub struct Config {
endpoint: String,
auth: Auth,
retry: Option<RetryConfig>,
tls: Option<TlsConfig>,
timeout: Option<Duration>,
max_decoding_message_size: usize,
}
pub const DEFAULT_MAX_DECODING_MESSAGE_SIZE: usize = 128 * 1024 * 1024;
impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config")
.field("endpoint", &redact_url(&self.endpoint))
.field("auth", &self.auth)
.field("retry", &self.retry)
.field("tls", &self.tls)
.field("timeout", &self.timeout)
.field("max_decoding_message_size", &self.max_decoding_message_size)
.finish()
}
}
impl Config {
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
auth: Auth::None,
retry: None,
tls: None,
timeout: None,
max_decoding_message_size: DEFAULT_MAX_DECODING_MESSAGE_SIZE,
}
}
#[must_use]
pub fn with_token(mut self, token: impl Into<String>) -> Self {
self.auth = Auth::Static(token.into());
self
}
#[must_use]
pub fn with_oidc<T: TokenSource + 'static>(mut self, provider: T) -> Self {
self.auth = Auth::Dynamic(Arc::new(provider));
self
}
#[must_use]
pub fn with_retry(mut self, retry: RetryConfig) -> Self {
self.retry = Some(retry);
self
}
#[must_use]
pub fn with_tls(mut self, tls: TlsConfig) -> Self {
self.tls = Some(tls);
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
#[must_use]
pub fn with_max_decoding_message_size(mut self, bytes: usize) -> Self {
self.max_decoding_message_size = bytes;
self
}
#[must_use]
pub fn max_decoding_message_size(&self) -> usize {
self.max_decoding_message_size
}
#[must_use]
pub fn endpoint(&self) -> &str {
&self.endpoint
}
#[must_use]
pub fn auth(&self) -> &Auth {
&self.auth
}
#[must_use]
pub fn retry(&self) -> Option<&RetryConfig> {
self.retry.as_ref()
}
pub fn connect_channel(&self) -> Result<Channel> {
let (uri, want_tls) = resolve_endpoint(&self.endpoint, self.tls.is_some());
let mut endpoint = Endpoint::from_shared(uri.clone())
.map_err(|e| {
Error::InvalidRequest(format!("invalid endpoint uri {}: {e}", redact_url(&uri)))
})?
.timeout(self.timeout.unwrap_or(Duration::from_secs(30)))
.connect_timeout(Duration::from_secs(10))
.http2_keep_alive_interval(Duration::from_secs(30))
.keep_alive_timeout(Duration::from_secs(20))
.keep_alive_while_idle(true)
.tcp_keepalive(Some(Duration::from_secs(60)))
.tcp_nodelay(true);
if want_tls {
endpoint = endpoint
.tls_config(build_tls(self.tls.as_ref()))
.map_err(|e| Error::InvalidRequest(format!("invalid TLS config: {e}")))?;
}
Ok(endpoint.connect_lazy())
}
}
fn resolve_endpoint(endpoint: &str, tls_configured: bool) -> (String, bool) {
if !endpoint.contains("://") {
let scheme = if tls_configured { "https" } else { "http" };
return (format!("{scheme}://{endpoint}"), tls_configured);
}
let is_https = endpoint
.get(..8)
.is_some_and(|s| s.eq_ignore_ascii_case("https://"));
let is_http = endpoint
.get(..7)
.is_some_and(|s| s.eq_ignore_ascii_case("http://"));
let want_tls = tls_configured || is_https;
if want_tls && is_http {
(format!("https://{}", &endpoint[7..]), true)
} else {
(endpoint.to_string(), want_tls)
}
}
#[cfg(test)]
mod decode_limit_tests {
use super::*;
#[test]
fn the_default_is_far_above_what_a_real_page_needs() {
assert_eq!(DEFAULT_MAX_DECODING_MESSAGE_SIZE, 128 * 1024 * 1024);
const { assert!(DEFAULT_MAX_DECODING_MESSAGE_SIZE > 4 * 1024 * 1024) };
assert_eq!(
Config::new("http://localhost:3901").max_decoding_message_size(),
DEFAULT_MAX_DECODING_MESSAGE_SIZE
);
}
#[test]
fn the_limit_is_configurable_in_both_directions() {
let big = Config::new("http://x").with_max_decoding_message_size(512 * 1024 * 1024);
assert_eq!(big.max_decoding_message_size(), 512 * 1024 * 1024);
let small = Config::new("http://x").with_max_decoding_message_size(1024);
assert_eq!(small.max_decoding_message_size(), 1024);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod redaction_tests {
use super::*;
const KEY: &[u8] = b"-----BEGIN PRIVATE KEY-----SUPERSECRET-----END PRIVATE KEY-----";
#[test]
fn debug_never_prints_key_material() {
let tls = TlsConfig::new().with_client_identity(b"certbytes".to_vec(), KEY.to_vec());
let rendered = format!("{tls:?}");
assert!(
!rendered.contains("83, 85, 80"),
"key bytes leaked: {rendered}"
);
assert!(!rendered.contains("PRIVATE KEY"), "{rendered}");
assert!(rendered.contains("redacted"), "{rendered}");
assert!(
rendered.contains(&format!("{} bytes", KEY.len())),
"{rendered}"
);
let cfg = Config::new("https://host:3901").with_tls(tls);
let rendered = format!("{cfg:?}");
assert!(
!rendered.contains("83, 85, 80"),
"leaked via Config: {rendered}"
);
}
#[test]
fn debug_redacts_credentials_in_the_endpoint() {
let cfg = Config::new("https://alice:s3cr3t@localhost:3901");
let rendered = format!("{cfg:?}");
assert!(!rendered.contains("s3cr3t"), "{rendered}");
assert!(!rendered.contains("alice"), "{rendered}");
assert!(
rendered.contains("localhost:3901"),
"host must survive: {rendered}"
);
}
#[test]
fn redact_url_keeps_everything_that_is_not_a_credential() {
assert_eq!(redact_url("https://u:p@h:1/x?q=1"), "https://***@h:1/x?q=1");
assert_eq!(redact_url("ws://tok@h/v2/updates"), "ws://***@h/v2/updates");
assert_eq!(redact_url("https://h/a@b"), "https://h/a@b");
for url in [
"https://localhost:3901",
"http://kc:8082/realms/AppProvider/protocol/openid-connect/token",
"not a url at all",
"://",
"",
] {
assert_eq!(redact_url(url), url, "should be untouched: {url}");
}
}
}
#[cfg(test)]
#[allow(clippy::bool_assert_comparison)]
mod tests {
use super::resolve_endpoint;
#[test]
fn with_tls_on_http_endpoint_is_upgraded_to_https() {
let (uri, tls) = resolve_endpoint("http://host:5001", true);
assert_eq!(uri, "https://host:5001");
assert_eq!(tls, true);
}
#[test]
fn https_scheme_detection_is_case_insensitive() {
let (uri, tls) = resolve_endpoint("HTTPS://host:443", false);
assert_eq!(uri, "HTTPS://host:443");
assert_eq!(tls, true);
}
#[test]
fn plain_http_without_tls_stays_plaintext() {
let (uri, tls) = resolve_endpoint("http://host:3901", false);
assert_eq!(uri, "http://host:3901");
assert_eq!(tls, false);
}
#[test]
fn https_without_explicit_tls_wants_tls() {
let (uri, tls) = resolve_endpoint("https://host:443", false);
assert_eq!(uri, "https://host:443");
assert_eq!(tls, true);
}
#[test]
fn uppercase_http_with_tls_is_upgraded() {
let (uri, tls) = resolve_endpoint("HTTP://host:5001", true);
assert_eq!(uri, "https://host:5001");
assert_eq!(tls, true);
}
#[test]
fn a_scheme_less_host_and_port_gets_the_scheme_it_implies() {
let (uri, tls) =
resolve_endpoint("grpc-ledger-api.app-provider.demo.localhost:3901", false);
assert_eq!(
uri,
"http://grpc-ledger-api.app-provider.demo.localhost:3901"
);
assert_eq!(tls, false);
let (uri, tls) = resolve_endpoint("ledger.example:443", true);
assert_eq!(uri, "https://ledger.example:443");
assert_eq!(tls, true);
let (uri, _) = resolve_endpoint("[::1]:3901", false);
assert_eq!(uri, "http://[::1]:3901");
}
}