use std::{
fmt,
sync::{Arc, Weak},
time::{Duration, Instant},
};
use anyhow::{Result, anyhow};
use reqwest::{
Client,
header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue},
redirect::Policy,
};
use serde::Deserialize;
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
use crate::{gateway::Gateway, gateway_check::GatewayCheckError, upstream::UpstreamProxy};
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_RESPONSE_BYTES: u64 = 64 * 1024;
const RENEW_RETRY_DELAY: Duration = Duration::from_secs(5);
const MIN_BACKGROUND_INTERVAL: Duration = Duration::from_secs(1);
const MAX_BACKGROUND_RETRY_DELAY: Duration = Duration::from_secs(300);
#[derive(Clone)]
pub(crate) enum GatewayAuth {
None,
Basic(String),
Token(Arc<TokenSession>),
}
impl fmt::Debug for GatewayAuth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.describe())
}
}
impl GatewayAuth {
pub(crate) async fn login(
gateway: &Gateway,
basic_auth: String,
insecure: bool,
upstream_proxy: Option<&UpstreamProxy>,
headers: &[(HeaderName, HeaderValue)],
) -> Result<Self, GatewayCheckError> {
let session = TokenSession::new(gateway, &basic_auth, insecure, upstream_proxy, headers)
.map_err(|err| GatewayCheckError::LoginFailed(format!("{err:#}")))?;
let login = {
let mut state = session.state.lock().await;
session.renew(&mut state).await
};
match login {
Ok(()) => {
let session = Arc::new(session);
tokio::spawn(keep_fresh(Arc::downgrade(&session)));
Ok(Self::Token(session))
}
Err(RequestError::Rejected) => Err(GatewayCheckError::Unauthorized {
credentials_configured: true,
}),
Err(RequestError::Failed(reason)) => Err(GatewayCheckError::LoginFailed(format!(
"{reason:#} (POST {}); the gateway has to offer token authentication \
(a ws2tcp-router with Basic credentials configured)",
gateway.auth_url("token")
))),
}
}
pub(crate) fn describe(&self) -> &'static str {
match self {
Self::None => "none",
Self::Basic(_) => "basic",
Self::Token(_) => "token",
}
}
pub(crate) async fn authorization(&self) -> Result<Option<String>> {
match self {
Self::None => Ok(None),
Self::Basic(basic_auth) => Ok(Some(basic_auth.clone())),
Self::Token(session) => session.authorization().await.map(Some),
}
}
pub(crate) fn can_renew(&self) -> bool {
matches!(self, Self::Token(_))
}
pub(crate) async fn rejected(&self, authorization: Option<&str>) {
if let (Self::Token(session), Some(authorization)) = (self, authorization) {
session.rejected(authorization).await;
}
}
}
struct Tokens {
access: String,
refresh: String,
renew_at: Instant,
access_expires: Instant,
refresh_expires: Instant,
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
expires_in: u64,
refresh_token: String,
refresh_expires_in: u64,
}
impl TokenResponse {
fn into_tokens(self, now: Instant) -> Result<Tokens> {
for token in [&self.access_token, &self.refresh_token] {
if token.is_empty() || !token.bytes().all(|byte| byte.is_ascii_graphic()) {
return Err(anyhow!("gateway sent a malformed token"));
}
}
let access_ttl = Duration::from_secs(self.expires_in);
Ok(Tokens {
access: self.access_token,
refresh: self.refresh_token,
renew_at: now + access_ttl * 4 / 5,
access_expires: now + access_ttl,
refresh_expires: now + Duration::from_secs(self.refresh_expires_in),
})
}
}
enum RequestError {
Rejected,
Failed(anyhow::Error),
}
impl fmt::Display for RequestError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Rejected => f.write_str("the gateway rejected the credentials (401)"),
Self::Failed(err) => write!(f, "{err:#}"),
}
}
}
impl From<RequestError> for anyhow::Error {
fn from(err: RequestError) -> Self {
anyhow!("gateway token request failed: {err}")
}
}
pub(crate) struct TokenSession {
client: Client,
login_url: String,
refresh_url: String,
basic_auth: HeaderValue,
headers: HeaderMap,
state: Mutex<Option<Tokens>>,
}
impl TokenSession {
fn new(
gateway: &Gateway,
basic_auth: &str,
insecure: bool,
upstream_proxy: Option<&UpstreamProxy>,
headers: &[(HeaderName, HeaderValue)],
) -> Result<Self> {
let mut basic_auth = HeaderValue::from_str(basic_auth)
.map_err(|_| anyhow!("Basic authorization header contains invalid characters"))?;
basic_auth.set_sensitive(true);
let mut header_map = HeaderMap::new();
for (name, value) in headers {
header_map.insert(name.clone(), value.clone());
}
let mut client = Client::builder()
.timeout(REQUEST_TIMEOUT)
.danger_accept_invalid_certs(insecure)
.no_proxy()
.redirect(Policy::none());
if let Some(upstream_proxy) = upstream_proxy {
client = client.proxy(
upstream_proxy
.to_reqwest()
.map_err(|err| anyhow!("invalid upstream proxy: {}", err.without_url()))?,
);
}
let client = client
.build()
.map_err(|err| anyhow!("failed to build the token client: {err}"))?;
Ok(Self {
client,
login_url: gateway.auth_url("token"),
refresh_url: gateway.auth_url("refresh"),
basic_auth,
headers: header_map,
state: Mutex::new(None),
})
}
async fn authorization(&self) -> Result<String> {
let mut state = self.state.lock().await;
let due = state
.as_ref()
.is_none_or(|tokens| Instant::now() >= tokens.renew_at);
if due && let Err(err) = self.renew(&mut state).await {
match state.as_mut() {
Some(tokens) if Instant::now() < tokens.access_expires => {
warn!(error = %err, "failed to renew the gateway token; using the current one");
tokens.renew_at = Instant::now() + RENEW_RETRY_DELAY;
}
_ => return Err(err.into()),
}
}
let tokens = state.as_ref().expect("a login or renewal filled the state");
Ok(format!("Bearer {}", tokens.access))
}
async fn until_background_renewal(&self, retry_at: Option<Instant>) -> Duration {
let now = Instant::now();
let state = self.state.lock().await;
let renew_at = state.as_ref().map_or(now, |tokens| tokens.renew_at);
let due = retry_at.map_or(renew_at, |retry_at| renew_at.max(retry_at));
due.saturating_duration_since(now)
.max(MIN_BACKGROUND_INTERVAL)
}
async fn renew_in_background(&self) -> bool {
let mut state = self.state.lock().await;
if state
.as_ref()
.is_some_and(|tokens| Instant::now() < tokens.renew_at)
{
return false;
}
match self.renew(&mut state).await {
Ok(()) => false,
Err(err) => {
warn!(error = %err, "failed to renew the gateway token in the background; will try again");
true
}
}
}
async fn rejected(&self, authorization: &str) {
let mut state = self.state.lock().await;
if let Some(tokens) = state.as_mut()
&& authorization.strip_prefix("Bearer ") == Some(tokens.access.as_str())
{
tokens.renew_at = Instant::now();
}
}
async fn renew(&self, state: &mut Option<Tokens>) -> Result<(), RequestError> {
if let Some(tokens) = state.as_ref()
&& Instant::now() < tokens.refresh_expires
{
let refresh = bearer(&tokens.refresh);
match self.request(&self.refresh_url, refresh).await {
Ok(fresh) => {
debug!("refreshed the gateway access token");
*state = Some(fresh);
return Ok(());
}
Err(RequestError::Rejected) => {
info!("gateway refused the refresh token; logging in again");
}
Err(err @ RequestError::Failed(_)) => return Err(err),
}
}
let fresh = self
.request(&self.login_url, self.basic_auth.clone())
.await?;
debug!("logged in to the gateway");
*state = Some(fresh);
Ok(())
}
async fn request(
&self,
url: &str,
mut authorization: HeaderValue,
) -> Result<Tokens, RequestError> {
authorization.set_sensitive(true);
let response = self
.client
.post(url)
.headers(self.headers.clone())
.header(AUTHORIZATION, authorization)
.send()
.await
.map_err(|err| RequestError::Failed(anyhow!("{}", err.without_url())))?;
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(RequestError::Rejected);
}
if !status.is_success() {
return Err(RequestError::Failed(anyhow!(
"gateway answered HTTP {status}"
)));
}
if response
.content_length()
.is_some_and(|length| length > MAX_RESPONSE_BYTES)
{
return Err(RequestError::Failed(anyhow!("token response is too large")));
}
let body = response
.bytes()
.await
.map_err(|err| RequestError::Failed(anyhow!("{}", err.without_url())))?;
let parsed: TokenResponse = serde_json::from_slice(&body)
.map_err(|err| RequestError::Failed(anyhow!("malformed token response: {err}")))?;
parsed
.into_tokens(Instant::now())
.map_err(RequestError::Failed)
}
}
async fn keep_fresh(session: Weak<TokenSession>) {
let mut failures = 0_u32;
let mut retry_at = None;
loop {
let Some(current) = session.upgrade() else {
return;
};
let wait = current.until_background_renewal(retry_at).await;
drop(current);
tokio::time::sleep(wait).await;
let Some(current) = session.upgrade() else {
return;
};
if current.renew_in_background().await {
let delay = RENEW_RETRY_DELAY
.saturating_mul(2_u32.saturating_pow(failures))
.min(MAX_BACKGROUND_RETRY_DELAY);
failures = failures.saturating_add(1);
retry_at = Some(Instant::now() + delay);
} else {
failures = 0;
retry_at = None;
}
}
}
fn bearer(token: &str) -> HeaderValue {
HeaderValue::from_str(&format!("Bearer {token}"))
.expect("tokens were checked to be printable ASCII")
}
#[cfg(test)]
mod tests {
use std::sync::Mutex as StdMutex;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
use super::*;
const ALICE: &str = "Basic YWxpY2U6c2VjcmV0";
type Seen = Arc<StdMutex<Vec<(String, Option<String>)>>>;
async fn spawn_gateway(
handler: impl Fn(&str, Option<&str>) -> String + Send + Sync + 'static,
) -> (Gateway, Seen) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let seen: Seen = Arc::default();
let recorded = Arc::clone(&seen);
let handler = Arc::new(handler);
tokio::spawn(async move {
loop {
let (mut stream, _) = listener.accept().await.unwrap();
let (handler, recorded) = (Arc::clone(&handler), Arc::clone(&recorded));
tokio::spawn(async move {
let mut head = Vec::new();
let mut chunk = [0_u8; 1024];
while !head.windows(4).any(|window| window == b"\r\n\r\n") {
let n = stream.read(&mut chunk).await.unwrap();
if n == 0 {
return;
}
head.extend_from_slice(&chunk[..n]);
}
let head = String::from_utf8_lossy(&head).into_owned();
let mut lines = head.lines();
let path = lines
.next()
.and_then(|line| line.split(' ').nth(1))
.unwrap_or_default()
.to_owned();
let authorization = lines.find_map(|line| {
let (name, value) = line.split_once(": ")?;
name.eq_ignore_ascii_case("authorization")
.then(|| value.to_owned())
});
let response = handler(&path, authorization.as_deref());
recorded.lock().unwrap().push((path, authorization));
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
});
}
});
(Gateway::parse(&format!("ws://{addr}")).unwrap(), seen)
}
fn tokens_response(access: &str, refresh: &str, expires_in: u64) -> String {
let body = format!(
r#"{{"token_type":"Bearer","access_token":"{access}","expires_in":{expires_in},"refresh_token":"{refresh}","refresh_expires_in":3600}}"#
);
format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
}
fn status_response(status: &str) -> String {
format!("HTTP/1.1 {status}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
}
fn router_like(expires_in: u64) -> impl Fn(&str, Option<&str>) -> String + Send + Sync {
let issued = StdMutex::new(0_u32);
move |path, authorization| {
let mut issued = issued.lock().unwrap();
match (path, authorization) {
("/auth/token", Some(ALICE)) => {
*issued += 1;
tokens_response(
&format!("access-{issued}"),
&format!("refresh-{issued}"),
expires_in,
)
}
("/auth/refresh", Some(value)) if value == format!("Bearer refresh-{issued}") => {
*issued += 1;
tokens_response(
&format!("access-{issued}"),
&format!("refresh-{issued}"),
expires_in,
)
}
_ => status_response("401 Unauthorized"),
}
}
}
async fn login(gateway: &Gateway, basic_auth: &str) -> GatewayAuth {
GatewayAuth::login(gateway, basic_auth.to_owned(), false, None, &[])
.await
.expect("the login should work")
}
fn paths(seen: &Seen) -> Vec<String> {
seen.lock()
.unwrap()
.iter()
.map(|(path, _)| path.clone())
.collect()
}
#[tokio::test]
async fn logs_in_once_and_reuses_the_access_token() {
let (gateway, seen) = spawn_gateway(router_like(600)).await;
let auth = login(&gateway, ALICE).await;
assert_eq!(auth.describe(), "token");
for _ in 0..3 {
assert_eq!(
auth.authorization().await.unwrap().as_deref(),
Some("Bearer access-1")
);
}
assert_eq!(paths(&seen), ["/auth/token"]);
assert_eq!(
seen.lock().unwrap()[0].1.as_deref(),
Some(ALICE),
"the login carries the Basic Auth credentials"
);
}
#[tokio::test]
async fn renews_with_the_refresh_token_before_the_access_token_runs_out() {
let (gateway, seen) = spawn_gateway(router_like(0)).await;
let auth = login(&gateway, ALICE).await;
assert_eq!(
auth.authorization().await.unwrap().as_deref(),
Some("Bearer access-2")
);
assert_eq!(
auth.authorization().await.unwrap().as_deref(),
Some("Bearer access-3")
);
assert_eq!(
paths(&seen),
["/auth/token", "/auth/refresh", "/auth/refresh"]
);
assert_eq!(
seen.lock().unwrap()[1].1.as_deref(),
Some("Bearer refresh-1")
);
}
#[tokio::test]
async fn logs_in_again_when_the_refresh_token_is_rejected() {
let logins = Arc::new(StdMutex::new(0_u32));
let counted = Arc::clone(&logins);
let (gateway, seen) =
spawn_gateway(move |path, authorization| match (path, authorization) {
("/auth/token", Some(ALICE)) => {
let mut logins = counted.lock().unwrap();
*logins += 1;
tokens_response(&format!("access-{logins}"), "refresh", 0)
}
_ => status_response("401 Unauthorized"),
})
.await;
let auth = login(&gateway, ALICE).await;
assert_eq!(
auth.authorization().await.unwrap().as_deref(),
Some("Bearer access-2")
);
assert_eq!(*logins.lock().unwrap(), 2);
assert_eq!(
paths(&seen),
["/auth/token", "/auth/refresh", "/auth/token"]
);
}
#[tokio::test]
async fn a_rejected_access_token_is_replaced_on_the_next_call() {
let (gateway, seen) = spawn_gateway(router_like(600)).await;
let auth = login(&gateway, ALICE).await;
let used = auth.authorization().await.unwrap();
assert!(auth.can_renew());
auth.rejected(used.as_deref()).await;
assert_eq!(
auth.authorization().await.unwrap().as_deref(),
Some("Bearer access-2")
);
assert_eq!(paths(&seen), ["/auth/token", "/auth/refresh"]);
auth.rejected(used.as_deref()).await;
assert_eq!(
auth.authorization().await.unwrap().as_deref(),
Some("Bearer access-2")
);
assert_eq!(paths(&seen).len(), 2);
}
#[tokio::test]
async fn token_requests_go_through_the_upstream_proxy() {
let router = router_like(3600);
let (proxy, seen) = spawn_gateway(move |path, authorization| {
router(
path.strip_prefix("http://gateway.invalid:8000")
.unwrap_or(path),
authorization,
)
})
.await;
let upstream_proxy =
UpstreamProxy::parse(&proxy.base().replace("ws://", "http://")).unwrap();
let gateway = Gateway::parse("ws://gateway.invalid:8000").unwrap();
let auth = GatewayAuth::login(
&gateway,
ALICE.to_owned(),
false,
Some(&upstream_proxy),
&[],
)
.await
.expect("the login should work through the proxy");
assert_eq!(
auth.authorization().await.unwrap().as_deref(),
Some("Bearer access-1")
);
assert_eq!(paths(&seen), ["http://gateway.invalid:8000/auth/token"]);
}
#[tokio::test]
async fn concurrent_requests_share_one_renewal() {
let (gateway, seen) = spawn_gateway(router_like(0)).await;
let auth = login(&gateway, ALICE).await;
let auth = Arc::new(auth);
let calls: Vec<_> = (0..5)
.map(|_| {
let auth = Arc::clone(&auth);
tokio::spawn(async move { auth.authorization().await.unwrap() })
})
.collect();
for call in calls {
call.await.unwrap();
}
let paths = paths(&seen);
assert_eq!(paths[0], "/auth/token");
assert!(paths[1..].iter().all(|path| path == "/auth/refresh"));
let seen = seen.lock().unwrap();
for (index, (_, authorization)) in seen[1..].iter().enumerate() {
assert_eq!(
authorization.as_deref(),
Some(format!("Bearer refresh-{}", index + 1).as_str())
);
}
}
#[tokio::test]
async fn wrong_credentials_fail_the_login_as_unauthorized() {
let (gateway, _) = spawn_gateway(router_like(600)).await;
let err = GatewayAuth::login(
&gateway,
"Basic YWxpY2U6d3Jvbmc=".to_owned(),
false,
None,
&[],
)
.await
.unwrap_err();
assert!(
matches!(
err,
GatewayCheckError::Unauthorized {
credentials_configured: true
}
),
"{err}"
);
}
#[tokio::test]
async fn custom_headers_are_sent_on_token_requests() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let seen_head = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buffer = vec![0_u8; 2048];
let n = stream.read(&mut buffer).await.unwrap();
let _ = stream
.write_all(status_response("404 Not Found").as_bytes())
.await;
String::from_utf8_lossy(&buffer[..n]).to_lowercase()
});
let gateway = Gateway::parse(&format!("ws://{addr}")).unwrap();
let headers = [(
HeaderName::from_static("user-agent"),
HeaderValue::from_static("ws2tcp-local/test"),
)];
let _ = GatewayAuth::login(&gateway, ALICE.to_owned(), false, None, &headers).await;
let head = seen_head.await.unwrap();
assert!(head.starts_with("post /auth/token http/1.1"), "{head}");
assert!(head.contains("user-agent: ws2tcp-local/test"), "{head}");
}
#[tokio::test]
async fn a_login_that_cannot_be_completed_is_an_error_and_never_a_fallback() {
for response in [
status_response("404 Not Found"),
status_response("403 Forbidden"),
String::new(),
{
let body = r#"{"access_token":"a b","expires_in":1,"refresh_token":"r","refresh_expires_in":1}"#;
format!(
"HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
},
] {
let (gateway, seen) = spawn_gateway(move |_, _| response.clone()).await;
let err = GatewayAuth::login(&gateway, ALICE.to_owned(), false, None, &[])
.await
.unwrap_err();
assert!(matches!(err, GatewayCheckError::LoginFailed(_)), "{err}");
let message = err.to_string();
assert!(
message.starts_with("gateway token login failed: "),
"{message}"
);
assert!(message.contains("/auth/token"), "{message}");
assert!(
message.contains("has to offer token authentication"),
"{message}"
);
assert_eq!(paths(&seen), ["/auth/token"]);
}
}
#[tokio::test]
async fn an_unreachable_gateway_fails_the_login() {
let addr = TcpListener::bind("127.0.0.1:0")
.await
.unwrap()
.local_addr()
.unwrap();
let gateway = Gateway::parse(&format!("ws://{addr}")).unwrap();
let err = GatewayAuth::login(&gateway, ALICE.to_owned(), false, None, &[])
.await
.unwrap_err();
assert!(matches!(err, GatewayCheckError::LoginFailed(_)), "{err}");
}
#[tokio::test]
async fn basic_mode_and_anonymous_send_only_what_is_configured() {
let basic = GatewayAuth::Basic(ALICE.to_owned());
assert_eq!(basic.describe(), "basic");
assert_eq!(basic.authorization().await.unwrap().as_deref(), Some(ALICE));
assert!(!basic.can_renew());
let anonymous = GatewayAuth::None;
assert_eq!(anonymous.describe(), "none");
assert_eq!(anonymous.authorization().await.unwrap(), None);
}
#[tokio::test]
async fn the_access_token_is_renewed_in_the_background_without_any_tunnel() {
let (gateway, seen) = spawn_gateway(router_like(2)).await;
let auth = login(&gateway, ALICE).await;
assert_eq!(paths(&seen), ["/auth/token"]);
tokio::time::sleep(Duration::from_millis(2500)).await;
assert_eq!(paths(&seen), ["/auth/token", "/auth/refresh"]);
assert_eq!(
seen.lock().unwrap()[1].1.as_deref(),
Some("Bearer refresh-1")
);
assert_eq!(
auth.authorization().await.unwrap().as_deref(),
Some("Bearer access-2")
);
assert_eq!(paths(&seen), ["/auth/token", "/auth/refresh"]);
}
#[tokio::test]
async fn the_background_renewal_ends_with_the_session() {
let (gateway, seen) = spawn_gateway(router_like(2)).await;
let auth = login(&gateway, ALICE).await;
drop(auth);
tokio::time::sleep(Duration::from_millis(2500)).await;
assert_eq!(paths(&seen), ["/auth/token"]);
}
#[test]
fn debug_output_never_contains_credentials() {
let basic = GatewayAuth::Basic(ALICE.to_owned());
assert_eq!(format!("{basic:?}"), "basic");
}
}